diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 58aaa48..93e739f 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -79,5 +79,5 @@ jobs: run: | node app --setup="${SETUP}" --ci="${CI}" - - name: Run ESLint - run: npm run lint + - name: Run xo + run: npx xo diff --git a/Gruntfile.js b/Gruntfile.js index fc4e596..34fd55a 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,15 +1,15 @@ 'use strict'; -const path = require('path'); +const path = require('node:path'); const nconf = require('nconf'); nconf.argv().env({ - separator: '__', + separator: '__', }); const winston = require('winston'); -const { fork } = require('child_process'); +const {fork} = require('node:child_process'); -const { env } = process; +const {env} = process; let worker; env.NODE_ENV = env.NODE_ENV || 'development'; @@ -23,184 +23,214 @@ const db = require('./src/database'); const plugins = require('./src/plugins'); module.exports = function (grunt) { - const args = []; - - if (!grunt.option('verbose')) { - args.push('--log-level=info'); - nconf.set('log-level', 'info'); - } - prestart.setupWinston(); - - grunt.initConfig({ - watch: {}, - }); - - grunt.loadNpmTasks('grunt-contrib-watch'); - - grunt.registerTask('default', ['watch']); - - grunt.registerTask('init', async function () { - const done = this.async(); - let pluginList = []; - if (!process.argv.includes('--core')) { - await db.init(); - pluginList = await plugins.getActive(); - if (!pluginList.includes('nodebb-plugin-composer-default')) { - pluginList.push('nodebb-plugin-composer-default'); - } - } - - 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 = 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 = 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 = 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: { - files: [ - 'public/less/**/*.less', - 'themes/**/*.less', - ...styleUpdated_Client, - ], - options: { - interval: 1000, - }, - }, - styleUpdated_Admin: { - files: [ - 'public/less/**/*.less', - 'themes/**/*.less', - ...styleUpdated_Admin, - ], - options: { - interval: 1000, - }, - }, - clientUpdated: { - files: [ - 'public/src/**/*.js', - 'public/vendor/**/*.js', - ...clientUpdated, - 'node_modules/benchpressjs/build/benchpress.js', - ], - options: { - interval: 1000, - }, - }, - serverUpdated: { - files: [ - 'app.js', - 'install/*.js', - 'src/**/*.js', - 'public/src/modules/translator.common.js', - 'public/src/modules/helpers.common.js', - 'public/src/utils.common.js', - serverUpdated, - '!src/upgrades/**', - ], - options: { - interval: 1000, - }, - }, - typescriptUpdated: { - files: [ - 'install/*.ts', - 'src/**/*.ts', - 'public/src/**/*.ts', - 'public/vendor/**/*.ts', - ], - options: { - interval: 1000, - }, - }, - templatesUpdated: { - files: [ - 'src/views/**/*.tpl', - 'themes/**/*.tpl', - ...templatesUpdated, - ], - options: { - interval: 1000, - }, - }, - langUpdated: { - files: [ - 'public/language/en-GB/*.json', - 'public/language/en-GB/**/*.json', - ...langUpdated, - ], - options: { - interval: 1000, - }, - }, - }); - const build = require('./src/meta/build'); - if (!grunt.option('skip')) { - await build.build(true, { watch: true }); - } - run(); - done(); - }); - - function run() { - if (worker) { - worker.kill(); - } - - const execArgv = []; - const inspect = process.argv.find(a => a.startsWith('--inspect')); - - if (inspect) { - execArgv.push(inspect); - } - - worker = fork('app.js', args, { - env, - execArgv, - }); - } - - grunt.task.run('init'); - - grunt.event.removeAllListeners('watch'); - grunt.event.on('watch', (action, filepath, target) => { - let compiling; - if (target === 'styleUpdated_Client') { - compiling = 'clientCSS'; - } else if (target === 'styleUpdated_Admin') { - compiling = 'acpCSS'; - } else if (target === 'clientUpdated' || target === 'typescriptUpdated') { - compiling = 'js'; - } else if (target === 'templatesUpdated') { - compiling = 'tpl'; - } else if (target === 'langUpdated') { - compiling = 'lang'; - } else if (target === 'serverUpdated') { - // empty require cache - const paths = ['./src/meta/build.js', './src/meta/index.js']; - paths.forEach(p => delete require.cache[require.resolve(p)]); - return run(); - } - - require('./src/meta/build').build([compiling], { webpack: false }, (err) => { - if (err) { - winston.error(err.stack); - } - if (worker) { - worker.send({ compiling: compiling }); - } - }); - }); + const arguments_ = []; + + if (!grunt.option('verbose')) { + arguments_.push('--log-level=info'); + nconf.set('log-level', 'info'); + } + + prestart.setupWinston(); + + grunt.initConfig({ + watch: {}, + }); + + grunt.loadNpmTasks('grunt-contrib-watch'); + + grunt.registerTask('default', ['watch']); + + grunt.registerTask('init', async function () { + const done = this.async(); + let pluginList = []; + if (!process.argv.includes('--core')) { + await db.init(); + pluginList = await plugins.getActive(); + if (!pluginList.includes('nodebb-plugin-composer-default')) { + pluginList.push('nodebb-plugin-composer-default'); + } + } + + 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 = 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 = 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 = 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: { + files: [ + 'public/less/**/*.less', + 'themes/**/*.less', + ...styleUpdated_Client, + ], + options: { + interval: 1000, + }, + }, + styleUpdated_Admin: { + files: [ + 'public/less/**/*.less', + 'themes/**/*.less', + ...styleUpdated_Admin, + ], + options: { + interval: 1000, + }, + }, + clientUpdated: { + files: [ + 'public/src/**/*.js', + 'public/vendor/**/*.js', + ...clientUpdated, + 'node_modules/benchpressjs/build/benchpress.js', + ], + options: { + interval: 1000, + }, + }, + serverUpdated: { + files: [ + 'app.js', + 'install/*.js', + 'src/**/*.js', + 'public/src/modules/translator.common.js', + 'public/src/modules/helpers.common.js', + 'public/src/utils.common.js', + serverUpdated, + '!src/upgrades/**', + ], + options: { + interval: 1000, + }, + }, + typescriptUpdated: { + files: [ + 'install/*.ts', + 'src/**/*.ts', + 'public/src/**/*.ts', + 'public/vendor/**/*.ts', + ], + options: { + interval: 1000, + }, + }, + templatesUpdated: { + files: [ + 'src/views/**/*.tpl', + 'themes/**/*.tpl', + ...templatesUpdated, + ], + options: { + interval: 1000, + }, + }, + langUpdated: { + files: [ + 'public/language/en-GB/*.json', + 'public/language/en-GB/**/*.json', + ...langUpdated, + ], + options: { + interval: 1000, + }, + }, + }); + const build = require('./src/meta/build'); + if (!grunt.option('skip')) { + await build.build(true, {watch: true}); + } + + run(); + done(); + }); + + function run() { + if (worker) { + worker.kill(); + } + + const execArgv = []; + const inspect = process.argv.find(a => a.startsWith('--inspect')); + + if (inspect) { + execArgv.push(inspect); + } + + worker = fork('app.js', arguments_, { + env, + execArgv, + }); + } + + grunt.task.run('init'); + + grunt.event.removeAllListeners('watch'); + grunt.event.on('watch', (action, filepath, target) => { + let compiling; + switch (target) { + case 'styleUpdated_Client': { + compiling = 'clientCSS'; + + break; + } + + case 'styleUpdated_Admin': { + compiling = 'acpCSS'; + + break; + } + + case 'clientUpdated': + case 'typescriptUpdated': { + compiling = 'js'; + + break; + } + + case 'templatesUpdated': { + compiling = 'tpl'; + + break; + } + + case 'langUpdated': { + compiling = 'lang'; + + break; + } + + case 'serverUpdated': { + // Empty require cache + const paths = ['./src/meta/build.js', './src/meta/index.js']; + for (const p of paths) { + delete require.cache[require.resolve(p)]; + } + + return run(); + } + // No default + } + + require('./src/meta/build').build([compiling], {webpack: false}, error => { + if (error) { + winston.error(error.stack); + } + + if (worker) { + worker.send({compiling}); + } + }); + }); }; diff --git a/app.js b/app.js index eeb84ae..3a5fbf9 100644 --- a/app.js +++ b/app.js @@ -24,12 +24,11 @@ require('./require-main'); const nconf = require('nconf'); nconf.argv().env({ - separator: '__', + separator: '__', }); const winston = require('winston'); -const path = require('path'); - +const path = require('node:path'); const file = require('./src/file'); process.env.NODE_ENV = process.env.NODE_ENV || 'production'; @@ -48,35 +47,35 @@ prestart.versionCheck(); winston.verbose('* using configuration stored in: %s', configFile); if (!process.send) { - // If run using `node app`, log GNU copyright info along with server info - winston.info(`NodeBB v${nconf.get('version')} Copyright (C) 2013-${(new Date()).getFullYear()} NodeBB Inc.`); - winston.info('This program comes with ABSOLUTELY NO WARRANTY.'); - winston.info('This is free software, and you are welcome to redistribute it under certain conditions.'); - winston.info(''); + // If run using `node app`, log GNU copyright info along with server info + winston.info(`NodeBB v${nconf.get('version')} Copyright (C) 2013-${(new Date()).getFullYear()} NodeBB Inc.`); + winston.info('This program comes with ABSOLUTELY NO WARRANTY.'); + winston.info('This is free software, and you are welcome to redistribute it under certain conditions.'); + winston.info(''); } if (nconf.get('setup') || nconf.get('install')) { - require('./src/cli/setup').setup(); + require('./src/cli/setup').setup(); } else if (!configExists) { - require('./install/web').install(nconf.get('port')); + require('./install/web').install(nconf.get('port')); } else if (nconf.get('upgrade')) { - require('./src/cli/upgrade').upgrade(true); + require('./src/cli/upgrade').upgrade(true); } else if (nconf.get('reset')) { - require('./src/cli/reset').reset({ - theme: nconf.get('t'), - plugin: nconf.get('p'), - widgets: nconf.get('w'), - settings: nconf.get('s'), - all: nconf.get('a'), - }); + require('./src/cli/reset').reset({ + theme: nconf.get('t'), + plugin: nconf.get('p'), + widgets: nconf.get('w'), + settings: nconf.get('s'), + all: nconf.get('a'), + }); } 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') && typeof nconf.get('plugins') !== 'object') { - require('./src/cli/manage').listPlugins(); + require('./src/cli/manage').listPlugins(); } else if (nconf.get('build')) { - require('./src/cli/manage').build(nconf.get('build')); + require('./src/cli/manage').build(nconf.get('build')); } else if (nconf.get('events')) { - require('./src/cli/manage').listEvents(); + require('./src/cli/manage').listEvents(); } else { - require('./src/start').start(); + require('./src/start').start(); } diff --git a/commitlint.config.js b/commitlint.config.js index 42719c6..062d24b 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -1,26 +1,26 @@ 'use strict'; module.exports = { - extends: ['@commitlint/config-angular'], - rules: { - 'header-max-length': [1, 'always', 72], - 'type-enum': [ - 2, - 'always', - [ - 'breaking', - 'build', - 'chore', - 'ci', - 'docs', - 'feat', - 'fix', - 'perf', - 'refactor', - 'revert', - 'style', - 'test', - ], - ], - }, + extends: ['@commitlint/config-angular'], + rules: { + 'header-max-length': [1, 'always', 72], + 'type-enum': [ + 2, + 'always', + [ + 'breaking', + 'build', + 'chore', + 'ci', + 'docs', + 'feat', + 'fix', + 'perf', + 'refactor', + 'revert', + 'style', + 'test', + ], + ], + }, }; diff --git a/install/databases.js b/install/databases.js index 33996b5..41db0c8 100644 --- a/install/databases.js +++ b/install/databases.js @@ -4,84 +4,106 @@ const prompt = require('prompt'); const winston = require('winston'); const questions = { - redis: require('../src/database/redis').questions, - mongo: require('../src/database/mongo').questions, - postgres: require('../src/database/postgres').questions, + redis: require('../src/database/redis').questions, + mongo: require('../src/database/mongo').questions, + postgres: require('../src/database/postgres').questions, }; module.exports = async function (config) { - winston.info(`\nNow configuring ${config.database} database:`); - const databaseConfig = await getDatabaseConfig(config); - return saveDatabaseConfig(config, databaseConfig); + winston.info(`\nNow configuring ${config.database} database:`); + const databaseConfig = await getDatabaseConfig(config); + return saveDatabaseConfig(config, databaseConfig); }; async function getDatabaseConfig(config) { - if (!config) { - throw new Error('invalid config, aborted'); - } - - if (config.database === 'redis') { - if (config['redis:host'] && config['redis:port']) { - return config; - } - return await prompt.get(questions.redis); - } else if (config.database === 'mongo') { - if ((config['mongo:host'] && config['mongo:port']) || config['mongo:uri']) { - return config; - } - return await prompt.get(questions.mongo); - } else if (config.database === 'postgres') { - if (config['postgres:host'] && config['postgres:port']) { - return config; - } - return await prompt.get(questions.postgres); - } - throw new Error(`unknown database : ${config.database}`); + if (!config) { + throw new Error('invalid config, aborted'); + } + + if (config.database === 'redis') { + if (config['redis:host'] && config['redis:port']) { + return config; + } + + return await prompt.get(questions.redis); + } + + if (config.database === 'mongo') { + if ((config['mongo:host'] && config['mongo:port']) || config['mongo:uri']) { + return config; + } + + return await prompt.get(questions.mongo); + } + + if (config.database === 'postgres') { + if (config['postgres:host'] && config['postgres:port']) { + return config; + } + + return await prompt.get(questions.postgres); + } + + throw new Error(`unknown database : ${config.database}`); } function saveDatabaseConfig(config, databaseConfig) { - if (!databaseConfig) { - throw new Error('invalid config, aborted'); - } - - // Translate redis properties into redis object - if (config.database === 'redis') { - config.redis = { - host: databaseConfig['redis:host'], - port: databaseConfig['redis:port'], - password: databaseConfig['redis:password'], - database: databaseConfig['redis:database'], - }; - - if (config.redis.host.slice(0, 1) === '/') { - delete config.redis.port; - } - } else if (config.database === 'mongo') { - config.mongo = { - host: databaseConfig['mongo:host'], - port: databaseConfig['mongo:port'], - username: databaseConfig['mongo:username'], - password: databaseConfig['mongo:password'], - database: databaseConfig['mongo:database'], - uri: databaseConfig['mongo:uri'], - }; - } else if (config.database === 'postgres') { - config.postgres = { - host: databaseConfig['postgres:host'], - port: databaseConfig['postgres:port'], - username: databaseConfig['postgres:username'], - password: databaseConfig['postgres:password'], - database: databaseConfig['postgres:database'], - ssl: databaseConfig['postgres:ssl'], - }; - } else { - throw new Error(`unknown database : ${config.database}`); - } - - const allQuestions = questions.redis.concat(questions.mongo).concat(questions.postgres); - for (let x = 0; x < allQuestions.length; x += 1) { - delete config[allQuestions[x].name]; - } - - return config; + if (!databaseConfig) { + throw new Error('invalid config, aborted'); + } + + // Translate redis properties into redis object + switch (config.database) { + case 'redis': { + config.redis = { + host: databaseConfig['redis:host'], + port: databaseConfig['redis:port'], + password: databaseConfig['redis:password'], + database: databaseConfig['redis:database'], + }; + + if (config.redis.host.slice(0, 1) === '/') { + delete config.redis.port; + } + + break; + } + + case 'mongo': { + config.mongo = { + host: databaseConfig['mongo:host'], + port: databaseConfig['mongo:port'], + username: databaseConfig['mongo:username'], + password: databaseConfig['mongo:password'], + database: databaseConfig['mongo:database'], + uri: databaseConfig['mongo:uri'], + }; + + break; + } + + case 'postgres': { + config.postgres = { + host: databaseConfig['postgres:host'], + port: databaseConfig['postgres:port'], + username: databaseConfig['postgres:username'], + password: databaseConfig['postgres:password'], + database: databaseConfig['postgres:database'], + ssl: databaseConfig['postgres:ssl'], + }; + + break; + } + + default: { + throw new Error(`unknown database : ${config.database}`); + } + } + + const allQuestions = questions.redis.concat(questions.mongo).concat(questions.postgres); + for (const allQuestion of allQuestions) { + delete config[allQuestion.name]; + } + + return config; } diff --git a/install/package.json b/install/package.json index 919c51a..ad51bb0 100644 --- a/install/package.json +++ b/install/package.json @@ -14,7 +14,8 @@ "lint": "npx tsc && eslint --cache ./nodebb .", "test": "npx tsc && nyc --reporter=html --reporter=text-summary mocha", "coverage": "nyc report --reporter=text-lcov > ./coverage/lcov.info", - "coveralls": "nyc report --reporter=text-lcov | coveralls && rm -r coverage" + "coveralls": "nyc report --reporter=text-lcov | coveralls && rm -r coverage", + "xo": "npx xo" }, "nyc": { "exclude": [ @@ -141,6 +142,7 @@ "webpack-merge": "5.8.0", "winston": "3.8.2", "xml": "1.0.1", + "xo": "*", "xregexp": "5.1.1", "yargs": "17.6.2", "zxcvbn": "4.4.2" diff --git a/install/web.js b/install/web.js index 233d7bb..c41c01a 100644 --- a/install/web.js +++ b/install/web.js @@ -1,48 +1,46 @@ 'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); +const childProcess = require('node:child_process'); const winston = require('winston'); const express = require('express'); const bodyParser = require('body-parser'); -const fs = require('fs'); -const path = require('path'); -const childProcess = require('child_process'); const less = require('less'); - const webpack = require('webpack'); const nconf = require('nconf'); - const Benchpress = require('benchpressjs'); const mkdirp = require('mkdirp'); -const { paths } = require('../src/constants'); +const {paths} = require('../src/constants'); const app = express(); let server; const formats = [ - winston.format.colorize(), + winston.format.colorize(), ]; -const timestampFormat = winston.format((info) => { - const dateString = `${new Date().toISOString()} [${global.process.pid}]`; - info.level = `${dateString} - ${info.level}`; - return info; +const timestampFormat = winston.format(info => { + const dateString = `${new Date().toISOString()} [${global.process.pid}]`; + info.level = `${dateString} - ${info.level}`; + return info; }); formats.push(timestampFormat()); formats.push(winston.format.splat()); formats.push(winston.format.simple()); winston.configure({ - level: 'verbose', - format: winston.format.combine.apply(null, formats), - transports: [ - new winston.transports.Console({ - handleExceptions: true, - }), - new winston.transports.File({ - filename: 'logs/webinstall.log', - handleExceptions: true, - }), - ], + level: 'verbose', + format: winston.format.combine.apply(null, formats), + transports: [ + new winston.transports.Console({ + handleExceptions: true, + }), + new winston.transports.File({ + filename: 'logs/webinstall.log', + handleExceptions: true, + }), + ], }); const web = module.exports; @@ -54,233 +52,235 @@ let launchUrl; const viewsDir = path.join(paths.baseDir, 'build/public/templates'); web.install = async function (port) { - port = port || 4567; - winston.info(`Launching web installer on port ${port}`); - - app.use(express.static('public', {})); - app.use('/assets', express.static(path.join(__dirname, '../build/public'), {})); - - app.engine('tpl', (filepath, options, callback) => { - filepath = filepath.replace(/\.tpl$/, '.js'); - - Benchpress.__express(filepath, options, callback); - }); - app.set('view engine', 'tpl'); - app.set('views', viewsDir); - app.use(bodyParser.urlencoded({ - extended: true, - })); - try { - await Promise.all([ - compileTemplate(), - compileLess(), - runWebpack(), - copyCSS(), - loadDefaults(), - ]); - setupRoutes(); - launchExpress(port); - } catch (err) { - winston.error(err.stack); - } + port ||= 4567; + winston.info(`Launching web installer on port ${port}`); + + app.use(express.static('public', {})); + app.use('/assets', express.static(path.join(__dirname, '../build/public'), {})); + + app.engine('tpl', (filepath, options, callback) => { + filepath = filepath.replace(/\.tpl$/, '.js'); + + Benchpress.__express(filepath, options, callback); + }); + app.set('view engine', 'tpl'); + app.set('views', viewsDir); + app.use(bodyParser.urlencoded({ + extended: true, + })); + try { + await Promise.all([ + compileTemplate(), + compileLess(), + runWebpack(), + copyCSS(), + loadDefaults(), + ]); + setupRoutes(); + launchExpress(port); + } catch (error_) { + winston.error(error_.stack); + } }; async function runWebpack() { - const util = require('util'); - const webpackCfg = require('../webpack.installer'); - const compiler = webpack(webpackCfg); - const webpackRun = util.promisify(compiler.run).bind(compiler); - await webpackRun(); + const util = require('node:util'); + const webpackCfg = require('../webpack.installer'); + const compiler = webpack(webpackCfg); + const webpackRun = util.promisify(compiler.run).bind(compiler); + await webpackRun(); } function launchExpress(port) { - server = app.listen(port, () => { - winston.info('Web installer listening on http://%s:%s', '0.0.0.0', port); - }); + server = app.listen(port, () => { + winston.info('Web installer listening on http://%s:%s', '0.0.0.0', port); + }); } function setupRoutes() { - app.get('/', welcome); - app.post('/', install); - app.post('/launch', launch); - app.get('/ping', ping); - app.get('/sping', ping); + app.get('/', welcome); + app.post('/', install); + app.post('/launch', launch); + app.get('/ping', ping); + app.get('/sping', ping); } -function ping(req, res) { - res.status(200).send(req.path === '/sping' ? 'healthy' : '200'); +function ping(request, res) { + res.status(200).send(request.path === '/sping' ? 'healthy' : '200'); } -function welcome(req, res) { - const dbs = ['mongo', 'redis', 'postgres']; - const databases = dbs.map((databaseName) => { - const questions = require(`../src/database/${databaseName}`).questions.filter(question => question && !question.hideOnWebInstall); - - return { - name: databaseName, - questions: questions, - }; - }); - - const defaults = require('./data/defaults.json'); - - res.render('install/index', { - url: nconf.get('url') || (`${req.protocol}://${req.get('host')}`), - launchUrl: launchUrl, - skipGeneralSetup: !!nconf.get('url'), - databases: databases, - skipDatabaseSetup: !!nconf.get('database'), - error: error, - success: success, - values: req.body, - minimumPasswordLength: defaults.minimumPasswordLength, - minimumPasswordStrength: defaults.minimumPasswordStrength, - installing: installing, - }); +function welcome(request, res) { + const dbs = ['mongo', 'redis', 'postgres']; + const databases = dbs.map(databaseName => { + const questions = require(`../src/database/${databaseName}`).questions.filter(question => question && !question.hideOnWebInstall); + + return { + name: databaseName, + questions, + }; + }); + + const defaults = require('./data/defaults.json'); + + res.render('install/index', { + url: nconf.get('url') || (`${request.protocol}://${request.get('host')}`), + launchUrl, + skipGeneralSetup: Boolean(nconf.get('url')), + databases, + skipDatabaseSetup: Boolean(nconf.get('database')), + error, + success, + values: request.body, + minimumPasswordLength: defaults.minimumPasswordLength, + minimumPasswordStrength: defaults.minimumPasswordStrength, + installing, + }); } -function install(req, res) { - if (installing) { - return welcome(req, res); - } - req.setTimeout(0); - installing = true; - - const database = nconf.get('database') || req.body.database || 'mongo'; - const setupEnvVars = { - ...process.env, - NODEBB_URL: nconf.get('url') || req.body.url || (`${req.protocol}://${req.get('host')}`), - NODEBB_PORT: nconf.get('port') || 4567, - NODEBB_ADMIN_USERNAME: nconf.get('admin:username') || req.body['admin:username'], - NODEBB_ADMIN_PASSWORD: nconf.get('admin:password') || req.body['admin:password'], - NODEBB_ADMIN_EMAIL: nconf.get('admin:email') || req.body['admin:email'], - NODEBB_DB: database, - NODEBB_DB_HOST: nconf.get(`${database}:host`) || req.body[`${database}:host`], - NODEBB_DB_PORT: nconf.get(`${database}:port`) || req.body[`${database}:port`], - NODEBB_DB_USER: nconf.get(`${database}:username`) || req.body[`${database}:username`], - NODEBB_DB_PASSWORD: nconf.get(`${database}:password`) || req.body[`${database}:password`], - NODEBB_DB_NAME: nconf.get(`${database}:database`) || req.body[`${database}:database`], - NODEBB_DB_SSL: nconf.get(`${database}:ssl`) || req.body[`${database}:ssl`], - defaultPlugins: JSON.stringify(nconf.get('defaultplugins') || nconf.get('defaultPlugins') || []), - }; - - winston.info('Starting setup process'); - launchUrl = setupEnvVars.NODEBB_URL; - - const child = require('child_process').fork('app', ['--setup'], { - env: setupEnvVars, - }); - - child.on('close', (data) => { - installing = false; - success = data === 0; - error = data !== 0; - - welcome(req, res); - }); +function install(request, res) { + if (installing) { + return welcome(request, res); + } + + request.setTimeout(0); + installing = true; + + const database = nconf.get('database') || request.body.database || 'mongo'; + const setupEnvVariables = { + ...process.env, + NODEBB_URL: nconf.get('url') || request.body.url || (`${request.protocol}://${request.get('host')}`), + NODEBB_PORT: nconf.get('port') || 4567, + NODEBB_ADMIN_USERNAME: nconf.get('admin:username') || request.body['admin:username'], + NODEBB_ADMIN_PASSWORD: nconf.get('admin:password') || request.body['admin:password'], + NODEBB_ADMIN_EMAIL: nconf.get('admin:email') || request.body['admin:email'], + NODEBB_DB: database, + NODEBB_DB_HOST: nconf.get(`${database}:host`) || request.body[`${database}:host`], + NODEBB_DB_PORT: nconf.get(`${database}:port`) || request.body[`${database}:port`], + NODEBB_DB_USER: nconf.get(`${database}:username`) || request.body[`${database}:username`], + NODEBB_DB_PASSWORD: nconf.get(`${database}:password`) || request.body[`${database}:password`], + NODEBB_DB_NAME: nconf.get(`${database}:database`) || request.body[`${database}:database`], + NODEBB_DB_SSL: nconf.get(`${database}:ssl`) || request.body[`${database}:ssl`], + defaultPlugins: JSON.stringify(nconf.get('defaultplugins') || nconf.get('defaultPlugins') || []), + }; + + winston.info('Starting setup process'); + launchUrl = setupEnvVariables.NODEBB_URL; + + const child = require('node:child_process').fork('app', ['--setup'], { + env: setupEnvVariables, + }); + + child.on('close', data => { + installing = false; + success = data === 0; + error = data !== 0; + + welcome(request, res); + }); } -async function launch(req, res) { - try { - res.json({}); - server.close(); - req.setTimeout(0); - let child; - - if (!nconf.get('launchCmd')) { - child = childProcess.spawn('node', ['loader.js'], { - detached: true, - stdio: ['ignore', 'ignore', 'ignore'], - }); - - console.log('\nStarting NodeBB'); - console.log(' "./nodebb stop" to stop the NodeBB server'); - console.log(' "./nodebb log" to view server output'); - console.log(' "./nodebb restart" to restart NodeBB'); - } else { - // Use launchCmd instead, if specified - child = childProcess.exec(nconf.get('launchCmd'), { - detached: true, - stdio: ['ignore', 'ignore', 'ignore'], - }); - } - - const filesToDelete = [ - path.join(__dirname, '../public', 'installer.css'), - path.join(__dirname, '../public', 'bootstrap.min.css'), - path.join(__dirname, '../build/public', 'installer.min.js'), - ]; - try { - await Promise.all( - filesToDelete.map( - filename => fs.promises.unlink(filename) - ) - ); - } catch (err) { - console.log(err.stack); - } - - child.unref(); - process.exit(0); - } catch (err) { - winston.error(err.stack); - throw err; - } +async function launch(request, res) { + try { + res.json({}); + server.close(); + request.setTimeout(0); + let child; + + if (nconf.get('launchCmd')) { + // Use launchCmd instead, if specified + child = childProcess.exec(nconf.get('launchCmd'), { + detached: true, + stdio: ['ignore', 'ignore', 'ignore'], + }); + } else { + child = childProcess.spawn('node', ['loader.js'], { + detached: true, + stdio: ['ignore', 'ignore', 'ignore'], + }); + + console.log('\nStarting NodeBB'); + console.log(' "./nodebb stop" to stop the NodeBB server'); + console.log(' "./nodebb log" to view server output'); + console.log(' "./nodebb restart" to restart NodeBB'); + } + + const filesToDelete = [ + path.join(__dirname, '../public', 'installer.css'), + path.join(__dirname, '../public', 'bootstrap.min.css'), + path.join(__dirname, '../build/public', 'installer.min.js'), + ]; + try { + await Promise.all( + filesToDelete.map( + filename => fs.promises.unlink(filename), + ), + ); + } catch (error_) { + console.log(error_.stack); + } + + child.unref(); + process.exit(0); + } catch (error_) { + winston.error(error_.stack); + throw error_; + } } -// this is necessary because otherwise the compiled templates won't be available on a clean install +// This is necessary because otherwise the compiled templates won't be available on a clean install async function compileTemplate() { - const sourceFile = path.join(__dirname, '../src/views/install/index.tpl'); - const destTpl = path.join(viewsDir, 'install/index.tpl'); - const destJs = path.join(viewsDir, 'install/index.js'); + const sourceFile = path.join(__dirname, '../src/views/install/index.tpl'); + const destinationTpl = path.join(viewsDir, 'install/index.tpl'); + const destinationJs = path.join(viewsDir, 'install/index.js'); - const source = await fs.promises.readFile(sourceFile, 'utf8'); + const source = await fs.promises.readFile(sourceFile, 'utf8'); - const [compiled] = await Promise.all([ - Benchpress.precompile(source, { filename: 'install/index.tpl' }), - mkdirp(path.dirname(destJs)), - ]); + const [compiled] = await Promise.all([ + Benchpress.precompile(source, {filename: 'install/index.tpl'}), + mkdirp(path.dirname(destinationJs)), + ]); - await Promise.all([ - fs.promises.writeFile(destJs, compiled), - fs.promises.writeFile(destTpl, source), - ]); + await Promise.all([ + fs.promises.writeFile(destinationJs, compiled), + fs.promises.writeFile(destinationTpl, source), + ]); } async function compileLess() { - try { - const installSrc = path.join(__dirname, '../public/less/install.less'); - const style = await fs.promises.readFile(installSrc); - const css = await less.render(String(style), { filename: path.resolve(installSrc) }); - await fs.promises.writeFile(path.join(__dirname, '../public/installer.css'), css.css); - } catch (err) { - winston.error(`Unable to compile LESS: \n${err.stack}`); - throw err; - } + try { + const installSource = path.join(__dirname, '../public/less/install.less'); + const style = await fs.promises.readFile(installSource); + const css = await less.render(String(style), {filename: path.resolve(installSource)}); + await fs.promises.writeFile(path.join(__dirname, '../public/installer.css'), css.css); + } catch (error_) { + winston.error(`Unable to compile LESS: \n${error_.stack}`); + throw error_; + } } async function copyCSS() { - const src = await fs.promises.readFile( - path.join(__dirname, '../node_modules/bootstrap/dist/css/bootstrap.min.css'), 'utf8' - ); - await fs.promises.writeFile(path.join(__dirname, '../public/bootstrap.min.css'), src); + const source = await fs.promises.readFile( + path.join(__dirname, '../node_modules/bootstrap/dist/css/bootstrap.min.css'), 'utf8', + ); + await fs.promises.writeFile(path.join(__dirname, '../public/bootstrap.min.css'), source); } async function loadDefaults() { - const setupDefaultsPath = path.join(__dirname, '../setup.json'); - try { - // eslint-disable-next-line no-bitwise - await fs.promises.access(setupDefaultsPath, fs.constants.F_OK | fs.constants.R_OK); - } catch (err) { - // setup.json not found or inaccessible, proceed with no defaults - if (err.code !== 'ENOENT') { - throw err; - } - - return; - } - winston.info('[installer] Found setup.json, populating default values'); - nconf.file({ - file: setupDefaultsPath, - }); + const setupDefaultsPath = path.join(__dirname, '../setup.json'); + try { + // eslint-disable-next-line no-bitwise + await fs.promises.access(setupDefaultsPath, fs.constants.F_OK | fs.constants.R_OK); + } catch (error_) { + // Setup.json not found or inaccessible, proceed with no defaults + if (error_.code !== 'ENOENT') { + throw error_; + } + + return; + } + + winston.info('[installer] Found setup.json, populating default values'); + nconf.file({ + file: setupDefaultsPath, + }); } diff --git a/loader.js b/loader.js index 964c7ac..d041967 100644 --- a/loader.js +++ b/loader.js @@ -1,20 +1,19 @@ 'use strict'; +const fs = require('node:fs'); +const url = require('node:url'); +const path = require('node:path'); +const {fork} = require('node:child_process'); const nconf = require('nconf'); -const fs = require('fs'); -const url = require('url'); -const path = require('path'); -const { fork } = require('child_process'); const logrotate = require('logrotate-stream'); const mkdirp = require('mkdirp'); - const file = require('./src/file'); const pkg = require('./package.json'); const pathToConfig = path.resolve(__dirname, process.env.CONFIG || 'config.json'); nconf.argv().env().file({ - file: pathToConfig, + file: pathToConfig, }); const pidFilePath = path.join(__dirname, 'pidfile'); @@ -23,227 +22,243 @@ const outputLogFilePath = path.join(__dirname, nconf.get('logFile') || 'logs/out const logDir = path.dirname(outputLogFilePath); if (!fs.existsSync(logDir)) { - mkdirp.sync(path.dirname(outputLogFilePath)); + mkdirp.sync(path.dirname(outputLogFilePath)); } -const output = logrotate({ file: outputLogFilePath, size: '1m', keep: 3, compress: true }); +const output = logrotate({ + file: outputLogFilePath, size: '1m', keep: 3, compress: true, +}); const silent = nconf.get('silent') === 'false' ? false : nconf.get('silent') !== false; -let numProcs; +let numberProcs; const workers = []; const Loader = { - timesStarted: 0, + timesStarted: 0, }; const appPath = path.join(__dirname, 'app.js'); Loader.init = function () { - if (silent) { - console.log = (...args) => { - output.write(`${args.join(' ')}\n`); - }; - } - - process.on('SIGHUP', Loader.restart); - process.on('SIGTERM', Loader.stop); + if (silent) { + console.log = (...arguments_) => { + output.write(`${arguments_.join(' ')}\n`); + }; + } + + process.on('SIGHUP', Loader.restart); + process.on('SIGTERM', Loader.stop); }; Loader.displayStartupMessages = function () { - console.log(''); - console.log(`NodeBB v${pkg.version} Copyright (C) 2013-${(new Date()).getFullYear()} NodeBB Inc.`); - console.log('This program comes with ABSOLUTELY NO WARRANTY.'); - console.log('This is free software, and you are welcome to redistribute it under certain conditions.'); - console.log('For the full license, please visit: http://www.gnu.org/copyleft/gpl.html'); - console.log(''); + console.log(''); + console.log(`NodeBB v${pkg.version} Copyright (C) 2013-${(new Date()).getFullYear()} NodeBB Inc.`); + console.log('This program comes with ABSOLUTELY NO WARRANTY.'); + console.log('This is free software, and you are welcome to redistribute it under certain conditions.'); + console.log('For the full license, please visit: http://www.gnu.org/copyleft/gpl.html'); + console.log(''); }; Loader.addWorkerEvents = function (worker) { - worker.on('exit', (code, signal) => { - if (code !== 0) { - if (Loader.timesStarted < numProcs * 3) { - Loader.timesStarted += 1; - if (Loader.crashTimer) { - clearTimeout(Loader.crashTimer); - } - Loader.crashTimer = setTimeout(() => { - Loader.timesStarted = 0; - }, 10000); - } else { - console.log(`${numProcs * 3} restarts in 10 seconds, most likely an error on startup. Halting.`); - process.exit(); - } - } - - console.log(`[cluster] Child Process (${worker.pid}) has exited (code: ${code}, signal: ${signal})`); - if (!(worker.suicide || code === 0)) { - console.log('[cluster] Spinning up another process...'); - - forkWorker(worker.index, worker.isPrimary); - } - }); - - worker.on('message', (message) => { - if (message && typeof message === 'object' && message.action) { - switch (message.action) { - case 'restart': - console.log('[cluster] Restarting...'); - Loader.restart(); - break; - case 'pubsub': - workers.forEach((w) => { - w.send(message); - }); - break; - case 'socket.io': - workers.forEach((w) => { - if (w !== worker) { - w.send(message); - } - }); - break; - } - } - }); + worker.on('exit', (code, signal) => { + if (code !== 0) { + if (Loader.timesStarted < numberProcs * 3) { + Loader.timesStarted += 1; + if (Loader.crashTimer) { + clearTimeout(Loader.crashTimer); + } + + Loader.crashTimer = setTimeout(() => { + Loader.timesStarted = 0; + }, 10_000); + } else { + console.log(`${numberProcs * 3} restarts in 10 seconds, most likely an error on startup. Halting.`); + process.exit(); + } + } + + console.log(`[cluster] Child Process (${worker.pid}) has exited (code: ${code}, signal: ${signal})`); + if (!(worker.suicide || code === 0)) { + console.log('[cluster] Spinning up another process...'); + + forkWorker(worker.index, worker.isPrimary); + } + }); + + worker.on('message', message => { + if (message && typeof message === 'object' && message.action) { + switch (message.action) { + case 'restart': { + console.log('[cluster] Restarting...'); + Loader.restart(); + break; + } + + case 'pubsub': { + for (const w of workers) { + w.send(message); + } + + break; + } + + case 'socket.io': { + for (const w of workers) { + if (w !== worker) { + w.send(message); + } + } + + break; + } + } + } + }); }; Loader.start = function () { - numProcs = getPorts().length; - console.log(`Clustering enabled: Spinning up ${numProcs} process(es).\n`); + numberProcs = getPorts().length; + console.log(`Clustering enabled: Spinning up ${numberProcs} process(es).\n`); - for (let x = 0; x < numProcs; x += 1) { - forkWorker(x, x === 0); - } + for (let x = 0; x < numberProcs; x += 1) { + forkWorker(x, x === 0); + } }; function forkWorker(index, isPrimary) { - const ports = getPorts(); - const args = []; + const ports = getPorts(); + const arguments_ = []; - if (!ports[index]) { - return console.log(`[cluster] invalid port for worker : ${index} ports: ${ports.length}`); - } + if (!ports[index]) { + return console.log(`[cluster] invalid port for worker : ${index} ports: ${ports.length}`); + } - process.env.isPrimary = isPrimary; - process.env.isCluster = nconf.get('isCluster') || ports.length > 1; - process.env.port = ports[index]; + process.env.isPrimary = isPrimary; + process.env.isCluster = nconf.get('isCluster') || ports.length > 1; + process.env.port = ports[index]; - const worker = fork(appPath, args, { - silent: silent, - env: process.env, - }); + const worker = fork(appPath, arguments_, { + silent, + env: process.env, + }); - worker.index = index; - worker.isPrimary = isPrimary; + worker.index = index; + worker.isPrimary = isPrimary; - workers[index] = worker; + workers[index] = worker; - Loader.addWorkerEvents(worker); + Loader.addWorkerEvents(worker); - if (silent) { - const output = logrotate({ file: outputLogFilePath, size: '1m', keep: 3, compress: true }); - worker.stdout.pipe(output); - worker.stderr.pipe(output); - } + if (silent) { + const output = logrotate({ + file: outputLogFilePath, size: '1m', keep: 3, compress: true, + }); + worker.stdout.pipe(output); + worker.stderr.pipe(output); + } } function getPorts() { - const _url = nconf.get('url'); - if (!_url) { - console.log('[cluster] url is undefined, please check your config.json'); - process.exit(); - } - const urlObject = url.parse(_url); - let port = nconf.get('PORT') || nconf.get('port') || urlObject.port || 4567; - if (!Array.isArray(port)) { - port = [port]; - } - return port; + const _url = nconf.get('url'); + if (!_url) { + console.log('[cluster] url is undefined, please check your config.json'); + process.exit(); + } + + const urlObject = url.parse(_url); + let port = nconf.get('PORT') || nconf.get('port') || urlObject.port || 4567; + if (!Array.isArray(port)) { + port = [port]; + } + + return port; } Loader.restart = function () { - killWorkers(); + killWorkers(); + + nconf.remove('file'); + nconf.use('file', {file: pathToConfig}); - nconf.remove('file'); - nconf.use('file', { file: pathToConfig }); + fs.readFile(pathToConfig, {encoding: 'utf-8'}, (error, configFile) => { + if (error) { + console.error('Error reading config'); + throw error; + } - fs.readFile(pathToConfig, { encoding: 'utf-8' }, (err, configFile) => { - if (err) { - console.error('Error reading config'); - throw err; - } + const config = JSON.parse(configFile); - const conf = JSON.parse(configFile); + nconf.stores.env.readOnly = false; + nconf.set('url', config.url); + nconf.stores.env.readOnly = true; - nconf.stores.env.readOnly = false; - nconf.set('url', conf.url); - nconf.stores.env.readOnly = true; + if (process.env.url !== config.url) { + process.env.url = config.url; + } - if (process.env.url !== conf.url) { - process.env.url = conf.url; - } - Loader.start(); - }); + Loader.start(); + }); }; Loader.stop = function () { - killWorkers(); + killWorkers(); - // Clean up the pidfile - if (nconf.get('daemon') !== 'false' && nconf.get('daemon') !== false) { - fs.unlinkSync(pidFilePath); - } + // Clean up the pidfile + if (nconf.get('daemon') !== 'false' && nconf.get('daemon') !== false) { + fs.unlinkSync(pidFilePath); + } }; function killWorkers() { - workers.forEach((worker) => { - worker.suicide = true; - worker.kill(); - }); + for (const worker of workers) { + worker.suicide = true; + worker.kill(); + } } -fs.open(pathToConfig, 'r', (err) => { - if (err) { - // No config detected, kickstart web installer - fork('app'); - return; - } - - if (nconf.get('daemon') !== 'false' && nconf.get('daemon') !== false) { - if (file.existsSync(pidFilePath)) { - let pid = 0; - try { - pid = fs.readFileSync(pidFilePath, { encoding: 'utf-8' }); - if (pid) { - process.kill(pid, 0); - console.info(`Process "${pid}" from pidfile already running, exiting`); - process.exit(); - } else { - console.info(`Invalid pid "${pid}" from pidfile, deleting pidfile`); - fs.unlinkSync(pidFilePath); - } - } catch (err) { - if (err.code === 'ESRCH') { - console.info(`Process "${pid}" from pidfile not found, deleting pidfile`); - fs.unlinkSync(pidFilePath); - } else { - console.error(err.stack); - throw err; - } - } - } - - require('daemon')({ - stdout: process.stdout, - stderr: process.stderr, - cwd: process.cwd(), - }); - - fs.writeFileSync(pidFilePath, String(process.pid)); - } - try { - Loader.init(); - Loader.displayStartupMessages(); - Loader.start(); - } catch (err) { - console.error('[loader] Error during startup'); - throw err; - } +fs.open(pathToConfig, 'r', error => { + if (error) { + // No config detected, kickstart web installer + fork('app'); + return; + } + + if (nconf.get('daemon') !== 'false' && nconf.get('daemon') !== false) { + if (file.existsSync(pidFilePath)) { + let pid = 0; + try { + pid = fs.readFileSync(pidFilePath, {encoding: 'utf-8'}); + if (pid) { + process.kill(pid, 0); + console.info(`Process "${pid}" from pidfile already running, exiting`); + process.exit(); + } else { + console.info(`Invalid pid "${pid}" from pidfile, deleting pidfile`); + fs.unlinkSync(pidFilePath); + } + } catch (error) { + if (error.code === 'ESRCH') { + console.info(`Process "${pid}" from pidfile not found, deleting pidfile`); + fs.unlinkSync(pidFilePath); + } else { + console.error(error.stack); + throw error; + } + } + } + + require('daemon')({ + stdout: process.stdout, + stderr: process.stderr, + cwd: process.cwd(), + }); + + fs.writeFileSync(pidFilePath, String(process.pid)); + } + + try { + Loader.init(); + Loader.displayStartupMessages(); + Loader.start(); + } catch (error) { + console.error('[loader] Error during startup'); + throw error; + } }); diff --git a/public/src/admin/advanced/cache.js b/public/src/admin/advanced/cache.js index 4d26e42..3433186 100644 --- a/public/src/admin/advanced/cache.js +++ b/public/src/admin/advanced/cache.js @@ -1,32 +1,34 @@ 'use strict'; -define('admin/advanced/cache', ['alerts'], function (alerts) { - const Cache = {}; - Cache.init = function () { - require(['admin/settings'], function (Settings) { - Settings.prepare(); - }); +define('admin/advanced/cache', ['alerts'], alerts => { + const Cache = {}; + Cache.init = function () { + require(['admin/settings'], Settings => { + Settings.prepare(); + }); - $('.clear').on('click', function () { - const name = $(this).attr('data-name'); - socket.emit('admin.cache.clear', { name: name }, function (err) { - if (err) { - return alerts.error(err); - } - ajaxify.refresh(); - }); - }); + $('.clear').on('click', function () { + const name = $(this).attr('data-name'); + socket.emit('admin.cache.clear', {name}, error => { + if (error) { + return alerts.error(error); + } - $('.checkbox').on('change', function () { - const input = $(this).find('input'); - const flag = input.is(':checked'); - const name = $(this).attr('data-name'); - socket.emit('admin.cache.toggle', { name: name, enabled: flag }, function (err) { - if (err) { - return alerts.error(err); - } - }); - }); - }; - return Cache; + ajaxify.refresh(); + }); + }); + + $('.checkbox').on('change', function () { + const input = $(this).find('input'); + const flag = input.is(':checked'); + const name = $(this).attr('data-name'); + socket.emit('admin.cache.toggle', {name, enabled: flag}, error => { + if (error) { + return alerts.error(error); + } + }); + }); + }; + + return Cache; }); diff --git a/public/src/admin/advanced/errors.js b/public/src/admin/advanced/errors.js index d69eb26..d84297e 100644 --- a/public/src/admin/advanced/errors.js +++ b/public/src/admin/advanced/errors.js @@ -1,113 +1,112 @@ 'use strict'; +define('admin/advanced/errors', ['bootbox', 'alerts', 'Chart'], (bootbox, alerts, Chart) => { + const Errors = {}; -define('admin/advanced/errors', ['bootbox', 'alerts', 'Chart'], function (bootbox, alerts, Chart) { - const Errors = {}; + Errors.init = function () { + Errors.setupCharts(); - Errors.init = function () { - Errors.setupCharts(); + $('[data-action="clear"]').on('click', Errors.clear404); + }; - $('[data-action="clear"]').on('click', Errors.clear404); - }; + Errors.clear404 = function () { + bootbox.confirm('[[admin/advanced/errors:clear404-confirm]]', ok => { + if (ok) { + socket.emit('admin.errors.clear', {}, error => { + if (error) { + return alerts.error(error); + } - Errors.clear404 = function () { - bootbox.confirm('[[admin/advanced/errors:clear404-confirm]]', function (ok) { - if (ok) { - socket.emit('admin.errors.clear', {}, function (err) { - if (err) { - return alerts.error(err); - } + ajaxify.refresh(); + alerts.success('[[admin/advanced/errors:clear404-success]]'); + }); + } + }); + }; - ajaxify.refresh(); - alerts.success('[[admin/advanced/errors:clear404-success]]'); - }); - } - }); - }; + Errors.setupCharts = function () { + const notFoundCanvas = document.querySelector('#not-found'); + const tooBusyCanvas = document.querySelector('#toobusy'); + let dailyLabels = utils.getDaysArray(); - Errors.setupCharts = function () { - const notFoundCanvas = document.getElementById('not-found'); - const tooBusyCanvas = document.getElementById('toobusy'); - let dailyLabels = utils.getDaysArray(); + dailyLabels = dailyLabels.slice(-7); - dailyLabels = dailyLabels.slice(-7); + if (utils.isMobile()) { + Chart.defaults.global.tooltips.enabled = false; + } - if (utils.isMobile()) { - Chart.defaults.global.tooltips.enabled = false; - } + const data = { + 'not-found': { + labels: dailyLabels, + datasets: [ + { + label: '', + backgroundColor: 'rgba(186,139,175,0.2)', + borderColor: 'rgba(186,139,175,1)', + pointBackgroundColor: 'rgba(186,139,175,1)', + pointHoverBackgroundColor: '#fff', + pointBorderColor: '#fff', + pointHoverBorderColor: 'rgba(186,139,175,1)', + data: ajaxify.data.analytics['not-found'], + }, + ], + }, + toobusy: { + labels: dailyLabels, + datasets: [ + { + label: '', + backgroundColor: 'rgba(151,187,205,0.2)', + borderColor: 'rgba(151,187,205,1)', + pointBackgroundColor: 'rgba(151,187,205,1)', + pointHoverBackgroundColor: '#fff', + pointBorderColor: '#fff', + pointHoverBorderColor: 'rgba(151,187,205,1)', + data: ajaxify.data.analytics.toobusy, + }, + ], + }, + }; - const data = { - 'not-found': { - labels: dailyLabels, - datasets: [ - { - label: '', - backgroundColor: 'rgba(186,139,175,0.2)', - borderColor: 'rgba(186,139,175,1)', - pointBackgroundColor: 'rgba(186,139,175,1)', - pointHoverBackgroundColor: '#fff', - pointBorderColor: '#fff', - pointHoverBorderColor: 'rgba(186,139,175,1)', - data: ajaxify.data.analytics['not-found'], - }, - ], - }, - toobusy: { - labels: dailyLabels, - datasets: [ - { - label: '', - backgroundColor: 'rgba(151,187,205,0.2)', - borderColor: 'rgba(151,187,205,1)', - pointBackgroundColor: 'rgba(151,187,205,1)', - pointHoverBackgroundColor: '#fff', - pointBorderColor: '#fff', - pointHoverBorderColor: 'rgba(151,187,205,1)', - data: ajaxify.data.analytics.toobusy, - }, - ], - }, - }; + notFoundCanvas.width = $(notFoundCanvas).parent().width(); + tooBusyCanvas.width = $(tooBusyCanvas).parent().width(); - notFoundCanvas.width = $(notFoundCanvas).parent().width(); - tooBusyCanvas.width = $(tooBusyCanvas).parent().width(); + new Chart(notFoundCanvas.getContext('2d'), { + type: 'line', + data: data['not-found'], + options: { + responsive: true, + legend: { + display: false, + }, + scales: { + yAxes: [{ + ticks: { + beginAtZero: true, + }, + }], + }, + }, + }); - new Chart(notFoundCanvas.getContext('2d'), { - type: 'line', - data: data['not-found'], - options: { - responsive: true, - legend: { - display: false, - }, - scales: { - yAxes: [{ - ticks: { - beginAtZero: true, - }, - }], - }, - }, - }); + new Chart(tooBusyCanvas.getContext('2d'), { + type: 'line', + data: data.toobusy, + options: { + responsive: true, + legend: { + display: false, + }, + scales: { + yAxes: [{ + ticks: { + beginAtZero: true, + }, + }], + }, + }, + }); + }; - new Chart(tooBusyCanvas.getContext('2d'), { - type: 'line', - data: data.toobusy, - options: { - responsive: true, - legend: { - display: false, - }, - scales: { - yAxes: [{ - ticks: { - beginAtZero: true, - }, - }], - }, - }, - }); - }; - - return Errors; + return Errors; }); diff --git a/public/src/admin/advanced/events.js b/public/src/admin/advanced/events.js index a2794fe..160e2e9 100644 --- a/public/src/admin/advanced/events.js +++ b/public/src/admin/advanced/events.js @@ -1,43 +1,44 @@ 'use strict'; - -define('admin/advanced/events', ['bootbox', 'alerts'], function (bootbox, alerts) { - const Events = {}; - - Events.init = function () { - $('[data-action="clear"]').on('click', function () { - bootbox.confirm('[[admin/advanced/events:confirm-delete-all-events]]', (confirm) => { - if (confirm) { - socket.emit('admin.deleteAllEvents', function (err) { - if (err) { - return alerts.error(err); - } - $('.events-list').empty(); - }); - } - }); - }); - - $('.delete-event').on('click', function () { - const $parentEl = $(this).parents('[data-eid]'); - const eid = $parentEl.attr('data-eid'); - socket.emit('admin.deleteEvents', [eid], function (err) { - if (err) { - return alerts.error(err); - } - $parentEl.remove(); - }); - }); - - $('#apply').on('click', Events.refresh); - }; - - Events.refresh = function (event) { - event.preventDefault(); - - const $formEl = $('#filters'); - ajaxify.go('admin/advanced/events?' + $formEl.serialize()); - }; - - return Events; +define('admin/advanced/events', ['bootbox', 'alerts'], (bootbox, alerts) => { + const Events = {}; + + Events.init = function () { + $('[data-action="clear"]').on('click', () => { + bootbox.confirm('[[admin/advanced/events:confirm-delete-all-events]]', confirm => { + if (confirm) { + socket.emit('admin.deleteAllEvents', error => { + if (error) { + return alerts.error(error); + } + + $('.events-list').empty(); + }); + } + }); + }); + + $('.delete-event').on('click', function () { + const $parentElement = $(this).parents('[data-eid]'); + const eid = $parentElement.attr('data-eid'); + socket.emit('admin.deleteEvents', [eid], error => { + if (error) { + return alerts.error(error); + } + + $parentElement.remove(); + }); + }); + + $('#apply').on('click', Events.refresh); + }; + + Events.refresh = function (event) { + event.preventDefault(); + + const $formElement = $('#filters'); + ajaxify.go('admin/advanced/events?' + $formElement.serialize()); + }; + + return Events; }); diff --git a/public/src/admin/advanced/logs.js b/public/src/admin/advanced/logs.js index 1c120e3..3abafd0 100644 --- a/public/src/admin/advanced/logs.js +++ b/public/src/admin/advanced/logs.js @@ -1,44 +1,45 @@ 'use strict'; +define('admin/advanced/logs', ['alerts'], alerts => { + const Logs = {}; -define('admin/advanced/logs', ['alerts'], function (alerts) { - const Logs = {}; + Logs.init = function () { + const logsElement = $('.logs pre'); + logsElement.scrollTop(logsElement.prop('scrollHeight')); + // Affix menu + $('.affix').affix(); - Logs.init = function () { - const logsEl = $('.logs pre'); - logsEl.scrollTop(logsEl.prop('scrollHeight')); - // Affix menu - $('.affix').affix(); + $('.logs').find('button[data-action]').on('click', function () { + const buttonElement = $(this); + const action = buttonElement.attr('data-action'); - $('.logs').find('button[data-action]').on('click', function () { - const btnEl = $(this); - const action = btnEl.attr('data-action'); + switch (action) { + case 'reload': { + socket.emit('admin.logs.get', (error, logs) => { + if (error) { + alerts.error(error); + } else { + logsElement.text(logs); + logsElement.scrollTop(logsElement.prop('scrollHeight')); + } + }); + break; + } - switch (action) { - case 'reload': - socket.emit('admin.logs.get', function (err, logs) { - if (!err) { - logsEl.text(logs); - logsEl.scrollTop(logsEl.prop('scrollHeight')); - } else { - alerts.error(err); - } - }); - break; + case 'clear': { + socket.emit('admin.logs.clear', error => { + if (error) { + alerts.error(error); + } else { + alerts.success('[[admin/advanced/logs:clear-success]]'); + buttonElement.prev().click(); + } + }); + break; + } + } + }); + }; - case 'clear': - socket.emit('admin.logs.clear', function (err) { - if (!err) { - alerts.success('[[admin/advanced/logs:clear-success]]'); - btnEl.prev().click(); - } else { - alerts.error(err); - } - }); - break; - } - }); - }; - - return Logs; + return Logs; }); diff --git a/public/src/admin/appearance/customise.js b/public/src/admin/appearance/customise.js index e3b324e..2213e4e 100644 --- a/public/src/admin/appearance/customise.js +++ b/public/src/admin/appearance/customise.js @@ -1,40 +1,40 @@ 'use strict'; -define('admin/appearance/customise', ['admin/settings', 'ace/ace'], function (Settings, ace) { - const Customise = {}; +define('admin/appearance/customise', ['admin/settings', 'ace/ace'], (Settings, ace) => { + const Customise = {}; - Customise.init = function () { - Settings.prepare(function () { - $('#customCSS').text($('#customCSS-holder').val()); - $('#customJS').text($('#customJS-holder').val()); - $('#customHTML').text($('#customHTML-holder').val()); + Customise.init = function () { + Settings.prepare(() => { + $('#customCSS').text($('#customCSS-holder').val()); + $('#customJS').text($('#customJS-holder').val()); + $('#customHTML').text($('#customHTML-holder').val()); - initACE('customCSS', 'less', '#customCSS-holder'); - initACE('customJS', 'javascript', '#customJS-holder'); - initACE('customHTML', 'html', '#customHTML-holder'); + initACE('customCSS', 'less', '#customCSS-holder'); + initACE('customJS', 'javascript', '#customJS-holder'); + initACE('customHTML', 'html', '#customHTML-holder'); - $('#save').on('click', function () { - if ($('#enableLiveReload').is(':checked')) { - socket.emit('admin.reloadAllSessions'); - } - }); - }); - }; + $('#save').on('click', () => { + if ($('#enableLiveReload').is(':checked')) { + socket.emit('admin.reloadAllSessions'); + } + }); + }); + }; - function initACE(aceElementId, mode, holder) { - const editorEl = ace.edit(aceElementId, { - mode: 'ace/mode/' + mode, - theme: 'ace/theme/twilight', - maxLines: 30, - minLines: 30, - fontSize: 14, - }); - editorEl.on('change', function () { - app.flags = app.flags || {}; - app.flags._unsaved = true; - $(holder).val(editorEl.getValue()); - }); - } + function initACE(aceElementId, mode, holder) { + const editorElement = ace.edit(aceElementId, { + mode: 'ace/mode/' + mode, + theme: 'ace/theme/twilight', + maxLines: 30, + minLines: 30, + fontSize: 14, + }); + editorElement.on('change', () => { + app.flags = app.flags || {}; + app.flags._unsaved = true; + $(holder).val(editorElement.getValue()); + }); + } - return Customise; + return Customise; }); diff --git a/public/src/admin/appearance/skins.js b/public/src/admin/appearance/skins.js index 14c3864..a8fa42b 100644 --- a/public/src/admin/appearance/skins.js +++ b/public/src/admin/appearance/skins.js @@ -1,113 +1,110 @@ 'use strict'; - -define('admin/appearance/skins', ['translator', 'alerts'], function (translator, alerts) { - const Skins = {}; - - Skins.init = function () { - // Populate skins from Bootswatch API - $.ajax({ - method: 'get', - url: 'https://bootswatch.com/api/3.json', - }).done(Skins.render); - - $('#skins').on('click', function (e) { - let target = $(e.target); - - if (!target.attr('data-action')) { - target = target.parents('[data-action]'); - } - - const action = target.attr('data-action'); - - if (action && action === 'use') { - const parentEl = target.parents('[data-theme]'); - const themeType = parentEl.attr('data-type'); - const cssSrc = parentEl.attr('data-css'); - const themeId = parentEl.attr('data-theme'); - - - socket.emit('admin.themes.set', { - type: themeType, - id: themeId, - src: cssSrc, - }, function (err) { - if (err) { - return alerts.error(err); - } - highlightSelectedTheme(themeId); - - alerts.alert({ - alert_id: 'admin:theme', - type: 'info', - title: '[[admin/appearance/skins:skin-updated]]', - message: themeId ? ('[[admin/appearance/skins:applied-success, ' + themeId + ']]') : '[[admin/appearance/skins:revert-success]]', - timeout: 5000, - }); - }); - } - }); - }; - - Skins.render = function (bootswatch) { - const themeContainer = $('#bootstrap_themes'); - - app.parseAndTranslate('admin/partials/theme_list', { - themes: bootswatch.themes.map(function (theme) { - return { - type: 'bootswatch', - id: theme.name, - name: theme.name, - description: theme.description, - screenshot_url: theme.thumbnail, - url: theme.preview, - css: theme.cssCdn, - skin: true, - }; - }), - showRevert: true, - }, function (html) { - themeContainer.html(html); - - if (config['theme:src']) { - const skin = config['theme:src'] - .match(/latest\/(\S+)\/bootstrap.min.css/)[1] - .replace(/(^|\s)([a-z])/g, function (m, p1, p2) { return p1 + p2.toUpperCase(); }); - - highlightSelectedTheme(skin); - } - }); - }; - - function highlightSelectedTheme(themeId) { - translator.translate('[[admin/appearance/skins:select-skin]] || [[admin/appearance/skins:current-skin]]', function (text) { - text = text.split(' || '); - const select = text[0]; - const current = text[1]; - - $('[data-theme]') - .removeClass('selected') - .find('[data-action="use"]').each(function () { - if ($(this).parents('[data-theme]').attr('data-theme')) { - $(this) - .html(select) - .removeClass('btn-success') - .addClass('btn-primary'); - } - }); - - if (!themeId) { - return; - } - - $('[data-theme="' + themeId + '"]') - .addClass('selected') - .find('[data-action="use"]') - .html(current) - .removeClass('btn-primary') - .addClass('btn-success'); - }); - } - - return Skins; +define('admin/appearance/skins', ['translator', 'alerts'], (translator, alerts) => { + const Skins = {}; + + Skins.init = function () { + // Populate skins from Bootswatch API + $.ajax({ + method: 'get', + url: 'https://bootswatch.com/api/3.json', + }).done(Skins.render); + + $('#skins').on('click', e => { + let target = $(e.target); + + if (!target.attr('data-action')) { + target = target.parents('[data-action]'); + } + + const action = target.attr('data-action'); + + if (action && action === 'use') { + const parentElement = target.parents('[data-theme]'); + const themeType = parentElement.attr('data-type'); + const cssSource = parentElement.attr('data-css'); + const themeId = parentElement.attr('data-theme'); + + socket.emit('admin.themes.set', { + type: themeType, + id: themeId, + src: cssSource, + }, error => { + if (error) { + return alerts.error(error); + } + + highlightSelectedTheme(themeId); + + alerts.alert({ + alert_id: 'admin:theme', + type: 'info', + title: '[[admin/appearance/skins:skin-updated]]', + message: themeId ? ('[[admin/appearance/skins:applied-success, ' + themeId + ']]') : '[[admin/appearance/skins:revert-success]]', + timeout: 5000, + }); + }); + } + }); + }; + + Skins.render = function (bootswatch) { + const themeContainer = $('#bootstrap_themes'); + + app.parseAndTranslate('admin/partials/theme_list', { + themes: bootswatch.themes.map(theme => ({ + type: 'bootswatch', + id: theme.name, + name: theme.name, + description: theme.description, + screenshot_url: theme.thumbnail, + url: theme.preview, + css: theme.cssCdn, + skin: true, + })), + showRevert: true, + }, html => { + themeContainer.html(html); + + if (config['theme:src']) { + const skin = config['theme:src'] + .match(/latest\/(\S+)\/bootstrap.min.css/)[1] + .replaceAll(/(^|\s)([a-z])/g, (m, p1, p2) => p1 + p2.toUpperCase()); + + highlightSelectedTheme(skin); + } + }); + }; + + function highlightSelectedTheme(themeId) { + translator.translate('[[admin/appearance/skins:select-skin]] || [[admin/appearance/skins:current-skin]]', text => { + text = text.split(' || '); + const select = text[0]; + const current = text[1]; + + $('[data-theme]') + .removeClass('selected') + .find('[data-action="use"]').each(function () { + if ($(this).parents('[data-theme]').attr('data-theme')) { + $(this) + .html(select) + .removeClass('btn-success') + .addClass('btn-primary'); + } + }); + + if (!themeId) { + return; + } + + $('[data-theme="' + themeId + '"]') + .addClass('selected') + .find('[data-action="use"]') + .html(current) + .removeClass('btn-primary') + .addClass('btn-success'); + }); + } + + return Skins; }); diff --git a/public/src/admin/appearance/themes.js b/public/src/admin/appearance/themes.js index a9d6796..db00cfc 100644 --- a/public/src/admin/appearance/themes.js +++ b/public/src/admin/appearance/themes.js @@ -1,118 +1,121 @@ 'use strict'; +define('admin/appearance/themes', ['bootbox', 'translator', 'alerts'], (bootbox, translator, alerts) => { + const Themes = {}; -define('admin/appearance/themes', ['bootbox', 'translator', 'alerts'], function (bootbox, translator, alerts) { - const Themes = {}; - - Themes.init = function () { - $('#installed_themes').on('click', function (e) { - const target = $(e.target); - const action = target.attr('data-action'); - - if (action && action === 'use') { - const parentEl = target.parents('[data-theme]'); - const themeType = parentEl.attr('data-type'); - const cssSrc = parentEl.attr('data-css'); - const themeId = parentEl.attr('data-theme'); - - if (config['theme:id'] === themeId) { - return; - } - socket.emit('admin.themes.set', { - type: themeType, - id: themeId, - src: cssSrc, - }, function (err) { - if (err) { - return alerts.error(err); - } - config['theme:id'] = themeId; - highlightSelectedTheme(themeId); - - alerts.alert({ - alert_id: 'admin:theme', - type: 'info', - title: '[[admin/appearance/themes:theme-changed]]', - message: '[[admin/appearance/themes:restart-to-activate]]', - timeout: 5000, - clickfn: function () { - require(['admin/modules/instance'], function (instance) { - instance.rebuildAndRestart(); - }); - }, - }); - }); - } - }); - - $('#revert_theme').on('click', function () { - if (config['theme:id'] === 'nodebb-theme-persona') { - return; - } - bootbox.confirm('[[admin/appearance/themes:revert-confirm]]', function (confirm) { - if (confirm) { - socket.emit('admin.themes.set', { - type: 'local', - id: 'nodebb-theme-persona', - }, function (err) { - if (err) { - return alerts.error(err); - } - config['theme:id'] = 'nodebb-theme-persona'; - highlightSelectedTheme('nodebb-theme-persona'); - alerts.alert({ - alert_id: 'admin:theme', - type: 'success', - title: '[[admin/appearance/themes:theme-changed]]', - message: '[[admin/appearance/themes:revert-success]]', - timeout: 3500, - }); - }); - } - }); - }); - - socket.emit('admin.themes.getInstalled', function (err, themes) { - if (err) { - return alerts.error(err); - } - - const instListEl = $('#installed_themes'); - - if (!themes.length) { - instListEl.append($('
  • ').addClass('no-themes').translateHtml('[[admin/appearance/themes:no-themes]]')); - } else { - app.parseAndTranslate('admin/partials/theme_list', { - themes: themes, - }, function (html) { - instListEl.html(html); - highlightSelectedTheme(config['theme:id']); - }); - } - }); - }; - - function highlightSelectedTheme(themeId) { - translator.translate('[[admin/appearance/themes:select-theme]] || [[admin/appearance/themes:current-theme]]', function (text) { - text = text.split(' || '); - const select = text[0]; - const current = text[1]; - - $('[data-theme]') - .removeClass('selected') - .find('[data-action="use"]') - .html(select) - .removeClass('btn-success') - .addClass('btn-primary'); - - $('[data-theme="' + themeId + '"]') - .addClass('selected') - .find('[data-action="use"]') - .html(current) - .removeClass('btn-primary') - .addClass('btn-success'); - }); - } - - return Themes; + Themes.init = function () { + $('#installed_themes').on('click', e => { + const target = $(e.target); + const action = target.attr('data-action'); + + if (action && action === 'use') { + const parentElement = target.parents('[data-theme]'); + const themeType = parentElement.attr('data-type'); + const cssSource = parentElement.attr('data-css'); + const themeId = parentElement.attr('data-theme'); + + if (config['theme:id'] === themeId) { + return; + } + + socket.emit('admin.themes.set', { + type: themeType, + id: themeId, + src: cssSource, + }, error => { + if (error) { + return alerts.error(error); + } + + config['theme:id'] = themeId; + highlightSelectedTheme(themeId); + + alerts.alert({ + alert_id: 'admin:theme', + type: 'info', + title: '[[admin/appearance/themes:theme-changed]]', + message: '[[admin/appearance/themes:restart-to-activate]]', + timeout: 5000, + clickfn() { + require(['admin/modules/instance'], instance => { + instance.rebuildAndRestart(); + }); + }, + }); + }); + } + }); + + $('#revert_theme').on('click', () => { + if (config['theme:id'] === 'nodebb-theme-persona') { + return; + } + + bootbox.confirm('[[admin/appearance/themes:revert-confirm]]', confirm => { + if (confirm) { + socket.emit('admin.themes.set', { + type: 'local', + id: 'nodebb-theme-persona', + }, error => { + if (error) { + return alerts.error(error); + } + + config['theme:id'] = 'nodebb-theme-persona'; + highlightSelectedTheme('nodebb-theme-persona'); + alerts.alert({ + alert_id: 'admin:theme', + type: 'success', + title: '[[admin/appearance/themes:theme-changed]]', + message: '[[admin/appearance/themes:revert-success]]', + timeout: 3500, + }); + }); + } + }); + }); + + socket.emit('admin.themes.getInstalled', (error, themes) => { + if (error) { + return alerts.error(error); + } + + const instListElement = $('#installed_themes'); + + if (themes.length === 0) { + instListElement.append($('
  • ').addClass('no-themes').translateHtml('[[admin/appearance/themes:no-themes]]')); + } else { + app.parseAndTranslate('admin/partials/theme_list', { + themes, + }, html => { + instListElement.html(html); + highlightSelectedTheme(config['theme:id']); + }); + } + }); + }; + + function highlightSelectedTheme(themeId) { + translator.translate('[[admin/appearance/themes:select-theme]] || [[admin/appearance/themes:current-theme]]', text => { + text = text.split(' || '); + const select = text[0]; + const current = text[1]; + + $('[data-theme]') + .removeClass('selected') + .find('[data-action="use"]') + .html(select) + .removeClass('btn-success') + .addClass('btn-primary'); + + $('[data-theme="' + themeId + '"]') + .addClass('selected') + .find('[data-action="use"]') + .html(current) + .removeClass('btn-primary') + .addClass('btn-success'); + }); + } + + return Themes; }); diff --git a/public/src/admin/dashboard.js b/public/src/admin/dashboard.js index 47e793a..3b87489 100644 --- a/public/src/admin/dashboard.js +++ b/public/src/admin/dashboard.js @@ -1,103 +1,102 @@ 'use strict'; - define('admin/dashboard', [ - 'Chart', 'translator', 'benchpress', 'bootbox', 'alerts', -], function (Chart, translator, Benchpress, bootbox, alerts) { - const Admin = {}; - const intervals = { - rooms: false, - graphs: false, - }; - let isMobile = false; - const graphData = { - rooms: {}, - traffic: {}, - }; - const currentGraph = { - units: 'hours', - until: undefined, - }; - - const DEFAULTS = { - roomInterval: 10000, - graphInterval: 15000, - realtimeInterval: 1500, - }; - - const usedTopicColors = []; - - $(window).on('action:ajaxify.start', function () { - clearInterval(intervals.rooms); - clearInterval(intervals.graphs); - - intervals.rooms = null; - intervals.graphs = null; - graphData.rooms = null; - graphData.traffic = null; - usedTopicColors.length = 0; - }); - - Admin.init = function () { - app.enterRoom('admin'); - - isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); - - $('[data-toggle="tooltip"]').tooltip(); - - setupRealtimeButton(); - setupGraphs(function () { - socket.emit('admin.rooms.getAll', Admin.updateRoomUsage); - initiateDashboard(); - }); - setupFullscreen(); - }; - - Admin.updateRoomUsage = function (err, data) { - if (err) { - return alerts.error(err); - } - - if (JSON.stringify(graphData.rooms) === JSON.stringify(data)) { - return; - } - - graphData.rooms = data; - - const html = '
    ' + - '' + data.onlineRegisteredCount + '' + - '
    [[admin/dashboard:active-users.users]]
    ' + - '
    ' + - '
    ' + - '' + data.onlineGuestCount + '' + - '
    [[admin/dashboard:active-users.guests]]
    ' + - '
    ' + - '
    ' + - '' + (data.onlineRegisteredCount + data.onlineGuestCount) + '' + - '
    [[admin/dashboard:active-users.total]]
    ' + - '
    ' + - '
    ' + - '' + data.socketCount + '' + - '
    [[admin/dashboard:active-users.connections]]
    ' + - '
    '; - - updateRegisteredGraph(data.onlineRegisteredCount, data.onlineGuestCount); - updatePresenceGraph(data.users); - updateTopicsGraph(data.topTenTopics); - - $('#active-users').translateHtml(html); - }; - - const graphs = { - traffic: null, - registered: null, - presence: null, - topics: null, - }; - - const topicColors = ['#bf616a', '#5B90BF', '#d08770', '#ebcb8b', '#a3be8c', '#96b5b4', '#8fa1b3', '#b48ead', '#ab7967', '#46BFBD']; - - /* eslint-disable */ + 'Chart', 'translator', 'benchpress', 'bootbox', 'alerts', +], (Chart, translator, Benchpress, bootbox, alerts) => { + const Admin = {}; + const intervals = { + rooms: false, + graphs: false, + }; + let isMobile = false; + const graphData = { + rooms: {}, + traffic: {}, + }; + const currentGraph = { + units: 'hours', + until: undefined, + }; + + const DEFAULTS = { + roomInterval: 10_000, + graphInterval: 15_000, + realtimeInterval: 1500, + }; + + const usedTopicColors = []; + + $(window).on('action:ajaxify.start', () => { + clearInterval(intervals.rooms); + clearInterval(intervals.graphs); + + intervals.rooms = null; + intervals.graphs = null; + graphData.rooms = null; + graphData.traffic = null; + usedTopicColors.length = 0; + }); + + Admin.init = function () { + app.enterRoom('admin'); + + isMobile = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(navigator.userAgent); + + $('[data-toggle="tooltip"]').tooltip(); + + setupRealtimeButton(); + setupGraphs(() => { + socket.emit('admin.rooms.getAll', Admin.updateRoomUsage); + initiateDashboard(); + }); + setupFullscreen(); + }; + + Admin.updateRoomUsage = function (error, data) { + if (error) { + return alerts.error(error); + } + + if (JSON.stringify(graphData.rooms) === JSON.stringify(data)) { + return; + } + + graphData.rooms = data; + + const html = '
    ' + + '' + data.onlineRegisteredCount + '' + + '
    [[admin/dashboard:active-users.users]]
    ' + + '
    ' + + '
    ' + + '' + data.onlineGuestCount + '' + + '
    [[admin/dashboard:active-users.guests]]
    ' + + '
    ' + + '
    ' + + '' + (data.onlineRegisteredCount + data.onlineGuestCount) + '' + + '
    [[admin/dashboard:active-users.total]]
    ' + + '
    ' + + '
    ' + + '' + data.socketCount + '' + + '
    [[admin/dashboard:active-users.connections]]
    ' + + '
    '; + + updateRegisteredGraph(data.onlineRegisteredCount, data.onlineGuestCount); + updatePresenceGraph(data.users); + updateTopicsGraph(data.topTenTopics); + + $('#active-users').translateHtml(html); + }; + + const graphs = { + traffic: null, + registered: null, + presence: null, + topics: null, + }; + + const topicColors = ['#bf616a', '#5B90BF', '#d08770', '#ebcb8b', '#a3be8c', '#96b5b4', '#8fa1b3', '#b48ead', '#ab7967', '#46BFBD']; + + /* eslint-disable */ // from chartjs.org function lighten(col, amt) { let usePound = false; @@ -128,478 +127,483 @@ define('admin/dashboard', [ } /* eslint-enable */ - function setupGraphs(callback) { - callback = callback || function () {}; - const trafficCanvas = document.getElementById('analytics-traffic'); - const registeredCanvas = document.getElementById('analytics-registered'); - const presenceCanvas = document.getElementById('analytics-presence'); - const topicsCanvas = document.getElementById('analytics-topics'); - const trafficCtx = trafficCanvas.getContext('2d'); - const registeredCtx = registeredCanvas.getContext('2d'); - const presenceCtx = presenceCanvas.getContext('2d'); - const topicsCtx = topicsCanvas.getContext('2d'); - const trafficLabels = utils.getHoursArray(); - - if (isMobile) { - Chart.defaults.global.tooltips.enabled = false; - } - - const t = translator.Translator.create(); - Promise.all([ - t.translateKey('admin/dashboard:graphs.page-views', []), - t.translateKey('admin/dashboard:graphs.page-views-registered', []), - t.translateKey('admin/dashboard:graphs.page-views-guest', []), - t.translateKey('admin/dashboard:graphs.page-views-bot', []), - t.translateKey('admin/dashboard:graphs.unique-visitors', []), - t.translateKey('admin/dashboard:graphs.registered-users', []), - t.translateKey('admin/dashboard:graphs.guest-users', []), - t.translateKey('admin/dashboard:on-categories', []), - t.translateKey('admin/dashboard:reading-posts', []), - t.translateKey('admin/dashboard:browsing-topics', []), - t.translateKey('admin/dashboard:recent', []), - t.translateKey('admin/dashboard:unread', []), - ]).then(function (translations) { - const data = { - labels: trafficLabels, - datasets: [ - { - label: translations[0], - backgroundColor: 'rgba(220,220,220,0.2)', - borderColor: 'rgba(220,220,220,1)', - pointBackgroundColor: 'rgba(220,220,220,1)', - pointHoverBackgroundColor: '#fff', - pointBorderColor: '#fff', - pointHoverBorderColor: 'rgba(220,220,220,1)', - data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - }, - { - label: translations[1], - backgroundColor: '#ab464233', - borderColor: '#ab4642', - pointBackgroundColor: '#ab4642', - pointHoverBackgroundColor: '#ab4642', - pointBorderColor: '#fff', - pointHoverBorderColor: '#ab4642', - data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - }, - { - label: translations[2], - backgroundColor: '#ba8baf33', - borderColor: '#ba8baf', - pointBackgroundColor: '#ba8baf', - pointHoverBackgroundColor: '#ba8baf', - pointBorderColor: '#fff', - pointHoverBorderColor: '#ba8baf', - data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - }, - { - label: translations[3], - backgroundColor: '#f7ca8833', - borderColor: '#f7ca88', - pointBackgroundColor: '#f7ca88', - pointHoverBackgroundColor: '#f7ca88', - pointBorderColor: '#fff', - pointHoverBorderColor: '#f7ca88', - data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - }, - { - label: translations[4], - backgroundColor: 'rgba(151,187,205,0.2)', - borderColor: 'rgba(151,187,205,1)', - pointBackgroundColor: 'rgba(151,187,205,1)', - pointHoverBackgroundColor: 'rgba(151,187,205,1)', - pointBorderColor: '#fff', - pointHoverBorderColor: 'rgba(151,187,205,1)', - data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - }, - ], - }; - - trafficCanvas.width = $(trafficCanvas).parent().width(); - - data.datasets[0].yAxisID = 'left-y-axis'; - data.datasets[1].yAxisID = 'left-y-axis'; - data.datasets[2].yAxisID = 'left-y-axis'; - data.datasets[3].yAxisID = 'left-y-axis'; - data.datasets[4].yAxisID = 'right-y-axis'; - - graphs.traffic = new Chart(trafficCtx, { - type: 'line', - data: data, - options: { - responsive: true, - legend: { - display: true, - }, - scales: { - yAxes: [{ - id: 'left-y-axis', - ticks: { - beginAtZero: true, - precision: 0, - }, - type: 'linear', - position: 'left', - scaleLabel: { - display: true, - labelString: translations[0], - }, - }, { - id: 'right-y-axis', - ticks: { - beginAtZero: true, - suggestedMax: 10, - precision: 0, - }, - type: 'linear', - position: 'right', - scaleLabel: { - display: true, - labelString: translations[4], - }, - }], - }, - tooltips: { - mode: 'x', - }, - }, - }); - - graphs.registered = new Chart(registeredCtx, { - type: 'doughnut', - data: { - labels: translations.slice(5, 7), - datasets: [{ - data: [1, 1], - backgroundColor: ['#F7464A', '#46BFBD'], - hoverBackgroundColor: ['#FF5A5E', '#5AD3D1'], - }], - }, - options: { - responsive: true, - legend: { - display: false, - }, - }, - }); - - graphs.presence = new Chart(presenceCtx, { - type: 'doughnut', - data: { - labels: translations.slice(7, 12), - datasets: [{ - data: [1, 1, 1, 1, 1], - backgroundColor: ['#F7464A', '#46BFBD', '#FDB45C', '#949FB1', '#9FB194'], - hoverBackgroundColor: ['#FF5A5E', '#5AD3D1', '#FFC870', '#A8B3C5', '#A8B3C5'], - }], - }, - options: { - responsive: true, - legend: { - display: false, - }, - }, - }); - - graphs.topics = new Chart(topicsCtx, { - type: 'doughnut', - data: { - labels: [], - datasets: [{ - data: [], - backgroundColor: [], - hoverBackgroundColor: [], - }], - }, - options: { - responsive: true, - legend: { - display: false, - }, - }, - }); - - updateTrafficGraph(); - - $(window).on('resize', adjustPieCharts); - adjustPieCharts(); - - $('[data-action="updateGraph"]:not([data-units="custom"])').on('click', function () { - let until = new Date(); - const amount = $(this).attr('data-amount'); - if ($(this).attr('data-units') === 'days') { - until.setHours(0, 0, 0, 0); - } - until = until.getTime(); - updateTrafficGraph($(this).attr('data-units'), until, amount); - - require(['translator'], function (translator) { - translator.translate('[[admin/dashboard:page-views-custom]]', function (translated) { - $('[data-action="updateGraph"][data-units="custom"]').text(translated); - }); - }); - }); - - $('[data-action="updateGraph"][data-units="custom"]').on('click', function () { - const targetEl = $(this); - - Benchpress.render('admin/partials/pageviews-range-select', {}).then(function (html) { - const modal = bootbox.dialog({ - title: '[[admin/dashboard:page-views-custom]]', - message: html, - buttons: { - submit: { - label: '[[global:search]]', - className: 'btn-primary', - callback: submit, - }, - }, - }).on('shown.bs.modal', function () { - const date = new Date(); - const today = date.toISOString().slice(0, 10); - date.setDate(date.getDate() - 1); - const yesterday = date.toISOString().slice(0, 10); - - modal.find('#startRange').val(targetEl.attr('data-startRange') || yesterday); - modal.find('#endRange').val(targetEl.attr('data-endRange') || today); - }); - - function submit() { - // NEED TO ADD VALIDATION HERE FOR YYYY-MM-DD - const formData = modal.find('form').serializeObject(); - const validRegexp = /\d{4}-\d{2}-\d{2}/; - - // Input validation - if (!formData.startRange && !formData.endRange) { - // No range? Assume last 30 days - updateTrafficGraph('days'); - return; - } else if (!validRegexp.test(formData.startRange) || !validRegexp.test(formData.endRange)) { - // Invalid Input - modal.find('.alert-danger').removeClass('hidden'); - return false; - } - - let until = new Date(formData.endRange); - until.setDate(until.getDate() + 1); - until = until.getTime(); - const amount = (until - new Date(formData.startRange).getTime()) / (1000 * 60 * 60 * 24); - - updateTrafficGraph('days', until, amount); - - // Update "custom range" label - targetEl.attr('data-startRange', formData.startRange); - targetEl.attr('data-endRange', formData.endRange); - targetEl.html(formData.startRange + ' – ' + formData.endRange); - } - }); - }); - - socket.emit('admin.rooms.getAll', Admin.updateRoomUsage); - initiateDashboard(); - callback(); - }); - } - - function adjustPieCharts() { - $('.pie-chart.legend-up').each(function () { - const $this = $(this); - - if ($this.width() < 320) { - $this.addClass('compact'); - } else { - $this.removeClass('compact'); - } - }); - } - - function updateTrafficGraph(units, until, amount) { - // until and amount are optional - - if (!app.isFocused) { - return; - } - - socket.emit('admin.analytics.get', { - graph: 'traffic', - units: units || 'hours', - until: until, - amount: amount, - }, function (err, data) { - if (err) { - return alerts.error(err); - } - if (JSON.stringify(graphData.traffic) === JSON.stringify(data)) { - return; - } - - graphData.traffic = data; - - if (units === 'days') { - graphs.traffic.data.xLabels = utils.getDaysArray(until, amount); - } else { - graphs.traffic.data.xLabels = utils.getHoursArray(); - - $('#pageViewsThirty').html(data.summary.thirty); - $('#pageViewsSeven').html(data.summary.seven); - $('#pageViewsPastDay').html(data.pastDay); - utils.addCommasToNumbers($('#pageViewsThirty')); - utils.addCommasToNumbers($('#pageViewsSeven')); - utils.addCommasToNumbers($('#pageViewsPastDay')); - } - - graphs.traffic.data.datasets[0].data = data.pageviews; - graphs.traffic.data.datasets[1].data = data.pageviewsRegistered; - graphs.traffic.data.datasets[2].data = data.pageviewsGuest; - graphs.traffic.data.datasets[3].data = data.pageviewsBot; - graphs.traffic.data.datasets[4].data = data.uniqueVisitors; - graphs.traffic.data.labels = graphs.traffic.data.xLabels; - - graphs.traffic.update(); - currentGraph.units = units; - currentGraph.until = until; - currentGraph.amount = amount; - - // Update the View as JSON button url - const apiEl = $('#view-as-json'); - const newHref = $.param({ - units: units || 'hours', - until: until, - count: amount, - }); - apiEl.attr('href', config.relative_path + '/api/admin/analytics?' + newHref); - }); - } - - function updateRegisteredGraph(registered, guest) { - $('#analytics-legend .registered').parent().find('.count').text(registered); - $('#analytics-legend .guest').parent().find('.count').text(guest); - graphs.registered.data.datasets[0].data[0] = registered; - graphs.registered.data.datasets[0].data[1] = guest; - graphs.registered.update(); - } - - function updatePresenceGraph(users) { - $('#analytics-presence-legend .on-categories').parent().find('.count').text(users.categories); - $('#analytics-presence-legend .reading-posts').parent().find('.count').text(users.topics); - $('#analytics-presence-legend .browsing-topics').parent().find('.count').text(users.category); - $('#analytics-presence-legend .recent').parent().find('.count').text(users.recent); - $('#analytics-presence-legend .unread').parent().find('.count').text(users.unread); - graphs.presence.data.datasets[0].data[0] = users.categories; - graphs.presence.data.datasets[0].data[1] = users.topics; - graphs.presence.data.datasets[0].data[2] = users.category; - graphs.presence.data.datasets[0].data[3] = users.recent; - graphs.presence.data.datasets[0].data[4] = users.unread; - - graphs.presence.update(); - } - - function updateTopicsGraph(topics) { - if (!topics.length) { - translator.translate('[[admin/dashboard:no-users-browsing]]', function (translated) { - topics = [{ - title: translated, - count: 1, - }]; - updateTopicsGraph(topics); - }); - return; - } - - graphs.topics.data.labels = []; - graphs.topics.data.datasets[0].data = []; - graphs.topics.data.datasets[0].backgroundColor = []; - graphs.topics.data.datasets[0].hoverBackgroundColor = []; - - topics.forEach(function (topic, i) { - graphs.topics.data.labels.push(topic.title); - graphs.topics.data.datasets[0].data.push(topic.count); - graphs.topics.data.datasets[0].backgroundColor.push(topicColors[i]); - graphs.topics.data.datasets[0].hoverBackgroundColor.push(lighten(topicColors[i], 10)); - }); - - function buildTopicsLegend() { - let html = ''; - topics.forEach(function (t, i) { - const link = t.tid ? ' ' + t.title + '' : t.title; - const label = t.count === '0' ? t.title : link; - - html += '
  • ' + - '
    ' + - ' (' + t.count + ') ' + label + '' + - '
  • '; - }); - $('#topics-legend').translateHtml(html); - } - - buildTopicsLegend(); - graphs.topics.update(); - } - - function setupRealtimeButton() { - $('#toggle-realtime .fa').on('click', function () { - const $this = $(this); - if ($this.hasClass('fa-toggle-on')) { - $this.removeClass('fa-toggle-on').addClass('fa-toggle-off'); - $this.parent().find('strong').html('OFF'); - initiateDashboard(false); - } else { - $this.removeClass('fa-toggle-off').addClass('fa-toggle-on'); - $this.parent().find('strong').html('ON'); - initiateDashboard(true); - } - }); - } - - function initiateDashboard(realtime) { - clearInterval(intervals.rooms); - clearInterval(intervals.graphs); - - intervals.rooms = setInterval(function () { - if (app.isFocused && socket.connected) { - socket.emit('admin.rooms.getAll', Admin.updateRoomUsage); - } - }, realtime ? DEFAULTS.realtimeInterval : DEFAULTS.roomInterval); - - intervals.graphs = setInterval(function () { - updateTrafficGraph(currentGraph.units, currentGraph.until, currentGraph.amount); - }, realtime ? DEFAULTS.realtimeInterval : DEFAULTS.graphInterval); - } - - function setupFullscreen() { - const container = document.getElementById('analytics-panel'); - const $container = $(container); - const btn = $container.find('.fa-expand'); - let fsMethod; - let exitMethod; - - if (container.requestFullscreen) { - fsMethod = 'requestFullscreen'; - exitMethod = 'exitFullscreen'; - } else if (container.mozRequestFullScreen) { - fsMethod = 'mozRequestFullScreen'; - exitMethod = 'mozCancelFullScreen'; - } else if (container.webkitRequestFullscreen) { - fsMethod = 'webkitRequestFullscreen'; - exitMethod = 'webkitCancelFullScreen'; - } else if (container.msRequestFullscreen) { - fsMethod = 'msRequestFullscreen'; - exitMethod = 'msCancelFullScreen'; - } - - if (fsMethod) { - btn.addClass('active'); - btn.on('click', function () { - if ($container.hasClass('fullscreen')) { - document[exitMethod](); - $container.removeClass('fullscreen'); - } else { - container[fsMethod](); - $container.addClass('fullscreen'); - } - }); - } - } - - return Admin; + function setupGraphs(callback) { + callback ||= function () {}; + const trafficCanvas = document.querySelector('#analytics-traffic'); + const registeredCanvas = document.querySelector('#analytics-registered'); + const presenceCanvas = document.querySelector('#analytics-presence'); + const topicsCanvas = document.querySelector('#analytics-topics'); + const trafficContext = trafficCanvas.getContext('2d'); + const registeredContext = registeredCanvas.getContext('2d'); + const presenceContext = presenceCanvas.getContext('2d'); + const topicsContext = topicsCanvas.getContext('2d'); + const trafficLabels = utils.getHoursArray(); + + if (isMobile) { + Chart.defaults.global.tooltips.enabled = false; + } + + const t = translator.Translator.create(); + Promise.all([ + t.translateKey('admin/dashboard:graphs.page-views', []), + t.translateKey('admin/dashboard:graphs.page-views-registered', []), + t.translateKey('admin/dashboard:graphs.page-views-guest', []), + t.translateKey('admin/dashboard:graphs.page-views-bot', []), + t.translateKey('admin/dashboard:graphs.unique-visitors', []), + t.translateKey('admin/dashboard:graphs.registered-users', []), + t.translateKey('admin/dashboard:graphs.guest-users', []), + t.translateKey('admin/dashboard:on-categories', []), + t.translateKey('admin/dashboard:reading-posts', []), + t.translateKey('admin/dashboard:browsing-topics', []), + t.translateKey('admin/dashboard:recent', []), + t.translateKey('admin/dashboard:unread', []), + ]).then(translations => { + const data = { + labels: trafficLabels, + datasets: [ + { + label: translations[0], + backgroundColor: 'rgba(220,220,220,0.2)', + borderColor: 'rgba(220,220,220,1)', + pointBackgroundColor: 'rgba(220,220,220,1)', + pointHoverBackgroundColor: '#fff', + pointBorderColor: '#fff', + pointHoverBorderColor: 'rgba(220,220,220,1)', + data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, + { + label: translations[1], + backgroundColor: '#ab464233', + borderColor: '#ab4642', + pointBackgroundColor: '#ab4642', + pointHoverBackgroundColor: '#ab4642', + pointBorderColor: '#fff', + pointHoverBorderColor: '#ab4642', + data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, + { + label: translations[2], + backgroundColor: '#ba8baf33', + borderColor: '#ba8baf', + pointBackgroundColor: '#ba8baf', + pointHoverBackgroundColor: '#ba8baf', + pointBorderColor: '#fff', + pointHoverBorderColor: '#ba8baf', + data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, + { + label: translations[3], + backgroundColor: '#f7ca8833', + borderColor: '#f7ca88', + pointBackgroundColor: '#f7ca88', + pointHoverBackgroundColor: '#f7ca88', + pointBorderColor: '#fff', + pointHoverBorderColor: '#f7ca88', + data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, + { + label: translations[4], + backgroundColor: 'rgba(151,187,205,0.2)', + borderColor: 'rgba(151,187,205,1)', + pointBackgroundColor: 'rgba(151,187,205,1)', + pointHoverBackgroundColor: 'rgba(151,187,205,1)', + pointBorderColor: '#fff', + pointHoverBorderColor: 'rgba(151,187,205,1)', + data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, + ], + }; + + trafficCanvas.width = $(trafficCanvas).parent().width(); + + data.datasets[0].yAxisID = 'left-y-axis'; + data.datasets[1].yAxisID = 'left-y-axis'; + data.datasets[2].yAxisID = 'left-y-axis'; + data.datasets[3].yAxisID = 'left-y-axis'; + data.datasets[4].yAxisID = 'right-y-axis'; + + graphs.traffic = new Chart(trafficContext, { + type: 'line', + data, + options: { + responsive: true, + legend: { + display: true, + }, + scales: { + yAxes: [{ + id: 'left-y-axis', + ticks: { + beginAtZero: true, + precision: 0, + }, + type: 'linear', + position: 'left', + scaleLabel: { + display: true, + labelString: translations[0], + }, + }, { + id: 'right-y-axis', + ticks: { + beginAtZero: true, + suggestedMax: 10, + precision: 0, + }, + type: 'linear', + position: 'right', + scaleLabel: { + display: true, + labelString: translations[4], + }, + }], + }, + tooltips: { + mode: 'x', + }, + }, + }); + + graphs.registered = new Chart(registeredContext, { + type: 'doughnut', + data: { + labels: translations.slice(5, 7), + datasets: [{ + data: [1, 1], + backgroundColor: ['#F7464A', '#46BFBD'], + hoverBackgroundColor: ['#FF5A5E', '#5AD3D1'], + }], + }, + options: { + responsive: true, + legend: { + display: false, + }, + }, + }); + + graphs.presence = new Chart(presenceContext, { + type: 'doughnut', + data: { + labels: translations.slice(7, 12), + datasets: [{ + data: [1, 1, 1, 1, 1], + backgroundColor: ['#F7464A', '#46BFBD', '#FDB45C', '#949FB1', '#9FB194'], + hoverBackgroundColor: ['#FF5A5E', '#5AD3D1', '#FFC870', '#A8B3C5', '#A8B3C5'], + }], + }, + options: { + responsive: true, + legend: { + display: false, + }, + }, + }); + + graphs.topics = new Chart(topicsContext, { + type: 'doughnut', + data: { + labels: [], + datasets: [{ + data: [], + backgroundColor: [], + hoverBackgroundColor: [], + }], + }, + options: { + responsive: true, + legend: { + display: false, + }, + }, + }); + + updateTrafficGraph(); + + $(window).on('resize', adjustPieCharts); + adjustPieCharts(); + + $('[data-action="updateGraph"]:not([data-units="custom"])').on('click', function () { + let until = new Date(); + const amount = $(this).attr('data-amount'); + if ($(this).attr('data-units') === 'days') { + until.setHours(0, 0, 0, 0); + } + + until = until.getTime(); + updateTrafficGraph($(this).attr('data-units'), until, amount); + + require(['translator'], translator => { + translator.translate('[[admin/dashboard:page-views-custom]]', translated => { + $('[data-action="updateGraph"][data-units="custom"]').text(translated); + }); + }); + }); + + $('[data-action="updateGraph"][data-units="custom"]').on('click', function () { + const targetElement = $(this); + + Benchpress.render('admin/partials/pageviews-range-select', {}).then(html => { + const modal = bootbox.dialog({ + title: '[[admin/dashboard:page-views-custom]]', + message: html, + buttons: { + submit: { + label: '[[global:search]]', + className: 'btn-primary', + callback: submit, + }, + }, + }).on('shown.bs.modal', () => { + const date = new Date(); + const today = date.toISOString().slice(0, 10); + date.setDate(date.getDate() - 1); + const yesterday = date.toISOString().slice(0, 10); + + modal.find('#startRange').val(targetElement.attr('data-startRange') || yesterday); + modal.find('#endRange').val(targetElement.attr('data-endRange') || today); + }); + + function submit() { + // NEED TO ADD VALIDATION HERE FOR YYYY-MM-DD + const formData = modal.find('form').serializeObject(); + const validRegexp = /\d{4}-\d{2}-\d{2}/; + + // Input validation + if (!formData.startRange && !formData.endRange) { + // No range? Assume last 30 days + updateTrafficGraph('days'); + return; + } + + if (!validRegexp.test(formData.startRange) || !validRegexp.test(formData.endRange)) { + // Invalid Input + modal.find('.alert-danger').removeClass('hidden'); + return false; + } + + let until = new Date(formData.endRange); + until.setDate(until.getDate() + 1); + until = until.getTime(); + const amount = (until - new Date(formData.startRange).getTime()) / (1000 * 60 * 60 * 24); + + updateTrafficGraph('days', until, amount); + + // Update "custom range" label + targetElement.attr('data-startRange', formData.startRange); + targetElement.attr('data-endRange', formData.endRange); + targetElement.html(formData.startRange + ' – ' + formData.endRange); + } + }); + }); + + socket.emit('admin.rooms.getAll', Admin.updateRoomUsage); + initiateDashboard(); + callback(); + }); + } + + function adjustPieCharts() { + $('.pie-chart.legend-up').each(function () { + const $this = $(this); + + if ($this.width() < 320) { + $this.addClass('compact'); + } else { + $this.removeClass('compact'); + } + }); + } + + function updateTrafficGraph(units, until, amount) { + // Until and amount are optional + + if (!app.isFocused) { + return; + } + + socket.emit('admin.analytics.get', { + graph: 'traffic', + units: units || 'hours', + until, + amount, + }, (error, data) => { + if (error) { + return alerts.error(error); + } + + if (JSON.stringify(graphData.traffic) === JSON.stringify(data)) { + return; + } + + graphData.traffic = data; + + if (units === 'days') { + graphs.traffic.data.xLabels = utils.getDaysArray(until, amount); + } else { + graphs.traffic.data.xLabels = utils.getHoursArray(); + + $('#pageViewsThirty').html(data.summary.thirty); + $('#pageViewsSeven').html(data.summary.seven); + $('#pageViewsPastDay').html(data.pastDay); + utils.addCommasToNumbers($('#pageViewsThirty')); + utils.addCommasToNumbers($('#pageViewsSeven')); + utils.addCommasToNumbers($('#pageViewsPastDay')); + } + + graphs.traffic.data.datasets[0].data = data.pageviews; + graphs.traffic.data.datasets[1].data = data.pageviewsRegistered; + graphs.traffic.data.datasets[2].data = data.pageviewsGuest; + graphs.traffic.data.datasets[3].data = data.pageviewsBot; + graphs.traffic.data.datasets[4].data = data.uniqueVisitors; + graphs.traffic.data.labels = graphs.traffic.data.xLabels; + + graphs.traffic.update(); + currentGraph.units = units; + currentGraph.until = until; + currentGraph.amount = amount; + + // Update the View as JSON button url + const apiElement = $('#view-as-json'); + const newHref = $.param({ + units: units || 'hours', + until, + count: amount, + }); + apiElement.attr('href', config.relative_path + '/api/admin/analytics?' + newHref); + }); + } + + function updateRegisteredGraph(registered, guest) { + $('#analytics-legend .registered').parent().find('.count').text(registered); + $('#analytics-legend .guest').parent().find('.count').text(guest); + graphs.registered.data.datasets[0].data[0] = registered; + graphs.registered.data.datasets[0].data[1] = guest; + graphs.registered.update(); + } + + function updatePresenceGraph(users) { + $('#analytics-presence-legend .on-categories').parent().find('.count').text(users.categories); + $('#analytics-presence-legend .reading-posts').parent().find('.count').text(users.topics); + $('#analytics-presence-legend .browsing-topics').parent().find('.count').text(users.category); + $('#analytics-presence-legend .recent').parent().find('.count').text(users.recent); + $('#analytics-presence-legend .unread').parent().find('.count').text(users.unread); + graphs.presence.data.datasets[0].data[0] = users.categories; + graphs.presence.data.datasets[0].data[1] = users.topics; + graphs.presence.data.datasets[0].data[2] = users.category; + graphs.presence.data.datasets[0].data[3] = users.recent; + graphs.presence.data.datasets[0].data[4] = users.unread; + + graphs.presence.update(); + } + + function updateTopicsGraph(topics) { + if (topics.length === 0) { + translator.translate('[[admin/dashboard:no-users-browsing]]', translated => { + topics = [{ + title: translated, + count: 1, + }]; + updateTopicsGraph(topics); + }); + return; + } + + graphs.topics.data.labels = []; + graphs.topics.data.datasets[0].data = []; + graphs.topics.data.datasets[0].backgroundColor = []; + graphs.topics.data.datasets[0].hoverBackgroundColor = []; + + for (const [i, topic] of topics.entries()) { + graphs.topics.data.labels.push(topic.title); + graphs.topics.data.datasets[0].data.push(topic.count); + graphs.topics.data.datasets[0].backgroundColor.push(topicColors[i]); + graphs.topics.data.datasets[0].hoverBackgroundColor.push(lighten(topicColors[i], 10)); + } + + function buildTopicsLegend() { + let html = ''; + for (const [i, t] of topics.entries()) { + const link = t.tid ? ' ' + t.title + '' : t.title; + const label = t.count === '0' ? t.title : link; + + html += '
  • ' + + '
    ' + + ' (' + t.count + ') ' + label + '' + + '
  • '; + } + + $('#topics-legend').translateHtml(html); + } + + buildTopicsLegend(); + graphs.topics.update(); + } + + function setupRealtimeButton() { + $('#toggle-realtime .fa').on('click', function () { + const $this = $(this); + if ($this.hasClass('fa-toggle-on')) { + $this.removeClass('fa-toggle-on').addClass('fa-toggle-off'); + $this.parent().find('strong').html('OFF'); + initiateDashboard(false); + } else { + $this.removeClass('fa-toggle-off').addClass('fa-toggle-on'); + $this.parent().find('strong').html('ON'); + initiateDashboard(true); + } + }); + } + + function initiateDashboard(realtime) { + clearInterval(intervals.rooms); + clearInterval(intervals.graphs); + + intervals.rooms = setInterval(() => { + if (app.isFocused && socket.connected) { + socket.emit('admin.rooms.getAll', Admin.updateRoomUsage); + } + }, realtime ? DEFAULTS.realtimeInterval : DEFAULTS.roomInterval); + + intervals.graphs = setInterval(() => { + updateTrafficGraph(currentGraph.units, currentGraph.until, currentGraph.amount); + }, realtime ? DEFAULTS.realtimeInterval : DEFAULTS.graphInterval); + } + + function setupFullscreen() { + const container = document.querySelector('#analytics-panel'); + const $container = $(container); + const button = $container.find('.fa-expand'); + let fsMethod; + let exitMethod; + + if (container.requestFullscreen) { + fsMethod = 'requestFullscreen'; + exitMethod = 'exitFullscreen'; + } else if (container.mozRequestFullScreen) { + fsMethod = 'mozRequestFullScreen'; + exitMethod = 'mozCancelFullScreen'; + } else if (container.webkitRequestFullscreen) { + fsMethod = 'webkitRequestFullscreen'; + exitMethod = 'webkitCancelFullScreen'; + } else if (container.msRequestFullscreen) { + fsMethod = 'msRequestFullscreen'; + exitMethod = 'msCancelFullScreen'; + } + + if (fsMethod) { + button.addClass('active'); + button.on('click', () => { + if ($container.hasClass('fullscreen')) { + document[exitMethod](); + $container.removeClass('fullscreen'); + } else { + container[fsMethod](); + $container.addClass('fullscreen'); + } + }); + } + } + + return Admin; }); diff --git a/public/src/admin/dashboard/logins.js b/public/src/admin/dashboard/logins.js index 92cc2f2..f82ed06 100644 --- a/public/src/admin/dashboard/logins.js +++ b/public/src/admin/dashboard/logins.js @@ -1,14 +1,14 @@ 'use strict'; -define('admin/dashboard/logins', ['admin/modules/dashboard-line-graph'], (graph) => { - const ACP = {}; +define('admin/dashboard/logins', ['admin/modules/dashboard-line-graph'], graph => { + const ACP = {}; - ACP.init = () => { - graph.init({ - set: 'logins', - dataset: ajaxify.data.dataset, - }); - }; + ACP.init = () => { + graph.init({ + set: 'logins', + dataset: ajaxify.data.dataset, + }); + }; - return ACP; + return ACP; }); diff --git a/public/src/admin/dashboard/topics.js b/public/src/admin/dashboard/topics.js index 4119c45..f18eacd 100644 --- a/public/src/admin/dashboard/topics.js +++ b/public/src/admin/dashboard/topics.js @@ -1,32 +1,32 @@ 'use strict'; define('admin/dashboard/topics', ['admin/modules/dashboard-line-graph', 'hooks'], (graph, hooks) => { - const ACP = {}; + const ACP = {}; - ACP.init = () => { - graph.init({ - set: 'topics', - dataset: ajaxify.data.dataset, - }).then(() => { - hooks.onPage('action:admin.dashboard.updateGraph', ACP.updateTable); - }); - }; + ACP.init = () => { + graph.init({ + set: 'topics', + dataset: ajaxify.data.dataset, + }).then(() => { + hooks.onPage('action:admin.dashboard.updateGraph', ACP.updateTable); + }); + }; - ACP.updateTable = () => { - if (window.fetch) { - fetch(`${config.relative_path}/api${ajaxify.data.url}${window.location.search}`, { credentials: 'include' }).then((response) => { - if (response.ok) { - response.json().then(function (payload) { - app.parseAndTranslate(ajaxify.data.template.name, 'topics', payload, function (html) { - const tbodyEl = document.querySelector('.topics-list tbody'); - tbodyEl.innerHTML = ''; - tbodyEl.append(...html.map((idx, el) => el)); - }); - }); - } - }); - } - }; + ACP.updateTable = () => { + if (window.fetch) { + fetch(`${config.relative_path}/api${ajaxify.data.url}${window.location.search}`, {credentials: 'include'}).then(response => { + if (response.ok) { + response.json().then(payload => { + app.parseAndTranslate(ajaxify.data.template.name, 'topics', payload, html => { + const tbodyElement = document.querySelector('.topics-list tbody'); + tbodyElement.innerHTML = ''; + tbodyElement.append(...html.map((index, element) => element)); + }); + }); + } + }); + } + }; - return ACP; + return ACP; }); diff --git a/public/src/admin/dashboard/users.js b/public/src/admin/dashboard/users.js index d1f5d2f..b3c1f44 100644 --- a/public/src/admin/dashboard/users.js +++ b/public/src/admin/dashboard/users.js @@ -1,34 +1,34 @@ 'use strict'; define('admin/dashboard/users', ['admin/modules/dashboard-line-graph', 'hooks'], (graph, hooks) => { - const ACP = {}; + const ACP = {}; - ACP.init = () => { - graph.init({ - set: 'registrations', - dataset: ajaxify.data.dataset, - }).then(() => { - hooks.onPage('action:admin.dashboard.updateGraph', ACP.updateTable); - }); - }; + ACP.init = () => { + graph.init({ + set: 'registrations', + dataset: ajaxify.data.dataset, + }).then(() => { + hooks.onPage('action:admin.dashboard.updateGraph', ACP.updateTable); + }); + }; - ACP.updateTable = () => { - if (window.fetch) { - fetch(`${config.relative_path}/api${ajaxify.data.url}${window.location.search}`, { credentials: 'include' }).then((response) => { - if (response.ok) { - response.json().then(function (payload) { - app.parseAndTranslate(ajaxify.data.template.name, 'users', payload, function (html) { - const tbodyEl = document.querySelector('.users-list tbody'); - tbodyEl.innerHTML = ''; - tbodyEl.append(...html.map((idx, el) => el)); + ACP.updateTable = () => { + if (window.fetch) { + fetch(`${config.relative_path}/api${ajaxify.data.url}${window.location.search}`, {credentials: 'include'}).then(response => { + if (response.ok) { + response.json().then(payload => { + app.parseAndTranslate(ajaxify.data.template.name, 'users', payload, html => { + const tbodyElement = document.querySelector('.users-list tbody'); + tbodyElement.innerHTML = ''; + tbodyElement.append(...html.map((index, element) => element)); - html.find('.timeago').timeago(); - }); - }); - } - }); - } - }; + html.find('.timeago').timeago(); + }); + }); + } + }); + } + }; - return ACP; + return ACP; }); diff --git a/public/src/admin/extend/plugins.js b/public/src/admin/extend/plugins.js index 9da5b47..b7f65bd 100644 --- a/public/src/admin/extend/plugins.js +++ b/public/src/admin/extend/plugins.js @@ -1,348 +1,353 @@ 'use strict'; - define('admin/extend/plugins', [ - 'translator', - 'benchpress', - 'bootbox', - 'alerts', - 'jquery-ui/widgets/sortable', -], function (translator, Benchpress, bootbox, alerts) { - const Plugins = {}; - Plugins.init = function () { - const pluginsList = $('.plugins'); - const numPlugins = pluginsList[0].querySelectorAll('li').length; - let pluginID; - - if (!numPlugins) { - translator.translate('
  • [[admin/extend/plugins:none-found]]

  • ', function (html) { - pluginsList.append(html); - }); - return; - } - - const searchInputEl = document.querySelector('#plugin-search'); - searchInputEl.value = ''; - - pluginsList.on('click', 'button[data-action="toggleActive"]', function () { - const pluginEl = $(this).parents('li'); - pluginID = pluginEl.attr('data-plugin-id'); - const btn = $('[id="' + pluginID + '"] [data-action="toggleActive"]'); - - const pluginData = ajaxify.data.installed[pluginEl.attr('data-plugin-index')]; - - function toggleActivate() { - socket.emit('admin.plugins.toggleActive', pluginID, function (err, status) { - if (err) { - return alerts.error(err); - } - translator.translate(' [[admin/extend/plugins:plugin-item.' + (status.active ? 'deactivate' : 'activate') + ']]', function (buttonText) { - btn.html(buttonText); - btn.toggleClass('btn-warning', status.active).toggleClass('btn-success', !status.active); - - // clone it to active plugins tab - if (status.active && !$('#active [id="' + pluginID + '"]').length) { - $('#active ul').prepend(pluginEl.clone(true)); - } - - // Toggle active state in template data - pluginData.active = !pluginData.active; - - alerts.alert({ - alert_id: 'plugin_toggled', - title: '[[admin/extend/plugins:alert.' + (status.active ? 'enabled' : 'disabled') + ']]', - message: '[[admin/extend/plugins:alert.' + (status.active ? 'activate-success' : 'deactivate-success') + ']]', - type: status.active ? 'warning' : 'success', - timeout: 5000, - clickfn: function () { - require(['admin/modules/instance'], function (instance) { - instance.rebuildAndRestart(); - }); - }, - }); - }); - }); - } - - if (pluginData.license && pluginData.active !== true) { - Benchpress.render('admin/partials/plugins/license', pluginData).then(function (html) { - bootbox.dialog({ - title: '[[admin/extend/plugins:license.title]]', - message: html, - size: 'large', - buttons: { - cancel: { - label: '[[modules:bootbox.cancel]]', - className: 'btn-link', - }, - save: { - label: '[[modules:bootbox.confirm]]', - className: 'btn-primary', - callback: toggleActivate, - }, - }, - onShown: function () { - const saveEl = this.querySelector('button.btn-primary'); - if (saveEl) { - saveEl.focus(); - } - }, - }); - }); - } else { - toggleActivate(pluginID); - } - }); - - pluginsList.on('click', 'button[data-action="toggleInstall"]', function () { - const btn = $(this); - btn.attr('disabled', true); - pluginID = $(this).parents('li').attr('data-plugin-id'); - - if ($(this).attr('data-installed') === '1') { - return Plugins.toggleInstall(pluginID, $(this).parents('li').attr('data-version')); - } - - Plugins.suggest(pluginID, function (err, payload) { - if (err) { - bootbox.confirm(translator.compile('admin/extend/plugins:alert.suggest-error', err.status, err.responseText), function (confirm) { - if (confirm) { - Plugins.toggleInstall(pluginID, 'latest'); - } else { - btn.removeAttr('disabled'); - } - }); - return; - } - - if (payload.version !== 'latest') { - Plugins.toggleInstall(pluginID, payload.version); - } else if (payload.version === 'latest') { - confirmInstall(pluginID, function (confirm) { - if (confirm) { - Plugins.toggleInstall(pluginID, 'latest'); - } else { - btn.removeAttr('disabled'); - } - }); - } else { - btn.removeAttr('disabled'); - } - }); - }); - - pluginsList.on('click', 'button[data-action="upgrade"]', function () { - const btn = $(this); - const parent = btn.parents('li'); - pluginID = parent.attr('data-plugin-id'); - - Plugins.suggest(pluginID, function (err, payload) { - if (err) { - return bootbox.alert('[[admin/extend/plugins:alert.package-manager-unreachable]]'); - } - - require(['compare-versions'], function (compareVersions) { - const currentVersion = parent.find('.currentVersion').text(); - if (payload.version !== 'latest' && compareVersions.compare(payload.version, currentVersion, '>')) { - upgrade(pluginID, btn, payload.version); - } else if (payload.version === 'latest') { - confirmInstall(pluginID, function () { - upgrade(pluginID, btn, payload.version); - }); - } else { - bootbox.alert(translator.compile('admin/extend/plugins:alert.incompatible', app.config.version, payload.version)); - } - }); - }); - }); - - $(searchInputEl).on('input propertychange', function () { - const term = $(this).val(); - $('.plugins li').each(function () { - const pluginId = $(this).attr('data-plugin-id'); - $(this).toggleClass('hide', pluginId && pluginId.indexOf(term) === -1); - }); - - const tabEls = document.querySelectorAll('.plugins .tab-pane'); - tabEls.forEach((tabEl) => { - const remaining = tabEl.querySelectorAll('li:not(.hide)').length; - const noticeEl = tabEl.querySelector('.no-plugins'); - if (noticeEl) { - noticeEl.classList.toggle('hide', remaining !== 0); - } - }); - }); - - $('#plugin-submit-usage').on('click', function () { - socket.emit('admin.config.setMultiple', { - submitPluginUsage: $(this).prop('checked') ? '1' : '0', - }, function (err) { - if (err) { - return alerts.error(err); - } - }); - }); - - $('#plugin-order').on('click', function () { - $('#order-active-plugins-modal').modal('show'); - socket.emit('admin.plugins.getActive', function (err, activePlugins) { - if (err) { - return alerts.error(err); - } - let html = ''; - activePlugins.forEach(function (plugin) { - html += '
  • ' + plugin + '
  • '; - }); - if (!activePlugins.length) { - translator.translate('[[admin/extend/plugins:none-active]]', function (text) { - $('#order-active-plugins-modal .plugin-list').html(text).sortable(); - }); - return; - } - const list = $('#order-active-plugins-modal .plugin-list'); - list.html(html).sortable(); - - list.find('.fa-chevron-up').on('click', function () { - const item = $(this).parents('li'); - item.prev().before(item); - }); - - list.find('.fa-chevron-down').on('click', function () { - const item = $(this).parents('li'); - item.next().after(item); - }); - }); - }); - - $('#save-plugin-order').on('click', function () { - const plugins = $('#order-active-plugins-modal .plugin-list').children(); - const data = []; - plugins.each(function (index, el) { - data.push({ name: $(el).text(), order: index }); - }); - - socket.emit('admin.plugins.orderActivePlugins', data, function (err) { - if (err) { - return alerts.error(err); - } - $('#order-active-plugins-modal').modal('hide'); - - alerts.alert({ - alert_id: 'plugin_reordered', - title: '[[admin/extend/plugins:alert.reorder]]', - message: '[[admin/extend/plugins:alert.reorder-success]]', - type: 'success', - timeout: 5000, - clickfn: function () { - require(['admin/modules/instance'], function (instance) { - instance.rebuildAndRestart(); - }); - }, - }); - }); - }); - - populateUpgradeablePlugins(); - populateActivePlugins(); - searchInputEl.focus(); - }; - - function confirmInstall(pluginID, callback) { - bootbox.confirm(translator.compile('admin/extend/plugins:alert.possibly-incompatible', pluginID), function (confirm) { - callback(confirm); - }); - } - - function upgrade(pluginID, btn, version) { - btn.attr('disabled', true).find('i').attr('class', 'fa fa-refresh fa-spin'); - socket.emit('admin.plugins.upgrade', { - id: pluginID, - version: version, - }, function (err, isActive) { - if (err) { - return alerts.error(err); - } - const parent = btn.parents('li'); - parent.find('.fa-exclamation-triangle').remove(); - parent.find('.currentVersion').text(version); - btn.remove(); - if (isActive) { - alerts.alert({ - alert_id: 'plugin_upgraded', - title: '[[admin/extend/plugins:alert.upgraded]]', - message: '[[admin/extend/plugins:alert.upgrade-success]]', - type: 'warning', - timeout: 5000, - clickfn: function () { - require(['admin/modules/instance'], function (instance) { - instance.rebuildAndRestart(); - }); - }, - }); - } - }); - } - - Plugins.toggleInstall = function (pluginID, version, callback) { - const btn = $('li[data-plugin-id="' + pluginID + '"] button[data-action="toggleInstall"]'); - btn.find('i').attr('class', 'fa fa-refresh fa-spin'); - - socket.emit('admin.plugins.toggleInstall', { - id: pluginID, - version: version, - }, function (err, pluginData) { - if (err) { - btn.removeAttr('disabled'); - return alerts.error(err); - } - - ajaxify.refresh(); - - alerts.alert({ - alert_id: 'plugin_toggled', - title: '[[admin/extend/plugins:alert.' + (pluginData.installed ? 'installed' : 'uninstalled') + ']]', - message: '[[admin/extend/plugins:alert.' + (pluginData.installed ? 'install-success' : 'uninstall-success') + ']]', - type: 'info', - timeout: 5000, - }); - - if (typeof callback === 'function') { - callback.apply(this, arguments); - } - }); - }; - - Plugins.suggest = function (pluginId, callback) { - const nbbVersion = app.config.version.match(/^\d+\.\d+\.\d+/); - $.ajax((app.config.registry || 'https://packages.nodebb.org') + '/api/v1/suggest', { - type: 'GET', - data: { - package: pluginId, - version: nbbVersion[0], - }, - dataType: 'json', - }).done(function (payload) { - callback(undefined, payload); - }).fail(callback); - }; - - function populateUpgradeablePlugins() { - $('#installed ul li').each(function () { - if ($(this).children('[data-action="upgrade"]').length) { - $('#upgrade ul').append($(this).clone(true)); - } - }); - } - - function populateActivePlugins() { - $('#installed ul li').each(function () { - if ($(this).hasClass('active')) { - $('#active ul').append($(this).clone(true)); - } else { - $('#deactive ul').append($(this).clone(true)); - } - }); - } - - return Plugins; + 'translator', + 'benchpress', + 'bootbox', + 'alerts', + 'jquery-ui/widgets/sortable', +], (translator, Benchpress, bootbox, alerts) => { + const Plugins = {}; + Plugins.init = function () { + const pluginsList = $('.plugins'); + const numberPlugins = pluginsList[0].querySelectorAll('li').length; + let pluginID; + + if (!numberPlugins) { + translator.translate('
  • [[admin/extend/plugins:none-found]]

  • ', html => { + pluginsList.append(html); + }); + return; + } + + const searchInputElement = document.querySelector('#plugin-search'); + searchInputElement.value = ''; + + pluginsList.on('click', 'button[data-action="toggleActive"]', function () { + const pluginElement = $(this).parents('li'); + pluginID = pluginElement.attr('data-plugin-id'); + const button = $('[id="' + pluginID + '"] [data-action="toggleActive"]'); + + const pluginData = ajaxify.data.installed[pluginElement.attr('data-plugin-index')]; + + function toggleActivate() { + socket.emit('admin.plugins.toggleActive', pluginID, (error, status) => { + if (error) { + return alerts.error(error); + } + + translator.translate(' [[admin/extend/plugins:plugin-item.' + (status.active ? 'deactivate' : 'activate') + ']]', buttonText => { + button.html(buttonText); + button.toggleClass('btn-warning', status.active).toggleClass('btn-success', !status.active); + + // Clone it to active plugins tab + if (status.active && $('#active [id="' + pluginID + '"]').length === 0) { + $('#active ul').prepend(pluginElement.clone(true)); + } + + // Toggle active state in template data + pluginData.active = !pluginData.active; + + alerts.alert({ + alert_id: 'plugin_toggled', + title: '[[admin/extend/plugins:alert.' + (status.active ? 'enabled' : 'disabled') + ']]', + message: '[[admin/extend/plugins:alert.' + (status.active ? 'activate-success' : 'deactivate-success') + ']]', + type: status.active ? 'warning' : 'success', + timeout: 5000, + clickfn() { + require(['admin/modules/instance'], instance => { + instance.rebuildAndRestart(); + }); + }, + }); + }); + }); + } + + if (pluginData.license && pluginData.active !== true) { + Benchpress.render('admin/partials/plugins/license', pluginData).then(html => { + bootbox.dialog({ + title: '[[admin/extend/plugins:license.title]]', + message: html, + size: 'large', + buttons: { + cancel: { + label: '[[modules:bootbox.cancel]]', + className: 'btn-link', + }, + save: { + label: '[[modules:bootbox.confirm]]', + className: 'btn-primary', + callback: toggleActivate, + }, + }, + onShown() { + const saveElement = this.querySelector('button.btn-primary'); + if (saveElement) { + saveElement.focus(); + } + }, + }); + }); + } else { + toggleActivate(pluginID); + } + }); + + pluginsList.on('click', 'button[data-action="toggleInstall"]', function () { + const button = $(this); + button.attr('disabled', true); + pluginID = $(this).parents('li').attr('data-plugin-id'); + + if ($(this).attr('data-installed') === '1') { + return Plugins.toggleInstall(pluginID, $(this).parents('li').attr('data-version')); + } + + Plugins.suggest(pluginID, (error, payload) => { + if (error) { + bootbox.confirm(translator.compile('admin/extend/plugins:alert.suggest-error', error.status, error.responseText), confirm => { + if (confirm) { + Plugins.toggleInstall(pluginID, 'latest'); + } else { + button.removeAttr('disabled'); + } + }); + return; + } + + if (payload.version !== 'latest') { + Plugins.toggleInstall(pluginID, payload.version); + } else if (payload.version === 'latest') { + confirmInstall(pluginID, confirm => { + if (confirm) { + Plugins.toggleInstall(pluginID, 'latest'); + } else { + button.removeAttr('disabled'); + } + }); + } else { + button.removeAttr('disabled'); + } + }); + }); + + pluginsList.on('click', 'button[data-action="upgrade"]', function () { + const button = $(this); + const parent = button.parents('li'); + pluginID = parent.attr('data-plugin-id'); + + Plugins.suggest(pluginID, (error, payload) => { + if (error) { + return bootbox.alert('[[admin/extend/plugins:alert.package-manager-unreachable]]'); + } + + require(['compare-versions'], compareVersions => { + const currentVersion = parent.find('.currentVersion').text(); + if (payload.version !== 'latest' && compareVersions.compare(payload.version, currentVersion, '>')) { + upgrade(pluginID, button, payload.version); + } else if (payload.version === 'latest') { + confirmInstall(pluginID, () => { + upgrade(pluginID, button, payload.version); + }); + } else { + bootbox.alert(translator.compile('admin/extend/plugins:alert.incompatible', app.config.version, payload.version)); + } + }); + }); + }); + + $(searchInputElement).on('input propertychange', function () { + const term = $(this).val(); + $('.plugins li').each(function () { + const pluginId = $(this).attr('data-plugin-id'); + $(this).toggleClass('hide', pluginId && !pluginId.includes(term)); + }); + + const tabEls = document.querySelectorAll('.plugins .tab-pane'); + for (const tabElement of tabEls) { + const remaining = tabElement.querySelectorAll('li:not(.hide)').length; + const noticeElement = tabElement.querySelector('.no-plugins'); + if (noticeElement) { + noticeElement.classList.toggle('hide', remaining !== 0); + } + } + }); + + $('#plugin-submit-usage').on('click', function () { + socket.emit('admin.config.setMultiple', { + submitPluginUsage: $(this).prop('checked') ? '1' : '0', + }, error => { + if (error) { + return alerts.error(error); + } + }); + }); + + $('#plugin-order').on('click', () => { + $('#order-active-plugins-modal').modal('show'); + socket.emit('admin.plugins.getActive', (error, activePlugins) => { + if (error) { + return alerts.error(error); + } + + let html = ''; + for (const plugin of activePlugins) { + html += '
  • ' + plugin + '
  • '; + } + + if (activePlugins.length === 0) { + translator.translate('[[admin/extend/plugins:none-active]]', text => { + $('#order-active-plugins-modal .plugin-list').html(text).sortable(); + }); + return; + } + + const list = $('#order-active-plugins-modal .plugin-list'); + list.html(html).sortable(); + + list.find('.fa-chevron-up').on('click', function () { + const item = $(this).parents('li'); + item.prev().before(item); + }); + + list.find('.fa-chevron-down').on('click', function () { + const item = $(this).parents('li'); + item.next().after(item); + }); + }); + }); + + $('#save-plugin-order').on('click', () => { + const plugins = $('#order-active-plugins-modal .plugin-list').children(); + const data = []; + plugins.each((index, element) => { + data.push({name: $(element).text(), order: index}); + }); + + socket.emit('admin.plugins.orderActivePlugins', data, error => { + if (error) { + return alerts.error(error); + } + + $('#order-active-plugins-modal').modal('hide'); + + alerts.alert({ + alert_id: 'plugin_reordered', + title: '[[admin/extend/plugins:alert.reorder]]', + message: '[[admin/extend/plugins:alert.reorder-success]]', + type: 'success', + timeout: 5000, + clickfn() { + require(['admin/modules/instance'], instance => { + instance.rebuildAndRestart(); + }); + }, + }); + }); + }); + + populateUpgradeablePlugins(); + populateActivePlugins(); + searchInputElement.focus(); + }; + + function confirmInstall(pluginID, callback) { + bootbox.confirm(translator.compile('admin/extend/plugins:alert.possibly-incompatible', pluginID), confirm => { + callback(confirm); + }); + } + + function upgrade(pluginID, button, version) { + button.attr('disabled', true).find('i').attr('class', 'fa fa-refresh fa-spin'); + socket.emit('admin.plugins.upgrade', { + id: pluginID, + version, + }, (error, isActive) => { + if (error) { + return alerts.error(error); + } + + const parent = button.parents('li'); + parent.find('.fa-exclamation-triangle').remove(); + parent.find('.currentVersion').text(version); + button.remove(); + if (isActive) { + alerts.alert({ + alert_id: 'plugin_upgraded', + title: '[[admin/extend/plugins:alert.upgraded]]', + message: '[[admin/extend/plugins:alert.upgrade-success]]', + type: 'warning', + timeout: 5000, + clickfn() { + require(['admin/modules/instance'], instance => { + instance.rebuildAndRestart(); + }); + }, + }); + } + }); + } + + Plugins.toggleInstall = function (pluginID, version, callback) { + const button = $('li[data-plugin-id="' + pluginID + '"] button[data-action="toggleInstall"]'); + button.find('i').attr('class', 'fa fa-refresh fa-spin'); + + socket.emit('admin.plugins.toggleInstall', { + id: pluginID, + version, + }, function (error, pluginData) { + if (error) { + button.removeAttr('disabled'); + return alerts.error(error); + } + + ajaxify.refresh(); + + alerts.alert({ + alert_id: 'plugin_toggled', + title: '[[admin/extend/plugins:alert.' + (pluginData.installed ? 'installed' : 'uninstalled') + ']]', + message: '[[admin/extend/plugins:alert.' + (pluginData.installed ? 'install-success' : 'uninstall-success') + ']]', + type: 'info', + timeout: 5000, + }); + + if (typeof callback === 'function') { + Reflect.apply(callback, this, arguments); + } + }); + }; + + Plugins.suggest = function (pluginId, callback) { + const nbbVersion = app.config.version.match(/^\d+\.\d+\.\d+/); + $.ajax((app.config.registry || 'https://packages.nodebb.org') + '/api/v1/suggest', { + type: 'GET', + data: { + package: pluginId, + version: nbbVersion[0], + }, + dataType: 'json', + }).done(payload => { + callback(undefined, payload); + }).fail(callback); + }; + + function populateUpgradeablePlugins() { + $('#installed ul li').each(function () { + if ($(this).children('[data-action="upgrade"]').length > 0) { + $('#upgrade ul').append($(this).clone(true)); + } + }); + } + + function populateActivePlugins() { + $('#installed ul li').each(function () { + if ($(this).hasClass('active')) { + $('#active ul').append($(this).clone(true)); + } else { + $('#deactive ul').append($(this).clone(true)); + } + }); + } + + return Plugins; }); diff --git a/public/src/admin/extend/rewards.js b/public/src/admin/extend/rewards.js index c7c05d7..ab4a191 100644 --- a/public/src/admin/extend/rewards.js +++ b/public/src/admin/extend/rewards.js @@ -1,186 +1,189 @@ 'use strict'; - -define('admin/extend/rewards', ['alerts'], function (alerts) { - const rewards = {}; - - - let available; - let active; - let conditions; - let conditionals; - - rewards.init = function () { - available = ajaxify.data.rewards; - active = ajaxify.data.active; - conditions = ajaxify.data.conditions; - conditionals = ajaxify.data.conditionals; - - $('[data-selected]').each(function () { - select($(this)); - }); - - $('#active') - .on('change', '[data-selected]', function () { - update($(this)); - }) - .on('click', '.delete', function () { - const parent = $(this).parents('[data-id]'); - const id = parent.attr('data-id'); - - socket.emit('admin.rewards.delete', { id: id }, function (err) { - if (err) { - alerts.error(err); - } else { - alerts.success('[[admin/extend/rewards:alert.delete-success]]'); - } - }); - - parent.remove(); - return false; - }) - .on('click', '.toggle', function () { - const btn = $(this); - const disabled = btn.hasClass('btn-success'); - btn.toggleClass('btn-warning').toggleClass('btn-success').translateHtml('[[admin/extend/rewards:' + (disabled ? 'disable' : 'enable') + ']]'); - // send disable api call - return false; - }); - - $('#new').on('click', newReward); - $('#save').on('click', saveRewards); - - populateInputs(); - }; - - function select(el) { - el.val(el.attr('data-selected')); - switch (el.attr('name')) { - case 'rid': - selectReward(el); - break; - } - } - - function update(el) { - el.attr('data-selected', el.val()); - switch (el.attr('name')) { - case 'rid': - selectReward(el); - break; - } - } - - function selectReward(el) { - const parent = el.parents('[data-rid]'); - const div = parent.find('.inputs'); - let inputs; - let html = ''; - - for (const reward in available) { - if (available.hasOwnProperty(reward)) { - if (available[reward].rid === el.attr('data-selected')) { - inputs = available[reward].inputs; - parent.attr('data-rid', available[reward].rid); - break; - } - } - } - - if (!inputs) { - return alerts.error('[[admin/extend/rewards:alert.no-inputs-found]] ' + el.attr('data-selected')); - } - - inputs.forEach(function (input) { - html += '
    '; - }); - - div.html(html); - } - - function populateInputs() { - $('[data-rid]').each(function (i) { - const div = $(this).find('.inputs'); - const rewards = active[i].rewards; - - for (const reward in rewards) { - if (rewards.hasOwnProperty(reward)) { - div.find('[name="' + reward + '"]').val(rewards[reward]); - } - } - }); - } - - function newReward() { - const ul = $('#active'); - - const data = { - active: [{ - disabled: true, - value: '', - claimable: 1, - rid: null, - id: null, - }], - conditions: conditions, - conditionals: conditionals, - rewards: available, - }; - - app.parseAndTranslate('admin/extend/rewards', 'active', data, function (li) { - ul.append(li); - li.find('select').val(''); - }); - } - - function saveRewards() { - const activeRewards = []; - - $('#active li').each(function () { - const data = { rewards: {} }; - const main = $(this).find('form.main').serializeArray(); - const rewards = $(this).find('form.rewards').serializeArray(); - - main.forEach(function (obj) { - data[obj.name] = obj.value; - }); - - rewards.forEach(function (obj) { - data.rewards[obj.name] = obj.value; - }); - - data.id = $(this).attr('data-id'); - data.disabled = $(this).find('.toggle').hasClass('btn-success'); - - activeRewards.push(data); - }); - - socket.emit('admin.rewards.save', activeRewards, function (err, result) { - if (err) { - alerts.error(err); - } else { - alerts.success('[[admin/extend/rewards:alert.save-success]]'); - // newly added rewards are missing data-id, update to prevent rewards getting duplicated - $('#active li').each(function (index) { - if (!$(this).attr('data-id')) { - $(this).attr('data-id', result[index].id); - } - }); - } - }); - } - - return rewards; +define('admin/extend/rewards', ['alerts'], alerts => { + const rewards = {}; + + let available; + let active; + let conditions; + let conditionals; + + rewards.init = function () { + available = ajaxify.data.rewards; + active = ajaxify.data.active; + conditions = ajaxify.data.conditions; + conditionals = ajaxify.data.conditionals; + + $('[data-selected]').each(function () { + select($(this)); + }); + + $('#active') + .on('change', '[data-selected]', function () { + update($(this)); + }) + .on('click', '.delete', function () { + const parent = $(this).parents('[data-id]'); + const id = parent.attr('data-id'); + + socket.emit('admin.rewards.delete', {id}, error => { + if (error) { + alerts.error(error); + } else { + alerts.success('[[admin/extend/rewards:alert.delete-success]]'); + } + }); + + parent.remove(); + return false; + }) + .on('click', '.toggle', function () { + const button = $(this); + const disabled = button.hasClass('btn-success'); + button.toggleClass('btn-warning').toggleClass('btn-success').translateHtml('[[admin/extend/rewards:' + (disabled ? 'disable' : 'enable') + ']]'); + // Send disable api call + return false; + }); + + $('#new').on('click', newReward); + $('#save').on('click', saveRewards); + + populateInputs(); + }; + + function select(element) { + element.val(element.attr('data-selected')); + switch (element.attr('name')) { + case 'rid': { + selectReward(element); + break; + } + } + } + + function update(element) { + element.attr('data-selected', element.val()); + switch (element.attr('name')) { + case 'rid': { + selectReward(element); + break; + } + } + } + + function selectReward(element) { + const parent = element.parents('[data-rid]'); + const div = parent.find('.inputs'); + let inputs; + let html = ''; + + for (const reward in available) { + if (available.hasOwnProperty(reward) && available[reward].rid === element.attr('data-selected')) { + inputs = available[reward].inputs; + parent.attr('data-rid', available[reward].rid); + break; + } + } + + if (!inputs) { + return alerts.error('[[admin/extend/rewards:alert.no-inputs-found]] ' + element.attr('data-selected')); + } + + for (const input of inputs) { + html += '
    '; + } + + div.html(html); + } + + function populateInputs() { + $('[data-rid]').each(function (i) { + const div = $(this).find('.inputs'); + const rewards = active[i].rewards; + + for (const reward in rewards) { + if (rewards.hasOwnProperty(reward)) { + div.find('[name="' + reward + '"]').val(rewards[reward]); + } + } + }); + } + + function newReward() { + const ul = $('#active'); + + const data = { + active: [{ + disabled: true, + value: '', + claimable: 1, + rid: null, + id: null, + }], + conditions, + conditionals, + rewards: available, + }; + + app.parseAndTranslate('admin/extend/rewards', 'active', data, li => { + ul.append(li); + li.find('select').val(''); + }); + } + + function saveRewards() { + const activeRewards = []; + + $('#active li').each(function () { + const data = {rewards: {}}; + const main = $(this).find('form.main').serializeArray(); + const rewards = $(this).find('form.rewards').serializeArray(); + + for (const object of main) { + data[object.name] = object.value; + } + + for (const object of rewards) { + data.rewards[object.name] = object.value; + } + + data.id = $(this).attr('data-id'); + data.disabled = $(this).find('.toggle').hasClass('btn-success'); + + activeRewards.push(data); + }); + + socket.emit('admin.rewards.save', activeRewards, (error, result) => { + if (error) { + alerts.error(error); + } else { + alerts.success('[[admin/extend/rewards:alert.save-success]]'); + // Newly added rewards are missing data-id, update to prevent rewards getting duplicated + $('#active li').each(function (index) { + if (!$(this).attr('data-id')) { + $(this).attr('data-id', result[index].id); + } + }); + } + }); + } + + return rewards; }); diff --git a/public/src/admin/extend/widgets.js b/public/src/admin/extend/widgets.js index d307b27..629f343 100644 --- a/public/src/admin/extend/widgets.js +++ b/public/src/admin/extend/widgets.js @@ -1,282 +1,279 @@ 'use strict'; - define('admin/extend/widgets', [ - 'bootbox', - 'alerts', - 'jquery-ui/widgets/sortable', - 'jquery-ui/widgets/draggable', - 'jquery-ui/widgets/droppable', - 'jquery-ui/widgets/datepicker', -], function (bootbox, alerts) { - const Widgets = {}; - - Widgets.init = function () { - $('#widgets .nav-pills .dropdown-menu a').on('click', function (ev) { - const $this = $(this); - $('#widgets .tab-pane').removeClass('active'); - const templateName = $this.attr('data-template'); - $('#widgets .tab-pane[data-template="' + templateName + '"]').addClass('active'); - $('#widgets .selected-template').text(templateName); - $('#widgets .nav-pills .dropdown').trigger('click'); - ev.preventDefault(); - return false; - }); - - $('#widget-selector').on('change', function () { - $('.available-widgets [data-widget]').addClass('hide'); - $('.available-widgets [data-widget="' + $(this).val() + '"]').removeClass('hide'); - }); - - $('#widget-selector').trigger('change'); - - loadWidgetData(); - setupCloneButton(); - }; - - function prepareWidgets() { - $('[data-location="drafts"]').insertAfter($('[data-location="drafts"]').closest('.tab-content')); - - $('#widgets .available-widgets .widget-panel').draggable({ - helper: function (e) { - return $(e.target).parents('.widget-panel').clone(); - }, - distance: 10, - connectToSortable: '.widget-area', - }); - - $('#widgets .available-containers .containers > [data-container-html]') - .draggable({ - helper: function (e) { - let target = $(e.target); - target = target.attr('data-container-html') ? target : target.parents('[data-container-html]'); - - return target.clone().addClass('block').width(target.width()).css('opacity', '0.5'); - }, - distance: 10, - }) - .each(function () { - $(this).attr('data-container-html', $(this).attr('data-container-html').replace(/\\\{([\s\S]*?)\\\}/g, '{$1}')); - }); - - $('#widgets .widget-area').sortable({ - update: function (event, ui) { - createDatePicker(ui.item); - appendToggle(ui.item); - }, - connectWith: 'div', - }).on('click', '.delete-widget', function () { - const panel = $(this).parents('.widget-panel'); - - bootbox.confirm('[[admin/extend/widgets:alert.confirm-delete]]', function (confirm) { - if (confirm) { - panel.remove(); - } - }); - }).on('mouseup', '> .panel > .panel-heading', function (evt) { - if (!($(this).parent().is('.ui-sortable-helper') || $(evt.target).closest('.delete-widget').length)) { - $(this).parent().children('.panel-body').toggleClass('hidden'); - } - }); - - $('#save').on('click', saveWidgets); - - function saveWidgets() { - const saveData = []; - $('#widgets [data-template][data-location]').each(function (i, el) { - el = $(el); - - const template = el.attr('data-template'); - const location = el.attr('data-location'); - const area = el.children('.widget-area'); - const widgets = []; - - area.find('.widget-panel[data-widget]').each(function () { - const widgetData = {}; - const data = $(this).find('form').serializeArray(); - - for (const d in data) { - if (data.hasOwnProperty(d)) { - if (data[d].name) { - if (widgetData[data[d].name]) { - if (!Array.isArray(widgetData[data[d].name])) { - widgetData[data[d].name] = [ - widgetData[data[d].name], - ]; - } - widgetData[data[d].name].push(data[d].value); - } else { - widgetData[data[d].name] = data[d].value; - } - } - } - } - - widgets.push({ - widget: $(this).attr('data-widget'), - data: widgetData, - }); - }); - - saveData.push({ - template: template, - location: location, - widgets: widgets, - }); - }); - - socket.emit('admin.widgets.set', saveData, function (err) { - if (err) { - alerts.error(err); - } - - alerts.alert({ - alert_id: 'admin:widgets', - type: 'success', - title: '[[admin/extend/widgets:alert.updated]]', - message: '[[admin/extend/widgets:alert.update-success]]', - timeout: 2500, - }); - }); - } - - $('.color-selector').on('click', '.btn', function () { - const btn = $(this); - const selector = btn.parents('.color-selector'); - const container = selector.parents('[data-container-html]'); - const classList = []; - - selector.children().each(function () { - classList.push($(this).attr('data-class')); - }); - - container - .removeClass(classList.join(' ')) - .addClass(btn.attr('data-class')); - - container.attr('data-container-html', container.attr('data-container-html') - .replace(/class="[a-zA-Z0-9-\s]+"/, 'class="' + container[0].className.replace(' pointer ui-draggable ui-draggable-handle', '') + '"')); - }); - } - - function createDatePicker(el) { - const currentYear = new Date().getFullYear(); - el.find('.date-selector').datepicker({ - changeMonth: true, - changeYear: true, - yearRange: currentYear + ':' + (currentYear + 100), - }); - } - - function appendToggle(el) { - if (!el.hasClass('block')) { - el.addClass('block').css('width', '').css('height', '') - .droppable({ - accept: '[data-container-html]', - drop: function (event, ui) { - const el = $(this); - - el.find('.panel-body .container-html').val(ui.draggable.attr('data-container-html')); - el.find('.panel-body').removeClass('hidden'); - }, - hoverClass: 'panel-info', - }) - .children('.panel-heading') - .append('
     
    ') - .children('small') - .html(''); - } - } - - function loadWidgetData() { - function populateWidget(widget, data) { - if (data.title) { - const title = widget.find('.panel-heading strong'); - title.text(title.text() + ' - ' + data.title); - } - - widget.find('input, textarea, select').each(function () { - const input = $(this); - const value = data[input.attr('name')]; - - if (input.attr('type') === 'checkbox') { - input.prop('checked', !!value).trigger('change'); - } else { - input.val(value); - } - }); - - return widget; - } - - $.get(config.relative_path + '/api/admin/extend/widgets', function (data) { - const areas = data.areas; - - for (let i = 0; i < areas.length; i += 1) { - const area = areas[i]; - const widgetArea = $('#widgets .area[data-template="' + area.template + '"][data-location="' + area.location + '"]').find('.widget-area'); - - widgetArea.html(''); - - for (let k = 0; k < area.data.length; k += 1) { - const widgetData = area.data[k]; - const widgetEl = $('.available-widgets [data-widget="' + widgetData.widget + '"]').clone(true).removeClass('hide'); - - widgetArea.append(populateWidget(widgetEl, widgetData.data)); - appendToggle(widgetEl); - createDatePicker(widgetEl); - } - } - - prepareWidgets(); - }); - } - - function setupCloneButton() { - const clone = $('[component="clone"]'); - const cloneBtn = $('[component="clone/button"]'); - - clone.find('.dropdown-menu li').on('click', function () { - const template = $(this).find('a').text(); - cloneBtn.translateHtml('[[admin/extend/widgets:clone-from]] ' + template + ''); - cloneBtn.attr('data-template', template); - }); - - cloneBtn.on('click', function () { - const template = cloneBtn.attr('data-template'); - if (!template) { - return alerts.error('[[admin/extend/widgets:error.select-clone]]'); - } - - const currentTemplate = $('#active-widgets .active.tab-pane[data-template] .area'); - const templateToClone = $('#active-widgets .tab-pane[data-template="' + template + '"] .area'); - - const currentAreas = currentTemplate.map(function () { - return $(this).attr('data-location'); - }).get(); - - const areasToClone = templateToClone.map(function () { - const location = $(this).attr('data-location'); - return currentAreas.indexOf(location) !== -1 ? location : undefined; - }).get().filter(function (i) { return i; }); - - function clone(location) { - $('#active-widgets .tab-pane[data-template="' + template + '"] [data-location="' + location + '"]').each(function () { - $(this).find('[data-widget]').each(function () { - const widget = $(this).clone(true); - $('#active-widgets .active.tab-pane[data-template]:not([data-template="global"]) [data-location="' + location + '"] .widget-area').append(widget); - }); - }); - } - - for (let i = 0, ii = areasToClone.length; i < ii; i++) { - const location = areasToClone[i]; - clone(location); - } - - alerts.success('[[admin/extend/widgets:alert.clone-success]]'); - }); - } - - return Widgets; + 'bootbox', + 'alerts', + 'jquery-ui/widgets/sortable', + 'jquery-ui/widgets/draggable', + 'jquery-ui/widgets/droppable', + 'jquery-ui/widgets/datepicker', +], (bootbox, alerts) => { + const Widgets = {}; + + Widgets.init = function () { + $('#widgets .nav-pills .dropdown-menu a').on('click', function (event) { + const $this = $(this); + $('#widgets .tab-pane').removeClass('active'); + const templateName = $this.attr('data-template'); + $('#widgets .tab-pane[data-template="' + templateName + '"]').addClass('active'); + $('#widgets .selected-template').text(templateName); + $('#widgets .nav-pills .dropdown').trigger('click'); + event.preventDefault(); + return false; + }); + + $('#widget-selector').on('change', function () { + $('.available-widgets [data-widget]').addClass('hide'); + $('.available-widgets [data-widget="' + $(this).val() + '"]').removeClass('hide'); + }); + + $('#widget-selector').trigger('change'); + + loadWidgetData(); + setupCloneButton(); + }; + + function prepareWidgets() { + $('[data-location="drafts"]').insertAfter($('[data-location="drafts"]').closest('.tab-content')); + + $('#widgets .available-widgets .widget-panel').draggable({ + helper(e) { + return $(e.target).parents('.widget-panel').clone(); + }, + distance: 10, + connectToSortable: '.widget-area', + }); + + $('#widgets .available-containers .containers > [data-container-html]') + .draggable({ + helper(e) { + let target = $(e.target); + target = target.attr('data-container-html') ? target : target.parents('[data-container-html]'); + + return target.clone().addClass('block').width(target.width()).css('opacity', '0.5'); + }, + distance: 10, + }) + .each(function () { + $(this).attr('data-container-html', $(this).attr('data-container-html').replaceAll(/\\{([\s\S]*?)\\}/g, '{$1}')); + }); + + $('#widgets .widget-area').sortable({ + update(event, ui) { + createDatePicker(ui.item); + appendToggle(ui.item); + }, + connectWith: 'div', + }).on('click', '.delete-widget', function () { + const panel = $(this).parents('.widget-panel'); + + bootbox.confirm('[[admin/extend/widgets:alert.confirm-delete]]', confirm => { + if (confirm) { + panel.remove(); + } + }); + }).on('mouseup', '> .panel > .panel-heading', function (event) { + if (!($(this).parent().is('.ui-sortable-helper') || $(event.target).closest('.delete-widget').length > 0)) { + $(this).parent().children('.panel-body').toggleClass('hidden'); + } + }); + + $('#save').on('click', saveWidgets); + + function saveWidgets() { + const saveData = []; + $('#widgets [data-template][data-location]').each((i, element) => { + element = $(element); + + const template = element.attr('data-template'); + const location = element.attr('data-location'); + const area = element.children('.widget-area'); + const widgets = []; + + area.find('.widget-panel[data-widget]').each(function () { + const widgetData = {}; + const data = $(this).find('form').serializeArray(); + + for (const d in data) { + if (data.hasOwnProperty(d) && data[d].name) { + if (widgetData[data[d].name]) { + if (!Array.isArray(widgetData[data[d].name])) { + widgetData[data[d].name] = [ + widgetData[data[d].name], + ]; + } + + widgetData[data[d].name].push(data[d].value); + } else { + widgetData[data[d].name] = data[d].value; + } + } + } + + widgets.push({ + widget: $(this).attr('data-widget'), + data: widgetData, + }); + }); + + saveData.push({ + template, + location, + widgets, + }); + }); + + socket.emit('admin.widgets.set', saveData, error => { + if (error) { + alerts.error(error); + } + + alerts.alert({ + alert_id: 'admin:widgets', + type: 'success', + title: '[[admin/extend/widgets:alert.updated]]', + message: '[[admin/extend/widgets:alert.update-success]]', + timeout: 2500, + }); + }); + } + + $('.color-selector').on('click', '.btn', function () { + const button = $(this); + const selector = button.parents('.color-selector'); + const container = selector.parents('[data-container-html]'); + const classList = []; + + selector.children().each(function () { + classList.push($(this).attr('data-class')); + }); + + container + .removeClass(classList.join(' ')) + .addClass(button.attr('data-class')); + + container.attr('data-container-html', container.attr('data-container-html') + .replace(/class="[a-zA-Z\d-\s]+"/, 'class="' + container[0].className.replace(' pointer ui-draggable ui-draggable-handle', '') + '"')); + }); + } + + function createDatePicker(element) { + const currentYear = new Date().getFullYear(); + element.find('.date-selector').datepicker({ + changeMonth: true, + changeYear: true, + yearRange: currentYear + ':' + (currentYear + 100), + }); + } + + function appendToggle(element) { + if (!element.hasClass('block')) { + element.addClass('block').css('width', '').css('height', '') + .droppable({ + accept: '[data-container-html]', + drop(event, ui) { + const element = $(this); + + element.find('.panel-body .container-html').val(ui.draggable.attr('data-container-html')); + element.find('.panel-body').removeClass('hidden'); + }, + hoverClass: 'panel-info', + }) + .children('.panel-heading') + .append('
     
    ') + .children('small') + .html(''); + } + } + + function loadWidgetData() { + function populateWidget(widget, data) { + if (data.title) { + const title = widget.find('.panel-heading strong'); + title.text(title.text() + ' - ' + data.title); + } + + widget.find('input, textarea, select').each(function () { + const input = $(this); + const value = data[input.attr('name')]; + + if (input.attr('type') === 'checkbox') { + input.prop('checked', Boolean(value)).trigger('change'); + } else { + input.val(value); + } + }); + + return widget; + } + + $.get(config.relative_path + '/api/admin/extend/widgets', data => { + const areas = data.areas; + + for (const area of areas) { + const widgetArea = $('#widgets .area[data-template="' + area.template + '"][data-location="' + area.location + '"]').find('.widget-area'); + + widgetArea.html(''); + + for (let k = 0; k < area.data.length; k += 1) { + const widgetData = area.data[k]; + const widgetElement = $('.available-widgets [data-widget="' + widgetData.widget + '"]').clone(true).removeClass('hide'); + + widgetArea.append(populateWidget(widgetElement, widgetData.data)); + appendToggle(widgetElement); + createDatePicker(widgetElement); + } + } + + prepareWidgets(); + }); + } + + function setupCloneButton() { + const clone = $('[component="clone"]'); + const cloneButton = $('[component="clone/button"]'); + + clone.find('.dropdown-menu li').on('click', function () { + const template = $(this).find('a').text(); + cloneButton.translateHtml('[[admin/extend/widgets:clone-from]] ' + template + ''); + cloneButton.attr('data-template', template); + }); + + cloneButton.on('click', () => { + const template = cloneButton.attr('data-template'); + if (!template) { + return alerts.error('[[admin/extend/widgets:error.select-clone]]'); + } + + const currentTemplate = $('#active-widgets .active.tab-pane[data-template] .area'); + const templateToClone = $('#active-widgets .tab-pane[data-template="' + template + '"] .area'); + + const currentAreas = currentTemplate.map(function () { + return $(this).attr('data-location'); + }).get(); + + const areasToClone = templateToClone.map(function () { + const location = $(this).attr('data-location'); + return currentAreas.includes(location) ? location : undefined; + }).get().filter(Boolean); + + function clone(location) { + $('#active-widgets .tab-pane[data-template="' + template + '"] [data-location="' + location + '"]').each(function () { + $(this).find('[data-widget]').each(function () { + const widget = $(this).clone(true); + $('#active-widgets .active.tab-pane[data-template]:not([data-template="global"]) [data-location="' + location + '"] .widget-area').append(widget); + }); + }); + } + + for (let i = 0, ii = areasToClone.length; i < ii; i++) { + const location = areasToClone[i]; + clone(location); + } + + alerts.success('[[admin/extend/widgets:alert.clone-success]]'); + }); + } + + return Widgets; }); diff --git a/public/src/admin/manage/admins-mods.js b/public/src/admin/manage/admins-mods.js index b5acce0..ea481ca 100644 --- a/public/src/admin/manage/admins-mods.js +++ b/public/src/admin/manage/admins-mods.js @@ -1,133 +1,137 @@ 'use strict'; define('admin/manage/admins-mods', [ - 'autocomplete', 'api', 'bootbox', 'alerts', 'categorySelector', -], function (autocomplete, api, bootbox, alerts, categorySelector) { - const AdminsMods = {}; - - AdminsMods.init = function () { - autocomplete.user($('#admin-search'), function (ev, ui) { - socket.emit('admin.user.makeAdmins', [ui.item.user.uid], function (err) { - if (err) { - return alerts.error(err); - } - alerts.success('[[admin/manage/users:alerts.make-admin-success]]'); - $('#admin-search').val(''); - - if ($('.administrator-area [data-uid="' + ui.item.user.uid + '"]').length) { - return; - } - - app.parseAndTranslate('admin/manage/admins-mods', 'admins.members', { admins: { members: [ui.item.user] } }, function (html) { - $('.administrator-area').prepend(html); - }); - }); - }); - - $('.administrator-area').on('click', '.remove-user-icon', function () { - const userCard = $(this).parents('[data-uid]'); - const uid = userCard.attr('data-uid'); - if (parseInt(uid, 10) === parseInt(app.user.uid, 10)) { - return alerts.error('[[admin/manage/users:alerts.no-remove-yourself-admin]]'); - } - bootbox.confirm('[[admin/manage/users:alerts.confirm-remove-admin]]', function (confirm) { - if (confirm) { - socket.emit('admin.user.removeAdmins', [uid], function (err) { - if (err) { - return alerts.error(err.message); - } - alerts.success('[[admin/manage/users:alerts.remove-admin-success]]'); - userCard.remove(); - }); - } - }); - }); - - autocomplete.user($('#global-mod-search'), function (ev, ui) { - api.put('/groups/global-moderators/membership/' + ui.item.user.uid).then(() => { - alerts.success('[[admin/manage/users:alerts.make-global-mod-success]]'); - $('#global-mod-search').val(''); - - if ($('.global-moderator-area [data-uid="' + ui.item.user.uid + '"]').length) { - return; - } - - app.parseAndTranslate('admin/manage/admins-mods', 'globalMods.members', { globalMods: { members: [ui.item.user] } }, function (html) { - $('.global-moderator-area').prepend(html); - $('#no-global-mods-warning').addClass('hidden'); - }); - }).catch(alerts.error); - }); - - $('.global-moderator-area').on('click', '.remove-user-icon', function () { - const userCard = $(this).parents('[data-uid]'); - const uid = userCard.attr('data-uid'); - - bootbox.confirm('[[admin/manage/users:alerts.confirm-remove-global-mod]]', function (confirm) { - if (confirm) { - api.del('/groups/global-moderators/membership/' + uid).then(() => { - alerts.success('[[admin/manage/users:alerts.remove-global-mod-success]]'); - userCard.remove(); - if (!$('.global-moderator-area').children().length) { - $('#no-global-mods-warning').removeClass('hidden'); - } - }).catch(alerts.error); - } - }); - }); - - - categorySelector.init($('[component="category-selector"]'), { - parentCid: ajaxify.data.selectedCategory ? ajaxify.data.selectedCategory.cid : 0, - onSelect: function (selectedCategory) { - ajaxify.go('admin/manage/admins-mods' + (selectedCategory.cid ? '?cid=' + selectedCategory.cid : '')); - }, - localCategories: [], - }); - - autocomplete.user($('.moderator-search'), function (ev, ui) { - const input = $(ev.target); - const cid = $(ev.target).attr('data-cid'); - api.put(`/categories/${cid}/moderator/${ui.item.user.uid}`, {}, function (err) { - if (err) { - return alerts.error(err); - } - alerts.success('[[admin/manage/users:alerts.make-moderator-success]]'); - input.val(''); - - if ($('.moderator-area[data-cid="' + cid + '"] [data-uid="' + ui.item.user.uid + '"]').length) { - return; - } - - app.parseAndTranslate('admin/manage/admins-mods', 'globalMods.members', { globalMods: { members: [ui.item.user] } }, function (html) { - $('.moderator-area[data-cid="' + cid + '"]').prepend(html); - $('.no-moderator-warning[data-cid="' + cid + '"]').addClass('hidden'); - }); - }); - }); - - $('.moderator-area').on('click', '.remove-user-icon', function () { - const moderatorArea = $(this).parents('[data-cid]'); - const cid = moderatorArea.attr('data-cid'); - const userCard = $(this).parents('[data-uid]'); - const uid = userCard.attr('data-uid'); - - bootbox.confirm('[[admin/manage/users:alerts.confirm-remove-moderator]]', function (confirm) { - if (confirm) { - api.delete(`/categories/${cid}/moderator/${uid}`, {}, function (err) { - if (err) { - return alerts.error(err); - } - alerts.success('[[admin/manage/users:alerts.remove-moderator-success]]'); - userCard.remove(); - if (!moderatorArea.children().length) { - $('.no-moderator-warning[data-cid="' + cid + '"]').removeClass('hidden'); - } - }); - } - }); - }); - }; - - return AdminsMods; + 'autocomplete', 'api', 'bootbox', 'alerts', 'categorySelector', +], (autocomplete, api, bootbox, alerts, categorySelector) => { + const AdminsMods = {}; + + AdminsMods.init = function () { + autocomplete.user($('#admin-search'), (event, ui) => { + socket.emit('admin.user.makeAdmins', [ui.item.user.uid], error => { + if (error) { + return alerts.error(error); + } + + alerts.success('[[admin/manage/users:alerts.make-admin-success]]'); + $('#admin-search').val(''); + + if ($('.administrator-area [data-uid="' + ui.item.user.uid + '"]').length > 0) { + return; + } + + app.parseAndTranslate('admin/manage/admins-mods', 'admins.members', {admins: {members: [ui.item.user]}}, html => { + $('.administrator-area').prepend(html); + }); + }); + }); + + $('.administrator-area').on('click', '.remove-user-icon', function () { + const userCard = $(this).parents('[data-uid]'); + const uid = userCard.attr('data-uid'); + if (Number.parseInt(uid, 10) === Number.parseInt(app.user.uid, 10)) { + return alerts.error('[[admin/manage/users:alerts.no-remove-yourself-admin]]'); + } + + bootbox.confirm('[[admin/manage/users:alerts.confirm-remove-admin]]', confirm => { + if (confirm) { + socket.emit('admin.user.removeAdmins', [uid], error => { + if (error) { + return alerts.error(error.message); + } + + alerts.success('[[admin/manage/users:alerts.remove-admin-success]]'); + userCard.remove(); + }); + } + }); + }); + + autocomplete.user($('#global-mod-search'), (event, ui) => { + api.put('/groups/global-moderators/membership/' + ui.item.user.uid).then(() => { + alerts.success('[[admin/manage/users:alerts.make-global-mod-success]]'); + $('#global-mod-search').val(''); + + if ($('.global-moderator-area [data-uid="' + ui.item.user.uid + '"]').length > 0) { + return; + } + + app.parseAndTranslate('admin/manage/admins-mods', 'globalMods.members', {globalMods: {members: [ui.item.user]}}, html => { + $('.global-moderator-area').prepend(html); + $('#no-global-mods-warning').addClass('hidden'); + }); + }).catch(alerts.error); + }); + + $('.global-moderator-area').on('click', '.remove-user-icon', function () { + const userCard = $(this).parents('[data-uid]'); + const uid = userCard.attr('data-uid'); + + bootbox.confirm('[[admin/manage/users:alerts.confirm-remove-global-mod]]', confirm => { + if (confirm) { + api.del('/groups/global-moderators/membership/' + uid).then(() => { + alerts.success('[[admin/manage/users:alerts.remove-global-mod-success]]'); + userCard.remove(); + if ($('.global-moderator-area').children().length === 0) { + $('#no-global-mods-warning').removeClass('hidden'); + } + }).catch(alerts.error); + } + }); + }); + + categorySelector.init($('[component="category-selector"]'), { + parentCid: ajaxify.data.selectedCategory ? ajaxify.data.selectedCategory.cid : 0, + onSelect(selectedCategory) { + ajaxify.go('admin/manage/admins-mods' + (selectedCategory.cid ? '?cid=' + selectedCategory.cid : '')); + }, + localCategories: [], + }); + + autocomplete.user($('.moderator-search'), (event, ui) => { + const input = $(event.target); + const cid = $(event.target).attr('data-cid'); + api.put(`/categories/${cid}/moderator/${ui.item.user.uid}`, {}, error => { + if (error) { + return alerts.error(error); + } + + alerts.success('[[admin/manage/users:alerts.make-moderator-success]]'); + input.val(''); + + if ($('.moderator-area[data-cid="' + cid + '"] [data-uid="' + ui.item.user.uid + '"]').length > 0) { + return; + } + + app.parseAndTranslate('admin/manage/admins-mods', 'globalMods.members', {globalMods: {members: [ui.item.user]}}, html => { + $('.moderator-area[data-cid="' + cid + '"]').prepend(html); + $('.no-moderator-warning[data-cid="' + cid + '"]').addClass('hidden'); + }); + }); + }); + + $('.moderator-area').on('click', '.remove-user-icon', function () { + const moderatorArea = $(this).parents('[data-cid]'); + const cid = moderatorArea.attr('data-cid'); + const userCard = $(this).parents('[data-uid]'); + const uid = userCard.attr('data-uid'); + + bootbox.confirm('[[admin/manage/users:alerts.confirm-remove-moderator]]', confirm => { + if (confirm) { + api.delete(`/categories/${cid}/moderator/${uid}`, {}, error => { + if (error) { + return alerts.error(error); + } + + alerts.success('[[admin/manage/users:alerts.remove-moderator-success]]'); + userCard.remove(); + if (moderatorArea.children().length === 0) { + $('.no-moderator-warning[data-cid="' + cid + '"]').removeClass('hidden'); + } + }); + } + }); + }); + }; + + return AdminsMods; }); diff --git a/public/src/admin/manage/categories.js b/public/src/admin/manage/categories.js index 3c6660a..9832038 100644 --- a/public/src/admin/manage/categories.js +++ b/public/src/admin/manage/categories.js @@ -1,240 +1,240 @@ 'use strict'; define('admin/manage/categories', [ - 'translator', - 'benchpress', - 'categorySelector', - 'api', - 'Sortable', - 'bootbox', - 'alerts', -], function (translator, Benchpress, categorySelector, api, Sortable, bootbox, alerts) { - Sortable = Sortable.default; - const Categories = {}; - let newCategoryId = -1; - let sortables; - - Categories.init = function () { - categorySelector.init($('.category [component="category-selector"]'), { - parentCid: ajaxify.data.selectedCategory ? ajaxify.data.selectedCategory.cid : 0, - onSelect: function (selectedCategory) { - ajaxify.go('/admin/manage/categories' + (selectedCategory.cid ? '?cid=' + selectedCategory.cid : '')); - }, - localCategories: [], - }); - Categories.render(ajaxify.data.categoriesTree); - - $('button[data-action="create"]').on('click', Categories.throwCreateModal); - - // Enable/Disable toggle events - $('.categories').on('click', '.category-tools [data-action="toggle"]', function () { - const $this = $(this); - const cid = $this.attr('data-disable-cid'); - const parentEl = $this.parents('li[data-cid="' + cid + '"]'); - const disabled = parentEl.hasClass('disabled'); - const childrenEls = parentEl.find('li[data-cid]'); - const childrenCids = childrenEls.map(function () { - return $(this).attr('data-cid'); - }).get(); - - Categories.toggle([cid].concat(childrenCids), !disabled); - }); - - $('.categories').on('click', '.toggle', function () { - const el = $(this); - el.find('i').toggleClass('fa-chevron-down').toggleClass('fa-chevron-right'); - el.closest('[data-cid]').find('> ul[data-cid]').toggleClass('hidden'); - }); - - $('.categories').on('click', '.set-order', function () { - const cid = $(this).attr('data-cid'); - const order = $(this).attr('data-order'); - const modal = bootbox.dialog({ - title: '[[admin/manage/categories:set-order]]', - message: '

    [[admin/manage/categories:set-order-help]]

    ', - show: true, - buttons: { - save: { - label: '[[modules:bootbox.confirm]]', - className: 'btn-primary', - callback: function () { - const val = modal.find('input').val(); - if (val && cid) { - const modified = {}; - modified[cid] = { order: Math.max(1, parseInt(val, 10)) }; - api.put('/categories/' + cid, modified[cid]).then(function () { - ajaxify.refresh(); - }).catch(alerts.error); - } else { - return false; - } - }, - }, - }, - }); - }); - - $('#collapse-all').on('click', function () { - toggleAll(false); - }); - - $('#expand-all').on('click', function () { - toggleAll(true); - }); - - function toggleAll(expand) { - const el = $('.categories .toggle'); - el.find('i').toggleClass('fa-chevron-down', expand).toggleClass('fa-chevron-right', !expand); - el.closest('[data-cid]').find('> ul[data-cid]').toggleClass('hidden', !expand); - } - }; - - Categories.throwCreateModal = function () { - Benchpress.render('admin/partials/categories/create', {}).then(function (html) { - const modal = bootbox.dialog({ - title: '[[admin/manage/categories:alert.create]]', - message: html, - buttons: { - save: { - label: '[[global:save]]', - className: 'btn-primary', - callback: submit, - }, - }, - }); - const options = { - localCategories: [ - { - cid: 0, - name: '[[admin/manage/categories:parent-category-none]]', - icon: 'fa-none', - }, - ], - }; - const parentSelector = categorySelector.init(modal.find('#parentCidGroup [component="category-selector"]'), options); - const cloneFromSelector = categorySelector.init(modal.find('#cloneFromCidGroup [component="category-selector"]'), options); - function submit() { - const formData = modal.find('form').serializeObject(); - formData.description = ''; - formData.icon = 'fa-comments'; - formData.uid = app.user.uid; - formData.parentCid = parentSelector.getSelectedCid(); - formData.cloneFromCid = cloneFromSelector.getSelectedCid(); - - Categories.create(formData); - modal.modal('hide'); - return false; - } - - $('#cloneChildren').on('change', function () { - const check = $(this); - const parentSelect = modal.find('#parentCidGroup [component="category-selector"] .dropdown-toggle'); - - if (check.prop('checked')) { - parentSelect.attr('disabled', 'disabled'); - parentSelector.selectCategory(0); - } else { - parentSelect.removeAttr('disabled'); - } - }); - - modal.find('form').on('submit', submit); - }); - }; - - Categories.create = function (payload) { - api.post('/categories', payload, function (err, data) { - if (err) { - return alerts.error(err); - } - - alerts.alert({ - alert_id: 'category_created', - title: '[[admin/manage/categories:alert.created]]', - message: '[[admin/manage/categories:alert.create-success]]', - type: 'success', - timeout: 2000, - }); - - ajaxify.go('admin/manage/categories/' + data.cid); - }); - }; - - Categories.render = function (categories) { - const container = $('.categories'); - - if (!categories || !categories.length) { - translator.translate('[[admin/manage/categories:alert.none-active]]', function (text) { - $('
    ') - .addClass('alert alert-info text-center') - .text(text) - .appendTo(container); - }); - } else { - sortables = {}; - renderList(categories, container, { cid: 0 }); - } - }; - - Categories.toggle = function (cids, disabled) { - const listEl = document.querySelector('.categories ul'); - Promise.all(cids.map(cid => api.put('/categories/' + cid, { - disabled: disabled ? 1 : 0, - }).then(() => { - const categoryEl = listEl.querySelector(`li[data-cid="${cid}"]`); - categoryEl.classList[disabled ? 'add' : 'remove']('disabled'); - $(categoryEl).find('li a[data-action="toggle"]').first().translateText(disabled ? '[[admin/manage/categories:enable]]' : '[[admin/manage/categories:disable]]'); - }).catch(alerts.error))); - }; - - function itemDidAdd(e) { - newCategoryId = e.to.dataset.cid; - } - - function itemDragDidEnd(e) { - const isCategoryUpdate = parseInt(newCategoryId, 10) !== -1; - - // Update needed? - if ((e.newIndex != null && parseInt(e.oldIndex, 10) !== parseInt(e.newIndex, 10)) || isCategoryUpdate) { - const cid = e.item.dataset.cid; - const modified = {}; - // on page 1 baseIndex is 0, on page n baseIndex is (n - 1) * ajaxify.data.categoriesPerPage - // this makes sure order is correct when drag & drop is used on pages > 1 - const baseIndex = (ajaxify.data.pagination.currentPage - 1) * ajaxify.data.categoriesPerPage; - modified[cid] = { - order: baseIndex + e.newIndex + 1, - }; - - if (isCategoryUpdate) { - modified[cid].parentCid = newCategoryId; - - // Show/hide expand buttons after drag completion - const oldParentCid = parseInt(e.from.getAttribute('data-cid'), 10); - const newParentCid = parseInt(e.to.getAttribute('data-cid'), 10); - if (oldParentCid !== newParentCid) { - const toggle = document.querySelector(`.categories li[data-cid="${newParentCid}"] .toggle`); - if (toggle) { - toggle.classList.toggle('hide', false); - } - - const children = document.querySelectorAll(`.categories li[data-cid="${oldParentCid}"] ul[data-cid] li[data-cid]`); - if (!children.length) { - const toggle = document.querySelector(`.categories li[data-cid="${oldParentCid}"] .toggle`); - if (toggle) { - toggle.classList.toggle('hide', true); - } - } - - e.item.dataset.parentCid = newParentCid; - } - } - - newCategoryId = -1; - api.put('/categories/' + cid, modified[cid]).catch(alerts.error); - } - } - - /** + 'translator', + 'benchpress', + 'categorySelector', + 'api', + 'Sortable', + 'bootbox', + 'alerts', +], (translator, Benchpress, categorySelector, api, Sortable, bootbox, alerts) => { + Sortable = Sortable.default; + const Categories = {}; + let newCategoryId = -1; + let sortables; + + Categories.init = function () { + categorySelector.init($('.category [component="category-selector"]'), { + parentCid: ajaxify.data.selectedCategory ? ajaxify.data.selectedCategory.cid : 0, + onSelect(selectedCategory) { + ajaxify.go('/admin/manage/categories' + (selectedCategory.cid ? '?cid=' + selectedCategory.cid : '')); + }, + localCategories: [], + }); + Categories.render(ajaxify.data.categoriesTree); + + $('button[data-action="create"]').on('click', Categories.throwCreateModal); + + // Enable/Disable toggle events + $('.categories').on('click', '.category-tools [data-action="toggle"]', function () { + const $this = $(this); + const cid = $this.attr('data-disable-cid'); + const parentElement = $this.parents('li[data-cid="' + cid + '"]'); + const disabled = parentElement.hasClass('disabled'); + const childrenEls = parentElement.find('li[data-cid]'); + const childrenCids = childrenEls.map(function () { + return $(this).attr('data-cid'); + }).get(); + + Categories.toggle([cid].concat(childrenCids), !disabled); + }); + + $('.categories').on('click', '.toggle', function () { + const element = $(this); + element.find('i').toggleClass('fa-chevron-down').toggleClass('fa-chevron-right'); + element.closest('[data-cid]').find('> ul[data-cid]').toggleClass('hidden'); + }); + + $('.categories').on('click', '.set-order', function () { + const cid = $(this).attr('data-cid'); + const order = $(this).attr('data-order'); + const modal = bootbox.dialog({ + title: '[[admin/manage/categories:set-order]]', + message: '

    [[admin/manage/categories:set-order-help]]

    ', + show: true, + buttons: { + save: { + label: '[[modules:bootbox.confirm]]', + className: 'btn-primary', + callback() { + const value = modal.find('input').val(); + if (value && cid) { + const modified = {}; + modified[cid] = {order: Math.max(1, Number.parseInt(value, 10))}; + api.put('/categories/' + cid, modified[cid]).then(() => { + ajaxify.refresh(); + }).catch(alerts.error); + } else { + return false; + } + }, + }, + }, + }); + }); + + $('#collapse-all').on('click', () => { + toggleAll(false); + }); + + $('#expand-all').on('click', () => { + toggleAll(true); + }); + + function toggleAll(expand) { + const element = $('.categories .toggle'); + element.find('i').toggleClass('fa-chevron-down', expand).toggleClass('fa-chevron-right', !expand); + element.closest('[data-cid]').find('> ul[data-cid]').toggleClass('hidden', !expand); + } + }; + + Categories.throwCreateModal = function () { + Benchpress.render('admin/partials/categories/create', {}).then(html => { + const modal = bootbox.dialog({ + title: '[[admin/manage/categories:alert.create]]', + message: html, + buttons: { + save: { + label: '[[global:save]]', + className: 'btn-primary', + callback: submit, + }, + }, + }); + const options = { + localCategories: [ + { + cid: 0, + name: '[[admin/manage/categories:parent-category-none]]', + icon: 'fa-none', + }, + ], + }; + const parentSelector = categorySelector.init(modal.find('#parentCidGroup [component="category-selector"]'), options); + const cloneFromSelector = categorySelector.init(modal.find('#cloneFromCidGroup [component="category-selector"]'), options); + function submit() { + const formData = modal.find('form').serializeObject(); + formData.description = ''; + formData.icon = 'fa-comments'; + formData.uid = app.user.uid; + formData.parentCid = parentSelector.getSelectedCid(); + formData.cloneFromCid = cloneFromSelector.getSelectedCid(); + + Categories.create(formData); + modal.modal('hide'); + return false; + } + + $('#cloneChildren').on('change', function () { + const check = $(this); + const parentSelect = modal.find('#parentCidGroup [component="category-selector"] .dropdown-toggle'); + + if (check.prop('checked')) { + parentSelect.attr('disabled', 'disabled'); + parentSelector.selectCategory(0); + } else { + parentSelect.removeAttr('disabled'); + } + }); + + modal.find('form').on('submit', submit); + }); + }; + + Categories.create = function (payload) { + api.post('/categories', payload, (error, data) => { + if (error) { + return alerts.error(error); + } + + alerts.alert({ + alert_id: 'category_created', + title: '[[admin/manage/categories:alert.created]]', + message: '[[admin/manage/categories:alert.create-success]]', + type: 'success', + timeout: 2000, + }); + + ajaxify.go('admin/manage/categories/' + data.cid); + }); + }; + + Categories.render = function (categories) { + const container = $('.categories'); + + if (!categories || categories.length === 0) { + translator.translate('[[admin/manage/categories:alert.none-active]]', text => { + $('
    ') + .addClass('alert alert-info text-center') + .text(text) + .appendTo(container); + }); + } else { + sortables = {}; + renderList(categories, container, {cid: 0}); + } + }; + + Categories.toggle = function (cids, disabled) { + const listElement = document.querySelector('.categories ul'); + Promise.all(cids.map(cid => api.put('/categories/' + cid, { + disabled: disabled ? 1 : 0, + }).then(() => { + const categoryElement = listElement.querySelector(`li[data-cid="${cid}"]`); + categoryElement.classList[disabled ? 'add' : 'remove']('disabled'); + $(categoryElement).find('li a[data-action="toggle"]').first().translateText(disabled ? '[[admin/manage/categories:enable]]' : '[[admin/manage/categories:disable]]'); + }).catch(alerts.error))); + }; + + function itemDidAdd(e) { + newCategoryId = e.to.dataset.cid; + } + + function itemDragDidEnd(e) { + const isCategoryUpdate = Number.parseInt(newCategoryId, 10) !== -1; + + // Update needed? + if ((e.newIndex != null && Number.parseInt(e.oldIndex, 10) !== Number.parseInt(e.newIndex, 10)) || isCategoryUpdate) { + const cid = e.item.dataset.cid; + const modified = {}; + // On page 1 baseIndex is 0, on page n baseIndex is (n - 1) * ajaxify.data.categoriesPerPage + // this makes sure order is correct when drag & drop is used on pages > 1 + const baseIndex = (ajaxify.data.pagination.currentPage - 1) * ajaxify.data.categoriesPerPage; + modified[cid] = { + order: baseIndex + e.newIndex + 1, + }; + + if (isCategoryUpdate) { + modified[cid].parentCid = newCategoryId; + + // Show/hide expand buttons after drag completion + const oldParentCid = Number.parseInt(e.from.dataset.cid, 10); + const newParentCid = Number.parseInt(e.to.dataset.cid, 10); + if (oldParentCid !== newParentCid) { + const toggle = document.querySelector(`.categories li[data-cid="${newParentCid}"] .toggle`); + if (toggle) { + toggle.classList.toggle('hide', false); + } + + const children = document.querySelectorAll(`.categories li[data-cid="${oldParentCid}"] ul[data-cid] li[data-cid]`); + if (children.length === 0) { + const toggle = document.querySelector(`.categories li[data-cid="${oldParentCid}"] .toggle`); + if (toggle) { + toggle.classList.toggle('hide', true); + } + } + + e.item.dataset.parentCid = newParentCid; + } + } + + newCategoryId = -1; + api.put('/categories/' + cid, modified[cid]).catch(alerts.error); + } + } + + /** * Render categories - recursively * * @param categories {array} categories tree @@ -242,63 +242,64 @@ define('admin/manage/categories', [ * @param container {object} parent jquery element for the list * @param parentId {number} parent category identifier */ - function renderList(categories, container, parentCategory) { - // Translate category names if needed - let count = 0; - const parentId = parentCategory.cid; - categories.forEach(function (category, idx, parent) { - translator.translate(category.name, function (translated) { - if (category.name !== translated) { - category.name = translated; - } - count += 1; - - if (count === parent.length) { - continueRender(); - } - }); - }); - - if (!categories.length) { - continueRender(); - } - - function continueRender() { - app.parseAndTranslate('admin/partials/categories/category-rows', { - cid: parentCategory.cid, - categories: categories, - parentCategory: parentCategory, - }, function (html) { - if (container.find('.category-row').length) { - container.find('.category-row').after(html); - } else { - container.append(html); - } - - // Disable expand toggle - if (!categories.length) { - const toggleEl = container.get(0).querySelector('.toggle'); - toggleEl.classList.toggle('hide', true); - } - - // Handle and children categories in this level have - for (let x = 0, numCategories = categories.length; x < numCategories; x += 1) { - renderList(categories[x].children, $('li[data-cid="' + categories[x].cid + '"]'), categories[x]); - } - - // Make list sortable - sortables[parentId] = Sortable.create($('ul[data-cid="' + parentId + '"]')[0], { - group: 'cross-categories', - animation: 150, - handle: '.information', - dataIdAttr: 'data-cid', - ghostClass: 'placeholder', - onAdd: itemDidAdd, - onEnd: itemDragDidEnd, - }); - }); - } - } - - return Categories; + function renderList(categories, container, parentCategory) { + // Translate category names if needed + let count = 0; + const parentId = parentCategory.cid; + categories.forEach((category, index, parent) => { + translator.translate(category.name, translated => { + if (category.name !== translated) { + category.name = translated; + } + + count += 1; + + if (count === parent.length) { + continueRender(); + } + }); + }); + + if (categories.length === 0) { + continueRender(); + } + + function continueRender() { + app.parseAndTranslate('admin/partials/categories/category-rows', { + cid: parentCategory.cid, + categories, + parentCategory, + }, html => { + if (container.find('.category-row').length > 0) { + container.find('.category-row').after(html); + } else { + container.append(html); + } + + // Disable expand toggle + if (categories.length === 0) { + const toggleElement = container.get(0).querySelector('.toggle'); + toggleElement.classList.toggle('hide', true); + } + + // Handle and children categories in this level have + for (let x = 0, numberCategories = categories.length; x < numberCategories; x += 1) { + renderList(categories[x].children, $('li[data-cid="' + categories[x].cid + '"]'), categories[x]); + } + + // Make list sortable + sortables[parentId] = Sortable.create($('ul[data-cid="' + parentId + '"]')[0], { + group: 'cross-categories', + animation: 150, + handle: '.information', + dataIdAttr: 'data-cid', + ghostClass: 'placeholder', + onAdd: itemDidAdd, + onEnd: itemDragDidEnd, + }); + }); + } + } + + return Categories; }); diff --git a/public/src/admin/manage/category-analytics.js b/public/src/admin/manage/category-analytics.js index f9694e0..4ff31d7 100644 --- a/public/src/admin/manage/category-analytics.js +++ b/public/src/admin/manage/category-analytics.js @@ -1,173 +1,168 @@ 'use strict'; +define('admin/manage/category-analytics', ['Chart'], Chart => { + const CategoryAnalytics = {}; -define('admin/manage/category-analytics', ['Chart'], function (Chart) { - const CategoryAnalytics = {}; + CategoryAnalytics.init = function () { + const hourlyCanvas = document.querySelector('#pageviews:hourly'); + const dailyCanvas = document.querySelector('#pageviews:daily'); + const topicsCanvas = document.querySelector('#topics:daily'); + const postsCanvas = document.querySelector('#posts:daily'); + const hourlyLabels = utils.getHoursArray().map((text, index) => index % 3 ? '' : text); + const dailyLabels = utils.getDaysArray().map((text, index) => index % 3 ? '' : text); - CategoryAnalytics.init = function () { - const hourlyCanvas = document.getElementById('pageviews:hourly'); - const dailyCanvas = document.getElementById('pageviews:daily'); - const topicsCanvas = document.getElementById('topics:daily'); - const postsCanvas = document.getElementById('posts:daily'); - const hourlyLabels = utils.getHoursArray().map(function (text, idx) { - return idx % 3 ? '' : text; - }); - const dailyLabels = utils.getDaysArray().map(function (text, idx) { - return idx % 3 ? '' : text; - }); + if (utils.isMobile()) { + Chart.defaults.global.tooltips.enabled = false; + } - if (utils.isMobile()) { - Chart.defaults.global.tooltips.enabled = false; - } + const data = { + 'pageviews:hourly': { + labels: hourlyLabels, + datasets: [ + { + label: '', + backgroundColor: 'rgba(186,139,175,0.2)', + borderColor: 'rgba(186,139,175,1)', + pointBackgroundColor: 'rgba(186,139,175,1)', + pointHoverBackgroundColor: '#fff', + pointBorderColor: '#fff', + pointHoverBorderColor: 'rgba(186,139,175,1)', + data: ajaxify.data.analytics['pageviews:hourly'], + }, + ], + }, + 'pageviews:daily': { + labels: dailyLabels, + datasets: [ + { + label: '', + backgroundColor: 'rgba(151,187,205,0.2)', + borderColor: 'rgba(151,187,205,1)', + pointBackgroundColor: 'rgba(151,187,205,1)', + pointHoverBackgroundColor: '#fff', + pointBorderColor: '#fff', + pointHoverBorderColor: 'rgba(151,187,205,1)', + data: ajaxify.data.analytics['pageviews:daily'], + }, + ], + }, + 'topics:daily': { + labels: dailyLabels.slice(-7), + datasets: [ + { + label: '', + backgroundColor: 'rgba(171,70,66,0.2)', + borderColor: 'rgba(171,70,66,1)', + pointBackgroundColor: 'rgba(171,70,66,1)', + pointHoverBackgroundColor: '#fff', + pointBorderColor: '#fff', + pointHoverBorderColor: 'rgba(171,70,66,1)', + data: ajaxify.data.analytics['topics:daily'], + }, + ], + }, + 'posts:daily': { + labels: dailyLabels.slice(-7), + datasets: [ + { + label: '', + backgroundColor: 'rgba(161,181,108,0.2)', + borderColor: 'rgba(161,181,108,1)', + pointBackgroundColor: 'rgba(161,181,108,1)', + pointHoverBackgroundColor: '#fff', + pointBorderColor: '#fff', + pointHoverBorderColor: 'rgba(161,181,108,1)', + data: ajaxify.data.analytics['posts:daily'], + }, + ], + }, + }; - const data = { - 'pageviews:hourly': { - labels: hourlyLabels, - datasets: [ - { - label: '', - backgroundColor: 'rgba(186,139,175,0.2)', - borderColor: 'rgba(186,139,175,1)', - pointBackgroundColor: 'rgba(186,139,175,1)', - pointHoverBackgroundColor: '#fff', - pointBorderColor: '#fff', - pointHoverBorderColor: 'rgba(186,139,175,1)', - data: ajaxify.data.analytics['pageviews:hourly'], - }, - ], - }, - 'pageviews:daily': { - labels: dailyLabels, - datasets: [ - { - label: '', - backgroundColor: 'rgba(151,187,205,0.2)', - borderColor: 'rgba(151,187,205,1)', - pointBackgroundColor: 'rgba(151,187,205,1)', - pointHoverBackgroundColor: '#fff', - pointBorderColor: '#fff', - pointHoverBorderColor: 'rgba(151,187,205,1)', - data: ajaxify.data.analytics['pageviews:daily'], - }, - ], - }, - 'topics:daily': { - labels: dailyLabels.slice(-7), - datasets: [ - { - label: '', - backgroundColor: 'rgba(171,70,66,0.2)', - borderColor: 'rgba(171,70,66,1)', - pointBackgroundColor: 'rgba(171,70,66,1)', - pointHoverBackgroundColor: '#fff', - pointBorderColor: '#fff', - pointHoverBorderColor: 'rgba(171,70,66,1)', - data: ajaxify.data.analytics['topics:daily'], - }, - ], - }, - 'posts:daily': { - labels: dailyLabels.slice(-7), - datasets: [ - { - label: '', - backgroundColor: 'rgba(161,181,108,0.2)', - borderColor: 'rgba(161,181,108,1)', - pointBackgroundColor: 'rgba(161,181,108,1)', - pointHoverBackgroundColor: '#fff', - pointBorderColor: '#fff', - pointHoverBorderColor: 'rgba(161,181,108,1)', - data: ajaxify.data.analytics['posts:daily'], - }, - ], - }, - }; + hourlyCanvas.width = $(hourlyCanvas).parent().width(); + dailyCanvas.width = $(dailyCanvas).parent().width(); + topicsCanvas.width = $(topicsCanvas).parent().width(); + postsCanvas.width = $(postsCanvas).parent().width(); - hourlyCanvas.width = $(hourlyCanvas).parent().width(); - dailyCanvas.width = $(dailyCanvas).parent().width(); - topicsCanvas.width = $(topicsCanvas).parent().width(); - postsCanvas.width = $(postsCanvas).parent().width(); + new Chart(hourlyCanvas.getContext('2d'), { + type: 'line', + data: data['pageviews:hourly'], + options: { + responsive: true, + animation: false, + legend: { + display: false, + }, + scales: { + yAxes: [{ + ticks: { + beginAtZero: true, + precision: 0, + }, + }], + }, + }, + }); - new Chart(hourlyCanvas.getContext('2d'), { - type: 'line', - data: data['pageviews:hourly'], - options: { - responsive: true, - animation: false, - legend: { - display: false, - }, - scales: { - yAxes: [{ - ticks: { - beginAtZero: true, - precision: 0, - }, - }], - }, - }, - }); + new Chart(dailyCanvas.getContext('2d'), { + type: 'line', + data: data['pageviews:daily'], + options: { + responsive: true, + animation: false, + legend: { + display: false, + }, + scales: { + yAxes: [{ + ticks: { + beginAtZero: true, + precision: 0, + }, + }], + }, + }, + }); - new Chart(dailyCanvas.getContext('2d'), { - type: 'line', - data: data['pageviews:daily'], - options: { - responsive: true, - animation: false, - legend: { - display: false, - }, - scales: { - yAxes: [{ - ticks: { - beginAtZero: true, - precision: 0, - }, - }], - }, - }, - }); + new Chart(topicsCanvas.getContext('2d'), { + type: 'line', + data: data['topics:daily'], + options: { + responsive: true, + animation: false, + legend: { + display: false, + }, + scales: { + yAxes: [{ + ticks: { + beginAtZero: true, + precision: 0, + }, + }], + }, + }, + }); - new Chart(topicsCanvas.getContext('2d'), { - type: 'line', - data: data['topics:daily'], - options: { - responsive: true, - animation: false, - legend: { - display: false, - }, - scales: { - yAxes: [{ - ticks: { - beginAtZero: true, - precision: 0, - }, - }], - }, - }, - }); + new Chart(postsCanvas.getContext('2d'), { + type: 'line', + data: data['posts:daily'], + options: { + responsive: true, + animation: false, + legend: { + display: false, + }, + scales: { + yAxes: [{ + ticks: { + beginAtZero: true, + precision: 0, + }, + }], + }, + }, + }); + }; - new Chart(postsCanvas.getContext('2d'), { - type: 'line', - data: data['posts:daily'], - options: { - responsive: true, - animation: false, - legend: { - display: false, - }, - scales: { - yAxes: [{ - ticks: { - beginAtZero: true, - precision: 0, - }, - }], - }, - }, - }); - }; - - return CategoryAnalytics; + return CategoryAnalytics; }); diff --git a/public/src/admin/manage/category.js b/public/src/admin/manage/category.js index d5b14e8..d248c70 100644 --- a/public/src/admin/manage/category.js +++ b/public/src/admin/manage/category.js @@ -1,310 +1,315 @@ 'use strict'; define('admin/manage/category', [ - 'uploader', - 'iconSelect', - 'categorySelector', - 'benchpress', - 'api', - 'bootbox', - 'alerts', -], function (uploader, iconSelect, categorySelector, Benchpress, api, bootbox, alerts) { - const Category = {}; - let updateHash = {}; - - Category.init = function () { - $('#category-settings select').each(function () { - const $this = $(this); - $this.val($this.attr('data-value')); - }); - - categorySelector.init($('[component="category-selector"]'), { - onSelect: function (selectedCategory) { - ajaxify.go('admin/manage/categories/' + selectedCategory.cid); - }, - showLinks: true, - }); - - handleTags(); - - $('#category-settings input, #category-settings select, #category-settings textarea').on('change', function (ev) { - modified(ev.target); - }); - - $('[data-name="imageClass"]').on('change', function () { - $('.category-preview').css('background-size', $(this).val()); - }); - - $('[data-name="bgColor"], [data-name="color"]').on('input', function () { - const $inputEl = $(this); - const previewEl = $inputEl.parents('[data-cid]').find('.category-preview'); - if ($inputEl.attr('data-name') === 'bgColor') { - previewEl.css('background-color', $inputEl.val()); - } else if ($inputEl.attr('data-name') === 'color') { - previewEl.css('color', $inputEl.val()); - } - - modified($inputEl[0]); - }); - - $('#save').on('click', function () { - const tags = $('#tag-whitelist').val() ? $('#tag-whitelist').val().split(',') : []; - if (tags.length && tags.length < parseInt($('#cid-min-tags').val(), 10)) { - return alerts.error('[[admin/manage/categories:alert.not-enough-whitelisted-tags]]'); - } - - const cid = ajaxify.data.category.cid; - api.put('/categories/' + cid, updateHash).then((res) => { - app.flags._unsaved = false; - alerts.alert({ - title: 'Updated Categories', - message: 'Category "' + res.name + '" was successfully updated.', - type: 'success', - timeout: 5000, - }); - updateHash = {}; - }).catch(alerts.error); - - return false; - }); - - $('.purge').on('click', function (e) { - e.preventDefault(); - - Benchpress.render('admin/partials/categories/purge', { - name: ajaxify.data.category.name, - topic_count: ajaxify.data.category.topic_count, - }).then(function (html) { - const modal = bootbox.dialog({ - title: '[[admin/manage/categories:purge]]', - message: html, - size: 'large', - buttons: { - save: { - label: '[[modules:bootbox.confirm]]', - className: 'btn-primary', - callback: function () { - modal.find('.modal-footer button').prop('disabled', true); - - const intervalId = setInterval(function () { - socket.emit('categories.getTopicCount', ajaxify.data.category.cid, function (err, count) { - if (err) { - return alerts.error(err); - } - - let percent = 0; - if (ajaxify.data.category.topic_count > 0) { - percent = - Math.max(0, (1 - (count / ajaxify.data.category.topic_count))) * 100; - } - - modal.find('.progress-bar').css({ width: percent + '%' }); - }); - }, 1000); - - api.del('/categories/' + ajaxify.data.category.cid).then(() => { - if (intervalId) { - clearInterval(intervalId); - } - modal.modal('hide'); - alerts.success('[[admin/manage/categories:alert.purge-success]]'); - ajaxify.go('admin/manage/categories'); - }).catch(alerts.error); - - return false; - }, - }, - }, - }); - }); - }); - - $('.copy-settings').on('click', function () { - Benchpress.render('admin/partials/categories/copy-settings', {}).then(function (html) { - let selectedCid; - const modal = bootbox.dialog({ - title: '[[modules:composer.select_category]]', - message: html, - buttons: { - save: { - label: '[[modules:bootbox.confirm]]', - className: 'btn-primary', - callback: function () { - if (!selectedCid || - parseInt(selectedCid, 10) === parseInt(ajaxify.data.category.cid, 10)) { - return; - } - - socket.emit('admin.categories.copySettingsFrom', { - fromCid: selectedCid, - toCid: ajaxify.data.category.cid, - copyParent: modal.find('#copyParent').prop('checked'), - }, function (err) { - if (err) { - return alerts.error(err); - } - - modal.modal('hide'); - alert.success('[[admin/manage/categories:alert.copy-success]]'); - ajaxify.refresh(); - }); - return false; - }, - }, - }, - }); - modal.find('.modal-footer button').prop('disabled', true); - categorySelector.init(modal.find('[component="category-selector"]'), { - onSelect: function (selectedCategory) { - selectedCid = selectedCategory && selectedCategory.cid; - if (selectedCid) { - modal.find('.modal-footer button').prop('disabled', false); - } - }, - showLinks: true, - }); - }); - return false; - }); - - $('.upload-button').on('click', function () { - const inputEl = $(this); - const cid = inputEl.attr('data-cid'); - - uploader.show({ - title: '[[admin/manage/categories:alert.upload-image]]', - route: config.relative_path + '/api/admin/category/uploadpicture', - params: { cid: cid }, - }, function (imageUrlOnServer) { - $('#category-image').val(imageUrlOnServer); - const previewBox = inputEl.parent().parent().siblings('.category-preview'); - previewBox.css('background', 'url(' + imageUrlOnServer + '?' + new Date().getTime() + ')'); - - modified($('#category-image')); - }); - }); - - $('#category-image').on('change', function () { - $('.category-preview').css('background-image', $(this).val() ? ('url("' + $(this).val() + '")') : ''); - modified($('#category-image')); - }); - - $('.delete-image').on('click', function (e) { - e.preventDefault(); - - const inputEl = $('#category-image'); - const previewBox = $('.category-preview'); - - inputEl.val(''); - previewBox.css('background-image', ''); - modified(inputEl[0]); - $(this).parent().addClass('hide').hide(); - }); - - $('.category-preview').on('click', function () { - iconSelect.init($(this).find('i'), modified); - }); - - $('[type="checkbox"]').on('change', function () { - modified($(this)); - }); - - $('button[data-action="setParent"], button[data-action="changeParent"]').on('click', Category.launchParentSelector); - $('button[data-action="removeParent"]').on('click', function () { - api.put('/categories/' + ajaxify.data.category.cid, { - parentCid: 0, - }).then(() => { - $('button[data-action="removeParent"]').parent().addClass('hide'); - $('button[data-action="changeParent"]').parent().addClass('hide'); - $('button[data-action="setParent"]').removeClass('hide'); - }).catch(alerts.error); - }); - $('button[data-action="toggle"]').on('click', function () { - const $this = $(this); - const disabled = $this.attr('data-disabled') === '1'; - api.put('/categories/' + ajaxify.data.category.cid, { - disabled: disabled ? 0 : 1, - }).then(() => { - $this.translateText(!disabled ? '[[admin/manage/categories:enable]]' : '[[admin/manage/categories:disable]]'); - $this.toggleClass('btn-primary', !disabled).toggleClass('btn-danger', disabled); - $this.attr('data-disabled', disabled ? 0 : 1); - }).catch(alerts.error); - }); - }; - - function modified(el) { - let value; - if ($(el).is(':checkbox')) { - value = $(el).is(':checked') ? 1 : 0; - } else { - value = $(el).val(); - } - const dataName = $(el).attr('data-name'); - const fields = dataName.match(/[^\][.]+/g); - - function setNestedFields(obj, index) { - if (index === fields.length) { - return; - } - obj[fields[index]] = obj[fields[index]] || {}; - if (index === fields.length - 1) { - obj[fields[index]] = value; - } - setNestedFields(obj[fields[index]], index + 1); - } - - if (fields && fields.length) { - if (fields.length === 1) { // simple field name ie data-name="name" - updateHash[fields[0]] = value; - } else if (fields.length > 1) { // nested field name ie data-name="name[sub1][sub2]" - setNestedFields(updateHash, 0); - } - } - - app.flags = app.flags || {}; - app.flags._unsaved = true; - } - - function handleTags() { - const tagEl = $('#tag-whitelist'); - tagEl.tagsinput({ - confirmKeys: [13, 44], - trimValue: true, - }); - - ajaxify.data.category.tagWhitelist.forEach(function (tag) { - tagEl.tagsinput('add', tag); - }); - - tagEl.on('itemAdded itemRemoved', function () { - modified(tagEl); - }); - } - - Category.launchParentSelector = function () { - categorySelector.modal({ - onSubmit: function (selectedCategory) { - const parentCid = selectedCategory.cid; - if (!parentCid) { - return; - } - api.put('/categories/' + ajaxify.data.category.cid, { - parentCid: parentCid, - }).then(() => { - api.get(`/categories/${parentCid}`, {}).then(function (parent) { - if (parent && parent.icon && parent.name) { - const buttonHtml = ' ' + parent.name; - $('button[data-action="changeParent"]').html(buttonHtml).parent().removeClass('hide'); - } - }); - - $('button[data-action="removeParent"]').parent().removeClass('hide'); - $('button[data-action="setParent"]').addClass('hide'); - }).catch(alerts.error); - }, - showLinks: true, - }); - }; - - return Category; + 'uploader', + 'iconSelect', + 'categorySelector', + 'benchpress', + 'api', + 'bootbox', + 'alerts', +], (uploader, iconSelect, categorySelector, Benchpress, api, bootbox, alerts) => { + const Category = {}; + let updateHash = {}; + + Category.init = function () { + $('#category-settings select').each(function () { + const $this = $(this); + $this.val($this.attr('data-value')); + }); + + categorySelector.init($('[component="category-selector"]'), { + onSelect(selectedCategory) { + ajaxify.go('admin/manage/categories/' + selectedCategory.cid); + }, + showLinks: true, + }); + + handleTags(); + + $('#category-settings input, #category-settings select, #category-settings textarea').on('change', event => { + modified(event.target); + }); + + $('[data-name="imageClass"]').on('change', function () { + $('.category-preview').css('background-size', $(this).val()); + }); + + $('[data-name="bgColor"], [data-name="color"]').on('input', function () { + const $inputElement = $(this); + const previewElement = $inputElement.parents('[data-cid]').find('.category-preview'); + if ($inputElement.attr('data-name') === 'bgColor') { + previewElement.css('background-color', $inputElement.val()); + } else if ($inputElement.attr('data-name') === 'color') { + previewElement.css('color', $inputElement.val()); + } + + modified($inputElement[0]); + }); + + $('#save').on('click', () => { + const tags = $('#tag-whitelist').val() ? $('#tag-whitelist').val().split(',') : []; + if (tags.length > 0 && tags.length < Number.parseInt($('#cid-min-tags').val(), 10)) { + return alerts.error('[[admin/manage/categories:alert.not-enough-whitelisted-tags]]'); + } + + const cid = ajaxify.data.category.cid; + api.put('/categories/' + cid, updateHash).then(res => { + app.flags._unsaved = false; + alerts.alert({ + title: 'Updated Categories', + message: 'Category "' + res.name + '" was successfully updated.', + type: 'success', + timeout: 5000, + }); + updateHash = {}; + }).catch(alerts.error); + + return false; + }); + + $('.purge').on('click', e => { + e.preventDefault(); + + Benchpress.render('admin/partials/categories/purge', { + name: ajaxify.data.category.name, + topic_count: ajaxify.data.category.topic_count, + }).then(html => { + const modal = bootbox.dialog({ + title: '[[admin/manage/categories:purge]]', + message: html, + size: 'large', + buttons: { + save: { + label: '[[modules:bootbox.confirm]]', + className: 'btn-primary', + callback() { + modal.find('.modal-footer button').prop('disabled', true); + + const intervalId = setInterval(() => { + socket.emit('categories.getTopicCount', ajaxify.data.category.cid, (error, count) => { + if (error) { + return alerts.error(error); + } + + let percent = 0; + if (ajaxify.data.category.topic_count > 0) { + percent + = Math.max(0, (1 - (count / ajaxify.data.category.topic_count))) * 100; + } + + modal.find('.progress-bar').css({width: percent + '%'}); + }); + }, 1000); + + api.del('/categories/' + ajaxify.data.category.cid).then(() => { + if (intervalId) { + clearInterval(intervalId); + } + + modal.modal('hide'); + alerts.success('[[admin/manage/categories:alert.purge-success]]'); + ajaxify.go('admin/manage/categories'); + }).catch(alerts.error); + + return false; + }, + }, + }, + }); + }); + }); + + $('.copy-settings').on('click', () => { + Benchpress.render('admin/partials/categories/copy-settings', {}).then(html => { + let selectedCid; + const modal = bootbox.dialog({ + title: '[[modules:composer.select_category]]', + message: html, + buttons: { + save: { + label: '[[modules:bootbox.confirm]]', + className: 'btn-primary', + callback() { + if (!selectedCid + || Number.parseInt(selectedCid, 10) === Number.parseInt(ajaxify.data.category.cid, 10)) { + return; + } + + socket.emit('admin.categories.copySettingsFrom', { + fromCid: selectedCid, + toCid: ajaxify.data.category.cid, + copyParent: modal.find('#copyParent').prop('checked'), + }, error => { + if (error) { + return alerts.error(error); + } + + modal.modal('hide'); + alert.success('[[admin/manage/categories:alert.copy-success]]'); + ajaxify.refresh(); + }); + return false; + }, + }, + }, + }); + modal.find('.modal-footer button').prop('disabled', true); + categorySelector.init(modal.find('[component="category-selector"]'), { + onSelect(selectedCategory) { + selectedCid = selectedCategory && selectedCategory.cid; + if (selectedCid) { + modal.find('.modal-footer button').prop('disabled', false); + } + }, + showLinks: true, + }); + }); + return false; + }); + + $('.upload-button').on('click', function () { + const inputElement = $(this); + const cid = inputElement.attr('data-cid'); + + uploader.show({ + title: '[[admin/manage/categories:alert.upload-image]]', + route: config.relative_path + '/api/admin/category/uploadpicture', + params: {cid}, + }, imageUrlOnServer => { + $('#category-image').val(imageUrlOnServer); + const previewBox = inputElement.parent().parent().siblings('.category-preview'); + previewBox.css('background', 'url(' + imageUrlOnServer + '?' + Date.now() + ')'); + + modified($('#category-image')); + }); + }); + + $('#category-image').on('change', function () { + $('.category-preview').css('background-image', $(this).val() ? ('url("' + $(this).val() + '")') : ''); + modified($('#category-image')); + }); + + $('.delete-image').on('click', function (e) { + e.preventDefault(); + + const inputElement = $('#category-image'); + const previewBox = $('.category-preview'); + + inputElement.val(''); + previewBox.css('background-image', ''); + modified(inputElement[0]); + $(this).parent().addClass('hide').hide(); + }); + + $('.category-preview').on('click', function () { + iconSelect.init($(this).find('i'), modified); + }); + + $('[type="checkbox"]').on('change', function () { + modified($(this)); + }); + + $('button[data-action="setParent"], button[data-action="changeParent"]').on('click', Category.launchParentSelector); + $('button[data-action="removeParent"]').on('click', () => { + api.put('/categories/' + ajaxify.data.category.cid, { + parentCid: 0, + }).then(() => { + $('button[data-action="removeParent"]').parent().addClass('hide'); + $('button[data-action="changeParent"]').parent().addClass('hide'); + $('button[data-action="setParent"]').removeClass('hide'); + }).catch(alerts.error); + }); + $('button[data-action="toggle"]').on('click', function () { + const $this = $(this); + const disabled = $this.attr('data-disabled') === '1'; + api.put('/categories/' + ajaxify.data.category.cid, { + disabled: disabled ? 0 : 1, + }).then(() => { + $this.translateText(disabled ? '[[admin/manage/categories:disable]]' : '[[admin/manage/categories:enable]]'); + $this.toggleClass('btn-primary', !disabled).toggleClass('btn-danger', disabled); + $this.attr('data-disabled', disabled ? 0 : 1); + }).catch(alerts.error); + }); + }; + + function modified(element) { + let value; + if ($(element).is(':checkbox')) { + value = $(element).is(':checked') ? 1 : 0; + } else { + value = $(element).val(); + } + + const dataName = $(element).attr('data-name'); + const fields = dataName.match(/[^\][.]+/g); + + function setNestedFields(object, index) { + if (index === fields.length) { + return; + } + + object[fields[index]] = object[fields[index]] || {}; + if (index === fields.length - 1) { + object[fields[index]] = value; + } + + setNestedFields(object[fields[index]], index + 1); + } + + if (fields && fields.length > 0) { + if (fields.length === 1) { // Simple field name ie data-name="name" + updateHash[fields[0]] = value; + } else if (fields.length > 1) { // Nested field name ie data-name="name[sub1][sub2]" + setNestedFields(updateHash, 0); + } + } + + app.flags = app.flags || {}; + app.flags._unsaved = true; + } + + function handleTags() { + const tagElement = $('#tag-whitelist'); + tagElement.tagsinput({ + confirmKeys: [13, 44], + trimValue: true, + }); + + for (const tag of ajaxify.data.category.tagWhitelist) { + tagElement.tagsinput('add', tag); + } + + tagElement.on('itemAdded itemRemoved', () => { + modified(tagElement); + }); + } + + Category.launchParentSelector = function () { + categorySelector.modal({ + onSubmit(selectedCategory) { + const parentCid = selectedCategory.cid; + if (!parentCid) { + return; + } + + api.put('/categories/' + ajaxify.data.category.cid, { + parentCid, + }).then(() => { + api.get(`/categories/${parentCid}`, {}).then(parent => { + if (parent && parent.icon && parent.name) { + const buttonHtml = ' ' + parent.name; + $('button[data-action="changeParent"]').html(buttonHtml).parent().removeClass('hide'); + } + }); + + $('button[data-action="removeParent"]').parent().removeClass('hide'); + $('button[data-action="setParent"]').addClass('hide'); + }).catch(alerts.error); + }, + showLinks: true, + }); + }; + + return Category; }); diff --git a/public/src/admin/manage/digest.js b/public/src/admin/manage/digest.js index d8e518c..9076fe9 100644 --- a/public/src/admin/manage/digest.js +++ b/public/src/admin/manage/digest.js @@ -1,45 +1,44 @@ 'use strict'; - -define('admin/manage/digest', ['bootbox', 'alerts'], function (bootbox, alerts) { - const Digest = {}; - - Digest.init = function () { - $('table').on('click', '[data-action]', function () { - const action = this.getAttribute('data-action'); - const uid = this.getAttribute('data-uid'); - - if (action.startsWith('resend-')) { - const interval = action.slice(7); - bootbox.confirm('[[admin/manage/digest:resend-all-confirm]]', function (ok) { - if (ok) { - Digest.send(action, undefined, function (err) { - if (err) { - return alerts.error(err); - } - - alerts.success('[[admin/manage/digest:resent-' + interval + ']]'); - }); - } - }); - } else { - Digest.send(action, uid, function (err) { - if (err) { - return alerts.error(err); - } - - alerts.success('[[admin/manage/digest:resent-single]]'); - }); - } - }); - }; - - Digest.send = function (action, uid, callback) { - socket.emit('admin.digest.resend', { - action: action, - uid: uid, - }, callback); - }; - - return Digest; +define('admin/manage/digest', ['bootbox', 'alerts'], (bootbox, alerts) => { + const Digest = {}; + + Digest.init = function () { + $('table').on('click', '[data-action]', function () { + const action = this.dataset.action; + const uid = this.dataset.uid; + + if (action.startsWith('resend-')) { + const interval = action.slice(7); + bootbox.confirm('[[admin/manage/digest:resend-all-confirm]]', ok => { + if (ok) { + Digest.send(action, undefined, error => { + if (error) { + return alerts.error(error); + } + + alerts.success('[[admin/manage/digest:resent-' + interval + ']]'); + }); + } + }); + } else { + Digest.send(action, uid, error => { + if (error) { + return alerts.error(error); + } + + alerts.success('[[admin/manage/digest:resent-single]]'); + }); + } + }); + }; + + Digest.send = function (action, uid, callback) { + socket.emit('admin.digest.resend', { + action, + uid, + }, callback); + }; + + return Digest; }); diff --git a/public/src/admin/manage/group.js b/public/src/admin/manage/group.js index 6104778..1119b8d 100644 --- a/public/src/admin/manage/group.js +++ b/public/src/admin/manage/group.js @@ -1,165 +1,173 @@ 'use strict'; define('admin/manage/group', [ - 'forum/groups/memberlist', - 'iconSelect', - 'translator', - 'categorySelector', - 'groupSearch', - 'slugify', - 'api', - 'bootbox', - 'alerts', -], function (memberList, iconSelect, translator, categorySelector, groupSearch, slugify, api, bootbox, alerts) { - const Groups = {}; - - Groups.init = function () { - const groupIcon = $('#group-icon'); - const changeGroupUserTitle = $('#change-group-user-title'); - const changeGroupLabelColor = $('#change-group-label-color'); - const changeGroupTextColor = $('#change-group-text-color'); - const groupLabelPreview = $('#group-label-preview'); - const groupLabelPreviewText = $('#group-label-preview-text'); - - const groupName = ajaxify.data.group.name; - - $('#group-selector').on('change', function () { - ajaxify.go('admin/manage/groups/' + $(this).val() + window.location.hash); - }); - - memberList.init('admin/manage/group'); - - changeGroupUserTitle.on('keyup', function () { - groupLabelPreviewText.translateText(changeGroupUserTitle.val()); - }); - - changeGroupLabelColor.on('keyup input', function () { - groupLabelPreview.css('background-color', changeGroupLabelColor.val() || '#000000'); - }); - - changeGroupTextColor.on('keyup input', function () { - groupLabelPreview.css('color', changeGroupTextColor.val() || '#ffffff'); - }); - - setupGroupMembersMenu(); - - $('#group-icon, #group-icon-label').on('click', function () { - const currentIcon = groupIcon.attr('value'); - iconSelect.init(groupIcon, function () { - let newIcon = groupIcon.attr('value'); - if (newIcon === currentIcon) { - return; - } - if (newIcon === 'fa-nbb-none') { - newIcon = 'hidden'; - } - $('#group-icon-preview').attr('class', 'fa fa-fw ' + (newIcon || 'hidden')); - app.flags = app.flags || {}; - app.flags._unsaved = true; - }); - }); - - categorySelector.init($('.edit-privileges-selector [component="category-selector"]'), { - onSelect: function (selectedCategory) { - navigateToCategory(selectedCategory.cid); - }, - showLinks: true, - }); - - const cidSelector = categorySelector.init($('.member-post-cids-selector [component="category-selector"]'), { - onSelect: function (selectedCategory) { - let cids = ($('#memberPostCids').val() || '').split(',').map(cid => parseInt(cid, 10)); - cids.push(selectedCategory.cid); - cids = cids.filter((cid, index, array) => array.indexOf(cid) === index); - $('#memberPostCids').val(cids.join(',')); - cidSelector.selectCategory(0); - }, - }); - - groupSearch.init($('[component="group-selector"]')); - - $('form [data-property]').on('change', function () { - app.flags = app.flags || {}; - app.flags._unsaved = true; - }); - - $('#save').on('click', function () { - api.put(`/groups/${slugify(groupName)}`, { - name: $('#change-group-name').val(), - userTitle: changeGroupUserTitle.val(), - description: $('#change-group-desc').val(), - icon: groupIcon.attr('value'), - labelColor: changeGroupLabelColor.val(), - textColor: changeGroupTextColor.val(), - userTitleEnabled: $('#group-userTitleEnabled').is(':checked'), - private: $('#group-private').is(':checked'), - hidden: $('#group-hidden').is(':checked'), - memberPostCids: $('#memberPostCids').val(), - disableJoinRequests: $('#group-disableJoinRequests').is(':checked'), - disableLeave: $('#group-disableLeave').is(':checked'), - }).then(() => { - const newName = $('#change-group-name').val(); - - // If the group name changed, change url - if (groupName !== newName) { - ajaxify.go('admin/manage/groups/' + encodeURIComponent(newName), undefined, true); - } - - alerts.success('[[admin/manage/groups:edit.save-success]]'); - }).catch(alerts.error); - return false; - }); - }; - - function setupGroupMembersMenu() { - $('[component="groups/members"]').on('click', '[data-action]', function () { - const btnEl = $(this); - const userRow = btnEl.parents('[data-uid]'); - const ownerFlagEl = userRow.find('.member-name .user-owner-icon'); - const isOwner = !ownerFlagEl.hasClass('invisible'); - const uid = userRow.attr('data-uid'); - const action = btnEl.attr('data-action'); - - switch (action) { - case 'toggleOwnership': - api[isOwner ? 'del' : 'put'](`/groups/${ajaxify.data.group.slug}/ownership/${uid}`, {}).then(() => { - ownerFlagEl.toggleClass('invisible'); - }).catch(alerts.error); - break; - - case 'kick': - bootbox.confirm('[[admin/manage/groups:edit.confirm-remove-user]]', function (confirm) { - if (!confirm) { - return; - } - api.del('/groups/' + ajaxify.data.group.slug + '/membership/' + uid).then(() => { - userRow.slideUp().remove(); - }).catch(alerts.error); - }); - break; - default: - break; - } - }); - } - - function navigateToCategory(cid) { - if (cid) { - const url = 'admin/manage/privileges/' + cid + '?group=' + ajaxify.data.group.nameEncoded; - if (app.flags && app.flags._unsaved === true) { - translator.translate('[[global:unsaved-changes]]', function (text) { - bootbox.confirm(text, function (navigate) { - if (navigate) { - app.flags._unsaved = false; - ajaxify.go(url); - } - }); - }); - return; - } - ajaxify.go(url); - } - } - - return Groups; + 'forum/groups/memberlist', + 'iconSelect', + 'translator', + 'categorySelector', + 'groupSearch', + 'slugify', + 'api', + 'bootbox', + 'alerts', +], (memberList, iconSelect, translator, categorySelector, groupSearch, slugify, api, bootbox, alerts) => { + const Groups = {}; + + Groups.init = function () { + const groupIcon = $('#group-icon'); + const changeGroupUserTitle = $('#change-group-user-title'); + const changeGroupLabelColor = $('#change-group-label-color'); + const changeGroupTextColor = $('#change-group-text-color'); + const groupLabelPreview = $('#group-label-preview'); + const groupLabelPreviewText = $('#group-label-preview-text'); + + const groupName = ajaxify.data.group.name; + + $('#group-selector').on('change', function () { + ajaxify.go('admin/manage/groups/' + $(this).val() + window.location.hash); + }); + + memberList.init('admin/manage/group'); + + changeGroupUserTitle.on('keyup', () => { + groupLabelPreviewText.translateText(changeGroupUserTitle.val()); + }); + + changeGroupLabelColor.on('keyup input', () => { + groupLabelPreview.css('background-color', changeGroupLabelColor.val() || '#000000'); + }); + + changeGroupTextColor.on('keyup input', () => { + groupLabelPreview.css('color', changeGroupTextColor.val() || '#ffffff'); + }); + + setupGroupMembersMenu(); + + $('#group-icon, #group-icon-label').on('click', () => { + const currentIcon = groupIcon.attr('value'); + iconSelect.init(groupIcon, () => { + let newIcon = groupIcon.attr('value'); + if (newIcon === currentIcon) { + return; + } + + if (newIcon === 'fa-nbb-none') { + newIcon = 'hidden'; + } + + $('#group-icon-preview').attr('class', 'fa fa-fw ' + (newIcon || 'hidden')); + app.flags = app.flags || {}; + app.flags._unsaved = true; + }); + }); + + categorySelector.init($('.edit-privileges-selector [component="category-selector"]'), { + onSelect(selectedCategory) { + navigateToCategory(selectedCategory.cid); + }, + showLinks: true, + }); + + const cidSelector = categorySelector.init($('.member-post-cids-selector [component="category-selector"]'), { + onSelect(selectedCategory) { + let cids = ($('#memberPostCids').val() || '').split(',').map(cid => Number.parseInt(cid, 10)); + cids.push(selectedCategory.cid); + cids = cids.filter((cid, index, array) => array.indexOf(cid) === index); + $('#memberPostCids').val(cids.join(',')); + cidSelector.selectCategory(0); + }, + }); + + groupSearch.init($('[component="group-selector"]')); + + $('form [data-property]').on('change', () => { + app.flags = app.flags || {}; + app.flags._unsaved = true; + }); + + $('#save').on('click', () => { + api.put(`/groups/${slugify(groupName)}`, { + name: $('#change-group-name').val(), + userTitle: changeGroupUserTitle.val(), + description: $('#change-group-desc').val(), + icon: groupIcon.attr('value'), + labelColor: changeGroupLabelColor.val(), + textColor: changeGroupTextColor.val(), + userTitleEnabled: $('#group-userTitleEnabled').is(':checked'), + private: $('#group-private').is(':checked'), + hidden: $('#group-hidden').is(':checked'), + memberPostCids: $('#memberPostCids').val(), + disableJoinRequests: $('#group-disableJoinRequests').is(':checked'), + disableLeave: $('#group-disableLeave').is(':checked'), + }).then(() => { + const newName = $('#change-group-name').val(); + + // If the group name changed, change url + if (groupName !== newName) { + ajaxify.go('admin/manage/groups/' + encodeURIComponent(newName), undefined, true); + } + + alerts.success('[[admin/manage/groups:edit.save-success]]'); + }).catch(alerts.error); + return false; + }); + }; + + function setupGroupMembersMenu() { + $('[component="groups/members"]').on('click', '[data-action]', function () { + const buttonElement = $(this); + const userRow = buttonElement.parents('[data-uid]'); + const ownerFlagElement = userRow.find('.member-name .user-owner-icon'); + const isOwner = !ownerFlagElement.hasClass('invisible'); + const uid = userRow.attr('data-uid'); + const action = buttonElement.attr('data-action'); + + switch (action) { + case 'toggleOwnership': { + api[isOwner ? 'del' : 'put'](`/groups/${ajaxify.data.group.slug}/ownership/${uid}`, {}).then(() => { + ownerFlagElement.toggleClass('invisible'); + }).catch(alerts.error); + break; + } + + case 'kick': { + bootbox.confirm('[[admin/manage/groups:edit.confirm-remove-user]]', confirm => { + if (!confirm) { + return; + } + + api.del('/groups/' + ajaxify.data.group.slug + '/membership/' + uid).then(() => { + userRow.slideUp().remove(); + }).catch(alerts.error); + }); + break; + } + + default: { + break; + } + } + }); + } + + function navigateToCategory(cid) { + if (cid) { + const url = 'admin/manage/privileges/' + cid + '?group=' + ajaxify.data.group.nameEncoded; + if (app.flags && app.flags._unsaved === true) { + translator.translate('[[global:unsaved-changes]]', text => { + bootbox.confirm(text, navigate => { + if (navigate) { + app.flags._unsaved = false; + ajaxify.go(url); + } + }); + }); + return; + } + + ajaxify.go(url); + } + } + + return Groups; }); diff --git a/public/src/admin/manage/groups.js b/public/src/admin/manage/groups.js index 5015416..51500ce 100644 --- a/public/src/admin/manage/groups.js +++ b/public/src/admin/manage/groups.js @@ -1,122 +1,124 @@ 'use strict'; define('admin/manage/groups', [ - 'categorySelector', - 'slugify', - 'api', - 'bootbox', - 'alerts', -], function (categorySelector, slugify, api, bootbox, alerts) { - const Groups = {}; - - Groups.init = function () { - const createModal = $('#create-modal'); - const createGroupName = $('#create-group-name'); - const createModalGo = $('#create-modal-go'); - const createModalError = $('#create-modal-error'); - - handleSearch(); - - createModal.on('keypress', function (e) { - if (e.keyCode === 13) { - createModalGo.click(); - } - }); - - $('#create').on('click', function () { - createModal.modal('show'); - setTimeout(function () { - createGroupName.focus(); - }, 250); - }); - - createModalGo.on('click', function () { - const submitObj = { - name: createGroupName.val(), - description: $('#create-group-desc').val(), - private: $('#create-group-private').is(':checked') ? 1 : 0, - hidden: $('#create-group-hidden').is(':checked') ? 1 : 0, - }; - - api.post('/groups', submitObj).then((response) => { - createModalError.addClass('hide'); - createGroupName.val(''); - createModal.on('hidden.bs.modal', function () { - ajaxify.go('admin/manage/groups/' + response.name); - }); - createModal.modal('hide'); - }).catch((err) => { - if (!utils.hasLanguageKey(err.status.message)) { - err.status.message = '[[admin/manage/groups:alerts.create-failure]]'; - } - createModalError.translateHtml(err.status.message).removeClass('hide'); - }); - }); - - $('.groups-list').on('click', '[data-action]', function () { - const el = $(this); - const action = el.attr('data-action'); - const groupName = el.parents('tr[data-groupname]').attr('data-groupname'); - - switch (action) { - case 'delete': - bootbox.confirm('[[admin/manage/groups:alerts.confirm-delete]]', function (confirm) { - if (confirm) { - api.del(`/groups/${slugify(groupName)}`, {}).then(ajaxify.refresh).catch(alerts.error); - } - }); - break; - } - }); - - enableCategorySelectors(); - }; - - function enableCategorySelectors() { - $('.groups-list [component="category-selector"]').each(function () { - const nameEncoded = $(this).parents('[data-name-encoded]').attr('data-name-encoded'); - categorySelector.init($(this), { - onSelect: function (selectedCategory) { - ajaxify.go('admin/manage/privileges/' + selectedCategory.cid + '?group=' + nameEncoded); - }, - showLinks: true, - }); - }); - } - - function handleSearch() { - const queryEl = $('#group-search'); - - function doSearch() { - if (!queryEl.val()) { - return ajaxify.refresh(); - } - $('.pagination').addClass('hide'); - const groupsEl = $('.groups-list'); - socket.emit('groups.search', { - query: queryEl.val(), - options: { - sort: 'date', - }, - }, function (err, groups) { - if (err) { - return alerts.error(err); - } - - app.parseAndTranslate('admin/manage/groups', 'groups', { - groups: groups, - categories: ajaxify.data.categories, - }, function (html) { - groupsEl.find('[data-groupname]').remove(); - groupsEl.find('tbody').append(html); - enableCategorySelectors(); - }); - }); - } - - queryEl.on('keyup', utils.debounce(doSearch, 200)); - } - - - return Groups; + 'categorySelector', + 'slugify', + 'api', + 'bootbox', + 'alerts', +], (categorySelector, slugify, api, bootbox, alerts) => { + const Groups = {}; + + Groups.init = function () { + const createModal = $('#create-modal'); + const createGroupName = $('#create-group-name'); + const createModalGo = $('#create-modal-go'); + const createModalError = $('#create-modal-error'); + + handleSearch(); + + createModal.on('keypress', e => { + if (e.keyCode === 13) { + createModalGo.click(); + } + }); + + $('#create').on('click', () => { + createModal.modal('show'); + setTimeout(() => { + createGroupName.focus(); + }, 250); + }); + + createModalGo.on('click', () => { + const submitObject = { + name: createGroupName.val(), + description: $('#create-group-desc').val(), + private: $('#create-group-private').is(':checked') ? 1 : 0, + hidden: $('#create-group-hidden').is(':checked') ? 1 : 0, + }; + + api.post('/groups', submitObject).then(response => { + createModalError.addClass('hide'); + createGroupName.val(''); + createModal.on('hidden.bs.modal', () => { + ajaxify.go('admin/manage/groups/' + response.name); + }); + createModal.modal('hide'); + }).catch(error => { + if (!utils.hasLanguageKey(error.status.message)) { + error.status.message = '[[admin/manage/groups:alerts.create-failure]]'; + } + + createModalError.translateHtml(error.status.message).removeClass('hide'); + }); + }); + + $('.groups-list').on('click', '[data-action]', function () { + const element = $(this); + const action = element.attr('data-action'); + const groupName = element.parents('tr[data-groupname]').attr('data-groupname'); + + switch (action) { + case 'delete': { + bootbox.confirm('[[admin/manage/groups:alerts.confirm-delete]]', confirm => { + if (confirm) { + api.del(`/groups/${slugify(groupName)}`, {}).then(ajaxify.refresh).catch(alerts.error); + } + }); + break; + } + } + }); + + enableCategorySelectors(); + }; + + function enableCategorySelectors() { + $('.groups-list [component="category-selector"]').each(function () { + const nameEncoded = $(this).parents('[data-name-encoded]').attr('data-name-encoded'); + categorySelector.init($(this), { + onSelect(selectedCategory) { + ajaxify.go('admin/manage/privileges/' + selectedCategory.cid + '?group=' + nameEncoded); + }, + showLinks: true, + }); + }); + } + + function handleSearch() { + const queryElement = $('#group-search'); + + function doSearch() { + if (!queryElement.val()) { + return ajaxify.refresh(); + } + + $('.pagination').addClass('hide'); + const groupsElement = $('.groups-list'); + socket.emit('groups.search', { + query: queryElement.val(), + options: { + sort: 'date', + }, + }, (error, groups) => { + if (error) { + return alerts.error(error); + } + + app.parseAndTranslate('admin/manage/groups', 'groups', { + groups, + categories: ajaxify.data.categories, + }, html => { + groupsElement.find('[data-groupname]').remove(); + groupsElement.find('tbody').append(html); + enableCategorySelectors(); + }); + }); + } + + queryElement.on('keyup', utils.debounce(doSearch, 200)); + } + + return Groups; }); diff --git a/public/src/admin/manage/privileges.js b/public/src/admin/manage/privileges.js index 38f9792..26fd385 100644 --- a/public/src/admin/manage/privileges.js +++ b/public/src/admin/manage/privileges.js @@ -1,501 +1,517 @@ 'use strict'; define('admin/manage/privileges', [ - 'api', - 'autocomplete', - 'bootbox', - 'alerts', - 'translator', - 'categorySelector', - 'mousetrap', - 'admin/modules/checkboxRowSelector', -], function (api, autocomplete, bootbox, alerts, translator, categorySelector, mousetrap, checkboxRowSelector) { - const Privileges = {}; - - let cid; - // number of columns to skip in category privilege tables - const SKIP_PRIV_COLS = 3; - - Privileges.init = function () { - cid = isNaN(parseInt(ajaxify.data.selectedCategory.cid, 10)) ? 'admin' : ajaxify.data.selectedCategory.cid; - - checkboxRowSelector.init('.privilege-table-container'); - - categorySelector.init($('[component="category-selector"]'), { - onSelect: function (category) { - cid = parseInt(category.cid, 10); - cid = isNaN(cid) ? 'admin' : cid; - Privileges.refreshPrivilegeTable(); - ajaxify.updateHistory('admin/manage/privileges/' + (cid || '')); - }, - localCategories: ajaxify.data.categories, - privilege: 'find', - showLinks: true, - }); - - Privileges.setupPrivilegeTable(); - - highlightRow(); - $('.privilege-filters button:last-child').click(); - }; - - Privileges.setupPrivilegeTable = function () { - $('.privilege-table-container').on('change', 'input[type="checkbox"]:not(.checkbox-helper)', function () { - const $checkboxEl = $(this); - const $wrapperEl = $checkboxEl.parent(); - const columnNo = $wrapperEl.index() + 1; - const privilege = $wrapperEl.attr('data-privilege'); - const state = $checkboxEl.prop('checked'); - const $rowEl = $checkboxEl.parents('tr'); - const member = $rowEl.attr('data-group-name') || $rowEl.attr('data-uid'); - const isPrivate = parseInt($rowEl.attr('data-private') || 0, 10); - const isGroup = $rowEl.attr('data-group-name') !== undefined; - const isBanned = (isGroup && $rowEl.attr('data-group-name') === 'banned-users') || $rowEl.attr('data-banned') !== undefined; - const sourceGroupName = isBanned ? 'banned-users' : 'registered-users'; - const delta = $checkboxEl.prop('checked') === ($wrapperEl.attr('data-value') === 'true') ? null : state; - - if (member) { - if (isGroup && privilege === 'groups:moderate' && !isPrivate && state) { - bootbox.confirm('[[admin/manage/privileges:alert.confirm-moderate]]', function (confirm) { - if (confirm) { - $wrapperEl.attr('data-delta', delta); - Privileges.exposeSingleAssumedPriv(columnNo, sourceGroupName); - } else { - $checkboxEl.prop('checked', !$checkboxEl.prop('checked')); - } - }); - } else if (privilege.endsWith('admin:admins-mods') && state) { - bootbox.confirm('[[admin/manage/privileges:alert.confirm-admins-mods]]', function (confirm) { - if (confirm) { - $wrapperEl.attr('data-delta', delta); - Privileges.exposeSingleAssumedPriv(columnNo, sourceGroupName); - } else { - $checkboxEl.prop('checked', !$checkboxEl.prop('checked')); - } - }); - } else { - $wrapperEl.attr('data-delta', delta); - Privileges.exposeSingleAssumedPriv(columnNo, sourceGroupName); - } - checkboxRowSelector.updateState($checkboxEl); - } else { - alerts.error('[[error:invalid-data]]'); - } - }); - - Privileges.exposeAssumedPrivileges(); - checkboxRowSelector.updateAll(); - Privileges.addEvents(); // events with confirmation modals - }; - - Privileges.addEvents = function () { - document.getElementById('save').addEventListener('click', function () { - throwConfirmModal('save', Privileges.commit); - }); - - document.getElementById('discard').addEventListener('click', function () { - throwConfirmModal('discard', Privileges.discard); - }); - - // Expose discard button as necessary - const containerEl = document.querySelector('.privilege-table-container'); - containerEl.addEventListener('change', (e) => { - const subselector = e.target.closest('td[data-privilege] input'); - if (subselector) { - document.getElementById('discard').style.display = containerEl.querySelectorAll('td[data-delta]').length ? 'unset' : 'none'; - } - }); - - const $privTableCon = $('.privilege-table-container'); - $privTableCon.on('click', '[data-action="search.user"]', Privileges.addUserToPrivilegeTable); - $privTableCon.on('click', '[data-action="search.group"]', Privileges.addGroupToPrivilegeTable); - $privTableCon.on('click', '[data-action="copyToChildren"]', function () { - throwConfirmModal('copyToChildren', Privileges.copyPrivilegesToChildren.bind(null, cid, '')); - }); - $privTableCon.on('click', '[data-action="copyToChildrenGroup"]', function () { - const groupName = $(this).parents('[data-group-name]').attr('data-group-name'); - throwConfirmModal('copyToChildrenGroup', Privileges.copyPrivilegesToChildren.bind(null, cid, groupName)); - }); - - $privTableCon.on('click', '[data-action="copyPrivilegesFrom"]', function () { - Privileges.copyPrivilegesFromCategory(cid, ''); - }); - $privTableCon.on('click', '[data-action="copyPrivilegesFromGroup"]', function () { - const groupName = $(this).parents('[data-group-name]').attr('data-group-name'); - Privileges.copyPrivilegesFromCategory(cid, groupName); - }); - - $privTableCon.on('click', '[data-action="copyToAll"]', function () { - throwConfirmModal('copyToAll', Privileges.copyPrivilegesToAllCategories.bind(null, cid, '')); - }); - $privTableCon.on('click', '[data-action="copyToAllGroup"]', function () { - const groupName = $(this).parents('[data-group-name]').attr('data-group-name'); - throwConfirmModal('copyToAllGroup', Privileges.copyPrivilegesToAllCategories.bind(null, cid, groupName)); - }); - - $privTableCon.on('click', '.privilege-filters > button', filterPrivileges); - - mousetrap.bind('ctrl+s', function (ev) { - throwConfirmModal('save', Privileges.commit); - ev.preventDefault(); - }); - - function throwConfirmModal(method, onConfirm) { - const privilegeSubset = getPrivilegeSubset(); - bootbox.confirm(`[[admin/manage/privileges:alert.confirm-${method}, ${privilegeSubset}]]

    [[admin/manage/privileges:alert.no-undo]]`, function (ok) { - if (ok) { - onConfirm.call(); - } - }); - } - }; - - Privileges.commit = function () { - const tableEl = document.querySelector('.privilege-table-container'); - const requests = $.map(tableEl.querySelectorAll('td[data-delta]'), function (el) { - const privilege = el.getAttribute('data-privilege'); - const rowEl = el.parentNode; - const member = rowEl.getAttribute('data-group-name') || rowEl.getAttribute('data-uid'); - const state = el.getAttribute('data-delta') === 'true' ? 1 : 0; - - return Privileges.setPrivilege(member, privilege, state); - }); - - Promise.allSettled(requests).then((results) => { - Privileges.refreshPrivilegeTable(); - - const rejects = results.filter(r => r.status === 'rejected'); - if (rejects.length) { - rejects.forEach((result) => { - alerts.error(result.reason); - }); - } else { - alerts.success('[[admin/manage/privileges:alert.saved]]'); - } - }); - }; - - Privileges.discard = function () { - Privileges.refreshPrivilegeTable(); - alerts.success('[[admin/manage/privileges:alert.discarded]]'); - }; - - Privileges.refreshPrivilegeTable = function (groupToHighlight) { - api.get(`/categories/${cid}/privileges`, {}).then((privileges) => { - ajaxify.data.privileges = { ...ajaxify.data.privileges, ...privileges }; - const tpl = parseInt(cid, 10) ? 'admin/partials/privileges/category' : 'admin/partials/privileges/global'; - const isAdminPriv = ajaxify.currentPage.endsWith('admin/manage/privileges/admin'); - app.parseAndTranslate(tpl, { privileges, isAdminPriv }).then((html) => { - // Get currently selected filters - const btnIndices = $('.privilege-filters button.btn-warning').map((idx, el) => $(el).index()).get(); - $('.privilege-table-container').html(html); - Privileges.exposeAssumedPrivileges(); - document.querySelectorAll('.privilege-filters').forEach((con, i) => { - // Three buttons, placed in reverse order - const lastIdx = $('.privilege-filters').first().find('button').length - 1; - const idx = btnIndices[i] === undefined ? lastIdx : btnIndices[i]; - con.querySelectorAll('button')[idx].click(); - }); - - hightlightRowByDataAttr('data-group-name', groupToHighlight); - }); - }).catch(alert.error); - }; - - Privileges.exposeAssumedPrivileges = function () { - /* + 'api', + 'autocomplete', + 'bootbox', + 'alerts', + 'translator', + 'categorySelector', + 'mousetrap', + 'admin/modules/checkboxRowSelector', +], (api, autocomplete, bootbox, alerts, translator, categorySelector, mousetrap, checkboxRowSelector) => { + const Privileges = {}; + + let cid; + // Number of columns to skip in category privilege tables + const SKIP_PRIV_COLS = 3; + + Privileges.init = function () { + cid = isNaN(Number.parseInt(ajaxify.data.selectedCategory.cid, 10)) ? 'admin' : ajaxify.data.selectedCategory.cid; + + checkboxRowSelector.init('.privilege-table-container'); + + categorySelector.init($('[component="category-selector"]'), { + onSelect(category) { + cid = Number.parseInt(category.cid, 10); + cid = isNaN(cid) ? 'admin' : cid; + Privileges.refreshPrivilegeTable(); + ajaxify.updateHistory('admin/manage/privileges/' + (cid || '')); + }, + localCategories: ajaxify.data.categories, + privilege: 'find', + showLinks: true, + }); + + Privileges.setupPrivilegeTable(); + + highlightRow(); + $('.privilege-filters button:last-child').click(); + }; + + Privileges.setupPrivilegeTable = function () { + $('.privilege-table-container').on('change', 'input[type="checkbox"]:not(.checkbox-helper)', function () { + const $checkboxElement = $(this); + const $wrapperElement = $checkboxElement.parent(); + const columnNo = $wrapperElement.index() + 1; + const privilege = $wrapperElement.attr('data-privilege'); + const state = $checkboxElement.prop('checked'); + const $rowElement = $checkboxElement.parents('tr'); + const member = $rowElement.attr('data-group-name') || $rowElement.attr('data-uid'); + const isPrivate = Number.parseInt($rowElement.attr('data-private') || 0, 10); + const isGroup = $rowElement.attr('data-group-name') !== undefined; + const isBanned = (isGroup && $rowElement.attr('data-group-name') === 'banned-users') || $rowElement.attr('data-banned') !== undefined; + const sourceGroupName = isBanned ? 'banned-users' : 'registered-users'; + const delta = $checkboxElement.prop('checked') === ($wrapperElement.attr('data-value') === 'true') ? null : state; + + if (member) { + if (isGroup && privilege === 'groups:moderate' && !isPrivate && state) { + bootbox.confirm('[[admin/manage/privileges:alert.confirm-moderate]]', confirm => { + if (confirm) { + $wrapperElement.attr('data-delta', delta); + Privileges.exposeSingleAssumedPriv(columnNo, sourceGroupName); + } else { + $checkboxElement.prop('checked', !$checkboxElement.prop('checked')); + } + }); + } else if (privilege.endsWith('admin:admins-mods') && state) { + bootbox.confirm('[[admin/manage/privileges:alert.confirm-admins-mods]]', confirm => { + if (confirm) { + $wrapperElement.attr('data-delta', delta); + Privileges.exposeSingleAssumedPriv(columnNo, sourceGroupName); + } else { + $checkboxElement.prop('checked', !$checkboxElement.prop('checked')); + } + }); + } else { + $wrapperElement.attr('data-delta', delta); + Privileges.exposeSingleAssumedPriv(columnNo, sourceGroupName); + } + + checkboxRowSelector.updateState($checkboxElement); + } else { + alerts.error('[[error:invalid-data]]'); + } + }); + + Privileges.exposeAssumedPrivileges(); + checkboxRowSelector.updateAll(); + Privileges.addEvents(); // Events with confirmation modals + }; + + Privileges.addEvents = function () { + document.querySelector('#save').addEventListener('click', () => { + throwConfirmModal('save', Privileges.commit); + }); + + document.querySelector('#discard').addEventListener('click', () => { + throwConfirmModal('discard', Privileges.discard); + }); + + // Expose discard button as necessary + const containerElement = document.querySelector('.privilege-table-container'); + containerElement.addEventListener('change', e => { + const subselector = e.target.closest('td[data-privilege] input'); + if (subselector) { + document.querySelector('#discard').style.display = containerElement.querySelectorAll('td[data-delta]').length > 0 ? 'unset' : 'none'; + } + }); + + const $privTableCon = $('.privilege-table-container'); + $privTableCon.on('click', '[data-action="search.user"]', Privileges.addUserToPrivilegeTable); + $privTableCon.on('click', '[data-action="search.group"]', Privileges.addGroupToPrivilegeTable); + $privTableCon.on('click', '[data-action="copyToChildren"]', () => { + throwConfirmModal('copyToChildren', Privileges.copyPrivilegesToChildren.bind(null, cid, '')); + }); + $privTableCon.on('click', '[data-action="copyToChildrenGroup"]', function () { + const groupName = $(this).parents('[data-group-name]').attr('data-group-name'); + throwConfirmModal('copyToChildrenGroup', Privileges.copyPrivilegesToChildren.bind(null, cid, groupName)); + }); + + $privTableCon.on('click', '[data-action="copyPrivilegesFrom"]', () => { + Privileges.copyPrivilegesFromCategory(cid, ''); + }); + $privTableCon.on('click', '[data-action="copyPrivilegesFromGroup"]', function () { + const groupName = $(this).parents('[data-group-name]').attr('data-group-name'); + Privileges.copyPrivilegesFromCategory(cid, groupName); + }); + + $privTableCon.on('click', '[data-action="copyToAll"]', () => { + throwConfirmModal('copyToAll', Privileges.copyPrivilegesToAllCategories.bind(null, cid, '')); + }); + $privTableCon.on('click', '[data-action="copyToAllGroup"]', function () { + const groupName = $(this).parents('[data-group-name]').attr('data-group-name'); + throwConfirmModal('copyToAllGroup', Privileges.copyPrivilegesToAllCategories.bind(null, cid, groupName)); + }); + + $privTableCon.on('click', '.privilege-filters > button', filterPrivileges); + + mousetrap.bind('ctrl+s', event => { + throwConfirmModal('save', Privileges.commit); + event.preventDefault(); + }); + + function throwConfirmModal(method, onConfirm) { + const privilegeSubset = getPrivilegeSubset(); + bootbox.confirm(`[[admin/manage/privileges:alert.confirm-${method}, ${privilegeSubset}]]

    [[admin/manage/privileges:alert.no-undo]]`, ok => { + if (ok) { + onConfirm.call(); + } + }); + } + }; + + Privileges.commit = function () { + const tableElement = document.querySelector('.privilege-table-container'); + const requests = $.map(tableElement.querySelectorAll('td[data-delta]'), element => { + const privilege = element.dataset.privilege; + const rowElement = element.parentNode; + const member = rowElement.dataset.groupName || rowElement.dataset.uid; + const state = element.dataset.delta === 'true' ? 1 : 0; + + return Privileges.setPrivilege(member, privilege, state); + }); + + Promise.allSettled(requests).then(results => { + Privileges.refreshPrivilegeTable(); + + const rejects = results.filter(r => r.status === 'rejected'); + if (rejects.length > 0) { + for (const result of rejects) { + alerts.error(result.reason); + } + } else { + alerts.success('[[admin/manage/privileges:alert.saved]]'); + } + }); + }; + + Privileges.discard = function () { + Privileges.refreshPrivilegeTable(); + alerts.success('[[admin/manage/privileges:alert.discarded]]'); + }; + + Privileges.refreshPrivilegeTable = function (groupToHighlight) { + api.get(`/categories/${cid}/privileges`, {}).then(privileges => { + ajaxify.data.privileges = {...ajaxify.data.privileges, ...privileges}; + const tpl = Number.parseInt(cid, 10) ? 'admin/partials/privileges/category' : 'admin/partials/privileges/global'; + const isAdminPriv = ajaxify.currentPage.endsWith('admin/manage/privileges/admin'); + app.parseAndTranslate(tpl, {privileges, isAdminPriv}).then(html => { + // Get currently selected filters + const buttonIndices = $('.privilege-filters button.btn-warning').map((index, element) => $(element).index()).get(); + $('.privilege-table-container').html(html); + Privileges.exposeAssumedPrivileges(); + for (const [i, con] of document.querySelectorAll('.privilege-filters').entries()) { + // Three buttons, placed in reverse order + const lastIndex = $('.privilege-filters').first().find('button').length - 1; + const index = buttonIndices[i] === undefined ? lastIndex : buttonIndices[i]; + con.querySelectorAll('button')[index].click(); + } + + hightlightRowByDataAttribute('data-group-name', groupToHighlight); + }); + }).catch(alert.error); + }; + + Privileges.exposeAssumedPrivileges = function () { + /* If registered-users has a privilege enabled, then all users and groups of that privilege should be assumed to have that privilege as well, even if not set in the db, so reflect this arrangement in the table */ - // As such, individual banned users inherits privileges from banned-users group - const getBannedUsersInputSelector = (privs, i) => `.privilege-table tr[data-banned] td[data-privilege="${privs[i]}"] input`; - const bannedUsersPrivs = getPrivilegesFromRow('banned-users'); - applyPrivileges(bannedUsersPrivs, getBannedUsersInputSelector); - - // For rest that inherits from registered-users - const getRegisteredUsersInputSelector = (privs, i) => `.privilege-table tr[data-group-name]:not([data-group-name="registered-users"],[data-group-name="banned-users"],[data-group-name="guests"],[data-group-name="spiders"]) td[data-privilege="${privs[i]}"] input, .privilege-table tr[data-uid]:not([data-banned]) td[data-privilege="${privs[i]}"] input`; - const registeredUsersPrivs = getPrivilegesFromRow('registered-users'); - applyPrivileges(registeredUsersPrivs, getRegisteredUsersInputSelector); - }; - - Privileges.exposeSingleAssumedPriv = function (columnNo, sourceGroupName) { - let inputSelectorFn; - switch (sourceGroupName) { - case 'banned-users': - inputSelectorFn = () => `.privilege-table tr[data-banned] td[data-privilege]:nth-child(${columnNo}) input`; - break; - default: - inputSelectorFn = () => `.privilege-table tr[data-group-name]:not([data-group-name="registered-users"],[data-group-name="banned-users"],[data-group-name="guests"],[data-group-name="spiders"]) td[data-privilege]:nth-child(${columnNo}) input, .privilege-table tr[data-uid]:not([data-banned]) td[data-privilege]:nth-child(${columnNo}) input`; - } - - const sourceChecked = getPrivilegeFromColumn(sourceGroupName, columnNo); - applyPrivilegesToColumn(inputSelectorFn, sourceChecked); - }; - - Privileges.setPrivilege = (member, privilege, state) => api[state ? 'put' : 'delete'](`/categories/${isNaN(cid) ? 0 : cid}/privileges/${encodeURIComponent(privilege)}`, { member }); - - Privileges.addUserToPrivilegeTable = function () { - const modal = bootbox.dialog({ - title: '[[admin/manage/categories:alert.find-user]]', - message: '', - show: true, - }); - - modal.on('shown.bs.modal', function () { - const inputEl = modal.find('input'); - inputEl.focus(); - - autocomplete.user(inputEl, function (ev, ui) { - addUserToCategory(ui.item.user, function () { - modal.modal('hide'); - }); - }); - }); - }; - - Privileges.addGroupToPrivilegeTable = function () { - const modal = bootbox.dialog({ - title: '[[admin/manage/categories:alert.find-group]]', - message: '', - show: true, - }); - - modal.on('shown.bs.modal', function () { - const inputEl = modal.find('input'); - inputEl.focus(); - - autocomplete.group(inputEl, function (ev, ui) { - if (ui.item.group.name === 'administrators') { - return alerts.alert({ - type: 'warning', - message: '[[admin/manage/privileges:alert.admin-warning]]', - }); - } - addGroupToCategory(ui.item.group.name, function () { - modal.modal('hide'); - }); - }); - }); - }; - - Privileges.copyPrivilegesToChildren = function (cid, group) { - const filter = getPrivilegeFilter(); - socket.emit('admin.categories.copyPrivilegesToChildren', { cid, group, filter }, function (err) { - if (err) { - return alerts.error(err.message); - } - alerts.success('[[admin/manage/categories:privileges.copy-success]]'); - }); - }; - - Privileges.copyPrivilegesFromCategory = function (cid, group) { - const privilegeSubset = getPrivilegeSubset(); - const message = '
    ' + - (group ? `[[admin/manage/privileges:alert.copyPrivilegesFromGroup-warning, ${privilegeSubset}]]` : - `[[admin/manage/privileges:alert.copyPrivilegesFrom-warning, ${privilegeSubset}]]`) + - '

    [[admin/manage/privileges:alert.no-undo]]'; - categorySelector.modal({ - title: '[[admin/manage/privileges:alert.copyPrivilegesFrom-title]]', - message, - localCategories: [], - showLinks: true, - onSubmit: function (selectedCategory) { - socket.emit('admin.categories.copyPrivilegesFrom', { - toCid: cid, - filter: getPrivilegeFilter(), - fromCid: selectedCategory.cid, - group: group, - }, function (err) { - if (err) { - return alerts.error(err); - } - ajaxify.refresh(); - }); - }, - }); - }; - - Privileges.copyPrivilegesToAllCategories = function (cid, group) { - const filter = getPrivilegeFilter(); - socket.emit('admin.categories.copyPrivilegesToAllCategories', { cid, group, filter }, function (err) { - if (err) { - return alerts.error(err); - } - alerts.success('[[admin/manage/categories:privileges.copy-success]]'); - }); - }; - - function getPrivilegesFromRow(sourceGroupName) { - const privs = []; - $(`.privilege-table tr[data-group-name="${sourceGroupName}"] td input[type="checkbox"]:not(.checkbox-helper)`) - .parent() - .each(function (idx, el) { - if ($(el).find('input').prop('checked')) { - privs.push(el.getAttribute('data-privilege')); - } - }); - - // Also apply to non-group privileges - return privs.concat(privs.map(function (priv) { - if (priv.startsWith('groups:')) { - return priv.slice(7); - } - - return false; - })).filter(Boolean); - } - - function getPrivilegeFromColumn(sourceGroupName, columnNo) { - return $(`.privilege-table tr[data-group-name="${sourceGroupName}"] td:nth-child(${columnNo}) input[type="checkbox"]`)[0].checked; - } - - function applyPrivileges(privs, inputSelectorFn) { - for (let x = 0, numPrivs = privs.length; x < numPrivs; x += 1) { - const inputs = $(inputSelectorFn(privs, x)); - inputs.each(function (idx, el) { - if (!el.checked) { - el.indeterminate = true; - } - }); - } - } - - function applyPrivilegesToColumn(inputSelectorFn, sourceChecked) { - const $inputs = $(inputSelectorFn()); - $inputs.each((idx, el) => { - el.indeterminate = el.checked ? false : sourceChecked; - }); - } - - function hightlightRowByDataAttr(attrName, attrValue) { - if (attrValue) { - const $el = $('[' + attrName + ']').filter(function () { - return $(this).attr(attrName) === String(attrValue); - }); - - if ($el.length) { - $el.addClass('selected'); - return true; - } - } - return false; - } - - function highlightRow() { - if (ajaxify.data.group) { - if (hightlightRowByDataAttr('data-group-name', ajaxify.data.group)) { - return; - } - addGroupToCategory(ajaxify.data.group); - } - } - - function addGroupToCategory(group, cb) { - cb = cb || function () {}; - const groupRow = document.querySelector('.privilege-table [data-group-name="' + group + '"]'); - if (groupRow) { - hightlightRowByDataAttr('data-group-name', group); - return cb(); - } - // Generate data for new row - const privilegeSet = ajaxify.data.privileges.keys.groups.reduce(function (memo, cur) { - memo[cur] = false; - return memo; - }, {}); - - app.parseAndTranslate('admin/partials/privileges/' + ((isNaN(cid) || cid === 0) ? 'global' : 'category'), 'privileges.groups', { - privileges: { - groups: [ - { - name: group, - nameEscaped: translator.escape(group), - privileges: privilegeSet, - }, - ], - }, - }, function (html) { - const tbodyEl = document.querySelector('.privilege-table tbody'); - const btnIdx = $('.privilege-filters').first().find('button.btn-warning').index(); - tbodyEl.append(html.get(0)); - Privileges.exposeAssumedPrivileges(); - hightlightRowByDataAttr('data-group-name', group); - document.querySelector('.privilege-filters').querySelectorAll('button')[btnIdx].click(); - cb(); - }); - } - - async function addUserToCategory(user, cb) { - cb = cb || function () {}; - const userRow = document.querySelector('.privilege-table [data-uid="' + user.uid + '"]'); - if (userRow) { - hightlightRowByDataAttr('data-uid', user.uid); - return cb(); - } - // Generate data for new row - const privilegeSet = ajaxify.data.privileges.keys.users.reduce(function (memo, cur) { - memo[cur] = false; - return memo; - }, {}); - - const html = await app.parseAndTranslate('admin/partials/privileges/' + (isNaN(cid) ? 'global' : 'category'), 'privileges.users', { - privileges: { - users: [ - { - picture: user.picture, - username: user.username, - banned: user.banned, - uid: user.uid, - 'icon:text': user['icon:text'], - 'icon:bgColor': user['icon:bgColor'], - privileges: privilegeSet, - }, - ], - }, - }); - - const tbodyEl = document.querySelectorAll('.privilege-table tbody'); - const btnIdx = $('.privilege-filters').last().find('button.btn-warning').index(); - tbodyEl[1].append(html.get(0)); - Privileges.exposeAssumedPrivileges(); - hightlightRowByDataAttr('data-uid', user.uid); - document.querySelectorAll('.privilege-filters')[1].querySelectorAll('button')[btnIdx].click(); - cb(); - } - - function filterPrivileges(ev) { - const [startIdx, endIdx] = ev.target.getAttribute('data-filter').split(',').map(i => parseInt(i, 10)); - const rows = $(ev.target).closest('table')[0].querySelectorAll('thead tr:last-child, tbody tr '); - rows.forEach((tr) => { - tr.querySelectorAll('td, th').forEach((el, idx) => { - const offset = el.tagName.toUpperCase() === 'TH' ? 1 : 0; - if (idx < (SKIP_PRIV_COLS - offset)) { - return; - } - el.classList.toggle('hidden', !(idx >= (startIdx - offset) && idx <= (endIdx - offset))); - }); - }); - checkboxRowSelector.updateAll(); - $(ev.target).siblings('button').toArray().forEach(btn => btn.classList.remove('btn-warning')); - ev.target.classList.add('btn-warning'); - } - - function getPrivilegeFilter() { - const indices = document.querySelector('.privilege-filters .btn-warning') - .getAttribute('data-filter') - .split(',') - .map(i => parseInt(i, 10)); - indices[0] -= SKIP_PRIV_COLS; - indices[1] = indices[1] - SKIP_PRIV_COLS + 1; - return indices; - } - - function getPrivilegeSubset() { - const currentPrivFilter = document.querySelector('.privilege-filters .btn-warning'); - const filterText = currentPrivFilter ? currentPrivFilter.textContent.toLocaleLowerCase() : ''; - return filterText.indexOf('privileges') > -1 ? filterText : `${filterText} privileges`.trim(); - } - - return Privileges; + // As such, individual banned users inherits privileges from banned-users group + const getBannedUsersInputSelector = (privs, i) => `.privilege-table tr[data-banned] td[data-privilege="${privs[i]}"] input`; + const bannedUsersPrivs = getPrivilegesFromRow('banned-users'); + applyPrivileges(bannedUsersPrivs, getBannedUsersInputSelector); + + // For rest that inherits from registered-users + const getRegisteredUsersInputSelector = (privs, i) => `.privilege-table tr[data-group-name]:not([data-group-name="registered-users"],[data-group-name="banned-users"],[data-group-name="guests"],[data-group-name="spiders"]) td[data-privilege="${privs[i]}"] input, .privilege-table tr[data-uid]:not([data-banned]) td[data-privilege="${privs[i]}"] input`; + const registeredUsersPrivs = getPrivilegesFromRow('registered-users'); + applyPrivileges(registeredUsersPrivs, getRegisteredUsersInputSelector); + }; + + Privileges.exposeSingleAssumedPriv = function (columnNo, sourceGroupName) { + let inputSelectorFunction; + switch (sourceGroupName) { + case 'banned-users': { + inputSelectorFunction = () => `.privilege-table tr[data-banned] td[data-privilege]:nth-child(${columnNo}) input`; + break; + } + + default: { + inputSelectorFunction = () => `.privilege-table tr[data-group-name]:not([data-group-name="registered-users"],[data-group-name="banned-users"],[data-group-name="guests"],[data-group-name="spiders"]) td[data-privilege]:nth-child(${columnNo}) input, .privilege-table tr[data-uid]:not([data-banned]) td[data-privilege]:nth-child(${columnNo}) input`; + } + } + + const sourceChecked = getPrivilegeFromColumn(sourceGroupName, columnNo); + applyPrivilegesToColumn(inputSelectorFunction, sourceChecked); + }; + + Privileges.setPrivilege = (member, privilege, state) => api[state ? 'put' : 'delete'](`/categories/${isNaN(cid) ? 0 : cid}/privileges/${encodeURIComponent(privilege)}`, {member}); + + Privileges.addUserToPrivilegeTable = function () { + const modal = bootbox.dialog({ + title: '[[admin/manage/categories:alert.find-user]]', + message: '', + show: true, + }); + + modal.on('shown.bs.modal', () => { + const inputElement = modal.find('input'); + inputElement.focus(); + + autocomplete.user(inputElement, (event, ui) => { + addUserToCategory(ui.item.user, () => { + modal.modal('hide'); + }); + }); + }); + }; + + Privileges.addGroupToPrivilegeTable = function () { + const modal = bootbox.dialog({ + title: '[[admin/manage/categories:alert.find-group]]', + message: '', + show: true, + }); + + modal.on('shown.bs.modal', () => { + const inputElement = modal.find('input'); + inputElement.focus(); + + autocomplete.group(inputElement, (event, ui) => { + if (ui.item.group.name === 'administrators') { + return alerts.alert({ + type: 'warning', + message: '[[admin/manage/privileges:alert.admin-warning]]', + }); + } + + addGroupToCategory(ui.item.group.name, () => { + modal.modal('hide'); + }); + }); + }); + }; + + Privileges.copyPrivilegesToChildren = function (cid, group) { + const filter = getPrivilegeFilter(); + socket.emit('admin.categories.copyPrivilegesToChildren', {cid, group, filter}, error => { + if (error) { + return alerts.error(error.message); + } + + alerts.success('[[admin/manage/categories:privileges.copy-success]]'); + }); + }; + + Privileges.copyPrivilegesFromCategory = function (cid, group) { + const privilegeSubset = getPrivilegeSubset(); + const message = '
    ' + + (group ? `[[admin/manage/privileges:alert.copyPrivilegesFromGroup-warning, ${privilegeSubset}]]` + : `[[admin/manage/privileges:alert.copyPrivilegesFrom-warning, ${privilegeSubset}]]`) + + '

    [[admin/manage/privileges:alert.no-undo]]'; + categorySelector.modal({ + title: '[[admin/manage/privileges:alert.copyPrivilegesFrom-title]]', + message, + localCategories: [], + showLinks: true, + onSubmit(selectedCategory) { + socket.emit('admin.categories.copyPrivilegesFrom', { + toCid: cid, + filter: getPrivilegeFilter(), + fromCid: selectedCategory.cid, + group, + }, error => { + if (error) { + return alerts.error(error); + } + + ajaxify.refresh(); + }); + }, + }); + }; + + Privileges.copyPrivilegesToAllCategories = function (cid, group) { + const filter = getPrivilegeFilter(); + socket.emit('admin.categories.copyPrivilegesToAllCategories', {cid, group, filter}, error => { + if (error) { + return alerts.error(error); + } + + alerts.success('[[admin/manage/categories:privileges.copy-success]]'); + }); + }; + + function getPrivilegesFromRow(sourceGroupName) { + const privs = []; + $(`.privilege-table tr[data-group-name="${sourceGroupName}"] td input[type="checkbox"]:not(.checkbox-helper)`) + .parent() + .each((index, element) => { + if ($(element).find('input').prop('checked')) { + privs.push(element.dataset.privilege); + } + }); + + // Also apply to non-group privileges + return privs.concat(privs.map(priv => { + if (priv.startsWith('groups:')) { + return priv.slice(7); + } + + return false; + })).filter(Boolean); + } + + function getPrivilegeFromColumn(sourceGroupName, columnNo) { + return $(`.privilege-table tr[data-group-name="${sourceGroupName}"] td:nth-child(${columnNo}) input[type="checkbox"]`)[0].checked; + } + + function applyPrivileges(privs, inputSelectorFunction) { + for (let x = 0, numberPrivs = privs.length; x < numberPrivs; x += 1) { + const inputs = $(inputSelectorFunction(privs, x)); + inputs.each((index, element) => { + if (!element.checked) { + element.indeterminate = true; + } + }); + } + } + + function applyPrivilegesToColumn(inputSelectorFunction, sourceChecked) { + const $inputs = $(inputSelectorFunction()); + $inputs.each((index, element) => { + element.indeterminate = element.checked ? false : sourceChecked; + }); + } + + function hightlightRowByDataAttribute(attributeName, attributeValue) { + if (attributeValue) { + const $element = $('[' + attributeName + ']').filter(function () { + return $(this).attr(attributeName) === String(attributeValue); + }); + + if ($element.length > 0) { + $element.addClass('selected'); + return true; + } + } + + return false; + } + + function highlightRow() { + if (ajaxify.data.group) { + if (hightlightRowByDataAttribute('data-group-name', ajaxify.data.group)) { + return; + } + + addGroupToCategory(ajaxify.data.group); + } + } + + function addGroupToCategory(group, callback) { + callback ||= function () {}; + const groupRow = document.querySelector('.privilege-table [data-group-name="' + group + '"]'); + if (groupRow) { + hightlightRowByDataAttribute('data-group-name', group); + return callback(); + } + + // Generate data for new row + const privilegeSet = ajaxify.data.privileges.keys.groups.reduce((memo, current) => { + memo[current] = false; + return memo; + }, {}); + + app.parseAndTranslate('admin/partials/privileges/' + ((isNaN(cid) || cid === 0) ? 'global' : 'category'), 'privileges.groups', { + privileges: { + groups: [ + { + name: group, + nameEscaped: translator.escape(group), + privileges: privilegeSet, + }, + ], + }, + }, html => { + const tbodyElement = document.querySelector('.privilege-table tbody'); + const buttonIndex = $('.privilege-filters').first().find('button.btn-warning').index(); + tbodyElement.append(html.get(0)); + Privileges.exposeAssumedPrivileges(); + hightlightRowByDataAttribute('data-group-name', group); + document.querySelector('.privilege-filters').querySelectorAll('button')[buttonIndex].click(); + callback(); + }); + } + + async function addUserToCategory(user, callback) { + callback ||= function () {}; + const userRow = document.querySelector('.privilege-table [data-uid="' + user.uid + '"]'); + if (userRow) { + hightlightRowByDataAttribute('data-uid', user.uid); + return callback(); + } + + // Generate data for new row + const privilegeSet = ajaxify.data.privileges.keys.users.reduce((memo, current) => { + memo[current] = false; + return memo; + }, {}); + + const html = await app.parseAndTranslate('admin/partials/privileges/' + (isNaN(cid) ? 'global' : 'category'), 'privileges.users', { + privileges: { + users: [ + { + picture: user.picture, + username: user.username, + banned: user.banned, + uid: user.uid, + 'icon:text': user['icon:text'], + 'icon:bgColor': user['icon:bgColor'], + privileges: privilegeSet, + }, + ], + }, + }); + + const tbodyElement = document.querySelectorAll('.privilege-table tbody'); + const buttonIndex = $('.privilege-filters').last().find('button.btn-warning').index(); + tbodyElement[1].append(html.get(0)); + Privileges.exposeAssumedPrivileges(); + hightlightRowByDataAttribute('data-uid', user.uid); + document.querySelectorAll('.privilege-filters')[1].querySelectorAll('button')[buttonIndex].click(); + callback(); + } + + function filterPrivileges(event) { + const [startIndex, endIndex] = event.target.dataset.filter.split(',').map(i => Number.parseInt(i, 10)); + const rows = $(event.target).closest('table')[0].querySelectorAll('thead tr:last-child, tbody tr '); + for (const tr of rows) { + for (const [index, element] of tr.querySelectorAll('td, th').entries()) { + const offset = element.tagName.toUpperCase() === 'TH' ? 1 : 0; + if (index < (SKIP_PRIV_COLS - offset)) { + continue; + } + + element.classList.toggle('hidden', !(index >= (startIndex - offset) && index <= (endIndex - offset))); + } + } + + checkboxRowSelector.updateAll(); + for (const button of $(event.target).siblings('button').toArray()) { + button.classList.remove('btn-warning'); + } + + event.target.classList.add('btn-warning'); + } + + function getPrivilegeFilter() { + const indices = document.querySelector('.privilege-filters .btn-warning').dataset.filter + .split(',') + .map(i => Number.parseInt(i, 10)); + indices[0] -= SKIP_PRIV_COLS; + indices[1] = indices[1] - SKIP_PRIV_COLS + 1; + return indices; + } + + function getPrivilegeSubset() { + const currentPrivFilter = document.querySelector('.privilege-filters .btn-warning'); + const filterText = currentPrivFilter ? currentPrivFilter.textContent.toLocaleLowerCase() : ''; + return filterText.includes('privileges') ? filterText : `${filterText} privileges`.trim(); + } + + return Privileges; }); diff --git a/public/src/admin/manage/registration.js b/public/src/admin/manage/registration.js index 4b63647..60562e7 100644 --- a/public/src/admin/manage/registration.js +++ b/public/src/admin/manage/registration.js @@ -1,56 +1,60 @@ 'use strict'; +define('admin/manage/registration', ['bootbox', 'alerts'], (bootbox, alerts) => { + const Registration = {}; -define('admin/manage/registration', ['bootbox', 'alerts'], function (bootbox, alerts) { - const Registration = {}; - - Registration.init = function () { - $('.users-list').on('click', '[data-action]', function () { - const parent = $(this).parents('[data-username]'); - const action = $(this).attr('data-action'); - const username = parent.attr('data-username'); - const method = action === 'accept' ? 'user.acceptRegistration' : 'user.rejectRegistration'; - - socket.emit(method, { username: username }, function (err) { - if (err) { - return alerts.error(err); - } - parent.remove(); - }); - return false; - }); - - $('.invites-list').on('click', '[data-action]', function () { - const parent = $(this).parents('[data-invitation-mail][data-invited-by]'); - const email = parent.attr('data-invitation-mail'); - const invitedBy = parent.attr('data-invited-by'); - const action = $(this).attr('data-action'); - const method = 'user.deleteInvitation'; - - const removeRow = function () { - const nextRow = parent.next(); - const thisRowinvitedBy = parent.find('.invited-by'); - const nextRowInvitedBy = nextRow.find('.invited-by'); - if (nextRowInvitedBy.html() !== undefined && nextRowInvitedBy.html().length < 2) { - nextRowInvitedBy.html(thisRowinvitedBy.html()); - } - parent.remove(); - }; - if (action === 'delete') { - bootbox.confirm('[[admin/manage/registration:invitations.confirm-delete]]', function (confirm) { - if (confirm) { - socket.emit(method, { email: email, invitedBy: invitedBy }, function (err) { - if (err) { - return alerts.error(err); - } - removeRow(); - }); - } - }); - } - return false; - }); - }; - - return Registration; + Registration.init = function () { + $('.users-list').on('click', '[data-action]', function () { + const parent = $(this).parents('[data-username]'); + const action = $(this).attr('data-action'); + const username = parent.attr('data-username'); + const method = action === 'accept' ? 'user.acceptRegistration' : 'user.rejectRegistration'; + + socket.emit(method, {username}, error => { + if (error) { + return alerts.error(error); + } + + parent.remove(); + }); + return false; + }); + + $('.invites-list').on('click', '[data-action]', function () { + const parent = $(this).parents('[data-invitation-mail][data-invited-by]'); + const email = parent.attr('data-invitation-mail'); + const invitedBy = parent.attr('data-invited-by'); + const action = $(this).attr('data-action'); + const method = 'user.deleteInvitation'; + + const removeRow = function () { + const nextRow = parent.next(); + const thisRowinvitedBy = parent.find('.invited-by'); + const nextRowInvitedBy = nextRow.find('.invited-by'); + if (nextRowInvitedBy.html() !== undefined && nextRowInvitedBy.html().length < 2) { + nextRowInvitedBy.html(thisRowinvitedBy.html()); + } + + parent.remove(); + }; + + if (action === 'delete') { + bootbox.confirm('[[admin/manage/registration:invitations.confirm-delete]]', confirm => { + if (confirm) { + socket.emit(method, {email, invitedBy}, error => { + if (error) { + return alerts.error(error); + } + + removeRow(); + }); + } + }); + } + + return false; + }); + }; + + return Registration; }); diff --git a/public/src/admin/manage/tags.js b/public/src/admin/manage/tags.js index 08668ac..9a98c9f 100644 --- a/public/src/admin/manage/tags.js +++ b/public/src/admin/manage/tags.js @@ -1,141 +1,143 @@ 'use strict'; - define('admin/manage/tags', [ - 'bootbox', - 'alerts', - 'admin/modules/selectable', -], function (bootbox, alerts, selectable) { - const Tags = {}; - - Tags.init = function () { - selectable.enable('.tag-management', '.tag-row'); - - handleCreate(); - handleSearch(); - handleRename(); - handleDeleteSelected(); - }; - - function handleCreate() { - const createModal = $('#create-modal'); - const createTagName = $('#create-tag-name'); - const createModalGo = $('#create-modal-go'); - - createModal.on('keypress', function (e) { - if (e.keyCode === 13) { - createModalGo.click(); - } - }); - - $('#create').on('click', function () { - createModal.modal('show'); - setTimeout(function () { - createTagName.focus(); - }, 250); - }); - - createModalGo.on('click', function () { - socket.emit('admin.tags.create', { - tag: createTagName.val(), - }, function (err) { - if (err) { - return alerts.error(err); - } - - createTagName.val(''); - createModal.on('hidden.bs.modal', function () { - ajaxify.refresh(); - }); - createModal.modal('hide'); - }); - }); - } - - function handleSearch() { - $('#tag-search').on('input propertychange', utils.debounce(function () { - socket.emit('topics.searchAndLoadTags', { - query: $('#tag-search').val(), - }, function (err, result) { - if (err) { - return alerts.error(err); - } - - app.parseAndTranslate('admin/manage/tags', 'tags', { - tags: result.tags, - }, function (html) { - $('.tag-list').html(html); - utils.makeNumbersHumanReadable(html.find('.human-readable-number')); - selectable.enable('.tag-management', '.tag-row'); - }); - }); - }, 250)); - } - - function handleRename() { - $('#rename').on('click', function () { - const tagsToModify = $('.tag-row.ui-selected'); - if (!tagsToModify.length) { - return; - } - - const modal = bootbox.dialog({ - title: '[[admin/manage/tags:alerts.editing]]', - message: $('.rename-modal').html(), - buttons: { - success: { - label: 'Save', - className: 'btn-primary save', - callback: function () { - const data = []; - tagsToModify.each(function (idx, tag) { - tag = $(tag); - data.push({ - value: tag.attr('data-tag'), - newName: modal.find('[data-name="value"]').val(), - }); - }); - - socket.emit('admin.tags.rename', data, function (err) { - if (err) { - return alerts.error(err); - } - alerts.success('[[admin/manage/tags:alerts.update-success]]'); - ajaxify.refresh(); - }); - }, - }, - }, - }); - }); - } - - function handleDeleteSelected() { - $('#deleteSelected').on('click', function () { - const tagsToDelete = $('.tag-row.ui-selected'); - if (!tagsToDelete.length) { - return; - } - - bootbox.confirm('[[admin/manage/tags:alerts.confirm-delete]]', function (confirm) { - if (!confirm) { - return; - } - const tags = []; - tagsToDelete.each(function (index, el) { - tags.push($(el).attr('data-tag')); - }); - socket.emit('admin.tags.deleteTags', { - tags: tags, - }, function (err) { - if (err) { - return alerts.error(err); - } - tagsToDelete.remove(); - }); - }); - }); - } - - return Tags; + 'bootbox', + 'alerts', + 'admin/modules/selectable', +], (bootbox, alerts, selectable) => { + const Tags = {}; + + Tags.init = function () { + selectable.enable('.tag-management', '.tag-row'); + + handleCreate(); + handleSearch(); + handleRename(); + handleDeleteSelected(); + }; + + function handleCreate() { + const createModal = $('#create-modal'); + const createTagName = $('#create-tag-name'); + const createModalGo = $('#create-modal-go'); + + createModal.on('keypress', e => { + if (e.keyCode === 13) { + createModalGo.click(); + } + }); + + $('#create').on('click', () => { + createModal.modal('show'); + setTimeout(() => { + createTagName.focus(); + }, 250); + }); + + createModalGo.on('click', () => { + socket.emit('admin.tags.create', { + tag: createTagName.val(), + }, error => { + if (error) { + return alerts.error(error); + } + + createTagName.val(''); + createModal.on('hidden.bs.modal', () => { + ajaxify.refresh(); + }); + createModal.modal('hide'); + }); + }); + } + + function handleSearch() { + $('#tag-search').on('input propertychange', utils.debounce(() => { + socket.emit('topics.searchAndLoadTags', { + query: $('#tag-search').val(), + }, (error, result) => { + if (error) { + return alerts.error(error); + } + + app.parseAndTranslate('admin/manage/tags', 'tags', { + tags: result.tags, + }, html => { + $('.tag-list').html(html); + utils.makeNumbersHumanReadable(html.find('.human-readable-number')); + selectable.enable('.tag-management', '.tag-row'); + }); + }); + }, 250)); + } + + function handleRename() { + $('#rename').on('click', () => { + const tagsToModify = $('.tag-row.ui-selected'); + if (tagsToModify.length === 0) { + return; + } + + const modal = bootbox.dialog({ + title: '[[admin/manage/tags:alerts.editing]]', + message: $('.rename-modal').html(), + buttons: { + success: { + label: 'Save', + className: 'btn-primary save', + callback() { + const data = []; + tagsToModify.each((index, tag) => { + tag = $(tag); + data.push({ + value: tag.attr('data-tag'), + newName: modal.find('[data-name="value"]').val(), + }); + }); + + socket.emit('admin.tags.rename', data, error => { + if (error) { + return alerts.error(error); + } + + alerts.success('[[admin/manage/tags:alerts.update-success]]'); + ajaxify.refresh(); + }); + }, + }, + }, + }); + }); + } + + function handleDeleteSelected() { + $('#deleteSelected').on('click', () => { + const tagsToDelete = $('.tag-row.ui-selected'); + if (tagsToDelete.length === 0) { + return; + } + + bootbox.confirm('[[admin/manage/tags:alerts.confirm-delete]]', confirm => { + if (!confirm) { + return; + } + + const tags = []; + tagsToDelete.each((index, element) => { + tags.push($(element).attr('data-tag')); + }); + socket.emit('admin.tags.deleteTags', { + tags, + }, error => { + if (error) { + return alerts.error(error); + } + + tagsToDelete.remove(); + }); + }); + }); + } + + return Tags; }); diff --git a/public/src/admin/manage/uploads.js b/public/src/admin/manage/uploads.js index 5985647..c537347 100644 --- a/public/src/admin/manage/uploads.js +++ b/public/src/admin/manage/uploads.js @@ -1,49 +1,49 @@ 'use strict'; -define('admin/manage/uploads', ['api', 'bootbox', 'alerts', 'uploader'], function (api, bootbox, alerts, uploader) { - const Uploads = {}; +define('admin/manage/uploads', ['api', 'bootbox', 'alerts', 'uploader'], (api, bootbox, alerts, uploader) => { + const Uploads = {}; - Uploads.init = function () { - $('#upload').on('click', function () { - uploader.show({ - title: '[[admin/manage/uploads:upload-file]]', - route: config.relative_path + '/api/admin/upload/file', - params: { folder: ajaxify.data.currentFolder }, - }, function () { - ajaxify.refresh(); - }); - }); + Uploads.init = function () { + $('#upload').on('click', () => { + uploader.show({ + title: '[[admin/manage/uploads:upload-file]]', + route: config.relative_path + '/api/admin/upload/file', + params: {folder: ajaxify.data.currentFolder}, + }, () => { + ajaxify.refresh(); + }); + }); - $('.delete').on('click', function () { - const file = $(this).parents('[data-path]'); - bootbox.confirm('[[admin/manage/uploads:confirm-delete]]', function (ok) { - if (!ok) { - return; - } + $('.delete').on('click', function () { + const file = $(this).parents('[data-path]'); + bootbox.confirm('[[admin/manage/uploads:confirm-delete]]', ok => { + if (!ok) { + return; + } - api.del('/files', { - path: file.attr('data-path'), - }).then(() => { - file.remove(); - }).catch(alerts.error); - }); - }); + api.del('/files', { + path: file.attr('data-path'), + }).then(() => { + file.remove(); + }).catch(alerts.error); + }); + }); - $('#new-folder').on('click', async function () { - bootbox.prompt('[[admin/manage/uploads:name-new-folder]]', (newFolderName) => { - if (!newFolderName || !newFolderName.trim()) { - return; - } + $('#new-folder').on('click', async () => { + bootbox.prompt('[[admin/manage/uploads:name-new-folder]]', newFolderName => { + if (!newFolderName || !newFolderName.trim()) { + return; + } - api.put('/files/folder', { - path: ajaxify.data.currentFolder, - folderName: newFolderName, - }).then(() => { - ajaxify.refresh(); - }).catch(alerts.error); - }); - }); - }; + api.put('/files/folder', { + path: ajaxify.data.currentFolder, + folderName: newFolderName, + }).then(() => { + ajaxify.refresh(); + }).catch(alerts.error); + }); + }); + }; - return Uploads; + return Uploads; }); diff --git a/public/src/admin/manage/users.js b/public/src/admin/manage/users.js index aef2a57..a18b0b6 100644 --- a/public/src/admin/manage/users.js +++ b/public/src/admin/manage/users.js @@ -1,549 +1,558 @@ 'use strict'; define('admin/manage/users', [ - 'translator', 'benchpress', 'autocomplete', 'api', 'slugify', 'bootbox', 'alerts', 'accounts/invite', -], function (translator, Benchpress, autocomplete, api, slugify, bootbox, alerts, AccountInvite) { - const Users = {}; - - Users.init = function () { - $('#results-per-page').val(ajaxify.data.resultsPerPage).on('change', function () { - const query = utils.params(); - query.resultsPerPage = $('#results-per-page').val(); - const qs = buildSearchQuery(query); - ajaxify.go(window.location.pathname + '?' + qs); - }); - - $('.export-csv').on('click', function () { - socket.once('event:export-users-csv', function () { - alerts.remove('export-users-start'); - alerts.alert({ - alert_id: 'export-users', - type: 'success', - title: '[[global:alert.success]]', - message: '[[admin/manage/users:export-users-completed]]', - clickfn: function () { - window.location.href = config.relative_path + '/api/admin/users/csv'; - }, - timeout: 0, - }); - }); - socket.emit('admin.user.exportUsersCSV', {}, function (err) { - if (err) { - return alerts.error(err); - } - alerts.alert({ - alert_id: 'export-users-start', - message: '[[admin/manage/users:export-users-started]]', - timeout: (ajaxify.data.userCount / 5000) * 500, - }); - }); - - return false; - }); - - function getSelectedUids() { - const uids = []; - - $('.users-table [component="user/select/single"]').each(function () { - if ($(this).is(':checked')) { - uids.push($(this).attr('data-uid')); - } - }); - - return uids; - } - - function update(className, state) { - $('.users-table [component="user/select/single"]:checked').parents('.user-row').find(className).each(function () { - $(this).toggleClass('hidden', !state); - }); - } - - function unselectAll() { - $('.users-table [component="user/select/single"]').prop('checked', false); - $('.users-table [component="user/select/all"]').prop('checked', false); - } - - function removeRow(uid) { - const checkboxEl = document.querySelector(`.users-table [component="user/select/single"][data-uid="${uid}"]`); - if (checkboxEl) { - const rowEl = checkboxEl.closest('.user-row'); - rowEl.parentNode.removeChild(rowEl); - } - } - - // use onSuccess instead - function done(successMessage, className, flag) { - return function (err) { - if (err) { - return alerts.error(err); - } - alerts.success(successMessage); - if (className) { - update(className, flag); - } - unselectAll(); - }; - } - - function onSuccess(successMessage, className, flag) { - alerts.success(successMessage); - if (className) { - update(className, flag); - } - unselectAll(); - } - - $('[component="user/select/all"]').on('click', function () { - $('.users-table [component="user/select/single"]').prop('checked', $(this).is(':checked')); - }); - - $('.manage-groups').on('click', function () { - const uids = getSelectedUids(); - if (!uids.length) { - alerts.error('[[error:no-users-selected]]'); - return false; - } - socket.emit('admin.user.loadGroups', uids, function (err, data) { - if (err) { - return alerts.error(err); - } - Benchpress.render('admin/partials/manage_user_groups', data).then(function (html) { - const modal = bootbox.dialog({ - message: html, - title: '[[admin/manage/users:manage-groups]]', - onEscape: true, - }); - modal.on('shown.bs.modal', function () { - autocomplete.group(modal.find('.group-search'), function (ev, ui) { - const uid = $(ev.target).attr('data-uid'); - api.put('/groups/' + ui.item.group.slug + '/membership/' + uid, undefined).then(() => { - ui.item.group.nameEscaped = translator.escape(ui.item.group.displayName); - app.parseAndTranslate('admin/partials/manage_user_groups', { users: [{ groups: [ui.item.group] }] }, function (html) { - $('[data-uid=' + uid + '] .group-area').append(html.find('.group-area').html()); - }); - }).catch(alerts.error); - }); - }); - modal.on('click', '.group-area a', function () { - modal.modal('hide'); - }); - modal.on('click', '.remove-group-icon', function () { - const groupCard = $(this).parents('[data-group-name]'); - const groupName = groupCard.attr('data-group-name'); - const uid = $(this).parents('[data-uid]').attr('data-uid'); - api.del('/groups/' + slugify(groupName) + '/membership/' + uid).then(() => { - groupCard.remove(); - }).catch(alerts.error); - return false; - }); - }); - }); - }); - - $('.ban-user').on('click', function () { - const uids = getSelectedUids(); - if (!uids.length) { - alerts.error('[[error:no-users-selected]]'); - return false; // specifically to keep the menu open - } - - bootbox.confirm((uids.length > 1 ? '[[admin/manage/users:alerts.confirm-ban-multi]]' : '[[admin/manage/users:alerts.confirm-ban]]'), function (confirm) { - if (confirm) { - Promise.all(uids.map(function (uid) { - return api.put('/users/' + uid + '/ban'); - })).then(() => { - onSuccess('[[admin/manage/users:alerts.ban-success]]', '.ban', true); - }).catch(alerts.error); - } - }); - }); - - $('.ban-user-temporary').on('click', function () { - const uids = getSelectedUids(); - if (!uids.length) { - alerts.error('[[error:no-users-selected]]'); - return false; // specifically to keep the menu open - } - - Benchpress.render('admin/partials/temporary-ban', {}).then(function (html) { - bootbox.dialog({ - className: 'ban-modal', - title: '[[user:ban_account]]', - message: html, - show: true, - buttons: { - close: { - label: '[[global:close]]', - className: 'btn-link', - }, - submit: { - label: '[[admin/manage/users:alerts.button-ban-x, ' + uids.length + ']]', - callback: function () { - const formData = $('.ban-modal form').serializeArray().reduce(function (data, cur) { - data[cur.name] = cur.value; - return data; - }, {}); - const until = formData.length > 0 ? ( - Date.now() + - (formData.length * 1000 * 60 * 60 * (parseInt(formData.unit, 10) ? 24 : 1)) - ) : 0; - - Promise.all(uids.map(function (uid) { - return api.put('/users/' + uid + '/ban', { - until: until, - reason: formData.reason, - }); - })).then(() => { - onSuccess('[[admin/manage/users:alerts.ban-success]]', '.ban', true); - }).catch(alerts.error); - }, - }, - }, - }); - }); - }); - - $('.unban-user').on('click', function () { - const uids = getSelectedUids(); - if (!uids.length) { - alerts.error('[[error:no-users-selected]]'); - return false; // specifically to keep the menu open - } - - Promise.all(uids.map(function (uid) { - return api.del('/users/' + uid + '/ban'); - })).then(() => { - onSuccess('[[admin/manage/users:alerts.unban-success]]', '.ban', false); - }); - }); - - $('.reset-lockout').on('click', function () { - const uids = getSelectedUids(); - if (!uids.length) { - return; - } - - socket.emit('admin.user.resetLockouts', uids, done('[[admin/manage/users:alerts.lockout-reset-success]]')); - }); - - $('.validate-email').on('click', function () { - const uids = getSelectedUids(); - if (!uids.length) { - return; - } - - bootbox.confirm('[[admin/manage/users:alerts.confirm-validate-email]]', function (confirm) { - if (!confirm) { - return; - } - socket.emit('admin.user.validateEmail', uids, function (err) { - if (err) { - return alerts.error(err); - } - alerts.success('[[admin/manage/users:alerts.validate-email-success]]'); - update('.notvalidated', false); - update('.validated', true); - unselectAll(); - }); - }); - }); - - $('.send-validation-email').on('click', function () { - const uids = getSelectedUids(); - if (!uids.length) { - return; - } - socket.emit('admin.user.sendValidationEmail', uids, function (err) { - if (err) { - return alerts.error(err); - } - alerts.success('[[notifications:email-confirm-sent]]'); - }); - }); - - $('.password-reset-email').on('click', function () { - const uids = getSelectedUids(); - if (!uids.length) { - return; - } - - bootbox.confirm('[[admin/manage/users:alerts.password-reset-confirm]]', function (confirm) { - if (confirm) { - socket.emit('admin.user.sendPasswordResetEmail', uids, done('[[admin/manage/users:alerts.password-reset-email-sent]]')); - } - }); - }); - - $('.force-password-reset').on('click', function () { - const uids = getSelectedUids(); - if (!uids.length) { - return; - } - - bootbox.confirm('[[admin/manage/users:alerts.confirm-force-password-reset]]', function (confirm) { - if (confirm) { - socket.emit('admin.user.forcePasswordReset', uids, done('[[admin/manage/users:alerts.validate-force-password-reset-success]]')); - } - }); - }); - - $('.delete-user').on('click', () => { - handleDelete('[[admin/manage/users:alerts.confirm-delete]]', '/account'); - }); - - $('.delete-user-content').on('click', () => { - handleDelete('[[admin/manage/users:alerts.confirm-delete-content]]', '/content'); - }); - - $('.delete-user-and-content').on('click', () => { - handleDelete('[[admin/manage/users:alerts.confirm-purge]]', ''); - }); - - const tableEl = document.querySelector('.users-table'); - const actionBtn = document.getElementById('action-dropdown'); - tableEl.addEventListener('change', (e) => { - const subselector = e.target.closest('[component="user/select/single"]') || e.target.closest('[component="user/select/all"]'); - if (subselector) { - const uids = getSelectedUids(); - if (uids.length) { - actionBtn.removeAttribute('disabled'); - } else { - actionBtn.setAttribute('disabled', 'disabled'); - } - } - }); - - function handleDelete(confirmMsg, path) { - const uids = getSelectedUids(); - if (!uids.length) { - return; - } - - bootbox.confirm(confirmMsg, function (confirm) { - if (confirm) { - Promise.all( - uids.map( - uid => api.del(`/users/${uid}${path}`, {}).then(() => { - if (path !== '/content') { - removeRow(uid); - } - }) - ) - ).then(() => { - if (path !== '/content') { - alerts.success('[[admin/manage/users:alerts.delete-success]]'); - } else { - alerts.success('[[admin/manage/users:alerts.delete-content-success]]'); - } - unselectAll(); - if (!$('.users-table [component="user/select/single"]').length) { - ajaxify.refresh(); - } - }).catch(alerts.error); - } - }); - } - - function handleUserCreate() { - $('[data-action="create"]').on('click', function () { - Benchpress.render('admin/partials/create_user_modal', {}).then(function (html) { - const modal = bootbox.dialog({ - message: html, - title: '[[admin/manage/users:alerts.create]]', - onEscape: true, - buttons: { - cancel: { - label: '[[admin/manage/users:alerts.button-cancel]]', - className: 'btn-link', - }, - create: { - label: '[[admin/manage/users:alerts.button-create]]', - className: 'btn-primary', - callback: function () { - createUser.call(this); - return false; - }, - }, - }, - }); - modal.on('shown.bs.modal', function () { - modal.find('#create-user-name').focus(); - }); - }); - return false; - }); - } - - function createUser() { - const modal = this; - const username = document.getElementById('create-user-name').value; - const email = document.getElementById('create-user-email').value; - const password = document.getElementById('create-user-password').value; - const passwordAgain = document.getElementById('create-user-password-again').value; - - const errorEl = $('#create-modal-error'); - - if (password !== passwordAgain) { - return errorEl.translateHtml('[[admin/manage/users:alerts.error-x, [[admin/manage/users:alerts.error-passwords-different]]]]').removeClass('hide'); - } - - const user = { - username: username, - email: email, - password: password, - }; - - api.post('/users', user) - .then(() => { - modal.modal('hide'); - modal.on('hidden.bs.modal', function () { - ajaxify.refresh(); - }); - alerts.success('[[admin/manage/users:alerts.create-success]]'); - }) - .catch(err => errorEl.translateHtml('[[admin/manage/users:alerts.error-x, ' + err.message + ']]').removeClass('hidden')); - } - - handleSearch(); - handleUserCreate(); - handleSort(); - handleFilter(); - AccountInvite.handle(); - }; - - function handleSearch() { - function doSearch() { - $('.fa-spinner').removeClass('hidden'); - loadSearchPage({ - searchBy: $('#user-search-by').val(), - query: $('#user-search').val(), - page: 1, - }); - } - $('#user-search').on('keyup', utils.debounce(doSearch, 250)); - $('#user-search-by').on('change', doSearch); - } - - function loadSearchPage(query) { - const params = utils.params(); - params.searchBy = query.searchBy; - params.query = query.query; - params.page = query.page; - params.sortBy = params.sortBy || 'lastonline'; - const qs = decodeURIComponent($.param(params)); - $.get(config.relative_path + '/api/admin/manage/users?' + qs, function (data) { - renderSearchResults(data); - const url = config.relative_path + '/admin/manage/users?' + qs; - if (history.pushState) { - history.pushState({ - url: url, - }, null, window.location.protocol + '//' + window.location.host + url); - } - }).fail(function (xhrErr) { - if (xhrErr && xhrErr.responseJSON && xhrErr.responseJSON.error) { - alerts.error(xhrErr.responseJSON.error); - } - }); - } - - function renderSearchResults(data) { - Benchpress.render('partials/paginator', { pagination: data.pagination }).then(function (html) { - $('.pagination-container').replaceWith(html); - }); - - app.parseAndTranslate('admin/manage/users', 'users', data, function (html) { - $('.users-table tbody tr').remove(); - $('.users-table tbody').append(html); - html.find('.timeago').timeago(); - $('.fa-spinner').addClass('hidden'); - if (!$('#user-search').val()) { - $('#user-found-notify').addClass('hidden'); - $('#user-notfound-notify').addClass('hidden'); - return; - } - if (data && data.users.length === 0) { - $('#user-notfound-notify').translateHtml('[[admin/manage/users:search.not-found]]') - .removeClass('hidden'); - $('#user-found-notify').addClass('hidden'); - } else { - $('#user-found-notify').translateHtml( - translator.compile('admin/manage/users:alerts.x-users-found', data.matchCount, data.timing) - ).removeClass('hidden'); - $('#user-notfound-notify').addClass('hidden'); - } - }); - } - - function buildSearchQuery(params) { - if ($('#user-search').val()) { - params.query = $('#user-search').val(); - params.searchBy = $('#user-search-by').val(); - } else { - delete params.query; - delete params.searchBy; - } - - return decodeURIComponent($.param(params)); - } - - function handleSort() { - $('.users-table thead th').on('click', function () { - const $this = $(this); - const sortBy = $this.attr('data-sort'); - if (!sortBy) { - return; - } - const params = utils.params(); - params.sortBy = sortBy; - if (ajaxify.data.sortBy === sortBy) { - params.sortDirection = ajaxify.data.reverse ? 'asc' : 'desc'; - } else { - params.sortDirection = 'desc'; - } - - const qs = buildSearchQuery(params); - ajaxify.go('admin/manage/users?' + qs); - }); - } - - function getFilters() { - const filters = []; - $('#filter-by').find('[data-filter-by]').each(function () { - if ($(this).find('.fa-check').length) { - filters.push($(this).attr('data-filter-by')); - } - }); - return filters; - } - - function handleFilter() { - let currentFilters = getFilters(); - $('#filter-by').on('click', 'li', function () { - const $this = $(this); - $this.find('i').toggleClass('fa-check', !$this.find('i').hasClass('fa-check')); - return false; - }); - - $('#filter-by').on('hidden.bs.dropdown', function () { - const filters = getFilters(); - let changed = filters.length !== currentFilters.length; - if (filters.length === currentFilters.length) { - filters.forEach(function (filter, i) { - if (filter !== currentFilters[i]) { - changed = true; - } - }); - } - currentFilters = getFilters(); - if (changed) { - const params = utils.params(); - params.filters = filters; - const qs = buildSearchQuery(params); - ajaxify.go('admin/manage/users?' + qs); - } - }); - } - - return Users; + 'translator', 'benchpress', 'autocomplete', 'api', 'slugify', 'bootbox', 'alerts', 'accounts/invite', +], (translator, Benchpress, autocomplete, api, slugify, bootbox, alerts, AccountInvite) => { + const Users = {}; + + Users.init = function () { + $('#results-per-page').val(ajaxify.data.resultsPerPage).on('change', () => { + const query = utils.params(); + query.resultsPerPage = $('#results-per-page').val(); + const qs = buildSearchQuery(query); + ajaxify.go(window.location.pathname + '?' + qs); + }); + + $('.export-csv').on('click', () => { + socket.once('event:export-users-csv', () => { + alerts.remove('export-users-start'); + alerts.alert({ + alert_id: 'export-users', + type: 'success', + title: '[[global:alert.success]]', + message: '[[admin/manage/users:export-users-completed]]', + clickfn() { + window.location.href = config.relative_path + '/api/admin/users/csv'; + }, + timeout: 0, + }); + }); + socket.emit('admin.user.exportUsersCSV', {}, error => { + if (error) { + return alerts.error(error); + } + + alerts.alert({ + alert_id: 'export-users-start', + message: '[[admin/manage/users:export-users-started]]', + timeout: (ajaxify.data.userCount / 5000) * 500, + }); + }); + + return false; + }); + + function getSelectedUids() { + const uids = []; + + $('.users-table [component="user/select/single"]').each(function () { + if ($(this).is(':checked')) { + uids.push($(this).attr('data-uid')); + } + }); + + return uids; + } + + function update(className, state) { + $('.users-table [component="user/select/single"]:checked').parents('.user-row').find(className).each(function () { + $(this).toggleClass('hidden', !state); + }); + } + + function unselectAll() { + $('.users-table [component="user/select/single"]').prop('checked', false); + $('.users-table [component="user/select/all"]').prop('checked', false); + } + + function removeRow(uid) { + const checkboxElement = document.querySelector(`.users-table [component="user/select/single"][data-uid="${uid}"]`); + if (checkboxElement) { + const rowElement = checkboxElement.closest('.user-row'); + rowElement.remove(); + } + } + + // Use onSuccess instead + function done(successMessage, className, flag) { + return function (error) { + if (error) { + return alerts.error(error); + } + + alerts.success(successMessage); + if (className) { + update(className, flag); + } + + unselectAll(); + }; + } + + function onSuccess(successMessage, className, flag) { + alerts.success(successMessage); + if (className) { + update(className, flag); + } + + unselectAll(); + } + + $('[component="user/select/all"]').on('click', function () { + $('.users-table [component="user/select/single"]').prop('checked', $(this).is(':checked')); + }); + + $('.manage-groups').on('click', () => { + const uids = getSelectedUids(); + if (uids.length === 0) { + alerts.error('[[error:no-users-selected]]'); + return false; + } + + socket.emit('admin.user.loadGroups', uids, (error, data) => { + if (error) { + return alerts.error(error); + } + + Benchpress.render('admin/partials/manage_user_groups', data).then(html => { + const modal = bootbox.dialog({ + message: html, + title: '[[admin/manage/users:manage-groups]]', + onEscape: true, + }); + modal.on('shown.bs.modal', () => { + autocomplete.group(modal.find('.group-search'), (event, ui) => { + const uid = $(event.target).attr('data-uid'); + api.put('/groups/' + ui.item.group.slug + '/membership/' + uid, undefined).then(() => { + ui.item.group.nameEscaped = translator.escape(ui.item.group.displayName); + app.parseAndTranslate('admin/partials/manage_user_groups', {users: [{groups: [ui.item.group]}]}, html => { + $('[data-uid=' + uid + '] .group-area').append(html.find('.group-area').html()); + }); + }).catch(alerts.error); + }); + }); + modal.on('click', '.group-area a', () => { + modal.modal('hide'); + }); + modal.on('click', '.remove-group-icon', function () { + const groupCard = $(this).parents('[data-group-name]'); + const groupName = groupCard.attr('data-group-name'); + const uid = $(this).parents('[data-uid]').attr('data-uid'); + api.del('/groups/' + slugify(groupName) + '/membership/' + uid).then(() => { + groupCard.remove(); + }).catch(alerts.error); + return false; + }); + }); + }); + }); + + $('.ban-user').on('click', () => { + const uids = getSelectedUids(); + if (uids.length === 0) { + alerts.error('[[error:no-users-selected]]'); + return false; // Specifically to keep the menu open + } + + bootbox.confirm((uids.length > 1 ? '[[admin/manage/users:alerts.confirm-ban-multi]]' : '[[admin/manage/users:alerts.confirm-ban]]'), confirm => { + if (confirm) { + Promise.all(uids.map(uid => api.put('/users/' + uid + '/ban'))).then(() => { + onSuccess('[[admin/manage/users:alerts.ban-success]]', '.ban', true); + }).catch(alerts.error); + } + }); + }); + + $('.ban-user-temporary').on('click', () => { + const uids = getSelectedUids(); + if (uids.length === 0) { + alerts.error('[[error:no-users-selected]]'); + return false; // Specifically to keep the menu open + } + + Benchpress.render('admin/partials/temporary-ban', {}).then(html => { + bootbox.dialog({ + className: 'ban-modal', + title: '[[user:ban_account]]', + message: html, + show: true, + buttons: { + close: { + label: '[[global:close]]', + className: 'btn-link', + }, + submit: { + label: '[[admin/manage/users:alerts.button-ban-x, ' + uids.length + ']]', + callback() { + const formData = $('.ban-modal form').serializeArray().reduce((data, current) => { + data[current.name] = current.value; + return data; + }, {}); + const until = formData.length > 0 ? ( + Date.now() + + (formData.length * 1000 * 60 * 60 * (Number.parseInt(formData.unit, 10) ? 24 : 1)) + ) : 0; + + Promise.all(uids.map(uid => api.put('/users/' + uid + '/ban', { + until, + reason: formData.reason, + }))).then(() => { + onSuccess('[[admin/manage/users:alerts.ban-success]]', '.ban', true); + }).catch(alerts.error); + }, + }, + }, + }); + }); + }); + + $('.unban-user').on('click', () => { + const uids = getSelectedUids(); + if (uids.length === 0) { + alerts.error('[[error:no-users-selected]]'); + return false; // Specifically to keep the menu open + } + + Promise.all(uids.map(uid => api.del('/users/' + uid + '/ban'))).then(() => { + onSuccess('[[admin/manage/users:alerts.unban-success]]', '.ban', false); + }); + }); + + $('.reset-lockout').on('click', () => { + const uids = getSelectedUids(); + if (uids.length === 0) { + return; + } + + socket.emit('admin.user.resetLockouts', uids, done('[[admin/manage/users:alerts.lockout-reset-success]]')); + }); + + $('.validate-email').on('click', () => { + const uids = getSelectedUids(); + if (uids.length === 0) { + return; + } + + bootbox.confirm('[[admin/manage/users:alerts.confirm-validate-email]]', confirm => { + if (!confirm) { + return; + } + + socket.emit('admin.user.validateEmail', uids, error => { + if (error) { + return alerts.error(error); + } + + alerts.success('[[admin/manage/users:alerts.validate-email-success]]'); + update('.notvalidated', false); + update('.validated', true); + unselectAll(); + }); + }); + }); + + $('.send-validation-email').on('click', () => { + const uids = getSelectedUids(); + if (uids.length === 0) { + return; + } + + socket.emit('admin.user.sendValidationEmail', uids, error => { + if (error) { + return alerts.error(error); + } + + alerts.success('[[notifications:email-confirm-sent]]'); + }); + }); + + $('.password-reset-email').on('click', () => { + const uids = getSelectedUids(); + if (uids.length === 0) { + return; + } + + bootbox.confirm('[[admin/manage/users:alerts.password-reset-confirm]]', confirm => { + if (confirm) { + socket.emit('admin.user.sendPasswordResetEmail', uids, done('[[admin/manage/users:alerts.password-reset-email-sent]]')); + } + }); + }); + + $('.force-password-reset').on('click', () => { + const uids = getSelectedUids(); + if (uids.length === 0) { + return; + } + + bootbox.confirm('[[admin/manage/users:alerts.confirm-force-password-reset]]', confirm => { + if (confirm) { + socket.emit('admin.user.forcePasswordReset', uids, done('[[admin/manage/users:alerts.validate-force-password-reset-success]]')); + } + }); + }); + + $('.delete-user').on('click', () => { + handleDelete('[[admin/manage/users:alerts.confirm-delete]]', '/account'); + }); + + $('.delete-user-content').on('click', () => { + handleDelete('[[admin/manage/users:alerts.confirm-delete-content]]', '/content'); + }); + + $('.delete-user-and-content').on('click', () => { + handleDelete('[[admin/manage/users:alerts.confirm-purge]]', ''); + }); + + const tableElement = document.querySelector('.users-table'); + const actionButton = document.querySelector('#action-dropdown'); + tableElement.addEventListener('change', e => { + const subselector = e.target.closest('[component="user/select/single"]') || e.target.closest('[component="user/select/all"]'); + if (subselector) { + const uids = getSelectedUids(); + if (uids.length > 0) { + actionButton.removeAttribute('disabled'); + } else { + actionButton.setAttribute('disabled', 'disabled'); + } + } + }); + + function handleDelete(confirmMessage, path) { + const uids = getSelectedUids(); + if (uids.length === 0) { + return; + } + + bootbox.confirm(confirmMessage, confirm => { + if (confirm) { + Promise.all( + uids.map( + uid => api.del(`/users/${uid}${path}`, {}).then(() => { + if (path !== '/content') { + removeRow(uid); + } + }), + ), + ).then(() => { + if (path === '/content') { + alerts.success('[[admin/manage/users:alerts.delete-content-success]]'); + } else { + alerts.success('[[admin/manage/users:alerts.delete-success]]'); + } + + unselectAll(); + if ($('.users-table [component="user/select/single"]').length === 0) { + ajaxify.refresh(); + } + }).catch(alerts.error); + } + }); + } + + function handleUserCreate() { + $('[data-action="create"]').on('click', () => { + Benchpress.render('admin/partials/create_user_modal', {}).then(html => { + const modal = bootbox.dialog({ + message: html, + title: '[[admin/manage/users:alerts.create]]', + onEscape: true, + buttons: { + cancel: { + label: '[[admin/manage/users:alerts.button-cancel]]', + className: 'btn-link', + }, + create: { + label: '[[admin/manage/users:alerts.button-create]]', + className: 'btn-primary', + callback() { + createUser.call(this); + return false; + }, + }, + }, + }); + modal.on('shown.bs.modal', () => { + modal.find('#create-user-name').focus(); + }); + }); + return false; + }); + } + + function createUser() { + const modal = this; + const username = document.querySelector('#create-user-name').value; + const email = document.querySelector('#create-user-email').value; + const password = document.querySelector('#create-user-password').value; + const passwordAgain = document.querySelector('#create-user-password-again').value; + + const errorElement = $('#create-modal-error'); + + if (password !== passwordAgain) { + return errorElement.translateHtml('[[admin/manage/users:alerts.error-x, [[admin/manage/users:alerts.error-passwords-different]]]]').removeClass('hide'); + } + + const user = { + username, + email, + password, + }; + + api.post('/users', user) + .then(() => { + modal.modal('hide'); + modal.on('hidden.bs.modal', () => { + ajaxify.refresh(); + }); + alerts.success('[[admin/manage/users:alerts.create-success]]'); + }) + .catch(error => errorElement.translateHtml('[[admin/manage/users:alerts.error-x, ' + error.message + ']]').removeClass('hidden')); + } + + handleSearch(); + handleUserCreate(); + handleSort(); + handleFilter(); + AccountInvite.handle(); + }; + + function handleSearch() { + function doSearch() { + $('.fa-spinner').removeClass('hidden'); + loadSearchPage({ + searchBy: $('#user-search-by').val(), + query: $('#user-search').val(), + page: 1, + }); + } + + $('#user-search').on('keyup', utils.debounce(doSearch, 250)); + $('#user-search-by').on('change', doSearch); + } + + function loadSearchPage(query) { + const parameters = utils.params(); + parameters.searchBy = query.searchBy; + parameters.query = query.query; + parameters.page = query.page; + parameters.sortBy = parameters.sortBy || 'lastonline'; + const qs = decodeURIComponent($.param(parameters)); + $.get(config.relative_path + '/api/admin/manage/users?' + qs, data => { + renderSearchResults(data); + const url = config.relative_path + '/admin/manage/users?' + qs; + if (history.pushState) { + history.pushState({ + url, + }, null, window.location.protocol + '//' + window.location.host + url); + } + }).fail(xhrError => { + if (xhrError && xhrError.responseJSON && xhrError.responseJSON.error) { + alerts.error(xhrError.responseJSON.error); + } + }); + } + + function renderSearchResults(data) { + Benchpress.render('partials/paginator', {pagination: data.pagination}).then(html => { + $('.pagination-container').replaceWith(html); + }); + + app.parseAndTranslate('admin/manage/users', 'users', data, html => { + $('.users-table tbody tr').remove(); + $('.users-table tbody').append(html); + html.find('.timeago').timeago(); + $('.fa-spinner').addClass('hidden'); + if (!$('#user-search').val()) { + $('#user-found-notify').addClass('hidden'); + $('#user-notfound-notify').addClass('hidden'); + return; + } + + if (data && data.users.length === 0) { + $('#user-notfound-notify').translateHtml('[[admin/manage/users:search.not-found]]') + .removeClass('hidden'); + $('#user-found-notify').addClass('hidden'); + } else { + $('#user-found-notify').translateHtml( + translator.compile('admin/manage/users:alerts.x-users-found', data.matchCount, data.timing), + ).removeClass('hidden'); + $('#user-notfound-notify').addClass('hidden'); + } + }); + } + + function buildSearchQuery(parameters) { + if ($('#user-search').val()) { + parameters.query = $('#user-search').val(); + parameters.searchBy = $('#user-search-by').val(); + } else { + delete parameters.query; + delete parameters.searchBy; + } + + return decodeURIComponent($.param(parameters)); + } + + function handleSort() { + $('.users-table thead th').on('click', function () { + const $this = $(this); + const sortBy = $this.attr('data-sort'); + if (!sortBy) { + return; + } + + const parameters = utils.params(); + parameters.sortBy = sortBy; + if (ajaxify.data.sortBy === sortBy) { + parameters.sortDirection = ajaxify.data.reverse ? 'asc' : 'desc'; + } else { + parameters.sortDirection = 'desc'; + } + + const qs = buildSearchQuery(parameters); + ajaxify.go('admin/manage/users?' + qs); + }); + } + + function getFilters() { + const filters = []; + $('#filter-by').find('[data-filter-by]').each(function () { + if ($(this).find('.fa-check').length > 0) { + filters.push($(this).attr('data-filter-by')); + } + }); + return filters; + } + + function handleFilter() { + let currentFilters = getFilters(); + $('#filter-by').on('click', 'li', function () { + const $this = $(this); + $this.find('i').toggleClass('fa-check', !$this.find('i').hasClass('fa-check')); + return false; + }); + + $('#filter-by').on('hidden.bs.dropdown', () => { + const filters = getFilters(); + let changed = filters.length !== currentFilters.length; + if (filters.length === currentFilters.length) { + for (const [i, filter] of filters.entries()) { + if (filter !== currentFilters[i]) { + changed = true; + } + } + } + + currentFilters = getFilters(); + if (changed) { + const parameters = utils.params(); + parameters.filters = filters; + const qs = buildSearchQuery(parameters); + ajaxify.go('admin/manage/users?' + qs); + } + }); + } + + return Users; }); diff --git a/public/src/admin/modules/checkboxRowSelector.js b/public/src/admin/modules/checkboxRowSelector.js index af22331..f13e761 100644 --- a/public/src/admin/modules/checkboxRowSelector.js +++ b/public/src/admin/modules/checkboxRowSelector.js @@ -1,49 +1,51 @@ 'use strict'; -define('admin/modules/checkboxRowSelector', function () { - const self = {}; - let $tableContainer; - - self.toggling = false; - - self.init = function (tableCssSelector) { - $tableContainer = $(tableCssSelector); - $tableContainer.on('change', 'input.checkbox-helper', handleChange); - }; - - self.updateAll = function () { - $tableContainer.find('input.checkbox-helper').each((idx, el) => { - self.updateState($(el)); - }); - }; - - self.updateState = function ($checkboxEl) { - if (self.toggling) { - return; - } - const checkboxes = $checkboxEl.closest('tr').find('input:not([disabled]):visible').toArray(); - const $toggler = $(checkboxes.shift()); - const rowState = checkboxes.length && checkboxes.every(el => el.checked); - $toggler.prop('checked', rowState); - }; - - function handleChange(ev) { - const $checkboxEl = $(ev.target); - toggleAll($checkboxEl); - } - - function toggleAll($checkboxEl) { - self.toggling = true; - const state = $checkboxEl.prop('checked'); - $checkboxEl.closest('tr').find('input:not(.checkbox-helper):visible').each((idx, el) => { - const $checkbox = $(el); - if ($checkbox.prop('checked') === state) { - return; - } - $checkbox.click(); - }); - self.toggling = false; - } - - return self; +define('admin/modules/checkboxRowSelector', () => { + const self = {}; + let $tableContainer; + + self.toggling = false; + + self.init = function (tableCssSelector) { + $tableContainer = $(tableCssSelector); + $tableContainer.on('change', 'input.checkbox-helper', handleChange); + }; + + self.updateAll = function () { + $tableContainer.find('input.checkbox-helper').each((index, element) => { + self.updateState($(element)); + }); + }; + + self.updateState = function ($checkboxElement) { + if (self.toggling) { + return; + } + + const checkboxes = $checkboxElement.closest('tr').find('input:not([disabled]):visible').toArray(); + const $toggler = $(checkboxes.shift()); + const rowState = checkboxes.length && checkboxes.every(element => element.checked); + $toggler.prop('checked', rowState); + }; + + function handleChange(event) { + const $checkboxElement = $(event.target); + toggleAll($checkboxElement); + } + + function toggleAll($checkboxElement) { + self.toggling = true; + const state = $checkboxElement.prop('checked'); + $checkboxElement.closest('tr').find('input:not(.checkbox-helper):visible').each((index, element) => { + const $checkbox = $(element); + if ($checkbox.prop('checked') === state) { + return; + } + + $checkbox.click(); + }); + self.toggling = false; + } + + return self; }); diff --git a/public/src/admin/modules/colorpicker.js b/public/src/admin/modules/colorpicker.js index ff42b0f..b77b589 100644 --- a/public/src/admin/modules/colorpicker.js +++ b/public/src/admin/modules/colorpicker.js @@ -1,31 +1,31 @@ 'use strict'; // TODO: no longer used remove in 1.19.0 -define('admin/modules/colorpicker', function () { - const colorpicker = {}; +define('admin/modules/colorpicker', () => { + const colorpicker = {}; - colorpicker.enable = function (inputEl, callback) { - (inputEl instanceof jQuery ? inputEl : $(inputEl)).each(function () { - const $this = $(this); + colorpicker.enable = function (inputElement, callback) { + (inputElement instanceof jQuery ? inputElement : $(inputElement)).each(function () { + const $this = $(this); - $this.ColorPicker({ - color: $this.val() || '#000', - onChange: function (hsb, hex) { - $this.val('#' + hex); - if (typeof callback === 'function') { - callback(hsb, hex); - } - }, - onShow: function (colpkr) { - $(colpkr).css('z-index', 1051); - }, - }); + $this.ColorPicker({ + color: $this.val() || '#000', + onChange(hsb, hex) { + $this.val('#' + hex); + if (typeof callback === 'function') { + callback(hsb, hex); + } + }, + onShow(colpkr) { + $(colpkr).css('z-index', 1051); + }, + }); - $(window).one('action:ajaxify.start', function () { - $this.ColorPickerHide(); - }); - }); - }; + $(window).one('action:ajaxify.start', () => { + $this.ColorPickerHide(); + }); + }); + }; - return colorpicker; + return colorpicker; }); diff --git a/public/src/admin/modules/dashboard-line-graph.js b/public/src/admin/modules/dashboard-line-graph.js index 4de2154..9462104 100644 --- a/public/src/admin/modules/dashboard-line-graph.js +++ b/public/src/admin/modules/dashboard-line-graph.js @@ -1,196 +1,195 @@ 'use strict'; -define('admin/modules/dashboard-line-graph', ['Chart', 'translator', 'benchpress', 'api', 'hooks', 'bootbox'], function (Chart, translator, Benchpress, api, hooks, bootbox) { - const Graph = { - _current: null, - }; - let isMobile = false; - - Graph.init = ({ set, dataset }) => { - const canvas = document.getElementById('analytics-traffic'); - const canvasCtx = canvas.getContext('2d'); - const trafficLabels = utils.getHoursArray(); - - isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); - if (isMobile) { - Chart.defaults.global.tooltips.enabled = false; - } - - Graph.handleUpdateControls({ set }); - - const t = translator.Translator.create(); - return new Promise((resolve) => { - t.translateKey(`admin/menu:${ajaxify.data.template.name.replace('admin/', '')}`, []).then((key) => { - const data = { - labels: trafficLabels, - datasets: [ - { - label: key, - backgroundColor: 'rgba(151,187,205,0.2)', - borderColor: 'rgba(151,187,205,1)', - pointBackgroundColor: 'rgba(151,187,205,1)', - pointHoverBackgroundColor: 'rgba(151,187,205,1)', - pointBorderColor: '#fff', - pointHoverBorderColor: 'rgba(151,187,205,1)', - data: dataset || [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - }, - ], - }; - - canvas.width = $(canvas).parent().width(); - - data.datasets[0].yAxisID = 'left-y-axis'; - - Graph._current = new Chart(canvasCtx, { - type: 'line', - data: data, - options: { - responsive: true, - legend: { - display: true, - }, - scales: { - yAxes: [{ - id: 'left-y-axis', - ticks: { - beginAtZero: true, - precision: 0, - }, - type: 'linear', - position: 'left', - scaleLabel: { - display: true, - labelString: key, - }, - }], - }, - tooltips: { - mode: 'x', - }, - }, - }); - - if (!dataset) { - Graph.update(set).then(resolve); - } else { - resolve(Graph._current); - } - }); - }); - }; - - Graph.handleUpdateControls = ({ set }) => { - $('[data-action="updateGraph"]:not([data-units="custom"])').on('click', function () { - let until = new Date(); - const amount = $(this).attr('data-amount'); - if ($(this).attr('data-units') === 'days') { - until.setHours(0, 0, 0, 0); - } - until = until.getTime(); - Graph.update(set, $(this).attr('data-units'), until, amount); - - require(['translator'], function (translator) { - translator.translate('[[admin/dashboard:page-views-custom]]', function (translated) { - $('[data-action="updateGraph"][data-units="custom"]').text(translated); - }); - }); - }); - - $('[data-action="updateGraph"][data-units="custom"]').on('click', function () { - const targetEl = $(this); - - Benchpress.render('admin/partials/pageviews-range-select', {}).then(function (html) { - const modal = bootbox.dialog({ - title: '[[admin/dashboard:page-views-custom]]', - message: html, - buttons: { - submit: { - label: '[[global:search]]', - className: 'btn-primary', - callback: submit, - }, - }, - }).on('shown.bs.modal', function () { - const date = new Date(); - const today = date.toISOString().slice(0, 10); - date.setDate(date.getDate() - 1); - const yesterday = date.toISOString().slice(0, 10); - - modal.find('#startRange').val(targetEl.attr('data-startRange') || yesterday); - modal.find('#endRange').val(targetEl.attr('data-endRange') || today); - }); - - function submit() { - // NEED TO ADD VALIDATION HERE FOR YYYY-MM-DD - const formData = modal.find('form').serializeObject(); - const validRegexp = /\d{4}-\d{2}-\d{2}/; - - // Input validation - if (!formData.startRange && !formData.endRange) { - // No range? Assume last 30 days - Graph.update(set, 'days'); - return; - } else if (!validRegexp.test(formData.startRange) || !validRegexp.test(formData.endRange)) { - // Invalid Input - modal.find('.alert-danger').removeClass('hidden'); - return false; - } - - let until = new Date(formData.endRange); - until.setDate(until.getDate() + 1); - until = until.getTime(); - const amount = (until - new Date(formData.startRange).getTime()) / (1000 * 60 * 60 * 24); - - Graph.update(set, 'days', until, amount); - - // Update "custom range" label - targetEl.attr('data-startRange', formData.startRange); - targetEl.attr('data-endRange', formData.endRange); - targetEl.html(formData.startRange + ' – ' + formData.endRange); - } - }); - }); - }; - - Graph.update = ( - set, - units = ajaxify.data.query.units || 'hours', - until = ajaxify.data.query.until, - amount = ajaxify.data.query.count - ) => { - if (!Graph._current) { - return Promise.reject(new Error('[[error:invalid-data]]')); - } - - return new Promise((resolve) => { - api.get(`/admin/analytics/${set}`, { units, until, amount }).then((dataset) => { - if (units === 'days') { - Graph._current.data.xLabels = utils.getDaysArray(until, amount); - } else { - Graph._current.data.xLabels = utils.getHoursArray(); - } - - Graph._current.data.datasets[0].data = dataset; - Graph._current.data.labels = Graph._current.data.xLabels; - Graph._current.update(); - - // Update address bar and "View as JSON" button url - const apiEl = $('#view-as-json'); - const newHref = $.param({ - units: units || 'hours', - until: until, - count: amount, - }); - apiEl.attr('href', `${config.relative_path}/api/v3/admin/analytics/${ajaxify.data.set}?${newHref}`); - const url = ajaxify.removeRelativePath(ajaxify.data.url.slice(1)); - ajaxify.updateHistory(`${url}?${newHref}`, true); - hooks.fire('action:admin.dashboard.updateGraph', { - graph: Graph._current, - }); - resolve(Graph._current); - }); - }); - }; - - return Graph; +define('admin/modules/dashboard-line-graph', ['Chart', 'translator', 'benchpress', 'api', 'hooks', 'bootbox'], (Chart, translator, Benchpress, api, hooks, bootbox) => { + const Graph = { + _current: null, + }; + let isMobile = false; + + Graph.init = ({set, dataset}) => { + const canvas = document.querySelector('#analytics-traffic'); + const canvasContext = canvas.getContext('2d'); + const trafficLabels = utils.getHoursArray(); + + isMobile = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(navigator.userAgent); + if (isMobile) { + Chart.defaults.global.tooltips.enabled = false; + } + + Graph.handleUpdateControls({set}); + + const t = translator.Translator.create(); + return new Promise(resolve => { + t.translateKey(`admin/menu:${ajaxify.data.template.name.replace('admin/', '')}`, []).then(key => { + const data = { + labels: trafficLabels, + datasets: [ + { + label: key, + backgroundColor: 'rgba(151,187,205,0.2)', + borderColor: 'rgba(151,187,205,1)', + pointBackgroundColor: 'rgba(151,187,205,1)', + pointHoverBackgroundColor: 'rgba(151,187,205,1)', + pointBorderColor: '#fff', + pointHoverBorderColor: 'rgba(151,187,205,1)', + data: dataset || [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, + ], + }; + + canvas.width = $(canvas).parent().width(); + + data.datasets[0].yAxisID = 'left-y-axis'; + + Graph._current = new Chart(canvasContext, { + type: 'line', + data, + options: { + responsive: true, + legend: { + display: true, + }, + scales: { + yAxes: [{ + id: 'left-y-axis', + ticks: { + beginAtZero: true, + precision: 0, + }, + type: 'linear', + position: 'left', + scaleLabel: { + display: true, + labelString: key, + }, + }], + }, + tooltips: { + mode: 'x', + }, + }, + }); + + if (dataset) { + resolve(Graph._current); + } else { + Graph.update(set).then(resolve); + } + }); + }); + }; + + Graph.handleUpdateControls = ({set}) => { + $('[data-action="updateGraph"]:not([data-units="custom"])').on('click', function () { + let until = new Date(); + const amount = $(this).attr('data-amount'); + if ($(this).attr('data-units') === 'days') { + until.setHours(0, 0, 0, 0); + } + + until = until.getTime(); + Graph.update(set, $(this).attr('data-units'), until, amount); + + require(['translator'], translator => { + translator.translate('[[admin/dashboard:page-views-custom]]', translated => { + $('[data-action="updateGraph"][data-units="custom"]').text(translated); + }); + }); + }); + + $('[data-action="updateGraph"][data-units="custom"]').on('click', function () { + const targetElement = $(this); + + Benchpress.render('admin/partials/pageviews-range-select', {}).then(html => { + const modal = bootbox.dialog({ + title: '[[admin/dashboard:page-views-custom]]', + message: html, + buttons: { + submit: { + label: '[[global:search]]', + className: 'btn-primary', + callback: submit, + }, + }, + }).on('shown.bs.modal', () => { + const date = new Date(); + const today = date.toISOString().slice(0, 10); + date.setDate(date.getDate() - 1); + const yesterday = date.toISOString().slice(0, 10); + + modal.find('#startRange').val(targetElement.attr('data-startRange') || yesterday); + modal.find('#endRange').val(targetElement.attr('data-endRange') || today); + }); + + function submit() { + // NEED TO ADD VALIDATION HERE FOR YYYY-MM-DD + const formData = modal.find('form').serializeObject(); + const validRegexp = /\d{4}-\d{2}-\d{2}/; + + // Input validation + if (!formData.startRange && !formData.endRange) { + // No range? Assume last 30 days + Graph.update(set, 'days'); + return; + } + + if (!validRegexp.test(formData.startRange) || !validRegexp.test(formData.endRange)) { + // Invalid Input + modal.find('.alert-danger').removeClass('hidden'); + return false; + } + + let until = new Date(formData.endRange); + until.setDate(until.getDate() + 1); + until = until.getTime(); + const amount = (until - new Date(formData.startRange).getTime()) / (1000 * 60 * 60 * 24); + + Graph.update(set, 'days', until, amount); + + // Update "custom range" label + targetElement.attr('data-startRange', formData.startRange); + targetElement.attr('data-endRange', formData.endRange); + targetElement.html(formData.startRange + ' – ' + formData.endRange); + } + }); + }); + }; + + Graph.update = ( + set, + units = ajaxify.data.query.units || 'hours', + until = ajaxify.data.query.until, + amount = ajaxify.data.query.count, + ) => { + if (!Graph._current) { + return Promise.reject(new Error('[[error:invalid-data]]')); + } + + return new Promise(resolve => { + api.get(`/admin/analytics/${set}`, {units, until, amount}).then(dataset => { + Graph._current.data.xLabels = units === 'days' ? utils.getDaysArray(until, amount) : utils.getHoursArray(); + + Graph._current.data.datasets[0].data = dataset; + Graph._current.data.labels = Graph._current.data.xLabels; + Graph._current.update(); + + // Update address bar and "View as JSON" button url + const apiElement = $('#view-as-json'); + const newHref = $.param({ + units: units || 'hours', + until, + count: amount, + }); + apiElement.attr('href', `${config.relative_path}/api/v3/admin/analytics/${ajaxify.data.set}?${newHref}`); + const url = ajaxify.removeRelativePath(ajaxify.data.url.slice(1)); + ajaxify.updateHistory(`${url}?${newHref}`, true); + hooks.fire('action:admin.dashboard.updateGraph', { + graph: Graph._current, + }); + resolve(Graph._current); + }); + }); + }; + + return Graph; }); diff --git a/public/src/admin/modules/instance.js b/public/src/admin/modules/instance.js index af5eaba..0f9ac3e 100644 --- a/public/src/admin/modules/instance.js +++ b/public/src/admin/modules/instance.js @@ -1,66 +1,66 @@ 'use strict'; define('admin/modules/instance', [ - 'alerts', -], function (alerts) { - const instance = {}; + 'alerts', +], alerts => { + const instance = {}; - instance.rebuildAndRestart = function (callback) { - alerts.alert({ - alert_id: 'instance_rebuild_and_restart', - type: 'info', - title: 'Rebuilding... ', - message: 'NodeBB is rebuilding front-end assets (css, javascript, etc).', - }); + instance.rebuildAndRestart = function (callback) { + alerts.alert({ + alert_id: 'instance_rebuild_and_restart', + type: 'info', + title: 'Rebuilding... ', + message: 'NodeBB is rebuilding front-end assets (css, javascript, etc).', + }); - $(window).one('action:reconnected', function () { - alerts.alert({ - alert_id: 'instance_rebuild_and_restart', - type: 'success', - title: ' Success', - message: 'NodeBB has rebuilt and restarted successfully.', - timeout: 5000, - }); + $(window).one('action:reconnected', () => { + alerts.alert({ + alert_id: 'instance_rebuild_and_restart', + type: 'success', + title: ' Success', + message: 'NodeBB has rebuilt and restarted successfully.', + timeout: 5000, + }); - if (typeof callback === 'function') { - callback(); - } - }); + if (typeof callback === 'function') { + callback(); + } + }); - socket.emit('admin.reload', function () { - alerts.alert({ - alert_id: 'instance_rebuild_and_restart', - type: 'info', - title: 'Build Complete!... ', - message: 'NodeBB is restarting.', - }); - }); - }; + socket.emit('admin.reload', () => { + alerts.alert({ + alert_id: 'instance_rebuild_and_restart', + type: 'info', + title: 'Build Complete!... ', + message: 'NodeBB is restarting.', + }); + }); + }; - instance.restart = function (callback) { - alerts.alert({ - alert_id: 'instance_restart', - type: 'info', - title: 'Restarting... ', - message: 'NodeBB is restarting.', - }); + instance.restart = function (callback) { + alerts.alert({ + alert_id: 'instance_restart', + type: 'info', + title: 'Restarting... ', + message: 'NodeBB is restarting.', + }); - $(window).one('action:reconnected', function () { - alerts.alert({ - alert_id: 'instance_restart', - type: 'success', - title: ' Success', - message: 'NodeBB has restarted successfully.', - timeout: 5000, - }); + $(window).one('action:reconnected', () => { + alerts.alert({ + alert_id: 'instance_restart', + type: 'success', + title: ' Success', + message: 'NodeBB has restarted successfully.', + timeout: 5000, + }); - if (typeof callback === 'function') { - callback(); - } - }); + if (typeof callback === 'function') { + callback(); + } + }); - socket.emit('admin.restart'); - }; + socket.emit('admin.restart'); + }; - return instance; + return instance; }); diff --git a/public/src/admin/modules/search.js b/public/src/admin/modules/search.js index 8588ee8..1848b26 100644 --- a/public/src/admin/modules/search.js +++ b/public/src/admin/modules/search.js @@ -1,164 +1,166 @@ 'use strict'; -define('admin/modules/search', ['mousetrap', 'alerts'], function (mousetrap, alerts) { - const search = {}; - - function find(dict, term) { - const html = dict.filter(function (elem) { - return elem.translations.toLowerCase().includes(term); - }).map(function (params) { - const namespace = params.namespace; - const translations = params.translations; - let title = params.title; - const escaped = utils.escapeRegexChars(term); - - const results = translations - // remove all lines without a match - .replace(new RegExp('^(?:(?!' + escaped + ').)*$', 'gmi'), '') - // remove lines that only match the title - .replace(new RegExp('(^|\\n).*?' + title + '.*?(\\n|$)', 'g'), '') - // get up to 25 characters of context on both sides of the match - // and wrap the match in a `.search-match` element - .replace( - new RegExp('^[\\s\\S]*?(.{0,25})(' + escaped + ')(.{0,25})[\\s\\S]*?$', 'gmi'), - '...$1$2$3...
    ' - ) - // collapse whitespace - .replace(/(?:\n ?)+/g, '\n') - .trim(); - - title = title.replace( - new RegExp('(^.*?)(' + escaped + ')(.*?$)', 'gi'), - '$1$2$3' - ); - - return ''; - }).join(''); - return html; - } - - search.init = function () { - if (!app.user.privileges['admin:settings']) { - return; - } - - socket.emit('admin.getSearchDict', {}, function (err, dict) { - if (err) { - alerts.error(err); - throw err; - } - setupACPSearch(dict); - }); - }; - - function setupACPSearch(dict) { - const dropdown = $('#acp-search .dropdown'); - const menu = $('#acp-search .dropdown-menu'); - const input = $('#acp-search input'); - const placeholderText = dropdown.attr('data-text'); - - if (!config.searchEnabled) { - menu.addClass('search-disabled'); - } - - input.on('keyup', function () { - dropdown.addClass('open'); - }); - - $('#acp-search').parents('form').on('submit', function (ev) { - const query = input.val(); - const selected = menu.get(0).querySelector('li.result > a.focus') || menu.get(0).querySelector('li.result > a'); - const href = selected ? selected.getAttribute('href') : config.relative_path + '/search?in=titlesposts&term=' + escape(query); - - ajaxify.go(href.replace(/^\//, '')); - - setTimeout(function () { - dropdown.removeClass('open'); - input.blur(); - dropdown.attr('data-text', query || placeholderText); - }, 150); - - ev.preventDefault(); - return false; - }); - - mousetrap.bind('/', function (ev) { - input.select(); - ev.preventDefault(); - }); - - mousetrap(input[0]).bind(['up', 'down'], function (ev, key) { - let next; - if (key === 'up') { - next = menu.find('li.result > a.focus').removeClass('focus').parent().prev('.result') - .children(); - if (!next.length) { - next = menu.find('li.result > a').last(); - } - next.addClass('focus'); - if (menu[0].getBoundingClientRect().top > next[0].getBoundingClientRect().top) { - next[0].scrollIntoView(true); - } - } else if (key === 'down') { - next = menu.find('li.result > a.focus').removeClass('focus').parent().next('.result') - .children(); - if (!next.length) { - next = menu.find('li.result > a').first(); - } - next.addClass('focus'); - if (menu[0].getBoundingClientRect().bottom < next[0].getBoundingClientRect().bottom) { - next[0].scrollIntoView(false); - } - } - - ev.preventDefault(); - }); - - let prevValue; - - input.on('keyup focus', function () { - const value = input.val().toLowerCase(); - - if (value === prevValue) { - return; - } - prevValue = value; - - menu.children('.result').remove(); - - const len = /\W/.test(value) ? 3 : value.length; - let results; - - menu.toggleClass('state-start-typing', len === 0); - menu.toggleClass('state-keep-typing', len > 0 && len < 3); - - if (len >= 3) { - menu.prepend(find(dict, value)); - - results = menu.children('.result').length; - - menu.toggleClass('state-no-results', !results); - menu.toggleClass('state-yes-results', !!results); - - menu.find('.search-forum') - .not('.divider') - .find('a') - .attr('href', config.relative_path + '/search?in=titlesposts&term=' + escape(value)) - .find('strong') - .text(value); - } else { - menu.removeClass('state-no-results state-yes-results'); - } - }); - } - - return search; +define('admin/modules/search', ['mousetrap', 'alerts'], (mousetrap, alerts) => { + const search = {}; + + function find(dictionary, term) { + const html = dictionary.filter(element => element.translations.toLowerCase().includes(term)).map(parameters => { + const namespace = parameters.namespace; + const translations = parameters.translations; + let title = parameters.title; + const escaped = utils.escapeRegexChars(term); + + const results = translations + // Remove all lines without a match + .replaceAll(new RegExp('^(?:(?!' + escaped + ').)*$', 'gmi'), '') + // Remove lines that only match the title + .replaceAll(new RegExp('(^|\\n).*?' + title + '.*?(\\n|$)', 'g'), '') + // Get up to 25 characters of context on both sides of the match + // and wrap the match in a `.search-match` element + .replaceAll( + new RegExp('^[\\s\\S]*?(.{0,25})(' + escaped + ')(.{0,25})[\\s\\S]*?$', 'gmi'), + '...$1$2$3...
    ', + ) + // Collapse whitespace + .replaceAll(/(?:\n ?)+/g, '\n') + .trim(); + + title = title.replaceAll( + new RegExp('(^.*?)(' + escaped + ')(.*?$)', 'gi'), + '$1$2$3', + ); + + return ''; + }).join(''); + return html; + } + + search.init = function () { + if (!app.user.privileges['admin:settings']) { + return; + } + + socket.emit('admin.getSearchDict', {}, (error, dictionary) => { + if (error) { + alerts.error(error); + throw error; + } + + setupACPSearch(dictionary); + }); + }; + + function setupACPSearch(dictionary) { + const dropdown = $('#acp-search .dropdown'); + const menu = $('#acp-search .dropdown-menu'); + const input = $('#acp-search input'); + const placeholderText = dropdown.attr('data-text'); + + if (!config.searchEnabled) { + menu.addClass('search-disabled'); + } + + input.on('keyup', () => { + dropdown.addClass('open'); + }); + + $('#acp-search').parents('form').on('submit', event => { + const query = input.val(); + const selected = menu.get(0).querySelector('li.result > a.focus') || menu.get(0).querySelector('li.result > a'); + const href = selected ? selected.getAttribute('href') : config.relative_path + '/search?in=titlesposts&term=' + escape(query); + + ajaxify.go(href.replace(/^\//, '')); + + setTimeout(() => { + dropdown.removeClass('open'); + input.blur(); + dropdown.attr('data-text', query || placeholderText); + }, 150); + + event.preventDefault(); + return false; + }); + + mousetrap.bind('/', event => { + input.select(); + event.preventDefault(); + }); + + mousetrap(input[0]).bind(['up', 'down'], (event, key) => { + let next; + if (key === 'up') { + next = menu.find('li.result > a.focus').removeClass('focus').parent().prev('.result') + .children(); + if (next.length === 0) { + next = menu.find('li.result > a').last(); + } + + next.addClass('focus'); + if (menu[0].getBoundingClientRect().top > next[0].getBoundingClientRect().top) { + next[0].scrollIntoView(true); + } + } else if (key === 'down') { + next = menu.find('li.result > a.focus').removeClass('focus').parent().next('.result') + .children(); + if (next.length === 0) { + next = menu.find('li.result > a').first(); + } + + next.addClass('focus'); + if (menu[0].getBoundingClientRect().bottom < next[0].getBoundingClientRect().bottom) { + next[0].scrollIntoView(false); + } + } + + event.preventDefault(); + }); + + let previousValue; + + input.on('keyup focus', () => { + const value = input.val().toLowerCase(); + + if (value === previousValue) { + return; + } + + previousValue = value; + + menu.children('.result').remove(); + + const length = /\W/.test(value) ? 3 : value.length; + let results; + + menu.toggleClass('state-start-typing', length === 0); + menu.toggleClass('state-keep-typing', length > 0 && length < 3); + + if (length >= 3) { + menu.prepend(find(dictionary, value)); + + results = menu.children('.result').length; + + menu.toggleClass('state-no-results', !results); + menu.toggleClass('state-yes-results', Boolean(results)); + + menu.find('.search-forum') + .not('.divider') + .find('a') + .attr('href', config.relative_path + '/search?in=titlesposts&term=' + escape(value)) + .find('strong') + .text(value); + } else { + menu.removeClass('state-no-results state-yes-results'); + } + }); + } + + return search; }); diff --git a/public/src/admin/modules/selectable.js b/public/src/admin/modules/selectable.js index 064633b..4d02a44 100644 --- a/public/src/admin/modules/selectable.js +++ b/public/src/admin/modules/selectable.js @@ -1,16 +1,15 @@ 'use strict'; - define('admin/modules/selectable', [ - 'jquery-ui/widgets/selectable', -], function () { - const selectable = {}; + 'jquery-ui/widgets/selectable', +], () => { + const selectable = {}; - selectable.enable = function (containerEl, targets) { - $(containerEl).selectable({ - filter: targets, - }); - }; + selectable.enable = function (containerElement, targets) { + $(containerElement).selectable({ + filter: targets, + }); + }; - return selectable; + return selectable; }); diff --git a/public/src/admin/settings.js b/public/src/admin/settings.js index 531f141..de78704 100644 --- a/public/src/admin/settings.js +++ b/public/src/admin/settings.js @@ -1,200 +1,201 @@ 'use strict'; - -define('admin/settings', ['uploader', 'mousetrap', 'hooks', 'alerts', 'settings'], function (uploader, mousetrap, hooks, alerts, settings) { - const Settings = {}; - - Settings.populateTOC = function () { - const headers = $('.settings-header'); - - if (headers.length > 1) { - headers.each(function () { - const header = $(this).text(); - const anchor = header.toLowerCase().replace(/ /g, '-').trim(); - - $(this).prepend(''); - $('.section-content ul').append('
  • ' + header + '
  • '); - }); - - const scrollTo = $('a[name="' + window.location.hash.replace('#', '') + '"]'); - if (scrollTo.length) { - $('html, body').animate({ - scrollTop: (scrollTo.offset().top) + 'px', - }, 400); - } - } else { - $('.content-header').parents('.row').remove(); - } - }; - - Settings.prepare = function (callback) { - // Populate the fields on the page from the config - const fields = $('#content [data-field]'); - const numFields = fields.length; - const saveBtn = $('#save'); - const revertBtn = $('#revert'); - let x; - let key; - let inputType; - let field; - - // Handle unsaved changes - fields.on('change', function () { - app.flags = app.flags || {}; - app.flags._unsaved = true; - }); - const defaultInputs = ['text', 'hidden', 'password', 'textarea', 'number']; - for (x = 0; x < numFields; x += 1) { - field = fields.eq(x); - key = field.attr('data-field'); - inputType = field.attr('type'); - if (app.config.hasOwnProperty(key)) { - if (field.is('input') && inputType === 'checkbox') { - const checked = parseInt(app.config[key], 10) === 1; - field.prop('checked', checked); - field.parents('.mdl-switch').toggleClass('is-checked', checked); - } else if (field.is('textarea') || field.is('select') || (field.is('input') && defaultInputs.indexOf(inputType) !== -1)) { - field.val(app.config[key]); - } - } - } - - revertBtn.off('click').on('click', function () { - ajaxify.refresh(); - }); - - saveBtn.off('click').on('click', function (e) { - e.preventDefault(); - - const ok = settings.check(document.querySelectorAll('#content [data-field]')); - if (!ok) { - return; - } - - saveFields(fields, function onFieldsSaved(err) { - if (err) { - return alerts.alert({ - alert_id: 'config_status', - timeout: 2500, - title: '[[admin/admin:changes-not-saved]]', - message: `[[admin/admin:changes-not-saved-message, ${err.message}]]`, - type: 'danger', - }); - } - - app.flags._unsaved = false; - - alerts.alert({ - alert_id: 'config_status', - timeout: 2500, - title: '[[admin/admin:changes-saved]]', - message: '[[admin/admin:changes-saved-message]]', - type: 'success', - }); - - hooks.fire('action:admin.settingsSaved'); - }); - }); - - mousetrap.bind('ctrl+s', function (ev) { - saveBtn.click(); - ev.preventDefault(); - }); - - handleUploads(); - setupTagsInput(); - - $('#clear-sitemap-cache').off('click').on('click', function () { - socket.emit('admin.settings.clearSitemapCache', function () { - alerts.success('Sitemap Cache Cleared!'); - }); - return false; - }); - - if (typeof callback === 'function') { - callback(); - } - - setTimeout(function () { - hooks.fire('action:admin.settingsLoaded'); - }, 0); - }; - - function handleUploads() { - $('#content input[data-action="upload"]').each(function () { - const uploadBtn = $(this); - uploadBtn.on('click', function () { - uploader.show({ - title: uploadBtn.attr('data-title'), - description: uploadBtn.attr('data-description'), - route: uploadBtn.attr('data-route'), - params: {}, - showHelp: uploadBtn.attr('data-help') ? uploadBtn.attr('data-help') === 1 : undefined, - accept: uploadBtn.attr('data-accept'), - }, function (image) { - $('#' + uploadBtn.attr('data-target')).val(image); - }); - }); - }); - } - - function setupTagsInput() { - $('[data-field-type="tagsinput"]').tagsinput({ - confirmKeys: [13, 44], - trimValue: true, - }); - app.flags._unsaved = false; - } - - Settings.remove = function (key) { - socket.emit('admin.config.remove', key); - }; - - function saveFields(fields, callback) { - const data = {}; - - fields.each(function () { - const field = $(this); - const key = field.attr('data-field'); - let value; - let inputType; - - if (field.is('input')) { - inputType = field.attr('type'); - switch (inputType) { - case 'text': - case 'password': - case 'hidden': - case 'textarea': - case 'number': - value = field.val(); - break; - - case 'checkbox': - value = field.prop('checked') ? '1' : '0'; - break; - } - } else if (field.is('textarea') || field.is('select')) { - value = field.val(); - } - - data[key] = value; - }); - - socket.emit('admin.config.setMultiple', data, function (err) { - if (err) { - return callback(err); - } - - for (const field in data) { - if (data.hasOwnProperty(field)) { - app.config[field] = data[field]; - } - } - - callback(); - }); - } - - return Settings; +define('admin/settings', ['uploader', 'mousetrap', 'hooks', 'alerts', 'settings'], (uploader, mousetrap, hooks, alerts, settings) => { + const Settings = {}; + + Settings.populateTOC = function () { + const headers = $('.settings-header'); + + if (headers.length > 1) { + headers.each(function () { + const header = $(this).text(); + const anchor = header.toLowerCase().replaceAll(' ', '-').trim(); + + $(this).prepend(''); + $('.section-content ul').append('
  • ' + header + '
  • '); + }); + + const scrollTo = $('a[name="' + window.location.hash.replace('#', '') + '"]'); + if (scrollTo.length > 0) { + $('html, body').animate({ + scrollTop: (scrollTo.offset().top) + 'px', + }, 400); + } + } else { + $('.content-header').parents('.row').remove(); + } + }; + + Settings.prepare = function (callback) { + // Populate the fields on the page from the config + const fields = $('#content [data-field]'); + const numberFields = fields.length; + const saveButton = $('#save'); + const revertButton = $('#revert'); + let x; + let key; + let inputType; + let field; + + // Handle unsaved changes + fields.on('change', () => { + app.flags = app.flags || {}; + app.flags._unsaved = true; + }); + const defaultInputs = new Set(['text', 'hidden', 'password', 'textarea', 'number']); + for (x = 0; x < numberFields; x += 1) { + field = fields.eq(x); + key = field.attr('data-field'); + inputType = field.attr('type'); + if (app.config.hasOwnProperty(key)) { + if (field.is('input') && inputType === 'checkbox') { + const checked = Number.parseInt(app.config[key], 10) === 1; + field.prop('checked', checked); + field.parents('.mdl-switch').toggleClass('is-checked', checked); + } else if (field.is('textarea') || field.is('select') || (field.is('input') && defaultInputs.has(inputType))) { + field.val(app.config[key]); + } + } + } + + revertButton.off('click').on('click', () => { + ajaxify.refresh(); + }); + + saveButton.off('click').on('click', e => { + e.preventDefault(); + + const ok = settings.check(document.querySelectorAll('#content [data-field]')); + if (!ok) { + return; + } + + saveFields(fields, function onFieldsSaved(error) { + if (error) { + return alerts.alert({ + alert_id: 'config_status', + timeout: 2500, + title: '[[admin/admin:changes-not-saved]]', + message: `[[admin/admin:changes-not-saved-message, ${error.message}]]`, + type: 'danger', + }); + } + + app.flags._unsaved = false; + + alerts.alert({ + alert_id: 'config_status', + timeout: 2500, + title: '[[admin/admin:changes-saved]]', + message: '[[admin/admin:changes-saved-message]]', + type: 'success', + }); + + hooks.fire('action:admin.settingsSaved'); + }); + }); + + mousetrap.bind('ctrl+s', event => { + saveButton.click(); + event.preventDefault(); + }); + + handleUploads(); + setupTagsInput(); + + $('#clear-sitemap-cache').off('click').on('click', () => { + socket.emit('admin.settings.clearSitemapCache', () => { + alerts.success('Sitemap Cache Cleared!'); + }); + return false; + }); + + if (typeof callback === 'function') { + callback(); + } + + setTimeout(() => { + hooks.fire('action:admin.settingsLoaded'); + }, 0); + }; + + function handleUploads() { + $('#content input[data-action="upload"]').each(function () { + const uploadButton = $(this); + uploadButton.on('click', () => { + uploader.show({ + title: uploadButton.attr('data-title'), + description: uploadButton.attr('data-description'), + route: uploadButton.attr('data-route'), + params: {}, + showHelp: uploadButton.attr('data-help') ? uploadButton.attr('data-help') === 1 : undefined, + accept: uploadButton.attr('data-accept'), + }, image => { + $('#' + uploadButton.attr('data-target')).val(image); + }); + }); + }); + } + + function setupTagsInput() { + $('[data-field-type="tagsinput"]').tagsinput({ + confirmKeys: [13, 44], + trimValue: true, + }); + app.flags._unsaved = false; + } + + Settings.remove = function (key) { + socket.emit('admin.config.remove', key); + }; + + function saveFields(fields, callback) { + const data = {}; + + fields.each(function () { + const field = $(this); + const key = field.attr('data-field'); + let value; + let inputType; + + if (field.is('input')) { + inputType = field.attr('type'); + switch (inputType) { + case 'text': + case 'password': + case 'hidden': + case 'textarea': + case 'number': { + value = field.val(); + break; + } + + case 'checkbox': { + value = field.prop('checked') ? '1' : '0'; + break; + } + } + } else if (field.is('textarea') || field.is('select')) { + value = field.val(); + } + + data[key] = value; + }); + + socket.emit('admin.config.setMultiple', data, error => { + if (error) { + return callback(error); + } + + for (const field in data) { + if (data.hasOwnProperty(field)) { + app.config[field] = data[field]; + } + } + + callback(); + }); + } + + return Settings; }); diff --git a/public/src/admin/settings/api.js b/public/src/admin/settings/api.js index b7aa816..143ec9a 100644 --- a/public/src/admin/settings/api.js +++ b/public/src/admin/settings/api.js @@ -1,34 +1,34 @@ 'use strict'; -define('admin/settings/api', ['settings', 'alerts', 'hooks'], function (settings, alerts, hooks) { - const ACP = {}; +define('admin/settings/api', ['settings', 'alerts', 'hooks'], (settings, alerts, hooks) => { + const ACP = {}; - ACP.init = function () { - settings.load('core.api', $('.core-api-settings')); - $('#save').on('click', saveSettings); + ACP.init = function () { + settings.load('core.api', $('.core-api-settings')); + $('#save').on('click', saveSettings); - hooks.on('action:settings.sorted-list.itemLoaded', ({ element }) => { - element.addEventListener('click', (ev) => { - if (ev.target.closest('input[readonly]')) { - // Select entire input text - ev.target.selectionStart = 0; - ev.target.selectionEnd = ev.target.value.length; - } - }); - }); - }; + hooks.on('action:settings.sorted-list.itemLoaded', ({element}) => { + element.addEventListener('click', event => { + if (event.target.closest('input[readonly]')) { + // Select entire input text + event.target.selectionStart = 0; + event.target.selectionEnd = event.target.value.length; + } + }); + }); + }; - function saveSettings() { - settings.save('core.api', $('.core-api-settings'), function () { - alerts.alert({ - type: 'success', - alert_id: 'core.api-saved', - title: 'Settings Saved', - timeout: 5000, - }); - ajaxify.refresh(); - }); - } + function saveSettings() { + settings.save('core.api', $('.core-api-settings'), () => { + alerts.alert({ + type: 'success', + alert_id: 'core.api-saved', + title: 'Settings Saved', + timeout: 5000, + }); + ajaxify.refresh(); + }); + } - return ACP; + return ACP; }); diff --git a/public/src/admin/settings/cookies.js b/public/src/admin/settings/cookies.js index 7e22dfd..47c7371 100644 --- a/public/src/admin/settings/cookies.js +++ b/public/src/admin/settings/cookies.js @@ -1,19 +1,20 @@ 'use strict'; -define('admin/settings/cookies', ['alerts'], function (alerts) { - const Module = {}; +define('admin/settings/cookies', ['alerts'], alerts => { + const Module = {}; - Module.init = function () { - $('#delete-all-sessions').on('click', function () { - socket.emit('admin.deleteAllSessions', function (err) { - if (err) { - return alerts.error(err); - } - window.location.href = config.relative_path + '/login'; - }); - return false; - }); - }; + Module.init = function () { + $('#delete-all-sessions').on('click', () => { + socket.emit('admin.deleteAllSessions', error => { + if (error) { + return alerts.error(error); + } - return Module; + window.location.href = config.relative_path + '/login'; + }); + return false; + }); + }; + + return Module; }); diff --git a/public/src/admin/settings/email.js b/public/src/admin/settings/email.js index b928e23..24d4466 100644 --- a/public/src/admin/settings/email.js +++ b/public/src/admin/settings/email.js @@ -1,126 +1,125 @@ 'use strict'; - -define('admin/settings/email', ['ace/ace', 'alerts', 'admin/settings'], function (ace, alerts) { - const module = {}; - let emailEditor; - - module.init = function () { - configureEmailTester(); - configureEmailEditor(); - handleDigestHourChange(); - handleSmtpServiceChange(); - - $(window).on('action:admin.settingsLoaded action:admin.settingsSaved', handleDigestHourChange); - $(window).on('action:admin.settingsSaved', function () { - socket.emit('admin.user.restartJobs'); - }); - $('[id="email:smtpTransport:service"]').change(handleSmtpServiceChange); - }; - - function configureEmailTester() { - $('button[data-action="email.test"]').off('click').on('click', function () { - socket.emit('admin.email.test', { template: $('#test-email').val() }, function (err) { - if (err) { - console.error(err.message); - return alerts.error(err); - } - alerts.success('Test Email Sent'); - }); - return false; - }); - } - - function configureEmailEditor() { - $('#email-editor-selector').on('change', updateEmailEditor); - - emailEditor = ace.edit('email-editor'); - emailEditor.$blockScrolling = Infinity; - emailEditor.setTheme('ace/theme/twilight'); - emailEditor.getSession().setMode('ace/mode/html'); - - emailEditor.on('change', function () { - const emailPath = $('#email-editor-selector').val(); - let original; - ajaxify.data.emails.forEach(function (email) { - if (email.path === emailPath) { - original = email.original; - } - }); - const newEmail = emailEditor.getValue(); - $('#email-editor-holder').val(newEmail !== original ? newEmail : ''); - }); - - $('button[data-action="email.revert"]').off('click').on('click', function () { - ajaxify.data.emails.forEach(function (email) { - if (email.path === $('#email-editor-selector').val()) { - emailEditor.getSession().setValue(email.original); - $('#email-editor-holder').val(''); - } - }); - }); - - updateEmailEditor(); - } - - function updateEmailEditor() { - ajaxify.data.emails.forEach(function (email) { - if (email.path === $('#email-editor-selector').val()) { - emailEditor.getSession().setValue(email.text); - $('#email-editor-holder') - .val(email.text !== email.original ? email.text : '') - .attr('data-field', 'email:custom:' + email.path); - } - }); - } - - function handleDigestHourChange() { - let hour = parseInt($('#digestHour').val(), 10); - - if (isNaN(hour)) { - hour = 17; - } else if (hour > 23 || hour < 0) { - hour = 0; - } - - socket.emit('admin.getServerTime', {}, function (err, now) { - if (err) { - return alerts.error(err); - } - - const date = new Date(now.timestamp); - const offset = (new Date().getTimezoneOffset() - now.offset) / 60; - date.setHours(date.getHours() + offset); - - $('#serverTime').text(date.toLocaleTimeString()); - - date.setHours(parseInt(hour, 10) - offset, 0, 0, 0); - - // If adjusted time is in the past, move to next day - if (date.getTime() < Date.now()) { - date.setDate(date.getDate() + 1); - } - - $('#nextDigestTime').text(date.toLocaleString()); - }); - } - - function handleSmtpServiceChange() { - const isCustom = $('[id="email:smtpTransport:service"]').val() === 'nodebb-custom-smtp'; - $('[id="email:smtpTransport:custom-service"]')[isCustom ? 'slideDown' : 'slideUp'](isCustom); - - const enabledEl = document.getElementById('email:smtpTransport:enabled'); - if (enabledEl) { - if (!enabledEl.checked) { - enabledEl.closest('label').classList.toggle('is-checked', true); - enabledEl.checked = true; - alerts.alert({ - message: '[[admin/settings/email:smtp-transport.auto-enable-toast]]', - timeout: 5000, - }); - } - } - } - - return module; +define('admin/settings/email', ['ace/ace', 'alerts', 'admin/settings'], (ace, alerts) => { + const module = {}; + let emailEditor; + + module.init = function () { + configureEmailTester(); + configureEmailEditor(); + handleDigestHourChange(); + handleSmtpServiceChange(); + + $(window).on('action:admin.settingsLoaded action:admin.settingsSaved', handleDigestHourChange); + $(window).on('action:admin.settingsSaved', () => { + socket.emit('admin.user.restartJobs'); + }); + $('[id="email:smtpTransport:service"]').change(handleSmtpServiceChange); + }; + + function configureEmailTester() { + $('button[data-action="email.test"]').off('click').on('click', () => { + socket.emit('admin.email.test', {template: $('#test-email').val()}, error => { + if (error) { + console.error(error.message); + return alerts.error(error); + } + + alerts.success('Test Email Sent'); + }); + return false; + }); + } + + function configureEmailEditor() { + $('#email-editor-selector').on('change', updateEmailEditor); + + emailEditor = ace.edit('email-editor'); + emailEditor.$blockScrolling = Number.POSITIVE_INFINITY; + emailEditor.setTheme('ace/theme/twilight'); + emailEditor.getSession().setMode('ace/mode/html'); + + emailEditor.on('change', () => { + const emailPath = $('#email-editor-selector').val(); + let original; + for (const email of ajaxify.data.emails) { + if (email.path === emailPath) { + original = email.original; + } + } + + const newEmail = emailEditor.getValue(); + $('#email-editor-holder').val(newEmail === original ? '' : newEmail); + }); + + $('button[data-action="email.revert"]').off('click').on('click', () => { + for (const email of ajaxify.data.emails) { + if (email.path === $('#email-editor-selector').val()) { + emailEditor.getSession().setValue(email.original); + $('#email-editor-holder').val(''); + } + } + }); + + updateEmailEditor(); + } + + function updateEmailEditor() { + for (const email of ajaxify.data.emails) { + if (email.path === $('#email-editor-selector').val()) { + emailEditor.getSession().setValue(email.text); + $('#email-editor-holder') + .val(email.text === email.original ? '' : email.text) + .attr('data-field', 'email:custom:' + email.path); + } + } + } + + function handleDigestHourChange() { + let hour = Number.parseInt($('#digestHour').val(), 10); + + if (isNaN(hour)) { + hour = 17; + } else if (hour > 23 || hour < 0) { + hour = 0; + } + + socket.emit('admin.getServerTime', {}, (error, now) => { + if (error) { + return alerts.error(error); + } + + const date = new Date(now.timestamp); + const offset = (new Date().getTimezoneOffset() - now.offset) / 60; + date.setHours(date.getHours() + offset); + + $('#serverTime').text(date.toLocaleTimeString()); + + date.setHours(Number.parseInt(hour, 10) - offset, 0, 0, 0); + + // If adjusted time is in the past, move to next day + if (date.getTime() < Date.now()) { + date.setDate(date.getDate() + 1); + } + + $('#nextDigestTime').text(date.toLocaleString()); + }); + } + + function handleSmtpServiceChange() { + const isCustom = $('[id="email:smtpTransport:service"]').val() === 'nodebb-custom-smtp'; + $('[id="email:smtpTransport:custom-service"]')[isCustom ? 'slideDown' : 'slideUp'](isCustom); + + const enabledElement = document.querySelector('#email:smtpTransport:enabled'); + if (enabledElement && !enabledElement.checked) { + enabledElement.closest('label').classList.toggle('is-checked', true); + enabledElement.checked = true; + alerts.alert({ + message: '[[admin/settings/email:smtp-transport.auto-enable-toast]]', + timeout: 5000, + }); + } + } + + return module; }); diff --git a/public/src/admin/settings/general.js b/public/src/admin/settings/general.js index 1c6aff4..4463329 100644 --- a/public/src/admin/settings/general.js +++ b/public/src/admin/settings/general.js @@ -1,26 +1,25 @@ 'use strict'; +define('admin/settings/general', ['admin/settings'], () => { + const Module = {}; -define('admin/settings/general', ['admin/settings'], function () { - const Module = {}; + Module.init = function () { + $('button[data-action="removeLogo"]').on('click', () => { + $('input[data-field="brand:logo"]').val(''); + }); + $('button[data-action="removeFavicon"]').on('click', () => { + $('input[data-field="brand:favicon"]').val(''); + }); + $('button[data-action="removeTouchIcon"]').on('click', () => { + $('input[data-field="brand:touchIcon"]').val(''); + }); + $('button[data-action="removeMaskableIcon"]').on('click', () => { + $('input[data-field="brand:maskableIcon"]').val(''); + }); + $('button[data-action="removeOgImage"]').on('click', () => { + $('input[data-field="removeOgImage"]').val(''); + }); + }; - Module.init = function () { - $('button[data-action="removeLogo"]').on('click', function () { - $('input[data-field="brand:logo"]').val(''); - }); - $('button[data-action="removeFavicon"]').on('click', function () { - $('input[data-field="brand:favicon"]').val(''); - }); - $('button[data-action="removeTouchIcon"]').on('click', function () { - $('input[data-field="brand:touchIcon"]').val(''); - }); - $('button[data-action="removeMaskableIcon"]').on('click', function () { - $('input[data-field="brand:maskableIcon"]').val(''); - }); - $('button[data-action="removeOgImage"]').on('click', function () { - $('input[data-field="removeOgImage"]').val(''); - }); - }; - - return Module; + return Module; }); diff --git a/public/src/admin/settings/homepage.js b/public/src/admin/settings/homepage.js index acd4104..f197e4a 100644 --- a/public/src/admin/settings/homepage.js +++ b/public/src/admin/settings/homepage.js @@ -1,22 +1,21 @@ 'use strict'; +define('admin/settings/homepage', ['admin/settings'], () => { + function toggleCustomRoute() { + if ($('[data-field="homePageRoute"]').val() === 'custom') { + $('#homePageCustom').show(); + } else { + $('#homePageCustom').hide(); + } + } -define('admin/settings/homepage', ['admin/settings'], function () { - function toggleCustomRoute() { - if ($('[data-field="homePageRoute"]').val() === 'custom') { - $('#homePageCustom').show(); - } else { - $('#homePageCustom').hide(); - } - } + const Homepage = {}; - const Homepage = {}; + Homepage.init = function () { + $('[data-field="homePageRoute"]').on('change', toggleCustomRoute); - Homepage.init = function () { - $('[data-field="homePageRoute"]').on('change', toggleCustomRoute); + toggleCustomRoute(); + }; - toggleCustomRoute(); - }; - - return Homepage; + return Homepage; }); diff --git a/public/src/admin/settings/navigation.js b/public/src/admin/settings/navigation.js index ad0f16a..2d10de8 100644 --- a/public/src/admin/settings/navigation.js +++ b/public/src/admin/settings/navigation.js @@ -1,157 +1,158 @@ 'use strict'; - define('admin/settings/navigation', [ - 'translator', - 'iconSelect', - 'benchpress', - 'alerts', - 'jquery-ui/widgets/draggable', - 'jquery-ui/widgets/droppable', - 'jquery-ui/widgets/sortable', -], function (translator, iconSelect, Benchpress, alerts) { - const navigation = {}; - let available; - - navigation.init = function () { - available = ajaxify.data.available; - - $('#available').find('li .drag-item').draggable({ - connectToSortable: '#active-navigation', - helper: 'clone', - distance: 10, - stop: drop, - }); - - $('#active-navigation').sortable().droppable({ - accept: $('#available li .drag-item'), - }); - - $('#enabled').on('click', '.iconPicker', function () { - const iconEl = $(this).find('i'); - iconSelect.init(iconEl, function (el) { - const newIconClass = el.attr('value'); - const index = iconEl.parents('[data-index]').attr('data-index'); - $('#active-navigation [data-index="' + index + '"] i.nav-icon').attr('class', 'fa fa-fw ' + newIconClass); - iconEl.siblings('[name="iconClass"]').val(newIconClass); - iconEl.siblings('.change-icon-link').toggleClass('hidden', !!newIconClass); - }); - }); - - $('#enabled').on('click', '[name="dropdown"]', function () { - const el = $(this); - const index = el.parents('[data-index]').attr('data-index'); - $('#active-navigation [data-index="' + index + '"] i.dropdown-icon').toggleClass('hidden', !el.is(':checked')); - }); - - $('#active-navigation').on('click', 'li', onSelect); - - $('#enabled') - .on('click', '.delete', remove) - .on('click', '.toggle', toggle); - - $('#save').on('click', save); - }; - - function onSelect() { - const clickedIndex = $(this).attr('data-index'); - $('#active-navigation li').removeClass('active'); - $(this).addClass('active'); - - const detailsForm = $('#enabled').children('[data-index="' + clickedIndex + '"]'); - $('#enabled li').addClass('hidden'); - - if (detailsForm.length) { - detailsForm.removeClass('hidden'); - } - return false; - } - - function drop(ev, ui) { - const id = ui.helper.attr('data-id'); - const el = $('#active-navigation [data-id="' + id + '"]'); - const data = id === 'custom' ? { - iconClass: 'fa-navicon', - groups: available[0].groups, - enabled: true, - } : available[id]; - - data.index = (parseInt($('#enabled').children().last().attr('data-index'), 10) || 0) + 1; - data.title = translator.escape(data.title); - data.text = translator.escape(data.text); - data.groups = ajaxify.data.groups; - Benchpress.parse('admin/settings/navigation', 'navigation', { navigation: [data] }, function (li) { - translator.translate(li, function (li) { - li = $(translator.unescape(li)); - el.after(li); - el.remove(); - }); - }); - Benchpress.parse('admin/settings/navigation', 'enabled', { enabled: [data] }, function (li) { - translator.translate(li, function (li) { - li = $(translator.unescape(li)); - $('#enabled').append(li); - componentHandler.upgradeDom(); - }); - }); - } - - function save() { - const nav = []; - - const indices = []; - $('#active-navigation li').each(function () { - indices.push($(this).attr('data-index')); - }); - - indices.forEach(function (index) { - const el = $('#enabled').children('[data-index="' + index + '"]'); - const form = el.find('form').serializeArray(); - const data = {}; - - form.forEach(function (input) { - if (data[input.name]) { - if (!Array.isArray(data[input.name])) { - data[input.name] = [ - data[input.name], - ]; - } - data[input.name].push(input.value); - } else { - data[input.name] = input.value; - } - }); - - nav.push(data); - }); - - socket.emit('admin.navigation.save', nav, function (err) { - if (err) { - alerts.error(err); - } else { - alerts.success('Successfully saved navigation'); - } - }); - } - - function remove() { - const index = $(this).parents('[data-index]').attr('data-index'); - $('#active-navigation [data-index="' + index + '"]').remove(); - $('#enabled [data-index="' + index + '"]').remove(); - return false; - } - - function toggle() { - const btn = $(this); - const disabled = btn.hasClass('btn-success'); - const index = btn.parents('[data-index]').attr('data-index'); - translator.translate(disabled ? '[[admin/settings/navigation:btn.disable]]' : '[[admin/settings/navigation:btn.enable]]', function (html) { - btn.toggleClass('btn-warning').toggleClass('btn-success').html(html); - btn.parents('li').find('[name="enabled"]').val(disabled ? 'on' : ''); - $('#active-navigation [data-index="' + index + '"] a').toggleClass('text-muted', !disabled); - }); - return false; - } - - return navigation; + 'translator', + 'iconSelect', + 'benchpress', + 'alerts', + 'jquery-ui/widgets/draggable', + 'jquery-ui/widgets/droppable', + 'jquery-ui/widgets/sortable', +], (translator, iconSelect, Benchpress, alerts) => { + const navigation = {}; + let available; + + navigation.init = function () { + available = ajaxify.data.available; + + $('#available').find('li .drag-item').draggable({ + connectToSortable: '#active-navigation', + helper: 'clone', + distance: 10, + stop: drop, + }); + + $('#active-navigation').sortable().droppable({ + accept: $('#available li .drag-item'), + }); + + $('#enabled').on('click', '.iconPicker', function () { + const iconElement = $(this).find('i'); + iconSelect.init(iconElement, element => { + const newIconClass = element.attr('value'); + const index = iconElement.parents('[data-index]').attr('data-index'); + $('#active-navigation [data-index="' + index + '"] i.nav-icon').attr('class', 'fa fa-fw ' + newIconClass); + iconElement.siblings('[name="iconClass"]').val(newIconClass); + iconElement.siblings('.change-icon-link').toggleClass('hidden', Boolean(newIconClass)); + }); + }); + + $('#enabled').on('click', '[name="dropdown"]', function () { + const element = $(this); + const index = element.parents('[data-index]').attr('data-index'); + $('#active-navigation [data-index="' + index + '"] i.dropdown-icon').toggleClass('hidden', !element.is(':checked')); + }); + + $('#active-navigation').on('click', 'li', onSelect); + + $('#enabled') + .on('click', '.delete', remove) + .on('click', '.toggle', toggle); + + $('#save').on('click', save); + }; + + function onSelect() { + const clickedIndex = $(this).attr('data-index'); + $('#active-navigation li').removeClass('active'); + $(this).addClass('active'); + + const detailsForm = $('#enabled').children('[data-index="' + clickedIndex + '"]'); + $('#enabled li').addClass('hidden'); + + if (detailsForm.length > 0) { + detailsForm.removeClass('hidden'); + } + + return false; + } + + function drop(event, ui) { + const id = ui.helper.attr('data-id'); + const element = $('#active-navigation [data-id="' + id + '"]'); + const data = id === 'custom' ? { + iconClass: 'fa-navicon', + groups: available[0].groups, + enabled: true, + } : available[id]; + + data.index = (Number.parseInt($('#enabled').children().last().attr('data-index'), 10) || 0) + 1; + data.title = translator.escape(data.title); + data.text = translator.escape(data.text); + data.groups = ajaxify.data.groups; + Benchpress.parse('admin/settings/navigation', 'navigation', {navigation: [data]}, li => { + translator.translate(li, li => { + li = $(translator.unescape(li)); + element.after(li); + element.remove(); + }); + }); + Benchpress.parse('admin/settings/navigation', 'enabled', {enabled: [data]}, li => { + translator.translate(li, li => { + li = $(translator.unescape(li)); + $('#enabled').append(li); + componentHandler.upgradeDom(); + }); + }); + } + + function save() { + const nav = []; + + const indices = []; + $('#active-navigation li').each(function () { + indices.push($(this).attr('data-index')); + }); + + for (const index of indices) { + const element = $('#enabled').children('[data-index="' + index + '"]'); + const form = element.find('form').serializeArray(); + const data = {}; + + for (const input of form) { + if (data[input.name]) { + if (!Array.isArray(data[input.name])) { + data[input.name] = [ + data[input.name], + ]; + } + + data[input.name].push(input.value); + } else { + data[input.name] = input.value; + } + } + + nav.push(data); + } + + socket.emit('admin.navigation.save', nav, error => { + if (error) { + alerts.error(error); + } else { + alerts.success('Successfully saved navigation'); + } + }); + } + + function remove() { + const index = $(this).parents('[data-index]').attr('data-index'); + $('#active-navigation [data-index="' + index + '"]').remove(); + $('#enabled [data-index="' + index + '"]').remove(); + return false; + } + + function toggle() { + const button = $(this); + const disabled = button.hasClass('btn-success'); + const index = button.parents('[data-index]').attr('data-index'); + translator.translate(disabled ? '[[admin/settings/navigation:btn.disable]]' : '[[admin/settings/navigation:btn.enable]]', html => { + button.toggleClass('btn-warning').toggleClass('btn-success').html(html); + button.parents('li').find('[name="enabled"]').val(disabled ? 'on' : ''); + $('#active-navigation [data-index="' + index + '"] a').toggleClass('text-muted', !disabled); + }); + return false; + } + + return navigation; }); diff --git a/public/src/admin/settings/notifications.js b/public/src/admin/settings/notifications.js index 424fbc3..a9dfb98 100644 --- a/public/src/admin/settings/notifications.js +++ b/public/src/admin/settings/notifications.js @@ -1,18 +1,18 @@ 'use strict'; define('admin/settings/notifications', [ - 'autocomplete', -], function (autocomplete) { - const Notifications = {}; + 'autocomplete', +], autocomplete => { + const Notifications = {}; - Notifications.init = function () { - const searchInput = $('[data-field="welcomeUid"]'); - autocomplete.user(searchInput, function (event, selected) { - setTimeout(function () { - searchInput.val(selected.item.user.uid); - }); - }); - }; + Notifications.init = function () { + const searchInput = $('[data-field="welcomeUid"]'); + autocomplete.user(searchInput, (event, selected) => { + setTimeout(() => { + searchInput.val(selected.item.user.uid); + }); + }); + }; - return Notifications; + return Notifications; }); diff --git a/public/src/admin/settings/social.js b/public/src/admin/settings/social.js index 46e4436..0524c82 100644 --- a/public/src/admin/settings/social.js +++ b/public/src/admin/settings/social.js @@ -1,27 +1,26 @@ 'use strict'; +define('admin/settings/social', ['alerts'], alerts => { + const social = {}; -define('admin/settings/social', ['alerts'], function (alerts) { - const social = {}; + social.init = function () { + $('#save').on('click', () => { + const networks = []; + $('#postSharingNetworks input[type="checkbox"]').each(function () { + if ($(this).prop('checked')) { + networks.push($(this).attr('id')); + } + }); - social.init = function () { - $('#save').on('click', function () { - const networks = []; - $('#postSharingNetworks input[type="checkbox"]').each(function () { - if ($(this).prop('checked')) { - networks.push($(this).attr('id')); - } - }); + socket.emit('admin.social.savePostSharingNetworks', networks, error => { + if (error) { + return alerts.error(error); + } - socket.emit('admin.social.savePostSharingNetworks', networks, function (err) { - if (err) { - return alerts.error(err); - } + alerts.success('[[admin/settings/social:save-success]]'); + }); + }); + }; - alerts.success('[[admin/settings/social:save-success]]'); - }); - }); - }; - - return social; + return social; }); diff --git a/public/src/ajaxify.js b/public/src/ajaxify.js index a626690..261ba8c 100644 --- a/public/src/ajaxify.js +++ b/public/src/ajaxify.js @@ -1,594 +1,597 @@ 'use strict'; const hooks = require('./modules/hooks'); -const { render } = require('./widgets'); +const {render} = require('./widgets'); window.ajaxify = window.ajaxify || {}; -ajaxify.widgets = { render: render }; +ajaxify.widgets = {render}; (function () { - let apiXHR = null; - let ajaxifyTimer; - - let retry = true; - let previousBodyClass = ''; - - ajaxify.count = 0; - ajaxify.currentPage = null; - - ajaxify.go = function (url, callback, quiet) { - // Automatically reconnect to socket and re-ajaxify on success - if (!socket.connected) { - app.reconnect(); - - if (ajaxify.reconnectAction) { - $(window).off('action:reconnected', ajaxify.reconnectAction); - } - ajaxify.reconnectAction = function (e) { - ajaxify.go(url, callback, quiet); - $(window).off(e); - }; - $(window).on('action:reconnected', ajaxify.reconnectAction); - } - - // Abort subsequent requests if clicked multiple times within a short window of time - if (ajaxifyTimer && (Date.now() - ajaxifyTimer) < 500) { - return true; - } - ajaxifyTimer = Date.now(); - - if (ajaxify.handleRedirects(url)) { - return true; - } - - if (!quiet && url === ajaxify.currentPage + window.location.search + window.location.hash) { - quiet = true; - } - - ajaxify.cleanup(url, ajaxify.data.template.name); - - if ($('#content').hasClass('ajaxifying') && apiXHR) { - apiXHR.abort(); - } - - app.previousUrl = !['reset'].includes(ajaxify.currentPage) ? - window.location.pathname.slice(config.relative_path.length) + window.location.search : - app.previousUrl; - - url = ajaxify.start(url); - - // If any listeners alter url and set it to an empty string, abort the ajaxification - if (url === null) { - hooks.fire('action:ajaxify.end', { url: url, tpl_url: ajaxify.data.template.name, title: ajaxify.data.title }); - return false; - } - - previousBodyClass = ajaxify.data.bodyClass; - $('#footer, #content').removeClass('hide').addClass('ajaxifying'); - - ajaxify.loadData(url, function (err, data) { - if (!err || ( - err && - err.data && - (parseInt(err.data.status, 10) !== 302 && parseInt(err.data.status, 10) !== 308) - )) { - ajaxify.updateHistory(url, quiet); - } - - if (err) { - return onAjaxError(err, url, callback, quiet); - } - - retry = true; - - renderTemplate(url, data.templateToRender || data.template.name, data, callback); - }); - - return true; - }; - - // this function is called just once from footer on page load - ajaxify.coldLoad = function () { - const url = ajaxify.start(window.location.pathname.slice(1) + window.location.search + window.location.hash); - ajaxify.updateHistory(url, true); - ajaxify.end(url, ajaxify.data.template.name); - hooks.fire('action:ajaxify.coldLoad'); - }; - - ajaxify.isCold = function () { - return ajaxify.count <= 1; - }; - - ajaxify.handleRedirects = function (url) { - url = ajaxify.removeRelativePath(url.replace(/^\/|\/$/g, '')).toLowerCase(); - const isClientToAdmin = url.startsWith('admin') && window.location.pathname.indexOf(config.relative_path + '/admin') !== 0; - const isAdminToClient = !url.startsWith('admin') && window.location.pathname.indexOf(config.relative_path + '/admin') === 0; - - if (isClientToAdmin || isAdminToClient) { - window.open(config.relative_path + '/' + url, '_top'); - return true; - } - return false; - }; - - ajaxify.start = function (url) { - url = ajaxify.removeRelativePath(url.replace(/^\/|\/$/g, '')); - - const payload = { - url: url, - }; - - hooks.logs.collect(); - hooks.fire('action:ajaxify.start', payload); - - ajaxify.count += 1; - - return payload.url; - }; - - ajaxify.updateHistory = function (url, quiet) { - ajaxify.currentPage = url.split(/[?#]/)[0]; - if (window.history && window.history.pushState) { - window.history[!quiet ? 'pushState' : 'replaceState']({ - url: url, - }, url, config.relative_path + '/' + url); - } - }; - - function onAjaxError(err, url, callback, quiet) { - const data = err.data; - const textStatus = err.textStatus; - - if (data) { - let status = parseInt(data.status, 10); - if ([400, 403, 404, 500, 502, 504].includes(status)) { - if (status === 502 && retry) { - retry = false; - ajaxifyTimer = undefined; - return ajaxify.go(url, callback, quiet); - } - if (status === 502) { - status = 500; - } - if (data.responseJSON) { - data.responseJSON.config = config; - } - - $('#footer, #content').removeClass('hide').addClass('ajaxifying'); - return renderTemplate(url, status.toString(), data.responseJSON || {}, callback); - } else if (status === 401) { - require(['alerts'], function (alerts) { - alerts.error('[[global:please_log_in]]'); - }); - app.previousUrl = url; - window.location.href = config.relative_path + '/login'; - } else if (status === 302 || status === 308) { - if (data.responseJSON && data.responseJSON.external) { - // this is used by sso plugins to redirect to the auth route - // cant use ajaxify.go for /auth/sso routes - window.location.href = data.responseJSON.external; - } else if (typeof data.responseJSON === 'string') { - ajaxifyTimer = undefined; - if (data.responseJSON.startsWith('http://') || data.responseJSON.startsWith('https://')) { - window.location.href = data.responseJSON; - } else { - ajaxify.go(data.responseJSON.slice(1), callback, quiet); - } - } - } - } else if (textStatus !== 'abort') { - require(['alerts'], function (alerts) { - alerts.error(data.responseJSON.error); - }); - } - } - - function renderTemplate(url, tpl_url, data, callback) { - hooks.fire('action:ajaxify.loadingTemplates', {}); - require(['translator', 'benchpress'], function (translator, Benchpress) { - Benchpress.render(tpl_url, data) - .then(rendered => translator.translate(rendered)) - .then(function (translated) { - translated = translator.unescape(translated); - $('body').removeClass(previousBodyClass).addClass(data.bodyClass); - $('#content').html(translated); - - ajaxify.end(url, tpl_url); - - if (typeof callback === 'function') { - callback(); - } - - $('#content, #footer').removeClass('ajaxifying'); - - // Only executed on ajaxify. Otherwise these'd be in ajaxify.end() - updateTitle(data.title); - updateTags(); - }); - }); - } - - function updateTitle(title) { - if (!title) { - return; - } - require(['translator'], function (translator) { - title = config.titleLayout.replace(/{/g, '{').replace(/}/g, '}') - .replace('{pageTitle}', function () { return title; }) - .replace('{browserTitle}', function () { return config.browserTitle; }); - - // Allow translation strings in title on ajaxify (#5927) - title = translator.unescape(title); - const data = { title: title }; - hooks.fire('action:ajaxify.updateTitle', data); - translator.translate(data.title, function (translated) { - window.document.title = $('
    ').html(translated).text(); - }); - }); - } - - function updateTags() { - const metaWhitelist = ['title', 'description', /og:.+/, /article:.+/, 'robots'].map(function (val) { - return new RegExp(val); - }); - const linkWhitelist = ['canonical', 'alternate', 'up']; - - // Delete the old meta tags - Array.prototype.slice - .call(document.querySelectorAll('head meta')) - .filter(function (el) { - const name = el.getAttribute('property') || el.getAttribute('name'); - return metaWhitelist.some(function (exp) { - return !!exp.test(name); - }); - }) - .forEach(function (el) { - document.head.removeChild(el); - }); - require(['translator'], function (translator) { - // Add new meta tags - ajaxify.data._header.tags.meta - .filter(function (tagObj) { - const name = tagObj.name || tagObj.property; - return metaWhitelist.some(function (exp) { - return !!exp.test(name); - }); - }).forEach(async function (tagObj) { - if (tagObj.content) { - tagObj.content = await translator.translate(tagObj.content); - } - const metaEl = document.createElement('meta'); - Object.keys(tagObj).forEach(function (prop) { - metaEl.setAttribute(prop, tagObj[prop]); - }); - document.head.appendChild(metaEl); - }); - }); - - // Delete the old link tags - Array.prototype.slice - .call(document.querySelectorAll('head link')) - .filter(function (el) { - const name = el.getAttribute('rel'); - return linkWhitelist.some(function (item) { - return item === name; - }); - }) - .forEach(function (el) { - document.head.removeChild(el); - }); - - // Add new link tags - ajaxify.data._header.tags.link - .filter(function (tagObj) { - return linkWhitelist.some(function (item) { - return item === tagObj.rel; - }); - }) - .forEach(function (tagObj) { - const linkEl = document.createElement('link'); - Object.keys(tagObj).forEach(function (prop) { - linkEl.setAttribute(prop, tagObj[prop]); - }); - document.head.appendChild(linkEl); - }); - } - - ajaxify.end = function (url, tpl_url) { - // Scroll back to top of page - if (!ajaxify.isCold()) { - window.scrollTo(0, 0); - } - ajaxify.loadScript(tpl_url, function done() { - hooks.fire('action:ajaxify.end', { url: url, tpl_url: tpl_url, title: ajaxify.data.title }); - hooks.logs.flush(); - }); - ajaxify.widgets.render(tpl_url); - - hooks.fire('action:ajaxify.contentLoaded', { url: url, tpl: tpl_url }); - - app.processPage(); - }; - - ajaxify.parseData = () => { - const dataEl = document.getElementById('ajaxify-data'); - if (dataEl) { - try { - ajaxify.data = JSON.parse(dataEl.textContent); - } catch (e) { - console.error(e); - ajaxify.data = {}; - } finally { - dataEl.remove(); - } - } - }; - - ajaxify.removeRelativePath = function (url) { - if (url.startsWith(config.relative_path.slice(1))) { - url = url.slice(config.relative_path.length); - } - return url; - }; - - ajaxify.refresh = function (callback) { - ajaxify.go(ajaxify.currentPage + window.location.search + window.location.hash, callback, true); - }; - - ajaxify.loadScript = function (tpl_url, callback) { - let location = !app.inAdmin ? 'forum/' : ''; - - if (tpl_url.startsWith('admin')) { - location = ''; - } - const data = { - tpl_url: tpl_url, - scripts: [location + tpl_url], - }; - - // Hint: useful if you want to load a module on a specific page (append module name to `scripts`) - hooks.fire('action:script.load', data); - hooks.fire('filter:script.load', data).then((data) => { - // Require and parse modules - let outstanding = data.scripts.length; - - const scripts = data.scripts.map(function (script) { - if (typeof script === 'function') { - return function (next) { - script(); - next(); - }; - } - if (typeof script === 'string') { - return async function (next) { - const module = await app.require(script); - // Hint: useful if you want to override a loaded library (e.g. replace core client-side logic), - // or call a method other than .init() - hooks.fire('static:script.init', { tpl_url, name: script, module }).then(() => { - if (module && module.init) { - module.init(); - } - next(); - }); - }; - } - return null; - }).filter(Boolean); - - if (scripts.length) { - scripts.forEach(function (fn) { - fn(function () { - outstanding -= 1; - if (outstanding === 0) { - callback(); - } - }); - }); - } else { - callback(); - } - }); - }; - - ajaxify.loadData = function (url, callback) { - url = ajaxify.removeRelativePath(url); - - hooks.fire('action:ajaxify.loadingData', { url: url }); - - apiXHR = $.ajax({ - url: config.relative_path + '/api/' + url, - cache: false, - headers: { - 'X-Return-To': app.previousUrl, - }, - success: function (data, textStatus, xhr) { - if (!data) { - return; - } - - if (xhr.getResponseHeader('X-Redirect')) { - return callback({ - data: { - status: 302, - responseJSON: data, - }, - textStatus: 'error', - }); - } - - ajaxify.data = data; - data.config = config; - - hooks.fire('action:ajaxify.dataLoaded', { url: url, data: data }); - - callback(null, data); - }, - error: function (data, textStatus) { - if (data.status === 0 && textStatus === 'error') { - data.status = 500; - data.responseJSON = data.responseJSON || {}; - data.responseJSON.error = '[[error:no-connection]]'; - } - callback({ - data: data, - textStatus: textStatus, - }); - }, - }); - }; - - ajaxify.loadTemplate = function (template, callback) { - $.ajax({ - url: `${config.asset_base_url}/templates/${template}.js`, - cache: false, - dataType: 'text', - success: function (script) { - // eslint-disable-next-line no-new-func - const renderFunction = new Function('module', script); - const moduleObj = { exports: {} }; - renderFunction(moduleObj); - callback(moduleObj.exports); - }, - }).fail(function () { - console.error('Unable to load template: ' + template); - callback(new Error('[[error:unable-to-load-template]]')); - }); - }; - - ajaxify.cleanup = (url, tpl_url) => { - app.leaveCurrentRoom(); - $(window).off('scroll'); - hooks.fire('action:ajaxify.cleanup', { url, tpl_url }); - }; - - require(['translator', 'benchpress'], function (translator, Benchpress) { - translator.translate('[[error:no-connection]]'); - translator.translate('[[error:socket-reconnect-failed]]'); - translator.translate(`[[global:reconnecting-message, ${config.siteTitle}]]`); - Benchpress.registerLoader(ajaxify.loadTemplate); - Benchpress.setGlobal('config', config); - Benchpress.render('500', {}); // loads and caches the 500.tpl - }); -}()); - -$(document).ready(function () { - $(window).on('popstate', function (ev) { - ev = ev.originalEvent; - - if (ev !== null && ev.state) { - if (ev.state.url === null && ev.state.returnPath !== undefined) { - window.history.replaceState({ - url: ev.state.returnPath, - }, ev.state.returnPath, config.relative_path + '/' + ev.state.returnPath); - } else if (ev.state.url !== undefined) { - ajaxify.go(ev.state.url, function () { - hooks.fire('action:popstate', { url: ev.state.url }); - }, true); - } - } - }); - - function ajaxifyAnchors() { - function hrefEmpty(href) { - // eslint-disable-next-line no-script-url - return href === undefined || href === '' || href === 'javascript:;'; - } - const location = document.location || window.location; - const rootUrl = location.protocol + '//' + (location.hostname || location.host) + (location.port ? ':' + location.port : ''); - const contentEl = document.getElementById('content'); - - // Enhancing all anchors to ajaxify... - $(document.body).on('click', 'a', function (e) { - const _self = this; - if (this.target !== '' || (this.protocol !== 'http:' && this.protocol !== 'https:')) { - return; - } - - const $this = $(this); - const href = $this.attr('href'); - const internalLink = utils.isInternalURI(this, window.location, config.relative_path); - - const rootAndPath = new RegExp(`^${rootUrl}${config.relative_path}/?`); - const process = function () { - if (!e.ctrlKey && !e.shiftKey && !e.metaKey && e.which === 1) { - if (internalLink) { - const pathname = this.href.replace(rootAndPath, ''); - - // Special handling for urls with hashes - if (window.location.pathname === this.pathname && this.hash.length) { - window.location.hash = this.hash; - } else if (ajaxify.go(pathname)) { - e.preventDefault(); - } - } else if (window.location.pathname !== config.relative_path + '/outgoing') { - if (config.openOutgoingLinksInNewTab && $.contains(contentEl, this)) { - const externalTab = window.open(); - externalTab.opener = null; - externalTab.location = this.href; - e.preventDefault(); - } else if (config.useOutgoingLinksPage) { - const safeUrls = config.outgoingLinksWhitelist.trim().split(/[\s,]+/g).filter(Boolean); - const href = this.href; - if (!safeUrls.length || - !safeUrls.some(function (url) { return href.indexOf(url) !== -1; })) { - ajaxify.go('outgoing?url=' + encodeURIComponent(href)); - e.preventDefault(); - } - } - } - } - }; - - if ($this.attr('data-ajaxify') === 'false') { - if (!internalLink) { - return; - } - return e.preventDefault(); - } - - // Default behaviour for rss feeds - if (internalLink && href && href.endsWith('.rss')) { - return; - } - - // Default behaviour for sitemap - if (internalLink && href && String(_self.pathname).startsWith(config.relative_path + '/sitemap') && href.endsWith('.xml')) { - return; - } - - // Default behaviour for uploads and direct links to API urls - if (internalLink && ['/uploads', '/assets/', '/api/'].some(function (prefix) { - return String(_self.pathname).startsWith(config.relative_path + prefix); - })) { - return; - } - - // eslint-disable-next-line no-script-url - if (hrefEmpty(this.href) || this.protocol === 'javascript:' || href === '#' || href === '') { - return e.preventDefault(); - } - - if (app.flags && app.flags.hasOwnProperty('_unsaved') && app.flags._unsaved === true) { - if (e.ctrlKey) { - return; - } - - require(['bootbox'], function (bootbox) { - bootbox.confirm('[[global:unsaved-changes]]', function (navigate) { - if (navigate) { - app.flags._unsaved = false; - process.call(_self); - } - }); - }); - return e.preventDefault(); - } - - process.call(_self); - }); - } - - if (window.history && window.history.pushState) { - // Progressive Enhancement, ajaxify available only to modern browsers - ajaxifyAnchors(); - } + let apiXHR = null; + let ajaxifyTimer; + + let retry = true; + let previousBodyClass = ''; + + ajaxify.count = 0; + ajaxify.currentPage = null; + + ajaxify.go = function (url, callback, quiet) { + // Automatically reconnect to socket and re-ajaxify on success + if (!socket.connected) { + app.reconnect(); + + if (ajaxify.reconnectAction) { + $(window).off('action:reconnected', ajaxify.reconnectAction); + } + + ajaxify.reconnectAction = function (e) { + ajaxify.go(url, callback, quiet); + $(window).off(e); + }; + + $(window).on('action:reconnected', ajaxify.reconnectAction); + } + + // Abort subsequent requests if clicked multiple times within a short window of time + if (ajaxifyTimer && (Date.now() - ajaxifyTimer) < 500) { + return true; + } + + ajaxifyTimer = Date.now(); + + if (ajaxify.handleRedirects(url)) { + return true; + } + + if (!quiet && url === ajaxify.currentPage + window.location.search + window.location.hash) { + quiet = true; + } + + ajaxify.cleanup(url, ajaxify.data.template.name); + + if ($('#content').hasClass('ajaxifying') && apiXHR) { + apiXHR.abort(); + } + + app.previousUrl = ['reset'].includes(ajaxify.currentPage) + ? app.previousUrl + : window.location.pathname.slice(config.relative_path.length) + window.location.search; + + url = ajaxify.start(url); + + // If any listeners alter url and set it to an empty string, abort the ajaxification + if (url === null) { + hooks.fire('action:ajaxify.end', {url, tpl_url: ajaxify.data.template.name, title: ajaxify.data.title}); + return false; + } + + previousBodyClass = ajaxify.data.bodyClass; + $('#footer, #content').removeClass('hide').addClass('ajaxifying'); + + ajaxify.loadData(url, (error, data) => { + if (!error || ( + error + && error.data + && (Number.parseInt(error.data.status, 10) !== 302 && Number.parseInt(error.data.status, 10) !== 308) + )) { + ajaxify.updateHistory(url, quiet); + } + + if (error) { + return onAjaxError(error, url, callback, quiet); + } + + retry = true; + + renderTemplate(url, data.templateToRender || data.template.name, data, callback); + }); + + return true; + }; + + // This function is called just once from footer on page load + ajaxify.coldLoad = function () { + const url = ajaxify.start(window.location.pathname.slice(1) + window.location.search + window.location.hash); + ajaxify.updateHistory(url, true); + ajaxify.end(url, ajaxify.data.template.name); + hooks.fire('action:ajaxify.coldLoad'); + }; + + ajaxify.isCold = function () { + return ajaxify.count <= 1; + }; + + ajaxify.handleRedirects = function (url) { + url = ajaxify.removeRelativePath(url.replaceAll(/^\/|\/$/g, '')).toLowerCase(); + const isClientToAdmin = url.startsWith('admin') && window.location.pathname.indexOf(config.relative_path + '/admin') !== 0; + const isAdminToClient = !url.startsWith('admin') && window.location.pathname.indexOf(config.relative_path + '/admin') === 0; + + if (isClientToAdmin || isAdminToClient) { + window.open(config.relative_path + '/' + url, '_top'); + return true; + } + + return false; + }; + + ajaxify.start = function (url) { + url = ajaxify.removeRelativePath(url.replaceAll(/^\/|\/$/g, '')); + + const payload = { + url, + }; + + hooks.logs.collect(); + hooks.fire('action:ajaxify.start', payload); + + ajaxify.count += 1; + + return payload.url; + }; + + ajaxify.updateHistory = function (url, quiet) { + ajaxify.currentPage = url.split(/[?#]/)[0]; + if (window.history && window.history.pushState) { + window.history[quiet ? 'replaceState' : 'pushState']({ + url, + }, url, config.relative_path + '/' + url); + } + }; + + function onAjaxError(error, url, callback, quiet) { + const data = error.data; + const textStatus = error.textStatus; + + if (data) { + let status = Number.parseInt(data.status, 10); + if ([400, 403, 404, 500, 502, 504].includes(status)) { + if (status === 502 && retry) { + retry = false; + ajaxifyTimer = undefined; + return ajaxify.go(url, callback, quiet); + } + + if (status === 502) { + status = 500; + } + + if (data.responseJSON) { + data.responseJSON.config = config; + } + + $('#footer, #content').removeClass('hide').addClass('ajaxifying'); + return renderTemplate(url, status.toString(), data.responseJSON || {}, callback); + } + + if (status === 401) { + require(['alerts'], alerts => { + alerts.error('[[global:please_log_in]]'); + }); + app.previousUrl = url; + window.location.href = config.relative_path + '/login'; + } else if (status === 302 || status === 308) { + if (data.responseJSON && data.responseJSON.external) { + // This is used by sso plugins to redirect to the auth route + // cant use ajaxify.go for /auth/sso routes + window.location.href = data.responseJSON.external; + } else if (typeof data.responseJSON === 'string') { + ajaxifyTimer = undefined; + if (data.responseJSON.startsWith('http://') || data.responseJSON.startsWith('https://')) { + window.location.href = data.responseJSON; + } else { + ajaxify.go(data.responseJSON.slice(1), callback, quiet); + } + } + } + } else if (textStatus !== 'abort') { + require(['alerts'], alerts => { + alerts.error(data.responseJSON.error); + }); + } + } + + function renderTemplate(url, tpl_url, data, callback) { + hooks.fire('action:ajaxify.loadingTemplates', {}); + require(['translator', 'benchpress'], (translator, Benchpress) => { + Benchpress.render(tpl_url, data) + .then(rendered => translator.translate(rendered)) + .then(translated => { + translated = translator.unescape(translated); + $('body').removeClass(previousBodyClass).addClass(data.bodyClass); + $('#content').html(translated); + + ajaxify.end(url, tpl_url); + + if (typeof callback === 'function') { + callback(); + } + + $('#content, #footer').removeClass('ajaxifying'); + + // Only executed on ajaxify. Otherwise these'd be in ajaxify.end() + updateTitle(data.title); + updateTags(); + }); + }); + } + + function updateTitle(title) { + if (!title) { + return; + } + + require(['translator'], translator => { + title = config.titleLayout.replaceAll('{', '{').replaceAll('}', '}') + .replace('{pageTitle}', () => title) + .replace('{browserTitle}', () => config.browserTitle); + + // Allow translation strings in title on ajaxify (#5927) + title = translator.unescape(title); + const data = {title}; + hooks.fire('action:ajaxify.updateTitle', data); + translator.translate(data.title, translated => { + window.document.title = $('
    ').html(translated).text(); + }); + }); + } + + function updateTags() { + const metaInclude = ['title', 'description', /og:.+/, /article:.+/, 'robots'].map(value => new RegExp(value)); + const linkInclude = new Set(['canonical', 'alternate', 'up']); + + // Delete the old meta tags + for (const element of Array.prototype.slice + .call(document.querySelectorAll('head meta')) + .filter(element_ => { + const name = element_.getAttribute('property') || element_.getAttribute('name'); + return metaInclude.some(exp => Boolean(exp.test(name))); + })) { + element.remove(); + } + + require(['translator'], translator => { + // Add new meta tags + ajaxify.data._header.tags.meta + .filter(tagObject => { + const name = tagObject.name || tagObject.property; + return metaInclude.some(exp => Boolean(exp.test(name))); + }).forEach(async tagObject => { + tagObject.content &&= await translator.translate(tagObject.content); + + const metaElement = document.createElement('meta'); + for (const property of Object.keys(tagObject)) { + metaElement.setAttribute(property, tagObject[property]); + } + + document.head.append(metaElement); + }); + }); + + // Delete the old link tags + for (const element of Array.prototype.slice + .call(document.querySelectorAll('head link')) + .filter(element_ => { + const name = element_.getAttribute('rel'); + return linkInclude.has(name); + })) { + element.remove(); + } + + // Add new link tags + for (const tagObject of ajaxify.data._header.tags.link + .filter(tagObject_ => linkInclude.has(tagObject_.rel))) { + const linkElement = document.createElement('link'); + for (const property of Object.keys(tagObject)) { + linkElement.setAttribute(property, tagObject[property]); + } + + document.head.append(linkElement); + } + } + + ajaxify.end = function (url, tpl_url) { + // Scroll back to top of page + if (!ajaxify.isCold()) { + window.scrollTo(0, 0); + } + + ajaxify.loadScript(tpl_url, function done() { + hooks.fire('action:ajaxify.end', {url, tpl_url, title: ajaxify.data.title}); + hooks.logs.flush(); + }); + ajaxify.widgets.render(tpl_url); + + hooks.fire('action:ajaxify.contentLoaded', {url, tpl: tpl_url}); + + app.processPage(); + }; + + ajaxify.parseData = () => { + const dataElement = document.querySelector('#ajaxify-data'); + if (dataElement) { + try { + ajaxify.data = JSON.parse(dataElement.textContent); + } catch (error) { + console.error(error); + ajaxify.data = {}; + } finally { + dataElement.remove(); + } + } + }; + + ajaxify.removeRelativePath = function (url) { + if (url.startsWith(config.relative_path.slice(1))) { + url = url.slice(config.relative_path.length); + } + + return url; + }; + + ajaxify.refresh = function (callback) { + ajaxify.go(ajaxify.currentPage + window.location.search + window.location.hash, callback, true); + }; + + ajaxify.loadScript = function (tpl_url, callback) { + let location = app.inAdmin ? '' : 'forum/'; + + if (tpl_url.startsWith('admin')) { + location = ''; + } + + const data = { + tpl_url, + scripts: [location + tpl_url], + }; + + // Hint: useful if you want to load a module on a specific page (append module name to `scripts`) + hooks.fire('action:script.load', data); + hooks.fire('filter:script.load', data).then(data => { + // Require and parse modules + let outstanding = data.scripts.length; + + const scripts = data.scripts.map(script => { + if (typeof script === 'function') { + return function (next) { + script(); + next(); + }; + } + + if (typeof script === 'string') { + return async function (next) { + const module = await app.require(script); + // Hint: useful if you want to override a loaded library (e.g. replace core client-side logic), + // or call a method other than .init() + hooks.fire('static:script.init', {tpl_url, name: script, module}).then(() => { + if (module && module.init) { + module.init(); + } + + next(); + }); + }; + } + + return null; + }).filter(Boolean); + + if (scripts.length > 0) { + for (const function_ of scripts) { + function_(() => { + outstanding -= 1; + if (outstanding === 0) { + callback(); + } + }); + } + } else { + callback(); + } + }); + }; + + ajaxify.loadData = function (url, callback) { + url = ajaxify.removeRelativePath(url); + + hooks.fire('action:ajaxify.loadingData', {url}); + + apiXHR = $.ajax({ + url: config.relative_path + '/api/' + url, + cache: false, + headers: { + 'X-Return-To': app.previousUrl, + }, + success(data, textStatus, xhr) { + if (!data) { + return; + } + + if (xhr.getResponseHeader('X-Redirect')) { + return callback({ + data: { + status: 302, + responseJSON: data, + }, + textStatus: 'error', + }); + } + + ajaxify.data = data; + data.config = config; + + hooks.fire('action:ajaxify.dataLoaded', {url, data}); + + callback(null, data); + }, + error(data, textStatus) { + if (data.status === 0 && textStatus === 'error') { + data.status = 500; + data.responseJSON = data.responseJSON || {}; + data.responseJSON.error = '[[error:no-connection]]'; + } + + callback({ + data, + textStatus, + }); + }, + }); + }; + + ajaxify.loadTemplate = function (template, callback) { + $.ajax({ + url: `${config.asset_base_url}/templates/${template}.js`, + cache: false, + dataType: 'text', + success(script) { + // eslint-disable-next-line no-new-func + const renderFunction = new Function('module', script); + const moduleObject = {exports: {}}; + renderFunction(moduleObject); + callback(moduleObject.exports); + }, + }).fail(() => { + console.error('Unable to load template: ' + template); + callback(new Error('[[error:unable-to-load-template]]')); + }); + }; + + ajaxify.cleanup = (url, tpl_url) => { + app.leaveCurrentRoom(); + $(window).off('scroll'); + hooks.fire('action:ajaxify.cleanup', {url, tpl_url}); + }; + + require(['translator', 'benchpress'], (translator, Benchpress) => { + translator.translate('[[error:no-connection]]'); + translator.translate('[[error:socket-reconnect-failed]]'); + translator.translate(`[[global:reconnecting-message, ${config.siteTitle}]]`); + Benchpress.registerLoader(ajaxify.loadTemplate); + Benchpress.setGlobal('config', config); + Benchpress.render('500', {}); // Loads and caches the 500.tpl + }); +})(); + +$(document).ready(() => { + $(window).on('popstate', event => { + event = event.originalEvent; + + if (event !== null && event.state) { + if (event.state.url === null && event.state.returnPath !== undefined) { + window.history.replaceState({ + url: event.state.returnPath, + }, event.state.returnPath, config.relative_path + '/' + event.state.returnPath); + } else if (event.state.url !== undefined) { + ajaxify.go(event.state.url, () => { + hooks.fire('action:popstate', {url: event.state.url}); + }, true); + } + } + }); + + function ajaxifyAnchors() { + function hrefEmpty(href) { + // eslint-disable-next-line no-script-url + return href === undefined || href === '' || href === 'javascript:;'; + } + + const location = document.location || window.location; + const rootUrl = location.protocol + '//' + (location.hostname || location.host) + (location.port ? ':' + location.port : ''); + const contentElement = document.querySelector('#content'); + + // Enhancing all anchors to ajaxify... + $(document.body).on('click', 'a', function (e) { + const _self = this; + if (this.target !== '' || (this.protocol !== 'http:' && this.protocol !== 'https:')) { + return; + } + + const $this = $(this); + const href = $this.attr('href'); + const internalLink = utils.isInternalURI(this, window.location, config.relative_path); + + const rootAndPath = new RegExp(`^${rootUrl}${config.relative_path}/?`); + const process = function () { + if (!e.ctrlKey && !e.shiftKey && !e.metaKey && e.which === 1) { + if (internalLink) { + const pathname = this.href.replace(rootAndPath, ''); + + // Special handling for urls with hashes + if (window.location.pathname === this.pathname && this.hash.length > 0) { + window.location.hash = this.hash; + } else if (ajaxify.go(pathname)) { + e.preventDefault(); + } + } else if (window.location.pathname !== config.relative_path + '/outgoing') { + if (config.openOutgoingLinksInNewTab && $.contains(contentElement, this)) { + const externalTab = window.open(); + externalTab.opener = null; + externalTab.location = this.href; + e.preventDefault(); + } else if (config.useOutgoingLinksPage) { + const safeUrls = config.outgoingLinksWhitelist.trim().split(/[\s,]+/g).filter(Boolean); + const href = this.href; + if (safeUrls.length === 0 + || !safeUrls.some(url => href.includes(url))) { + ajaxify.go('outgoing?url=' + encodeURIComponent(href)); + e.preventDefault(); + } + } + } + } + }; + + if ($this.attr('data-ajaxify') === 'false') { + if (!internalLink) { + return; + } + + return e.preventDefault(); + } + + // Default behaviour for rss feeds + if (internalLink && href && href.endsWith('.rss')) { + return; + } + + // Default behaviour for sitemap + if (internalLink && href && String(_self.pathname).startsWith(config.relative_path + '/sitemap') && href.endsWith('.xml')) { + return; + } + + // Default behaviour for uploads and direct links to API urls + if (internalLink && ['/uploads', '/assets/', '/api/'].some(prefix => String(_self.pathname).startsWith(config.relative_path + prefix))) { + return; + } + + // eslint-disable-next-line no-script-url + if (hrefEmpty(this.href) || this.protocol === 'javascript:' || href === '#' || href === '') { + return e.preventDefault(); + } + + if (app.flags && app.flags.hasOwnProperty('_unsaved') && app.flags._unsaved === true) { + if (e.ctrlKey) { + return; + } + + require(['bootbox'], bootbox => { + bootbox.confirm('[[global:unsaved-changes]]', navigate => { + if (navigate) { + app.flags._unsaved = false; + process.call(_self); + } + }); + }); + return e.preventDefault(); + } + + process.call(_self); + }); + } + + if (window.history && window.history.pushState) { + // Progressive Enhancement, ajaxify available only to modern browsers + ajaxifyAnchors(); + } }); diff --git a/public/src/client/account/best.js b/public/src/client/account/best.js index 3702c33..074a67c 100644 --- a/public/src/client/account/best.js +++ b/public/src/client/account/best.js @@ -1,16 +1,15 @@ 'use strict'; +define('forum/account/best', ['forum/account/header', 'forum/account/posts'], (header, posts) => { + const Best = {}; -define('forum/account/best', ['forum/account/header', 'forum/account/posts'], function (header, posts) { - const Best = {}; + Best.init = function () { + header.init(); - Best.init = function () { - header.init(); + $('[component="post/content"] img:not(.not-responsive)').addClass('img-responsive'); - $('[component="post/content"] img:not(.not-responsive)').addClass('img-responsive'); + posts.handleInfiniteScroll('account/best'); + }; - posts.handleInfiniteScroll('account/best'); - }; - - return Best; + return Best; }); diff --git a/public/src/client/account/blocks.js b/public/src/client/account/blocks.js index 6938969..b913060 100644 --- a/public/src/client/account/blocks.js +++ b/public/src/client/account/blocks.js @@ -1,67 +1,67 @@ 'use strict'; define('forum/account/blocks', [ - 'forum/account/header', - 'api', - 'hooks', - 'alerts', -], function (header, api, hooks, alerts) { - const Blocks = {}; + 'forum/account/header', + 'api', + 'hooks', + 'alerts', +], (header, api, hooks, alerts) => { + const Blocks = {}; - Blocks.init = function () { - header.init(); + Blocks.init = function () { + header.init(); - $('#user-search').on('keyup', function () { - const username = this.value; + $('#user-search').on('keyup', function () { + const username = this.value; - api.get('/api/users', { - query: username, - searchBy: 'username', - paginate: false, - }, function (err, data) { - if (err) { - return alerts.error(err); - } + api.get('/api/users', { + query: username, + searchBy: 'username', + paginate: false, + }, (error, data) => { + if (error) { + return alerts.error(error); + } - // Only show first 10 matches - if (data.matchCount > 10) { - data.users.length = 10; - } + // Only show first 10 matches + if (data.matchCount > 10) { + data.users.length = 10; + } - app.parseAndTranslate('account/blocks', 'edit', { - edit: data.users, - }, function (html) { - $('.block-edit').html(html); - }); - }); - }); + app.parseAndTranslate('account/blocks', 'edit', { + edit: data.users, + }, html => { + $('.block-edit').html(html); + }); + }); + }); - $('.block-edit').on('click', '[data-action="toggle"]', function () { - const uid = parseInt(this.getAttribute('data-uid'), 10); - socket.emit('user.toggleBlock', { - blockeeUid: uid, - blockerUid: ajaxify.data.uid, - }, Blocks.refreshList); - }); - }; + $('.block-edit').on('click', '[data-action="toggle"]', function () { + const uid = Number.parseInt(this.dataset.uid, 10); + socket.emit('user.toggleBlock', { + blockeeUid: uid, + blockerUid: ajaxify.data.uid, + }, Blocks.refreshList); + }); + }; - Blocks.refreshList = function (err) { - if (err) { - return alerts.error(err); - } + Blocks.refreshList = function (error) { + if (error) { + return alerts.error(error); + } - $.get(config.relative_path + '/api/' + ajaxify.currentPage) - .done(function (payload) { - app.parseAndTranslate('account/blocks', 'users', payload, function (html) { - $('#users-container').html(html); - $('#users-container').siblings('div.alert')[html.length ? 'hide' : 'show'](); - }); - hooks.fire('action:user.blocks.toggle', { data: payload }); - }) - .fail(function () { - ajaxify.go(ajaxify.currentPage); - }); - }; + $.get(config.relative_path + '/api/' + ajaxify.currentPage) + .done(payload => { + app.parseAndTranslate('account/blocks', 'users', payload, html => { + $('#users-container').html(html); + $('#users-container').siblings('div.alert')[html.length > 0 ? 'hide' : 'show'](); + }); + hooks.fire('action:user.blocks.toggle', {data: payload}); + }) + .fail(() => { + ajaxify.go(ajaxify.currentPage); + }); + }; - return Blocks; + return Blocks; }); diff --git a/public/src/client/account/bookmarks.js b/public/src/client/account/bookmarks.js index 1d1d390..a43c336 100644 --- a/public/src/client/account/bookmarks.js +++ b/public/src/client/account/bookmarks.js @@ -1,16 +1,15 @@ 'use strict'; +define('forum/account/bookmarks', ['forum/account/header', 'forum/account/posts'], (header, posts) => { + const Bookmarks = {}; -define('forum/account/bookmarks', ['forum/account/header', 'forum/account/posts'], function (header, posts) { - const Bookmarks = {}; + Bookmarks.init = function () { + header.init(); - Bookmarks.init = function () { - header.init(); + $('[component="post/content"] img:not(.not-responsive)').addClass('img-responsive'); - $('[component="post/content"] img:not(.not-responsive)').addClass('img-responsive'); + posts.handleInfiniteScroll('account/bookmarks'); + }; - posts.handleInfiniteScroll('account/bookmarks'); - }; - - return Bookmarks; + return Bookmarks; }); diff --git a/public/src/client/account/categories.js b/public/src/client/account/categories.js index 0221859..f75f2e9 100644 --- a/public/src/client/account/categories.js +++ b/public/src/client/account/categories.js @@ -1,62 +1,63 @@ 'use strict'; - -define('forum/account/categories', ['forum/account/header', 'alerts'], function (header, alerts) { - const Categories = {}; - - Categories.init = function () { - header.init(); - - ajaxify.data.categories.forEach(function (category) { - handleIgnoreWatch(category.cid); - }); - - $('[component="category/watch/all"]').find('[component="category/watching"], [component="category/ignoring"], [component="category/notwatching"]').on('click', function () { - const cids = []; - const state = $(this).attr('data-state'); - $('[data-parent-cid="0"]').each(function (index, el) { - cids.push($(el).attr('data-cid')); - }); - - socket.emit('categories.setWatchState', { cid: cids, state: state, uid: ajaxify.data.uid }, function (err, modified_cids) { - if (err) { - return alerts.error(err); - } - updateDropdowns(modified_cids, state); - }); - }); - }; - - function handleIgnoreWatch(cid) { - const category = $('[data-cid="' + cid + '"]'); - category.find('[component="category/watching"], [component="category/ignoring"], [component="category/notwatching"]').on('click', function () { - const $this = $(this); - const state = $this.attr('data-state'); - - socket.emit('categories.setWatchState', { cid: cid, state: state, uid: ajaxify.data.uid }, function (err, modified_cids) { - if (err) { - return alerts.error(err); - } - updateDropdowns(modified_cids, state); - - alerts.success('[[category:' + state + '.message]]'); - }); - }); - } - - function updateDropdowns(modified_cids, state) { - modified_cids.forEach(function (cid) { - const category = $('[data-cid="' + cid + '"]'); - category.find('[component="category/watching/menu"]').toggleClass('hidden', state !== 'watching'); - category.find('[component="category/watching/check"]').toggleClass('fa-check', state === 'watching'); - - category.find('[component="category/notwatching/menu"]').toggleClass('hidden', state !== 'notwatching'); - category.find('[component="category/notwatching/check"]').toggleClass('fa-check', state === 'notwatching'); - - category.find('[component="category/ignoring/menu"]').toggleClass('hidden', state !== 'ignoring'); - category.find('[component="category/ignoring/check"]').toggleClass('fa-check', state === 'ignoring'); - }); - } - - return Categories; +define('forum/account/categories', ['forum/account/header', 'alerts'], (header, alerts) => { + const Categories = {}; + + Categories.init = function () { + header.init(); + + for (const category of ajaxify.data.categories) { + handleIgnoreWatch(category.cid); + } + + $('[component="category/watch/all"]').find('[component="category/watching"], [component="category/ignoring"], [component="category/notwatching"]').on('click', function () { + const cids = []; + const state = $(this).attr('data-state'); + $('[data-parent-cid="0"]').each((index, element) => { + cids.push($(element).attr('data-cid')); + }); + + socket.emit('categories.setWatchState', {cid: cids, state, uid: ajaxify.data.uid}, (error, modified_cids) => { + if (error) { + return alerts.error(error); + } + + updateDropdowns(modified_cids, state); + }); + }); + }; + + function handleIgnoreWatch(cid) { + const category = $('[data-cid="' + cid + '"]'); + category.find('[component="category/watching"], [component="category/ignoring"], [component="category/notwatching"]').on('click', function () { + const $this = $(this); + const state = $this.attr('data-state'); + + socket.emit('categories.setWatchState', {cid, state, uid: ajaxify.data.uid}, (error, modified_cids) => { + if (error) { + return alerts.error(error); + } + + updateDropdowns(modified_cids, state); + + alerts.success('[[category:' + state + '.message]]'); + }); + }); + } + + function updateDropdowns(modified_cids, state) { + for (const cid of modified_cids) { + const category = $('[data-cid="' + cid + '"]'); + category.find('[component="category/watching/menu"]').toggleClass('hidden', state !== 'watching'); + category.find('[component="category/watching/check"]').toggleClass('fa-check', state === 'watching'); + + category.find('[component="category/notwatching/menu"]').toggleClass('hidden', state !== 'notwatching'); + category.find('[component="category/notwatching/check"]').toggleClass('fa-check', state === 'notwatching'); + + category.find('[component="category/ignoring/menu"]').toggleClass('hidden', state !== 'ignoring'); + category.find('[component="category/ignoring/check"]').toggleClass('fa-check', state === 'ignoring'); + } + } + + return Categories; }); diff --git a/public/src/client/account/consent.js b/public/src/client/account/consent.js index b3f1606..a827346 100644 --- a/public/src/client/account/consent.js +++ b/public/src/client/account/consent.js @@ -1,34 +1,33 @@ 'use strict'; - -define('forum/account/consent', ['forum/account/header', 'alerts', 'api'], function (header, alerts, api) { - const Consent = {}; - - Consent.init = function () { - header.init(); - - $('[data-action="consent"]').on('click', function () { - socket.emit('user.gdpr.consent', {}, function (err) { - if (err) { - return alerts.error(err); - } - - ajaxify.refresh(); - }); - }); - - handleExport($('[data-action="export-profile"]'), 'profile', '[[user:consent.export-profile-success]]'); - handleExport($('[data-action="export-posts"]'), 'posts', '[[user:consent.export-posts-success]]'); - handleExport($('[data-action="export-uploads"]'), 'uploads', '[[user:consent.export-uploads-success]]'); - - function handleExport(el, type, success) { - el.on('click', function () { - api.post(`/users/${ajaxify.data.uid}/exports/${type}`).then(() => { - alerts.success(success); - }).catch(alerts.error); - }); - } - }; - - return Consent; +define('forum/account/consent', ['forum/account/header', 'alerts', 'api'], (header, alerts, api) => { + const Consent = {}; + + Consent.init = function () { + header.init(); + + $('[data-action="consent"]').on('click', () => { + socket.emit('user.gdpr.consent', {}, error => { + if (error) { + return alerts.error(error); + } + + ajaxify.refresh(); + }); + }); + + handleExport($('[data-action="export-profile"]'), 'profile', '[[user:consent.export-profile-success]]'); + handleExport($('[data-action="export-posts"]'), 'posts', '[[user:consent.export-posts-success]]'); + handleExport($('[data-action="export-uploads"]'), 'uploads', '[[user:consent.export-uploads-success]]'); + + function handleExport(element, type, success) { + element.on('click', () => { + api.post(`/users/${ajaxify.data.uid}/exports/${type}`).then(() => { + alerts.success(success); + }).catch(alerts.error); + }); + } + }; + + return Consent; }); diff --git a/public/src/client/account/downvoted.js b/public/src/client/account/downvoted.js index ac18aaf..a113896 100644 --- a/public/src/client/account/downvoted.js +++ b/public/src/client/account/downvoted.js @@ -1,16 +1,15 @@ 'use strict'; +define('forum/account/downvoted', ['forum/account/header', 'forum/account/posts'], (header, posts) => { + const Downvoted = {}; -define('forum/account/downvoted', ['forum/account/header', 'forum/account/posts'], function (header, posts) { - const Downvoted = {}; + Downvoted.init = function () { + header.init(); - Downvoted.init = function () { - header.init(); + $('[component="post/content"] img:not(.not-responsive)').addClass('img-responsive'); - $('[component="post/content"] img:not(.not-responsive)').addClass('img-responsive'); + posts.handleInfiniteScroll('account/downvoted'); + }; - posts.handleInfiniteScroll('account/downvoted'); - }; - - return Downvoted; + return Downvoted; }); diff --git a/public/src/client/account/edit.js b/public/src/client/account/edit.js index 2ca8cf7..c709d51 100644 --- a/public/src/client/account/edit.js +++ b/public/src/client/account/edit.js @@ -1,161 +1,164 @@ 'use strict'; define('forum/account/edit', [ - 'forum/account/header', - 'accounts/picture', - 'translator', - 'api', - 'hooks', - 'bootbox', - 'alerts', -], function (header, picture, translator, api, hooks, bootbox, alerts) { - const AccountEdit = {}; - - AccountEdit.init = function () { - header.init(); - - $('#submitBtn').on('click', updateProfile); - - if (ajaxify.data.groupTitleArray.length === 1 && ajaxify.data.groupTitleArray[0] === '') { - $('#groupTitle option[value=""]').attr('selected', true); - } - - handleImageChange(); - handleAccountDelete(); - handleEmailConfirm(); - updateSignature(); - updateAboutMe(); - handleGroupSort(); - }; - - function updateProfile() { - const userData = $('form[component="profile/edit/form"]').serializeObject(); - userData.uid = ajaxify.data.uid; - userData.groupTitle = userData.groupTitle || ''; - userData.groupTitle = JSON.stringify( - Array.isArray(userData.groupTitle) ? userData.groupTitle : [userData.groupTitle] - ); - - hooks.fire('action:profile.update', userData); - - api.put('/users/' + userData.uid, userData).then((res) => { - alerts.success('[[user:profile_update_success]]'); - - if (res.picture) { - $('#user-current-picture').attr('src', res.picture); - } - - picture.updateHeader(res.picture); - }).catch(alerts.error); - - return false; - } - - function handleImageChange() { - $('#changePictureBtn').on('click', function () { - picture.openChangeModal(); - return false; - }); - } - - function handleAccountDelete() { - $('#deleteAccountBtn').on('click', function () { - translator.translate('[[user:delete_account_confirm]]', function (translated) { - const modal = bootbox.confirm(translated + '

    ', function (confirm) { - if (!confirm) { - return; - } - - const confirmBtn = modal.find('.btn-primary'); - confirmBtn.html(''); - confirmBtn.prop('disabled', true); - api.del(`/users/${ajaxify.data.uid}/account`, { - password: $('#confirm-password').val(), - }, function (err) { - function restoreButton() { - translator.translate('[[modules:bootbox.confirm]]', function (confirmText) { - confirmBtn.text(confirmText); - confirmBtn.prop('disabled', false); - }); - } - - if (err) { - restoreButton(); - return alerts.error(err); - } - - confirmBtn.html(''); - window.location.href = `${config.relative_path}/`; - }); - - return false; - }); - - modal.on('shown.bs.modal', function () { - modal.find('input').focus(); - }); - }); - return false; - }); - } - - function handleEmailConfirm() { - $('#confirm-email').on('click', function () { - const btn = $(this).attr('disabled', true); - socket.emit('user.emailConfirm', {}, function (err) { - btn.removeAttr('disabled'); - if (err) { - return alerts.error(err); - } - alerts.success('[[notifications:email-confirm-sent]]'); - }); - }); - } - - function getCharsLeft(el, max) { - return el.length ? '(' + el.val().length + '/' + max + ')' : ''; - } - - function updateSignature() { - const el = $('#signature'); - $('#signatureCharCountLeft').html(getCharsLeft(el, ajaxify.data.maximumSignatureLength)); - - el.on('keyup change', function () { - $('#signatureCharCountLeft').html(getCharsLeft(el, ajaxify.data.maximumSignatureLength)); - }); - } - - function updateAboutMe() { - const el = $('#aboutme'); - $('#aboutMeCharCountLeft').html(getCharsLeft(el, ajaxify.data.maximumAboutMeLength)); - - el.on('keyup change', function () { - $('#aboutMeCharCountLeft').html(getCharsLeft(el, ajaxify.data.maximumAboutMeLength)); - }); - } - - function handleGroupSort() { - function move(direction) { - const selected = $('#groupTitle').val(); - if (!ajaxify.data.allowMultipleBadges || (Array.isArray(selected) && selected.length > 1)) { - return; - } - const el = $('#groupTitle').find(':selected'); - if (el.length && el.val()) { - if (direction > 0) { - el.insertAfter(el.next()); - } else if (el.prev().val()) { - el.insertBefore(el.prev()); - } - } - } - $('[component="group/order/up"]').on('click', function () { - move(-1); - }); - $('[component="group/order/down"]').on('click', function () { - move(1); - }); - } - - return AccountEdit; + 'forum/account/header', + 'accounts/picture', + 'translator', + 'api', + 'hooks', + 'bootbox', + 'alerts', +], (header, picture, translator, api, hooks, bootbox, alerts) => { + const AccountEdit = {}; + + AccountEdit.init = function () { + header.init(); + + $('#submitBtn').on('click', updateProfile); + + if (ajaxify.data.groupTitleArray.length === 1 && ajaxify.data.groupTitleArray[0] === '') { + $('#groupTitle option[value=""]').attr('selected', true); + } + + handleImageChange(); + handleAccountDelete(); + handleEmailConfirm(); + updateSignature(); + updateAboutMe(); + handleGroupSort(); + }; + + function updateProfile() { + const userData = $('form[component="profile/edit/form"]').serializeObject(); + userData.uid = ajaxify.data.uid; + userData.groupTitle = userData.groupTitle || ''; + userData.groupTitle = JSON.stringify( + Array.isArray(userData.groupTitle) ? userData.groupTitle : [userData.groupTitle], + ); + + hooks.fire('action:profile.update', userData); + + api.put('/users/' + userData.uid, userData).then(res => { + alerts.success('[[user:profile_update_success]]'); + + if (res.picture) { + $('#user-current-picture').attr('src', res.picture); + } + + picture.updateHeader(res.picture); + }).catch(alerts.error); + + return false; + } + + function handleImageChange() { + $('#changePictureBtn').on('click', () => { + picture.openChangeModal(); + return false; + }); + } + + function handleAccountDelete() { + $('#deleteAccountBtn').on('click', () => { + translator.translate('[[user:delete_account_confirm]]', translated => { + const modal = bootbox.confirm(translated + '

    ', confirm => { + if (!confirm) { + return; + } + + const confirmButton = modal.find('.btn-primary'); + confirmButton.html(''); + confirmButton.prop('disabled', true); + api.del(`/users/${ajaxify.data.uid}/account`, { + password: $('#confirm-password').val(), + }, error => { + function restoreButton() { + translator.translate('[[modules:bootbox.confirm]]', confirmText => { + confirmButton.text(confirmText); + confirmButton.prop('disabled', false); + }); + } + + if (error) { + restoreButton(); + return alerts.error(error); + } + + confirmButton.html(''); + window.location.href = `${config.relative_path}/`; + }); + + return false; + }); + + modal.on('shown.bs.modal', () => { + modal.find('input').focus(); + }); + }); + return false; + }); + } + + function handleEmailConfirm() { + $('#confirm-email').on('click', function () { + const button = $(this).attr('disabled', true); + socket.emit('user.emailConfirm', {}, error => { + button.removeAttr('disabled'); + if (error) { + return alerts.error(error); + } + + alerts.success('[[notifications:email-confirm-sent]]'); + }); + }); + } + + function getCharsLeft(element, max) { + return element.length > 0 ? '(' + element.val().length + '/' + max + ')' : ''; + } + + function updateSignature() { + const element = $('#signature'); + $('#signatureCharCountLeft').html(getCharsLeft(element, ajaxify.data.maximumSignatureLength)); + + element.on('keyup change', () => { + $('#signatureCharCountLeft').html(getCharsLeft(element, ajaxify.data.maximumSignatureLength)); + }); + } + + function updateAboutMe() { + const element = $('#aboutme'); + $('#aboutMeCharCountLeft').html(getCharsLeft(element, ajaxify.data.maximumAboutMeLength)); + + element.on('keyup change', () => { + $('#aboutMeCharCountLeft').html(getCharsLeft(element, ajaxify.data.maximumAboutMeLength)); + }); + } + + function handleGroupSort() { + function move(direction) { + const selected = $('#groupTitle').val(); + if (!ajaxify.data.allowMultipleBadges || (Array.isArray(selected) && selected.length > 1)) { + return; + } + + const element = $('#groupTitle').find(':selected'); + if (element.length > 0 && element.val()) { + if (direction > 0) { + element.insertAfter(element.next()); + } else if (element.prev().val()) { + element.insertBefore(element.prev()); + } + } + } + + $('[component="group/order/up"]').on('click', () => { + move(-1); + }); + $('[component="group/order/down"]').on('click', () => { + move(1); + }); + } + + return AccountEdit; }); diff --git a/public/src/client/account/edit/password.js b/public/src/client/account/edit/password.js index 5b6213d..37f5926 100644 --- a/public/src/client/account/edit/password.js +++ b/public/src/client/account/edit/password.js @@ -1,121 +1,122 @@ 'use strict'; define('forum/account/edit/password', [ - 'forum/account/header', 'translator', 'zxcvbn', 'api', 'alerts', -], function (header, translator, zxcvbn, api, alerts) { - const AccountEditPassword = {}; - - AccountEditPassword.init = function () { - header.init(); - - handlePasswordChange(); - }; - - function handlePasswordChange() { - const currentPassword = $('#inputCurrentPassword'); - const password_notify = $('#password-notify'); - const password_confirm_notify = $('#password-confirm-notify'); - const password = $('#inputNewPassword'); - const password_confirm = $('#inputNewPasswordAgain'); - let passwordvalid = false; - let passwordsmatch = false; - - function onPasswordChanged() { - passwordvalid = false; - - try { - utils.assertPasswordValidity(password.val(), zxcvbn); - - if (password.val() === ajaxify.data.username) { - throw new Error('[[user:password_same_as_username]]'); - } else if (password.val() === ajaxify.data.email) { - throw new Error('[[user:password_same_as_email]]'); - } - - showSuccess(password_notify); - passwordvalid = true; - } catch (err) { - showError(password_notify, err.message); - } - } - - function onPasswordConfirmChanged() { - if (password.val() !== password_confirm.val()) { - showError(password_confirm_notify, '[[user:change_password_error_match]]'); - passwordsmatch = false; - } else { - if (password.val()) { - showSuccess(password_confirm_notify); - } else { - password_confirm_notify.parent().removeClass('alert-success alert-danger'); - password_confirm_notify.children().show(); - password_confirm_notify.find('.msg').html(''); - } - - passwordsmatch = true; - } - } - - password.on('blur', onPasswordChanged); - password_confirm.on('blur', onPasswordConfirmChanged); - - $('#changePasswordBtn').on('click', function () { - onPasswordChanged(); - onPasswordConfirmChanged(); - - const btn = $(this); - if (passwordvalid && passwordsmatch) { - btn.addClass('disabled').find('i').removeClass('hide'); - api.put('/users/' + ajaxify.data.theirid + '/password', { - currentPassword: currentPassword.val(), - newPassword: password.val(), - }) - .then(() => { - if (parseInt(app.user.uid, 10) === parseInt(ajaxify.data.uid, 10)) { - window.location.href = config.relative_path + '/login'; - } else { - ajaxify.go('user/' + ajaxify.data.userslug + '/edit'); - } - }) - .finally(() => { - btn.removeClass('disabled').find('i').addClass('hide'); - currentPassword.val(''); - password.val(''); - password_confirm.val(''); - password_notify.parent().removeClass('show-success show-danger'); - password_confirm_notify.parent().removeClass('show-success show-danger'); - passwordsmatch = false; - passwordvalid = false; - }); - } else { - if (!passwordsmatch) { - alerts.error('[[user:change_password_error_match]]'); - } - - if (!passwordvalid) { - alerts.error('[[user:change_password_error]]'); - } - } - return false; - }); - } - - function showError(element, msg) { - translator.translate(msg, function (msg) { - element.html(msg); - - element.parent() - .removeClass('show-success') - .addClass('show-danger'); - }); - } - - function showSuccess(element) { - element.html(''); - element.parent() - .removeClass('show-danger') - .addClass('show-success'); - } - - return AccountEditPassword; + 'forum/account/header', 'translator', 'zxcvbn', 'api', 'alerts', +], (header, translator, zxcvbn, api, alerts) => { + const AccountEditPassword = {}; + + AccountEditPassword.init = function () { + header.init(); + + handlePasswordChange(); + }; + + function handlePasswordChange() { + const currentPassword = $('#inputCurrentPassword'); + const password_notify = $('#password-notify'); + const password_confirm_notify = $('#password-confirm-notify'); + const password = $('#inputNewPassword'); + const password_confirm = $('#inputNewPasswordAgain'); + let passwordvalid = false; + let passwordsmatch = false; + + function onPasswordChanged() { + passwordvalid = false; + + try { + utils.assertPasswordValidity(password.val(), zxcvbn); + + if (password.val() === ajaxify.data.username) { + throw new Error('[[user:password_same_as_username]]'); + } else if (password.val() === ajaxify.data.email) { + throw new Error('[[user:password_same_as_email]]'); + } + + showSuccess(password_notify); + passwordvalid = true; + } catch (error) { + showError(password_notify, error.message); + } + } + + function onPasswordConfirmChanged() { + if (password.val() === password_confirm.val()) { + if (password.val()) { + showSuccess(password_confirm_notify); + } else { + password_confirm_notify.parent().removeClass('alert-success alert-danger'); + password_confirm_notify.children().show(); + password_confirm_notify.find('.msg').html(''); + } + + passwordsmatch = true; + } else { + showError(password_confirm_notify, '[[user:change_password_error_match]]'); + passwordsmatch = false; + } + } + + password.on('blur', onPasswordChanged); + password_confirm.on('blur', onPasswordConfirmChanged); + + $('#changePasswordBtn').on('click', function () { + onPasswordChanged(); + onPasswordConfirmChanged(); + + const button = $(this); + if (passwordvalid && passwordsmatch) { + button.addClass('disabled').find('i').removeClass('hide'); + api.put('/users/' + ajaxify.data.theirid + '/password', { + currentPassword: currentPassword.val(), + newPassword: password.val(), + }) + .then(() => { + if (Number.parseInt(app.user.uid, 10) === Number.parseInt(ajaxify.data.uid, 10)) { + window.location.href = config.relative_path + '/login'; + } else { + ajaxify.go('user/' + ajaxify.data.userslug + '/edit'); + } + }) + .finally(() => { + button.removeClass('disabled').find('i').addClass('hide'); + currentPassword.val(''); + password.val(''); + password_confirm.val(''); + password_notify.parent().removeClass('show-success show-danger'); + password_confirm_notify.parent().removeClass('show-success show-danger'); + passwordsmatch = false; + passwordvalid = false; + }); + } else { + if (!passwordsmatch) { + alerts.error('[[user:change_password_error_match]]'); + } + + if (!passwordvalid) { + alerts.error('[[user:change_password_error]]'); + } + } + + return false; + }); + } + + function showError(element, message) { + translator.translate(message, message_ => { + element.html(message_); + + element.parent() + .removeClass('show-success') + .addClass('show-danger'); + }); + } + + function showSuccess(element) { + element.html(''); + element.parent() + .removeClass('show-danger') + .addClass('show-success'); + } + + return AccountEditPassword; }); diff --git a/public/src/client/account/edit/username.js b/public/src/client/account/edit/username.js index 887ae67..84e572f 100644 --- a/public/src/client/account/edit/username.js +++ b/public/src/client/account/edit/username.js @@ -1,51 +1,51 @@ 'use strict'; define('forum/account/edit/username', [ - 'forum/account/header', 'api', 'slugify', 'alerts', -], function (header, api, slugify, alerts) { - const AccountEditUsername = {}; - - AccountEditUsername.init = function () { - header.init(); - - $('#submitBtn').on('click', function updateUsername() { - const userData = { - uid: $('#inputUID').val(), - username: $('#inputNewUsername').val(), - password: $('#inputCurrentPassword').val(), - }; - - if (!userData.username) { - return; - } - - if (userData.username === userData.password) { - return alerts.error('[[user:username_same_as_password]]'); - } - - const btn = $(this); - btn.addClass('disabled').find('i').removeClass('hide'); - - api.put('/users/' + userData.uid, userData).then((response) => { - const userslug = slugify(userData.username); - if (userData.username && userslug && parseInt(userData.uid, 10) === parseInt(app.user.uid, 10)) { - $('[component="header/profilelink"]').attr('href', config.relative_path + '/user/' + userslug); - $('[component="header/profilelink/edit"]').attr('href', config.relative_path + '/user/' + userslug + '/edit'); - $('[component="header/profilelink/settings"]').attr('href', config.relative_path + '/user/' + userslug + '/settings'); - $('[component="header/username"]').text(userData.username); - $('[component="header/usericon"]').css('background-color', response['icon:bgColor']).text(response['icon:text']); - $('[component="avatar/icon"]').css('background-color', response['icon:bgColor']).text(response['icon:text']); - } - - ajaxify.go('user/' + userslug + '/edit'); - }).catch(alerts.error) - .finally(() => { - btn.removeClass('disabled').find('i').addClass('hide'); - }); - - return false; - }); - }; - - return AccountEditUsername; + 'forum/account/header', 'api', 'slugify', 'alerts', +], (header, api, slugify, alerts) => { + const AccountEditUsername = {}; + + AccountEditUsername.init = function () { + header.init(); + + $('#submitBtn').on('click', function updateUsername() { + const userData = { + uid: $('#inputUID').val(), + username: $('#inputNewUsername').val(), + password: $('#inputCurrentPassword').val(), + }; + + if (!userData.username) { + return; + } + + if (userData.username === userData.password) { + return alerts.error('[[user:username_same_as_password]]'); + } + + const button = $(this); + button.addClass('disabled').find('i').removeClass('hide'); + + api.put('/users/' + userData.uid, userData).then(response => { + const userslug = slugify(userData.username); + if (userData.username && userslug && Number.parseInt(userData.uid, 10) === Number.parseInt(app.user.uid, 10)) { + $('[component="header/profilelink"]').attr('href', config.relative_path + '/user/' + userslug); + $('[component="header/profilelink/edit"]').attr('href', config.relative_path + '/user/' + userslug + '/edit'); + $('[component="header/profilelink/settings"]').attr('href', config.relative_path + '/user/' + userslug + '/settings'); + $('[component="header/username"]').text(userData.username); + $('[component="header/usericon"]').css('background-color', response['icon:bgColor']).text(response['icon:text']); + $('[component="avatar/icon"]').css('background-color', response['icon:bgColor']).text(response['icon:text']); + } + + ajaxify.go('user/' + userslug + '/edit'); + }).catch(alerts.error) + .finally(() => { + button.removeClass('disabled').find('i').addClass('hide'); + }); + + return false; + }); + }; + + return AccountEditUsername; }); diff --git a/public/src/client/account/followers.js b/public/src/client/account/followers.js index 1ee6acd..c36e5d8 100644 --- a/public/src/client/account/followers.js +++ b/public/src/client/account/followers.js @@ -1,12 +1,11 @@ 'use strict'; +define('forum/account/followers', ['forum/account/header'], header => { + const Followers = {}; -define('forum/account/followers', ['forum/account/header'], function (header) { - const Followers = {}; + Followers.init = function () { + header.init(); + }; - Followers.init = function () { - header.init(); - }; - - return Followers; + return Followers; }); diff --git a/public/src/client/account/following.js b/public/src/client/account/following.js index e6e5b27..a500f6e 100644 --- a/public/src/client/account/following.js +++ b/public/src/client/account/following.js @@ -1,12 +1,11 @@ 'use strict'; +define('forum/account/following', ['forum/account/header'], header => { + const Following = {}; -define('forum/account/following', ['forum/account/header'], function (header) { - const Following = {}; + Following.init = function () { + header.init(); + }; - Following.init = function () { - header.init(); - }; - - return Following; + return Following; }); diff --git a/public/src/client/account/groups.js b/public/src/client/account/groups.js index 71666f2..0ea5906 100644 --- a/public/src/client/account/groups.js +++ b/public/src/client/account/groups.js @@ -1,20 +1,19 @@ 'use strict'; +define('forum/account/groups', ['forum/account/header'], header => { + const AccountTopics = {}; -define('forum/account/groups', ['forum/account/header'], function (header) { - const AccountTopics = {}; + AccountTopics.init = function () { + header.init(); - AccountTopics.init = function () { - header.init(); + const groupsElement = $('#groups-list'); - const groupsEl = $('#groups-list'); + groupsElement.on('click', '.list-cover', function () { + const groupSlug = $(this).parents('[data-slug]').attr('data-slug'); - groupsEl.on('click', '.list-cover', function () { - const groupSlug = $(this).parents('[data-slug]').attr('data-slug'); + ajaxify.go('groups/' + groupSlug); + }); + }; - ajaxify.go('groups/' + groupSlug); - }); - }; - - return AccountTopics; + return AccountTopics; }); diff --git a/public/src/client/account/ignored.js b/public/src/client/account/ignored.js index 4d2aa93..65c6719 100644 --- a/public/src/client/account/ignored.js +++ b/public/src/client/account/ignored.js @@ -1,13 +1,13 @@ 'use strict'; -define('forum/account/ignored', ['forum/account/header', 'forum/account/topics'], function (header, topics) { - const AccountIgnored = {}; +define('forum/account/ignored', ['forum/account/header', 'forum/account/topics'], (header, topics) => { + const AccountIgnored = {}; - AccountIgnored.init = function () { - header.init(); + AccountIgnored.init = function () { + header.init(); - topics.handleInfiniteScroll('account/ignored'); - }; + topics.handleInfiniteScroll('account/ignored'); + }; - return AccountIgnored; + return AccountIgnored; }); diff --git a/public/src/client/account/info.js b/public/src/client/account/info.js index 74c5b41..9258d5b 100644 --- a/public/src/client/account/info.js +++ b/public/src/client/account/info.js @@ -1,38 +1,38 @@ 'use strict'; +define('forum/account/info', ['forum/account/header', 'alerts', 'forum/account/sessions'], (header, alerts, sessions) => { + const Info = {}; -define('forum/account/info', ['forum/account/header', 'alerts', 'forum/account/sessions'], function (header, alerts, sessions) { - const Info = {}; + Info.init = function () { + header.init(); + handleModerationNote(); + sessions.prepareSessionRevocation(); + }; - Info.init = function () { - header.init(); - handleModerationNote(); - sessions.prepareSessionRevocation(); - }; + function handleModerationNote() { + $('[component="account/save-moderation-note"]').on('click', () => { + const note = $('[component="account/moderation-note"]').val(); + socket.emit('user.setModerationNote', {uid: ajaxify.data.uid, note}, error => { + if (error) { + return alerts.error(error); + } - function handleModerationNote() { - $('[component="account/save-moderation-note"]').on('click', function () { - const note = $('[component="account/moderation-note"]').val(); - socket.emit('user.setModerationNote', { uid: ajaxify.data.uid, note: note }, function (err) { - if (err) { - return alerts.error(err); - } - $('[component="account/moderation-note"]').val(''); - alerts.success('[[user:info.moderation-note.success]]'); - const timestamp = Date.now(); - const data = [{ - note: utils.escapeHTML(note), - user: app.user, - timestamp: timestamp, - timestampISO: utils.toISOString(timestamp), - }]; - app.parseAndTranslate('account/info', 'moderationNotes', { moderationNotes: data }, function (html) { - $('[component="account/moderation-note/list"]').prepend(html); - html.find('.timeago').timeago(); - }); - }); - }); - } + $('[component="account/moderation-note"]').val(''); + alerts.success('[[user:info.moderation-note.success]]'); + const timestamp = Date.now(); + const data = [{ + note: utils.escapeHTML(note), + user: app.user, + timestamp, + timestampISO: utils.toISOString(timestamp), + }]; + app.parseAndTranslate('account/info', 'moderationNotes', {moderationNotes: data}, html => { + $('[component="account/moderation-note/list"]').prepend(html); + html.find('.timeago').timeago(); + }); + }); + }); + } - return Info; + return Info; }); diff --git a/public/src/client/account/posts.js b/public/src/client/account/posts.js index 90d59df..0eee7c0 100644 --- a/public/src/client/account/posts.js +++ b/public/src/client/account/posts.js @@ -1,56 +1,56 @@ 'use strict'; - -define('forum/account/posts', ['forum/account/header', 'forum/infinitescroll', 'hooks'], function (header, infinitescroll, hooks) { - const AccountPosts = {}; - - let template; - let page = 1; - - AccountPosts.init = function () { - header.init(); - - $('[component="post/content"] img:not(.not-responsive)').addClass('img-responsive'); - - AccountPosts.handleInfiniteScroll('account/posts'); - }; - - AccountPosts.handleInfiniteScroll = function (_template) { - template = _template; - page = ajaxify.data.pagination.currentPage; - if (!config.usePagination) { - infinitescroll.init(loadMore); - } - }; - - function loadMore(direction) { - if (direction < 0) { - return; - } - const params = utils.params(); - page += 1; - params.page = page; - - infinitescroll.loadMoreXhr(params, function (data, done) { - if (data.posts && data.posts.length) { - onPostsLoaded(data.posts, done); - } else { - done(); - } - }); - } - - function onPostsLoaded(posts, callback) { - app.parseAndTranslate(template, 'posts', { posts: posts }, function (html) { - $('[component="posts"]').append(html); - html.find('img:not(.not-responsive)').addClass('img-responsive'); - html.find('.timeago').timeago(); - app.createUserTooltips(html); - utils.makeNumbersHumanReadable(html.find('.human-readable-number')); - hooks.fire('action:posts.loaded', { posts: posts }); - callback(); - }); - } - - return AccountPosts; +define('forum/account/posts', ['forum/account/header', 'forum/infinitescroll', 'hooks'], (header, infinitescroll, hooks) => { + const AccountPosts = {}; + + let template; + let page = 1; + + AccountPosts.init = function () { + header.init(); + + $('[component="post/content"] img:not(.not-responsive)').addClass('img-responsive'); + + AccountPosts.handleInfiniteScroll('account/posts'); + }; + + AccountPosts.handleInfiniteScroll = function (_template) { + template = _template; + page = ajaxify.data.pagination.currentPage; + if (!config.usePagination) { + infinitescroll.init(loadMore); + } + }; + + function loadMore(direction) { + if (direction < 0) { + return; + } + + const parameters = utils.params(); + page += 1; + parameters.page = page; + + infinitescroll.loadMoreXhr(parameters, (data, done) => { + if (data.posts && data.posts.length > 0) { + onPostsLoaded(data.posts, done); + } else { + done(); + } + }); + } + + function onPostsLoaded(posts, callback) { + app.parseAndTranslate(template, 'posts', {posts}, html => { + $('[component="posts"]').append(html); + html.find('img:not(.not-responsive)').addClass('img-responsive'); + html.find('.timeago').timeago(); + app.createUserTooltips(html); + utils.makeNumbersHumanReadable(html.find('.human-readable-number')); + hooks.fire('action:posts.loaded', {posts}); + callback(); + }); + } + + return AccountPosts; }); diff --git a/public/src/client/account/profile.js b/public/src/client/account/profile.js index 2e0aa14..8216bed 100644 --- a/public/src/client/account/profile.js +++ b/public/src/client/account/profile.js @@ -1,38 +1,37 @@ 'use strict'; - define('forum/account/profile', [ - 'forum/account/header', - 'bootbox', -], function (header, bootbox) { - const Account = {}; + 'forum/account/header', + 'bootbox', +], (header, bootbox) => { + const Account = {}; - Account.init = function () { - header.init(); + Account.init = function () { + header.init(); - app.enterRoom('user/' + ajaxify.data.theirid); + app.enterRoom('user/' + ajaxify.data.theirid); - processPage(); + processPage(); - if (parseInt(ajaxify.data.emailChanged, 10) === 1) { - bootbox.alert('[[user:emailUpdate.change-instructions]]'); - } + if (Number.parseInt(ajaxify.data.emailChanged, 10) === 1) { + bootbox.alert('[[user:emailUpdate.change-instructions]]'); + } - socket.removeListener('event:user_status_change', onUserStatusChange); - socket.on('event:user_status_change', onUserStatusChange); - }; + socket.removeListener('event:user_status_change', onUserStatusChange); + socket.on('event:user_status_change', onUserStatusChange); + }; - function processPage() { - $('[component="posts"] [component="post/content"] img:not(.not-responsive), [component="aboutme"] img:not(.not-responsive)').addClass('img-responsive'); - } + function processPage() { + $('[component="posts"] [component="post/content"] img:not(.not-responsive), [component="aboutme"] img:not(.not-responsive)').addClass('img-responsive'); + } - function onUserStatusChange(data) { - if (parseInt(ajaxify.data.theirid, 10) !== parseInt(data.uid, 10)) { - return; - } + function onUserStatusChange(data) { + if (Number.parseInt(ajaxify.data.theirid, 10) !== Number.parseInt(data.uid, 10)) { + return; + } - app.updateUserStatus($('.account [data-uid="' + data.uid + '"] [component="user/status"]'), data.status); - } + app.updateUserStatus($('.account [data-uid="' + data.uid + '"] [component="user/status"]'), data.status); + } - return Account; + return Account; }); diff --git a/public/src/client/account/sessions.js b/public/src/client/account/sessions.js index 387c2f2..19adb86 100644 --- a/public/src/client/account/sessions.js +++ b/public/src/client/account/sessions.js @@ -1,38 +1,38 @@ 'use strict'; +define('forum/account/sessions', ['forum/account/header', 'components', 'api', 'alerts'], (header, components, api, alerts) => { + const Sessions = {}; -define('forum/account/sessions', ['forum/account/header', 'components', 'api', 'alerts'], function (header, components, api, alerts) { - const Sessions = {}; + Sessions.init = function () { + header.init(); + Sessions.prepareSessionRevocation(); + }; - Sessions.init = function () { - header.init(); - Sessions.prepareSessionRevocation(); - }; + Sessions.prepareSessionRevocation = function () { + components.get('user/sessions').on('click', '[data-action]', function () { + const parentElement = $(this).parents('[data-uuid]'); + const uuid = parentElement.attr('data-uuid'); - Sessions.prepareSessionRevocation = function () { - components.get('user/sessions').on('click', '[data-action]', function () { - const parentEl = $(this).parents('[data-uuid]'); - const uuid = parentEl.attr('data-uuid'); + if (uuid) { + // This is done via DELETE because a user shouldn't be able to + // revoke his own session! This is what logout is for + api.del(`/users/${ajaxify.data.uid}/sessions/${uuid}`, {}).then(() => { + parentElement.remove(); + }).catch(error => { + try { + const errorObject = JSON.parse(error.responseText); + if (errorObject.loggedIn === false) { + window.location.href = config.relative_path + '/login?error=' + errorObject.title; + } - if (uuid) { - // This is done via DELETE because a user shouldn't be able to - // revoke his own session! This is what logout is for - api.del(`/users/${ajaxify.data.uid}/sessions/${uuid}`, {}).then(() => { - parentEl.remove(); - }).catch((err) => { - try { - const errorObj = JSON.parse(err.responseText); - if (errorObj.loggedIn === false) { - window.location.href = config.relative_path + '/login?error=' + errorObj.title; - } - alerts.error(errorObj.title); - } catch (e) { - alerts.error('[[error:invalid-data]]'); - } - }); - } - }); - }; + alerts.error(errorObject.title); + } catch { + alerts.error('[[error:invalid-data]]'); + } + }); + } + }); + }; - return Sessions; + return Sessions; }); diff --git a/public/src/client/account/settings.js b/public/src/client/account/settings.js index 3506511..4f59495 100644 --- a/public/src/client/account/settings.js +++ b/public/src/client/account/settings.js @@ -1,147 +1,147 @@ 'use strict'; - define('forum/account/settings', [ - 'forum/account/header', 'components', 'translator', 'api', 'alerts', -], function (header, components, translator, api, alerts) { - const AccountSettings = {}; - - // If page skin is changed but not saved, switch the skin back - $(window).on('action:ajaxify.start', function () { - if (ajaxify.data.template.name === 'account/settings' && $('#bootswatchSkin').length && $('#bootswatchSkin').val() !== config.bootswatchSkin) { - reskin(config.bootswatchSkin); - } - }); - - AccountSettings.init = function () { - header.init(); - - $('#submitBtn').on('click', function () { - const settings = loadSettings(); - - if (settings.homePageRoute === 'custom' && settings.homePageCustom) { - $.get(config.relative_path + '/' + settings.homePageCustom, function () { - saveSettings(settings); - }).fail(function () { - alerts.error('[[error:invalid-home-page-route]]'); - }); - } else { - saveSettings(settings); - } - - return false; - }); - - $('#bootswatchSkin').on('change', function () { - reskin($(this).val()); - }); - - $('[data-property="homePageRoute"]').on('change', toggleCustomRoute); - - toggleCustomRoute(); - - components.get('user/sessions').find('.timeago').timeago(); - }; - - function loadSettings() { - const settings = {}; - - $('.account').find('input, textarea, select').each(function (id, input) { - input = $(input); - const setting = input.attr('data-property'); - if (input.is('select')) { - settings[setting] = input.val(); - return; - } - - switch (input.attr('type')) { - case 'checkbox': - settings[setting] = input.is(':checked') ? 1 : 0; - break; - default: - settings[setting] = input.val(); - break; - } - }); - - return settings; - } - - function saveSettings(settings) { - api.put(`/users/${ajaxify.data.uid}/settings`, { settings }).then((newSettings) => { - alerts.success('[[success:settings-saved]]'); - let languageChanged = false; - for (const key in newSettings) { - if (newSettings.hasOwnProperty(key)) { - if (key === 'userLang' && config.userLang !== newSettings.userLang) { - languageChanged = true; - } - if (config.hasOwnProperty(key)) { - config[key] = newSettings[key]; - } - } - } - - if (languageChanged && parseInt(app.user.uid, 10) === parseInt(ajaxify.data.theirid, 10)) { - translator.translate('[[language:dir]]', config.userLang, function (translated) { - const htmlEl = $('html'); - htmlEl.attr('data-dir', translated); - htmlEl.css('direction', translated); - }); - - translator.switchTimeagoLanguage(utils.userLangToTimeagoCode(config.userLang), function () { - overrides.overrideTimeago(); - ajaxify.refresh(); - }); - } - }); - } - - function toggleCustomRoute() { - if ($('[data-property="homePageRoute"]').val() === 'custom') { - $('#homePageCustom').show(); - } else { - $('#homePageCustom').hide(); - $('[data-property="homePageCustom"]').val(''); - } - } - - function reskin(skinName) { - const clientEl = Array.prototype.filter.call(document.querySelectorAll('link[rel="stylesheet"]'), function (el) { - return el.href.indexOf(config.relative_path + '/assets/client') !== -1; - })[0] || null; - if (!clientEl) { - return; - } - - const currentSkinClassName = $('body').attr('class').split(/\s+/).filter(function (className) { - return className.startsWith('skin-'); - }); - if (!currentSkinClassName[0]) { - return; - } - let currentSkin = currentSkinClassName[0].slice(5); - currentSkin = currentSkin !== 'noskin' ? currentSkin : ''; - - // Stop execution if skin didn't change - if (skinName === currentSkin) { - return; - } - - const linkEl = document.createElement('link'); - linkEl.rel = 'stylesheet'; - linkEl.type = 'text/css'; - linkEl.href = config.relative_path + '/assets/client' + (skinName ? '-' + skinName : '') + '.css'; - linkEl.onload = function () { - clientEl.parentNode.removeChild(clientEl); - - // Update body class with proper skin name - $('body').removeClass(currentSkinClassName.join(' ')); - $('body').addClass('skin-' + (skinName || 'noskin')); - }; - - document.head.appendChild(linkEl); - } - - return AccountSettings; + 'forum/account/header', 'components', 'translator', 'api', 'alerts', +], (header, components, translator, api, alerts) => { + const AccountSettings = {}; + + // If page skin is changed but not saved, switch the skin back + $(window).on('action:ajaxify.start', () => { + if (ajaxify.data.template.name === 'account/settings' && $('#bootswatchSkin').length > 0 && $('#bootswatchSkin').val() !== config.bootswatchSkin) { + reskin(config.bootswatchSkin); + } + }); + + AccountSettings.init = function () { + header.init(); + + $('#submitBtn').on('click', () => { + const settings = loadSettings(); + + if (settings.homePageRoute === 'custom' && settings.homePageCustom) { + $.get(config.relative_path + '/' + settings.homePageCustom, () => { + saveSettings(settings); + }).fail(() => { + alerts.error('[[error:invalid-home-page-route]]'); + }); + } else { + saveSettings(settings); + } + + return false; + }); + + $('#bootswatchSkin').on('change', function () { + reskin($(this).val()); + }); + + $('[data-property="homePageRoute"]').on('change', toggleCustomRoute); + + toggleCustomRoute(); + + components.get('user/sessions').find('.timeago').timeago(); + }; + + function loadSettings() { + const settings = {}; + + $('.account').find('input, textarea, select').each((id, input) => { + input = $(input); + const setting = input.attr('data-property'); + if (input.is('select')) { + settings[setting] = input.val(); + return; + } + + switch (input.attr('type')) { + case 'checkbox': { + settings[setting] = input.is(':checked') ? 1 : 0; + break; + } + + default: { + settings[setting] = input.val(); + break; + } + } + }); + + return settings; + } + + function saveSettings(settings) { + api.put(`/users/${ajaxify.data.uid}/settings`, {settings}).then(newSettings => { + alerts.success('[[success:settings-saved]]'); + let languageChanged = false; + for (const key in newSettings) { + if (newSettings.hasOwnProperty(key)) { + if (key === 'userLang' && config.userLang !== newSettings.userLang) { + languageChanged = true; + } + + if (config.hasOwnProperty(key)) { + config[key] = newSettings[key]; + } + } + } + + if (languageChanged && Number.parseInt(app.user.uid, 10) === Number.parseInt(ajaxify.data.theirid, 10)) { + translator.translate('[[language:dir]]', config.userLang, translated => { + const htmlElement = $('html'); + htmlElement.attr('data-dir', translated); + htmlElement.css('direction', translated); + }); + + translator.switchTimeagoLanguage(utils.userLangToTimeagoCode(config.userLang), () => { + overrides.overrideTimeago(); + ajaxify.refresh(); + }); + } + }); + } + + function toggleCustomRoute() { + if ($('[data-property="homePageRoute"]').val() === 'custom') { + $('#homePageCustom').show(); + } else { + $('#homePageCustom').hide(); + $('[data-property="homePageCustom"]').val(''); + } + } + + function reskin(skinName) { + const clientElement = Array.prototype.filter.call(document.querySelectorAll('link[rel="stylesheet"]'), element => element.href.includes(config.relative_path + '/assets/client'))[0] || null; + if (!clientElement) { + return; + } + + const currentSkinClassName = $('body').attr('class').split(/\s+/).filter(className => className.startsWith('skin-')); + if (!currentSkinClassName[0]) { + return; + } + + let currentSkin = currentSkinClassName[0].slice(5); + currentSkin = currentSkin === 'noskin' ? '' : currentSkin; + + // Stop execution if skin didn't change + if (skinName === currentSkin) { + return; + } + + const linkElement = document.createElement('link'); + linkElement.rel = 'stylesheet'; + linkElement.type = 'text/css'; + linkElement.href = config.relative_path + '/assets/client' + (skinName ? '-' + skinName : '') + '.css'; + linkElement.addEventListener('load', () => { + clientElement.remove(); + + // Update body class with proper skin name + $('body').removeClass(currentSkinClassName.join(' ')); + $('body').addClass('skin-' + (skinName || 'noskin')); + }); + + document.head.append(linkElement); + } + + return AccountSettings; }); diff --git a/public/src/client/account/topics.js b/public/src/client/account/topics.js index 30d601a..ba9ca37 100644 --- a/public/src/client/account/topics.js +++ b/public/src/client/account/topics.js @@ -1,57 +1,57 @@ 'use strict'; - define('forum/account/topics', [ - 'forum/account/header', - 'forum/infinitescroll', - 'hooks', -], function (header, infinitescroll, hooks) { - const AccountTopics = {}; - - let template; - let page = 1; - - AccountTopics.init = function () { - header.init(); - - AccountTopics.handleInfiniteScroll('account/topics'); - }; - - AccountTopics.handleInfiniteScroll = function (_template) { - template = _template; - page = ajaxify.data.pagination.currentPage; - if (!config.usePagination) { - infinitescroll.init(loadMore); - } - }; - - function loadMore(direction) { - if (direction < 0) { - return; - } - const params = utils.params(); - page += 1; - params.page = page; - - infinitescroll.loadMoreXhr(params, function (data, done) { - if (data.topics && data.topics.length) { - onTopicsLoaded(data.topics, done); - } else { - done(); - } - }); - } - - function onTopicsLoaded(topics, callback) { - app.parseAndTranslate(template, 'topics', { topics: topics }, function (html) { - $('[component="category"]').append(html); - html.find('.timeago').timeago(); - app.createUserTooltips(html); - utils.makeNumbersHumanReadable(html.find('.human-readable-number')); - hooks.fire('action:topics.loaded', { topics: topics }); - callback(); - }); - } - - return AccountTopics; + 'forum/account/header', + 'forum/infinitescroll', + 'hooks', +], (header, infinitescroll, hooks) => { + const AccountTopics = {}; + + let template; + let page = 1; + + AccountTopics.init = function () { + header.init(); + + AccountTopics.handleInfiniteScroll('account/topics'); + }; + + AccountTopics.handleInfiniteScroll = function (_template) { + template = _template; + page = ajaxify.data.pagination.currentPage; + if (!config.usePagination) { + infinitescroll.init(loadMore); + } + }; + + function loadMore(direction) { + if (direction < 0) { + return; + } + + const parameters = utils.params(); + page += 1; + parameters.page = page; + + infinitescroll.loadMoreXhr(parameters, (data, done) => { + if (data.topics && data.topics.length > 0) { + onTopicsLoaded(data.topics, done); + } else { + done(); + } + }); + } + + function onTopicsLoaded(topics, callback) { + app.parseAndTranslate(template, 'topics', {topics}, html => { + $('[component="category"]').append(html); + html.find('.timeago').timeago(); + app.createUserTooltips(html); + utils.makeNumbersHumanReadable(html.find('.human-readable-number')); + hooks.fire('action:topics.loaded', {topics}); + callback(); + }); + } + + return AccountTopics; }); diff --git a/public/src/client/account/uploads.js b/public/src/client/account/uploads.js index cb8fdad..271fbe8 100644 --- a/public/src/client/account/uploads.js +++ b/public/src/client/account/uploads.js @@ -1,24 +1,25 @@ 'use strict'; -define('forum/account/uploads', ['forum/account/header', 'alerts'], function (header, alerts) { - const AccountUploads = {}; +define('forum/account/uploads', ['forum/account/header', 'alerts'], (header, alerts) => { + const AccountUploads = {}; - AccountUploads.init = function () { - header.init(); + AccountUploads.init = function () { + header.init(); - $('[data-action="delete"]').on('click', function () { - const el = $(this).parents('[data-name]'); - const name = el.attr('data-name'); + $('[data-action="delete"]').on('click', function () { + const element = $(this).parents('[data-name]'); + const name = element.attr('data-name'); - socket.emit('user.deleteUpload', { name: name, uid: ajaxify.data.uid }, function (err) { - if (err) { - return alerts.error(err); - } - el.remove(); - }); - return false; - }); - }; + socket.emit('user.deleteUpload', {name, uid: ajaxify.data.uid}, error => { + if (error) { + return alerts.error(error); + } - return AccountUploads; + element.remove(); + }); + return false; + }); + }; + + return AccountUploads; }); diff --git a/public/src/client/account/upvoted.js b/public/src/client/account/upvoted.js index 0caf341..d0b1493 100644 --- a/public/src/client/account/upvoted.js +++ b/public/src/client/account/upvoted.js @@ -1,16 +1,15 @@ 'use strict'; +define('forum/account/upvoted', ['forum/account/header', 'forum/account/posts'], (header, posts) => { + const Upvoted = {}; -define('forum/account/upvoted', ['forum/account/header', 'forum/account/posts'], function (header, posts) { - const Upvoted = {}; + Upvoted.init = function () { + header.init(); - Upvoted.init = function () { - header.init(); + $('[component="post/content"] img:not(.not-responsive)').addClass('img-responsive'); - $('[component="post/content"] img:not(.not-responsive)').addClass('img-responsive'); + posts.handleInfiniteScroll('account/upvoted'); + }; - posts.handleInfiniteScroll('account/upvoted'); - }; - - return Upvoted; + return Upvoted; }); diff --git a/public/src/client/account/watched.js b/public/src/client/account/watched.js index 8469e72..d6b80c5 100644 --- a/public/src/client/account/watched.js +++ b/public/src/client/account/watched.js @@ -1,14 +1,13 @@ 'use strict'; +define('forum/account/watched', ['forum/account/header', 'forum/account/topics'], (header, topics) => { + const AccountWatched = {}; -define('forum/account/watched', ['forum/account/header', 'forum/account/topics'], function (header, topics) { - const AccountWatched = {}; + AccountWatched.init = function () { + header.init(); - AccountWatched.init = function () { - header.init(); + topics.handleInfiniteScroll('account/watched'); + }; - topics.handleInfiniteScroll('account/watched'); - }; - - return AccountWatched; + return AccountWatched; }); diff --git a/public/src/client/categories.js b/public/src/client/categories.js index e71d180..51a19dd 100644 --- a/public/src/client/categories.js +++ b/public/src/client/categories.js @@ -1,71 +1,71 @@ 'use strict'; +define('forum/categories', ['components', 'categorySelector', 'hooks'], (components, categorySelector, hooks) => { + const categories = {}; -define('forum/categories', ['components', 'categorySelector', 'hooks'], function (components, categorySelector, hooks) { - const categories = {}; + $(window).on('action:ajaxify.start', (event, data) => { + if (ajaxify.currentPage !== data.url) { + socket.removeListener('event:new_post', categories.onNewPost); + } + }); - $(window).on('action:ajaxify.start', function (ev, data) { - if (ajaxify.currentPage !== data.url) { - socket.removeListener('event:new_post', categories.onNewPost); - } - }); + categories.init = function () { + app.enterRoom('categories'); - categories.init = function () { - app.enterRoom('categories'); + socket.removeListener('event:new_post', categories.onNewPost); + socket.on('event:new_post', categories.onNewPost); + categorySelector.init($('[component="category-selector"]'), { + privilege: 'find', + onSelect(category) { + ajaxify.go('/category/' + category.cid); + }, + }); - socket.removeListener('event:new_post', categories.onNewPost); - socket.on('event:new_post', categories.onNewPost); - categorySelector.init($('[component="category-selector"]'), { - privilege: 'find', - onSelect: function (category) { - ajaxify.go('/category/' + category.cid); - }, - }); + $('.category-header').tooltip({ + placement: 'bottom', + }); + }; - $('.category-header').tooltip({ - placement: 'bottom', - }); - }; + categories.onNewPost = function (data) { + if (data && data.posts && data.posts.length > 0 && data.posts[0].topic) { + renderNewPost(data.posts[0].topic.cid, data.posts[0]); + } + }; - categories.onNewPost = function (data) { - if (data && data.posts && data.posts.length && data.posts[0].topic) { - renderNewPost(data.posts[0].topic.cid, data.posts[0]); - } - }; + function renderNewPost(cid, post) { + const category = components.get('categories/category', 'cid', cid); + const numberRecentReplies = category.attr('data-numRecentReplies'); + if (!numberRecentReplies || !Number.parseInt(numberRecentReplies, 10)) { + return; + } - function renderNewPost(cid, post) { - const category = components.get('categories/category', 'cid', cid); - const numRecentReplies = category.attr('data-numRecentReplies'); - if (!numRecentReplies || !parseInt(numRecentReplies, 10)) { - return; - } - if (!category.find('[component="topic/teaser"]').length) { - return; - } + if (category.find('[component="topic/teaser"]').length === 0) { + return; + } - const recentPosts = category.find('[component="category/posts"]'); + const recentPosts = category.find('[component="category/posts"]'); - app.parseAndTranslate('partials/categories/lastpost', 'posts', { posts: [post] }, function (html) { - html.find('.post-content img:not(.not-responsive)').addClass('img-responsive'); - html.hide(); - if (recentPosts.length === 0) { - html.appendTo(category); - } else { - html.insertBefore(recentPosts.first()); - } + app.parseAndTranslate('partials/categories/lastpost', 'posts', {posts: [post]}, html => { + html.find('.post-content img:not(.not-responsive)').addClass('img-responsive'); + html.hide(); + if (recentPosts.length === 0) { + html.appendTo(category); + } else { + html.insertBefore(recentPosts.first()); + } - html.fadeIn(); + html.fadeIn(); - app.createUserTooltips(html); - html.find('.timeago').timeago(); + app.createUserTooltips(html); + html.find('.timeago').timeago(); - if (category.find('[component="category/posts"]').length > parseInt(numRecentReplies, 10)) { - recentPosts.last().remove(); - } + if (category.find('[component="category/posts"]').length > Number.parseInt(numberRecentReplies, 10)) { + recentPosts.last().remove(); + } - hooks.fire('action:posts.loaded', { posts: [post] }); - }); - } + hooks.fire('action:posts.loaded', {posts: [post]}); + }); + } - return categories; + return categories; }); diff --git a/public/src/client/category.js b/public/src/client/category.js index a8a4887..9833a72 100644 --- a/public/src/client/category.js +++ b/public/src/client/category.js @@ -1,155 +1,157 @@ 'use strict'; define('forum/category', [ - 'forum/infinitescroll', - 'share', - 'navigator', - 'topicList', - 'sort', - 'categorySelector', - 'hooks', - 'alerts', -], function (infinitescroll, share, navigator, topicList, sort, categorySelector, hooks, alerts) { - const Category = {}; - - $(window).on('action:ajaxify.start', function (ev, data) { - if (!String(data.url).startsWith('category/')) { - navigator.disable(); - } - }); - - Category.init = function () { - const cid = ajaxify.data.cid; - - app.enterRoom('category_' + cid); - - share.addShareHandlers(ajaxify.data.name); - - topicList.init('category', loadTopicsAfter); - - sort.handleSort('categoryTopicSort', 'category/' + ajaxify.data.slug); - - if (!config.usePagination) { - navigator.init('[component="category/topic"]', ajaxify.data.topic_count, Category.toTop, Category.toBottom, Category.navigatorCallback); - } else { - navigator.disable(); - } - - handleScrollToTopicIndex(); - - handleIgnoreWatch(cid); - - handleLoadMoreSubcategories(); - - categorySelector.init($('[component="category-selector"]'), { - privilege: 'find', - parentCid: ajaxify.data.cid, - onSelect: function (category) { - ajaxify.go('/category/' + category.cid); - }, - }); - - hooks.fire('action:topics.loaded', { topics: ajaxify.data.topics }); - hooks.fire('action:category.loaded', { cid: ajaxify.data.cid }); - }; - - function handleScrollToTopicIndex() { - let topicIndex = ajaxify.data.topicIndex; - if (topicIndex && utils.isNumber(topicIndex)) { - topicIndex = Math.max(0, parseInt(topicIndex, 10)); - if (topicIndex && window.location.search.indexOf('page=') === -1) { - navigator.scrollToElement($('[component="category/topic"][data-index="' + topicIndex + '"]'), true, 0); - } - } - } - - function handleIgnoreWatch(cid) { - $('[component="category/watching"], [component="category/ignoring"], [component="category/notwatching"]').on('click', function () { - const $this = $(this); - const state = $this.attr('data-state'); - - socket.emit('categories.setWatchState', { cid: cid, state: state }, function (err) { - if (err) { - return alerts.error(err); - } - - $('[component="category/watching/menu"]').toggleClass('hidden', state !== 'watching'); - $('[component="category/watching/check"]').toggleClass('fa-check', state === 'watching'); - - $('[component="category/notwatching/menu"]').toggleClass('hidden', state !== 'notwatching'); - $('[component="category/notwatching/check"]').toggleClass('fa-check', state === 'notwatching'); - - $('[component="category/ignoring/menu"]').toggleClass('hidden', state !== 'ignoring'); - $('[component="category/ignoring/check"]').toggleClass('fa-check', state === 'ignoring'); - - alerts.success('[[category:' + state + '.message]]'); - }); - }); - } - - function handleLoadMoreSubcategories() { - $('[component="category/load-more-subcategories"]').on('click', function () { - const btn = $(this); - socket.emit('categories.loadMoreSubCategories', { - cid: ajaxify.data.cid, - start: ajaxify.data.nextSubCategoryStart, - }, function (err, data) { - if (err) { - return alerts.error(err); - } - btn.toggleClass('hidden', !data.length || data.length < ajaxify.data.subCategoriesPerPage); - if (!data.length) { - return; - } - app.parseAndTranslate('category', 'children', { children: data }, function (html) { - html.find('.timeago').timeago(); - $('[component="category/subcategory/container"]').append(html); - utils.makeNumbersHumanReadable(html.find('.human-readable-number')); - app.createUserTooltips(html); - ajaxify.data.nextSubCategoryStart += ajaxify.data.subCategoriesPerPage; - ajaxify.data.subCategoriesLeft -= data.length; - btn.toggleClass('hidden', ajaxify.data.subCategoriesLeft <= 0) - .translateText('[[category:x-more-categories, ' + ajaxify.data.subCategoriesLeft + ']]'); - }); - }); - return false; - }); - } - - Category.toTop = function () { - navigator.scrollTop(0); - }; - - Category.toBottom = function () { - socket.emit('categories.getTopicCount', ajaxify.data.cid, function (err, count) { - if (err) { - return alerts.error(err); - } - - navigator.scrollBottom(count - 1); - }); - }; - - Category.navigatorCallback = function (topIndex, bottomIndex) { - return bottomIndex; - }; - - function loadTopicsAfter(after, direction, callback) { - callback = callback || function () {}; - - hooks.fire('action:topics.loading'); - const params = utils.params(); - infinitescroll.loadMore('categories.loadMore', { - cid: ajaxify.data.cid, - after: after, - direction: direction, - query: params, - categoryTopicSort: config.categoryTopicSort, - }, function (data, done) { - hooks.fire('action:topics.loaded', { topics: data.topics }); - callback(data, done); - }); - } - - return Category; + 'forum/infinitescroll', + 'share', + 'navigator', + 'topicList', + 'sort', + 'categorySelector', + 'hooks', + 'alerts', +], (infinitescroll, share, navigator, topicList, sort, categorySelector, hooks, alerts) => { + const Category = {}; + + $(window).on('action:ajaxify.start', (event, data) => { + if (!String(data.url).startsWith('category/')) { + navigator.disable(); + } + }); + + Category.init = function () { + const cid = ajaxify.data.cid; + + app.enterRoom('category_' + cid); + + share.addShareHandlers(ajaxify.data.name); + + topicList.init('category', loadTopicsAfter); + + sort.handleSort('categoryTopicSort', 'category/' + ajaxify.data.slug); + + if (config.usePagination) { + navigator.disable(); + } else { + navigator.init('[component="category/topic"]', ajaxify.data.topic_count, Category.toTop, Category.toBottom, Category.navigatorCallback); + } + + handleScrollToTopicIndex(); + + handleIgnoreWatch(cid); + + handleLoadMoreSubcategories(); + + categorySelector.init($('[component="category-selector"]'), { + privilege: 'find', + parentCid: ajaxify.data.cid, + onSelect(category) { + ajaxify.go('/category/' + category.cid); + }, + }); + + hooks.fire('action:topics.loaded', {topics: ajaxify.data.topics}); + hooks.fire('action:category.loaded', {cid: ajaxify.data.cid}); + }; + + function handleScrollToTopicIndex() { + let topicIndex = ajaxify.data.topicIndex; + if (topicIndex && utils.isNumber(topicIndex)) { + topicIndex = Math.max(0, Number.parseInt(topicIndex, 10)); + if (topicIndex && !window.location.search.includes('page=')) { + navigator.scrollToElement($('[component="category/topic"][data-index="' + topicIndex + '"]'), true, 0); + } + } + } + + function handleIgnoreWatch(cid) { + $('[component="category/watching"], [component="category/ignoring"], [component="category/notwatching"]').on('click', function () { + const $this = $(this); + const state = $this.attr('data-state'); + + socket.emit('categories.setWatchState', {cid, state}, error => { + if (error) { + return alerts.error(error); + } + + $('[component="category/watching/menu"]').toggleClass('hidden', state !== 'watching'); + $('[component="category/watching/check"]').toggleClass('fa-check', state === 'watching'); + + $('[component="category/notwatching/menu"]').toggleClass('hidden', state !== 'notwatching'); + $('[component="category/notwatching/check"]').toggleClass('fa-check', state === 'notwatching'); + + $('[component="category/ignoring/menu"]').toggleClass('hidden', state !== 'ignoring'); + $('[component="category/ignoring/check"]').toggleClass('fa-check', state === 'ignoring'); + + alerts.success('[[category:' + state + '.message]]'); + }); + }); + } + + function handleLoadMoreSubcategories() { + $('[component="category/load-more-subcategories"]').on('click', function () { + const button = $(this); + socket.emit('categories.loadMoreSubCategories', { + cid: ajaxify.data.cid, + start: ajaxify.data.nextSubCategoryStart, + }, (error, data) => { + if (error) { + return alerts.error(error); + } + + button.toggleClass('hidden', data.length === 0 || data.length < ajaxify.data.subCategoriesPerPage); + if (data.length === 0) { + return; + } + + app.parseAndTranslate('category', 'children', {children: data}, html => { + html.find('.timeago').timeago(); + $('[component="category/subcategory/container"]').append(html); + utils.makeNumbersHumanReadable(html.find('.human-readable-number')); + app.createUserTooltips(html); + ajaxify.data.nextSubCategoryStart += ajaxify.data.subCategoriesPerPage; + ajaxify.data.subCategoriesLeft -= data.length; + button.toggleClass('hidden', ajaxify.data.subCategoriesLeft <= 0) + .translateText('[[category:x-more-categories, ' + ajaxify.data.subCategoriesLeft + ']]'); + }); + }); + return false; + }); + } + + Category.toTop = function () { + navigator.scrollTop(0); + }; + + Category.toBottom = function () { + socket.emit('categories.getTopicCount', ajaxify.data.cid, (error, count) => { + if (error) { + return alerts.error(error); + } + + navigator.scrollBottom(count - 1); + }); + }; + + Category.navigatorCallback = function (topIndex, bottomIndex) { + return bottomIndex; + }; + + function loadTopicsAfter(after, direction, callback) { + callback ||= function () {}; + + hooks.fire('action:topics.loading'); + const parameters = utils.params(); + infinitescroll.loadMore('categories.loadMore', { + cid: ajaxify.data.cid, + after, + direction, + query: parameters, + categoryTopicSort: config.categoryTopicSort, + }, (data, done) => { + hooks.fire('action:topics.loaded', {topics: data.topics}); + callback(data, done); + }); + } + + return Category; }); diff --git a/public/src/client/category/tools.js b/public/src/client/category/tools.js index cf96ffb..a9c1eb9 100644 --- a/public/src/client/category/tools.js +++ b/public/src/client/category/tools.js @@ -1,332 +1,340 @@ 'use strict'; - define('forum/category/tools', [ - 'topicSelect', - 'forum/topic/threadTools', - 'components', - 'api', - 'bootbox', - 'alerts', -], function (topicSelect, threadTools, components, api, bootbox, alerts) { - const CategoryTools = {}; - - CategoryTools.init = function () { - topicSelect.init(updateDropdownOptions); - - handlePinnedTopicSort(); - - components.get('topic/delete').on('click', function () { - categoryCommand('del', '/state', 'delete', onDeletePurgeComplete); - return false; - }); - - components.get('topic/restore').on('click', function () { - categoryCommand('put', '/state', 'restore', onDeletePurgeComplete); - return false; - }); - - components.get('topic/purge').on('click', function () { - categoryCommand('del', '', 'purge', onDeletePurgeComplete); - return false; - }); - - components.get('topic/lock').on('click', function () { - categoryCommand('put', '/lock', 'lock', onCommandComplete); - return false; - }); - - components.get('topic/unlock').on('click', function () { - categoryCommand('del', '/lock', 'unlock', onCommandComplete); - return false; - }); - - components.get('topic/private').on('click', function () { - categoryCommand('put', '/private', 'private', onCommandComplete); - return false; - }); - - components.get('topic/public').on('click', function () { - categoryCommand('del', '/private', 'public', onCommandComplete); - return false; - }); - - components.get('topic/pin').on('click', function () { - categoryCommand('put', '/pin', 'pin', onCommandComplete); - return false; - }); - - components.get('topic/unpin').on('click', function () { - categoryCommand('del', '/pin', 'unpin', onCommandComplete); - return false; - }); - - // todo: should also use categoryCommand, but no write api call exists for this yet - components.get('topic/mark-unread-for-all').on('click', function () { - const tids = topicSelect.getSelectedTids(); - if (!tids.length) { - return alerts.error('[[error:no-topics-selected]]'); - } - socket.emit('topics.markAsUnreadForAll', tids, function (err) { - if (err) { - return alerts.error(err); - } - alerts.success('[[topic:markAsUnreadForAll.success]]'); - tids.forEach(function (tid) { - $('[component="category/topic"][data-tid="' + tid + '"]').addClass('unread'); - }); - onCommandComplete(); - }); - return false; - }); - - components.get('topic/move').on('click', function () { - require(['forum/topic/move'], function (move) { - const tids = topicSelect.getSelectedTids(); - - if (!tids.length) { - return alerts.error('[[error:no-topics-selected]]'); - } - move.init(tids, null, onCommandComplete); - }); - - return false; - }); - - components.get('topic/move-all').on('click', function () { - const cid = ajaxify.data.cid; - if (!ajaxify.data.template.category) { - return alerts.error('[[error:invalid-data]]'); - } - require(['forum/topic/move'], function (move) { - move.init(null, cid, function (err) { - if (err) { - return alerts.error(err); - } - - ajaxify.refresh(); - }); - }); - }); - - components.get('topic/merge').on('click', function () { - const tids = topicSelect.getSelectedTids(); - require(['forum/topic/merge'], function (merge) { - merge.init(function () { - if (tids.length) { - tids.forEach(function (tid) { - merge.addTopic(tid); - }); - } - }); - }); - }); - - CategoryTools.removeListeners(); - socket.on('event:topic_deleted', setDeleteState); - socket.on('event:topic_restored', setDeleteState); - socket.on('event:topic_purged', onTopicPurged); - socket.on('event:topic_locked', setLockedState); - socket.on('event:topic_unlocked', setLockedState); - socket.on('event:topic_private', setPrivateState); - socket.on('event:topic_public', setPrivateState); - socket.on('event:topic_pinned', setPinnedState); - socket.on('event:topic_unpinned', setPinnedState); - socket.on('event:topic_moved', onTopicMoved); - }; - - function categoryCommand(method, path, command, onComplete) { - if (!onComplete) { - onComplete = function () {}; - } - const tids = topicSelect.getSelectedTids(); - const body = {}; - const execute = function (ok) { - if (ok) { - Promise.all(tids.map(tid => api[method](`/topics/${tid}${path}`, body))) - .then(onComplete) - .catch(alerts.error); - } - }; - - if (!tids.length) { - return alerts.error('[[error:no-topics-selected]]'); - } - - switch (command) { - case 'delete': - case 'restore': - case 'purge': - bootbox.confirm(`[[topic:thread_tools.${command}_confirm]]`, execute); - break; - - case 'pin': - threadTools.requestPinExpiry(body, execute.bind(null, true)); - break; - - default: - execute(true); - break; - } - } - - CategoryTools.removeListeners = function () { - socket.removeListener('event:topic_deleted', setDeleteState); - socket.removeListener('event:topic_restored', setDeleteState); - socket.removeListener('event:topic_purged', onTopicPurged); - socket.removeListener('event:topic_locked', setLockedState); - socket.removeListener('event:topic_unlocked', setLockedState); - socket.removeListener('event:topic_private', setPrivateState); - socket.removeListener('event:topic_public', setPrivateState); - socket.removeListener('event:topic_pinned', setPinnedState); - socket.removeListener('event:topic_unpinned', setPinnedState); - socket.removeListener('event:topic_moved', onTopicMoved); - }; - - function closeDropDown() { - $('.thread-tools.open').find('.dropdown-toggle').trigger('click'); - } - - function onCommandComplete() { - closeDropDown(); - topicSelect.unselectAll(); - } - - function onDeletePurgeComplete() { - closeDropDown(); - updateDropdownOptions(); - } - - function updateDropdownOptions() { - const tids = topicSelect.getSelectedTids(); - const isAnyDeleted = isAny(isTopicDeleted, tids); - const areAllDeleted = areAll(isTopicDeleted, tids); - const isAnyPinned = isAny(isTopicPinned, tids); - const isAnyLocked = isAny(isTopicLocked, tids); - const isAnyScheduled = isAny(isTopicScheduled, tids); - const areAllScheduled = areAll(isTopicScheduled, tids); - - components.get('topic/delete').toggleClass('hidden', isAnyDeleted); - components.get('topic/restore').toggleClass('hidden', isAnyScheduled || !isAnyDeleted); - components.get('topic/purge').toggleClass('hidden', !areAllDeleted); - - components.get('topic/lock').toggleClass('hidden', isAnyLocked); - components.get('topic/unlock').toggleClass('hidden', !isAnyLocked); - - components.get('topic/pin').toggleClass('hidden', areAllScheduled || isAnyPinned); - components.get('topic/unpin').toggleClass('hidden', areAllScheduled || !isAnyPinned); - - components.get('topic/merge').toggleClass('hidden', isAnyScheduled); - } - - function isAny(method, tids) { - for (let i = 0; i < tids.length; i += 1) { - if (method(tids[i])) { - return true; - } - } - return false; - } - - function areAll(method, tids) { - for (let i = 0; i < tids.length; i += 1) { - if (!method(tids[i])) { - return false; - } - } - return true; - } - - function isTopicDeleted(tid) { - return getTopicEl(tid).hasClass('deleted'); - } - - function isTopicLocked(tid) { - return getTopicEl(tid).hasClass('locked'); - } - - function isTopicPinned(tid) { - return getTopicEl(tid).hasClass('pinned'); - } - - function isTopicScheduled(tid) { - return getTopicEl(tid).hasClass('scheduled'); - } - - function getTopicEl(tid) { - return components.get('category/topic', 'tid', tid); - } - - function setDeleteState(data) { - const topic = getTopicEl(data.tid); - topic.toggleClass('deleted', data.isDeleted); - topic.find('[component="topic/locked"]').toggleClass('hide', !data.isDeleted); - } - - function setPrivateState(data) { - const topic = getTopicEl(data.tid); - topic.toggleClass('private', data.isPrivate); - topic.find('[component="topic/locked"]').toggleClass('hide', !data.isPrivate); - } - - function setPinnedState(data) { - const topic = getTopicEl(data.tid); - topic.toggleClass('pinned', data.isPinned); - topic.find('[component="topic/pinned"]').toggleClass('hide', !data.isPinned); - ajaxify.refresh(); - } - - function setLockedState(data) { - const topic = getTopicEl(data.tid); - topic.toggleClass('locked', data.isLocked); - topic.find('[component="topic/locked"]').toggleClass('hide', !data.isLocked); - } - - function onTopicMoved(data) { - getTopicEl(data.tid).remove(); - } - - function onTopicPurged(data) { - getTopicEl(data.tid).remove(); - } - - function handlePinnedTopicSort() { - if (!ajaxify.data.topics || !ajaxify.data.template.category) { - return; - } - const numPinned = ajaxify.data.topics.filter(topic => topic.pinned).length; - if ((!app.user.isAdmin && !app.user.isMod) || numPinned < 2) { - return; - } - - app.loadJQueryUI(function () { - const topicListEl = $('[component="category"]').filter(function (i, e) { - return !$(e).parents('[widget-area],[data-widget-area]').length; - }); - let baseIndex = 0; - topicListEl.sortable({ - handle: '[component="topic/pinned"]', - items: '[component="category/topic"].pinned', - start: function () { - baseIndex = parseInt(topicListEl.find('[component="category/topic"].pinned').first().attr('data-index'), 10); - }, - update: function (ev, ui) { - socket.emit('topics.orderPinnedTopics', { - tid: ui.item.attr('data-tid'), - order: baseIndex + ui.item.index(), - }, function (err) { - if (err) { - return alerts.error(err); - } - topicListEl.find('[component="category/topic"].pinned').each((index, el) => { - $(el).attr('data-index', baseIndex + index); - }); - }); - }, - }); - }); - } - - return CategoryTools; + 'topicSelect', + 'forum/topic/threadTools', + 'components', + 'api', + 'bootbox', + 'alerts', +], (topicSelect, threadTools, components, api, bootbox, alerts) => { + const CategoryTools = {}; + + CategoryTools.init = function () { + topicSelect.init(updateDropdownOptions); + + handlePinnedTopicSort(); + + components.get('topic/delete').on('click', () => { + categoryCommand('del', '/state', 'delete', onDeletePurgeComplete); + return false; + }); + + components.get('topic/restore').on('click', () => { + categoryCommand('put', '/state', 'restore', onDeletePurgeComplete); + return false; + }); + + components.get('topic/purge').on('click', () => { + categoryCommand('del', '', 'purge', onDeletePurgeComplete); + return false; + }); + + components.get('topic/lock').on('click', () => { + categoryCommand('put', '/lock', 'lock', onCommandComplete); + return false; + }); + + components.get('topic/unlock').on('click', () => { + categoryCommand('del', '/lock', 'unlock', onCommandComplete); + return false; + }); + + components.get('topic/private').on('click', () => { + categoryCommand('put', '/private', 'private', onCommandComplete); + return false; + }); + + components.get('topic/public').on('click', () => { + categoryCommand('del', '/private', 'public', onCommandComplete); + return false; + }); + + components.get('topic/pin').on('click', () => { + categoryCommand('put', '/pin', 'pin', onCommandComplete); + return false; + }); + + components.get('topic/unpin').on('click', () => { + categoryCommand('del', '/pin', 'unpin', onCommandComplete); + return false; + }); + + // Todo: should also use categoryCommand, but no write api call exists for this yet + components.get('topic/mark-unread-for-all').on('click', () => { + const tids = topicSelect.getSelectedTids(); + if (tids.length === 0) { + return alerts.error('[[error:no-topics-selected]]'); + } + + socket.emit('topics.markAsUnreadForAll', tids, error => { + if (error) { + return alerts.error(error); + } + + alerts.success('[[topic:markAsUnreadForAll.success]]'); + for (const tid of tids) { + $('[component="category/topic"][data-tid="' + tid + '"]').addClass('unread'); + } + + onCommandComplete(); + }); + return false; + }); + + components.get('topic/move').on('click', () => { + require(['forum/topic/move'], move => { + const tids = topicSelect.getSelectedTids(); + + if (tids.length === 0) { + return alerts.error('[[error:no-topics-selected]]'); + } + + move.init(tids, null, onCommandComplete); + }); + + return false; + }); + + components.get('topic/move-all').on('click', () => { + const cid = ajaxify.data.cid; + if (!ajaxify.data.template.category) { + return alerts.error('[[error:invalid-data]]'); + } + + require(['forum/topic/move'], move => { + move.init(null, cid, error => { + if (error) { + return alerts.error(error); + } + + ajaxify.refresh(); + }); + }); + }); + + components.get('topic/merge').on('click', () => { + const tids = topicSelect.getSelectedTids(); + require(['forum/topic/merge'], merge => { + merge.init(() => { + if (tids.length > 0) { + for (const tid of tids) { + merge.addTopic(tid); + } + } + }); + }); + }); + + CategoryTools.removeListeners(); + socket.on('event:topic_deleted', setDeleteState); + socket.on('event:topic_restored', setDeleteState); + socket.on('event:topic_purged', onTopicPurged); + socket.on('event:topic_locked', setLockedState); + socket.on('event:topic_unlocked', setLockedState); + socket.on('event:topic_private', setPrivateState); + socket.on('event:topic_public', setPrivateState); + socket.on('event:topic_pinned', setPinnedState); + socket.on('event:topic_unpinned', setPinnedState); + socket.on('event:topic_moved', onTopicMoved); + }; + + function categoryCommand(method, path, command, onComplete) { + onComplete ||= function () {}; + + const tids = topicSelect.getSelectedTids(); + const body = {}; + const execute = function (ok) { + if (ok) { + Promise.all(tids.map(tid => api[method](`/topics/${tid}${path}`, body))) + .then(onComplete) + .catch(alerts.error); + } + }; + + if (tids.length === 0) { + return alerts.error('[[error:no-topics-selected]]'); + } + + switch (command) { + case 'delete': + case 'restore': + case 'purge': { + bootbox.confirm(`[[topic:thread_tools.${command}_confirm]]`, execute); + break; + } + + case 'pin': { + threadTools.requestPinExpiry(body, execute.bind(null, true)); + break; + } + + default: { + execute(true); + break; + } + } + } + + CategoryTools.removeListeners = function () { + socket.removeListener('event:topic_deleted', setDeleteState); + socket.removeListener('event:topic_restored', setDeleteState); + socket.removeListener('event:topic_purged', onTopicPurged); + socket.removeListener('event:topic_locked', setLockedState); + socket.removeListener('event:topic_unlocked', setLockedState); + socket.removeListener('event:topic_private', setPrivateState); + socket.removeListener('event:topic_public', setPrivateState); + socket.removeListener('event:topic_pinned', setPinnedState); + socket.removeListener('event:topic_unpinned', setPinnedState); + socket.removeListener('event:topic_moved', onTopicMoved); + }; + + function closeDropDown() { + $('.thread-tools.open').find('.dropdown-toggle').trigger('click'); + } + + function onCommandComplete() { + closeDropDown(); + topicSelect.unselectAll(); + } + + function onDeletePurgeComplete() { + closeDropDown(); + updateDropdownOptions(); + } + + function updateDropdownOptions() { + const tids = topicSelect.getSelectedTids(); + const isAnyDeleted = isAny(isTopicDeleted, tids); + const areAllDeleted = areAll(isTopicDeleted, tids); + const isAnyPinned = isAny(isTopicPinned, tids); + const isAnyLocked = isAny(isTopicLocked, tids); + const isAnyScheduled = isAny(isTopicScheduled, tids); + const areAllScheduled = areAll(isTopicScheduled, tids); + + components.get('topic/delete').toggleClass('hidden', isAnyDeleted); + components.get('topic/restore').toggleClass('hidden', isAnyScheduled || !isAnyDeleted); + components.get('topic/purge').toggleClass('hidden', !areAllDeleted); + + components.get('topic/lock').toggleClass('hidden', isAnyLocked); + components.get('topic/unlock').toggleClass('hidden', !isAnyLocked); + + components.get('topic/pin').toggleClass('hidden', areAllScheduled || isAnyPinned); + components.get('topic/unpin').toggleClass('hidden', areAllScheduled || !isAnyPinned); + + components.get('topic/merge').toggleClass('hidden', isAnyScheduled); + } + + function isAny(method, tids) { + for (const tid of tids) { + if (method(tid)) { + return true; + } + } + + return false; + } + + function areAll(method, tids) { + for (const tid of tids) { + if (!method(tid)) { + return false; + } + } + + return true; + } + + function isTopicDeleted(tid) { + return getTopicElement(tid).hasClass('deleted'); + } + + function isTopicLocked(tid) { + return getTopicElement(tid).hasClass('locked'); + } + + function isTopicPinned(tid) { + return getTopicElement(tid).hasClass('pinned'); + } + + function isTopicScheduled(tid) { + return getTopicElement(tid).hasClass('scheduled'); + } + + function getTopicElement(tid) { + return components.get('category/topic', 'tid', tid); + } + + function setDeleteState(data) { + const topic = getTopicElement(data.tid); + topic.toggleClass('deleted', data.isDeleted); + topic.find('[component="topic/locked"]').toggleClass('hide', !data.isDeleted); + } + + function setPrivateState(data) { + const topic = getTopicElement(data.tid); + topic.toggleClass('private', data.isPrivate); + topic.find('[component="topic/locked"]').toggleClass('hide', !data.isPrivate); + } + + function setPinnedState(data) { + const topic = getTopicElement(data.tid); + topic.toggleClass('pinned', data.isPinned); + topic.find('[component="topic/pinned"]').toggleClass('hide', !data.isPinned); + ajaxify.refresh(); + } + + function setLockedState(data) { + const topic = getTopicElement(data.tid); + topic.toggleClass('locked', data.isLocked); + topic.find('[component="topic/locked"]').toggleClass('hide', !data.isLocked); + } + + function onTopicMoved(data) { + getTopicElement(data.tid).remove(); + } + + function onTopicPurged(data) { + getTopicElement(data.tid).remove(); + } + + function handlePinnedTopicSort() { + if (!ajaxify.data.topics || !ajaxify.data.template.category) { + return; + } + + const numberPinned = ajaxify.data.topics.filter(topic => topic.pinned).length; + if ((!app.user.isAdmin && !app.user.isMod) || numberPinned < 2) { + return; + } + + app.loadJQueryUI(() => { + const topicListElement = $('[component="category"]').filter((i, e) => $(e).parents('[widget-area],[data-widget-area]').length === 0); + let baseIndex = 0; + topicListElement.sortable({ + handle: '[component="topic/pinned"]', + items: '[component="category/topic"].pinned', + start() { + baseIndex = Number.parseInt(topicListElement.find('[component="category/topic"].pinned').first().attr('data-index'), 10); + }, + update(event, ui) { + socket.emit('topics.orderPinnedTopics', { + tid: ui.item.attr('data-tid'), + order: baseIndex + ui.item.index(), + }, error => { + if (error) { + return alerts.error(error); + } + + topicListElement.find('[component="category/topic"].pinned').each((index, element) => { + $(element).attr('data-index', baseIndex + index); + }); + }); + }, + }); + }); + } + + return CategoryTools; }); diff --git a/public/src/client/chats.js b/public/src/client/chats.js index 88dbbbf..6891c04 100644 --- a/public/src/client/chats.js +++ b/public/src/client/chats.js @@ -1,521 +1,525 @@ 'use strict'; - define('forum/chats', [ - 'components', - 'translator', - 'mousetrap', - 'forum/chats/recent', - 'forum/chats/search', - 'forum/chats/messages', - 'composer/autocomplete', - 'hooks', - 'bootbox', - 'alerts', - 'chat', - 'api', - 'uploadHelpers', -], function ( - components, translator, mousetrap, - recentChats, search, messages, - autocomplete, hooks, bootbox, alerts, chatModule, - api, uploadHelpers -) { - const Chats = { - initialised: false, - }; - - let newMessage = false; - - Chats.init = function () { - const env = utils.findBootstrapEnvironment(); - - if (!Chats.initialised) { - Chats.addSocketListeners(); - Chats.addGlobalEventListeners(); - } - - recentChats.init(); - - Chats.addEventListeners(); - Chats.setActive(); - - if (env === 'md' || env === 'lg') { - Chats.addHotkeys(); - } - - $(document).ready(function () { - hooks.fire('action:chat.loaded', $('.chats-full')); - }); - - Chats.initialised = true; - messages.scrollToBottom($('.expanded-chat ul.chat-content')); - - search.init(); - - if (ajaxify.data.hasOwnProperty('roomId')) { - components.get('chat/input').focus(); - } - }; - - Chats.addEventListeners = function () { - Chats.addSendHandlers(ajaxify.data.roomId, $('.chat-input'), $('.expanded-chat button[data-action="send"]')); - Chats.addPopoutHandler(); - Chats.addActionHandlers(components.get('chat/messages'), ajaxify.data.roomId); - Chats.addMemberHandler(ajaxify.data.roomId, components.get('chat/controls').find('[data-action="members"]')); - Chats.addRenameHandler(ajaxify.data.roomId, components.get('chat/controls').find('[data-action="rename"]')); - Chats.addLeaveHandler(ajaxify.data.roomId, components.get('chat/controls').find('[data-action="leave"]')); - Chats.addScrollHandler(ajaxify.data.roomId, ajaxify.data.uid, $('.chat-content')); - Chats.addScrollBottomHandler($('.chat-content')); - Chats.addCharactersLeftHandler($('[component="chat/main-wrapper"]')); - Chats.addIPHandler($('[component="chat/main-wrapper"]')); - Chats.createAutoComplete($('[component="chat/input"]')); - Chats.addUploadHandler({ - dragDropAreaEl: $('.chats-full'), - pasteEl: $('[component="chat/input"]'), - uploadFormEl: $('[component="chat/upload"]'), - inputEl: $('[component="chat/input"]'), - }); - - $('[data-action="close"]').on('click', function () { - Chats.switchChat(); - }); - }; - - Chats.addUploadHandler = function (options) { - uploadHelpers.init({ - dragDropAreaEl: options.dragDropAreaEl, - pasteEl: options.pasteEl, - uploadFormEl: options.uploadFormEl, - route: '/api/post/upload', // using same route as post uploads - callback: function (uploads) { - const inputEl = options.inputEl; - let text = inputEl.val(); - uploads.forEach((upload) => { - text = text + (text ? '\n' : '') + (upload.isImage ? '!' : '') + `[${upload.filename}](${upload.url})`; - }); - inputEl.val(text); - }, - }); - }; - - Chats.addIPHandler = function (container) { - container.on('click', '.chat-ip-button', function () { - const ipEl = $(this).parent(); - const mid = ipEl.parents('[data-mid]').attr('data-mid'); - socket.emit('modules.chats.getIP', mid, function (err, ip) { - if (err) { - return alerts.error(err); - } - ipEl.html(ip); - }); - }); - }; - - Chats.addPopoutHandler = function () { - $('[data-action="pop-out"]').on('click', function () { - const text = components.get('chat/input').val(); - const roomId = ajaxify.data.roomId; - - if (app.previousUrl && app.previousUrl.match(/chats/)) { - ajaxify.go('user/' + ajaxify.data.userslug + '/chats', function () { - chatModule.openChat(roomId, ajaxify.data.uid); - }, true); - } else { - window.history.go(-1); - chatModule.openChat(roomId, ajaxify.data.uid); - } - - $(window).one('action:chat.loaded', function () { - components.get('chat/input').val(text); - }); - }); - }; - - Chats.addScrollHandler = function (roomId, uid, el) { - let loading = false; - el.off('scroll').on('scroll', function () { - messages.toggleScrollUpAlert(el); - if (loading) { - return; - } - - const top = (el[0].scrollHeight - el.height()) * 0.1; - if (el.scrollTop() >= top) { - return; - } - loading = true; - const start = parseInt(el.children('[data-mid]').length, 10); - api.get(`/chats/${roomId}/messages`, { uid, start }).then((data) => { - data = data.messages; - - if (!data) { - loading = false; - return; - } - data = data.filter(function (chatMsg) { - return !$('[component="chat/message"][data-mid="' + chatMsg.messageId + '"]').length; - }); - if (!data.length) { - loading = false; - return; - } - messages.parseMessage(data, function (html) { - const currentScrollTop = el.scrollTop(); - const previousHeight = el[0].scrollHeight; - html = $(html); - el.prepend(html); - html.find('.timeago').timeago(); - html.find('img:not(.not-responsive)').addClass('img-responsive'); - el.scrollTop((el[0].scrollHeight - previousHeight) + currentScrollTop); - loading = false; - }); - }).catch(alerts.error); - }); - }; - - Chats.addScrollBottomHandler = function (chatContent) { - chatContent.parent() - .find('[component="chat/messages/scroll-up-alert"]') - .off('click').on('click', function () { - messages.scrollToBottom(chatContent); - }); - }; - - Chats.addCharactersLeftHandler = function (parent) { - const element = parent.find('[component="chat/input"]'); - element.on('change keyup paste', function () { - messages.updateRemainingLength(parent); - }); - }; - - Chats.addActionHandlers = function (element, roomId) { - element.on('click', '[data-action]', function () { - const messageId = $(this).parents('[data-mid]').attr('data-mid'); - const action = this.getAttribute('data-action'); - - switch (action) { - case 'edit': { - const inputEl = $('[data-roomid="' + roomId + '"] [component="chat/input"]'); - messages.prepEdit(inputEl, messageId, roomId); - break; - } - case 'delete': - messages.delete(messageId, roomId); - break; - - case 'restore': - messages.restore(messageId, roomId); - break; - } - }); - }; - - Chats.addHotkeys = function () { - mousetrap.bind('ctrl+up', function () { - const activeContact = $('.chats-list .bg-info'); - const prev = activeContact.prev(); - - if (prev.length) { - Chats.switchChat(prev.attr('data-roomid')); - } - }); - mousetrap.bind('ctrl+down', function () { - const activeContact = $('.chats-list .bg-info'); - const next = activeContact.next(); - - if (next.length) { - Chats.switchChat(next.attr('data-roomid')); - } - }); - mousetrap.bind('up', function (e) { - if (e.target === components.get('chat/input').get(0)) { - // Retrieve message id from messages list - const message = components.get('chat/messages').find('.chat-message[data-self="1"]').last(); - if (!message.length) { - return; - } - const lastMid = message.attr('data-mid'); - const inputEl = components.get('chat/input'); - - messages.prepEdit(inputEl, lastMid, ajaxify.data.roomId); - } - }); - }; - - Chats.addMemberHandler = function (roomId, buttonEl) { - let modal; - - buttonEl.on('click', function () { - app.parseAndTranslate('partials/modals/manage_room', {}, function (html) { - modal = bootbox.dialog({ - title: '[[modules:chat.manage-room]]', - message: html, - }); - - modal.attr('component', 'chat/manage-modal'); - - Chats.refreshParticipantsList(roomId, modal); - Chats.addKickHandler(roomId, modal); - - const searchInput = modal.find('input'); - const errorEl = modal.find('.text-danger'); - require(['autocomplete', 'translator'], function (autocomplete, translator) { - autocomplete.user(searchInput, function (event, selected) { - errorEl.text(''); - api.post(`/chats/${roomId}/users`, { - uids: [selected.item.user.uid], - }).then((body) => { - Chats.refreshParticipantsList(roomId, modal, body); - searchInput.val(''); - }).catch((err) => { - translator.translate(err.message, function (translated) { - errorEl.text(translated); - }); - }); - }); - }); - }); - }); - }; - - Chats.addKickHandler = function (roomId, modal) { - modal.on('click', '[data-action="kick"]', function () { - const uid = parseInt(this.getAttribute('data-uid'), 10); - - api.delete(`/chats/${roomId}/users/${uid}`, {}).then((body) => { - Chats.refreshParticipantsList(roomId, modal, body); - }).catch(alerts.error); - }); - }; - - Chats.addLeaveHandler = function (roomId, buttonEl) { - buttonEl.on('click', function () { - bootbox.confirm({ - size: 'small', - title: '[[modules:chat.leave]]', - message: '

    [[modules:chat.leave-prompt]]

    [[modules:chat.leave-help]]

    ', - callback: function (ok) { - if (ok) { - api.delete(`/chats/${roomId}/users/${app.user.uid}`, {}).then(() => { - // Return user to chats page. If modal, close modal. - const modal = buttonEl.parents('.chat-modal'); - if (modal.length) { - chatModule.close(modal); - } else { - ajaxify.go('chats'); - } - }).catch(alerts.error); - } - }, - }); - }); - }; - - Chats.refreshParticipantsList = async (roomId, modal, data) => { - const listEl = modal.find('.list-group'); - - if (!data) { - try { - data = await api.get(`/chats/${roomId}/users`, {}); - } catch (err) { - translator.translate('[[error:invalid-data]]', function (translated) { - listEl.find('li').text(translated); - }); - } - } - - app.parseAndTranslate('partials/modals/manage_room_users', data, function (html) { - listEl.html(html); - }); - }; - - Chats.addRenameHandler = function (roomId, buttonEl, roomName) { - let modal; - - buttonEl.on('click', function () { - app.parseAndTranslate('partials/modals/rename_room', { - name: roomName || ajaxify.data.roomName, - }, function (html) { - modal = bootbox.dialog({ - title: '[[modules:chat.rename-room]]', - message: html, - buttons: { - save: { - label: '[[global:save]]', - className: 'btn-primary', - callback: submit, - }, - }, - }); - }); - }); - - function submit() { - api.put(`/chats/${roomId}`, { - name: modal.find('#roomName').val(), - }).catch(alerts.error); - } - }; - - Chats.addSendHandlers = function (roomId, inputEl, sendEl) { - inputEl.off('keypress').on('keypress', function (e) { - if (e.which === 13 && !e.shiftKey) { - messages.sendMessage(roomId, inputEl); - return false; - } - }); - - sendEl.off('click').on('click', function () { - messages.sendMessage(roomId, inputEl); - inputEl.focus(); - return false; - }); - }; - - Chats.createAutoComplete = function (element) { - if (!element.length) { - return; - } - - const data = { - element: element, - strategies: [], - options: { - style: { - 'z-index': 20000, - flex: 0, - top: 'inherit', - }, - placement: 'top', - }, - }; - - $(window).trigger('chat:autocomplete:init', data); - if (data.strategies.length) { - autocomplete.setup(data); - } - }; - - Chats.leave = function (el) { - const roomId = el.attr('data-roomid'); - api.delete(`/chats/${roomId}/users/${app.user.uid}`, {}).then(() => { - if (parseInt(roomId, 10) === parseInt(ajaxify.data.roomId, 10)) { - ajaxify.go('user/' + ajaxify.data.userslug + '/chats'); - } else { - el.remove(); - } - - const modal = chatModule.getModal(roomId); - if (modal.length) { - chatModule.close(modal); - } - }).catch(alerts.error); - }; - - Chats.switchChat = function (roomid) { - // Allow empty arg for return to chat list/close chat - if (!roomid) { - roomid = ''; - } - - const url = 'user/' + ajaxify.data.userslug + '/chats/' + roomid + window.location.search; - if (self.fetch) { - fetch(config.relative_path + '/api/' + url, { credentials: 'include' }) - .then(function (response) { - if (response.ok) { - response.json().then(function (payload) { - app.parseAndTranslate('partials/chats/message-window', payload, function (html) { - components.get('chat/main-wrapper').html(html); - html.find('.timeago').timeago(); - ajaxify.data = payload; - Chats.setActive(); - Chats.addEventListeners(); - hooks.fire('action:chat.loaded', $('.chats-full')); - messages.scrollToBottom($('.expanded-chat ul.chat-content')); - if (history.pushState) { - history.pushState({ - url: url, - }, null, window.location.protocol + '//' + window.location.host + config.relative_path + '/' + url); - } - }); - }); - } else { - console.warn('[search] Received ' + response.status); - } - }) - .catch(function (error) { - console.warn('[search] ' + error.message); - }); - } else { - ajaxify.go(url); - } - }; - - Chats.addGlobalEventListeners = function () { - $(window).on('mousemove keypress click', function () { - if (newMessage && ajaxify.data.roomId) { - socket.emit('modules.chats.markRead', ajaxify.data.roomId); - newMessage = false; - } - }); - }; - - Chats.addSocketListeners = function () { - socket.on('event:chats.receive', function (data) { - if (parseInt(data.roomId, 10) === parseInt(ajaxify.data.roomId, 10)) { - newMessage = data.self === 0; - data.message.self = data.self; - - messages.appendChatMessage($('.expanded-chat .chat-content'), data.message); - } else if (ajaxify.data.template.chats) { - const roomEl = $('[data-roomid=' + data.roomId + ']'); - - if (roomEl.length > 0) { - roomEl.addClass('unread'); - } else { - const recentEl = components.get('chat/recent'); - app.parseAndTranslate('partials/chats/recent_room', { - rooms: { - roomId: data.roomId, - lastUser: data.message.fromUser, - usernames: data.message.fromUser.username, - unread: true, - }, - }, function (html) { - recentEl.prepend(html); - }); - } - } - }); - - socket.on('event:user_status_change', function (data) { - app.updateUserStatus($('.chats-list [data-uid="' + data.uid + '"] [component="user/status"]'), data.status); - }); - - messages.addSocketListeners(); - - socket.on('event:chats.roomRename', function (data) { - const roomEl = components.get('chat/recent/room', data.roomId); - const titleEl = roomEl.find('[component="chat/title"]'); - ajaxify.data.roomName = data.newName; - - titleEl.text(data.newName); - }); - }; - - Chats.setActive = function () { - if (ajaxify.data.roomId) { - socket.emit('modules.chats.markRead', ajaxify.data.roomId); - $('[data-roomid="' + ajaxify.data.roomId + '"]').toggleClass('unread', false); - $('.expanded-chat [component="chat/input"]').focus(); - } - $('.chats-list li').removeClass('bg-info'); - $('.chats-list li[data-roomid="' + ajaxify.data.roomId + '"]').addClass('bg-info'); - - components.get('chat/nav-wrapper').attr('data-loaded', ajaxify.data.roomId ? '1' : '0'); - }; - - - return Chats; + 'components', + 'translator', + 'mousetrap', + 'forum/chats/recent', + 'forum/chats/search', + 'forum/chats/messages', + 'composer/autocomplete', + 'hooks', + 'bootbox', + 'alerts', + 'chat', + 'api', + 'uploadHelpers', +], ( + components, translator, mousetrap, + recentChats, search, messages, + autocomplete, hooks, bootbox, alerts, chatModule, + api, uploadHelpers, +) => { + const Chats = { + initialised: false, + }; + + let newMessage = false; + + Chats.init = function () { + const env = utils.findBootstrapEnvironment(); + + if (!Chats.initialised) { + Chats.addSocketListeners(); + Chats.addGlobalEventListeners(); + } + + recentChats.init(); + + Chats.addEventListeners(); + Chats.setActive(); + + if (env === 'md' || env === 'lg') { + Chats.addHotkeys(); + } + + $(document).ready(() => { + hooks.fire('action:chat.loaded', $('.chats-full')); + }); + + Chats.initialised = true; + messages.scrollToBottom($('.expanded-chat ul.chat-content')); + + search.init(); + + if (ajaxify.data.hasOwnProperty('roomId')) { + components.get('chat/input').focus(); + } + }; + + Chats.addEventListeners = function () { + Chats.addSendHandlers(ajaxify.data.roomId, $('.chat-input'), $('.expanded-chat button[data-action="send"]')); + Chats.addPopoutHandler(); + Chats.addActionHandlers(components.get('chat/messages'), ajaxify.data.roomId); + Chats.addMemberHandler(ajaxify.data.roomId, components.get('chat/controls').find('[data-action="members"]')); + Chats.addRenameHandler(ajaxify.data.roomId, components.get('chat/controls').find('[data-action="rename"]')); + Chats.addLeaveHandler(ajaxify.data.roomId, components.get('chat/controls').find('[data-action="leave"]')); + Chats.addScrollHandler(ajaxify.data.roomId, ajaxify.data.uid, $('.chat-content')); + Chats.addScrollBottomHandler($('.chat-content')); + Chats.addCharactersLeftHandler($('[component="chat/main-wrapper"]')); + Chats.addIPHandler($('[component="chat/main-wrapper"]')); + Chats.createAutoComplete($('[component="chat/input"]')); + Chats.addUploadHandler({ + dragDropAreaEl: $('.chats-full'), + pasteEl: $('[component="chat/input"]'), + uploadFormEl: $('[component="chat/upload"]'), + inputEl: $('[component="chat/input"]'), + }); + + $('[data-action="close"]').on('click', () => { + Chats.switchChat(); + }); + }; + + Chats.addUploadHandler = function (options) { + uploadHelpers.init({ + dragDropAreaEl: options.dragDropAreaEl, + pasteEl: options.pasteEl, + uploadFormEl: options.uploadFormEl, + route: '/api/post/upload', // Using same route as post uploads + callback(uploads) { + const inputElement = options.inputEl; + let text = inputElement.val(); + for (const upload of uploads) { + text = text + (text ? '\n' : '') + (upload.isImage ? '!' : '') + `[${upload.filename}](${upload.url})`; + } + + inputElement.val(text); + }, + }); + }; + + Chats.addIPHandler = function (container) { + container.on('click', '.chat-ip-button', function () { + const ipElement = $(this).parent(); + const mid = ipElement.parents('[data-mid]').attr('data-mid'); + socket.emit('modules.chats.getIP', mid, (error, ip) => { + if (error) { + return alerts.error(error); + } + + ipElement.html(ip); + }); + }); + }; + + Chats.addPopoutHandler = function () { + $('[data-action="pop-out"]').on('click', () => { + const text = components.get('chat/input').val(); + const roomId = ajaxify.data.roomId; + + if (app.previousUrl && /chats/.test(app.previousUrl)) { + ajaxify.go('user/' + ajaxify.data.userslug + '/chats', () => { + chatModule.openChat(roomId, ajaxify.data.uid); + }, true); + } else { + window.history.go(-1); + chatModule.openChat(roomId, ajaxify.data.uid); + } + + $(window).one('action:chat.loaded', () => { + components.get('chat/input').val(text); + }); + }); + }; + + Chats.addScrollHandler = function (roomId, uid, element) { + let loading = false; + element.off('scroll').on('scroll', () => { + messages.toggleScrollUpAlert(element); + if (loading) { + return; + } + + const top = (element[0].scrollHeight - element.height()) * 0.1; + if (element.scrollTop() >= top) { + return; + } + + loading = true; + const start = Number.parseInt(element.children('[data-mid]').length, 10); + api.get(`/chats/${roomId}/messages`, {uid, start}).then(data => { + data = data.messages; + + if (!data) { + loading = false; + return; + } + + data = data.filter(chatMessage => $('[component="chat/message"][data-mid="' + chatMessage.messageId + '"]').length === 0); + if (data.length === 0) { + loading = false; + return; + } + + messages.parseMessage(data, html => { + const currentScrollTop = element.scrollTop(); + const previousHeight = element[0].scrollHeight; + html = $(html); + element.prepend(html); + html.find('.timeago').timeago(); + html.find('img:not(.not-responsive)').addClass('img-responsive'); + element.scrollTop((element[0].scrollHeight - previousHeight) + currentScrollTop); + loading = false; + }); + }).catch(alerts.error); + }); + }; + + Chats.addScrollBottomHandler = function (chatContent) { + chatContent.parent() + .find('[component="chat/messages/scroll-up-alert"]') + .off('click').on('click', () => { + messages.scrollToBottom(chatContent); + }); + }; + + Chats.addCharactersLeftHandler = function (parent) { + const element = parent.find('[component="chat/input"]'); + element.on('change keyup paste', () => { + messages.updateRemainingLength(parent); + }); + }; + + Chats.addActionHandlers = function (element, roomId) { + element.on('click', '[data-action]', function () { + const messageId = $(this).parents('[data-mid]').attr('data-mid'); + const action = this.dataset.action; + + switch (action) { + case 'edit': { + const inputElement = $('[data-roomid="' + roomId + '"] [component="chat/input"]'); + messages.prepEdit(inputElement, messageId, roomId); + break; + } + + case 'delete': { + messages.delete(messageId, roomId); + break; + } + + case 'restore': { + messages.restore(messageId, roomId); + break; + } + } + }); + }; + + Chats.addHotkeys = function () { + mousetrap.bind('ctrl+up', () => { + const activeContact = $('.chats-list .bg-info'); + const previous = activeContact.prev(); + + if (previous.length > 0) { + Chats.switchChat(previous.attr('data-roomid')); + } + }); + mousetrap.bind('ctrl+down', () => { + const activeContact = $('.chats-list .bg-info'); + const next = activeContact.next(); + + if (next.length > 0) { + Chats.switchChat(next.attr('data-roomid')); + } + }); + mousetrap.bind('up', e => { + if (e.target === components.get('chat/input').get(0)) { + // Retrieve message id from messages list + const message = components.get('chat/messages').find('.chat-message[data-self="1"]').last(); + if (message.length === 0) { + return; + } + + const lastMid = message.attr('data-mid'); + const inputElement = components.get('chat/input'); + + messages.prepEdit(inputElement, lastMid, ajaxify.data.roomId); + } + }); + }; + + Chats.addMemberHandler = function (roomId, buttonElement) { + let modal; + + buttonElement.on('click', () => { + app.parseAndTranslate('partials/modals/manage_room', {}, html => { + modal = bootbox.dialog({ + title: '[[modules:chat.manage-room]]', + message: html, + }); + + modal.attr('component', 'chat/manage-modal'); + + Chats.refreshParticipantsList(roomId, modal); + Chats.addKickHandler(roomId, modal); + + const searchInput = modal.find('input'); + const errorElement = modal.find('.text-danger'); + require(['autocomplete', 'translator'], (autocomplete, translator) => { + autocomplete.user(searchInput, (event, selected) => { + errorElement.text(''); + api.post(`/chats/${roomId}/users`, { + uids: [selected.item.user.uid], + }).then(body => { + Chats.refreshParticipantsList(roomId, modal, body); + searchInput.val(''); + }).catch(error => { + translator.translate(error.message, translated => { + errorElement.text(translated); + }); + }); + }); + }); + }); + }); + }; + + Chats.addKickHandler = function (roomId, modal) { + modal.on('click', '[data-action="kick"]', function () { + const uid = Number.parseInt(this.dataset.uid, 10); + + api.delete(`/chats/${roomId}/users/${uid}`, {}).then(body => { + Chats.refreshParticipantsList(roomId, modal, body); + }).catch(alerts.error); + }); + }; + + Chats.addLeaveHandler = function (roomId, buttonElement) { + buttonElement.on('click', () => { + bootbox.confirm({ + size: 'small', + title: '[[modules:chat.leave]]', + message: '

    [[modules:chat.leave-prompt]]

    [[modules:chat.leave-help]]

    ', + callback(ok) { + if (ok) { + api.delete(`/chats/${roomId}/users/${app.user.uid}`, {}).then(() => { + // Return user to chats page. If modal, close modal. + const modal = buttonElement.parents('.chat-modal'); + if (modal.length > 0) { + chatModule.close(modal); + } else { + ajaxify.go('chats'); + } + }).catch(alerts.error); + } + }, + }); + }); + }; + + Chats.refreshParticipantsList = async (roomId, modal, data) => { + const listElement = modal.find('.list-group'); + + if (!data) { + try { + data = await api.get(`/chats/${roomId}/users`, {}); + } catch { + translator.translate('[[error:invalid-data]]', translated => { + listElement.find('li').text(translated); + }); + } + } + + app.parseAndTranslate('partials/modals/manage_room_users', data, html => { + listElement.html(html); + }); + }; + + Chats.addRenameHandler = function (roomId, buttonElement, roomName) { + let modal; + + buttonElement.on('click', () => { + app.parseAndTranslate('partials/modals/rename_room', { + name: roomName || ajaxify.data.roomName, + }, html => { + modal = bootbox.dialog({ + title: '[[modules:chat.rename-room]]', + message: html, + buttons: { + save: { + label: '[[global:save]]', + className: 'btn-primary', + callback: submit, + }, + }, + }); + }); + }); + + function submit() { + api.put(`/chats/${roomId}`, { + name: modal.find('#roomName').val(), + }).catch(alerts.error); + } + }; + + Chats.addSendHandlers = function (roomId, inputElement, sendElement) { + inputElement.off('keypress').on('keypress', e => { + if (e.which === 13 && !e.shiftKey) { + messages.sendMessage(roomId, inputElement); + return false; + } + }); + + sendElement.off('click').on('click', () => { + messages.sendMessage(roomId, inputElement); + inputElement.focus(); + return false; + }); + }; + + Chats.createAutoComplete = function (element) { + if (element.length === 0) { + return; + } + + const data = { + element, + strategies: [], + options: { + style: { + 'z-index': 20_000, + flex: 0, + top: 'inherit', + }, + placement: 'top', + }, + }; + + $(window).trigger('chat:autocomplete:init', data); + if (data.strategies.length > 0) { + autocomplete.setup(data); + } + }; + + Chats.leave = function (element) { + const roomId = element.attr('data-roomid'); + api.delete(`/chats/${roomId}/users/${app.user.uid}`, {}).then(() => { + if (Number.parseInt(roomId, 10) === Number.parseInt(ajaxify.data.roomId, 10)) { + ajaxify.go('user/' + ajaxify.data.userslug + '/chats'); + } else { + element.remove(); + } + + const modal = chatModule.getModal(roomId); + if (modal.length > 0) { + chatModule.close(modal); + } + }).catch(alerts.error); + }; + + Chats.switchChat = function (roomid) { + // Allow empty arg for return to chat list/close chat + roomid ||= ''; + + const url = 'user/' + ajaxify.data.userslug + '/chats/' + roomid + window.location.search; + if (self.fetch) { + fetch(config.relative_path + '/api/' + url, {credentials: 'include'}) + .then(response => { + if (response.ok) { + response.json().then(payload => { + app.parseAndTranslate('partials/chats/message-window', payload, html => { + components.get('chat/main-wrapper').html(html); + html.find('.timeago').timeago(); + ajaxify.data = payload; + Chats.setActive(); + Chats.addEventListeners(); + hooks.fire('action:chat.loaded', $('.chats-full')); + messages.scrollToBottom($('.expanded-chat ul.chat-content')); + if (history.pushState) { + history.pushState({ + url, + }, null, window.location.protocol + '//' + window.location.host + config.relative_path + '/' + url); + } + }); + }); + } else { + console.warn('[search] Received ' + response.status); + } + }) + .catch(error => { + console.warn('[search] ' + error.message); + }); + } else { + ajaxify.go(url); + } + }; + + Chats.addGlobalEventListeners = function () { + $(window).on('mousemove keypress click', () => { + if (newMessage && ajaxify.data.roomId) { + socket.emit('modules.chats.markRead', ajaxify.data.roomId); + newMessage = false; + } + }); + }; + + Chats.addSocketListeners = function () { + socket.on('event:chats.receive', data => { + if (Number.parseInt(data.roomId, 10) === Number.parseInt(ajaxify.data.roomId, 10)) { + newMessage = data.self === 0; + data.message.self = data.self; + + messages.appendChatMessage($('.expanded-chat .chat-content'), data.message); + } else if (ajaxify.data.template.chats) { + const roomElement = $('[data-roomid=' + data.roomId + ']'); + + if (roomElement.length > 0) { + roomElement.addClass('unread'); + } else { + const recentElement = components.get('chat/recent'); + app.parseAndTranslate('partials/chats/recent_room', { + rooms: { + roomId: data.roomId, + lastUser: data.message.fromUser, + usernames: data.message.fromUser.username, + unread: true, + }, + }, html => { + recentElement.prepend(html); + }); + } + } + }); + + socket.on('event:user_status_change', data => { + app.updateUserStatus($('.chats-list [data-uid="' + data.uid + '"] [component="user/status"]'), data.status); + }); + + messages.addSocketListeners(); + + socket.on('event:chats.roomRename', data => { + const roomElement = components.get('chat/recent/room', data.roomId); + const titleElement = roomElement.find('[component="chat/title"]'); + ajaxify.data.roomName = data.newName; + + titleElement.text(data.newName); + }); + }; + + Chats.setActive = function () { + if (ajaxify.data.roomId) { + socket.emit('modules.chats.markRead', ajaxify.data.roomId); + $('[data-roomid="' + ajaxify.data.roomId + '"]').toggleClass('unread', false); + $('.expanded-chat [component="chat/input"]').focus(); + } + + $('.chats-list li').removeClass('bg-info'); + $('.chats-list li[data-roomid="' + ajaxify.data.roomId + '"]').addClass('bg-info'); + + components.get('chat/nav-wrapper').attr('data-loaded', ajaxify.data.roomId ? '1' : '0'); + }; + + return Chats; }); diff --git a/public/src/client/chats/messages.js b/public/src/client/chats/messages.js index b330f72..b2a9357 100644 --- a/public/src/client/chats/messages.js +++ b/public/src/client/chats/messages.js @@ -1,210 +1,215 @@ 'use strict'; - define('forum/chats/messages', [ - 'components', 'translator', 'benchpress', 'hooks', - 'bootbox', 'alerts', 'messages', 'api', -], function (components, translator, Benchpress, hooks, bootbox, alerts, messagesModule, api) { - const messages = {}; - - messages.sendMessage = async function (roomId, inputEl) { - let message = inputEl.val(); - let mid = inputEl.attr('data-mid'); - - if (!message.trim().length) { - return; - } - - inputEl.val(''); - inputEl.removeAttr('data-mid'); - messages.updateRemainingLength(inputEl.parent()); - const payload = { roomId, message, mid }; - // TODO: move this to success callback of api.post/put call? - hooks.fire('action:chat.sent', payload); - ({ roomId, message, mid } = await hooks.fire('filter:chat.send', payload)); - - if (!mid) { - api.post(`/chats/${roomId}`, { message }).catch((err) => { - inputEl.val(message); - messages.updateRemainingLength(inputEl.parent()); - if (err.message === '[[error:email-not-confirmed-chat]]') { - return messagesModule.showEmailConfirmWarning(err.message); - } - - return alerts.alert({ - alert_id: 'chat_spam_error', - title: '[[global:alert.error]]', - message: err.message, - type: 'danger', - timeout: 10000, - }); - }); - } else { - api.put(`/chats/${roomId}/messages/${mid}`, { message }).catch((err) => { - inputEl.val(message); - inputEl.attr('data-mid', mid); - messages.updateRemainingLength(inputEl.parent()); - return alerts.error(err); - }); - } - }; - - messages.updateRemainingLength = function (parent) { - const element = parent.find('[component="chat/input"]'); - parent.find('[component="chat/message/length"]').text(element.val().length); - parent.find('[component="chat/message/remaining"]').text(config.maximumChatMessageLength - element.val().length); - hooks.fire('action:chat.updateRemainingLength', { - parent: parent, - }); - }; - - messages.appendChatMessage = function (chatContentEl, data) { - const lastSpeaker = parseInt(chatContentEl.find('.chat-message').last().attr('data-uid'), 10); - const lasttimestamp = parseInt(chatContentEl.find('.chat-message').last().attr('data-timestamp'), 10); - if (!Array.isArray(data)) { - data.newSet = lastSpeaker !== parseInt(data.fromuid, 10) || - parseInt(data.timestamp, 10) > parseInt(lasttimestamp, 10) + (1000 * 60 * 3); - } - - messages.parseMessage(data, function (html) { - onMessagesParsed(chatContentEl, html); - }); - }; - - function onMessagesParsed(chatContentEl, html) { - const newMessage = $(html); - const isAtBottom = messages.isAtBottom(chatContentEl); - newMessage.appendTo(chatContentEl); - newMessage.find('.timeago').timeago(); - newMessage.find('img:not(.not-responsive)').addClass('img-responsive'); - if (isAtBottom) { - messages.scrollToBottom(chatContentEl); - } - - hooks.fire('action:chat.received', { - messageEl: newMessage, - }); - } - - - messages.parseMessage = function (data, callback) { - function done(html) { - translator.translate(html, callback); - } - - if (Array.isArray(data)) { - Benchpress.render('partials/chats/message' + (Array.isArray(data) ? 's' : ''), { - messages: data, - }).then(done); - } else { - Benchpress.render('partials/chats/' + (data.system ? 'system-message' : 'message'), { - messages: data, - }).then(done); - } - }; - - messages.isAtBottom = function (containerEl, threshold) { - if (containerEl.length) { - const distanceToBottom = containerEl[0].scrollHeight - ( - containerEl.outerHeight() + containerEl.scrollTop() - ); - return distanceToBottom < (threshold || 100); - } - }; - - messages.scrollToBottom = function (containerEl) { - if (containerEl && containerEl.length) { - containerEl.scrollTop(containerEl[0].scrollHeight - containerEl.height()); - containerEl.parent() - .find('[component="chat/messages/scroll-up-alert"]') - .addClass('hidden'); - } - }; - - messages.toggleScrollUpAlert = function (containerEl) { - const isAtBottom = messages.isAtBottom(containerEl, 300); - containerEl.parent() - .find('[component="chat/messages/scroll-up-alert"]') - .toggleClass('hidden', isAtBottom); - }; - - messages.prepEdit = function (inputEl, messageId, roomId) { - socket.emit('modules.chats.getRaw', { mid: messageId, roomId: roomId }, function (err, raw) { - if (err) { - return alerts.error(err); - } - // Populate the input field with the raw message content - if (inputEl.val().length === 0) { - // By setting the `data-mid` attribute, I tell the chat code that I am editing a - // message, instead of posting a new one. - inputEl.attr('data-mid', messageId).addClass('editing'); - inputEl.val(raw).focus(); - - hooks.fire('action:chat.prepEdit', { - inputEl: inputEl, - messageId: messageId, - roomId: roomId, - }); - } - }); - }; - - messages.addSocketListeners = function () { - socket.removeListener('event:chats.edit', onChatMessageEdited); - socket.on('event:chats.edit', onChatMessageEdited); - - socket.removeListener('event:chats.delete', onChatMessageDeleted); - socket.on('event:chats.delete', onChatMessageDeleted); - - socket.removeListener('event:chats.restore', onChatMessageRestored); - socket.on('event:chats.restore', onChatMessageRestored); - }; - - function onChatMessageEdited(data) { - data.messages.forEach(function (message) { - const self = parseInt(message.fromuid, 10) === parseInt(app.user.uid, 10); - message.self = self ? 1 : 0; - messages.parseMessage(message, function (html) { - const body = components.get('chat/message', message.messageId); - if (body.length) { - body.replaceWith(html); - components.get('chat/message', message.messageId).find('.timeago').timeago(); - } - }); - }); - } - - function onChatMessageDeleted(messageId) { - components.get('chat/message', messageId) - .toggleClass('deleted', true) - .find('[component="chat/message/body"]').translateHtml('[[modules:chat.message-deleted]]'); - } - - function onChatMessageRestored(message) { - components.get('chat/message', message.messageId) - .toggleClass('deleted', false) - .find('[component="chat/message/body"]').html(message.content); - } - - messages.delete = function (messageId, roomId) { - translator.translate('[[modules:chat.delete_message_confirm]]', function (translated) { - bootbox.confirm(translated, function (ok) { - if (!ok) { - return; - } - - api.delete(`/chats/${roomId}/messages/${messageId}`, {}).then(() => { - components.get('chat/message', messageId).toggleClass('deleted', true); - }).catch(alerts.error); - }); - }); - }; - - messages.restore = function (messageId, roomId) { - api.post(`/chats/${roomId}/messages/${messageId}`, {}).then(() => { - components.get('chat/message', messageId).toggleClass('deleted', false); - }).catch(alerts.error); - }; - - return messages; + 'components', + 'translator', + 'benchpress', + 'hooks', + 'bootbox', + 'alerts', + 'messages', + 'api', +], (components, translator, Benchpress, hooks, bootbox, alerts, messagesModule, api) => { + const messages = {}; + + messages.sendMessage = async function (roomId, inputElement) { + let message = inputElement.val(); + let mid = inputElement.attr('data-mid'); + + if (message.trim().length === 0) { + return; + } + + inputElement.val(''); + inputElement.removeAttr('data-mid'); + messages.updateRemainingLength(inputElement.parent()); + const payload = {roomId, message, mid}; + // TODO: move this to success callback of api.post/put call? + hooks.fire('action:chat.sent', payload); + ({roomId, message, mid} = await hooks.fire('filter:chat.send', payload)); + + if (mid) { + api.put(`/chats/${roomId}/messages/${mid}`, {message}).catch(error => { + inputElement.val(message); + inputElement.attr('data-mid', mid); + messages.updateRemainingLength(inputElement.parent()); + return alerts.error(error); + }); + } else { + api.post(`/chats/${roomId}`, {message}).catch(error => { + inputElement.val(message); + messages.updateRemainingLength(inputElement.parent()); + if (error.message === '[[error:email-not-confirmed-chat]]') { + return messagesModule.showEmailConfirmWarning(error.message); + } + + return alerts.alert({ + alert_id: 'chat_spam_error', + title: '[[global:alert.error]]', + message: error.message, + type: 'danger', + timeout: 10_000, + }); + }); + } + }; + + messages.updateRemainingLength = function (parent) { + const element = parent.find('[component="chat/input"]'); + parent.find('[component="chat/message/length"]').text(element.val().length); + parent.find('[component="chat/message/remaining"]').text(config.maximumChatMessageLength - element.val().length); + hooks.fire('action:chat.updateRemainingLength', { + parent, + }); + }; + + messages.appendChatMessage = function (chatContentElement, data) { + const lastSpeaker = Number.parseInt(chatContentElement.find('.chat-message').last().attr('data-uid'), 10); + const lasttimestamp = Number.parseInt(chatContentElement.find('.chat-message').last().attr('data-timestamp'), 10); + if (!Array.isArray(data)) { + data.newSet = lastSpeaker !== Number.parseInt(data.fromuid, 10) + || Number.parseInt(data.timestamp, 10) > Number.parseInt(lasttimestamp, 10) + (1000 * 60 * 3); + } + + messages.parseMessage(data, html => { + onMessagesParsed(chatContentElement, html); + }); + }; + + function onMessagesParsed(chatContentElement, html) { + const newMessage = $(html); + const isAtBottom = messages.isAtBottom(chatContentElement); + newMessage.appendTo(chatContentElement); + newMessage.find('.timeago').timeago(); + newMessage.find('img:not(.not-responsive)').addClass('img-responsive'); + if (isAtBottom) { + messages.scrollToBottom(chatContentElement); + } + + hooks.fire('action:chat.received', { + messageEl: newMessage, + }); + } + + messages.parseMessage = function (data, callback) { + function done(html) { + translator.translate(html, callback); + } + + if (Array.isArray(data)) { + Benchpress.render('partials/chats/message' + (Array.isArray(data) ? 's' : ''), { + messages: data, + }).then(done); + } else { + Benchpress.render('partials/chats/' + (data.system ? 'system-message' : 'message'), { + messages: data, + }).then(done); + } + }; + + messages.isAtBottom = function (containerElement, threshold) { + if (containerElement.length > 0) { + const distanceToBottom = containerElement[0].scrollHeight - ( + containerElement.outerHeight() + containerElement.scrollTop() + ); + return distanceToBottom < (threshold || 100); + } + }; + + messages.scrollToBottom = function (containerElement) { + if (containerElement && containerElement.length > 0) { + containerElement.scrollTop(containerElement[0].scrollHeight - containerElement.height()); + containerElement.parent() + .find('[component="chat/messages/scroll-up-alert"]') + .addClass('hidden'); + } + }; + + messages.toggleScrollUpAlert = function (containerElement) { + const isAtBottom = messages.isAtBottom(containerElement, 300); + containerElement.parent() + .find('[component="chat/messages/scroll-up-alert"]') + .toggleClass('hidden', isAtBottom); + }; + + messages.prepEdit = function (inputElement, messageId, roomId) { + socket.emit('modules.chats.getRaw', {mid: messageId, roomId}, (error, raw) => { + if (error) { + return alerts.error(error); + } + + // Populate the input field with the raw message content + if (inputElement.val().length === 0) { + // By setting the `data-mid` attribute, I tell the chat code that I am editing a + // message, instead of posting a new one. + inputElement.attr('data-mid', messageId).addClass('editing'); + inputElement.val(raw).focus(); + + hooks.fire('action:chat.prepEdit', { + inputEl: inputElement, + messageId, + roomId, + }); + } + }); + }; + + messages.addSocketListeners = function () { + socket.removeListener('event:chats.edit', onChatMessageEdited); + socket.on('event:chats.edit', onChatMessageEdited); + + socket.removeListener('event:chats.delete', onChatMessageDeleted); + socket.on('event:chats.delete', onChatMessageDeleted); + + socket.removeListener('event:chats.restore', onChatMessageRestored); + socket.on('event:chats.restore', onChatMessageRestored); + }; + + function onChatMessageEdited(data) { + for (const message of data.messages) { + const self = Number.parseInt(message.fromuid, 10) === Number.parseInt(app.user.uid, 10); + message.self = self ? 1 : 0; + messages.parseMessage(message, html => { + const body = components.get('chat/message', message.messageId); + if (body.length > 0) { + body.replaceWith(html); + components.get('chat/message', message.messageId).find('.timeago').timeago(); + } + }); + } + } + + function onChatMessageDeleted(messageId) { + components.get('chat/message', messageId) + .toggleClass('deleted', true) + .find('[component="chat/message/body"]').translateHtml('[[modules:chat.message-deleted]]'); + } + + function onChatMessageRestored(message) { + components.get('chat/message', message.messageId) + .toggleClass('deleted', false) + .find('[component="chat/message/body"]').html(message.content); + } + + messages.delete = function (messageId, roomId) { + translator.translate('[[modules:chat.delete_message_confirm]]', translated => { + bootbox.confirm(translated, ok => { + if (!ok) { + return; + } + + api.delete(`/chats/${roomId}/messages/${messageId}`, {}).then(() => { + components.get('chat/message', messageId).toggleClass('deleted', true); + }).catch(alerts.error); + }); + }); + }; + + messages.restore = function (messageId, roomId) { + api.post(`/chats/${roomId}/messages/${messageId}`, {}).then(() => { + components.get('chat/message', messageId).toggleClass('deleted', false); + }).catch(alerts.error); + }; + + return messages; }); diff --git a/public/src/client/chats/recent.js b/public/src/client/chats/recent.js index 18f17e6..24b2d75 100644 --- a/public/src/client/chats/recent.js +++ b/public/src/client/chats/recent.js @@ -1,62 +1,61 @@ 'use strict'; - -define('forum/chats/recent', ['alerts'], function (alerts) { - const recent = {}; - - recent.init = function () { - require(['forum/chats'], function (Chats) { - $('[component="chat/recent"]').on('click', '[component="chat/recent/room"]', function () { - Chats.switchChat($(this).attr('data-roomid')); - }); - - $('[component="chat/recent"]').on('scroll', function () { - const $this = $(this); - const bottom = ($this[0].scrollHeight - $this.height()) * 0.9; - if ($this.scrollTop() > bottom) { - loadMoreRecentChats(); - } - }); - }); - }; - - function loadMoreRecentChats() { - const recentChats = $('[component="chat/recent"]'); - if (recentChats.attr('loading')) { - return; - } - recentChats.attr('loading', 1); - socket.emit('modules.chats.getRecentChats', { - uid: ajaxify.data.uid, - after: recentChats.attr('data-nextstart'), - }, function (err, data) { - if (err) { - return alerts.error(err); - } - - if (data && data.rooms.length) { - onRecentChatsLoaded(data, function () { - recentChats.removeAttr('loading'); - recentChats.attr('data-nextstart', data.nextStart); - }); - } else { - recentChats.removeAttr('loading'); - } - }); - } - - function onRecentChatsLoaded(data, callback) { - if (!data.rooms.length) { - return callback(); - } - - app.parseAndTranslate('chats', 'rooms', data, function (html) { - $('[component="chat/recent"]').append(html); - html.find('.timeago').timeago(); - callback(); - }); - } - - - return recent; +define('forum/chats/recent', ['alerts'], alerts => { + const recent = {}; + + recent.init = function () { + require(['forum/chats'], Chats => { + $('[component="chat/recent"]').on('click', '[component="chat/recent/room"]', function () { + Chats.switchChat($(this).attr('data-roomid')); + }); + + $('[component="chat/recent"]').on('scroll', function () { + const $this = $(this); + const bottom = ($this[0].scrollHeight - $this.height()) * 0.9; + if ($this.scrollTop() > bottom) { + loadMoreRecentChats(); + } + }); + }); + }; + + function loadMoreRecentChats() { + const recentChats = $('[component="chat/recent"]'); + if (recentChats.attr('loading')) { + return; + } + + recentChats.attr('loading', 1); + socket.emit('modules.chats.getRecentChats', { + uid: ajaxify.data.uid, + after: recentChats.attr('data-nextstart'), + }, (error, data) => { + if (error) { + return alerts.error(error); + } + + if (data && data.rooms.length > 0) { + onRecentChatsLoaded(data, () => { + recentChats.removeAttr('loading'); + recentChats.attr('data-nextstart', data.nextStart); + }); + } else { + recentChats.removeAttr('loading'); + } + }); + } + + function onRecentChatsLoaded(data, callback) { + if (data.rooms.length === 0) { + return callback(); + } + + app.parseAndTranslate('chats', 'rooms', data, html => { + $('[component="chat/recent"]').append(html); + html.find('.timeago').timeago(); + callback(); + }); + } + + return recent; }); diff --git a/public/src/client/chats/search.js b/public/src/client/chats/search.js index 7adee5c..14b8d2c 100644 --- a/public/src/client/chats/search.js +++ b/public/src/client/chats/search.js @@ -1,81 +1,79 @@ 'use strict'; +define('forum/chats/search', ['components', 'api', 'alerts'], (components, api, alerts) => { + const search = {}; -define('forum/chats/search', ['components', 'api', 'alerts'], function (components, api, alerts) { - const search = {}; + search.init = function () { + components.get('chat/search').on('keyup', utils.debounce(doSearch, 250)); + }; - search.init = function () { - components.get('chat/search').on('keyup', utils.debounce(doSearch, 250)); - }; + function doSearch() { + const username = components.get('chat/search').val(); + if (!username) { + return $('[component="chat/search/list"]').empty(); + } - function doSearch() { - const username = components.get('chat/search').val(); - if (!username) { - return $('[component="chat/search/list"]').empty(); - } + api.get('/api/users', { + query: username, + searchBy: 'username', + paginate: false, + }).then(displayResults) + .catch(alerts.error); + } - api.get('/api/users', { - query: username, - searchBy: 'username', - paginate: false, - }).then(displayResults) - .catch(alerts.error); - } + function displayResults(data) { + const chatsListElement = $('[component="chat/search/list"]'); + chatsListElement.empty(); - function displayResults(data) { - const chatsListEl = $('[component="chat/search/list"]'); - chatsListEl.empty(); + data.users = data.users.filter(user => Number.parseInt(user.uid, 10) !== Number.parseInt(app.user.uid, 10)); - data.users = data.users.filter(function (user) { - return parseInt(user.uid, 10) !== parseInt(app.user.uid, 10); - }); + if (data.users.length === 0) { + return chatsListElement.translateHtml('
  • [[users:no-users-found]]
  • '); + } - if (!data.users.length) { - return chatsListEl.translateHtml('
  • [[users:no-users-found]]
  • '); - } + for (const userObject of data.users) { + const chatElement = displayUser(chatsListElement, userObject); + onUserClick(chatElement, userObject); + } - data.users.forEach(function (userObj) { - const chatEl = displayUser(chatsListEl, userObj); - onUserClick(chatEl, userObj); - }); + chatsListElement.parent().toggleClass('open', true); + } - chatsListEl.parent().toggleClass('open', true); - } + function displayUser(chatsListElement, userObject) { + function createUserImage() { + return (userObject.picture + ? '' + : '
    ' + userObject['icon:text'] + '
    ') + + ' ' + userObject.username; + } - function displayUser(chatsListEl, userObj) { - function createUserImage() { - return (userObj.picture ? - '' : - '
    ' + userObj['icon:text'] + '
    ') + - ' ' + userObj.username; - } + const chatElement = $('
  • ') + .attr('data-uid', userObject.uid) + .appendTo(chatsListElement); - const chatEl = $('
  • ') - .attr('data-uid', userObj.uid) - .appendTo(chatsListEl); + chatElement.append(createUserImage()); + return chatElement; + } - chatEl.append(createUserImage()); - return chatEl; - } + function onUserClick(chatElement, userObject) { + chatElement.on('click', () => { + socket.emit('modules.chats.hasPrivateChat', userObject.uid, (error, roomId) => { + if (error) { + return alerts.error(error); + } - function onUserClick(chatEl, userObj) { - chatEl.on('click', function () { - socket.emit('modules.chats.hasPrivateChat', userObj.uid, function (err, roomId) { - if (err) { - return alerts.error(err); - } - if (roomId) { - require(['forum/chats'], function (chats) { - chats.switchChat(roomId); - }); - } else { - require(['chat'], function (chat) { - chat.newChat(userObj.uid); - }); - } - }); - }); - } + if (roomId) { + require(['forum/chats'], chats => { + chats.switchChat(roomId); + }); + } else { + require(['chat'], chat => { + chat.newChat(userObject.uid); + }); + } + }); + }); + } - return search; + return search; }); diff --git a/public/src/client/compose.js b/public/src/client/compose.js index 5ac2dd0..2e69894 100644 --- a/public/src/client/compose.js +++ b/public/src/client/compose.js @@ -1,18 +1,17 @@ 'use strict'; +define('forum/compose', ['hooks'], hooks => { + const Compose = {}; -define('forum/compose', ['hooks'], function (hooks) { - const Compose = {}; + Compose.init = function () { + const container = $('.composer'); - Compose.init = function () { - const container = $('.composer'); + if (container.length > 0) { + hooks.fire('action:composer.enhance', { + container, + }); + } + }; - if (container.length) { - hooks.fire('action:composer.enhance', { - container: container, - }); - } - }; - - return Compose; + return Compose; }); diff --git a/public/src/client/flags/detail.js b/public/src/client/flags/detail.js index a412514..1fc0ec4 100644 --- a/public/src/client/flags/detail.js +++ b/public/src/client/flags/detail.js @@ -1,178 +1,193 @@ 'use strict'; define('forum/flags/detail', [ - 'components', 'translator', 'benchpress', 'forum/account/header', 'accounts/delete', 'api', 'bootbox', 'alerts', -], function (components, translator, Benchpress, AccountHeader, AccountsDelete, api, bootbox, alerts) { - const Detail = {}; - - Detail.init = function () { - // Update attributes - $('#state').val(ajaxify.data.state).removeAttr('disabled'); - $('#assignee').val(ajaxify.data.assignee).removeAttr('disabled'); - - $('#content > div').on('click', '[data-action]', function () { - const action = this.getAttribute('data-action'); - const uid = $(this).parents('[data-uid]').attr('data-uid'); - const noteEl = document.getElementById('note'); - - switch (action) { - case 'assign': - $('#assignee').val(app.user.uid); - // falls through - - case 'update': { - const data = $('#attributes').serializeArray().reduce((memo, cur) => { - memo[cur.name] = cur.value; - return memo; - }, {}); - - api.put(`/flags/${ajaxify.data.flagId}`, data).then(({ history }) => { - alerts.success('[[flags:updated]]'); - Detail.reloadHistory(history); - }).catch(alerts.error); - break; - } - - case 'appendNote': - api.post(`/flags/${ajaxify.data.flagId}/notes`, { - note: noteEl.value, - datetime: parseInt(noteEl.getAttribute('data-datetime'), 10), - }).then((payload) => { - alerts.success('[[flags:note-added]]'); - Detail.reloadNotes(payload.notes); - Detail.reloadHistory(payload.history); - - noteEl.removeAttribute('data-datetime'); - }).catch(alerts.error); - break; - - case 'delete-note': { - const datetime = parseInt(this.closest('[data-datetime]').getAttribute('data-datetime'), 10); - bootbox.confirm('[[flags:delete-note-confirm]]', function (ok) { - if (ok) { - api.delete(`/flags/${ajaxify.data.flagId}/notes/${datetime}`, {}).then((payload) => { - alerts.success('[[flags:note-deleted]]'); - Detail.reloadNotes(payload.notes); - Detail.reloadHistory(payload.history); - }).catch(alerts.error); - } - }); - break; - } - case 'chat': - require(['chat'], function (chat) { - chat.newChat(uid); - }); - break; - - case 'ban': - AccountHeader.banAccount(uid, ajaxify.refresh); - break; - - case 'unban': - AccountHeader.unbanAccount(uid); - break; - - case 'mute': - AccountHeader.muteAccount(uid, ajaxify.refresh); - break; - - case 'unmute': - AccountHeader.unmuteAccount(uid); - break; - - case 'delete-account': - AccountsDelete.account(uid, ajaxify.refresh); - break; - - case 'delete-content': - AccountsDelete.content(uid, ajaxify.refresh); - break; - - case 'delete-all': - AccountsDelete.purge(uid, ajaxify.refresh); - break; - - case 'delete-post': - postAction('delete', api.del, `/posts/${ajaxify.data.target.pid}/state`); - break; - - case 'purge-post': - postAction('purge', api.del, `/posts/${ajaxify.data.target.pid}`); - break; - - case 'restore-post': - postAction('restore', api.put, `/posts/${ajaxify.data.target.pid}/state`); - break; - - case 'prepare-edit': { - const selectedNoteEl = this.closest('[data-index]'); - const index = selectedNoteEl.getAttribute('data-index'); - const textareaEl = document.getElementById('note'); - textareaEl.value = ajaxify.data.notes[index].content; - textareaEl.setAttribute('data-datetime', ajaxify.data.notes[index].datetime); - - const siblings = selectedNoteEl.parentElement.children; - for (const el in siblings) { - if (siblings.hasOwnProperty(el)) { - siblings[el].classList.remove('editing'); - } - } - selectedNoteEl.classList.add('editing'); - textareaEl.focus(); - break; - } - - case 'delete-flag': { - bootbox.confirm('[[flags:delete-flag-confirm]]', function (ok) { - if (ok) { - api.delete(`/flags/${ajaxify.data.flagId}`, {}).then(() => { - alerts.success('[[flags:flag-deleted]]'); - ajaxify.go('flags'); - }).catch(alerts.error); - } - }); - break; - } - } - }); - }; - - function postAction(action, method, path) { - translator.translate('[[topic:post_' + action + '_confirm]]', function (msg) { - bootbox.confirm(msg, function (confirm) { - if (!confirm) { - return; - } - - method(path).then(ajaxify.refresh).catch(alerts.error); - }); - }); - } - - Detail.reloadNotes = function (notes) { - ajaxify.data.notes = notes; - Benchpress.render('flags/detail', { - notes: notes, - }, 'notes').then(function (html) { - const wrapperEl = components.get('flag/notes'); - wrapperEl.empty(); - wrapperEl.html(html); - wrapperEl.find('span.timeago').timeago(); - document.getElementById('note').value = ''; - }); - }; - - Detail.reloadHistory = function (history) { - app.parseAndTranslate('flags/detail', 'history', { - history: history, - }, function (html) { - const wrapperEl = components.get('flag/history'); - wrapperEl.empty(); - wrapperEl.html(html); - wrapperEl.find('span.timeago').timeago(); - }); - }; - - return Detail; + 'components', 'translator', 'benchpress', 'forum/account/header', 'accounts/delete', 'api', 'bootbox', 'alerts', +], (components, translator, Benchpress, AccountHeader, AccountsDelete, api, bootbox, alerts) => { + const Detail = {}; + + Detail.init = function () { + // Update attributes + $('#state').val(ajaxify.data.state).removeAttr('disabled'); + $('#assignee').val(ajaxify.data.assignee).removeAttr('disabled'); + + $('#content > div').on('click', '[data-action]', function () { + const action = this.dataset.action; + const uid = $(this).parents('[data-uid]').attr('data-uid'); + const noteElement = document.querySelector('#note'); + + switch (action) { + case 'assign': { + $('#assignee').val(app.user.uid); + } + // Falls through + + case 'update': { + const data = $('#attributes').serializeArray().reduce((memo, current) => { + memo[current.name] = current.value; + return memo; + }, {}); + + api.put(`/flags/${ajaxify.data.flagId}`, data).then(({history}) => { + alerts.success('[[flags:updated]]'); + Detail.reloadHistory(history); + }).catch(alerts.error); + break; + } + + case 'appendNote': { + api.post(`/flags/${ajaxify.data.flagId}/notes`, { + note: noteElement.value, + datetime: Number.parseInt(noteElement.dataset.datetime, 10), + }).then(payload => { + alerts.success('[[flags:note-added]]'); + Detail.reloadNotes(payload.notes); + Detail.reloadHistory(payload.history); + + delete noteElement.dataset.datetime; + }).catch(alerts.error); + break; + } + + case 'delete-note': { + const datetime = Number.parseInt(this.closest('[data-datetime]').dataset.datetime, 10); + bootbox.confirm('[[flags:delete-note-confirm]]', ok => { + if (ok) { + api.delete(`/flags/${ajaxify.data.flagId}/notes/${datetime}`, {}).then(payload => { + alerts.success('[[flags:note-deleted]]'); + Detail.reloadNotes(payload.notes); + Detail.reloadHistory(payload.history); + }).catch(alerts.error); + } + }); + break; + } + + case 'chat': { + require(['chat'], chat => { + chat.newChat(uid); + }); + break; + } + + case 'ban': { + AccountHeader.banAccount(uid, ajaxify.refresh); + break; + } + + case 'unban': { + AccountHeader.unbanAccount(uid); + break; + } + + case 'mute': { + AccountHeader.muteAccount(uid, ajaxify.refresh); + break; + } + + case 'unmute': { + AccountHeader.unmuteAccount(uid); + break; + } + + case 'delete-account': { + AccountsDelete.account(uid, ajaxify.refresh); + break; + } + + case 'delete-content': { + AccountsDelete.content(uid, ajaxify.refresh); + break; + } + + case 'delete-all': { + AccountsDelete.purge(uid, ajaxify.refresh); + break; + } + + case 'delete-post': { + postAction('delete', api.del, `/posts/${ajaxify.data.target.pid}/state`); + break; + } + + case 'purge-post': { + postAction('purge', api.del, `/posts/${ajaxify.data.target.pid}`); + break; + } + + case 'restore-post': { + postAction('restore', api.put, `/posts/${ajaxify.data.target.pid}/state`); + break; + } + + case 'prepare-edit': { + const selectedNoteElement = this.closest('[data-index]'); + const index = selectedNoteElement.dataset.index; + const textareaElement = document.querySelector('#note'); + textareaElement.value = ajaxify.data.notes[index].content; + textareaElement.dataset.datetime = ajaxify.data.notes[index].datetime; + + const siblings = selectedNoteElement.parentElement.children; + for (const element in siblings) { + if (siblings.hasOwnProperty(element)) { + siblings[element].classList.remove('editing'); + } + } + + selectedNoteElement.classList.add('editing'); + textareaElement.focus(); + break; + } + + case 'delete-flag': { + bootbox.confirm('[[flags:delete-flag-confirm]]', ok => { + if (ok) { + api.delete(`/flags/${ajaxify.data.flagId}`, {}).then(() => { + alerts.success('[[flags:flag-deleted]]'); + ajaxify.go('flags'); + }).catch(alerts.error); + } + }); + break; + } + } + }); + }; + + function postAction(action, method, path) { + translator.translate('[[topic:post_' + action + '_confirm]]', message => { + bootbox.confirm(message, confirm => { + if (!confirm) { + return; + } + + method(path).then(ajaxify.refresh).catch(alerts.error); + }); + }); + } + + Detail.reloadNotes = function (notes) { + ajaxify.data.notes = notes; + Benchpress.render('flags/detail', { + notes, + }, 'notes').then(html => { + const wrapperElement = components.get('flag/notes'); + wrapperElement.empty(); + wrapperElement.html(html); + wrapperElement.find('span.timeago').timeago(); + document.querySelector('#note').value = ''; + }); + }; + + Detail.reloadHistory = function (history) { + app.parseAndTranslate('flags/detail', 'history', { + history, + }, html => { + const wrapperElement = components.get('flag/history'); + wrapperElement.empty(); + wrapperElement.html(html); + wrapperElement.find('span.timeago').timeago(); + }); + }; + + return Detail; }); diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js index 33ef934..e101fa2 100644 --- a/public/src/client/flags/list.js +++ b/public/src/client/flags/list.js @@ -1,231 +1,228 @@ 'use strict'; define('forum/flags/list', [ - 'components', 'Chart', 'categoryFilter', 'autocomplete', 'api', 'alerts', -], function (components, Chart, categoryFilter, autocomplete, api, alerts) { - const Flags = {}; - - let selectedCids; - - Flags.init = function () { - Flags.enableFilterForm(); - Flags.enableCheckboxes(); - Flags.handleBulkActions(); - - selectedCids = []; - if (ajaxify.data.filters.hasOwnProperty('cid')) { - selectedCids = Array.isArray(ajaxify.data.filters.cid) ? - ajaxify.data.filters.cid : [ajaxify.data.filters.cid]; - } - - categoryFilter.init($('[component="category/dropdown"]'), { - privilege: 'moderate', - selectedCids: selectedCids, - onHidden: function (data) { - selectedCids = data.selectedCids; - }, - }); - - components.get('flags/list') - .on('click', '[data-flag-id]', function (e) { - if (['BUTTON', 'A'].includes(e.target.nodeName)) { - return; - } - - const flagId = this.getAttribute('data-flag-id'); - ajaxify.go('flags/' + flagId); - }); - - $('#flags-daily-wrapper').one('shown.bs.collapse', function () { - Flags.handleGraphs(); - }); - - autocomplete.user($('#filter-assignee, #filter-targetUid, #filter-reporterId'), (ev, ui) => { - setTimeout(() => { ev.target.value = ui.item.user.uid; }); - }); - }; - - Flags.enableFilterForm = function () { - const $filtersEl = components.get('flags/filters'); - - // Parse ajaxify data to set form values to reflect current filters - for (const filter in ajaxify.data.filters) { - if (ajaxify.data.filters.hasOwnProperty(filter)) { - $filtersEl.find('[name="' + filter + '"]').val(ajaxify.data.filters[filter]); - } - } - $filtersEl.find('[name="sort"]').val(ajaxify.data.sort); - - document.getElementById('apply-filters').addEventListener('click', function () { - const payload = $filtersEl.serializeArray(); - // cid is special comes from categoryFilter module - selectedCids.forEach(function (cid) { - payload.push({ name: 'cid', value: cid }); - }); - - ajaxify.go('flags?' + (payload.length ? $.param(payload) : 'reset=1')); - }); - - $filtersEl.find('button[data-target="#more-filters"]').click((ev) => { - const textVariant = ev.target.getAttribute('data-text-variant'); - if (!textVariant) { - return; - } - ev.target.setAttribute('data-text-variant', ev.target.textContent); - ev.target.firstChild.textContent = textVariant; - }); - }; - - Flags.enableCheckboxes = function () { - const flagsList = document.querySelector('[component="flags/list"]'); - const checkboxes = flagsList.querySelectorAll('[data-flag-id] input[type="checkbox"]'); - const bulkEl = document.querySelector('[component="flags/bulk-actions"] button'); - let lastClicked; - - document.querySelector('[data-action="toggle-all"]').addEventListener('click', function () { - const state = this.checked; - - checkboxes.forEach(function (el) { - el.checked = state; - }); - bulkEl.disabled = !state; - }); - - flagsList.addEventListener('click', function (e) { - const subselector = e.target.closest('input[type="checkbox"]'); - if (subselector) { - // Stop checkbox clicks from going into the flag details - e.stopImmediatePropagation(); - - if (lastClicked && e.shiftKey && lastClicked !== subselector) { - // Select all the checkboxes in between - const state = subselector.checked; - let started = false; - - checkboxes.forEach(function (el) { - if ([subselector, lastClicked].some(function (ref) { - return ref === el; - })) { - started = !started; - } - - if (started) { - el.checked = state; - } - }); - } - - // (De)activate bulk actions button based on checkboxes' state - bulkEl.disabled = !Array.prototype.some.call(checkboxes, function (el) { - return el.checked; - }); - - lastClicked = subselector; - } - - // If you miss the checkbox, don't descend into the flag details, either - if (e.target.querySelector('input[type="checkbox"]')) { - e.stopImmediatePropagation(); - } - }); - }; - - Flags.handleBulkActions = function () { - document.querySelector('[component="flags/bulk-actions"]').addEventListener('click', function (e) { - const subselector = e.target.closest('[data-action]'); - if (subselector) { - const action = subselector.getAttribute('data-action'); - const flagIds = Flags.getSelected(); - const promises = flagIds.map((flagId) => { - const data = {}; - if (action === 'bulk-assign') { - data.assignee = app.user.uid; - } else if (action === 'bulk-mark-resolved') { - data.state = 'resolved'; - } - return api.put(`/flags/${flagId}`, data); - }); - - Promise.allSettled(promises).then(function (results) { - const fulfilled = results.filter(function (res) { - return res.status === 'fulfilled'; - }).length; - const errors = results.filter(function (res) { - return res.status === 'rejected'; - }); - if (fulfilled) { - alerts.success('[[flags:bulk-success, ' + fulfilled + ']]'); - ajaxify.refresh(); - } - - errors.forEach(function (res) { - alerts.error(res.reason); - }); - }); - } - }); - }; - - Flags.getSelected = function () { - const checkboxes = document.querySelectorAll('[component="flags/list"] [data-flag-id] input[type="checkbox"]'); - const payload = []; - checkboxes.forEach(function (el) { - if (el.checked) { - payload.push(el.closest('[data-flag-id]').getAttribute('data-flag-id')); - } - }); - - return payload; - }; - - Flags.handleGraphs = function () { - const dailyCanvas = document.getElementById('flags:daily'); - const dailyLabels = utils.getDaysArray().map(function (text, idx) { - return idx % 3 ? '' : text; - }); - - if (utils.isMobile()) { - Chart.defaults.global.tooltips.enabled = false; - } - const data = { - 'flags:daily': { - labels: dailyLabels, - datasets: [ - { - label: '', - backgroundColor: 'rgba(151,187,205,0.2)', - borderColor: 'rgba(151,187,205,1)', - pointBackgroundColor: 'rgba(151,187,205,1)', - pointHoverBackgroundColor: '#fff', - pointBorderColor: '#fff', - pointHoverBorderColor: 'rgba(151,187,205,1)', - data: ajaxify.data.analytics, - }, - ], - }, - }; - - dailyCanvas.width = $(dailyCanvas).parent().width(); - new Chart(dailyCanvas.getContext('2d'), { - type: 'line', - data: data['flags:daily'], - options: { - responsive: true, - animation: false, - legend: { - display: false, - }, - scales: { - yAxes: [{ - ticks: { - beginAtZero: true, - precision: 0, - }, - }], - }, - }, - }); - }; - - return Flags; + 'components', 'Chart', 'categoryFilter', 'autocomplete', 'api', 'alerts', +], (components, Chart, categoryFilter, autocomplete, api, alerts) => { + const Flags = {}; + + let selectedCids; + + Flags.init = function () { + Flags.enableFilterForm(); + Flags.enableCheckboxes(); + Flags.handleBulkActions(); + + selectedCids = []; + if (ajaxify.data.filters.hasOwnProperty('cid')) { + selectedCids = Array.isArray(ajaxify.data.filters.cid) + ? ajaxify.data.filters.cid : [ajaxify.data.filters.cid]; + } + + categoryFilter.init($('[component="category/dropdown"]'), { + privilege: 'moderate', + selectedCids, + onHidden(data) { + selectedCids = data.selectedCids; + }, + }); + + components.get('flags/list') + .on('click', '[data-flag-id]', function (e) { + if (['BUTTON', 'A'].includes(e.target.nodeName)) { + return; + } + + const flagId = this.dataset.flagId; + ajaxify.go('flags/' + flagId); + }); + + $('#flags-daily-wrapper').one('shown.bs.collapse', () => { + Flags.handleGraphs(); + }); + + autocomplete.user($('#filter-assignee, #filter-targetUid, #filter-reporterId'), (event, ui) => { + setTimeout(() => { + event.target.value = ui.item.user.uid; + }); + }); + }; + + Flags.enableFilterForm = function () { + const $filtersElement = components.get('flags/filters'); + + // Parse ajaxify data to set form values to reflect current filters + for (const filter in ajaxify.data.filters) { + if (ajaxify.data.filters.hasOwnProperty(filter)) { + $filtersElement.find('[name="' + filter + '"]').val(ajaxify.data.filters[filter]); + } + } + + $filtersElement.find('[name="sort"]').val(ajaxify.data.sort); + + document.querySelector('#apply-filters').addEventListener('click', () => { + const payload = $filtersElement.serializeArray(); + // Cid is special comes from categoryFilter module + for (const cid of selectedCids) { + payload.push({name: 'cid', value: cid}); + } + + ajaxify.go('flags?' + (payload.length > 0 ? $.param(payload) : 'reset=1')); + }); + + $filtersElement.find('button[data-target="#more-filters"]').click(event => { + const textVariant = event.target.dataset.textVariant; + if (!textVariant) { + return; + } + + event.target.dataset.textVariant = event.target.textContent; + event.target.firstChild.textContent = textVariant; + }); + }; + + Flags.enableCheckboxes = function () { + const flagsList = document.querySelector('[component="flags/list"]'); + const checkboxes = flagsList.querySelectorAll('[data-flag-id] input[type="checkbox"]'); + const bulkElement = document.querySelector('[component="flags/bulk-actions"] button'); + let lastClicked; + + document.querySelector('[data-action="toggle-all"]').addEventListener('click', function () { + const state = this.checked; + + for (const element of checkboxes) { + element.checked = state; + } + + bulkElement.disabled = !state; + }); + + flagsList.addEventListener('click', e => { + const subselector = e.target.closest('input[type="checkbox"]'); + if (subselector) { + // Stop checkbox clicks from going into the flag details + e.stopImmediatePropagation(); + + if (lastClicked && e.shiftKey && lastClicked !== subselector) { + // Select all the checkboxes in between + const state = subselector.checked; + let started = false; + + for (const element of checkboxes) { + if ([subselector, lastClicked].includes(element)) { + started = !started; + } + + if (started) { + element.checked = state; + } + } + } + + // (De)activate bulk actions button based on checkboxes' state + bulkElement.disabled = !Array.prototype.some.call(checkboxes, element => element.checked); + + lastClicked = subselector; + } + + // If you miss the checkbox, don't descend into the flag details, either + if (e.target.querySelector('input[type="checkbox"]')) { + e.stopImmediatePropagation(); + } + }); + }; + + Flags.handleBulkActions = function () { + document.querySelector('[component="flags/bulk-actions"]').addEventListener('click', e => { + const subselector = e.target.closest('[data-action]'); + if (subselector) { + const action = subselector.dataset.action; + const flagIds = Flags.getSelected(); + const promises = flagIds.map(flagId => { + const data = {}; + if (action === 'bulk-assign') { + data.assignee = app.user.uid; + } else if (action === 'bulk-mark-resolved') { + data.state = 'resolved'; + } + + return api.put(`/flags/${flagId}`, data); + }); + + Promise.allSettled(promises).then(results => { + const fulfilled = results.filter(res => res.status === 'fulfilled').length; + const errors = results.filter(res => res.status === 'rejected'); + if (fulfilled) { + alerts.success('[[flags:bulk-success, ' + fulfilled + ']]'); + ajaxify.refresh(); + } + + for (const res of errors) { + alerts.error(res.reason); + } + }); + } + }); + }; + + Flags.getSelected = function () { + const checkboxes = document.querySelectorAll('[component="flags/list"] [data-flag-id] input[type="checkbox"]'); + const payload = []; + for (const element of checkboxes) { + if (element.checked) { + payload.push(element.closest('[data-flag-id]').dataset.flagId); + } + } + + return payload; + }; + + Flags.handleGraphs = function () { + const dailyCanvas = document.querySelector('#flags:daily'); + const dailyLabels = utils.getDaysArray().map((text, index) => index % 3 ? '' : text); + + if (utils.isMobile()) { + Chart.defaults.global.tooltips.enabled = false; + } + + const data = { + 'flags:daily': { + labels: dailyLabels, + datasets: [ + { + label: '', + backgroundColor: 'rgba(151,187,205,0.2)', + borderColor: 'rgba(151,187,205,1)', + pointBackgroundColor: 'rgba(151,187,205,1)', + pointHoverBackgroundColor: '#fff', + pointBorderColor: '#fff', + pointHoverBorderColor: 'rgba(151,187,205,1)', + data: ajaxify.data.analytics, + }, + ], + }, + }; + + dailyCanvas.width = $(dailyCanvas).parent().width(); + new Chart(dailyCanvas.getContext('2d'), { + type: 'line', + data: data['flags:daily'], + options: { + responsive: true, + animation: false, + legend: { + display: false, + }, + scales: { + yAxes: [{ + ticks: { + beginAtZero: true, + precision: 0, + }, + }], + }, + }, + }); + }; + + return Flags; }); diff --git a/public/src/client/groups/details.js b/public/src/client/groups/details.js index 8053644..960566a 100644 --- a/public/src/client/groups/details.js +++ b/public/src/client/groups/details.js @@ -1,303 +1,313 @@ 'use strict'; define('forum/groups/details', [ - 'forum/groups/memberlist', - 'iconSelect', - 'components', - 'coverPhoto', - 'pictureCropper', - 'translator', - 'api', - 'slugify', - 'categorySelector', - 'bootbox', - 'alerts', -], function ( - memberList, - iconSelect, - components, - coverPhoto, - pictureCropper, - translator, - api, - slugify, - categorySelector, - bootbox, - alerts -) { - const Details = {}; - let groupName; - - Details.init = function () { - const detailsPage = components.get('groups/container'); - - groupName = ajaxify.data.group.name; - - if (ajaxify.data.group.isOwner) { - Details.prepareSettings(); - - coverPhoto.init( - components.get('groups/cover'), - function (imageData, position, callback) { - socket.emit('groups.cover.update', { - groupName: groupName, - imageData: imageData, - position: position, - }, callback); - }, - function () { - pictureCropper.show({ - title: '[[groups:upload-group-cover]]', - socketMethod: 'groups.cover.update', - aspectRatio: NaN, - allowSkippingCrop: true, - restrictImageDimension: false, - paramName: 'groupName', - paramValue: groupName, - }, function (imageUrlOnServer) { - imageUrlOnServer = (!imageUrlOnServer.startsWith('http') ? config.relative_path : '') + imageUrlOnServer + '?' + Date.now(); - components.get('groups/cover').css('background-image', 'url(' + imageUrlOnServer + ')'); - }); - }, - removeCover - ); - } - - memberList.init(); - - handleMemberInvitations(); - - components.get('groups/activity').find('.content img:not(.not-responsive)').addClass('img-responsive'); - - detailsPage.on('click', '[data-action]', function () { - const btnEl = $(this); - const userRow = btnEl.parents('[data-uid]'); - const ownerFlagEl = userRow.find('.member-name > i'); - const isOwner = !ownerFlagEl.hasClass('invisible'); - const uid = userRow.attr('data-uid'); - const action = btnEl.attr('data-action'); - - switch (action) { - case 'toggleOwnership': - api[isOwner ? 'del' : 'put'](`/groups/${ajaxify.data.group.slug}/ownership/${uid}`, {}).then(() => { - ownerFlagEl.toggleClass('invisible'); - }).catch(alerts.error); - break; - - case 'kick': - translator.translate('[[groups:details.kick_confirm]]', function (translated) { - bootbox.confirm(translated, function (confirm) { - if (!confirm) { - return; - } - - api.del(`/groups/${ajaxify.data.group.slug}/membership/${uid}`, undefined).then(() => userRow.slideUp().remove()).catch(alerts.error); - }); - }); - break; - - case 'update': - Details.update(); - break; - - case 'delete': - Details.deleteGroup(); - break; - - case 'join': - api.put('/groups/' + ajaxify.data.group.slug + '/membership/' + (uid || app.user.uid), undefined).then(() => ajaxify.refresh()).catch(alerts.error); - break; - - case 'leave': - api.del('/groups/' + ajaxify.data.group.slug + '/membership/' + (uid || app.user.uid), undefined).then(() => ajaxify.refresh()).catch(alerts.error); - break; - - // TODO (14/10/2020): rewrite these to use api module and merge with above 2 case blocks - case 'accept': // intentional fall-throughs! - case 'reject': - case 'issueInvite': - case 'rescindInvite': - case 'acceptInvite': - case 'rejectInvite': - case 'acceptAll': - case 'rejectAll': - socket.emit('groups.' + action, { - toUid: uid, - groupName: groupName, - }, function (err) { - if (!err) { - ajaxify.refresh(); - } else { - alerts.error(err); - } - }); - break; - } - }); - }; - - Details.prepareSettings = function () { - const settingsFormEl = components.get('groups/settings'); - const labelColorValueEl = settingsFormEl.find('[name="labelColor"]'); - const textColorValueEl = settingsFormEl.find('[name="textColor"]'); - const iconBtn = settingsFormEl.find('[data-action="icon-select"]'); - const previewEl = settingsFormEl.find('.label'); - const previewElText = settingsFormEl.find('.label-text'); - const previewIcon = previewEl.find('i'); - const userTitleEl = settingsFormEl.find('[name="userTitle"]'); - const userTitleEnabledEl = settingsFormEl.find('[name="userTitleEnabled"]'); - const iconValueEl = settingsFormEl.find('[name="icon"]'); - - labelColorValueEl.on('input', function () { - previewEl.css('background-color', labelColorValueEl.val()); - }); - - textColorValueEl.on('input', function () { - previewEl.css('color', textColorValueEl.val()); - }); - - // Add icon selection interface - iconBtn.on('click', function () { - iconSelect.init(previewIcon, function () { - iconValueEl.val(previewIcon.val()); - }); - }); - - // If the user title changes, update that too - userTitleEl.on('keyup', function () { - previewElText.translateText((this.value || settingsFormEl.find('#name').val())); - }); - - // Disable user title customisation options if the the user title itself is disabled - userTitleEnabledEl.on('change', function () { - const customOpts = components.get('groups/userTitleOption'); - - if (this.checked) { - customOpts.removeAttr('disabled'); - previewEl.removeClass('hide'); - } else { - customOpts.attr('disabled', 'disabled'); - previewEl.addClass('hide'); - } - }); - - const cidSelector = categorySelector.init($('.member-post-cids-selector [component="category-selector"]'), { - onSelect: function (selectedCategory) { - let cids = ($('#memberPostCids').val() || '').split(',').map(cid => parseInt(cid, 10)); - cids.push(selectedCategory.cid); - cids = cids.filter((cid, index, array) => array.indexOf(cid) === index); - $('#memberPostCids').val(cids.join(',')); - cidSelector.selectCategory(0); - }, - }); - }; - - Details.update = function () { - const settingsFormEl = components.get('groups/settings'); - const checkboxes = settingsFormEl.find('input[type="checkbox"][name]'); - - if (settingsFormEl.length) { - const settings = settingsFormEl.serializeObject(); - - // serializeObject doesnt return array for multi selects if only one item is selected - if (!Array.isArray(settings.memberPostCids)) { - settings.memberPostCids = $('#memberPostCids').val(); - } - - // Fix checkbox values - checkboxes.each(function (idx, inputEl) { - inputEl = $(inputEl); - if (inputEl.length) { - settings[inputEl.attr('name')] = inputEl.prop('checked'); - } - }); - - api.put(`/groups/${ajaxify.data.group.slug}`, settings).then(() => { - if (settings.name) { - let pathname = window.location.pathname; - pathname = pathname.slice(1, pathname.lastIndexOf('/') + 1); - ajaxify.go(pathname + slugify(settings.name)); - } else { - ajaxify.refresh(); - } - - alerts.success('[[groups:event.updated]]'); - }).catch(alerts.error); - } - }; - - Details.deleteGroup = function () { - bootbox.confirm('Are you sure you want to delete the group: ' + utils.escapeHTML(groupName), function (confirm) { - if (confirm) { - bootbox.prompt('Please enter the name of this group in order to delete it:', function (response) { - if (response === groupName) { - api.del(`/groups/${ajaxify.data.group.slug}`, {}).then(() => { - alerts.success('[[groups:event.deleted, ' + utils.escapeHTML(groupName) + ']]'); - ajaxify.go('groups'); - }).catch(alerts.error); - } - }); - } - }); - }; - - function handleMemberInvitations() { - if (!ajaxify.data.group.isOwner) { - return; - } - - const searchInput = $('[component="groups/members/invite"]'); - require(['autocomplete'], function (autocomplete) { - autocomplete.user(searchInput, function (event, selected) { - socket.emit('groups.issueInvite', { - toUid: selected.item.user.uid, - groupName: ajaxify.data.group.name, - }, function (err) { - if (err) { - return alerts.error(err); - } - ajaxify.refresh(); - }); - }); - }); - - $('[component="groups/members/bulk-invite-button"]').on('click', function () { - const usernames = $('[component="groups/members/bulk-invite"]').val(); - if (!usernames) { - return false; - } - socket.emit('groups.issueMassInvite', { - usernames: usernames, - groupName: ajaxify.data.group.name, - }, function (err) { - if (err) { - return alerts.error(err); - } - ajaxify.refresh(); - }); - return false; - }); - } - - function removeCover() { - translator.translate('[[groups:remove_group_cover_confirm]]', function (translated) { - bootbox.confirm(translated, function (confirm) { - if (!confirm) { - return; - } - - socket.emit('groups.cover.remove', { - groupName: ajaxify.data.group.name, - }, function (err) { - if (!err) { - ajaxify.refresh(); - } else { - alerts.error(err); - } - }); - }); - }); - } - - return Details; + 'forum/groups/memberlist', + 'iconSelect', + 'components', + 'coverPhoto', + 'pictureCropper', + 'translator', + 'api', + 'slugify', + 'categorySelector', + 'bootbox', + 'alerts', +], ( + memberList, + iconSelect, + components, + coverPhoto, + pictureCropper, + translator, + api, + slugify, + categorySelector, + bootbox, + alerts, +) => { + const Details = {}; + let groupName; + + Details.init = function () { + const detailsPage = components.get('groups/container'); + + groupName = ajaxify.data.group.name; + + if (ajaxify.data.group.isOwner) { + Details.prepareSettings(); + + coverPhoto.init( + components.get('groups/cover'), + (imageData, position, callback) => { + socket.emit('groups.cover.update', { + groupName, + imageData, + position, + }, callback); + }, + () => { + pictureCropper.show({ + title: '[[groups:upload-group-cover]]', + socketMethod: 'groups.cover.update', + aspectRatio: Number.NaN, + allowSkippingCrop: true, + restrictImageDimension: false, + paramName: 'groupName', + paramValue: groupName, + }, imageUrlOnServer => { + imageUrlOnServer = (imageUrlOnServer.startsWith('http') ? '' : config.relative_path) + imageUrlOnServer + '?' + Date.now(); + components.get('groups/cover').css('background-image', 'url(' + imageUrlOnServer + ')'); + }); + }, + removeCover, + ); + } + + memberList.init(); + + handleMemberInvitations(); + + components.get('groups/activity').find('.content img:not(.not-responsive)').addClass('img-responsive'); + + detailsPage.on('click', '[data-action]', function () { + const buttonElement = $(this); + const userRow = buttonElement.parents('[data-uid]'); + const ownerFlagElement = userRow.find('.member-name > i'); + const isOwner = !ownerFlagElement.hasClass('invisible'); + const uid = userRow.attr('data-uid'); + const action = buttonElement.attr('data-action'); + + switch (action) { + case 'toggleOwnership': { + api[isOwner ? 'del' : 'put'](`/groups/${ajaxify.data.group.slug}/ownership/${uid}`, {}).then(() => { + ownerFlagElement.toggleClass('invisible'); + }).catch(alerts.error); + break; + } + + case 'kick': { + translator.translate('[[groups:details.kick_confirm]]', translated => { + bootbox.confirm(translated, confirm => { + if (!confirm) { + return; + } + + api.del(`/groups/${ajaxify.data.group.slug}/membership/${uid}`, undefined).then(() => userRow.slideUp().remove()).catch(alerts.error); + }); + }); + break; + } + + case 'update': { + Details.update(); + break; + } + + case 'delete': { + Details.deleteGroup(); + break; + } + + case 'join': { + api.put('/groups/' + ajaxify.data.group.slug + '/membership/' + (uid || app.user.uid), undefined).then(() => ajaxify.refresh()).catch(alerts.error); + break; + } + + case 'leave': { + api.del('/groups/' + ajaxify.data.group.slug + '/membership/' + (uid || app.user.uid), undefined).then(() => ajaxify.refresh()).catch(alerts.error); + break; + } + + // TODO (14/10/2020): rewrite these to use api module and merge with above 2 case blocks + case 'accept': // Intentional fall-throughs! + case 'reject': + case 'issueInvite': + case 'rescindInvite': + case 'acceptInvite': + case 'rejectInvite': + case 'acceptAll': + case 'rejectAll': { + socket.emit('groups.' + action, { + toUid: uid, + groupName, + }, error => { + if (error) { + alerts.error(error); + } else { + ajaxify.refresh(); + } + }); + break; + } + } + }); + }; + + Details.prepareSettings = function () { + const settingsFormElement = components.get('groups/settings'); + const labelColorValueElement = settingsFormElement.find('[name="labelColor"]'); + const textColorValueElement = settingsFormElement.find('[name="textColor"]'); + const iconButton = settingsFormElement.find('[data-action="icon-select"]'); + const previewElement = settingsFormElement.find('.label'); + const previewElementText = settingsFormElement.find('.label-text'); + const previewIcon = previewElement.find('i'); + const userTitleElement = settingsFormElement.find('[name="userTitle"]'); + const userTitleEnabledElement = settingsFormElement.find('[name="userTitleEnabled"]'); + const iconValueElement = settingsFormElement.find('[name="icon"]'); + + labelColorValueElement.on('input', () => { + previewElement.css('background-color', labelColorValueElement.val()); + }); + + textColorValueElement.on('input', () => { + previewElement.css('color', textColorValueElement.val()); + }); + + // Add icon selection interface + iconButton.on('click', () => { + iconSelect.init(previewIcon, () => { + iconValueElement.val(previewIcon.val()); + }); + }); + + // If the user title changes, update that too + userTitleElement.on('keyup', function () { + previewElementText.translateText((this.value || settingsFormElement.find('#name').val())); + }); + + // Disable user title customisation options if the the user title itself is disabled + userTitleEnabledElement.on('change', function () { + const customOptions = components.get('groups/userTitleOption'); + + if (this.checked) { + customOptions.removeAttr('disabled'); + previewElement.removeClass('hide'); + } else { + customOptions.attr('disabled', 'disabled'); + previewElement.addClass('hide'); + } + }); + + const cidSelector = categorySelector.init($('.member-post-cids-selector [component="category-selector"]'), { + onSelect(selectedCategory) { + let cids = ($('#memberPostCids').val() || '').split(',').map(cid => Number.parseInt(cid, 10)); + cids.push(selectedCategory.cid); + cids = cids.filter((cid, index, array) => array.indexOf(cid) === index); + $('#memberPostCids').val(cids.join(',')); + cidSelector.selectCategory(0); + }, + }); + }; + + Details.update = function () { + const settingsFormElement = components.get('groups/settings'); + const checkboxes = settingsFormElement.find('input[type="checkbox"][name]'); + + if (settingsFormElement.length > 0) { + const settings = settingsFormElement.serializeObject(); + + // SerializeObject doesnt return array for multi selects if only one item is selected + if (!Array.isArray(settings.memberPostCids)) { + settings.memberPostCids = $('#memberPostCids').val(); + } + + // Fix checkbox values + checkboxes.each((index, inputElement) => { + inputElement = $(inputElement); + if (inputElement.length > 0) { + settings[inputElement.attr('name')] = inputElement.prop('checked'); + } + }); + + api.put(`/groups/${ajaxify.data.group.slug}`, settings).then(() => { + if (settings.name) { + let pathname = window.location.pathname; + pathname = pathname.slice(1, pathname.lastIndexOf('/') + 1); + ajaxify.go(pathname + slugify(settings.name)); + } else { + ajaxify.refresh(); + } + + alerts.success('[[groups:event.updated]]'); + }).catch(alerts.error); + } + }; + + Details.deleteGroup = function () { + bootbox.confirm('Are you sure you want to delete the group: ' + utils.escapeHTML(groupName), confirm => { + if (confirm) { + bootbox.prompt('Please enter the name of this group in order to delete it:', response => { + if (response === groupName) { + api.del(`/groups/${ajaxify.data.group.slug}`, {}).then(() => { + alerts.success('[[groups:event.deleted, ' + utils.escapeHTML(groupName) + ']]'); + ajaxify.go('groups'); + }).catch(alerts.error); + } + }); + } + }); + }; + + function handleMemberInvitations() { + if (!ajaxify.data.group.isOwner) { + return; + } + + const searchInput = $('[component="groups/members/invite"]'); + require(['autocomplete'], autocomplete => { + autocomplete.user(searchInput, (event, selected) => { + socket.emit('groups.issueInvite', { + toUid: selected.item.user.uid, + groupName: ajaxify.data.group.name, + }, error => { + if (error) { + return alerts.error(error); + } + + ajaxify.refresh(); + }); + }); + }); + + $('[component="groups/members/bulk-invite-button"]').on('click', () => { + const usernames = $('[component="groups/members/bulk-invite"]').val(); + if (!usernames) { + return false; + } + + socket.emit('groups.issueMassInvite', { + usernames, + groupName: ajaxify.data.group.name, + }, error => { + if (error) { + return alerts.error(error); + } + + ajaxify.refresh(); + }); + return false; + }); + } + + function removeCover() { + translator.translate('[[groups:remove_group_cover_confirm]]', translated => { + bootbox.confirm(translated, confirm => { + if (!confirm) { + return; + } + + socket.emit('groups.cover.remove', { + groupName: ajaxify.data.group.name, + }, error => { + if (error) { + alerts.error(error); + } else { + ajaxify.refresh(); + } + }); + }); + }); + } + + return Details; }); diff --git a/public/src/client/groups/list.js b/public/src/client/groups/list.js index 871905e..19fdf48 100644 --- a/public/src/client/groups/list.js +++ b/public/src/client/groups/list.js @@ -1,90 +1,89 @@ 'use strict'; define('forum/groups/list', [ - 'forum/infinitescroll', 'benchpress', 'api', 'bootbox', 'alerts', -], function (infinitescroll, Benchpress, api, bootbox, alerts) { - const Groups = {}; + 'forum/infinitescroll', 'benchpress', 'api', 'bootbox', 'alerts', +], (infinitescroll, Benchpress, api, bootbox, alerts) => { + const Groups = {}; - Groups.init = function () { - infinitescroll.init(Groups.loadMoreGroups); + Groups.init = function () { + infinitescroll.init(Groups.loadMoreGroups); - // Group creation - $('button[data-action="new"]').on('click', function () { - bootbox.prompt('[[groups:new-group.group_name]]', function (name) { - if (name && name.length) { - api.post('/groups', { - name: name, - }).then((res) => { - ajaxify.go('groups/' + res.slug); - }).catch(alerts.error); - } - }); - }); - const params = utils.params(); - $('#search-sort').val(params.sort || 'alpha'); + // Group creation + $('button[data-action="new"]').on('click', () => { + bootbox.prompt('[[groups:new-group.group_name]]', name => { + if (name && name.length > 0) { + api.post('/groups', { + name, + }).then(res => { + ajaxify.go('groups/' + res.slug); + }).catch(alerts.error); + } + }); + }); + const parameters = utils.params(); + $('#search-sort').val(parameters.sort || 'alpha'); - // Group searching - $('#search-text').on('keyup', Groups.search); - $('#search-button').on('click', Groups.search); - $('#search-sort').on('change', function () { - ajaxify.go('groups?sort=' + $('#search-sort').val()); - }); - }; + // Group searching + $('#search-text').on('keyup', Groups.search); + $('#search-button').on('click', Groups.search); + $('#search-sort').on('change', () => { + ajaxify.go('groups?sort=' + $('#search-sort').val()); + }); + }; - Groups.loadMoreGroups = function (direction) { - if (direction < 0) { - return; - } + Groups.loadMoreGroups = function (direction) { + if (direction < 0) { + return; + } - infinitescroll.loadMore('groups.loadMore', { - sort: $('#search-sort').val(), - after: $('[component="groups/container"]').attr('data-nextstart'), - }, function (data, done) { - if (data && data.groups.length) { - Benchpress.render('partials/groups/list', { - groups: data.groups, - }).then(function (html) { - $('#groups-list').append(html); - done(); - }); - } else { - done(); - } + infinitescroll.loadMore('groups.loadMore', { + sort: $('#search-sort').val(), + after: $('[component="groups/container"]').attr('data-nextstart'), + }, (data, done) => { + if (data && data.groups.length > 0) { + Benchpress.render('partials/groups/list', { + groups: data.groups, + }).then(html => { + $('#groups-list').append(html); + done(); + }); + } else { + done(); + } - if (data && data.nextStart) { - $('[component="groups/container"]').attr('data-nextstart', data.nextStart); - } - }); - }; + if (data && data.nextStart) { + $('[component="groups/container"]').attr('data-nextstart', data.nextStart); + } + }); + }; - Groups.search = function () { - const groupsEl = $('#groups-list'); - const queryEl = $('#search-text'); - const sortEl = $('#search-sort'); + Groups.search = function () { + const groupsElement = $('#groups-list'); + const queryElement = $('#search-text'); + const sortElement = $('#search-sort'); - socket.emit('groups.search', { - query: queryEl.val(), - options: { - sort: sortEl.val(), - filterHidden: true, - showMembers: true, - hideEphemeralGroups: true, - }, - }, function (err, groups) { - if (err) { - return alerts.error(err); - } - groups = groups.filter(function (group) { - return group.name !== 'registered-users' && group.name !== 'guests'; - }); - Benchpress.render('partials/groups/list', { - groups: groups, - }).then(function (html) { - groupsEl.empty().append(html); - }); - }); - return false; - }; + socket.emit('groups.search', { + query: queryElement.val(), + options: { + sort: sortElement.val(), + filterHidden: true, + showMembers: true, + hideEphemeralGroups: true, + }, + }, (error, groups) => { + if (error) { + return alerts.error(error); + } - return Groups; + groups = groups.filter(group => group.name !== 'registered-users' && group.name !== 'guests'); + Benchpress.render('partials/groups/list', { + groups, + }).then(html => { + groupsElement.empty().append(html); + }); + }); + return false; + }; + + return Groups; }); diff --git a/public/src/client/groups/memberlist.js b/public/src/client/groups/memberlist.js index e716306..14ac215 100644 --- a/public/src/client/groups/memberlist.js +++ b/public/src/client/groups/memberlist.js @@ -1,167 +1,169 @@ 'use strict'; -define('forum/groups/memberlist', ['api', 'bootbox', 'alerts'], function (api, bootbox, alerts) { - const MemberList = {}; - let groupName; - let templateName; - - MemberList.init = function (_templateName) { - templateName = _templateName || 'groups/details'; - groupName = ajaxify.data.group.name; - - handleMemberAdd(); - handleMemberSearch(); - handleMemberInfiniteScroll(); - }; - - function handleMemberAdd() { - $('[component="groups/members/add"]').on('click', function () { - app.parseAndTranslate('admin/partials/groups/add-members', {}, function (html) { - const foundUsers = []; - const modal = bootbox.dialog({ - title: '[[groups:details.add-member]]', - message: html, - buttons: { - ok: { - callback: function () { - const users = []; - modal.find('[data-uid][data-selected]').each(function (index, el) { - users.push(foundUsers[$(el).attr('data-uid')]); - }); - addUserToGroup(users, function () { - modal.modal('hide'); - }); - }, - }, - }, - }); - modal.on('click', '[data-username]', function () { - const isSelected = $(this).attr('data-selected') === '1'; - if (isSelected) { - $(this).removeAttr('data-selected'); - } else { - $(this).attr('data-selected', 1); - } - $(this).find('i').toggleClass('invisible'); - }); - modal.find('input').on('keyup', function () { - api.get('/api/users', { - query: $(this).val(), - paginate: false, - }, function (err, result) { - if (err) { - return alerts.error(err); - } - result.users.forEach(function (user) { - foundUsers[user.uid] = user; - }); - app.parseAndTranslate('admin/partials/groups/add-members', 'users', { users: result.users }, function (html) { - modal.find('#search-result').html(html); - }); - }); - }); - }); - }); - } - - function addUserToGroup(users, callback) { - function done() { - users = users.filter(function (user) { - return !$('[component="groups/members"] [data-uid="' + user.uid + '"]').length; - }); - parseAndTranslate(users, function (html) { - $('[component="groups/members"] tbody').prepend(html); - }); - callback(); - } - const uids = users.map(function (user) { return user.uid; }); - if (groupName === 'administrators') { - socket.emit('admin.user.makeAdmins', uids, function (err) { - if (err) { - return alerts.error(err); - } - done(); - }); - } else { - Promise.all(uids.map(uid => api.put('/groups/' + ajaxify.data.group.slug + '/membership/' + uid))).then(done).catch(alerts.error); - } - } - - function handleMemberSearch() { - const searchEl = $('[component="groups/members/search"]'); - searchEl.on('keyup', utils.debounce(function () { - const query = searchEl.val(); - socket.emit('groups.searchMembers', { - groupName: groupName, - query: query, - }, function (err, results) { - if (err) { - return alerts.error(err); - } - parseAndTranslate(results.users, function (html) { - $('[component="groups/members"] tbody').html(html); - $('[component="groups/members"]').attr('data-nextstart', 20); - }); - }); - }, 250)); - } - - function handleMemberInfiniteScroll() { - $('[component="groups/members"] tbody').on('scroll', function () { - const $this = $(this); - const bottom = ($this[0].scrollHeight - $this.innerHeight()) * 0.9; - - if ($this.scrollTop() > bottom && !$('[component="groups/members/search"]').val()) { - loadMoreMembers(); - } - }); - } - - function loadMoreMembers() { - const members = $('[component="groups/members"]'); - if (members.attr('loading')) { - return; - } - - members.attr('loading', 1); - socket.emit('groups.loadMoreMembers', { - groupName: groupName, - after: members.attr('data-nextstart'), - }, function (err, data) { - if (err) { - return alerts.error(err); - } - - if (data && data.users.length) { - onMembersLoaded(data.users, function () { - members.removeAttr('loading'); - members.attr('data-nextstart', data.nextStart); - }); - } else { - members.removeAttr('loading'); - } - }); - } - - function onMembersLoaded(users, callback) { - users = users.filter(function (user) { - return !$('[component="groups/members"] [data-uid="' + user.uid + '"]').length; - }); - - parseAndTranslate(users, function (html) { - $('[component="groups/members"] tbody').append(html); - callback(); - }); - } - - function parseAndTranslate(users, callback) { - app.parseAndTranslate(templateName, 'group.members', { - group: { - members: users, - isOwner: ajaxify.data.group.isOwner, - }, - }, callback); - } - - return MemberList; +define('forum/groups/memberlist', ['api', 'bootbox', 'alerts'], (api, bootbox, alerts) => { + const MemberList = {}; + let groupName; + let templateName; + + MemberList.init = function (_templateName) { + templateName = _templateName || 'groups/details'; + groupName = ajaxify.data.group.name; + + handleMemberAdd(); + handleMemberSearch(); + handleMemberInfiniteScroll(); + }; + + function handleMemberAdd() { + $('[component="groups/members/add"]').on('click', () => { + app.parseAndTranslate('admin/partials/groups/add-members', {}, html => { + const foundUsers = []; + const modal = bootbox.dialog({ + title: '[[groups:details.add-member]]', + message: html, + buttons: { + ok: { + callback() { + const users = []; + modal.find('[data-uid][data-selected]').each((index, element) => { + users.push(foundUsers[$(element).attr('data-uid')]); + }); + addUserToGroup(users, () => { + modal.modal('hide'); + }); + }, + }, + }, + }); + modal.on('click', '[data-username]', function () { + const isSelected = $(this).attr('data-selected') === '1'; + if (isSelected) { + $(this).removeAttr('data-selected'); + } else { + $(this).attr('data-selected', 1); + } + + $(this).find('i').toggleClass('invisible'); + }); + modal.find('input').on('keyup', function () { + api.get('/api/users', { + query: $(this).val(), + paginate: false, + }, (error, result) => { + if (error) { + return alerts.error(error); + } + + for (const user of result.users) { + foundUsers[user.uid] = user; + } + + app.parseAndTranslate('admin/partials/groups/add-members', 'users', {users: result.users}, html => { + modal.find('#search-result').html(html); + }); + }); + }); + }); + }); + } + + function addUserToGroup(users, callback) { + function done() { + users = users.filter(user => $('[component="groups/members"] [data-uid="' + user.uid + '"]').length === 0); + parseAndTranslate(users, html => { + $('[component="groups/members"] tbody').prepend(html); + }); + callback(); + } + + const uids = users.map(user => user.uid); + if (groupName === 'administrators') { + socket.emit('admin.user.makeAdmins', uids, error => { + if (error) { + return alerts.error(error); + } + + done(); + }); + } else { + Promise.all(uids.map(uid => api.put('/groups/' + ajaxify.data.group.slug + '/membership/' + uid))).then(done).catch(alerts.error); + } + } + + function handleMemberSearch() { + const searchElement = $('[component="groups/members/search"]'); + searchElement.on('keyup', utils.debounce(() => { + const query = searchElement.val(); + socket.emit('groups.searchMembers', { + groupName, + query, + }, (error, results) => { + if (error) { + return alerts.error(error); + } + + parseAndTranslate(results.users, html => { + $('[component="groups/members"] tbody').html(html); + $('[component="groups/members"]').attr('data-nextstart', 20); + }); + }); + }, 250)); + } + + function handleMemberInfiniteScroll() { + $('[component="groups/members"] tbody').on('scroll', function () { + const $this = $(this); + const bottom = ($this[0].scrollHeight - $this.innerHeight()) * 0.9; + + if ($this.scrollTop() > bottom && !$('[component="groups/members/search"]').val()) { + loadMoreMembers(); + } + }); + } + + function loadMoreMembers() { + const members = $('[component="groups/members"]'); + if (members.attr('loading')) { + return; + } + + members.attr('loading', 1); + socket.emit('groups.loadMoreMembers', { + groupName, + after: members.attr('data-nextstart'), + }, (error, data) => { + if (error) { + return alerts.error(error); + } + + if (data && data.users.length > 0) { + onMembersLoaded(data.users, () => { + members.removeAttr('loading'); + members.attr('data-nextstart', data.nextStart); + }); + } else { + members.removeAttr('loading'); + } + }); + } + + function onMembersLoaded(users, callback) { + users = users.filter(user => $('[component="groups/members"] [data-uid="' + user.uid + '"]').length === 0); + + parseAndTranslate(users, html => { + $('[component="groups/members"] tbody').append(html); + callback(); + }); + } + + function parseAndTranslate(users, callback) { + app.parseAndTranslate(templateName, 'group.members', { + group: { + members: users, + isOwner: ajaxify.data.group.isOwner, + }, + }, callback); + } + + return MemberList; }); diff --git a/public/src/client/header.js b/public/src/client/header.js index fdfc969..d280428 100644 --- a/public/src/client/header.js +++ b/public/src/client/header.js @@ -1,79 +1,80 @@ 'use strict'; define('forum/header', [ - 'forum/header/unread', - 'forum/header/notifications', - 'forum/header/chat', - 'alerts', -], function (unread, notifications, chat, alerts) { - const module = {}; + 'forum/header/unread', + 'forum/header/notifications', + 'forum/header/chat', + 'alerts', +], (unread, notifications, chat, alerts) => { + const module = {}; - module.prepareDOM = function () { - if (app.user.uid > 0) { - unread.initUnreadTopics(); - } - notifications.prepareDOM(); - chat.prepareDOM(); - handleStatusChange(); - createHeaderTooltips(); - handleLogout(); - }; + module.prepareDOM = function () { + if (app.user.uid > 0) { + unread.initUnreadTopics(); + } - function handleStatusChange() { - $('[component="header/usercontrol"] [data-status]').off('click').on('click', function (e) { - const status = $(this).attr('data-status'); - socket.emit('user.setStatus', status, function (err) { - if (err) { - return alerts.error(err); - } - $('[data-uid="' + app.user.uid + '"] [component="user/status"], [component="header/profilelink"] [component="user/status"]') - .removeClass('away online dnd offline') - .addClass(status); - $('[component="header/usercontrol"] [data-status]').each(function () { - $(this).find('span').toggleClass('bold', $(this).attr('data-status') === status); - }); - app.user.status = status; - }); - e.preventDefault(); - }); - } + notifications.prepareDOM(); + chat.prepareDOM(); + handleStatusChange(); + createHeaderTooltips(); + handleLogout(); + }; - function createHeaderTooltips() { - const env = utils.findBootstrapEnvironment(); - if (env === 'xs' || env === 'sm' || utils.isTouchDevice()) { - return; - } - $('#header-menu li a[title]').each(function () { - $(this).tooltip({ - placement: 'bottom', - trigger: 'hover', - title: $(this).attr('title'), - }); - }); + function handleStatusChange() { + $('[component="header/usercontrol"] [data-status]').off('click').on('click', function (e) { + const status = $(this).attr('data-status'); + socket.emit('user.setStatus', status, error => { + if (error) { + return alerts.error(error); + } + $('[data-uid="' + app.user.uid + '"] [component="user/status"], [component="header/profilelink"] [component="user/status"]') + .removeClass('away online dnd offline') + .addClass(status); + $('[component="header/usercontrol"] [data-status]').each(function () { + $(this).find('span').toggleClass('bold', $(this).attr('data-status') === status); + }); + app.user.status = status; + }); + e.preventDefault(); + }); + } - $('#search-form').tooltip({ - placement: 'bottom', - trigger: 'hover', - title: $('#search-button i').attr('title'), - }); + function createHeaderTooltips() { + const env = utils.findBootstrapEnvironment(); + if (env === 'xs' || env === 'sm' || utils.isTouchDevice()) { + return; + } + $('#header-menu li a[title]').each(function () { + $(this).tooltip({ + placement: 'bottom', + trigger: 'hover', + title: $(this).attr('title'), + }); + }); - $('#user_dropdown').tooltip({ - placement: 'bottom', - trigger: 'hover', - title: $('#user_dropdown').attr('title'), - }); - } + $('#search-form').tooltip({ + placement: 'bottom', + trigger: 'hover', + title: $('#search-button i').attr('title'), + }); - function handleLogout() { - $('#header-menu .container').on('click', '[component="user/logout"]', function () { - require(['logout'], function (logout) { - logout(); - }); - return false; - }); - } + $('#user_dropdown').tooltip({ + placement: 'bottom', + trigger: 'hover', + title: $('#user_dropdown').attr('title'), + }); + } - return module; + function handleLogout() { + $('#header-menu .container').on('click', '[component="user/logout"]', () => { + require(['logout'], logout => { + logout(); + }); + return false; + }); + } + + return module; }); diff --git a/public/src/client/header/chat.js b/public/src/client/header/chat.js index 3eb2856..e647623 100644 --- a/public/src/client/header/chat.js +++ b/public/src/client/header/chat.js @@ -1,56 +1,57 @@ 'use strict'; -define('forum/header/chat', ['components'], function (components) { - const chat = {}; - - chat.prepareDOM = function () { - const chatsToggleEl = components.get('chat/dropdown'); - const chatsListEl = components.get('chat/list'); - - chatsToggleEl.on('click', function () { - if (chatsToggleEl.parent().hasClass('open')) { - return; - } - requireAndCall('loadChatsDropdown', chatsListEl); - }); - - if (chatsToggleEl.parents('.dropdown').hasClass('open')) { - requireAndCall('loadChatsDropdown', chatsListEl); - } - - socket.removeListener('event:chats.receive', onChatMessageReceived); - socket.on('event:chats.receive', onChatMessageReceived); - - socket.removeListener('event:user_status_change', onUserStatusChange); - socket.on('event:user_status_change', onUserStatusChange); - - socket.removeListener('event:chats.roomRename', onRoomRename); - socket.on('event:chats.roomRename', onRoomRename); - - socket.on('event:unread.updateChatCount', function (count) { - components.get('chat/icon') - .toggleClass('unread-count', count > 0) - .attr('data-content', count > 99 ? '99+' : count); - }); - }; - - function onChatMessageReceived(data) { - requireAndCall('onChatMessageReceived', data); - } - - function onUserStatusChange(data) { - requireAndCall('onUserStatusChange', data); - } - - function onRoomRename(data) { - requireAndCall('onRoomRename', data); - } - - function requireAndCall(method, param) { - require(['chat'], function (chat) { - chat[method](param); - }); - } - - return chat; +define('forum/header/chat', ['components'], components => { + const chat = {}; + + chat.prepareDOM = function () { + const chatsToggleElement = components.get('chat/dropdown'); + const chatsListElement = components.get('chat/list'); + + chatsToggleElement.on('click', () => { + if (chatsToggleElement.parent().hasClass('open')) { + return; + } + + requireAndCall('loadChatsDropdown', chatsListElement); + }); + + if (chatsToggleElement.parents('.dropdown').hasClass('open')) { + requireAndCall('loadChatsDropdown', chatsListElement); + } + + socket.removeListener('event:chats.receive', onChatMessageReceived); + socket.on('event:chats.receive', onChatMessageReceived); + + socket.removeListener('event:user_status_change', onUserStatusChange); + socket.on('event:user_status_change', onUserStatusChange); + + socket.removeListener('event:chats.roomRename', onRoomRename); + socket.on('event:chats.roomRename', onRoomRename); + + socket.on('event:unread.updateChatCount', count => { + components.get('chat/icon') + .toggleClass('unread-count', count > 0) + .attr('data-content', count > 99 ? '99+' : count); + }); + }; + + function onChatMessageReceived(data) { + requireAndCall('onChatMessageReceived', data); + } + + function onUserStatusChange(data) { + requireAndCall('onUserStatusChange', data); + } + + function onRoomRename(data) { + requireAndCall('onRoomRename', data); + } + + function requireAndCall(method, parameter) { + require(['chat'], chat => { + chat[method](parameter); + }); + } + + return chat; }); diff --git a/public/src/client/header/notifications.js b/public/src/client/header/notifications.js index dbccf83..4c5cef6 100644 --- a/public/src/client/header/notifications.js +++ b/public/src/client/header/notifications.js @@ -1,46 +1,46 @@ 'use strict'; -define('forum/header/notifications', ['components'], function (components) { - const notifications = {}; +define('forum/header/notifications', ['components'], components => { + const notifications = {}; - notifications.prepareDOM = function () { - const notifContainer = components.get('notifications'); - const notifTrigger = notifContainer.children('a'); - const notifList = components.get('notifications/list'); + notifications.prepareDOM = function () { + const notificationContainer = components.get('notifications'); + const notificationTrigger = notificationContainer.children('a'); + const notificationList = components.get('notifications/list'); - notifTrigger.on('click', function (e) { - e.preventDefault(); - if (notifContainer.hasClass('open')) { - return; - } + notificationTrigger.on('click', e => { + e.preventDefault(); + if (notificationContainer.hasClass('open')) { + return; + } - requireAndCall('loadNotifications', notifList); - }); + requireAndCall('loadNotifications', notificationList); + }); - if (notifTrigger.parents('.dropdown').hasClass('open')) { - requireAndCall('loadNotifications', notifList); - } + if (notificationTrigger.parents('.dropdown').hasClass('open')) { + requireAndCall('loadNotifications', notificationList); + } - socket.removeListener('event:new_notification', onNewNotification); - socket.on('event:new_notification', onNewNotification); + socket.removeListener('event:new_notification', onNewNotification); + socket.on('event:new_notification', onNewNotification); - socket.removeListener('event:notifications.updateCount', onUpdateCount); - socket.on('event:notifications.updateCount', onUpdateCount); - }; + socket.removeListener('event:notifications.updateCount', onUpdateCount); + socket.on('event:notifications.updateCount', onUpdateCount); + }; - function onNewNotification(data) { - requireAndCall('onNewNotification', data); - } + function onNewNotification(data) { + requireAndCall('onNewNotification', data); + } - function onUpdateCount(data) { - requireAndCall('updateNotifCount', data); - } + function onUpdateCount(data) { + requireAndCall('updateNotifCount', data); + } - function requireAndCall(method, param) { - require(['notifications'], function (notifications) { - notifications[method](param); - }); - } + function requireAndCall(method, parameter) { + require(['notifications'], notifications => { + notifications[method](parameter); + }); + } - return notifications; + return notifications; }); diff --git a/public/src/client/header/unread.js b/public/src/client/header/unread.js index f464df7..67029c6 100644 --- a/public/src/client/header/unread.js +++ b/public/src/client/header/unread.js @@ -1,96 +1,99 @@ 'use strict'; -define('forum/header/unread', function () { - const unread = {}; - const watchStates = { - ignoring: 1, - notwatching: 2, - watching: 3, - }; - - unread.initUnreadTopics = function () { - const unreadTopics = app.user.unreadData; - - function onNewPost(data) { - if (data && data.posts && data.posts.length && unreadTopics) { - const post = data.posts[0]; - if (parseInt(post.uid, 10) === parseInt(app.user.uid, 10) || - (!post.topic.isFollowing && post.categoryWatchState !== watchStates.watching) - ) { - return; - } - - const tid = post.topic.tid; - if (!unreadTopics[''][tid] || !unreadTopics.new[tid] || - !unreadTopics.watched[tid] || !unreadTopics.unreplied[tid]) { - markTopicsUnread(tid); - } - - if (!unreadTopics[''][tid]) { - increaseUnreadCount(''); - unreadTopics[''][tid] = true; - } - const isNewTopic = post.isMain && parseInt(post.uid, 10) !== parseInt(app.user.uid, 10); - if (isNewTopic && !unreadTopics.new[tid]) { - increaseUnreadCount('new'); - unreadTopics.new[tid] = true; - } - const isUnreplied = parseInt(post.topic.postcount, 10) <= 1; - if (isUnreplied && !unreadTopics.unreplied[tid]) { - increaseUnreadCount('unreplied'); - unreadTopics.unreplied[tid] = true; - } - - if (post.topic.isFollowing && !unreadTopics.watched[tid]) { - increaseUnreadCount('watched'); - unreadTopics.watched[tid] = true; - } - } - } - - function increaseUnreadCount(filter) { - const unreadUrl = '/unread' + (filter ? '?filter=' + filter : ''); - const newCount = 1 + parseInt($('a[href="' + config.relative_path + unreadUrl + '"].navigation-link i').attr('data-content'), 10); - updateUnreadTopicCount(unreadUrl, newCount); - } - - function markTopicsUnread(tid) { - $('[data-tid="' + tid + '"]').addClass('unread'); - } - - $(window).on('action:ajaxify.end', function () { - if (ajaxify.data.template.topic) { - ['', 'new', 'watched', 'unreplied'].forEach(function (filter) { - delete unreadTopics[filter][ajaxify.data.tid]; - }); - } - }); - socket.removeListener('event:new_post', onNewPost); - socket.on('event:new_post', onNewPost); - - socket.removeListener('event:unread.updateCount', updateUnreadCounters); - socket.on('event:unread.updateCount', updateUnreadCounters); - }; - - function updateUnreadCounters(data) { - updateUnreadTopicCount('/unread', data.unreadTopicCount); - updateUnreadTopicCount('/unread?filter=new', data.unreadNewTopicCount); - updateUnreadTopicCount('/unread?filter=watched', data.unreadWatchedTopicCount); - updateUnreadTopicCount('/unread?filter=unreplied', data.unreadUnrepliedTopicCount); - } - - function updateUnreadTopicCount(url, count) { - if (!utils.isNumber(count)) { - return; - } - - $('a[href="' + config.relative_path + url + '"].navigation-link i') - .toggleClass('unread-count', count > 0) - .attr('data-content', count > 99 ? '99+' : count); - - $('#mobile-menu [data-unread-url="' + url + '"]').attr('data-content', count > 99 ? '99+' : count); - } - unread.updateUnreadTopicCount = updateUnreadTopicCount; - - return unread; +define('forum/header/unread', () => { + const unread = {}; + const watchStates = { + ignoring: 1, + notwatching: 2, + watching: 3, + }; + + unread.initUnreadTopics = function () { + const unreadTopics = app.user.unreadData; + + function onNewPost(data) { + if (data && data.posts && data.posts.length > 0 && unreadTopics) { + const post = data.posts[0]; + if (Number.parseInt(post.uid, 10) === Number.parseInt(app.user.uid, 10) + || (!post.topic.isFollowing && post.categoryWatchState !== watchStates.watching) + ) { + return; + } + + const tid = post.topic.tid; + if (!unreadTopics[''][tid] || !unreadTopics.new[tid] + || !unreadTopics.watched[tid] || !unreadTopics.unreplied[tid]) { + markTopicsUnread(tid); + } + + if (!unreadTopics[''][tid]) { + increaseUnreadCount(''); + unreadTopics[''][tid] = true; + } + + const isNewTopic = post.isMain && Number.parseInt(post.uid, 10) !== Number.parseInt(app.user.uid, 10); + if (isNewTopic && !unreadTopics.new[tid]) { + increaseUnreadCount('new'); + unreadTopics.new[tid] = true; + } + + const isUnreplied = Number.parseInt(post.topic.postcount, 10) <= 1; + if (isUnreplied && !unreadTopics.unreplied[tid]) { + increaseUnreadCount('unreplied'); + unreadTopics.unreplied[tid] = true; + } + + if (post.topic.isFollowing && !unreadTopics.watched[tid]) { + increaseUnreadCount('watched'); + unreadTopics.watched[tid] = true; + } + } + } + + function increaseUnreadCount(filter) { + const unreadUrl = '/unread' + (filter ? '?filter=' + filter : ''); + const newCount = 1 + Number.parseInt($('a[href="' + config.relative_path + unreadUrl + '"].navigation-link i').attr('data-content'), 10); + updateUnreadTopicCount(unreadUrl, newCount); + } + + function markTopicsUnread(tid) { + $('[data-tid="' + tid + '"]').addClass('unread'); + } + + $(window).on('action:ajaxify.end', () => { + if (ajaxify.data.template.topic) { + for (const filter of ['', 'new', 'watched', 'unreplied']) { + delete unreadTopics[filter][ajaxify.data.tid]; + } + } + }); + socket.removeListener('event:new_post', onNewPost); + socket.on('event:new_post', onNewPost); + + socket.removeListener('event:unread.updateCount', updateUnreadCounters); + socket.on('event:unread.updateCount', updateUnreadCounters); + }; + + function updateUnreadCounters(data) { + updateUnreadTopicCount('/unread', data.unreadTopicCount); + updateUnreadTopicCount('/unread?filter=new', data.unreadNewTopicCount); + updateUnreadTopicCount('/unread?filter=watched', data.unreadWatchedTopicCount); + updateUnreadTopicCount('/unread?filter=unreplied', data.unreadUnrepliedTopicCount); + } + + function updateUnreadTopicCount(url, count) { + if (!utils.isNumber(count)) { + return; + } + + $('a[href="' + config.relative_path + url + '"].navigation-link i') + .toggleClass('unread-count', count > 0) + .attr('data-content', count > 99 ? '99+' : count); + + $('#mobile-menu [data-unread-url="' + url + '"]').attr('data-content', count > 99 ? '99+' : count); + } + + unread.updateUnreadTopicCount = updateUnreadTopicCount; + + return unread; }); diff --git a/public/src/client/infinitescroll.js b/public/src/client/infinitescroll.js index ab5c64a..48de0ea 100644 --- a/public/src/client/infinitescroll.js +++ b/public/src/client/infinitescroll.js @@ -1,124 +1,130 @@ 'use strict'; - -define('forum/infinitescroll', ['hooks', 'alerts'], function (hooks, alerts) { - const scroll = {}; - let callback; - let previousScrollTop = 0; - let loadingMore = false; - let container; - let scrollTimeout = 0; - - scroll.init = function (el, cb) { - const $body = $('body'); - if (typeof el === 'function') { - callback = el; - container = $body; - } else { - callback = cb; - container = el || $body; - } - previousScrollTop = $(window).scrollTop(); - $(window).off('scroll', startScrollTimeout).on('scroll', startScrollTimeout); - - if ($body.height() <= $(window).height()) { - callback(1); - } - }; - - function startScrollTimeout() { - if (scrollTimeout) { - clearTimeout(scrollTimeout); - } - scrollTimeout = setTimeout(function () { - scrollTimeout = 0; - onScroll(); - }, 60); - } - - function onScroll() { - const bsEnv = utils.findBootstrapEnvironment(); - const mobileComposerOpen = (bsEnv === 'xs' || bsEnv === 'sm') && $('html').hasClass('composing'); - if (loadingMore || mobileComposerOpen) { - return; - } - const currentScrollTop = $(window).scrollTop(); - const wh = $(window).height(); - const viewportHeight = container.height() - wh; - const offsetTop = container.offset() ? container.offset().top : 0; - const scrollPercent = 100 * (currentScrollTop - offsetTop) / (viewportHeight <= 0 ? wh : viewportHeight); - - const top = 15; - const bottom = 85; - const direction = currentScrollTop > previousScrollTop ? 1 : -1; - - if (scrollPercent < top && currentScrollTop < previousScrollTop) { - callback(direction); - } else if (scrollPercent > bottom && currentScrollTop > previousScrollTop) { - callback(direction); - } else if (scrollPercent < 0 && direction > 0 && viewportHeight < 0) { - callback(direction); - } - - previousScrollTop = currentScrollTop; - } - - scroll.loadMore = function (method, data, callback) { - if (loadingMore) { - return; - } - loadingMore = true; - - const hookData = { method: method, data: data }; - hooks.fire('action:infinitescroll.loadmore', hookData); - - socket.emit(hookData.method, hookData.data, function (err, data) { - if (err) { - loadingMore = false; - return alerts.error(err); - } - callback(data, function () { - loadingMore = false; - }); - }); - }; - - scroll.loadMoreXhr = function (data, callback) { - if (loadingMore) { - return; - } - loadingMore = true; - const url = config.relative_path + '/api' + location.pathname.replace(new RegExp('^' + config.relative_path), ''); - const hookData = { url: url, data: data }; - hooks.fire('action:infinitescroll.loadmore.xhr', hookData); - - $.get(url, data, function (data) { - callback(data, function () { - loadingMore = false; - }); - }).fail(function (jqXHR) { - loadingMore = false; - alerts.error(String(jqXHR.responseJSON || jqXHR.statusText)); - }); - }; - - scroll.removeExtra = function (els, direction, count) { - let removedEls = $(); - if (els.length <= count) { - return removedEls; - } - - const removeCount = els.length - count; - if (direction > 0) { - const height = $(document).height(); - const scrollTop = $(window).scrollTop(); - removedEls = els.slice(0, removeCount).remove(); - $(window).scrollTop(scrollTop + ($(document).height() - height)); - } else { - removedEls = els.slice(els.length - removeCount).remove(); - } - return removedEls; - }; - - return scroll; +define('forum/infinitescroll', ['hooks', 'alerts'], (hooks, alerts) => { + const scroll = {}; + let callback; + let previousScrollTop = 0; + let loadingMore = false; + let container; + let scrollTimeout = 0; + + scroll.init = function (element, callback_) { + const $body = $('body'); + if (typeof element === 'function') { + callback = element; + container = $body; + } else { + callback = callback_; + container = element || $body; + } + + previousScrollTop = $(window).scrollTop(); + $(window).off('scroll', startScrollTimeout).on('scroll', startScrollTimeout); + + if ($body.height() <= $(window).height()) { + callback(1); + } + }; + + function startScrollTimeout() { + if (scrollTimeout) { + clearTimeout(scrollTimeout); + } + + scrollTimeout = setTimeout(() => { + scrollTimeout = 0; + onScroll(); + }, 60); + } + + function onScroll() { + const bsEnv = utils.findBootstrapEnvironment(); + const mobileComposerOpen = (bsEnv === 'xs' || bsEnv === 'sm') && $('html').hasClass('composing'); + if (loadingMore || mobileComposerOpen) { + return; + } + + const currentScrollTop = $(window).scrollTop(); + const wh = $(window).height(); + const viewportHeight = container.height() - wh; + const offsetTop = container.offset() ? container.offset().top : 0; + const scrollPercent = 100 * (currentScrollTop - offsetTop) / (viewportHeight <= 0 ? wh : viewportHeight); + + const top = 15; + const bottom = 85; + const direction = currentScrollTop > previousScrollTop ? 1 : -1; + + if (scrollPercent < top && currentScrollTop < previousScrollTop) { + callback(direction); + } else if (scrollPercent > bottom && currentScrollTop > previousScrollTop) { + callback(direction); + } else if (scrollPercent < 0 && direction > 0 && viewportHeight < 0) { + callback(direction); + } + + previousScrollTop = currentScrollTop; + } + + scroll.loadMore = function (method, data, callback) { + if (loadingMore) { + return; + } + + loadingMore = true; + + const hookData = {method, data}; + hooks.fire('action:infinitescroll.loadmore', hookData); + + socket.emit(hookData.method, hookData.data, (error, data) => { + if (error) { + loadingMore = false; + return alerts.error(error); + } + + callback(data, () => { + loadingMore = false; + }); + }); + }; + + scroll.loadMoreXhr = function (data, callback) { + if (loadingMore) { + return; + } + + loadingMore = true; + const url = config.relative_path + '/api' + location.pathname.replace(new RegExp('^' + config.relative_path), ''); + const hookData = {url, data}; + hooks.fire('action:infinitescroll.loadmore.xhr', hookData); + + $.get(url, data, data => { + callback(data, () => { + loadingMore = false; + }); + }).fail(jqXHR => { + loadingMore = false; + alerts.error(String(jqXHR.responseJSON || jqXHR.statusText)); + }); + }; + + scroll.removeExtra = function (els, direction, count) { + let removedEls = $(); + if (els.length <= count) { + return removedEls; + } + + const removeCount = els.length - count; + if (direction > 0) { + const height = $(document).height(); + const scrollTop = $(window).scrollTop(); + removedEls = els.slice(0, removeCount).remove(); + $(window).scrollTop(scrollTop + ($(document).height() - height)); + } else { + removedEls = els.slice(els.length - removeCount).remove(); + } + + return removedEls; + }; + + return scroll; }); diff --git a/public/src/client/ip-blacklist.js b/public/src/client/ip-blacklist.js index 7649bdf..dd6e1e2 100644 --- a/public/src/client/ip-blacklist.js +++ b/public/src/client/ip-blacklist.js @@ -1,134 +1,130 @@ 'use strict'; +define('forum/ip-blacklist', ['Chart', 'benchpress', 'bootbox', 'alerts'], (Chart, Benchpress, bootbox, alerts) => { + const Exclude = {}; -define('forum/ip-blacklist', ['Chart', 'benchpress', 'bootbox', 'alerts'], function (Chart, Benchpress, bootbox, alerts) { - const Blacklist = {}; + Exclude.init = function () { + const exclude = $('#blacklist-rules'); - Blacklist.init = function () { - const blacklist = $('#blacklist-rules'); + exclude.on('keyup', () => { + $('#blacklist-rules-holder').val(exclude.val()); + }); - blacklist.on('keyup', function () { - $('#blacklist-rules-holder').val(blacklist.val()); - }); + $('[data-action="apply"]').on('click', () => { + socket.emit('blacklist.save', exclude.val(), error => { + if (error) { + return alerts.error(error); + } - $('[data-action="apply"]').on('click', function () { - socket.emit('blacklist.save', blacklist.val(), function (err) { - if (err) { - return alerts.error(err); - } - alerts.alert({ - type: 'success', - alert_id: 'blacklist-saved', - title: '[[ip-blacklist:alerts.applied-success]]', - }); - }); - }); + alerts.alert({ + type: 'success', + alert_id: 'blacklist-saved', + title: '[[ip-blacklist:alerts.applied-success]]', + }); + }); + }); - $('[data-action="test"]').on('click', function () { - socket.emit('blacklist.validate', { - rules: blacklist.val(), - }, function (err, data) { - if (err) { - return alerts.error(err); - } + $('[data-action="test"]').on('click', () => { + socket.emit('blacklist.validate', { + rules: exclude.val(), + }, (error, data) => { + if (error) { + return alerts.error(error); + } - Benchpress.render('admin/partials/blacklist-validate', data).then(function (html) { - bootbox.alert(html); - }); - }); - }); + Benchpress.render('admin/partials/blacklist-validate', data).then(html => { + bootbox.alert(html); + }); + }); + }); - Blacklist.setupAnalytics(); - }; + Exclude.setupAnalytics(); + }; - Blacklist.setupAnalytics = function () { - const hourlyCanvas = document.getElementById('blacklist:hourly'); - const dailyCanvas = document.getElementById('blacklist:daily'); - const hourlyLabels = utils.getHoursArray().map(function (text, idx) { - return idx % 3 ? '' : text; - }); - const dailyLabels = utils.getDaysArray().slice(-7).map(function (text, idx) { - return idx % 3 ? '' : text; - }); + Exclude.setupAnalytics = function () { + const hourlyCanvas = document.querySelector('#blacklist:hourly'); + const dailyCanvas = document.querySelector('#blacklist:daily'); + const hourlyLabels = utils.getHoursArray().map((text, index) => index % 3 ? '' : text); + const dailyLabels = utils.getDaysArray().slice(-7).map((text, index) => index % 3 ? '' : text); - if (utils.isMobile()) { - Chart.defaults.global.tooltips.enabled = false; - } + if (utils.isMobile()) { + Chart.defaults.global.tooltips.enabled = false; + } - const data = { - 'blacklist:hourly': { - labels: hourlyLabels, - datasets: [ - { - label: '', - backgroundColor: 'rgba(186,139,175,0.2)', - borderColor: 'rgba(186,139,175,1)', - pointBackgroundColor: 'rgba(186,139,175,1)', - pointHoverBackgroundColor: '#fff', - pointBorderColor: '#fff', - pointHoverBorderColor: 'rgba(186,139,175,1)', - data: ajaxify.data.analytics.hourly, - }, - ], - }, - 'blacklist:daily': { - labels: dailyLabels, - datasets: [ - { - label: '', - backgroundColor: 'rgba(151,187,205,0.2)', - borderColor: 'rgba(151,187,205,1)', - pointBackgroundColor: 'rgba(151,187,205,1)', - pointHoverBackgroundColor: '#fff', - pointBorderColor: '#fff', - pointHoverBorderColor: 'rgba(151,187,205,1)', - data: ajaxify.data.analytics.daily, - }, - ], - }, - }; + const data = { + 'blacklist:hourly': { + labels: hourlyLabels, + datasets: [ + { + label: '', + backgroundColor: 'rgba(186,139,175,0.2)', + borderColor: 'rgba(186,139,175,1)', + pointBackgroundColor: 'rgba(186,139,175,1)', + pointHoverBackgroundColor: '#fff', + pointBorderColor: '#fff', + pointHoverBorderColor: 'rgba(186,139,175,1)', + data: ajaxify.data.analytics.hourly, + }, + ], + }, + 'blacklist:daily': { + labels: dailyLabels, + datasets: [ + { + label: '', + backgroundColor: 'rgba(151,187,205,0.2)', + borderColor: 'rgba(151,187,205,1)', + pointBackgroundColor: 'rgba(151,187,205,1)', + pointHoverBackgroundColor: '#fff', + pointBorderColor: '#fff', + pointHoverBorderColor: 'rgba(151,187,205,1)', + data: ajaxify.data.analytics.daily, + }, + ], + }, + }; - hourlyCanvas.width = $(hourlyCanvas).parent().width(); - dailyCanvas.width = $(dailyCanvas).parent().width(); + hourlyCanvas.width = $(hourlyCanvas).parent().width(); + dailyCanvas.width = $(dailyCanvas).parent().width(); - new Chart(hourlyCanvas.getContext('2d'), { - type: 'line', - data: data['blacklist:hourly'], - options: { - responsive: true, - animation: false, - legend: { - display: false, - }, - scales: { - yAxes: [{ - ticks: { - beginAtZero: true, - }, - }], - }, - }, - }); + new Chart(hourlyCanvas.getContext('2d'), { + type: 'line', + data: data['blacklist:hourly'], + options: { + responsive: true, + animation: false, + legend: { + display: false, + }, + scales: { + yAxes: [{ + ticks: { + beginAtZero: true, + }, + }], + }, + }, + }); - new Chart(dailyCanvas.getContext('2d'), { - type: 'line', - data: data['blacklist:daily'], - options: { - responsive: true, - animation: false, - legend: { - display: false, - }, - scales: { - yAxes: [{ - ticks: { - beginAtZero: true, - }, - }], - }, - }, - }); - }; + new Chart(dailyCanvas.getContext('2d'), { + type: 'line', + data: data['blacklist:daily'], + options: { + responsive: true, + animation: false, + legend: { + display: false, + }, + scales: { + yAxes: [{ + ticks: { + beginAtZero: true, + }, + }], + }, + }, + }); + }; - return Blacklist; + return Exclude; }); diff --git a/public/src/client/login.js b/public/src/client/login.js index d2924cf..2111072 100644 --- a/public/src/client/login.js +++ b/public/src/client/login.js @@ -1,111 +1,115 @@ 'use strict'; - -define('forum/login', ['hooks', 'translator', 'jquery-form'], function (hooks, translator) { - const Login = { - _capsState: false, - }; - - Login.init = function () { - const errorEl = $('#login-error-notify'); - const submitEl = $('#login'); - const formEl = $('#login-form'); - - submitEl.on('click', function (e) { - e.preventDefault(); - - if (!$('#username').val() || !$('#password').val()) { - errorEl.find('p').translateText('[[error:invalid-username-or-password]]'); - errorEl.show(); - } else { - errorEl.hide(); - - if (submitEl.hasClass('disabled')) { - return; - } - - submitEl.addClass('disabled'); - - hooks.fire('action:app.login'); - formEl.ajaxSubmit({ - headers: { - 'x-csrf-token': config.csrf_token, - }, - beforeSend: function () { - app.flags._login = true; - }, - success: function (data) { - hooks.fire('action:app.loggedIn', data); - const pathname = utils.urlToLocation(data.next).pathname; - const params = utils.params({ url: data.next }); - params.loggedin = true; - delete params.register; // clear register message incase it exists - const qs = decodeURIComponent($.param(params)); - - window.location.href = pathname + '?' + qs; - }, - error: function (data) { - let message = data.responseText; - const errInfo = data.responseJSON; - if (data.status === 403 && data.responseText === 'Forbidden') { - window.location.href = config.relative_path + '/login?error=csrf-invalid'; - } else if (errInfo && errInfo.hasOwnProperty('banned_until')) { - message = errInfo.banned_until ? - translator.compile('error:user-banned-reason-until', (new Date(errInfo.banned_until).toLocaleString()), errInfo.reason) : - '[[error:user-banned-reason, ' + errInfo.reason + ']]'; - } - errorEl.find('p').translateText(message); - errorEl.show(); - submitEl.removeClass('disabled'); - - // Select the entire password if that field has focus - if ($('#password:focus').length) { - $('#password').select(); - } - }, - }); - } - }); - - // Guard against caps lock - Login.capsLockCheck(document.querySelector('#password'), document.querySelector('#caps-lock-warning')); - - $('#login-error-notify button').on('click', function (e) { - e.preventDefault(); - errorEl.hide(); - return false; - }); - - if ($('#content #username').val()) { - $('#content #password').val('').focus(); - } else { - $('#content #username').focus(); - } - $('#content #noscript').val('false'); - }; - - Login.capsLockCheck = (inputEl, warningEl) => { - const toggle = (state) => { - warningEl.classList[state ? 'remove' : 'add']('hidden'); - warningEl.parentNode.classList[state ? 'add' : 'remove']('has-warning'); - }; - if (!inputEl) { - return; - } - inputEl.addEventListener('keyup', function (e) { - if (Login._capsState && e.key === 'CapsLock') { - toggle(false); - Login._capsState = !Login._capsState; - return; - } - Login._capsState = e.getModifierState && e.getModifierState('CapsLock'); - toggle(Login._capsState); - }); - - if (Login._capsState) { - toggle(true); - } - }; - - return Login; +define('forum/login', ['hooks', 'translator', 'jquery-form'], (hooks, translator) => { + const Login = { + _capsState: false, + }; + + Login.init = function () { + const errorElement = $('#login-error-notify'); + const submitElement = $('#login'); + const formElement = $('#login-form'); + + submitElement.on('click', e => { + e.preventDefault(); + + if (!$('#username').val() || !$('#password').val()) { + errorElement.find('p').translateText('[[error:invalid-username-or-password]]'); + errorElement.show(); + } else { + errorElement.hide(); + + if (submitElement.hasClass('disabled')) { + return; + } + + submitElement.addClass('disabled'); + + hooks.fire('action:app.login'); + formElement.ajaxSubmit({ + headers: { + 'x-csrf-token': config.csrf_token, + }, + beforeSend() { + app.flags._login = true; + }, + success(data) { + hooks.fire('action:app.loggedIn', data); + const pathname = utils.urlToLocation(data.next).pathname; + const parameters = utils.params({url: data.next}); + parameters.loggedin = true; + delete parameters.register; // Clear register message incase it exists + const qs = decodeURIComponent($.param(parameters)); + + window.location.href = pathname + '?' + qs; + }, + error(data) { + let message = data.responseText; + const errorInfo = data.responseJSON; + if (data.status === 403 && data.responseText === 'Forbidden') { + window.location.href = config.relative_path + '/login?error=csrf-invalid'; + } else if (errorInfo && errorInfo.hasOwnProperty('banned_until')) { + message = errorInfo.banned_until + ? translator.compile('error:user-banned-reason-until', (new Date(errorInfo.banned_until).toLocaleString()), errorInfo.reason) + : '[[error:user-banned-reason, ' + errorInfo.reason + ']]'; + } + + errorElement.find('p').translateText(message); + errorElement.show(); + submitElement.removeClass('disabled'); + + // Select the entire password if that field has focus + if ($('#password:focus').length > 0) { + $('#password').select(); + } + }, + }); + } + }); + + // Guard against caps lock + Login.capsLockCheck(document.querySelector('#password'), document.querySelector('#caps-lock-warning')); + + $('#login-error-notify button').on('click', e => { + e.preventDefault(); + errorElement.hide(); + return false; + }); + + if ($('#content #username').val()) { + $('#content #password').val('').focus(); + } else { + $('#content #username').focus(); + } + + $('#content #noscript').val('false'); + }; + + Login.capsLockCheck = (inputElement, warningElement) => { + const toggle = state => { + warningElement.classList[state ? 'remove' : 'add']('hidden'); + warningElement.parentNode.classList[state ? 'add' : 'remove']('has-warning'); + }; + + if (!inputElement) { + return; + } + + inputElement.addEventListener('keyup', e => { + if (Login._capsState && e.key === 'CapsLock') { + toggle(false); + Login._capsState = !Login._capsState; + return; + } + + Login._capsState = e.getModifierState && e.getModifierState('CapsLock'); + toggle(Login._capsState); + }); + + if (Login._capsState) { + toggle(true); + } + }; + + return Login; }); diff --git a/public/src/client/notifications.js b/public/src/client/notifications.js index 9f99a80..840e360 100644 --- a/public/src/client/notifications.js +++ b/public/src/client/notifications.js @@ -1,30 +1,29 @@ 'use strict'; +define('forum/notifications', ['components', 'alerts'], (components, alerts) => { + const Notifications = {}; -define('forum/notifications', ['components', 'alerts'], function (components, alerts) { - const Notifications = {}; + Notifications.init = function () { + const listElement = $('.notifications-list'); + listElement.on('click', '[component="notifications/item/link"]', function () { + const nid = $(this).parents('[data-nid]').attr('data-nid'); + socket.emit('notifications.markRead', nid, error => { + if (error) { + return alerts.error(error); + } + }); + }); - Notifications.init = function () { - const listEl = $('.notifications-list'); - listEl.on('click', '[component="notifications/item/link"]', function () { - const nid = $(this).parents('[data-nid]').attr('data-nid'); - socket.emit('notifications.markRead', nid, function (err) { - if (err) { - return alerts.error(err); - } - }); - }); + components.get('notifications/mark_all').on('click', () => { + socket.emit('notifications.markAllRead', error => { + if (error) { + return alerts.error(error); + } - components.get('notifications/mark_all').on('click', function () { - socket.emit('notifications.markAllRead', function (err) { - if (err) { - return alerts.error(err); - } + components.get('notifications/item').removeClass('unread'); + }); + }); + }; - components.get('notifications/item').removeClass('unread'); - }); - }); - }; - - return Notifications; + return Notifications; }); diff --git a/public/src/client/pagination.js b/public/src/client/pagination.js index 665f3ea..89e63cd 100644 --- a/public/src/client/pagination.js +++ b/public/src/client/pagination.js @@ -1,39 +1,38 @@ 'use strict'; - -define('forum/pagination', ['bootbox'], function (bootbox) { - const pagination = {}; - - pagination.init = function () { - $('body').on('click', '[component="pagination/select-page"]', function () { - bootbox.prompt('[[global:enter_page_number]]', function (pageNum) { - pagination.loadPage(pageNum); - }); - return false; - }); - }; - - pagination.loadPage = function (page, callback) { - callback = callback || function () {}; - page = parseInt(page, 10); - if (!utils.isNumber(page) || page < 1 || page > ajaxify.data.pagination.pageCount) { - return; - } - - const query = utils.params(); - query.page = page; - - const url = window.location.pathname + '?' + $.param(query); - ajaxify.go(url, callback); - }; - - pagination.nextPage = function (callback) { - pagination.loadPage(ajaxify.data.pagination.currentPage + 1, callback); - }; - - pagination.previousPage = function (callback) { - pagination.loadPage(ajaxify.data.pagination.currentPage - 1, callback); - }; - - return pagination; +define('forum/pagination', ['bootbox'], bootbox => { + const pagination = {}; + + pagination.init = function () { + $('body').on('click', '[component="pagination/select-page"]', () => { + bootbox.prompt('[[global:enter_page_number]]', pageNumber => { + pagination.loadPage(pageNumber); + }); + return false; + }); + }; + + pagination.loadPage = function (page, callback) { + callback ||= function () {}; + page = Number.parseInt(page, 10); + if (!utils.isNumber(page) || page < 1 || page > ajaxify.data.pagination.pageCount) { + return; + } + + const query = utils.params(); + query.page = page; + + const url = window.location.pathname + '?' + $.param(query); + ajaxify.go(url, callback); + }; + + pagination.nextPage = function (callback) { + pagination.loadPage(ajaxify.data.pagination.currentPage + 1, callback); + }; + + pagination.previousPage = function (callback) { + pagination.loadPage(ajaxify.data.pagination.currentPage - 1, callback); + }; + + return pagination; }); diff --git a/public/src/client/popular.js b/public/src/client/popular.js index a34c1d1..d1d33e4 100644 --- a/public/src/client/popular.js +++ b/public/src/client/popular.js @@ -1,14 +1,13 @@ 'use strict'; +define('forum/popular', ['topicList'], topicList => { + const Popular = {}; -define('forum/popular', ['topicList'], function (topicList) { - const Popular = {}; + Popular.init = function () { + app.enterRoom('popular_topics'); - Popular.init = function () { - app.enterRoom('popular_topics'); + topicList.init('popular'); + }; - topicList.init('popular'); - }; - - return Popular; + return Popular; }); diff --git a/public/src/client/post-queue.js b/public/src/client/post-queue.js index 30bc1b7..e7fef26 100644 --- a/public/src/client/post-queue.js +++ b/public/src/client/post-queue.js @@ -1,185 +1,190 @@ 'use strict'; - define('forum/post-queue', [ - 'categoryFilter', 'categorySelector', 'api', 'alerts', 'bootbox', -], function (categoryFilter, categorySelector, api, alerts, bootbox) { - const PostQueue = {}; - - PostQueue.init = function () { - $('[data-toggle="tooltip"]').tooltip(); - - categoryFilter.init($('[component="category/dropdown"]'), { - privilege: 'moderate', - }); - - handleBulkActions(); - - $('.posts-list').on('click', '[data-action]', async function () { - function getMessage() { - return new Promise((resolve) => { - const modal = bootbox.dialog({ - title: '[[post-queue:notify-user]]', - message: '', - buttons: { - OK: { - label: '[[modules:bootbox.send]]', - callback: function () { - const val = modal.find('textarea').val(); - if (val) { - resolve(val); - } - }, - }, - }, - }); - }); - } - - const parent = $(this).parents('[data-id]'); - const action = $(this).attr('data-action'); - const id = parent.attr('data-id'); - const listContainer = parent.get(0).parentNode; - - if ((!['accept', 'reject', 'notify'].includes(action)) || (action === 'reject' && !await confirmReject('[[post-queue:confirm-reject]]'))) { - return; - } - - socket.emit('posts.' + action, { - id: id, - message: action === 'notify' ? await getMessage() : undefined, - }, function (err) { - if (err) { - return alerts.error(err); - } - if (action === 'accept' || action === 'reject') { - parent.remove(); - } - - if (listContainer.childElementCount === 0) { - if (ajaxify.data.singlePost) { - ajaxify.go('/post-queue' + window.location.search); - } else { - ajaxify.refresh(); - } - } - }); - return false; - }); - - handleContentEdit('.post-content', '.post-content-editable', 'textarea'); - handleContentEdit('.topic-title', '.topic-title-editable', 'input'); - - $('.posts-list').on('click', '.topic-category[data-editable]', function () { - const $this = $(this); - const id = $this.parents('[data-id]').attr('data-id'); - categorySelector.modal({ - onSubmit: function (selectedCategory) { - Promise.all([ - api.get(`/categories/${selectedCategory.cid}`, {}), - socket.emit('posts.editQueuedContent', { - id: id, - cid: selectedCategory.cid, - }), - ]).then(function (result) { - const category = result[0]; - app.parseAndTranslate('post-queue', 'posts', { - posts: [{ - category: category, - }], - }, function (html) { - if ($this.find('.category-text').length) { - $this.find('.category-text').text(html.find('.topic-category .category-text').text()); - } else { - // for backwards compatibility, remove in 1.16.0 - $this.replaceWith(html.find('.topic-category')); - } - }); - }).catch(alerts.error); - }, - }); - return false; - }); - - $('[component="post/content"] img:not(.not-responsive)').addClass('img-responsive'); - }; - - function confirmReject(msg) { - return new Promise((resolve) => { - bootbox.confirm(msg, resolve); - }); - } - - function handleContentEdit(displayClass, editableClass, inputSelector) { - $('.posts-list').on('click', displayClass, function () { - const el = $(this); - const inputEl = el.parent().find(editableClass); - if (inputEl.length) { - el.addClass('hidden'); - inputEl.removeClass('hidden').find(inputSelector).focus(); - } - }); - - $('.posts-list').on('blur', editableClass + ' ' + inputSelector, function () { - const textarea = $(this); - const preview = textarea.parent().parent().find(displayClass); - const id = textarea.parents('[data-id]').attr('data-id'); - const titleEdit = displayClass === '.topic-title'; - - socket.emit('posts.editQueuedContent', { - id: id, - title: titleEdit ? textarea.val() : undefined, - content: titleEdit ? undefined : textarea.val(), - }, function (err, data) { - if (err) { - return alerts.error(err); - } - if (titleEdit) { - if (preview.find('.title-text').length) { - preview.find('.title-text').text(data.postData.title); - } else { - // for backwards compatibility, remove in 1.16.0 - preview.html(data.postData.title); - } - } else { - preview.html(data.postData.content); - } - - textarea.parent().addClass('hidden'); - preview.removeClass('hidden'); - }); - }); - } - - function handleBulkActions() { - $('[component="post-queue/bulk-actions"]').on('click', '[data-action]', async function () { - const bulkAction = $(this).attr('data-action'); - let queueEls = $('.posts-list [data-id]'); - if (bulkAction === 'accept-selected' || bulkAction === 'reject-selected') { - queueEls = queueEls.filter( - (i, el) => $(el).find('input[type="checkbox"]').is(':checked') - ); - } - const ids = queueEls.map((i, el) => $(el).attr('data-id')).get(); - const showConfirm = bulkAction === 'reject-all' || bulkAction === 'reject-selected'; - if (!ids.length || (showConfirm && !(await confirmReject(`[[post-queue:${bulkAction}-confirm, ${ids.length}]]`)))) { - return; - } - const action = bulkAction.split('-')[0]; - const promises = ids.map(id => socket.emit('posts.' + action, { id: id })); - - Promise.allSettled(promises).then(function (results) { - const fulfilled = results.filter(res => res.status === 'fulfilled').length; - const errors = results.filter(res => res.status === 'rejected'); - if (fulfilled) { - alerts.success(`[[post-queue:bulk-${action}-success, ${fulfilled}]]`); - ajaxify.refresh(); - } - - errors.forEach(res => alerts.error(res.reason)); - }); - }); - } - - return PostQueue; + 'categoryFilter', 'categorySelector', 'api', 'alerts', 'bootbox', +], (categoryFilter, categorySelector, api, alerts, bootbox) => { + const PostQueue = {}; + + PostQueue.init = function () { + $('[data-toggle="tooltip"]').tooltip(); + + categoryFilter.init($('[component="category/dropdown"]'), { + privilege: 'moderate', + }); + + handleBulkActions(); + + $('.posts-list').on('click', '[data-action]', async function () { + function getMessage() { + return new Promise(resolve => { + const modal = bootbox.dialog({ + title: '[[post-queue:notify-user]]', + message: '', + buttons: { + OK: { + label: '[[modules:bootbox.send]]', + callback() { + const value = modal.find('textarea').val(); + if (value) { + resolve(value); + } + }, + }, + }, + }); + }); + } + + const parent = $(this).parents('[data-id]'); + const action = $(this).attr('data-action'); + const id = parent.attr('data-id'); + const listContainer = parent.get(0).parentNode; + + if ((!['accept', 'reject', 'notify'].includes(action)) || (action === 'reject' && !await confirmReject('[[post-queue:confirm-reject]]'))) { + return; + } + + socket.emit('posts.' + action, { + id, + message: action === 'notify' ? await getMessage() : undefined, + }, error => { + if (error) { + return alerts.error(error); + } + + if (action === 'accept' || action === 'reject') { + parent.remove(); + } + + if (listContainer.childElementCount === 0) { + if (ajaxify.data.singlePost) { + ajaxify.go('/post-queue' + window.location.search); + } else { + ajaxify.refresh(); + } + } + }); + return false; + }); + + handleContentEdit('.post-content', '.post-content-editable', 'textarea'); + handleContentEdit('.topic-title', '.topic-title-editable', 'input'); + + $('.posts-list').on('click', '.topic-category[data-editable]', function () { + const $this = $(this); + const id = $this.parents('[data-id]').attr('data-id'); + categorySelector.modal({ + onSubmit(selectedCategory) { + Promise.all([ + api.get(`/categories/${selectedCategory.cid}`, {}), + socket.emit('posts.editQueuedContent', { + id, + cid: selectedCategory.cid, + }), + ]).then(result => { + const category = result[0]; + app.parseAndTranslate('post-queue', 'posts', { + posts: [{ + category, + }], + }, html => { + if ($this.find('.category-text').length > 0) { + $this.find('.category-text').text(html.find('.topic-category .category-text').text()); + } else { + // For backwards compatibility, remove in 1.16.0 + $this.replaceWith(html.find('.topic-category')); + } + }); + }).catch(alerts.error); + }, + }); + return false; + }); + + $('[component="post/content"] img:not(.not-responsive)').addClass('img-responsive'); + }; + + function confirmReject(message) { + return new Promise(resolve => { + bootbox.confirm(message, resolve); + }); + } + + function handleContentEdit(displayClass, editableClass, inputSelector) { + $('.posts-list').on('click', displayClass, function () { + const element = $(this); + const inputElement = element.parent().find(editableClass); + if (inputElement.length > 0) { + element.addClass('hidden'); + inputElement.removeClass('hidden').find(inputSelector).focus(); + } + }); + + $('.posts-list').on('blur', editableClass + ' ' + inputSelector, function () { + const textarea = $(this); + const preview = textarea.parent().parent().find(displayClass); + const id = textarea.parents('[data-id]').attr('data-id'); + const titleEdit = displayClass === '.topic-title'; + + socket.emit('posts.editQueuedContent', { + id, + title: titleEdit ? textarea.val() : undefined, + content: titleEdit ? undefined : textarea.val(), + }, (error, data) => { + if (error) { + return alerts.error(error); + } + + if (titleEdit) { + if (preview.find('.title-text').length > 0) { + preview.find('.title-text').text(data.postData.title); + } else { + // For backwards compatibility, remove in 1.16.0 + preview.html(data.postData.title); + } + } else { + preview.html(data.postData.content); + } + + textarea.parent().addClass('hidden'); + preview.removeClass('hidden'); + }); + }); + } + + function handleBulkActions() { + $('[component="post-queue/bulk-actions"]').on('click', '[data-action]', async function () { + const bulkAction = $(this).attr('data-action'); + let queueEls = $('.posts-list [data-id]'); + if (bulkAction === 'accept-selected' || bulkAction === 'reject-selected') { + queueEls = queueEls.filter( + (i, element) => $(element).find('input[type="checkbox"]').is(':checked'), + ); + } + + const ids = queueEls.map((i, element) => $(element).attr('data-id')).get(); + const showConfirm = bulkAction === 'reject-all' || bulkAction === 'reject-selected'; + if (ids.length === 0 || (showConfirm && !(await confirmReject(`[[post-queue:${bulkAction}-confirm, ${ids.length}]]`)))) { + return; + } + + const action = bulkAction.split('-')[0]; + const promises = ids.map(id => socket.emit('posts.' + action, {id})); + + Promise.allSettled(promises).then(results => { + const fulfilled = results.filter(res => res.status === 'fulfilled').length; + const errors = results.filter(res => res.status === 'rejected'); + if (fulfilled) { + alerts.success(`[[post-queue:bulk-${action}-success, ${fulfilled}]]`); + ajaxify.refresh(); + } + + for (const res of errors) { + alerts.error(res.reason); + } + }); + }); + } + + return PostQueue; }); diff --git a/public/src/client/recent.js b/public/src/client/recent.js index 6257bf9..549b8f2 100644 --- a/public/src/client/recent.js +++ b/public/src/client/recent.js @@ -1,13 +1,13 @@ 'use strict'; -define('forum/recent', ['topicList'], function (topicList) { - const Recent = {}; +define('forum/recent', ['topicList'], topicList => { + const Recent = {}; - Recent.init = function () { - app.enterRoom('recent_topics'); + Recent.init = function () { + app.enterRoom('recent_topics'); - topicList.init('recent'); - }; + topicList.init('recent'); + }; - return Recent; + return Recent; }); diff --git a/public/src/client/register.js b/public/src/client/register.js index 62dbc41..a65eac0 100644 --- a/public/src/client/register.js +++ b/public/src/client/register.js @@ -1,209 +1,209 @@ 'use strict'; - define('forum/register', [ - 'translator', 'slugify', 'api', 'bootbox', 'forum/login', 'zxcvbn', 'jquery-form', -], function (translator, slugify, api, bootbox, Login, zxcvbn) { - const Register = {}; - let validationError = false; - const successIcon = ''; - - Register.init = function () { - const username = $('#username'); - const password = $('#password'); - const password_confirm = $('#password-confirm'); - const register = $('#register'); - - handleLanguageOverride(); - - $('#content #noscript').val('false'); - - const query = utils.params(); - if (query.token) { - $('#token').val(query.token); - } - - // Update the "others can mention you via" text - username.on('keyup', function () { - $('#yourUsername').text(this.value.length > 0 ? slugify(this.value) : 'username'); - }); - - username.on('blur', function () { - if (username.val().length) { - validateUsername(username.val()); - } - }); - - password.on('blur', function () { - if (password.val().length) { - validatePassword(password.val(), password_confirm.val()); - } - }); - - password_confirm.on('blur', function () { - if (password_confirm.val().length) { - validatePasswordConfirm(password.val(), password_confirm.val()); - } - }); - - function validateForm(callback) { - validationError = false; - validatePassword(password.val(), password_confirm.val()); - validatePasswordConfirm(password.val(), password_confirm.val()); - validateUsername(username.val(), callback); - } - - // Guard against caps lock - Login.capsLockCheck(document.querySelector('#password'), document.querySelector('#caps-lock-warning')); - - register.on('click', function (e) { - const registerBtn = $(this); - const errorEl = $('#register-error-notify'); - errorEl.addClass('hidden'); - e.preventDefault(); - validateForm(function () { - if (validationError) { - return; - } - - registerBtn.addClass('disabled'); - - registerBtn.parents('form').ajaxSubmit({ - headers: { - 'x-csrf-token': config.csrf_token, - }, - success: function (data) { - registerBtn.removeClass('disabled'); - if (!data) { - return; - } - if (data.next) { - const pathname = utils.urlToLocation(data.next).pathname; - - const params = utils.params({ url: data.next }); - params.registered = true; - const qs = decodeURIComponent($.param(params)); - - window.location.href = pathname + '?' + qs; - } else if (data.message) { - translator.translate(data.message, function (msg) { - bootbox.alert(msg); - ajaxify.go('/'); - }); - } - }, - error: function (data) { - translator.translate(data.responseText, config.defaultLang, function (translated) { - if (data.status === 403 && data.responseText === 'Forbidden') { - window.location.href = config.relative_path + '/register?error=csrf-invalid'; - } else { - errorEl.find('p').text(translated); - errorEl.removeClass('hidden'); - registerBtn.removeClass('disabled'); - } - }); - }, - }); - }); - }); - - // Set initial focus - $('#username').focus(); - }; - - function validateUsername(username, callback) { - callback = callback || function () {}; - - const username_notify = $('#username-notify'); - const userslug = slugify(username); - if (username.length < ajaxify.data.minimumUsernameLength || - userslug.length < ajaxify.data.minimumUsernameLength) { - showError(username_notify, '[[error:username-too-short]]'); - } else if (username.length > ajaxify.data.maximumUsernameLength) { - showError(username_notify, '[[error:username-too-long]]'); - } else if (!utils.isUserNameValid(username) || !userslug) { - showError(username_notify, '[[error:invalid-username]]'); - } else { - Promise.allSettled([ - api.head(`/users/bySlug/${username}`, {}), - api.head(`/groups/${username}`, {}), - ]).then((results) => { - if (results.every(obj => obj.status === 'rejected')) { - showSuccess(username_notify, successIcon); - } else { - showError(username_notify, '[[error:username-taken]]'); - } - - callback(); - }); - } - } - - function validatePassword(password, password_confirm) { - const password_notify = $('#password-notify'); - const password_confirm_notify = $('#password-confirm-notify'); - - try { - utils.assertPasswordValidity(password, zxcvbn); - - if (password === $('#username').val()) { - throw new Error('[[user:password_same_as_username]]'); - } - - showSuccess(password_notify, successIcon); - } catch (err) { - showError(password_notify, err.message); - } - - if (password !== password_confirm && password_confirm !== '') { - showError(password_confirm_notify, '[[user:change_password_error_match]]'); - } - } - - function validatePasswordConfirm(password, password_confirm) { - const password_notify = $('#password-notify'); - const password_confirm_notify = $('#password-confirm-notify'); - - if (!password || password_notify.hasClass('alert-error')) { - return; - } - - if (password !== password_confirm) { - showError(password_confirm_notify, '[[user:change_password_error_match]]'); - } else { - showSuccess(password_confirm_notify, successIcon); - } - } - - function showError(element, msg) { - translator.translate(msg, function (msg) { - element.html(msg); - element.parent() - .removeClass('register-success') - .addClass('register-danger'); - element.show(); - }); - validationError = true; - } - - function showSuccess(element, msg) { - translator.translate(msg, function (msg) { - element.html(msg); - element.parent() - .removeClass('register-danger') - .addClass('register-success'); - element.show(); - }); - } - - function handleLanguageOverride() { - if (!app.user.uid && config.defaultLang !== config.userLang) { - const formEl = $('[component="register/local"]'); - const langEl = $(''); - - formEl.append(langEl); - } - } - - return Register; + 'translator', 'slugify', 'api', 'bootbox', 'forum/login', 'zxcvbn', 'jquery-form', +], (translator, slugify, api, bootbox, Login, zxcvbn) => { + const Register = {}; + let validationError = false; + const successIcon = ''; + + Register.init = function () { + const username = $('#username'); + const password = $('#password'); + const password_confirm = $('#password-confirm'); + const register = $('#register'); + + handleLanguageOverride(); + + $('#content #noscript').val('false'); + + const query = utils.params(); + if (query.token) { + $('#token').val(query.token); + } + + // Update the "others can mention you via" text + username.on('keyup', function () { + $('#yourUsername').text(this.value.length > 0 ? slugify(this.value) : 'username'); + }); + + username.on('blur', () => { + if (username.val().length > 0) { + validateUsername(username.val()); + } + }); + + password.on('blur', () => { + if (password.val().length > 0) { + validatePassword(password.val(), password_confirm.val()); + } + }); + + password_confirm.on('blur', () => { + if (password_confirm.val().length > 0) { + validatePasswordConfirm(password.val(), password_confirm.val()); + } + }); + + function validateForm(callback) { + validationError = false; + validatePassword(password.val(), password_confirm.val()); + validatePasswordConfirm(password.val(), password_confirm.val()); + validateUsername(username.val(), callback); + } + + // Guard against caps lock + Login.capsLockCheck(document.querySelector('#password'), document.querySelector('#caps-lock-warning')); + + register.on('click', function (e) { + const registerButton = $(this); + const errorElement = $('#register-error-notify'); + errorElement.addClass('hidden'); + e.preventDefault(); + validateForm(() => { + if (validationError) { + return; + } + + registerButton.addClass('disabled'); + + registerButton.parents('form').ajaxSubmit({ + headers: { + 'x-csrf-token': config.csrf_token, + }, + success(data) { + registerButton.removeClass('disabled'); + if (!data) { + return; + } + + if (data.next) { + const pathname = utils.urlToLocation(data.next).pathname; + + const parameters = utils.params({url: data.next}); + parameters.registered = true; + const qs = decodeURIComponent($.param(parameters)); + + window.location.href = pathname + '?' + qs; + } else if (data.message) { + translator.translate(data.message, message => { + bootbox.alert(message); + ajaxify.go('/'); + }); + } + }, + error(data) { + translator.translate(data.responseText, config.defaultLang, translated => { + if (data.status === 403 && data.responseText === 'Forbidden') { + window.location.href = config.relative_path + '/register?error=csrf-invalid'; + } else { + errorElement.find('p').text(translated); + errorElement.removeClass('hidden'); + registerButton.removeClass('disabled'); + } + }); + }, + }); + }); + }); + + // Set initial focus + $('#username').focus(); + }; + + function validateUsername(username, callback) { + callback ||= function () {}; + + const username_notify = $('#username-notify'); + const userslug = slugify(username); + if (username.length < ajaxify.data.minimumUsernameLength + || userslug.length < ajaxify.data.minimumUsernameLength) { + showError(username_notify, '[[error:username-too-short]]'); + } else if (username.length > ajaxify.data.maximumUsernameLength) { + showError(username_notify, '[[error:username-too-long]]'); + } else if (!utils.isUserNameValid(username) || !userslug) { + showError(username_notify, '[[error:invalid-username]]'); + } else { + Promise.allSettled([ + api.head(`/users/bySlug/${username}`, {}), + api.head(`/groups/${username}`, {}), + ]).then(results => { + if (results.every(object => object.status === 'rejected')) { + showSuccess(username_notify, successIcon); + } else { + showError(username_notify, '[[error:username-taken]]'); + } + + callback(); + }); + } + } + + function validatePassword(password, password_confirm) { + const password_notify = $('#password-notify'); + const password_confirm_notify = $('#password-confirm-notify'); + + try { + utils.assertPasswordValidity(password, zxcvbn); + + if (password === $('#username').val()) { + throw new Error('[[user:password_same_as_username]]'); + } + + showSuccess(password_notify, successIcon); + } catch (error) { + showError(password_notify, error.message); + } + + if (password !== password_confirm && password_confirm !== '') { + showError(password_confirm_notify, '[[user:change_password_error_match]]'); + } + } + + function validatePasswordConfirm(password, password_confirm) { + const password_notify = $('#password-notify'); + const password_confirm_notify = $('#password-confirm-notify'); + + if (!password || password_notify.hasClass('alert-error')) { + return; + } + + if (password === password_confirm) { + showSuccess(password_confirm_notify, successIcon); + } else { + showError(password_confirm_notify, '[[user:change_password_error_match]]'); + } + } + + function showError(element, message) { + translator.translate(message, message_ => { + element.html(message_); + element.parent() + .removeClass('register-success') + .addClass('register-danger'); + element.show(); + }); + validationError = true; + } + + function showSuccess(element, message) { + translator.translate(message, message_ => { + element.html(message_); + element.parent() + .removeClass('register-danger') + .addClass('register-success'); + element.show(); + }); + } + + function handleLanguageOverride() { + if (!app.user.uid && config.defaultLang !== config.userLang) { + const formElement = $('[component="register/local"]'); + const langElement = $(''); + + formElement.append(langElement); + } + } + + return Register; }); diff --git a/public/src/client/reset.js b/public/src/client/reset.js index 16158ad..f2eacd3 100644 --- a/public/src/client/reset.js +++ b/public/src/client/reset.js @@ -1,32 +1,32 @@ 'use strict'; +define('forum/reset', ['alerts'], alerts => { + const ResetPassword = {}; -define('forum/reset', ['alerts'], function (alerts) { - const ResetPassword = {}; + ResetPassword.init = function () { + const inputElement = $('#email'); + const errorElement = $('#error'); + const successElement = $('#success'); - ResetPassword.init = function () { - const inputEl = $('#email'); - const errorEl = $('#error'); - const successEl = $('#success'); + $('#reset').on('click', () => { + if (inputElement.val() && inputElement.val().includes('@')) { + socket.emit('user.reset.send', inputElement.val(), error => { + if (error) { + return alerts.error(error); + } - $('#reset').on('click', function () { - if (inputEl.val() && inputEl.val().indexOf('@') !== -1) { - socket.emit('user.reset.send', inputEl.val(), function (err) { - if (err) { - return alerts.error(err); - } + errorElement.addClass('hide'); + successElement.removeClass('hide'); + inputElement.val(''); + }); + } else { + successElement.addClass('hide'); + errorElement.removeClass('hide'); + } - errorEl.addClass('hide'); - successEl.removeClass('hide'); - inputEl.val(''); - }); - } else { - successEl.addClass('hide'); - errorEl.removeClass('hide'); - } - return false; - }); - }; + return false; + }); + }; - return ResetPassword; + return ResetPassword; }); diff --git a/public/src/client/reset_code.js b/public/src/client/reset_code.js index 260204d..4d46eaa 100644 --- a/public/src/client/reset_code.js +++ b/public/src/client/reset_code.js @@ -1,44 +1,43 @@ 'use strict'; - -define('forum/reset_code', ['alerts', 'zxcvbn'], function (alerts, zxcvbn) { - const ResetCode = {}; - - ResetCode.init = function () { - const reset_code = ajaxify.data.code; - - const resetEl = $('#reset'); - const password = $('#password'); - const repeat = $('#repeat'); - - resetEl.on('click', function () { - try { - utils.assertPasswordValidity(password.val(), zxcvbn); - - if (password.val() !== repeat.val()) { - throw new Error('[[reset_password:passwords_do_not_match]]'); - } - - resetEl.prop('disabled', true).translateHtml(' [[reset_password:changing_password]]'); - socket.emit('user.reset.commit', { - code: reset_code, - password: password.val(), - }, function (err) { - if (err) { - ajaxify.refresh(); - return alerts.error(err); - } - - window.location.href = config.relative_path + '/login'; - }); - } catch (err) { - $('#notice').removeClass('hidden'); - $('#notice strong').translateText(err.message); - } - - return false; - }); - }; - - return ResetCode; +define('forum/reset_code', ['alerts', 'zxcvbn'], (alerts, zxcvbn) => { + const ResetCode = {}; + + ResetCode.init = function () { + const reset_code = ajaxify.data.code; + + const resetElement = $('#reset'); + const password = $('#password'); + const repeat = $('#repeat'); + + resetElement.on('click', () => { + try { + utils.assertPasswordValidity(password.val(), zxcvbn); + + if (password.val() !== repeat.val()) { + throw new Error('[[reset_password:passwords_do_not_match]]'); + } + + resetElement.prop('disabled', true).translateHtml(' [[reset_password:changing_password]]'); + socket.emit('user.reset.commit', { + code: reset_code, + password: password.val(), + }, error => { + if (error) { + ajaxify.refresh(); + return alerts.error(error); + } + + window.location.href = config.relative_path + '/login'; + }); + } catch (error) { + $('#notice').removeClass('hidden'); + $('#notice strong').translateText(error.message); + } + + return false; + }); + }; + + return ResetCode; }); diff --git a/public/src/client/search.js b/public/src/client/search.js index e1a3697..0be4496 100644 --- a/public/src/client/search.js +++ b/public/src/client/search.js @@ -1,189 +1,190 @@ 'use strict'; - define('forum/search', [ - 'search', - 'autocomplete', - 'storage', - 'hooks', - 'alerts', -], function (searchModule, autocomplete, storage, hooks, alerts) { - const Search = {}; - - Search.init = function () { - const searchQuery = $('#results').attr('data-search-query'); - - const searchIn = $('#search-in'); - - searchIn.on('change', function () { - updateFormItemVisiblity(searchIn.val()); - }); - - searchModule.highlightMatches(searchQuery, $('.search-result-text p, .search-result-text.search-result-title a')); - - $('#advanced-search').off('submit').on('submit', function (e) { - e.preventDefault(); - searchModule.query(getSearchDataFromDOM(), function () { - $('#search-input').val(''); - }); - return false; - }); - - handleSavePreferences(); - - enableAutoComplete(); - - fillOutForm(); - }; - - function getSearchDataFromDOM() { - const form = $('#advanced-search'); - const searchData = { - in: $('#search-in').val(), - }; - searchData.term = $('#search-input').val(); - if (searchData.in === 'posts' || searchData.in === 'titlesposts' || searchData.in === 'titles') { - searchData.matchWords = form.find('#match-words-filter').val(); - searchData.by = form.find('#posted-by-user').tagsinput('items'); - searchData.topicName = form.find('#topic-name').tagsinput('items'); - searchData.categories = form.find('#posted-in-categories').val(); - searchData.searchChildren = form.find('#search-children').is(':checked'); - searchData.hasTags = form.find('#has-tags').tagsinput('items'); - searchData.replies = form.find('#reply-count').val(); - searchData.repliesFilter = form.find('#reply-count-filter').val(); - searchData.timeFilter = form.find('#post-time-filter').val(); - searchData.timeRange = form.find('#post-time-range').val(); - searchData.sortBy = form.find('#post-sort-by').val(); - searchData.sortDirection = form.find('#post-sort-direction').val(); - searchData.showAs = form.find('#show-as-topics').is(':checked') ? 'topics' : 'posts'; - } - - hooks.fire('action:search.getSearchDataFromDOM', { - form: form, - data: searchData, - }); - - return searchData; - } - - function updateFormItemVisiblity(searchIn) { - const hide = searchIn.indexOf('posts') === -1 && searchIn.indexOf('titles') === -1; - $('.post-search-item').toggleClass('hide', hide); - } - - function fillOutForm() { - const params = utils.params({ - disableToType: true, - }); - - const searchData = searchModule.getSearchPreferences(); - const formData = utils.merge(searchData, params); - - if (formData) { - if (ajaxify.data.term) { - $('#search-input').val(ajaxify.data.term); - } - formData.in = formData.in || ajaxify.data.searchDefaultIn; - $('#search-in').val(formData.in); - updateFormItemVisiblity(formData.in); - - if (formData.matchWords) { - $('#match-words-filter').val(formData.matchWords); - } - - if (formData.by) { - formData.by = Array.isArray(formData.by) ? formData.by : [formData.by]; - formData.by.forEach(function (by) { - $('#posted-by-user').tagsinput('add', by); - }); - } - - if (formData.categories) { - $('#posted-in-categories').val(formData.categories); - } - - if (formData.searchChildren) { - $('#search-children').prop('checked', true); - } - - if (formData.hasTags) { - formData.hasTags = Array.isArray(formData.hasTags) ? formData.hasTags : [formData.hasTags]; - formData.hasTags.forEach(function (tag) { - $('#has-tags').tagsinput('add', tag); - }); - } - - if (formData.topicName) { - formData.topicName = Array.isArray(formData.topicName) ? formData.topicName : [formData.topicName]; - formData.topicName.forEach(function (topicName) { - $('#topic-name').tagsinput('add', topicName); - }); - } - - if (formData.replies) { - $('#reply-count').val(formData.replies); - $('#reply-count-filter').val(formData.repliesFilter); - } - - if (formData.timeRange) { - $('#post-time-range').val(formData.timeRange); - $('#post-time-filter').val(formData.timeFilter); - } - - if (formData.sortBy || ajaxify.data.searchDefaultSortBy) { - $('#post-sort-by').val(formData.sortBy || ajaxify.data.searchDefaultSortBy); - } - $('#post-sort-direction').val(formData.sortDirection || 'desc'); - - if (formData.showAs) { - const isTopic = formData.showAs === 'topics'; - const isPost = formData.showAs === 'posts'; - $('#show-as-topics').prop('checked', isTopic).parent().toggleClass('active', isTopic); - $('#show-as-posts').prop('checked', isPost).parent().toggleClass('active', isPost); - } - - hooks.fire('action:search.fillOutForm', { - form: formData, - }); - } - } - - function handleSavePreferences() { - $('#save-preferences').on('click', function () { - storage.setItem('search-preferences', JSON.stringify(getSearchDataFromDOM())); - alerts.success('[[search:search-preferences-saved]]'); - return false; - }); - - $('#clear-preferences').on('click', function () { - storage.removeItem('search-preferences'); - const query = $('#search-input').val(); - $('#advanced-search')[0].reset(); - $('#search-input').val(query); - alerts.success('[[search:search-preferences-cleared]]'); - return false; - }); - } - - function enableAutoComplete() { - const userEl = $('#posted-by-user'); - userEl.tagsinput({ - confirmKeys: [13, 44], - trimValue: true, - }); - if (app.user.privileges['search:users']) { - autocomplete.user(userEl.siblings('.bootstrap-tagsinput').find('input')); - } - - const tagEl = $('#has-tags'); - tagEl.tagsinput({ - confirmKeys: [13, 44], - trimValue: true, - }); - if (app.user.privileges['search:tags']) { - autocomplete.tag(tagEl.siblings('.bootstrap-tagsinput').find('input')); - } - } - - return Search; + 'search', + 'autocomplete', + 'storage', + 'hooks', + 'alerts', +], (searchModule, autocomplete, storage, hooks, alerts) => { + const Search = {}; + + Search.init = function () { + const searchQuery = $('#results').attr('data-search-query'); + + const searchIn = $('#search-in'); + + searchIn.on('change', () => { + updateFormItemVisiblity(searchIn.val()); + }); + + searchModule.highlightMatches(searchQuery, $('.search-result-text p, .search-result-text.search-result-title a')); + + $('#advanced-search').off('submit').on('submit', e => { + e.preventDefault(); + searchModule.query(getSearchDataFromDOM(), () => { + $('#search-input').val(''); + }); + return false; + }); + + handleSavePreferences(); + + enableAutoComplete(); + + fillOutForm(); + }; + + function getSearchDataFromDOM() { + const form = $('#advanced-search'); + const searchData = { + in: $('#search-in').val(), + }; + searchData.term = $('#search-input').val(); + if (searchData.in === 'posts' || searchData.in === 'titlesposts' || searchData.in === 'titles') { + searchData.matchWords = form.find('#match-words-filter').val(); + searchData.by = form.find('#posted-by-user').tagsinput('items'); + searchData.topicName = form.find('#topic-name').tagsinput('items'); + searchData.categories = form.find('#posted-in-categories').val(); + searchData.searchChildren = form.find('#search-children').is(':checked'); + searchData.hasTags = form.find('#has-tags').tagsinput('items'); + searchData.replies = form.find('#reply-count').val(); + searchData.repliesFilter = form.find('#reply-count-filter').val(); + searchData.timeFilter = form.find('#post-time-filter').val(); + searchData.timeRange = form.find('#post-time-range').val(); + searchData.sortBy = form.find('#post-sort-by').val(); + searchData.sortDirection = form.find('#post-sort-direction').val(); + searchData.showAs = form.find('#show-as-topics').is(':checked') ? 'topics' : 'posts'; + } + + hooks.fire('action:search.getSearchDataFromDOM', { + form, + data: searchData, + }); + + return searchData; + } + + function updateFormItemVisiblity(searchIn) { + const hide = !searchIn.includes('posts') && !searchIn.includes('titles'); + $('.post-search-item').toggleClass('hide', hide); + } + + function fillOutForm() { + const parameters = utils.params({ + disableToType: true, + }); + + const searchData = searchModule.getSearchPreferences(); + const formData = utils.merge(searchData, parameters); + + if (formData) { + if (ajaxify.data.term) { + $('#search-input').val(ajaxify.data.term); + } + + formData.in = formData.in || ajaxify.data.searchDefaultIn; + $('#search-in').val(formData.in); + updateFormItemVisiblity(formData.in); + + if (formData.matchWords) { + $('#match-words-filter').val(formData.matchWords); + } + + if (formData.by) { + formData.by = Array.isArray(formData.by) ? formData.by : [formData.by]; + for (const by of formData.by) { + $('#posted-by-user').tagsinput('add', by); + } + } + + if (formData.categories) { + $('#posted-in-categories').val(formData.categories); + } + + if (formData.searchChildren) { + $('#search-children').prop('checked', true); + } + + if (formData.hasTags) { + formData.hasTags = Array.isArray(formData.hasTags) ? formData.hasTags : [formData.hasTags]; + for (const tag of formData.hasTags) { + $('#has-tags').tagsinput('add', tag); + } + } + + if (formData.topicName) { + formData.topicName = Array.isArray(formData.topicName) ? formData.topicName : [formData.topicName]; + for (const topicName of formData.topicName) { + $('#topic-name').tagsinput('add', topicName); + } + } + + if (formData.replies) { + $('#reply-count').val(formData.replies); + $('#reply-count-filter').val(formData.repliesFilter); + } + + if (formData.timeRange) { + $('#post-time-range').val(formData.timeRange); + $('#post-time-filter').val(formData.timeFilter); + } + + if (formData.sortBy || ajaxify.data.searchDefaultSortBy) { + $('#post-sort-by').val(formData.sortBy || ajaxify.data.searchDefaultSortBy); + } + + $('#post-sort-direction').val(formData.sortDirection || 'desc'); + + if (formData.showAs) { + const isTopic = formData.showAs === 'topics'; + const isPost = formData.showAs === 'posts'; + $('#show-as-topics').prop('checked', isTopic).parent().toggleClass('active', isTopic); + $('#show-as-posts').prop('checked', isPost).parent().toggleClass('active', isPost); + } + + hooks.fire('action:search.fillOutForm', { + form: formData, + }); + } + } + + function handleSavePreferences() { + $('#save-preferences').on('click', () => { + storage.setItem('search-preferences', JSON.stringify(getSearchDataFromDOM())); + alerts.success('[[search:search-preferences-saved]]'); + return false; + }); + + $('#clear-preferences').on('click', () => { + storage.removeItem('search-preferences'); + const query = $('#search-input').val(); + $('#advanced-search')[0].reset(); + $('#search-input').val(query); + alerts.success('[[search:search-preferences-cleared]]'); + return false; + }); + } + + function enableAutoComplete() { + const userElement = $('#posted-by-user'); + userElement.tagsinput({ + confirmKeys: [13, 44], + trimValue: true, + }); + if (app.user.privileges['search:users']) { + autocomplete.user(userElement.siblings('.bootstrap-tagsinput').find('input')); + } + + const tagElement = $('#has-tags'); + tagElement.tagsinput({ + confirmKeys: [13, 44], + trimValue: true, + }); + if (app.user.privileges['search:tags']) { + autocomplete.tag(tagElement.siblings('.bootstrap-tagsinput').find('input')); + } + } + + return Search; }); diff --git a/public/src/client/tag.js b/public/src/client/tag.js index 81be8f8..86f21e6 100644 --- a/public/src/client/tag.js +++ b/public/src/client/tag.js @@ -1,13 +1,13 @@ 'use strict'; -define('forum/tag', ['topicList', 'forum/infinitescroll'], function (topicList) { - const Tag = {}; +define('forum/tag', ['topicList', 'forum/infinitescroll'], topicList => { + const Tag = {}; - Tag.init = function () { - app.enterRoom('tags'); + Tag.init = function () { + app.enterRoom('tags'); - topicList.init('tag'); - }; + topicList.init('tag'); + }; - return Tag; + return Tag; }); diff --git a/public/src/client/tags.js b/public/src/client/tags.js index 79e253f..d969471 100644 --- a/public/src/client/tags.js +++ b/public/src/client/tags.js @@ -1,64 +1,65 @@ 'use strict'; +define('forum/tags', ['forum/infinitescroll', 'alerts'], (infinitescroll, alerts) => { + const Tags = {}; -define('forum/tags', ['forum/infinitescroll', 'alerts'], function (infinitescroll, alerts) { - const Tags = {}; + Tags.init = function () { + app.enterRoom('tags'); + $('#tag-search').focus(); + $('#tag-search').on('input propertychange', utils.debounce(() => { + if ($('#tag-search').val().length === 0) { + return resetSearch(); + } - Tags.init = function () { - app.enterRoom('tags'); - $('#tag-search').focus(); - $('#tag-search').on('input propertychange', utils.debounce(function () { - if (!$('#tag-search').val().length) { - return resetSearch(); - } + socket.emit('topics.searchAndLoadTags', {query: $('#tag-search').val()}, (error, results) => { + if (error) { + return alerts.error(error); + } - socket.emit('topics.searchAndLoadTags', { query: $('#tag-search').val() }, function (err, results) { - if (err) { - return alerts.error(err); - } - onTagsLoaded(results.tags, true); - }); - }, 250)); + onTagsLoaded(results.tags, true); + }); + }, 250)); - infinitescroll.init(Tags.loadMoreTags); - }; + infinitescroll.init(Tags.loadMoreTags); + }; - Tags.loadMoreTags = function (direction) { - if (direction < 0 || !$('.tag-list').length || $('#tag-search').val()) { - return; - } + Tags.loadMoreTags = function (direction) { + if (direction < 0 || $('.tag-list').length === 0 || $('#tag-search').val()) { + return; + } - infinitescroll.loadMore('topics.loadMoreTags', { - after: $('.tag-list').attr('data-nextstart'), - }, function (data, done) { - if (data && data.tags && data.tags.length) { - onTagsLoaded(data.tags, false, done); - $('.tag-list').attr('data-nextstart', data.nextStart); - } else { - done(); - } - }); - }; + infinitescroll.loadMore('topics.loadMoreTags', { + after: $('.tag-list').attr('data-nextstart'), + }, (data, done) => { + if (data && data.tags && data.tags.length > 0) { + onTagsLoaded(data.tags, false, done); + $('.tag-list').attr('data-nextstart', data.nextStart); + } else { + done(); + } + }); + }; - function resetSearch() { - socket.emit('topics.loadMoreTags', { - after: 0, - }, function (err, data) { - if (err) { - return alerts.error(err); - } - onTagsLoaded(data.tags, true); - }); - } + function resetSearch() { + socket.emit('topics.loadMoreTags', { + after: 0, + }, (error, data) => { + if (error) { + return alerts.error(error); + } - function onTagsLoaded(tags, replace, callback) { - callback = callback || function () {}; - app.parseAndTranslate('tags', 'tags', { tags: tags }, function (html) { - $('.tag-list')[replace ? 'html' : 'append'](html); - utils.makeNumbersHumanReadable(html.find('.human-readable-number')); - callback(); - }); - } + onTagsLoaded(data.tags, true); + }); + } - return Tags; + function onTagsLoaded(tags, replace, callback) { + callback ||= function () {}; + app.parseAndTranslate('tags', 'tags', {tags}, html => { + $('.tag-list')[replace ? 'html' : 'append'](html); + utils.makeNumbersHumanReadable(html.find('.human-readable-number')); + callback(); + }); + } + + return Tags; }); diff --git a/public/src/client/top.js b/public/src/client/top.js index 46810ef..9de0ac3 100644 --- a/public/src/client/top.js +++ b/public/src/client/top.js @@ -1,13 +1,13 @@ 'use strict'; -define('forum/top', ['topicList'], function (topicList) { - const Top = {}; +define('forum/top', ['topicList'], topicList => { + const Top = {}; - Top.init = function () { - app.enterRoom('top_topics'); + Top.init = function () { + app.enterRoom('top_topics'); - topicList.init('top'); - }; + topicList.init('top'); + }; - return Top; + return Top; }); diff --git a/public/src/client/topic.js b/public/src/client/topic.js index 969b3da..ed68d18 100644 --- a/public/src/client/topic.js +++ b/public/src/client/topic.js @@ -1,364 +1,368 @@ 'use strict'; - define('forum/topic', [ - 'forum/infinitescroll', - 'forum/topic/threadTools', - 'forum/topic/postTools', - 'forum/topic/events', - 'forum/topic/posts', - 'navigator', - 'sort', - 'components', - 'storage', - 'hooks', - 'api', - 'alerts', -], function ( - infinitescroll, threadTools, postTools, - events, posts, navigator, sort, - components, storage, hooks, api, alerts -) { - const Topic = {}; - let tid = 0; - let currentUrl = ''; - - $(window).on('action:ajaxify.start', function (ev, data) { - events.removeListeners(); - - if (!String(data.url).startsWith('topic/')) { - navigator.disable(); - components.get('navbar/title').find('span').text('').hide(); - alerts.remove('bookmark'); - } - }); - - Topic.init = function () { - const tidChanged = !tid || parseInt(tid, 10) !== parseInt(ajaxify.data.tid, 10); - tid = ajaxify.data.tid; - currentUrl = ajaxify.currentPage; - hooks.fire('action:topic.loading'); - - app.enterRoom('topic_' + tid); - - if (tidChanged) { - posts.signaturesShown = {}; - } - posts.onTopicPageLoad(components.get('post')); - navigator.init('[component="post"]', ajaxify.data.postcount, Topic.toTop, Topic.toBottom, utils.debounce(Topic.navigatorCallback, 500)); - - postTools.init(tid); - threadTools.init(tid, $('.topic')); - events.init(); - - sort.handleSort('topicPostSort', 'topic/' + ajaxify.data.slug); - - if (!config.usePagination) { - infinitescroll.init($('[component="topic"]'), posts.loadMorePosts); - } - - addBlockQuoteHandler(); - addParentHandler(); - addDropupHandler(); - addRepliesHandler(); - addPostsPreviewHandler(); - - handleBookmark(tid); - - $(window).on('scroll', utils.debounce(updateTopicTitle, 250)); - - handleTopicSearch(); - - hooks.fire('action:topic.loaded', ajaxify.data); - }; - - function handleTopicSearch() { - require(['mousetrap'], (mousetrap) => { - if (config.topicSearchEnabled) { - require(['search'], function (search) { - mousetrap.bind(['command+f', 'ctrl+f'], function (e) { - e.preventDefault(); - $('#search-fields input').val('in:topic-' + ajaxify.data.tid + ' '); - search.showAndFocusInput(); - }); - - hooks.onPage('action:ajaxify.cleanup', () => { - mousetrap.unbind(['command+f', 'ctrl+f']); - }); - }); - } - - mousetrap.bind('j', () => { - const index = navigator.getIndex(); - const count = navigator.getCount(); - if (index === count) { - return; - } - - navigator.scrollToIndex(index, true, 0); - }); - - mousetrap.bind('k', () => { - const index = navigator.getIndex(); - if (index === 1) { - return; - } - navigator.scrollToIndex(index - 2, true, 0); - }); - }); - } - - Topic.toTop = function () { - navigator.scrollTop(0); - }; - - Topic.toBottom = function () { - socket.emit('topics.postcount', ajaxify.data.tid, function (err, postCount) { - if (err) { - return alerts.error(err); - } - - navigator.scrollBottom(postCount - 1); - }); - }; - - function handleBookmark(tid) { - if (window.location.hash) { - const el = $(utils.escapeHTML(window.location.hash)); - if (el.length) { - return navigator.scrollToElement(el, true, 0); - } - } - const bookmark = ajaxify.data.bookmark || storage.getItem('topic:' + tid + ':bookmark'); - const postIndex = ajaxify.data.postIndex; - - if (postIndex > 1) { - if (components.get('post/anchor', postIndex - 1).length) { - return navigator.scrollToPostIndex(postIndex - 1, true, 0); - } - } else if (bookmark && ( - !config.usePagination || - (config.usePagination && ajaxify.data.pagination.currentPage === 1) - ) && ajaxify.data.postcount > ajaxify.data.bookmarkThreshold) { - alerts.alert({ - alert_id: 'bookmark', - message: '[[topic:bookmark_instructions]]', - timeout: 0, - type: 'info', - clickfn: function () { - navigator.scrollToIndex(parseInt(bookmark, 10), true); - }, - closefn: function () { - storage.removeItem('topic:' + tid + ':bookmark'); - }, - }); - setTimeout(function () { - alerts.remove('bookmark'); - }, 10000); - } - } - - function addBlockQuoteHandler() { - components.get('topic').on('click', 'blockquote .toggle', function () { - const blockQuote = $(this).parent('blockquote'); - const toggle = $(this); - blockQuote.toggleClass('uncollapsed'); - const collapsed = !blockQuote.hasClass('uncollapsed'); - toggle.toggleClass('fa-angle-down', collapsed).toggleClass('fa-angle-up', !collapsed); - }); - } - - function addParentHandler() { - components.get('topic').on('click', '[component="post/parent"]', function (e) { - const toPid = $(this).attr('data-topid'); - - const toPost = $('[component="topic"]>[component="post"][data-pid="' + toPid + '"]'); - if (toPost.length) { - e.preventDefault(); - navigator.scrollToIndex(toPost.attr('data-index'), true); - return false; - } - }); - } - - Topic.applyDropup = function () { - const containerRect = this.getBoundingClientRect(); - const dropdownEl = this.querySelector('.dropdown-menu'); - const dropdownStyle = window.getComputedStyle(dropdownEl); - const dropdownHeight = dropdownStyle.getPropertyValue('height').slice(0, -2); - const offset = 60; - - // Toggler position (including its height, since the menu spawns above it), - // minus the dropdown's height and navbar offset - const dropUp = (containerRect.top + containerRect.height - dropdownHeight - offset) > 0; - this.classList.toggle('dropup', dropUp); - }; - - function addDropupHandler() { - // Locate all dropdowns - const target = $('#content .dropdown-menu').parent(); - $(target).on('shown.bs.dropdown', function () { - const dropdownEl = this.querySelector('.dropdown-menu'); - if (dropdownEl.innerHTML) { - Topic.applyDropup.call(this); - } - }); - hooks.onPage('action:topic.tools.load', ({ element }) => { - Topic.applyDropup.call(element.get(0).parentNode); - }); - hooks.onPage('action:post.tools.load', ({ element }) => { - Topic.applyDropup.call(element.get(0).parentNode); - }); - } - - function addRepliesHandler() { - $('[component="topic"]').on('click', '[component="post/reply-count"]', function () { - const btn = $(this); - require(['forum/topic/replies'], function (replies) { - replies.init(btn); - }); - }); - } - - function addPostsPreviewHandler() { - if (!ajaxify.data.showPostPreviewsOnHover || utils.isMobile()) { - return; - } - let timeoutId = 0; - const postCache = {}; - $(window).one('action:ajaxify.start', function () { - clearTimeout(timeoutId); - $('#post-tooltip').remove(); - }); - $('[component="topic"]').on('mouseenter', '[component="post"] a, [component="topic/event"] a', async function () { - const link = $(this); - - async function renderPost(pid) { - const postData = postCache[pid] || await socket.emit('posts.getPostSummaryByPid', { pid: pid }); - $('#post-tooltip').remove(); - if (postData && ajaxify.data.template.topic) { - postCache[pid] = postData; - const tooltip = await app.parseAndTranslate('partials/topic/post-preview', { post: postData }); - tooltip.hide().find('.timeago').timeago(); - tooltip.appendTo($('body')).fadeIn(300); - const postContent = link.parents('[component="topic"]').find('[component="post/content"]').first(); - const postRect = postContent.offset(); - const postWidth = postContent.width(); - const linkRect = link.offset(); - tooltip.css({ - top: linkRect.top + 30, - left: postRect.left, - width: postWidth, - }); - } - } - - const href = link.attr('href'); - const location = utils.urlToLocation(href); - const pathname = location.pathname; - const validHref = href && href !== '#' && window.location.hostname === location.hostname; - $('#post-tooltip').remove(); - const postMatch = validHref && pathname && pathname.match(/\/post\/([\d]+)/); - const topicMatch = validHref && pathname && pathname.match(/\/topic\/([\d]+)/); - if (postMatch) { - const pid = postMatch[1]; - if (parseInt(link.parents('[component="post"]').attr('data-pid'), 10) === parseInt(pid, 10)) { - return; // dont render self post - } - - timeoutId = setTimeout(async () => { - renderPost(pid); - }, 300); - } else if (topicMatch) { - timeoutId = setTimeout(async () => { - const tid = topicMatch[1]; - const topicData = await api.get('/topics/' + tid, {}); - renderPost(topicData.mainPid); - }, 300); - } - }).on('mouseleave', '[component="post"] a, [component="topic/event"] a', function () { - clearTimeout(timeoutId); - $('#post-tooltip').remove(); - }); - } - - function updateTopicTitle() { - const span = components.get('navbar/title').find('span'); - if ($(window).scrollTop() > 50 && span.hasClass('hidden')) { - span.html(ajaxify.data.title).removeClass('hidden'); - } else if ($(window).scrollTop() <= 50 && !span.hasClass('hidden')) { - span.html('').addClass('hidden'); - } - if ($(window).scrollTop() > 300) { - alerts.remove('bookmark'); - } - } - - Topic.navigatorCallback = function (index, elementCount) { - if (!ajaxify.data.template.topic || navigator.scrollActive) { - return; - } - - const newUrl = 'topic/' + ajaxify.data.slug + (index > 1 ? ('/' + index) : ''); - if (newUrl !== currentUrl) { - currentUrl = newUrl; - - if (index >= elementCount && app.user.uid) { - socket.emit('topics.markAsRead', [ajaxify.data.tid]); - } - - updateUserBookmark(index); - - Topic.replaceURLTimeout = 0; - if (ajaxify.data.updateUrlWithPostIndex && history.replaceState) { - let search = window.location.search || ''; - if (!config.usePagination) { - search = (search && !/^\?page=\d+$/.test(search) ? search : ''); - } - - history.replaceState({ - url: newUrl + search, - }, null, window.location.protocol + '//' + window.location.host + config.relative_path + '/' + newUrl + search); - } - } - }; - - function updateUserBookmark(index) { - const bookmarkKey = 'topic:' + ajaxify.data.tid + ':bookmark'; - const currentBookmark = ajaxify.data.bookmark || storage.getItem(bookmarkKey); - if (config.topicPostSort === 'newest_to_oldest') { - index = Math.max(1, ajaxify.data.postcount - index + 2); - } - - if ( - ajaxify.data.postcount > ajaxify.data.bookmarkThreshold && - ( - !currentBookmark || - parseInt(index, 10) > parseInt(currentBookmark, 10) || - ajaxify.data.postcount < parseInt(currentBookmark, 10) + 'forum/infinitescroll', + 'forum/topic/threadTools', + 'forum/topic/postTools', + 'forum/topic/events', + 'forum/topic/posts', + 'navigator', + 'sort', + 'components', + 'storage', + 'hooks', + 'api', + 'alerts', +], ( + infinitescroll, threadTools, postTools, + events, posts, navigator, sort, + components, storage, hooks, api, alerts, +) => { + const Topic = {}; + let tid = 0; + let currentUrl = ''; + + $(window).on('action:ajaxify.start', (event, data) => { + events.removeListeners(); + + if (!String(data.url).startsWith('topic/')) { + navigator.disable(); + components.get('navbar/title').find('span').text('').hide(); + alerts.remove('bookmark'); + } + }); + + Topic.init = function () { + const tidChanged = !tid || Number.parseInt(tid, 10) !== Number.parseInt(ajaxify.data.tid, 10); + tid = ajaxify.data.tid; + currentUrl = ajaxify.currentPage; + hooks.fire('action:topic.loading'); + + app.enterRoom('topic_' + tid); + + if (tidChanged) { + posts.signaturesShown = {}; + } + + posts.onTopicPageLoad(components.get('post')); + navigator.init('[component="post"]', ajaxify.data.postcount, Topic.toTop, Topic.toBottom, utils.debounce(Topic.navigatorCallback, 500)); + + postTools.init(tid); + threadTools.init(tid, $('.topic')); + events.init(); + + sort.handleSort('topicPostSort', 'topic/' + ajaxify.data.slug); + + if (!config.usePagination) { + infinitescroll.init($('[component="topic"]'), posts.loadMorePosts); + } + + addBlockQuoteHandler(); + addParentHandler(); + addDropupHandler(); + addRepliesHandler(); + addPostsPreviewHandler(); + + handleBookmark(tid); + + $(window).on('scroll', utils.debounce(updateTopicTitle, 250)); + + handleTopicSearch(); + + hooks.fire('action:topic.loaded', ajaxify.data); + }; + + function handleTopicSearch() { + require(['mousetrap'], mousetrap => { + if (config.topicSearchEnabled) { + require(['search'], search => { + mousetrap.bind(['command+f', 'ctrl+f'], e => { + e.preventDefault(); + $('#search-fields input').val('in:topic-' + ajaxify.data.tid + ' '); + search.showAndFocusInput(); + }); + + hooks.onPage('action:ajaxify.cleanup', () => { + mousetrap.unbind(['command+f', 'ctrl+f']); + }); + }); + } + + mousetrap.bind('j', () => { + const index = navigator.getIndex(); + const count = navigator.getCount(); + if (index === count) { + return; + } + + navigator.scrollToIndex(index, true, 0); + }); + + mousetrap.bind('k', () => { + const index = navigator.getIndex(); + if (index === 1) { + return; + } + + navigator.scrollToIndex(index - 2, true, 0); + }); + }); + } + + Topic.toTop = function () { + navigator.scrollTop(0); + }; + + Topic.toBottom = function () { + socket.emit('topics.postcount', ajaxify.data.tid, (error, postCount) => { + if (error) { + return alerts.error(error); + } + + navigator.scrollBottom(postCount - 1); + }); + }; + + function handleBookmark(tid) { + if (window.location.hash) { + const element = $(utils.escapeHTML(window.location.hash)); + if (element.length > 0) { + return navigator.scrollToElement(element, true, 0); + } + } + + const bookmark = ajaxify.data.bookmark || storage.getItem('topic:' + tid + ':bookmark'); + const postIndex = ajaxify.data.postIndex; + + if (postIndex > 1) { + if (components.get('post/anchor', postIndex - 1).length > 0) { + return navigator.scrollToPostIndex(postIndex - 1, true, 0); + } + } else if (bookmark && ( + !config.usePagination + || (config.usePagination && ajaxify.data.pagination.currentPage === 1) + ) && ajaxify.data.postcount > ajaxify.data.bookmarkThreshold) { + alerts.alert({ + alert_id: 'bookmark', + message: '[[topic:bookmark_instructions]]', + timeout: 0, + type: 'info', + clickfn() { + navigator.scrollToIndex(Number.parseInt(bookmark, 10), true); + }, + closefn() { + storage.removeItem('topic:' + tid + ':bookmark'); + }, + }); + setTimeout(() => { + alerts.remove('bookmark'); + }, 10_000); + } + } + + function addBlockQuoteHandler() { + components.get('topic').on('click', 'blockquote .toggle', function () { + const blockQuote = $(this).parent('blockquote'); + const toggle = $(this); + blockQuote.toggleClass('uncollapsed'); + const collapsed = !blockQuote.hasClass('uncollapsed'); + toggle.toggleClass('fa-angle-down', collapsed).toggleClass('fa-angle-up', !collapsed); + }); + } + + function addParentHandler() { + components.get('topic').on('click', '[component="post/parent"]', function (e) { + const toPid = $(this).attr('data-topid'); + + const toPost = $('[component="topic"]>[component="post"][data-pid="' + toPid + '"]'); + if (toPost.length > 0) { + e.preventDefault(); + navigator.scrollToIndex(toPost.attr('data-index'), true); + return false; + } + }); + } + + Topic.applyDropup = function () { + const containerRect = this.getBoundingClientRect(); + const dropdownElement = this.querySelector('.dropdown-menu'); + const dropdownStyle = window.getComputedStyle(dropdownElement); + const dropdownHeight = dropdownStyle.getPropertyValue('height').slice(0, -2); + const offset = 60; + + // Toggler position (including its height, since the menu spawns above it), + // minus the dropdown's height and navbar offset + const dropUp = (containerRect.top + containerRect.height - dropdownHeight - offset) > 0; + this.classList.toggle('dropup', dropUp); + }; + + function addDropupHandler() { + // Locate all dropdowns + const target = $('#content .dropdown-menu').parent(); + $(target).on('shown.bs.dropdown', function () { + const dropdownElement = this.querySelector('.dropdown-menu'); + if (dropdownElement.innerHTML) { + Topic.applyDropup.call(this); + } + }); + hooks.onPage('action:topic.tools.load', ({element}) => { + Topic.applyDropup.call(element.get(0).parentNode); + }); + hooks.onPage('action:post.tools.load', ({element}) => { + Topic.applyDropup.call(element.get(0).parentNode); + }); + } + + function addRepliesHandler() { + $('[component="topic"]').on('click', '[component="post/reply-count"]', function () { + const button = $(this); + require(['forum/topic/replies'], replies => { + replies.init(button); + }); + }); + } + + function addPostsPreviewHandler() { + if (!ajaxify.data.showPostPreviewsOnHover || utils.isMobile()) { + return; + } + + let timeoutId = 0; + const postCache = {}; + $(window).one('action:ajaxify.start', () => { + clearTimeout(timeoutId); + $('#post-tooltip').remove(); + }); + $('[component="topic"]').on('mouseenter', '[component="post"] a, [component="topic/event"] a', async function () { + const link = $(this); + + async function renderPost(pid) { + const postData = postCache[pid] || await socket.emit('posts.getPostSummaryByPid', {pid}); + $('#post-tooltip').remove(); + if (postData && ajaxify.data.template.topic) { + postCache[pid] = postData; + const tooltip = await app.parseAndTranslate('partials/topic/post-preview', {post: postData}); + tooltip.hide().find('.timeago').timeago(); + tooltip.appendTo($('body')).fadeIn(300); + const postContent = link.parents('[component="topic"]').find('[component="post/content"]').first(); + const postRect = postContent.offset(); + const postWidth = postContent.width(); + const linkRect = link.offset(); + tooltip.css({ + top: linkRect.top + 30, + left: postRect.left, + width: postWidth, + }); + } + } + + const href = link.attr('href'); + const location = utils.urlToLocation(href); + const pathname = location.pathname; + const validHref = href && href !== '#' && window.location.hostname === location.hostname; + $('#post-tooltip').remove(); + const postMatch = validHref && pathname && pathname.match(/\/post\/(\d+)/); + const topicMatch = validHref && pathname && pathname.match(/\/topic\/(\d+)/); + if (postMatch) { + const pid = postMatch[1]; + if (Number.parseInt(link.parents('[component="post"]').attr('data-pid'), 10) === Number.parseInt(pid, 10)) { + return; // Dont render self post + } + + timeoutId = setTimeout(async () => { + renderPost(pid); + }, 300); + } else if (topicMatch) { + timeoutId = setTimeout(async () => { + const tid = topicMatch[1]; + const topicData = await api.get('/topics/' + tid, {}); + renderPost(topicData.mainPid); + }, 300); + } + }).on('mouseleave', '[component="post"] a, [component="topic/event"] a', () => { + clearTimeout(timeoutId); + $('#post-tooltip').remove(); + }); + } + + function updateTopicTitle() { + const span = components.get('navbar/title').find('span'); + if ($(window).scrollTop() > 50 && span.hasClass('hidden')) { + span.html(ajaxify.data.title).removeClass('hidden'); + } else if ($(window).scrollTop() <= 50 && !span.hasClass('hidden')) { + span.html('').addClass('hidden'); + } + + if ($(window).scrollTop() > 300) { + alerts.remove('bookmark'); + } + } + + Topic.navigatorCallback = function (index, elementCount) { + if (!ajaxify.data.template.topic || navigator.scrollActive) { + return; + } + + const newUrl = 'topic/' + ajaxify.data.slug + (index > 1 ? ('/' + index) : ''); + if (newUrl !== currentUrl) { + currentUrl = newUrl; + + if (index >= elementCount && app.user.uid) { + socket.emit('topics.markAsRead', [ajaxify.data.tid]); + } + + updateUserBookmark(index); + + Topic.replaceURLTimeout = 0; + if (ajaxify.data.updateUrlWithPostIndex && history.replaceState) { + let search = window.location.search || ''; + if (!config.usePagination) { + search = (search && !/^\?page=\d+$/.test(search) ? search : ''); + } + + history.replaceState({ + url: newUrl + search, + }, null, window.location.protocol + '//' + window.location.host + config.relative_path + '/' + newUrl + search); + } + } + }; + + function updateUserBookmark(index) { + const bookmarkKey = 'topic:' + ajaxify.data.tid + ':bookmark'; + const currentBookmark = ajaxify.data.bookmark || storage.getItem(bookmarkKey); + if (config.topicPostSort === 'newest_to_oldest') { + index = Math.max(1, ajaxify.data.postcount - index + 2); + } + + if ( + ajaxify.data.postcount > ajaxify.data.bookmarkThreshold + && ( + !currentBookmark + || Number.parseInt(index, 10) > Number.parseInt(currentBookmark, 10) + || ajaxify.data.postcount < Number.parseInt(currentBookmark, 10) ) - ) { - if (app.user.uid) { - socket.emit('topics.bookmark', { - tid: ajaxify.data.tid, - index: index, - }, function (err) { - if (err) { - return alerts.error(err); - } - ajaxify.data.bookmark = index + 1; - }); - } else { - storage.setItem(bookmarkKey, index); - } - } - - // removes the bookmark alert when we get to / past the bookmark - if (!currentBookmark || parseInt(index, 10) >= parseInt(currentBookmark, 10)) { - alerts.remove('bookmark'); - } - } - - - return Topic; + ) { + if (app.user.uid) { + socket.emit('topics.bookmark', { + tid: ajaxify.data.tid, + index, + }, error => { + if (error) { + return alerts.error(error); + } + + ajaxify.data.bookmark = index + 1; + }); + } else { + storage.setItem(bookmarkKey, index); + } + } + + // Removes the bookmark alert when we get to / past the bookmark + if (!currentBookmark || Number.parseInt(index, 10) >= Number.parseInt(currentBookmark, 10)) { + alerts.remove('bookmark'); + } + } + + return Topic; }); diff --git a/public/src/client/topic/change-owner.js b/public/src/client/topic/change-owner.js index 65f8783..f0dce6f 100644 --- a/public/src/client/topic/change-owner.js +++ b/public/src/client/topic/change-owner.js @@ -1,91 +1,93 @@ 'use strict'; - define('forum/topic/change-owner', [ - 'postSelect', - 'autocomplete', - 'alerts', -], function (postSelect, autocomplete, alerts) { - const ChangeOwner = {}; - - let modal; - let commit; - let toUid = 0; - ChangeOwner.init = function (postEl) { - if (modal) { - return; - } - app.parseAndTranslate('partials/change_owner_modal', {}, function (html) { - modal = html; - - commit = modal.find('#change_owner_commit'); - - $('body').append(modal); - - modal.find('.close,#change_owner_cancel').on('click', closeModal); - modal.find('#username').on('keyup', checkButtonEnable); - postSelect.init(onPostToggled, { - allowMainPostSelect: true, - }); - showPostsSelected(); - - if (postEl) { - postSelect.togglePostSelection(postEl, postEl.attr('data-pid')); - } - - commit.on('click', function () { - changeOwner(); - }); - - autocomplete.user(modal.find('#username'), { filters: ['notbanned'] }, function (ev, ui) { - toUid = ui.item.user.uid; - checkButtonEnable(); - }); - }); - }; - - function showPostsSelected() { - if (postSelect.pids.length) { - modal.find('#pids').translateHtml('[[topic:fork_pid_count, ' + postSelect.pids.length + ']]'); - } else { - modal.find('#pids').translateHtml('[[topic:fork_no_pids]]'); - } - } - - function checkButtonEnable() { - if (toUid && modal.find('#username').length && modal.find('#username').val().length && postSelect.pids.length) { - commit.removeAttr('disabled'); - } else { - commit.attr('disabled', true); - } - } - - function onPostToggled() { - checkButtonEnable(); - showPostsSelected(); - } - - function changeOwner() { - if (!toUid) { - return; - } - socket.emit('posts.changeOwner', { pids: postSelect.pids, toUid: toUid }, function (err) { - if (err) { - return alerts.error(err); - } - ajaxify.refresh(); - - closeModal(); - }); - } - - function closeModal() { - if (modal) { - modal.remove(); - modal = null; - postSelect.disable(); - } - } - - return ChangeOwner; + 'postSelect', + 'autocomplete', + 'alerts', +], (postSelect, autocomplete, alerts) => { + const ChangeOwner = {}; + + let modal; + let commit; + let toUid = 0; + ChangeOwner.init = function (postElement) { + if (modal) { + return; + } + + app.parseAndTranslate('partials/change_owner_modal', {}, html => { + modal = html; + + commit = modal.find('#change_owner_commit'); + + $('body').append(modal); + + modal.find('.close,#change_owner_cancel').on('click', closeModal); + modal.find('#username').on('keyup', checkButtonEnable); + postSelect.init(onPostToggled, { + allowMainPostSelect: true, + }); + showPostsSelected(); + + if (postElement) { + postSelect.togglePostSelection(postElement, postElement.attr('data-pid')); + } + + commit.on('click', () => { + changeOwner(); + }); + + autocomplete.user(modal.find('#username'), {filters: ['notbanned']}, (event, ui) => { + toUid = ui.item.user.uid; + checkButtonEnable(); + }); + }); + }; + + function showPostsSelected() { + if (postSelect.pids.length > 0) { + modal.find('#pids').translateHtml('[[topic:fork_pid_count, ' + postSelect.pids.length + ']]'); + } else { + modal.find('#pids').translateHtml('[[topic:fork_no_pids]]'); + } + } + + function checkButtonEnable() { + if (toUid && modal.find('#username').length > 0 && modal.find('#username').val().length > 0 && postSelect.pids.length > 0) { + commit.removeAttr('disabled'); + } else { + commit.attr('disabled', true); + } + } + + function onPostToggled() { + checkButtonEnable(); + showPostsSelected(); + } + + function changeOwner() { + if (!toUid) { + return; + } + + socket.emit('posts.changeOwner', {pids: postSelect.pids, toUid}, error => { + if (error) { + return alerts.error(error); + } + + ajaxify.refresh(); + + closeModal(); + }); + } + + function closeModal() { + if (modal) { + modal.remove(); + modal = null; + postSelect.disable(); + } + } + + return ChangeOwner; }); diff --git a/public/src/client/topic/delete-posts.js b/public/src/client/topic/delete-posts.js index bb01220..381b654 100644 --- a/public/src/client/topic/delete-posts.js +++ b/public/src/client/topic/delete-posts.js @@ -1,90 +1,90 @@ 'use strict'; define('forum/topic/delete-posts', [ - 'postSelect', 'alerts', 'api', -], function (postSelect, alerts, api) { - const DeletePosts = {}; - let modal; - let deleteBtn; - let purgeBtn; - let tid; - - DeletePosts.init = function () { - tid = ajaxify.data.tid; - - $(window).off('action:ajaxify.end', onAjaxifyEnd).on('action:ajaxify.end', onAjaxifyEnd); - - if (modal) { - return; - } - - app.parseAndTranslate('partials/delete_posts_modal', {}, function (html) { - modal = html; - - $('body').append(modal); - - deleteBtn = modal.find('#delete_posts_confirm'); - purgeBtn = modal.find('#purge_posts_confirm'); - - modal.find('.close,#delete_posts_cancel').on('click', closeModal); - - postSelect.init(function () { - checkButtonEnable(); - showPostsSelected(); - }); - showPostsSelected(); - - deleteBtn.on('click', function () { - deletePosts(deleteBtn, pid => `/posts/${pid}/state`); - }); - purgeBtn.on('click', function () { - deletePosts(purgeBtn, pid => `/posts/${pid}`); - }); - }); - }; - - function onAjaxifyEnd() { - if (ajaxify.data.template.name !== 'topic' || ajaxify.data.tid !== tid) { - closeModal(); - $(window).off('action:ajaxify.end', onAjaxifyEnd); - } - } - - function deletePosts(btn, route) { - btn.attr('disabled', true); - Promise.all(postSelect.pids.map(pid => api.delete(route(pid), {}))) - .then(closeModal) - .catch(alerts.error) - .finally(() => { - btn.removeAttr('disabled'); - }); - } - - function showPostsSelected() { - if (postSelect.pids.length) { - modal.find('#pids').translateHtml('[[topic:fork_pid_count, ' + postSelect.pids.length + ']]'); - } else { - modal.find('#pids').translateHtml('[[topic:fork_no_pids]]'); - } - } - - function checkButtonEnable() { - if (postSelect.pids.length) { - deleteBtn.removeAttr('disabled'); - purgeBtn.removeAttr('disabled'); - } else { - deleteBtn.attr('disabled', true); - purgeBtn.attr('disabled', true); - } - } - - function closeModal() { - if (modal) { - modal.remove(); - modal = null; - postSelect.disable(); - } - } - - return DeletePosts; + 'postSelect', 'alerts', 'api', +], (postSelect, alerts, api) => { + const DeletePosts = {}; + let modal; + let deleteButton; + let purgeButton; + let tid; + + DeletePosts.init = function () { + tid = ajaxify.data.tid; + + $(window).off('action:ajaxify.end', onAjaxifyEnd).on('action:ajaxify.end', onAjaxifyEnd); + + if (modal) { + return; + } + + app.parseAndTranslate('partials/delete_posts_modal', {}, html => { + modal = html; + + $('body').append(modal); + + deleteButton = modal.find('#delete_posts_confirm'); + purgeButton = modal.find('#purge_posts_confirm'); + + modal.find('.close,#delete_posts_cancel').on('click', closeModal); + + postSelect.init(() => { + checkButtonEnable(); + showPostsSelected(); + }); + showPostsSelected(); + + deleteButton.on('click', () => { + deletePosts(deleteButton, pid => `/posts/${pid}/state`); + }); + purgeButton.on('click', () => { + deletePosts(purgeButton, pid => `/posts/${pid}`); + }); + }); + }; + + function onAjaxifyEnd() { + if (ajaxify.data.template.name !== 'topic' || ajaxify.data.tid !== tid) { + closeModal(); + $(window).off('action:ajaxify.end', onAjaxifyEnd); + } + } + + function deletePosts(button, route) { + button.attr('disabled', true); + Promise.all(postSelect.pids.map(pid => api.delete(route(pid), {}))) + .then(closeModal) + .catch(alerts.error) + .finally(() => { + button.removeAttr('disabled'); + }); + } + + function showPostsSelected() { + if (postSelect.pids.length > 0) { + modal.find('#pids').translateHtml('[[topic:fork_pid_count, ' + postSelect.pids.length + ']]'); + } else { + modal.find('#pids').translateHtml('[[topic:fork_no_pids]]'); + } + } + + function checkButtonEnable() { + if (postSelect.pids.length > 0) { + deleteButton.removeAttr('disabled'); + purgeButton.removeAttr('disabled'); + } else { + deleteButton.attr('disabled', true); + purgeButton.attr('disabled', true); + } + } + + function closeModal() { + if (modal) { + modal.remove(); + modal = null; + postSelect.disable(); + } + } + + return DeletePosts; }); diff --git a/public/src/client/topic/diffs.js b/public/src/client/topic/diffs.js index 0e0b8b5..c606c82 100644 --- a/public/src/client/topic/diffs.js +++ b/public/src/client/topic/diffs.js @@ -1,117 +1,119 @@ 'use strict'; -define('forum/topic/diffs', ['api', 'bootbox', 'alerts', 'forum/topic/images'], function (api, bootbox, alerts) { - const Diffs = {}; - const localeStringOpts = { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' }; - - Diffs.open = function (pid) { - if (!config.enablePostHistory) { - return; - } - - api.get(`/posts/${pid}/diffs`, {}).then((data) => { - parsePostHistory(data).then(($html) => { - const $modal = bootbox.dialog({ title: '[[topic:diffs.title]]', message: $html, size: 'large' }); - - if (!data.timestamps.length) { - return; - } - - const $selectEl = $modal.find('select'); - const $revertEl = $modal.find('button[data-action="restore"]'); - const $deleteEl = $modal.find('button[data-action="delete"]'); - const $postContainer = $modal.find('ul.posts-list'); - const $numberOfDiffCon = $modal.find('.number-of-diffs strong'); - - $selectEl.on('change', function () { - Diffs.load(pid, this.value, $postContainer); - $revertEl.prop('disabled', data.timestamps.indexOf(this.value) === 0); - $deleteEl.prop('disabled', data.timestamps.indexOf(this.value) === 0); - }); - - $revertEl.on('click', function () { - Diffs.restore(pid, $selectEl.val(), $modal); - }); - - $deleteEl.on('click', function () { - Diffs.delete(pid, $selectEl.val(), $selectEl, $numberOfDiffCon); - }); - - $modal.on('shown.bs.modal', function () { - Diffs.load(pid, $selectEl.val(), $postContainer); - $revertEl.prop('disabled', true); - $deleteEl.prop('disabled', true); - }); - }); - }).catch(alerts.error); - }; - - Diffs.load = function (pid, since, $postContainer) { - if (!config.enablePostHistory) { - return; - } - - api.get(`/posts/${pid}/diffs/${since}`, {}).then((data) => { - data.deleted = !!parseInt(data.deleted, 10); - - app.parseAndTranslate('partials/posts_list', 'posts', { - posts: [data], - }, function ($html) { - $postContainer.empty().append($html); - $postContainer.find('.timeago').timeago(); - }); - }).catch(alerts.error); - }; - - Diffs.restore = function (pid, since, $modal) { - if (!config.enablePostHistory) { - return; - } - - api.put(`/posts/${pid}/diffs/${since}`, {}).then(() => { - $modal.modal('hide'); - alerts.success('[[topic:diffs.post-restored]]'); - }).catch(alerts.error); - }; - - Diffs.delete = function (pid, timestamp, $selectEl, $numberOfDiffCon) { - api.del(`/posts/${pid}/diffs/${timestamp}`).then((data) => { - parsePostHistory(data, 'diffs').then(($html) => { - $selectEl.empty().append($html); - $selectEl.trigger('change'); - const numberOfDiffs = $selectEl.find('option').length; - $numberOfDiffCon.text(numberOfDiffs); - alerts.success('[[topic:diffs.deleted]]'); - }); - }).catch(alerts.error); - }; - - function parsePostHistory(data, blockName) { - return new Promise((resolve) => { - const params = [{ - diffs: data.revisions.map(function (revision) { - const timestamp = parseInt(revision.timestamp, 10); - - return { - username: revision.username, - timestamp: timestamp, - pretty: new Date(timestamp).toLocaleString(config.userLang.replace('_', '-'), localeStringOpts), - }; - }), - numDiffs: data.timestamps.length, - editable: data.editable, - deletable: data.deletable, - }, function ($html) { - resolve($html); - }]; - - if (blockName) { - params.unshift(blockName); - } - - app.parseAndTranslate('partials/modals/post_history', ...params); - }); - } - - return Diffs; +define('forum/topic/diffs', ['api', 'bootbox', 'alerts', 'forum/topic/images'], (api, bootbox, alerts) => { + const Diffs = {}; + const localeStringOptions = { + year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', + }; + + Diffs.open = function (pid) { + if (!config.enablePostHistory) { + return; + } + + api.get(`/posts/${pid}/diffs`, {}).then(data => { + parsePostHistory(data).then($html => { + const $modal = bootbox.dialog({title: '[[topic:diffs.title]]', message: $html, size: 'large'}); + + if (data.timestamps.length === 0) { + return; + } + + const $selectElement = $modal.find('select'); + const $revertElement = $modal.find('button[data-action="restore"]'); + const $deleteElement = $modal.find('button[data-action="delete"]'); + const $postContainer = $modal.find('ul.posts-list'); + const $numberOfDiffCon = $modal.find('.number-of-diffs strong'); + + $selectElement.on('change', function () { + Diffs.load(pid, this.value, $postContainer); + $revertElement.prop('disabled', data.timestamps.indexOf(this.value) === 0); + $deleteElement.prop('disabled', data.timestamps.indexOf(this.value) === 0); + }); + + $revertElement.on('click', () => { + Diffs.restore(pid, $selectElement.val(), $modal); + }); + + $deleteElement.on('click', () => { + Diffs.delete(pid, $selectElement.val(), $selectElement, $numberOfDiffCon); + }); + + $modal.on('shown.bs.modal', () => { + Diffs.load(pid, $selectElement.val(), $postContainer); + $revertElement.prop('disabled', true); + $deleteElement.prop('disabled', true); + }); + }); + }).catch(alerts.error); + }; + + Diffs.load = function (pid, since, $postContainer) { + if (!config.enablePostHistory) { + return; + } + + api.get(`/posts/${pid}/diffs/${since}`, {}).then(data => { + data.deleted = Boolean(Number.parseInt(data.deleted, 10)); + + app.parseAndTranslate('partials/posts_list', 'posts', { + posts: [data], + }, $html => { + $postContainer.empty().append($html); + $postContainer.find('.timeago').timeago(); + }); + }).catch(alerts.error); + }; + + Diffs.restore = function (pid, since, $modal) { + if (!config.enablePostHistory) { + return; + } + + api.put(`/posts/${pid}/diffs/${since}`, {}).then(() => { + $modal.modal('hide'); + alerts.success('[[topic:diffs.post-restored]]'); + }).catch(alerts.error); + }; + + Diffs.delete = function (pid, timestamp, $selectElement, $numberOfDiffCon) { + api.del(`/posts/${pid}/diffs/${timestamp}`).then(data => { + parsePostHistory(data, 'diffs').then($html => { + $selectElement.empty().append($html); + $selectElement.trigger('change'); + const numberOfDiffs = $selectElement.find('option').length; + $numberOfDiffCon.text(numberOfDiffs); + alerts.success('[[topic:diffs.deleted]]'); + }); + }).catch(alerts.error); + }; + + function parsePostHistory(data, blockName) { + return new Promise(resolve => { + const parameters = [{ + diffs: data.revisions.map(revision => { + const timestamp = Number.parseInt(revision.timestamp, 10); + + return { + username: revision.username, + timestamp, + pretty: new Date(timestamp).toLocaleString(config.userLang.replace('_', '-'), localeStringOptions), + }; + }), + numDiffs: data.timestamps.length, + editable: data.editable, + deletable: data.deletable, + }, function ($html) { + resolve($html); + }]; + + if (blockName) { + parameters.unshift(blockName); + } + + app.parseAndTranslate('partials/modals/post_history', ...parameters); + }); + } + + return Diffs; }); diff --git a/public/src/client/topic/events.js b/public/src/client/topic/events.js index a3049b0..eb2638c 100644 --- a/public/src/client/topic/events.js +++ b/public/src/client/topic/events.js @@ -1,241 +1,232 @@ 'use strict'; - define('forum/topic/events', [ - 'forum/topic/postTools', - 'forum/topic/threadTools', - 'forum/topic/posts', - 'forum/topic/images', - 'components', - 'translator', - 'benchpress', - 'hooks', -], function (postTools, threadTools, posts, images, components, translator, Benchpress, hooks) { - const Events = {}; + 'forum/topic/postTools', + 'forum/topic/threadTools', + 'forum/topic/posts', + 'forum/topic/images', + 'components', + 'translator', + 'benchpress', + 'hooks', +], (postTools, threadTools, posts, images, components, translator, Benchpress, hooks) => { + const Events = {}; - const events = { - 'event:user_status_change': onUserStatusChange, - 'event:voted': updatePostVotesAndUserReputation, - 'event:bookmarked': updateBookmarkCount, + const events = { + 'event:user_status_change': onUserStatusChange, + 'event:voted': updatePostVotesAndUserReputation, + 'event:bookmarked': updateBookmarkCount, - 'event:topic_deleted': threadTools.setDeleteState, - 'event:topic_restored': threadTools.setDeleteState, - 'event:topic_purged': onTopicPurged, + 'event:topic_deleted': threadTools.setDeleteState, + 'event:topic_restored': threadTools.setDeleteState, + 'event:topic_purged': onTopicPurged, - 'event:topic_locked': threadTools.setLockedState, - 'event:topic_unlocked': threadTools.setLockedState, + 'event:topic_locked': threadTools.setLockedState, + 'event:topic_unlocked': threadTools.setLockedState, - 'event:topic_pinned': threadTools.setPinnedState, - 'event:topic_unpinned': threadTools.setPinnedState, + 'event:topic_pinned': threadTools.setPinnedState, + 'event:topic_unpinned': threadTools.setPinnedState, - 'event:topic_private': threadTools.setPrivateState, - 'event:topic_public': threadTools.setPrivateState, + 'event:topic_private': threadTools.setPrivateState, + 'event:topic_public': threadTools.setPrivateState, - 'event:topic_moved': onTopicMoved, + 'event:topic_moved': onTopicMoved, - 'event:post_edited': onPostEdited, - 'event:post_purged': onPostPurged, + 'event:post_edited': onPostEdited, + 'event:post_purged': onPostPurged, - 'event:post_deleted': togglePostDeleteState, - 'event:post_restored': togglePostDeleteState, + 'event:post_deleted': togglePostDeleteState, + 'event:post_restored': togglePostDeleteState, - 'posts.bookmark': togglePostBookmark, - 'posts.unbookmark': togglePostBookmark, + 'posts.bookmark': togglePostBookmark, + 'posts.unbookmark': togglePostBookmark, - 'posts.resolve': togglePostResolve, + 'posts.resolve': togglePostResolve, - /* + /* Since this change does not depend on the signature of this function at all, I will just assert type information in the function itself for now -- tkroenin */ - 'posts.pin': togglePostPinned, - 'posts.unpin': togglePostPinned, - - 'posts.upvote': togglePostVote, - 'posts.downvote': togglePostVote, - 'posts.unvote': togglePostVote, - - 'event:new_notification': onNewNotification, - 'event:new_post': posts.onNewPost, - }; - - Events.init = function () { - Events.removeListeners(); - for (const eventName in events) { - if (events.hasOwnProperty(eventName)) { - socket.on(eventName, events[eventName]); - } - } - }; - - Events.removeListeners = function () { - for (const eventName in events) { - if (events.hasOwnProperty(eventName)) { - socket.removeListener(eventName, events[eventName]); - } - } - }; - - function onUserStatusChange(data) { - app.updateUserStatus($('[data-uid="' + data.uid + '"] [component="user/status"]'), data.status); - } - - function updatePostVotesAndUserReputation(data) { - const votes = $('[data-pid="' + data.post.pid + '"] [component="post/vote-count"]').filter(function (index, el) { - return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10); - }); - const reputationElements = $('.reputation[data-uid="' + data.post.uid + '"]'); - votes.html(data.post.votes).attr('data-votes', data.post.votes); - reputationElements.html(data.user.reputation).attr('data-reputation', data.user.reputation); - } - - function updateBookmarkCount(data) { - $('[data-pid="' + data.post.pid + '"] .bookmarkCount').filter(function (index, el) { - return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10); - }).html(data.post.bookmarks).attr('data-bookmarks', data.post.bookmarks); - } - - function onTopicPurged(data) { - if ( - ajaxify.data.category && - ajaxify.data.category.slug && - parseInt(data.tid, 10) === parseInt(ajaxify.data.tid, 10) - ) { - ajaxify.go('category/' + ajaxify.data.category.slug, null, true); - } - } - - function onTopicMoved(data) { - if (data && data.slug && parseInt(data.tid, 10) === parseInt(ajaxify.data.tid, 10)) { - ajaxify.go('topic/' + data.slug, null, true); - } - } - - function onPostEdited(data) { - if (!data || !data.post || parseInt(data.post.tid, 10) !== parseInt(ajaxify.data.tid, 10)) { - return; - } - const editedPostEl = components.get('post/content', data.post.pid).filter(function (index, el) { - return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10); - }); - - const editorEl = $('[data-pid="' + data.post.pid + '"] [component="post/editor"]').filter(function (index, el) { - return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10); - }); - const topicTitle = components.get('topic/title'); - const navbarTitle = components.get('navbar/title').find('span'); - const breadCrumb = components.get('breadcrumb/current'); - - if (data.topic.rescheduled) { - return ajaxify.go('topic/' + data.topic.slug, null, true); - } - - if (topicTitle.length && data.topic.title && data.topic.renamed) { - ajaxify.data.title = data.topic.title; - const newUrl = 'topic/' + data.topic.slug + (window.location.search ? window.location.search : ''); - history.replaceState({ url: newUrl }, null, window.location.protocol + '//' + window.location.host + config.relative_path + '/' + newUrl); - - topicTitle.fadeOut(250, function () { - topicTitle.html(data.topic.title).fadeIn(250); - }); - breadCrumb.fadeOut(250, function () { - breadCrumb.html(data.topic.title).fadeIn(250); - }); - navbarTitle.fadeOut(250, function () { - navbarTitle.html(data.topic.title).fadeIn(250); - }); - } - - if (data.post.changed) { - editedPostEl.fadeOut(250, function () { - editedPostEl.html(translator.unescape(data.post.content)); - editedPostEl.find('img:not(.not-responsive)').addClass('img-responsive'); - images.wrapImagesInLinks(editedPostEl.parent()); - posts.addBlockquoteEllipses(editedPostEl.parent()); - editedPostEl.fadeIn(250); - - const editData = { - editor: data.editor, - editedISO: utils.toISOString(data.post.edited), - }; - - app.parseAndTranslate('partials/topic/post-editor', editData, function (html) { - editorEl.replaceWith(html); - $('[data-pid="' + data.post.pid + '"] [component="post/editor"] .timeago').timeago(); - hooks.fire('action:posts.edited', data); - }); - }); - } else { - hooks.fire('action:posts.edited', data); - } - - if (data.topic.tags && data.topic.tagsupdated) { - Benchpress.render('partials/topic/tags', { tags: data.topic.tags }).then(function (html) { - const tags = $('.tags'); - - tags.fadeOut(250, function () { - tags.html(html).fadeIn(250); - }); - }); - } - - postTools.removeMenu(components.get('post', 'pid', data.post.pid)); - } - - function onPostPurged(postData) { - if (!postData || parseInt(postData.tid, 10) !== parseInt(ajaxify.data.tid, 10)) { - return; - } - components.get('post', 'pid', postData.pid).fadeOut(500, function () { - $(this).remove(); - posts.showBottomPostBar(); - }); - ajaxify.data.postcount -= 1; - postTools.updatePostCount(ajaxify.data.postcount); - require(['forum/topic/replies'], function (replies) { - replies.onPostPurged(postData); - }); - } - - function togglePostDeleteState(data) { - const postEl = components.get('post', 'pid', data.pid); - - if (!postEl.length) { - return; - } - - postEl.toggleClass('deleted'); - const isDeleted = postEl.hasClass('deleted'); - postTools.toggle(data.pid, isDeleted); - - if (!ajaxify.data.privileges.isAdminOrMod && parseInt(data.uid, 10) !== parseInt(app.user.uid, 10)) { - postEl.find('[component="post/tools"]').toggleClass('hidden', isDeleted); - if (isDeleted) { - postEl.find('[component="post/content"]').translateHtml('[[topic:post_is_deleted]]'); - } else { - postEl.find('[component="post/content"]').html(translator.unescape(data.content)); - } - } - } - - function togglePostBookmark(data) { - const el = $('[data-pid="' + data.post.pid + '"] [component="post/bookmark"]').filter(function (index, el) { - return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10); - }); - if (!el.length) { - return; - } - - el.attr('data-bookmarked', data.isBookmarked); - - el.find('[component="post/bookmark/on"]').toggleClass('hidden', !data.isBookmarked); - el.find('[component="post/bookmark/off"]').toggleClass('hidden', data.isBookmarked); - } - - function togglePostPinned(data) { - /* + 'posts.pin': togglePostPinned, + 'posts.unpin': togglePostPinned, + + 'posts.upvote': togglePostVote, + 'posts.downvote': togglePostVote, + 'posts.unvote': togglePostVote, + + 'event:new_notification': onNewNotification, + 'event:new_post': posts.onNewPost, + }; + + Events.init = function () { + Events.removeListeners(); + for (const eventName in events) { + if (events.hasOwnProperty(eventName)) { + socket.on(eventName, events[eventName]); + } + } + }; + + Events.removeListeners = function () { + for (const eventName in events) { + if (events.hasOwnProperty(eventName)) { + socket.removeListener(eventName, events[eventName]); + } + } + }; + + function onUserStatusChange(data) { + app.updateUserStatus($('[data-uid="' + data.uid + '"] [component="user/status"]'), data.status); + } + + function updatePostVotesAndUserReputation(data) { + const votes = $('[data-pid="' + data.post.pid + '"] [component="post/vote-count"]').filter((index, element) => Number.parseInt($(element).closest('[data-pid]').attr('data-pid'), 10) === Number.parseInt(data.post.pid, 10)); + const reputationElements = $('.reputation[data-uid="' + data.post.uid + '"]'); + votes.html(data.post.votes).attr('data-votes', data.post.votes); + reputationElements.html(data.user.reputation).attr('data-reputation', data.user.reputation); + } + + function updateBookmarkCount(data) { + $('[data-pid="' + data.post.pid + '"] .bookmarkCount').filter((index, element) => Number.parseInt($(element).closest('[data-pid]').attr('data-pid'), 10) === Number.parseInt(data.post.pid, 10)).html(data.post.bookmarks).attr('data-bookmarks', data.post.bookmarks); + } + + function onTopicPurged(data) { + if ( + ajaxify.data.category + && ajaxify.data.category.slug + && Number.parseInt(data.tid, 10) === Number.parseInt(ajaxify.data.tid, 10) + ) { + ajaxify.go('category/' + ajaxify.data.category.slug, null, true); + } + } + + function onTopicMoved(data) { + if (data && data.slug && Number.parseInt(data.tid, 10) === Number.parseInt(ajaxify.data.tid, 10)) { + ajaxify.go('topic/' + data.slug, null, true); + } + } + + function onPostEdited(data) { + if (!data || !data.post || Number.parseInt(data.post.tid, 10) !== Number.parseInt(ajaxify.data.tid, 10)) { + return; + } + + const editedPostElement = components.get('post/content', data.post.pid).filter((index, element) => Number.parseInt($(element).closest('[data-pid]').attr('data-pid'), 10) === Number.parseInt(data.post.pid, 10)); + + const editorElement = $('[data-pid="' + data.post.pid + '"] [component="post/editor"]').filter((index, element) => Number.parseInt($(element).closest('[data-pid]').attr('data-pid'), 10) === Number.parseInt(data.post.pid, 10)); + const topicTitle = components.get('topic/title'); + const navbarTitle = components.get('navbar/title').find('span'); + const breadCrumb = components.get('breadcrumb/current'); + + if (data.topic.rescheduled) { + return ajaxify.go('topic/' + data.topic.slug, null, true); + } + + if (topicTitle.length > 0 && data.topic.title && data.topic.renamed) { + ajaxify.data.title = data.topic.title; + const newUrl = 'topic/' + data.topic.slug + (window.location.search ? window.location.search : ''); + history.replaceState({url: newUrl}, null, window.location.protocol + '//' + window.location.host + config.relative_path + '/' + newUrl); + + topicTitle.fadeOut(250, () => { + topicTitle.html(data.topic.title).fadeIn(250); + }); + breadCrumb.fadeOut(250, () => { + breadCrumb.html(data.topic.title).fadeIn(250); + }); + navbarTitle.fadeOut(250, () => { + navbarTitle.html(data.topic.title).fadeIn(250); + }); + } + + if (data.post.changed) { + editedPostElement.fadeOut(250, () => { + editedPostElement.html(translator.unescape(data.post.content)); + editedPostElement.find('img:not(.not-responsive)').addClass('img-responsive'); + images.wrapImagesInLinks(editedPostElement.parent()); + posts.addBlockquoteEllipses(editedPostElement.parent()); + editedPostElement.fadeIn(250); + + const editData = { + editor: data.editor, + editedISO: utils.toISOString(data.post.edited), + }; + + app.parseAndTranslate('partials/topic/post-editor', editData, html => { + editorElement.replaceWith(html); + $('[data-pid="' + data.post.pid + '"] [component="post/editor"] .timeago').timeago(); + hooks.fire('action:posts.edited', data); + }); + }); + } else { + hooks.fire('action:posts.edited', data); + } + + if (data.topic.tags && data.topic.tagsupdated) { + Benchpress.render('partials/topic/tags', {tags: data.topic.tags}).then(html => { + const tags = $('.tags'); + + tags.fadeOut(250, () => { + tags.html(html).fadeIn(250); + }); + }); + } + + postTools.removeMenu(components.get('post', 'pid', data.post.pid)); + } + + function onPostPurged(postData) { + if (!postData || Number.parseInt(postData.tid, 10) !== Number.parseInt(ajaxify.data.tid, 10)) { + return; + } + + components.get('post', 'pid', postData.pid).fadeOut(500, function () { + $(this).remove(); + posts.showBottomPostBar(); + }); + ajaxify.data.postcount -= 1; + postTools.updatePostCount(ajaxify.data.postcount); + require(['forum/topic/replies'], replies => { + replies.onPostPurged(postData); + }); + } + + function togglePostDeleteState(data) { + const postElement = components.get('post', 'pid', data.pid); + + if (postElement.length === 0) { + return; + } + + postElement.toggleClass('deleted'); + const isDeleted = postElement.hasClass('deleted'); + postTools.toggle(data.pid, isDeleted); + + if (!ajaxify.data.privileges.isAdminOrMod && Number.parseInt(data.uid, 10) !== Number.parseInt(app.user.uid, 10)) { + postElement.find('[component="post/tools"]').toggleClass('hidden', isDeleted); + if (isDeleted) { + postElement.find('[component="post/content"]').translateHtml('[[topic:post_is_deleted]]'); + } else { + postElement.find('[component="post/content"]').html(translator.unescape(data.content)); + } + } + } + + function togglePostBookmark(data) { + const element = $('[data-pid="' + data.post.pid + '"] [component="post/bookmark"]').filter((index, element_) => Number.parseInt($(element_).closest('[data-pid]').attr('data-pid'), 10) === Number.parseInt(data.post.pid, 10)); + if (element.length === 0) { + return; + } + + element.attr('data-bookmarked', data.isBookmarked); + + element.find('[component="post/bookmark/on"]').toggleClass('hidden', !data.isBookmarked); + element.find('[component="post/bookmark/off"]').toggleClass('hidden', data.isBookmarked); + } + + function togglePostPinned(data) { + /* Parameters: Takes in a parameter `data`. For the purposes of this function, we only care that it contains a field corresponding to information @@ -247,42 +238,38 @@ define('forum/topic/events', [ the topic. */ - /* I think this style of assertion is the best you can do in front-end + /* I think this style of assertion is the best you can do in front-end code */ - console.assert(data.hasOwnProperty('post'), 'Data has no post property'); - console.assert(data.post.hasOwnProperty('tid'), 'Post field has not tid property'); - console.assert(typeof (data.post.tid) === typeof (1), `Expected type 'number' for 'tid' field, but got ${typeof (data.post.tid)}`); - - // Just redirect the user back to the top of the topic - if (data) { - ajaxify.go('topic/' + data.post.tid, null, true); - } - - // Nothing to assert for the return - } - - function togglePostResolve(data) { - const post = $('[data-pid="' + data.post.pid + '"]'); - post.find('[component="post/resolved"]').toggleClass('hidden', !data.isResolved); - post.find('[component="post/resolve"]').toggleClass('hidden', data.isResolved); - } - - function togglePostVote(data) { - const post = $('[data-pid="' + data.post.pid + '"]'); - post.find('[component="post/upvote"]').filter(function (index, el) { - return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10); - }).toggleClass('upvoted', data.upvote); - post.find('[component="post/downvote"]').filter(function (index, el) { - return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10); - }).toggleClass('downvoted', data.downvote); - } - - function onNewNotification(data) { - const tid = ajaxify.data.tid; - if (data && data.tid && parseInt(data.tid, 10) === parseInt(tid, 10)) { - socket.emit('topics.markTopicNotificationsRead', [tid]); - } - } - - return Events; + console.assert(data.hasOwnProperty('post'), 'Data has no post property'); + console.assert(data.post.hasOwnProperty('tid'), 'Post field has not tid property'); + console.assert(typeof (data.post.tid) === typeof (1), `Expected type 'number' for 'tid' field, but got ${typeof (data.post.tid)}`); + + // Just redirect the user back to the top of the topic + if (data) { + ajaxify.go('topic/' + data.post.tid, null, true); + } + + // Nothing to assert for the return + } + + function togglePostResolve(data) { + const post = $('[data-pid="' + data.post.pid + '"]'); + post.find('[component="post/resolved"]').toggleClass('hidden', !data.isResolved); + post.find('[component="post/resolve"]').toggleClass('hidden', data.isResolved); + } + + function togglePostVote(data) { + const post = $('[data-pid="' + data.post.pid + '"]'); + post.find('[component="post/upvote"]').filter((index, element) => Number.parseInt($(element).closest('[data-pid]').attr('data-pid'), 10) === Number.parseInt(data.post.pid, 10)).toggleClass('upvoted', data.upvote); + post.find('[component="post/downvote"]').filter((index, element) => Number.parseInt($(element).closest('[data-pid]').attr('data-pid'), 10) === Number.parseInt(data.post.pid, 10)).toggleClass('downvoted', data.downvote); + } + + function onNewNotification(data) { + const tid = ajaxify.data.tid; + if (data && data.tid && Number.parseInt(data.tid, 10) === Number.parseInt(tid, 10)) { + socket.emit('topics.markTopicNotificationsRead', [tid]); + } + } + + return Events; }); diff --git a/public/src/client/topic/fork.js b/public/src/client/topic/fork.js index 119e171..e3270c5 100644 --- a/public/src/client/topic/fork.js +++ b/public/src/client/topic/fork.js @@ -1,106 +1,106 @@ 'use strict'; - -define('forum/topic/fork', ['components', 'postSelect', 'alerts'], function (components, postSelect, alerts) { - const Fork = {}; - let forkModal; - let forkCommit; - let fromTid; - - Fork.init = function () { - fromTid = ajaxify.data.tid; - - $(window).off('action:ajaxify.end', onAjaxifyEnd).on('action:ajaxify.end', onAjaxifyEnd); - - if (forkModal) { - return; - } - - app.parseAndTranslate('partials/fork_thread_modal', {}, function (html) { - forkModal = html; - - forkCommit = forkModal.find('#fork_thread_commit'); - - $('body').append(forkModal); - - forkModal.find('.close,#fork_thread_cancel').on('click', closeForkModal); - forkModal.find('#fork-title').on('keyup', checkForkButtonEnable); - - postSelect.init(function () { - checkForkButtonEnable(); - showPostsSelected(); - }); - showPostsSelected(); - - forkCommit.on('click', createTopicFromPosts); - }); - }; - - function onAjaxifyEnd() { - if (ajaxify.data.template.name !== 'topic' || ajaxify.data.tid !== fromTid) { - closeForkModal(); - $(window).off('action:ajaxify.end', onAjaxifyEnd); - } - } - - function createTopicFromPosts() { - forkCommit.attr('disabled', true); - socket.emit('topics.createTopicFromPosts', { - title: forkModal.find('#fork-title').val(), - pids: postSelect.pids, - fromTid: fromTid, - }, function (err, newTopic) { - function fadeOutAndRemove(pid) { - components.get('post', 'pid', pid).fadeOut(500, function () { - $(this).remove(); - }); - } - forkCommit.removeAttr('disabled'); - if (err) { - return alerts.error(err.message); - } - - alerts.alert({ - timeout: 5000, - title: '[[global:alert.success]]', - message: '[[topic:fork_success]]', - type: 'success', - clickfn: function () { - ajaxify.go('topic/' + newTopic.slug); - }, - }); - - postSelect.pids.forEach(function (pid) { - fadeOutAndRemove(pid); - }); - - closeForkModal(); - }); - } - - function showPostsSelected() { - if (postSelect.pids.length) { - forkModal.find('#fork-pids').translateHtml('[[topic:fork_pid_count, ' + postSelect.pids.length + ']]'); - } else { - forkModal.find('#fork-pids').translateHtml('[[topic:fork_no_pids]]'); - } - } - - function checkForkButtonEnable() { - if (forkModal.find('#fork-title').val().length && postSelect.pids.length) { - forkCommit.removeAttr('disabled'); - } else { - forkCommit.attr('disabled', true); - } - } - - function closeForkModal() { - if (forkModal) { - forkModal.remove(); - forkModal = null; - postSelect.disable(); - } - } - - return Fork; +define('forum/topic/fork', ['components', 'postSelect', 'alerts'], (components, postSelect, alerts) => { + const Fork = {}; + let forkModal; + let forkCommit; + let fromTid; + + Fork.init = function () { + fromTid = ajaxify.data.tid; + + $(window).off('action:ajaxify.end', onAjaxifyEnd).on('action:ajaxify.end', onAjaxifyEnd); + + if (forkModal) { + return; + } + + app.parseAndTranslate('partials/fork_thread_modal', {}, html => { + forkModal = html; + + forkCommit = forkModal.find('#fork_thread_commit'); + + $('body').append(forkModal); + + forkModal.find('.close,#fork_thread_cancel').on('click', closeForkModal); + forkModal.find('#fork-title').on('keyup', checkForkButtonEnable); + + postSelect.init(() => { + checkForkButtonEnable(); + showPostsSelected(); + }); + showPostsSelected(); + + forkCommit.on('click', createTopicFromPosts); + }); + }; + + function onAjaxifyEnd() { + if (ajaxify.data.template.name !== 'topic' || ajaxify.data.tid !== fromTid) { + closeForkModal(); + $(window).off('action:ajaxify.end', onAjaxifyEnd); + } + } + + function createTopicFromPosts() { + forkCommit.attr('disabled', true); + socket.emit('topics.createTopicFromPosts', { + title: forkModal.find('#fork-title').val(), + pids: postSelect.pids, + fromTid, + }, (error, newTopic) => { + function fadeOutAndRemove(pid) { + components.get('post', 'pid', pid).fadeOut(500, function () { + $(this).remove(); + }); + } + + forkCommit.removeAttr('disabled'); + if (error) { + return alerts.error(error.message); + } + + alerts.alert({ + timeout: 5000, + title: '[[global:alert.success]]', + message: '[[topic:fork_success]]', + type: 'success', + clickfn() { + ajaxify.go('topic/' + newTopic.slug); + }, + }); + + for (const pid of postSelect.pids) { + fadeOutAndRemove(pid); + } + + closeForkModal(); + }); + } + + function showPostsSelected() { + if (postSelect.pids.length > 0) { + forkModal.find('#fork-pids').translateHtml('[[topic:fork_pid_count, ' + postSelect.pids.length + ']]'); + } else { + forkModal.find('#fork-pids').translateHtml('[[topic:fork_no_pids]]'); + } + } + + function checkForkButtonEnable() { + if (forkModal.find('#fork-title').val().length > 0 && postSelect.pids.length > 0) { + forkCommit.removeAttr('disabled'); + } else { + forkCommit.attr('disabled', true); + } + } + + function closeForkModal() { + if (forkModal) { + forkModal.remove(); + forkModal = null; + postSelect.disable(); + } + } + + return Fork; }); diff --git a/public/src/client/topic/images.js b/public/src/client/topic/images.js index 38ae167..facb575 100644 --- a/public/src/client/topic/images.js +++ b/public/src/client/topic/images.js @@ -1,34 +1,34 @@ 'use strict'; +define('forum/topic/images', [], () => { + const Images = {}; -define('forum/topic/images', [], function () { - const Images = {}; + Images.wrapImagesInLinks = function (posts) { + posts.find('[component="post/content"] img:not(.emoji)').each(function () { + const $this = $(this); + let source = $this.attr('src') || ''; + const alt = $this.attr('alt') || ''; + const suffixRegex = /-resized(\.\w+)?$/; - Images.wrapImagesInLinks = function (posts) { - posts.find('[component="post/content"] img:not(.emoji)').each(function () { - const $this = $(this); - let src = $this.attr('src') || ''; - const alt = $this.attr('alt') || ''; - const suffixRegex = /-resized(\.[\w]+)?$/; + if (source === 'about:blank') { + return; + } - if (src === 'about:blank') { - return; - } + if (utils.isRelativeUrl(source) && suffixRegex.test(source)) { + source = source.replace(suffixRegex, '$1'); + } - if (utils.isRelativeUrl(src) && suffixRegex.test(src)) { - src = src.replace(suffixRegex, '$1'); - } - const srcExt = src.split('.').slice(1).pop(); - const altFilename = alt.split('/').pop(); - const altExt = altFilename.split('.').slice(1).pop(); + const sourceExtension = source.split('.').slice(1).pop(); + const altFilename = alt.split('/').pop(); + const altExtension = altFilename.split('.').slice(1).pop(); - if (!$this.parent().is('a')) { - $this.wrap(''); - } - }); - }; + if (!$this.parent().is('a')) { + $this.wrap(''); + } + }); + }; - return Images; + return Images; }); diff --git a/public/src/client/topic/merge.js b/public/src/client/topic/merge.js index 4f074a1..175162d 100644 --- a/public/src/client/topic/merge.js +++ b/public/src/client/topic/merge.js @@ -1,144 +1,146 @@ 'use strict'; - -define('forum/topic/merge', ['search', 'alerts', 'api'], function (search, alerts, api) { - const Merge = {}; - let modal; - let mergeBtn; - - let selectedTids = {}; - - Merge.init = function (callback) { - callback = callback || function () {}; - if (modal) { - return; - } - app.parseAndTranslate('partials/merge_topics_modal', {}, function (html) { - modal = html; - - $('body').append(modal); - - mergeBtn = modal.find('#merge_topics_confirm'); - - modal.find('.close,#merge_topics_cancel').on('click', closeModal); - - $('#content').on('click', '[component="topic/select"]', onTopicClicked); - - showTopicsSelected(); - - mergeBtn.on('click', function () { - mergeTopics(mergeBtn); - }); - - search.enableQuickSearch({ - searchElements: { - inputEl: modal.find('.topic-search-input'), - resultEl: modal.find('.quick-search-container'), - }, - searchOptions: { - in: 'titles', - }, - }); - modal.on('click', '[data-tid]', function () { - if ($(this).attr('data-tid')) { - Merge.addTopic($(this).attr('data-tid')); - } - return false; - }); - - callback(); - }); - }; - - Merge.addTopic = function (tid, callback) { - callback = callback || function () {}; - api.get(`/topics/${tid}`, {}).then(function (topicData) { - const title = topicData ? topicData.title : 'No title'; - if (selectedTids[tid]) { - delete selectedTids[tid]; - } else { - selectedTids[tid] = title; - } - checkButtonEnable(); - showTopicsSelected(); - callback(); - }).catch(alerts.error); - }; - - function onTopicClicked(ev) { - if (!modal) { - return; - } - const tid = $(this).parents('[component="category/topic"]').attr('data-tid'); - Merge.addTopic(tid); - - ev.preventDefault(); - ev.stopPropagation(); - return false; - } - - function mergeTopics(btn) { - btn.attr('disabled', true); - const tids = Object.keys(selectedTids); - const options = {}; - if (modal.find('.merge-main-topic-radio').is(':checked')) { - options.mainTid = modal.find('.merge-main-topic-select').val(); - } else if (modal.find('.merge-new-title-radio').is(':checked')) { - options.newTopicTitle = modal.find('.merge-new-title-input').val(); - } - - socket.emit('topics.merge', { tids: tids, options: options }, function (err, tid) { - btn.removeAttr('disabled'); - if (err) { - return alerts.error(err); - } - ajaxify.go('/topic/' + tid); - closeModal(); - }); - } - - function showTopicsSelected() { - if (!modal) { - return; - } - const tids = Object.keys(selectedTids); - tids.sort(function (a, b) { - return a - b; - }); - - const topics = tids.map(function (tid) { - return { tid: tid, title: selectedTids[tid] }; - }); - - if (tids.length) { - app.parseAndTranslate('partials/merge_topics_modal', { - config: config, - topics: topics, - }, function (html) { - modal.find('.topics-section').html(html.find('.topics-section').html()); - modal.find('.merge-main-topic-select').html(html.find('.merge-main-topic-select').html()); - }); - } else { - modal.find('.topics-section').translateHtml('[[error:no-topics-selected]]'); - } - } - - function checkButtonEnable() { - if (Object.keys(selectedTids).length) { - mergeBtn.removeAttr('disabled'); - } else { - mergeBtn.attr('disabled', true); - } - } - - function closeModal() { - if (modal) { - modal.remove(); - modal = null; - } - selectedTids = {}; - $('#content').off('click', '[component="topic/select"]', onTopicClicked); - } - - return Merge; +define('forum/topic/merge', ['search', 'alerts', 'api'], (search, alerts, api) => { + const Merge = {}; + let modal; + let mergeButton; + + let selectedTids = {}; + + Merge.init = function (callback) { + callback ||= function () {}; + if (modal) { + return; + } + + app.parseAndTranslate('partials/merge_topics_modal', {}, html => { + modal = html; + + $('body').append(modal); + + mergeButton = modal.find('#merge_topics_confirm'); + + modal.find('.close,#merge_topics_cancel').on('click', closeModal); + + $('#content').on('click', '[component="topic/select"]', onTopicClicked); + + showTopicsSelected(); + + mergeButton.on('click', () => { + mergeTopics(mergeButton); + }); + + search.enableQuickSearch({ + searchElements: { + inputEl: modal.find('.topic-search-input'), + resultEl: modal.find('.quick-search-container'), + }, + searchOptions: { + in: 'titles', + }, + }); + modal.on('click', '[data-tid]', function () { + if ($(this).attr('data-tid')) { + Merge.addTopic($(this).attr('data-tid')); + } + + return false; + }); + + callback(); + }); + }; + + Merge.addTopic = function (tid, callback) { + callback ||= function () {}; + api.get(`/topics/${tid}`, {}).then(topicData => { + const title = topicData ? topicData.title : 'No title'; + if (selectedTids[tid]) { + delete selectedTids[tid]; + } else { + selectedTids[tid] = title; + } + + checkButtonEnable(); + showTopicsSelected(); + callback(); + }).catch(alerts.error); + }; + + function onTopicClicked(event) { + if (!modal) { + return; + } + + const tid = $(this).parents('[component="category/topic"]').attr('data-tid'); + Merge.addTopic(tid); + + event.preventDefault(); + event.stopPropagation(); + return false; + } + + function mergeTopics(button) { + button.attr('disabled', true); + const tids = Object.keys(selectedTids); + const options = {}; + if (modal.find('.merge-main-topic-radio').is(':checked')) { + options.mainTid = modal.find('.merge-main-topic-select').val(); + } else if (modal.find('.merge-new-title-radio').is(':checked')) { + options.newTopicTitle = modal.find('.merge-new-title-input').val(); + } + + socket.emit('topics.merge', {tids, options}, (error, tid) => { + button.removeAttr('disabled'); + if (error) { + return alerts.error(error); + } + + ajaxify.go('/topic/' + tid); + closeModal(); + }); + } + + function showTopicsSelected() { + if (!modal) { + return; + } + + const tids = Object.keys(selectedTids); + tids.sort((a, b) => a - b); + + const topics = tids.map(tid => ({tid, title: selectedTids[tid]})); + + if (tids.length > 0) { + app.parseAndTranslate('partials/merge_topics_modal', { + config, + topics, + }, html => { + modal.find('.topics-section').html(html.find('.topics-section').html()); + modal.find('.merge-main-topic-select').html(html.find('.merge-main-topic-select').html()); + }); + } else { + modal.find('.topics-section').translateHtml('[[error:no-topics-selected]]'); + } + } + + function checkButtonEnable() { + if (Object.keys(selectedTids).length > 0) { + mergeButton.removeAttr('disabled'); + } else { + mergeButton.attr('disabled', true); + } + } + + function closeModal() { + if (modal) { + modal.remove(); + modal = null; + } + + selectedTids = {}; + $('#content').off('click', '[component="topic/select"]', onTopicClicked); + } + + return Merge; }); diff --git a/public/src/client/topic/move-post.js b/public/src/client/topic/move-post.js index 181bb33..8e055e5 100644 --- a/public/src/client/topic/move-post.js +++ b/public/src/client/topic/move-post.js @@ -1,167 +1,177 @@ 'use strict'; - define('forum/topic/move-post', [ - 'components', 'postSelect', 'translator', 'alerts', 'api', -], function (components, postSelect, translator, alerts, api) { - const MovePost = {}; - - let moveModal; - let moveCommit; - let fromTid; - - MovePost.init = function (postEl) { - if (moveModal) { - return; - } - fromTid = ajaxify.data.tid; - app.parseAndTranslate('modals/move-post', {}, function (html) { - moveModal = html; - - moveCommit = moveModal.find('#move_posts_confirm'); - - $('body').append(moveModal); - - moveModal.find('.close,#move_posts_cancel').on('click', closeMoveModal); - moveModal.find('#topicId').on('keyup', utils.debounce(checkMoveButtonEnable, 200)); - postSelect.init(onPostToggled); - showPostsSelected(); - - if (postEl) { - postSelect.togglePostSelection(postEl, postEl.attr('data-pid')); - } - - $(window).off('action:ajaxify.end', onAjaxifyEnd) - .on('action:ajaxify.end', onAjaxifyEnd); - - moveCommit.on('click', function () { - const targetTid = getTargetTid(); - if (!targetTid) { - return; - } - moveCommit.attr('disabled', true); - const data = { - pids: postSelect.pids.slice(), - tid: targetTid, - }; - if (config.undoTimeout > 0) { - return alerts.alert({ - alert_id: 'pids_move_' + postSelect.pids.join('-'), - title: '[[topic:thread_tools.move-posts]]', - message: '[[topic:topic_move_posts_success]]', - type: 'success', - timeout: 10000, - timeoutfn: function () { - movePosts(data); - }, - clickfn: function (alert, params) { - delete params.timeoutfn; - alerts.success('[[topic:topic_move_posts_undone]]'); - moveCommit.removeAttr('disabled'); - }, - }); - } - - movePosts(data); - }); - }); - }; - - function onAjaxifyEnd() { - if (!moveModal) { - return; - } - const tidInput = moveModal.find('#topicId'); - let targetTid = null; - if (ajaxify.data.template.topic && ajaxify.data.tid && - parseInt(ajaxify.data.tid, 10) !== fromTid - ) { - targetTid = ajaxify.data.tid; - } - if (targetTid && !tidInput.val()) { - tidInput.val(targetTid); - } - checkMoveButtonEnable(); - } - - function getTargetTid() { - const tidInput = moveModal.find('#topicId'); - if (tidInput.length && tidInput.val()) { - return tidInput.val(); - } - return ajaxify.data.template.topic && ajaxify.data.tid; - } - - function showPostsSelected() { - if (!moveModal) { - return; - } - const targetTid = getTargetTid(); - if (postSelect.pids.length) { - if (targetTid && parseInt(targetTid, 10) !== parseInt(fromTid, 10)) { - api.get('/topics/' + targetTid, {}).then(function (data) { - if (!data || !data.tid) { - return alerts.error('[[error:no-topic]]'); - } - if (data.scheduled) { - return alerts.error('[[error:cant-move-posts-to-scheduled]]'); - } - const translateStr = translator.compile('topic:x-posts-will-be-moved-to-y', postSelect.pids.length, data.title); - moveModal.find('#pids').translateHtml(translateStr); - }); - } else { - moveModal.find('#pids').translateHtml('[[topic:x-posts-selected, ' + postSelect.pids.length + ']]'); - } - } else { - moveModal.find('#pids').translateHtml('[[topic:no-posts-selected]]'); - } - } - - function checkMoveButtonEnable() { - if (!moveModal) { - return; - } - const targetTid = getTargetTid(); - if (postSelect.pids.length && targetTid && - parseInt(targetTid, 10) !== parseInt(fromTid, 10) - ) { - moveCommit.removeAttr('disabled'); - } else { - moveCommit.attr('disabled', true); - } - showPostsSelected(); - } - - function onPostToggled() { - checkMoveButtonEnable(); - } - - function movePosts(data) { - if (!data.tid) { - return; - } - - Promise.all(data.pids.map(pid => api.put(`/posts/${pid}/move`, { - tid: data.tid, - }))).then(() => { - data.pids.forEach(function (pid) { - components.get('post', 'pid', pid).fadeOut(500, function () { - $(this).remove(); - }); - }); - - closeMoveModal(); - }).catch(alerts.error); - } - - function closeMoveModal() { - if (moveModal) { - moveModal.remove(); - moveModal = null; - postSelect.disable(); - $(window).off('action:ajaxify.end', onAjaxifyEnd); - } - } - - return MovePost; + 'components', 'postSelect', 'translator', 'alerts', 'api', +], (components, postSelect, translator, alerts, api) => { + const MovePost = {}; + + let moveModal; + let moveCommit; + let fromTid; + + MovePost.init = function (postElement) { + if (moveModal) { + return; + } + + fromTid = ajaxify.data.tid; + app.parseAndTranslate('modals/move-post', {}, html => { + moveModal = html; + + moveCommit = moveModal.find('#move_posts_confirm'); + + $('body').append(moveModal); + + moveModal.find('.close,#move_posts_cancel').on('click', closeMoveModal); + moveModal.find('#topicId').on('keyup', utils.debounce(checkMoveButtonEnable, 200)); + postSelect.init(onPostToggled); + showPostsSelected(); + + if (postElement) { + postSelect.togglePostSelection(postElement, postElement.attr('data-pid')); + } + + $(window).off('action:ajaxify.end', onAjaxifyEnd) + .on('action:ajaxify.end', onAjaxifyEnd); + + moveCommit.on('click', () => { + const targetTid = getTargetTid(); + if (!targetTid) { + return; + } + + moveCommit.attr('disabled', true); + const data = { + pids: postSelect.pids.slice(), + tid: targetTid, + }; + if (config.undoTimeout > 0) { + return alerts.alert({ + alert_id: 'pids_move_' + postSelect.pids.join('-'), + title: '[[topic:thread_tools.move-posts]]', + message: '[[topic:topic_move_posts_success]]', + type: 'success', + timeout: 10_000, + timeoutfn() { + movePosts(data); + }, + clickfn(alert, parameters) { + delete parameters.timeoutfn; + alerts.success('[[topic:topic_move_posts_undone]]'); + moveCommit.removeAttr('disabled'); + }, + }); + } + + movePosts(data); + }); + }); + }; + + function onAjaxifyEnd() { + if (!moveModal) { + return; + } + + const tidInput = moveModal.find('#topicId'); + let targetTid = null; + if (ajaxify.data.template.topic && ajaxify.data.tid + && Number.parseInt(ajaxify.data.tid, 10) !== fromTid + ) { + targetTid = ajaxify.data.tid; + } + + if (targetTid && !tidInput.val()) { + tidInput.val(targetTid); + } + + checkMoveButtonEnable(); + } + + function getTargetTid() { + const tidInput = moveModal.find('#topicId'); + if (tidInput.length > 0 && tidInput.val()) { + return tidInput.val(); + } + + return ajaxify.data.template.topic && ajaxify.data.tid; + } + + function showPostsSelected() { + if (!moveModal) { + return; + } + + const targetTid = getTargetTid(); + if (postSelect.pids.length > 0) { + if (targetTid && Number.parseInt(targetTid, 10) !== Number.parseInt(fromTid, 10)) { + api.get('/topics/' + targetTid, {}).then(data => { + if (!data || !data.tid) { + return alerts.error('[[error:no-topic]]'); + } + + if (data.scheduled) { + return alerts.error('[[error:cant-move-posts-to-scheduled]]'); + } + + const translateString = translator.compile('topic:x-posts-will-be-moved-to-y', postSelect.pids.length, data.title); + moveModal.find('#pids').translateHtml(translateString); + }); + } else { + moveModal.find('#pids').translateHtml('[[topic:x-posts-selected, ' + postSelect.pids.length + ']]'); + } + } else { + moveModal.find('#pids').translateHtml('[[topic:no-posts-selected]]'); + } + } + + function checkMoveButtonEnable() { + if (!moveModal) { + return; + } + + const targetTid = getTargetTid(); + if (postSelect.pids.length > 0 && targetTid + && Number.parseInt(targetTid, 10) !== Number.parseInt(fromTid, 10) + ) { + moveCommit.removeAttr('disabled'); + } else { + moveCommit.attr('disabled', true); + } + + showPostsSelected(); + } + + function onPostToggled() { + checkMoveButtonEnable(); + } + + function movePosts(data) { + if (!data.tid) { + return; + } + + Promise.all(data.pids.map(pid => api.put(`/posts/${pid}/move`, { + tid: data.tid, + }))).then(() => { + for (const pid of data.pids) { + components.get('post', 'pid', pid).fadeOut(500, function () { + $(this).remove(); + }); + } + + closeMoveModal(); + }).catch(alerts.error); + } + + function closeMoveModal() { + if (moveModal) { + moveModal.remove(); + moveModal = null; + postSelect.disable(); + $(window).off('action:ajaxify.end', onAjaxifyEnd); + } + } + + return MovePost; }); diff --git a/public/src/client/topic/move.js b/public/src/client/topic/move.js index a8e47ec..aef0269 100644 --- a/public/src/client/topic/move.js +++ b/public/src/client/topic/move.js @@ -1,102 +1,102 @@ 'use strict'; - -define('forum/topic/move', ['categorySelector', 'alerts', 'hooks'], function (categorySelector, alerts, hooks) { - const Move = {}; - let modal; - let selectedCategory; - - Move.init = function (tids, currentCid, onComplete) { - Move.tids = tids; - Move.currentCid = currentCid; - Move.onComplete = onComplete; - Move.moveAll = !tids; - - showModal(); - }; - - function showModal() { - app.parseAndTranslate('partials/move_thread_modal', {}, function (html) { - modal = html; - modal.on('hidden.bs.modal', function () { - modal.remove(); - }); - - modal.find('#move-confirm').addClass('hide'); - - if (Move.moveAll || (Move.tids && Move.tids.length > 1)) { - modal.find('.modal-header h3').translateText('[[topic:move_topics]]'); - } - - categorySelector.init(modal.find('[component="category-selector"]'), { - onSelect: onCategorySelected, - privilege: 'moderate', - }); - - modal.find('#move_thread_commit').on('click', onCommitClicked); - - modal.modal('show'); - }); - } - - function onCategorySelected(category) { - selectedCategory = category; - modal.find('#move_thread_commit').prop('disabled', false); - } - - function onCommitClicked() { - const commitEl = modal.find('#move_thread_commit'); - - if (!commitEl.prop('disabled') && selectedCategory && selectedCategory.cid) { - commitEl.prop('disabled', true); - - modal.modal('hide'); - let message = '[[topic:topic_move_success, ' + selectedCategory.name + ']]'; - if (Move.tids && Move.tids.length > 1) { - message = '[[topic:topic_move_multiple_success, ' + selectedCategory.name + ']]'; - } else if (!Move.tids) { - message = '[[topic:topic_move_all_success, ' + selectedCategory.name + ']]'; - } - const data = { - tids: Move.tids ? Move.tids.slice() : null, - cid: selectedCategory.cid, - currentCid: Move.currentCid, - onComplete: Move.onComplete, - }; - if (config.undoTimeout > 0) { - return alerts.alert({ - alert_id: 'tids_move_' + (Move.tids ? Move.tids.join('-') : 'all'), - title: '[[topic:thread_tools.move]]', - message: message, - type: 'success', - timeout: config.undoTimeout, - timeoutfn: function () { - moveTopics(data); - }, - clickfn: function (alert, params) { - delete params.timeoutfn; - alerts.success('[[topic:topic_move_undone]]'); - }, - }); - } - - moveTopics(data); - } - } - - function moveTopics(data) { - hooks.fire('action:topic.move', data); - - socket.emit(!data.tids ? 'topics.moveAll' : 'topics.move', data, function (err) { - if (err) { - return alerts.error(err); - } - - if (typeof data.onComplete === 'function') { - data.onComplete(); - } - }); - } - - return Move; +define('forum/topic/move', ['categorySelector', 'alerts', 'hooks'], (categorySelector, alerts, hooks) => { + const Move = {}; + let modal; + let selectedCategory; + + Move.init = function (tids, currentCid, onComplete) { + Move.tids = tids; + Move.currentCid = currentCid; + Move.onComplete = onComplete; + Move.moveAll = !tids; + + showModal(); + }; + + function showModal() { + app.parseAndTranslate('partials/move_thread_modal', {}, html => { + modal = html; + modal.on('hidden.bs.modal', () => { + modal.remove(); + }); + + modal.find('#move-confirm').addClass('hide'); + + if (Move.moveAll || (Move.tids && Move.tids.length > 1)) { + modal.find('.modal-header h3').translateText('[[topic:move_topics]]'); + } + + categorySelector.init(modal.find('[component="category-selector"]'), { + onSelect: onCategorySelected, + privilege: 'moderate', + }); + + modal.find('#move_thread_commit').on('click', onCommitClicked); + + modal.modal('show'); + }); + } + + function onCategorySelected(category) { + selectedCategory = category; + modal.find('#move_thread_commit').prop('disabled', false); + } + + function onCommitClicked() { + const commitElement = modal.find('#move_thread_commit'); + + if (!commitElement.prop('disabled') && selectedCategory && selectedCategory.cid) { + commitElement.prop('disabled', true); + + modal.modal('hide'); + let message = '[[topic:topic_move_success, ' + selectedCategory.name + ']]'; + if (Move.tids && Move.tids.length > 1) { + message = '[[topic:topic_move_multiple_success, ' + selectedCategory.name + ']]'; + } else if (!Move.tids) { + message = '[[topic:topic_move_all_success, ' + selectedCategory.name + ']]'; + } + + const data = { + tids: Move.tids ? Move.tids.slice() : null, + cid: selectedCategory.cid, + currentCid: Move.currentCid, + onComplete: Move.onComplete, + }; + if (config.undoTimeout > 0) { + return alerts.alert({ + alert_id: 'tids_move_' + (Move.tids ? Move.tids.join('-') : 'all'), + title: '[[topic:thread_tools.move]]', + message, + type: 'success', + timeout: config.undoTimeout, + timeoutfn() { + moveTopics(data); + }, + clickfn(alert, parameters) { + delete parameters.timeoutfn; + alerts.success('[[topic:topic_move_undone]]'); + }, + }); + } + + moveTopics(data); + } + } + + function moveTopics(data) { + hooks.fire('action:topic.move', data); + + socket.emit(data.tids ? 'topics.move' : 'topics.moveAll', data, error => { + if (error) { + return alerts.error(error); + } + + if (typeof data.onComplete === 'function') { + data.onComplete(); + } + }); + } + + return Move; }); diff --git a/public/src/client/topic/postTools.js b/public/src/client/topic/postTools.js index 5b9603f..445dd67 100644 --- a/public/src/client/topic/postTools.js +++ b/public/src/client/topic/postTools.js @@ -1,412 +1,410 @@ 'use strict'; - define('forum/topic/postTools', [ - 'share', - 'navigator', - 'components', - 'translator', - 'forum/topic/votes', - 'api', - 'bootbox', - 'alerts', - 'hooks', -], function (share, navigator, components, translator, votes, api, bootbox, alerts, hooks) { - const PostTools = {}; - - let staleReplyAnyway = false; - - PostTools.init = function (tid) { - staleReplyAnyway = false; - - renderMenu(); - - addPostHandlers(tid); - - share.addShareHandlers(ajaxify.data.titleRaw); - - votes.addVoteHandler(); - - PostTools.updatePostCount(ajaxify.data.postcount); - }; - - function renderMenu() { - $('[component="topic"]').on('show.bs.dropdown', '.moderator-tools', function () { - const $this = $(this); - const dropdownMenu = $this.find('.dropdown-menu'); - if (dropdownMenu.html()) { - return; - } - const postEl = $this.parents('[data-pid]'); - const pid = postEl.attr('data-pid'); - const index = parseInt(postEl.attr('data-index'), 10); - - socket.emit('posts.loadPostTools', { pid: pid, cid: ajaxify.data.cid }, async (err, data) => { - if (err) { - return alerts.error(err); - } - data.posts.display_move_tools = data.posts.display_move_tools && index !== 0; - - const html = await app.parseAndTranslate('partials/topic/post-menu-list', data); - const clipboard = require('clipboard'); - - dropdownMenu.html(html); - dropdownMenu.get(0).classList.toggle('hidden', false); - new clipboard('[data-clipboard-text]'); - - hooks.fire('action:post.tools.load', { - element: dropdownMenu, - }); - }); - }); - } - - PostTools.toggle = function (pid, isDeleted) { - const postEl = components.get('post', 'pid', pid); - - postEl.find('[component="post/quote"], [component="post/bookmark"], [component="post/reply"], [component="post/flag"], [component="user/chat"], [component="user/resolve"]') - .toggleClass('hidden', isDeleted); - - postEl.find('[component="post/delete"]').toggleClass('hidden', isDeleted).parent().attr('hidden', isDeleted ? '' : null); - postEl.find('[component="post/restore"]').toggleClass('hidden', !isDeleted).parent().attr('hidden', !isDeleted ? '' : null); - postEl.find('[component="post/purge"]').toggleClass('hidden', !isDeleted).parent().attr('hidden', !isDeleted ? '' : null); - - PostTools.removeMenu(postEl); - }; - - PostTools.removeMenu = function (postEl) { - postEl.find('[component="post/tools"] .dropdown-menu').html(''); - }; - - PostTools.updatePostCount = function (postCount) { - const postCountEl = components.get('topic/post-count'); - postCountEl.html(postCount).attr('title', postCount); - utils.makeNumbersHumanReadable(postCountEl); - navigator.setCount(postCount); - }; - - function addPostHandlers(tid) { - const postContainer = components.get('topic'); - - handleSelectionTooltip(); - - postContainer.on('click', '[component="post/quote"]', function () { - onQuoteClicked($(this), tid); - }); - - // postContainer.on('click', '[component="post/resolve"]', function () { - // onResolvedClicked($(this)); - // }); - - postContainer.on('click', '[component="post/resolve"]', function () { - return onResolveClicked(getData($(this), 'data-pid')); - }); - - postContainer.on('click', '[component="post/reply"]', function () { - onReplyClicked($(this), tid); - }); - - $('.topic').on('click', '[component="topic/reply"]', function (e) { - e.preventDefault(); - onReplyClicked($(this), tid); - }); - - $('.topic').on('click', '[component="topic/reply-as-topic"]', function () { - translator.translate('[[topic:link_back, ' + ajaxify.data.titleRaw + ', ' + config.relative_path + '/topic/' + ajaxify.data.slug + ']]', function (body) { - hooks.fire('action:composer.topic.new', { - cid: ajaxify.data.cid, - body: body, - }); - }); - }); - - postContainer.on('click', '[component="post/bookmark"]', function () { - return bookmarkPost($(this), getData($(this), 'data-pid')); - }); - - postContainer.on('click', '[component="post/pin"]', function () { - /* + 'share', + 'navigator', + 'components', + 'translator', + 'forum/topic/votes', + 'api', + 'bootbox', + 'alerts', + 'hooks', +], (share, navigator, components, translator, votes, api, bootbox, alerts, hooks) => { + const PostTools = {}; + + let staleReplyAnyway = false; + + PostTools.init = function (tid) { + staleReplyAnyway = false; + + renderMenu(); + + addPostHandlers(tid); + + share.addShareHandlers(ajaxify.data.titleRaw); + + votes.addVoteHandler(); + + PostTools.updatePostCount(ajaxify.data.postcount); + }; + + function renderMenu() { + $('[component="topic"]').on('show.bs.dropdown', '.moderator-tools', function () { + const $this = $(this); + const dropdownMenu = $this.find('.dropdown-menu'); + if (dropdownMenu.html()) { + return; + } + + const postElement = $this.parents('[data-pid]'); + const pid = postElement.attr('data-pid'); + const index = Number.parseInt(postElement.attr('data-index'), 10); + + socket.emit('posts.loadPostTools', {pid, cid: ajaxify.data.cid}, async (error, data) => { + if (error) { + return alerts.error(error); + } + + data.posts.display_move_tools = data.posts.display_move_tools && index !== 0; + + const html = await app.parseAndTranslate('partials/topic/post-menu-list', data); + const clipboard = require('clipboard'); + + dropdownMenu.html(html); + dropdownMenu.get(0).classList.toggle('hidden', false); + new clipboard('[data-clipboard-text]'); + + hooks.fire('action:post.tools.load', { + element: dropdownMenu, + }); + }); + }); + } + + PostTools.toggle = function (pid, isDeleted) { + const postElement = components.get('post', 'pid', pid); + + postElement.find('[component="post/quote"], [component="post/bookmark"], [component="post/reply"], [component="post/flag"], [component="user/chat"], [component="user/resolve"]') + .toggleClass('hidden', isDeleted); + + postElement.find('[component="post/delete"]').toggleClass('hidden', isDeleted).parent().attr('hidden', isDeleted ? '' : null); + postElement.find('[component="post/restore"]').toggleClass('hidden', !isDeleted).parent().attr('hidden', isDeleted ? null : ''); + postElement.find('[component="post/purge"]').toggleClass('hidden', !isDeleted).parent().attr('hidden', isDeleted ? null : ''); + + PostTools.removeMenu(postElement); + }; + + PostTools.removeMenu = function (postElement) { + postElement.find('[component="post/tools"] .dropdown-menu').html(''); + }; + + PostTools.updatePostCount = function (postCount) { + const postCountElement = components.get('topic/post-count'); + postCountElement.html(postCount).attr('title', postCount); + utils.makeNumbersHumanReadable(postCountElement); + navigator.setCount(postCount); + }; + + function addPostHandlers(tid) { + const postContainer = components.get('topic'); + + handleSelectionTooltip(); + + postContainer.on('click', '[component="post/quote"]', function () { + onQuoteClicked($(this), tid); + }); + + // PostContainer.on('click', '[component="post/resolve"]', function () { + // onResolvedClicked($(this)); + // }); + + postContainer.on('click', '[component="post/resolve"]', function () { + return onResolveClicked(getData($(this), 'data-pid')); + }); + + postContainer.on('click', '[component="post/reply"]', function () { + onReplyClicked($(this), tid); + }); + + $('.topic').on('click', '[component="topic/reply"]', function (e) { + e.preventDefault(); + onReplyClicked($(this), tid); + }); + + $('.topic').on('click', '[component="topic/reply-as-topic"]', () => { + translator.translate('[[topic:link_back, ' + ajaxify.data.titleRaw + ', ' + config.relative_path + '/topic/' + ajaxify.data.slug + ']]', body => { + hooks.fire('action:composer.topic.new', { + cid: ajaxify.data.cid, + body, + }); + }); + }); + + postContainer.on('click', '[component="post/bookmark"]', function () { + return bookmarkPost($(this), getData($(this), 'data-pid')); + }); + + postContainer.on('click', '[component="post/pin"]', function () { + /* This is an event handler - and so doesn't have any interesting parameters or return types What's important is that element actually has a data-pid attribute. */ - console.assert(this.hasAttribute('data-pinned'), "Element didn't have data-pinned property!"); - const attributeValue = this.getAttribute('data-pinned'); - console.assert(attributeValue === 'true' || attributeValue === 'false', 'data-pinned is not true'); - - const dataPid = getData($(this), 'data-pid'); - console.assert(!(isNaN(dataPid)), 'Invalid data-pid.'); - // End of tests - - return pinPost($(this), getData($(this), 'data-pid')); - }); - - postContainer.on('click', '[component="post/upvote"]', function () { - return votes.toggleVote($(this), '.upvoted', 1); - }); - - postContainer.on('click', '[component="post/downvote"]', function () { - return votes.toggleVote($(this), '.downvoted', -1); - }); - - postContainer.on('click', '[component="post/vote-count"]', function () { - votes.showVotes(getData($(this), 'data-pid')); - }); - - postContainer.on('click', '[component="post/flag"]', function () { - const pid = getData($(this), 'data-pid'); - require(['flags'], function (flags) { - flags.showFlagModal({ - type: 'post', - id: pid, - }); - }); - }); - - postContainer.on('click', '[component="post/flagUser"]', function () { - const uid = getData($(this), 'data-uid'); - require(['flags'], function (flags) { - flags.showFlagModal({ - type: 'user', - id: uid, - }); - }); - }); - - postContainer.on('click', '[component="post/flagResolve"]', function () { - const flagId = $(this).attr('data-flagId'); - require(['flags'], function (flags) { - flags.resolve(flagId); - }); - }); - - postContainer.on('click', '[component="post/edit"]', function () { - const btn = $(this); - - const timestamp = parseInt(getData(btn, 'data-timestamp'), 10); - const postEditDuration = parseInt(ajaxify.data.postEditDuration, 10); - - if (checkDuration(postEditDuration, timestamp, 'post-edit-duration-expired')) { - hooks.fire('action:composer.post.edit', { - pid: getData(btn, 'data-pid'), - }); - } - }); - - if (config.enablePostHistory && ajaxify.data.privileges['posts:history']) { - postContainer.on('click', '[component="post/view-history"], [component="post/edit-indicator"]', function () { - const btn = $(this); - require(['forum/topic/diffs'], function (diffs) { - diffs.open(getData(btn, 'data-pid')); - }); - }); - } - - postContainer.on('click', '[component="post/delete"]', function () { - const btn = $(this); - const timestamp = parseInt(getData(btn, 'data-timestamp'), 10); - const postDeleteDuration = parseInt(ajaxify.data.postDeleteDuration, 10); - if (checkDuration(postDeleteDuration, timestamp, 'post-delete-duration-expired')) { - togglePostDelete($(this)); - } - }); - - function checkDuration(duration, postTimestamp, languageKey) { - if (!ajaxify.data.privileges.isAdminOrMod && duration && Date.now() - postTimestamp > duration * 1000) { - const numDays = Math.floor(duration / 86400); - const numHours = Math.floor((duration % 86400) / 3600); - const numMinutes = Math.floor(((duration % 86400) % 3600) / 60); - const numSeconds = ((duration % 86400) % 3600) % 60; - let msg = '[[error:' + languageKey + ', ' + duration + ']]'; - if (numDays) { - if (numHours) { - msg = '[[error:' + languageKey + '-days-hours, ' + numDays + ', ' + numHours + ']]'; - } else { - msg = '[[error:' + languageKey + '-days, ' + numDays + ']]'; - } - } else if (numHours) { - if (numMinutes) { - msg = '[[error:' + languageKey + '-hours-minutes, ' + numHours + ', ' + numMinutes + ']]'; - } else { - msg = '[[error:' + languageKey + '-hours, ' + numHours + ']]'; - } - } else if (numMinutes) { - if (numSeconds) { - msg = '[[error:' + languageKey + '-minutes-seconds, ' + numMinutes + ', ' + numSeconds + ']]'; - } else { - msg = '[[error:' + languageKey + '-minutes, ' + numMinutes + ']]'; - } - } - alerts.error(msg); - return false; - } - return true; - } - - postContainer.on('click', '[component="post/restore"]', function () { - togglePostDelete($(this)); - }); - - postContainer.on('click', '[component="post/purge"]', function () { - purgePost($(this)); - }); - - postContainer.on('click', '[component="post/move"]', function () { - const btn = $(this); - require(['forum/topic/move-post'], function (movePost) { - movePost.init(btn.parents('[data-pid]')); - }); - }); - - postContainer.on('click', '[component="post/change-owner"]', function () { - const btn = $(this); - require(['forum/topic/change-owner'], function (changeOwner) { - changeOwner.init(btn.parents('[data-pid]')); - }); - }); - - postContainer.on('click', '[component="post/ban-ip"]', function () { - const ip = $(this).attr('data-ip'); - socket.emit('blacklist.addRule', ip, function (err) { - if (err) { - return alerts.error(err); - } - alerts.success('[[admin/manage/blacklist:ban-ip]]'); - }); - }); - - postContainer.on('click', '[component="post/chat"]', function () { - openChat($(this)); - }); - } - - async function onReplyClicked(button, tid) { - const selectedNode = await getSelectedNode(); - - showStaleWarning(async function () { - let username = await getUserSlug(button); - if (getData(button, 'data-uid') === '0' || !getData(button, 'data-userslug')) { - username = ''; - } - - const toPid = button.is('[component="post/reply"]') ? getData(button, 'data-pid') : null; - const isQuoteToPid = !toPid || !selectedNode.pid || toPid === selectedNode.pid; - - if (selectedNode.text && isQuoteToPid) { - username = username || selectedNode.username; - hooks.fire('action:composer.addQuote', { - tid: tid, - pid: toPid, - topicName: ajaxify.data.titleRaw, - username: username, - text: selectedNode.text, - selectedPid: selectedNode.pid, - }); - } else { - hooks.fire('action:composer.post.new', { - tid: tid, - pid: toPid, - topicName: ajaxify.data.titleRaw, - text: username ? username + ' ' : ($('[component="topic/quickreply/text"]').val() || ''), - }); - } - }); - } - - async function onQuoteClicked(button, tid) { - const selectedNode = await getSelectedNode(); - - showStaleWarning(async function () { - const username = await getUserSlug(button); - const toPid = getData(button, 'data-pid'); - - function quote(text) { - hooks.fire('action:composer.addQuote', { - tid: tid, - pid: toPid, - username: username, - topicName: ajaxify.data.titleRaw, - text: text, - }); - } - - if (selectedNode.text && toPid && toPid === selectedNode.pid) { - return quote(selectedNode.text); - } - socket.emit('posts.getRawPost', toPid, function (err, post) { - if (err) { - return alerts.error(err); - } - - quote(post); - }); - }); - } - // async function onResolvedClicked(button) { - // button.html(' Resolved'); - // } - - function onResolveClicked(pid) { - const method = 'put'; - - api[method](`/posts/${pid}/resolve`, undefined, function (err) { - if (err) { - return alerts.error(err); - } - hooks.fire(`action:post.resolve`, { pid: pid }); - }); - return false; - } - - async function getSelectedNode() { - let selectedText = ''; - let selectedPid; - let username = ''; - const selection = window.getSelection ? window.getSelection() : document.selection.createRange(); - const postContents = $('[component="post"] [component="post/content"]'); - let content; - postContents.each(function (index, el) { - if (selection && selection.containsNode && el && selection.containsNode(el, true)) { - content = el; - } - }); - - if (content) { - const bounds = document.createRange(); - bounds.selectNodeContents(content); - const range = selection.getRangeAt(0).cloneRange(); - if (range.compareBoundaryPoints(Range.START_TO_START, bounds) < 0) { - range.setStart(bounds.startContainer, bounds.startOffset); - } - if (range.compareBoundaryPoints(Range.END_TO_END, bounds) > 0) { - range.setEnd(bounds.endContainer, bounds.endOffset); - } - bounds.detach(); - selectedText = range.toString(); - const postEl = $(content).parents('[component="post"]'); - selectedPid = postEl.attr('data-pid'); - username = await getUserSlug($(content)); - range.detach(); - } - return { text: selectedText, pid: selectedPid, username: username }; - } - - function bookmarkPost(button, pid) { - const method = button.attr('data-bookmarked') === 'false' ? 'put' : 'del'; - - api[method](`/posts/${pid}/bookmark`, undefined, function (err) { - if (err) { - return alerts.error(err); - } - const type = method === 'put' ? 'bookmark' : 'unbookmark'; - hooks.fire(`action:post.${type}`, { pid: pid }); - }); - return false; - } - - function pinPost(button, pid) { - /* + console.assert(Object.hasOwn(this.dataset, 'pinned'), 'Element didn\'t have data-pinned property!'); + const attributeValue = this.dataset.pinned; + console.assert(attributeValue === 'true' || attributeValue === 'false', 'data-pinned is not true'); + + const dataPid = getData($(this), 'data-pid'); + console.assert(!(isNaN(dataPid)), 'Invalid data-pid.'); + // End of tests + + return pinPost($(this), getData($(this), 'data-pid')); + }); + + postContainer.on('click', '[component="post/upvote"]', function () { + return votes.toggleVote($(this), '.upvoted', 1); + }); + + postContainer.on('click', '[component="post/downvote"]', function () { + return votes.toggleVote($(this), '.downvoted', -1); + }); + + postContainer.on('click', '[component="post/vote-count"]', function () { + votes.showVotes(getData($(this), 'data-pid')); + }); + + postContainer.on('click', '[component="post/flag"]', function () { + const pid = getData($(this), 'data-pid'); + require(['flags'], flags => { + flags.showFlagModal({ + type: 'post', + id: pid, + }); + }); + }); + + postContainer.on('click', '[component="post/flagUser"]', function () { + const uid = getData($(this), 'data-uid'); + require(['flags'], flags => { + flags.showFlagModal({ + type: 'user', + id: uid, + }); + }); + }); + + postContainer.on('click', '[component="post/flagResolve"]', function () { + const flagId = $(this).attr('data-flagId'); + require(['flags'], flags => { + flags.resolve(flagId); + }); + }); + + postContainer.on('click', '[component="post/edit"]', function () { + const button = $(this); + + const timestamp = Number.parseInt(getData(button, 'data-timestamp'), 10); + const postEditDuration = Number.parseInt(ajaxify.data.postEditDuration, 10); + + if (checkDuration(postEditDuration, timestamp, 'post-edit-duration-expired')) { + hooks.fire('action:composer.post.edit', { + pid: getData(button, 'data-pid'), + }); + } + }); + + if (config.enablePostHistory && ajaxify.data.privileges['posts:history']) { + postContainer.on('click', '[component="post/view-history"], [component="post/edit-indicator"]', function () { + const button = $(this); + require(['forum/topic/diffs'], diffs => { + diffs.open(getData(button, 'data-pid')); + }); + }); + } + + postContainer.on('click', '[component="post/delete"]', function () { + const button = $(this); + const timestamp = Number.parseInt(getData(button, 'data-timestamp'), 10); + const postDeleteDuration = Number.parseInt(ajaxify.data.postDeleteDuration, 10); + if (checkDuration(postDeleteDuration, timestamp, 'post-delete-duration-expired')) { + togglePostDelete($(this)); + } + }); + + function checkDuration(duration, postTimestamp, languageKey) { + if (!ajaxify.data.privileges.isAdminOrMod && duration && Date.now() - postTimestamp > duration * 1000) { + const numberDays = Math.floor(duration / 86_400); + const numberHours = Math.floor((duration % 86_400) / 3600); + const numberMinutes = Math.floor(((duration % 86_400) % 3600) / 60); + const numberSeconds = ((duration % 86_400) % 3600) % 60; + let message = '[[error:' + languageKey + ', ' + duration + ']]'; + if (numberDays) { + message = numberHours ? '[[error:' + languageKey + '-days-hours, ' + numberDays + ', ' + numberHours + ']]' : '[[error:' + languageKey + '-days, ' + numberDays + ']]'; + } else if (numberHours) { + message = numberMinutes ? '[[error:' + languageKey + '-hours-minutes, ' + numberHours + ', ' + numberMinutes + ']]' : '[[error:' + languageKey + '-hours, ' + numberHours + ']]'; + } else if (numberMinutes) { + message = numberSeconds ? '[[error:' + languageKey + '-minutes-seconds, ' + numberMinutes + ', ' + numberSeconds + ']]' : '[[error:' + languageKey + '-minutes, ' + numberMinutes + ']]'; + } + + alerts.error(message); + return false; + } + + return true; + } + + postContainer.on('click', '[component="post/restore"]', function () { + togglePostDelete($(this)); + }); + + postContainer.on('click', '[component="post/purge"]', function () { + purgePost($(this)); + }); + + postContainer.on('click', '[component="post/move"]', function () { + const button = $(this); + require(['forum/topic/move-post'], movePost => { + movePost.init(button.parents('[data-pid]')); + }); + }); + + postContainer.on('click', '[component="post/change-owner"]', function () { + const button = $(this); + require(['forum/topic/change-owner'], changeOwner => { + changeOwner.init(button.parents('[data-pid]')); + }); + }); + + postContainer.on('click', '[component="post/ban-ip"]', function () { + const ip = $(this).attr('data-ip'); + socket.emit('blacklist.addRule', ip, error => { + if (error) { + return alerts.error(error); + } + + alerts.success('[[admin/manage/blacklist:ban-ip]]'); + }); + }); + + postContainer.on('click', '[component="post/chat"]', function () { + openChat($(this)); + }); + } + + async function onReplyClicked(button, tid) { + const selectedNode = await getSelectedNode(); + + showStaleWarning(async () => { + let username = await getUserSlug(button); + if (getData(button, 'data-uid') === '0' || !getData(button, 'data-userslug')) { + username = ''; + } + + const toPid = button.is('[component="post/reply"]') ? getData(button, 'data-pid') : null; + const isQuoteToPid = !toPid || !selectedNode.pid || toPid === selectedNode.pid; + + if (selectedNode.text && isQuoteToPid) { + username ||= selectedNode.username; + hooks.fire('action:composer.addQuote', { + tid, + pid: toPid, + topicName: ajaxify.data.titleRaw, + username, + text: selectedNode.text, + selectedPid: selectedNode.pid, + }); + } else { + hooks.fire('action:composer.post.new', { + tid, + pid: toPid, + topicName: ajaxify.data.titleRaw, + text: username ? username + ' ' : ($('[component="topic/quickreply/text"]').val() || ''), + }); + } + }); + } + + async function onQuoteClicked(button, tid) { + const selectedNode = await getSelectedNode(); + + showStaleWarning(async () => { + const username = await getUserSlug(button); + const toPid = getData(button, 'data-pid'); + + function quote(text) { + hooks.fire('action:composer.addQuote', { + tid, + pid: toPid, + username, + topicName: ajaxify.data.titleRaw, + text, + }); + } + + if (selectedNode.text && toPid && toPid === selectedNode.pid) { + return quote(selectedNode.text); + } + + socket.emit('posts.getRawPost', toPid, (error, post) => { + if (error) { + return alerts.error(error); + } + + quote(post); + }); + }); + } + // Async function onResolvedClicked(button) { + // button.html(' Resolved'); + // } + + function onResolveClicked(pid) { + const method = 'put'; + + api[method](`/posts/${pid}/resolve`, undefined, error => { + if (error) { + return alerts.error(error); + } + + hooks.fire('action:post.resolve', {pid}); + }); + return false; + } + + async function getSelectedNode() { + let selectedText = ''; + let selectedPid; + let username = ''; + const selection = window.getSelection ? window.getSelection() : document.selection.createRange(); + const postContents = $('[component="post"] [component="post/content"]'); + let content; + postContents.each((index, element) => { + if (selection && selection.containsNode && element && selection.containsNode(element, true)) { + content = element; + } + }); + + if (content) { + const bounds = document.createRange(); + bounds.selectNodeContents(content); + const range = selection.getRangeAt(0).cloneRange(); + if (range.compareBoundaryPoints(Range.START_TO_START, bounds) < 0) { + range.setStart(bounds.startContainer, bounds.startOffset); + } + + if (range.compareBoundaryPoints(Range.END_TO_END, bounds) > 0) { + range.setEnd(bounds.endContainer, bounds.endOffset); + } + + bounds.detach(); + selectedText = range.toString(); + const postElement = $(content).parents('[component="post"]'); + selectedPid = postElement.attr('data-pid'); + username = await getUserSlug($(content)); + range.detach(); + } + + return {text: selectedText, pid: selectedPid, username}; + } + + function bookmarkPost(button, pid) { + const method = button.attr('data-bookmarked') === 'false' ? 'put' : 'del'; + + api[method](`/posts/${pid}/bookmark`, undefined, error => { + if (error) { + return alerts.error(error); + } + + const type = method === 'put' ? 'bookmark' : 'unbookmark'; + hooks.fire(`action:post.${type}`, {pid}); + }); + return false; + } + + function pinPost(button, pid) { + /* Parameters: an HTML element representing the button we pressed, and a pid of the post we're interacting with. @@ -414,198 +412,199 @@ define('forum/topic/postTools', [ if everything goes well, but fires a hook. */ - // We only really care about checking that the pid is a number - console.assert(!(isNaN(pid)), 'pid argument to pinPost is not a valid number'); - - const method = button.attr('data-pinned') === 'false' ? 'put' : 'del'; - - // Make an API call as above to get the post pinned... - api[method](`/posts/${pid}/pin`, undefined, function (err) { - if (err) { - return alerts.error(err); - } - const type = method === 'put' ? 'pin' : 'unpin'; - hooks.fire(`action:post.${type}`, { pid: pid }); - }); - return false; - } - - function getData(button, data) { - return button.parents('[data-pid]').attr(data); - } - - function getUserSlug(button) { - return new Promise((resolve) => { - let slug = ''; - if (button.attr('component') === 'topic/reply') { - resolve(slug); - return; - } - const post = button.parents('[data-pid]'); - if (post.length) { - require(['slugify'], function (slugify) { - slug = slugify(post.attr('data-username'), true); - if (!slug) { - if (post.attr('data-uid') !== '0') { - slug = '[[global:former_user]]'; - } else { - slug = '[[global:guest]]'; - } - } - if (slug && slug !== '[[global:former_user]]' && slug !== '[[global:guest]]') { - slug = '@' + slug; - } - resolve(slug); - }); - return; - } - - resolve(slug); - }); - } - - function togglePostDelete(button) { - const pid = getData(button, 'data-pid'); - const postEl = components.get('post', 'pid', pid); - const action = !postEl.hasClass('deleted') ? 'delete' : 'restore'; - - postAction(action, pid); - } - - function purgePost(button) { - postAction('purge', getData(button, 'data-pid')); - } - - async function postAction(action, pid) { - ({ action } = await hooks.fire(`static:post.${action}`, { action, pid })); - if (!action) { - return; - } - - bootbox.confirm('[[topic:post_' + action + '_confirm]]', function (confirm) { - if (!confirm) { - return; - } - - const route = action === 'purge' ? '' : '/state'; - const method = action === 'restore' ? 'put' : 'del'; - api[method](`/posts/${pid}${route}`).catch(alerts.error); - }); - } - - function openChat(button) { - const post = button.parents('[data-pid]'); - require(['chat'], function (chat) { - chat.newChat(post.attr('data-uid')); - }); - button.parents('.btn-group').find('.dropdown-toggle').click(); - return false; - } - - function showStaleWarning(callback) { - const staleThreshold = - Math.min(Date.now() - (1000 * 60 * 60 * 24 * ajaxify.data.topicStaleDays), 8640000000000000); - if (staleReplyAnyway || ajaxify.data.lastposttime >= staleThreshold) { - return callback(); - } - - const warning = bootbox.dialog({ - title: '[[topic:stale.title]]', - message: '[[topic:stale.warning]]', - buttons: { - reply: { - label: '[[topic:stale.reply_anyway]]', - className: 'btn-link', - callback: function () { - staleReplyAnyway = true; - callback(); - }, - }, - create: { - label: '[[topic:stale.create]]', - className: 'btn-primary', - callback: function () { - translator.translate('[[topic:link_back, ' + ajaxify.data.title + ', ' + config.relative_path + '/topic/' + ajaxify.data.slug + ']]', function (body) { - hooks.fire('action:composer.topic.new', { - cid: ajaxify.data.cid, - body: body, - fromStaleTopic: true, - }); - }); - }, - }, - }, - }); - - warning.modal(); - } - - const selectionChangeFn = utils.debounce(selectionChange, 100); - - function handleSelectionTooltip() { - if (!ajaxify.data.privileges['topics:reply']) { - return; - } - - hooks.onPage('action:posts.loaded', delayedTooltip); - - $(document).off('selectionchange', selectionChangeFn).on('selectionchange', selectionChangeFn); - } - - function selectionChange() { - const selectionEmpty = window.getSelection().toString() === ''; - if (selectionEmpty) { - $('[component="selection/tooltip"]').addClass('hidden'); - } else { - delayedTooltip(); - } - } - - async function delayedTooltip() { - let selectionTooltip = $('[component="selection/tooltip"]'); - selectionTooltip.addClass('hidden'); - if (selectionTooltip.attr('data-ajaxify') === '1') { - selectionTooltip.remove(); - return; - } - - const selection = window.getSelection(); - if (selection.focusNode && selection.type === 'Range' && ajaxify.data.template.topic) { - const focusNode = $(selection.focusNode); - const anchorNode = $(selection.anchorNode); - const firstPid = anchorNode.parents('[data-pid]').attr('data-pid'); - const lastPid = focusNode.parents('[data-pid]').attr('data-pid'); - if (firstPid !== lastPid || !focusNode.parents('[component="post/content"]').length || !anchorNode.parents('[component="post/content"]').length) { - return; - } - const postEl = focusNode.parents('[data-pid]'); - const selectionRange = selection.getRangeAt(0); - if (!postEl.length || selectionRange.collapsed) { - return; - } - const rects = selectionRange.getClientRects(); - const lastRect = rects[rects.length - 1]; - - if (!selectionTooltip.length) { - selectionTooltip = await app.parseAndTranslate('partials/topic/selection-tooltip', ajaxify.data); - selectionTooltip.addClass('hidden').appendTo('body'); - } - selectionTooltip.off('click').on('click', '[component="selection/tooltip/quote"]', function () { - selectionTooltip.addClass('hidden'); - onQuoteClicked(postEl.find('[component="post/quote"]'), ajaxify.data.tid); - }); - selectionTooltip.removeClass('hidden'); - $(window).one('action:ajaxify.start', function () { - selectionTooltip.attr('data-ajaxify', 1).addClass('hidden'); - $(document).off('selectionchange', selectionChangeFn); - }); - const tooltipWidth = selectionTooltip.outerWidth(true); - selectionTooltip.css({ - top: lastRect.bottom + $(window).scrollTop(), - left: tooltipWidth > lastRect.width ? lastRect.left : lastRect.left + lastRect.width - tooltipWidth, - }); - } - } - - return PostTools; + // We only really care about checking that the pid is a number + console.assert(!(isNaN(pid)), 'pid argument to pinPost is not a valid number'); + + const method = button.attr('data-pinned') === 'false' ? 'put' : 'del'; + + // Make an API call as above to get the post pinned... + api[method](`/posts/${pid}/pin`, undefined, error => { + if (error) { + return alerts.error(error); + } + + const type = method === 'put' ? 'pin' : 'unpin'; + hooks.fire(`action:post.${type}`, {pid}); + }); + return false; + } + + function getData(button, data) { + return button.parents('[data-pid]').attr(data); + } + + function getUserSlug(button) { + return new Promise(resolve => { + let slug = ''; + if (button.attr('component') === 'topic/reply') { + resolve(slug); + return; + } + + const post = button.parents('[data-pid]'); + if (post.length > 0) { + require(['slugify'], slugify => { + slug = slugify(post.attr('data-username'), true); + slug ||= post.attr('data-uid') === '0' ? '[[global:guest]]' : '[[global:former_user]]'; + + if (slug && slug !== '[[global:former_user]]' && slug !== '[[global:guest]]') { + slug = '@' + slug; + } + + resolve(slug); + }); + return; + } + + resolve(slug); + }); + } + + function togglePostDelete(button) { + const pid = getData(button, 'data-pid'); + const postElement = components.get('post', 'pid', pid); + const action = postElement.hasClass('deleted') ? 'restore' : 'delete'; + + postAction(action, pid); + } + + function purgePost(button) { + postAction('purge', getData(button, 'data-pid')); + } + + async function postAction(action, pid) { + ({action} = await hooks.fire(`static:post.${action}`, {action, pid})); + if (!action) { + return; + } + + bootbox.confirm('[[topic:post_' + action + '_confirm]]', confirm => { + if (!confirm) { + return; + } + + const route = action === 'purge' ? '' : '/state'; + const method = action === 'restore' ? 'put' : 'del'; + api[method](`/posts/${pid}${route}`).catch(alerts.error); + }); + } + + function openChat(button) { + const post = button.parents('[data-pid]'); + require(['chat'], chat => { + chat.newChat(post.attr('data-uid')); + }); + button.parents('.btn-group').find('.dropdown-toggle').click(); + return false; + } + + function showStaleWarning(callback) { + const staleThreshold + = Math.min(Date.now() - (1000 * 60 * 60 * 24 * ajaxify.data.topicStaleDays), 8_640_000_000_000_000); + if (staleReplyAnyway || ajaxify.data.lastposttime >= staleThreshold) { + return callback(); + } + + const warning = bootbox.dialog({ + title: '[[topic:stale.title]]', + message: '[[topic:stale.warning]]', + buttons: { + reply: { + label: '[[topic:stale.reply_anyway]]', + className: 'btn-link', + callback() { + staleReplyAnyway = true; + callback(); + }, + }, + create: { + label: '[[topic:stale.create]]', + className: 'btn-primary', + callback() { + translator.translate('[[topic:link_back, ' + ajaxify.data.title + ', ' + config.relative_path + '/topic/' + ajaxify.data.slug + ']]', body => { + hooks.fire('action:composer.topic.new', { + cid: ajaxify.data.cid, + body, + fromStaleTopic: true, + }); + }); + }, + }, + }, + }); + + warning.modal(); + } + + const selectionChangeFunction = utils.debounce(selectionChange, 100); + + function handleSelectionTooltip() { + if (!ajaxify.data.privileges['topics:reply']) { + return; + } + + hooks.onPage('action:posts.loaded', delayedTooltip); + + $(document).off('selectionchange', selectionChangeFunction).on('selectionchange', selectionChangeFunction); + } + + function selectionChange() { + const selectionEmpty = window.getSelection().toString() === ''; + if (selectionEmpty) { + $('[component="selection/tooltip"]').addClass('hidden'); + } else { + delayedTooltip(); + } + } + + async function delayedTooltip() { + let selectionTooltip = $('[component="selection/tooltip"]'); + selectionTooltip.addClass('hidden'); + if (selectionTooltip.attr('data-ajaxify') === '1') { + selectionTooltip.remove(); + return; + } + + const selection = window.getSelection(); + if (selection.focusNode && selection.type === 'Range' && ajaxify.data.template.topic) { + const focusNode = $(selection.focusNode); + const anchorNode = $(selection.anchorNode); + const firstPid = anchorNode.parents('[data-pid]').attr('data-pid'); + const lastPid = focusNode.parents('[data-pid]').attr('data-pid'); + if (firstPid !== lastPid || focusNode.parents('[component="post/content"]').length === 0 || anchorNode.parents('[component="post/content"]').length === 0) { + return; + } + + const postElement = focusNode.parents('[data-pid]'); + const selectionRange = selection.getRangeAt(0); + if (postElement.length === 0 || selectionRange.collapsed) { + return; + } + + const rects = selectionRange.getClientRects(); + const lastRect = rects.at(-1); + + if (selectionTooltip.length === 0) { + selectionTooltip = await app.parseAndTranslate('partials/topic/selection-tooltip', ajaxify.data); + selectionTooltip.addClass('hidden').appendTo('body'); + } + + selectionTooltip.off('click').on('click', '[component="selection/tooltip/quote"]', () => { + selectionTooltip.addClass('hidden'); + onQuoteClicked(postElement.find('[component="post/quote"]'), ajaxify.data.tid); + }); + selectionTooltip.removeClass('hidden'); + $(window).one('action:ajaxify.start', () => { + selectionTooltip.attr('data-ajaxify', 1).addClass('hidden'); + $(document).off('selectionchange', selectionChangeFunction); + }); + const tooltipWidth = selectionTooltip.outerWidth(true); + selectionTooltip.css({ + top: lastRect.bottom + $(window).scrollTop(), + left: tooltipWidth > lastRect.width ? lastRect.left : lastRect.left + lastRect.width - tooltipWidth, + }); + } + } + + return PostTools; }); diff --git a/public/src/client/topic/posts.js b/public/src/client/topic/posts.js index 4136354..7a71bf9 100644 --- a/public/src/client/topic/posts.js +++ b/public/src/client/topic/posts.js @@ -1,443 +1,444 @@ 'use strict'; - define('forum/topic/posts', [ - 'forum/pagination', - 'forum/infinitescroll', - 'forum/topic/postTools', - 'forum/topic/images', - 'navigator', - 'components', - 'translator', - 'hooks', - 'helpers', -], function (pagination, infinitescroll, postTools, images, navigator, components, translator, hooks, helpers) { - const Posts = { }; - - Posts.signaturesShown = {}; - - Posts.onNewPost = function (data) { - if ( - !data || - !data.posts || - !data.posts.length || - parseInt(data.posts[0].tid, 10) !== parseInt(ajaxify.data.tid, 10) - ) { - return; - } - - data.loggedIn = !!app.user.uid; - data.privileges = ajaxify.data.privileges; - - // if not a scheduled topic, prevent timeago in future by setting timestamp to 1 sec behind now - data.posts[0].timestamp = data.posts[0].topic.scheduled ? data.posts[0].timestamp : Date.now() - 1000; - data.posts[0].timestampISO = utils.toISOString(data.posts[0].timestamp); - - Posts.modifyPostsByPrivileges(data.posts); - - updatePostCounts(data.posts); - - updatePostIndices(data.posts); - - ajaxify.data.postcount += 1; - postTools.updatePostCount(ajaxify.data.postcount); - - if (config.usePagination) { - onNewPostPagination(data); - } else { - onNewPostInfiniteScroll(data); - } - - require(['forum/topic/replies'], function (replies) { - replies.onNewPost(data); - }); - }; - - Posts.modifyPostsByPrivileges = function (posts) { - posts.forEach(function (post) { - post.selfPost = !!app.user.uid && parseInt(post.uid, 10) === parseInt(app.user.uid, 10); - post.topicOwnerPost = parseInt(post.uid, 10) === parseInt(ajaxify.data.uid, 10); - - post.display_edit_tools = (ajaxify.data.privileges['posts:edit'] && post.selfPost) || ajaxify.data.privileges.isAdminOrMod; - post.display_delete_tools = (ajaxify.data.privileges['posts:delete'] && post.selfPost) || ajaxify.data.privileges.isAdminOrMod; - post.display_moderator_tools = post.display_edit_tools || post.display_delete_tools; - post.display_move_tools = ajaxify.data.privileges.isAdminOrMod; - post.display_post_menu = ajaxify.data.privileges.isAdminOrMod || - (post.selfPost && !ajaxify.data.locked && !post.deleted) || - (post.selfPost && post.deleted && parseInt(post.deleterUid, 10) === parseInt(app.user.uid, 10)) || - ((app.user.uid || ajaxify.data.postSharing.length) && !post.deleted); - }); - }; - - function updatePostCounts(posts) { - for (let i = 0; i < posts.length; i += 1) { - const cmp = components.get('user/postcount', posts[i].uid); - cmp.html(parseInt(cmp.attr('data-postcount'), 10) + 1); - utils.addCommasToNumbers(cmp); - } - } - - function updatePostIndices(posts) { - if (config.topicPostSort === 'newest_to_oldest') { - posts[0].index = 1; - components.get('post').not('[data-index=0]').each(function () { - const newIndex = parseInt($(this).attr('data-index'), 10) + 1; - $(this).attr('data-index', newIndex); - }); - } - } - - function onNewPostPagination(data) { - function scrollToPost() { - scrollToPostIfSelf(data.posts[0]); - } - - const posts = data.posts; - - ajaxify.data.pagination.pageCount = Math.max(1, Math.ceil(posts[0].topic.postcount / config.postsPerPage)); - const direction = config.topicPostSort === 'oldest_to_newest' || config.topicPostSort === 'most_votes' ? 1 : -1; - - const isPostVisible = ( - ajaxify.data.pagination.currentPage === ajaxify.data.pagination.pageCount && - direction === 1 - ) || (ajaxify.data.pagination.currentPage === 1 && direction === -1); - - if (isPostVisible) { - const repliesSelector = $('[component="post"]:not([data-index=0]), [component="topic/event"]'); - createNewPosts(data, repliesSelector, direction, false, scrollToPost); - } else if (ajaxify.data.scrollToMyPost && parseInt(posts[0].uid, 10) === parseInt(app.user.uid, 10)) { - // https://github.com/NodeBB/NodeBB/issues/5004#issuecomment-247157441 - setTimeout(function () { - pagination.loadPage(ajaxify.data.pagination.pageCount, scrollToPost); - }, 250); - } else { - updatePagination(); - } - } - - function updatePagination() { - $.get(config.relative_path + '/api/topic/pagination/' + ajaxify.data.tid, { page: ajaxify.data.pagination.currentPage }, function (paginationData) { - app.parseAndTranslate('partials/paginator', paginationData, function (html) { - $('[component="pagination"]').after(html).remove(); - }); - }); - } - - function onNewPostInfiniteScroll(data) { - const direction = (config.topicPostSort === 'oldest_to_newest' || config.topicPostSort === 'most_votes') ? 1 : -1; - - const isPreviousPostAdded = $('[component="post"][data-index="' + (data.posts[0].index - 1) + '"]').length; - if (!isPreviousPostAdded && (!data.posts[0].selfPost || !ajaxify.data.scrollToMyPost)) { - return; - } - - if (!isPreviousPostAdded && data.posts[0].selfPost) { - return ajaxify.go('post/' + data.posts[0].pid); - } - const repliesSelector = $('[component="topic"]>[component="post"]:not([data-index=0]), [component="topic"]>[component="topic/event"]'); - createNewPosts(data, repliesSelector, direction, false, function (html) { - if (html) { - html.addClass('new'); - } - scrollToPostIfSelf(data.posts[0]); - }); - } - - function scrollToPostIfSelf(post) { - if (post.selfPost && ajaxify.data.scrollToMyPost) { - navigator.scrollBottom(post.index); - } - } - - function createNewPosts(data, repliesSelector, direction, userScrolled, callback) { - callback = callback || function () {}; - if (!data || (data.posts && !data.posts.length)) { - return callback(); - } - - function removeAlreadyAddedPosts() { - const newPosts = $('[component="post"].new'); - - if (newPosts.length === data.posts.length) { - let allSamePids = true; - newPosts.each(function (index, el) { - if (parseInt($(el).attr('data-pid'), 10) !== parseInt(data.posts[index].pid, 10)) { - allSamePids = false; - } - }); - - if (allSamePids) { - newPosts.each(function () { - $(this).removeClass('new'); - }); - data.posts.length = 0; - return; - } - } - - if (newPosts.length && data.posts.length > 1) { - data.posts.forEach(function (post) { - const p = components.get('post', 'pid', post.pid); - if (p.hasClass('new')) { - p.remove(); - } - }); - } - - data.posts = data.posts.filter(function (post) { - return post.index === -1 || $('[component="post"][data-pid="' + post.pid + '"]').length === 0; - }); - } - - removeAlreadyAddedPosts(); - - if (!data.posts.length) { - return callback(); - } - - let after; - let before; - - if (direction > 0 && repliesSelector.length) { - after = repliesSelector.last(); - } else if (direction < 0 && repliesSelector.length) { - before = repliesSelector.first(); - } - - hooks.fire('action:posts.loading', { posts: data.posts, after: after, before: before }); - - app.parseAndTranslate('topic', 'posts', Object.assign({}, ajaxify.data, data), function (html) { - html = html.filter(function () { - const $this = $(this); - const pid = $this.attr('data-pid'); - const index = parseInt($this.attr('data-index'), 10); - const isPost = $this.is('[component="post"]'); - return !isPost || index === -1 || (pid && $('[component="post"][data-pid="' + pid + '"]').length === 0); - }); - - if (after) { - html.insertAfter(after); - } else if (before) { - // Save document height and position for future reference (about 5 lines down) - const height = $(document).height(); - const scrollTop = $(window).scrollTop(); - - html.insertBefore(before); - - // Now restore the relative position the user was on prior to new post insertion - if (userScrolled || scrollTop > 0) { - $(window).scrollTop(scrollTop + ($(document).height() - height)); - } - } else { - components.get('topic').append(html); - } - - const removedEls = infinitescroll.removeExtra($('[component="post"]'), direction, Math.max(20, config.postsPerPage * 2)); - removeNecroPostMessages(removedEls); - - hooks.fire('action:posts.loaded', { posts: data.posts }); - - Posts.onNewPostsAddedToDom(html); - - callback(html); - }); - } - - Posts.loadMorePosts = function (direction) { - if (!components.get('topic').length || navigator.scrollActive) { - return; - } - - const replies = components.get('topic').find(components.get('post').not('[data-index=0]').not('.new')); - const afterEl = direction > 0 ? replies.last() : replies.first(); - const after = parseInt(afterEl.attr('data-index'), 10) || 0; - - const tid = ajaxify.data.tid; - if (!utils.isNumber(tid) || !utils.isNumber(after) || (direction < 0 && components.get('post', 'index', 0).length)) { - return; - } - - const indicatorEl = $('.loading-indicator'); - if (!indicatorEl.is(':animated')) { - indicatorEl.fadeIn(); - } - - infinitescroll.loadMore('topics.loadMore', { - tid: tid, - after: after + (direction > 0 ? 1 : 0), - count: config.postsPerPage, - direction: direction, - topicPostSort: config.topicPostSort, - }, function (data, done) { - indicatorEl.fadeOut(); - - if (data && data.posts && data.posts.length) { - const repliesSelector = $('[component="post"]:not([data-index=0]):not(.new), [component="topic/event"]'); - createNewPosts(data, repliesSelector, direction, true, done); - } else { - navigator.update(); - done(); - } - }); - }; - - Posts.onTopicPageLoad = function (posts) { - handlePrivateUploads(posts); - images.wrapImagesInLinks(posts); - hideDuplicateSignatures(posts); - Posts.showBottomPostBar(); - posts.find('[component="post/content"] img:not(.not-responsive)').addClass('img-responsive'); - Posts.addBlockquoteEllipses(posts); - hidePostToolsForDeletedPosts(posts); - addNecroPostMessage(); - }; - - Posts.addTopicEvents = function (events) { - if (config.topicPostSort === 'most_votes') { - return; - } - const html = helpers.renderEvents.call(ajaxify.data, events); - translator.translate(html, (translated) => { - if (config.topicPostSort === 'oldest_to_newest') { - $('[component="topic"]').append(translated); - } else if (config.topicPostSort === 'newest_to_oldest') { - const mainPost = $('[component="topic"] [component="post"][data-index="0"]'); - if (mainPost.length) { - $(translated).insertAfter(mainPost); - } else { - $('[component="topic"]').prepend(translated); - } - } - - $('[component="topic/event"] .timeago').timeago(); - }); - }; - - function addNecroPostMessage() { - const necroThreshold = ajaxify.data.necroThreshold * 24 * 60 * 60 * 1000; - if (!necroThreshold || (config.topicPostSort !== 'newest_to_oldest' && config.topicPostSort !== 'oldest_to_newest')) { - return; - } - - const postEls = $('[component="post"]').toArray(); - postEls.forEach(function (post) { - post = $(post); - const prev = post.prev('[component="post"]'); - if (post.is(':has(.necro-post)') || !prev.length) { - return; - } - if (config.topicPostSort === 'newest_to_oldest' && parseInt(prev.attr('data-index'), 10) === 0) { - return; - } - - const diff = post.attr('data-timestamp') - prev.attr('data-timestamp'); - if (Math.abs(diff) >= necroThreshold) { - const suffixAgo = $.timeago.settings.strings.suffixAgo; - const prefixAgo = $.timeago.settings.strings.prefixAgo; - const suffixFromNow = $.timeago.settings.strings.suffixFromNow; - const prefixFromNow = $.timeago.settings.strings.prefixFromNow; - - $.timeago.settings.strings.suffixAgo = ''; - $.timeago.settings.strings.prefixAgo = ''; - $.timeago.settings.strings.suffixFromNow = ''; - $.timeago.settings.strings.prefixFromNow = ''; - - const translationText = (diff > 0 ? '[[topic:timeago_later,' : '[[topic:timeago_earlier,') + $.timeago.inWords(diff) + ']]'; - - $.timeago.settings.strings.suffixAgo = suffixAgo; - $.timeago.settings.strings.prefixAgo = prefixAgo; - $.timeago.settings.strings.suffixFromNow = suffixFromNow; - $.timeago.settings.strings.prefixFromNow = prefixFromNow; - app.parseAndTranslate('partials/topic/necro-post', { text: translationText }, function (html) { - html.attr('data-necro-post-index', prev.attr('data-index')); - html.insertBefore(post); - }); - } - }); - } - - function hideDuplicateSignatures(posts) { - if (ajaxify.data['signatures:hideDuplicates']) { - posts.each((index, el) => { - const signatureEl = $(el).find('[component="post/signature"]'); - const uid = signatureEl.attr('data-uid'); - if (Posts.signaturesShown[uid]) { - signatureEl.addClass('hidden'); - } else { - Posts.signaturesShown[uid] = true; - } - }); - } - } - - function removeNecroPostMessages(removedPostEls) { - removedPostEls.each((index, el) => { - $(`[data-necro-post-index="${$(el).attr('data-index')}"]`).remove(); - }); - } - - function handlePrivateUploads(posts) { - if (app.user.uid || !ajaxify.data.privateUploads) { - return; - } - - // Replace all requests for uploaded images/files with a login link - const loginEl = document.createElement('a'); - loginEl.className = 'login-required'; - loginEl.href = config.relative_path + '/login'; - - translator.translate('[[topic:login-to-view]]', function (translated) { - loginEl.appendChild(document.createTextNode(translated)); - posts.each(function (idx, postEl) { - $(postEl).find('[component="post/content"] img').each(function (idx, imgEl) { - imgEl = $(imgEl); - if (imgEl.attr('src').startsWith(config.relative_path + config.upload_url)) { - imgEl.replaceWith(loginEl.cloneNode(true)); - } - }); - }); - }); - } - - Posts.onNewPostsAddedToDom = function (posts) { - Posts.onTopicPageLoad(posts); - - app.createUserTooltips(posts); - - utils.addCommasToNumbers(posts.find('.formatted-number')); - utils.makeNumbersHumanReadable(posts.find('.human-readable-number')); - posts.find('.timeago').timeago(); - }; - - Posts.showBottomPostBar = function () { - const mainPost = components.get('post', 'index', 0); - const placeHolder = $('.post-bar-placeholder'); - const posts = $('[component="post"]'); - if (!!mainPost.length && posts.length > 1 && $('.post-bar').length < 2 && placeHolder.length) { - $('.post-bar').clone().insertAfter(placeHolder); - placeHolder.remove(); - } else if (mainPost.length && posts.length < 2) { - mainPost.find('.post-bar').remove(); - } - }; - - function hidePostToolsForDeletedPosts(posts) { - posts.each(function () { - if ($(this).hasClass('deleted')) { - postTools.toggle($(this).attr('data-pid'), true); - } - }); - } - - Posts.addBlockquoteEllipses = function (posts) { - const blockquotes = posts.find('[component="post/content"] > blockquote > blockquote'); - blockquotes.each(function () { - const $this = $(this); - if ($this.find(':hidden:not(br)').length && !$this.find('.toggle').length) { - $this.append(''); - } - }); - }; - - return Posts; + 'forum/pagination', + 'forum/infinitescroll', + 'forum/topic/postTools', + 'forum/topic/images', + 'navigator', + 'components', + 'translator', + 'hooks', + 'helpers', +], (pagination, infinitescroll, postTools, images, navigator, components, translator, hooks, helpers) => { + const Posts = {}; + + Posts.signaturesShown = {}; + + Posts.onNewPost = function (data) { + if ( + !data + || !data.posts + || data.posts.length === 0 + || Number.parseInt(data.posts[0].tid, 10) !== Number.parseInt(ajaxify.data.tid, 10) + ) { + return; + } + + data.loggedIn = Boolean(app.user.uid); + data.privileges = ajaxify.data.privileges; + + // If not a scheduled topic, prevent timeago in future by setting timestamp to 1 sec behind now + data.posts[0].timestamp = data.posts[0].topic.scheduled ? data.posts[0].timestamp : Date.now() - 1000; + data.posts[0].timestampISO = utils.toISOString(data.posts[0].timestamp); + + Posts.modifyPostsByPrivileges(data.posts); + + updatePostCounts(data.posts); + + updatePostIndices(data.posts); + + ajaxify.data.postcount += 1; + postTools.updatePostCount(ajaxify.data.postcount); + + if (config.usePagination) { + onNewPostPagination(data); + } else { + onNewPostInfiniteScroll(data); + } + + require(['forum/topic/replies'], replies => { + replies.onNewPost(data); + }); + }; + + Posts.modifyPostsByPrivileges = function (posts) { + for (const post of posts) { + post.selfPost = Boolean(app.user.uid) && Number.parseInt(post.uid, 10) === Number.parseInt(app.user.uid, 10); + post.topicOwnerPost = Number.parseInt(post.uid, 10) === Number.parseInt(ajaxify.data.uid, 10); + + post.display_edit_tools = (ajaxify.data.privileges['posts:edit'] && post.selfPost) || ajaxify.data.privileges.isAdminOrMod; + post.display_delete_tools = (ajaxify.data.privileges['posts:delete'] && post.selfPost) || ajaxify.data.privileges.isAdminOrMod; + post.display_moderator_tools = post.display_edit_tools || post.display_delete_tools; + post.display_move_tools = ajaxify.data.privileges.isAdminOrMod; + post.display_post_menu = ajaxify.data.privileges.isAdminOrMod + || (post.selfPost && !ajaxify.data.locked && !post.deleted) + || (post.selfPost && post.deleted && Number.parseInt(post.deleterUid, 10) === Number.parseInt(app.user.uid, 10)) + || ((app.user.uid || ajaxify.data.postSharing.length) && !post.deleted); + } + }; + + function updatePostCounts(posts) { + for (const post of posts) { + const cmp = components.get('user/postcount', post.uid); + cmp.html(Number.parseInt(cmp.attr('data-postcount'), 10) + 1); + utils.addCommasToNumbers(cmp); + } + } + + function updatePostIndices(posts) { + if (config.topicPostSort === 'newest_to_oldest') { + posts[0].index = 1; + components.get('post').not('[data-index=0]').each(function () { + const newIndex = Number.parseInt($(this).attr('data-index'), 10) + 1; + $(this).attr('data-index', newIndex); + }); + } + } + + function onNewPostPagination(data) { + function scrollToPost() { + scrollToPostIfSelf(data.posts[0]); + } + + const posts = data.posts; + + ajaxify.data.pagination.pageCount = Math.max(1, Math.ceil(posts[0].topic.postcount / config.postsPerPage)); + const direction = config.topicPostSort === 'oldest_to_newest' || config.topicPostSort === 'most_votes' ? 1 : -1; + + const isPostVisible = ( + ajaxify.data.pagination.currentPage === ajaxify.data.pagination.pageCount + && direction === 1 + ) || (ajaxify.data.pagination.currentPage === 1 && direction === -1); + + if (isPostVisible) { + const repliesSelector = $('[component="post"]:not([data-index=0]), [component="topic/event"]'); + createNewPosts(data, repliesSelector, direction, false, scrollToPost); + } else if (ajaxify.data.scrollToMyPost && Number.parseInt(posts[0].uid, 10) === Number.parseInt(app.user.uid, 10)) { + // https://github.com/NodeBB/NodeBB/issues/5004#issuecomment-247157441 + setTimeout(() => { + pagination.loadPage(ajaxify.data.pagination.pageCount, scrollToPost); + }, 250); + } else { + updatePagination(); + } + } + + function updatePagination() { + $.get(config.relative_path + '/api/topic/pagination/' + ajaxify.data.tid, {page: ajaxify.data.pagination.currentPage}, paginationData => { + app.parseAndTranslate('partials/paginator', paginationData, html => { + $('[component="pagination"]').after(html).remove(); + }); + }); + } + + function onNewPostInfiniteScroll(data) { + const direction = (config.topicPostSort === 'oldest_to_newest' || config.topicPostSort === 'most_votes') ? 1 : -1; + + const isPreviousPostAdded = $('[component="post"][data-index="' + (data.posts[0].index - 1) + '"]').length; + if (!isPreviousPostAdded && (!data.posts[0].selfPost || !ajaxify.data.scrollToMyPost)) { + return; + } + + if (!isPreviousPostAdded && data.posts[0].selfPost) { + return ajaxify.go('post/' + data.posts[0].pid); + } + + const repliesSelector = $('[component="topic"]>[component="post"]:not([data-index=0]), [component="topic"]>[component="topic/event"]'); + createNewPosts(data, repliesSelector, direction, false, html => { + if (html) { + html.addClass('new'); + } + + scrollToPostIfSelf(data.posts[0]); + }); + } + + function scrollToPostIfSelf(post) { + if (post.selfPost && ajaxify.data.scrollToMyPost) { + navigator.scrollBottom(post.index); + } + } + + function createNewPosts(data, repliesSelector, direction, userScrolled, callback) { + callback ||= function () {}; + if (!data || (data.posts && data.posts.length === 0)) { + return callback(); + } + + function removeAlreadyAddedPosts() { + const newPosts = $('[component="post"].new'); + + if (newPosts.length === data.posts.length) { + let allSamePids = true; + newPosts.each((index, element) => { + if (Number.parseInt($(element).attr('data-pid'), 10) !== Number.parseInt(data.posts[index].pid, 10)) { + allSamePids = false; + } + }); + + if (allSamePids) { + newPosts.each(function () { + $(this).removeClass('new'); + }); + data.posts.length = 0; + return; + } + } + + if (newPosts.length > 0 && data.posts.length > 1) { + for (const post of data.posts) { + const p = components.get('post', 'pid', post.pid); + if (p.hasClass('new')) { + p.remove(); + } + } + } + + data.posts = data.posts.filter(post => post.index === -1 || $('[component="post"][data-pid="' + post.pid + '"]').length === 0); + } + + removeAlreadyAddedPosts(); + + if (data.posts.length === 0) { + return callback(); + } + + let after; + let before; + + if (direction > 0 && repliesSelector.length > 0) { + after = repliesSelector.last(); + } else if (direction < 0 && repliesSelector.length > 0) { + before = repliesSelector.first(); + } + + hooks.fire('action:posts.loading', {posts: data.posts, after, before}); + + app.parseAndTranslate('topic', 'posts', Object.assign({}, ajaxify.data, data), html => { + html = html.filter(function () { + const $this = $(this); + const pid = $this.attr('data-pid'); + const index = Number.parseInt($this.attr('data-index'), 10); + const isPost = $this.is('[component="post"]'); + return !isPost || index === -1 || (pid && $('[component="post"][data-pid="' + pid + '"]').length === 0); + }); + + if (after) { + html.insertAfter(after); + } else if (before) { + // Save document height and position for future reference (about 5 lines down) + const height = $(document).height(); + const scrollTop = $(window).scrollTop(); + + html.insertBefore(before); + + // Now restore the relative position the user was on prior to new post insertion + if (userScrolled || scrollTop > 0) { + $(window).scrollTop(scrollTop + ($(document).height() - height)); + } + } else { + components.get('topic').append(html); + } + + const removedEls = infinitescroll.removeExtra($('[component="post"]'), direction, Math.max(20, config.postsPerPage * 2)); + removeNecroPostMessages(removedEls); + + hooks.fire('action:posts.loaded', {posts: data.posts}); + + Posts.onNewPostsAddedToDom(html); + + callback(html); + }); + } + + Posts.loadMorePosts = function (direction) { + if (components.get('topic').length === 0 || navigator.scrollActive) { + return; + } + + const replies = components.get('topic').find(components.get('post').not('[data-index=0]').not('.new')); + const afterElement = direction > 0 ? replies.last() : replies.first(); + const after = Number.parseInt(afterElement.attr('data-index'), 10) || 0; + + const tid = ajaxify.data.tid; + if (!utils.isNumber(tid) || !utils.isNumber(after) || (direction < 0 && components.get('post', 'index', 0).length > 0)) { + return; + } + + const indicatorElement = $('.loading-indicator'); + if (!indicatorElement.is(':animated')) { + indicatorElement.fadeIn(); + } + + infinitescroll.loadMore('topics.loadMore', { + tid, + after: after + (direction > 0 ? 1 : 0), + count: config.postsPerPage, + direction, + topicPostSort: config.topicPostSort, + }, (data, done) => { + indicatorElement.fadeOut(); + + if (data && data.posts && data.posts.length > 0) { + const repliesSelector = $('[component="post"]:not([data-index=0]):not(.new), [component="topic/event"]'); + createNewPosts(data, repliesSelector, direction, true, done); + } else { + navigator.update(); + done(); + } + }); + }; + + Posts.onTopicPageLoad = function (posts) { + handlePrivateUploads(posts); + images.wrapImagesInLinks(posts); + hideDuplicateSignatures(posts); + Posts.showBottomPostBar(); + posts.find('[component="post/content"] img:not(.not-responsive)').addClass('img-responsive'); + Posts.addBlockquoteEllipses(posts); + hidePostToolsForDeletedPosts(posts); + addNecroPostMessage(); + }; + + Posts.addTopicEvents = function (events) { + if (config.topicPostSort === 'most_votes') { + return; + } + + const html = helpers.renderEvents.call(ajaxify.data, events); + translator.translate(html, translated => { + if (config.topicPostSort === 'oldest_to_newest') { + $('[component="topic"]').append(translated); + } else if (config.topicPostSort === 'newest_to_oldest') { + const mainPost = $('[component="topic"] [component="post"][data-index="0"]'); + if (mainPost.length > 0) { + $(translated).insertAfter(mainPost); + } else { + $('[component="topic"]').prepend(translated); + } + } + + $('[component="topic/event"] .timeago').timeago(); + }); + }; + + function addNecroPostMessage() { + const necroThreshold = ajaxify.data.necroThreshold * 24 * 60 * 60 * 1000; + if (!necroThreshold || (config.topicPostSort !== 'newest_to_oldest' && config.topicPostSort !== 'oldest_to_newest')) { + return; + } + + const postEls = $('[component="post"]').toArray(); + for (let post of postEls) { + post = $(post); + const previous = post.prev('[component="post"]'); + if (post.is(':has(.necro-post)') || previous.length === 0) { + continue; + } + + if (config.topicPostSort === 'newest_to_oldest' && Number.parseInt(previous.attr('data-index'), 10) === 0) { + continue; + } + + const diff = post.attr('data-timestamp') - previous.attr('data-timestamp'); + if (Math.abs(diff) >= necroThreshold) { + const suffixAgo = $.timeago.settings.strings.suffixAgo; + const prefixAgo = $.timeago.settings.strings.prefixAgo; + const suffixFromNow = $.timeago.settings.strings.suffixFromNow; + const prefixFromNow = $.timeago.settings.strings.prefixFromNow; + + $.timeago.settings.strings.suffixAgo = ''; + $.timeago.settings.strings.prefixAgo = ''; + $.timeago.settings.strings.suffixFromNow = ''; + $.timeago.settings.strings.prefixFromNow = ''; + + const translationText = (diff > 0 ? '[[topic:timeago_later,' : '[[topic:timeago_earlier,') + $.timeago.inWords(diff) + ']]'; + + $.timeago.settings.strings.suffixAgo = suffixAgo; + $.timeago.settings.strings.prefixAgo = prefixAgo; + $.timeago.settings.strings.suffixFromNow = suffixFromNow; + $.timeago.settings.strings.prefixFromNow = prefixFromNow; + app.parseAndTranslate('partials/topic/necro-post', {text: translationText}, html => { + html.attr('data-necro-post-index', previous.attr('data-index')); + html.insertBefore(post); + }); + } + } + } + + function hideDuplicateSignatures(posts) { + if (ajaxify.data['signatures:hideDuplicates']) { + posts.each((index, element) => { + const signatureElement = $(element).find('[component="post/signature"]'); + const uid = signatureElement.attr('data-uid'); + if (Posts.signaturesShown[uid]) { + signatureElement.addClass('hidden'); + } else { + Posts.signaturesShown[uid] = true; + } + }); + } + } + + function removeNecroPostMessages(removedPostEls) { + removedPostEls.each((index, element) => { + $(`[data-necro-post-index="${$(element).attr('data-index')}"]`).remove(); + }); + } + + function handlePrivateUploads(posts) { + if (app.user.uid || !ajaxify.data.privateUploads) { + return; + } + + // Replace all requests for uploaded images/files with a login link + const loginElement = document.createElement('a'); + loginElement.className = 'login-required'; + loginElement.href = config.relative_path + '/login'; + + translator.translate('[[topic:login-to-view]]', translated => { + loginElement.append(document.createTextNode(translated)); + posts.each((index, postElement) => { + $(postElement).find('[component="post/content"] img').each((index, imgElement) => { + imgElement = $(imgElement); + if (imgElement.attr('src').startsWith(config.relative_path + config.upload_url)) { + imgElement.replaceWith(loginElement.cloneNode(true)); + } + }); + }); + }); + } + + Posts.onNewPostsAddedToDom = function (posts) { + Posts.onTopicPageLoad(posts); + + app.createUserTooltips(posts); + + utils.addCommasToNumbers(posts.find('.formatted-number')); + utils.makeNumbersHumanReadable(posts.find('.human-readable-number')); + posts.find('.timeago').timeago(); + }; + + Posts.showBottomPostBar = function () { + const mainPost = components.get('post', 'index', 0); + const placeHolder = $('.post-bar-placeholder'); + const posts = $('[component="post"]'); + if (mainPost.length > 0 && posts.length > 1 && $('.post-bar').length < 2 && placeHolder.length > 0) { + $('.post-bar').clone().insertAfter(placeHolder); + placeHolder.remove(); + } else if (mainPost.length > 0 && posts.length < 2) { + mainPost.find('.post-bar').remove(); + } + }; + + function hidePostToolsForDeletedPosts(posts) { + posts.each(function () { + if ($(this).hasClass('deleted')) { + postTools.toggle($(this).attr('data-pid'), true); + } + }); + } + + Posts.addBlockquoteEllipses = function (posts) { + const blockquotes = posts.find('[component="post/content"] > blockquote > blockquote'); + blockquotes.each(function () { + const $this = $(this); + if ($this.find(':hidden:not(br)').length > 0 && $this.find('.toggle').length === 0) { + $this.append(''); + } + }); + }; + + return Posts; }); diff --git a/public/src/client/topic/replies.js b/public/src/client/topic/replies.js index 9862b75..9c49c0f 100644 --- a/public/src/client/topic/replies.js +++ b/public/src/client/topic/replies.js @@ -1,110 +1,111 @@ 'use strict'; - -define('forum/topic/replies', ['forum/topic/posts', 'hooks', 'alerts'], function (posts, hooks, alerts) { - const Replies = {}; - - Replies.init = function (button) { - const post = button.closest('[data-pid]'); - const pid = post.data('pid'); - const open = button.find('[component="post/replies/open"]'); - const loading = button.find('[component="post/replies/loading"]'); - const close = button.find('[component="post/replies/close"]'); - - if (open.is(':not(.hidden)') && loading.is('.hidden')) { - open.addClass('hidden'); - loading.removeClass('hidden'); - - socket.emit('posts.getReplies', pid, function (err, data) { - loading.addClass('hidden'); - if (err) { - open.removeClass('hidden'); - return alerts.error(err); - } - - close.removeClass('hidden'); - - posts.modifyPostsByPrivileges(data); - const tplData = { - posts: data, - privileges: ajaxify.data.privileges, - 'downvote:disabled': ajaxify.data['downvote:disabled'], - 'reputation:disabled': ajaxify.data['reputation:disabled'], - loggedIn: !!app.user.uid, - hideReplies: config.hasOwnProperty('showNestedReplies') ? !config.showNestedReplies : true, - }; - app.parseAndTranslate('topic', 'posts', tplData, function (html) { - const repliesEl = $('
    ', { component: 'post/replies' }).html(html).hide(); - if (button.attr('data-target-component')) { - post.find('[component="' + button.attr('data-target-component') + '"]').html(repliesEl); - } else { - repliesEl.insertAfter(button); - } - - repliesEl.slideDown('fast'); - posts.onNewPostsAddedToDom(html); - hooks.fire('action:posts.loaded', { posts: data }); - }); - }); - } else if (close.is(':not(.hidden)')) { - close.addClass('hidden'); - open.removeClass('hidden'); - loading.addClass('hidden'); - post.find('[component="post/replies"]').slideUp('fast', function () { - $(this).remove(); - }); - } - }; - - Replies.onNewPost = function (data) { - const post = data.posts[0]; - if (!post) { - return; - } - incrementCount(post, 1); - data.hideReplies = config.hasOwnProperty('showNestedReplies') ? !config.showNestedReplies : true; - app.parseAndTranslate('topic', 'posts', data, function (html) { - const replies = $('[component="post"][data-pid="' + post.toPid + '"] [component="post/replies"]').first(); - if (replies.length) { - if (config.topicPostSort === 'newest_to_oldest') { - replies.prepend(html); - } else { - replies.append(html); - } - posts.onNewPostsAddedToDom(html); - } - }); - }; - - Replies.onPostPurged = function (post) { - incrementCount(post, -1); - }; - - function incrementCount(post, inc) { - const replyCount = $('[component="post"][data-pid="' + post.toPid + '"]').find('[component="post/reply-count"]').first(); - const countEl = replyCount.find('[component="post/reply-count/text"]'); - const avatars = replyCount.find('[component="post/reply-count/avatars"]'); - const count = Math.max(0, parseInt(countEl.attr('data-replies'), 10) + inc); - const timestamp = replyCount.find('.timeago').attr('title', post.timestampISO); - - countEl.attr('data-replies', count); - replyCount.toggleClass('hidden', count <= 0); - if (count > 1) { - countEl.translateText('[[topic:replies_to_this_post, ' + count + ']]'); - } else { - countEl.translateText('[[topic:one_reply_to_this_post]]'); - } - - if (!avatars.find('[data-uid="' + post.uid + '"]').length && count < 7) { - app.parseAndTranslate('topic', 'posts', { posts: [{ replies: { users: [post.user] } }] }, function (html) { - avatars.prepend(html.find('[component="post/reply-count/avatars"] [component="avatar/picture"]')); - }); - } - - avatars.addClass('hasMore'); - - timestamp.data('timeago', null).timeago(); - } - - return Replies; +define('forum/topic/replies', ['forum/topic/posts', 'hooks', 'alerts'], (posts, hooks, alerts) => { + const Replies = {}; + + Replies.init = function (button) { + const post = button.closest('[data-pid]'); + const pid = post.data('pid'); + const open = button.find('[component="post/replies/open"]'); + const loading = button.find('[component="post/replies/loading"]'); + const close = button.find('[component="post/replies/close"]'); + + if (open.is(':not(.hidden)') && loading.is('.hidden')) { + open.addClass('hidden'); + loading.removeClass('hidden'); + + socket.emit('posts.getReplies', pid, (error, data) => { + loading.addClass('hidden'); + if (error) { + open.removeClass('hidden'); + return alerts.error(error); + } + + close.removeClass('hidden'); + + posts.modifyPostsByPrivileges(data); + const tplData = { + posts: data, + privileges: ajaxify.data.privileges, + 'downvote:disabled': ajaxify.data['downvote:disabled'], + 'reputation:disabled': ajaxify.data['reputation:disabled'], + loggedIn: Boolean(app.user.uid), + hideReplies: config.hasOwnProperty('showNestedReplies') ? !config.showNestedReplies : true, + }; + app.parseAndTranslate('topic', 'posts', tplData, html => { + const repliesElement = $('
    ', {component: 'post/replies'}).html(html).hide(); + if (button.attr('data-target-component')) { + post.find('[component="' + button.attr('data-target-component') + '"]').html(repliesElement); + } else { + repliesElement.insertAfter(button); + } + + repliesElement.slideDown('fast'); + posts.onNewPostsAddedToDom(html); + hooks.fire('action:posts.loaded', {posts: data}); + }); + }); + } else if (close.is(':not(.hidden)')) { + close.addClass('hidden'); + open.removeClass('hidden'); + loading.addClass('hidden'); + post.find('[component="post/replies"]').slideUp('fast', function () { + $(this).remove(); + }); + } + }; + + Replies.onNewPost = function (data) { + const post = data.posts[0]; + if (!post) { + return; + } + + incrementCount(post, 1); + data.hideReplies = config.hasOwnProperty('showNestedReplies') ? !config.showNestedReplies : true; + app.parseAndTranslate('topic', 'posts', data, html => { + const replies = $('[component="post"][data-pid="' + post.toPid + '"] [component="post/replies"]').first(); + if (replies.length > 0) { + if (config.topicPostSort === 'newest_to_oldest') { + replies.prepend(html); + } else { + replies.append(html); + } + + posts.onNewPostsAddedToDom(html); + } + }); + }; + + Replies.onPostPurged = function (post) { + incrementCount(post, -1); + }; + + function incrementCount(post, increment) { + const replyCount = $('[component="post"][data-pid="' + post.toPid + '"]').find('[component="post/reply-count"]').first(); + const countElement = replyCount.find('[component="post/reply-count/text"]'); + const avatars = replyCount.find('[component="post/reply-count/avatars"]'); + const count = Math.max(0, Number.parseInt(countElement.attr('data-replies'), 10) + increment); + const timestamp = replyCount.find('.timeago').attr('title', post.timestampISO); + + countElement.attr('data-replies', count); + replyCount.toggleClass('hidden', count <= 0); + if (count > 1) { + countElement.translateText('[[topic:replies_to_this_post, ' + count + ']]'); + } else { + countElement.translateText('[[topic:one_reply_to_this_post]]'); + } + + if (avatars.find('[data-uid="' + post.uid + '"]').length === 0 && count < 7) { + app.parseAndTranslate('topic', 'posts', {posts: [{replies: {users: [post.user]}}]}, html => { + avatars.prepend(html.find('[component="post/reply-count/avatars"] [component="avatar/picture"]')); + }); + } + + avatars.addClass('hasMore'); + + timestamp.data('timeago', null).timeago(); + } + + return Replies; }); diff --git a/public/src/client/topic/threadTools.js b/public/src/client/topic/threadTools.js index 3a72d47..62b3b68 100644 --- a/public/src/client/topic/threadTools.js +++ b/public/src/client/topic/threadTools.js @@ -1,323 +1,326 @@ 'use strict'; -const { utils } = require('sortablejs'); - +const {utils} = require('sortablejs'); define('forum/topic/threadTools', [ - 'components', - 'translator', - 'handleBack', - 'forum/topic/posts', - 'api', - 'hooks', - 'bootbox', - 'alerts', -], function (components, translator, handleBack, posts, api, hooks, bootbox, alerts) { - const ThreadTools = {}; - - ThreadTools.init = function (tid, topicContainer) { - renderMenu(topicContainer); - - // function topicCommand(method, path, command, onComplete) { - topicContainer.on('click', '[component="topic/delete"]', function () { - topicCommand('del', '/state', 'delete'); - return false; - }); - - topicContainer.on('click', '[component="topic/restore"]', function () { - topicCommand('put', '/state', 'restore'); - return false; - }); - - topicContainer.on('click', '[component="topic/purge"]', function () { - topicCommand('del', '', 'purge'); - return false; - }); - - topicContainer.on('click', '[component="topic/lock"]', function () { - topicCommand('put', '/lock', 'lock'); - return false; - }); - - topicContainer.on('click', '[component="topic/unlock"]', function () { - topicCommand('del', '/lock', 'unlock'); - return false; - }); - - topicContainer.on('click', '[component="topic/pin"]', function () { - topicCommand('put', '/pin', 'pin'); - return false; - }); - - topicContainer.on('click', '[component="topic/unpin"]', function () { - topicCommand('del', '/pin', 'unpin'); - return false; - }); - - topicContainer.on('click', '[component="topic/private"]', function () { - topicCommand('put', '/private', 'private'); - return false; - }); - - topicContainer.on('click', '[component="topic/public"]', function () { - topicCommand('del', '/private', 'public'); - return false; - }); - - topicContainer.on('click', '[component="topic/event/delete"]', function () { - const eventId = $(this).attr('data-topic-event-id'); - const eventEl = $(this).parents('[component="topic/event"]'); - bootbox.confirm('[[topic:delete-event-confirm]]', (ok) => { - if (ok) { - api.del(`/topics/${tid}/events/${eventId}`, {}) - .then(function () { - eventEl.remove(); - }) - .catch(alerts.error); - } - }); - }); - - // todo: should also use topicCommand, but no write api call exists for this yet - topicContainer.on('click', '[component="topic/mark-unread"]', function () { - socket.emit('topics.markUnread', tid, function (err) { - if (err) { - return alerts.error(err); - } - - if (app.previousUrl && !app.previousUrl.match('^/topic')) { - ajaxify.go(app.previousUrl, function () { - handleBack.onBackClicked(true); - }); - } else if (ajaxify.data.category) { - ajaxify.go('category/' + ajaxify.data.category.slug, handleBack.onBackClicked); - } - - alerts.success('[[topic:mark_unread.success]]'); - }); - return false; - }); - - topicContainer.on('click', '[component="topic/mark-unread-for-all"]', function () { - const btn = $(this); - socket.emit('topics.markAsUnreadForAll', [tid], function (err) { - if (err) { - return alerts.error(err); - } - alerts.success('[[topic:markAsUnreadForAll.success]]'); - btn.parents('.thread-tools.open').find('.dropdown-toggle').trigger('click'); - }); - return false; - }); - - topicContainer.on('click', '[component="topic/move"]', function () { - require(['forum/topic/move'], function (move) { - move.init([tid], ajaxify.data.cid); - }); - return false; - }); - - topicContainer.on('click', '[component="topic/delete/posts"]', function () { - require(['forum/topic/delete-posts'], function (deletePosts) { - deletePosts.init(); - }); - }); - - topicContainer.on('click', '[component="topic/fork"]', function () { - require(['forum/topic/fork'], function (fork) { - fork.init(); - }); - }); - - topicContainer.on('click', '[component="topic/move-posts"]', function () { - require(['forum/topic/move-post'], function (movePosts) { - movePosts.init(); - }); - }); - - topicContainer.on('click', '[component="topic/following"]', function () { - changeWatching('follow'); - }); - topicContainer.on('click', '[component="topic/not-following"]', function () { - changeWatching('follow', 0); - }); - topicContainer.on('click', '[component="topic/ignoring"]', function () { - changeWatching('ignore'); - }); - - function changeWatching(type, state = 1) { - const method = state ? 'put' : 'del'; - api[method](`/topics/${tid}/${type}`, {}, () => { - let message = ''; - if (type === 'follow') { - message = state ? '[[topic:following_topic.message]]' : '[[topic:not_following_topic.message]]'; - } else if (type === 'ignore') { - message = state ? '[[topic:ignoring_topic.message]]' : '[[topic:not_following_topic.message]]'; - } - - // From here on out, type changes to 'unfollow' if state is falsy - if (!state) { - type = 'unfollow'; - } - - setFollowState(type); - - alerts.alert({ - alert_id: 'follow_thread', - message: message, - type: 'success', - timeout: 5000, - }); - - hooks.fire('action:topics.changeWatching', { tid: tid, type: type }); - }, () => { - alerts.alert({ - type: 'danger', - alert_id: 'topic_follow', - title: '[[global:please_log_in]]', - message: '[[topic:login_to_subscribe]]', - timeout: 5000, - }); - }); - - return false; - } - }; - - function renderMenu(container) { - container.on('show.bs.dropdown', '.thread-tools', function () { - const $this = $(this); - const dropdownMenu = $this.find('.dropdown-menu'); - if (dropdownMenu.html()) { - return; - } - - dropdownMenu.toggleClass('hidden', true); - socket.emit('topics.loadTopicTools', { tid: ajaxify.data.tid, cid: ajaxify.data.cid }, function (err, data) { - if (err) { - return alerts.error(err); - } - app.parseAndTranslate('partials/topic/topic-menu-list', data, function (html) { - dropdownMenu.html(html); - dropdownMenu.toggleClass('hidden', false); - - hooks.fire('action:topic.tools.load', { - element: dropdownMenu, - }); - }); - }); - }); - } - - function topicCommand(method, path, command, onComplete) { - if (!onComplete) { - onComplete = function () {}; - } - const tid = ajaxify.data.tid; - const body = {}; - const execute = function (ok) { - if (ok) { - api[method](`/topics/${tid}${path}`, body) - .then(onComplete) - .catch(alerts.error); - } - }; - - switch (command) { - case 'delete': - case 'restore': - case 'purge': - bootbox.confirm(`[[topic:thread_tools.${command}_confirm]]`, execute); - break; - - case 'pin': - ThreadTools.requestPinExpiry(body, execute.bind(null, true)); - break; - - default: - execute(true); - break; - } - } - - ThreadTools.requestPinExpiry = function (body, onSuccess) { - app.parseAndTranslate('modals/set-pin-expiry', {}, function (html) { - const modal = bootbox.dialog({ - title: '[[topic:thread_tools.pin]]', - message: html, - onEscape: true, - size: 'small', - buttons: { - cancel: { - label: '[[modules:bootbox.cancel]]', - className: 'btn-link', - }, - save: { - label: '[[global:save]]', - className: 'btn-primary', - callback: function () { - const expiryEl = modal.get(0).querySelector('#expiry'); - let expiry = expiryEl.value; - - // No expiry set - if (expiry === '') { - return onSuccess(); - } - - // Expiration date set - expiry = new Date(expiry); - - if (expiry && expiry.getTime() > Date.now()) { - body.expiry = expiry.getTime(); - onSuccess(); - } else { - alerts.error('[[error:invalid-date]]'); - } - }, - }, - }, - }); - }); - }; - - ThreadTools.setLockedState = function (data) { - const threadEl = components.get('topic'); - if (parseInt(data.tid, 10) !== parseInt(threadEl.attr('data-tid'), 10)) { - return; - } - - const isLocked = data.isLocked && !ajaxify.data.privileges.isAdminOrMod; - - components.get('topic/lock').toggleClass('hidden', data.isLocked).parent().attr('hidden', data.isLocked ? '' : null); - components.get('topic/unlock').toggleClass('hidden', !data.isLocked).parent().attr('hidden', !data.isLocked ? '' : null); - - const hideReply = !!((data.isLocked || ajaxify.data.deleted) && !ajaxify.data.privileges.isAdminOrMod); - - components.get('topic/reply/container').toggleClass('hidden', hideReply); - components.get('topic/reply/locked').toggleClass('hidden', ajaxify.data.privileges.isAdminOrMod || !data.isLocked || ajaxify.data.deleted); - - threadEl.find('[component="post"]:not(.deleted) [component="post/reply"], [component="post"]:not(.deleted) [component="post/quote"]').toggleClass('hidden', hideReply); - threadEl.find('[component="post/edit"], [component="post/delete"]').toggleClass('hidden', isLocked); - - threadEl.find('[component="post"][data-uid="' + app.user.uid + '"].deleted [component="post/tools"]').toggleClass('hidden', isLocked); - - $('[component="topic/labels"] [component="topic/locked"]').toggleClass('hidden', !data.isLocked); - $('[component="post/tools"] .dropdown-menu').html(''); - ajaxify.data.locked = data.isLocked; - - posts.addTopicEvents(data.events); - }; - - ThreadTools.setPrivateState = function (data) { - const threadEl = components.get('topic'); - if (parseInt(data.tid, 10) !== parseInt(threadEl.attr('data-tid'), 10)) { - return; - } - - components.get('topic/private').toggleClass('hidden', data.isPrivate).parent().attr('hidden', data.isPrivate ? '' : null); - components.get('topic/public').toggleClass('hidden', !data.isPrivate).parent().attr('hidden', !data.isPrivate ? '' : null); - - /* if (data.isPrivate) { + 'components', + 'translator', + 'handleBack', + 'forum/topic/posts', + 'api', + 'hooks', + 'bootbox', + 'alerts', +], (components, translator, handleBack, posts, api, hooks, bootbox, alerts) => { + const ThreadTools = {}; + + ThreadTools.init = function (tid, topicContainer) { + renderMenu(topicContainer); + + // Function topicCommand(method, path, command, onComplete) { + topicContainer.on('click', '[component="topic/delete"]', () => { + topicCommand('del', '/state', 'delete'); + return false; + }); + + topicContainer.on('click', '[component="topic/restore"]', () => { + topicCommand('put', '/state', 'restore'); + return false; + }); + + topicContainer.on('click', '[component="topic/purge"]', () => { + topicCommand('del', '', 'purge'); + return false; + }); + + topicContainer.on('click', '[component="topic/lock"]', () => { + topicCommand('put', '/lock', 'lock'); + return false; + }); + + topicContainer.on('click', '[component="topic/unlock"]', () => { + topicCommand('del', '/lock', 'unlock'); + return false; + }); + + topicContainer.on('click', '[component="topic/pin"]', () => { + topicCommand('put', '/pin', 'pin'); + return false; + }); + + topicContainer.on('click', '[component="topic/unpin"]', () => { + topicCommand('del', '/pin', 'unpin'); + return false; + }); + + topicContainer.on('click', '[component="topic/private"]', () => { + topicCommand('put', '/private', 'private'); + return false; + }); + + topicContainer.on('click', '[component="topic/public"]', () => { + topicCommand('del', '/private', 'public'); + return false; + }); + + topicContainer.on('click', '[component="topic/event/delete"]', function () { + const eventId = $(this).attr('data-topic-event-id'); + const eventElement = $(this).parents('[component="topic/event"]'); + bootbox.confirm('[[topic:delete-event-confirm]]', ok => { + if (ok) { + api.del(`/topics/${tid}/events/${eventId}`, {}) + .then(() => { + eventElement.remove(); + }) + .catch(alerts.error); + } + }); + }); + + // Todo: should also use topicCommand, but no write api call exists for this yet + topicContainer.on('click', '[component="topic/mark-unread"]', () => { + socket.emit('topics.markUnread', tid, error => { + if (error) { + return alerts.error(error); + } + + if (app.previousUrl && !app.previousUrl.match('^/topic')) { + ajaxify.go(app.previousUrl, () => { + handleBack.onBackClicked(true); + }); + } else if (ajaxify.data.category) { + ajaxify.go('category/' + ajaxify.data.category.slug, handleBack.onBackClicked); + } + + alerts.success('[[topic:mark_unread.success]]'); + }); + return false; + }); + + topicContainer.on('click', '[component="topic/mark-unread-for-all"]', function () { + const button = $(this); + socket.emit('topics.markAsUnreadForAll', [tid], error => { + if (error) { + return alerts.error(error); + } + + alerts.success('[[topic:markAsUnreadForAll.success]]'); + button.parents('.thread-tools.open').find('.dropdown-toggle').trigger('click'); + }); + return false; + }); + + topicContainer.on('click', '[component="topic/move"]', () => { + require(['forum/topic/move'], move => { + move.init([tid], ajaxify.data.cid); + }); + return false; + }); + + topicContainer.on('click', '[component="topic/delete/posts"]', () => { + require(['forum/topic/delete-posts'], deletePosts => { + deletePosts.init(); + }); + }); + + topicContainer.on('click', '[component="topic/fork"]', () => { + require(['forum/topic/fork'], fork => { + fork.init(); + }); + }); + + topicContainer.on('click', '[component="topic/move-posts"]', () => { + require(['forum/topic/move-post'], movePosts => { + movePosts.init(); + }); + }); + + topicContainer.on('click', '[component="topic/following"]', () => { + changeWatching('follow'); + }); + topicContainer.on('click', '[component="topic/not-following"]', () => { + changeWatching('follow', 0); + }); + topicContainer.on('click', '[component="topic/ignoring"]', () => { + changeWatching('ignore'); + }); + + function changeWatching(type, state = 1) { + const method = state ? 'put' : 'del'; + api[method](`/topics/${tid}/${type}`, {}, () => { + let message = ''; + if (type === 'follow') { + message = state ? '[[topic:following_topic.message]]' : '[[topic:not_following_topic.message]]'; + } else if (type === 'ignore') { + message = state ? '[[topic:ignoring_topic.message]]' : '[[topic:not_following_topic.message]]'; + } + + // From here on out, type changes to 'unfollow' if state is falsy + if (!state) { + type = 'unfollow'; + } + + setFollowState(type); + + alerts.alert({ + alert_id: 'follow_thread', + message, + type: 'success', + timeout: 5000, + }); + + hooks.fire('action:topics.changeWatching', {tid, type}); + }, () => { + alerts.alert({ + type: 'danger', + alert_id: 'topic_follow', + title: '[[global:please_log_in]]', + message: '[[topic:login_to_subscribe]]', + timeout: 5000, + }); + }); + + return false; + } + }; + + function renderMenu(container) { + container.on('show.bs.dropdown', '.thread-tools', function () { + const $this = $(this); + const dropdownMenu = $this.find('.dropdown-menu'); + if (dropdownMenu.html()) { + return; + } + + dropdownMenu.toggleClass('hidden', true); + socket.emit('topics.loadTopicTools', {tid: ajaxify.data.tid, cid: ajaxify.data.cid}, (error, data) => { + if (error) { + return alerts.error(error); + } + + app.parseAndTranslate('partials/topic/topic-menu-list', data, html => { + dropdownMenu.html(html); + dropdownMenu.toggleClass('hidden', false); + + hooks.fire('action:topic.tools.load', { + element: dropdownMenu, + }); + }); + }); + }); + } + + function topicCommand(method, path, command, onComplete) { + onComplete ||= function () {}; + + const tid = ajaxify.data.tid; + const body = {}; + const execute = function (ok) { + if (ok) { + api[method](`/topics/${tid}${path}`, body) + .then(onComplete) + .catch(alerts.error); + } + }; + + switch (command) { + case 'delete': + case 'restore': + case 'purge': { + bootbox.confirm(`[[topic:thread_tools.${command}_confirm]]`, execute); + break; + } + + case 'pin': { + ThreadTools.requestPinExpiry(body, execute.bind(null, true)); + break; + } + + default: { + execute(true); + break; + } + } + } + + ThreadTools.requestPinExpiry = function (body, onSuccess) { + app.parseAndTranslate('modals/set-pin-expiry', {}, html => { + const modal = bootbox.dialog({ + title: '[[topic:thread_tools.pin]]', + message: html, + onEscape: true, + size: 'small', + buttons: { + cancel: { + label: '[[modules:bootbox.cancel]]', + className: 'btn-link', + }, + save: { + label: '[[global:save]]', + className: 'btn-primary', + callback() { + const expiryElement = modal.get(0).querySelector('#expiry'); + let expiry = expiryElement.value; + + // No expiry set + if (expiry === '') { + return onSuccess(); + } + + // Expiration date set + expiry = new Date(expiry); + + if (expiry && expiry.getTime() > Date.now()) { + body.expiry = expiry.getTime(); + onSuccess(); + } else { + alerts.error('[[error:invalid-date]]'); + } + }, + }, + }, + }); + }); + }; + + ThreadTools.setLockedState = function (data) { + const threadElement = components.get('topic'); + if (Number.parseInt(data.tid, 10) !== Number.parseInt(threadElement.attr('data-tid'), 10)) { + return; + } + + const isLocked = data.isLocked && !ajaxify.data.privileges.isAdminOrMod; + + components.get('topic/lock').toggleClass('hidden', data.isLocked).parent().attr('hidden', data.isLocked ? '' : null); + components.get('topic/unlock').toggleClass('hidden', !data.isLocked).parent().attr('hidden', data.isLocked ? null : ''); + + const hideReply = Boolean((data.isLocked || ajaxify.data.deleted) && !ajaxify.data.privileges.isAdminOrMod); + + components.get('topic/reply/container').toggleClass('hidden', hideReply); + components.get('topic/reply/locked').toggleClass('hidden', ajaxify.data.privileges.isAdminOrMod || !data.isLocked || ajaxify.data.deleted); + + threadElement.find('[component="post"]:not(.deleted) [component="post/reply"], [component="post"]:not(.deleted) [component="post/quote"]').toggleClass('hidden', hideReply); + threadElement.find('[component="post/edit"], [component="post/delete"]').toggleClass('hidden', isLocked); + + threadElement.find('[component="post"][data-uid="' + app.user.uid + '"].deleted [component="post/tools"]').toggleClass('hidden', isLocked); + + $('[component="topic/labels"] [component="topic/locked"]').toggleClass('hidden', !data.isLocked); + $('[component="post/tools"] .dropdown-menu').html(''); + ajaxify.data.locked = data.isLocked; + + posts.addTopicEvents(data.events); + }; + + ThreadTools.setPrivateState = function (data) { + const threadElement = components.get('topic'); + if (Number.parseInt(data.tid, 10) !== Number.parseInt(threadElement.attr('data-tid'), 10)) { + return; + } + + components.get('topic/private').toggleClass('hidden', data.isPrivate).parent().attr('hidden', data.isPrivate ? '' : null); + components.get('topic/public').toggleClass('hidden', !data.isPrivate).parent().attr('hidden', data.isPrivate ? null : ''); + + /* If (data.isPrivate) { app.parseAndTranslate('partials/topic/privated-message', { privater: data.user, private: true, @@ -328,93 +331,93 @@ define('forum/topic/threadTools', [ }); } */ - threadEl.toggleClass('private', data.isPrivate); - ajaxify.data.private = data.isPrivate ? 1 : 0; - - posts.addTopicEvents(data.event); - }; - - ThreadTools.setDeleteState = function (data) { - const threadEl = components.get('topic'); - if (parseInt(data.tid, 10) !== parseInt(threadEl.attr('data-tid'), 10)) { - return; - } - - components.get('topic/delete').toggleClass('hidden', data.isDelete).parent().attr('hidden', data.isDelete ? '' : null); - components.get('topic/restore').toggleClass('hidden', !data.isDelete).parent().attr('hidden', !data.isDelete ? '' : null); - components.get('topic/purge').toggleClass('hidden', !data.isDelete).parent().attr('hidden', !data.isDelete ? '' : null); - components.get('topic/deleted/message').toggleClass('hidden', !data.isDelete); - - if (data.isDelete) { - app.parseAndTranslate('partials/topic/deleted-message', { - deleter: data.user, - deleted: true, - deletedTimestampISO: utils.toISOString(Date.now()), - }, function (html) { - components.get('topic/deleted/message').replaceWith(html); - html.find('.timeago').timeago(); - }); - } - const hideReply = data.isDelete && !ajaxify.data.privileges.isAdminOrMod; - - components.get('topic/reply/container').toggleClass('hidden', hideReply); - components.get('topic/reply/locked').toggleClass('hidden', ajaxify.data.privileges.isAdminOrMod || !ajaxify.data.locked || data.isDelete); - threadEl.find('[component="post"]:not(.deleted) [component="post/reply"], [component="post"]:not(.deleted) [component="post/quote"]').toggleClass('hidden', hideReply); - - threadEl.toggleClass('deleted', data.isDelete); - ajaxify.data.deleted = data.isDelete ? 1 : 0; - - posts.addTopicEvents(data.events); - }; - - - ThreadTools.setPinnedState = function (data) { - const threadEl = components.get('topic'); - if (parseInt(data.tid, 10) !== parseInt(threadEl.attr('data-tid'), 10)) { - return; - } - - components.get('topic/pin').toggleClass('hidden', data.pinned).parent().attr('hidden', data.pinned ? '' : null); - components.get('topic/unpin').toggleClass('hidden', !data.pinned).parent().attr('hidden', !data.pinned ? '' : null); - const icon = $('[component="topic/labels"] [component="topic/pinned"]'); - icon.toggleClass('hidden', !data.pinned); - if (data.pinned) { - icon.translateAttr('title', ( - data.pinExpiry && data.pinExpiryISO ? - '[[topic:pinned-with-expiry, ' + data.pinExpiryISO + ']]' : - '[[topic:pinned]]' - )); - } - ajaxify.data.pinned = data.pinned; - - posts.addTopicEvents(data.events); - }; - - function setFollowState(state) { - const titles = { - follow: '[[topic:watching]]', - unfollow: '[[topic:not-watching]]', - ignore: '[[topic:ignoring]]', - }; - translator.translate(titles[state], function (translatedTitle) { - $('[component="topic/watch"] button') - .attr('title', translatedTitle) - .tooltip('fixTitle'); - }); - - let menu = components.get('topic/following/menu'); - menu.toggleClass('hidden', state !== 'follow'); - components.get('topic/following/check').toggleClass('fa-check', state === 'follow'); - - menu = components.get('topic/not-following/menu'); - menu.toggleClass('hidden', state !== 'unfollow'); - components.get('topic/not-following/check').toggleClass('fa-check', state === 'unfollow'); - - menu = components.get('topic/ignoring/menu'); - menu.toggleClass('hidden', state !== 'ignore'); - components.get('topic/ignoring/check').toggleClass('fa-check', state === 'ignore'); - } - - - return ThreadTools; + threadElement.toggleClass('private', data.isPrivate); + ajaxify.data.private = data.isPrivate ? 1 : 0; + + posts.addTopicEvents(data.event); + }; + + ThreadTools.setDeleteState = function (data) { + const threadElement = components.get('topic'); + if (Number.parseInt(data.tid, 10) !== Number.parseInt(threadElement.attr('data-tid'), 10)) { + return; + } + + components.get('topic/delete').toggleClass('hidden', data.isDelete).parent().attr('hidden', data.isDelete ? '' : null); + components.get('topic/restore').toggleClass('hidden', !data.isDelete).parent().attr('hidden', data.isDelete ? null : ''); + components.get('topic/purge').toggleClass('hidden', !data.isDelete).parent().attr('hidden', data.isDelete ? null : ''); + components.get('topic/deleted/message').toggleClass('hidden', !data.isDelete); + + if (data.isDelete) { + app.parseAndTranslate('partials/topic/deleted-message', { + deleter: data.user, + deleted: true, + deletedTimestampISO: utils.toISOString(Date.now()), + }, html => { + components.get('topic/deleted/message').replaceWith(html); + html.find('.timeago').timeago(); + }); + } + + const hideReply = data.isDelete && !ajaxify.data.privileges.isAdminOrMod; + + components.get('topic/reply/container').toggleClass('hidden', hideReply); + components.get('topic/reply/locked').toggleClass('hidden', ajaxify.data.privileges.isAdminOrMod || !ajaxify.data.locked || data.isDelete); + threadElement.find('[component="post"]:not(.deleted) [component="post/reply"], [component="post"]:not(.deleted) [component="post/quote"]').toggleClass('hidden', hideReply); + + threadElement.toggleClass('deleted', data.isDelete); + ajaxify.data.deleted = data.isDelete ? 1 : 0; + + posts.addTopicEvents(data.events); + }; + + ThreadTools.setPinnedState = function (data) { + const threadElement = components.get('topic'); + if (Number.parseInt(data.tid, 10) !== Number.parseInt(threadElement.attr('data-tid'), 10)) { + return; + } + + components.get('topic/pin').toggleClass('hidden', data.pinned).parent().attr('hidden', data.pinned ? '' : null); + components.get('topic/unpin').toggleClass('hidden', !data.pinned).parent().attr('hidden', data.pinned ? null : ''); + const icon = $('[component="topic/labels"] [component="topic/pinned"]'); + icon.toggleClass('hidden', !data.pinned); + if (data.pinned) { + icon.translateAttr('title', ( + data.pinExpiry && data.pinExpiryISO + ? '[[topic:pinned-with-expiry, ' + data.pinExpiryISO + ']]' + : '[[topic:pinned]]' + )); + } + + ajaxify.data.pinned = data.pinned; + + posts.addTopicEvents(data.events); + }; + + function setFollowState(state) { + const titles = { + follow: '[[topic:watching]]', + unfollow: '[[topic:not-watching]]', + ignore: '[[topic:ignoring]]', + }; + translator.translate(titles[state], translatedTitle => { + $('[component="topic/watch"] button') + .attr('title', translatedTitle) + .tooltip('fixTitle'); + }); + + let menu = components.get('topic/following/menu'); + menu.toggleClass('hidden', state !== 'follow'); + components.get('topic/following/check').toggleClass('fa-check', state === 'follow'); + + menu = components.get('topic/not-following/menu'); + menu.toggleClass('hidden', state !== 'unfollow'); + components.get('topic/not-following/check').toggleClass('fa-check', state === 'unfollow'); + + menu = components.get('topic/ignoring/menu'); + menu.toggleClass('hidden', state !== 'ignore'); + components.get('topic/ignoring/check').toggleClass('fa-check', state === 'ignore'); + } + + return ThreadTools; }); diff --git a/public/src/client/topic/votes.js b/public/src/client/topic/votes.js index 05632f2..a202164 100644 --- a/public/src/client/topic/votes.js +++ b/public/src/client/topic/votes.js @@ -1,110 +1,111 @@ 'use strict'; - define('forum/topic/votes', [ - 'components', 'translator', 'api', 'hooks', 'bootbox', 'alerts', -], function (components, translator, api, hooks, bootbox, alerts) { - const Votes = {}; - - Votes.addVoteHandler = function () { - components.get('topic').on('mouseenter', '[data-pid] [component="post/vote-count"]', loadDataAndCreateTooltip); - }; - - function loadDataAndCreateTooltip(e) { - e.stopPropagation(); - - const $this = $(this); - const el = $this.parent(); - el.find('.tooltip').css('display', 'none'); - const pid = el.parents('[data-pid]').attr('data-pid'); - - socket.emit('posts.getUpvoters', [pid], function (err, data) { - if (err) { - return alerts.error(err); - } - - if (data.length) { - createTooltip($this, data[0]); - } - }); - return false; - } - - function createTooltip(el, data) { - function doCreateTooltip(title) { - el.attr('title', title).tooltip('fixTitle').tooltip('show'); - el.parent().find('.tooltip').css('display', ''); - } - let usernames = data.usernames - .filter(name => name !== '[[global:former_user]]'); - if (!usernames.length) { - return; - } - if (usernames.length + data.otherCount > 6) { - usernames = usernames.join(', ').replace(/,/g, '|'); - translator.translate('[[topic:users_and_others, ' + usernames + ', ' + data.otherCount + ']]', function (translated) { - translated = translated.replace(/\|/g, ','); - doCreateTooltip(translated); - }); - } else { - usernames = usernames.join(', '); - doCreateTooltip(usernames); - } - } - - - Votes.toggleVote = function (button, className, delta) { - const post = button.closest('[data-pid]'); - const currentState = post.find(className).length; - - const method = currentState ? 'del' : 'put'; - const pid = post.attr('data-pid'); - api[method](`/posts/${pid}/vote`, { - delta: delta, - }, function (err) { - if (err) { - if (!app.user.uid) { - ajaxify.go('login'); - return; - } - return alerts.error(err); - } - hooks.fire('action:post.toggleVote', { - pid: pid, - delta: delta, - unvote: method === 'del', - }); - }); - - return false; - }; - - Votes.showVotes = function (pid) { - socket.emit('posts.getVoters', { pid: pid, cid: ajaxify.data.cid }, function (err, data) { - if (err) { - if (err.message === '[[error:no-privileges]]') { - return; - } - - // Only show error if it's an unexpected error. - return alerts.error(err); - } - - app.parseAndTranslate('partials/modals/votes_modal', data, function (html) { - const dialog = bootbox.dialog({ - title: '[[global:voters]]', - message: html, - className: 'vote-modal', - show: true, - }); - - dialog.on('click', function () { - dialog.modal('hide'); - }); - }); - }); - }; - - - return Votes; + 'components', 'translator', 'api', 'hooks', 'bootbox', 'alerts', +], (components, translator, api, hooks, bootbox, alerts) => { + const Votes = {}; + + Votes.addVoteHandler = function () { + components.get('topic').on('mouseenter', '[data-pid] [component="post/vote-count"]', loadDataAndCreateTooltip); + }; + + function loadDataAndCreateTooltip(e) { + e.stopPropagation(); + + const $this = $(this); + const element = $this.parent(); + element.find('.tooltip').css('display', 'none'); + const pid = element.parents('[data-pid]').attr('data-pid'); + + socket.emit('posts.getUpvoters', [pid], (error, data) => { + if (error) { + return alerts.error(error); + } + + if (data.length > 0) { + createTooltip($this, data[0]); + } + }); + return false; + } + + function createTooltip(element, data) { + function doCreateTooltip(title) { + element.attr('title', title).tooltip('fixTitle').tooltip('show'); + element.parent().find('.tooltip').css('display', ''); + } + + let usernames = data.usernames + .filter(name => name !== '[[global:former_user]]'); + if (usernames.length === 0) { + return; + } + + if (usernames.length + data.otherCount > 6) { + usernames = usernames.join(', ').replaceAll(',', '|'); + translator.translate('[[topic:users_and_others, ' + usernames + ', ' + data.otherCount + ']]', translated => { + translated = translated.replaceAll('|', ','); + doCreateTooltip(translated); + }); + } else { + usernames = usernames.join(', '); + doCreateTooltip(usernames); + } + } + + Votes.toggleVote = function (button, className, delta) { + const post = button.closest('[data-pid]'); + const currentState = post.find(className).length; + + const method = currentState ? 'del' : 'put'; + const pid = post.attr('data-pid'); + api[method](`/posts/${pid}/vote`, { + delta, + }, error => { + if (error) { + if (!app.user.uid) { + ajaxify.go('login'); + return; + } + + return alerts.error(error); + } + + hooks.fire('action:post.toggleVote', { + pid, + delta, + unvote: method === 'del', + }); + }); + + return false; + }; + + Votes.showVotes = function (pid) { + socket.emit('posts.getVoters', {pid, cid: ajaxify.data.cid}, (error, data) => { + if (error) { + if (error.message === '[[error:no-privileges]]') { + return; + } + + // Only show error if it's an unexpected error. + return alerts.error(error); + } + + app.parseAndTranslate('partials/modals/votes_modal', data, html => { + const dialog = bootbox.dialog({ + title: '[[global:voters]]', + message: html, + className: 'vote-modal', + show: true, + }); + + dialog.on('click', () => { + dialog.modal('hide'); + }); + }); + }); + }; + + return Votes; }); diff --git a/public/src/client/unread.js b/public/src/client/unread.js index 8d7b529..78d1136 100644 --- a/public/src/client/unread.js +++ b/public/src/client/unread.js @@ -1,112 +1,114 @@ 'use strict'; - define('forum/unread', [ - 'forum/header/unread', 'topicSelect', 'components', 'topicList', 'categorySelector', 'alerts', -], function (headerUnread, topicSelect, components, topicList, categorySelector, alerts) { - const Unread = {}; - - Unread.init = function () { - app.enterRoom('unread_topics'); - - handleMarkRead(); - - topicList.init('unread'); - - headerUnread.updateUnreadTopicCount('/' + ajaxify.data.selectedFilter.url, ajaxify.data.topicCount); - }; - - function handleMarkRead() { - function markAllRead() { - socket.emit('topics.markAllRead', function (err) { - if (err) { - return alerts.error(err); - } - - alerts.success('[[unread:topics_marked_as_read.success]]'); - - $('[component="category"]').empty(); - $('[component="pagination"]').addClass('hidden'); - $('#category-no-topics').removeClass('hidden'); - $('.markread').addClass('hidden'); - }); - } - - function markSelectedRead() { - const tids = topicSelect.getSelectedTids(); - if (!tids.length) { - return; - } - socket.emit('topics.markAsRead', tids, function (err) { - if (err) { - return alerts.error(err); - } - - doneRemovingTids(tids); - }); - } - - function markCategoryRead(cid) { - function getCategoryTids(cid) { - const tids = []; - components.get('category/topic', 'cid', cid).each(function () { - tids.push($(this).attr('data-tid')); - }); - return tids; - } - const tids = getCategoryTids(cid); - - socket.emit('topics.markCategoryTopicsRead', cid, function (err) { - if (err) { - return alerts.error(err); - } - - doneRemovingTids(tids); - }); - } - const selector = categorySelector.init($('[component="category-selector"]'), { - onSelect: function (category) { - selector.selectCategory(0); - if (category.cid === 'all') { - markAllRead(); - } else if (category.cid === 'selected') { - markSelectedRead(); - } else if (parseInt(category.cid, 10) > 0) { - markCategoryRead(category.cid); - } - }, - selectCategoryLabel: ajaxify.data.selectCategoryLabel || '[[unread:mark_as_read]]', - localCategories: [ - { - cid: 'selected', - name: '[[unread:selected]]', - icon: '', - }, - { - cid: 'all', - name: '[[unread:all]]', - icon: '', - }, - ], - }); - } - - function doneRemovingTids(tids) { - removeTids(tids); - - alerts.success('[[unread:topics_marked_as_read.success]]'); - - if (!$('[component="category"]').children().length) { - $('#category-no-topics').removeClass('hidden'); - $('.markread').addClass('hidden'); - } - } - - function removeTids(tids) { - for (let i = 0; i < tids.length; i += 1) { - components.get('category/topic', 'tid', tids[i]).remove(); - } - } - - return Unread; + 'forum/header/unread', 'topicSelect', 'components', 'topicList', 'categorySelector', 'alerts', +], (headerUnread, topicSelect, components, topicList, categorySelector, alerts) => { + const Unread = {}; + + Unread.init = function () { + app.enterRoom('unread_topics'); + + handleMarkRead(); + + topicList.init('unread'); + + headerUnread.updateUnreadTopicCount('/' + ajaxify.data.selectedFilter.url, ajaxify.data.topicCount); + }; + + function handleMarkRead() { + function markAllRead() { + socket.emit('topics.markAllRead', error => { + if (error) { + return alerts.error(error); + } + + alerts.success('[[unread:topics_marked_as_read.success]]'); + + $('[component="category"]').empty(); + $('[component="pagination"]').addClass('hidden'); + $('#category-no-topics').removeClass('hidden'); + $('.markread').addClass('hidden'); + }); + } + + function markSelectedRead() { + const tids = topicSelect.getSelectedTids(); + if (tids.length === 0) { + return; + } + + socket.emit('topics.markAsRead', tids, error => { + if (error) { + return alerts.error(error); + } + + doneRemovingTids(tids); + }); + } + + function markCategoryRead(cid) { + function getCategoryTids(cid) { + const tids = []; + components.get('category/topic', 'cid', cid).each(function () { + tids.push($(this).attr('data-tid')); + }); + return tids; + } + + const tids = getCategoryTids(cid); + + socket.emit('topics.markCategoryTopicsRead', cid, error => { + if (error) { + return alerts.error(error); + } + + doneRemovingTids(tids); + }); + } + + const selector = categorySelector.init($('[component="category-selector"]'), { + onSelect(category) { + selector.selectCategory(0); + if (category.cid === 'all') { + markAllRead(); + } else if (category.cid === 'selected') { + markSelectedRead(); + } else if (Number.parseInt(category.cid, 10) > 0) { + markCategoryRead(category.cid); + } + }, + selectCategoryLabel: ajaxify.data.selectCategoryLabel || '[[unread:mark_as_read]]', + localCategories: [ + { + cid: 'selected', + name: '[[unread:selected]]', + icon: '', + }, + { + cid: 'all', + name: '[[unread:all]]', + icon: '', + }, + ], + }); + } + + function doneRemovingTids(tids) { + removeTids(tids); + + alerts.success('[[unread:topics_marked_as_read.success]]'); + + if ($('[component="category"]').children().length === 0) { + $('#category-no-topics').removeClass('hidden'); + $('.markread').addClass('hidden'); + } + } + + function removeTids(tids) { + for (const tid of tids) { + components.get('category/topic', 'tid', tid).remove(); + } + } + + return Unread; }); diff --git a/public/src/client/users.js b/public/src/client/users.js index 3e36b1f..d8df77f 100644 --- a/public/src/client/users.js +++ b/public/src/client/users.js @@ -1,122 +1,138 @@ 'use strict'; - define('forum/users', [ - 'translator', 'benchpress', 'api', 'alerts', 'accounts/invite', -], function (translator, Benchpress, api, alerts, AccountInvite) { - const Users = {}; - - let searchResultCount = 0; - - Users.init = function () { - app.enterRoom('user_list'); - - const section = utils.param('section') ? ('?section=' + utils.param('section')) : ''; - $('.nav-pills li').removeClass('active').find('a[href="' + window.location.pathname + section + '"]').parent() - .addClass('active'); - - Users.handleSearch(); - - AccountInvite.handle(); - - socket.removeListener('event:user_status_change', onUserStatusChange); - socket.on('event:user_status_change', onUserStatusChange); - }; - - Users.handleSearch = function (params) { - searchResultCount = params && params.resultCount; - $('#search-user').on('keyup', utils.debounce(doSearch, 250)); - $('.search select, .search input[type="checkbox"]').on('change', doSearch); - }; - - function doSearch() { - if (!ajaxify.data.template.users) { - return; - } - $('[component="user/search/icon"]').removeClass('fa-search').addClass('fa-spinner fa-spin'); - const username = $('#search-user').val(); - const activeSection = getActiveSection(); - - const query = { - section: activeSection, - page: 1, - }; - - if (!username) { - return loadPage(query); - } - - query.query = username; - query.sortBy = getSortBy(); - const filters = []; - if ($('.search .online-only').is(':checked') || (activeSection === 'online')) { - filters.push('online'); - } - if (activeSection === 'banned') { - filters.push('banned'); - } - if (activeSection === 'flagged') { - filters.push('flagged'); - } - if (filters.length) { - query.filters = filters; - } - - loadPage(query); - } - - function getSortBy() { - let sortBy; - const activeSection = getActiveSection(); - if (activeSection === 'sort-posts') { - sortBy = 'postcount'; - } else if (activeSection === 'sort-reputation') { - sortBy = 'reputation'; - } else if (activeSection === 'users') { - sortBy = 'joindate'; - } - return sortBy; - } - - - function loadPage(query) { - api.get('/api/users', query) - .then(renderSearchResults) - .catch(alerts.error); - } - - function renderSearchResults(data) { - Benchpress.render('partials/paginator', { pagination: data.pagination }).then(function (html) { - $('.pagination-container').replaceWith(html); - }); - - if (searchResultCount) { - data.users = data.users.slice(0, searchResultCount); - } - - data.isAdminOrGlobalMod = app.user.isAdmin || app.user.isGlobalMod; - app.parseAndTranslate('users', 'users', data, function (html) { - $('#users-container').html(html); - html.find('span.timeago').timeago(); - $('[component="user/search/icon"]').addClass('fa-search').removeClass('fa-spinner fa-spin'); - }); - } - - function onUserStatusChange(data) { - const section = getActiveSection(); - - if ((section.startsWith('online') || section.startsWith('users'))) { - updateUser(data); - } - } - - function updateUser(data) { - app.updateUserStatus($('#users-container [data-uid="' + data.uid + '"] [component="user/status"]'), data.status); - } - - function getActiveSection() { - return utils.param('section') || ''; - } - - return Users; + 'translator', 'benchpress', 'api', 'alerts', 'accounts/invite', +], (translator, Benchpress, api, alerts, AccountInvite) => { + const Users = {}; + + let searchResultCount = 0; + + Users.init = function () { + app.enterRoom('user_list'); + + const section = utils.param('section') ? ('?section=' + utils.param('section')) : ''; + $('.nav-pills li').removeClass('active').find('a[href="' + window.location.pathname + section + '"]').parent() + .addClass('active'); + + Users.handleSearch(); + + AccountInvite.handle(); + + socket.removeListener('event:user_status_change', onUserStatusChange); + socket.on('event:user_status_change', onUserStatusChange); + }; + + Users.handleSearch = function (parameters) { + searchResultCount = parameters && parameters.resultCount; + $('#search-user').on('keyup', utils.debounce(doSearch, 250)); + $('.search select, .search input[type="checkbox"]').on('change', doSearch); + }; + + function doSearch() { + if (!ajaxify.data.template.users) { + return; + } + + $('[component="user/search/icon"]').removeClass('fa-search').addClass('fa-spinner fa-spin'); + const username = $('#search-user').val(); + const activeSection = getActiveSection(); + + const query = { + section: activeSection, + page: 1, + }; + + if (!username) { + return loadPage(query); + } + + query.query = username; + query.sortBy = getSortBy(); + const filters = []; + if ($('.search .online-only').is(':checked') || (activeSection === 'online')) { + filters.push('online'); + } + + if (activeSection === 'banned') { + filters.push('banned'); + } + + if (activeSection === 'flagged') { + filters.push('flagged'); + } + + if (filters.length > 0) { + query.filters = filters; + } + + loadPage(query); + } + + function getSortBy() { + let sortBy; + const activeSection = getActiveSection(); + switch (activeSection) { + case 'sort-posts': { + sortBy = 'postcount'; + + break; + } + + case 'sort-reputation': { + sortBy = 'reputation'; + + break; + } + + case 'users': { + sortBy = 'joindate'; + + break; + } + // No default + } + + return sortBy; + } + + function loadPage(query) { + api.get('/api/users', query) + .then(renderSearchResults) + .catch(alerts.error); + } + + function renderSearchResults(data) { + Benchpress.render('partials/paginator', {pagination: data.pagination}).then(html => { + $('.pagination-container').replaceWith(html); + }); + + if (searchResultCount) { + data.users = data.users.slice(0, searchResultCount); + } + + data.isAdminOrGlobalMod = app.user.isAdmin || app.user.isGlobalMod; + app.parseAndTranslate('users', 'users', data, html => { + $('#users-container').html(html); + html.find('span.timeago').timeago(); + $('[component="user/search/icon"]').addClass('fa-search').removeClass('fa-spinner fa-spin'); + }); + } + + function onUserStatusChange(data) { + const section = getActiveSection(); + + if ((section.startsWith('online') || section.startsWith('users'))) { + updateUser(data); + } + } + + function updateUser(data) { + app.updateUserStatus($('#users-container [data-uid="' + data.uid + '"] [component="user/status"]'), data.status); + } + + function getActiveSection() { + return utils.param('section') || ''; + } + + return Users; }); diff --git a/public/src/installer/install.js b/public/src/installer/install.js index bf6b560..0526c1f 100644 --- a/public/src/installer/install.js +++ b/public/src/installer/install.js @@ -1,4 +1,3 @@ -/* eslint-disable no-redeclare */ 'use strict'; @@ -7,138 +6,148 @@ const zxcvbn = require('zxcvbn'); const utils = require('../utils'); const slugify = require('../modules/slugify'); -$('document').ready(function () { - setupInputs(); - $('[name="username"]').focus(); - - activate('database', $('[name="database"]')); - - if ($('#database-error').length) { - $('[name="database"]').parents('.input-row').addClass('error'); - $('html, body').animate({ - scrollTop: ($('#database-error').offset().top + 100) + 'px', - }, 400); - } - - $('#launch').on('click', launchForum); - - if ($('#installing').length) { - setTimeout(function () { - window.location.reload(true); - }, 5000); - } - - function setupInputs() { - $('form').on('focus', '.form-control', function () { - const parent = $(this).parents('.input-row'); - - $('.input-row.active').removeClass('active'); - parent.addClass('active').removeClass('error'); - - const help = parent.find('.help-text'); - help.html(help.attr('data-help')); - }); - - $('form').on('blur change', '[name]', function () { - activate($(this).attr('name'), $(this)); - }); - - $('form').submit(validateAll); - } - - function validateAll(ev) { - $('form .admin [name]').each(function () { - activate($(this).attr('name'), $(this)); - }); - - if ($('form .admin .error').length) { - ev.preventDefault(); - $('html, body').animate({ scrollTop: '0px' }, 400); - - return false; - } - $('#submit .working').removeClass('hide'); - } - - function activate(type, el) { - const field = el.val(); - const parent = el.parents('.input-row'); - const help = parent.children('.help-text'); - - function validateUsername(field) { - if (!utils.isUserNameValid(field) || !slugify(field)) { - parent.addClass('error'); - help.html('Invalid Username.'); - } else { - parent.removeClass('error'); - } - } - - function validatePassword(field) { - if (!utils.isPasswordValid(field)) { - parent.addClass('error'); - help.html('Invalid Password.'); - } else if (field.length < $('[name="admin:password"]').attr('data-minimum-length')) { - parent.addClass('error'); - help.html('Password is too short.'); - } else if (zxcvbn(field).score < parseInt($('[name="admin:password"]').attr('data-minimum-strength'), 10)) { - parent.addClass('error'); - help.html('Password is too weak.'); - } else { - parent.removeClass('error'); - } - } - - function validateConfirmPassword() { - if ($('[name="admin:password"]').val() !== $('[name="admin:passwordConfirm"]').val()) { - parent.addClass('error'); - help.html('Passwords do not match.'); - } else { - parent.removeClass('error'); - } - } - - function validateEmail(field) { - if (!utils.isEmailValid(field)) { - parent.addClass('error'); - help.html('Invalid Email Address.'); - } else { - parent.removeClass('error'); - } - } - - function switchDatabase(field) { - $('#database-config').html($('[data-database="' + field + '"]').html()); - } - - switch (type) { - case 'admin:username': - return validateUsername(field); - case 'admin:password': - return validatePassword(field); - case 'admin:passwordConfirm': - return validateConfirmPassword(field); - case 'admin:email': - return validateEmail(field); - case 'database': - return switchDatabase(field); - } - } - - function launchForum() { - $('#launch .working').removeClass('hide'); - $.post('/launch', function () { - let successCount = 0; - const url = $('#launch').attr('data-url'); - setInterval(function () { - $.get(url + '/admin').done(function () { - if (successCount >= 5) { - window.location = 'admin'; - } else { - successCount += 1; - } - }); - }, 750); - }); - } +$('document').ready(() => { + setupInputs(); + $('[name="username"]').focus(); + + activate('database', $('[name="database"]')); + + if ($('#database-error').length > 0) { + $('[name="database"]').parents('.input-row').addClass('error'); + $('html, body').animate({ + scrollTop: ($('#database-error').offset().top + 100) + 'px', + }, 400); + } + + $('#launch').on('click', launchForum); + + if ($('#installing').length > 0) { + setTimeout(() => { + window.location.reload(true); + }, 5000); + } + + function setupInputs() { + $('form').on('focus', '.form-control', function () { + const parent = $(this).parents('.input-row'); + + $('.input-row.active').removeClass('active'); + parent.addClass('active').removeClass('error'); + + const help = parent.find('.help-text'); + help.html(help.attr('data-help')); + }); + + $('form').on('blur change', '[name]', function () { + activate($(this).attr('name'), $(this)); + }); + + $('form').submit(validateAll); + } + + function validateAll(event) { + $('form .admin [name]').each(function () { + activate($(this).attr('name'), $(this)); + }); + + if ($('form .admin .error').length > 0) { + event.preventDefault(); + $('html, body').animate({scrollTop: '0px'}, 400); + + return false; + } + + $('#submit .working').removeClass('hide'); + } + + function activate(type, element) { + const field = element.val(); + const parent = element.parents('.input-row'); + const help = parent.children('.help-text'); + + function validateUsername(field) { + if (!utils.isUserNameValid(field) || !slugify(field)) { + parent.addClass('error'); + help.html('Invalid Username.'); + } else { + parent.removeClass('error'); + } + } + + function validatePassword(field) { + if (!utils.isPasswordValid(field)) { + parent.addClass('error'); + help.html('Invalid Password.'); + } else if (field.length < $('[name="admin:password"]').attr('data-minimum-length')) { + parent.addClass('error'); + help.html('Password is too short.'); + } else if (zxcvbn(field).score < Number.parseInt($('[name="admin:password"]').attr('data-minimum-strength'), 10)) { + parent.addClass('error'); + help.html('Password is too weak.'); + } else { + parent.removeClass('error'); + } + } + + function validateConfirmPassword() { + if ($('[name="admin:password"]').val() === $('[name="admin:passwordConfirm"]').val()) { + parent.removeClass('error'); + } else { + parent.addClass('error'); + help.html('Passwords do not match.'); + } + } + + function validateEmail(field) { + if (utils.isEmailValid(field)) { + parent.removeClass('error'); + } else { + parent.addClass('error'); + help.html('Invalid Email Address.'); + } + } + + function switchDatabase(field) { + $('#database-config').html($('[data-database="' + field + '"]').html()); + } + + switch (type) { + case 'admin:username': { + return validateUsername(field); + } + + case 'admin:password': { + return validatePassword(field); + } + + case 'admin:passwordConfirm': { + return validateConfirmPassword(field); + } + + case 'admin:email': { + return validateEmail(field); + } + + case 'database': { + return switchDatabase(field); + } + } + } + + function launchForum() { + $('#launch .working').removeClass('hide'); + $.post('/launch', () => { + let successCount = 0; + const url = $('#launch').attr('data-url'); + setInterval(() => { + $.get(url + '/admin').done(() => { + if (successCount >= 5) { + window.location = 'admin'; + } else { + successCount += 1; + } + }); + }, 750); + }); + } }); diff --git a/public/src/modules/accounts/delete.js b/public/src/modules/accounts/delete.js index e8f16de..0eb1b83 100644 --- a/public/src/modules/accounts/delete.js +++ b/public/src/modules/accounts/delete.js @@ -1,53 +1,53 @@ 'use strict'; -define('accounts/delete', ['api', 'bootbox', 'alerts'], function (api, bootbox, alerts) { - const Delete = {}; - - Delete.account = function (uid, callback) { - executeAction( - uid, - '[[user:delete_this_account_confirm]]', - '/account', - '[[user:account-deleted]]', - callback - ); - }; - - Delete.content = function (uid, callback) { - executeAction( - uid, - '[[user:delete_account_content_confirm]]', - '/content', - '[[user:account-content-deleted]]', - callback - ); - }; - - Delete.purge = function (uid, callback) { - executeAction( - uid, - '[[user:delete_all_confirm]]', - '', - '[[user:account-deleted]]', - callback - ); - }; - - function executeAction(uid, confirmText, path, successText, callback) { - bootbox.confirm(confirmText, function (confirm) { - if (!confirm) { - return; - } - - api.del(`/users/${uid}${path}`, {}).then(() => { - alerts.success(successText); - - if (typeof callback === 'function') { - return callback(); - } - }).catch(alerts.error); - }); - } - - return Delete; +define('accounts/delete', ['api', 'bootbox', 'alerts'], (api, bootbox, alerts) => { + const Delete = {}; + + Delete.account = function (uid, callback) { + executeAction( + uid, + '[[user:delete_this_account_confirm]]', + '/account', + '[[user:account-deleted]]', + callback, + ); + }; + + Delete.content = function (uid, callback) { + executeAction( + uid, + '[[user:delete_account_content_confirm]]', + '/content', + '[[user:account-content-deleted]]', + callback, + ); + }; + + Delete.purge = function (uid, callback) { + executeAction( + uid, + '[[user:delete_all_confirm]]', + '', + '[[user:account-deleted]]', + callback, + ); + }; + + function executeAction(uid, confirmText, path, successText, callback) { + bootbox.confirm(confirmText, confirm => { + if (!confirm) { + return; + } + + api.del(`/users/${uid}${path}`, {}).then(() => { + alerts.success(successText); + + if (typeof callback === 'function') { + return callback(); + } + }).catch(alerts.error); + }); + } + + return Delete; }); diff --git a/public/src/modules/accounts/invite.js b/public/src/modules/accounts/invite.js index 95f6177..867f7fa 100644 --- a/public/src/modules/accounts/invite.js +++ b/public/src/modules/accounts/invite.js @@ -1,60 +1,60 @@ 'use strict'; -define('accounts/invite', ['api', 'benchpress', 'bootbox', 'alerts'], function (api, Benchpress, bootbox, alerts) { - const Invite = {}; - - function isACP() { - return ajaxify.data.template.name.startsWith('admin/'); - } - - Invite.handle = function () { - $('[component="user/invite"]').on('click', function (e) { - e.preventDefault(); - api.get(`/api/v3/users/${app.user.uid}/invites/groups`, {}).then((groups) => { - Benchpress.parse('modals/invite', { groups: groups }, function (html) { - bootbox.dialog({ - message: html, - title: `[[${isACP() ? 'admin/manage/users:invite' : 'users:invite'}]]`, - onEscape: true, - buttons: { - cancel: { - label: `[[${isACP() ? 'admin/manage/users:alerts.button-cancel' : 'modules:bootbox.cancel'}]]`, - className: 'btn-default', - }, - invite: { - label: `[[${isACP() ? 'admin/manage/users:invite' : 'users:invite'}]]`, - className: 'btn-primary', - callback: Invite.send, - }, - }, - }); - }); - }).catch(alerts.error); - }); - }; - - Invite.send = function () { - const $emails = $('#invite-modal-emails'); - const $groups = $('#invite-modal-groups'); - - const data = { - emails: $emails.val() - .split(',') - .map(m => m.trim()) - .filter(Boolean) - .filter((m, i, arr) => i === arr.indexOf(m)) - .join(','), - groupsToJoin: $groups.val(), - }; - - if (!data.emails) { - return; - } - - api.post(`/users/${app.user.uid}/invites`, data).then(() => { - alerts.success(`[[${isACP() ? 'admin/manage/users:alerts.email-sent-to' : 'users:invitation-email-sent'}, ${data.emails.replace(/,/g, ', ')}]]`); - }).catch(alerts.error); - }; - - return Invite; +define('accounts/invite', ['api', 'benchpress', 'bootbox', 'alerts'], (api, Benchpress, bootbox, alerts) => { + const Invite = {}; + + function isACP() { + return ajaxify.data.template.name.startsWith('admin/'); + } + + Invite.handle = function () { + $('[component="user/invite"]').on('click', e => { + e.preventDefault(); + api.get(`/api/v3/users/${app.user.uid}/invites/groups`, {}).then(groups => { + Benchpress.parse('modals/invite', {groups}, html => { + bootbox.dialog({ + message: html, + title: `[[${isACP() ? 'admin/manage/users:invite' : 'users:invite'}]]`, + onEscape: true, + buttons: { + cancel: { + label: `[[${isACP() ? 'admin/manage/users:alerts.button-cancel' : 'modules:bootbox.cancel'}]]`, + className: 'btn-default', + }, + invite: { + label: `[[${isACP() ? 'admin/manage/users:invite' : 'users:invite'}]]`, + className: 'btn-primary', + callback: Invite.send, + }, + }, + }); + }); + }).catch(alerts.error); + }); + }; + + Invite.send = function () { + const $emails = $('#invite-modal-emails'); + const $groups = $('#invite-modal-groups'); + + const data = { + emails: $emails.val() + .split(',') + .map(m => m.trim()) + .filter(Boolean) + .filter((m, i, array) => i === array.indexOf(m)) + .join(','), + groupsToJoin: $groups.val(), + }; + + if (!data.emails) { + return; + } + + api.post(`/users/${app.user.uid}/invites`, data).then(() => { + alerts.success(`[[${isACP() ? 'admin/manage/users:alerts.email-sent-to' : 'users:invitation-email-sent'}, ${data.emails.replaceAll(',', ', ')}]]`); + }).catch(alerts.error); + }; + + return Invite; }); diff --git a/public/src/modules/accounts/picture.js b/public/src/modules/accounts/picture.js index 0d905c1..aaad5c0 100644 --- a/public/src/modules/accounts/picture.js +++ b/public/src/modules/accounts/picture.js @@ -1,219 +1,220 @@ 'use strict'; define('accounts/picture', [ - 'pictureCropper', - 'api', - 'bootbox', - 'alerts', + 'pictureCropper', + 'api', + 'bootbox', + 'alerts', ], (pictureCropper, api, bootbox, alerts) => { - const Picture = {}; - - Picture.openChangeModal = () => { - socket.emit('user.getProfilePictures', { - uid: ajaxify.data.uid, - }, function (err, pictures) { - if (err) { - return alerts.error(err); - } - - // boolean to signify whether an uploaded picture is present in the pictures list - const uploaded = pictures.reduce(function (memo, cur) { - return memo || cur.type === 'uploaded'; - }, false); - - app.parseAndTranslate('partials/modals/change_picture_modal', { - pictures: pictures, - uploaded: uploaded, - icon: { text: ajaxify.data['icon:text'], bgColor: ajaxify.data['icon:bgColor'] }, - defaultAvatar: ajaxify.data.defaultAvatar, - allowProfileImageUploads: ajaxify.data.allowProfileImageUploads, - iconBackgrounds: config.iconBackgrounds, - user: { - uid: ajaxify.data.uid, - username: ajaxify.data.username, - picture: ajaxify.data.picture, - 'icon:text': ajaxify.data['icon:text'], - 'icon:bgColor': ajaxify.data['icon:bgColor'], - }, - }, function (html) { - const modal = bootbox.dialog({ - className: 'picture-switcher', - title: '[[user:change_picture]]', - message: html, - show: true, - buttons: { - close: { - label: '[[global:close]]', - callback: onCloseModal, - className: 'btn-link', - }, - update: { - label: '[[global:save_changes]]', - callback: saveSelection, - }, - }, - }); - - modal.on('shown.bs.modal', updateImages); - modal.on('click', '.list-group-item', function selectImageType() { - modal.find('.list-group-item').removeClass('active'); - $(this).addClass('active'); - }); - modal.on('change', 'input[type="radio"][name="icon:bgColor"]', (e) => { - const value = e.target.value; - modal.find('.user-icon').css('background-color', value); - }); - - handleImageUpload(modal); - - function updateImages() { - // Check to see which one is the active picture - if (!ajaxify.data.picture) { - modal.find('.list-group-item .user-icon').parents('.list-group-item').addClass('active'); - } else { - modal.find('.list-group-item img').each(function () { - if (this.getAttribute('src') === ajaxify.data.picture) { - $(this).parents('.list-group-item').addClass('active'); - } - }); - } - - // Update avatar background colour - const radioEl = document.querySelector(`.modal input[type="radio"][value="${ajaxify.data['icon:bgColor']}"]`); - if (radioEl) { - radioEl.checked = true; - } else { - // Check the first one - document.querySelector('.modal input[type="radio"]').checked = true; - } - } - - function saveSelection() { - const type = modal.find('.list-group-item.active').attr('data-type'); - const iconBgColor = document.querySelector('.modal.picture-switcher input[type="radio"]:checked').value || 'transparent'; - - changeUserPicture(type, iconBgColor).then(() => { - Picture.updateHeader(type === 'default' ? '' : modal.find('.list-group-item.active img').attr('src'), iconBgColor); - ajaxify.refresh(); - }).catch(alerts.error); - } - - function onCloseModal() { - modal.modal('hide'); - } - }); - }); - }; - - Picture.updateHeader = (picture, iconBgColor) => { - if (parseInt(ajaxify.data.theirid, 10) !== parseInt(ajaxify.data.yourid, 10)) { - return; - } - if (!picture && ajaxify.data.defaultAvatar) { - picture = ajaxify.data.defaultAvatar; - } - $('#header [component="avatar/picture"]')[picture ? 'show' : 'hide'](); - $('#header [component="avatar/icon"]')[!picture ? 'show' : 'hide'](); - if (picture) { - $('#header [component="avatar/picture"]').attr('src', picture); - } - - if (iconBgColor) { - document.querySelectorAll('[component="navbar"] [component="avatar/icon"]').forEach((el) => { - el.style['background-color'] = iconBgColor; - }); - } - }; - - function handleImageUpload(modal) { - function onUploadComplete(urlOnServer) { - urlOnServer = (!urlOnServer.startsWith('http') ? config.relative_path : '') + urlOnServer + '?' + Date.now(); - - Picture.updateHeader(urlOnServer); - - if (ajaxify.data.picture && ajaxify.data.picture.length) { - $('#user-current-picture, img.avatar').attr('src', urlOnServer); - ajaxify.data.uploadedpicture = urlOnServer; - } else { - ajaxify.refresh(function () { - $('#user-current-picture, img.avatar').attr('src', urlOnServer); - }); - } - } - - function onRemoveComplete() { - if (ajaxify.data.uploadedpicture === ajaxify.data.picture) { - ajaxify.refresh(); - Picture.updateHeader(); - } - } - - modal.find('[data-action="upload"]').on('click', function () { - modal.modal('hide'); - - pictureCropper.show({ - socketMethod: 'user.uploadCroppedPicture', - route: config.relative_path + '/api/user/' + ajaxify.data.userslug + '/uploadpicture', - aspectRatio: 1 / 1, - paramName: 'uid', - paramValue: ajaxify.data.theirid, - fileSize: ajaxify.data.maximumProfileImageSize, - allowSkippingCrop: false, - title: '[[user:upload_picture]]', - description: '[[user:upload_a_picture]]', - accept: ajaxify.data.allowedProfileImageExtensions, - }, function (url) { - onUploadComplete(url); - }); - - return false; - }); - - modal.find('[data-action="upload-url"]').on('click', function () { - modal.modal('hide'); - app.parseAndTranslate('partials/modals/upload_picture_from_url_modal', {}, function (uploadModal) { - uploadModal.modal('show'); - - uploadModal.find('.upload-btn').on('click', function () { - const url = uploadModal.find('#uploadFromUrl').val(); - if (!url) { - return false; - } - - uploadModal.modal('hide'); - - pictureCropper.handleImageCrop({ - url: url, - socketMethod: 'user.uploadCroppedPicture', - aspectRatio: 1, - allowSkippingCrop: false, - paramName: 'uid', - paramValue: ajaxify.data.theirid, - }, onUploadComplete); - - return false; - }); - }); - - return false; - }); - - modal.find('[data-action="remove-uploaded"]').on('click', function () { - socket.emit('user.removeUploadedPicture', { - uid: ajaxify.data.theirid, - }, function (err) { - modal.modal('hide'); - if (err) { - return alerts.error(err); - } - onRemoveComplete(); - }); - }); - } - - function changeUserPicture(type, bgColor) { - return api.put(`/users/${ajaxify.data.theirid}/picture`, { type, bgColor }); - } - - return Picture; + const Picture = {}; + + Picture.openChangeModal = () => { + socket.emit('user.getProfilePictures', { + uid: ajaxify.data.uid, + }, (error, pictures) => { + if (error) { + return alerts.error(error); + } + + // Boolean to signify whether an uploaded picture is present in the pictures list + const uploaded = pictures.reduce((memo, current) => memo || current.type === 'uploaded', false); + + app.parseAndTranslate('partials/modals/change_picture_modal', { + pictures, + uploaded, + icon: {text: ajaxify.data['icon:text'], bgColor: ajaxify.data['icon:bgColor']}, + defaultAvatar: ajaxify.data.defaultAvatar, + allowProfileImageUploads: ajaxify.data.allowProfileImageUploads, + iconBackgrounds: config.iconBackgrounds, + user: { + uid: ajaxify.data.uid, + username: ajaxify.data.username, + picture: ajaxify.data.picture, + 'icon:text': ajaxify.data['icon:text'], + 'icon:bgColor': ajaxify.data['icon:bgColor'], + }, + }, html => { + const modal = bootbox.dialog({ + className: 'picture-switcher', + title: '[[user:change_picture]]', + message: html, + show: true, + buttons: { + close: { + label: '[[global:close]]', + callback: onCloseModal, + className: 'btn-link', + }, + update: { + label: '[[global:save_changes]]', + callback: saveSelection, + }, + }, + }); + + modal.on('shown.bs.modal', updateImages); + modal.on('click', '.list-group-item', function selectImageType() { + modal.find('.list-group-item').removeClass('active'); + $(this).addClass('active'); + }); + modal.on('change', 'input[type="radio"][name="icon:bgColor"]', e => { + const value = e.target.value; + modal.find('.user-icon').css('background-color', value); + }); + + handleImageUpload(modal); + + function updateImages() { + // Check to see which one is the active picture + if (ajaxify.data.picture) { + modal.find('.list-group-item img').each(function () { + if (this.getAttribute('src') === ajaxify.data.picture) { + $(this).parents('.list-group-item').addClass('active'); + } + }); + } else { + modal.find('.list-group-item .user-icon').parents('.list-group-item').addClass('active'); + } + + // Update avatar background colour + const radioElement = document.querySelector(`.modal input[type="radio"][value="${ajaxify.data['icon:bgColor']}"]`); + if (radioElement) { + radioElement.checked = true; + } else { + // Check the first one + document.querySelector('.modal input[type="radio"]').checked = true; + } + } + + function saveSelection() { + const type = modal.find('.list-group-item.active').attr('data-type'); + const iconBgColor = document.querySelector('.modal.picture-switcher input[type="radio"]:checked').value || 'transparent'; + + changeUserPicture(type, iconBgColor).then(() => { + Picture.updateHeader(type === 'default' ? '' : modal.find('.list-group-item.active img').attr('src'), iconBgColor); + ajaxify.refresh(); + }).catch(alerts.error); + } + + function onCloseModal() { + modal.modal('hide'); + } + }); + }); + }; + + Picture.updateHeader = (picture, iconBgColor) => { + if (Number.parseInt(ajaxify.data.theirid, 10) !== Number.parseInt(ajaxify.data.yourid, 10)) { + return; + } + + if (!picture && ajaxify.data.defaultAvatar) { + picture = ajaxify.data.defaultAvatar; + } + + $('#header [component="avatar/picture"]')[picture ? 'show' : 'hide'](); + $('#header [component="avatar/icon"]')[picture ? 'hide' : 'show'](); + if (picture) { + $('#header [component="avatar/picture"]').attr('src', picture); + } + + if (iconBgColor) { + for (const element of document.querySelectorAll('[component="navbar"] [component="avatar/icon"]')) { + element.style['background-color'] = iconBgColor; + } + } + }; + + function handleImageUpload(modal) { + function onUploadComplete(urlOnServer) { + urlOnServer = (urlOnServer.startsWith('http') ? '' : config.relative_path) + urlOnServer + '?' + Date.now(); + + Picture.updateHeader(urlOnServer); + + if (ajaxify.data.picture && ajaxify.data.picture.length > 0) { + $('#user-current-picture, img.avatar').attr('src', urlOnServer); + ajaxify.data.uploadedpicture = urlOnServer; + } else { + ajaxify.refresh(() => { + $('#user-current-picture, img.avatar').attr('src', urlOnServer); + }); + } + } + + function onRemoveComplete() { + if (ajaxify.data.uploadedpicture === ajaxify.data.picture) { + ajaxify.refresh(); + Picture.updateHeader(); + } + } + + modal.find('[data-action="upload"]').on('click', () => { + modal.modal('hide'); + + pictureCropper.show({ + socketMethod: 'user.uploadCroppedPicture', + route: config.relative_path + '/api/user/' + ajaxify.data.userslug + '/uploadpicture', + aspectRatio: 1 / 1, + paramName: 'uid', + paramValue: ajaxify.data.theirid, + fileSize: ajaxify.data.maximumProfileImageSize, + allowSkippingCrop: false, + title: '[[user:upload_picture]]', + description: '[[user:upload_a_picture]]', + accept: ajaxify.data.allowedProfileImageExtensions, + }, url => { + onUploadComplete(url); + }); + + return false; + }); + + modal.find('[data-action="upload-url"]').on('click', () => { + modal.modal('hide'); + app.parseAndTranslate('partials/modals/upload_picture_from_url_modal', {}, uploadModal => { + uploadModal.modal('show'); + + uploadModal.find('.upload-btn').on('click', () => { + const url = uploadModal.find('#uploadFromUrl').val(); + if (!url) { + return false; + } + + uploadModal.modal('hide'); + + pictureCropper.handleImageCrop({ + url, + socketMethod: 'user.uploadCroppedPicture', + aspectRatio: 1, + allowSkippingCrop: false, + paramName: 'uid', + paramValue: ajaxify.data.theirid, + }, onUploadComplete); + + return false; + }); + }); + + return false; + }); + + modal.find('[data-action="remove-uploaded"]').on('click', () => { + socket.emit('user.removeUploadedPicture', { + uid: ajaxify.data.theirid, + }, error => { + modal.modal('hide'); + if (error) { + return alerts.error(error); + } + + onRemoveComplete(); + }); + }); + } + + function changeUserPicture(type, bgColor) { + return api.put(`/users/${ajaxify.data.theirid}/picture`, {type, bgColor}); + } + + return Picture; }); diff --git a/public/src/modules/alerts.js b/public/src/modules/alerts.js index 682101f..f882b5f 100644 --- a/public/src/modules/alerts.js +++ b/public/src/modules/alerts.js @@ -1,155 +1,157 @@ 'use strict'; - -define('alerts', ['translator', 'components', 'hooks'], function (translator, components, hooks) { - const module = {}; - - module.alert = function (params) { - params.alert_id = 'alert_button_' + (params.alert_id ? params.alert_id : new Date().getTime()); - params.title = params.title ? params.title.trim() || '' : ''; - params.message = params.message ? params.message.trim() : ''; - params.type = params.type || 'info'; - - const alert = $('#' + params.alert_id); - if (alert.length) { - updateAlert(alert, params); - } else { - createNew(params); - } - }; - - module.success = function (message, timeout) { - module.alert({ - alert_id: utils.generateUUID(), - title: '[[global:alert.success]]', - message: message, - type: 'success', - timeout: timeout || 5000, - }); - }; - - module.error = function (message, timeout) { - message = (message && message.message) || message; - - if (message === '[[error:revalidate-failure]]') { - socket.disconnect(); - app.reconnect(); - return; - } - - module.alert({ - alert_id: utils.generateUUID(), - title: '[[global:alert.error]]', - message: message, - type: 'danger', - timeout: timeout || 10000, - }); - }; - - module.remove = function (id) { - $('#alert_button_' + id).remove(); - }; - - function createNew(params) { - app.parseAndTranslate('alert', params, function (html) { - let alert = $('#' + params.alert_id); - if (alert.length) { - return updateAlert(alert, params); - } - alert = html; - alert.fadeIn(200); - - components.get('toaster/tray').prepend(alert); - - if (typeof params.closefn === 'function') { - alert.find('button').on('click', function () { - params.closefn(); - fadeOut(alert); - return false; - }); - } - - if (params.timeout) { - startTimeout(alert, params); - } - - if (typeof params.clickfn === 'function') { - alert - .addClass('pointer') - .on('click', function (e) { - if (!$(e.target).is('.close')) { - params.clickfn(alert, params); - } - fadeOut(alert); - }); - } - - hooks.fire('action:alert.new', { alert, params }); - }); - } - - function updateAlert(alert, params) { - alert.find('strong').translateHtml(params.title); - alert.find('p').translateHtml(params.message); - alert.attr('class', 'alert alert-dismissable alert-' + params.type + ' clearfix'); - - clearTimeout(parseInt(alert.attr('timeoutId'), 10)); - if (params.timeout) { - startTimeout(alert, params); - } - - hooks.fire('action:alert.update', { alert, params }); - - // Handle changes in the clickfn - alert.off('click').removeClass('pointer'); - if (typeof params.clickfn === 'function') { - alert - .addClass('pointer') - .on('click', function (e) { - if (!$(e.target).is('.close')) { - params.clickfn(); - } - fadeOut(alert); - }); - } - } - - function fadeOut(alert) { - alert.fadeOut(500, function () { - $(this).remove(); - }); - } - - function startTimeout(alert, params) { - const timeout = params.timeout; - - const timeoutId = setTimeout(function () { - fadeOut(alert); - - if (typeof params.timeoutfn === 'function') { - params.timeoutfn(alert, params); - } - }, timeout); - - alert.attr('timeoutId', timeoutId); - - // Reset and start animation - alert.css('transition-property', 'none'); - alert.removeClass('animate'); - - setTimeout(function () { - alert.css('transition-property', ''); - alert.css('transition', 'width ' + (timeout + 450) + 'ms linear, background-color ' + (timeout + 450) + 'ms ease-in'); - alert.addClass('animate'); - hooks.fire('action:alert.animate', { alert, params }); - }, 50); - - // Handle mouseenter/mouseleave - alert - .on('mouseenter', function () { - $(this).css('transition-duration', 0); - }); - } - - return module; +define('alerts', ['translator', 'components', 'hooks'], (translator, components, hooks) => { + const module = {}; + + module.alert = function (parameters) { + parameters.alert_id = 'alert_button_' + (parameters.alert_id ? parameters.alert_id : Date.now()); + parameters.title = parameters.title ? parameters.title.trim() || '' : ''; + parameters.message = parameters.message ? parameters.message.trim() : ''; + parameters.type = parameters.type || 'info'; + + const alert = $('#' + parameters.alert_id); + if (alert.length > 0) { + updateAlert(alert, parameters); + } else { + createNew(parameters); + } + }; + + module.success = function (message, timeout) { + module.alert({ + alert_id: utils.generateUUID(), + title: '[[global:alert.success]]', + message, + type: 'success', + timeout: timeout || 5000, + }); + }; + + module.error = function (message, timeout) { + message = (message && message.message) || message; + + if (message === '[[error:revalidate-failure]]') { + socket.disconnect(); + app.reconnect(); + return; + } + + module.alert({ + alert_id: utils.generateUUID(), + title: '[[global:alert.error]]', + message, + type: 'danger', + timeout: timeout || 10_000, + }); + }; + + module.remove = function (id) { + $('#alert_button_' + id).remove(); + }; + + function createNew(parameters) { + app.parseAndTranslate('alert', parameters, html => { + let alert = $('#' + parameters.alert_id); + if (alert.length > 0) { + return updateAlert(alert, parameters); + } + + alert = html; + alert.fadeIn(200); + + components.get('toaster/tray').prepend(alert); + + if (typeof parameters.closefn === 'function') { + alert.find('button').on('click', () => { + parameters.closefn(); + fadeOut(alert); + return false; + }); + } + + if (parameters.timeout) { + startTimeout(alert, parameters); + } + + if (typeof parameters.clickfn === 'function') { + alert + .addClass('pointer') + .on('click', e => { + if (!$(e.target).is('.close')) { + parameters.clickfn(alert, parameters); + } + + fadeOut(alert); + }); + } + + hooks.fire('action:alert.new', {alert, params: parameters}); + }); + } + + function updateAlert(alert, parameters) { + alert.find('strong').translateHtml(parameters.title); + alert.find('p').translateHtml(parameters.message); + alert.attr('class', 'alert alert-dismissable alert-' + parameters.type + ' clearfix'); + + clearTimeout(Number.parseInt(alert.attr('timeoutId'), 10)); + if (parameters.timeout) { + startTimeout(alert, parameters); + } + + hooks.fire('action:alert.update', {alert, params: parameters}); + + // Handle changes in the clickfn + alert.off('click').removeClass('pointer'); + if (typeof parameters.clickfn === 'function') { + alert + .addClass('pointer') + .on('click', e => { + if (!$(e.target).is('.close')) { + parameters.clickfn(); + } + + fadeOut(alert); + }); + } + } + + function fadeOut(alert) { + alert.fadeOut(500, function () { + $(this).remove(); + }); + } + + function startTimeout(alert, parameters) { + const timeout = parameters.timeout; + + const timeoutId = setTimeout(() => { + fadeOut(alert); + + if (typeof parameters.timeoutfn === 'function') { + parameters.timeoutfn(alert, parameters); + } + }, timeout); + + alert.attr('timeoutId', timeoutId); + + // Reset and start animation + alert.css('transition-property', 'none'); + alert.removeClass('animate'); + + setTimeout(() => { + alert.css('transition-property', ''); + alert.css('transition', 'width ' + (timeout + 450) + 'ms linear, background-color ' + (timeout + 450) + 'ms ease-in'); + alert.addClass('animate'); + hooks.fire('action:alert.animate', {alert, params: parameters}); + }, 50); + + // Handle mouseenter/mouseleave + alert + .on('mouseenter', function () { + $(this).css('transition-duration', 0); + }); + } + + return module; }); diff --git a/public/src/modules/api.js b/public/src/modules/api.js index 32b1e8e..1ba45de 100644 --- a/public/src/modules/api.js +++ b/public/src/modules/api.js @@ -1,100 +1,103 @@ 'use strict'; -define('api', ['hooks'], (hooks) => { - const api = {}; - const baseUrl = config.relative_path + '/api/v3'; +define('api', ['hooks'], hooks => { + const api = {}; + const baseUrl = config.relative_path + '/api/v3'; - function call(options, callback) { - options.url = options.url.startsWith('/api') ? - config.relative_path + options.url : - baseUrl + options.url; + function call(options, callback) { + options.url = options.url.startsWith('/api') + ? config.relative_path + options.url + : baseUrl + options.url; - async function doAjax(cb) { - // Allow options to be modified by plugins, etc. - ({ options } = await hooks.fire('filter:api.options', { options })); + async function doAjax(callback_) { + // Allow options to be modified by plugins, etc. + ({options} = await hooks.fire('filter:api.options', {options})); - $.ajax(options) - .done((res) => { - cb(null, ( - res && - res.hasOwnProperty('status') && - res.hasOwnProperty('response') ? res.response : (res || {}) - )); - }) - .fail((ev) => { - let errMessage; - if (ev.responseJSON) { - errMessage = ev.responseJSON.status && ev.responseJSON.status.message ? - ev.responseJSON.status.message : - ev.responseJSON.error; - } + $.ajax(options) + .done(res => { + callback_(null, ( + res + && res.hasOwnProperty('status') + && res.hasOwnProperty('response') ? res.response : (res || {}) + )); + }) + .fail(event => { + let errorMessage; + if (event.responseJSON) { + errorMessage = event.responseJSON.status && event.responseJSON.status.message + ? event.responseJSON.status.message + : event.responseJSON.error; + } - cb(new Error(errMessage || ev.statusText)); - }); - } + callback_(new Error(errorMessage || event.statusText)); + }); + } - if (typeof callback === 'function') { - doAjax(callback); - return; - } + if (typeof callback === 'function') { + doAjax(callback); + return; + } - return new Promise((resolve, reject) => { - doAjax(function (err, data) { - if (err) reject(err); - else resolve(data); - }); - }); - } + return new Promise((resolve, reject) => { + doAjax((error, data) => { + if (error) { + reject(error); + } else { + resolve(data); + } + }); + }); + } - api.get = (route, payload, onSuccess) => call({ - url: route + (payload && Object.keys(payload).length ? ('?' + $.param(payload)) : ''), - }, onSuccess); + api.get = (route, payload, onSuccess) => call({ + url: route + (payload && Object.keys(payload).length > 0 ? ('?' + $.param(payload)) : ''), + }, onSuccess); - api.head = (route, payload, onSuccess) => call({ - url: route + (payload && Object.keys(payload).length ? ('?' + $.param(payload)) : ''), - method: 'head', - }, onSuccess); + api.head = (route, payload, onSuccess) => call({ + url: route + (payload && Object.keys(payload).length > 0 ? ('?' + $.param(payload)) : ''), + method: 'head', + }, onSuccess); - api.post = (route, payload, onSuccess) => call({ - url: route, - method: 'post', - data: JSON.stringify(payload || {}), - contentType: 'application/json; charset=utf-8', - headers: { - 'x-csrf-token': config.csrf_token, - }, - }, onSuccess); + api.post = (route, payload, onSuccess) => call({ + url: route, + method: 'post', + data: JSON.stringify(payload || {}), + contentType: 'application/json; charset=utf-8', + headers: { + 'x-csrf-token': config.csrf_token, + }, + }, onSuccess); - api.patch = (route, payload, onSuccess) => call({ - url: route, - method: 'patch', - data: JSON.stringify(payload || {}), - contentType: 'application/json; charset=utf-8', - headers: { - 'x-csrf-token': config.csrf_token, - }, - }, onSuccess); + api.patch = (route, payload, onSuccess) => call({ + url: route, + method: 'patch', + data: JSON.stringify(payload || {}), + contentType: 'application/json; charset=utf-8', + headers: { + 'x-csrf-token': config.csrf_token, + }, + }, onSuccess); - api.put = (route, payload, onSuccess) => call({ - url: route, - method: 'put', - data: JSON.stringify(payload || {}), - contentType: 'application/json; charset=utf-8', - headers: { - 'x-csrf-token': config.csrf_token, - }, - }, onSuccess); + api.put = (route, payload, onSuccess) => call({ + url: route, + method: 'put', + data: JSON.stringify(payload || {}), + contentType: 'application/json; charset=utf-8', + headers: { + 'x-csrf-token': config.csrf_token, + }, + }, onSuccess); - api.del = (route, payload, onSuccess) => call({ - url: route, - method: 'delete', - data: JSON.stringify(payload), - contentType: 'application/json; charset=utf-8', - headers: { - 'x-csrf-token': config.csrf_token, - }, - }, onSuccess); - api.delete = api.del; + api.del = (route, payload, onSuccess) => call({ + url: route, + method: 'delete', + data: JSON.stringify(payload), + contentType: 'application/json; charset=utf-8', + headers: { + 'x-csrf-token': config.csrf_token, + }, + }, onSuccess); + api.delete = api.del; - return api; + return api; }); diff --git a/public/src/modules/autocomplete.js b/public/src/modules/autocomplete.js index 77aaece..30c30d5 100644 --- a/public/src/modules/autocomplete.js +++ b/public/src/modules/autocomplete.js @@ -1,133 +1,136 @@ 'use strict'; -define('autocomplete', ['api', 'alerts'], function (api, alerts) { - const module = {}; - const _default = { - delay: 200, - }; - - module.init = (params) => { - const { input, source, onSelect, delay } = { ..._default, ...params }; - - app.loadJQueryUI(function () { - input.autocomplete({ - delay, - open: function () { - $(this).autocomplete('widget').css('z-index', 100005); - }, - select: function (event, ui) { - handleOnSelect(input, onSelect, event, ui); - }, - source, - }); - }); - }; - - module.user = function (input, params, onSelect) { - if (typeof params === 'function') { - onSelect = params; - params = {}; - } - params = params || {}; - - module.init({ - input, - onSelect, - source: (request, response) => { - params.query = request.term; - - api.get('/api/users', params, function (err, result) { - if (err) { - return alerts.error(err); - } - - if (result && result.users) { - const names = result.users.map(function (user) { - const username = $('
    ').html(user.username).text(); - return user && { - label: username, - value: username, - user: { - uid: user.uid, - name: user.username, - slug: user.userslug, - username: user.username, - userslug: user.userslug, - picture: user.picture, - banned: user.banned, - 'icon:text': user['icon:text'], - 'icon:bgColor': user['icon:bgColor'], - }, - }; - }); - response(names); - } - - $('.ui-autocomplete a').attr('data-ajaxify', 'false'); - }); - }, - }); - }; - - module.group = function (input, onSelect) { - module.init({ - input, - onSelect, - source: (request, response) => { - socket.emit('groups.search', { - query: request.term, - }, function (err, results) { - if (err) { - return alerts.error(err); - } - if (results && results.length) { - const names = results.map(function (group) { - return group && { - label: group.name, - value: group.name, - group: group, - }; - }); - response(names); - } - $('.ui-autocomplete a').attr('data-ajaxify', 'false'); - }); - }, - }); - }; - - module.tag = function (input, onSelect) { - module.init({ - input, - onSelect, - delay: 100, - source: (request, response) => { - socket.emit('topics.autocompleteTags', { - query: request.term, - cid: ajaxify.data.cid || 0, - }, function (err, tags) { - if (err) { - return alerts.error(err); - } - if (tags) { - response(tags); - } - $('.ui-autocomplete a').attr('data-ajaxify', 'false'); - }); - }, - }); - }; - - function handleOnSelect(input, onselect, event, ui) { - onselect = onselect || function () { }; - const e = jQuery.Event('keypress'); - e.which = 13; - e.keyCode = 13; - setTimeout(function () { - input.trigger(e); - }, 100); - onselect(event, ui); - } - - return module; +define('autocomplete', ['api', 'alerts'], (api, alerts) => { + const module = {}; + const _default = { + delay: 200, + }; + + module.init = parameters => { + const {input, source, onSelect, delay} = {..._default, ...parameters}; + + app.loadJQueryUI(() => { + input.autocomplete({ + delay, + open() { + $(this).autocomplete('widget').css('z-index', 100_005); + }, + select(event, ui) { + handleOnSelect(input, onSelect, event, ui); + }, + source, + }); + }); + }; + + module.user = function (input, parameters, onSelect) { + if (typeof parameters === 'function') { + onSelect = parameters; + parameters = {}; + } + + parameters ||= {}; + + module.init({ + input, + onSelect, + source(request, response) { + parameters.query = request.term; + + api.get('/api/users', parameters, (error, result) => { + if (error) { + return alerts.error(error); + } + + if (result && result.users) { + const names = result.users.map(user => { + const username = $('
    ').html(user.username).text(); + return user && { + label: username, + value: username, + user: { + uid: user.uid, + name: user.username, + slug: user.userslug, + username: user.username, + userslug: user.userslug, + picture: user.picture, + banned: user.banned, + 'icon:text': user['icon:text'], + 'icon:bgColor': user['icon:bgColor'], + }, + }; + }); + response(names); + } + + $('.ui-autocomplete a').attr('data-ajaxify', 'false'); + }); + }, + }); + }; + + module.group = function (input, onSelect) { + module.init({ + input, + onSelect, + source(request, response) { + socket.emit('groups.search', { + query: request.term, + }, (error, results) => { + if (error) { + return alerts.error(error); + } + + if (results && results.length > 0) { + const names = results.map(group => group && { + label: group.name, + value: group.name, + group, + }); + response(names); + } + + $('.ui-autocomplete a').attr('data-ajaxify', 'false'); + }); + }, + }); + }; + + module.tag = function (input, onSelect) { + module.init({ + input, + onSelect, + delay: 100, + source(request, response) { + socket.emit('topics.autocompleteTags', { + query: request.term, + cid: ajaxify.data.cid || 0, + }, (error, tags) => { + if (error) { + return alerts.error(error); + } + + if (tags) { + response(tags); + } + + $('.ui-autocomplete a').attr('data-ajaxify', 'false'); + }); + }, + }); + }; + + function handleOnSelect(input, onselect, event, ui) { + onselect ||= function () {}; + const e = jQuery.Event('keypress'); + e.which = 13; + e.keyCode = 13; + setTimeout(() => { + input.trigger(e); + }, 100); + onselect(event, ui); + } + + return module; }); diff --git a/public/src/modules/categoryFilter.js b/public/src/modules/categoryFilter.js index fef03a8..793855d 100644 --- a/public/src/modules/categoryFilter.js +++ b/public/src/modules/categoryFilter.js @@ -1,103 +1,111 @@ 'use strict'; -define('categoryFilter', ['categorySearch', 'api', 'hooks'], function (categorySearch, api, hooks) { - const categoryFilter = {}; - - categoryFilter.init = function (el, options) { - if (!el || !el.length) { - return; - } - options = options || {}; - options.states = options.states || ['watching', 'notwatching', 'ignoring']; - options.template = 'partials/category-filter'; - - hooks.fire('action:category.filter.options', { el: el, options: options }); - - categorySearch.init(el, options); - - let selectedCids = []; - let initialCids = []; - if (Array.isArray(options.selectedCids)) { - selectedCids = options.selectedCids.map(cid => parseInt(cid, 10)); - } else if (Array.isArray(ajaxify.data.selectedCids)) { - selectedCids = ajaxify.data.selectedCids.map(cid => parseInt(cid, 10)); - } - initialCids = selectedCids.slice(); - - el.on('hidden.bs.dropdown', function () { - let changed = initialCids.length !== selectedCids.length; - initialCids.forEach(function (cid, index) { - if (cid !== selectedCids[index]) { - changed = true; - } - }); - if (changed) { - updateFilterButton(el, selectedCids); - } - if (options.onHidden) { - options.onHidden({ changed: changed, selectedCids: selectedCids.slice() }); - return; - } - if (changed) { - let url = window.location.pathname; - const currentParams = utils.params(); - if (selectedCids.length) { - currentParams.cid = selectedCids; - url += '?' + decodeURIComponent($.param(currentParams)); - } - ajaxify.go(url); - } - }); - - el.on('click', '[component="category/list"] [data-cid]', function () { - const listEl = el.find('[component="category/list"]'); - const categoryEl = $(this); - const link = categoryEl.find('a').attr('href'); - if (link && link !== '#' && link.length) { - return; - } - const cid = parseInt(categoryEl.attr('data-cid'), 10); - const icon = categoryEl.find('[component="category/select/icon"]'); - - if (selectedCids.includes(cid)) { - selectedCids.splice(selectedCids.indexOf(cid), 1); - } else { - selectedCids.push(cid); - } - selectedCids.sort(function (a, b) { - return a - b; - }); - options.selectedCids = selectedCids; - - icon.toggleClass('invisible'); - listEl.find('li[data-all="all"] i').toggleClass('invisible', !!selectedCids.length); - if (options.onSelect) { - options.onSelect({ cid: cid, selectedCids: selectedCids.slice() }); - } - return false; - }); - }; - - function updateFilterButton(el, selectedCids) { - if (selectedCids.length > 1) { - renderButton({ - icon: 'fa-plus', - name: '[[unread:multiple-categories-selected]]', - bgColor: '#ddd', - }); - } else if (selectedCids.length === 1) { - api.get(`/categories/${selectedCids[0]}`, {}).then(renderButton); - } else { - renderButton(); - } - function renderButton(category) { - app.parseAndTranslate('partials/category-filter-content', { - selectedCategory: category, - }, function (html) { - el.find('button').replaceWith($('
    ').html(html).find('button')); - }); - } - } - - return categoryFilter; +define('categoryFilter', ['categorySearch', 'api', 'hooks'], (categorySearch, api, hooks) => { + const categoryFilter = {}; + + categoryFilter.init = function (element, options) { + if (!element || element.length === 0) { + return; + } + + options ||= {}; + options.states = options.states || ['watching', 'notwatching', 'ignoring']; + options.template = 'partials/category-filter'; + + hooks.fire('action:category.filter.options', {el: element, options}); + + categorySearch.init(element, options); + + let selectedCids = []; + let initialCids = []; + if (Array.isArray(options.selectedCids)) { + selectedCids = options.selectedCids.map(cid => Number.parseInt(cid, 10)); + } else if (Array.isArray(ajaxify.data.selectedCids)) { + selectedCids = ajaxify.data.selectedCids.map(cid => Number.parseInt(cid, 10)); + } + + initialCids = selectedCids.slice(); + + element.on('hidden.bs.dropdown', () => { + let changed = initialCids.length !== selectedCids.length; + for (const [index, cid] of initialCids.entries()) { + if (cid !== selectedCids[index]) { + changed = true; + } + } + + if (changed) { + updateFilterButton(element, selectedCids); + } + + if (options.onHidden) { + options.onHidden({changed, selectedCids: selectedCids.slice()}); + return; + } + + if (changed) { + let url = window.location.pathname; + const currentParameters = utils.params(); + if (selectedCids.length > 0) { + currentParameters.cid = selectedCids; + url += '?' + decodeURIComponent($.param(currentParameters)); + } + + ajaxify.go(url); + } + }); + + element.on('click', '[component="category/list"] [data-cid]', function () { + const listElement = element.find('[component="category/list"]'); + const categoryElement = $(this); + const link = categoryElement.find('a').attr('href'); + if (link && link !== '#' && link.length > 0) { + return; + } + + const cid = Number.parseInt(categoryElement.attr('data-cid'), 10); + const icon = categoryElement.find('[component="category/select/icon"]'); + + if (selectedCids.includes(cid)) { + selectedCids.splice(selectedCids.indexOf(cid), 1); + } else { + selectedCids.push(cid); + } + + selectedCids.sort((a, b) => a - b); + options.selectedCids = selectedCids; + + icon.toggleClass('invisible'); + listElement.find('li[data-all="all"] i').toggleClass('invisible', selectedCids.length > 0); + if (options.onSelect) { + options.onSelect({cid, selectedCids: selectedCids.slice()}); + } + + return false; + }); + }; + + function updateFilterButton(element, selectedCids) { + if (selectedCids.length > 1) { + renderButton({ + icon: 'fa-plus', + name: '[[unread:multiple-categories-selected]]', + bgColor: '#ddd', + }); + } else if (selectedCids.length === 1) { + api.get(`/categories/${selectedCids[0]}`, {}).then(renderButton); + } else { + renderButton(); + } + + function renderButton(category) { + app.parseAndTranslate('partials/category-filter-content', { + selectedCategory: category, + }, html => { + element.find('button').replaceWith($('
    ').html(html).find('button')); + }); + } + } + + return categoryFilter; }); diff --git a/public/src/modules/categorySearch.js b/public/src/modules/categorySearch.js index 3db9432..a6a6b3a 100644 --- a/public/src/modules/categorySearch.js +++ b/public/src/modules/categorySearch.js @@ -1,101 +1,104 @@ 'use strict'; -define('categorySearch', ['alerts'], function (alerts) { - const categorySearch = {}; - - categorySearch.init = function (el, options) { - let categoriesList = null; - options = options || {}; - options.privilege = options.privilege || 'topics:read'; - options.states = options.states || ['watching', 'notwatching', 'ignoring']; - - let localCategories = []; - if (Array.isArray(options.localCategories)) { - localCategories = options.localCategories.map(c => ({ ...c })); - } - options.selectedCids = options.selectedCids || ajaxify.data.selectedCids || []; - - const searchEl = el.find('[component="category-selector-search"]'); - if (!searchEl.length) { - return; - } - - const toggleVisibility = searchEl.parent('[component="category/dropdown"]').length > 0 || - searchEl.parent('[component="category-selector"]').length > 0; - - el.on('show.bs.dropdown', function () { - if (toggleVisibility) { - el.find('.dropdown-toggle').addClass('hidden'); - searchEl.removeClass('hidden'); - } - - function doSearch() { - const val = searchEl.find('input').val(); - if (val.length > 1 || (!val && !categoriesList)) { - loadList(val, function (categories) { - categoriesList = categoriesList || categories; - renderList(categories); - }); - } else if (!val && categoriesList) { - categoriesList.forEach(function (c) { - c.selected = options.selectedCids.includes(c.cid); - }); - renderList(categoriesList); - } - } - - searchEl.on('click', function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - }); - searchEl.find('input').val('').on('keyup', utils.debounce(doSearch, 300)); - doSearch(); - }); - - el.on('shown.bs.dropdown', function () { - searchEl.find('input').focus(); - }); - - el.on('hide.bs.dropdown', function () { - if (toggleVisibility) { - el.find('.dropdown-toggle').removeClass('hidden'); - searchEl.addClass('hidden'); - } - - searchEl.off('click'); - searchEl.find('input').off('keyup'); - }); - - function loadList(search, callback) { - socket.emit('categories.categorySearch', { - search: search, - query: utils.params(), - parentCid: options.parentCid || 0, - selectedCids: options.selectedCids, - privilege: options.privilege, - states: options.states, - showLinks: options.showLinks, - }, function (err, categories) { - if (err) { - return alerts.error(err); - } - callback(localCategories.concat(categories)); - }); - } - - function renderList(categories) { - app.parseAndTranslate(options.template, { - categoryItems: categories.slice(0, 200), - selectedCategory: ajaxify.data.selectedCategory, - allCategoriesUrl: ajaxify.data.allCategoriesUrl, - }, function (html) { - el.find('[component="category/list"]') - .replaceWith(html.find('[component="category/list"]')); - el.find('[component="category/list"] [component="category/no-matches"]') - .toggleClass('hidden', !!categories.length); - }); - } - }; - - return categorySearch; +define('categorySearch', ['alerts'], alerts => { + const categorySearch = {}; + + categorySearch.init = function (element, options) { + let categoriesList = null; + options ||= {}; + options.privilege = options.privilege || 'topics:read'; + options.states = options.states || ['watching', 'notwatching', 'ignoring']; + + let localCategories = []; + if (Array.isArray(options.localCategories)) { + localCategories = options.localCategories.map(c => ({...c})); + } + + options.selectedCids = options.selectedCids || ajaxify.data.selectedCids || []; + + const searchElement = element.find('[component="category-selector-search"]'); + if (searchElement.length === 0) { + return; + } + + const toggleVisibility = searchElement.parent('[component="category/dropdown"]').length > 0 + || searchElement.parent('[component="category-selector"]').length > 0; + + element.on('show.bs.dropdown', () => { + if (toggleVisibility) { + element.find('.dropdown-toggle').addClass('hidden'); + searchElement.removeClass('hidden'); + } + + function doSearch() { + const value = searchElement.find('input').val(); + if (value.length > 1 || (!value && !categoriesList)) { + loadList(value, categories => { + categoriesList ||= categories; + renderList(categories); + }); + } else if (!value && categoriesList) { + for (const c of categoriesList) { + c.selected = options.selectedCids.includes(c.cid); + } + + renderList(categoriesList); + } + } + + searchElement.on('click', event => { + event.preventDefault(); + event.stopPropagation(); + }); + searchElement.find('input').val('').on('keyup', utils.debounce(doSearch, 300)); + doSearch(); + }); + + element.on('shown.bs.dropdown', () => { + searchElement.find('input').focus(); + }); + + element.on('hide.bs.dropdown', () => { + if (toggleVisibility) { + element.find('.dropdown-toggle').removeClass('hidden'); + searchElement.addClass('hidden'); + } + + searchElement.off('click'); + searchElement.find('input').off('keyup'); + }); + + function loadList(search, callback) { + socket.emit('categories.categorySearch', { + search, + query: utils.params(), + parentCid: options.parentCid || 0, + selectedCids: options.selectedCids, + privilege: options.privilege, + states: options.states, + showLinks: options.showLinks, + }, (error, categories) => { + if (error) { + return alerts.error(error); + } + + callback(localCategories.concat(categories)); + }); + } + + function renderList(categories) { + app.parseAndTranslate(options.template, { + categoryItems: categories.slice(0, 200), + selectedCategory: ajaxify.data.selectedCategory, + allCategoriesUrl: ajaxify.data.allCategoriesUrl, + }, html => { + element.find('[component="category/list"]') + .replaceWith(html.find('[component="category/list"]')); + element.find('[component="category/list"] [component="category/no-matches"]') + .toggleClass('hidden', categories.length > 0); + }); + } + }; + + return categorySearch; }); diff --git a/public/src/modules/categorySelector.js b/public/src/modules/categorySelector.js index 8ac3295..4e40397 100644 --- a/public/src/modules/categorySelector.js +++ b/public/src/modules/categorySelector.js @@ -1,96 +1,104 @@ 'use strict'; define('categorySelector', [ - 'categorySearch', 'bootbox', 'hooks', -], function (categorySearch, bootbox, hooks) { - const categorySelector = {}; - - categorySelector.init = function (el, options) { - if (!el || !el.length) { - return; - } - options = options || {}; - const onSelect = options.onSelect || function () {}; - - options.states = options.states || ['watching', 'notwatching', 'ignoring']; - options.template = 'partials/category-selector'; - hooks.fire('action:category.selector.options', { el: el, options: options }); - - categorySearch.init(el, options); - - const selector = { - el: el, - selectedCategory: null, - }; - el.on('click', '[data-cid]', function () { - const categoryEl = $(this); - if (categoryEl.hasClass('disabled')) { - return false; - } - selector.selectCategory(categoryEl.attr('data-cid')); - onSelect(selector.selectedCategory); - }); - const defaultSelectHtml = selector.el.find('[component="category-selector-selected"]').html(); - selector.selectCategory = function (cid) { - const categoryEl = selector.el.find('[data-cid="' + cid + '"]'); - selector.selectedCategory = { - cid: cid, - name: categoryEl.attr('data-name'), - }; - - if (categoryEl.length) { - selector.el.find('[component="category-selector-selected"]').html( - categoryEl.find('[component="category-markup"]').html() - ); - } else { - selector.el.find('[component="category-selector-selected"]').html( - defaultSelectHtml - ); - } - }; - selector.getSelectedCategory = function () { - return selector.selectedCategory; - }; - selector.getSelectedCid = function () { - return selector.selectedCategory ? selector.selectedCategory.cid : 0; - }; - return selector; - }; - - categorySelector.modal = function (options) { - options = options || {}; - options.onSelect = options.onSelect || function () {}; - options.onSubmit = options.onSubmit || function () {}; - app.parseAndTranslate('admin/partials/categories/select-category', { message: options.message }, function (html) { - const modal = bootbox.dialog({ - title: options.title || '[[modules:composer.select_category]]', - message: html, - buttons: { - save: { - label: '[[global:select]]', - className: 'btn-primary', - callback: submit, - }, - }, - }); - - const selector = categorySelector.init(modal.find('[component="category-selector"]'), options); - function submit(ev) { - ev.preventDefault(); - if (selector.selectedCategory) { - options.onSubmit(selector.selectedCategory); - modal.modal('hide'); - } - return false; - } - if (options.openOnLoad) { - modal.on('shown.bs.modal', function () { - modal.find('.dropdown-toggle').dropdown('toggle'); - }); - } - modal.find('form').on('submit', submit); - }); - }; - - return categorySelector; + 'categorySearch', 'bootbox', 'hooks', +], (categorySearch, bootbox, hooks) => { + const categorySelector = {}; + + categorySelector.init = function (element, options) { + if (!element || element.length === 0) { + return; + } + + options ||= {}; + const onSelect = options.onSelect || function () {}; + + options.states = options.states || ['watching', 'notwatching', 'ignoring']; + options.template = 'partials/category-selector'; + hooks.fire('action:category.selector.options', {el: element, options}); + + categorySearch.init(element, options); + + const selector = { + el: element, + selectedCategory: null, + }; + element.on('click', '[data-cid]', function () { + const categoryElement = $(this); + if (categoryElement.hasClass('disabled')) { + return false; + } + + selector.selectCategory(categoryElement.attr('data-cid')); + onSelect(selector.selectedCategory); + }); + const defaultSelectHtml = selector.el.find('[component="category-selector-selected"]').html(); + selector.selectCategory = function (cid) { + const categoryElement = selector.el.find('[data-cid="' + cid + '"]'); + selector.selectedCategory = { + cid, + name: categoryElement.attr('data-name'), + }; + + if (categoryElement.length > 0) { + selector.el.find('[component="category-selector-selected"]').html( + categoryElement.find('[component="category-markup"]').html(), + ); + } else { + selector.el.find('[component="category-selector-selected"]').html( + defaultSelectHtml, + ); + } + }; + + selector.getSelectedCategory = function () { + return selector.selectedCategory; + }; + + selector.getSelectedCid = function () { + return selector.selectedCategory ? selector.selectedCategory.cid : 0; + }; + + return selector; + }; + + categorySelector.modal = function (options) { + options ||= {}; + options.onSelect = options.onSelect || function () {}; + options.onSubmit = options.onSubmit || function () {}; + app.parseAndTranslate('admin/partials/categories/select-category', {message: options.message}, html => { + const modal = bootbox.dialog({ + title: options.title || '[[modules:composer.select_category]]', + message: html, + buttons: { + save: { + label: '[[global:select]]', + className: 'btn-primary', + callback: submit, + }, + }, + }); + + const selector = categorySelector.init(modal.find('[component="category-selector"]'), options); + function submit(event) { + event.preventDefault(); + if (selector.selectedCategory) { + options.onSubmit(selector.selectedCategory); + modal.modal('hide'); + } + + return false; + } + + if (options.openOnLoad) { + modal.on('shown.bs.modal', () => { + modal.find('.dropdown-toggle').dropdown('toggle'); + }); + } + + modal.find('form').on('submit', submit); + }); + }; + + return categorySelector; }); diff --git a/public/src/modules/chat.js b/public/src/modules/chat.js index 0657e6b..2077eec 100644 --- a/public/src/modules/chat.js +++ b/public/src/modules/chat.js @@ -1,434 +1,432 @@ 'use strict'; define('chat', [ - 'components', 'taskbar', 'translator', 'hooks', 'bootbox', 'alerts', 'api', -], function (components, taskbar, translator, hooks, bootbox, alerts, api) { - const module = {}; - let newMessage = false; - - module.openChat = function (roomId, uid) { - if (!app.user.uid) { - return alerts.error('[[error:not-logged-in]]'); - } - - function loadAndCenter(chatModal) { - module.load(chatModal.attr('data-uuid')); - module.center(chatModal); - module.focusInput(chatModal); - } - - if (module.modalExists(roomId)) { - loadAndCenter(module.getModal(roomId)); - } else { - api.get(`/chats/${roomId}`, { - uid: uid || app.user.uid, - }).then((roomData) => { - roomData.users = roomData.users.filter(function (user) { - return user && parseInt(user.uid, 10) !== parseInt(app.user.uid, 10); - }); - roomData.uid = uid || app.user.uid; - roomData.isSelf = true; - module.createModal(roomData, loadAndCenter); - }).catch(alerts.error); - } - }; - - module.newChat = function (touid, callback) { - function createChat() { - api.post(`/chats`, { - uids: [touid], - }).then(({ roomId }) => { - if (!ajaxify.data.template.chats) { - module.openChat(roomId); - } else { - ajaxify.go('chats/' + roomId); - } - - callback(null, roomId); - }).catch(alerts.error); - } - - callback = callback || function () { }; - if (!app.user.uid) { - return alerts.error('[[error:not-logged-in]]'); - } - - if (parseInt(touid, 10) === parseInt(app.user.uid, 10)) { - return alerts.error('[[error:cant-chat-with-yourself]]'); - } - socket.emit('modules.chats.isDnD', touid, function (err, isDnD) { - if (err) { - return alerts.error(err); - } - if (!isDnD) { - return createChat(); - } - - bootbox.confirm('[[modules:chat.confirm-chat-with-dnd-user]]', function (ok) { - if (ok) { - createChat(); - } - }); - }); - }; - - module.loadChatsDropdown = function (chatsListEl) { - socket.emit('modules.chats.getRecentChats', { - uid: app.user.uid, - after: 0, - }, function (err, data) { - if (err) { - return alerts.error(err); - } - - const rooms = data.rooms.filter(function (room) { - return room.teaser; - }); - - translator.toggleTimeagoShorthand(function () { - for (let i = 0; i < rooms.length; i += 1) { - rooms[i].teaser.timeago = $.timeago(new Date(parseInt(rooms[i].teaser.timestamp, 10))); - } - translator.toggleTimeagoShorthand(); - app.parseAndTranslate('partials/chats/dropdown', { rooms: rooms }, function (html) { - chatsListEl.find('*').not('.navigation-link').remove(); - chatsListEl.prepend(html); - app.createUserTooltips(chatsListEl, 'right'); - chatsListEl.off('click').on('click', '[data-roomid]', function (ev) { - if ($(ev.target).parents('.user-link').length) { - return; - } - const roomId = $(this).attr('data-roomid'); - if (!ajaxify.currentPage.match(/^chats\//)) { - module.openChat(roomId); - } else { - ajaxify.go('user/' + app.user.userslug + '/chats/' + roomId); - } - }); - - $('[component="chats/mark-all-read"]').off('click').on('click', function () { - socket.emit('modules.chats.markAllRead', function (err) { - if (err) { - return alerts.error(err); - } - }); - }); - }); - }); - }); - }; - - - module.onChatMessageReceived = function (data) { - const isSelf = data.self === 1; - data.message.self = data.self; - - newMessage = data.self === 0; - if (module.modalExists(data.roomId)) { - addMessageToModal(data); - } else if (!ajaxify.data.template.chats) { - api.get(`/chats/${data.roomId}`, {}).then((roomData) => { - roomData.users = roomData.users.filter(function (user) { - return user && parseInt(user.uid, 10) !== parseInt(app.user.uid, 10); - }); - roomData.silent = true; - roomData.uid = app.user.uid; - roomData.isSelf = isSelf; - module.createModal(roomData); - }).catch(alerts.error); - } - }; - - function addMessageToModal(data) { - const modal = module.getModal(data.roomId); - const username = data.message.fromUser.username; - const isSelf = data.self === 1; - require(['forum/chats/messages'], function (ChatsMessages) { - // don't add if already added - if (!modal.find('[data-mid="' + data.message.messageId + '"]').length) { - ChatsMessages.appendChatMessage(modal.find('.chat-content'), data.message); - } - - if (modal.is(':visible')) { - taskbar.updateActive(modal.attr('data-uuid')); - if (ChatsMessages.isAtBottom(modal.find('.chat-content'))) { - ChatsMessages.scrollToBottom(modal.find('.chat-content')); - } - } else if (!ajaxify.data.template.chats) { - module.toggleNew(modal.attr('data-uuid'), true, true); - } - - if (!isSelf && (!modal.is(':visible') || !app.isFocused)) { - taskbar.push('chat', modal.attr('data-uuid'), { - title: '[[modules:chat.chatting_with]] ' + (data.roomName || username), - touid: data.message.fromUser.uid, - roomId: data.roomId, - isSelf: false, - }); - } - }); - } - - module.onUserStatusChange = function (data) { - const modal = module.getModal(data.uid); - app.updateUserStatus(modal.find('[component="user/status"]'), data.status); - }; - - module.onRoomRename = function (data) { - const newTitle = $('
    ').html(data.newName).text(); - const modal = module.getModal(data.roomId); - modal.find('[component="chat/room/name"]').text(newTitle); - taskbar.update('chat', modal.attr('data-uuid'), { - title: newTitle, - }); - hooks.fire('action:chat.renamed', Object.assign(data, { - modal: modal, - })); - }; - - module.getModal = function (roomId) { - return $('#chat-modal-' + roomId); - }; - - module.modalExists = function (roomId) { - return $('#chat-modal-' + roomId).length !== 0; - }; - - module.createModal = function (data, callback) { - callback = callback || function () {}; - require([ - 'scrollStop', 'forum/chats', 'forum/chats/messages', - ], function (scrollStop, Chats, ChatsMessages) { - app.parseAndTranslate('chat', data, function (chatModal) { - if (module.modalExists(data.roomId)) { - return callback(module.getModal(data.roomId)); - } - const uuid = utils.generateUUID(); - let dragged = false; - - chatModal.attr('id', 'chat-modal-' + data.roomId); - chatModal.attr('data-roomid', data.roomId); - chatModal.attr('intervalId', 0); - chatModal.attr('data-uuid', uuid); - chatModal.css('position', 'fixed'); - chatModal.appendTo($('body')); - chatModal.find('.timeago').timeago(); - module.center(chatModal); - - app.loadJQueryUI(function () { - chatModal.find('.modal-content').resizable({ - handles: 'n, e, s, w, se', - minHeight: 250, - minWidth: 400, - }); - - chatModal.find('.modal-content').on('resize', function (event, ui) { - if (ui.originalSize.height === ui.size.height) { - return; - } - - chatModal.find('.modal-body').css('height', module.calculateChatListHeight(chatModal)); - }); - - chatModal.draggable({ - start: function () { - taskbar.updateActive(uuid); - }, - stop: function () { - module.focusInput(chatModal); - }, - distance: 10, - handle: '.modal-header', - }); - }); - - scrollStop.apply(chatModal.find('[component="chat/messages"]')); - - chatModal.find('#chat-close-btn').on('click', function () { - module.close(chatModal); - }); - - function gotoChats() { - const text = components.get('chat/input').val(); - $(window).one('action:ajaxify.end', function () { - components.get('chat/input').val(text); - }); - - ajaxify.go('user/' + app.user.userslug + '/chats/' + chatModal.attr('data-roomid')); - module.close(chatModal); - } - - chatModal.find('.modal-header').on('dblclick', gotoChats); - chatModal.find('button[data-action="maximize"]').on('click', gotoChats); - chatModal.find('button[data-action="minimize"]').on('click', function () { - const uuid = chatModal.attr('data-uuid'); - module.minimize(uuid); - }); - - chatModal.on('mouseup', function () { - taskbar.updateActive(chatModal.attr('data-uuid')); - - if (dragged) { - dragged = false; - } - }); - - chatModal.on('mousemove', function (e) { - if (e.which === 1) { - dragged = true; - } - }); - - chatModal.on('mousemove keypress click', function () { - if (newMessage) { - socket.emit('modules.chats.markRead', data.roomId); - newMessage = false; - } - }); - - Chats.addActionHandlers(chatModal.find('[component="chat/messages"]'), data.roomId); - Chats.addRenameHandler(chatModal.attr('data-roomid'), chatModal.find('[data-action="rename"]'), data.roomName); - Chats.addLeaveHandler(chatModal.attr('data-roomid'), chatModal.find('[data-action="leave"]')); - Chats.addSendHandlers(chatModal.attr('data-roomid'), chatModal.find('.chat-input'), chatModal.find('[data-action="send"]')); - Chats.addMemberHandler(chatModal.attr('data-roomid'), chatModal.find('[data-action="members"]')); - - Chats.createAutoComplete(chatModal.find('[component="chat/input"]')); - - Chats.addScrollHandler(chatModal.attr('data-roomid'), data.uid, chatModal.find('.chat-content')); - Chats.addScrollBottomHandler(chatModal.find('.chat-content')); - - Chats.addCharactersLeftHandler(chatModal); - Chats.addIPHandler(chatModal); - - Chats.addUploadHandler({ - dragDropAreaEl: chatModal.find('.modal-content'), - pasteEl: chatModal, - uploadFormEl: chatModal.find('[component="chat/upload"]'), - inputEl: chatModal.find('[component="chat/input"]'), - }); - - ChatsMessages.addSocketListeners(); - - taskbar.push('chat', chatModal.attr('data-uuid'), { - title: '[[modules:chat.chatting_with]] ' + (data.roomName || (data.users.length ? data.users[0].username : '')), - roomId: data.roomId, - icon: 'fa-comment', - state: '', - isSelf: data.isSelf, - }, function () { - taskbar.toggleNew(chatModal.attr('data-uuid'), !data.isSelf); - hooks.fire('action:chat.loaded', chatModal); - - if (typeof callback === 'function') { - callback(chatModal); - } - }); - }); - }); - }; - - module.focusInput = function (chatModal) { - setTimeout(function () { - chatModal.find('[component="chat/input"]').focus(); - }, 20); - }; - - module.close = function (chatModal) { - const uuid = chatModal.attr('data-uuid'); - clearInterval(chatModal.attr('intervalId')); - chatModal.attr('intervalId', 0); - chatModal.remove(); - chatModal.data('modal', null); - taskbar.discard('chat', uuid); - - if (chatModal.attr('data-mobile')) { - module.disableMobileBehaviour(chatModal); - } - - hooks.fire('action:chat.closed', { - uuid: uuid, - modal: chatModal, - }); - }; - - // TODO: see taskbar.js:44 - module.closeByUUID = function (uuid) { - const chatModal = $('.chat-modal[data-uuid="' + uuid + '"]'); - module.close(chatModal); - }; - - module.center = function (chatModal) { - let hideAfter = false; - if (chatModal.hasClass('hide')) { - chatModal.removeClass('hide'); - hideAfter = true; - } - chatModal.css('left', Math.max(0, (($(window).width() - $(chatModal).outerWidth()) / 2) + $(window).scrollLeft()) + 'px'); - chatModal.css('top', Math.max(0, ($(window).height() / 2) - ($(chatModal).outerHeight() / 2)) + 'px'); - - if (hideAfter) { - chatModal.addClass('hide'); - } - return chatModal; - }; - - module.load = function (uuid) { - require(['forum/chats/messages'], function (ChatsMessages) { - const chatModal = $('.chat-modal[data-uuid="' + uuid + '"]'); - chatModal.removeClass('hide'); - taskbar.updateActive(uuid); - ChatsMessages.scrollToBottom(chatModal.find('.chat-content')); - module.focusInput(chatModal); - socket.emit('modules.chats.markRead', chatModal.attr('data-roomid')); - - const env = utils.findBootstrapEnvironment(); - if (env === 'xs' || env === 'sm') { - module.enableMobileBehaviour(chatModal); - } - }); - }; - - module.enableMobileBehaviour = function (modalEl) { - app.toggleNavbar(false); - modalEl.attr('data-mobile', '1'); - const messagesEl = modalEl.find('.modal-body'); - messagesEl.css('height', module.calculateChatListHeight(modalEl)); - function resize() { - messagesEl.css('height', module.calculateChatListHeight(modalEl)); - require(['forum/chats/messages'], function (ChatsMessages) { - ChatsMessages.scrollToBottom(modalEl.find('.chat-content')); - }); - } - - $(window).on('resize', resize); - $(window).one('action:ajaxify.start', function () { - module.close(modalEl); - $(window).off('resize', resize); - }); - }; - - module.disableMobileBehaviour = function () { - app.toggleNavbar(true); - }; - - module.calculateChatListHeight = function (modalEl) { - // Formula: modal height minus header height. Simple(tm). - return modalEl.find('.modal-content').outerHeight() - modalEl.find('.modal-header').outerHeight(); - }; - - module.minimize = function (uuid) { - const chatModal = $('.chat-modal[data-uuid="' + uuid + '"]'); - chatModal.addClass('hide'); - taskbar.minimize('chat', uuid); - clearInterval(chatModal.attr('intervalId')); - chatModal.attr('intervalId', 0); - hooks.fire('action:chat.minimized', { - uuid: uuid, - modal: chatModal, - }); - }; - - module.toggleNew = taskbar.toggleNew; - - return module; + 'components', 'taskbar', 'translator', 'hooks', 'bootbox', 'alerts', 'api', +], (components, taskbar, translator, hooks, bootbox, alerts, api) => { + const module = {}; + let newMessage = false; + + module.openChat = function (roomId, uid) { + if (!app.user.uid) { + return alerts.error('[[error:not-logged-in]]'); + } + + function loadAndCenter(chatModal) { + module.load(chatModal.attr('data-uuid')); + module.center(chatModal); + module.focusInput(chatModal); + } + + if (module.modalExists(roomId)) { + loadAndCenter(module.getModal(roomId)); + } else { + api.get(`/chats/${roomId}`, { + uid: uid || app.user.uid, + }).then(roomData => { + roomData.users = roomData.users.filter(user => user && Number.parseInt(user.uid, 10) !== Number.parseInt(app.user.uid, 10)); + roomData.uid = uid || app.user.uid; + roomData.isSelf = true; + module.createModal(roomData, loadAndCenter); + }).catch(alerts.error); + } + }; + + module.newChat = function (touid, callback) { + function createChat() { + api.post('/chats', { + uids: [touid], + }).then(({roomId}) => { + if (ajaxify.data.template.chats) { + ajaxify.go('chats/' + roomId); + } else { + module.openChat(roomId); + } + + callback(null, roomId); + }).catch(alerts.error); + } + + callback ||= function () {}; + if (!app.user.uid) { + return alerts.error('[[error:not-logged-in]]'); + } + + if (Number.parseInt(touid, 10) === Number.parseInt(app.user.uid, 10)) { + return alerts.error('[[error:cant-chat-with-yourself]]'); + } + + socket.emit('modules.chats.isDnD', touid, (error, isDnD) => { + if (error) { + return alerts.error(error); + } + + if (!isDnD) { + return createChat(); + } + + bootbox.confirm('[[modules:chat.confirm-chat-with-dnd-user]]', ok => { + if (ok) { + createChat(); + } + }); + }); + }; + + module.loadChatsDropdown = function (chatsListElement) { + socket.emit('modules.chats.getRecentChats', { + uid: app.user.uid, + after: 0, + }, (error, data) => { + if (error) { + return alerts.error(error); + } + + const rooms = data.rooms.filter(room => room.teaser); + + translator.toggleTimeagoShorthand(() => { + for (const room of rooms) { + room.teaser.timeago = $.timeago(new Date(Number.parseInt(room.teaser.timestamp, 10))); + } + + translator.toggleTimeagoShorthand(); + app.parseAndTranslate('partials/chats/dropdown', {rooms}, html => { + chatsListElement.find('*').not('.navigation-link').remove(); + chatsListElement.prepend(html); + app.createUserTooltips(chatsListElement, 'right'); + chatsListElement.off('click').on('click', '[data-roomid]', function (event) { + if ($(event.target).parents('.user-link').length > 0) { + return; + } + + const roomId = $(this).attr('data-roomid'); + if (/^chats\//.test(ajaxify.currentPage)) { + ajaxify.go('user/' + app.user.userslug + '/chats/' + roomId); + } else { + module.openChat(roomId); + } + }); + + $('[component="chats/mark-all-read"]').off('click').on('click', () => { + socket.emit('modules.chats.markAllRead', error => { + if (error) { + return alerts.error(error); + } + }); + }); + }); + }); + }); + }; + + module.onChatMessageReceived = function (data) { + const isSelf = data.self === 1; + data.message.self = data.self; + + newMessage = data.self === 0; + if (module.modalExists(data.roomId)) { + addMessageToModal(data); + } else if (!ajaxify.data.template.chats) { + api.get(`/chats/${data.roomId}`, {}).then(roomData => { + roomData.users = roomData.users.filter(user => user && Number.parseInt(user.uid, 10) !== Number.parseInt(app.user.uid, 10)); + roomData.silent = true; + roomData.uid = app.user.uid; + roomData.isSelf = isSelf; + module.createModal(roomData); + }).catch(alerts.error); + } + }; + + function addMessageToModal(data) { + const modal = module.getModal(data.roomId); + const username = data.message.fromUser.username; + const isSelf = data.self === 1; + require(['forum/chats/messages'], ChatsMessages => { + // Don't add if already added + if (modal.find('[data-mid="' + data.message.messageId + '"]').length === 0) { + ChatsMessages.appendChatMessage(modal.find('.chat-content'), data.message); + } + + if (modal.is(':visible')) { + taskbar.updateActive(modal.attr('data-uuid')); + if (ChatsMessages.isAtBottom(modal.find('.chat-content'))) { + ChatsMessages.scrollToBottom(modal.find('.chat-content')); + } + } else if (!ajaxify.data.template.chats) { + module.toggleNew(modal.attr('data-uuid'), true, true); + } + + if (!isSelf && (!modal.is(':visible') || !app.isFocused)) { + taskbar.push('chat', modal.attr('data-uuid'), { + title: '[[modules:chat.chatting_with]] ' + (data.roomName || username), + touid: data.message.fromUser.uid, + roomId: data.roomId, + isSelf: false, + }); + } + }); + } + + module.onUserStatusChange = function (data) { + const modal = module.getModal(data.uid); + app.updateUserStatus(modal.find('[component="user/status"]'), data.status); + }; + + module.onRoomRename = function (data) { + const newTitle = $('
    ').html(data.newName).text(); + const modal = module.getModal(data.roomId); + modal.find('[component="chat/room/name"]').text(newTitle); + taskbar.update('chat', modal.attr('data-uuid'), { + title: newTitle, + }); + hooks.fire('action:chat.renamed', Object.assign(data, { + modal, + })); + }; + + module.getModal = function (roomId) { + return $('#chat-modal-' + roomId); + }; + + module.modalExists = function (roomId) { + return $('#chat-modal-' + roomId).length > 0; + }; + + module.createModal = function (data, callback) { + callback ||= function () {}; + require([ + 'scrollStop', 'forum/chats', 'forum/chats/messages', + ], (scrollStop, Chats, ChatsMessages) => { + app.parseAndTranslate('chat', data, chatModal => { + if (module.modalExists(data.roomId)) { + return callback(module.getModal(data.roomId)); + } + + const uuid = utils.generateUUID(); + let dragged = false; + + chatModal.attr('id', 'chat-modal-' + data.roomId); + chatModal.attr('data-roomid', data.roomId); + chatModal.attr('intervalId', 0); + chatModal.attr('data-uuid', uuid); + chatModal.css('position', 'fixed'); + chatModal.appendTo($('body')); + chatModal.find('.timeago').timeago(); + module.center(chatModal); + + app.loadJQueryUI(() => { + chatModal.find('.modal-content').resizable({ + handles: 'n, e, s, w, se', + minHeight: 250, + minWidth: 400, + }); + + chatModal.find('.modal-content').on('resize', (event, ui) => { + if (ui.originalSize.height === ui.size.height) { + return; + } + + chatModal.find('.modal-body').css('height', module.calculateChatListHeight(chatModal)); + }); + + chatModal.draggable({ + start() { + taskbar.updateActive(uuid); + }, + stop() { + module.focusInput(chatModal); + }, + distance: 10, + handle: '.modal-header', + }); + }); + + scrollStop.apply(chatModal.find('[component="chat/messages"]')); + + chatModal.find('#chat-close-btn').on('click', () => { + module.close(chatModal); + }); + + function gotoChats() { + const text = components.get('chat/input').val(); + $(window).one('action:ajaxify.end', () => { + components.get('chat/input').val(text); + }); + + ajaxify.go('user/' + app.user.userslug + '/chats/' + chatModal.attr('data-roomid')); + module.close(chatModal); + } + + chatModal.find('.modal-header').on('dblclick', gotoChats); + chatModal.find('button[data-action="maximize"]').on('click', gotoChats); + chatModal.find('button[data-action="minimize"]').on('click', () => { + const uuid = chatModal.attr('data-uuid'); + module.minimize(uuid); + }); + + chatModal.on('mouseup', () => { + taskbar.updateActive(chatModal.attr('data-uuid')); + + dragged &&= false; + }); + + chatModal.on('mousemove', e => { + if (e.which === 1) { + dragged = true; + } + }); + + chatModal.on('mousemove keypress click', () => { + if (newMessage) { + socket.emit('modules.chats.markRead', data.roomId); + newMessage = false; + } + }); + + Chats.addActionHandlers(chatModal.find('[component="chat/messages"]'), data.roomId); + Chats.addRenameHandler(chatModal.attr('data-roomid'), chatModal.find('[data-action="rename"]'), data.roomName); + Chats.addLeaveHandler(chatModal.attr('data-roomid'), chatModal.find('[data-action="leave"]')); + Chats.addSendHandlers(chatModal.attr('data-roomid'), chatModal.find('.chat-input'), chatModal.find('[data-action="send"]')); + Chats.addMemberHandler(chatModal.attr('data-roomid'), chatModal.find('[data-action="members"]')); + + Chats.createAutoComplete(chatModal.find('[component="chat/input"]')); + + Chats.addScrollHandler(chatModal.attr('data-roomid'), data.uid, chatModal.find('.chat-content')); + Chats.addScrollBottomHandler(chatModal.find('.chat-content')); + + Chats.addCharactersLeftHandler(chatModal); + Chats.addIPHandler(chatModal); + + Chats.addUploadHandler({ + dragDropAreaEl: chatModal.find('.modal-content'), + pasteEl: chatModal, + uploadFormEl: chatModal.find('[component="chat/upload"]'), + inputEl: chatModal.find('[component="chat/input"]'), + }); + + ChatsMessages.addSocketListeners(); + + taskbar.push('chat', chatModal.attr('data-uuid'), { + title: '[[modules:chat.chatting_with]] ' + (data.roomName || (data.users.length > 0 ? data.users[0].username : '')), + roomId: data.roomId, + icon: 'fa-comment', + state: '', + isSelf: data.isSelf, + }, () => { + taskbar.toggleNew(chatModal.attr('data-uuid'), !data.isSelf); + hooks.fire('action:chat.loaded', chatModal); + + if (typeof callback === 'function') { + callback(chatModal); + } + }); + }); + }); + }; + + module.focusInput = function (chatModal) { + setTimeout(() => { + chatModal.find('[component="chat/input"]').focus(); + }, 20); + }; + + module.close = function (chatModal) { + const uuid = chatModal.attr('data-uuid'); + clearInterval(chatModal.attr('intervalId')); + chatModal.attr('intervalId', 0); + chatModal.remove(); + chatModal.data('modal', null); + taskbar.discard('chat', uuid); + + if (chatModal.attr('data-mobile')) { + module.disableMobileBehaviour(chatModal); + } + + hooks.fire('action:chat.closed', { + uuid, + modal: chatModal, + }); + }; + + // TODO: see taskbar.js:44 + module.closeByUUID = function (uuid) { + const chatModal = $('.chat-modal[data-uuid="' + uuid + '"]'); + module.close(chatModal); + }; + + module.center = function (chatModal) { + let hideAfter = false; + if (chatModal.hasClass('hide')) { + chatModal.removeClass('hide'); + hideAfter = true; + } + + chatModal.css('left', Math.max(0, (($(window).width() - $(chatModal).outerWidth()) / 2) + $(window).scrollLeft()) + 'px'); + chatModal.css('top', Math.max(0, ($(window).height() / 2) - ($(chatModal).outerHeight() / 2)) + 'px'); + + if (hideAfter) { + chatModal.addClass('hide'); + } + + return chatModal; + }; + + module.load = function (uuid) { + require(['forum/chats/messages'], ChatsMessages => { + const chatModal = $('.chat-modal[data-uuid="' + uuid + '"]'); + chatModal.removeClass('hide'); + taskbar.updateActive(uuid); + ChatsMessages.scrollToBottom(chatModal.find('.chat-content')); + module.focusInput(chatModal); + socket.emit('modules.chats.markRead', chatModal.attr('data-roomid')); + + const env = utils.findBootstrapEnvironment(); + if (env === 'xs' || env === 'sm') { + module.enableMobileBehaviour(chatModal); + } + }); + }; + + module.enableMobileBehaviour = function (modalElement) { + app.toggleNavbar(false); + modalElement.attr('data-mobile', '1'); + const messagesElement = modalElement.find('.modal-body'); + messagesElement.css('height', module.calculateChatListHeight(modalElement)); + function resize() { + messagesElement.css('height', module.calculateChatListHeight(modalElement)); + require(['forum/chats/messages'], ChatsMessages => { + ChatsMessages.scrollToBottom(modalElement.find('.chat-content')); + }); + } + + $(window).on('resize', resize); + $(window).one('action:ajaxify.start', () => { + module.close(modalElement); + $(window).off('resize', resize); + }); + }; + + module.disableMobileBehaviour = function () { + app.toggleNavbar(true); + }; + + module.calculateChatListHeight = function (modalElement) { + // Formula: modal height minus header height. Simple(tm). + return modalElement.find('.modal-content').outerHeight() - modalElement.find('.modal-header').outerHeight(); + }; + + module.minimize = function (uuid) { + const chatModal = $('.chat-modal[data-uuid="' + uuid + '"]'); + chatModal.addClass('hide'); + taskbar.minimize('chat', uuid); + clearInterval(chatModal.attr('intervalId')); + chatModal.attr('intervalId', 0); + hooks.fire('action:chat.minimized', { + uuid, + modal: chatModal, + }); + }; + + module.toggleNew = taskbar.toggleNew; + + return module; }); diff --git a/public/src/modules/components.js b/public/src/modules/components.js index 305aa3c..db56d32 100644 --- a/public/src/modules/components.js +++ b/public/src/modules/components.js @@ -1,73 +1,75 @@ 'use strict'; -define('components', function () { - const components = {}; +define('components', () => { + const components = {}; - components.core = { - 'topic/teaser': function (tid) { - if (tid) { - return $('[component="category/topic"][data-tid="' + tid + '"] [component="topic/teaser"]'); - } - return $('[component="topic/teaser"]'); - }, - topic: function (name, value) { - return $('[component="topic"][data-' + name + '="' + value + '"]'); - }, - post: function (name, value) { - return $('[component="post"][data-' + name + '="' + value + '"]'); - }, - 'post/content': function (pid) { - return $('[component="post"][data-pid="' + pid + '"] [component="post/content"]'); - }, - 'post/header': function (pid) { - return $('[component="post"][data-pid="' + pid + '"] [component="post/header"]'); - }, - 'post/anchor': function (index) { - return $('[component="post"][data-index="' + index + '"] [component="post/anchor"]'); - }, - 'post/vote-count': function (pid) { - return $('[component="post"][data-pid="' + pid + '"] [component="post/vote-count"]'); - }, - 'post/bookmark-count': function (pid) { - return $('[component="post"][data-pid="' + pid + '"] [component="post/bookmark-count"]'); - }, + components.core = { + 'topic/teaser'(tid) { + if (tid) { + return $('[component="category/topic"][data-tid="' + tid + '"] [component="topic/teaser"]'); + } - 'user/postcount': function (uid) { - return $('[component="user/postcount"][data-uid="' + uid + '"]'); - }, - 'user/reputation': function (uid) { - return $('[component="user/reputation"][data-uid="' + uid + '"]'); - }, + return $('[component="topic/teaser"]'); + }, + topic(name, value) { + return $('[component="topic"][data-' + name + '="' + value + '"]'); + }, + post(name, value) { + return $('[component="post"][data-' + name + '="' + value + '"]'); + }, + 'post/content'(pid) { + return $('[component="post"][data-pid="' + pid + '"] [component="post/content"]'); + }, + 'post/header'(pid) { + return $('[component="post"][data-pid="' + pid + '"] [component="post/header"]'); + }, + 'post/anchor'(index) { + return $('[component="post"][data-index="' + index + '"] [component="post/anchor"]'); + }, + 'post/vote-count'(pid) { + return $('[component="post"][data-pid="' + pid + '"] [component="post/vote-count"]'); + }, + 'post/bookmark-count'(pid) { + return $('[component="post"][data-pid="' + pid + '"] [component="post/bookmark-count"]'); + }, - 'category/topic': function (name, value) { - return $('[component="category/topic"][data-' + name + '="' + value + '"]'); - }, + 'user/postcount'(uid) { + return $('[component="user/postcount"][data-uid="' + uid + '"]'); + }, + 'user/reputation'(uid) { + return $('[component="user/reputation"][data-uid="' + uid + '"]'); + }, - 'categories/category': function (name, value) { - return $('[component="categories/category"][data-' + name + '="' + value + '"]'); - }, + 'category/topic'(name, value) { + return $('[component="category/topic"][data-' + name + '="' + value + '"]'); + }, - 'chat/message': function (messageId) { - return $('[component="chat/message"][data-mid="' + messageId + '"]'); - }, + 'categories/category'(name, value) { + return $('[component="categories/category"][data-' + name + '="' + value + '"]'); + }, - 'chat/message/body': function (messageId) { - return $('[component="chat/message"][data-mid="' + messageId + '"] [component="chat/message/body"]'); - }, + 'chat/message'(messageId) { + return $('[component="chat/message"][data-mid="' + messageId + '"]'); + }, - 'chat/recent/room': function (roomid) { - return $('[component="chat/recent/room"][data-roomid="' + roomid + '"]'); - }, - }; + 'chat/message/body'(messageId) { + return $('[component="chat/message"][data-mid="' + messageId + '"] [component="chat/message/body"]'); + }, - components.get = function () { - const args = Array.prototype.slice.call(arguments, 1); + 'chat/recent/room'(roomid) { + return $('[component="chat/recent/room"][data-roomid="' + roomid + '"]'); + }, + }; - if (components.core[arguments[0]] && args.length) { - return components.core[arguments[0]].apply(this, args); - } - return $('[component="' + arguments[0] + '"]'); - }; + components.get = function () { + const arguments_ = Array.prototype.slice.call(arguments, 1); - return components; + if (components.core[arguments[0]] && arguments_.length > 0) { + return components.core[arguments[0]].apply(this, arguments_); + } + + return $('[component="' + arguments[0] + '"]'); + }; + + return components; }); diff --git a/public/src/modules/coverPhoto.js b/public/src/modules/coverPhoto.js index aa62779..0c7d795 100644 --- a/public/src/modules/coverPhoto.js +++ b/public/src/modules/coverPhoto.js @@ -1,89 +1,88 @@ 'use strict'; - define('coverPhoto', [ - 'alerts', - 'vendor/jquery/draggable-background/backgroundDraggable', -], function (alerts) { - const coverPhoto = { - coverEl: null, - saveFn: null, - }; - - coverPhoto.init = function (coverEl, saveFn, uploadFn, removeFn) { - coverPhoto.coverEl = coverEl; - coverPhoto.saveFn = saveFn; - - coverEl.find('.upload').on('click', uploadFn); - coverEl.find('.resize').on('click', function () { - enableDragging(coverEl); - }); - coverEl.find('.remove').on('click', removeFn); - - coverEl - .on('dragover', coverPhoto.onDragOver) - .on('drop', coverPhoto.onDrop); - - coverEl.find('.save').on('click', coverPhoto.save); - coverEl.addClass('initialised'); - }; - - coverPhoto.onDragOver = function (e) { - e.stopPropagation(); - e.preventDefault(); - e.originalEvent.dataTransfer.dropEffect = 'copy'; - }; - - coverPhoto.onDrop = function (e) { - e.stopPropagation(); - e.preventDefault(); - - const files = e.originalEvent.dataTransfer.files; - const reader = new FileReader(); - - if (files.length && files[0].type.match('image.*')) { - reader.onload = function (e) { - coverPhoto.coverEl.css('background-image', 'url(' + e.target.result + ')'); - coverPhoto.newCover = e.target.result; - }; - - reader.readAsDataURL(files[0]); - enableDragging(coverPhoto.coverEl); - } - }; - - function enableDragging(coverEl) { - coverEl.toggleClass('active', 1) - .backgroundDraggable({ - axis: 'y', - units: 'percent', - }); - - alerts.alert({ - alert_id: 'drag_start', - title: '[[modules:cover.dragging_title]]', - message: '[[modules:cover.dragging_message]]', - timeout: 5000, - }); - } - - coverPhoto.save = function () { - coverPhoto.coverEl.addClass('saving'); - - coverPhoto.saveFn(coverPhoto.newCover || undefined, coverPhoto.coverEl.css('background-position'), function (err) { - if (!err) { - coverPhoto.coverEl.toggleClass('active', 0); - coverPhoto.coverEl.backgroundDraggable('disable'); - coverPhoto.coverEl.off('dragover', coverPhoto.onDragOver); - coverPhoto.coverEl.off('drop', coverPhoto.onDrop); - alerts.success('[[modules:cover.saved]]'); - } else { - alerts.error(err); - } - - coverPhoto.coverEl.removeClass('saving'); - }); - }; - - return coverPhoto; + 'alerts', + 'vendor/jquery/draggable-background/backgroundDraggable', +], alerts => { + const coverPhoto = { + coverEl: null, + saveFn: null, + }; + + coverPhoto.init = function (coverElement, saveFunction, uploadFunction, removeFunction) { + coverPhoto.coverEl = coverElement; + coverPhoto.saveFn = saveFunction; + + coverElement.find('.upload').on('click', uploadFunction); + coverElement.find('.resize').on('click', () => { + enableDragging(coverElement); + }); + coverElement.find('.remove').on('click', removeFunction); + + coverElement + .on('dragover', coverPhoto.onDragOver) + .on('drop', coverPhoto.onDrop); + + coverElement.find('.save').on('click', coverPhoto.save); + coverElement.addClass('initialised'); + }; + + coverPhoto.onDragOver = function (e) { + e.stopPropagation(); + e.preventDefault(); + e.originalEvent.dataTransfer.dropEffect = 'copy'; + }; + + coverPhoto.onDrop = function (e) { + e.stopPropagation(); + e.preventDefault(); + + const files = e.originalEvent.dataTransfer.files; + const reader = new FileReader(); + + if (files.length > 0 && files[0].type.match('image.*')) { + reader.addEventListener('load', e => { + coverPhoto.coverEl.css('background-image', 'url(' + e.target.result + ')'); + coverPhoto.newCover = e.target.result; + }); + + reader.readAsDataURL(files[0]); + enableDragging(coverPhoto.coverEl); + } + }; + + function enableDragging(coverElement) { + coverElement.toggleClass('active', 1) + .backgroundDraggable({ + axis: 'y', + units: 'percent', + }); + + alerts.alert({ + alert_id: 'drag_start', + title: '[[modules:cover.dragging_title]]', + message: '[[modules:cover.dragging_message]]', + timeout: 5000, + }); + } + + coverPhoto.save = function () { + coverPhoto.coverEl.addClass('saving'); + + coverPhoto.saveFn(coverPhoto.newCover || undefined, coverPhoto.coverEl.css('background-position'), error => { + if (error) { + alerts.error(error); + } else { + coverPhoto.coverEl.toggleClass('active', 0); + coverPhoto.coverEl.backgroundDraggable('disable'); + coverPhoto.coverEl.off('dragover', coverPhoto.onDragOver); + coverPhoto.coverEl.off('drop', coverPhoto.onDrop); + alerts.success('[[modules:cover.saved]]'); + } + + coverPhoto.coverEl.removeClass('saving'); + }); + }; + + return coverPhoto; }); diff --git a/public/src/modules/flags.js b/public/src/modules/flags.js index a1473cc..4b80efb 100644 --- a/public/src/modules/flags.js +++ b/public/src/modules/flags.js @@ -1,95 +1,97 @@ 'use strict'; +define('flags', ['hooks', 'components', 'api', 'alerts'], (hooks, components, api, alerts) => { + const Flag = {}; + let flagModal; + let flagCommit; + let flagReason; -define('flags', ['hooks', 'components', 'api', 'alerts'], function (hooks, components, api, alerts) { - const Flag = {}; - let flagModal; - let flagCommit; - let flagReason; - - Flag.showFlagModal = function (data) { - app.parseAndTranslate('partials/modals/flag_modal', data, function (html) { - flagModal = html; - flagModal.on('hidden.bs.modal', function () { - flagModal.remove(); - }); - - flagCommit = flagModal.find('#flag-post-commit'); - flagReason = flagModal.find('#flag-reason-custom'); - - flagModal.on('click', 'input[name="flag-reason"]', function () { - if ($(this).attr('id') === 'flag-reason-other') { - flagReason.removeAttr('disabled'); - if (!flagReason.val().length) { - flagCommit.attr('disabled', true); - } - } else { - flagReason.attr('disabled', true); - flagCommit.removeAttr('disabled'); - } - }); - - flagCommit.on('click', function () { - const selected = $('input[name="flag-reason"]:checked'); - let reason = selected.val(); - if (selected.attr('id') === 'flag-reason-other') { - reason = flagReason.val(); - } - createFlag(data.type, data.id, reason); - }); - - flagModal.on('click', '#flag-reason-other', function () { - flagReason.focus(); - }); - - flagModal.modal('show'); - hooks.fire('action:flag.showModal', { - modalEl: flagModal, - type: data.type, - id: data.id, - }); - - flagModal.find('#flag-reason-custom').on('keyup blur change', checkFlagButtonEnable); - }); - }; - - Flag.resolve = function (flagId) { - api.put(`/flags/${flagId}`, { - state: 'resolved', - }).then(() => { - alerts.success('[[flags:resolved]]'); - hooks.fire('action:flag.resolved', { flagId: flagId }); - }).catch(alerts.error); - }; - - function createFlag(type, id, reason) { - if (!type || !id || !reason) { - return; - } - const data = { type: type, id: id, reason: reason }; - api.post('/flags', data, function (err, flagId) { - if (err) { - return alerts.error(err); - } - - flagModal.modal('hide'); - alerts.success('[[flags:modal-submit-success]]'); - if (type === 'post') { - const postEl = components.get('post', 'pid', id); - postEl.find('[component="post/flag"]').addClass('hidden').parent().attr('hidden', ''); - postEl.find('[component="post/already-flagged"]').removeClass('hidden').parent().attr('hidden', null); - } - hooks.fire('action:flag.create', { flagId: flagId, data: data }); - }); - } - - function checkFlagButtonEnable() { - if (flagModal.find('#flag-reason-custom').val()) { - flagCommit.removeAttr('disabled'); - } else { - flagCommit.attr('disabled', true); - } - } - - return Flag; + Flag.showFlagModal = function (data) { + app.parseAndTranslate('partials/modals/flag_modal', data, html => { + flagModal = html; + flagModal.on('hidden.bs.modal', () => { + flagModal.remove(); + }); + + flagCommit = flagModal.find('#flag-post-commit'); + flagReason = flagModal.find('#flag-reason-custom'); + + flagModal.on('click', 'input[name="flag-reason"]', function () { + if ($(this).attr('id') === 'flag-reason-other') { + flagReason.removeAttr('disabled'); + if (flagReason.val().length === 0) { + flagCommit.attr('disabled', true); + } + } else { + flagReason.attr('disabled', true); + flagCommit.removeAttr('disabled'); + } + }); + + flagCommit.on('click', () => { + const selected = $('input[name="flag-reason"]:checked'); + let reason = selected.val(); + if (selected.attr('id') === 'flag-reason-other') { + reason = flagReason.val(); + } + + createFlag(data.type, data.id, reason); + }); + + flagModal.on('click', '#flag-reason-other', () => { + flagReason.focus(); + }); + + flagModal.modal('show'); + hooks.fire('action:flag.showModal', { + modalEl: flagModal, + type: data.type, + id: data.id, + }); + + flagModal.find('#flag-reason-custom').on('keyup blur change', checkFlagButtonEnable); + }); + }; + + Flag.resolve = function (flagId) { + api.put(`/flags/${flagId}`, { + state: 'resolved', + }).then(() => { + alerts.success('[[flags:resolved]]'); + hooks.fire('action:flag.resolved', {flagId}); + }).catch(alerts.error); + }; + + function createFlag(type, id, reason) { + if (!type || !id || !reason) { + return; + } + + const data = {type, id, reason}; + api.post('/flags', data, (error, flagId) => { + if (error) { + return alerts.error(error); + } + + flagModal.modal('hide'); + alerts.success('[[flags:modal-submit-success]]'); + if (type === 'post') { + const postElement = components.get('post', 'pid', id); + postElement.find('[component="post/flag"]').addClass('hidden').parent().attr('hidden', ''); + postElement.find('[component="post/already-flagged"]').removeClass('hidden').parent().attr('hidden', null); + } + + hooks.fire('action:flag.create', {flagId, data}); + }); + } + + function checkFlagButtonEnable() { + if (flagModal.find('#flag-reason-custom').val()) { + flagCommit.removeAttr('disabled'); + } else { + flagCommit.attr('disabled', true); + } + } + + return Flag; }); diff --git a/public/src/modules/groupSearch.js b/public/src/modules/groupSearch.js index 016173e..3c9dc8f 100644 --- a/public/src/modules/groupSearch.js +++ b/public/src/modules/groupSearch.js @@ -1,60 +1,64 @@ 'use strict'; -define('groupSearch', function () { - const groupSearch = {}; - - groupSearch.init = function (el) { - if (utils.isTouchDevice()) { - return; - } - const searchEl = el.find('[component="group-selector-search"]'); - if (!searchEl.length) { - return; - } - const toggleVisibility = searchEl.parent('[component="group-selector"]').length > 0; - - const groupEls = el.find('[component="group-list"] [data-name]'); - el.on('show.bs.dropdown', function () { - function updateList() { - const val = searchEl.find('input').val().toLowerCase(); - let noMatch = true; - groupEls.each(function () { - const liEl = $(this); - const isMatch = liEl.attr('data-name').toLowerCase().indexOf(val) !== -1; - if (noMatch && isMatch) { - noMatch = false; - } - - liEl.toggleClass('hidden', !isMatch); - }); - - el.find('[component="group-list"] [component="group-no-matches"]').toggleClass('hidden', !noMatch); - } - if (toggleVisibility) { - el.find('.dropdown-toggle').addClass('hidden'); - searchEl.removeClass('hidden'); - } - - searchEl.on('click', function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - }); - searchEl.find('input').val('').on('keyup', updateList); - updateList(); - }); - - el.on('shown.bs.dropdown', function () { - searchEl.find('input').focus(); - }); - - el.on('hide.bs.dropdown', function () { - if (toggleVisibility) { - el.find('.dropdown-toggle').removeClass('hidden'); - searchEl.addClass('hidden'); - } - searchEl.off('click').find('input').off('keyup'); - }); - }; - - return groupSearch; +define('groupSearch', () => { + const groupSearch = {}; + + groupSearch.init = function (element) { + if (utils.isTouchDevice()) { + return; + } + + const searchElement = element.find('[component="group-selector-search"]'); + if (searchElement.length === 0) { + return; + } + + const toggleVisibility = searchElement.parent('[component="group-selector"]').length > 0; + + const groupEls = element.find('[component="group-list"] [data-name]'); + element.on('show.bs.dropdown', () => { + function updateList() { + const value = searchElement.find('input').val().toLowerCase(); + let noMatch = true; + groupEls.each(function () { + const liElement = $(this); + const isMatch = liElement.attr('data-name').toLowerCase().includes(value); + if (noMatch && isMatch) { + noMatch = false; + } + + liElement.toggleClass('hidden', !isMatch); + }); + + element.find('[component="group-list"] [component="group-no-matches"]').toggleClass('hidden', !noMatch); + } + + if (toggleVisibility) { + element.find('.dropdown-toggle').addClass('hidden'); + searchElement.removeClass('hidden'); + } + + searchElement.on('click', event => { + event.preventDefault(); + event.stopPropagation(); + }); + searchElement.find('input').val('').on('keyup', updateList); + updateList(); + }); + + element.on('shown.bs.dropdown', () => { + searchElement.find('input').focus(); + }); + + element.on('hide.bs.dropdown', () => { + if (toggleVisibility) { + element.find('.dropdown-toggle').removeClass('hidden'); + searchElement.addClass('hidden'); + } + + searchElement.off('click').find('input').off('keyup'); + }); + }; + + return groupSearch; }); diff --git a/public/src/modules/handleBack.js b/public/src/modules/handleBack.js index 389add5..b8792b8 100644 --- a/public/src/modules/handleBack.js +++ b/public/src/modules/handleBack.js @@ -1,106 +1,106 @@ 'use strict'; define('handleBack', [ - 'components', - 'storage', - 'navigator', - 'forum/pagination', -], function (components, storage, navigator, pagination) { - const handleBack = {}; - let loadTopicsMethod; + 'components', + 'storage', + 'navigator', + 'forum/pagination', +], (components, storage, navigator, pagination) => { + const handleBack = {}; + let loadTopicsMethod; - handleBack.init = function (_loadTopicsMethod) { - loadTopicsMethod = _loadTopicsMethod; - saveClickedIndex(); - $(window).off('action:popstate', onBackClicked).on('action:popstate', onBackClicked); - }; + handleBack.init = function (_loadTopicsMethod) { + loadTopicsMethod = _loadTopicsMethod; + saveClickedIndex(); + $(window).off('action:popstate', onBackClicked).on('action:popstate', onBackClicked); + }; - handleBack.onBackClicked = onBackClicked; + handleBack.onBackClicked = onBackClicked; - function saveClickedIndex() { - $('[component="category"]').on('click', '[component="topic/header"]', function () { - const clickedIndex = $(this).parents('[data-index]').attr('data-index'); - const windowScrollTop = $(window).scrollTop(); - $('[component="category/topic"]').each(function (index, el) { - if ($(el).offset().top - windowScrollTop > 0) { - storage.setItem('category:bookmark', $(el).attr('data-index')); - storage.setItem('category:bookmark:clicked', clickedIndex); - storage.setItem('category:bookmark:offset', $(el).offset().top - windowScrollTop); - return false; - } - }); - }); - } + function saveClickedIndex() { + $('[component="category"]').on('click', '[component="topic/header"]', function () { + const clickedIndex = $(this).parents('[data-index]').attr('data-index'); + const windowScrollTop = $(window).scrollTop(); + $('[component="category/topic"]').each((index, element) => { + if ($(element).offset().top - windowScrollTop > 0) { + storage.setItem('category:bookmark', $(element).attr('data-index')); + storage.setItem('category:bookmark:clicked', clickedIndex); + storage.setItem('category:bookmark:offset', $(element).offset().top - windowScrollTop); + return false; + } + }); + }); + } - function onBackClicked(isMarkedUnread) { - const highlightUnread = isMarkedUnread && ajaxify.data.template.unread; - if ( - ajaxify.data.template.category || - ajaxify.data.template.recent || - ajaxify.data.template.popular || - highlightUnread - ) { - let bookmarkIndex = storage.getItem('category:bookmark'); - let clickedIndex = storage.getItem('category:bookmark:clicked'); + function onBackClicked(isMarkedUnread) { + const highlightUnread = isMarkedUnread && ajaxify.data.template.unread; + if ( + ajaxify.data.template.category + || ajaxify.data.template.recent + || ajaxify.data.template.popular + || highlightUnread + ) { + let bookmarkIndex = storage.getItem('category:bookmark'); + let clickedIndex = storage.getItem('category:bookmark:clicked'); - storage.removeItem('category:bookmark'); - storage.removeItem('category:bookmark:clicked'); - if (!utils.isNumber(bookmarkIndex)) { - return; - } + storage.removeItem('category:bookmark'); + storage.removeItem('category:bookmark:clicked'); + if (!utils.isNumber(bookmarkIndex)) { + return; + } - bookmarkIndex = Math.max(0, parseInt(bookmarkIndex, 10) || 0); - clickedIndex = Math.max(0, parseInt(clickedIndex, 10) || 0); + bookmarkIndex = Math.max(0, Number.parseInt(bookmarkIndex, 10) || 0); + clickedIndex = Math.max(0, Number.parseInt(clickedIndex, 10) || 0); - if (config.usePagination) { - const page = Math.ceil((parseInt(bookmarkIndex, 10) + 1) / config.topicsPerPage); - if (parseInt(page, 10) !== ajaxify.data.pagination.currentPage) { - pagination.loadPage(page, function () { - handleBack.scrollToTopic(bookmarkIndex, clickedIndex); - }); - } else { - handleBack.scrollToTopic(bookmarkIndex, clickedIndex); - } - } else { - if (bookmarkIndex === 0) { - handleBack.scrollToTopic(bookmarkIndex, clickedIndex); - return; - } + if (config.usePagination) { + const page = Math.ceil((Number.parseInt(bookmarkIndex, 10) + 1) / config.topicsPerPage); + if (Number.parseInt(page, 10) === ajaxify.data.pagination.currentPage) { + handleBack.scrollToTopic(bookmarkIndex, clickedIndex); + } else { + pagination.loadPage(page, () => { + handleBack.scrollToTopic(bookmarkIndex, clickedIndex); + }); + } + } else { + if (bookmarkIndex === 0) { + handleBack.scrollToTopic(bookmarkIndex, clickedIndex); + return; + } - $('[component="category"]').empty(); - loadTopicsMethod(Math.max(0, bookmarkIndex - 1) + 1, function () { - handleBack.scrollToTopic(bookmarkIndex, clickedIndex); - }); - } - } - } + $('[component="category"]').empty(); + loadTopicsMethod(Math.max(0, bookmarkIndex - 1) + 1, () => { + handleBack.scrollToTopic(bookmarkIndex, clickedIndex); + }); + } + } + } - handleBack.highlightTopic = function (topicIndex) { - const highlight = components.get('category/topic', 'index', topicIndex); + handleBack.highlightTopic = function (topicIndex) { + const highlight = components.get('category/topic', 'index', topicIndex); - if (highlight.length && !highlight.hasClass('highlight')) { - highlight.addClass('highlight'); - setTimeout(function () { - highlight.removeClass('highlight'); - }, 5000); - } - }; + if (highlight.length > 0 && !highlight.hasClass('highlight')) { + highlight.addClass('highlight'); + setTimeout(() => { + highlight.removeClass('highlight'); + }, 5000); + } + }; - handleBack.scrollToTopic = function (bookmarkIndex, clickedIndex) { - if (!utils.isNumber(bookmarkIndex)) { - return; - } + handleBack.scrollToTopic = function (bookmarkIndex, clickedIndex) { + if (!utils.isNumber(bookmarkIndex)) { + return; + } - const scrollTo = components.get('category/topic', 'index', bookmarkIndex); + const scrollTo = components.get('category/topic', 'index', bookmarkIndex); - if (scrollTo.length) { - const offset = storage.getItem('category:bookmark:offset'); - storage.removeItem('category:bookmark:offset'); - $(window).scrollTop(scrollTo.offset().top - offset); - handleBack.highlightTopic(clickedIndex); - navigator.update(); - } - }; + if (scrollTo.length > 0) { + const offset = storage.getItem('category:bookmark:offset'); + storage.removeItem('category:bookmark:offset'); + $(window).scrollTop(scrollTo.offset().top - offset); + handleBack.highlightTopic(clickedIndex); + navigator.update(); + } + }; - return handleBack; + return handleBack; }); diff --git a/public/src/modules/helpers.common.js b/public/src/modules/helpers.common.js index b85fb5f..09c3847 100644 --- a/public/src/modules/helpers.common.js +++ b/public/src/modules/helpers.common.js @@ -1,202 +1,213 @@ 'use strict'; module.exports = function (utils, Benchpress, relative_path) { - Benchpress.setGlobal('true', true); - Benchpress.setGlobal('false', false); - - const helpers = { - displayMenuItem, - buildMetaTag, - buildLinkTag, - stringify, - escape, - stripTags, - generateCategoryBackground, - generateChildrenCategories, - generateTopicClass, - membershipBtn, - spawnPrivilegeStates, - localeToHTML, - renderTopicImage, - renderTopicEvents, - renderEvents, - renderDigestAvatar, - userAgentIcons, - buildAvatar, - register, - __escape: identity, - }; - - function identity(str) { - return str; - } - - function displayMenuItem(data, index) { - const item = data.navigation[index]; - if (!item) { - return false; - } - - if (item.route.match('/users') && data.user && !data.user.privileges['view:users']) { - return false; - } - - if (item.route.match('/tags') && data.user && !data.user.privileges['view:tags']) { - return false; - } - - if (item.route.match('/groups') && data.user && !data.user.privileges['view:groups']) { - return false; - } - - return true; - } - - function buildMetaTag(tag) { - const name = tag.name ? 'name="' + tag.name + '" ' : ''; - const property = tag.property ? 'property="' + tag.property + '" ' : ''; - const content = tag.content ? 'content="' + tag.content.replace(/\n/g, ' ') + '" ' : ''; - - return '\n\t'; - } - - function buildLinkTag(tag) { - const attributes = ['link', 'rel', 'as', 'type', 'href', 'sizes', 'title', 'crossorigin']; - const [link, rel, as, type, href, sizes, title, crossorigin] = attributes.map(attr => (tag[attr] ? `${attr}="${tag[attr]}" ` : '')); - - return '\n\t'; - } - - function stringify(obj) { - // Turns the incoming object into a JSON string - return JSON.stringify(obj).replace(/&/gm, '&').replace(//gm, '>') - .replace(/"/g, '"'); - } - - function escape(str) { - return utils.escapeHTML(str); - } - - function stripTags(str) { - return utils.stripHTMLTags(str); - } - - function generateCategoryBackground(category) { - if (!category) { - return ''; - } - const style = []; - - if (category.bgColor) { - style.push('background-color: ' + category.bgColor); - } - - if (category.color) { - style.push('color: ' + category.color); - } - - if (category.backgroundImage) { - style.push('background-image: url(' + category.backgroundImage + ')'); - if (category.imageClass) { - style.push('background-size: ' + category.imageClass); - } - } - - return style.join('; ') + ';'; - } - - function generateChildrenCategories(category) { - let html = ''; - if (!category || !category.children || !category.children.length) { - return html; - } - category.children.forEach(function (child) { - if (child && !child.isSection) { - const link = child.link ? child.link : (relative_path + '/category/' + child.slug); - html += '' + - '' + - '
    ' + child.name + ''; - } - }); - html = html ? ('' + html + '') : html; - return html; - } - - function generateTopicClass(topic) { - const fields = ['locked', 'pinned', 'deleted', 'unread', 'scheduled']; - return fields.filter(field => !!topic[field]).join(' '); - } - - // Groups helpers - function membershipBtn(groupObj) { - if (groupObj.isMember && groupObj.name !== 'administrators') { - return ''; - } - - if (groupObj.isPending && groupObj.name !== 'administrators') { - return ''; - } else if (groupObj.isInvited) { - return ''; - } else if (!groupObj.disableJoinRequests && groupObj.name !== 'administrators') { - return ''; - } - return ''; - } - - function spawnPrivilegeStates(member, privileges) { - const states = []; - for (const priv in privileges) { - if (privileges.hasOwnProperty(priv)) { - states.push({ - name: priv, - state: privileges[priv], - }); - } - } - return states.map(function (priv) { - const guestDisabled = ['groups:moderate', 'groups:posts:upvote', 'groups:posts:downvote', 'groups:local:login', 'groups:group:create']; - const spidersEnabled = ['groups:find', 'groups:read', 'groups:topics:read', 'groups:view:users', 'groups:view:tags', 'groups:view:groups']; - const globalModDisabled = ['groups:moderate']; - const disabled = - (member === 'guests' && (guestDisabled.includes(priv.name) || priv.name.startsWith('groups:admin:'))) || - (member === 'spiders' && !spidersEnabled.includes(priv.name)) || - (member === 'Global Moderators' && globalModDisabled.includes(priv.name)); - - return ''; - }).join(''); - } - - function localeToHTML(locale, fallback) { - locale = locale || fallback || 'en-GB'; - return locale.replace('_', '-'); - } - - function renderTopicImage(topicObj) { - if (topicObj.thumb) { - return ''; - } - return ''; - } - - function renderTopicEvents(index, sort) { - if (sort === 'most_votes') { - return ''; - } - const start = this.posts[index].eventStart; - const end = this.posts[index].eventEnd; - const events = this.events.filter(event => event.timestamp >= start && event.timestamp < end); - if (!events.length) { - return ''; - } - - return renderEvents.call(this, events); - } - - function renderEvents(events) { - return events.reduce((html, event) => { - html += `
  • + Benchpress.setGlobal('true', true); + Benchpress.setGlobal('false', false); + + const helpers = { + displayMenuItem, + buildMetaTag, + buildLinkTag, + stringify, + escape, + stripTags, + generateCategoryBackground, + generateChildrenCategories, + generateTopicClass, + membershipBtn: membershipButton, + spawnPrivilegeStates, + localeToHTML, + renderTopicImage, + renderTopicEvents, + renderEvents, + renderDigestAvatar, + userAgentIcons, + buildAvatar, + register, + __escape: identity, + }; + + function identity(string_) { + return string_; + } + + function displayMenuItem(data, index) { + const item = data.navigation[index]; + if (!item) { + return false; + } + + if (item.route.match('/users') && data.user && !data.user.privileges['view:users']) { + return false; + } + + if (item.route.match('/tags') && data.user && !data.user.privileges['view:tags']) { + return false; + } + + if (item.route.match('/groups') && data.user && !data.user.privileges['view:groups']) { + return false; + } + + return true; + } + + function buildMetaTag(tag) { + const name = tag.name ? 'name="' + tag.name + '" ' : ''; + const property = tag.property ? 'property="' + tag.property + '" ' : ''; + const content = tag.content ? 'content="' + tag.content.replaceAll('\n', ' ') + '" ' : ''; + + return '\n\t'; + } + + function buildLinkTag(tag) { + const attributes = ['link', 'rel', 'as', 'type', 'href', 'sizes', 'title', 'crossorigin']; + const [link, rel, as, type, href, sizes, title, crossorigin] = attributes.map(attribute => (tag[attribute] ? `${attribute}="${tag[attribute]}" ` : '')); + + return '\n\t'; + } + + function stringify(object) { + // Turns the incoming object into a JSON string + return JSON.stringify(object).replaceAll(/&/gm, '&').replaceAll(//gm, '>') + .replaceAll('"', '"'); + } + + function escape(string_) { + return utils.escapeHTML(string_); + } + + function stripTags(string_) { + return utils.stripHTMLTags(string_); + } + + function generateCategoryBackground(category) { + if (!category) { + return ''; + } + + const style = []; + + if (category.bgColor) { + style.push('background-color: ' + category.bgColor); + } + + if (category.color) { + style.push('color: ' + category.color); + } + + if (category.backgroundImage) { + style.push('background-image: url(' + category.backgroundImage + ')'); + if (category.imageClass) { + style.push('background-size: ' + category.imageClass); + } + } + + return style.join('; ') + ';'; + } + + function generateChildrenCategories(category) { + let html = ''; + if (!category || !category.children || category.children.length === 0) { + return html; + } + + for (const child of category.children) { + if (child && !child.isSection) { + const link = child.link ? child.link : (relative_path + '/category/' + child.slug); + html += '' + + '' + + '' + child.name + ''; + } + } + + html = html ? ('' + html + '') : html; + return html; + } + + function generateTopicClass(topic) { + const fields = ['locked', 'pinned', 'deleted', 'unread', 'scheduled']; + return fields.filter(field => Boolean(topic[field])).join(' '); + } + + // Groups helpers + function membershipButton(groupObject) { + if (groupObject.isMember && groupObject.name !== 'administrators') { + return ''; + } + + if (groupObject.isPending && groupObject.name !== 'administrators') { + return ''; + } + + if (groupObject.isInvited) { + return ''; + } + + if (!groupObject.disableJoinRequests && groupObject.name !== 'administrators') { + return ''; + } + + return ''; + } + + function spawnPrivilegeStates(member, privileges) { + const states = []; + for (const priv in privileges) { + if (privileges.hasOwnProperty(priv)) { + states.push({ + name: priv, + state: privileges[priv], + }); + } + } + + return states.map(priv => { + const guestDisabled = ['groups:moderate', 'groups:posts:upvote', 'groups:posts:downvote', 'groups:local:login', 'groups:group:create']; + const spidersEnabled = ['groups:find', 'groups:read', 'groups:topics:read', 'groups:view:users', 'groups:view:tags', 'groups:view:groups']; + const globalModuleDisabled = ['groups:moderate']; + const disabled + = (member === 'guests' && (guestDisabled.includes(priv.name) || priv.name.startsWith('groups:admin:'))) + || (member === 'spiders' && !spidersEnabled.includes(priv.name)) + || (member === 'Global Moderators' && globalModuleDisabled.includes(priv.name)); + + return ''; + }).join(''); + } + + function localeToHTML(locale, fallback) { + locale ||= fallback || 'en-GB'; + return locale.replace('_', '-'); + } + + function renderTopicImage(topicObject) { + if (topicObject.thumb) { + return ''; + } + + return ''; + } + + function renderTopicEvents(index, sort) { + if (sort === 'most_votes') { + return ''; + } + + const start = this.posts[index].eventStart; + const end = this.posts[index].eventEnd; + const events = this.events.filter(event => event.timestamp >= start && event.timestamp < end); + if (events.length === 0) { + return ''; + } + + return renderEvents.call(this, events); + } + + function renderEvents(events) { + return events.reduce((html, event) => { + html += `
  • @@ -205,92 +216,115 @@ module.exports = function (utils, Benchpress, relative_path) { `; - if (event.user) { - if (!event.user.system) { - html += `${buildAvatar(event.user, 'xs', true)} ${event.user.username} `; - } else { - html += `[[global:system-user]] `; - } - } - - html += ``; - - if (this.privileges.isAdminOrMod) { - html += ` `; - } - - return html; - }, ''); - } - - function renderDigestAvatar(block) { - if (block.teaser) { - if (block.teaser.user.picture) { - return ''; - } - return '
    ' + block.teaser.user['icon:text'] + '
    '; - } - if (block.user.picture) { - return ''; - } - return '
    ' + block.user['icon:text'] + '
    '; - } - - function userAgentIcons(data) { - let icons = ''; - - switch (data.platform) { - case 'Linux': - icons += ''; - break; - case 'Microsoft Windows': - icons += ''; - break; - case 'Apple Mac': - icons += ''; - break; - case 'Android': - icons += ''; - break; - case 'iPad': - icons += ''; - break; - case 'iPod': // intentional fall-through - case 'iPhone': - icons += ''; - break; - default: - icons += ''; - break; - } - - switch (data.browser) { - case 'Chrome': - icons += ''; - break; - case 'Firefox': - icons += ''; - break; - case 'Safari': - icons += ''; - break; - case 'IE': - icons += ''; - break; - case 'Edge': - icons += ''; - break; - default: - icons += ''; - break; - } - - return icons; - } - - function buildAvatar(userObj, size, rounded, classNames, component) { - /** - * userObj requires: + if (event.user) { + html += event.user.system ? '[[global:system-user]] ' : `${buildAvatar(event.user, 'xs', true)} ${event.user.username} `; + } + + html += ``; + + if (this.privileges.isAdminOrMod) { + html += ` `; + } + + return html; + }, ''); + } + + function renderDigestAvatar(block) { + if (block.teaser) { + if (block.teaser.user.picture) { + return ''; + } + + return '
    ' + block.teaser.user['icon:text'] + '
    '; + } + + if (block.user.picture) { + return ''; + } + + return '
    ' + block.user['icon:text'] + '
    '; + } + + function userAgentIcons(data) { + let icons = ''; + + switch (data.platform) { + case 'Linux': { + icons += ''; + break; + } + + case 'Microsoft Windows': { + icons += ''; + break; + } + + case 'Apple Mac': { + icons += ''; + break; + } + + case 'Android': { + icons += ''; + break; + } + + case 'iPad': { + icons += ''; + break; + } + + case 'iPod': // Intentional fall-through + case 'iPhone': { + icons += ''; + break; + } + + default: { + icons += ''; + break; + } + } + + switch (data.browser) { + case 'Chrome': { + icons += ''; + break; + } + + case 'Firefox': { + icons += ''; + break; + } + + case 'Safari': { + icons += ''; + break; + } + + case 'IE': { + icons += ''; + break; + } + + case 'Edge': { + icons += ''; + break; + } + + default: { + icons += ''; + break; + } + } + + return icons; + } + + function buildAvatar(userObject, size, rounded, classNames, component) { + /** + * UserObj requires: * - uid, picture, icon:bgColor, icon:text (getUserField w/ "picture" should return all 4), username * size: one of "xs", "sm", "md", "lg", or "xl" (required), or an integer * rounded: true or false (optional, default false) @@ -298,50 +332,49 @@ module.exports = function (utils, Benchpress, relative_path) { * component: overrides the default component (optional, default none) */ - // Try to use root context if passed-in userObj is undefined - if (!userObj) { - userObj = this; - } - - const attributes = [ - 'alt="' + userObj.username + '"', - 'title="' + userObj.username + '"', - 'data-uid="' + userObj.uid + '"', - 'loading="lazy"', - ]; - const styles = []; - classNames = classNames || ''; - - // Validate sizes, handle integers, otherwise fall back to `avatar-sm` - if (['xs', 'sm', 'sm2x', 'md', 'lg', 'xl'].includes(size)) { - classNames += ' avatar-' + size; - } else if (!isNaN(parseInt(size, 10))) { - styles.push('width: ' + size + 'px;', 'height: ' + size + 'px;', 'line-height: ' + size + 'px;', 'font-size: ' + (parseInt(size, 10) / 16) + 'rem;'); - } else { - classNames += ' avatar-sm'; - } - attributes.unshift('class="avatar ' + classNames + (rounded ? ' avatar-rounded' : '') + '"'); - - // Component override - if (component) { - attributes.push('component="' + component + '"'); - } else { - attributes.push('component="avatar/' + (userObj.picture ? 'picture' : 'icon') + '"'); - } - - if (userObj.picture) { - return ''; - } - - styles.push('background-color: ' + userObj['icon:bgColor'] + ';'); - return '' + userObj['icon:text'] + ''; - } - - function register() { - Object.keys(helpers).forEach(function (helperName) { - Benchpress.registerHelper(helperName, helpers[helperName]); - }); - } - - return helpers; + // Try to use root context if passed-in userObj is undefined + userObject ||= this; + + const attributes = [ + 'alt="' + userObject.username + '"', + 'title="' + userObject.username + '"', + 'data-uid="' + userObject.uid + '"', + 'loading="lazy"', + ]; + const styles = []; + classNames ||= ''; + + // Validate sizes, handle integers, otherwise fall back to `avatar-sm` + if (['xs', 'sm', 'sm2x', 'md', 'lg', 'xl'].includes(size)) { + classNames += ' avatar-' + size; + } else if (isNaN(Number.parseInt(size, 10))) { + classNames += ' avatar-sm'; + } else { + styles.push('width: ' + size + 'px;', 'height: ' + size + 'px;', 'line-height: ' + size + 'px;', 'font-size: ' + (Number.parseInt(size, 10) / 16) + 'rem;'); + } + + attributes.unshift('class="avatar ' + classNames + (rounded ? ' avatar-rounded' : '') + '"'); + + // Component override + if (component) { + attributes.push('component="' + component + '"'); + } else { + attributes.push('component="avatar/' + (userObject.picture ? 'picture' : 'icon') + '"'); + } + + if (userObject.picture) { + return ''; + } + + styles.push('background-color: ' + userObject['icon:bgColor'] + ';'); + return '' + userObject['icon:text'] + ''; + } + + function register() { + for (const helperName of Object.keys(helpers)) { + Benchpress.registerHelper(helperName, helpers[helperName]); + } + } + + return helpers; }; diff --git a/public/src/modules/helpers.js b/public/src/modules/helpers.js index b5651c7..4ca7ba2 100644 --- a/public/src/modules/helpers.js +++ b/public/src/modules/helpers.js @@ -2,6 +2,4 @@ const factory = require('./helpers.common'); -define('helpers', ['utils', 'benchpressjs'], function (utils, Benchpressjs) { - return factory(utils, Benchpressjs, config.relative_path); -}); +define('helpers', ['utils', 'benchpressjs'], (utils, Benchpressjs) => factory(utils, Benchpressjs, config.relative_path)); diff --git a/public/src/modules/hooks.js b/public/src/modules/hooks.js index 0f84c96..a7b8c84 100644 --- a/public/src/modules/hooks.js +++ b/public/src/modules/hooks.js @@ -1,173 +1,182 @@ 'use strict'; define('hooks', [], () => { - const Hooks = { - loaded: {}, - temporary: new Set(), - runOnce: new Set(), - deprecated: { - - }, - logs: { - _collection: new Set(), - }, - }; - - Hooks.logs.collect = () => { - if (Hooks.logs._collection) { - return; - } - - Hooks.logs._collection = new Set(); - }; - - Hooks.logs.log = (...args) => { - if (Hooks.logs._collection) { - Hooks.logs._collection.add(args); - } else { - console.log.apply(console, args); - } - }; - - Hooks.logs.flush = () => { - if (Hooks.logs._collection && Hooks.logs._collection.size) { - console.groupCollapsed('[hooks] Changes to hooks on this page …'); - Hooks.logs._collection.forEach((args) => { - console.log.apply(console, args); - }); - console.groupEnd(); - } - - delete Hooks.logs._collection; - }; - - Hooks.register = (hookName, method) => { - Hooks.loaded[hookName] = Hooks.loaded[hookName] || new Set(); - Hooks.loaded[hookName].add(method); - - if (Hooks.deprecated.hasOwnProperty(hookName)) { - const deprecated = Hooks.deprecated[hookName]; - - if (deprecated) { - console.groupCollapsed(`[hooks] Hook "${hookName}" is deprecated, please use "${deprecated}" instead.`); - } else { - console.groupCollapsed(`[hooks] Hook "${hookName}" is deprecated, there is no alternative.`); - } - - console.info(method); - console.groupEnd(); - } - - Hooks.logs.log(`[hooks] Registered ${hookName}`, method); - return Hooks; - }; - Hooks.on = Hooks.register; - Hooks.one = (hookName, method) => { - Hooks.runOnce.add({ hookName, method }); - return Hooks.register(hookName, method); - }; - - // registerPage/onPage takes care of unregistering the listener on ajaxify - Hooks.registerPage = (hookName, method) => { - Hooks.temporary.add({ hookName, method }); - return Hooks.register(hookName, method); - }; - Hooks.onPage = Hooks.registerPage; - Hooks.register('action:ajaxify.start', () => { - Hooks.temporary.forEach((pair) => { - Hooks.unregister(pair.hookName, pair.method); - Hooks.temporary.delete(pair); - }); - }); - - Hooks.unregister = (hookName, method) => { - if (Hooks.loaded[hookName] && Hooks.loaded[hookName].has(method)) { - Hooks.loaded[hookName].delete(method); - Hooks.logs.log(`[hooks] Unregistered ${hookName}`, method); - } else { - Hooks.logs.log(`[hooks] Unregistration of ${hookName} failed, passed-in method is not a registered listener or the hook itself has no listeners, currently.`); - } - - return Hooks; - }; - Hooks.off = Hooks.unregister; - - Hooks.hasListeners = hookName => Hooks.loaded[hookName] && Hooks.loaded[hookName].size > 0; - - const _onHookError = (e, listener, data) => { - console.warn(`[hooks] Exception encountered in ${listener.name ? listener.name : 'anonymous function'}, stack trace follows.`); - console.error(e); - return Promise.resolve(data); - }; - - const _fireFilterHook = (hookName, data) => { - if (!Hooks.hasListeners(hookName)) { - return Promise.resolve(data); - } - - const listeners = Array.from(Hooks.loaded[hookName]); - return listeners.reduce((promise, listener) => promise.then((data) => { - try { - const result = listener(data); - return utils.isPromise(result) ? - result.then(data => Promise.resolve(data)).catch(e => _onHookError(e, listener, data)) : - result; - } catch (e) { - return _onHookError(e, listener, data); - } - }), Promise.resolve(data)); - }; - - const _fireActionHook = (hookName, data) => { - if (Hooks.hasListeners(hookName)) { - Hooks.loaded[hookName].forEach(listener => listener(data)); - } - - // Backwards compatibility (remove this when we eventually remove jQuery from NodeBB core) - $(window).trigger(hookName, data); - }; - - const _fireStaticHook = async (hookName, data) => { - if (!Hooks.hasListeners(hookName)) { - return Promise.resolve(data); - } - - const listeners = Array.from(Hooks.loaded[hookName]); - await Promise.allSettled(listeners.map((listener) => { - try { - return listener(data); - } catch (e) { - return _onHookError(e, listener); - } - })); - - return await Promise.resolve(data); - }; - - Hooks.fire = (hookName, data) => { - const type = hookName.split(':').shift(); - let result; - switch (type) { - case 'filter': - result = _fireFilterHook(hookName, data); - break; - - case 'action': - result = _fireActionHook(hookName, data); - break; - - case 'static': - result = _fireStaticHook(hookName, data); - break; - } - Hooks.runOnce.forEach((pair) => { - if (pair.hookName === hookName) { - Hooks.unregister(hookName, pair.method); - Hooks.runOnce.delete(pair); - } - }); - return result; - }; - - return Hooks; + const Hooks = { + loaded: {}, + temporary: new Set(), + runOnce: new Set(), + deprecated: {}, + logs: { + _collection: new Set(), + }, + }; + + Hooks.logs.collect = () => { + if (Hooks.logs._collection) { + return; + } + + Hooks.logs._collection = new Set(); + }; + + Hooks.logs.log = (...arguments_) => { + if (Hooks.logs._collection) { + Hooks.logs._collection.add(arguments_); + } else { + console.log.apply(console, arguments_); + } + }; + + Hooks.logs.flush = () => { + if (Hooks.logs._collection && Hooks.logs._collection.size > 0) { + console.groupCollapsed('[hooks] Changes to hooks on this page …'); + for (const arguments_ of Hooks.logs._collection) { + console.log.apply(console, arguments_); + } + + console.groupEnd(); + } + + delete Hooks.logs._collection; + }; + + Hooks.register = (hookName, method) => { + Hooks.loaded[hookName] = Hooks.loaded[hookName] || new Set(); + Hooks.loaded[hookName].add(method); + + if (Hooks.deprecated.hasOwnProperty(hookName)) { + const deprecated = Hooks.deprecated[hookName]; + + if (deprecated) { + console.groupCollapsed(`[hooks] Hook "${hookName}" is deprecated, please use "${deprecated}" instead.`); + } else { + console.groupCollapsed(`[hooks] Hook "${hookName}" is deprecated, there is no alternative.`); + } + + console.info(method); + console.groupEnd(); + } + + Hooks.logs.log(`[hooks] Registered ${hookName}`, method); + return Hooks; + }; + + Hooks.on = Hooks.register; + Hooks.one = (hookName, method) => { + Hooks.runOnce.add({hookName, method}); + return Hooks.register(hookName, method); + }; + + // RegisterPage/onPage takes care of unregistering the listener on ajaxify + Hooks.registerPage = (hookName, method) => { + Hooks.temporary.add({hookName, method}); + return Hooks.register(hookName, method); + }; + + Hooks.onPage = Hooks.registerPage; + Hooks.register('action:ajaxify.start', () => { + for (const pair of Hooks.temporary) { + Hooks.unregister(pair.hookName, pair.method); + Hooks.temporary.delete(pair); + } + }); + + Hooks.unregister = (hookName, method) => { + if (Hooks.loaded[hookName] && Hooks.loaded[hookName].has(method)) { + Hooks.loaded[hookName].delete(method); + Hooks.logs.log(`[hooks] Unregistered ${hookName}`, method); + } else { + Hooks.logs.log(`[hooks] Unregistration of ${hookName} failed, passed-in method is not a registered listener or the hook itself has no listeners, currently.`); + } + + return Hooks; + }; + + Hooks.off = Hooks.unregister; + + Hooks.hasListeners = hookName => Hooks.loaded[hookName] && Hooks.loaded[hookName].size > 0; + + const _onHookError = (e, listener, data) => { + console.warn(`[hooks] Exception encountered in ${listener.name ? listener.name : 'anonymous function'}, stack trace follows.`); + console.error(e); + return Promise.resolve(data); + }; + + const _fireFilterHook = (hookName, data) => { + if (!Hooks.hasListeners(hookName)) { + return Promise.resolve(data); + } + + const listeners = Array.from(Hooks.loaded[hookName]); + return listeners.reduce((promise, listener) => promise.then(data => { + try { + const result = listener(data); + return utils.isPromise(result) + ? result.then(data => data).catch(error => _onHookError(error, listener, data)) + : result; + } catch (error) { + return _onHookError(error, listener, data); + } + }), Promise.resolve(data)); + }; + + const _fireActionHook = (hookName, data) => { + if (Hooks.hasListeners(hookName)) { + for (const listener of Hooks.loaded[hookName]) { + listener(data); + } + } + + // Backwards compatibility (remove this when we eventually remove jQuery from NodeBB core) + $(window).trigger(hookName, data); + }; + + const _fireStaticHook = async (hookName, data) => { + if (!Hooks.hasListeners(hookName)) { + return data; + } + + const listeners = Array.from(Hooks.loaded[hookName]); + await Promise.allSettled(listeners.map(listener => { + try { + return listener(data); + } catch (error) { + return _onHookError(error, listener); + } + })); + + return await Promise.resolve(data); + }; + + Hooks.fire = (hookName, data) => { + const type = hookName.split(':').shift(); + let result; + switch (type) { + case 'filter': { + result = _fireFilterHook(hookName, data); + break; + } + + case 'action': { + result = _fireActionHook(hookName, data); + break; + } + + case 'static': { + result = _fireStaticHook(hookName, data); + break; + } + } + + for (const pair of Hooks.runOnce) { + if (pair.hookName === hookName) { + Hooks.unregister(hookName, pair.method); + Hooks.runOnce.delete(pair); + } + } + + return result; + }; + + return Hooks; }); diff --git a/public/src/modules/iconSelect.js b/public/src/modules/iconSelect.js index b1cb443..cf8bd96 100644 --- a/public/src/modules/iconSelect.js +++ b/public/src/modules/iconSelect.js @@ -1,125 +1,124 @@ 'use strict'; - -define('iconSelect', ['benchpress', 'bootbox'], function (Benchpress, bootbox) { - const iconSelect = {}; - - iconSelect.init = function (el, onModified) { - onModified = onModified || function () {}; - const doubleSize = el.hasClass('fa-2x'); - let selected = el.attr('class').replace('fa-2x', '').replace('fa', '').replace(/\s+/g, ''); - - $('#icons .selected').removeClass('selected'); - - if (selected) { - try { - $('#icons .fa-icons .fa.' + selected).addClass('selected'); - } catch (err) { - selected = ''; - } - } - - Benchpress.render('partials/fontawesome', {}).then(function (html) { - html = $(html); - html.find('.fa-icons').prepend($('')); - - const picker = bootbox.dialog({ - onEscape: true, - backdrop: true, - show: false, - message: html, - title: 'Select an Icon', - buttons: { - noIcon: { - label: 'No Icon', - className: 'btn-default', - callback: function () { - el.attr('class', 'fa ' + (doubleSize ? 'fa-2x ' : '')); - el.val(''); - el.attr('value', ''); - - onModified(el); - }, - }, - success: { - label: 'Select', - className: 'btn-primary', - callback: function () { - const iconClass = $('.bootbox .selected').attr('class') || `fa fa-${$('.bootbox #fa-filter').val()}`; - const categoryIconClass = $('
    ').addClass(iconClass).removeClass('fa').removeClass('selected') - .attr('class'); - const searchElVal = picker.find('input').val(); - - if (categoryIconClass) { - el.attr('class', 'fa ' + (doubleSize ? 'fa-2x ' : '') + categoryIconClass); - el.val(categoryIconClass); - el.attr('value', categoryIconClass); - } else if (searchElVal) { - el.attr('class', searchElVal); - el.val(searchElVal); - el.attr('value', searchElVal); - } - - onModified(el); - }, - }, - }, - }); - - picker.on('show.bs.modal', function () { - const modalEl = $(this); - const searchEl = modalEl.find('input'); - - if (selected) { - modalEl.find('.' + selected).addClass('selected'); - searchEl.val(selected.replace('fa-', '')); - } - }).modal('show'); - - picker.on('shown.bs.modal', function () { - const modalEl = $(this); - const searchEl = modalEl.find('input'); - const icons = modalEl.find('.fa-icons i'); - const submitEl = modalEl.find('button.btn-primary'); - - function changeSelection(newSelection) { - modalEl.find('i.selected').removeClass('selected'); - if (newSelection) { - newSelection.addClass('selected'); - } else if (searchEl.val().length === 0) { - if (selected) { - modalEl.find('.' + selected).addClass('selected'); - } - } else { - modalEl.find('i:visible').first().addClass('selected'); - } - } - - // Focus on the input box - searchEl.selectRange(0, searchEl.val().length); - - modalEl.find('.icon-container').on('click', 'i', function () { - searchEl.val($(this).attr('class').replace('fa fa-', '').replace('selected', '')); - changeSelection($(this)); - }); - - searchEl.on('keyup', function (e) { - if (e.keyCode !== 13) { - // Filter - icons.show(); - icons.each(function (idx, el) { - if (!el.className.match(new RegExp('^fa fa-.*' + searchEl.val() + '.*$'))) { - $(el).hide(); - } - }); - changeSelection(); - } else { - submitEl.click(); - } - }); - }); - }); - }; - - return iconSelect; +define('iconSelect', ['benchpress', 'bootbox'], (Benchpress, bootbox) => { + const iconSelect = {}; + + iconSelect.init = function (element, onModified) { + onModified ||= function () {}; + const doubleSize = element.hasClass('fa-2x'); + let selected = element.attr('class').replace('fa-2x', '').replace('fa', '').replaceAll(/\s+/g, ''); + + $('#icons .selected').removeClass('selected'); + + if (selected) { + try { + $('#icons .fa-icons .fa.' + selected).addClass('selected'); + } catch { + selected = ''; + } + } + + Benchpress.render('partials/fontawesome', {}).then(html => { + html = $(html); + html.find('.fa-icons').prepend($('')); + + const picker = bootbox.dialog({ + onEscape: true, + backdrop: true, + show: false, + message: html, + title: 'Select an Icon', + buttons: { + noIcon: { + label: 'No Icon', + className: 'btn-default', + callback() { + element.attr('class', 'fa ' + (doubleSize ? 'fa-2x ' : '')); + element.val(''); + element.attr('value', ''); + + onModified(element); + }, + }, + success: { + label: 'Select', + className: 'btn-primary', + callback() { + const iconClass = $('.bootbox .selected').attr('class') || `fa fa-${$('.bootbox #fa-filter').val()}`; + const categoryIconClass = $('
    ').addClass(iconClass).removeClass('fa').removeClass('selected') + .attr('class'); + const searchElementValue = picker.find('input').val(); + + if (categoryIconClass) { + element.attr('class', 'fa ' + (doubleSize ? 'fa-2x ' : '') + categoryIconClass); + element.val(categoryIconClass); + element.attr('value', categoryIconClass); + } else if (searchElementValue) { + element.attr('class', searchElementValue); + element.val(searchElementValue); + element.attr('value', searchElementValue); + } + + onModified(element); + }, + }, + }, + }); + + picker.on('show.bs.modal', function () { + const modalElement = $(this); + const searchElement = modalElement.find('input'); + + if (selected) { + modalElement.find('.' + selected).addClass('selected'); + searchElement.val(selected.replace('fa-', '')); + } + }).modal('show'); + + picker.on('shown.bs.modal', function () { + const modalElement = $(this); + const searchElement = modalElement.find('input'); + const icons = modalElement.find('.fa-icons i'); + const submitElement = modalElement.find('button.btn-primary'); + + function changeSelection(newSelection) { + modalElement.find('i.selected').removeClass('selected'); + if (newSelection) { + newSelection.addClass('selected'); + } else if (searchElement.val().length === 0) { + if (selected) { + modalElement.find('.' + selected).addClass('selected'); + } + } else { + modalElement.find('i:visible').first().addClass('selected'); + } + } + + // Focus on the input box + searchElement.selectRange(0, searchElement.val().length); + + modalElement.find('.icon-container').on('click', 'i', function () { + searchElement.val($(this).attr('class').replace('fa fa-', '').replace('selected', '')); + changeSelection($(this)); + }); + + searchElement.on('keyup', e => { + if (e.keyCode === 13) { + submitElement.click(); + } else { + // Filter + icons.show(); + icons.each((index, element_) => { + if (!new RegExp('^fa fa-.*' + searchElement.val() + '.*$').test(element_.className)) { + $(element_).hide(); + } + }); + changeSelection(); + } + }); + }); + }); + }; + + return iconSelect; }); diff --git a/public/src/modules/logout.js b/public/src/modules/logout.js index 70bfcd9..135dd35 100644 --- a/public/src/modules/logout.js +++ b/public/src/modules/logout.js @@ -1,28 +1,26 @@ 'use strict'; -define('logout', ['hooks'], function (hooks) { - return function logout(redirect) { - redirect = redirect === undefined ? true : redirect; - hooks.fire('action:app.logout'); +define('logout', ['hooks'], hooks => function logout(redirect) { + redirect = redirect === undefined ? true : redirect; + hooks.fire('action:app.logout'); - $.ajax(config.relative_path + '/logout', { - type: 'POST', - headers: { - 'x-csrf-token': config.csrf_token, - }, - beforeSend: function () { - app.flags._logout = true; - }, - success: function (data) { - hooks.fire('action:app.loggedOut', data); - if (redirect) { - if (data.next) { - window.location.href = data.next; - } else { - window.location.reload(); - } - } - }, - }); - }; + $.ajax(config.relative_path + '/logout', { + type: 'POST', + headers: { + 'x-csrf-token': config.csrf_token, + }, + beforeSend() { + app.flags._logout = true; + }, + success(data) { + hooks.fire('action:app.loggedOut', data); + if (redirect) { + if (data.next) { + window.location.href = data.next; + } else { + window.location.reload(); + } + } + }, + }); }); diff --git a/public/src/modules/messages.js b/public/src/modules/messages.js index e30d2a4..01dfa35 100644 --- a/public/src/modules/messages.js +++ b/public/src/modules/messages.js @@ -1,131 +1,134 @@ 'use strict'; -define('messages', ['bootbox', 'translator', 'storage', 'alerts', 'hooks'], function (bootbox, translator, storage, alerts, hooks) { - const messages = {}; - - let showWelcomeMessage; - let registerMessage; - - messages.show = function () { - hooks.one('action:ajaxify.end', () => { - showQueryStringMessages(); - showCookieWarning(); - messages.showEmailConfirmWarning(); - }); - }; - - messages.showEmailConfirmWarning = function (message) { - if (!config.emailPrompt || !app.user.uid || parseInt(storage.getItem('email-confirm-dismiss'), 10) === 1) { - return; - } - const msg = { - alert_id: 'email_confirm', - type: 'warning', - timeout: 0, - closefn: () => { - storage.setItem('email-confirm-dismiss', 1); - }, - }; - - if (!app.user.email) { - msg.message = '[[error:no-email-to-confirm]]'; - msg.clickfn = function () { - alerts.remove('email_confirm'); - ajaxify.go('user/' + app.user.userslug + '/edit/email'); - }; - alerts.alert(msg); - } else if (!app.user['email:confirmed'] && !app.user.isEmailConfirmSent) { - msg.message = message || '[[error:email-not-confirmed]]'; - msg.clickfn = function () { - alerts.remove('email_confirm'); - ajaxify.go('/me/edit/email'); - }; - alerts.alert(msg); - } else if (!app.user['email:confirmed'] && app.user.isEmailConfirmSent) { - msg.message = '[[error:email-not-confirmed-email-sent]]'; - alerts.alert(msg); - } - }; - - function showCookieWarning() { - if (!config.cookies.enabled || !navigator.cookieEnabled || app.inAdmin || storage.getItem('cookieconsent') === '1') { - return; - } - - config.cookies.message = translator.unescape(config.cookies.message); - config.cookies.dismiss = translator.unescape(config.cookies.dismiss); - config.cookies.link = translator.unescape(config.cookies.link); - config.cookies.link_url = translator.unescape(config.cookies.link_url); - - app.parseAndTranslate('partials/cookie-consent', config.cookies, function (html) { - $(document.body).append(html); - $(document.body).addClass('cookie-consent-open'); - - const warningEl = $('.cookie-consent'); - const dismissEl = warningEl.find('button'); - dismissEl.on('click', function () { - // Save consent cookie and remove warning element - storage.setItem('cookieconsent', '1'); - warningEl.remove(); - $(document.body).removeClass('cookie-consent-open'); - }); - }); - } - - function showQueryStringMessages() { - const params = utils.params({ full: true }); - showWelcomeMessage = params.has('loggedin'); - registerMessage = params.get('register'); - - if (showWelcomeMessage) { - alerts.alert({ - type: 'success', - title: '[[global:welcome_back]] ' + app.user.username + '!', - message: '[[global:you_have_successfully_logged_in]]', - timeout: 5000, - }); - - params.delete('loggedin'); - } - - if (registerMessage) { - bootbox.alert({ - message: utils.escapeHTML(decodeURIComponent(registerMessage)), - }); - - params.delete('register'); - } - - if (params.has('lang') && params.get('lang') === config.defaultLang) { - console.info(`The "lang" parameter was passed in to set the language to "${params.get('lang')}", but that is already the forum default language.`); - params.delete('lang'); - } - - const qs = params.toString(); - ajaxify.updateHistory(ajaxify.currentPage + (qs ? `?${qs}` : '') + document.location.hash, true); - } - - messages.showInvalidSession = function () { - bootbox.alert({ - title: '[[error:invalid-session]]', - message: '[[error:invalid-session-text]]', - closeButton: false, - callback: function () { - window.location.reload(); - }, - }); - }; - - messages.showSessionMismatch = function () { - bootbox.alert({ - title: '[[error:session-mismatch]]', - message: '[[error:session-mismatch-text]]', - closeButton: false, - callback: function () { - window.location.reload(); - }, - }); - }; - - return messages; +define('messages', ['bootbox', 'translator', 'storage', 'alerts', 'hooks'], (bootbox, translator, storage, alerts, hooks) => { + const messages = {}; + + let showWelcomeMessage; + let registerMessage; + + messages.show = function () { + hooks.one('action:ajaxify.end', () => { + showQueryStringMessages(); + showCookieWarning(); + messages.showEmailConfirmWarning(); + }); + }; + + messages.showEmailConfirmWarning = function (message) { + if (!config.emailPrompt || !app.user.uid || Number.parseInt(storage.getItem('email-confirm-dismiss'), 10) === 1) { + return; + } + + const message_ = { + alert_id: 'email_confirm', + type: 'warning', + timeout: 0, + closefn() { + storage.setItem('email-confirm-dismiss', 1); + }, + }; + + if (!app.user.email) { + message_.message = '[[error:no-email-to-confirm]]'; + message_.clickfn = function () { + alerts.remove('email_confirm'); + ajaxify.go('user/' + app.user.userslug + '/edit/email'); + }; + + alerts.alert(message_); + } else if (!app.user['email:confirmed'] && !app.user.isEmailConfirmSent) { + message_.message = message || '[[error:email-not-confirmed]]'; + message_.clickfn = function () { + alerts.remove('email_confirm'); + ajaxify.go('/me/edit/email'); + }; + + alerts.alert(message_); + } else if (!app.user['email:confirmed'] && app.user.isEmailConfirmSent) { + message_.message = '[[error:email-not-confirmed-email-sent]]'; + alerts.alert(message_); + } + }; + + function showCookieWarning() { + if (!config.cookies.enabled || !navigator.cookieEnabled || app.inAdmin || storage.getItem('cookieconsent') === '1') { + return; + } + + config.cookies.message = translator.unescape(config.cookies.message); + config.cookies.dismiss = translator.unescape(config.cookies.dismiss); + config.cookies.link = translator.unescape(config.cookies.link); + config.cookies.link_url = translator.unescape(config.cookies.link_url); + + app.parseAndTranslate('partials/cookie-consent', config.cookies, html => { + $(document.body).append(html); + $(document.body).addClass('cookie-consent-open'); + + const warningElement = $('.cookie-consent'); + const dismissElement = warningElement.find('button'); + dismissElement.on('click', () => { + // Save consent cookie and remove warning element + storage.setItem('cookieconsent', '1'); + warningElement.remove(); + $(document.body).removeClass('cookie-consent-open'); + }); + }); + } + + function showQueryStringMessages() { + const parameters = utils.params({full: true}); + showWelcomeMessage = parameters.has('loggedin'); + registerMessage = parameters.get('register'); + + if (showWelcomeMessage) { + alerts.alert({ + type: 'success', + title: '[[global:welcome_back]] ' + app.user.username + '!', + message: '[[global:you_have_successfully_logged_in]]', + timeout: 5000, + }); + + parameters.delete('loggedin'); + } + + if (registerMessage) { + bootbox.alert({ + message: utils.escapeHTML(decodeURIComponent(registerMessage)), + }); + + parameters.delete('register'); + } + + if (parameters.has('lang') && parameters.get('lang') === config.defaultLang) { + console.info(`The "lang" parameter was passed in to set the language to "${parameters.get('lang')}", but that is already the forum default language.`); + parameters.delete('lang'); + } + + const qs = parameters.toString(); + ajaxify.updateHistory(ajaxify.currentPage + (qs ? `?${qs}` : '') + document.location.hash, true); + } + + messages.showInvalidSession = function () { + bootbox.alert({ + title: '[[error:invalid-session]]', + message: '[[error:invalid-session-text]]', + closeButton: false, + callback() { + window.location.reload(); + }, + }); + }; + + messages.showSessionMismatch = function () { + bootbox.alert({ + title: '[[error:session-mismatch]]', + message: '[[error:session-mismatch-text]]', + closeButton: false, + callback() { + window.location.reload(); + }, + }); + }; + + return messages; }); diff --git a/public/src/modules/navigator.js b/public/src/modules/navigator.js index 1a84c4c..de22a11 100644 --- a/public/src/modules/navigator.js +++ b/public/src/modules/navigator.js @@ -1,646 +1,665 @@ 'use strict'; -define('navigator', ['forum/pagination', 'components', 'hooks', 'alerts'], function (pagination, components, hooks, alerts) { - const navigator = {}; - let index = 0; - let count = 0; - let navigatorUpdateTimeoutId; - - let renderPostIntervalId; - let touchX; - let touchY; - let renderPostIndex; - let isNavigating = false; - let firstMove = true; - - navigator.scrollActive = false; - - let paginationBlockEl = $('.pagination-block'); - let paginationTextEl = paginationBlockEl.find('.pagination-text'); - let paginationBlockMeterEl = paginationBlockEl.find('meter'); - let paginationBlockProgressEl = paginationBlockEl.find('.progress-bar'); - let thumb; - let thumbText; - let thumbIcon; - let thumbIconHeight; - let thumbIconHalfHeight; - - $(window).on('action:ajaxify.start', function () { - $(window).off('keydown', onKeyDown); - }); - - navigator.init = function (selector, count, toTop, toBottom, callback) { - index = 0; - navigator.selector = selector; - navigator.callback = callback; - navigator.toTop = toTop || function () {}; - navigator.toBottom = toBottom || function () {}; - - paginationBlockEl = $('.pagination-block'); - paginationTextEl = paginationBlockEl.find('.pagination-text'); - paginationBlockMeterEl = paginationBlockEl.find('meter'); - paginationBlockProgressEl = paginationBlockEl.find('.progress-bar'); - - thumbIcon = $('.scroller-thumb-icon'); - thumbIconHeight = thumbIcon.height(); - thumbIconHalfHeight = thumbIconHeight / 2; - thumb = $('.scroller-thumb'); - thumbText = thumb.find('.thumb-text'); - - $(window).off('scroll', navigator.delayedUpdate).on('scroll', navigator.delayedUpdate); - - paginationBlockEl.find('.dropdown-menu').off('click').on('click', function (e) { - e.stopPropagation(); - }); - - paginationBlockEl.off('shown.bs.dropdown', '.wrapper').on('shown.bs.dropdown', '.wrapper', function () { - setTimeout(async function () { - if (utils.findBootstrapEnvironment() === 'lg') { - $('.pagination-block input').focus(); - } - const postCountInTopic = await socket.emit('topics.getPostCountInTopic', ajaxify.data.tid); - if (postCountInTopic > 0) { - paginationBlockEl.find('#myNextPostBtn').removeAttr('disabled'); - } - }, 100); - }); - paginationBlockEl.find('.pageup').off('click').on('click', navigator.scrollUp); - paginationBlockEl.find('.pagedown').off('click').on('click', navigator.scrollDown); - paginationBlockEl.find('.pagetop').off('click').on('click', navigator.toTop); - paginationBlockEl.find('.pagebottom').off('click').on('click', navigator.toBottom); - paginationBlockEl.find('#myNextPostBtn').off('click').on('click', gotoMyNextPost); - - paginationBlockEl.find('input').on('keydown', function (e) { - if (e.which === 13) { - const input = $(this); - if (!utils.isNumber(input.val())) { - input.val(''); - return; - } - - const index = parseInt(input.val(), 10); - const url = generateUrl(index); - input.val(''); - $('.pagination-block .dropdown-toggle').trigger('click'); - ajaxify.go(url); - } - }); - - if (ajaxify.data.template.topic) { - handleScrollNav(); - } - - handleKeys(); - - navigator.setCount(count); - navigator.update(0); - }; - - let lastNextIndex = 0; - async function gotoMyNextPost() { - async function getNext(startIndex) { - return await socket.emit('topics.getMyNextPostIndex', { - tid: ajaxify.data.tid, - index: Math.max(1, startIndex), - sort: config.topicPostSort, - }); - } - if (ajaxify.data.template.topic) { - let nextIndex = await getNext(index); - if (lastNextIndex === nextIndex) { // handles last post in pagination - nextIndex = await getNext(nextIndex); - } - if (nextIndex && index !== nextIndex + 1) { - lastNextIndex = nextIndex; - $(window).one('action:ajaxify.end', function () { - if (paginationBlockEl.find('.dropdown-menu').is(':hidden')) { - paginationBlockEl.find('.dropdown-toggle').dropdown('toggle'); - } - }); - navigator.scrollToIndex(nextIndex, true, 0); - } else { - alerts.alert({ - message: '[[topic:no-more-next-post]]', - type: 'info', - }); - - lastNextIndex = 1; - } - } - } - - function clampTop(newTop) { - const parent = thumb.parent(); - const parentOffset = parent.offset(); - if (newTop < parentOffset.top) { - newTop = parentOffset.top; - } else if (newTop > parentOffset.top + parent.height() - thumbIconHeight) { - newTop = parentOffset.top + parent.height() - thumbIconHeight; - } - return newTop; - } - - function setThumbToIndex(index) { - if (!thumb.length || thumb.is(':hidden')) { - return; - } - const parent = thumb.parent(); - const parentOffset = parent.offset(); - let percent = (index - 1) / ajaxify.data.postcount; - if (index === count) { - percent = 1; - } - const newTop = clampTop(parentOffset.top + ((parent.height() - thumbIconHeight) * percent)); - - const offset = { top: newTop, left: thumb.offset().left }; - thumb.offset(offset); - thumbText.text(index + '/' + ajaxify.data.postcount); - renderPost(index); - } - - function handleScrollNav() { - if (!thumb.length) { - return; - } - - const parent = thumb.parent(); - parent.on('click', function (ev) { - if ($(ev.target).hasClass('scroller-container')) { - const index = calculateIndexFromY(ev.pageY); - navigator.scrollToIndex(index - 1, true, 0); - return false; - } - }); - - function calculateIndexFromY(y) { - const newTop = clampTop(y - thumbIconHalfHeight); - const parentOffset = parent.offset(); - const percent = (newTop - parentOffset.top) / (parent.height() - thumbIconHeight); - index = Math.max(1, Math.ceil(ajaxify.data.postcount * percent)); - return index > ajaxify.data.postcount ? ajaxify.data.count : index; - } - - let mouseDragging = false; - hooks.on('action:ajaxify.end', function () { - renderPostIndex = null; - }); - $('.pagination-block .dropdown-menu').parent().on('shown.bs.dropdown', function () { - setThumbToIndex(index); - }); - - thumb.on('mousedown', function () { - mouseDragging = true; - $(window).on('mousemove', mousemove); - firstMove = true; - }); - - function mouseup() { - $(window).off('mousemove', mousemove); - if (mouseDragging) { - navigator.scrollToIndex(index - 1, true, 0); - paginationBlockEl.find('[data-toggle="dropdown"]').trigger('click'); - } - clearRenderInterval(); - mouseDragging = false; - firstMove = false; - } - - function mousemove(ev) { - const newTop = clampTop(ev.pageY - thumbIconHalfHeight); - thumb.offset({ top: newTop, left: thumb.offset().left }); - const index = calculateIndexFromY(ev.pageY); - navigator.updateTextAndProgressBar(); - thumbText.text(index + '/' + ajaxify.data.postcount); - if (firstMove) { - delayedRenderPost(); - } - firstMove = false; - ev.stopPropagation(); - return false; - } - - function delayedRenderPost() { - clearRenderInterval(); - renderPostIntervalId = setInterval(function () { - renderPost(index); - }, 250); - } - - $(window).off('mousemove', mousemove); - $(window).off('mouseup', mouseup).on('mouseup', mouseup); - - thumb.on('touchstart', function (ev) { - isNavigating = true; - touchX = Math.min($(window).width(), Math.max(0, ev.touches[0].clientX)); - touchY = Math.min($(window).height(), Math.max(0, ev.touches[0].clientY)); - firstMove = true; - }); - - thumb.on('touchmove', function (ev) { - const windowWidth = $(window).width(); - const windowHeight = $(window).height(); - const deltaX = Math.abs(touchX - Math.min(windowWidth, Math.max(0, ev.touches[0].clientX))); - const deltaY = Math.abs(touchY - Math.min(windowHeight, Math.max(0, ev.touches[0].clientY))); - touchX = Math.min(windowWidth, Math.max(0, ev.touches[0].clientX)); - touchY = Math.min(windowHeight, Math.max(0, ev.touches[0].clientY)); - - if (deltaY >= deltaX && firstMove) { - isNavigating = true; - delayedRenderPost(); - } - - if (isNavigating && ev.cancelable) { - ev.preventDefault(); - ev.stopPropagation(); - const newTop = clampTop(touchY + $(window).scrollTop() - thumbIconHalfHeight); - thumb.offset({ top: newTop, left: thumb.offset().left }); - const index = calculateIndexFromY(touchY + $(window).scrollTop()); - navigator.updateTextAndProgressBar(); - thumbText.text(index + '/' + ajaxify.data.postcount); - if (firstMove) { - renderPost(index); - } - } - firstMove = false; - }); - - thumb.on('touchend', function () { - clearRenderInterval(); - if (isNavigating) { - navigator.scrollToIndex(index - 1, true, 0); - isNavigating = false; - paginationBlockEl.find('[data-toggle="dropdown"]').trigger('click'); - } - }); - } - - function clearRenderInterval() { - if (renderPostIntervalId) { - clearInterval(renderPostIntervalId); - renderPostIntervalId = 0; - } - } - - function renderPost(index, callback) { - callback = callback || function () {}; - if (renderPostIndex === index || paginationBlockEl.find('.post-content').is(':hidden')) { - return; - } - renderPostIndex = index; - - socket.emit('posts.getPostSummaryByIndex', { tid: ajaxify.data.tid, index: index - 1 }, function (err, postData) { - if (err) { - return alerts.error(err); - } - app.parseAndTranslate('partials/topic/navigation-post', { post: postData }, function (html) { - paginationBlockEl - .find('.post-content') - .html(html) - .find('.timeago').timeago(); - }); - - callback(); - }); - } - - function handleKeys() { - if (!config.usePagination) { - $(window).off('keydown', onKeyDown).on('keydown', onKeyDown); - } - } - - function onKeyDown(ev) { - if (ev.target.nodeName === 'BODY') { - if (ev.shiftKey || ev.ctrlKey || ev.altKey) { - return; - } - if (ev.which === 36 && navigator.toTop) { // home key - navigator.toTop(); - return false; - } else if (ev.which === 35 && navigator.toBottom) { // end key - navigator.toBottom(); - return false; - } - } - } - - function generateUrl(index) { - const pathname = window.location.pathname.replace(config.relative_path, ''); - const parts = pathname.split('/'); - return parts[1] + '/' + parts[2] + '/' + parts[3] + (index ? '/' + index : ''); - } - - navigator.getCount = () => count; - - navigator.setCount = function (value) { - value = parseInt(value, 10); - if (value === count) { - return; - } - count = value; - navigator.updateTextAndProgressBar(); - }; - - navigator.show = function () { - toggle(true); - }; - - navigator.disable = function () { - count = 0; - index = 1; - navigator.callback = null; - navigator.selector = null; - $(window).off('scroll', navigator.delayedUpdate); - - toggle(false); - }; - - function toggle(flag) { - const path = ajaxify.removeRelativePath(window.location.pathname.slice(1)); - if (flag && (!path.startsWith('topic') && !path.startsWith('category'))) { - return; - } - - paginationBlockEl.toggleClass('ready', flag); - } - - navigator.delayedUpdate = function () { - if (!navigatorUpdateTimeoutId) { - navigatorUpdateTimeoutId = setTimeout(function () { - navigator.update(); - navigatorUpdateTimeoutId = undefined; - }, 100); - } - }; - - navigator.update = function (threshold) { - /* +define('navigator', ['forum/pagination', 'components', 'hooks', 'alerts'], (pagination, components, hooks, alerts) => { + const navigator = {}; + let index = 0; + let count = 0; + let navigatorUpdateTimeoutId; + + let renderPostIntervalId; + let touchX; + let touchY; + let renderPostIndex; + let isNavigating = false; + let firstMove = true; + + navigator.scrollActive = false; + + let paginationBlockElement = $('.pagination-block'); + let paginationTextElement = paginationBlockElement.find('.pagination-text'); + let paginationBlockMeterElement = paginationBlockElement.find('meter'); + let paginationBlockProgressElement = paginationBlockElement.find('.progress-bar'); + let thumb; + let thumbText; + let thumbIcon; + let thumbIconHeight; + let thumbIconHalfHeight; + + $(window).on('action:ajaxify.start', () => { + $(window).off('keydown', onKeyDown); + }); + + navigator.init = function (selector, count, toTop, toBottom, callback) { + index = 0; + navigator.selector = selector; + navigator.callback = callback; + navigator.toTop = toTop || function () {}; + navigator.toBottom = toBottom || function () {}; + + paginationBlockElement = $('.pagination-block'); + paginationTextElement = paginationBlockElement.find('.pagination-text'); + paginationBlockMeterElement = paginationBlockElement.find('meter'); + paginationBlockProgressElement = paginationBlockElement.find('.progress-bar'); + + thumbIcon = $('.scroller-thumb-icon'); + thumbIconHeight = thumbIcon.height(); + thumbIconHalfHeight = thumbIconHeight / 2; + thumb = $('.scroller-thumb'); + thumbText = thumb.find('.thumb-text'); + + $(window).off('scroll', navigator.delayedUpdate).on('scroll', navigator.delayedUpdate); + + paginationBlockElement.find('.dropdown-menu').off('click').on('click', e => { + e.stopPropagation(); + }); + + paginationBlockElement.off('shown.bs.dropdown', '.wrapper').on('shown.bs.dropdown', '.wrapper', () => { + setTimeout(async () => { + if (utils.findBootstrapEnvironment() === 'lg') { + $('.pagination-block input').focus(); + } + + const postCountInTopic = await socket.emit('topics.getPostCountInTopic', ajaxify.data.tid); + if (postCountInTopic > 0) { + paginationBlockElement.find('#myNextPostBtn').removeAttr('disabled'); + } + }, 100); + }); + paginationBlockElement.find('.pageup').off('click').on('click', navigator.scrollUp); + paginationBlockElement.find('.pagedown').off('click').on('click', navigator.scrollDown); + paginationBlockElement.find('.pagetop').off('click').on('click', navigator.toTop); + paginationBlockElement.find('.pagebottom').off('click').on('click', navigator.toBottom); + paginationBlockElement.find('#myNextPostBtn').off('click').on('click', gotoMyNextPost); + + paginationBlockElement.find('input').on('keydown', function (e) { + if (e.which === 13) { + const input = $(this); + if (!utils.isNumber(input.val())) { + input.val(''); + return; + } + + const index = Number.parseInt(input.val(), 10); + const url = generateUrl(index); + input.val(''); + $('.pagination-block .dropdown-toggle').trigger('click'); + ajaxify.go(url); + } + }); + + if (ajaxify.data.template.topic) { + handleScrollNav(); + } + + handleKeys(); + + navigator.setCount(count); + navigator.update(0); + }; + + let lastNextIndex = 0; + async function gotoMyNextPost() { + async function getNext(startIndex) { + return await socket.emit('topics.getMyNextPostIndex', { + tid: ajaxify.data.tid, + index: Math.max(1, startIndex), + sort: config.topicPostSort, + }); + } + + if (ajaxify.data.template.topic) { + let nextIndex = await getNext(index); + if (lastNextIndex === nextIndex) { // Handles last post in pagination + nextIndex = await getNext(nextIndex); + } + + if (nextIndex && index !== nextIndex + 1) { + lastNextIndex = nextIndex; + $(window).one('action:ajaxify.end', () => { + if (paginationBlockElement.find('.dropdown-menu').is(':hidden')) { + paginationBlockElement.find('.dropdown-toggle').dropdown('toggle'); + } + }); + navigator.scrollToIndex(nextIndex, true, 0); + } else { + alerts.alert({ + message: '[[topic:no-more-next-post]]', + type: 'info', + }); + + lastNextIndex = 1; + } + } + } + + function clampTop(newTop) { + const parent = thumb.parent(); + const parentOffset = parent.offset(); + if (newTop < parentOffset.top) { + newTop = parentOffset.top; + } else if (newTop > parentOffset.top + parent.height() - thumbIconHeight) { + newTop = parentOffset.top + parent.height() - thumbIconHeight; + } + + return newTop; + } + + function setThumbToIndex(index) { + if (thumb.length === 0 || thumb.is(':hidden')) { + return; + } + + const parent = thumb.parent(); + const parentOffset = parent.offset(); + let percent = (index - 1) / ajaxify.data.postcount; + if (index === count) { + percent = 1; + } + + const newTop = clampTop(parentOffset.top + ((parent.height() - thumbIconHeight) * percent)); + + const offset = {top: newTop, left: thumb.offset().left}; + thumb.offset(offset); + thumbText.text(index + '/' + ajaxify.data.postcount); + renderPost(index); + } + + function handleScrollNav() { + if (thumb.length === 0) { + return; + } + + const parent = thumb.parent(); + parent.on('click', event => { + if ($(event.target).hasClass('scroller-container')) { + const index = calculateIndexFromY(event.pageY); + navigator.scrollToIndex(index - 1, true, 0); + return false; + } + }); + + function calculateIndexFromY(y) { + const newTop = clampTop(y - thumbIconHalfHeight); + const parentOffset = parent.offset(); + const percent = (newTop - parentOffset.top) / (parent.height() - thumbIconHeight); + index = Math.max(1, Math.ceil(ajaxify.data.postcount * percent)); + return index > ajaxify.data.postcount ? ajaxify.data.count : index; + } + + let mouseDragging = false; + hooks.on('action:ajaxify.end', () => { + renderPostIndex = null; + }); + $('.pagination-block .dropdown-menu').parent().on('shown.bs.dropdown', () => { + setThumbToIndex(index); + }); + + thumb.on('mousedown', () => { + mouseDragging = true; + $(window).on('mousemove', mousemove); + firstMove = true; + }); + + function mouseup() { + $(window).off('mousemove', mousemove); + if (mouseDragging) { + navigator.scrollToIndex(index - 1, true, 0); + paginationBlockElement.find('[data-toggle="dropdown"]').trigger('click'); + } + + clearRenderInterval(); + mouseDragging = false; + firstMove = false; + } + + function mousemove(event) { + const newTop = clampTop(event.pageY - thumbIconHalfHeight); + thumb.offset({top: newTop, left: thumb.offset().left}); + const index = calculateIndexFromY(event.pageY); + navigator.updateTextAndProgressBar(); + thumbText.text(index + '/' + ajaxify.data.postcount); + if (firstMove) { + delayedRenderPost(); + } + + firstMove = false; + event.stopPropagation(); + return false; + } + + function delayedRenderPost() { + clearRenderInterval(); + renderPostIntervalId = setInterval(() => { + renderPost(index); + }, 250); + } + + $(window).off('mousemove', mousemove); + $(window).off('mouseup', mouseup).on('mouseup', mouseup); + + thumb.on('touchstart', event => { + isNavigating = true; + touchX = Math.min($(window).width(), Math.max(0, event.touches[0].clientX)); + touchY = Math.min($(window).height(), Math.max(0, event.touches[0].clientY)); + firstMove = true; + }); + + thumb.on('touchmove', event => { + const windowWidth = $(window).width(); + const windowHeight = $(window).height(); + const deltaX = Math.abs(touchX - Math.min(windowWidth, Math.max(0, event.touches[0].clientX))); + const deltaY = Math.abs(touchY - Math.min(windowHeight, Math.max(0, event.touches[0].clientY))); + touchX = Math.min(windowWidth, Math.max(0, event.touches[0].clientX)); + touchY = Math.min(windowHeight, Math.max(0, event.touches[0].clientY)); + + if (deltaY >= deltaX && firstMove) { + isNavigating = true; + delayedRenderPost(); + } + + if (isNavigating && event.cancelable) { + event.preventDefault(); + event.stopPropagation(); + const newTop = clampTop(touchY + $(window).scrollTop() - thumbIconHalfHeight); + thumb.offset({top: newTop, left: thumb.offset().left}); + const index = calculateIndexFromY(touchY + $(window).scrollTop()); + navigator.updateTextAndProgressBar(); + thumbText.text(index + '/' + ajaxify.data.postcount); + if (firstMove) { + renderPost(index); + } + } + + firstMove = false; + }); + + thumb.on('touchend', () => { + clearRenderInterval(); + if (isNavigating) { + navigator.scrollToIndex(index - 1, true, 0); + isNavigating = false; + paginationBlockElement.find('[data-toggle="dropdown"]').trigger('click'); + } + }); + } + + function clearRenderInterval() { + if (renderPostIntervalId) { + clearInterval(renderPostIntervalId); + renderPostIntervalId = 0; + } + } + + function renderPost(index, callback) { + callback ||= function () {}; + if (renderPostIndex === index || paginationBlockElement.find('.post-content').is(':hidden')) { + return; + } + + renderPostIndex = index; + + socket.emit('posts.getPostSummaryByIndex', {tid: ajaxify.data.tid, index: index - 1}, (error, postData) => { + if (error) { + return alerts.error(error); + } + + app.parseAndTranslate('partials/topic/navigation-post', {post: postData}, html => { + paginationBlockElement + .find('.post-content') + .html(html) + .find('.timeago').timeago(); + }); + + callback(); + }); + } + + function handleKeys() { + if (!config.usePagination) { + $(window).off('keydown', onKeyDown).on('keydown', onKeyDown); + } + } + + function onKeyDown(event) { + if (event.target.nodeName === 'BODY') { + if (event.shiftKey || event.ctrlKey || event.altKey) { + return; + } + + if (event.which === 36 && navigator.toTop) { // Home key + navigator.toTop(); + return false; + } + + if (event.which === 35 && navigator.toBottom) { // End key + navigator.toBottom(); + return false; + } + } + } + + function generateUrl(index) { + const pathname = window.location.pathname.replace(config.relative_path, ''); + const parts = pathname.split('/'); + return parts[1] + '/' + parts[2] + '/' + parts[3] + (index ? '/' + index : ''); + } + + navigator.getCount = () => count; + + navigator.setCount = function (value) { + value = Number.parseInt(value, 10); + if (value === count) { + return; + } + + count = value; + navigator.updateTextAndProgressBar(); + }; + + navigator.show = function () { + toggle(true); + }; + + navigator.disable = function () { + count = 0; + index = 1; + navigator.callback = null; + navigator.selector = null; + $(window).off('scroll', navigator.delayedUpdate); + + toggle(false); + }; + + function toggle(flag) { + const path = ajaxify.removeRelativePath(window.location.pathname.slice(1)); + if (flag && (!path.startsWith('topic') && !path.startsWith('category'))) { + return; + } + + paginationBlockElement.toggleClass('ready', flag); + } + + navigator.delayedUpdate = function () { + navigatorUpdateTimeoutId ||= setTimeout(() => { + navigator.update(); + navigatorUpdateTimeoutId = undefined; + }, 100); + }; + + navigator.update = function (threshold) { + /* The "threshold" is defined as the distance from the top of the page to a spot where a user is expecting to begin reading. */ - threshold = typeof threshold === 'number' ? threshold : undefined; - let newIndex = index; - const els = $(navigator.selector); - if (els.length) { - newIndex = parseInt(els.first().attr('data-index'), 10) + 1; - } - - const scrollTop = $(window).scrollTop(); - const windowHeight = $(window).height(); - const documentHeight = $(document).height(); - const middleOfViewport = scrollTop + (windowHeight / 2); - let previousDistance = Number.MAX_VALUE; - els.each(function () { - const $this = $(this); - const elIndex = parseInt($this.attr('data-index'), 10); - if (elIndex >= 0) { - const distanceToMiddle = - Math.abs(middleOfViewport - ($this.offset().top + ($this.outerHeight(true) / 2))); - - if (distanceToMiddle > previousDistance) { - return false; - } - - if (distanceToMiddle < previousDistance) { - newIndex = elIndex + 1; - previousDistance = distanceToMiddle; - } - } - }); - - const atTop = scrollTop === 0 && parseInt(els.first().attr('data-index'), 10) === 0; - const nearBottom = scrollTop + windowHeight > documentHeight - 100 && parseInt(els.last().attr('data-index'), 10) === count - 1; - - if (atTop) { - newIndex = 1; - } else if (nearBottom) { - newIndex = count; - } - - // If a threshold is undefined, try to determine one based on new index - if (threshold === undefined && ajaxify.data.template.topic) { - if (atTop) { - threshold = 0; - } else { - const anchorEl = components.get('post/anchor', index - 1); - if (anchorEl.length) { - const anchorRect = anchorEl.get(0).getBoundingClientRect(); - threshold = anchorRect.top; - } - } - } - - if (typeof navigator.callback === 'function') { - navigator.callback(newIndex, count, threshold); - } - - if (newIndex !== index) { - index = newIndex; - navigator.updateTextAndProgressBar(); - setThumbToIndex(index); - } - - toggle(!!count); - }; - - navigator.getIndex = () => index; - - navigator.setIndex = (newIndex) => { - index = newIndex + 1; - navigator.updateTextAndProgressBar(); - setThumbToIndex(index); - }; - - navigator.updateTextAndProgressBar = function () { - if (!utils.isNumber(index)) { - return; - } - index = index > count ? count : index; - paginationTextEl.translateHtml('[[global:pagination.out_of, ' + index + ', ' + count + ']]'); - const fraction = (index - 1) / (count - 1 || 1); - paginationBlockMeterEl.val(fraction); - paginationBlockProgressEl.width((fraction * 100) + '%'); - }; - - navigator.scrollUp = function () { - const $window = $(window); - - if (config.usePagination) { - const atTop = $window.scrollTop() <= 0; - if (atTop) { - return pagination.previousPage(function () { - $('body,html').scrollTop($(document).height() - $window.height()); - }); - } - } - $('body,html').animate({ - scrollTop: $window.scrollTop() - $window.height(), - }); - }; - - navigator.scrollDown = function () { - const $window = $(window); - - if (config.usePagination) { - const atBottom = $window.scrollTop() >= $(document).height() - $window.height(); - if (atBottom) { - return pagination.nextPage(); - } - } - $('body,html').animate({ - scrollTop: $window.scrollTop() + $window.height(), - }); - }; - - navigator.scrollTop = function (index) { - if ($(navigator.selector + '[data-index="' + index + '"]').length) { - navigator.scrollToIndex(index, true); - } else { - ajaxify.go(generateUrl()); - } - }; - - navigator.scrollBottom = function (index) { - if (parseInt(index, 10) < 0) { - return; - } - - if ($(navigator.selector + '[data-index="' + index + '"]').length) { - navigator.scrollToIndex(index, true); - } else { - index = parseInt(index, 10) + 1; - ajaxify.go(generateUrl(index)); - } - }; - - navigator.scrollToIndex = function (index, highlight, duration) { - const inTopic = !!components.get('topic').length; - const inCategory = !!components.get('category').length; - - if (!utils.isNumber(index) || (!inTopic && !inCategory)) { - return; - } - - duration = duration !== undefined ? duration : 400; - navigator.scrollActive = true; - - // if in topic and item already on page - if (inTopic && components.get('post/anchor', index).length) { - return navigator.scrollToPostIndex(index, highlight, duration); - } - - // if in category and item alreay on page - if (inCategory && $('[component="category/topic"][data-index="' + index + '"]').length) { - return navigator.scrollToTopicIndex(index, highlight, duration); - } - - if (!config.usePagination) { - navigator.scrollActive = false; - index = parseInt(index, 10) + 1; - ajaxify.go(generateUrl(index)); - return; - } - - const scrollMethod = inTopic ? navigator.scrollToPostIndex : navigator.scrollToTopicIndex; - - const page = 1 + Math.floor(index / config.postsPerPage); - if (parseInt(page, 10) !== ajaxify.data.pagination.currentPage) { - pagination.loadPage(page, function () { - scrollMethod(index, highlight, duration); - }); - } else { - scrollMethod(index, highlight, duration); - } - }; - - navigator.scrollToPostIndex = function (postIndex, highlight, duration) { - const scrollTo = components.get('post', 'index', postIndex); - navigator.scrollToElement(scrollTo, highlight, duration, postIndex); - }; - - navigator.scrollToTopicIndex = function (topicIndex, highlight, duration) { - const scrollTo = $('[component="category/topic"][data-index="' + topicIndex + '"]'); - navigator.scrollToElement(scrollTo, highlight, duration, topicIndex); - }; - - navigator.scrollToElement = async (scrollTo, highlight, duration, newIndex = null) => { - if (!scrollTo.length) { - navigator.scrollActive = false; - return; - } - - await hooks.fire('filter:navigator.scroll', { scrollTo, highlight, duration, newIndex }); - - const postHeight = scrollTo.outerHeight(true); - const navbarHeight = components.get('navbar').outerHeight(true) || 0; - const topicHeaderHeight = $('.topic-header').outerHeight(true) || 0; - const viewportHeight = $(window).height(); - - // Temporarily disable navigator update on scroll - $(window).off('scroll', navigator.delayedUpdate); - - duration = duration !== undefined ? duration : 400; - navigator.scrollActive = true; - let done = false; - - function animateScroll() { - function reenableScroll() { - // Re-enable onScroll behaviour - setTimeout(() => { // fixes race condition from jQuery — onAnimateComplete called too quickly - $(window).on('scroll', navigator.delayedUpdate); - - hooks.fire('action:navigator.scrolled', { scrollTo, highlight, duration, newIndex }); - }, 50); - } - function onAnimateComplete() { - if (done) { - reenableScroll(); - return; - } - done = true; - - navigator.scrollActive = false; - highlightPost(); - - const scrollToRect = scrollTo.get(0).getBoundingClientRect(); - if (!newIndex) { - navigator.update(scrollToRect.top); - } else { - navigator.setIndex(newIndex); - } - } - - let scrollTop = 0; - if (postHeight < viewportHeight - navbarHeight - topicHeaderHeight) { - scrollTop = scrollTo.offset().top - (viewportHeight / 2) + (postHeight / 2); - } else { - scrollTop = scrollTo.offset().top - navbarHeight - topicHeaderHeight; - } - - if (duration === 0) { - $(window).scrollTop(scrollTop); - onAnimateComplete(); - reenableScroll(); - return; - } - $('html, body').animate({ - scrollTop: scrollTop + 'px', - }, duration, onAnimateComplete); - } - - function highlightPost() { - if (highlight) { - $('[component="post"],[component="category/topic"]').removeClass('highlight'); - scrollTo.addClass('highlight'); - setTimeout(function () { - scrollTo.removeClass('highlight'); - }, 10000); - } - } - - animateScroll(); - }; - - return navigator; + threshold = typeof threshold === 'number' ? threshold : undefined; + let newIndex = index; + const els = $(navigator.selector); + if (els.length > 0) { + newIndex = Number.parseInt(els.first().attr('data-index'), 10) + 1; + } + + const scrollTop = $(window).scrollTop(); + const windowHeight = $(window).height(); + const documentHeight = $(document).height(); + const middleOfViewport = scrollTop + (windowHeight / 2); + let previousDistance = Number.MAX_VALUE; + els.each(function () { + const $this = $(this); + const elementIndex = Number.parseInt($this.attr('data-index'), 10); + if (elementIndex >= 0) { + const distanceToMiddle + = Math.abs(middleOfViewport - ($this.offset().top + ($this.outerHeight(true) / 2))); + + if (distanceToMiddle > previousDistance) { + return false; + } + + if (distanceToMiddle < previousDistance) { + newIndex = elementIndex + 1; + previousDistance = distanceToMiddle; + } + } + }); + + const atTop = scrollTop === 0 && Number.parseInt(els.first().attr('data-index'), 10) === 0; + const nearBottom = scrollTop + windowHeight > documentHeight - 100 && Number.parseInt(els.last().attr('data-index'), 10) === count - 1; + + if (atTop) { + newIndex = 1; + } else if (nearBottom) { + newIndex = count; + } + + // If a threshold is undefined, try to determine one based on new index + if (threshold === undefined && ajaxify.data.template.topic) { + if (atTop) { + threshold = 0; + } else { + const anchorElement = components.get('post/anchor', index - 1); + if (anchorElement.length > 0) { + const anchorRect = anchorElement.get(0).getBoundingClientRect(); + threshold = anchorRect.top; + } + } + } + + if (typeof navigator.callback === 'function') { + navigator.callback(newIndex, count, threshold); + } + + if (newIndex !== index) { + index = newIndex; + navigator.updateTextAndProgressBar(); + setThumbToIndex(index); + } + + toggle(Boolean(count)); + }; + + navigator.getIndex = () => index; + + navigator.setIndex = newIndex => { + index = newIndex + 1; + navigator.updateTextAndProgressBar(); + setThumbToIndex(index); + }; + + navigator.updateTextAndProgressBar = function () { + if (!utils.isNumber(index)) { + return; + } + + index = index > count ? count : index; + paginationTextElement.translateHtml('[[global:pagination.out_of, ' + index + ', ' + count + ']]'); + const fraction = (index - 1) / (count - 1 || 1); + paginationBlockMeterElement.val(fraction); + paginationBlockProgressElement.width((fraction * 100) + '%'); + }; + + navigator.scrollUp = function () { + const $window = $(window); + + if (config.usePagination) { + const atTop = $window.scrollTop() <= 0; + if (atTop) { + return pagination.previousPage(() => { + $('body,html').scrollTop($(document).height() - $window.height()); + }); + } + } + + $('body,html').animate({ + scrollTop: $window.scrollTop() - $window.height(), + }); + }; + + navigator.scrollDown = function () { + const $window = $(window); + + if (config.usePagination) { + const atBottom = $window.scrollTop() >= $(document).height() - $window.height(); + if (atBottom) { + return pagination.nextPage(); + } + } + + $('body,html').animate({ + scrollTop: $window.scrollTop() + $window.height(), + }); + }; + + navigator.scrollTop = function (index) { + if ($(navigator.selector + '[data-index="' + index + '"]').length > 0) { + navigator.scrollToIndex(index, true); + } else { + ajaxify.go(generateUrl()); + } + }; + + navigator.scrollBottom = function (index) { + if (Number.parseInt(index, 10) < 0) { + return; + } + + if ($(navigator.selector + '[data-index="' + index + '"]').length > 0) { + navigator.scrollToIndex(index, true); + } else { + index = Number.parseInt(index, 10) + 1; + ajaxify.go(generateUrl(index)); + } + }; + + navigator.scrollToIndex = function (index, highlight, duration) { + const inTopic = components.get('topic').length > 0; + const inCategory = components.get('category').length > 0; + + if (!utils.isNumber(index) || (!inTopic && !inCategory)) { + return; + } + + duration = duration === undefined ? 400 : duration; + navigator.scrollActive = true; + + // If in topic and item already on page + if (inTopic && components.get('post/anchor', index).length > 0) { + return navigator.scrollToPostIndex(index, highlight, duration); + } + + // If in category and item alreay on page + if (inCategory && $('[component="category/topic"][data-index="' + index + '"]').length > 0) { + return navigator.scrollToTopicIndex(index, highlight, duration); + } + + if (!config.usePagination) { + navigator.scrollActive = false; + index = Number.parseInt(index, 10) + 1; + ajaxify.go(generateUrl(index)); + return; + } + + const scrollMethod = inTopic ? navigator.scrollToPostIndex : navigator.scrollToTopicIndex; + + const page = 1 + Math.floor(index / config.postsPerPage); + if (Number.parseInt(page, 10) === ajaxify.data.pagination.currentPage) { + scrollMethod(index, highlight, duration); + } else { + pagination.loadPage(page, () => { + scrollMethod(index, highlight, duration); + }); + } + }; + + navigator.scrollToPostIndex = function (postIndex, highlight, duration) { + const scrollTo = components.get('post', 'index', postIndex); + navigator.scrollToElement(scrollTo, highlight, duration, postIndex); + }; + + navigator.scrollToTopicIndex = function (topicIndex, highlight, duration) { + const scrollTo = $('[component="category/topic"][data-index="' + topicIndex + '"]'); + navigator.scrollToElement(scrollTo, highlight, duration, topicIndex); + }; + + navigator.scrollToElement = async (scrollTo, highlight, duration, newIndex = null) => { + if (scrollTo.length === 0) { + navigator.scrollActive = false; + return; + } + + await hooks.fire('filter:navigator.scroll', { + scrollTo, highlight, duration, newIndex, + }); + + const postHeight = scrollTo.outerHeight(true); + const navbarHeight = components.get('navbar').outerHeight(true) || 0; + const topicHeaderHeight = $('.topic-header').outerHeight(true) || 0; + const viewportHeight = $(window).height(); + + // Temporarily disable navigator update on scroll + $(window).off('scroll', navigator.delayedUpdate); + + duration = duration === undefined ? 400 : duration; + navigator.scrollActive = true; + let done = false; + + function animateScroll() { + function reenableScroll() { + // Re-enable onScroll behaviour + setTimeout(() => { // Fixes race condition from jQuery — onAnimateComplete called too quickly + $(window).on('scroll', navigator.delayedUpdate); + + hooks.fire('action:navigator.scrolled', { + scrollTo, highlight, duration, newIndex, + }); + }, 50); + } + + function onAnimateComplete() { + if (done) { + reenableScroll(); + return; + } + + done = true; + + navigator.scrollActive = false; + highlightPost(); + + const scrollToRect = scrollTo.get(0).getBoundingClientRect(); + if (newIndex) { + navigator.setIndex(newIndex); + } else { + navigator.update(scrollToRect.top); + } + } + + let scrollTop = 0; + scrollTop = postHeight < viewportHeight - navbarHeight - topicHeaderHeight ? scrollTo.offset().top - (viewportHeight / 2) + (postHeight / 2) : scrollTo.offset().top - navbarHeight - topicHeaderHeight; + + if (duration === 0) { + $(window).scrollTop(scrollTop); + onAnimateComplete(); + reenableScroll(); + return; + } + + $('html, body').animate({ + scrollTop: scrollTop + 'px', + }, duration, onAnimateComplete); + } + + function highlightPost() { + if (highlight) { + $('[component="post"],[component="category/topic"]').removeClass('highlight'); + scrollTo.addClass('highlight'); + setTimeout(() => { + scrollTo.removeClass('highlight'); + }, 10_000); + } + } + + animateScroll(); + }; + + return navigator; }); diff --git a/public/src/modules/notifications.js b/public/src/modules/notifications.js index da512fd..1f5703b 100644 --- a/public/src/modules/notifications.js +++ b/public/src/modules/notifications.js @@ -1,160 +1,162 @@ 'use strict'; - define('notifications', [ - 'translator', - 'components', - 'navigator', - 'tinycon', - 'hooks', - 'alerts', -], function (translator, components, navigator, Tinycon, hooks, alerts) { - const Notifications = {}; - - let unreadNotifs = {}; - - const _addShortTimeagoString = ({ notifications: notifs }) => new Promise((resolve) => { - translator.toggleTimeagoShorthand(function () { - for (let i = 0; i < notifs.length; i += 1) { - notifs[i].timeago = $.timeago(new Date(parseInt(notifs[i].datetime, 10))); - } - translator.toggleTimeagoShorthand(); - resolve({ notifications: notifs }); - }); - }); - hooks.on('filter:notifications.load', _addShortTimeagoString); - - Notifications.loadNotifications = function (notifList, callback) { - callback = callback || function () {}; - socket.emit('notifications.get', null, function (err, data) { - if (err) { - return alerts.error(err); - } - - const notifs = data.unread.concat(data.read).sort(function (a, b) { - return parseInt(a.datetime, 10) > parseInt(b.datetime, 10) ? -1 : 1; - }); - - hooks.fire('filter:notifications.load', { notifications: notifs }).then(({ notifications }) => { - app.parseAndTranslate('partials/notifications_list', { notifications }, function (html) { - notifList.html(html); - notifList.off('click').on('click', '[data-nid]', function (ev) { - const notifEl = $(this); - if (scrollToPostIndexIfOnPage(notifEl)) { - ev.stopPropagation(); - ev.preventDefault(); - components.get('notifications/list').dropdown('toggle'); - } - - const unread = notifEl.hasClass('unread'); - if (!unread) { - return; - } - const nid = notifEl.attr('data-nid'); - markNotification(nid, true); - }); - components.get('notifications').on('click', '.mark-all-read', Notifications.markAllRead); - - notifList.on('click', '.mark-read', function () { - const liEl = $(this).parents('li'); - const unread = liEl.hasClass('unread'); - const nid = liEl.attr('data-nid'); - markNotification(nid, unread, function () { - liEl.toggleClass('unread'); - }); - return false; - }); - - hooks.fire('action:notifications.loaded', { - notifications: notifs, - list: notifList, - }); - callback(); - }); - }); - }); - }; - - Notifications.onNewNotification = function (notifData) { - if (ajaxify.currentPage === 'notifications') { - ajaxify.refresh(); - } - - socket.emit('notifications.getCount', function (err, count) { - if (err) { - return alerts.error(err); - } - - Notifications.updateNotifCount(count); - }); - - if (!unreadNotifs[notifData.nid]) { - unreadNotifs[notifData.nid] = true; - } - }; - - function markNotification(nid, read, callback) { - socket.emit('notifications.mark' + (read ? 'Read' : 'Unread'), nid, function (err) { - if (err) { - return alerts.error(err); - } - - if (read && unreadNotifs[nid]) { - delete unreadNotifs[nid]; - } - if (callback) { - callback(); - } - }); - } - - function scrollToPostIndexIfOnPage(notifEl) { - // Scroll to index if already in topic (gh#5873) - const pid = notifEl.attr('data-pid'); - const path = notifEl.attr('data-path'); - const postEl = components.get('post', 'pid', pid); - if (path.startsWith(config.relative_path + '/post/') && pid && postEl.length && ajaxify.data.template.topic) { - navigator.scrollToIndex(postEl.attr('data-index'), true); - return true; - } - return false; - } - - Notifications.updateNotifCount = function (count) { - const notifIcon = components.get('notifications/icon'); - count = Math.max(0, count); - if (count > 0) { - notifIcon.removeClass('fa-bell-o').addClass('fa-bell'); - } else { - notifIcon.removeClass('fa-bell').addClass('fa-bell-o'); - } - - notifIcon.toggleClass('unread-count', count > 0); - notifIcon.attr('data-content', count > 99 ? '99+' : count); - - const payload = { - count: count, - updateFavicon: true, - }; - hooks.fire('action:notification.updateCount', payload); - - if (payload.updateFavicon) { - Tinycon.setBubble(count > 99 ? '99+' : count); - } - - if (navigator.setAppBadge) { // feature detection - navigator.setAppBadge(count); - } - }; - - Notifications.markAllRead = function () { - socket.emit('notifications.markAllRead', function (err) { - if (err) { - alerts.error(err); - } - unreadNotifs = {}; - }); - }; - - return Notifications; + 'translator', + 'components', + 'navigator', + 'tinycon', + 'hooks', + 'alerts', +], (translator, components, navigator, Tinycon, hooks, alerts) => { + const Notifications = {}; + + let unreadNotifs = {}; + + const _addShortTimeagoString = ({notifications: notifs}) => new Promise(resolve => { + translator.toggleTimeagoShorthand(() => { + for (const notification of notifs) { + notification.timeago = $.timeago(new Date(Number.parseInt(notification.datetime, 10))); + } + + translator.toggleTimeagoShorthand(); + resolve({notifications: notifs}); + }); + }); + hooks.on('filter:notifications.load', _addShortTimeagoString); + + Notifications.loadNotifications = function (notificationList, callback) { + callback ||= function () {}; + socket.emit('notifications.get', null, (error, data) => { + if (error) { + return alerts.error(error); + } + + const notifs = data.unread.concat(data.read).sort((a, b) => Number.parseInt(a.datetime, 10) > Number.parseInt(b.datetime, 10) ? -1 : 1); + + hooks.fire('filter:notifications.load', {notifications: notifs}).then(({notifications}) => { + app.parseAndTranslate('partials/notifications_list', {notifications}, html => { + notificationList.html(html); + notificationList.off('click').on('click', '[data-nid]', function (event) { + const notificationElement = $(this); + if (scrollToPostIndexIfOnPage(notificationElement)) { + event.stopPropagation(); + event.preventDefault(); + components.get('notifications/list').dropdown('toggle'); + } + + const unread = notificationElement.hasClass('unread'); + if (!unread) { + return; + } + + const nid = notificationElement.attr('data-nid'); + markNotification(nid, true); + }); + components.get('notifications').on('click', '.mark-all-read', Notifications.markAllRead); + + notificationList.on('click', '.mark-read', function () { + const liElement = $(this).parents('li'); + const unread = liElement.hasClass('unread'); + const nid = liElement.attr('data-nid'); + markNotification(nid, unread, () => { + liElement.toggleClass('unread'); + }); + return false; + }); + + hooks.fire('action:notifications.loaded', { + notifications: notifs, + list: notificationList, + }); + callback(); + }); + }); + }); + }; + + Notifications.onNewNotification = function (notificationData) { + if (ajaxify.currentPage === 'notifications') { + ajaxify.refresh(); + } + + socket.emit('notifications.getCount', (error, count) => { + if (error) { + return alerts.error(error); + } + + Notifications.updateNotifCount(count); + }); + + if (!unreadNotifs[notificationData.nid]) { + unreadNotifs[notificationData.nid] = true; + } + }; + + function markNotification(nid, read, callback) { + socket.emit('notifications.mark' + (read ? 'Read' : 'Unread'), nid, error => { + if (error) { + return alerts.error(error); + } + + if (read && unreadNotifs[nid]) { + delete unreadNotifs[nid]; + } + + if (callback) { + callback(); + } + }); + } + + function scrollToPostIndexIfOnPage(notificationElement) { + // Scroll to index if already in topic (gh#5873) + const pid = notificationElement.attr('data-pid'); + const path = notificationElement.attr('data-path'); + const postElement = components.get('post', 'pid', pid); + if (path.startsWith(config.relative_path + '/post/') && pid && postElement.length > 0 && ajaxify.data.template.topic) { + navigator.scrollToIndex(postElement.attr('data-index'), true); + return true; + } + + return false; + } + + Notifications.updateNotifCount = function (count) { + const notificationIcon = components.get('notifications/icon'); + count = Math.max(0, count); + if (count > 0) { + notificationIcon.removeClass('fa-bell-o').addClass('fa-bell'); + } else { + notificationIcon.removeClass('fa-bell').addClass('fa-bell-o'); + } + + notificationIcon.toggleClass('unread-count', count > 0); + notificationIcon.attr('data-content', count > 99 ? '99+' : count); + + const payload = { + count, + updateFavicon: true, + }; + hooks.fire('action:notification.updateCount', payload); + + if (payload.updateFavicon) { + Tinycon.setBubble(count > 99 ? '99+' : count); + } + + if (navigator.setAppBadge) { // Feature detection + navigator.setAppBadge(count); + } + }; + + Notifications.markAllRead = function () { + socket.emit('notifications.markAllRead', error => { + if (error) { + alerts.error(error); + } + + unreadNotifs = {}; + }); + }; + + return Notifications; }); diff --git a/public/src/modules/postSelect.js b/public/src/modules/postSelect.js index e1f266d..193e594 100644 --- a/public/src/modules/postSelect.js +++ b/public/src/modules/postSelect.js @@ -1,73 +1,74 @@ 'use strict'; +define('postSelect', ['components'], components => { + const PostSelect = {}; + let onSelect; -define('postSelect', ['components'], function (components) { - const PostSelect = {}; - let onSelect; + PostSelect.pids = []; - PostSelect.pids = []; + let allowMainPostSelect = false; - let allowMainPostSelect = false; + PostSelect.init = function (_onSelect, options) { + PostSelect.pids.length = 0; + onSelect = _onSelect; + options ||= {}; + allowMainPostSelect = options.allowMainPostSelect || false; + $('#content').on('click', '[component="topic"] [component="post"]', onPostClicked); + disableClicksOnPosts(); + }; - PostSelect.init = function (_onSelect, options) { - PostSelect.pids.length = 0; - onSelect = _onSelect; - options = options || {}; - allowMainPostSelect = options.allowMainPostSelect || false; - $('#content').on('click', '[component="topic"] [component="post"]', onPostClicked); - disableClicksOnPosts(); - }; + function onPostClicked(event) { + event.stopPropagation(); + const pidClicked = $(this).attr('data-pid'); + const postEls = $('[component="topic"] [data-pid="' + pidClicked + '"]'); + if (!allowMainPostSelect && Number.parseInt($(this).attr('data-index'), 10) === 0) { + return; + } - function onPostClicked(ev) { - ev.stopPropagation(); - const pidClicked = $(this).attr('data-pid'); - const postEls = $('[component="topic"] [data-pid="' + pidClicked + '"]'); - if (!allowMainPostSelect && parseInt($(this).attr('data-index'), 10) === 0) { - return; - } - PostSelect.togglePostSelection(postEls, pidClicked); - } + PostSelect.togglePostSelection(postEls, pidClicked); + } - PostSelect.disable = function () { - PostSelect.pids.forEach(function (pid) { - components.get('post', 'pid', pid).toggleClass('bg-success', false); - }); + PostSelect.disable = function () { + for (const pid of PostSelect.pids) { + components.get('post', 'pid', pid).toggleClass('bg-success', false); + } - $('#content').off('click', '[component="topic"] [component="post"]', onPostClicked); - enableClicksOnPosts(); - }; + $('#content').off('click', '[component="topic"] [component="post"]', onPostClicked); + enableClicksOnPosts(); + }; - PostSelect.togglePostSelection = function (postEls, pid) { - if (pid) { - const index = PostSelect.pids.indexOf(pid); - if (index === -1) { - PostSelect.pids.push(pid); - postEls.toggleClass('bg-success', true); - } else { - PostSelect.pids.splice(index, 1); - postEls.toggleClass('bg-success', false); - } + PostSelect.togglePostSelection = function (postEls, pid) { + if (pid) { + const index = PostSelect.pids.indexOf(pid); + if (index === -1) { + PostSelect.pids.push(pid); + postEls.toggleClass('bg-success', true); + } else { + PostSelect.pids.splice(index, 1); + postEls.toggleClass('bg-success', false); + } - if (PostSelect.pids.length) { - PostSelect.pids.sort(function (a, b) { return a - b; }); - } - if (typeof onSelect === 'function') { - onSelect(); - } - } - }; + if (PostSelect.pids.length > 0) { + PostSelect.pids.sort((a, b) => a - b); + } - function disableClicks() { - return false; - } + if (typeof onSelect === 'function') { + onSelect(); + } + } + }; - function disableClicksOnPosts() { - $('#content').on('click', '[component="post"] button, [component="post"] a', disableClicks); - } + function disableClicks() { + return false; + } - function enableClicksOnPosts() { - $('#content').off('click', '[component="post"] button, [component="post"] a', disableClicks); - } + function disableClicksOnPosts() { + $('#content').on('click', '[component="post"] button, [component="post"] a', disableClicks); + } - return PostSelect; + function enableClicksOnPosts() { + $('#content').off('click', '[component="post"] button, [component="post"] a', disableClicks); + } + + return PostSelect; }); diff --git a/public/src/modules/scrollStop.js b/public/src/modules/scrollStop.js index 12f2562..98c4645 100644 --- a/public/src/modules/scrollStop.js +++ b/public/src/modules/scrollStop.js @@ -1,6 +1,5 @@ 'use strict'; - /* The point of this library is to enhance(tm) a textarea so that if scrolled, you can only scroll to the top of it and the event doesn't bubble up to @@ -9,23 +8,23 @@ While I'm here, might I say this is a solved issue on Linux? */ -define('scrollStop', function () { - const Module = {}; +define('scrollStop', () => { + const Module = {}; - Module.apply = function (element) { - $(element).on('mousewheel', function (e) { - const scrollTop = this.scrollTop; - const scrollHeight = this.scrollHeight; - const elementHeight = Math.round(this.getBoundingClientRect().height); + Module.apply = function (element) { + $(element).on('mousewheel', function (e) { + const scrollTop = this.scrollTop; + const scrollHeight = this.scrollHeight; + const elementHeight = Math.round(this.getBoundingClientRect().height); - if ( - (e.originalEvent.deltaY < 0 && scrollTop === 0) || // scroll up - (e.originalEvent.deltaY > 0 && (elementHeight + scrollTop) >= scrollHeight) // scroll down - ) { - return false; - } - }); - }; + if ( + (e.originalEvent.deltaY < 0 && scrollTop === 0) // Scroll up + || (e.originalEvent.deltaY > 0 && (elementHeight + scrollTop) >= scrollHeight) // Scroll down + ) { + return false; + } + }); + }; - return Module; + return Module; }); diff --git a/public/src/modules/search.js b/public/src/modules/search.js index b055336..6993cf5 100644 --- a/public/src/modules/search.js +++ b/public/src/modules/search.js @@ -1,345 +1,349 @@ 'use strict'; -define('search', ['translator', 'storage', 'hooks', 'alerts'], function (translator, storage, hooks, alerts) { - const Search = { - current: {}, - }; - - Search.init = function (searchOptions) { - if (!config.searchEnabled) { - return; - } - - searchOptions = searchOptions || { in: config.searchDefaultInQuick || 'titles' }; - const searchButton = $('#search-button'); - const searchFields = $('#search-fields'); - const searchInput = $('#search-fields input'); - const quickSearchContainer = $('#quick-search-container'); - - $('#search-form .advanced-search-link').off('mousedown').on('mousedown', function () { - ajaxify.go('/search'); - }); - - $('#search-form').off('submit').on('submit', function () { - searchInput.blur(); - }); - searchInput.off('blur').on('blur', function dismissSearch() { - setTimeout(function () { - if (!searchInput.is(':focus')) { - searchFields.addClass('hidden'); - searchButton.removeClass('hidden'); - } - }, 200); - }); - searchInput.off('focus'); - - const searchElements = { - inputEl: searchInput, - resultEl: quickSearchContainer, - }; - - Search.enableQuickSearch({ - searchOptions: searchOptions, - searchElements: searchElements, - }); - - searchButton.off('click').on('click', function (e) { - if (!config.loggedIn && !app.user.privileges['search:content']) { - alerts.alert({ - message: '[[error:search-requires-login]]', - timeout: 3000, - }); - ajaxify.go('login'); - return false; - } - e.stopPropagation(); - - Search.showAndFocusInput(); - return false; - }); - - $('#search-form').off('submit').on('submit', function () { - const input = $(this).find('input'); - const data = Search.getSearchPreferences(); - data.term = input.val(); - data.in = searchOptions.in; - hooks.fire('action:search.submit', { - searchOptions: data, - searchElements: searchElements, - }); - Search.query(data, function () { - input.val(''); - }); - - return false; - }); - }; - - Search.enableQuickSearch = function (options) { - if (!config.searchEnabled || !app.user.privileges['search:content']) { - return; - } - - const searchOptions = Object.assign({ in: config.searchDefaultInQuick || 'titles' }, options.searchOptions); - const quickSearchResults = options.searchElements.resultEl; - const inputEl = options.searchElements.inputEl; - let oldValue = inputEl.val(); - const filterCategoryEl = quickSearchResults.find('.filter-category'); - - function updateCategoryFilterName() { - if (ajaxify.data.template.category && ajaxify.data.cid) { - translator.translate('[[search:search-in-category, ' + ajaxify.data.name + ']]', function (translated) { - const name = $('
    ').html(translated).text(); - filterCategoryEl.find('.name').text(name); - }); - } - filterCategoryEl.toggleClass('hidden', !(ajaxify.data.template.category && ajaxify.data.cid)); - } - - function doSearch() { - options.searchOptions = Object.assign({}, searchOptions); - options.searchOptions.term = inputEl.val(); - updateCategoryFilterName(); - - if (ajaxify.data.template.category && ajaxify.data.cid) { - if (filterCategoryEl.find('input[type="checkbox"]').is(':checked')) { - options.searchOptions.categories = [ajaxify.data.cid]; - options.searchOptions.searchChildren = true; - } - } - - quickSearchResults.removeClass('hidden').find('.quick-search-results-container').html(''); - quickSearchResults.find('.loading-indicator').removeClass('hidden'); - hooks.fire('action:search.quick.start', options); - options.searchOptions.searchOnly = 1; - Search.api(options.searchOptions, function (data) { - quickSearchResults.find('.loading-indicator').addClass('hidden'); - if (!data.posts || (options.hideOnNoMatches && !data.posts.length)) { - return quickSearchResults.addClass('hidden').find('.quick-search-results-container').html(''); - } - data.posts.forEach(function (p) { - const text = $('
    ' + p.content + '
    ').text(); - const query = inputEl.val().toLowerCase().replace(/^in:topic-\d+/, ''); - const start = Math.max(0, text.toLowerCase().indexOf(query) - 40); - p.snippet = utils.escapeHTML((start > 0 ? '...' : '') + - text.slice(start, start + 80) + - (text.length - start > 80 ? '...' : '')); - }); - app.parseAndTranslate('partials/quick-search-results', data, function (html) { - if (html.length) { - html.find('.timeago').timeago(); - } - quickSearchResults.toggleClass('hidden', !html.length || !inputEl.is(':focus')) - .find('.quick-search-results-container') - .html(html.length ? html : ''); - const highlightEls = quickSearchResults.find( - '.quick-search-results .quick-search-title, .quick-search-results .snippet' - ); - Search.highlightMatches(options.searchOptions.term, highlightEls); - hooks.fire('action:search.quick.complete', { - data: data, - options: options, - }); - }); - }); - } - - quickSearchResults.find('.filter-category input[type="checkbox"]').on('change', function () { - inputEl.focus(); - doSearch(); - }); - - inputEl.off('keyup').on('keyup', utils.debounce(function () { - if (inputEl.val().length < 3) { - quickSearchResults.addClass('hidden'); - oldValue = inputEl.val(); - return; - } - if (inputEl.val() === oldValue) { - return; - } - oldValue = inputEl.val(); - if (!inputEl.is(':focus')) { - return quickSearchResults.addClass('hidden'); - } - doSearch(); - }, 500)); - - let mousedownOnResults = false; - quickSearchResults.on('mousedown', function () { - $(window).one('mouseup', function () { - quickSearchResults.addClass('hidden'); - }); - mousedownOnResults = true; - }); - inputEl.on('blur', function () { - if (!inputEl.is(':focus') && !mousedownOnResults && !quickSearchResults.hasClass('hidden')) { - quickSearchResults.addClass('hidden'); - } - }); - - let ajaxified = false; - hooks.on('action:ajaxify.end', function () { - if (!ajaxify.isCold()) { - ajaxified = true; - } - }); - - inputEl.on('focus', function () { - mousedownOnResults = false; - const query = inputEl.val(); - oldValue = query; - if (query && quickSearchResults.find('#quick-search-results').children().length) { - updateCategoryFilterName(); - if (ajaxified) { - doSearch(); - ajaxified = false; - } else { - quickSearchResults.removeClass('hidden'); - } - inputEl[0].setSelectionRange( - query.startsWith('in:topic') ? query.indexOf(' ') + 1 : 0, - query.length - ); - } - }); - - inputEl.off('refresh').on('refresh', function () { - doSearch(); - }); - }; - - Search.showAndFocusInput = function () { - $('#search-fields').removeClass('hidden'); - $('#search-button').addClass('hidden'); - $('#search-fields input').focus(); - }; - - Search.query = function (data, callback) { - callback = callback || function () {}; - ajaxify.go('search?' + createQueryString(data)); - callback(); - }; - - Search.api = function (data, callback) { - const apiURL = config.relative_path + '/api/search?' + createQueryString(data); - data.searchOnly = undefined; - const searchURL = config.relative_path + '/search?' + createQueryString(data); - $.get(apiURL, function (result) { - result.url = searchURL; - callback(result); - }); - }; - - function createQueryString(data) { - const searchIn = data.in || 'titles'; - const postedBy = data.by || ''; - let term = data.term.replace(/^[ ?#]*/, ''); - try { - term = encodeURIComponent(term); - } catch (e) { - return alerts.error('[[error:invalid-search-term]]'); - } - - const query = { - term: term, - in: searchIn, - }; - - if (data.matchWords) { - query.matchWords = data.matchWords; - } - - if (postedBy && postedBy.length && (searchIn === 'posts' || searchIn === 'titles' || searchIn === 'titlesposts')) { - query.by = postedBy; - } - - if (data.topicName) { - query.topicName = data.topicName; - } - - if (data.categories && data.categories.length) { - query.categories = data.categories; - if (data.searchChildren) { - query.searchChildren = data.searchChildren; - } - } - - if (data.hasTags && data.hasTags.length) { - query.hasTags = data.hasTags; - } - - if (parseInt(data.replies, 10) > 0) { - query.replies = data.replies; - query.repliesFilter = data.repliesFilter || 'atleast'; - } - - if (data.timeRange) { - query.timeRange = data.timeRange; - query.timeFilter = data.timeFilter || 'newer'; - } - - if (data.sortBy) { - query.sortBy = data.sortBy; - query.sortDirection = data.sortDirection; - } - - if (data.showAs) { - query.showAs = data.showAs; - } - - if (data.searchOnly) { - query.searchOnly = data.searchOnly; - } - - hooks.fire('action:search.createQueryString', { - query: query, - data: data, - }); - - return decodeURIComponent($.param(query)); - } - - Search.getSearchPreferences = function () { - try { - return JSON.parse(storage.getItem('search-preferences') || '{}'); - } catch (e) { - return {}; - } - }; - - Search.highlightMatches = function (searchQuery, els) { - if (!searchQuery || !els.length) { - return; - } - searchQuery = utils.escapeHTML(searchQuery.replace(/^"/, '').replace(/"$/, '').trim()); - const regexStr = searchQuery.split(' ') - .map(function (word) { return utils.escapeRegexChars(word); }) - .join('|'); - const regex = new RegExp('(' + regexStr + ')', 'gi'); - - els.each(function () { - const result = $(this); - const nested = []; - - result.find('*').each(function () { - $(this).after(''); - nested.push($('
    ').append($(this))); - }); - - result.html(result.html().replace(regex, function (match, p1) { - return '' + p1 + ''; - })); - - nested.forEach(function (nestedEl, i) { - result.html(result.html().replace('', function () { - return nestedEl.html(); - })); - }); - }); - - $('.search-result-text').find('img:not(.not-responsive)').addClass('img-responsive'); - }; - - return Search; +define('search', ['translator', 'storage', 'hooks', 'alerts'], (translator, storage, hooks, alerts) => { + const Search = { + current: {}, + }; + + Search.init = function (searchOptions) { + if (!config.searchEnabled) { + return; + } + + searchOptions ||= {in: config.searchDefaultInQuick || 'titles'}; + const searchButton = $('#search-button'); + const searchFields = $('#search-fields'); + const searchInput = $('#search-fields input'); + const quickSearchContainer = $('#quick-search-container'); + + $('#search-form .advanced-search-link').off('mousedown').on('mousedown', () => { + ajaxify.go('/search'); + }); + + $('#search-form').off('submit').on('submit', () => { + searchInput.blur(); + }); + searchInput.off('blur').on('blur', function dismissSearch() { + setTimeout(() => { + if (!searchInput.is(':focus')) { + searchFields.addClass('hidden'); + searchButton.removeClass('hidden'); + } + }, 200); + }); + searchInput.off('focus'); + + const searchElements = { + inputEl: searchInput, + resultEl: quickSearchContainer, + }; + + Search.enableQuickSearch({ + searchOptions, + searchElements, + }); + + searchButton.off('click').on('click', e => { + if (!config.loggedIn && !app.user.privileges['search:content']) { + alerts.alert({ + message: '[[error:search-requires-login]]', + timeout: 3000, + }); + ajaxify.go('login'); + return false; + } + + e.stopPropagation(); + + Search.showAndFocusInput(); + return false; + }); + + $('#search-form').off('submit').on('submit', function () { + const input = $(this).find('input'); + const data = Search.getSearchPreferences(); + data.term = input.val(); + data.in = searchOptions.in; + hooks.fire('action:search.submit', { + searchOptions: data, + searchElements, + }); + Search.query(data, () => { + input.val(''); + }); + + return false; + }); + }; + + Search.enableQuickSearch = function (options) { + if (!config.searchEnabled || !app.user.privileges['search:content']) { + return; + } + + const searchOptions = Object.assign({in: config.searchDefaultInQuick || 'titles'}, options.searchOptions); + const quickSearchResults = options.searchElements.resultEl; + const inputElement = options.searchElements.inputEl; + let oldValue = inputElement.val(); + const filterCategoryElement = quickSearchResults.find('.filter-category'); + + function updateCategoryFilterName() { + if (ajaxify.data.template.category && ajaxify.data.cid) { + translator.translate('[[search:search-in-category, ' + ajaxify.data.name + ']]', translated => { + const name = $('
    ').html(translated).text(); + filterCategoryElement.find('.name').text(name); + }); + } + + filterCategoryElement.toggleClass('hidden', !(ajaxify.data.template.category && ajaxify.data.cid)); + } + + function doSearch() { + options.searchOptions = Object.assign({}, searchOptions); + options.searchOptions.term = inputElement.val(); + updateCategoryFilterName(); + + if (ajaxify.data.template.category && ajaxify.data.cid && filterCategoryElement.find('input[type="checkbox"]').is(':checked')) { + options.searchOptions.categories = [ajaxify.data.cid]; + options.searchOptions.searchChildren = true; + } + + quickSearchResults.removeClass('hidden').find('.quick-search-results-container').html(''); + quickSearchResults.find('.loading-indicator').removeClass('hidden'); + hooks.fire('action:search.quick.start', options); + options.searchOptions.searchOnly = 1; + Search.api(options.searchOptions, data => { + quickSearchResults.find('.loading-indicator').addClass('hidden'); + if (!data.posts || (options.hideOnNoMatches && data.posts.length === 0)) { + return quickSearchResults.addClass('hidden').find('.quick-search-results-container').html(''); + } + + for (const p of data.posts) { + const text = $('
    ' + p.content + '
    ').text(); + const query = inputElement.val().toLowerCase().replace(/^in:topic-\d+/, ''); + const start = Math.max(0, text.toLowerCase().indexOf(query) - 40); + p.snippet = utils.escapeHTML((start > 0 ? '...' : '') + + text.slice(start, start + 80) + + (text.length - start > 80 ? '...' : '')); + } + + app.parseAndTranslate('partials/quick-search-results', data, html => { + if (html.length > 0) { + html.find('.timeago').timeago(); + } + + quickSearchResults.toggleClass('hidden', html.length === 0 || !inputElement.is(':focus')) + .find('.quick-search-results-container') + .html(html.length > 0 ? html : ''); + const highlightEls = quickSearchResults.find( + '.quick-search-results .quick-search-title, .quick-search-results .snippet', + ); + Search.highlightMatches(options.searchOptions.term, highlightEls); + hooks.fire('action:search.quick.complete', { + data, + options, + }); + }); + }); + } + + quickSearchResults.find('.filter-category input[type="checkbox"]').on('change', () => { + inputElement.focus(); + doSearch(); + }); + + inputElement.off('keyup').on('keyup', utils.debounce(() => { + if (inputElement.val().length < 3) { + quickSearchResults.addClass('hidden'); + oldValue = inputElement.val(); + return; + } + + if (inputElement.val() === oldValue) { + return; + } + + oldValue = inputElement.val(); + if (!inputElement.is(':focus')) { + return quickSearchResults.addClass('hidden'); + } + + doSearch(); + }, 500)); + + let mousedownOnResults = false; + quickSearchResults.on('mousedown', () => { + $(window).one('mouseup', () => { + quickSearchResults.addClass('hidden'); + }); + mousedownOnResults = true; + }); + inputElement.on('blur', () => { + if (!inputElement.is(':focus') && !mousedownOnResults && !quickSearchResults.hasClass('hidden')) { + quickSearchResults.addClass('hidden'); + } + }); + + let ajaxified = false; + hooks.on('action:ajaxify.end', () => { + if (!ajaxify.isCold()) { + ajaxified = true; + } + }); + + inputElement.on('focus', () => { + mousedownOnResults = false; + const query = inputElement.val(); + oldValue = query; + if (query && quickSearchResults.find('#quick-search-results').children().length > 0) { + updateCategoryFilterName(); + if (ajaxified) { + doSearch(); + ajaxified = false; + } else { + quickSearchResults.removeClass('hidden'); + } + + inputElement[0].setSelectionRange( + query.startsWith('in:topic') ? query.indexOf(' ') + 1 : 0, + query.length, + ); + } + }); + + inputElement.off('refresh').on('refresh', () => { + doSearch(); + }); + }; + + Search.showAndFocusInput = function () { + $('#search-fields').removeClass('hidden'); + $('#search-button').addClass('hidden'); + $('#search-fields input').focus(); + }; + + Search.query = function (data, callback) { + callback ||= function () {}; + ajaxify.go('search?' + createQueryString(data)); + callback(); + }; + + Search.api = function (data, callback) { + const apiURL = config.relative_path + '/api/search?' + createQueryString(data); + data.searchOnly = undefined; + const searchURL = config.relative_path + '/search?' + createQueryString(data); + $.get(apiURL, result => { + result.url = searchURL; + callback(result); + }); + }; + + function createQueryString(data) { + const searchIn = data.in || 'titles'; + const postedBy = data.by || ''; + let term = data.term.replace(/^[ ?#]*/, ''); + try { + term = encodeURIComponent(term); + } catch { + return alerts.error('[[error:invalid-search-term]]'); + } + + const query = { + term, + in: searchIn, + }; + + if (data.matchWords) { + query.matchWords = data.matchWords; + } + + if (postedBy && postedBy.length > 0 && (searchIn === 'posts' || searchIn === 'titles' || searchIn === 'titlesposts')) { + query.by = postedBy; + } + + if (data.topicName) { + query.topicName = data.topicName; + } + + if (data.categories && data.categories.length > 0) { + query.categories = data.categories; + if (data.searchChildren) { + query.searchChildren = data.searchChildren; + } + } + + if (data.hasTags && data.hasTags.length > 0) { + query.hasTags = data.hasTags; + } + + if (Number.parseInt(data.replies, 10) > 0) { + query.replies = data.replies; + query.repliesFilter = data.repliesFilter || 'atleast'; + } + + if (data.timeRange) { + query.timeRange = data.timeRange; + query.timeFilter = data.timeFilter || 'newer'; + } + + if (data.sortBy) { + query.sortBy = data.sortBy; + query.sortDirection = data.sortDirection; + } + + if (data.showAs) { + query.showAs = data.showAs; + } + + if (data.searchOnly) { + query.searchOnly = data.searchOnly; + } + + hooks.fire('action:search.createQueryString', { + query, + data, + }); + + return decodeURIComponent($.param(query)); + } + + Search.getSearchPreferences = function () { + try { + return JSON.parse(storage.getItem('search-preferences') || '{}'); + } catch { + return {}; + } + }; + + Search.highlightMatches = function (searchQuery, els) { + if (!searchQuery || els.length === 0) { + return; + } + + searchQuery = utils.escapeHTML(searchQuery.replace(/^"/, '').replace(/"$/, '').trim()); + const regexString = searchQuery.split(' ') + .map(word => utils.escapeRegexChars(word)) + .join('|'); + const regex = new RegExp('(' + regexString + ')', 'gi'); + + els.each(function () { + const result = $(this); + const nested = []; + + result.find('*').each(function () { + $(this).after(''); + nested.push($('
    ').append($(this))); + }); + + result.html(result.html().replace(regex, (match, p1) => '' + p1 + '')); + + for (const [i, nestedElement] of nested.entries()) { + result.html(result.html().replace('', () => nestedElement.html())); + } + }); + + $('.search-result-text').find('img:not(.not-responsive)').addClass('img-responsive'); + }; + + return Search; }); diff --git a/public/src/modules/settings.js b/public/src/modules/settings.js index 166ab5c..77f2cde 100644 --- a/public/src/modules/settings.js +++ b/public/src/modules/settings.js @@ -1,374 +1,395 @@ 'use strict'; +define('settings', ['hooks', 'alerts'], (hooks, alerts) => { + // eslint-disable-next-line prefer-const + let Settings; + let onReady = []; + let waitingJobs = 0; -define('settings', ['hooks', 'alerts'], function (hooks, alerts) { - // eslint-disable-next-line prefer-const - let Settings; - let onReady = []; - let waitingJobs = 0; - // eslint-disable-next-line prefer-const - let helper; + let helper; - /** + /** Returns the hook of given name that matches the given type or element. @param type The type of the element to get the matching hook for, or the element itself. @param name The name of the hook. */ - function getHook(type, name) { - if (typeof type !== 'string') { - type = $(type); - type = type.data('type') || type.attr('type') || type.prop('tagName'); - } - const plugin = Settings.plugins[type.toLowerCase()]; - if (plugin == null) { - return; - } - const hook = plugin[name]; - if (typeof hook === 'function') { - return hook; - } - return null; - } - - // eslint-disable-next-line prefer-const - helper = { - /** + function getHook(type, name) { + if (typeof type !== 'string') { + type = $(type); + type = type.data('type') || type.attr('type') || type.prop('tagName'); + } + + const plugin = Settings.plugins[type.toLowerCase()]; + if (plugin == null) { + return; + } + + const hook = plugin[name]; + if (typeof hook === 'function') { + return hook; + } + + return null; + } + + // eslint-disable-next-line prefer-const + helper = { + /** @returns Object A deep clone of the given object. */ - deepClone: function (obj) { - if (typeof obj === 'object') { - return JSON.parse(JSON.stringify(obj)); - } - return obj; - }, - /** + deepClone(object) { + if (typeof object === 'object') { + return JSON.parse(JSON.stringify(object)); + } + + return object; + }, + /** Creates a new Element with given data. @param tagName The tag-name of the element to create. @param data The attributes to set. @param text The text to add into the element. @returns HTMLElement The created element. */ - createElement: function (tagName, data, text) { - const element = document.createElement(tagName); - for (const k in data) { - if (data.hasOwnProperty(k)) { - element.setAttribute(k, data[k]); - } - } - if (text) { - element.appendChild(document.createTextNode(text)); - } - return element; - }, - /** + createElement(tagName, data, text) { + const element = document.createElement(tagName); + for (const k in data) { + if (data.hasOwnProperty(k)) { + element.setAttribute(k, data[k]); + } + } + + if (text) { + element.append(document.createTextNode(text)); + } + + return element; + }, + /** Calls the init-hook of the given element. @param element The element to initialize. */ - initElement: function (element) { - const hook = getHook(element, 'init'); - if (hook != null) { - hook.call(Settings, $(element)); - } - }, - /** + initElement(element) { + const hook = getHook(element, 'init'); + if (hook != null) { + hook.call(Settings, $(element)); + } + }, + /** Calls the destruct-hook of the given element. @param element The element to destruct. */ - destructElement: function (element) { - const hook = getHook(element, 'destruct'); - if (hook != null) { - hook.call(Settings, $(element)); - } - }, - /** + destructElement(element) { + const hook = getHook(element, 'destruct'); + if (hook != null) { + hook.call(Settings, $(element)); + } + }, + /** Creates and initializes a new element. @param type The type of the new element. @param tagName The tag-name of the new element. @param data The data to forward to create-hook or use as attributes. @returns JQuery The created element. */ - createElementOfType: function (type, tagName, data) { - let element; - const hook = getHook(type, 'create'); - if (hook != null) { - element = $(hook.call(Settings, type, tagName, data)); - } else { - if (data == null) { - data = {}; - } - if (type != null) { - data.type = type; - } - element = $(helper.createElement(tagName || 'input', data)); - } - element.data('type', type); - helper.initElement(element); - return element; - }, - /** + createElementOfType(type, tagName, data) { + let element; + const hook = getHook(type, 'create'); + if (hook == null) { + data ??= {}; + + if (type != null) { + data.type = type; + } + + element = $(helper.createElement(tagName || 'input', data)); + } else { + element = $(hook.call(Settings, type, tagName, data)); + } + + element.data('type', type); + helper.initElement(element); + return element; + }, + /** Creates a new Array that contains values of given Array depending on trim and empty. @param array The array to clean. @param trim Whether to trim each value if it has a trim-function. @param empty Whether empty values should get added. @returns Array The filtered and/or modified Array. */ - cleanArray: function (array, trim, empty) { - const cleaned = []; - if (!trim && empty) { - return array; - } - for (let i = 0; i < array.length; i += 1) { - let value = array[i]; - if (trim) { - if (value === !!value) { - value = +value; - } else if (value && typeof value.trim === 'function') { - value = value.trim(); - } - } - if (empty || (value != null && value.length)) { - cleaned.push(value); - } - } - return cleaned; - }, - isTrue: function (value) { - return value === 'true' || +value === 1; - }, - isFalse: function (value) { - return value === 'false' || +value === 0; - }, - /** + cleanArray(array, trim, empty) { + const cleaned = []; + if (!trim && empty) { + return array; + } + + for (let value of array) { + if (trim) { + if (value === Boolean(value)) { + value = Number(value); + } else if (value && typeof value.trim === 'function') { + value = value.trim(); + } + } + + if (empty || (value != null && value.length > 0)) { + cleaned.push(value); + } + } + + return cleaned; + }, + isTrue(value) { + return value === 'true' || Number(value) === 1; + }, + isFalse(value) { + return value === 'false' || Number(value) === 0; + }, + /** Calls the get-hook of the given element and returns its result. If no hook is specified it gets treated as input-field. @param element The element of that the value should get read. @returns Object The value of the element. */ - readValue: function (element) { - let empty = !helper.isFalse(element.data('empty')); - const trim = !helper.isFalse(element.data('trim')); - const split = element.data('split'); - const hook = getHook(element, 'get'); - let value; - if (hook != null) { - return hook.call(Settings, element, trim, empty); - } - if (split != null) { - empty = helper.isTrue(element.data('empty')); // default empty-value is false for arrays - value = element.val(); - const array = (value != null && value.split(split || ',')) || []; - return helper.cleanArray(array, trim, empty); - } - value = element.val(); - if (trim && value != null && typeof value.trim === 'function') { - value = value.trim(); - } - if (empty || (value !== undefined && (value == null || value.length !== 0))) { - return value; - } - }, - /** + readValue(element) { + let empty = !helper.isFalse(element.data('empty')); + const trim = !helper.isFalse(element.data('trim')); + const split = element.data('split'); + const hook = getHook(element, 'get'); + let value; + if (hook != null) { + return hook.call(Settings, element, trim, empty); + } + + if (split != null) { + empty = helper.isTrue(element.data('empty')); // Default empty-value is false for arrays + value = element.val(); + const array = (value != null && value.split(split || ',')) || []; + return helper.cleanArray(array, trim, empty); + } + + value = element.val(); + if (trim && value != null && typeof value.trim === 'function') { + value = value.trim(); + } + + if (empty || (value !== undefined && (value == null || value.length > 0))) { + return value; + } + }, + /** Calls the set-hook of the given element. If no hook is specified it gets treated as input-field. @param element The JQuery-Object of the element to fill. @param value The value to set. */ - fillField: function (element, value) { - const hook = getHook(element, 'set'); - let trim = element.data('trim'); - trim = trim !== 'false' && +trim !== 0; - if (hook != null) { - return hook.call(Settings, element, value, trim); - } - if (value instanceof Array) { - value = value.join(element.data('split') || (trim ? ', ' : ',')); - } - if (trim && value && typeof value.trim === 'function') { - value = value.trim(); - if (typeof value.toString === 'function') { - value = value.toString(); - } - } else if (value != null) { - if (typeof value.toString === 'function') { - value = value.toString(); - } - if (trim) { - value = value.trim(); - } - } else { - value = ''; - } - if (value !== undefined) { - element.val(value); - } - }, - /** + fillField(element, value) { + const hook = getHook(element, 'set'); + let trim = element.data('trim'); + trim = trim !== 'false' && Number(trim) !== 0; + if (hook != null) { + return hook.call(Settings, element, value, trim); + } + + if (Array.isArray(value)) { + value = value.join(element.data('split') || (trim ? ', ' : ',')); + } + + if (trim && value && typeof value.trim === 'function') { + value = value.trim(); + if (typeof value.toString === 'function') { + value = value.toString(); + } + } else if (value == null) { + value = ''; + } else { + if (typeof value.toString === 'function') { + value = value.toString(); + } + + if (trim) { + value = value.trim(); + } + } + + if (value !== undefined) { + element.val(value); + } + }, + /** Calls the init-hook and {@link helper.fillField} on each field within wrapper-object. @param wrapper The wrapper-element to set settings within. */ - initFields: function (wrapper) { - $('[data-key]', wrapper).each(function (ignored, field) { - field = $(field); - const hook = getHook(field, 'init'); - const keyParts = field.data('key').split('.'); - let value = Settings.get(); - if (hook != null) { - hook.call(Settings, field); - } - for (let i = 0; i < keyParts.length; i += 1) { - const part = keyParts[i]; - if (part && value != null) { - value = value[part]; - } - } - helper.fillField(field, value); - }); - }, - /** + initFields(wrapper) { + $('[data-key]', wrapper).each((ignored, field) => { + field = $(field); + const hook = getHook(field, 'init'); + const keyParts = field.data('key').split('.'); + let value = Settings.get(); + if (hook != null) { + hook.call(Settings, field); + } + + for (const part of keyParts) { + if (part && value != null) { + value = value[part]; + } + } + + helper.fillField(field, value); + }); + }, + /** Increases the amount of jobs before settings are ready by given amount. @param amount The amount of jobs to register. */ - registerReadyJobs: function (amount) { - waitingJobs += amount; - return waitingJobs; - }, - /** + registerReadyJobs(amount) { + waitingJobs += amount; + return waitingJobs; + }, + /** Decreases the amount of jobs before settings are ready by given amount or 1. If the amount is less or equal 0 all callbacks registered by {@link helper.whenReady} get called. @param amount The amount of jobs that finished. */ - beforeReadyJobsDecreased: function (amount) { - if (amount == null) { - amount = 1; - } - if (waitingJobs > 0) { - waitingJobs -= amount; - if (waitingJobs <= 0) { - for (let i = 0; i < onReady.length; i += 1) { - onReady[i](); - } - onReady = []; - } - } - }, - /** + beforeReadyJobsDecreased(amount) { + amount ??= 1; + + if (waitingJobs > 0) { + waitingJobs -= amount; + if (waitingJobs <= 0) { + for (const element of onReady) { + element(); + } + + onReady = []; + } + } + }, + /** Calls the given callback when the settings are ready. @param callback The callback. */ - whenReady: function (callback) { - if (waitingJobs <= 0) { - callback(); - } else { - onReady.push(callback); - } - }, - serializeForm: function (formEl) { - const values = formEl.serializeObject(); - - // "Fix" checkbox values, so that unchecked options are not omitted - formEl.find('input[type="checkbox"]').each(function (idx, inputEl) { - inputEl = $(inputEl); - if (!inputEl.is(':checked')) { - values[inputEl.attr('name')] = 'off'; - } - }); - - // save multiple selects as json arrays - formEl.find('select[multiple]').each(function (idx, selectEl) { - selectEl = $(selectEl); - values[selectEl.attr('name')] = JSON.stringify(selectEl.val()); - }); - - return values; - }, - /** + whenReady(callback) { + if (waitingJobs <= 0) { + callback(); + } else { + onReady.push(callback); + } + }, + serializeForm(formElement) { + const values = formElement.serializeObject(); + + // "Fix" checkbox values, so that unchecked options are not omitted + formElement.find('input[type="checkbox"]').each((index, inputElement) => { + inputElement = $(inputElement); + if (!inputElement.is(':checked')) { + values[inputElement.attr('name')] = 'off'; + } + }); + + // Save multiple selects as json arrays + formElement.find('select[multiple]').each((index, selectElement) => { + selectElement = $(selectElement); + values[selectElement.attr('name')] = JSON.stringify(selectElement.val()); + }); + + return values; + }, + /** Persists the given settings with given hash. @param hash The hash to use as settings-id. @param settings The settings-object to persist. @param notify Whether to send notification when settings got saved. @param callback The callback to call when done. */ - persistSettings: function (hash, settings, notify, callback) { - if (settings != null && settings._ != null && typeof settings._ !== 'string') { - settings = helper.deepClone(settings); - settings._ = JSON.stringify(settings._); - } - socket.emit('admin.settings.set', { - hash: hash, - values: settings, - }, function (err) { - if (notify) { - if (err) { - alerts.alert({ - title: '[[admin/admin:changes-not-saved]]', - type: 'danger', - message: `[[admin/admin/changes-not-saved-message, ${err.message}]]`, - timeout: 5000, - }); - } else { - alerts.alert({ - title: '[[admin/admin:changes-saved]]', - type: 'success', - message: '[[admin/admin:changes-saved-message]]', - timeout: 2500, - }); - } - } - if (typeof callback === 'function') { - callback(err); - } - }); - }, - /** + persistSettings(hash, settings, notify, callback) { + if (settings != null && settings._ != null && typeof settings._ !== 'string') { + settings = helper.deepClone(settings); + settings._ = JSON.stringify(settings._); + } + + socket.emit('admin.settings.set', { + hash, + values: settings, + }, error => { + if (notify) { + if (error) { + alerts.alert({ + title: '[[admin/admin:changes-not-saved]]', + type: 'danger', + message: `[[admin/admin/changes-not-saved-message, ${error.message}]]`, + timeout: 5000, + }); + } else { + alerts.alert({ + title: '[[admin/admin:changes-saved]]', + type: 'success', + message: '[[admin/admin:changes-saved-message]]', + timeout: 2500, + }); + } + } + + if (typeof callback === 'function') { + callback(error); + } + }); + }, + /** Sets the settings to use to given settings. @param settings The settings to use. */ - use: function (settings) { - try { - settings._ = JSON.parse(settings._); - } catch (_error) {} - Settings.cfg = settings; - }, - }; - - // eslint-disable-next-line prefer-const - Settings = { - helper: helper, - plugins: {}, - cfg: {}, - - /** + use(settings) { + try { + settings._ = JSON.parse(settings._); + } catch {} + + Settings.cfg = settings; + }, + }; + + Settings = { + helper, + plugins: {}, + cfg: {}, + + /** Returns the saved settings. @returns Object The settings. */ - get: function () { - if (Settings.cfg != null && Settings.cfg._ !== undefined) { - return Settings.cfg._; - } - return Settings.cfg; - }, - /** + get() { + if (Settings.cfg != null && Settings.cfg._ !== undefined) { + return Settings.cfg._; + } + + return Settings.cfg; + }, + /** Registers a new plugin and calls its use-hook. @param service The plugin to register. @param types The types to bind the plugin to. */ - registerPlugin: function (service, types) { - if (types == null) { - types = service.types; - } else { - service.types = types; - } - if (typeof service.use === 'function') { - service.use.call(Settings); - } - for (let i = 0; i < types.length; i += 1) { - const type = types[i].toLowerCase(); - if (Settings.plugins[type] == null) { - Settings.plugins[type] = service; - } - } - }, - /** + registerPlugin(service, types) { + if (types == null) { + types = service.types; + } else { + service.types = types; + } + + if (typeof service.use === 'function') { + service.use.call(Settings); + } + + for (const type_ of types) { + const type = type_.toLowerCase(); + if (Settings.plugins[type] == null) { + Settings.plugins[type] = service; + } + } + }, + /** Sets the settings to given ones, resets the fields within given wrapper and saves the settings server-side. @param hash The hash to use as settings-id. @param settings The settings to set. @@ -376,234 +397,234 @@ define('settings', ['hooks', 'alerts'], function (hooks, alerts) { @param callback The callback to call when done. @param notify Whether to send notification when settings got saved. */ - set: function (hash, settings, wrapper, callback, notify) { - if (notify == null) { - notify = true; - } - helper.whenReady(function () { - helper.use(settings); - helper.initFields(wrapper || 'form'); - helper.persistSettings(hash, settings, notify, callback); - }); - }, - /** + set(hash, settings, wrapper, callback, notify) { + notify ??= true; + + helper.whenReady(() => { + helper.use(settings); + helper.initFields(wrapper || 'form'); + helper.persistSettings(hash, settings, notify, callback); + }); + }, + /** Fetches the settings from server and calls {@link Settings.helper.initFields} once the settings are ready. @param hash The hash to use as settings-id. @param wrapper The wrapper-element to set settings within. @param callback The callback to call when done. */ - sync: function (hash, wrapper, callback) { - socket.emit('admin.settings.get', { - hash: hash, - }, function (err, values) { - if (err) { - if (typeof callback === 'function') { - callback(err); - } - } else { - helper.whenReady(function () { - helper.use(values); - helper.initFields(wrapper || 'form'); - if (typeof callback === 'function') { - callback(); - } - }); - } - }); - }, - /** + sync(hash, wrapper, callback) { + socket.emit('admin.settings.get', { + hash, + }, (error, values) => { + if (error) { + if (typeof callback === 'function') { + callback(error); + } + } else { + helper.whenReady(() => { + helper.use(values); + helper.initFields(wrapper || 'form'); + if (typeof callback === 'function') { + callback(); + } + }); + } + }); + }, + /** Reads the settings from fields and saves them server-side. @param hash The hash to use as settings-id. @param wrapper The wrapper-element to find settings within. @param callback The callback to call when done. @param notify Whether to send notification when settings got saved. */ - persist: function (hash, wrapper, callback, notify) { - const notSaved = []; - const fields = $('[data-key]', wrapper || 'form').toArray(); - if (notify == null) { - notify = true; - } - for (let i = 0; i < fields.length; i += 1) { - const field = $(fields[i]); - const value = helper.readValue(field); - let parentCfg = Settings.get(); - const keyParts = field.data('key').split('.'); - const lastKey = keyParts[keyParts.length - 1]; - if (keyParts.length > 1) { - for (let j = 0; j < keyParts.length - 1; j += 1) { - const part = keyParts[j]; - if (part && parentCfg != null) { - parentCfg = parentCfg[part]; - } - } - } - if (parentCfg != null) { - if (value != null) { - parentCfg[lastKey] = value; - } else { - delete parentCfg[lastKey]; - } - } else { - notSaved.push(field.data('key')); - } - } - if (notSaved.length) { - alerts.alert({ - title: 'Attributes Not Saved', - message: "'" + (notSaved.join(', ')) + "' could not be saved. Please contact the plugin-author!", - type: 'danger', - timeout: 5000, - }); - } - helper.persistSettings(hash, Settings.cfg, notify, callback); - }, - load: function (hash, formEl, callback) { - callback = callback || function () {}; - const call = formEl.attr('data-socket-get'); - - socket.emit(call || 'admin.settings.get', { - hash: hash, - }, function (err, values) { - if (err) { - return callback(err); - } - // multipe selects are saved as json arrays, parse them here - $(formEl).find('select[multiple]').each(function (idx, selectEl) { - const key = $(selectEl).attr('name'); - if (key && values.hasOwnProperty(key)) { - try { - values[key] = JSON.parse(values[key]); - } catch (e) { - // Leave the value as is - } - } - }); - - // Save loaded settings into ajaxify.data for use client-side - ajaxify.data[call ? hash : 'settings'] = values; - - helper.whenReady(function () { - $(formEl).find('[data-sorted-list]').each(function (idx, el) { - getHook(el, 'get').call(Settings, $(el), hash); - }); - }); - - $(formEl).deserialize(values); - $(formEl).find('input[type="checkbox"]').each(function () { - $(this).parents('.mdl-switch').toggleClass('is-checked', $(this).is(':checked')); - }); - hooks.fire('action:admin.settingsLoaded'); - - // Handle unsaved changes - $(formEl).on('change', 'input, select, textarea', function () { - app.flags = app.flags || {}; - app.flags._unsaved = true; - }); - - const saveEl = document.getElementById('save'); - if (saveEl) { - require(['mousetrap'], function (mousetrap) { - mousetrap.bind('ctrl+s', function (ev) { - saveEl.click(); - ev.preventDefault(); - }); - }); - } - - callback(null, values); - }); - }, - save: function (hash, formEl, callback) { - formEl = $(formEl); - - const controls = formEl.get(0).elements; - const ok = Settings.check(controls); - if (!ok) { - return; - } - - if (formEl.length) { - const values = helper.serializeForm(formEl); - - helper.whenReady(function () { - const list = formEl.find('[data-sorted-list]'); - if (list.length) { - list.each((idx, item) => { - getHook(item, 'set').call(Settings, $(item), values); - }); - } - }); - - const call = formEl.attr('data-socket-set'); - socket.emit(call || 'admin.settings.set', { - hash: hash, - values: values, - }, function (err) { - // Remove unsaved flag to re-enable ajaxify - app.flags._unsaved = false; - - // Also save to local ajaxify.data - ajaxify.data[call ? hash : 'settings'] = values; - - if (typeof callback === 'function') { - callback(err); - } else if (err) { - alerts.alert({ - title: '[[admin/admin:changes-not-saved]]', - message: `[[admin/admin:changes-not-saved-message, ${err.message}]]`, - type: 'error', - timeout: 2500, - }); - } else { - alerts.alert({ - title: '[[admin/admin:changes-saved]]', - type: 'success', - timeout: 2500, - }); - } - }); - } - }, - check: function (controls) { - const onTrigger = (e) => { - const wrapper = e.target.closest('.form-group'); - if (wrapper) { - wrapper.classList.add('has-error'); - } - - e.target.removeEventListener('invalid', onTrigger); - }; - - return Array.prototype.map.call(controls, (controlEl) => { - const wrapper = controlEl.closest('.form-group'); - if (wrapper) { - wrapper.classList.remove('has-error'); - } - - controlEl.addEventListener('invalid', onTrigger); - return controlEl.reportValidity(); - }).every(Boolean); - }, - }; - - - helper.registerReadyJobs(1); - require([ - 'settings/checkbox', - 'settings/number', - 'settings/textarea', - 'settings/select', - 'settings/array', - 'settings/key', - 'settings/object', - 'settings/sorted-list', - ], function () { - for (let i = 0; i < arguments.length; i += 1) { - Settings.registerPlugin(arguments[i]); - } - helper.beforeReadyJobsDecreased(); - }); - - return Settings; + persist(hash, wrapper, callback, notify) { + const notSaved = []; + const fields = $('[data-key]', wrapper || 'form').toArray(); + notify ??= true; + + for (const field_ of fields) { + const field = $(field_); + const value = helper.readValue(field); + let parentCfg = Settings.get(); + const keyParts = field.data('key').split('.'); + const lastKey = keyParts.at(-1); + if (keyParts.length > 1) { + for (let index = 0; index < keyParts.length - 1; index += 1) { + const part = keyParts[index]; + if (part && parentCfg != null) { + parentCfg = parentCfg[part]; + } + } + } + + if (parentCfg == null) { + notSaved.push(field.data('key')); + } else if (value == null) { + delete parentCfg[lastKey]; + } else { + parentCfg[lastKey] = value; + } + } + + if (notSaved.length > 0) { + alerts.alert({ + title: 'Attributes Not Saved', + message: '\'' + (notSaved.join(', ')) + '\' could not be saved. Please contact the plugin-author!', + type: 'danger', + timeout: 5000, + }); + } + + helper.persistSettings(hash, Settings.cfg, notify, callback); + }, + load(hash, formElement, callback) { + callback ||= function () {}; + const call = formElement.attr('data-socket-get'); + + socket.emit(call || 'admin.settings.get', { + hash, + }, (error, values) => { + if (error) { + return callback(error); + } + + // Multipe selects are saved as json arrays, parse them here + $(formElement).find('select[multiple]').each((index, selectElement) => { + const key = $(selectElement).attr('name'); + if (key && values.hasOwnProperty(key)) { + try { + values[key] = JSON.parse(values[key]); + } catch { + // Leave the value as is + } + } + }); + + // Save loaded settings into ajaxify.data for use client-side + ajaxify.data[call ? hash : 'settings'] = values; + + helper.whenReady(() => { + $(formElement).find('[data-sorted-list]').each((index, element) => { + getHook(element, 'get').call(Settings, $(element), hash); + }); + }); + + $(formElement).deserialize(values); + $(formElement).find('input[type="checkbox"]').each(function () { + $(this).parents('.mdl-switch').toggleClass('is-checked', $(this).is(':checked')); + }); + hooks.fire('action:admin.settingsLoaded'); + + // Handle unsaved changes + $(formElement).on('change', 'input, select, textarea', () => { + app.flags = app.flags || {}; + app.flags._unsaved = true; + }); + + const saveElement = document.querySelector('#save'); + if (saveElement) { + require(['mousetrap'], mousetrap => { + mousetrap.bind('ctrl+s', event => { + saveElement.click(); + event.preventDefault(); + }); + }); + } + + callback(null, values); + }); + }, + save(hash, formElement, callback) { + formElement = $(formElement); + + const controls = formElement.get(0).elements; + const ok = Settings.check(controls); + if (!ok) { + return; + } + + if (formElement.length > 0) { + const values = helper.serializeForm(formElement); + + helper.whenReady(() => { + const list = formElement.find('[data-sorted-list]'); + if (list.length > 0) { + list.each((index, item) => { + getHook(item, 'set').call(Settings, $(item), values); + }); + } + }); + + const call = formElement.attr('data-socket-set'); + socket.emit(call || 'admin.settings.set', { + hash, + values, + }, error => { + // Remove unsaved flag to re-enable ajaxify + app.flags._unsaved = false; + + // Also save to local ajaxify.data + ajaxify.data[call ? hash : 'settings'] = values; + + if (typeof callback === 'function') { + callback(error); + } else if (error) { + alerts.alert({ + title: '[[admin/admin:changes-not-saved]]', + message: `[[admin/admin:changes-not-saved-message, ${error.message}]]`, + type: 'error', + timeout: 2500, + }); + } else { + alerts.alert({ + title: '[[admin/admin:changes-saved]]', + type: 'success', + timeout: 2500, + }); + } + }); + } + }, + check(controls) { + const onTrigger = e => { + const wrapper = e.target.closest('.form-group'); + if (wrapper) { + wrapper.classList.add('has-error'); + } + + e.target.removeEventListener('invalid', onTrigger); + }; + + return Array.prototype.map.call(controls, controlElement => { + const wrapper = controlElement.closest('.form-group'); + if (wrapper) { + wrapper.classList.remove('has-error'); + } + + controlElement.addEventListener('invalid', onTrigger); + return controlElement.reportValidity(); + }).every(Boolean); + }, + }; + + helper.registerReadyJobs(1); + require([ + 'settings/checkbox', + 'settings/number', + 'settings/textarea', + 'settings/select', + 'settings/array', + 'settings/key', + 'settings/object', + 'settings/sorted-list', + ], function () { + for (const argument of arguments) { + Settings.registerPlugin(argument); + } + + helper.beforeReadyJobsDecreased(); + }); + + return Settings; }); diff --git a/public/src/modules/settings/array.js b/public/src/modules/settings/array.js index 64fa0ce..6b6f2e5 100644 --- a/public/src/modules/settings/array.js +++ b/public/src/modules/settings/array.js @@ -1,34 +1,34 @@ 'use strict'; -define('settings/array', function () { - let helper = null; +define('settings/array', () => { + let helper = null; - /** + /** Creates a new button that removes itself and the given elements on click. Calls {@link Settings.helper.destructElement} for each given field. @param elements The elements to remove on click. @returns JQuery The created remove-button. */ - function createRemoveButton(elements) { - const rm = $(helper.createElement('button', { - class: 'btn btn-xs btn-primary remove', - title: 'Remove Item', - }, '-')); - rm.click(function (event) { - event.preventDefault(); - elements.remove(); - rm.remove(); - elements.each(function (i, element) { - element = $(element); - if (element.is('[data-key]')) { - helper.destructElement(element); - } - }); - }); - return rm; - } + function createRemoveButton(elements) { + const rm = $(helper.createElement('button', { + class: 'btn btn-xs btn-primary remove', + title: 'Remove Item', + }, '-')); + rm.click(event => { + event.preventDefault(); + elements.remove(); + rm.remove(); + elements.each((i, element) => { + element = $(element); + if (element.is('[data-key]')) { + helper.destructElement(element); + } + }); + }); + return rm; + } - /** + /** Creates a new child-element of given field with given data and calls given callback with elements to add. @param field Any wrapper that contains all fields of the array. @param key The key of the array. @@ -38,108 +38,112 @@ define('settings/array', function () { @param separator The separator to use. @param insertCb The callback to insert the elements. */ - function addArrayChildElement(field, key, attributes, value, separator, insertCb) { - attributes = helper.deepClone(attributes); - const type = attributes['data-type'] || attributes.type || 'text'; - const element = $(helper.createElementOfType(type, attributes.tagName, attributes)); - element.attr('data-parent', '_' + key); - delete attributes['data-type']; - delete attributes.tagName; - for (const name in attributes) { - if (attributes.hasOwnProperty(name)) { - const val = attributes[name]; - if (name.search('data-') === 0) { - element.data(name.substring(5), val); - } else if (name.search('prop-') === 0) { - element.prop(name.substring(5), val); - } else { - element.attr(name, val); - } - } - } - helper.fillField(element, value); - if ($('[data-parent="_' + key + '"]', field).length) { - insertCb(separator); - } - insertCb(element); - insertCb(createRemoveButton(element.add(separator))); - } + function addArrayChildElement(field, key, attributes, value, separator, insertCallback) { + attributes = helper.deepClone(attributes); + const type = attributes['data-type'] || attributes.type || 'text'; + const element = $(helper.createElementOfType(type, attributes.tagName, attributes)); + element.attr('data-parent', '_' + key); + delete attributes['data-type']; + delete attributes.tagName; + for (const name in attributes) { + if (attributes.hasOwnProperty(name)) { + const value_ = attributes[name]; + if (name.search('data-') === 0) { + element.data(name.slice(5), value_); + } else if (name.search('prop-') === 0) { + element.prop(name.slice(5), value_); + } else { + element.attr(name, value_); + } + } + } - /** + helper.fillField(element, value); + if ($('[data-parent="_' + key + '"]', field).length > 0) { + insertCallback(separator); + } + + insertCallback(element); + insertCallback(createRemoveButton(element.add(separator))); + } + + /** Adds a new button that adds a new child-element to given element on click. @param element The element to insert the button. @param key The key to forward to {@link addArrayChildElement}. @param attributes The attributes to forward to {@link addArrayChildElement}. @param separator The separator to forward to {@link addArrayChildElement}. */ - function addAddButton(element, key, attributes, separator) { - const addSpace = $(document.createTextNode(' ')); - const newValue = element.data('new') || ''; - const add = $(helper.createElement('button', { - class: 'btn btn-sm btn-primary add', - title: 'Expand Array', - }, '+')); - add.click(function (event) { - event.preventDefault(); - addArrayChildElement(element, key, attributes, newValue, separator.clone(), function (el) { - addSpace.before(el); - }); - }); - element.append(addSpace); - element.append(add); - } + function addAddButton(element, key, attributes, separator) { + const addSpace = $(document.createTextNode(' ')); + const newValue = element.data('new') || ''; + const add = $(helper.createElement('button', { + class: 'btn btn-sm btn-primary add', + title: 'Expand Array', + }, '+')); + add.click(event => { + event.preventDefault(); + addArrayChildElement(element, key, attributes, newValue, separator.clone(), element_ => { + addSpace.before(element_); + }); + }); + element.append(addSpace); + element.append(add); + } + + const SettingsArray = { + types: ['array', 'div'], + use() { + helper = this.helper; + }, + create(ignored, tagName) { + return helper.createElement(tagName || 'div'); + }, + set(element, value) { + let attributes = element.data('attributes'); + const key = element.data('key') || element.data('parent'); + let separator = element.data('split') || ', '; + separator = (function () { + try { + return $(separator); + } catch { + return $(document.createTextNode(separator)); + } + })(); + if (typeof attributes !== 'object') { + attributes = {}; + } + + element.empty(); + if (!(Array.isArray(value))) { + value = []; + } + for (const element_ of value) { + addArrayChildElement(element, key, attributes, element_, separator.clone(), element__ => { + element.append(element__); + }); + } - const SettingsArray = { - types: ['array', 'div'], - use: function () { - helper = this.helper; - }, - create: function (ignored, tagName) { - return helper.createElement(tagName || 'div'); - }, - set: function (element, value) { - let attributes = element.data('attributes'); - const key = element.data('key') || element.data('parent'); - let separator = element.data('split') || ', '; - separator = (function () { - try { - return $(separator); - } catch (_error) { - return $(document.createTextNode(separator)); - } - }()); - if (typeof attributes !== 'object') { - attributes = {}; - } - element.empty(); - if (!(value instanceof Array)) { - value = []; - } - for (let i = 0; i < value.length; i += 1) { - addArrayChildElement(element, key, attributes, value[i], separator.clone(), function (el) { - element.append(el); - }); - } - addAddButton(element, key, attributes, separator); - }, - get: function (element, trim, empty) { - const key = element.data('key') || element.data('parent'); - const children = $('[data-parent="_' + key + '"]', element); - const values = []; - children.each(function (i, child) { - child = $(child); - const val = helper.readValue(child); - const empty = helper.isTrue(child.data('empty')); - if (empty || (val !== undefined && (val == null || val.length !== 0))) { - return values.push(val); - } - }); - if (empty || values.length) { - return values; - } - }, - }; + addAddButton(element, key, attributes, separator); + }, + get(element, trim, empty) { + const key = element.data('key') || element.data('parent'); + const children = $('[data-parent="_' + key + '"]', element); + const values = []; + children.each((i, child) => { + child = $(child); + const value = helper.readValue(child); + const empty = helper.isTrue(child.data('empty')); + if (empty || (value !== undefined && (value == null || value.length > 0))) { + return values.push(value); + } + }); + if (empty || values.length > 0) { + return values; + } + }, + }; - return SettingsArray; + return SettingsArray; }); diff --git a/public/src/modules/settings/checkbox.js b/public/src/modules/settings/checkbox.js index c21b3de..1e9ea2d 100644 --- a/public/src/modules/settings/checkbox.js +++ b/public/src/modules/settings/checkbox.js @@ -1,39 +1,43 @@ 'use strict'; -define('settings/checkbox', function () { - let Settings = null; +define('settings/checkbox', () => { + let Settings = null; - const SettingsCheckbox = { - types: ['checkbox'], - use: function () { - Settings = this; - }, - create: function () { - return Settings.helper.createElement('input', { - type: 'checkbox', - }); - }, - set: function (element, value) { - element.prop('checked', value); - element.closest('.mdl-switch').toggleClass('is-checked', element.is(':checked')); - }, - get: function (element, trim, empty) { - const value = element.prop('checked'); - if (value == null) { - return; - } - if (!empty) { - if (value) { - return value; - } - return; - } - if (trim) { - return value ? 1 : 0; - } - return value; - }, - }; + const SettingsCheckbox = { + types: ['checkbox'], + use() { + Settings = this; + }, + create() { + return Settings.helper.createElement('input', { + type: 'checkbox', + }); + }, + set(element, value) { + element.prop('checked', value); + element.closest('.mdl-switch').toggleClass('is-checked', element.is(':checked')); + }, + get(element, trim, empty) { + const value = element.prop('checked'); + if (value == null) { + return; + } - return SettingsCheckbox; + if (!empty) { + if (value) { + return value; + } + + return; + } + + if (trim) { + return value ? 1 : 0; + } + + return value; + }, + }; + + return SettingsCheckbox; }); diff --git a/public/src/modules/settings/key.js b/public/src/modules/settings/key.js index 5d25a1b..d0a69e7 100644 --- a/public/src/modules/settings/key.js +++ b/public/src/modules/settings/key.js @@ -1,100 +1,107 @@ 'use strict'; -define('settings/key', function () { - let helper = null; - let lastKey = null; - let oldKey = null; - const keyMap = Object.freeze({ - 0: '', - 8: 'Backspace', - 9: 'Tab', - 13: 'Enter', - 27: 'Escape', - 32: 'Space', - 37: 'Left', - 38: 'Up', - 39: 'Right', - 40: 'Down', - 45: 'Insert', - 46: 'Delete', - 187: '=', - 189: '-', - 190: '.', - 191: '/', - 219: '[', - 220: '\\', - 221: ']', - }); - - function Key() { - this.c = false; - this.a = false; - this.s = false; - this.m = false; - this.code = 0; - this.char = ''; - } - - /** +define('settings/key', () => { + let helper = null; + let lastKey = null; + let oldKey = null; + const keyMap = Object.freeze({ + 0: '', + 8: 'Backspace', + 9: 'Tab', + 13: 'Enter', + 27: 'Escape', + 32: 'Space', + 37: 'Left', + 38: 'Up', + 39: 'Right', + 40: 'Down', + 45: 'Insert', + 46: 'Delete', + 187: '=', + 189: '-', + 190: '.', + 191: '/', + 219: '[', + 220: '\\', + 221: ']', + }); + + function Key() { + this.c = false; + this.a = false; + this.s = false; + this.m = false; + this.code = 0; + this.char = ''; + } + + /** Returns either a Key-Object representing the given event or null if only modification-keys got released. @param event The event to inspect. @returns Key | null The Key-Object the focused element should be set to. */ - function getKey(event) { - const anyModChange = ( - event.ctrlKey !== lastKey.c || - event.altKey !== lastKey.a || - event.shiftKey !== lastKey.s || - event.metaKey !== lastKey.m - ); - const modChange = ( - event.ctrlKey + - event.altKey + - event.shiftKey + - event.metaKey - - lastKey.c - - lastKey.a - - lastKey.s - - lastKey.m - ); - const key = new Key(); - key.c = event.ctrlKey; - key.a = event.altKey; - key.s = event.shiftKey; - key.m = event.metaKey; - lastKey = key; - if (anyModChange) { - if (modChange < 0) { - return null; - } - key.code = oldKey.code; - key.char = oldKey.char; - } else { - key.code = event.which; - key.char = convertKeyCodeToChar(key.code); - } - oldKey = key; - return key; - } - - /** + function getKey(event) { + const anyModuleChange = ( + event.ctrlKey !== lastKey.c + || event.altKey !== lastKey.a + || event.shiftKey !== lastKey.s + || event.metaKey !== lastKey.m + ); + const moduleChange = ( + event.ctrlKey + + event.altKey + + event.shiftKey + + event.metaKey + - lastKey.c + - lastKey.a + - lastKey.s + - lastKey.m + ); + const key = new Key(); + key.c = event.ctrlKey; + key.a = event.altKey; + key.s = event.shiftKey; + key.m = event.metaKey; + lastKey = key; + if (anyModuleChange) { + if (moduleChange < 0) { + return null; + } + + key.code = oldKey.code; + key.char = oldKey.char; + } else { + key.code = event.which; + key.char = convertKeyCodeToChar(key.code); + } + + oldKey = key; + return key; + } + + /** Returns the string that represents the given key-code. @param code The key-code. @returns String Representation of the given key-code. */ - function convertKeyCodeToChar(code) { - code = +code; - if (code === 0) { - return ''; - } else if (code >= 48 && code <= 90) { - return String.fromCharCode(code).toUpperCase(); - } else if (code >= 112 && code <= 123) { - return 'F' + (code - 111); - } - return keyMap[code] || ('#' + code); - } - - /** + function convertKeyCodeToChar(code) { + code = Number(code); + if (code === 0) { + return ''; + } + + if (code >= 48 && code <= 90) { + return String.fromCharCode(code).toUpperCase(); + } + + if (code >= 112 && code <= 123) { + return 'F' + (code - 111); + } + + return keyMap[code] || ('#' + code); + } + + /** Returns a string to identify a Key-Object. @param key The Key-Object that should get identified. @param human Whether to show 'Enter a key' when key-char is empty. @@ -102,136 +109,153 @@ define('settings/key', function () { @param separator The separator between modification-names and key-char. @returns String The string to identify the given key-object the given way. */ - function getKeyString(key, human, short, separator) { - let str = ''; - if (!(key instanceof Key)) { - return str; - } - if (!key.char) { - if (human) { - return 'Enter a key'; - } - return ''; - } - if (!separator || /CtrlAShifMea#/.test(separator)) { - separator = human ? ' + ' : '+'; - } - if (key.c) { - str += (short ? 'C' : 'Ctrl') + separator; - } - if (key.a) { - str += (short ? 'A' : 'Alt') + separator; - } - if (key.s) { - str += (short ? 'S' : 'Shift') + separator; - } - if (key.m) { - str += (short ? 'M' : 'Meta') + separator; - } - - let out; - if (human) { - out = key.char; - } else if (key.code) { - out = '#' + key.code || ''; - } - - return str + out; - } - - /** + function getKeyString(key, human, short, separator) { + let string_ = ''; + if (!(key instanceof Key)) { + return string_; + } + + if (!key.char) { + if (human) { + return 'Enter a key'; + } + + return ''; + } + + if (!separator || /CtrlAShifMea#/.test(separator)) { + separator = human ? ' + ' : '+'; + } + + if (key.c) { + string_ += (short ? 'C' : 'Ctrl') + separator; + } + + if (key.a) { + string_ += (short ? 'A' : 'Alt') + separator; + } + + if (key.s) { + string_ += (short ? 'S' : 'Shift') + separator; + } + + if (key.m) { + string_ += (short ? 'M' : 'Meta') + separator; + } + + let out; + if (human) { + out = key.char; + } else if (key.code) { + out = '#' + key.code || ''; + } + + return string_ + out; + } + + /** Parses the given string into a Key-Object. @param str The string to parse. @returns Key The Key-Object that got identified by the given string. */ - function getKeyFromString(str) { - if (str instanceof Key) { - return str; - } - const key = new Key(); - const sep = /([^CtrlAShifMea#\d]+)(?:#|\d)/.exec(str); - const parts = sep != null ? str.split(sep[1]) : [str]; - for (let i = 0; i < parts.length; i += 1) { - const part = parts[i]; - switch (part) { - case 'C': - case 'Ctrl': - key.c = true; - break; - case 'A': - case 'Alt': - key.a = true; - break; - case 'S': - case 'Shift': - key.s = true; - break; - case 'M': - case 'Meta': - key.m = true; - break; - default: { - const num = /\d+/.exec(part); - if (num != null) { - key.code = num[0]; - } - key.char = convertKeyCodeToChar(key.code); - } - } - } - return key; - } - - - const SettingsKey = { - types: ['key'], - use: function () { - helper = this.helper; - }, - init: function (element) { - element.focus(function () { - oldKey = element.data('keyData') || new Key(); - lastKey = new Key(); - }).keydown(function (event) { - event.preventDefault(); - handleEvent(element, event); - }).keyup(function (event) { - handleEvent(element, event); - }); - return element; - }, - set: function (element, value) { - const key = getKeyFromString(value || ''); - element.data('keyData', key); - if (key.code) { - element.removeClass('alert-danger'); - } else { - element.addClass('alert-danger'); - } - element.val(getKeyString(key, true, false, ' + ')); - }, - get: function (element, trim, empty) { - const key = element.data('keyData'); - const separator = element.data('split') || element.data('separator') || '+'; - const short = !helper.isFalse(element.data('short')); - if (trim) { - if (empty || (key != null && key.char)) { - return getKeyString(key, false, short, separator); - } - } else if (empty || (key != null && key.code)) { - return key; - } - }, - }; - - function handleEvent(element, event) { - event = event || window.event; - event.which = event.which || event.keyCode || event.key; - const key = getKey(event); - if (key != null) { - SettingsKey.set(element, key); - } - } - - return SettingsKey; + function getKeyFromString(string_) { + if (string_ instanceof Key) { + return string_; + } + + const key = new Key(); + const separator = /([^CtrlAShifMea#\d]+)[#\d]/.exec(string_); + const parts = separator == null ? [string_] : string_.split(separator[1]); + for (const part of parts) { + switch (part) { + case 'C': + case 'Ctrl': { + key.c = true; + break; + } + + case 'A': + case 'Alt': { + key.a = true; + break; + } + + case 'S': + case 'Shift': { + key.s = true; + break; + } + + case 'M': + case 'Meta': { + key.m = true; + break; + } + + default: { + const number_ = /\d+/.exec(part); + if (number_ != null) { + key.code = number_[0]; + } + + key.char = convertKeyCodeToChar(key.code); + } + } + } + + return key; + } + + const SettingsKey = { + types: ['key'], + use() { + helper = this.helper; + }, + init(element) { + element.focus(() => { + oldKey = element.data('keyData') || new Key(); + lastKey = new Key(); + }).keydown(event => { + event.preventDefault(); + handleEvent(element, event); + }).keyup(event => { + handleEvent(element, event); + }); + return element; + }, + set(element, value) { + const key = getKeyFromString(value || ''); + element.data('keyData', key); + if (key.code) { + element.removeClass('alert-danger'); + } else { + element.addClass('alert-danger'); + } + + element.val(getKeyString(key, true, false, ' + ')); + }, + get(element, trim, empty) { + const key = element.data('keyData'); + const separator = element.data('split') || element.data('separator') || '+'; + const short = !helper.isFalse(element.data('short')); + if (trim) { + if (empty || (key != null && key.char)) { + return getKeyString(key, false, short, separator); + } + } else if (empty || (key != null && key.code)) { + return key; + } + }, + }; + + function handleEvent(element, event) { + event ||= window.event; + event.which = event.which || event.keyCode || event.key; + const key = getKey(event); + if (key != null) { + SettingsKey.set(element, key); + } + } + + return SettingsKey; }); diff --git a/public/src/modules/settings/number.js b/public/src/modules/settings/number.js index 4cf2884..e87bd6e 100644 --- a/public/src/modules/settings/number.js +++ b/public/src/modules/settings/number.js @@ -1,17 +1,17 @@ 'use strict'; -define('settings/number', function () { - return { - types: ['number'], - get: function (element, trim, empty) { - const value = element.val(); - if (!empty) { - if (value) { - return +value; - } - return; - } - return value ? +value : 0; - }, - }; -}); +define('settings/number', () => ({ + types: ['number'], + get(element, trim, empty) { + const value = element.val(); + if (!empty) { + if (value) { + return Number(value); + } + + return; + } + + return value ? Number(value) : 0; + }, +})); diff --git a/public/src/modules/settings/object.js b/public/src/modules/settings/object.js index 1c4bd82..98e552a 100644 --- a/public/src/modules/settings/object.js +++ b/public/src/modules/settings/object.js @@ -1,9 +1,9 @@ 'use strict'; -define('settings/object', function () { - let helper = null; +define('settings/object', () => { + let helper = null; - /** + /** Creates a new child-element of given property with given data and calls given callback with elements to add. @param field Any wrapper that contains all properties of the object. @param key The key of the object. @@ -13,112 +13,120 @@ define('settings/object', function () { @param separator The separator to use. @param insertCb The callback to insert the elements. */ - function addObjectPropertyElement(field, key, attributes, prop, value, separator, insertCb) { - const prepend = attributes['data-prepend']; - const append = attributes['data-append']; - delete attributes['data-prepend']; - delete attributes['data-append']; - attributes = helper.deepClone(attributes); - const type = attributes['data-type'] || attributes.type || 'text'; - const element = $(helper.createElementOfType(type, attributes.tagName, attributes)); - element.attr('data-parent', '_' + key); - element.attr('data-prop', prop); - delete attributes['data-type']; - delete attributes.tagName; - for (const name in attributes) { - if (attributes.hasOwnProperty(name)) { - const val = attributes[name]; - if (name.search('data-') === 0) { - element.data(name.substring(5), val); - } else if (name.search('prop-') === 0) { - element.prop(name.substring(5), val); - } else { - element.attr(name, val); - } - } - } - helper.fillField(element, value); - if ($('[data-parent="_' + key + '"]', field).length) { - insertCb(separator); - } - if (prepend) { - insertCb(prepend); - } - insertCb(element); - if (append) { - insertCb(append); - } - } + function addObjectPropertyElement(field, key, attributes, property, value, separator, insertCallback) { + const prepend = attributes['data-prepend']; + const append = attributes['data-append']; + delete attributes['data-prepend']; + delete attributes['data-append']; + attributes = helper.deepClone(attributes); + const type = attributes['data-type'] || attributes.type || 'text'; + const element = $(helper.createElementOfType(type, attributes.tagName, attributes)); + element.attr('data-parent', '_' + key); + element.attr('data-prop', property); + delete attributes['data-type']; + delete attributes.tagName; + for (const name in attributes) { + if (attributes.hasOwnProperty(name)) { + const value_ = attributes[name]; + if (name.search('data-') === 0) { + element.data(name.slice(5), value_); + } else if (name.search('prop-') === 0) { + element.prop(name.slice(5), value_); + } else { + element.attr(name, value_); + } + } + } - const SettingsObject = { - types: ['object'], - use: function () { - helper = this.helper; - }, - create: function (ignored, tagName) { - return helper.createElement(tagName || 'div'); - }, - set: function (element, value) { - const properties = element.data('attributes') || element.data('properties'); - const key = element.data('key') || element.data('parent'); - let separator = element.data('split') || ', '; - let propertyIndex; - let propertyName; - let attributes; - separator = (function () { - try { - return $(separator); - } catch (_error) { - return $(document.createTextNode(separator)); - } - }()); - element.empty(); - if (typeof value !== 'object') { - value = {}; - } - if (Array.isArray(properties)) { - for (propertyIndex in properties) { - if (properties.hasOwnProperty(propertyIndex)) { - attributes = properties[propertyIndex]; - if (typeof attributes !== 'object') { - attributes = {}; - } - propertyName = attributes['data-prop'] || attributes['data-property'] || propertyIndex; - if (value[propertyName] === undefined && attributes['data-new'] !== undefined) { - value[propertyName] = attributes['data-new']; - } - addObjectPropertyElement( - element, - key, - attributes, - propertyName, - value[propertyName], - separator.clone(), - function (el) { element.append(el); } - ); - } - } - } - }, - get: function (element, trim, empty) { - const key = element.data('key') || element.data('parent'); - const properties = $('[data-parent="_' + key + '"]', element); - const value = {}; - properties.each(function (i, property) { - property = $(property); - const val = helper.readValue(property); - const prop = property.data('prop'); - const empty = helper.isTrue(property.data('empty')); - if (empty || (val !== undefined && (val == null || val.length !== 0))) { - value[prop] = val; - return val; - } - }); - if (empty || Object.keys(value).length) { - return value; - } - }, - }; + helper.fillField(element, value); + if ($('[data-parent="_' + key + '"]', field).length > 0) { + insertCallback(separator); + } - return SettingsObject; + if (prepend) { + insertCallback(prepend); + } + + insertCallback(element); + if (append) { + insertCallback(append); + } + } + + const SettingsObject = { + types: ['object'], + use() { + helper = this.helper; + }, + create(ignored, tagName) { + return helper.createElement(tagName || 'div'); + }, + set(element, value) { + const properties = element.data('attributes') || element.data('properties'); + const key = element.data('key') || element.data('parent'); + let separator = element.data('split') || ', '; + let propertyIndex; + let propertyName; + let attributes; + separator = (function () { + try { + return $(separator); + } catch { + return $(document.createTextNode(separator)); + } + })(); + element.empty(); + if (typeof value !== 'object') { + value = {}; + } + + if (Array.isArray(properties)) { + for (propertyIndex in properties) { + if (properties.hasOwnProperty(propertyIndex)) { + attributes = properties[propertyIndex]; + if (typeof attributes !== 'object') { + attributes = {}; + } + + propertyName = attributes['data-prop'] || attributes['data-property'] || propertyIndex; + if (value[propertyName] === undefined && attributes['data-new'] !== undefined) { + value[propertyName] = attributes['data-new']; + } + + addObjectPropertyElement( + element, + key, + attributes, + propertyName, + value[propertyName], + separator.clone(), + element_ => { + element.append(element_); + }, + ); + } + } + } + }, + get(element, trim, empty) { + const key = element.data('key') || element.data('parent'); + const properties = $('[data-parent="_' + key + '"]', element); + const value = {}; + properties.each((i, property) => { + property = $(property); + const value_ = helper.readValue(property); + const property_ = property.data('prop'); + const empty = helper.isTrue(property.data('empty')); + if (empty || (value_ !== undefined && (value_ == null || value_.length > 0))) { + value[property_] = value_; + return value_; + } + }); + if (empty || Object.keys(value).length > 0) { + return value; + } + }, + }; + + return SettingsObject; }); diff --git a/public/src/modules/settings/select.js b/public/src/modules/settings/select.js index c149235..32ab601 100644 --- a/public/src/modules/settings/select.js +++ b/public/src/modules/settings/select.js @@ -1,46 +1,44 @@ 'use strict'; -define('settings/select', function () { - let Settings = null; +define('settings/select', () => { + let Settings = null; - function addOptions(element, options) { - for (let i = 0; i < options.length; i += 1) { - const optionData = options[i]; - const value = optionData.text || optionData.value; - delete optionData.text; - element.append($(Settings.helper.createElement('option', optionData)).text(value)); - } - } + function addOptions(element, options) { + for (const optionData of options) { + const value = optionData.text || optionData.value; + delete optionData.text; + element.append($(Settings.helper.createElement('option', optionData)).text(value)); + } + } + const SettingsSelect = { + types: ['select'], + use() { + Settings = this; + }, + create(ignore, ignored, data) { + const element = $(Settings.helper.createElement('select')); + // Prevent data-options from being attached to DOM + addOptions(element, data['data-options']); + delete data['data-options']; + return element; + }, + init(element) { + const options = element.data('options'); + if (options != null) { + addOptions(element, options); + } + }, + set(element, value) { + element.val(value || ''); + }, + get(element, ignored, empty) { + const value = element.val(); + if (empty || value) { + return value; + } + }, + }; - const SettingsSelect = { - types: ['select'], - use: function () { - Settings = this; - }, - create: function (ignore, ignored, data) { - const element = $(Settings.helper.createElement('select')); - // prevent data-options from being attached to DOM - addOptions(element, data['data-options']); - delete data['data-options']; - return element; - }, - init: function (element) { - const options = element.data('options'); - if (options != null) { - addOptions(element, options); - } - }, - set: function (element, value) { - element.val(value || ''); - }, - get: function (element, ignored, empty) { - const value = element.val(); - if (empty || value) { - return value; - } - }, - }; - - return SettingsSelect; + return SettingsSelect; }); diff --git a/public/src/modules/settings/sorted-list.js b/public/src/modules/settings/sorted-list.js index d565e4e..34b7f93 100644 --- a/public/src/modules/settings/sorted-list.js +++ b/public/src/modules/settings/sorted-list.js @@ -1,172 +1,171 @@ 'use strict'; define('settings/sorted-list', [ - 'benchpress', - 'bootbox', - 'hooks', - 'jquery-ui/widgets/sortable', -], function (benchpress, bootbox, hooks) { - let Settings; - - - const SortedList = { - types: ['sorted-list'], - use: function () { - Settings = this; - }, - set: function ($container, values) { - const key = $container.attr('data-sorted-list'); - - values[key] = []; - $container.find('[data-type="item"]').each(function (idx, item) { - const itemUUID = $(item).attr('data-sorted-list-uuid'); - - const formData = Settings.helper.serializeForm($('[data-sorted-list-object="' + key + '"][data-sorted-list-uuid="' + itemUUID + '"]')); - stripTags(formData); - values[key].push(formData); - }); - }, - get: async ($container, hash) => { - const { listEl, key, formTpl, formValues } = await hooks.fire('filter:settings.sorted-list.load', { - listEl: $container.find('[data-type="list"]'), - key: $container.attr('data-sorted-list'), - formTpl: $container.attr('data-form-template'), - formValues: {}, - }); - - const formHtml = await benchpress.render(formTpl, formValues); - - const addBtn = $('[data-sorted-list="' + key + '"] [data-type="add"]'); - - addBtn.on('click', function () { - const modal = bootbox.confirm(formHtml, function (save) { - if (save) { - SortedList.addItem(modal.find('form').children(), $container); - } - }); - hooks.fire('action:settings.sorted-list.modal', { modal }); - }); - - const call = $container.parents('form').attr('data-socket-get'); - const list = ajaxify.data[call ? hash : 'settings'][key]; - - if (Array.isArray(list) && typeof list[0] !== 'string') { - const items = await Promise.all(list.map(async (item) => { - ({ item } = await hooks.fire('filter:settings.sorted-list.loadItem', { item })); - - const itemUUID = utils.generateUUID(); - const form = $(formHtml).deserialize(item); - form.attr('data-sorted-list-uuid', itemUUID); - form.attr('data-sorted-list-object', key); - $('#content').append(form.hide()); - - return { itemUUID, item }; - })); - - // todo: parse() needs to be refactored to return the html, so multiple calls can be parallelized - // eslint-disable-next-line no-restricted-syntax - for (const { itemUUID, item } of items) { - // eslint-disable-next-line no-await-in-loop - await parse($container, itemUUID, item); - hooks.fire('action:settings.sorted-list.itemLoaded', { element: listEl.get(0) }); - } - - hooks.fire('action:settings.sorted-list.loaded', { - containerEl: $container.get(0), - listEl: listEl.get(0), - hash, - key, - }); - } - - listEl.sortable().addClass('pointer'); - }, - addItem: async ($formElements, $target) => { - const key = $target.attr('data-sorted-list'); - const itemUUID = utils.generateUUID(); - const form = $('
    '); - form.append($formElements); - - $('#content').append(form.hide()); - - let data = Settings.helper.serializeForm(form); - ({ item: data } = await hooks.fire('filter:settings.sorted-list.loadItem', { item: data })); - parse($target, itemUUID, data); - }, - }; - - function setupRemoveButton($container, itemUUID) { - const removeBtn = $container.find('[data-sorted-list-uuid="' + itemUUID + '"] [data-type="remove"]'); - removeBtn.on('click', function () { - $('[data-sorted-list-uuid="' + itemUUID + '"]').remove(); - }); - } - - function setupEditButton($container, itemUUID) { - const $list = $container.find('[data-type="list"]'); - const key = $container.attr('data-sorted-list'); - const editBtn = $('[data-sorted-list-uuid="' + itemUUID + '"] [data-type="edit"]'); - - editBtn.on('click', function () { - const form = $('[data-sorted-list-uuid="' + itemUUID + '"][data-sorted-list-object="' + key + '"]'); - const clone = form.clone(true).show(); - - // .clone() doesn't preserve the state of `select` elements, fixing after the fact - clone.find('select').each((idx, el) => { - el.value = form.find(`select#${el.id}`).val(); - }); - - const modal = bootbox.confirm(clone, async (save) => { - if (save) { - const form = $('
    '); - form.append(modal.find('form').children()); - - $('#content').find('[data-sorted-list-uuid="' + itemUUID + '"][data-sorted-list-object="' + key + '"]').remove(); - $('#content').append(form.hide()); - - - let data = Settings.helper.serializeForm(form); - ({ item: data } = await hooks.fire('filter:settings.sorted-list.loadItem', { item: data })); - stripTags(data); - - const oldItem = $list.find('[data-sorted-list-uuid="' + itemUUID + '"]'); - parse($container, itemUUID, data, oldItem); - } - }); - hooks.fire('action:settings.sorted-list.modal', { modal }); - }); - } - - function parse($container, itemUUID, data, replaceEl) { - // replaceEl is optional - const $list = $container.find('[data-type="list"]'); - const itemTpl = $container.attr('data-item-template'); - - stripTags(data); - - return new Promise((resolve) => { - app.parseAndTranslate(itemTpl, data, function (itemHtml) { - itemHtml = $(itemHtml); - if (replaceEl) { - replaceEl.replaceWith(itemHtml); - } else { - $list.append(itemHtml); - } - itemHtml.attr('data-sorted-list-uuid', itemUUID); - - setupRemoveButton($container, itemUUID); - setupEditButton($container, itemUUID); - hooks.fire('action:settings.sorted-list.parse', { itemHtml }); - resolve(); - }); - }); - } - - function stripTags(data) { - return Object.entries(data || {}).forEach(([field, value]) => { - data[field] = typeof value === 'string' ? utils.stripHTMLTags(value, utils.stripTags) : value; - }); - } - - return SortedList; + 'benchpress', + 'bootbox', + 'hooks', + 'jquery-ui/widgets/sortable', +], (benchpress, bootbox, hooks) => { + let Settings; + + const SortedList = { + types: ['sorted-list'], + use() { + Settings = this; + }, + set($container, values) { + const key = $container.attr('data-sorted-list'); + + values[key] = []; + $container.find('[data-type="item"]').each((index, item) => { + const itemUUID = $(item).attr('data-sorted-list-uuid'); + + const formData = Settings.helper.serializeForm($('[data-sorted-list-object="' + key + '"][data-sorted-list-uuid="' + itemUUID + '"]')); + stripTags(formData); + values[key].push(formData); + }); + }, + async get($container, hash) { + const {listEl, key, formTpl, formValues} = await hooks.fire('filter:settings.sorted-list.load', { + listEl: $container.find('[data-type="list"]'), + key: $container.attr('data-sorted-list'), + formTpl: $container.attr('data-form-template'), + formValues: {}, + }); + + const formHtml = await benchpress.render(formTpl, formValues); + + const addButton = $('[data-sorted-list="' + key + '"] [data-type="add"]'); + + addButton.on('click', () => { + const modal = bootbox.confirm(formHtml, save => { + if (save) { + SortedList.addItem(modal.find('form').children(), $container); + } + }); + hooks.fire('action:settings.sorted-list.modal', {modal}); + }); + + const call = $container.parents('form').attr('data-socket-get'); + const list = ajaxify.data[call ? hash : 'settings'][key]; + + if (Array.isArray(list) && typeof list[0] !== 'string') { + const items = await Promise.all(list.map(async item => { + ({item} = await hooks.fire('filter:settings.sorted-list.loadItem', {item})); + + const itemUUID = utils.generateUUID(); + const form = $(formHtml).deserialize(item); + form.attr('data-sorted-list-uuid', itemUUID); + form.attr('data-sorted-list-object', key); + $('#content').append(form.hide()); + + return {itemUUID, item}; + })); + + // Todo: parse() needs to be refactored to return the html, so multiple calls can be parallelized + + for (const {itemUUID, item} of items) { + // eslint-disable-next-line no-await-in-loop + await parse($container, itemUUID, item); + hooks.fire('action:settings.sorted-list.itemLoaded', {element: listEl.get(0)}); + } + + hooks.fire('action:settings.sorted-list.loaded', { + containerEl: $container.get(0), + listEl: listEl.get(0), + hash, + key, + }); + } + + listEl.sortable().addClass('pointer'); + }, + async addItem($formElements, $target) { + const key = $target.attr('data-sorted-list'); + const itemUUID = utils.generateUUID(); + const form = $('
    '); + form.append($formElements); + + $('#content').append(form.hide()); + + let data = Settings.helper.serializeForm(form); + ({item: data} = await hooks.fire('filter:settings.sorted-list.loadItem', {item: data})); + parse($target, itemUUID, data); + }, + }; + + function setupRemoveButton($container, itemUUID) { + const removeButton = $container.find('[data-sorted-list-uuid="' + itemUUID + '"] [data-type="remove"]'); + removeButton.on('click', () => { + $('[data-sorted-list-uuid="' + itemUUID + '"]').remove(); + }); + } + + function setupEditButton($container, itemUUID) { + const $list = $container.find('[data-type="list"]'); + const key = $container.attr('data-sorted-list'); + const editButton = $('[data-sorted-list-uuid="' + itemUUID + '"] [data-type="edit"]'); + + editButton.on('click', () => { + const form = $('[data-sorted-list-uuid="' + itemUUID + '"][data-sorted-list-object="' + key + '"]'); + const clone = form.clone(true).show(); + + // .clone() doesn't preserve the state of `select` elements, fixing after the fact + clone.find('select').each((index, element) => { + element.value = form.find(`select#${element.id}`).val(); + }); + + const modal = bootbox.confirm(clone, async save => { + if (save) { + const form = $('
    '); + form.append(modal.find('form').children()); + + $('#content').find('[data-sorted-list-uuid="' + itemUUID + '"][data-sorted-list-object="' + key + '"]').remove(); + $('#content').append(form.hide()); + + let data = Settings.helper.serializeForm(form); + ({item: data} = await hooks.fire('filter:settings.sorted-list.loadItem', {item: data})); + stripTags(data); + + const oldItem = $list.find('[data-sorted-list-uuid="' + itemUUID + '"]'); + parse($container, itemUUID, data, oldItem); + } + }); + hooks.fire('action:settings.sorted-list.modal', {modal}); + }); + } + + function parse($container, itemUUID, data, replaceElement) { + // ReplaceEl is optional + const $list = $container.find('[data-type="list"]'); + const itemTpl = $container.attr('data-item-template'); + + stripTags(data); + + return new Promise(resolve => { + app.parseAndTranslate(itemTpl, data, itemHtml => { + itemHtml = $(itemHtml); + if (replaceElement) { + replaceElement.replaceWith(itemHtml); + } else { + $list.append(itemHtml); + } + + itemHtml.attr('data-sorted-list-uuid', itemUUID); + + setupRemoveButton($container, itemUUID); + setupEditButton($container, itemUUID); + hooks.fire('action:settings.sorted-list.parse', {itemHtml}); + resolve(); + }); + }); + } + + function stripTags(data) { + return Object.entries(data || {}).forEach(([field, value]) => { + data[field] = typeof value === 'string' ? utils.stripHTMLTags(value, utils.stripTags) : value; + }); + } + + return SortedList; }); diff --git a/public/src/modules/settings/textarea.js b/public/src/modules/settings/textarea.js index 1ef9513..86d78d7 100644 --- a/public/src/modules/settings/textarea.js +++ b/public/src/modules/settings/textarea.js @@ -1,36 +1,34 @@ 'use strict'; -define('settings/textarea', function () { - let Settings = null; +define('settings/textarea', () => { + let Settings = null; - const SettingsArea = { - types: ['textarea'], - use: function () { - Settings = this; - }, - create: function () { - return Settings.helper.createElement('textarea'); - }, - set: function (element, value, trim) { - if (trim && value != null && typeof value.trim === 'function') { - value = value.trim(); - } - element.val(value || ''); - }, - get: function (element, trim, empty) { - let value = element.val(); - if (trim) { - if (value == null) { - value = undefined; - } else { - value = value.trim(); - } - } - if (empty || value) { - return value; - } - }, - }; + const SettingsArea = { + types: ['textarea'], + use() { + Settings = this; + }, + create() { + return Settings.helper.createElement('textarea'); + }, + set(element, value, trim) { + if (trim && value != null && typeof value.trim === 'function') { + value = value.trim(); + } - return SettingsArea; + element.val(value || ''); + }, + get(element, trim, empty) { + let value = element.val(); + if (trim) { + value = value == null ? undefined : value.trim(); + } + + if (empty || value) { + return value; + } + }, + }; + + return SettingsArea; }); diff --git a/public/src/modules/share.js b/public/src/modules/share.js index ea2f131..0961f14 100644 --- a/public/src/modules/share.js +++ b/public/src/modules/share.js @@ -1,55 +1,54 @@ 'use strict'; - -define('share', ['hooks'], function (hooks) { - const module = {}; - - module.addShareHandlers = function (name) { - const baseUrl = window.location.protocol + '//' + window.location.host; - - function openShare(url, urlToPost, width, height) { - window.open(url + encodeURIComponent(baseUrl + config.relative_path + urlToPost), '_blank', 'width=' + width + ',height=' + height + ',scrollbars=no,status=no'); - hooks.fire('action:share.open', { - url: url, - urlToPost: urlToPost, - }); - return false; - } - - $('#content').off('shown.bs.dropdown', '.share-dropdown').on('shown.bs.dropdown', '.share-dropdown', function () { - const postLink = $(this).find('.post-link'); - postLink.val(baseUrl + getPostUrl($(this))); - - // without the setTimeout can't select the text in the input - setTimeout(function () { - postLink.putCursorAtEnd().select(); - }, 50); - }); - - addHandler('.post-link', function (e) { - e.preventDefault(); - return false; - }); - - addHandler('[component="share/twitter"]', function () { - return openShare('https://twitter.com/intent/tweet?text=' + encodeURIComponent(name) + '&url=', getPostUrl($(this)), 550, 420); - }); - - addHandler('[component="share/facebook"]', function () { - return openShare('https://www.facebook.com/sharer/sharer.php?u=', getPostUrl($(this)), 626, 436); - }); - - hooks.fire('action:share.addHandlers', { openShare: openShare }); - }; - - function addHandler(selector, callback) { - $('#content').off('click', selector).on('click', selector, callback); - } - - function getPostUrl(clickedElement) { - const pid = parseInt(clickedElement.parents('[data-pid]').attr('data-pid'), 10); - return '/post' + (pid ? '/' + (pid) : ''); - } - - return module; +define('share', ['hooks'], hooks => { + const module = {}; + + module.addShareHandlers = function (name) { + const baseUrl = window.location.protocol + '//' + window.location.host; + + function openShare(url, urlToPost, width, height) { + window.open(url + encodeURIComponent(baseUrl + config.relative_path + urlToPost), '_blank', 'width=' + width + ',height=' + height + ',scrollbars=no,status=no'); + hooks.fire('action:share.open', { + url, + urlToPost, + }); + return false; + } + + $('#content').off('shown.bs.dropdown', '.share-dropdown').on('shown.bs.dropdown', '.share-dropdown', function () { + const postLink = $(this).find('.post-link'); + postLink.val(baseUrl + getPostUrl($(this))); + + // Without the setTimeout can't select the text in the input + setTimeout(() => { + postLink.putCursorAtEnd().select(); + }, 50); + }); + + addHandler('.post-link', e => { + e.preventDefault(); + return false; + }); + + addHandler('[component="share/twitter"]', function () { + return openShare('https://twitter.com/intent/tweet?text=' + encodeURIComponent(name) + '&url=', getPostUrl($(this)), 550, 420); + }); + + addHandler('[component="share/facebook"]', function () { + return openShare('https://www.facebook.com/sharer/sharer.php?u=', getPostUrl($(this)), 626, 436); + }); + + hooks.fire('action:share.addHandlers', {openShare}); + }; + + function addHandler(selector, callback) { + $('#content').off('click', selector).on('click', selector, callback); + } + + function getPostUrl(clickedElement) { + const pid = Number.parseInt(clickedElement.parents('[data-pid]').attr('data-pid'), 10); + return '/post' + (pid ? '/' + (pid) : ''); + } + + return module; }); diff --git a/public/src/modules/slugify.js b/public/src/modules/slugify.js index b189b2c..0df7157 100644 --- a/public/src/modules/slugify.js +++ b/public/src/modules/slugify.js @@ -2,39 +2,37 @@ /* global XRegExp */ (function (factory) { - if (typeof define === 'function' && define.amd) { - define('slugify', ['xregexp'], factory); - } else if (typeof exports === 'object') { - module.exports = factory(require('xregexp')); - } else { - window.slugify = factory(XRegExp); - } -}(function (XRegExp) { - const invalidUnicodeChars = XRegExp('[^\\p{L}\\s\\d\\-_]', 'g'); - const invalidLatinChars = /[^\w\s\d\-_]/g; - const trimRegex = /^\s+|\s+$/g; - const collapseWhitespace = /\s+/g; - const collapseDash = /-+/g; - const trimTrailingDash = /-$/g; - const trimLeadingDash = /^-/g; - const isLatin = /^[\w\d\s.,\-@]+$/; + if (typeof define === 'function' && define.amd) { + define('slugify', ['xregexp'], factory); + } else if (typeof exports === 'object') { + module.exports = factory(require('xregexp')); + } else { + window.slugify = factory(XRegExp); + } +})(XRegExp => { + const invalidUnicodeChars = XRegExp('[^\\p{L}\\s\\d\\-_]', 'g'); + const invalidLatinChars = /[^\w\s\d\-_]/g; + const trimRegex = /^\s+|\s+$/g; + const collapseWhitespace = /\s+/g; + const collapseDash = /-+/g; + const trimTrailingDash = /-$/g; + const trimLeadingDash = /^-/g; + const isLatin = /^[\w\d\s.,\-@]+$/; - // http://dense13.com/blog/2009/05/03/converting-string-to-slug-javascript/ - return function slugify(str, preserveCase) { - if (!str) { - return ''; - } - str = String(str).replace(trimRegex, ''); - if (isLatin.test(str)) { - str = str.replace(invalidLatinChars, '-'); - } else { - str = XRegExp.replace(str, invalidUnicodeChars, '-'); - } - str = !preserveCase ? str.toLocaleLowerCase() : str; - str = str.replace(collapseWhitespace, '-'); - str = str.replace(collapseDash, '-'); - str = str.replace(trimTrailingDash, ''); - str = str.replace(trimLeadingDash, ''); - return str; - }; -})); + // http://dense13.com/blog/2009/05/03/converting-string-to-slug-javascript/ + return function slugify(string_, preserveCase) { + if (!string_) { + return ''; + } + + string_ = String(string_).replaceAll(trimRegex, ''); + string_ = isLatin.test(string_) ? string_.replaceAll(invalidLatinChars, '-') : XRegExp.replace(string_, invalidUnicodeChars, '-'); + + string_ = preserveCase ? string_ : string_.toLocaleLowerCase(); + string_ = string_.replaceAll(collapseWhitespace, '-'); + string_ = string_.replaceAll(collapseDash, '-'); + string_ = string_.replaceAll(trimTrailingDash, ''); + string_ = string_.replaceAll(trimLeadingDash, ''); + return string_; + }; +}); diff --git a/public/src/modules/sort.js b/public/src/modules/sort.js index 85c4dfd..8ee5aa3 100644 --- a/public/src/modules/sort.js +++ b/public/src/modules/sort.js @@ -1,39 +1,39 @@ 'use strict'; +define('sort', ['components', 'api'], (components, api) => { + const module = {}; -define('sort', ['components', 'api'], function (components, api) { - const module = {}; + module.handleSort = function (field, gotoOnSave) { + const threadSort = components.get('thread/sort'); + threadSort.find('i').removeClass('fa-check'); + const currentSetting = threadSort.find('a[data-sort="' + config[field] + '"]'); + currentSetting.find('i').addClass('fa-check'); - module.handleSort = function (field, gotoOnSave) { - const threadSort = components.get('thread/sort'); - threadSort.find('i').removeClass('fa-check'); - const currentSetting = threadSort.find('a[data-sort="' + config[field] + '"]'); - currentSetting.find('i').addClass('fa-check'); + $('body') + .off('click', '[component="thread/sort"] a') + .on('click', '[component="thread/sort"] a', function () { + function refresh(newSetting, parameters) { + config[field] = newSetting; + const qs = decodeURIComponent($.param(parameters)); + ajaxify.go(gotoOnSave + (qs ? '?' + qs : '')); + } - $('body') - .off('click', '[component="thread/sort"] a') - .on('click', '[component="thread/sort"] a', function () { - function refresh(newSetting, params) { - config[field] = newSetting; - const qs = decodeURIComponent($.param(params)); - ajaxify.go(gotoOnSave + (qs ? '?' + qs : '')); - } - const newSetting = $(this).attr('data-sort'); - if (app.user.uid) { - const payload = { settings: {} }; - payload.settings[field] = newSetting; - api.put(`/users/${app.user.uid}/settings`, payload).then(() => { - // Yes, this is normal. If you are logged in, sort is not - // added to qs since it's saved to user settings - refresh(newSetting, utils.params()); - }); - } else { - const urlParams = utils.params(); - urlParams.sort = newSetting; - refresh(newSetting, urlParams); - } - }); - }; + const newSetting = $(this).attr('data-sort'); + if (app.user.uid) { + const payload = {settings: {}}; + payload.settings[field] = newSetting; + api.put(`/users/${app.user.uid}/settings`, payload).then(() => { + // Yes, this is normal. If you are logged in, sort is not + // added to qs since it's saved to user settings + refresh(newSetting, utils.params()); + }); + } else { + const urlParameters = utils.params(); + urlParameters.sort = newSetting; + refresh(newSetting, urlParameters); + } + }); + }; - return module; + return module; }); diff --git a/public/src/modules/storage.js b/public/src/modules/storage.js index b7d4c9a..a8813bd 100644 --- a/public/src/modules/storage.js +++ b/public/src/modules/storage.js @@ -3,82 +3,89 @@ /** * Checks localStorage and provides a fallback if it doesn't exist or is disabled */ -define('storage', function () { - function Storage() { - this._store = {}; - this._keys = []; - } - Storage.prototype.isMock = true; - Storage.prototype.setItem = function (key, val) { - key = String(key); - if (this._keys.indexOf(key) === -1) { - this._keys.push(key); - } - this._store[key] = val; - }; - Storage.prototype.getItem = function (key) { - key = String(key); - if (this._keys.indexOf(key) === -1) { - return null; - } - - return this._store[key]; - }; - Storage.prototype.removeItem = function (key) { - key = String(key); - this._keys = this._keys.filter(function (x) { - return x !== key; - }); - this._store[key] = null; - }; - Storage.prototype.clear = function () { - this._keys = []; - this._store = {}; - }; - Storage.prototype.key = function (n) { - n = parseInt(n, 10) || 0; - return this._keys[n]; - }; - if (Object.defineProperty) { - Object.defineProperty(Storage.prototype, 'length', { - get: function () { - return this._keys.length; - }, - }); - } - - let storage; - const item = Date.now().toString(); - - try { - storage = window.localStorage; - storage.setItem(item, item); - if (storage.getItem(item) !== item) { - throw Error('localStorage behaved unexpectedly'); - } - storage.removeItem(item); - - return storage; - } catch (e) { - console.warn(e); - console.warn('localStorage failed, falling back on sessionStorage'); - - // see if sessionStorage works, and if so, return that - try { - storage = window.sessionStorage; - storage.setItem(item, item); - if (storage.getItem(item) !== item) { - throw Error('sessionStorage behaved unexpectedly'); - } - storage.removeItem(item); - - return storage; - } catch (e) { - console.warn(e); - console.warn('sessionStorage failed, falling back on memory storage'); - - // return an object implementing mock methods - return new Storage(); - } - } +define('storage', () => { + function Storage() { + this._store = {}; + this._keys = []; + } + + Storage.prototype.isMock = true; + Storage.prototype.setItem = function (key, value) { + key = String(key); + if (!this._keys.includes(key)) { + this._keys.push(key); + } + + this._store[key] = value; + }; + + Storage.prototype.getItem = function (key) { + key = String(key); + if (!this._keys.includes(key)) { + return null; + } + + return this._store[key]; + }; + + Storage.prototype.removeItem = function (key) { + key = String(key); + this._keys = this._keys.filter(x => x !== key); + this._store[key] = null; + }; + + Storage.prototype.clear = function () { + this._keys = []; + this._store = {}; + }; + + Storage.prototype.key = function (n) { + n = Number.parseInt(n, 10) || 0; + return this._keys[n]; + }; + + if (Object.defineProperty) { + Object.defineProperty(Storage.prototype, 'length', { + get() { + return this._keys.length; + }, + }); + } + + let storage; + const item = Date.now().toString(); + + try { + storage = window.localStorage; + storage.setItem(item, item); + if (storage.getItem(item) !== item) { + throw new Error('localStorage behaved unexpectedly'); + } + + storage.removeItem(item); + + return storage; + } catch (error) { + console.warn(error); + console.warn('localStorage failed, falling back on sessionStorage'); + + // See if sessionStorage works, and if so, return that + try { + storage = window.sessionStorage; + storage.setItem(item, item); + if (storage.getItem(item) !== item) { + throw new Error('sessionStorage behaved unexpectedly'); + } + + storage.removeItem(item); + + return storage; + } catch (error) { + console.warn(error); + console.warn('sessionStorage failed, falling back on memory storage'); + + // Return an object implementing mock methods + return new Storage(); + } + } }); diff --git a/public/src/modules/taskbar.js b/public/src/modules/taskbar.js index 4d1ddcb..17c829a 100644 --- a/public/src/modules/taskbar.js +++ b/public/src/modules/taskbar.js @@ -1,213 +1,219 @@ 'use strict'; - -define('taskbar', ['benchpress', 'translator', 'hooks'], function (Benchpress, translator, hooks) { - const taskbar = {}; - - taskbar.init = function () { - const self = this; - - Benchpress.render('modules/taskbar', {}).then(function (html) { - self.taskbar = $(html); - self.tasklist = self.taskbar.find('ul'); - $(document.body).append(self.taskbar); - - self.taskbar.on('click', 'li', async function () { - const $btn = $(this); - const moduleName = $btn.attr('data-module'); - const uuid = $btn.attr('data-uuid'); - - const module = await app.require(moduleName); - if (!$btn.hasClass('active')) { - minimizeAll(); - module.load(uuid); - taskbar.toggleNew(uuid, false); - - taskbar.tasklist.removeClass('active'); - $btn.addClass('active'); - } else { - module.minimize(uuid); - } - return false; - }); - }); - - $(window).on('action:app.loggedOut', function () { - taskbar.closeAll(); - }); - }; - - taskbar.close = async function (moduleName, uuid) { - // Sends signal to the appropriate module's .close() fn (if present) - const btnEl = taskbar.tasklist.find('[data-module="' + module + '"][data-uuid="' + uuid + '"]'); - let fnName = 'close'; - - // TODO: Refactor chat module to not take uuid in close instead of by jQuery element - if (moduleName === 'chat') { - fnName = 'closeByUUID'; - } - - if (btnEl.length) { - const module = await app.require(moduleName); - if (module && typeof module[fnName] === 'function') { - module[fnName](uuid); - } - } - }; - - taskbar.closeAll = function (module) { - // module is optional - let selector = '[data-uuid]'; - - if (module) { - selector = '[data-module="' + module + '"]' + selector; - } - - taskbar.tasklist.find(selector).each(function (idx, el) { - taskbar.close(module || el.getAttribute('data-module'), el.getAttribute('data-uuid')); - }); - }; - - taskbar.discard = function (module, uuid) { - const btnEl = taskbar.tasklist.find('[data-module="' + module + '"][data-uuid="' + uuid + '"]'); - btnEl.remove(); - - update(); - }; - - taskbar.push = function (module, uuid, options, callback) { - callback = callback || function () {}; - const element = taskbar.tasklist.find('li[data-uuid="' + uuid + '"]'); - - const data = { - module: module, - uuid: uuid, - options: options, - element: element, - }; - - hooks.fire('filter:taskbar.push', data); - - if (!element.length && data.module) { - createTaskbarItem(data, callback); - } else { - callback(element); - } - }; - - taskbar.get = function (module) { - const items = $('[data-module="' + module + '"]').map(function (idx, el) { - return $(el).data(); - }); - - return items; - }; - - taskbar.minimize = function (module, uuid) { - const btnEl = taskbar.tasklist.find('[data-module="' + module + '"][data-uuid="' + uuid + '"]'); - btnEl.toggleClass('active', false); - }; - - taskbar.toggleNew = function (uuid, state, silent) { - const btnEl = taskbar.tasklist.find('[data-uuid="' + uuid + '"]'); - btnEl.toggleClass('new', state); - - if (!silent) { - hooks.fire('action:taskbar.toggleNew', uuid); - } - }; - - taskbar.updateActive = function (uuid) { - const tasks = taskbar.tasklist.find('li'); - tasks.removeClass('active'); - tasks.filter('[data-uuid="' + uuid + '"]').addClass('active'); - - $('[data-uuid]:not([data-module])').toggleClass('modal-unfocused', true); - $('[data-uuid="' + uuid + '"]:not([data-module])').toggleClass('modal-unfocused', false); - }; - - taskbar.isActive = function (uuid) { - const taskBtn = taskbar.tasklist.find('li[data-uuid="' + uuid + '"]'); - return taskBtn.hasClass('active'); - }; - - function update() { - const tasks = taskbar.tasklist.find('li'); - - if (tasks.length > 0) { - taskbar.taskbar.attr('data-active', '1'); - } else { - taskbar.taskbar.removeAttr('data-active'); - } - } - - function minimizeAll() { - taskbar.tasklist.find('.active').removeClass('active'); - } - - function createTaskbarItem(data, callback) { - translator.translate(data.options.title, function (taskTitle) { - const title = $('
    ').text(taskTitle || 'NodeBB Task').html(); - - const taskbarEl = $('
  • ') - .addClass(data.options.className) - .html('' + - (data.options.icon ? ' ' : '') + - '' + title + '' + - '') - .attr({ - title: title, - 'data-module': data.module, - 'data-uuid': data.uuid, - }) - .addClass(data.options.state !== undefined ? data.options.state : 'active'); - - if (!data.options.state || data.options.state === 'active') { - minimizeAll(); - } - - taskbar.tasklist.append(taskbarEl); - update(); - - data.element = taskbarEl; - - taskbarEl.data(data); - hooks.fire('action:taskbar.pushed', data); - callback(taskbarEl); - }); - } - - const processUpdate = function (element, key, value) { - switch (key) { - case 'title': - element.find('[component="taskbar/title"]').text(value); - break; - case 'icon': - element.find('i').attr('class', 'fa fa-' + value); - break; - case 'image': - element.find('a').css('background-image', value ? 'url("' + value.replace(///g, '/') + '")' : ''); - break; - case 'background-color': - element.find('a').css('background-color', value); - break; - } - }; - - taskbar.update = function (module, uuid, options) { - const element = taskbar.tasklist.find('[data-module="' + module + '"][data-uuid="' + uuid + '"]'); - if (!element.length) { - return; - } - const data = element.data(); - - Object.keys(options).forEach(function (key) { - data[key] = options[key]; - processUpdate(element, key, options[key]); - }); - - element.data(data); - }; - - return taskbar; +define('taskbar', ['benchpress', 'translator', 'hooks'], (Benchpress, translator, hooks) => { + const taskbar = {}; + + taskbar.init = function () { + const self = this; + + Benchpress.render('modules/taskbar', {}).then(html => { + self.taskbar = $(html); + self.tasklist = self.taskbar.find('ul'); + $(document.body).append(self.taskbar); + + self.taskbar.on('click', 'li', async function () { + const $button = $(this); + const moduleName = $button.attr('data-module'); + const uuid = $button.attr('data-uuid'); + + const module = await app.require(moduleName); + if ($button.hasClass('active')) { + module.minimize(uuid); + } else { + minimizeAll(); + module.load(uuid); + taskbar.toggleNew(uuid, false); + + taskbar.tasklist.removeClass('active'); + $button.addClass('active'); + } + + return false; + }); + }); + + $(window).on('action:app.loggedOut', () => { + taskbar.closeAll(); + }); + }; + + taskbar.close = async function (moduleName, uuid) { + // Sends signal to the appropriate module's .close() fn (if present) + const buttonElement = taskbar.tasklist.find('[data-module="' + module + '"][data-uuid="' + uuid + '"]'); + let functionName = 'close'; + + // TODO: Refactor chat module to not take uuid in close instead of by jQuery element + if (moduleName === 'chat') { + functionName = 'closeByUUID'; + } + + if (buttonElement.length > 0) { + const module = await app.require(moduleName); + if (module && typeof module[functionName] === 'function') { + module[functionName](uuid); + } + } + }; + + taskbar.closeAll = function (module) { + // Module is optional + let selector = '[data-uuid]'; + + if (module) { + selector = '[data-module="' + module + '"]' + selector; + } + + taskbar.tasklist.find(selector).each((index, element) => { + taskbar.close(module || element.dataset.module, element.dataset.uuid); + }); + }; + + taskbar.discard = function (module, uuid) { + const buttonElement = taskbar.tasklist.find('[data-module="' + module + '"][data-uuid="' + uuid + '"]'); + buttonElement.remove(); + + update(); + }; + + taskbar.push = function (module, uuid, options, callback) { + callback ||= function () {}; + const element = taskbar.tasklist.find('li[data-uuid="' + uuid + '"]'); + + const data = { + module, + uuid, + options, + element, + }; + + hooks.fire('filter:taskbar.push', data); + + if (element.length === 0 && data.module) { + createTaskbarItem(data, callback); + } else { + callback(element); + } + }; + + taskbar.get = function (module) { + const items = $('[data-module="' + module + '"]').map((index, element) => $(element).data()); + + return items; + }; + + taskbar.minimize = function (module, uuid) { + const buttonElement = taskbar.tasklist.find('[data-module="' + module + '"][data-uuid="' + uuid + '"]'); + buttonElement.toggleClass('active', false); + }; + + taskbar.toggleNew = function (uuid, state, silent) { + const buttonElement = taskbar.tasklist.find('[data-uuid="' + uuid + '"]'); + buttonElement.toggleClass('new', state); + + if (!silent) { + hooks.fire('action:taskbar.toggleNew', uuid); + } + }; + + taskbar.updateActive = function (uuid) { + const tasks = taskbar.tasklist.find('li'); + tasks.removeClass('active'); + tasks.filter('[data-uuid="' + uuid + '"]').addClass('active'); + + $('[data-uuid]:not([data-module])').toggleClass('modal-unfocused', true); + $('[data-uuid="' + uuid + '"]:not([data-module])').toggleClass('modal-unfocused', false); + }; + + taskbar.isActive = function (uuid) { + const taskButton = taskbar.tasklist.find('li[data-uuid="' + uuid + '"]'); + return taskButton.hasClass('active'); + }; + + function update() { + const tasks = taskbar.tasklist.find('li'); + + if (tasks.length > 0) { + taskbar.taskbar.attr('data-active', '1'); + } else { + taskbar.taskbar.removeAttr('data-active'); + } + } + + function minimizeAll() { + taskbar.tasklist.find('.active').removeClass('active'); + } + + function createTaskbarItem(data, callback) { + translator.translate(data.options.title, taskTitle => { + const title = $('
    ').text(taskTitle || 'NodeBB Task').html(); + + const taskbarElement = $('
  • ') + .addClass(data.options.className) + .html('' + + (data.options.icon ? ' ' : '') + + '' + title + '' + + '') + .attr({ + title, + 'data-module': data.module, + 'data-uuid': data.uuid, + }) + .addClass(data.options.state === undefined ? 'active' : data.options.state); + + if (!data.options.state || data.options.state === 'active') { + minimizeAll(); + } + + taskbar.tasklist.append(taskbarElement); + update(); + + data.element = taskbarElement; + + taskbarElement.data(data); + hooks.fire('action:taskbar.pushed', data); + callback(taskbarElement); + }); + } + + const processUpdate = function (element, key, value) { + switch (key) { + case 'title': { + element.find('[component="taskbar/title"]').text(value); + break; + } + + case 'icon': { + element.find('i').attr('class', 'fa fa-' + value); + break; + } + + case 'image': { + element.find('a').css('background-image', value ? 'url("' + value.replaceAll('/', '/') + '")' : ''); + break; + } + + case 'background-color': { + element.find('a').css('background-color', value); + break; + } + } + }; + + taskbar.update = function (module, uuid, options) { + const element = taskbar.tasklist.find('[data-module="' + module + '"][data-uuid="' + uuid + '"]'); + if (element.length === 0) { + return; + } + + const data = element.data(); + + for (const key of Object.keys(options)) { + data[key] = options[key]; + processUpdate(element, key, options[key]); + } + + element.data(data); + }; + + return taskbar; }); diff --git a/public/src/modules/topicList.js b/public/src/modules/topicList.js index 6342969..4d05de5 100644 --- a/public/src/modules/topicList.js +++ b/public/src/modules/topicList.js @@ -1,279 +1,278 @@ 'use strict'; define('topicList', [ - 'forum/infinitescroll', - 'handleBack', - 'topicSelect', - 'categoryFilter', - 'forum/category/tools', - 'hooks', -], function (infinitescroll, handleBack, topicSelect, categoryFilter, categoryTools, hooks) { - const TopicList = {}; - let templateName = ''; - - let newTopicCount = 0; - let newPostCount = 0; - - let loadTopicsCallback; - let topicListEl; - - const scheduledTopics = []; - - $(window).on('action:ajaxify.start', function () { - TopicList.removeListeners(); - categoryTools.removeListeners(); - }); - - TopicList.init = function (template, cb) { - topicListEl = findTopicListElement(); - - templateName = template; - loadTopicsCallback = cb || loadTopicsAfter; - - categoryTools.init(); - - TopicList.watchForNewPosts(); - const states = ['watching']; - if (ajaxify.data.selectedFilter && ajaxify.data.selectedFilter.filter === 'watched') { - states.push('notwatching', 'ignoring'); - } else if (template !== 'unread') { - states.push('notwatching'); - } - - categoryFilter.init($('[component="category/dropdown"]'), { - states: states, - }); - - if (!config.usePagination) { - infinitescroll.init(TopicList.loadMoreTopics); - } - - handleBack.init(function (after, handleBackCallback) { - loadTopicsCallback(after, 1, function (data, loadCallback) { - onTopicsLoaded(templateName, data.topics, ajaxify.data.showSelect, 1, function () { - handleBackCallback(); - loadCallback(); - }); - }); - }); - - if ($('body').height() <= $(window).height() && topicListEl.children().length >= 20) { - $('#load-more-btn').show(); - } - - $('#load-more-btn').on('click', function () { - TopicList.loadMoreTopics(1); - }); - - hooks.fire('action:topics.loaded', { topics: ajaxify.data.topics }); - }; - - function findTopicListElement() { - return $('[component="category"]').filter(function (i, e) { - return !$(e).parents('[widget-area],[data-widget-area]').length; - }); - } - - TopicList.watchForNewPosts = function () { - $('#new-topics-alert').on('click', function () { - $(this).addClass('hide'); - }); - newPostCount = 0; - newTopicCount = 0; - TopicList.removeListeners(); - socket.on('event:new_topic', onNewTopic); - socket.on('event:new_post', onNewPost); - }; - - TopicList.removeListeners = function () { - socket.removeListener('event:new_topic', onNewTopic); - socket.removeListener('event:new_post', onNewPost); - }; - - function onNewTopic(data) { - const d = ajaxify.data; - - const categories = d.selectedCids && - d.selectedCids.length && - d.selectedCids.indexOf(parseInt(data.cid, 10)) === -1; - const filterWatched = d.selectedFilter && - d.selectedFilter.filter === 'watched'; - const category = d.template.category && - parseInt(d.cid, 10) !== parseInt(data.cid, 10); - - const preventAlert = !!(categories || filterWatched || category || scheduledTopics.includes(data.tid)); - hooks.fire('filter:topicList.onNewTopic', { topic: data, preventAlert }).then((result) => { - if (result.preventAlert) { - return; - } - - if (data.scheduled && data.tid) { - scheduledTopics.push(data.tid); - } - newTopicCount += 1; - updateAlertText(); - }); - } - - function onNewPost(data) { - const post = data.posts[0]; - if (!post || !post.topic || post.topic.isFollowing) { - return; - } - - const d = ajaxify.data; - - const isMain = parseInt(post.topic.mainPid, 10) === parseInt(post.pid, 10); - const categories = d.selectedCids && - d.selectedCids.length && - d.selectedCids.indexOf(parseInt(post.topic.cid, 10)) === -1; - const filterNew = d.selectedFilter && - d.selectedFilter.filter === 'new'; - const filterWatched = d.selectedFilter && - d.selectedFilter.filter === 'watched' && - !post.topic.isFollowing; - const category = d.template.category && - parseInt(d.cid, 10) !== parseInt(post.topic.cid, 10); - - const preventAlert = !!(isMain || categories || filterNew || filterWatched || category); - hooks.fire('filter:topicList.onNewPost', { post, preventAlert }).then((result) => { - if (result.preventAlert) { - return; - } - - newPostCount += 1; - updateAlertText(); - }); - } - - function updateAlertText() { - let text = ''; - - if (newTopicCount === 0) { - if (newPostCount === 1) { - text = '[[recent:there-is-a-new-post]]'; - } else if (newPostCount > 1) { - text = '[[recent:there-are-new-posts, ' + newPostCount + ']]'; - } - } else if (newTopicCount === 1) { - if (newPostCount === 0) { - text = '[[recent:there-is-a-new-topic]]'; - } else if (newPostCount === 1) { - text = '[[recent:there-is-a-new-topic-and-a-new-post]]'; - } else if (newPostCount > 1) { - text = '[[recent:there-is-a-new-topic-and-new-posts, ' + newPostCount + ']]'; - } - } else if (newTopicCount > 1) { - if (newPostCount === 0) { - text = '[[recent:there-are-new-topics, ' + newTopicCount + ']]'; - } else if (newPostCount === 1) { - text = '[[recent:there-are-new-topics-and-a-new-post, ' + newTopicCount + ']]'; - } else if (newPostCount > 1) { - text = '[[recent:there-are-new-topics-and-new-posts, ' + newTopicCount + ', ' + newPostCount + ']]'; - } - } - - text += ' [[recent:click-here-to-reload]]'; - - $('#new-topics-alert').translateText(text).removeClass('hide').fadeIn('slow'); - $('#category-no-topics').addClass('hide'); - } - - TopicList.loadMoreTopics = function (direction) { - if (!topicListEl.length || !topicListEl.children().length) { - return; - } - const topics = topicListEl.find('[component="category/topic"]'); - const afterEl = direction > 0 ? topics.last() : topics.first(); - const after = (parseInt(afterEl.attr('data-index'), 10) || 0) + (direction > 0 ? 1 : 0); - - if (!utils.isNumber(after) || (after === 0 && topicListEl.find('[component="category/topic"][data-index="0"]').length)) { - return; - } - - loadTopicsCallback(after, direction, function (data, done) { - onTopicsLoaded(templateName, data.topics, ajaxify.data.showSelect, direction, done); - }); - }; - - function calculateNextPage(after, direction) { - return Math.floor(after / config.topicsPerPage) + (direction > 0 ? 1 : 0); - } - - function loadTopicsAfter(after, direction, callback) { - callback = callback || function () {}; - const query = utils.params(); - query.page = calculateNextPage(after, direction); - infinitescroll.loadMoreXhr(query, callback); - } - - function filterTopicsOnDom(topics) { - return topics.filter(function (topic) { - return !topicListEl.find('[component="category/topic"][data-tid="' + topic.tid + '"]').length; - }); - } - - function onTopicsLoaded(templateName, topics, showSelect, direction, callback) { - if (!topics || !topics.length) { - $('#load-more-btn').hide(); - return callback(); - } - topics = filterTopicsOnDom(topics); - - if (!topics.length) { - $('#load-more-btn').hide(); - return callback(); - } - - let after; - let before; - const topicEls = topicListEl.find('[component="category/topic"]'); - - if (direction > 0 && topics.length) { - after = topicEls.last(); - } else if (direction < 0 && topics.length) { - before = topicEls.first(); - } - - const tplData = { - topics: topics, - showSelect: showSelect, - template: { - name: templateName, - }, - }; - tplData.template[templateName] = true; - - hooks.fire('action:topics.loading', { topics: topics, after: after, before: before }); - - app.parseAndTranslate(templateName, 'topics', tplData, function (html) { - topicListEl.removeClass('hidden'); - $('#category-no-topics').remove(); - - if (after && after.length) { - html.insertAfter(after); - } else if (before && before.length) { - const height = $(document).height(); - const scrollTop = $(window).scrollTop(); - - html.insertBefore(before); - - $(window).scrollTop(scrollTop + ($(document).height() - height)); - } else { - topicListEl.append(html); - } - - if (!topicSelect.getSelectedTids().length) { - infinitescroll.removeExtra(topicListEl.find('[component="category/topic"]'), direction, Math.max(60, config.topicsPerPage * 3)); - } - - html.find('.timeago').timeago(); - app.createUserTooltips(html); - utils.makeNumbersHumanReadable(html.find('.human-readable-number')); - hooks.fire('action:topics.loaded', { topics: topics, template: templateName }); - callback(); - }); - } - - return TopicList; + 'forum/infinitescroll', + 'handleBack', + 'topicSelect', + 'categoryFilter', + 'forum/category/tools', + 'hooks', +], (infinitescroll, handleBack, topicSelect, categoryFilter, categoryTools, hooks) => { + const TopicList = {}; + let templateName = ''; + + let newTopicCount = 0; + let newPostCount = 0; + + let loadTopicsCallback; + let topicListElement; + + const scheduledTopics = []; + + $(window).on('action:ajaxify.start', () => { + TopicList.removeListeners(); + categoryTools.removeListeners(); + }); + + TopicList.init = function (template, callback) { + topicListElement = findTopicListElement(); + + templateName = template; + loadTopicsCallback = callback || loadTopicsAfter; + + categoryTools.init(); + + TopicList.watchForNewPosts(); + const states = ['watching']; + if (ajaxify.data.selectedFilter && ajaxify.data.selectedFilter.filter === 'watched') { + states.push('notwatching', 'ignoring'); + } else if (template !== 'unread') { + states.push('notwatching'); + } + + categoryFilter.init($('[component="category/dropdown"]'), { + states, + }); + + if (!config.usePagination) { + infinitescroll.init(TopicList.loadMoreTopics); + } + + handleBack.init((after, handleBackCallback) => { + loadTopicsCallback(after, 1, (data, loadCallback) => { + onTopicsLoaded(templateName, data.topics, ajaxify.data.showSelect, 1, () => { + handleBackCallback(); + loadCallback(); + }); + }); + }); + + if ($('body').height() <= $(window).height() && topicListElement.children().length >= 20) { + $('#load-more-btn').show(); + } + + $('#load-more-btn').on('click', () => { + TopicList.loadMoreTopics(1); + }); + + hooks.fire('action:topics.loaded', {topics: ajaxify.data.topics}); + }; + + function findTopicListElement() { + return $('[component="category"]').filter((i, e) => $(e).parents('[widget-area],[data-widget-area]').length === 0); + } + + TopicList.watchForNewPosts = function () { + $('#new-topics-alert').on('click', function () { + $(this).addClass('hide'); + }); + newPostCount = 0; + newTopicCount = 0; + TopicList.removeListeners(); + socket.on('event:new_topic', onNewTopic); + socket.on('event:new_post', onNewPost); + }; + + TopicList.removeListeners = function () { + socket.removeListener('event:new_topic', onNewTopic); + socket.removeListener('event:new_post', onNewPost); + }; + + function onNewTopic(data) { + const d = ajaxify.data; + + const categories = d.selectedCids + && d.selectedCids.length + && !d.selectedCids.includes(Number.parseInt(data.cid, 10)); + const filterWatched = d.selectedFilter + && d.selectedFilter.filter === 'watched'; + const category = d.template.category + && Number.parseInt(d.cid, 10) !== Number.parseInt(data.cid, 10); + + const preventAlert = Boolean(categories || filterWatched || category || scheduledTopics.includes(data.tid)); + hooks.fire('filter:topicList.onNewTopic', {topic: data, preventAlert}).then(result => { + if (result.preventAlert) { + return; + } + + if (data.scheduled && data.tid) { + scheduledTopics.push(data.tid); + } + + newTopicCount += 1; + updateAlertText(); + }); + } + + function onNewPost(data) { + const post = data.posts[0]; + if (!post || !post.topic || post.topic.isFollowing) { + return; + } + + const d = ajaxify.data; + + const isMain = Number.parseInt(post.topic.mainPid, 10) === Number.parseInt(post.pid, 10); + const categories = d.selectedCids + && d.selectedCids.length + && !d.selectedCids.includes(Number.parseInt(post.topic.cid, 10)); + const filterNew = d.selectedFilter + && d.selectedFilter.filter === 'new'; + const filterWatched = d.selectedFilter + && d.selectedFilter.filter === 'watched' + && !post.topic.isFollowing; + const category = d.template.category + && Number.parseInt(d.cid, 10) !== Number.parseInt(post.topic.cid, 10); + + const preventAlert = Boolean(isMain || categories || filterNew || filterWatched || category); + hooks.fire('filter:topicList.onNewPost', {post, preventAlert}).then(result => { + if (result.preventAlert) { + return; + } + + newPostCount += 1; + updateAlertText(); + }); + } + + function updateAlertText() { + let text = ''; + + if (newTopicCount === 0) { + if (newPostCount === 1) { + text = '[[recent:there-is-a-new-post]]'; + } else if (newPostCount > 1) { + text = '[[recent:there-are-new-posts, ' + newPostCount + ']]'; + } + } else if (newTopicCount === 1) { + if (newPostCount === 0) { + text = '[[recent:there-is-a-new-topic]]'; + } else if (newPostCount === 1) { + text = '[[recent:there-is-a-new-topic-and-a-new-post]]'; + } else if (newPostCount > 1) { + text = '[[recent:there-is-a-new-topic-and-new-posts, ' + newPostCount + ']]'; + } + } else if (newTopicCount > 1) { + if (newPostCount === 0) { + text = '[[recent:there-are-new-topics, ' + newTopicCount + ']]'; + } else if (newPostCount === 1) { + text = '[[recent:there-are-new-topics-and-a-new-post, ' + newTopicCount + ']]'; + } else if (newPostCount > 1) { + text = '[[recent:there-are-new-topics-and-new-posts, ' + newTopicCount + ', ' + newPostCount + ']]'; + } + } + + text += ' [[recent:click-here-to-reload]]'; + + $('#new-topics-alert').translateText(text).removeClass('hide').fadeIn('slow'); + $('#category-no-topics').addClass('hide'); + } + + TopicList.loadMoreTopics = function (direction) { + if (topicListElement.length === 0 || topicListElement.children().length === 0) { + return; + } + + const topics = topicListElement.find('[component="category/topic"]'); + const afterElement = direction > 0 ? topics.last() : topics.first(); + const after = (Number.parseInt(afterElement.attr('data-index'), 10) || 0) + (direction > 0 ? 1 : 0); + + if (!utils.isNumber(after) || (after === 0 && topicListElement.find('[component="category/topic"][data-index="0"]').length > 0)) { + return; + } + + loadTopicsCallback(after, direction, (data, done) => { + onTopicsLoaded(templateName, data.topics, ajaxify.data.showSelect, direction, done); + }); + }; + + function calculateNextPage(after, direction) { + return Math.floor(after / config.topicsPerPage) + (direction > 0 ? 1 : 0); + } + + function loadTopicsAfter(after, direction, callback) { + callback ||= function () {}; + const query = utils.params(); + query.page = calculateNextPage(after, direction); + infinitescroll.loadMoreXhr(query, callback); + } + + function filterTopicsOnDom(topics) { + return topics.filter(topic => topicListElement.find('[component="category/topic"][data-tid="' + topic.tid + '"]').length === 0); + } + + function onTopicsLoaded(templateName, topics, showSelect, direction, callback) { + if (!topics || topics.length === 0) { + $('#load-more-btn').hide(); + return callback(); + } + + topics = filterTopicsOnDom(topics); + + if (topics.length === 0) { + $('#load-more-btn').hide(); + return callback(); + } + + let after; + let before; + const topicEls = topicListElement.find('[component="category/topic"]'); + + if (direction > 0 && topics.length > 0) { + after = topicEls.last(); + } else if (direction < 0 && topics.length > 0) { + before = topicEls.first(); + } + + const tplData = { + topics, + showSelect, + template: { + name: templateName, + }, + }; + tplData.template[templateName] = true; + + hooks.fire('action:topics.loading', {topics, after, before}); + + app.parseAndTranslate(templateName, 'topics', tplData, html => { + topicListElement.removeClass('hidden'); + $('#category-no-topics').remove(); + + if (after && after.length > 0) { + html.insertAfter(after); + } else if (before && before.length > 0) { + const height = $(document).height(); + const scrollTop = $(window).scrollTop(); + + html.insertBefore(before); + + $(window).scrollTop(scrollTop + ($(document).height() - height)); + } else { + topicListElement.append(html); + } + + if (topicSelect.getSelectedTids().length === 0) { + infinitescroll.removeExtra(topicListElement.find('[component="category/topic"]'), direction, Math.max(60, config.topicsPerPage * 3)); + } + + html.find('.timeago').timeago(); + app.createUserTooltips(html); + utils.makeNumbersHumanReadable(html.find('.human-readable-number')); + hooks.fire('action:topics.loaded', {topics, template: templateName}); + callback(); + }); + } + + return TopicList; }); diff --git a/public/src/modules/topicSelect.js b/public/src/modules/topicSelect.js index 2767fea..45cdde1 100644 --- a/public/src/modules/topicSelect.js +++ b/public/src/modules/topicSelect.js @@ -1,88 +1,86 @@ 'use strict'; - -define('topicSelect', ['components'], function (components) { - const TopicSelect = {}; - let lastSelected; - - let topicsContainer; - - TopicSelect.init = function (onSelect) { - topicsContainer = $('[component="category"]'); - topicsContainer.on('selectstart', '[component="topic/select"]', function (ev) { - ev.preventDefault(); - }); - - topicsContainer.on('click', '[component="topic/select"]', function (ev) { - const select = $(this); - - if (ev.shiftKey) { - selectRange($(this).parents('[component="category/topic"]').attr('data-tid')); - lastSelected = select; - return false; - } - - const isSelected = select.parents('[data-tid]').hasClass('selected'); - toggleSelect(select, !isSelected); - lastSelected = select; - if (typeof onSelect === 'function') { - onSelect(); - } - }); - }; - - function toggleSelect(select, isSelected) { - select.toggleClass('fa-check-square-o', isSelected); - select.toggleClass('fa-square-o', !isSelected); - select.parents('[component="category/topic"]').toggleClass('selected', isSelected); - } - - TopicSelect.getSelectedTids = function () { - const tids = []; - if (!topicsContainer) { - return tids; - } - topicsContainer.find('[component="category/topic"].selected').each(function () { - tids.push($(this).attr('data-tid')); - }); - return tids; - }; - - TopicSelect.unselectAll = function () { - if (topicsContainer) { - topicsContainer.find('[component="category/topic"].selected').removeClass('selected'); - topicsContainer.find('[component="topic/select"]').toggleClass('fa-check-square-o', false).toggleClass('fa-square-o', true); - } - }; - - function selectRange(clickedTid) { - if (!lastSelected) { - lastSelected = $('[component="category/topic"]').first().find('[component="topic/select"]'); - } - - const isClickedSelected = components.get('category/topic', 'tid', clickedTid).hasClass('selected'); - - const clickedIndex = getIndex(clickedTid); - const lastIndex = getIndex(lastSelected.parents('[component="category/topic"]').attr('data-tid')); - selectIndexRange(clickedIndex, lastIndex, !isClickedSelected); - } - - function selectIndexRange(start, end, isSelected) { - if (start > end) { - const tmp = start; - start = end; - end = tmp; - } - - for (let i = start; i <= end; i += 1) { - const topic = $('[component="category/topic"]').eq(i); - toggleSelect(topic.find('[component="topic/select"]'), isSelected); - } - } - - function getIndex(tid) { - return components.get('category/topic', 'tid', tid).index('[component="category/topic"]'); - } - - return TopicSelect; +define('topicSelect', ['components'], components => { + const TopicSelect = {}; + let lastSelected; + + let topicsContainer; + + TopicSelect.init = function (onSelect) { + topicsContainer = $('[component="category"]'); + topicsContainer.on('selectstart', '[component="topic/select"]', event => { + event.preventDefault(); + }); + + topicsContainer.on('click', '[component="topic/select"]', function (event) { + const select = $(this); + + if (event.shiftKey) { + selectRange($(this).parents('[component="category/topic"]').attr('data-tid')); + lastSelected = select; + return false; + } + + const isSelected = select.parents('[data-tid]').hasClass('selected'); + toggleSelect(select, !isSelected); + lastSelected = select; + if (typeof onSelect === 'function') { + onSelect(); + } + }); + }; + + function toggleSelect(select, isSelected) { + select.toggleClass('fa-check-square-o', isSelected); + select.toggleClass('fa-square-o', !isSelected); + select.parents('[component="category/topic"]').toggleClass('selected', isSelected); + } + + TopicSelect.getSelectedTids = function () { + const tids = []; + if (!topicsContainer) { + return tids; + } + + topicsContainer.find('[component="category/topic"].selected').each(function () { + tids.push($(this).attr('data-tid')); + }); + return tids; + }; + + TopicSelect.unselectAll = function () { + if (topicsContainer) { + topicsContainer.find('[component="category/topic"].selected').removeClass('selected'); + topicsContainer.find('[component="topic/select"]').toggleClass('fa-check-square-o', false).toggleClass('fa-square-o', true); + } + }; + + function selectRange(clickedTid) { + lastSelected ||= $('[component="category/topic"]').first().find('[component="topic/select"]'); + + const isClickedSelected = components.get('category/topic', 'tid', clickedTid).hasClass('selected'); + + const clickedIndex = getIndex(clickedTid); + const lastIndex = getIndex(lastSelected.parents('[component="category/topic"]').attr('data-tid')); + selectIndexRange(clickedIndex, lastIndex, !isClickedSelected); + } + + function selectIndexRange(start, end, isSelected) { + if (start > end) { + const temporary = start; + start = end; + end = temporary; + } + + for (let i = start; i <= end; i += 1) { + const topic = $('[component="category/topic"]').eq(i); + toggleSelect(topic.find('[component="topic/select"]'), isSelected); + } + } + + function getIndex(tid) { + return components.get('category/topic', 'tid', tid).index('[component="category/topic"]'); + } + + return TopicSelect; }); diff --git a/public/src/modules/topicThumbs.js b/public/src/modules/topicThumbs.js index 761bf5d..ab78786 100644 --- a/public/src/modules/topicThumbs.js +++ b/public/src/modules/topicThumbs.js @@ -1,130 +1,130 @@ 'use strict'; define('topicThumbs', [ - 'api', 'bootbox', 'alerts', 'uploader', 'benchpress', 'translator', 'jquery-ui/widgets/sortable', -], function (api, bootbox, alerts, uploader, Benchpress, translator) { - const Thumbs = {}; - - Thumbs.get = id => api.get(`/topics/${id}/thumbs`, {}); - - Thumbs.getByPid = pid => api.get(`/posts/${pid}`, {}).then(post => Thumbs.get(post.tid)); - - Thumbs.delete = (id, path) => api.del(`/topics/${id}/thumbs`, { - path: path, - }); - - Thumbs.deleteAll = (id) => { - Thumbs.get(id).then((thumbs) => { - Promise.all(thumbs.map(thumb => Thumbs.delete(id, thumb.url))); - }); - }; - - Thumbs.upload = id => new Promise((resolve) => { - uploader.show({ - title: '[[topic:composer.thumb_title]]', - method: 'put', - route: config.relative_path + `/api/v3/topics/${id}/thumbs`, - }, function (url) { - resolve(url); - }); - }); - - Thumbs.modal = {}; - - Thumbs.modal.open = function (payload) { - const { id, pid } = payload; - let { modal } = payload; - let numThumbs; - - return new Promise((resolve) => { - Promise.all([ - Thumbs.get(id), - pid ? Thumbs.getByPid(pid) : [], - ]).then(results => new Promise((resolve) => { - const thumbs = results.reduce((memo, cur) => memo.concat(cur)); - numThumbs = thumbs.length; - - resolve(thumbs); - })).then(thumbs => Benchpress.render('modals/topic-thumbs', { thumbs })).then((html) => { - if (modal) { - translator.translate(html, function (translated) { - modal.find('.bootbox-body').html(translated); - Thumbs.modal.handleSort({ modal, numThumbs }); - }); - } else { - modal = bootbox.dialog({ - title: '[[modules:thumbs.modal.title]]', - message: html, - buttons: { - add: { - label: ' [[modules:thumbs.modal.add]]', - className: 'btn-success', - callback: () => { - Thumbs.upload(id).then(() => { - Thumbs.modal.open({ ...payload, modal }); - require(['composer'], (composer) => { - composer.updateThumbCount(id, $(`[component="composer"][data-uuid="${id}"]`)); - resolve(); - }); - }); - return false; - }, - }, - close: { - label: '[[global:close]]', - className: 'btn-primary', - }, - }, - }); - Thumbs.modal.handleDelete({ ...payload, modal }); - Thumbs.modal.handleSort({ modal, numThumbs }); - } - }); - }); - }; - - Thumbs.modal.handleDelete = (payload) => { - const modalEl = payload.modal.get(0); - - modalEl.addEventListener('click', (ev) => { - if (ev.target.closest('button[data-action="remove"]')) { - bootbox.confirm('[[modules:thumbs.modal.confirm-remove]]', (ok) => { - if (!ok) { - return; - } - - const id = ev.target.closest('.media[data-id]').getAttribute('data-id'); - const path = ev.target.closest('.media[data-path]').getAttribute('data-path'); - api.del(`/topics/${id}/thumbs`, { - path: path, - }).then(() => { - Thumbs.modal.open(payload); - }).catch(alerts.error); - }); - } - }); - }; - - Thumbs.modal.handleSort = ({ modal, numThumbs }) => { - if (numThumbs > 1) { - const selectorEl = modal.find('.topic-thumbs-modal'); - selectorEl.sortable({ - items: '[data-id]', - }); - selectorEl.on('sortupdate', Thumbs.modal.handleSortChange); - } - }; - - Thumbs.modal.handleSortChange = (ev, ui) => { - const items = ui.item.get(0).parentNode.querySelectorAll('[data-id]'); - Array.from(items).forEach((el, order) => { - const id = el.getAttribute('data-id'); - let path = el.getAttribute('data-path'); - path = path.replace(new RegExp(`^${config.upload_url}`), ''); - - api.put(`/topics/${id}/thumbs/order`, { path, order }).catch(alerts.error); - }); - }; - - return Thumbs; + 'api', 'bootbox', 'alerts', 'uploader', 'benchpress', 'translator', 'jquery-ui/widgets/sortable', +], (api, bootbox, alerts, uploader, Benchpress, translator) => { + const Thumbs = {}; + + Thumbs.get = id => api.get(`/topics/${id}/thumbs`, {}); + + Thumbs.getByPid = pid => api.get(`/posts/${pid}`, {}).then(post => Thumbs.get(post.tid)); + + Thumbs.delete = (id, path) => api.del(`/topics/${id}/thumbs`, { + path, + }); + + Thumbs.deleteAll = id => { + Thumbs.get(id).then(thumbs => { + Promise.all(thumbs.map(thumb => Thumbs.delete(id, thumb.url))); + }); + }; + + Thumbs.upload = id => new Promise(resolve => { + uploader.show({ + title: '[[topic:composer.thumb_title]]', + method: 'put', + route: config.relative_path + `/api/v3/topics/${id}/thumbs`, + }, url => { + resolve(url); + }); + }); + + Thumbs.modal = {}; + + Thumbs.modal.open = function (payload) { + const {id, pid} = payload; + let {modal} = payload; + let numberThumbs; + + return new Promise(resolve => { + Promise.all([ + Thumbs.get(id), + pid ? Thumbs.getByPid(pid) : [], + ]).then(results => new Promise(resolve => { + const thumbs = results.reduce((memo, current) => memo.concat(current)); + numberThumbs = thumbs.length; + + resolve(thumbs); + })).then(thumbs => Benchpress.render('modals/topic-thumbs', {thumbs})).then(html => { + if (modal) { + translator.translate(html, translated => { + modal.find('.bootbox-body').html(translated); + Thumbs.modal.handleSort({modal, numThumbs: numberThumbs}); + }); + } else { + modal = bootbox.dialog({ + title: '[[modules:thumbs.modal.title]]', + message: html, + buttons: { + add: { + label: ' [[modules:thumbs.modal.add]]', + className: 'btn-success', + callback() { + Thumbs.upload(id).then(() => { + Thumbs.modal.open({...payload, modal}); + require(['composer'], composer => { + composer.updateThumbCount(id, $(`[component="composer"][data-uuid="${id}"]`)); + resolve(); + }); + }); + return false; + }, + }, + close: { + label: '[[global:close]]', + className: 'btn-primary', + }, + }, + }); + Thumbs.modal.handleDelete({...payload, modal}); + Thumbs.modal.handleSort({modal, numThumbs: numberThumbs}); + } + }); + }); + }; + + Thumbs.modal.handleDelete = payload => { + const modalElement = payload.modal.get(0); + + modalElement.addEventListener('click', event => { + if (event.target.closest('button[data-action="remove"]')) { + bootbox.confirm('[[modules:thumbs.modal.confirm-remove]]', ok => { + if (!ok) { + return; + } + + const id = event.target.closest('.media[data-id]').dataset.id; + const path = event.target.closest('.media[data-path]').dataset.path; + api.del(`/topics/${id}/thumbs`, { + path, + }).then(() => { + Thumbs.modal.open(payload); + }).catch(alerts.error); + }); + } + }); + }; + + Thumbs.modal.handleSort = ({modal, numThumbs}) => { + if (numThumbs > 1) { + const selectorElement = modal.find('.topic-thumbs-modal'); + selectorElement.sortable({ + items: '[data-id]', + }); + selectorElement.on('sortupdate', Thumbs.modal.handleSortChange); + } + }; + + Thumbs.modal.handleSortChange = (event, ui) => { + const items = ui.item.get(0).parentNode.querySelectorAll('[data-id]'); + for (const [order, element] of Array.from(items).entries()) { + const id = element.dataset.id; + let path = element.dataset.path; + path = path.replace(new RegExp(`^${config.upload_url}`), ''); + + api.put(`/topics/${id}/thumbs/order`, {path, order}).catch(alerts.error); + } + }; + + return Thumbs; }); diff --git a/public/src/modules/translator.js b/public/src/modules/translator.js index c5e48c8..55462a5 100644 --- a/public/src/modules/translator.js +++ b/public/src/modules/translator.js @@ -2,24 +2,28 @@ const factory = require('./translator.common'); -define('translator', ['jquery', 'utils'], function (jQuery, utils) { - function loadClient(language, namespace) { - return new Promise(function (resolve, reject) { - jQuery.getJSON([config.asset_base_url, 'language', language, namespace].join('/') + '.json?' + config['cache-buster'], function (data) { - const payload = { - language: language, - namespace: namespace, - data: data, - }; - require(['hooks'], function (hooks) { - hooks.fire('action:translator.loadClient', payload); - resolve(payload.promise ? Promise.resolve(payload.promise) : data); - }); - }).fail(function (jqxhr, textStatus, error) { - reject(new Error(textStatus + ', ' + error)); - }); - }); - } - const warn = function () { console.warn.apply(console, arguments); }; - return factory(utils, loadClient, warn); +define('translator', ['jquery', 'utils'], (indexQuery, utils) => { + function loadClient(language, namespace) { + return new Promise((resolve, reject) => { + indexQuery.getJSON([config.asset_base_url, 'language', language, namespace].join('/') + '.json?' + config['cache-buster'], data => { + const payload = { + language, + namespace, + data, + }; + require(['hooks'], hooks => { + hooks.fire('action:translator.loadClient', payload); + resolve(payload.promise ? Promise.resolve(payload.promise) : data); + }); + }).fail((jqxhr, textStatus, error) => { + reject(new Error(textStatus + ', ' + error)); + }); + }); + } + + const warn = function () { + console.warn.apply(console, arguments); + }; + + return factory(utils, loadClient, warn); }); diff --git a/public/src/modules/uploadHelpers.js b/public/src/modules/uploadHelpers.js index e56f589..97fd6e1 100644 --- a/public/src/modules/uploadHelpers.js +++ b/public/src/modules/uploadHelpers.js @@ -1,199 +1,207 @@ 'use strict'; - -define('uploadHelpers', ['alerts'], function (alerts) { - const uploadHelpers = {}; - - uploadHelpers.init = function (options) { - const formEl = options.uploadFormEl; - if (!formEl.length) { - return; - } - formEl.attr('action', config.relative_path + options.route); - - if (options.dragDropAreaEl) { - uploadHelpers.handleDragDrop({ - container: options.dragDropAreaEl, - callback: function (upload) { - uploadHelpers.ajaxSubmit({ - uploadForm: formEl, - upload: upload, - callback: options.callback, - }); - }, - }); - } - - if (options.pasteEl) { - uploadHelpers.handlePaste({ - container: options.pasteEl, - callback: function (upload) { - uploadHelpers.ajaxSubmit({ - uploadForm: formEl, - upload: upload, - callback: options.callback, - }); - }, - }); - } - }; - - uploadHelpers.handleDragDrop = function (options) { - let draggingDocument = false; - const postContainer = options.container; - const drop = options.container.find('.imagedrop'); - - postContainer.on('dragenter', function onDragEnter() { - if (draggingDocument) { - return; - } - drop.css('top', '0px'); - drop.css('height', postContainer.height() + 'px'); - drop.css('line-height', postContainer.height() + 'px'); - drop.show(); - - drop.on('dragleave', function () { - drop.hide(); - drop.off('dragleave'); - }); - }); - - drop.on('drop', function onDragDrop(e) { - e.preventDefault(); - const files = e.originalEvent.dataTransfer.files; - - if (files.length) { - let formData; - if (window.FormData) { - formData = new FormData(); - for (var i = 0; i < files.length; ++i) { - formData.append('files[]', files[i], files[i].name); - } - } - options.callback({ - files: files, - formData: formData, - }); - } - - drop.hide(); - return false; - }); - - function cancel(e) { - e.preventDefault(); - return false; - } - - $(document) - .off('dragstart') - .on('dragstart', function () { - draggingDocument = true; - }) - .off('dragend') - .on('dragend, mouseup', function () { - draggingDocument = false; - }); - - drop.on('dragover', cancel); - drop.on('dragenter', cancel); - }; - - uploadHelpers.handlePaste = function (options) { - const container = options.container; - container.on('paste', function (event) { - const items = (event.clipboardData || event.originalEvent.clipboardData || {}).items; - const files = []; - const fileNames = []; - let formData = null; - if (window.FormData) { - formData = new FormData(); - } - [].forEach.call(items, function (item) { - const file = item.getAsFile(); - if (file) { - const fileName = utils.generateUUID() + '-' + file.name; - if (formData) { - formData.append('files[]', file, fileName); - } - files.push(file); - fileNames.push(fileName); - } - }); - - if (files.length) { - options.callback({ - files: files, - fileNames: fileNames, - formData: formData, - }); - } - }); - }; - - uploadHelpers.ajaxSubmit = function (options) { - const files = [...options.upload.files]; - - for (let i = 0; i < files.length; ++i) { - const isImage = files[i].type.match(/image./); - if ((isImage && !app.user.privileges['upload:post:image']) || (!isImage && !app.user.privileges['upload:post:file'])) { - return alerts.error('[[error:no-privileges]]'); - } - if (files[i].size > parseInt(config.maximumFileSize, 10) * 1024) { - options.uploadForm[0].reset(); - return alerts.error('[[error:file-too-big, ' + config.maximumFileSize + ']]'); - } - } - const alert_id = Date.now(); - options.uploadForm.off('submit').on('submit', function () { - $(this).ajaxSubmit({ - headers: { - 'x-csrf-token': config.csrf_token, - }, - resetForm: true, - clearForm: true, - formData: options.upload.formData, - error: function (xhr) { - let errorMsg = (xhr.responseJSON && - (xhr.responseJSON.error || (xhr.responseJSON.status && xhr.responseJSON.status.message))) || - '[[error:parse-error]]'; - - if (xhr && xhr.status === 413) { - errorMsg = xhr.statusText || 'Request Entity Too Large'; - } - alerts.error(errorMsg); - alerts.remove(alert_id); - }, - - uploadProgress: function (event, position, total, percent) { - alerts.alert({ - alert_id: alert_id, - message: '[[modules:composer.uploading, ' + percent + '%]]', - }); - }, - - success: function (res) { - const uploads = res.response.images; - if (uploads && uploads.length) { - for (var i = 0; i < uploads.length; ++i) { - uploads[i].filename = files[i].name; - uploads[i].isImage = /image./.test(files[i].type); - } - } - options.callback(uploads); - }, - - complete: function () { - options.uploadForm[0].reset(); - setTimeout(alerts.remove, 100, alert_id); - }, - }); - - return false; - }); - - options.uploadForm.submit(); - }; - - return uploadHelpers; +define('uploadHelpers', ['alerts'], alerts => { + const uploadHelpers = {}; + + uploadHelpers.init = function (options) { + const formElement = options.uploadFormEl; + if (formElement.length === 0) { + return; + } + + formElement.attr('action', config.relative_path + options.route); + + if (options.dragDropAreaEl) { + uploadHelpers.handleDragDrop({ + container: options.dragDropAreaEl, + callback(upload) { + uploadHelpers.ajaxSubmit({ + uploadForm: formElement, + upload, + callback: options.callback, + }); + }, + }); + } + + if (options.pasteEl) { + uploadHelpers.handlePaste({ + container: options.pasteEl, + callback(upload) { + uploadHelpers.ajaxSubmit({ + uploadForm: formElement, + upload, + callback: options.callback, + }); + }, + }); + } + }; + + uploadHelpers.handleDragDrop = function (options) { + let draggingDocument = false; + const postContainer = options.container; + const drop = options.container.find('.imagedrop'); + + postContainer.on('dragenter', function onDragEnter() { + if (draggingDocument) { + return; + } + + drop.css('top', '0px'); + drop.css('height', postContainer.height() + 'px'); + drop.css('line-height', postContainer.height() + 'px'); + drop.show(); + + drop.on('dragleave', () => { + drop.hide(); + drop.off('dragleave'); + }); + }); + + drop.on('drop', function onDragDrop(e) { + e.preventDefault(); + const files = e.originalEvent.dataTransfer.files; + + if (files.length > 0) { + let formData; + if (window.FormData) { + formData = new FormData(); + for (const file of files) { + formData.append('files[]', file, file.name); + } + } + + options.callback({ + files, + formData, + }); + } + + drop.hide(); + return false; + }); + + function cancel(e) { + e.preventDefault(); + return false; + } + + $(document) + .off('dragstart') + .on('dragstart', () => { + draggingDocument = true; + }) + .off('dragend') + .on('dragend, mouseup', () => { + draggingDocument = false; + }); + + drop.on('dragover', cancel); + drop.on('dragenter', cancel); + }; + + uploadHelpers.handlePaste = function (options) { + const container = options.container; + container.on('paste', event => { + const items = (event.clipboardData || event.originalEvent.clipboardData || {}).items; + const files = []; + const fileNames = []; + let formData = null; + if (window.FormData) { + formData = new FormData(); + } + + Array.prototype.forEach.call(items, item => { + const file = item.getAsFile(); + if (file) { + const fileName = utils.generateUUID() + '-' + file.name; + if (formData) { + formData.append('files[]', file, fileName); + } + + files.push(file); + fileNames.push(fileName); + } + }); + + if (files.length > 0) { + options.callback({ + files, + fileNames, + formData, + }); + } + }); + }; + + uploadHelpers.ajaxSubmit = function (options) { + const files = [...options.upload.files]; + + for (const file of files) { + const isImage = file.type.match(/image./); + if ((isImage && !app.user.privileges['upload:post:image']) || (!isImage && !app.user.privileges['upload:post:file'])) { + return alerts.error('[[error:no-privileges]]'); + } + + if (file.size > Number.parseInt(config.maximumFileSize, 10) * 1024) { + options.uploadForm[0].reset(); + return alerts.error('[[error:file-too-big, ' + config.maximumFileSize + ']]'); + } + } + + const alert_id = Date.now(); + options.uploadForm.off('submit').on('submit', function () { + $(this).ajaxSubmit({ + headers: { + 'x-csrf-token': config.csrf_token, + }, + resetForm: true, + clearForm: true, + formData: options.upload.formData, + error(xhr) { + let errorMessage = (xhr.responseJSON + && (xhr.responseJSON.error || (xhr.responseJSON.status && xhr.responseJSON.status.message))) + || '[[error:parse-error]]'; + + if (xhr && xhr.status === 413) { + errorMessage = xhr.statusText || 'Request Entity Too Large'; + } + + alerts.error(errorMessage); + alerts.remove(alert_id); + }, + + uploadProgress(event, position, total, percent) { + alerts.alert({ + alert_id, + message: '[[modules:composer.uploading, ' + percent + '%]]', + }); + }, + + success(res) { + const uploads = res.response.images; + if (uploads && uploads.length > 0) { + for (const [i, upload] of uploads.entries()) { + upload.filename = files[i].name; + upload.isImage = /image./.test(files[i].type); + } + } + + options.callback(uploads); + }, + + complete() { + options.uploadForm[0].reset(); + setTimeout(alerts.remove, 100, alert_id); + }, + }); + + return false; + }); + + options.uploadForm.submit(); + }; + + return uploadHelpers; }); diff --git a/public/src/modules/uploader.js b/public/src/modules/uploader.js index 62210a2..9393443 100644 --- a/public/src/modules/uploader.js +++ b/public/src/modules/uploader.js @@ -1,118 +1,121 @@ 'use strict'; - -define('uploader', ['jquery-form'], function () { - const module = {}; - - module.show = function (data, callback) { - const fileSize = data.hasOwnProperty('fileSize') && data.fileSize !== undefined ? parseInt(data.fileSize, 10) : false; - app.parseAndTranslate('partials/modals/upload_file_modal', { - showHelp: data.hasOwnProperty('showHelp') && data.showHelp !== undefined ? data.showHelp : true, - fileSize: fileSize, - title: data.title || '[[global:upload_file]]', - description: data.description || '', - button: data.button || '[[global:upload]]', - accept: data.accept ? data.accept.replace(/,/g, ', ') : '', - }, function (uploadModal) { - uploadModal.modal('show'); - uploadModal.on('hidden.bs.modal', function () { - uploadModal.remove(); - }); - - const uploadForm = uploadModal.find('#uploadForm'); - uploadForm.attr('action', data.route); - uploadForm.find('#params').val(JSON.stringify(data.params)); - - uploadModal.find('#fileUploadSubmitBtn').on('click', function () { - $(this).addClass('disabled'); - uploadForm.submit(); - }); - - uploadForm.submit(function () { - onSubmit(uploadModal, fileSize, callback); - return false; - }); - }); - }; - - module.hideAlerts = function (modal) { - $(modal).find('#alert-status, #alert-success, #alert-error, #upload-progress-box').addClass('hide'); - }; - - function onSubmit(uploadModal, fileSize, callback) { - showAlert(uploadModal, 'status', '[[uploads:uploading-file]]'); - - uploadModal.find('#upload-progress-bar').css('width', '0%'); - uploadModal.find('#upload-progress-box').show().removeClass('hide'); - - const fileInput = uploadModal.find('#fileInput'); - if (!fileInput.val()) { - return showAlert(uploadModal, 'error', '[[uploads:select-file-to-upload]]'); - } - if (!hasValidFileSize(fileInput[0], fileSize)) { - return showAlert(uploadModal, 'error', '[[error:file-too-big, ' + fileSize + ']]'); - } - - module.ajaxSubmit(uploadModal, callback); - } - - function showAlert(uploadModal, type, message) { - module.hideAlerts(uploadModal); - if (type === 'error') { - uploadModal.find('#fileUploadSubmitBtn').removeClass('disabled'); - } - uploadModal.find('#alert-' + type).translateText(message).removeClass('hide'); - } - - module.ajaxSubmit = function (uploadModal, callback) { - const uploadForm = uploadModal.find('#uploadForm'); - uploadForm.ajaxSubmit({ - headers: { - 'x-csrf-token': config.csrf_token, - }, - error: function (xhr) { - xhr = maybeParse(xhr); - showAlert(uploadModal, 'error', xhr.responseJSON ? (xhr.responseJSON.error || xhr.statusText) : 'error uploading, code : ' + xhr.status); - }, - uploadProgress: function (event, position, total, percent) { - uploadModal.find('#upload-progress-bar').css('width', percent + '%'); - }, - success: function (response) { - let images = maybeParse(response); - - // Appropriately handle v3 API responses - if (response.hasOwnProperty('response') && response.hasOwnProperty('status') && response.status.code === 'ok') { - images = response.response.images; - } - - callback(images[0].url); - - showAlert(uploadModal, 'success', '[[uploads:upload-success]]'); - setTimeout(function () { - module.hideAlerts(uploadModal); - uploadModal.modal('hide'); - }, 750); - }, - }); - }; - - function maybeParse(response) { - if (typeof response === 'string') { - try { - return $.parseJSON(response); - } catch (e) { - return { error: '[[error:parse-error]]' }; - } - } - return response; - } - - function hasValidFileSize(fileElement, maxSize) { - if (window.FileReader && maxSize) { - return fileElement.files[0].size <= maxSize * 1000; - } - return true; - } - - return module; +define('uploader', ['jquery-form'], () => { + const module = {}; + + module.show = function (data, callback) { + const fileSize = data.hasOwnProperty('fileSize') && data.fileSize !== undefined ? Number.parseInt(data.fileSize, 10) : false; + app.parseAndTranslate('partials/modals/upload_file_modal', { + showHelp: data.hasOwnProperty('showHelp') && data.showHelp !== undefined ? data.showHelp : true, + fileSize, + title: data.title || '[[global:upload_file]]', + description: data.description || '', + button: data.button || '[[global:upload]]', + accept: data.accept ? data.accept.replaceAll(',', ', ') : '', + }, uploadModal => { + uploadModal.modal('show'); + uploadModal.on('hidden.bs.modal', () => { + uploadModal.remove(); + }); + + const uploadForm = uploadModal.find('#uploadForm'); + uploadForm.attr('action', data.route); + uploadForm.find('#params').val(JSON.stringify(data.params)); + + uploadModal.find('#fileUploadSubmitBtn').on('click', function () { + $(this).addClass('disabled'); + uploadForm.submit(); + }); + + uploadForm.submit(() => { + onSubmit(uploadModal, fileSize, callback); + return false; + }); + }); + }; + + module.hideAlerts = function (modal) { + $(modal).find('#alert-status, #alert-success, #alert-error, #upload-progress-box').addClass('hide'); + }; + + function onSubmit(uploadModal, fileSize, callback) { + showAlert(uploadModal, 'status', '[[uploads:uploading-file]]'); + + uploadModal.find('#upload-progress-bar').css('width', '0%'); + uploadModal.find('#upload-progress-box').show().removeClass('hide'); + + const fileInput = uploadModal.find('#fileInput'); + if (!fileInput.val()) { + return showAlert(uploadModal, 'error', '[[uploads:select-file-to-upload]]'); + } + + if (!hasValidFileSize(fileInput[0], fileSize)) { + return showAlert(uploadModal, 'error', '[[error:file-too-big, ' + fileSize + ']]'); + } + + module.ajaxSubmit(uploadModal, callback); + } + + function showAlert(uploadModal, type, message) { + module.hideAlerts(uploadModal); + if (type === 'error') { + uploadModal.find('#fileUploadSubmitBtn').removeClass('disabled'); + } + + uploadModal.find('#alert-' + type).translateText(message).removeClass('hide'); + } + + module.ajaxSubmit = function (uploadModal, callback) { + const uploadForm = uploadModal.find('#uploadForm'); + uploadForm.ajaxSubmit({ + headers: { + 'x-csrf-token': config.csrf_token, + }, + error(xhr) { + xhr = maybeParse(xhr); + showAlert(uploadModal, 'error', xhr.responseJSON ? (xhr.responseJSON.error || xhr.statusText) : 'error uploading, code : ' + xhr.status); + }, + uploadProgress(event, position, total, percent) { + uploadModal.find('#upload-progress-bar').css('width', percent + '%'); + }, + success(response) { + let images = maybeParse(response); + + // Appropriately handle v3 API responses + if (response.hasOwnProperty('response') && response.hasOwnProperty('status') && response.status.code === 'ok') { + images = response.response.images; + } + + callback(images[0].url); + + showAlert(uploadModal, 'success', '[[uploads:upload-success]]'); + setTimeout(() => { + module.hideAlerts(uploadModal); + uploadModal.modal('hide'); + }, 750); + }, + }); + }; + + function maybeParse(response) { + if (typeof response === 'string') { + try { + return $.parseJSON(response); + } catch { + return {error: '[[error:parse-error]]'}; + } + } + + return response; + } + + function hasValidFileSize(fileElement, maxSize) { + if (window.FileReader && maxSize) { + return fileElement.files[0].size <= maxSize * 1000; + } + + return true; + } + + return module; }); diff --git a/public/src/overrides.js b/public/src/overrides.js index 3cae763..2cd8bfa 100644 --- a/public/src/overrides.js +++ b/public/src/overrides.js @@ -4,159 +4,161 @@ const translator = require('./modules/translator'); window.overrides = window.overrides || {}; -function translate(elements, type, str) { - return elements.each(function () { - var el = $(this); - translator.translate(str, function (translated) { - el[type](translated); - }); - }); +function translate(elements, type, string_) { + return elements.each(function () { + const element = $(this); + translator.translate(string_, translated => { + element[type](translated); + }); + }); } if (typeof window !== 'undefined') { - (function ($) { - $.fn.getCursorPosition = function () { - const el = $(this).get(0); - let pos = 0; - if ('selectionStart' in el) { - pos = el.selectionStart; - } else if ('selection' in document) { - el.focus(); - const Sel = document.selection.createRange(); - const SelLength = document.selection.createRange().text.length; - Sel.moveStart('character', -el.value.length); - pos = Sel.text.length - SelLength; - } - return pos; - }; - - $.fn.selectRange = function (start, end) { - if (!end) { - end = start; - } - return this.each(function () { - if (this.setSelectionRange) { - this.focus(); - this.setSelectionRange(start, end); - } else if (this.createTextRange) { - const range = this.createTextRange(); - range.collapse(true); - range.moveEnd('character', end); - range.moveStart('character', start); - range.select(); - } - }); - }; - - // http://stackoverflow.com/questions/511088/use-javascript-to-place-cursor-at-end-of-text-in-text-input-element - $.fn.putCursorAtEnd = function () { - return this.each(function () { - $(this).focus(); - - if (this.setSelectionRange) { - const len = $(this).val().length * 2; - this.setSelectionRange(len, len); - } else { - $(this).val($(this).val()); - } - this.scrollTop = 999999; - }); - }; - - $.fn.translateHtml = function (str) { - return translate(this, 'html', str); - }; - - $.fn.translateText = function (str) { - return translate(this, 'text', str); - }; - - $.fn.translateVal = function (str) { - return translate(this, 'val', str); - }; - - $.fn.translateAttr = function (attr, str) { - return this.each(function () { - const el = $(this); - translator.translate(str, function (translated) { - el.attr(attr, translated); - }); - }); - }; - }(jQuery || { fn: {} })); - - (function () { - // FIX FOR #1245 - https://github.com/NodeBB/NodeBB/issues/1245 - // from http://stackoverflow.com/questions/15931962/bootstrap-dropdown-disappear-with-right-click-on-firefox - // obtain a reference to the original handler - let _clearMenus = $._data(document, 'events').click.filter(function (el) { - return el.namespace === 'bs.data-api.dropdown' && el.selector === undefined; - }); - - if (_clearMenus.length) { - _clearMenus = _clearMenus[0].handler; - } - - // disable the old listener - $(document) - .off('click.data-api.dropdown', _clearMenus) - .on('click.data-api.dropdown', function (e) { - // call the handler only when not right-click - if (e.button !== 2) { - _clearMenus(); - } - }); - }()); - let timeagoFn; - overrides.overrideTimeagoCutoff = function () { - const cutoff = parseInt(ajaxify.data.timeagoCutoff || config.timeagoCutoff, 10); - if (cutoff === 0) { - $.timeago.settings.cutoff = 1; - } else if (cutoff > 0) { - $.timeago.settings.cutoff = 1000 * 60 * 60 * 24 * cutoff; - } - }; - - overrides.overrideTimeago = function () { - if (!timeagoFn) { - timeagoFn = $.fn.timeago; - } - - overrides.overrideTimeagoCutoff(); - - $.timeago.settings.allowFuture = true; - const userLang = config.userLang.replace('_', '-'); - const options = { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' }; - let formatFn = function (date) { - return date.toLocaleString(userLang, options); - }; - try { - if (typeof Intl !== 'undefined') { - const dtFormat = new Intl.DateTimeFormat(userLang, options); - formatFn = dtFormat.format; - } - } catch (err) { - console.error(err); - } - - let iso; - let date; - $.fn.timeago = function () { - const els = $(this); - // Convert "old" format to new format (#5108) - els.each(function () { - iso = this.getAttribute('title'); - if (!iso) { - return; - } - this.setAttribute('datetime', iso); - date = new Date(iso); - if (!isNaN(date)) { - this.textContent = formatFn(date); - } - }); - - timeagoFn.apply(this, arguments); - }; - }; + (function ($) { + $.fn.getCursorPosition = function () { + const element = $(this).get(0); + let pos = 0; + if ('selectionStart' in element) { + pos = element.selectionStart; + } else if ('selection' in document) { + element.focus(); + const Sel = document.selection.createRange(); + const SelLength = document.selection.createRange().text.length; + Sel.moveStart('character', -element.value.length); + pos = Sel.text.length - SelLength; + } + + return pos; + }; + + $.fn.selectRange = function (start, end) { + end ||= start; + + return this.each(function () { + if (this.setSelectionRange) { + this.focus(); + this.setSelectionRange(start, end); + } else if (this.createTextRange) { + const range = this.createTextRange(); + range.collapse(true); + range.moveEnd('character', end); + range.moveStart('character', start); + range.select(); + } + }); + }; + + // http://stackoverflow.com/questions/511088/use-javascript-to-place-cursor-at-end-of-text-in-text-input-element + $.fn.putCursorAtEnd = function () { + return this.each(function () { + $(this).focus(); + + if (this.setSelectionRange) { + const length = $(this).val().length * 2; + this.setSelectionRange(length, length); + } else { + $(this).val($(this).val()); + } + + this.scrollTop = 999_999; + }); + }; + + $.fn.translateHtml = function (string_) { + return translate(this, 'html', string_); + }; + + $.fn.translateText = function (string_) { + return translate(this, 'text', string_); + }; + + $.fn.translateVal = function (string_) { + return translate(this, 'val', string_); + }; + + $.fn.translateAttr = function (attribute, string_) { + return this.each(function () { + const element = $(this); + translator.translate(string_, translated => { + element.attr(attribute, translated); + }); + }); + }; + })(jQuery || {fn: {}}); + + (function () { + // FIX FOR #1245 - https://github.com/NodeBB/NodeBB/issues/1245 + // from http://stackoverflow.com/questions/15931962/bootstrap-dropdown-disappear-with-right-click-on-firefox + // obtain a reference to the original handler + let _clearMenus = $._data(document, 'events').click.filter(element => element.namespace === 'bs.data-api.dropdown' && element.selector === undefined); + + if (_clearMenus.length > 0) { + _clearMenus = _clearMenus[0].handler; + } + + // Disable the old listener + $(document) + .off('click.data-api.dropdown', _clearMenus) + .on('click.data-api.dropdown', e => { + // Call the handler only when not right-click + if (e.button !== 2) { + _clearMenus(); + } + }); + })(); + + let timeagoFunction; + overrides.overrideTimeagoCutoff = function () { + const cutoff = Number.parseInt(ajaxify.data.timeagoCutoff || config.timeagoCutoff, 10); + if (cutoff === 0) { + $.timeago.settings.cutoff = 1; + } else if (cutoff > 0) { + $.timeago.settings.cutoff = 1000 * 60 * 60 * 24 * cutoff; + } + }; + + overrides.overrideTimeago = function () { + timeagoFunction ||= $.fn.timeago; + + overrides.overrideTimeagoCutoff(); + + $.timeago.settings.allowFuture = true; + const userLang = config.userLang.replace('_', '-'); + const options = { + year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', + }; + let formatFunction = function (date) { + return date.toLocaleString(userLang, options); + }; + + try { + if (typeof Intl !== 'undefined') { + const dtFormat = new Intl.DateTimeFormat(userLang, options); + formatFunction = dtFormat.format; + } + } catch (error) { + console.error(error); + } + + let iso; + let date; + $.fn.timeago = function () { + const els = $(this); + // Convert "old" format to new format (#5108) + els.each(function () { + iso = this.getAttribute('title'); + if (!iso) { + return; + } + + this.setAttribute('datetime', iso); + date = new Date(iso); + if (!isNaN(date)) { + this.textContent = formatFunction(date); + } + }); + + Reflect.apply(timeagoFunction, this, arguments); + }; + }; } diff --git a/public/src/service-worker.js b/public/src/service-worker.js index 883a7dc..80714ad 100644 --- a/public/src/service-worker.js +++ b/public/src/service-worker.js @@ -1,19 +1,19 @@ 'use strict'; -self.addEventListener('fetch', function (event) { - // This is the code that ignores post requests - // https://github.com/NodeBB/NodeBB/issues/9151 - // https://github.com/w3c/ServiceWorker/issues/1141 - // https://stackoverflow.com/questions/54448367/ajax-xmlhttprequest-progress-monitoring-doesnt-work-with-service-workers - if (event.request.method === 'POST') { - return; - } +self.addEventListener('fetch', event => { + // This is the code that ignores post requests + // https://github.com/NodeBB/NodeBB/issues/9151 + // https://github.com/w3c/ServiceWorker/issues/1141 + // https://stackoverflow.com/questions/54448367/ajax-xmlhttprequest-progress-monitoring-doesnt-work-with-service-workers + if (event.request.method === 'POST') { + return; + } - event.respondWith(caches.match(event.request).then(function (response) { - if (!response) { - return fetch(event.request); - } + event.respondWith(caches.match(event.request).then(response => { + if (!response) { + return fetch(event.request); + } - return response; - })); + return response; + })); }); diff --git a/public/src/sockets.js b/public/src/sockets.js index 1665726..b913742 100644 --- a/public/src/sockets.js +++ b/public/src/sockets.js @@ -1,257 +1,259 @@ 'use strict'; -// eslint-disable-next-line no-redeclare const io = require('socket.io-client'); -// eslint-disable-next-line no-redeclare const $ = require('jquery'); app = window.app || {}; (function () { - let reconnecting = false; - - const ioParams = { - reconnectionAttempts: config.maxReconnectionAttempts, - reconnectionDelay: config.reconnectionDelay, - transports: config.socketioTransports, - path: config.relative_path + '/socket.io', - }; - - window.socket = io(config.websocketAddress, ioParams); - - const oEmit = socket.emit; - socket.emit = function (event, data, callback) { - if (typeof data === 'function') { - callback = data; - data = null; - } - if (typeof callback === 'function') { - oEmit.apply(socket, [event, data, callback]); - return; - } - - return new Promise(function (resolve, reject) { - oEmit.apply(socket, [event, data, function (err, result) { - if (err) reject(err); - else resolve(result); - }]); - }); - }; - - let hooks; - require(['hooks'], function (_hooks) { - hooks = _hooks; - if (parseInt(app.user.uid, 10) >= 0) { - addHandlers(); - } - }); - - window.app.reconnect = () => { - if (socket.connected) { - return; - } - - const reconnectEl = $('#reconnect'); - $('#reconnect-alert') - .removeClass('alert-danger pointer') - .addClass('alert-warning') - .find('p') - .translateText(`[[global:reconnecting-message, ${config.siteTitle}]]`); - - reconnectEl.html(''); - socket.connect(); - }; - - function addHandlers() { - socket.on('connect', onConnect); - - socket.on('disconnect', onDisconnect); - - socket.io.on('reconnect_failed', function () { - const reconnectEl = $('#reconnect'); - reconnectEl.html(''); - - $('#reconnect-alert') - .removeClass('alert-warning') - .addClass('alert-danger pointer') - .find('p') - .translateText('[[error:socket-reconnect-failed]]') - .one('click', app.reconnect); - - $(window).one('focus', app.reconnect); - }); - - socket.on('checkSession', function (uid) { - if (parseInt(uid, 10) !== parseInt(app.user.uid, 10)) { - handleSessionMismatch(); - } - }); - socket.on('event:invalid_session', () => { - handleInvalidSession(); - }); - - socket.on('setHostname', function (hostname) { - app.upstreamHost = hostname; - }); - - socket.on('event:banned', onEventBanned); - socket.on('event:unbanned', onEventUnbanned); - socket.on('event:logout', function () { - require(['logout'], function (logout) { - logout(); - }); - }); - socket.on('event:alert', function (params) { - require(['alerts'], function (alerts) { - alerts.alert(params); - }); - }); - socket.on('event:deprecated_call', function (data) { - console.warn('[socket.io] ', data.eventName, 'is now deprecated in favour of', data.replacement); - }); - - socket.removeAllListeners('event:nodebb.ready'); - socket.on('event:nodebb.ready', function (data) { - if ((data.hostname === app.upstreamHost) && (!app.cacheBuster || app.cacheBuster !== data['cache-buster'])) { - app.cacheBuster = data['cache-buster']; - require(['alerts'], function (alerts) { - alerts.alert({ - alert_id: 'forum_updated', - title: '[[global:updated.title]]', - message: '[[global:updated.message]]', - clickfn: function () { - window.location.reload(); - }, - type: 'warning', - }); - }); - } - }); - socket.on('event:livereload', function () { - if (app.user.isAdmin && !ajaxify.currentPage.match(/admin/)) { - window.location.reload(); - } - }); - } - - function handleInvalidSession() { - socket.disconnect(); - require(['messages', 'logout'], function (messages, logout) { - logout(false); - messages.showInvalidSession(); - }); - } - - function handleSessionMismatch() { - if (app.flags._login || app.flags._logout) { - return; - } - - socket.disconnect(); - require(['messages'], function (messages) { - messages.showSessionMismatch(); - }); - } - - function onConnect() { - if (!reconnecting) { - hooks.fire('action:connected'); - } - - if (reconnecting) { - const reconnectEl = $('#reconnect'); - const reconnectAlert = $('#reconnect-alert'); - - reconnectEl.tooltip('destroy'); - reconnectEl.html(''); - reconnectAlert.addClass('hide'); - reconnecting = false; - - reJoinCurrentRoom(); - - socket.emit('meta.reconnected'); - - hooks.fire('action:reconnected'); - - setTimeout(function () { - reconnectEl.removeClass('active').addClass('hide'); - }, 3000); - } - } - - function reJoinCurrentRoom() { - if (app.currentRoom) { - const current = app.currentRoom; - app.currentRoom = ''; - app.enterRoom(current); - } - } - - function onReconnecting() { - reconnecting = true; - const reconnectEl = $('#reconnect'); - const reconnectAlert = $('#reconnect-alert'); - - if (!reconnectEl.hasClass('active')) { - reconnectEl.html(''); - reconnectAlert.removeClass('hide'); - } - - reconnectEl.addClass('active').removeClass('hide').tooltip({ - placement: 'bottom', - }); - } - - function onDisconnect() { - setTimeout(function () { - if (socket.disconnected) { - onReconnecting(); - } - }, 2000); - - hooks.fire('action:disconnected'); - } - - function onEventBanned(data) { - require(['bootbox', 'translator'], function (bootbox, translator) { - const message = data.until ? - translator.compile('error:user-banned-reason-until', (new Date(data.until).toLocaleString()), data.reason) : - '[[error:user-banned-reason, ' + data.reason + ']]'; - translator.translate(message, function (message) { - bootbox.alert({ - title: '[[error:user-banned]]', - message: message, - closeButton: false, - callback: function () { - window.location.href = config.relative_path + '/'; - }, - }); - }); - }); - } - - function onEventUnbanned() { - require(['bootbox'], function (bootbox) { - bootbox.alert({ - title: '[[global:alert.unbanned]]', - message: '[[global:alert.unbanned.message]]', - closeButton: false, - callback: function () { - window.location.href = config.relative_path + '/'; - }, - }); - }); - } - - if ( - config.socketioOrigins && - config.socketioOrigins !== '*:*' && - config.socketioOrigins.indexOf(location.hostname) === -1 - ) { - console.error( - 'You are accessing the forum from an unknown origin. This will likely result in websockets failing to connect. \n' + - 'To fix this, set the `"url"` value in `config.json` to the URL at which you access the site. \n' + - 'For more information, see this FAQ topic: https://community.nodebb.org/topic/13388' - ); - } -}()); + let reconnecting = false; + + const ioParameters = { + reconnectionAttempts: config.maxReconnectionAttempts, + reconnectionDelay: config.reconnectionDelay, + transports: config.socketioTransports, + path: config.relative_path + '/socket.io', + }; + + window.socket = io(config.websocketAddress, ioParameters); + + const oEmit = socket.emit; + socket.emit = function (event, data, callback) { + if (typeof data === 'function') { + callback = data; + data = null; + } + + if (typeof callback === 'function') { + oEmit.apply(socket, [event, data, callback]); + return; + } + + return new Promise((resolve, reject) => { + oEmit.apply(socket, [event, data, function (error, result) { + if (error) { + reject(error); + } else { + resolve(result); + } + }]); + }); + }; + + let hooks; + require(['hooks'], _hooks => { + hooks = _hooks; + if (Number.parseInt(app.user.uid, 10) >= 0) { + addHandlers(); + } + }); + + window.app.reconnect = () => { + if (socket.connected) { + return; + } + + const reconnectElement = $('#reconnect'); + $('#reconnect-alert') + .removeClass('alert-danger pointer') + .addClass('alert-warning') + .find('p') + .translateText(`[[global:reconnecting-message, ${config.siteTitle}]]`); + + reconnectElement.html(''); + socket.connect(); + }; + + function addHandlers() { + socket.on('connect', onConnect); + + socket.on('disconnect', onDisconnect); + + socket.io.on('reconnect_failed', () => { + const reconnectElement = $('#reconnect'); + reconnectElement.html(''); + + $('#reconnect-alert') + .removeClass('alert-warning') + .addClass('alert-danger pointer') + .find('p') + .translateText('[[error:socket-reconnect-failed]]') + .one('click', app.reconnect); + + $(window).one('focus', app.reconnect); + }); + + socket.on('checkSession', uid => { + if (Number.parseInt(uid, 10) !== Number.parseInt(app.user.uid, 10)) { + handleSessionMismatch(); + } + }); + socket.on('event:invalid_session', () => { + handleInvalidSession(); + }); + + socket.on('setHostname', hostname => { + app.upstreamHost = hostname; + }); + + socket.on('event:banned', onEventBanned); + socket.on('event:unbanned', onEventUnbanned); + socket.on('event:logout', () => { + require(['logout'], logout => { + logout(); + }); + }); + socket.on('event:alert', parameters => { + require(['alerts'], alerts => { + alerts.alert(parameters); + }); + }); + socket.on('event:deprecated_call', data => { + console.warn('[socket.io]', data.eventName, 'is now deprecated in favour of', data.replacement); + }); + + socket.removeAllListeners('event:nodebb.ready'); + socket.on('event:nodebb.ready', data => { + if ((data.hostname === app.upstreamHost) && (!app.cacheBuster || app.cacheBuster !== data['cache-buster'])) { + app.cacheBuster = data['cache-buster']; + require(['alerts'], alerts => { + alerts.alert({ + alert_id: 'forum_updated', + title: '[[global:updated.title]]', + message: '[[global:updated.message]]', + clickfn() { + window.location.reload(); + }, + type: 'warning', + }); + }); + } + }); + socket.on('event:livereload', () => { + if (app.user.isAdmin && !/admin/.test(ajaxify.currentPage)) { + window.location.reload(); + } + }); + } + + function handleInvalidSession() { + socket.disconnect(); + require(['messages', 'logout'], (messages, logout) => { + logout(false); + messages.showInvalidSession(); + }); + } + + function handleSessionMismatch() { + if (app.flags._login || app.flags._logout) { + return; + } + + socket.disconnect(); + require(['messages'], messages => { + messages.showSessionMismatch(); + }); + } + + function onConnect() { + if (!reconnecting) { + hooks.fire('action:connected'); + } + + if (reconnecting) { + const reconnectElement = $('#reconnect'); + const reconnectAlert = $('#reconnect-alert'); + + reconnectElement.tooltip('destroy'); + reconnectElement.html(''); + reconnectAlert.addClass('hide'); + reconnecting = false; + + reJoinCurrentRoom(); + + socket.emit('meta.reconnected'); + + hooks.fire('action:reconnected'); + + setTimeout(() => { + reconnectElement.removeClass('active').addClass('hide'); + }, 3000); + } + } + + function reJoinCurrentRoom() { + if (app.currentRoom) { + const current = app.currentRoom; + app.currentRoom = ''; + app.enterRoom(current); + } + } + + function onReconnecting() { + reconnecting = true; + const reconnectElement = $('#reconnect'); + const reconnectAlert = $('#reconnect-alert'); + + if (!reconnectElement.hasClass('active')) { + reconnectElement.html(''); + reconnectAlert.removeClass('hide'); + } + + reconnectElement.addClass('active').removeClass('hide').tooltip({ + placement: 'bottom', + }); + } + + function onDisconnect() { + setTimeout(() => { + if (socket.disconnected) { + onReconnecting(); + } + }, 2000); + + hooks.fire('action:disconnected'); + } + + function onEventBanned(data) { + require(['bootbox', 'translator'], (bootbox, translator) => { + const message = data.until + ? translator.compile('error:user-banned-reason-until', (new Date(data.until).toLocaleString()), data.reason) + : '[[error:user-banned-reason, ' + data.reason + ']]'; + translator.translate(message, message => { + bootbox.alert({ + title: '[[error:user-banned]]', + message, + closeButton: false, + callback() { + window.location.href = config.relative_path + '/'; + }, + }); + }); + }); + } + + function onEventUnbanned() { + require(['bootbox'], bootbox => { + bootbox.alert({ + title: '[[global:alert.unbanned]]', + message: '[[global:alert.unbanned.message]]', + closeButton: false, + callback() { + window.location.href = config.relative_path + '/'; + }, + }); + }); + } + + if ( + config.socketioOrigins + && config.socketioOrigins !== '*:*' + && !config.socketioOrigins.includes(location.hostname) + ) { + console.error( + 'You are accessing the forum from an unknown origin. This will likely result in websockets failing to connect. \n' + + 'To fix this, set the `"url"` value in `config.json` to the URL at which you access the site. \n' + + 'For more information, see this FAQ topic: https://community.nodebb.org/topic/13388', + ); + } +})(); diff --git a/public/src/utils.common.js b/public/src/utils.common.js index 67af6c6..5658433 100644 --- a/public/src/utils.common.js +++ b/public/src/utils.common.js @@ -1,750 +1,979 @@ 'use strict'; - -// add default escape function for escaping HTML entities +// Add default escape function for escaping HTML entities const escapeCharMap = Object.freeze({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - '`': '`', - '=': '=', + '&': '&', + '<': '<', + '>': '>', + '"': '"', + '\'': ''', + '`': '`', + '=': '=', }); function replaceChar(c) { - return escapeCharMap[c]; + return escapeCharMap[c]; } + const escapeChars = /[&<>"'`=]/g; const HTMLEntities = Object.freeze({ - amp: '&', - gt: '>', - lt: '<', - quot: '"', - apos: "'", - AElig: 198, - Aacute: 193, - Acirc: 194, - Agrave: 192, - Aring: 197, - Atilde: 195, - Auml: 196, - Ccedil: 199, - ETH: 208, - Eacute: 201, - Ecirc: 202, - Egrave: 200, - Euml: 203, - Iacute: 205, - Icirc: 206, - Igrave: 204, - Iuml: 207, - Ntilde: 209, - Oacute: 211, - Ocirc: 212, - Ograve: 210, - Oslash: 216, - Otilde: 213, - Ouml: 214, - THORN: 222, - Uacute: 218, - Ucirc: 219, - Ugrave: 217, - Uuml: 220, - Yacute: 221, - aacute: 225, - acirc: 226, - aelig: 230, - agrave: 224, - aring: 229, - atilde: 227, - auml: 228, - ccedil: 231, - eacute: 233, - ecirc: 234, - egrave: 232, - eth: 240, - euml: 235, - iacute: 237, - icirc: 238, - igrave: 236, - iuml: 239, - ntilde: 241, - oacute: 243, - ocirc: 244, - ograve: 242, - oslash: 248, - otilde: 245, - ouml: 246, - szlig: 223, - thorn: 254, - uacute: 250, - ucirc: 251, - ugrave: 249, - uuml: 252, - yacute: 253, - yuml: 255, - copy: 169, - reg: 174, - nbsp: 160, - iexcl: 161, - cent: 162, - pound: 163, - curren: 164, - yen: 165, - brvbar: 166, - sect: 167, - uml: 168, - ordf: 170, - laquo: 171, - not: 172, - shy: 173, - macr: 175, - deg: 176, - plusmn: 177, - sup1: 185, - sup2: 178, - sup3: 179, - acute: 180, - micro: 181, - para: 182, - middot: 183, - cedil: 184, - ordm: 186, - raquo: 187, - frac14: 188, - frac12: 189, - frac34: 190, - iquest: 191, - times: 215, - divide: 247, - 'OElig;': 338, - 'oelig;': 339, - 'Scaron;': 352, - 'scaron;': 353, - 'Yuml;': 376, - 'fnof;': 402, - 'circ;': 710, - 'tilde;': 732, - 'Alpha;': 913, - 'Beta;': 914, - 'Gamma;': 915, - 'Delta;': 916, - 'Epsilon;': 917, - 'Zeta;': 918, - 'Eta;': 919, - 'Theta;': 920, - 'Iota;': 921, - 'Kappa;': 922, - 'Lambda;': 923, - 'Mu;': 924, - 'Nu;': 925, - 'Xi;': 926, - 'Omicron;': 927, - 'Pi;': 928, - 'Rho;': 929, - 'Sigma;': 931, - 'Tau;': 932, - 'Upsilon;': 933, - 'Phi;': 934, - 'Chi;': 935, - 'Psi;': 936, - 'Omega;': 937, - 'alpha;': 945, - 'beta;': 946, - 'gamma;': 947, - 'delta;': 948, - 'epsilon;': 949, - 'zeta;': 950, - 'eta;': 951, - 'theta;': 952, - 'iota;': 953, - 'kappa;': 954, - 'lambda;': 955, - 'mu;': 956, - 'nu;': 957, - 'xi;': 958, - 'omicron;': 959, - 'pi;': 960, - 'rho;': 961, - 'sigmaf;': 962, - 'sigma;': 963, - 'tau;': 964, - 'upsilon;': 965, - 'phi;': 966, - 'chi;': 967, - 'psi;': 968, - 'omega;': 969, - 'thetasym;': 977, - 'upsih;': 978, - 'piv;': 982, - 'ensp;': 8194, - 'emsp;': 8195, - 'thinsp;': 8201, - 'zwnj;': 8204, - 'zwj;': 8205, - 'lrm;': 8206, - 'rlm;': 8207, - 'ndash;': 8211, - 'mdash;': 8212, - 'lsquo;': 8216, - 'rsquo;': 8217, - 'sbquo;': 8218, - 'ldquo;': 8220, - 'rdquo;': 8221, - 'bdquo;': 8222, - 'dagger;': 8224, - 'Dagger;': 8225, - 'bull;': 8226, - 'hellip;': 8230, - 'permil;': 8240, - 'prime;': 8242, - 'Prime;': 8243, - 'lsaquo;': 8249, - 'rsaquo;': 8250, - 'oline;': 8254, - 'frasl;': 8260, - 'euro;': 8364, - 'image;': 8465, - 'weierp;': 8472, - 'real;': 8476, - 'trade;': 8482, - 'alefsym;': 8501, - 'larr;': 8592, - 'uarr;': 8593, - 'rarr;': 8594, - 'darr;': 8595, - 'harr;': 8596, - 'crarr;': 8629, - 'lArr;': 8656, - 'uArr;': 8657, - 'rArr;': 8658, - 'dArr;': 8659, - 'hArr;': 8660, - 'forall;': 8704, - 'part;': 8706, - 'exist;': 8707, - 'empty;': 8709, - 'nabla;': 8711, - 'isin;': 8712, - 'notin;': 8713, - 'ni;': 8715, - 'prod;': 8719, - 'sum;': 8721, - 'minus;': 8722, - 'lowast;': 8727, - 'radic;': 8730, - 'prop;': 8733, - 'infin;': 8734, - 'ang;': 8736, - 'and;': 8743, - 'or;': 8744, - 'cap;': 8745, - 'cup;': 8746, - 'int;': 8747, - 'there4;': 8756, - 'sim;': 8764, - 'cong;': 8773, - 'asymp;': 8776, - 'ne;': 8800, - 'equiv;': 8801, - 'le;': 8804, - 'ge;': 8805, - 'sub;': 8834, - 'sup;': 8835, - 'nsub;': 8836, - 'sube;': 8838, - 'supe;': 8839, - 'oplus;': 8853, - 'otimes;': 8855, - 'perp;': 8869, - 'sdot;': 8901, - 'lceil;': 8968, - 'rceil;': 8969, - 'lfloor;': 8970, - 'rfloor;': 8971, - 'lang;': 9001, - 'rang;': 9002, - 'loz;': 9674, - 'spades;': 9824, - 'clubs;': 9827, - 'hearts;': 9829, - 'diams;': 9830, + amp: '&', + gt: '>', + lt: '<', + quot: '"', + apos: '\'', + AElig: 198, + Aacute: 193, + Acirc: 194, + Agrave: 192, + Aring: 197, + Atilde: 195, + Auml: 196, + Ccedil: 199, + ETH: 208, + Eacute: 201, + Ecirc: 202, + Egrave: 200, + Euml: 203, + Iacute: 205, + Icirc: 206, + Igrave: 204, + Iuml: 207, + Ntilde: 209, + Oacute: 211, + Ocirc: 212, + Ograve: 210, + Oslash: 216, + Otilde: 213, + Ouml: 214, + THORN: 222, + Uacute: 218, + Ucirc: 219, + Ugrave: 217, + Uuml: 220, + Yacute: 221, + aacute: 225, + acirc: 226, + aelig: 230, + agrave: 224, + aring: 229, + atilde: 227, + auml: 228, + ccedil: 231, + eacute: 233, + ecirc: 234, + egrave: 232, + eth: 240, + euml: 235, + iacute: 237, + icirc: 238, + igrave: 236, + iuml: 239, + ntilde: 241, + oacute: 243, + ocirc: 244, + ograve: 242, + oslash: 248, + otilde: 245, + ouml: 246, + szlig: 223, + thorn: 254, + uacute: 250, + ucirc: 251, + ugrave: 249, + uuml: 252, + yacute: 253, + yuml: 255, + copy: 169, + reg: 174, + nbsp: 160, + iexcl: 161, + cent: 162, + pound: 163, + curren: 164, + yen: 165, + brvbar: 166, + sect: 167, + uml: 168, + ordf: 170, + laquo: 171, + not: 172, + shy: 173, + macr: 175, + deg: 176, + plusmn: 177, + sup1: 185, + sup2: 178, + sup3: 179, + acute: 180, + micro: 181, + para: 182, + middot: 183, + cedil: 184, + ordm: 186, + raquo: 187, + frac14: 188, + frac12: 189, + frac34: 190, + iquest: 191, + times: 215, + divide: 247, + 'OElig;': 338, + 'oelig;': 339, + 'Scaron;': 352, + 'scaron;': 353, + 'Yuml;': 376, + 'fnof;': 402, + 'circ;': 710, + 'tilde;': 732, + 'Alpha;': 913, + 'Beta;': 914, + 'Gamma;': 915, + 'Delta;': 916, + 'Epsilon;': 917, + 'Zeta;': 918, + 'Eta;': 919, + 'Theta;': 920, + 'Iota;': 921, + 'Kappa;': 922, + 'Lambda;': 923, + 'Mu;': 924, + 'Nu;': 925, + 'Xi;': 926, + 'Omicron;': 927, + 'Pi;': 928, + 'Rho;': 929, + 'Sigma;': 931, + 'Tau;': 932, + 'Upsilon;': 933, + 'Phi;': 934, + 'Chi;': 935, + 'Psi;': 936, + 'Omega;': 937, + 'alpha;': 945, + 'beta;': 946, + 'gamma;': 947, + 'delta;': 948, + 'epsilon;': 949, + 'zeta;': 950, + 'eta;': 951, + 'theta;': 952, + 'iota;': 953, + 'kappa;': 954, + 'lambda;': 955, + 'mu;': 956, + 'nu;': 957, + 'xi;': 958, + 'omicron;': 959, + 'pi;': 960, + 'rho;': 961, + 'sigmaf;': 962, + 'sigma;': 963, + 'tau;': 964, + 'upsilon;': 965, + 'phi;': 966, + 'chi;': 967, + 'psi;': 968, + 'omega;': 969, + 'thetasym;': 977, + 'upsih;': 978, + 'piv;': 982, + 'ensp;': 8194, + 'emsp;': 8195, + 'thinsp;': 8201, + 'zwnj;': 8204, + 'zwj;': 8205, + 'lrm;': 8206, + 'rlm;': 8207, + 'ndash;': 8211, + 'mdash;': 8212, + 'lsquo;': 8216, + 'rsquo;': 8217, + 'sbquo;': 8218, + 'ldquo;': 8220, + 'rdquo;': 8221, + 'bdquo;': 8222, + 'dagger;': 8224, + 'Dagger;': 8225, + 'bull;': 8226, + 'hellip;': 8230, + 'permil;': 8240, + 'prime;': 8242, + 'Prime;': 8243, + 'lsaquo;': 8249, + 'rsaquo;': 8250, + 'oline;': 8254, + 'frasl;': 8260, + 'euro;': 8364, + 'image;': 8465, + 'weierp;': 8472, + 'real;': 8476, + 'trade;': 8482, + 'alefsym;': 8501, + 'larr;': 8592, + 'uarr;': 8593, + 'rarr;': 8594, + 'darr;': 8595, + 'harr;': 8596, + 'crarr;': 8629, + 'lArr;': 8656, + 'uArr;': 8657, + 'rArr;': 8658, + 'dArr;': 8659, + 'hArr;': 8660, + 'forall;': 8704, + 'part;': 8706, + 'exist;': 8707, + 'empty;': 8709, + 'nabla;': 8711, + 'isin;': 8712, + 'notin;': 8713, + 'ni;': 8715, + 'prod;': 8719, + 'sum;': 8721, + 'minus;': 8722, + 'lowast;': 8727, + 'radic;': 8730, + 'prop;': 8733, + 'infin;': 8734, + 'ang;': 8736, + 'and;': 8743, + 'or;': 8744, + 'cap;': 8745, + 'cup;': 8746, + 'int;': 8747, + 'there4;': 8756, + 'sim;': 8764, + 'cong;': 8773, + 'asymp;': 8776, + 'ne;': 8800, + 'equiv;': 8801, + 'le;': 8804, + 'ge;': 8805, + 'sub;': 8834, + 'sup;': 8835, + 'nsub;': 8836, + 'sube;': 8838, + 'supe;': 8839, + 'oplus;': 8853, + 'otimes;': 8855, + 'perp;': 8869, + 'sdot;': 8901, + 'lceil;': 8968, + 'rceil;': 8969, + 'lfloor;': 8970, + 'rfloor;': 8971, + 'lang;': 9001, + 'rang;': 9002, + 'loz;': 9674, + 'spades;': 9824, + 'clubs;': 9827, + 'hearts;': 9829, + 'diams;': 9830, }); -/* eslint-disable no-redeclare */ const utils = { - // https://github.com/substack/node-ent/blob/master/index.js - decodeHTMLEntities: function (html) { - return String(html) - .replace(/&#(\d+);?/g, function (_, code) { - return String.fromCharCode(code); - }) - .replace(/&#[xX]([A-Fa-f0-9]+);?/g, function (_, hex) { - return String.fromCharCode(parseInt(hex, 16)); - }) - .replace(/&([^;\W]+;?)/g, function (m, e) { - const ee = e.replace(/;$/, ''); - const target = HTMLEntities[e] || (e.match(/;$/) && HTMLEntities[ee]); - - if (typeof target === 'number') { - return String.fromCharCode(target); - } else if (typeof target === 'string') { - return target; - } - - return m; - }); - }, - // https://github.com/jprichardson/string.js/blob/master/lib/string.js - stripHTMLTags: function (str, tags) { - const pattern = (tags || ['']).join('|'); - return String(str).replace(new RegExp('<(\\/)?(' + (pattern || '[^\\s>]+') + ')(\\s+[^<>]*?)?\\s*(\\/)?>', 'gi'), ''); - }, - - cleanUpTag: function (tag, maxLength) { - if (typeof tag !== 'string' || !tag.length) { - return ''; - } - - tag = tag.trim().toLowerCase(); - // see https://github.com/NodeBB/NodeBB/issues/4378 - tag = tag.replace(/\u202E/gi, ''); - tag = tag.replace(/[,/#!$^*;:{}=_`<>'"~()?|]/g, ''); - tag = tag.slice(0, maxLength || 15).trim(); - const matches = tag.match(/^[.-]*(.+?)[.-]*$/); - if (matches && matches.length > 1) { - tag = matches[1]; - } - return tag; - }, - - removePunctuation: function (str) { - return str.replace(/[.,-/#!$%^&*;:{}=\-_`<>'"~()?]/g, ''); - }, - - isEmailValid: function (email) { - return typeof email === 'string' && email.length && email.indexOf('@') !== -1 && email.indexOf(',') === -1 && email.indexOf(';') === -1; - }, - - isUserNameValid: function (name) { - return (name && name !== '' && (/^['" \-+.*[\]0-9\u00BF-\u1FFF\u2C00-\uD7FF\w]+$/.test(name))); - }, - - isPasswordValid: function (password) { - return typeof password === 'string' && password.length; - }, - - isNumber: function (n) { - // `isFinite('') === true` so isNan parseFloat check is necessary - return !isNaN(parseFloat(n)) && isFinite(n); - }, - - languageKeyRegex: /\[\[[\w]+:.+\]\]/, - hasLanguageKey: function (input) { - return utils.languageKeyRegex.test(input); - }, - userLangToTimeagoCode: function (userLang) { - const mapping = { - 'en-GB': 'en', - 'en-US': 'en', - 'fa-IR': 'fa', - 'pt-BR': 'pt-br', - nb: 'no', - }; - return mapping.hasOwnProperty(userLang) ? mapping[userLang] : userLang; - }, - // shallow objects merge - merge: function () { - const result = {}; - let obj; - let keys; - for (let i = 0; i < arguments.length; i += 1) { - obj = arguments[i] || {}; - keys = Object.keys(obj); - for (let j = 0; j < keys.length; j += 1) { - result[keys[j]] = obj[keys[j]]; - } - } - return result; - }, - - fileExtension: function (path) { - return ('' + path).split('.').pop(); - }, - - extensionMimeTypeMap: { - bmp: 'image/bmp', - cmx: 'image/x-cmx', - cod: 'image/cis-cod', - gif: 'image/gif', - ico: 'image/x-icon', - ief: 'image/ief', - jfif: 'image/pipeg', - jpe: 'image/jpeg', - jpeg: 'image/jpeg', - jpg: 'image/jpeg', - png: 'image/png', - pbm: 'image/x-portable-bitmap', - pgm: 'image/x-portable-graymap', - pnm: 'image/x-portable-anymap', - ppm: 'image/x-portable-pixmap', - ras: 'image/x-cmu-raster', - rgb: 'image/x-rgb', - svg: 'image/svg+xml', - tif: 'image/tiff', - tiff: 'image/tiff', - xbm: 'image/x-xbitmap', - xpm: 'image/x-xpixmap', - xwd: 'image/x-xwindowdump', - }, - - fileMimeType: function (path) { - return utils.extensionToMimeType(utils.fileExtension(path)); - }, - - extensionToMimeType: function (extension) { - return utils.extensionMimeTypeMap.hasOwnProperty(extension) ? utils.extensionMimeTypeMap[extension] : '*'; - }, - - isPromise: function (object) { - // https://stackoverflow.com/questions/27746304/how-do-i-tell-if-an-object-is-a-promise#comment97339131_27746324 - return object && typeof object.then === 'function'; - }, - - promiseParallel: function (obj) { - const keys = Object.keys(obj); - return Promise.all( - keys.map(function (k) { return obj[k]; }) - ).then(function (results) { - const data = {}; - keys.forEach(function (k, i) { - data[k] = results[i]; - }); - return data; - }); - }, - - // https://github.com/sindresorhus/is-absolute-url - isAbsoluteUrlRE: /^[a-zA-Z][a-zA-Z\d+\-.]*:/, - isWinPathRE: /^[a-zA-Z]:\\/, - isAbsoluteUrl: function (url) { - if (utils.isWinPathRE.test(url)) { - return false; - } - return utils.isAbsoluteUrlRE.test(url); - }, - - isRelativeUrl: function (url) { - return !utils.isAbsoluteUrl(url); - }, - - makeNumberHumanReadable: function (num) { - const n = parseInt(num, 10); - if (!n) { - return num; - } - if (n > 999999) { - return (n / 1000000).toFixed(1) + 'm'; - } else if (n > 999) { - return (n / 1000).toFixed(1) + 'k'; - } - return n; - }, - - // takes a string like 1000 and returns 1,000 - addCommas: function (text) { - return String(text).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,'); - }, - - toISOString: function (timestamp) { - if (!timestamp || !Date.prototype.toISOString) { - return ''; - } - - // Prevent too-high values to be passed to Date object - timestamp = Math.min(timestamp, 8640000000000000); - - try { - return new Date(parseInt(timestamp, 10)).toISOString(); - } catch (e) { - return timestamp; - } - }, - - tags: ['a', 'abbr', 'acronym', 'address', 'applet', 'area', 'article', 'aside', 'audio', 'b', 'base', 'basefont', - 'bdi', 'bdo', 'big', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', - 'command', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'em', 'embed', - 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', - 'head', 'header', 'hr', 'html', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'keygen', 'label', 'legend', 'li', 'link', - 'map', 'mark', 'menu', 'meta', 'meter', 'nav', 'noframes', 'noscript', 'object', 'ol', 'optgroup', 'option', - 'output', 'p', 'param', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'script', 'section', 'select', - 'small', 'source', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'textarea', 'tfoot', - 'th', 'thead', 'time', 'title', 'tr', 'track', 'tt', 'u', 'ul', 'const', 'video', 'wbr'], - - stripTags: ['abbr', 'acronym', 'address', 'applet', 'area', 'article', 'aside', 'audio', 'base', 'basefont', - 'bdi', 'bdo', 'big', 'blink', 'body', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', - 'command', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'em', 'embed', - 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', - 'head', 'header', 'hr', 'html', 'iframe', 'input', 'ins', 'kbd', 'keygen', 'label', 'legend', 'li', 'link', - 'map', 'mark', 'marquee', 'menu', 'meta', 'meter', 'nav', 'noframes', 'noscript', 'object', 'ol', 'optgroup', 'option', - 'output', 'param', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'script', 'section', 'select', - 'source', 'span', 'strike', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'textarea', 'tfoot', - 'th', 'thead', 'time', 'title', 'tr', 'track', 'tt', 'u', 'ul', 'const', 'video', 'wbr'], - - escapeRegexChars: function (text) { - return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); - }, - - escapeHTML: function (str) { - if (str == null) { - return ''; - } - if (!str) { - return String(str); - } - - return str.toString().replace(escapeChars, replaceChar); - }, - - isAndroidBrowser: function () { - // http://stackoverflow.com/questions/9286355/how-to-detect-only-the-native-android-browser - const nua = navigator.userAgent; - return ((nua.indexOf('Mozilla/5.0') > -1 && nua.indexOf('Android ') > -1 && nua.indexOf('AppleWebKit') > -1) && !(nua.indexOf('Chrome') > -1)); - }, - - isTouchDevice: function () { - return 'ontouchstart' in document.documentElement; - }, - - findBootstrapEnvironment: function () { - // http://stackoverflow.com/questions/14441456/how-to-detect-which-device-view-youre-on-using-twitter-bootstrap-api - const envs = ['xs', 'sm', 'md', 'lg']; - const $el = $('
    '); - - $el.appendTo($('body')); - - for (let i = envs.length - 1; i >= 0; i -= 1) { - const env = envs[i]; - - $el.addClass('hidden-' + env); - if ($el.is(':hidden')) { - $el.remove(); - return env; - } - } - }, - - isMobile: function () { - const env = utils.findBootstrapEnvironment(); - return ['xs', 'sm'].some(function (targetEnv) { - return targetEnv === env; - }); - }, - - getHoursArray: function () { - const currentHour = new Date().getHours(); - const labels = []; - - for (let i = currentHour, ii = currentHour - 24; i > ii; i -= 1) { - const hour = i < 0 ? 24 + i : i; - labels.push(hour + ':00'); - } - - return labels.reverse(); - }, - - getDaysArray: function (from, amount) { - const currentDay = new Date(parseInt(from, 10) || Date.now()).getTime(); - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - const labels = []; - let tmpDate; - - for (let x = (amount || 30) - 1; x >= 0; x -= 1) { - tmpDate = new Date(currentDay - (1000 * 60 * 60 * 24 * x)); - labels.push(months[tmpDate.getMonth()] + ' ' + tmpDate.getDate()); - } - - return labels; - }, - - /* Retrieved from http://stackoverflow.com/a/7557433 @ 27 Mar 2016 */ - isElementInViewport: function (el) { - // special bonus for those using jQuery - if (typeof jQuery === 'function' && el instanceof jQuery) { - el = el[0]; - } - - const rect = el.getBoundingClientRect(); - - return ( - rect.top >= 0 && - rect.left >= 0 && - rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && /* or $(window).height() */ - rect.right <= (window.innerWidth || document.documentElement.clientWidth) /* or $(window).width() */ - ); - }, - - // get all the url params in a single key/value hash - params: function (options = {}) { - let url; - if (options.url && !options.url.startsWith('http')) { - // relative path passed in - options.url = options.url.replace(new RegExp(`/?${config.relative_path.slice(1)}/`, 'g'), ''); - url = new URL(document.location); - url.pathname = options.url; - } else { - url = new URL(options.url || document.location); - } - let params = url.searchParams; - - if (options.full) { // return URLSearchParams object - return params; - } - - // Handle arrays passed in query string (Object.fromEntries does not) - const arrays = {}; - params.forEach((value, key) => { - if (!key.endsWith('[]')) { - return; - } - - key = key.slice(0, -2); - arrays[key] = arrays[key] || []; - arrays[key].push(utils.toType(value)); - }); - Object.keys(arrays).forEach((key) => { - params.delete(`${key}[]`); - }); - - // Backwards compatibility with v1.x -- all values passed through utils.toType() - params = Object.fromEntries(params); - Object.keys(params).forEach((key) => { - params[key] = utils.toType(params[key]); - }); - - return { ...params, ...arrays }; - }, - - param: function (key) { - return this.params()[key]; - }, - - urlToLocation: function (url) { - const a = document.createElement('a'); - a.href = url; - return a; - }, - - // return boolean if string 'true' or string 'false', or if a parsable string which is a number - // also supports JSON object and/or arrays parsing - toType: function (str) { - const type = typeof str; - if (type !== 'string') { - return str; - } - const nb = parseFloat(str); - if (!isNaN(nb) && isFinite(str)) { - return nb; - } - if (str === 'false') { - return false; - } - if (str === 'true') { - return true; - } - - try { - str = JSON.parse(str); - } catch (e) {} - - return str; - }, - - // Safely get/set chained properties on an object - // set example: utils.props(A, 'a.b.c.d', 10) // sets A to {a: {b: {c: {d: 10}}}}, and returns 10 - // get example: utils.props(A, 'a.b.c') // returns {d: 10} - // get example: utils.props(A, 'a.b.c.foo.bar') // returns undefined without throwing a TypeError - // credits to github.com/gkindel - props: function (obj, props, value) { - if (obj === undefined) { - obj = window; - } - if (props == null) { - return undefined; - } - const i = props.indexOf('.'); - if (i === -1) { - if (value !== undefined) { - obj[props] = value; - } - return obj[props]; - } - const prop = props.slice(0, i); - const newProps = props.slice(i + 1); - - if (props !== undefined && !(obj[prop] instanceof Object)) { - obj[prop] = {}; - } - - return utils.props(obj[prop], newProps, value); - }, - - isInternalURI: function (targetLocation, referenceLocation, relative_path) { - return targetLocation.host === '' || // Relative paths are always internal links - ( - targetLocation.host === referenceLocation.host && + // https://github.com/substack/node-ent/blob/master/index.js + decodeHTMLEntities(html) { + return String(html) + .replaceAll(/&#(\d+);?/g, (_, code) => String.fromCharCode(code)) + .replaceAll(/&#[xX]([A-Fa-f\d]+);?/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16))) + .replaceAll(/&([^;\W]+;?)/g, (m, e) => { + const ee = e.replace(/;$/, ''); + const target = HTMLEntities[e] || (e.match(/;$/) && HTMLEntities[ee]); + + if (typeof target === 'number') { + return String.fromCharCode(target); + } + + if (typeof target === 'string') { + return target; + } + + return m; + }); + }, + // https://github.com/jprichardson/string.js/blob/master/lib/string.js + stripHTMLTags(string_, tags) { + const pattern = (tags || ['']).join('|'); + return String(string_).replaceAll(new RegExp('<(\\/)?(' + (pattern || '[^\\s>]+') + ')(\\s+[^<>]*?)?\\s*(\\/)?>', 'gi'), ''); + }, + + cleanUpTag(tag, maxLength) { + if (typeof tag !== 'string' || tag.length === 0) { + return ''; + } + + tag = tag.trim().toLowerCase(); + // See https://github.com/NodeBB/NodeBB/issues/4378 + tag = tag.replaceAll(/\u202E/gi, ''); + tag = tag.replaceAll(/[,/#!$^*;:{}=_`<>'"~()?|]/g, ''); + tag = tag.slice(0, maxLength || 15).trim(); + const matches = tag.match(/^[.-]*(.+?)[.-]*$/); + if (matches && matches.length > 1) { + tag = matches[1]; + } + + return tag; + }, + + removePunctuation(string_) { + return string_.replaceAll(/[.,-/#!$%^&*;:{}=\-_`<>'"~()?]/g, ''); + }, + + isEmailValid(email) { + return typeof email === 'string' && email.length && email.includes('@') && !email.includes(',') && !email.includes(';'); + }, + + isUserNameValid(name) { + return (name && name !== '' && (/^['" \-+.*[\]\d\u00BF-\u1FFF\u2C00-\uD7FF\w]+$/.test(name))); + }, + + isPasswordValid(password) { + return typeof password === 'string' && password.length; + }, + + isNumber(n) { + // `isFinite('') === true` so isNan parseFloat check is necessary + return !isNaN(Number.parseFloat(n)) && isFinite(n); + }, + + languageKeyRegex: /\[\[\w+:.+]]/, + hasLanguageKey(input) { + return utils.languageKeyRegex.test(input); + }, + userLangToTimeagoCode(userLang) { + const mapping = { + 'en-GB': 'en', + 'en-US': 'en', + 'fa-IR': 'fa', + 'pt-BR': 'pt-br', + nb: 'no', + }; + return mapping.hasOwnProperty(userLang) ? mapping[userLang] : userLang; + }, + // Shallow objects merge + merge() { + const result = {}; + let object; + let keys; + for (const argument of arguments) { + object = argument || {}; + keys = Object.keys(object); + for (const key of keys) { + result[key] = object[key]; + } + } + + return result; + }, + + fileExtension(path) { + return (String(path)).split('.').pop(); + }, + + extensionMimeTypeMap: { + bmp: 'image/bmp', + cmx: 'image/x-cmx', + cod: 'image/cis-cod', + gif: 'image/gif', + ico: 'image/x-icon', + ief: 'image/ief', + jfif: 'image/pipeg', + jpe: 'image/jpeg', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + png: 'image/png', + pbm: 'image/x-portable-bitmap', + pgm: 'image/x-portable-graymap', + pnm: 'image/x-portable-anymap', + ppm: 'image/x-portable-pixmap', + ras: 'image/x-cmu-raster', + rgb: 'image/x-rgb', + svg: 'image/svg+xml', + tif: 'image/tiff', + tiff: 'image/tiff', + xbm: 'image/x-xbitmap', + xpm: 'image/x-xpixmap', + xwd: 'image/x-xwindowdump', + }, + + fileMimeType(path) { + return utils.extensionToMimeType(utils.fileExtension(path)); + }, + + extensionToMimeType(extension) { + return utils.extensionMimeTypeMap.hasOwnProperty(extension) ? utils.extensionMimeTypeMap[extension] : '*'; + }, + + isPromise(object) { + // https://stackoverflow.com/questions/27746304/how-do-i-tell-if-an-object-is-a-promise#comment97339131_27746324 + return object && typeof object.then === 'function'; + }, + + promiseParallel(object) { + const keys = Object.keys(object); + return Promise.all( + keys.map(k => object[k]), + ).then(results => { + const data = {}; + for (const [i, k] of keys.entries()) { + data[k] = results[i]; + } + + return data; + }); + }, + + // https://github.com/sindresorhus/is-absolute-url + isAbsoluteUrlRE: /^[a-zA-Z][a-zA-Z\d+\-.]*:/, + isWinPathRE: /^[a-zA-Z]:\\/, + isAbsoluteUrl(url) { + if (utils.isWinPathRE.test(url)) { + return false; + } + + return utils.isAbsoluteUrlRE.test(url); + }, + + isRelativeUrl(url) { + return !utils.isAbsoluteUrl(url); + }, + + makeNumberHumanReadable(number_) { + const n = Number.parseInt(number_, 10); + if (!n) { + return number_; + } + + if (n > 999_999) { + return (n / 1_000_000).toFixed(1) + 'm'; + } + + if (n > 999) { + return (n / 1000).toFixed(1) + 'k'; + } + + return n; + }, + + // Takes a string like 1000 and returns 1,000 + addCommas(text) { + return String(text).replaceAll(/(\d)(?=(\d{3})+(?!\d))/g, '$1,'); + }, + + toISOString(timestamp) { + if (!timestamp || !Date.prototype.toISOString) { + return ''; + } + + // Prevent too-high values to be passed to Date object + timestamp = Math.min(timestamp, 8_640_000_000_000_000); + + try { + return new Date(Number.parseInt(timestamp, 10)).toISOString(); + } catch { + return timestamp; + } + }, + + tags: ['a', + 'abbr', + 'acronym', + 'address', + 'applet', + 'area', + 'article', + 'aside', + 'audio', + 'b', + 'base', + 'basefont', + 'bdi', + 'bdo', + 'big', + 'blockquote', + 'body', + 'br', + 'button', + 'canvas', + 'caption', + 'center', + 'cite', + 'code', + 'col', + 'colgroup', + 'command', + 'datalist', + 'dd', + 'del', + 'details', + 'dfn', + 'dialog', + 'dir', + 'div', + 'dl', + 'dt', + 'em', + 'embed', + 'fieldset', + 'figcaption', + 'figure', + 'font', + 'footer', + 'form', + 'frame', + 'frameset', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'header', + 'hr', + 'html', + 'i', + 'iframe', + 'img', + 'input', + 'ins', + 'kbd', + 'keygen', + 'label', + 'legend', + 'li', + 'link', + 'map', + 'mark', + 'menu', + 'meta', + 'meter', + 'nav', + 'noframes', + 'noscript', + 'object', + 'ol', + 'optgroup', + 'option', + 'output', + 'p', + 'param', + 'pre', + 'progress', + 'q', + 'rp', + 'rt', + 'ruby', + 's', + 'samp', + 'script', + 'section', + 'select', + 'small', + 'source', + 'span', + 'strike', + 'strong', + 'style', + 'sub', + 'summary', + 'sup', + 'table', + 'tbody', + 'td', + 'textarea', + 'tfoot', + 'th', + 'thead', + 'time', + 'title', + 'tr', + 'track', + 'tt', + 'u', + 'ul', + 'const', + 'video', + 'wbr'], + + stripTags: ['abbr', + 'acronym', + 'address', + 'applet', + 'area', + 'article', + 'aside', + 'audio', + 'base', + 'basefont', + 'bdi', + 'bdo', + 'big', + 'blink', + 'body', + 'button', + 'canvas', + 'caption', + 'center', + 'cite', + 'code', + 'col', + 'colgroup', + 'command', + 'datalist', + 'dd', + 'del', + 'details', + 'dfn', + 'dialog', + 'dir', + 'div', + 'dl', + 'dt', + 'em', + 'embed', + 'fieldset', + 'figcaption', + 'figure', + 'font', + 'footer', + 'form', + 'frame', + 'frameset', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'header', + 'hr', + 'html', + 'iframe', + 'input', + 'ins', + 'kbd', + 'keygen', + 'label', + 'legend', + 'li', + 'link', + 'map', + 'mark', + 'marquee', + 'menu', + 'meta', + 'meter', + 'nav', + 'noframes', + 'noscript', + 'object', + 'ol', + 'optgroup', + 'option', + 'output', + 'param', + 'pre', + 'progress', + 'q', + 'rp', + 'rt', + 'ruby', + 's', + 'samp', + 'script', + 'section', + 'select', + 'source', + 'span', + 'strike', + 'style', + 'sub', + 'summary', + 'sup', + 'table', + 'tbody', + 'td', + 'textarea', + 'tfoot', + 'th', + 'thead', + 'time', + 'title', + 'tr', + 'track', + 'tt', + 'u', + 'ul', + 'const', + 'video', + 'wbr'], + + escapeRegexChars(text) { + return text.replaceAll(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); + }, + + escapeHTML(string_) { + if (string_ == null) { + return ''; + } + + if (!string_) { + return String(string_); + } + + return string_.toString().replaceAll(escapeChars, replaceChar); + }, + + isAndroidBrowser() { + // http://stackoverflow.com/questions/9286355/how-to-detect-only-the-native-android-browser + const nua = navigator.userAgent; + return ((nua.includes('Mozilla/5.0') && nua.includes('Android ') && nua.includes('AppleWebKit')) && !(nua.includes('Chrome'))); + }, + + isTouchDevice() { + return 'ontouchstart' in document.documentElement; + }, + + findBootstrapEnvironment() { + // http://stackoverflow.com/questions/14441456/how-to-detect-which-device-view-youre-on-using-twitter-bootstrap-api + const environments = ['xs', 'sm', 'md', 'lg']; + const $element = $('
    '); + + $element.appendTo($('body')); + + for (let i = environments.length - 1; i >= 0; i -= 1) { + const env = environments[i]; + + $element.addClass('hidden-' + env); + if ($element.is(':hidden')) { + $element.remove(); + return env; + } + } + }, + + isMobile() { + const env = utils.findBootstrapEnvironment(); + return ['xs', 'sm'].includes(env); + }, + + getHoursArray() { + const currentHour = new Date().getHours(); + const labels = []; + + for (let i = currentHour, ii = currentHour - 24; i > ii; i -= 1) { + const hour = i < 0 ? 24 + i : i; + labels.push(hour + ':00'); + } + + return labels.reverse(); + }, + + getDaysArray(from, amount) { + const currentDay = new Date(Number.parseInt(from, 10) || Date.now()).getTime(); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const labels = []; + let temporaryDate; + + for (let x = (amount || 30) - 1; x >= 0; x -= 1) { + temporaryDate = new Date(currentDay - (1000 * 60 * 60 * 24 * x)); + labels.push(months[temporaryDate.getMonth()] + ' ' + temporaryDate.getDate()); + } + + return labels; + }, + + /* Retrieved from http://stackoverflow.com/a/7557433 @ 27 Mar 2016 */ + isElementInViewport(element) { + // Special bonus for those using jQuery + if (typeof jQuery === 'function' && element instanceof jQuery) { + element = element[0]; + } + + const rect = element.getBoundingClientRect(); + + return ( + rect.top >= 0 + && rect.left >= 0 + && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) /* Or $(window).height() */ + && rect.right <= (window.innerWidth || document.documentElement.clientWidth) /* Or $(window).width() */ + ); + }, + + // Get all the url params in a single key/value hash + params(options = {}) { + let url; + if (options.url && !options.url.startsWith('http')) { + // Relative path passed in + options.url = options.url.replaceAll(new RegExp(`/?${config.relative_path.slice(1)}/`, 'g'), ''); + url = new URL(document.location); + url.pathname = options.url; + } else { + url = new URL(options.url || document.location); + } + + let parameters = url.searchParams; + + if (options.full) { // Return URLSearchParams object + return parameters; + } + + // Handle arrays passed in query string (Object.fromEntries does not) + const arrays = {}; + for (let [key, value] of parameters.entries()) { + if (!key.endsWith('[]')) { + continue; + } + + key = key.slice(0, -2); + arrays[key] = arrays[key] || []; + arrays[key].push(utils.toType(value)); + } + + for (const key of Object.keys(arrays)) { + parameters.delete(`${key}[]`); + } + + // Backwards compatibility with v1.x -- all values passed through utils.toType() + parameters = Object.fromEntries(parameters); + for (const key of Object.keys(parameters)) { + parameters[key] = utils.toType(parameters[key]); + } + + return {...parameters, ...arrays}; + }, + + param(key) { + return this.params()[key]; + }, + + urlToLocation(url) { + const a = document.createElement('a'); + a.href = url; + return a; + }, + + // Return boolean if string 'true' or string 'false', or if a parsable string which is a number + // also supports JSON object and/or arrays parsing + toType(string_) { + const type = typeof string_; + if (type !== 'string') { + return string_; + } + + const nb = Number.parseFloat(string_); + if (!isNaN(nb) && isFinite(string_)) { + return nb; + } + + if (string_ === 'false') { + return false; + } + + if (string_ === 'true') { + return true; + } + + try { + string_ = JSON.parse(string_); + } catch {} + + return string_; + }, + + // Safely get/set chained properties on an object + // set example: utils.props(A, 'a.b.c.d', 10) // sets A to {a: {b: {c: {d: 10}}}}, and returns 10 + // get example: utils.props(A, 'a.b.c') // returns {d: 10} + // get example: utils.props(A, 'a.b.c.foo.bar') // returns undefined without throwing a TypeError + // credits to github.com/gkindel + props(object, properties, value) { + if (object === undefined) { + object = window; + } + + if (properties == null) { + return undefined; + } + + const i = properties.indexOf('.'); + if (i === -1) { + if (value !== undefined) { + object[properties] = value; + } + + return object[properties]; + } + + const property = properties.slice(0, i); + const newProperties = properties.slice(i + 1); + + if (properties !== undefined && !(object[property] instanceof Object)) { + object[property] = {}; + } + + return utils.props(object[property], newProperties, value); + }, + + isInternalURI(targetLocation, referenceLocation, relative_path) { + return targetLocation.host === '' // Relative paths are always internal links + || ( + targetLocation.host === referenceLocation.host // Otherwise need to check if protocol and host match - targetLocation.protocol === referenceLocation.protocol && + && targetLocation.protocol === referenceLocation.protocol // Subfolder installs need this additional check - (relative_path.length > 0 ? targetLocation.pathname.indexOf(relative_path) === 0 : true) + && (relative_path.length > 0 ? targetLocation.pathname.indexOf(relative_path) === 0 : true) ); - }, - - rtrim: function (str) { - return str.replace(/\s+$/g, ''); - }, - - debounce: function (func, wait, immediate) { - // modified from https://davidwalsh.name/javascript-debounce-function - let timeout; - return function () { - const context = this; - const args = arguments; - const later = function () { - timeout = null; - if (!immediate) { - func.apply(context, args); - } - }; - const callNow = immediate && !timeout; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - if (callNow) { - func.apply(context, args); - } - }; - }, - throttle: function (func, wait, immediate) { - let timeout; - return function () { - const context = this; - const args = arguments; - const later = function () { - timeout = null; - if (!immediate) { - func.apply(context, args); - } - }; - const callNow = immediate && !timeout; - if (!timeout) { - timeout = setTimeout(later, wait); - } - if (callNow) { - func.apply(context, args); - } - }; - }, + }, + + rtrim(string_) { + return string_.replaceAll(/\s+$/g, ''); + }, + + debounce(function_, wait, immediate) { + // Modified from https://davidwalsh.name/javascript-debounce-function + let timeout; + return function () { + const context = this; + const arguments_ = arguments; + const later = function () { + timeout = null; + if (!immediate) { + function_.apply(context, arguments_); + } + }; + + const callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) { + function_.apply(context, arguments_); + } + }; + }, + throttle(function_, wait, immediate) { + let timeout; + return function () { + const context = this; + const arguments_ = arguments; + const later = function () { + timeout = null; + if (!immediate) { + function_.apply(context, arguments_); + } + }; + + const callNow = immediate && !timeout; + timeout ||= setTimeout(later, wait); + + if (callNow) { + function_.apply(context, arguments_); + } + }; + }, }; module.exports = utils; diff --git a/public/src/utils.js b/public/src/utils.js index fbb1695..ae856ec 100644 --- a/public/src/utils.js +++ b/public/src/utils.js @@ -1,83 +1,80 @@ -/* eslint-disable no-redeclare */ 'use strict'; const $ = require('jquery'); -const utils = { ...require('./utils.common') }; +const utils = {...require('./utils.common')}; utils.getLanguage = function () { - let lang = 'en-GB'; - if (typeof window === 'object' && window.config && window.utils) { - lang = utils.params().lang || config.userLang || config.defaultLang || 'en-GB'; - } - return lang; -}; + let lang = 'en-GB'; + if (typeof window === 'object' && window.config && window.utils) { + lang = utils.params().lang || config.userLang || config.defaultLang || 'en-GB'; + } + return lang; +}; utils.makeNumbersHumanReadable = function (elements) { - elements.each(function () { - $(this) - .html(utils.makeNumberHumanReadable($(this).attr('title'))) - .removeClass('hidden'); - }); + elements.each(function () { + $(this) + .html(utils.makeNumberHumanReadable($(this).attr('title'))) + .removeClass('hidden'); + }); }; utils.addCommasToNumbers = function (elements) { - elements.each(function (index, element) { - $(element) - .html(utils.addCommas($(element).html())) - .removeClass('hidden'); - }); + elements.each((index, element) => { + $(element) + .html(utils.addCommas($(element).html())) + .removeClass('hidden'); + }); }; utils.findBootstrapEnvironment = function () { - // http://stackoverflow.com/questions/14441456/how-to-detect-which-device-view-youre-on-using-twitter-bootstrap-api - const envs = ['xs', 'sm', 'md', 'lg']; - const $el = $('
    '); + // http://stackoverflow.com/questions/14441456/how-to-detect-which-device-view-youre-on-using-twitter-bootstrap-api + const environments = ['xs', 'sm', 'md', 'lg']; + const $element = $('
    '); - $el.appendTo($('body')); + $element.appendTo($('body')); - for (let i = envs.length - 1; i >= 0; i -= 1) { - const env = envs[i]; + for (let i = environments.length - 1; i >= 0; i -= 1) { + const env = environments[i]; - $el.addClass('hidden-' + env); - if ($el.is(':hidden')) { - $el.remove(); - return env; - } - } + $element.addClass('hidden-' + env); + if ($element.is(':hidden')) { + $element.remove(); + return env; + } + } }; utils.isMobile = function () { - const env = utils.findBootstrapEnvironment(); - return ['xs', 'sm'].some(function (targetEnv) { - return targetEnv === env; - }); + const env = utils.findBootstrapEnvironment(); + return ['xs', 'sm'].includes(env); }; utils.assertPasswordValidity = (password, zxcvbn) => { - // More checks on top of basic utils.isPasswordValid() - if (!utils.isPasswordValid(password)) { - throw new Error('[[user:change_password_error]]'); - } else if (password.length < ajaxify.data.minimumPasswordLength) { - throw new Error('[[reset_password:password_too_short]]'); - } else if (password.length > 512) { - throw new Error('[[error:password-too-long]]'); - } - - const passwordStrength = zxcvbn(password); - if (passwordStrength.score < ajaxify.data.minimumPasswordStrength) { - throw new Error('[[user:weak_password]]'); - } + // More checks on top of basic utils.isPasswordValid() + if (!utils.isPasswordValid(password)) { + throw new Error('[[user:change_password_error]]'); + } else if (password.length < ajaxify.data.minimumPasswordLength) { + throw new Error('[[reset_password:password_too_short]]'); + } else if (password.length > 512) { + throw new Error('[[error:password-too-long]]'); + } + + const passwordStrength = zxcvbn(password); + if (passwordStrength.score < ajaxify.data.minimumPasswordStrength) { + throw new Error('[[user:weak_password]]'); + } }; utils.generateUUID = function () { - // from https://github.com/tracker1/node-uuid4/blob/master/browser.js - const temp_url = URL.createObjectURL(new Blob()); - const uuid = temp_url.toString(); - URL.revokeObjectURL(temp_url); - return uuid.split(/[:/]/g).pop().toLowerCase(); // remove prefixes + // From https://github.com/tracker1/node-uuid4/blob/master/browser.js + const temporary_url = URL.createObjectURL(new Blob()); + const uuid = temporary_url.toString(); + URL.revokeObjectURL(temporary_url); + return uuid.split(/[:/]/g).pop().toLowerCase(); // Remove prefixes }; module.exports = utils; diff --git a/public/src/widgets.js b/public/src/widgets.js index 7ad6522..6c9bcbf 100644 --- a/public/src/widgets.js +++ b/public/src/widgets.js @@ -1,52 +1,52 @@ 'use strict'; module.exports.render = function (template) { - if (template.match(/^admin/)) { - return; - } - - const locations = Object.keys(ajaxify.data.widgets); - - locations.forEach(function (location) { - let area = $('#content [widget-area="' + location + '"],#content [data-widget-area="' + location + '"]').eq(0); - if (area.length) { - return; - } - - const widgetsAtLocation = ajaxify.data.widgets[location] || []; - let html = ''; - - widgetsAtLocation.forEach(function (widget) { - html += widget.html; - }); - - if (location === 'footer' && !$('#content [widget-area="footer"],#content [data-widget-area="footer"]').length) { - $('#content').append($('
    ')); - } else if (location === 'sidebar' && !$('#content [widget-area="sidebar"],#content [data-widget-area="sidebar"]').length) { - if ($('[component="account/cover"]').length) { - $('[component="account/cover"]').nextAll().wrapAll($('
    ')); - } else if ($('[component="groups/cover"]').length) { - $('[component="groups/cover"]').nextAll().wrapAll($('
    ')); - } else { - $('#content > *').wrapAll($('
    ')); - } - } else if (location === 'header' && !$('#content [widget-area="header"],#content [data-widget-area="header"]').length) { - $('#content').prepend($('
    ')); - } - - area = $('#content [widget-area="' + location + '"],#content [data-widget-area="' + location + '"]').eq(0); - if (html && area.length) { - area.html(html); - area.find('img:not(.not-responsive)').addClass('img-responsive'); - } - - if (widgetsAtLocation.length) { - area.removeClass('hidden'); - } - }); - - require(['hooks'], function (hooks) { - hooks.fire('action:widgets.loaded', {}); - }); + if (template.startsWith('admin')) { + return; + } + + const locations = Object.keys(ajaxify.data.widgets); + + for (const location of locations) { + let area = $('#content [widget-area="' + location + '"],#content [data-widget-area="' + location + '"]').eq(0); + if (area.length > 0) { + continue; + } + + const widgetsAtLocation = ajaxify.data.widgets[location] || []; + let html = ''; + + for (const widget of widgetsAtLocation) { + html += widget.html; + } + + if (location === 'footer' && $('#content [widget-area="footer"],#content [data-widget-area="footer"]').length === 0) { + $('#content').append($('
    ')); + } else if (location === 'sidebar' && $('#content [widget-area="sidebar"],#content [data-widget-area="sidebar"]').length === 0) { + if ($('[component="account/cover"]').length > 0) { + $('[component="account/cover"]').nextAll().wrapAll($('
    ')); + } else if ($('[component="groups/cover"]').length > 0) { + $('[component="groups/cover"]').nextAll().wrapAll($('
    ')); + } else { + $('#content > *').wrapAll($('
    ')); + } + } else if (location === 'header' && $('#content [widget-area="header"],#content [data-widget-area="header"]').length === 0) { + $('#content').prepend($('
    ')); + } + + area = $('#content [widget-area="' + location + '"],#content [data-widget-area="' + location + '"]').eq(0); + if (html && area.length > 0) { + area.html(html); + area.find('img:not(.not-responsive)').addClass('img-responsive'); + } + + if (widgetsAtLocation.length > 0) { + area.removeClass('hidden'); + } + } + + require(['hooks'], hooks => { + hooks.fire('action:widgets.loaded', {}); + }); }; diff --git a/require-main.js b/require-main.js index b062186..4d26d96 100644 --- a/require-main.js +++ b/require-main.js @@ -1,10 +1,10 @@ 'use strict'; -// this forces `require.main.require` to always be relative to this directory +// This forces `require.main.require` to always be relative to this directory // this allows plugins to use `require.main.require` to reference NodeBB modules // without worrying about multiple parent modules if (require.main !== module) { - require.main.require = function (path) { - return require(path); - }; + require.main.require = function (path) { + return require(path); + }; } diff --git a/src/admin/search.js b/src/admin/search.js index e15b920..4ae2660 100644 --- a/src/admin/search.js +++ b/src/admin/search.js @@ -1,137 +1,137 @@ 'use strict'; -const fs = require('fs'); -const path = require('path'); +const fs = require('node:fs'); +const path = require('node:path'); const sanitizeHTML = require('sanitize-html'); const nconf = require('nconf'); const winston = require('winston'); - const file = require('../file'); -const { Translator } = require('../translator'); +const {Translator} = require('../translator'); function filterDirectories(directories) { - return directories.map( - // get the relative path - // convert dir to use forward slashes - dir => dir.replace(/^.*(admin.*?).tpl$/, '$1').split(path.sep).join('/') - ).filter( - // exclude .js files - // exclude partials - // only include subpaths - // exclude category.tpl, group.tpl, category-analytics.tpl - dir => ( - !dir.endsWith('.js') && - !dir.includes('/partials/') && - /\/.*\//.test(dir) && - !/manage\/(category|group|category-analytics)$/.test(dir) - ) - ); + return directories.map( + // Get the relative path + // convert dir to use forward slashes + dir => dir.replace(/^.*(admin.*?).tpl$/, '$1').split(path.sep).join('/'), + ).filter( + // Exclude .js files + // exclude partials + // only include subpaths + // exclude category.tpl, group.tpl, category-analytics.tpl + dir => ( + !dir.endsWith('.js') + && !dir.includes('/partials/') + && /\/.*\//.test(dir) + && !/manage\/(category|group|category-analytics)$/.test(dir) + ), + ); } async function getAdminNamespaces() { - const directories = await file.walk(path.resolve(nconf.get('views_dir'), 'admin')); - return filterDirectories(directories); + const directories = await file.walk(path.resolve(nconf.get('views_dir'), 'admin')); + return filterDirectories(directories); } function sanitize(html) { - // reduce the template to just meaningful text - // remove all tags and strip out scripts, etc completely - return sanitizeHTML(html, { - allowedTags: [], - allowedAttributes: [], - }); + // Reduce the template to just meaningful text + // remove all tags and strip out scripts, etc completely + return sanitizeHTML(html, { + allowedTags: [], + allowedAttributes: [], + }); } function simplify(translations) { - return translations - // remove all mustaches - .replace(/(?:\{{1,2}[^}]*?\}{1,2})/g, '') - // collapse whitespace - .replace(/(?:[ \t]*[\n\r]+[ \t]*)+/g, '\n') - .replace(/[\t ]+/g, ' '); + return translations + // Remove all mustaches + .replaceAll(/(?:{{1,2}[^}]*?}{1,2})/g, '') + // Collapse whitespace + .replaceAll(/(?:[ \t]*[\n\r]+[ \t]*)+/g, '\n') + .replaceAll(/[\t ]+/g, ' '); } function nsToTitle(namespace) { - return namespace.replace('admin/', '').split('/').map(str => str[0].toUpperCase() + str.slice(1)).join(' > ') - .replace(/[^a-zA-Z> ]/g, ' '); + return namespace.replace('admin/', '').split('/').map(string_ => string_[0].toUpperCase() + string_.slice(1)).join(' > ') + .replaceAll(/[^a-zA-Z> ]/g, ' '); } const fallbackCache = {}; async function initFallback(namespace) { - const template = await fs.promises.readFile(path.resolve(nconf.get('views_dir'), `${namespace}.tpl`), 'utf8'); - - const title = nsToTitle(namespace); - let translations = sanitize(template); - translations = Translator.removePatterns(translations); - translations = simplify(translations); - translations += `\n${title}`; - - return { - namespace: namespace, - translations: translations, - title: title, - }; + const template = await fs.promises.readFile(path.resolve(nconf.get('views_dir'), `${namespace}.tpl`), 'utf8'); + + const title = nsToTitle(namespace); + let translations = sanitize(template); + translations = Translator.removePatterns(translations); + translations = simplify(translations); + translations += `\n${title}`; + + return { + namespace, + translations, + title, + }; } async function fallback(namespace) { - if (fallbackCache[namespace]) { - return fallbackCache[namespace]; - } + if (fallbackCache[namespace]) { + return fallbackCache[namespace]; + } - const params = await initFallback(namespace); - fallbackCache[namespace] = params; - return params; + const parameters = await initFallback(namespace); + fallbackCache[namespace] = parameters; + return parameters; } -async function initDict(language) { - const namespaces = await getAdminNamespaces(); - return await Promise.all(namespaces.map(ns => buildNamespace(language, ns))); +async function initDictionary(language) { + const namespaces = await getAdminNamespaces(); + return await Promise.all(namespaces.map(ns => buildNamespace(language, ns))); } async function buildNamespace(language, namespace) { - const translator = Translator.create(language); - try { - const translations = await translator.getTranslation(namespace); - if (!translations || !Object.keys(translations).length) { - return await fallback(namespace); - } - // join all translations into one string separated by newlines - let str = Object.keys(translations).map(key => translations[key]).join('\n'); - str = sanitize(str); - - let title = namespace; - title = title.match(/admin\/(.+?)\/(.+?)$/); - title = `[[admin/menu:section-${ - title[1] === 'development' ? 'advanced' : title[1] - }]]${title[2] ? (` > [[admin/menu:${ - title[1]}/${title[2]}]]`) : ''}`; - - title = await translator.translate(title); - return { - namespace: namespace, - translations: `${str}\n${title}`, - title: title, - }; - } catch (err) { - winston.error(err.stack); - return { - namespace: namespace, - translations: '', - }; - } + const translator = Translator.create(language); + try { + const translations = await translator.getTranslation(namespace); + if (!translations || Object.keys(translations).length === 0) { + return await fallback(namespace); + } + + // Join all translations into one string separated by newlines + let string_ = Object.keys(translations).map(key => translations[key]).join('\n'); + string_ = sanitize(string_); + + let title = namespace; + title = title.match(/admin\/(.+?)\/(.+?)$/); + title = `[[admin/menu:section-${ + title[1] === 'development' ? 'advanced' : title[1] + }]]${title[2] ? (` > [[admin/menu:${ + title[1]}/${title[2]}]]`) : ''}`; + + title = await translator.translate(title); + return { + namespace, + translations: `${string_}\n${title}`, + title, + }; + } catch (error) { + winston.error(error.stack); + return { + namespace, + translations: '', + }; + } } const cache = {}; async function getDictionary(language) { - if (cache[language]) { - return cache[language]; - } + if (cache[language]) { + return cache[language]; + } - const params = await initDict(language); - cache[language] = params; - return params; + const parameters = await initDictionary(language); + cache[language] = parameters; + return parameters; } module.exports.getDictionary = getDictionary; diff --git a/src/admin/versions.js b/src/admin/versions.js index b906b6e..168b7d6 100644 --- a/src/admin/versions.js +++ b/src/admin/versions.js @@ -1,7 +1,6 @@ 'use strict'; const request = require('request'); - const meta = require('../meta'); let versionCache = ''; @@ -10,40 +9,41 @@ let versionCacheLastModified = ''; const isPrerelease = /^v?\d+\.\d+\.\d+-.+$/; function getLatestVersion(callback) { - const headers = { - Accept: 'application/vnd.github.v3+json', - 'User-Agent': encodeURIComponent(`NodeBB Admin Control Panel/${meta.config.title}`), - }; - - if (versionCacheLastModified) { - headers['If-Modified-Since'] = versionCacheLastModified; - } - - request('https://api.github.com/repos/NodeBB/NodeBB/releases/latest', { - json: true, - headers: headers, - timeout: 2000, - }, (err, res, latestRelease) => { - if (err) { - return callback(err); - } - - if (res.statusCode === 304) { - return callback(null, versionCache); - } - - if (res.statusCode !== 200) { - return callback(new Error(res.statusMessage)); - } - - if (!latestRelease || !latestRelease.tag_name) { - return callback(new Error('[[error:cant-get-latest-release]]')); - } - const tagName = latestRelease.tag_name.replace(/^v/, ''); - versionCache = tagName; - versionCacheLastModified = res.headers['last-modified']; - callback(null, versionCache); - }); + const headers = { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': encodeURIComponent(`NodeBB Admin Control Panel/${meta.config.title}`), + }; + + if (versionCacheLastModified) { + headers['If-Modified-Since'] = versionCacheLastModified; + } + + request('https://api.github.com/repos/NodeBB/NodeBB/releases/latest', { + json: true, + headers, + timeout: 2000, + }, (error, res, latestRelease) => { + if (error) { + return callback(error); + } + + if (res.statusCode === 304) { + return callback(null, versionCache); + } + + if (res.statusCode !== 200) { + return callback(new Error(res.statusMessage)); + } + + if (!latestRelease || !latestRelease.tag_name) { + return callback(new Error('[[error:cant-get-latest-release]]')); + } + + const tagName = latestRelease.tag_name.replace(/^v/, ''); + versionCache = tagName; + versionCacheLastModified = res.headers['last-modified']; + callback(null, versionCache); + }); } exports.getLatestVersion = getLatestVersion; diff --git a/src/als.js b/src/als.js index a3aec02..b5acfc3 100644 --- a/src/als.js +++ b/src/als.js @@ -1,6 +1,6 @@ 'use strict'; -const { AsyncLocalStorage } = require('async_hooks'); +const {AsyncLocalStorage} = require('node:async_hooks'); const asyncLocalStorage = new AsyncLocalStorage(); diff --git a/src/analytics.js b/src/analytics.js index 6cfe293..ac943c6 100644 --- a/src/analytics.js +++ b/src/analytics.js @@ -3,8 +3,8 @@ const cronJob = require('cron').CronJob; const winston = require('winston'); const nconf = require('nconf'); -const crypto = require('crypto'); -const util = require('util'); +const crypto = require('node:crypto'); +const util = require('node:util'); const _ = require('lodash'); const sleep = util.promisify(setTimeout); @@ -21,13 +21,13 @@ const Analytics = module.exports; const secret = nconf.get('secret'); let local = { - counters: {}, - pageViews: 0, - pageViewsRegistered: 0, - pageViewsGuest: 0, - pageViewsBot: 0, - uniqueIPCount: 0, - uniquevisitors: 0, + counters: {}, + pageViews: 0, + pageViewsRegistered: 0, + pageViewsGuest: 0, + pageViewsBot: 0, + uniqueIPCount: 0, + uniquevisitors: 0, }; const empty = _.cloneDeep(local); const total = _.cloneDeep(local); @@ -37,265 +37,268 @@ let ipCache; const runJobs = nconf.get('runJobs'); Analytics.init = async function () { - ipCache = cacheCreate({ - max: parseInt(meta.config['analytics:maxCache'], 10) || 500, - ttl: 0, - }); - - new cronJob('*/10 * * * * *', (async () => { - publishLocalAnalytics(); - if (runJobs) { - await sleep(2000); - await Analytics.writeData(); - } - }), null, true); - - if (runJobs) { - pubsub.on('analytics:publish', (data) => { - incrementProperties(total, data.local); - }); - } + ipCache = cacheCreate({ + max: Number.parseInt(meta.config['analytics:maxCache'], 10) || 500, + ttl: 0, + }); + + new cronJob('*/10 * * * * *', (async () => { + publishLocalAnalytics(); + if (runJobs) { + await sleep(2000); + await Analytics.writeData(); + } + }), null, true); + + if (runJobs) { + pubsub.on('analytics:publish', data => { + incrementProperties(total, data.local); + }); + } }; function publishLocalAnalytics() { - pubsub.publish('analytics:publish', { - local: local, - }); - local = _.cloneDeep(empty); + pubsub.publish('analytics:publish', { + local, + }); + local = _.cloneDeep(empty); } -function incrementProperties(obj1, obj2) { - for (const [key, value] of Object.entries(obj2)) { - if (typeof value === 'object') { - incrementProperties(obj1[key], value); - } else if (utils.isNumber(value)) { - obj1[key] = obj1[key] || 0; - obj1[key] += obj2[key]; - } - } +function incrementProperties(object1, object2) { + for (const [key, value] of Object.entries(object2)) { + if (typeof value === 'object') { + incrementProperties(object1[key], value); + } else if (utils.isNumber(value)) { + object1[key] = object1[key] || 0; + object1[key] += object2[key]; + } + } } Analytics.increment = function (keys, callback) { - keys = Array.isArray(keys) ? keys : [keys]; + keys = Array.isArray(keys) ? keys : [keys]; - plugins.hooks.fire('action:analytics.increment', { keys: keys }); + plugins.hooks.fire('action:analytics.increment', {keys}); - keys.forEach((key) => { - local.counters[key] = local.counters[key] || 0; - local.counters[key] += 1; - }); + for (const key of keys) { + local.counters[key] = local.counters[key] || 0; + local.counters[key] += 1; + } - if (typeof callback === 'function') { - callback(); - } + if (typeof callback === 'function') { + callback(); + } }; Analytics.getKeys = async () => db.getSortedSetRange('analyticsKeys', 0, -1); Analytics.pageView = async function (payload) { - local.pageViews += 1; - - if (payload.uid > 0) { - local.pageViewsRegistered += 1; - } else if (payload.uid < 0) { - local.pageViewsBot += 1; - } else { - local.pageViewsGuest += 1; - } - - if (payload.ip) { - // Retrieve hash or calculate if not present - let hash = ipCache.get(payload.ip + secret); - if (!hash) { - hash = crypto.createHash('sha1').update(payload.ip + secret).digest('hex'); - ipCache.set(payload.ip + secret, hash); - } - - const score = await db.sortedSetScore('ip:recent', hash); - if (!score) { - local.uniqueIPCount += 1; - } - const today = new Date(); - today.setHours(today.getHours(), 0, 0, 0); - if (!score || score < today.getTime()) { - local.uniquevisitors += 1; - await db.sortedSetAdd('ip:recent', Date.now(), hash); - } - } + local.pageViews += 1; + + if (payload.uid > 0) { + local.pageViewsRegistered += 1; + } else if (payload.uid < 0) { + local.pageViewsBot += 1; + } else { + local.pageViewsGuest += 1; + } + + if (payload.ip) { + // Retrieve hash or calculate if not present + let hash = ipCache.get(payload.ip + secret); + if (!hash) { + hash = crypto.createHash('sha1').update(payload.ip + secret).digest('hex'); + ipCache.set(payload.ip + secret, hash); + } + + const score = await db.sortedSetScore('ip:recent', hash); + if (!score) { + local.uniqueIPCount += 1; + } + + const today = new Date(); + today.setHours(today.getHours(), 0, 0, 0); + if (!score || score < today.getTime()) { + local.uniquevisitors += 1; + await db.sortedSetAdd('ip:recent', Date.now(), hash); + } + } }; Analytics.writeData = async function () { - const today = new Date(); - const month = new Date(); - const dbQueue = []; - const incrByBulk = []; - - // Build list of metrics that were updated - let metrics = [ - 'pageviews', - 'pageviews:month', - ]; - metrics.forEach((metric) => { - const toAdd = ['registered', 'guest', 'bot'].map(type => `${metric}:${type}`); - metrics = [...metrics, ...toAdd]; - }); - metrics.push('uniquevisitors'); - - today.setHours(today.getHours(), 0, 0, 0); - month.setMonth(month.getMonth(), 1); - month.setHours(0, 0, 0, 0); - - if (total.pageViews > 0) { - incrByBulk.push(['analytics:pageviews', total.pageViews, today.getTime()]); - incrByBulk.push(['analytics:pageviews:month', total.pageViews, month.getTime()]); - total.pageViews = 0; - } - - if (total.pageViewsRegistered > 0) { - incrByBulk.push(['analytics:pageviews:registered', total.pageViewsRegistered, today.getTime()]); - incrByBulk.push(['analytics:pageviews:month:registered', total.pageViewsRegistered, month.getTime()]); - total.pageViewsRegistered = 0; - } - - if (total.pageViewsGuest > 0) { - incrByBulk.push(['analytics:pageviews:guest', total.pageViewsGuest, today.getTime()]); - incrByBulk.push(['analytics:pageviews:month:guest', total.pageViewsGuest, month.getTime()]); - total.pageViewsGuest = 0; - } - - if (total.pageViewsBot > 0) { - incrByBulk.push(['analytics:pageviews:bot', total.pageViewsBot, today.getTime()]); - incrByBulk.push(['analytics:pageviews:month:bot', total.pageViewsBot, month.getTime()]); - total.pageViewsBot = 0; - } - - if (total.uniquevisitors > 0) { - incrByBulk.push(['analytics:uniquevisitors', total.uniquevisitors, today.getTime()]); - total.uniquevisitors = 0; - } - - if (total.uniqueIPCount > 0) { - dbQueue.push(db.incrObjectFieldBy('global', 'uniqueIPCount', total.uniqueIPCount)); - total.uniqueIPCount = 0; - } - - for (const [key, value] of Object.entries(total.counters)) { - incrByBulk.push([`analytics:${key}`, value, today.getTime()]); - metrics.push(key); - delete total.counters[key]; - } - - if (incrByBulk.length) { - dbQueue.push(db.sortedSetIncrByBulk(incrByBulk)); - } - - // Update list of tracked metrics - dbQueue.push(db.sortedSetAdd('analyticsKeys', metrics.map(() => +Date.now()), metrics)); - - try { - await Promise.all(dbQueue); - } catch (err) { - winston.error(`[analytics] Encountered error while writing analytics to data store\n${err.stack}`); - } + const today = new Date(); + const month = new Date(); + const databaseQueue = []; + const incrByBulk = []; + + // Build list of metrics that were updated + let metrics = [ + 'pageviews', + 'pageviews:month', + ]; + for (const metric of metrics) { + const toAdd = ['registered', 'guest', 'bot'].map(type => `${metric}:${type}`); + metrics = [...metrics, ...toAdd]; + } + + metrics.push('uniquevisitors'); + + today.setHours(today.getHours(), 0, 0, 0); + month.setMonth(month.getMonth(), 1); + month.setHours(0, 0, 0, 0); + + if (total.pageViews > 0) { + incrByBulk.push(['analytics:pageviews', total.pageViews, today.getTime()]); + incrByBulk.push(['analytics:pageviews:month', total.pageViews, month.getTime()]); + total.pageViews = 0; + } + + if (total.pageViewsRegistered > 0) { + incrByBulk.push(['analytics:pageviews:registered', total.pageViewsRegistered, today.getTime()]); + incrByBulk.push(['analytics:pageviews:month:registered', total.pageViewsRegistered, month.getTime()]); + total.pageViewsRegistered = 0; + } + + if (total.pageViewsGuest > 0) { + incrByBulk.push(['analytics:pageviews:guest', total.pageViewsGuest, today.getTime()]); + incrByBulk.push(['analytics:pageviews:month:guest', total.pageViewsGuest, month.getTime()]); + total.pageViewsGuest = 0; + } + + if (total.pageViewsBot > 0) { + incrByBulk.push(['analytics:pageviews:bot', total.pageViewsBot, today.getTime()]); + incrByBulk.push(['analytics:pageviews:month:bot', total.pageViewsBot, month.getTime()]); + total.pageViewsBot = 0; + } + + if (total.uniquevisitors > 0) { + incrByBulk.push(['analytics:uniquevisitors', total.uniquevisitors, today.getTime()]); + total.uniquevisitors = 0; + } + + if (total.uniqueIPCount > 0) { + databaseQueue.push(db.incrObjectFieldBy('global', 'uniqueIPCount', total.uniqueIPCount)); + total.uniqueIPCount = 0; + } + + for (const [key, value] of Object.entries(total.counters)) { + incrByBulk.push([`analytics:${key}`, value, today.getTime()]); + metrics.push(key); + delete total.counters[key]; + } + + if (incrByBulk.length > 0) { + databaseQueue.push(db.sortedSetIncrByBulk(incrByBulk)); + } + + // Update list of tracked metrics + databaseQueue.push(db.sortedSetAdd('analyticsKeys', metrics.map(() => Number(Date.now())), metrics)); + + try { + await Promise.all(databaseQueue); + } catch (error) { + winston.error(`[analytics] Encountered error while writing analytics to data store\n${error.stack}`); + } }; -Analytics.getHourlyStatsForSet = async function (set, hour, numHours) { - // Guard against accidental ommission of `analytics:` prefix - if (!set.startsWith('analytics:')) { - set = `analytics:${set}`; - } +Analytics.getHourlyStatsForSet = async function (set, hour, numberHours) { + // Guard against accidental ommission of `analytics:` prefix + if (!set.startsWith('analytics:')) { + set = `analytics:${set}`; + } - const terms = {}; - const hoursArr = []; + const terms = {}; + const hoursArray = []; - hour = new Date(hour); - hour.setHours(hour.getHours(), 0, 0, 0); + hour = new Date(hour); + hour.setHours(hour.getHours(), 0, 0, 0); - for (let i = 0, ii = numHours; i < ii; i += 1) { - hoursArr.push(hour.getTime() - (i * 3600 * 1000)); - } + for (let i = 0, ii = numberHours; i < ii; i += 1) { + hoursArray.push(hour.getTime() - (i * 3600 * 1000)); + } - const counts = await db.sortedSetScores(set, hoursArr); + const counts = await db.sortedSetScores(set, hoursArray); - hoursArr.forEach((term, index) => { - terms[term] = parseInt(counts[index], 10) || 0; - }); + for (const [index, term] of hoursArray.entries()) { + terms[term] = Number.parseInt(counts[index], 10) || 0; + } - const termsArr = []; + const termsArray = []; - hoursArr.reverse(); - hoursArr.forEach((hour) => { - termsArr.push(terms[hour]); - }); + hoursArray.reverse(); + for (const hour of hoursArray) { + termsArray.push(terms[hour]); + } - return termsArr; + return termsArray; }; -Analytics.getDailyStatsForSet = async function (set, day, numDays) { - // Guard against accidental ommission of `analytics:` prefix - if (!set.startsWith('analytics:')) { - set = `analytics:${set}`; - } - - const daysArr = []; - day = new Date(day); - // set the date to tomorrow, because getHourlyStatsForSet steps *backwards* 24 hours to sum up the values - day.setDate(day.getDate() + 1); - day.setHours(0, 0, 0, 0); - - while (numDays > 0) { - /* eslint-disable no-await-in-loop */ - const dayData = await Analytics.getHourlyStatsForSet( - set, - day.getTime() - (1000 * 60 * 60 * 24 * (numDays - 1)), - 24 - ); - daysArr.push(dayData.reduce((cur, next) => cur + next)); - numDays -= 1; - } - return daysArr; +Analytics.getDailyStatsForSet = async function (set, day, numberDays) { + // Guard against accidental ommission of `analytics:` prefix + if (!set.startsWith('analytics:')) { + set = `analytics:${set}`; + } + + const daysArray = []; + day = new Date(day); + // Set the date to tomorrow, because getHourlyStatsForSet steps *backwards* 24 hours to sum up the values + day.setDate(day.getDate() + 1); + day.setHours(0, 0, 0, 0); + + while (numberDays > 0) { + /* eslint-disable no-await-in-loop */ + const dayData = await Analytics.getHourlyStatsForSet( + set, + day.getTime() - (1000 * 60 * 60 * 24 * (numberDays - 1)), + 24, + ); + daysArray.push(dayData.reduce((current, next) => current + next)); + numberDays -= 1; + } + + return daysArray; }; Analytics.getUnwrittenPageviews = function () { - return local.pageViews; + return local.pageViews; }; Analytics.getSummary = async function () { - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const [seven, thirty] = await Promise.all([ - Analytics.getDailyStatsForSet('analytics:pageviews', today, 7), - Analytics.getDailyStatsForSet('analytics:pageviews', today, 30), - ]); - - return { - seven: seven.reduce((sum, cur) => sum + cur, 0), - thirty: thirty.reduce((sum, cur) => sum + cur, 0), - }; + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const [seven, thirty] = await Promise.all([ + Analytics.getDailyStatsForSet('analytics:pageviews', today, 7), + Analytics.getDailyStatsForSet('analytics:pageviews', today, 30), + ]); + + return { + seven: seven.reduce((sum, current) => sum + current, 0), + thirty: thirty.reduce((sum, current) => sum + current, 0), + }; }; Analytics.getCategoryAnalytics = async function (cid) { - return await utils.promiseParallel({ - 'pageviews:hourly': Analytics.getHourlyStatsForSet(`analytics:pageviews:byCid:${cid}`, Date.now(), 24), - 'pageviews:daily': Analytics.getDailyStatsForSet(`analytics:pageviews:byCid:${cid}`, Date.now(), 30), - 'topics:daily': Analytics.getDailyStatsForSet(`analytics:topics:byCid:${cid}`, Date.now(), 7), - 'posts:daily': Analytics.getDailyStatsForSet(`analytics:posts:byCid:${cid}`, Date.now(), 7), - }); + return await utils.promiseParallel({ + 'pageviews:hourly': Analytics.getHourlyStatsForSet(`analytics:pageviews:byCid:${cid}`, Date.now(), 24), + 'pageviews:daily': Analytics.getDailyStatsForSet(`analytics:pageviews:byCid:${cid}`, Date.now(), 30), + 'topics:daily': Analytics.getDailyStatsForSet(`analytics:topics:byCid:${cid}`, Date.now(), 7), + 'posts:daily': Analytics.getDailyStatsForSet(`analytics:posts:byCid:${cid}`, Date.now(), 7), + }); }; Analytics.getErrorAnalytics = async function () { - return await utils.promiseParallel({ - 'not-found': Analytics.getDailyStatsForSet('analytics:errors:404', Date.now(), 7), - toobusy: Analytics.getDailyStatsForSet('analytics:errors:503', Date.now(), 7), - }); + return await utils.promiseParallel({ + 'not-found': Analytics.getDailyStatsForSet('analytics:errors:404', Date.now(), 7), + toobusy: Analytics.getDailyStatsForSet('analytics:errors:503', Date.now(), 7), + }); }; Analytics.getBlacklistAnalytics = async function () { - return await utils.promiseParallel({ - daily: Analytics.getDailyStatsForSet('analytics:blacklist', Date.now(), 7), - hourly: Analytics.getHourlyStatsForSet('analytics:blacklist', Date.now(), 24), - }); + return await utils.promiseParallel({ + daily: Analytics.getDailyStatsForSet('analytics:blacklist', Date.now(), 7), + hourly: Analytics.getHourlyStatsForSet('analytics:blacklist', Date.now(), 24), + }); }; require('./promisify')(Analytics); diff --git a/src/api/categories.js b/src/api/categories.js index 41191bd..0fbc0e9 100644 --- a/src/api/categories.js +++ b/src/api/categories.js @@ -9,94 +9,98 @@ const privileges = require('../privileges'); const categoriesAPI = module.exports; categoriesAPI.get = async function (caller, data) { - const [userPrivileges, category] = await Promise.all([ - privileges.categories.get(data.cid, caller.uid), - categories.getCategoryData(data.cid), - ]); - if (!category || !userPrivileges.read) { - return null; - } - - return category; + const [userPrivileges, category] = await Promise.all([ + privileges.categories.get(data.cid, caller.uid), + categories.getCategoryData(data.cid), + ]); + if (!category || !userPrivileges.read) { + return null; + } + + return category; }; categoriesAPI.create = async function (caller, data) { - const response = await categories.create(data); - const categoryObjs = await categories.getCategories([response.cid], caller.uid); - return categoryObjs[0]; + const response = await categories.create(data); + const categoryObjs = await categories.getCategories([response.cid], caller.uid); + return categoryObjs[0]; }; categoriesAPI.update = async function (caller, data) { - if (!data) { - throw new Error('[[error:invalid-data]]'); - } - await categories.update(data); + if (!data) { + throw new Error('[[error:invalid-data]]'); + } + + await categories.update(data); }; categoriesAPI.delete = async function (caller, data) { - const name = await categories.getCategoryField(data.cid, 'name'); - await categories.purge(data.cid, caller.uid); - await events.log({ - type: 'category-purge', - uid: caller.uid, - ip: caller.ip, - cid: data.cid, - name: name, - }); + const name = await categories.getCategoryField(data.cid, 'name'); + await categories.purge(data.cid, caller.uid); + await events.log({ + type: 'category-purge', + uid: caller.uid, + ip: caller.ip, + cid: data.cid, + name, + }); }; categoriesAPI.getPrivileges = async (caller, cid) => { - let responsePayload; + let responsePayload; - if (cid === 'admin') { - responsePayload = await privileges.admin.list(caller.uid); - } else if (!parseInt(cid, 10)) { - responsePayload = await privileges.global.list(); - } else { - responsePayload = await privileges.categories.list(cid); - } + if (cid === 'admin') { + responsePayload = await privileges.admin.list(caller.uid); + } else if (Number.parseInt(cid, 10)) { + responsePayload = await privileges.categories.list(cid); + } else { + responsePayload = await privileges.global.list(); + } - return responsePayload; + return responsePayload; }; categoriesAPI.setPrivilege = async (caller, data) => { - const [userExists, groupExists] = await Promise.all([ - user.exists(data.member), - groups.exists(data.member), - ]); - - if (!userExists && !groupExists) { - throw new Error('[[error:no-user-or-group]]'); - } - const privs = Array.isArray(data.privilege) ? data.privilege : [data.privilege]; - const type = data.set ? 'give' : 'rescind'; - if (!privs.length) { - throw new Error('[[error:invalid-data]]'); - } - if (parseInt(data.cid, 10) === 0) { - const adminPrivList = await privileges.admin.getPrivilegeList(); - const adminPrivs = privs.filter(priv => adminPrivList.includes(priv)); - if (adminPrivs.length) { - await privileges.admin[type](adminPrivs, data.member); - } - const globalPrivList = await privileges.global.getPrivilegeList(); - const globalPrivs = privs.filter(priv => globalPrivList.includes(priv)); - if (globalPrivs.length) { - await privileges.global[type](globalPrivs, data.member); - } - } else { - const categoryPrivList = await privileges.categories.getPrivilegeList(); - const categoryPrivs = privs.filter(priv => categoryPrivList.includes(priv)); - await privileges.categories[type](categoryPrivs, data.cid, data.member); - } - - await events.log({ - uid: caller.uid, - type: 'privilege-change', - ip: caller.ip, - privilege: data.privilege.toString(), - cid: data.cid, - action: data.set ? 'grant' : 'rescind', - target: data.member, - }); + const [userExists, groupExists] = await Promise.all([ + user.exists(data.member), + groups.exists(data.member), + ]); + + if (!userExists && !groupExists) { + throw new Error('[[error:no-user-or-group]]'); + } + + const privs = Array.isArray(data.privilege) ? data.privilege : [data.privilege]; + const type = data.set ? 'give' : 'rescind'; + if (privs.length === 0) { + throw new Error('[[error:invalid-data]]'); + } + + if (Number.parseInt(data.cid, 10) === 0) { + const adminPrivList = await privileges.admin.getPrivilegeList(); + const adminPrivs = privs.filter(priv => adminPrivList.includes(priv)); + if (adminPrivs.length > 0) { + await privileges.admin[type](adminPrivs, data.member); + } + + const globalPrivList = await privileges.global.getPrivilegeList(); + const globalPrivs = privs.filter(priv => globalPrivList.includes(priv)); + if (globalPrivs.length > 0) { + await privileges.global[type](globalPrivs, data.member); + } + } else { + const categoryPrivList = await privileges.categories.getPrivilegeList(); + const categoryPrivs = privs.filter(priv => categoryPrivList.includes(priv)); + await privileges.categories[type](categoryPrivs, data.cid, data.member); + } + + await events.log({ + uid: caller.uid, + type: 'privilege-change', + ip: caller.ip, + privilege: data.privilege.toString(), + cid: data.cid, + action: data.set ? 'grant' : 'rescind', + target: data.member, + }); }; diff --git a/src/api/chats.js b/src/api/chats.js index e6ade22..b1bb2ee 100644 --- a/src/api/chats.js +++ b/src/api/chats.js @@ -1,120 +1,118 @@ 'use strict'; const validator = require('validator'); - const user = require('../user'); const meta = require('../meta'); const messaging = require('../messaging'); const plugins = require('../plugins'); -// const websockets = require('../socket.io'); +// Const websockets = require('../socket.io'); const socketHelpers = require('../socket.io/helpers'); const chatsAPI = module.exports; function rateLimitExceeded(caller) { - const session = caller.request ? caller.request.session : caller.session; // socket vs req - const now = Date.now(); - session.lastChatMessageTime = session.lastChatMessageTime || 0; - if (now - session.lastChatMessageTime < meta.config.chatMessageDelay) { - return true; - } - session.lastChatMessageTime = now; - return false; + const session = caller.request ? caller.request.session : caller.session; // Socket vs req + const now = Date.now(); + session.lastChatMessageTime = session.lastChatMessageTime || 0; + if (now - session.lastChatMessageTime < meta.config.chatMessageDelay) { + return true; + } + + session.lastChatMessageTime = now; + return false; } chatsAPI.create = async function (caller, data) { - if (rateLimitExceeded(caller)) { - throw new Error('[[error:too-many-messages]]'); - } + if (rateLimitExceeded(caller)) { + throw new Error('[[error:too-many-messages]]'); + } - if (!data.uids || !Array.isArray(data.uids)) { - throw new Error(`[[error:wrong-parameter-type, uids, ${typeof data.uids}, Array]]`); - } + if (!data.uids || !Array.isArray(data.uids)) { + throw new Error(`[[error:wrong-parameter-type, uids, ${typeof data.uids}, Array]]`); + } - await Promise.all(data.uids.map(async uid => messaging.canMessageUser(caller.uid, uid))); - const roomId = await messaging.newRoom(caller.uid, data.uids); + await Promise.all(data.uids.map(async uid => messaging.canMessageUser(caller.uid, uid))); + const roomId = await messaging.newRoom(caller.uid, data.uids); - return await messaging.getRoomData(roomId); + return await messaging.getRoomData(roomId); }; chatsAPI.post = async (caller, data) => { - if (rateLimitExceeded(caller)) { - throw new Error('[[error:too-many-messages]]'); - } - - ({ data } = await plugins.hooks.fire('filter:messaging.send', { - data, - uid: caller.uid, - })); - - await messaging.canMessageRoom(caller.uid, data.roomId); - const message = await messaging.sendMessage({ - uid: caller.uid, - roomId: data.roomId, - content: data.message, - timestamp: Date.now(), - ip: caller.ip, - }); - messaging.notifyUsersInRoom(caller.uid, data.roomId, message); - user.updateOnlineUsers(caller.uid); - - return message; + if (rateLimitExceeded(caller)) { + throw new Error('[[error:too-many-messages]]'); + } + + ({data} = await plugins.hooks.fire('filter:messaging.send', { + data, + uid: caller.uid, + })); + + await messaging.canMessageRoom(caller.uid, data.roomId); + const message = await messaging.sendMessage({ + uid: caller.uid, + roomId: data.roomId, + content: data.message, + timestamp: Date.now(), + ip: caller.ip, + }); + messaging.notifyUsersInRoom(caller.uid, data.roomId, message); + user.updateOnlineUsers(caller.uid); + + return message; }; chatsAPI.rename = async (caller, data) => { - await messaging.renameRoom(caller.uid, data.roomId, data.name); - const uids = await messaging.getUidsInRoom(data.roomId, 0, -1); - const eventData = { roomId: data.roomId, newName: validator.escape(String(data.name)) }; - - socketHelpers.emitToUids('event:chats.roomRename', eventData, uids); - return messaging.loadRoom(caller.uid, { - roomId: data.roomId, - }); + await messaging.renameRoom(caller.uid, data.roomId, data.name); + const uids = await messaging.getUidsInRoom(data.roomId, 0, -1); + const eventData = {roomId: data.roomId, newName: validator.escape(String(data.name))}; + + socketHelpers.emitToUids('event:chats.roomRename', eventData, uids); + return messaging.loadRoom(caller.uid, { + roomId: data.roomId, + }); }; chatsAPI.users = async (caller, data) => { - const [isOwner, users] = await Promise.all([ - messaging.isRoomOwner(caller.uid, data.roomId), - messaging.getUsersInRoom(data.roomId, 0, -1), - ]); - users.forEach((user) => { - user.canKick = (parseInt(user.uid, 10) !== parseInt(caller.uid, 10)) && isOwner; - }); - return { users }; + const [isOwner, users] = await Promise.all([ + messaging.isRoomOwner(caller.uid, data.roomId), + messaging.getUsersInRoom(data.roomId, 0, -1), + ]); + for (const user of users) { + user.canKick = (Number.parseInt(user.uid, 10) !== Number.parseInt(caller.uid, 10)) && isOwner; + } + + return {users}; }; chatsAPI.invite = async (caller, data) => { - const userCount = await messaging.getUserCountInRoom(data.roomId); - const maxUsers = meta.config.maximumUsersInChatRoom; - if (maxUsers && userCount >= maxUsers) { - throw new Error('[[error:cant-add-more-users-to-chat-room]]'); - } - - const uidsExist = await user.exists(data.uids); - if (!uidsExist.every(Boolean)) { - throw new Error('[[error:no-user]]'); - } - await Promise.all(data.uids.map(async uid => messaging.canMessageUser(caller.uid, uid))); - await messaging.addUsersToRoom(caller.uid, data.uids, data.roomId); - - delete data.uids; - return chatsAPI.users(caller, data); + const userCount = await messaging.getUserCountInRoom(data.roomId); + const maxUsers = meta.config.maximumUsersInChatRoom; + if (maxUsers && userCount >= maxUsers) { + throw new Error('[[error:cant-add-more-users-to-chat-room]]'); + } + + const uidsExist = await user.exists(data.uids); + if (!uidsExist.every(Boolean)) { + throw new Error('[[error:no-user]]'); + } + + await Promise.all(data.uids.map(async uid => messaging.canMessageUser(caller.uid, uid))); + await messaging.addUsersToRoom(caller.uid, data.uids, data.roomId); + + delete data.uids; + return chatsAPI.users(caller, data); }; chatsAPI.kick = async (caller, data) => { - const uidsExist = await user.exists(data.uids); - if (!uidsExist.every(Boolean)) { - throw new Error('[[error:no-user]]'); - } - - // Additional checks if kicking vs leaving - if (data.uids.length === 1 && parseInt(data.uids[0], 10) === caller.uid) { - await messaging.leaveRoom([caller.uid], data.roomId); - } else { - await messaging.removeUsersFromRoom(caller.uid, data.uids, data.roomId); - } - - delete data.uids; - return chatsAPI.users(caller, data); + const uidsExist = await user.exists(data.uids); + if (!uidsExist.every(Boolean)) { + throw new Error('[[error:no-user]]'); + } + + // Additional checks if kicking vs leaving + await (data.uids.length === 1 && Number.parseInt(data.uids[0], 10) === caller.uid ? messaging.leaveRoom([caller.uid], data.roomId) : messaging.removeUsersFromRoom(caller.uid, data.uids, data.roomId)); + + delete data.uids; + return chatsAPI.users(caller, data); }; diff --git a/src/api/flags.js b/src/api/flags.js index 8b34f60..26945e7 100644 --- a/src/api/flags.js +++ b/src/api/flags.js @@ -6,79 +6,81 @@ const flags = require('../flags'); const flagsApi = module.exports; flagsApi.create = async (caller, data) => { - const required = ['type', 'id', 'reason']; - if (!required.every(prop => !!data[prop])) { - throw new Error('[[error:invalid-data]]'); - } + const required = ['type', 'id', 'reason']; + if (!required.every(property => Boolean(data[property]))) { + throw new Error('[[error:invalid-data]]'); + } - const { type, id, reason } = data; + const {type, id, reason} = data; - await flags.validate({ - uid: caller.uid, - type: type, - id: id, - }); + await flags.validate({ + uid: caller.uid, + type, + id, + }); - const flagObj = await flags.create(type, id, caller.uid, reason); - flags.notify(flagObj, caller.uid); + const flagObject = await flags.create(type, id, caller.uid, reason); + flags.notify(flagObject, caller.uid); - return flagObj; + return flagObject; }; flagsApi.update = async (caller, data) => { - const allowed = await user.isPrivileged(caller.uid); - if (!allowed) { - throw new Error('[[error:no-privileges]]'); - } + const allowed = await user.isPrivileged(caller.uid); + if (!allowed) { + throw new Error('[[error:no-privileges]]'); + } - const { flagId } = data; - delete data.flagId; + const {flagId} = data; + delete data.flagId; - await flags.update(flagId, caller.uid, data); - return await flags.getHistory(flagId); + await flags.update(flagId, caller.uid, data); + return await flags.getHistory(flagId); }; flagsApi.appendNote = async (caller, data) => { - const allowed = await user.isPrivileged(caller.uid); - if (!allowed) { - throw new Error('[[error:no-privileges]]'); - } - if (data.datetime && data.flagId) { - try { - const note = await flags.getNote(data.flagId, data.datetime); - if (note.uid !== caller.uid) { - throw new Error('[[error:no-privileges]]'); - } - } catch (e) { - // Okay if not does not exist in database - if (e.message !== '[[error:invalid-data]]') { - throw e; - } - } - } - await flags.appendNote(data.flagId, caller.uid, data.note, data.datetime); - const [notes, history] = await Promise.all([ - flags.getNotes(data.flagId), - flags.getHistory(data.flagId), - ]); - return { notes: notes, history: history }; + const allowed = await user.isPrivileged(caller.uid); + if (!allowed) { + throw new Error('[[error:no-privileges]]'); + } + + if (data.datetime && data.flagId) { + try { + const note = await flags.getNote(data.flagId, data.datetime); + if (note.uid !== caller.uid) { + throw new Error('[[error:no-privileges]]'); + } + } catch (error) { + // Okay if not does not exist in database + if (error.message !== '[[error:invalid-data]]') { + throw error; + } + } + } + + await flags.appendNote(data.flagId, caller.uid, data.note, data.datetime); + const [notes, history] = await Promise.all([ + flags.getNotes(data.flagId), + flags.getHistory(data.flagId), + ]); + return {notes, history}; }; flagsApi.deleteNote = async (caller, data) => { - const note = await flags.getNote(data.flagId, data.datetime); - if (note.uid !== caller.uid) { - throw new Error('[[error:no-privileges]]'); - } - - await flags.deleteNote(data.flagId, data.datetime); - await flags.appendHistory(data.flagId, caller.uid, { - notes: '[[flags:note-deleted]]', - datetime: Date.now(), - }); - - const [notes, history] = await Promise.all([ - flags.getNotes(data.flagId), - flags.getHistory(data.flagId), - ]); - return { notes: notes, history: history }; + const note = await flags.getNote(data.flagId, data.datetime); + if (note.uid !== caller.uid) { + throw new Error('[[error:no-privileges]]'); + } + + await flags.deleteNote(data.flagId, data.datetime); + await flags.appendHistory(data.flagId, caller.uid, { + notes: '[[flags:note-deleted]]', + datetime: Date.now(), + }); + + const [notes, history] = await Promise.all([ + flags.getNotes(data.flagId), + flags.getHistory(data.flagId), + ]); + return {notes, history}; }; diff --git a/src/api/groups.js b/src/api/groups.js index a5c2216..fa71f5d 100644 --- a/src/api/groups.js +++ b/src/api/groups.js @@ -1,7 +1,6 @@ 'use strict'; const validator = require('validator'); - const privileges = require('../privileges'); const events = require('../events'); const groups = require('../groups'); @@ -13,226 +12,233 @@ const slugify = require('../slugify'); const groupsAPI = module.exports; groupsAPI.create = async function (caller, data) { - if (!caller.uid) { - throw new Error('[[error:no-privileges]]'); - } else if (!data) { - throw new Error('[[error:invalid-data]]'); - } else if (typeof data.name !== 'string' || groups.isPrivilegeGroup(data.name)) { - throw new Error('[[error:invalid-group-name]]'); - } - - const canCreate = await privileges.global.can('group:create', caller.uid); - if (!canCreate) { - throw new Error('[[error:no-privileges]]'); - } - data.ownerUid = caller.uid; - data.system = false; - const groupData = await groups.create(data); - logGroupEvent(caller, 'group-create', { - groupName: data.name, - }); - - return groupData; + if (!caller.uid) { + throw new Error('[[error:no-privileges]]'); + } else if (!data) { + throw new Error('[[error:invalid-data]]'); + } else if (typeof data.name !== 'string' || groups.isPrivilegeGroup(data.name)) { + throw new Error('[[error:invalid-group-name]]'); + } + + const canCreate = await privileges.global.can('group:create', caller.uid); + if (!canCreate) { + throw new Error('[[error:no-privileges]]'); + } + + data.ownerUid = caller.uid; + data.system = false; + const groupData = await groups.create(data); + logGroupEvent(caller, 'group-create', { + groupName: data.name, + }); + + return groupData; }; groupsAPI.update = async function (caller, data) { - if (!data) { - throw new Error('[[error:invalid-data]]'); - } - const groupName = await groups.getGroupNameByGroupSlug(data.slug); - await isOwner(caller, groupName); + if (!data) { + throw new Error('[[error:invalid-data]]'); + } + + const groupName = await groups.getGroupNameByGroupSlug(data.slug); + await isOwner(caller, groupName); - delete data.slug; - await groups.update(groupName, data); + delete data.slug; + await groups.update(groupName, data); - return await groups.getGroupData(data.name || groupName); + return await groups.getGroupData(data.name || groupName); }; groupsAPI.delete = async function (caller, data) { - const groupName = await groups.getGroupNameByGroupSlug(data.slug); - await isOwner(caller, groupName); - if ( - groups.systemGroups.includes(groupName) || - groups.ephemeralGroups.includes(groupName) - ) { - throw new Error('[[error:not-allowed]]'); - } - - await groups.destroy(groupName); - logGroupEvent(caller, 'group-delete', { - groupName: groupName, - }); + const groupName = await groups.getGroupNameByGroupSlug(data.slug); + await isOwner(caller, groupName); + if ( + groups.systemGroups.includes(groupName) + || groups.ephemeralGroups.includes(groupName) + ) { + throw new Error('[[error:not-allowed]]'); + } + + await groups.destroy(groupName); + logGroupEvent(caller, 'group-delete', { + groupName, + }); }; groupsAPI.join = async function (caller, data) { - if (!data) { - throw new Error('[[error:invalid-data]]'); - } - if (caller.uid <= 0 || !data.uid) { - throw new Error('[[error:invalid-uid]]'); - } - - const groupName = await groups.getGroupNameByGroupSlug(data.slug); - if (!groupName) { - throw new Error('[[error:no-group]]'); - } - - const isCallerAdmin = await user.isAdministrator(caller.uid); - if (!isCallerAdmin && ( - groups.systemGroups.includes(groupName) || - groups.isPrivilegeGroup(groupName) - )) { - throw new Error('[[error:not-allowed]]'); - } - - const [groupData, isCallerOwner, userExists] = await Promise.all([ - groups.getGroupData(groupName), - groups.ownership.isOwner(caller.uid, groupName), - user.exists(data.uid), - ]); - - if (!userExists) { - throw new Error('[[error:invalid-uid]]'); - } - - const isSelf = parseInt(caller.uid, 10) === parseInt(data.uid, 10); - if (!meta.config.allowPrivateGroups && isSelf) { - // all groups are public! - await groups.join(groupName, data.uid); - logGroupEvent(caller, 'group-join', { - groupName: groupName, - targetUid: data.uid, - }); - return; - } - - if (!isCallerAdmin && isSelf && groupData.private && groupData.disableJoinRequests) { - throw new Error('[[error:group-join-disabled]]'); - } - - if ((!groupData.private && isSelf) || isCallerAdmin || isCallerOwner) { - await groups.join(groupName, data.uid); - logGroupEvent(caller, 'group-join', { - groupName: groupName, - targetUid: data.uid, - }); - } else if (isSelf) { - await groups.requestMembership(groupName, caller.uid); - logGroupEvent(caller, 'group-request-membership', { - groupName: groupName, - targetUid: data.uid, - }); - } + if (!data) { + throw new Error('[[error:invalid-data]]'); + } + + if (caller.uid <= 0 || !data.uid) { + throw new Error('[[error:invalid-uid]]'); + } + + const groupName = await groups.getGroupNameByGroupSlug(data.slug); + if (!groupName) { + throw new Error('[[error:no-group]]'); + } + + const isCallerAdmin = await user.isAdministrator(caller.uid); + if (!isCallerAdmin && ( + groups.systemGroups.includes(groupName) + || groups.isPrivilegeGroup(groupName) + )) { + throw new Error('[[error:not-allowed]]'); + } + + const [groupData, isCallerOwner, userExists] = await Promise.all([ + groups.getGroupData(groupName), + groups.ownership.isOwner(caller.uid, groupName), + user.exists(data.uid), + ]); + + if (!userExists) { + throw new Error('[[error:invalid-uid]]'); + } + + const isSelf = Number.parseInt(caller.uid, 10) === Number.parseInt(data.uid, 10); + if (!meta.config.allowPrivateGroups && isSelf) { + // All groups are public! + await groups.join(groupName, data.uid); + logGroupEvent(caller, 'group-join', { + groupName, + targetUid: data.uid, + }); + return; + } + + if (!isCallerAdmin && isSelf && groupData.private && groupData.disableJoinRequests) { + throw new Error('[[error:group-join-disabled]]'); + } + + if ((!groupData.private && isSelf) || isCallerAdmin || isCallerOwner) { + await groups.join(groupName, data.uid); + logGroupEvent(caller, 'group-join', { + groupName, + targetUid: data.uid, + }); + } else if (isSelf) { + await groups.requestMembership(groupName, caller.uid); + logGroupEvent(caller, 'group-request-membership', { + groupName, + targetUid: data.uid, + }); + } }; groupsAPI.leave = async function (caller, data) { - if (!data) { - throw new Error('[[error:invalid-data]]'); - } - if (caller.uid <= 0) { - throw new Error('[[error:invalid-uid]]'); - } - const isSelf = parseInt(caller.uid, 10) === parseInt(data.uid, 10); - const groupName = await groups.getGroupNameByGroupSlug(data.slug); - if (!groupName) { - throw new Error('[[error:no-group]]'); - } - - if (typeof groupName !== 'string') { - throw new Error('[[error:invalid-group-name]]'); - } - - if (groupName === 'administrators' && isSelf) { - throw new Error('[[error:cant-remove-self-as-admin]]'); - } - - const [groupData, isCallerAdmin, isCallerOwner, userExists, isMember] = await Promise.all([ - groups.getGroupData(groupName), - user.isAdministrator(caller.uid), - groups.ownership.isOwner(caller.uid, groupName), - user.exists(data.uid), - groups.isMember(data.uid, groupName), - ]); - - if (!userExists) { - throw new Error('[[error:invalid-uid]]'); - } - if (!isMember) { - return; - } - - if (groupData.disableLeave && isSelf) { - throw new Error('[[error:group-leave-disabled]]'); - } - - if (isSelf || isCallerAdmin || isCallerOwner) { - await groups.leave(groupName, data.uid); - } else { - throw new Error('[[error:no-privileges]]'); - } - - const { displayname } = await user.getUserFields(data.uid, ['username']); - - const notification = await notifications.create({ - type: 'group-leave', - bodyShort: `[[groups:membership.leave.notification_title, ${displayname}, ${groupName}]]`, - nid: `group:${validator.escape(groupName)}:uid:${data.uid}:group-leave`, - path: `/groups/${slugify(groupName)}`, - from: data.uid, - }); - const uids = await groups.getOwners(groupName); - await notifications.push(notification, uids); - - logGroupEvent(caller, 'group-leave', { - groupName: groupName, - targetUid: data.uid, - }); + if (!data) { + throw new Error('[[error:invalid-data]]'); + } + + if (caller.uid <= 0) { + throw new Error('[[error:invalid-uid]]'); + } + + const isSelf = Number.parseInt(caller.uid, 10) === Number.parseInt(data.uid, 10); + const groupName = await groups.getGroupNameByGroupSlug(data.slug); + if (!groupName) { + throw new Error('[[error:no-group]]'); + } + + if (typeof groupName !== 'string') { + throw new TypeError('[[error:invalid-group-name]]'); + } + + if (groupName === 'administrators' && isSelf) { + throw new Error('[[error:cant-remove-self-as-admin]]'); + } + + const [groupData, isCallerAdmin, isCallerOwner, userExists, isMember] = await Promise.all([ + groups.getGroupData(groupName), + user.isAdministrator(caller.uid), + groups.ownership.isOwner(caller.uid, groupName), + user.exists(data.uid), + groups.isMember(data.uid, groupName), + ]); + + if (!userExists) { + throw new Error('[[error:invalid-uid]]'); + } + + if (!isMember) { + return; + } + + if (groupData.disableLeave && isSelf) { + throw new Error('[[error:group-leave-disabled]]'); + } + + if (isSelf || isCallerAdmin || isCallerOwner) { + await groups.leave(groupName, data.uid); + } else { + throw new Error('[[error:no-privileges]]'); + } + + const {displayname} = await user.getUserFields(data.uid, ['username']); + + const notification = await notifications.create({ + type: 'group-leave', + bodyShort: `[[groups:membership.leave.notification_title, ${displayname}, ${groupName}]]`, + nid: `group:${validator.escape(groupName)}:uid:${data.uid}:group-leave`, + path: `/groups/${slugify(groupName)}`, + from: data.uid, + }); + const uids = await groups.getOwners(groupName); + await notifications.push(notification, uids); + + logGroupEvent(caller, 'group-leave', { + groupName, + targetUid: data.uid, + }); }; groupsAPI.grant = async (caller, data) => { - const groupName = await groups.getGroupNameByGroupSlug(data.slug); - await isOwner(caller, groupName); - - await groups.ownership.grant(data.uid, groupName); - logGroupEvent(caller, 'group-owner-grant', { - groupName: groupName, - targetUid: data.uid, - }); + const groupName = await groups.getGroupNameByGroupSlug(data.slug); + await isOwner(caller, groupName); + + await groups.ownership.grant(data.uid, groupName); + logGroupEvent(caller, 'group-owner-grant', { + groupName, + targetUid: data.uid, + }); }; groupsAPI.rescind = async (caller, data) => { - const groupName = await groups.getGroupNameByGroupSlug(data.slug); - await isOwner(caller, groupName); - - await groups.ownership.rescind(data.uid, groupName); - logGroupEvent(caller, 'group-owner-rescind', { - groupName: groupName, - targetUid: data.uid, - }); + const groupName = await groups.getGroupNameByGroupSlug(data.slug); + await isOwner(caller, groupName); + + await groups.ownership.rescind(data.uid, groupName); + logGroupEvent(caller, 'group-owner-rescind', { + groupName, + targetUid: data.uid, + }); }; async function isOwner(caller, groupName) { - if (typeof groupName !== 'string') { - throw new Error('[[error:invalid-group-name]]'); - } - const [hasAdminPrivilege, isGlobalModerator, isOwner, group] = await Promise.all([ - privileges.admin.can('admin:groups', caller.uid), - user.isGlobalModerator(caller.uid), - groups.ownership.isOwner(caller.uid, groupName), - groups.getGroupData(groupName), - ]); - - const check = isOwner || hasAdminPrivilege || (isGlobalModerator && !group.system); - if (!check) { - throw new Error('[[error:no-privileges]]'); - } + if (typeof groupName !== 'string') { + throw new TypeError('[[error:invalid-group-name]]'); + } + + const [hasAdminPrivilege, isGlobalModerator, isOwner, group] = await Promise.all([ + privileges.admin.can('admin:groups', caller.uid), + user.isGlobalModerator(caller.uid), + groups.ownership.isOwner(caller.uid, groupName), + groups.getGroupData(groupName), + ]); + + const check = isOwner || hasAdminPrivilege || (isGlobalModerator && !group.system); + if (!check) { + throw new Error('[[error:no-privileges]]'); + } } function logGroupEvent(caller, event, additional) { - events.log({ - type: event, - uid: caller.uid, - ip: caller.ip, - ...additional, - }); + events.log({ + type: event, + uid: caller.uid, + ip: caller.ip, + ...additional, + }); } diff --git a/src/api/helpers.js b/src/api/helpers.js index 0be5872..53d4eb8 100644 --- a/src/api/helpers.js +++ b/src/api/helpers.js @@ -1,6 +1,6 @@ 'use strict'; -const url = require('url'); +const url = require('node:url'); const user = require('../user'); const topics = require('../topics'); const posts = require('../posts'); @@ -10,133 +10,135 @@ const socketHelpers = require('../socket.io/helpers'); const websockets = require('../socket.io'); const events = require('../events'); -exports.setDefaultPostData = function (reqOrSocket, data) { - data.uid = reqOrSocket.uid; - data.req = exports.buildReqObject(reqOrSocket, { ...data }); - data.timestamp = Date.now(); - data.fromQueue = false; +exports.setDefaultPostData = function (requestOrSocket, data) { + data.uid = requestOrSocket.uid; + data.req = exports.buildReqObject(requestOrSocket, {...data}); + data.timestamp = Date.now(); + data.fromQueue = false; }; -// creates a slimmed down version of the request object -exports.buildReqObject = (req, payload) => { - req = req || {}; - const headers = req.headers || (req.request && req.request.headers) || {}; - const encrypted = req.connection ? !!req.connection.encrypted : false; - let { host } = headers; - const referer = headers.referer || ''; - - if (!host) { - host = url.parse(referer).host || ''; - } - - return { - uid: req.uid, - params: req.params, - method: req.method, - body: payload || req.body, - session: req.session, - ip: req.ip, - host: host, - protocol: encrypted ? 'https' : 'http', - secure: encrypted, - url: referer, - path: referer.slice(referer.indexOf(host) + host.length), - headers: headers, - }; +// Creates a slimmed down version of the request object +exports.buildReqObject = (request, payload) => { + request ||= {}; + const headers = request.headers || (request.request && request.request.headers) || {}; + const encrypted = request.connection ? Boolean(request.connection.encrypted) : false; + let {host} = headers; + const referer = headers.referer || ''; + + host ||= url.parse(referer).host || ''; + + return { + uid: request.uid, + params: request.params, + method: request.method, + body: payload || request.body, + session: request.session, + ip: request.ip, + host, + protocol: encrypted ? 'https' : 'http', + secure: encrypted, + url: referer, + path: referer.slice(referer.indexOf(host) + host.length), + headers, + }; }; -exports.doTopicAction = async function (action, event, caller, { tids }) { - if (!Array.isArray(tids)) { - throw new Error('[[error:invalid-tid]]'); - } - - const exists = await topics.exists(tids); - if (!exists.every(Boolean)) { - throw new Error('[[error:no-topic]]'); - } - - if (typeof topics.tools[action] !== 'function') { - return; - } - - const uids = await user.getUidsFromSet('users:online', 0, -1); - - await Promise.all(tids.map(async (tid) => { - const title = await topics.getTopicField(tid, 'title'); - const data = await topics.tools[action](tid, caller.uid); - const notifyUids = await privileges.categories.filterUids('topics:read', data.cid, uids); - socketHelpers.emitToUids(event, data, notifyUids); - await logTopicAction(action, caller, tid, title); - })); +exports.doTopicAction = async function (action, event, caller, {tids}) { + if (!Array.isArray(tids)) { + throw new TypeError('[[error:invalid-tid]]'); + } + + const exists = await topics.exists(tids); + if (!exists.every(Boolean)) { + throw new Error('[[error:no-topic]]'); + } + + if (typeof topics.tools[action] !== 'function') { + return; + } + + const uids = await user.getUidsFromSet('users:online', 0, -1); + + await Promise.all(tids.map(async tid => { + const title = await topics.getTopicField(tid, 'title'); + const data = await topics.tools[action](tid, caller.uid); + const notifyUids = await privileges.categories.filterUids('topics:read', data.cid, uids); + socketHelpers.emitToUids(event, data, notifyUids); + await logTopicAction(action, caller, tid, title); + })); }; -async function logTopicAction(action, req, tid, title) { - // Only log certain actions to system event log - const actionsToLog = ['delete', 'restore', 'purge']; - if (!actionsToLog.includes(action)) { - return; - } - await events.log({ - type: `topic-${action}`, - uid: req.uid, - ip: req.ip, - tid: tid, - title: String(title), - }); +async function logTopicAction(action, request, tid, title) { + // Only log certain actions to system event log + const actionsToLog = ['delete', 'restore', 'purge']; + if (!actionsToLog.includes(action)) { + return; + } + + await events.log({ + type: `topic-${action}`, + uid: request.uid, + ip: request.ip, + tid, + title: String(title), + }); } exports.postCommand = async function (caller, command, eventName, notification, data) { - if (!caller.uid) { - throw new Error('[[error:not-logged-in]]'); - } - - if (!data || !data.pid) { - throw new Error('[[error:invalid-data]]'); - } - - if (!data.room_id) { - throw new Error(`[[error:invalid-room-id, ${data.room_id} ]]`); - } - const [exists, deleted] = await Promise.all([ - posts.exists(data.pid), - posts.getPostField(data.pid, 'deleted'), - ]); - - if (!exists) { - throw new Error('[[error:invalid-pid]]'); - } - - if (deleted) { - throw new Error('[[error:post-deleted]]'); - } - - /* - hooks: + if (!caller.uid) { + throw new Error('[[error:not-logged-in]]'); + } + + if (!data || !data.pid) { + throw new Error('[[error:invalid-data]]'); + } + + if (!data.room_id) { + throw new Error(`[[error:invalid-room-id, ${data.room_id} ]]`); + } + + const [exists, deleted] = await Promise.all([ + posts.exists(data.pid), + posts.getPostField(data.pid, 'deleted'), + ]); + + if (!exists) { + throw new Error('[[error:invalid-pid]]'); + } + + if (deleted) { + throw new Error('[[error:post-deleted]]'); + } + + /* + Hooks: filter:post.upvote filter:post.downvote filter:post.unvote filter:post.bookmark filter:post.unbookmark */ - const filteredData = await plugins.hooks.fire(`filter:post.${command}`, { - data: data, - uid: caller.uid, - }); - return await executeCommand(caller, command, eventName, notification, filteredData.data); + const filteredData = await plugins.hooks.fire(`filter:post.${command}`, { + data, + uid: caller.uid, + }); + return await executeCommand(caller, command, eventName, notification, filteredData.data); }; async function executeCommand(caller, command, eventName, notification, data) { - const result = await posts[command](data.pid, caller.uid); - if (result && eventName) { - websockets.in(`uid_${caller.uid}`).emit(`posts.${command}`, result); - websockets.in(data.room_id).emit(`event:${eventName}`, result); - } - if (result && command === 'upvote') { - socketHelpers.upvote(result, notification); - } else if (result && notification) { - socketHelpers.sendNotificationToPostOwner(data.pid, caller.uid, command, notification); - } else if (result && command === 'unvote') { - socketHelpers.rescindUpvoteNotification(data.pid, caller.uid); - } - return result; + const result = await posts[command](data.pid, caller.uid); + if (result && eventName) { + websockets.in(`uid_${caller.uid}`).emit(`posts.${command}`, result); + websockets.in(data.room_id).emit(`event:${eventName}`, result); + } + + if (result && command === 'upvote') { + socketHelpers.upvote(result, notification); + } else if (result && notification) { + socketHelpers.sendNotificationToPostOwner(data.pid, caller.uid, command, notification); + } else if (result && command === 'unvote') { + socketHelpers.rescindUpvoteNotification(data.pid, caller.uid); + } + + return result; } diff --git a/src/api/index.js b/src/api/index.js index 3c1187a..a4b5f34 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -1,11 +1,11 @@ 'use strict'; module.exports = { - users: require('./users'), - groups: require('./groups'), - topics: require('./topics'), - posts: require('./posts'), - chats: require('./chats'), - categories: require('./categories'), - flags: require('./flags'), + users: require('./users'), + groups: require('./groups'), + topics: require('./topics'), + posts: require('./posts'), + chats: require('./chats'), + categories: require('./categories'), + flags: require('./flags'), }; diff --git a/src/api/posts.js b/src/api/posts.js index 7f56aff..b4eb289 100644 --- a/src/api/posts.js +++ b/src/api/posts.js @@ -2,7 +2,6 @@ const validator = require('validator'); const _ = require('lodash'); - const utils = require('../utils'); const user = require('../user'); const posts = require('../posts'); @@ -11,337 +10,350 @@ const groups = require('../groups'); const meta = require('../meta'); const events = require('../events'); const privileges = require('../privileges'); -const apiHelpers = require('./helpers'); const websockets = require('../socket.io'); const socketHelpers = require('../socket.io/helpers'); +const apiHelpers = require('./helpers'); const postsAPI = module.exports; postsAPI.get = async function (caller, data) { - const [userPrivileges, post, voted] = await Promise.all([ - privileges.posts.get([data.pid], caller.uid), - posts.getPostData(data.pid), - posts.hasVoted(data.pid, caller.uid), - ]); - if (!post) { - return null; - } - Object.assign(post, voted); - - const userPrivilege = userPrivileges[0]; - if (!userPrivilege.read || !userPrivilege['topics:read']) { - return null; - } - - post.ip = userPrivilege.isAdminOrMod ? post.ip : undefined; - const selfPost = caller.uid && caller.uid === parseInt(post.uid, 10); - if (post.deleted && !(userPrivilege.isAdminOrMod || selfPost)) { - post.content = '[[topic:post_is_deleted]]'; - } - - return post; + const [userPrivileges, post, voted] = await Promise.all([ + privileges.posts.get([data.pid], caller.uid), + posts.getPostData(data.pid), + posts.hasVoted(data.pid, caller.uid), + ]); + if (!post) { + return null; + } + + Object.assign(post, voted); + + const userPrivilege = userPrivileges[0]; + if (!userPrivilege.read || !userPrivilege['topics:read']) { + return null; + } + + post.ip = userPrivilege.isAdminOrMod ? post.ip : undefined; + const selfPost = caller.uid && caller.uid === Number.parseInt(post.uid, 10); + if (post.deleted && !(userPrivilege.isAdminOrMod || selfPost)) { + post.content = '[[topic:post_is_deleted]]'; + } + + return post; }; postsAPI.edit = async function (caller, data) { - if (!data || !data.pid || (meta.config.minimumPostLength !== 0 && !data.content)) { - throw new Error('[[error:invalid-data]]'); - } - if (!caller.uid) { - throw new Error('[[error:not-logged-in]]'); - } - // Trim and remove HTML (latter for composers that send in HTML, like redactor) - const contentLen = utils.stripHTMLTags(data.content).trim().length; - - if (data.title && data.title.length < meta.config.minimumTitleLength) { - throw new Error(`[[error:title-too-short, ${meta.config.minimumTitleLength}]]`); - } else if (data.title && data.title.length > meta.config.maximumTitleLength) { - throw new Error(`[[error:title-too-long, ${meta.config.maximumTitleLength}]]`); - } else if (meta.config.minimumPostLength !== 0 && contentLen < meta.config.minimumPostLength) { - throw new Error(`[[error:content-too-short, ${meta.config.minimumPostLength}]]`); - } else if (contentLen > meta.config.maximumPostLength) { - throw new Error(`[[error:content-too-long, ${meta.config.maximumPostLength}]]`); - } - - data.uid = caller.uid; - data.req = apiHelpers.buildReqObject(caller); - data.timestamp = parseInt(data.timestamp, 10) || Date.now(); - - const editResult = await posts.edit(data); - if (editResult.topic.isMainPost) { - await topics.thumbs.migrate(data.uuid, editResult.topic.tid); - } - const selfPost = parseInt(caller.uid, 10) === parseInt(editResult.post.uid, 10); - if (!selfPost && editResult.post.changed) { - await events.log({ - type: `post-edit`, - uid: caller.uid, - ip: caller.ip, - pid: editResult.post.pid, - oldContent: editResult.post.oldContent, - newContent: editResult.post.newContent, - }); - } - - if (editResult.topic.renamed) { - await events.log({ - type: 'topic-rename', - uid: caller.uid, - ip: caller.ip, - tid: editResult.topic.tid, - oldTitle: validator.escape(String(editResult.topic.oldTitle)), - newTitle: validator.escape(String(editResult.topic.title)), - }); - } - const postObj = await posts.getPostSummaryByPids([editResult.post.pid], caller.uid, {}); - const returnData = { ...postObj[0], ...editResult.post }; - returnData.topic = { ...postObj[0].topic, ...editResult.post.topic }; - - if (!editResult.post.deleted) { - websockets.in(`topic_${editResult.topic.tid}`).emit('event:post_edited', editResult); - return returnData; - } - - const memberData = await groups.getMembersOfGroups([ - 'administrators', - 'Global Moderators', - `cid:${editResult.topic.cid}:privileges:moderate`, - `cid:${editResult.topic.cid}:privileges:groups:moderate`, - ]); - - const uids = _.uniq(_.flatten(memberData).concat(String(caller.uid))); - uids.forEach(uid => websockets.in(`uid_${uid}`).emit('event:post_edited', editResult)); - return returnData; + if (!data || !data.pid || (meta.config.minimumPostLength !== 0 && !data.content)) { + throw new Error('[[error:invalid-data]]'); + } + + if (!caller.uid) { + throw new Error('[[error:not-logged-in]]'); + } + + // Trim and remove HTML (latter for composers that send in HTML, like redactor) + const contentLength = utils.stripHTMLTags(data.content).trim().length; + + if (data.title && data.title.length < meta.config.minimumTitleLength) { + throw new Error(`[[error:title-too-short, ${meta.config.minimumTitleLength}]]`); + } else if (data.title && data.title.length > meta.config.maximumTitleLength) { + throw new Error(`[[error:title-too-long, ${meta.config.maximumTitleLength}]]`); + } else if (meta.config.minimumPostLength !== 0 && contentLength < meta.config.minimumPostLength) { + throw new Error(`[[error:content-too-short, ${meta.config.minimumPostLength}]]`); + } else if (contentLength > meta.config.maximumPostLength) { + throw new Error(`[[error:content-too-long, ${meta.config.maximumPostLength}]]`); + } + + data.uid = caller.uid; + data.req = apiHelpers.buildReqObject(caller); + data.timestamp = Number.parseInt(data.timestamp, 10) || Date.now(); + + const editResult = await posts.edit(data); + if (editResult.topic.isMainPost) { + await topics.thumbs.migrate(data.uuid, editResult.topic.tid); + } + + const selfPost = Number.parseInt(caller.uid, 10) === Number.parseInt(editResult.post.uid, 10); + if (!selfPost && editResult.post.changed) { + await events.log({ + type: 'post-edit', + uid: caller.uid, + ip: caller.ip, + pid: editResult.post.pid, + oldContent: editResult.post.oldContent, + newContent: editResult.post.newContent, + }); + } + + if (editResult.topic.renamed) { + await events.log({ + type: 'topic-rename', + uid: caller.uid, + ip: caller.ip, + tid: editResult.topic.tid, + oldTitle: validator.escape(String(editResult.topic.oldTitle)), + newTitle: validator.escape(String(editResult.topic.title)), + }); + } + + const postObject = await posts.getPostSummaryByPids([editResult.post.pid], caller.uid, {}); + const returnData = {...postObject[0], ...editResult.post}; + returnData.topic = {...postObject[0].topic, ...editResult.post.topic}; + + if (!editResult.post.deleted) { + websockets.in(`topic_${editResult.topic.tid}`).emit('event:post_edited', editResult); + return returnData; + } + + const memberData = await groups.getMembersOfGroups([ + 'administrators', + 'Global Moderators', + `cid:${editResult.topic.cid}:privileges:moderate`, + `cid:${editResult.topic.cid}:privileges:groups:moderate`, + ]); + + const uids = _.uniq(memberData.flat().concat(String(caller.uid))); + for (const uid of uids) { + websockets.in(`uid_${uid}`).emit('event:post_edited', editResult); + } + + return returnData; }; postsAPI.delete = async function (caller, data) { - await deleteOrRestore(caller, data, { - command: 'delete', - event: 'event:post_deleted', - type: 'post-delete', - }); + await deleteOrRestore(caller, data, { + command: 'delete', + event: 'event:post_deleted', + type: 'post-delete', + }); }; postsAPI.restore = async function (caller, data) { - await deleteOrRestore(caller, data, { - command: 'restore', - event: 'event:post_restored', - type: 'post-restore', - }); + await deleteOrRestore(caller, data, { + command: 'restore', + event: 'event:post_restored', + type: 'post-restore', + }); }; -async function deleteOrRestore(caller, data, params) { - if (!data || !data.pid) { - throw new Error('[[error:invalid-data]]'); - } - const postData = await posts.tools[params.command](caller.uid, data.pid); - const results = await isMainAndLastPost(data.pid); - if (results.isMain && results.isLast) { - await deleteOrRestoreTopicOf(params.command, data.pid, caller); - } - - websockets.in(`topic_${postData.tid}`).emit(params.event, postData); - - await events.log({ - type: params.type, - uid: caller.uid, - pid: data.pid, - tid: postData.tid, - ip: caller.ip, - }); +async function deleteOrRestore(caller, data, parameters) { + if (!data || !data.pid) { + throw new Error('[[error:invalid-data]]'); + } + + const postData = await posts.tools[parameters.command](caller.uid, data.pid); + const results = await isMainAndLastPost(data.pid); + if (results.isMain && results.isLast) { + await deleteOrRestoreTopicOf(parameters.command, data.pid, caller); + } + + websockets.in(`topic_${postData.tid}`).emit(parameters.event, postData); + + await events.log({ + type: parameters.type, + uid: caller.uid, + pid: data.pid, + tid: postData.tid, + ip: caller.ip, + }); } async function deleteOrRestoreTopicOf(command, pid, caller) { - const topic = await posts.getTopicFields(pid, ['tid', 'cid', 'deleted', 'scheduled']); - // exempt scheduled topics from being deleted/restored - if (topic.scheduled) { - return; - } - // command: delete/restore - await apiHelpers.doTopicAction( - command, - topic.deleted ? 'event:topic_restored' : 'event:topic_deleted', - caller, - { tids: [topic.tid], cid: topic.cid } - ); + const topic = await posts.getTopicFields(pid, ['tid', 'cid', 'deleted', 'scheduled']); + // Exempt scheduled topics from being deleted/restored + if (topic.scheduled) { + return; + } + + // Command: delete/restore + await apiHelpers.doTopicAction( + command, + topic.deleted ? 'event:topic_restored' : 'event:topic_deleted', + caller, + {tids: [topic.tid], cid: topic.cid}, + ); } postsAPI.purge = async function (caller, data) { - if (!data || !parseInt(data.pid, 10)) { - throw new Error('[[error:invalid-data]]'); - } - - const results = await isMainAndLastPost(data.pid); - if (results.isMain && !results.isLast) { - throw new Error('[[error:cant-purge-main-post]]'); - } - - const isMainAndLast = results.isMain && results.isLast; - const postData = await posts.getPostFields(data.pid, ['toPid', 'tid']); - postData.pid = data.pid; - - const canPurge = await privileges.posts.canPurge(data.pid, caller.uid); - if (!canPurge) { - throw new Error('[[error:no-privileges]]'); - } - require('../posts/cache').del(data.pid); - await posts.purge(data.pid, caller.uid); - - websockets.in(`topic_${postData.tid}`).emit('event:post_purged', postData); - const topicData = await topics.getTopicFields(postData.tid, ['title', 'cid']); - - await events.log({ - type: 'post-purge', - pid: data.pid, - uid: caller.uid, - ip: caller.ip, - tid: postData.tid, - title: String(topicData.title), - }); - - if (isMainAndLast) { - await apiHelpers.doTopicAction( - 'purge', - 'event:topic_purged', - caller, - { tids: [postData.tid], cid: topicData.cid } - ); - } + if (!data || !Number.parseInt(data.pid, 10)) { + throw new Error('[[error:invalid-data]]'); + } + + const results = await isMainAndLastPost(data.pid); + if (results.isMain && !results.isLast) { + throw new Error('[[error:cant-purge-main-post]]'); + } + + const isMainAndLast = results.isMain && results.isLast; + const postData = await posts.getPostFields(data.pid, ['toPid', 'tid']); + postData.pid = data.pid; + + const canPurge = await privileges.posts.canPurge(data.pid, caller.uid); + if (!canPurge) { + throw new Error('[[error:no-privileges]]'); + } + + require('../posts/cache').del(data.pid); + await posts.purge(data.pid, caller.uid); + + websockets.in(`topic_${postData.tid}`).emit('event:post_purged', postData); + const topicData = await topics.getTopicFields(postData.tid, ['title', 'cid']); + + await events.log({ + type: 'post-purge', + pid: data.pid, + uid: caller.uid, + ip: caller.ip, + tid: postData.tid, + title: String(topicData.title), + }); + + if (isMainAndLast) { + await apiHelpers.doTopicAction( + 'purge', + 'event:topic_purged', + caller, + {tids: [postData.tid], cid: topicData.cid}, + ); + } }; async function isMainAndLastPost(pid) { - const [isMain, topicData] = await Promise.all([ - posts.isMain(pid), - posts.getTopicFields(pid, ['postcount']), - ]); - return { - isMain: isMain, - isLast: topicData && topicData.postcount === 1, - }; + const [isMain, topicData] = await Promise.all([ + posts.isMain(pid), + posts.getTopicFields(pid, ['postcount']), + ]); + return { + isMain, + isLast: topicData && topicData.postcount === 1, + }; } postsAPI.move = async function (caller, data) { - if (!caller.uid) { - throw new Error('[[error:not-logged-in]]'); - } - if (!data || !data.pid || !data.tid) { - throw new Error('[[error:invalid-data]]'); - } - const canMove = await Promise.all([ - privileges.topics.isAdminOrMod(data.tid, caller.uid), - privileges.posts.canMove(data.pid, caller.uid), - ]); - if (!canMove.every(Boolean)) { - throw new Error('[[error:no-privileges]]'); - } - - await topics.movePostToTopic(caller.uid, data.pid, data.tid); - - const [postDeleted, topicDeleted] = await Promise.all([ - posts.getPostField(data.pid, 'deleted'), - topics.getTopicField(data.tid, 'deleted'), - await events.log({ - type: `post-move`, - uid: caller.uid, - ip: caller.ip, - pid: data.pid, - toTid: data.tid, - }), - ]); - - if (!postDeleted && !topicDeleted) { - socketHelpers.sendNotificationToPostOwner(data.pid, caller.uid, 'move', 'notifications:moved_your_post'); - } + if (!caller.uid) { + throw new Error('[[error:not-logged-in]]'); + } + + if (!data || !data.pid || !data.tid) { + throw new Error('[[error:invalid-data]]'); + } + + const canMove = await Promise.all([ + privileges.topics.isAdminOrMod(data.tid, caller.uid), + privileges.posts.canMove(data.pid, caller.uid), + ]); + if (!canMove.every(Boolean)) { + throw new Error('[[error:no-privileges]]'); + } + + await topics.movePostToTopic(caller.uid, data.pid, data.tid); + + const [postDeleted, topicDeleted] = await Promise.all([ + posts.getPostField(data.pid, 'deleted'), + topics.getTopicField(data.tid, 'deleted'), + await events.log({ + type: 'post-move', + uid: caller.uid, + ip: caller.ip, + pid: data.pid, + toTid: data.tid, + }), + ]); + + if (!postDeleted && !topicDeleted) { + socketHelpers.sendNotificationToPostOwner(data.pid, caller.uid, 'move', 'notifications:moved_your_post'); + } }; postsAPI.upvote = async function (caller, data) { - return await apiHelpers.postCommand(caller, 'upvote', 'voted', 'notifications:upvoted_your_post_in', data); + return await apiHelpers.postCommand(caller, 'upvote', 'voted', 'notifications:upvoted_your_post_in', data); }; postsAPI.downvote = async function (caller, data) { - return await apiHelpers.postCommand(caller, 'downvote', 'voted', '', data); + return await apiHelpers.postCommand(caller, 'downvote', 'voted', '', data); }; postsAPI.unvote = async function (caller, data) { - return await apiHelpers.postCommand(caller, 'unvote', 'voted', '', data); + return await apiHelpers.postCommand(caller, 'unvote', 'voted', '', data); }; postsAPI.bookmark = async function (caller, data) { - return await apiHelpers.postCommand(caller, 'bookmark', 'bookmarked', '', data); + return await apiHelpers.postCommand(caller, 'bookmark', 'bookmarked', '', data); }; postsAPI.unbookmark = async function (caller, data) { - return await apiHelpers.postCommand(caller, 'unbookmark', 'bookmarked', '', data); + return await apiHelpers.postCommand(caller, 'unbookmark', 'bookmarked', '', data); }; postsAPI.pin = async function (caller, data) { - return await apiHelpers.postCommand(caller, 'pin', 'pinned', '', data); + return await apiHelpers.postCommand(caller, 'pin', 'pinned', '', data); }; postsAPI.unpin = async function (caller, data) { - return await apiHelpers.postCommand(caller, 'unpin', 'unpinned', '', data); + return await apiHelpers.postCommand(caller, 'unpin', 'unpinned', '', data); }; postsAPI.resolve = async function (caller, data) { - return await apiHelpers.postCommand(caller, 'resolve', 'unresolve', '', data); + return await apiHelpers.postCommand(caller, 'resolve', 'unresolve', '', data); }; async function diffsPrivilegeCheck(pid, uid) { - const [deleted, privilegesData] = await Promise.all([ - posts.getPostField(pid, 'deleted'), - privileges.posts.get([pid], uid), - ]); - - const allowed = privilegesData[0]['posts:history'] && (deleted ? privilegesData[0]['posts:view_deleted'] : true); - if (!allowed) { - throw new Error('[[error:no-privileges]]'); - } + const [deleted, privilegesData] = await Promise.all([ + posts.getPostField(pid, 'deleted'), + privileges.posts.get([pid], uid), + ]); + + const allowed = privilegesData[0]['posts:history'] && (deleted ? privilegesData[0]['posts:view_deleted'] : true); + if (!allowed) { + throw new Error('[[error:no-privileges]]'); + } } postsAPI.getDiffs = async (caller, data) => { - await diffsPrivilegeCheck(data.pid, caller.uid); - const timestamps = await posts.diffs.list(data.pid); - const post = await posts.getPostFields(data.pid, ['timestamp', 'uid']); - - const diffs = await posts.diffs.get(data.pid); - const uids = diffs.map(diff => diff.uid || null); - uids.push(post.uid); - let usernames = await user.getUsersFields(uids, ['username']); - usernames = usernames.map(userObj => (userObj.uid ? userObj.username : null)); - - const cid = await posts.getCidByPid(data.pid); - const [isAdmin, isModerator] = await Promise.all([ - user.isAdministrator(caller.uid), - privileges.users.isModerator(caller.uid, cid), - ]); - - // timestamps returned by posts.diffs.list are strings - timestamps.push(String(post.timestamp)); - - return { - timestamps: timestamps, - revisions: timestamps.map((timestamp, idx) => ({ - timestamp: timestamp, - username: usernames[idx], - })), - // Only admins, global mods and moderator of that cid can delete a diff - deletable: isAdmin || isModerator, - // These and post owners can restore to a different post version - editable: isAdmin || isModerator || parseInt(caller.uid, 10) === parseInt(post.uid, 10), - }; + await diffsPrivilegeCheck(data.pid, caller.uid); + const timestamps = await posts.diffs.list(data.pid); + const post = await posts.getPostFields(data.pid, ['timestamp', 'uid']); + + const diffs = await posts.diffs.get(data.pid); + const uids = diffs.map(diff => diff.uid || null); + uids.push(post.uid); + let usernames = await user.getUsersFields(uids, ['username']); + usernames = usernames.map(userObject => (userObject.uid ? userObject.username : null)); + + const cid = await posts.getCidByPid(data.pid); + const [isAdmin, isModerator] = await Promise.all([ + user.isAdministrator(caller.uid), + privileges.users.isModerator(caller.uid, cid), + ]); + + // Timestamps returned by posts.diffs.list are strings + timestamps.push(String(post.timestamp)); + + return { + timestamps, + revisions: timestamps.map((timestamp, index) => ({ + timestamp, + username: usernames[index], + })), + // Only admins, global mods and moderator of that cid can delete a diff + deletable: isAdmin || isModerator, + // These and post owners can restore to a different post version + editable: isAdmin || isModerator || Number.parseInt(caller.uid, 10) === Number.parseInt(post.uid, 10), + }; }; postsAPI.loadDiff = async (caller, data) => { - await diffsPrivilegeCheck(data.pid, caller.uid); - return await posts.diffs.load(data.pid, data.since, caller.uid); + await diffsPrivilegeCheck(data.pid, caller.uid); + return await posts.diffs.load(data.pid, data.since, caller.uid); }; postsAPI.restoreDiff = async (caller, data) => { - const cid = await posts.getCidByPid(data.pid); - const canEdit = await privileges.categories.can('posts:edit', cid, caller.uid); - if (!canEdit) { - throw new Error('[[error:no-privileges]]'); - } - - const edit = await posts.diffs.restore(data.pid, data.since, caller.uid, apiHelpers.buildReqObject(caller)); - websockets.in(`topic_${edit.topic.tid}`).emit('event:post_edited', edit); + const cid = await posts.getCidByPid(data.pid); + const canEdit = await privileges.categories.can('posts:edit', cid, caller.uid); + if (!canEdit) { + throw new Error('[[error:no-privileges]]'); + } + + const edit = await posts.diffs.restore(data.pid, data.since, caller.uid, apiHelpers.buildReqObject(caller)); + websockets.in(`topic_${edit.topic.tid}`).emit('event:post_edited', edit); }; diff --git a/src/api/topics.js b/src/api/topics.js index 989f1c2..d6bb625 100644 --- a/src/api/topics.js +++ b/src/api/topics.js @@ -5,10 +5,9 @@ const topics = require('../topics'); const posts = require('../posts'); const meta = require('../meta'); const privileges = require('../privileges'); - const apiHelpers = require('./helpers'); -const { doTopicAction } = apiHelpers; +const {doTopicAction} = apiHelpers; const websockets = require('../socket.io'); const socketHelpers = require('../socket.io/helpers'); @@ -16,151 +15,152 @@ const socketHelpers = require('../socket.io/helpers'); const topicsAPI = module.exports; topicsAPI.get = async function (caller, data) { - const [userPrivileges, topic] = await Promise.all([ - privileges.topics.get(data.tid, caller.uid), - topics.getTopicData(data.tid), - ]); - if ( - !topic || - !userPrivileges.read || - !userPrivileges['topics:read'] || - !privileges.topics.canViewDeletedScheduled(topic, userPrivileges) - ) { - return null; - } - - return topic; + const [userPrivileges, topic] = await Promise.all([ + privileges.topics.get(data.tid, caller.uid), + topics.getTopicData(data.tid), + ]); + if ( + !topic + || !userPrivileges.read + || !userPrivileges['topics:read'] + || !privileges.topics.canViewDeletedScheduled(topic, userPrivileges) + ) { + return null; + } + + return topic; }; topicsAPI.create = async function (caller, data) { - if (!data) { - throw new Error('[[error:invalid-data]]'); - } - - const payload = { ...data }; - payload.tags = payload.tags || []; - apiHelpers.setDefaultPostData(caller, payload); - const isScheduling = parseInt(data.timestamp, 10) > payload.timestamp; - if (isScheduling) { - if (await privileges.categories.can('topics:schedule', data.cid, caller.uid)) { - payload.timestamp = parseInt(data.timestamp, 10); - } else { - throw new Error('[[error:no-privileges]]'); - } - } - - await meta.blacklist.test(caller.ip); - const shouldQueue = await posts.shouldQueue(caller.uid, payload); - if (shouldQueue) { - return await posts.addToQueue(payload); - } - - const result = await topics.post(payload); - await topics.thumbs.migrate(data.uuid, result.topicData.tid); - - socketHelpers.emitToUids('event:new_post', { posts: [result.postData] }, [caller.uid]); - socketHelpers.emitToUids('event:new_topic', result.topicData, [caller.uid]); - socketHelpers.notifyNew(caller.uid, 'newTopic', { posts: [result.postData], topic: result.topicData }); - - return result.topicData; + if (!data) { + throw new Error('[[error:invalid-data]]'); + } + + const payload = {...data}; + payload.tags = payload.tags || []; + apiHelpers.setDefaultPostData(caller, payload); + const isScheduling = Number.parseInt(data.timestamp, 10) > payload.timestamp; + if (isScheduling) { + if (await privileges.categories.can('topics:schedule', data.cid, caller.uid)) { + payload.timestamp = Number.parseInt(data.timestamp, 10); + } else { + throw new Error('[[error:no-privileges]]'); + } + } + + await meta.blacklist.test(caller.ip); + const shouldQueue = await posts.shouldQueue(caller.uid, payload); + if (shouldQueue) { + return await posts.addToQueue(payload); + } + + const result = await topics.post(payload); + await topics.thumbs.migrate(data.uuid, result.topicData.tid); + + socketHelpers.emitToUids('event:new_post', {posts: [result.postData]}, [caller.uid]); + socketHelpers.emitToUids('event:new_topic', result.topicData, [caller.uid]); + socketHelpers.notifyNew(caller.uid, 'newTopic', {posts: [result.postData], topic: result.topicData}); + + return result.topicData; }; topicsAPI.reply = async function (caller, data) { - if (!data || !data.tid || (meta.config.minimumPostLength !== 0 && !data.content)) { - throw new Error('[[error:invalid-data]]'); - } - const payload = { ...data }; - apiHelpers.setDefaultPostData(caller, payload); - - await meta.blacklist.test(caller.ip); - const shouldQueue = await posts.shouldQueue(caller.uid, payload); - if (shouldQueue) { - return await posts.addToQueue(payload); - } - - const postData = await topics.reply(payload); // postData seems to be a subset of postObj, refactor? - const postObj = await posts.getPostSummaryByPids([postData.pid], caller.uid, {}); - - const result = { - posts: [postData], - 'reputation:disabled': meta.config['reputation:disabled'] === 1, - 'downvote:disabled': meta.config['downvote:disabled'] === 1, - }; - - user.updateOnlineUsers(caller.uid); - if (caller.uid) { - socketHelpers.emitToUids('event:new_post', result, [caller.uid]); - } else if (caller.uid === 0) { - websockets.in('online_guests').emit('event:new_post', result); - } - - socketHelpers.notifyNew(caller.uid, 'newPost', result); - - return postObj[0]; + if (!data || !data.tid || (meta.config.minimumPostLength !== 0 && !data.content)) { + throw new Error('[[error:invalid-data]]'); + } + + const payload = {...data}; + apiHelpers.setDefaultPostData(caller, payload); + + await meta.blacklist.test(caller.ip); + const shouldQueue = await posts.shouldQueue(caller.uid, payload); + if (shouldQueue) { + return await posts.addToQueue(payload); + } + + const postData = await topics.reply(payload); // PostData seems to be a subset of postObj, refactor? + const postObject = await posts.getPostSummaryByPids([postData.pid], caller.uid, {}); + + const result = { + posts: [postData], + 'reputation:disabled': meta.config['reputation:disabled'] === 1, + 'downvote:disabled': meta.config['downvote:disabled'] === 1, + }; + + user.updateOnlineUsers(caller.uid); + if (caller.uid) { + socketHelpers.emitToUids('event:new_post', result, [caller.uid]); + } else if (caller.uid === 0) { + websockets.in('online_guests').emit('event:new_post', result); + } + + socketHelpers.notifyNew(caller.uid, 'newPost', result); + + return postObject[0]; }; topicsAPI.delete = async function (caller, data) { - await doTopicAction('delete', 'event:topic_deleted', caller, { - tids: data.tids, - }); + await doTopicAction('delete', 'event:topic_deleted', caller, { + tids: data.tids, + }); }; topicsAPI.restore = async function (caller, data) { - await doTopicAction('restore', 'event:topic_restored', caller, { - tids: data.tids, - }); + await doTopicAction('restore', 'event:topic_restored', caller, { + tids: data.tids, + }); }; topicsAPI.purge = async function (caller, data) { - await doTopicAction('purge', 'event:topic_purged', caller, { - tids: data.tids, - }); + await doTopicAction('purge', 'event:topic_purged', caller, { + tids: data.tids, + }); }; topicsAPI.pin = async function (caller, data) { - await doTopicAction('pin', 'event:topic_pinned', caller, { - tids: data.tids, - }); + await doTopicAction('pin', 'event:topic_pinned', caller, { + tids: data.tids, + }); }; topicsAPI.unpin = async function (caller, data) { - await doTopicAction('unpin', 'event:topic_unpinned', caller, { - tids: data.tids, - }); + await doTopicAction('unpin', 'event:topic_unpinned', caller, { + tids: data.tids, + }); }; topicsAPI.private = async function (caller, data) { - await doTopicAction('private', 'event:topic_private', caller, { - tids: data.tids, - }); + await doTopicAction('private', 'event:topic_private', caller, { + tids: data.tids, + }); }; topicsAPI.public = async function (caller, data) { - await doTopicAction('public', 'event:topic_public', caller, { - tids: data.tids, - }); + await doTopicAction('public', 'event:topic_public', caller, { + tids: data.tids, + }); }; topicsAPI.lock = async function (caller, data) { - await doTopicAction('lock', 'event:topic_locked', caller, { - tids: data.tids, - }); + await doTopicAction('lock', 'event:topic_locked', caller, { + tids: data.tids, + }); }; topicsAPI.unlock = async function (caller, data) { - await doTopicAction('unlock', 'event:topic_unlocked', caller, { - tids: data.tids, - }); + await doTopicAction('unlock', 'event:topic_unlocked', caller, { + tids: data.tids, + }); }; topicsAPI.follow = async function (caller, data) { - await topics.follow(data.tid, caller.uid); + await topics.follow(data.tid, caller.uid); }; topicsAPI.ignore = async function (caller, data) { - await topics.ignore(data.tid, caller.uid); + await topics.ignore(data.tid, caller.uid); }; topicsAPI.unfollow = async function (caller, data) { - await topics.unfollow(data.tid, caller.uid); + await topics.unfollow(data.tid, caller.uid); }; diff --git a/src/api/users.js b/src/api/users.js index 7e8546b..72455d9 100644 --- a/src/api/users.js +++ b/src/api/users.js @@ -2,7 +2,6 @@ const validator = require('validator'); const winston = require('winston'); - const db = require('../database'); const user = require('../user'); const groups = require('../groups'); @@ -18,461 +17,471 @@ const sockets = require('../socket.io'); const usersAPI = module.exports; usersAPI.create = async function (caller, data) { - if (!data) { - throw new Error('[[error:invalid-data]]'); - } - const uid = await user.create(data); - return await user.getUserData(uid); + if (!data) { + throw new Error('[[error:invalid-data]]'); + } + + const uid = await user.create(data); + return await user.getUserData(uid); }; usersAPI.update = async function (caller, data) { - if (!caller.uid) { - throw new Error('[[error:invalid-uid]]'); - } - - if (!data || !data.uid) { - throw new Error('[[error:invalid-data]]'); - } - - const oldUserData = await user.getUserFields(data.uid, ['email', 'username']); - if (!oldUserData || !oldUserData.username) { - throw new Error('[[error:invalid-data]]'); - } - - const [isAdminOrGlobalMod, canEdit] = await Promise.all([ - user.isAdminOrGlobalMod(caller.uid), - privileges.users.canEdit(caller.uid, data.uid), - ]); - - // Changing own email/username requires password confirmation - if (data.hasOwnProperty('email') || data.hasOwnProperty('username')) { - await isPrivilegedOrSelfAndPasswordMatch(caller, data); - } - - if (!canEdit) { - throw new Error('[[error:no-privileges]]'); - } - - if (!isAdminOrGlobalMod && meta.config['username:disableEdit']) { - data.username = oldUserData.username; - } - - if (!isAdminOrGlobalMod && meta.config['email:disableEdit']) { - data.email = oldUserData.email; - } - - await user.updateProfile(caller.uid, data); - const userData = await user.getUserData(data.uid); - - if (userData.username !== oldUserData.username) { - await events.log({ - type: 'username-change', - uid: caller.uid, - targetUid: data.uid, - ip: caller.ip, - oldUsername: oldUserData.username, - newUsername: userData.username, - }); - } - return userData; + if (!caller.uid) { + throw new Error('[[error:invalid-uid]]'); + } + + if (!data || !data.uid) { + throw new Error('[[error:invalid-data]]'); + } + + const oldUserData = await user.getUserFields(data.uid, ['email', 'username']); + if (!oldUserData || !oldUserData.username) { + throw new Error('[[error:invalid-data]]'); + } + + const [isAdminOrGlobalModule, canEdit] = await Promise.all([ + user.isAdminOrGlobalMod(caller.uid), + privileges.users.canEdit(caller.uid, data.uid), + ]); + + // Changing own email/username requires password confirmation + if (data.hasOwnProperty('email') || data.hasOwnProperty('username')) { + await isPrivilegedOrSelfAndPasswordMatch(caller, data); + } + + if (!canEdit) { + throw new Error('[[error:no-privileges]]'); + } + + if (!isAdminOrGlobalModule && meta.config['username:disableEdit']) { + data.username = oldUserData.username; + } + + if (!isAdminOrGlobalModule && meta.config['email:disableEdit']) { + data.email = oldUserData.email; + } + + await user.updateProfile(caller.uid, data); + const userData = await user.getUserData(data.uid); + + if (userData.username !== oldUserData.username) { + await events.log({ + type: 'username-change', + uid: caller.uid, + targetUid: data.uid, + ip: caller.ip, + oldUsername: oldUserData.username, + newUsername: userData.username, + }); + } + + return userData; }; -usersAPI.delete = async function (caller, { uid, password }) { - await processDeletion({ uid: uid, method: 'delete', password, caller }); +usersAPI.delete = async function (caller, {uid, password}) { + await processDeletion({ + uid, method: 'delete', password, caller, + }); }; -usersAPI.deleteContent = async function (caller, { uid, password }) { - await processDeletion({ uid, method: 'deleteContent', password, caller }); +usersAPI.deleteContent = async function (caller, {uid, password}) { + await processDeletion({ + uid, method: 'deleteContent', password, caller, + }); }; -usersAPI.deleteAccount = async function (caller, { uid, password }) { - await processDeletion({ uid, method: 'deleteAccount', password, caller }); +usersAPI.deleteAccount = async function (caller, {uid, password}) { + await processDeletion({ + uid, method: 'deleteAccount', password, caller, + }); }; usersAPI.deleteMany = async function (caller, data) { - if (await canDeleteUids(data.uids)) { - await Promise.all(data.uids.map(uid => processDeletion({ uid, method: 'delete', caller }))); - } + if (await canDeleteUids(data.uids)) { + await Promise.all(data.uids.map(uid => processDeletion({uid, method: 'delete', caller}))); + } }; usersAPI.updateSettings = async function (caller, data) { - if (!caller.uid || !data || !data.settings) { - throw new Error('[[error:invalid-data]]'); - } - - const canEdit = await privileges.users.canEdit(caller.uid, data.uid); - if (!canEdit) { - throw new Error('[[error:no-privileges]]'); - } - - let defaults = await user.getSettings(0); - defaults = { - postsPerPage: defaults.postsPerPage, - topicsPerPage: defaults.topicsPerPage, - userLang: defaults.userLang, - acpLang: defaults.acpLang, - }; - // load raw settings without parsing values to booleans - const current = await db.getObject(`user:${data.uid}:settings`); - const payload = { ...defaults, ...current, ...data.settings }; - delete payload.uid; - - return await user.saveSettings(data.uid, payload); + if (!caller.uid || !data || !data.settings) { + throw new Error('[[error:invalid-data]]'); + } + + const canEdit = await privileges.users.canEdit(caller.uid, data.uid); + if (!canEdit) { + throw new Error('[[error:no-privileges]]'); + } + + let defaults = await user.getSettings(0); + defaults = { + postsPerPage: defaults.postsPerPage, + topicsPerPage: defaults.topicsPerPage, + userLang: defaults.userLang, + acpLang: defaults.acpLang, + }; + // Load raw settings without parsing values to booleans + const current = await db.getObject(`user:${data.uid}:settings`); + const payload = {...defaults, ...current, ...data.settings}; + delete payload.uid; + + return await user.saveSettings(data.uid, payload); }; usersAPI.changePassword = async function (caller, data) { - await user.changePassword(caller.uid, Object.assign(data, { ip: caller.ip })); - await events.log({ - type: 'password-change', - uid: caller.uid, - targetUid: data.uid, - ip: caller.ip, - }); + await user.changePassword(caller.uid, Object.assign(data, {ip: caller.ip})); + await events.log({ + type: 'password-change', + uid: caller.uid, + targetUid: data.uid, + ip: caller.ip, + }); }; usersAPI.follow = async function (caller, data) { - await user.follow(caller.uid, data.uid); - plugins.hooks.fire('action:user.follow', { - fromUid: caller.uid, - toUid: data.uid, - }); - - const userData = await user.getUserFields(caller.uid, ['username', 'userslug']); - const { displayname } = userData; - - const notifObj = await notifications.create({ - type: 'follow', - bodyShort: `[[notifications:user_started_following_you, ${displayname}]]`, - nid: `follow:${data.uid}:uid:${caller.uid}`, - from: caller.uid, - path: `/uid/${data.uid}/followers`, - mergeId: 'notifications:user_started_following_you', - }); - if (!notifObj) { - return; - } - notifObj.user = userData; - await notifications.push(notifObj, [data.uid]); + await user.follow(caller.uid, data.uid); + plugins.hooks.fire('action:user.follow', { + fromUid: caller.uid, + toUid: data.uid, + }); + + const userData = await user.getUserFields(caller.uid, ['username', 'userslug']); + const {displayname} = userData; + + const notificationObject = await notifications.create({ + type: 'follow', + bodyShort: `[[notifications:user_started_following_you, ${displayname}]]`, + nid: `follow:${data.uid}:uid:${caller.uid}`, + from: caller.uid, + path: `/uid/${data.uid}/followers`, + mergeId: 'notifications:user_started_following_you', + }); + if (!notificationObject) { + return; + } + + notificationObject.user = userData; + await notifications.push(notificationObject, [data.uid]); }; usersAPI.unfollow = async function (caller, data) { - await user.unfollow(caller.uid, data.uid); - plugins.hooks.fire('action:user.unfollow', { - fromUid: caller.uid, - toUid: data.uid, - }); + await user.unfollow(caller.uid, data.uid); + plugins.hooks.fire('action:user.unfollow', { + fromUid: caller.uid, + toUid: data.uid, + }); }; usersAPI.ban = async function (caller, data) { - if (!await privileges.users.hasBanPrivilege(caller.uid)) { - throw new Error('[[error:no-privileges]]'); - } else if (await user.isAdministrator(data.uid)) { - throw new Error('[[error:cant-ban-other-admins]]'); - } - - const banData = await user.bans.ban(data.uid, data.until, data.reason); - await db.setObjectField(`uid:${data.uid}:ban:${banData.timestamp}`, 'fromUid', caller.uid); - - if (!data.reason) { - data.reason = await translator.translate('[[user:info.banned-no-reason]]'); - } - - sockets.in(`uid_${data.uid}`).emit('event:banned', { - until: data.until, - reason: validator.escape(String(data.reason || '')), - }); - - await flags.resolveFlag('user', data.uid, caller.uid); - await flags.resolveUserPostFlags(data.uid, caller.uid); - await events.log({ - type: 'user-ban', - uid: caller.uid, - targetUid: data.uid, - ip: caller.ip, - reason: data.reason || undefined, - }); - plugins.hooks.fire('action:user.banned', { - callerUid: caller.uid, - ip: caller.ip, - uid: data.uid, - until: data.until > 0 ? data.until : undefined, - reason: data.reason || undefined, - }); - const canLoginIfBanned = await user.bans.canLoginIfBanned(data.uid); - if (!canLoginIfBanned) { - await user.auth.revokeAllSessions(data.uid); - } + if (!await privileges.users.hasBanPrivilege(caller.uid)) { + throw new Error('[[error:no-privileges]]'); + } else if (await user.isAdministrator(data.uid)) { + throw new Error('[[error:cant-ban-other-admins]]'); + } + + const banData = await user.bans.ban(data.uid, data.until, data.reason); + await db.setObjectField(`uid:${data.uid}:ban:${banData.timestamp}`, 'fromUid', caller.uid); + + data.reason ||= await translator.translate('[[user:info.banned-no-reason]]'); + + sockets.in(`uid_${data.uid}`).emit('event:banned', { + until: data.until, + reason: validator.escape(String(data.reason || '')), + }); + + await flags.resolveFlag('user', data.uid, caller.uid); + await flags.resolveUserPostFlags(data.uid, caller.uid); + await events.log({ + type: 'user-ban', + uid: caller.uid, + targetUid: data.uid, + ip: caller.ip, + reason: data.reason || undefined, + }); + plugins.hooks.fire('action:user.banned', { + callerUid: caller.uid, + ip: caller.ip, + uid: data.uid, + until: data.until > 0 ? data.until : undefined, + reason: data.reason || undefined, + }); + const canLoginIfBanned = await user.bans.canLoginIfBanned(data.uid); + if (!canLoginIfBanned) { + await user.auth.revokeAllSessions(data.uid); + } }; usersAPI.unban = async function (caller, data) { - if (!await privileges.users.hasBanPrivilege(caller.uid)) { - throw new Error('[[error:no-privileges]]'); - } - - await user.bans.unban(data.uid); - - sockets.in(`uid_${data.uid}`).emit('event:unbanned'); - - await events.log({ - type: 'user-unban', - uid: caller.uid, - targetUid: data.uid, - ip: caller.ip, - }); - plugins.hooks.fire('action:user.unbanned', { - callerUid: caller.uid, - ip: caller.ip, - uid: data.uid, - }); + if (!await privileges.users.hasBanPrivilege(caller.uid)) { + throw new Error('[[error:no-privileges]]'); + } + + await user.bans.unban(data.uid); + + sockets.in(`uid_${data.uid}`).emit('event:unbanned'); + + await events.log({ + type: 'user-unban', + uid: caller.uid, + targetUid: data.uid, + ip: caller.ip, + }); + plugins.hooks.fire('action:user.unbanned', { + callerUid: caller.uid, + ip: caller.ip, + uid: data.uid, + }); }; usersAPI.mute = async function (caller, data) { - if (!await privileges.users.hasMutePrivilege(caller.uid)) { - throw new Error('[[error:no-privileges]]'); - } else if (await user.isAdministrator(data.uid)) { - throw new Error('[[error:cant-mute-other-admins]]'); - } - const reason = data.reason || '[[user:info.muted-no-reason]]'; - await db.setObject(`user:${data.uid}`, { - mutedUntil: data.until, - mutedReason: reason, - }); - const now = Date.now(); - const muteKey = `uid:${data.uid}:mute:${now}`; - const muteData = { - fromUid: caller.uid, - uid: data.uid, - timestamp: now, - expire: data.until, - }; - if (data.reason) { - muteData.reason = reason; - } - await db.sortedSetAdd(`uid:${data.uid}:mutes:timestamp`, now, muteKey); - await db.setObject(muteKey, muteData); - await events.log({ - type: 'user-mute', - uid: caller.uid, - targetUid: data.uid, - ip: caller.ip, - reason: data.reason || undefined, - }); - plugins.hooks.fire('action:user.muted', { - callerUid: caller.uid, - ip: caller.ip, - uid: data.uid, - until: data.until > 0 ? data.until : undefined, - reason: data.reason || undefined, - }); + if (!await privileges.users.hasMutePrivilege(caller.uid)) { + throw new Error('[[error:no-privileges]]'); + } else if (await user.isAdministrator(data.uid)) { + throw new Error('[[error:cant-mute-other-admins]]'); + } + + const reason = data.reason || '[[user:info.muted-no-reason]]'; + await db.setObject(`user:${data.uid}`, { + mutedUntil: data.until, + mutedReason: reason, + }); + const now = Date.now(); + const muteKey = `uid:${data.uid}:mute:${now}`; + const muteData = { + fromUid: caller.uid, + uid: data.uid, + timestamp: now, + expire: data.until, + }; + if (data.reason) { + muteData.reason = reason; + } + + await db.sortedSetAdd(`uid:${data.uid}:mutes:timestamp`, now, muteKey); + await db.setObject(muteKey, muteData); + await events.log({ + type: 'user-mute', + uid: caller.uid, + targetUid: data.uid, + ip: caller.ip, + reason: data.reason || undefined, + }); + plugins.hooks.fire('action:user.muted', { + callerUid: caller.uid, + ip: caller.ip, + uid: data.uid, + until: data.until > 0 ? data.until : undefined, + reason: data.reason || undefined, + }); }; usersAPI.unmute = async function (caller, data) { - if (!await privileges.users.hasMutePrivilege(caller.uid)) { - throw new Error('[[error:no-privileges]]'); - } - - await db.deleteObjectFields(`user:${data.uid}`, ['mutedUntil', 'mutedReason']); - - await events.log({ - type: 'user-unmute', - uid: caller.uid, - targetUid: data.uid, - ip: caller.ip, - }); - plugins.hooks.fire('action:user.unmuted', { - callerUid: caller.uid, - ip: caller.ip, - uid: data.uid, - }); + if (!await privileges.users.hasMutePrivilege(caller.uid)) { + throw new Error('[[error:no-privileges]]'); + } + + await db.deleteObjectFields(`user:${data.uid}`, ['mutedUntil', 'mutedReason']); + + await events.log({ + type: 'user-unmute', + uid: caller.uid, + targetUid: data.uid, + ip: caller.ip, + }); + plugins.hooks.fire('action:user.unmuted', { + callerUid: caller.uid, + ip: caller.ip, + uid: data.uid, + }); }; async function isPrivilegedOrSelfAndPasswordMatch(caller, data) { - const { uid } = caller; - const isSelf = parseInt(uid, 10) === parseInt(data.uid, 10); - const canEdit = await privileges.users.canEdit(uid, data.uid); - - if (!canEdit) { - throw new Error('[[error:no-privileges]]'); - } - const [hasPassword, passwordMatch] = await Promise.all([ - user.hasPassword(data.uid), - data.password ? user.isPasswordCorrect(data.uid, data.password, caller.ip) : false, - ]); - - if (isSelf && hasPassword && !passwordMatch) { - throw new Error('[[error:invalid-password]]'); - } + const {uid} = caller; + const isSelf = Number.parseInt(uid, 10) === Number.parseInt(data.uid, 10); + const canEdit = await privileges.users.canEdit(uid, data.uid); + + if (!canEdit) { + throw new Error('[[error:no-privileges]]'); + } + + const [hasPassword, passwordMatch] = await Promise.all([ + user.hasPassword(data.uid), + data.password ? user.isPasswordCorrect(data.uid, data.password, caller.ip) : false, + ]); + + if (isSelf && hasPassword && !passwordMatch) { + throw new Error('[[error:invalid-password]]'); + } } -async function processDeletion({ uid, method, password, caller }) { - const isTargetAdmin = await user.isAdministrator(uid); - const isSelf = parseInt(uid, 10) === parseInt(caller.uid, 10); - const isAdmin = await user.isAdministrator(caller.uid); - - if (isSelf && meta.config.allowAccountDelete !== 1) { - throw new Error('[[error:account-deletion-disabled]]'); - } else if (!isSelf && !isAdmin) { - throw new Error('[[error:no-privileges]]'); - } else if (isTargetAdmin) { - throw new Error('[[error:cant-delete-admin]'); - } - - // Privilege checks -- only deleteAccount is available for non-admins - const hasAdminPrivilege = await privileges.admin.can('admin:users', caller.uid); - if (!hasAdminPrivilege && ['delete', 'deleteContent'].includes(method)) { - throw new Error('[[error:no-privileges]]'); - } - - // Self-deletions require a password - const hasPassword = await user.hasPassword(uid); - if (isSelf && hasPassword) { - const ok = await user.isPasswordCorrect(uid, password, caller.ip); - if (!ok) { - throw new Error('[[error:invalid-password]]'); - } - } - - await flags.resolveFlag('user', uid, caller.uid); - - let userData; - if (method === 'deleteAccount') { - userData = await user[method](uid); - } else { - userData = await user[method](caller.uid, uid); - } - userData = userData || {}; - - sockets.server.sockets.emit('event:user_status_change', { uid: caller.uid, status: 'offline' }); - - plugins.hooks.fire('action:user.delete', { - callerUid: caller.uid, - uid: uid, - ip: caller.ip, - user: userData, - }); - - await events.log({ - type: `user-${method}`, - uid: caller.uid, - targetUid: uid, - ip: caller.ip, - username: userData.username, - email: userData.email, - }); +async function processDeletion({uid, method, password, caller}) { + const isTargetAdmin = await user.isAdministrator(uid); + const isSelf = Number.parseInt(uid, 10) === Number.parseInt(caller.uid, 10); + const isAdmin = await user.isAdministrator(caller.uid); + + if (isSelf && meta.config.allowAccountDelete !== 1) { + throw new Error('[[error:account-deletion-disabled]]'); + } else if (!isSelf && !isAdmin) { + throw new Error('[[error:no-privileges]]'); + } else if (isTargetAdmin) { + throw new Error('[[error:cant-delete-admin]'); + } + + // Privilege checks -- only deleteAccount is available for non-admins + const hasAdminPrivilege = await privileges.admin.can('admin:users', caller.uid); + if (!hasAdminPrivilege && ['delete', 'deleteContent'].includes(method)) { + throw new Error('[[error:no-privileges]]'); + } + + // Self-deletions require a password + const hasPassword = await user.hasPassword(uid); + if (isSelf && hasPassword) { + const ok = await user.isPasswordCorrect(uid, password, caller.ip); + if (!ok) { + throw new Error('[[error:invalid-password]]'); + } + } + + await flags.resolveFlag('user', uid, caller.uid); + + let userData; + userData = await (method === 'deleteAccount' ? user[method](uid) : user[method](caller.uid, uid)); + + userData ||= {}; + + sockets.server.sockets.emit('event:user_status_change', {uid: caller.uid, status: 'offline'}); + + plugins.hooks.fire('action:user.delete', { + callerUid: caller.uid, + uid, + ip: caller.ip, + user: userData, + }); + + await events.log({ + type: `user-${method}`, + uid: caller.uid, + targetUid: uid, + ip: caller.ip, + username: userData.username, + email: userData.email, + }); } async function canDeleteUids(uids) { - if (!Array.isArray(uids)) { - throw new Error('[[error:invalid-data]]'); - } - const isMembers = await groups.isMembers(uids, 'administrators'); - if (isMembers.includes(true)) { - throw new Error('[[error:cant-delete-other-admins]]'); - } - - return true; + if (!Array.isArray(uids)) { + throw new TypeError('[[error:invalid-data]]'); + } + + const isMembers = await groups.isMembers(uids, 'administrators'); + if (isMembers.includes(true)) { + throw new Error('[[error:cant-delete-other-admins]]'); + } + + return true; } usersAPI.search = async function (caller, data) { - if (!data) { - throw new Error('[[error:invalid-data]]'); - } - const [allowed, isPrivileged] = await Promise.all([ - privileges.global.can('search:users', caller.uid), - user.isPrivileged(caller.uid), - ]); - let filters = data.filters || []; - filters = Array.isArray(filters) ? filters : [filters]; - if (!allowed || - (( - data.searchBy === 'ip' || - data.searchBy === 'email' || - filters.includes('banned') || - filters.includes('flagged') + if (!data) { + throw new Error('[[error:invalid-data]]'); + } + + const [allowed, isPrivileged] = await Promise.all([ + privileges.global.can('search:users', caller.uid), + user.isPrivileged(caller.uid), + ]); + let filters = data.filters || []; + filters = Array.isArray(filters) ? filters : [filters]; + if (!allowed + || (( + data.searchBy === 'ip' + || data.searchBy === 'email' + || filters.includes('banned') + || filters.includes('flagged') ) && !isPrivileged) - ) { - throw new Error('[[error:no-privileges]]'); - } - return await user.search({ - query: data.query, - searchBy: data.searchBy || 'username', - page: data.page || 1, - sortBy: data.sortBy || 'lastonline', - filters: filters, - }); + ) { + throw new Error('[[error:no-privileges]]'); + } + + return await user.search({ + query: data.query, + searchBy: data.searchBy || 'username', + page: data.page || 1, + sortBy: data.sortBy || 'lastonline', + filters, + }); }; usersAPI.changePicture = async (caller, data) => { - if (!data) { - throw new Error('[[error:invalid-data]]'); - } - - const { type, url } = data; - let picture = ''; - - await user.checkMinReputation(caller.uid, data.uid, 'min:rep:profile-picture'); - const canEdit = await privileges.users.canEdit(caller.uid, data.uid); - if (!canEdit) { - throw new Error('[[error:no-privileges]]'); - } - - if (type === 'default') { - picture = ''; - } else if (type === 'uploaded') { - picture = await user.getUserField(data.uid, 'uploadedpicture'); - } else if (type === 'external' && url) { - picture = validator.escape(url); - } else { - const returnData = await plugins.hooks.fire('filter:user.getPicture', { - uid: caller.uid, - type: type, - picture: undefined, - }); - picture = returnData && returnData.picture; - } - - const validBackgrounds = await user.getIconBackgrounds(caller.uid); - if (!validBackgrounds.includes(data.bgColor)) { - data.bgColor = validBackgrounds[0]; - } - - await user.updateProfile(caller.uid, { - uid: data.uid, - picture: picture, - 'icon:bgColor': data.bgColor, - }, ['picture', 'icon:bgColor']); + if (!data) { + throw new Error('[[error:invalid-data]]'); + } + + const {type, url} = data; + let picture = ''; + + await user.checkMinReputation(caller.uid, data.uid, 'min:rep:profile-picture'); + const canEdit = await privileges.users.canEdit(caller.uid, data.uid); + if (!canEdit) { + throw new Error('[[error:no-privileges]]'); + } + + if (type === 'default') { + picture = ''; + } else if (type === 'uploaded') { + picture = await user.getUserField(data.uid, 'uploadedpicture'); + } else if (type === 'external' && url) { + picture = validator.escape(url); + } else { + const returnData = await plugins.hooks.fire('filter:user.getPicture', { + uid: caller.uid, + type, + picture: undefined, + }); + picture = returnData && returnData.picture; + } + + const validBackgrounds = await user.getIconBackgrounds(caller.uid); + if (!validBackgrounds.includes(data.bgColor)) { + data.bgColor = validBackgrounds[0]; + } + + await user.updateProfile(caller.uid, { + uid: data.uid, + picture, + 'icon:bgColor': data.bgColor, + }, ['picture', 'icon:bgColor']); }; -usersAPI.generateExport = async (caller, { uid, type }) => { - const count = await db.incrObjectField('locks', `export:${uid}${type}`); - if (count > 1) { - throw new Error('[[error:already-exporting]]'); - } - - const child = require('child_process').fork(`./src/user/jobs/export-${type}.js`, [], { - env: process.env, - }); - child.send({ uid }); - child.on('error', async (err) => { - winston.error(err.stack); - await db.deleteObjectField('locks', `export:${uid}${type}`); - }); - child.on('exit', async () => { - await db.deleteObjectField('locks', `export:${uid}${type}`); - const userData = await user.getUserFields(uid, ['username', 'userslug']); - const { displayname } = userData; - const n = await notifications.create({ - bodyShort: `[[notifications:${type}-exported, ${displayname}]]`, - path: `/api/user/${userData.userslug}/export/${type}`, - nid: `${type}:export:${uid}`, - from: uid, - }); - await notifications.push(n, [caller.uid]); - await events.log({ - type: `export:${type}`, - uid: caller.uid, - targetUid: uid, - ip: caller.ip, - }); - }); +usersAPI.generateExport = async (caller, {uid, type}) => { + const count = await db.incrObjectField('locks', `export:${uid}${type}`); + if (count > 1) { + throw new Error('[[error:already-exporting]]'); + } + + const child = require('node:child_process').fork(`./src/user/jobs/export-${type}.js`, [], { + env: process.env, + }); + child.send({uid}); + child.on('error', async error => { + winston.error(error.stack); + await db.deleteObjectField('locks', `export:${uid}${type}`); + }); + child.on('exit', async () => { + await db.deleteObjectField('locks', `export:${uid}${type}`); + const userData = await user.getUserFields(uid, ['username', 'userslug']); + const {displayname} = userData; + const n = await notifications.create({ + bodyShort: `[[notifications:${type}-exported, ${displayname}]]`, + path: `/api/user/${userData.userslug}/export/${type}`, + nid: `${type}:export:${uid}`, + from: uid, + }); + await notifications.push(n, [caller.uid]); + await events.log({ + type: `export:${type}`, + uid: caller.uid, + targetUid: uid, + ip: caller.ip, + }); + }); }; diff --git a/src/batch.js b/src/batch.js index c53b007..8154e6d 100644 --- a/src/batch.js +++ b/src/batch.js @@ -1,8 +1,7 @@ 'use strict'; -const util = require('util'); - +const util = require('node:util'); const db = require('./database'); const utils = require('./utils'); @@ -11,82 +10,84 @@ const DEFAULT_BATCH_SIZE = 100; const sleep = util.promisify(setTimeout); exports.processSortedSet = async function (setKey, process, options) { - options = options || {}; + options ||= {}; + + if (typeof process !== 'function') { + throw new TypeError('[[error:process-not-a-function]]'); + } - if (typeof process !== 'function') { - throw new Error('[[error:process-not-a-function]]'); - } + // Progress bar handling (upgrade scripts) + if (options.progress) { + options.progress.total = await db.sortedSetCard(setKey); + } - // Progress bar handling (upgrade scripts) - if (options.progress) { - options.progress.total = await db.sortedSetCard(setKey); - } + options.batch = options.batch || DEFAULT_BATCH_SIZE; - options.batch = options.batch || DEFAULT_BATCH_SIZE; + // Use the fast path if possible + if (db.processSortedSet && typeof options.doneIf !== 'function' && !utils.isNumber(options.alwaysStartAt)) { + return await db.processSortedSet(setKey, process, options); + } - // use the fast path if possible - if (db.processSortedSet && typeof options.doneIf !== 'function' && !utils.isNumber(options.alwaysStartAt)) { - return await db.processSortedSet(setKey, process, options); - } + // Custom done condition + options.doneIf = typeof options.doneIf === 'function' ? options.doneIf : function () {}; - // custom done condition - options.doneIf = typeof options.doneIf === 'function' ? options.doneIf : function () {}; + let start = 0; + let stop = options.batch - 1; - let start = 0; - let stop = options.batch - 1; + if (process && process.constructor && process.constructor.name !== 'AsyncFunction') { + process = util.promisify(process); + } - if (process && process.constructor && process.constructor.name !== 'AsyncFunction') { - process = util.promisify(process); - } + while (true) { + /* eslint-disable no-await-in-loop */ + const ids = await db[`getSortedSetRange${options.withScores ? 'WithScores' : ''}`](setKey, start, stop); + if (ids.length === 0 || options.doneIf(start, stop, ids)) { + return; + } - while (true) { - /* eslint-disable no-await-in-loop */ - const ids = await db[`getSortedSetRange${options.withScores ? 'WithScores' : ''}`](setKey, start, stop); - if (!ids.length || options.doneIf(start, stop, ids)) { - return; - } - await process(ids); + await process(ids); - start += utils.isNumber(options.alwaysStartAt) ? options.alwaysStartAt : options.batch; - stop = start + options.batch - 1; + start += utils.isNumber(options.alwaysStartAt) ? options.alwaysStartAt : options.batch; + stop = start + options.batch - 1; - if (options.interval) { - await sleep(options.interval); - } - } + if (options.interval) { + await sleep(options.interval); + } + } }; exports.processArray = async function (array, process, options) { - options = options || {}; + options ||= {}; + + if (!Array.isArray(array) || array.length === 0) { + return; + } - if (!Array.isArray(array) || !array.length) { - return; - } - if (typeof process !== 'function') { - throw new Error('[[error:process-not-a-function]]'); - } + if (typeof process !== 'function') { + throw new TypeError('[[error:process-not-a-function]]'); + } - const batch = options.batch || DEFAULT_BATCH_SIZE; - let start = 0; - if (process && process.constructor && process.constructor.name !== 'AsyncFunction') { - process = util.promisify(process); - } + const batch = options.batch || DEFAULT_BATCH_SIZE; + let start = 0; + if (process && process.constructor && process.constructor.name !== 'AsyncFunction') { + process = util.promisify(process); + } - while (true) { - const currentBatch = array.slice(start, start + batch); + while (true) { + const currentBatch = array.slice(start, start + batch); - if (!currentBatch.length) { - return; - } + if (currentBatch.length === 0) { + return; + } - await process(currentBatch); + await process(currentBatch); - start += batch; + start += batch; - if (options.interval) { - await sleep(options.interval); - } - } + if (options.interval) { + await sleep(options.interval); + } + } }; require('./promisify')(exports); diff --git a/src/cache.js b/src/cache.js index c9c633f..5097351 100644 --- a/src/cache.js +++ b/src/cache.js @@ -3,7 +3,7 @@ const cacheCreate = require('./cache/lru'); module.exports = cacheCreate({ - name: 'local', - max: 40000, - ttl: 0, + name: 'local', + max: 40_000, + ttl: 0, }); diff --git a/src/cache/lru.js b/src/cache/lru.js index c2aa049..8562748 100644 --- a/src/cache/lru.js +++ b/src/cache/lru.js @@ -2,40 +2,40 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; -// import LRU from 'lru-cache'; +// Import LRU from 'lru-cache'; const lru_cache_1 = __importDefault(require("lru-cache")); -// lru-cache@7 deprecations +// Lru-cache@7 deprecations const winston_1 = __importDefault(require("winston")); const chalk_1 = __importDefault(require("chalk")); -// pubsub import should occur after import of chalk +// Pubsub import should occur after import of chalk const pubsub_1 = __importDefault(require("../pubsub")); -function cacheCreate(opts) { - // sometimes we kept passing in `length` with no corresponding `maxSize`. +function cacheCreate(options) { + // Sometimes we kept passing in `length` with no corresponding `maxSize`. // This is now enforced in v7; drop superfluous property - if (opts.hasOwnProperty('length') && !opts.hasOwnProperty('maxSize')) { - winston_1.default.warn(`[cache/init(${opts.name})] ${chalk_1.default.white.bgRed.bold('DEPRECATION')} ${chalk_1.default.yellow('length')} was passed in without a corresponding ${chalk_1.default.yellow('maxSize')}. Both are now required as of lru-cache@7.0.0.`); - delete opts.length; + if (options.hasOwnProperty('length') && !options.hasOwnProperty('maxSize')) { + winston_1.default.warn(`[cache/init(${options.name})] ${chalk_1.default.white.bgRed.bold('DEPRECATION')} ${chalk_1.default.yellow('length')} was passed in without a corresponding ${chalk_1.default.yellow('maxSize')}. Both are now required as of lru-cache@7.0.0.`); + delete options.length; } const deprecations = new Map([ ['stale', 'allowStale'], ['maxAge', 'ttl'], ['length', 'sizeCalculation'], ]); - deprecations.forEach((newProp, oldProp) => { - if (opts.hasOwnProperty(oldProp) && !opts.hasOwnProperty(newProp)) { - winston_1.default.warn(`[cache/init(${opts.name})] ${chalk_1.default.white.bgRed.bold('DEPRECATION')} The option ${chalk_1.default.yellow(oldProp)} has been deprecated as of lru-cache@7.0.0. Please change this to ${chalk_1.default.yellow(newProp)} instead.`); + for (const [oldProperty, newProperty] of deprecations.entries()) { + if (options.hasOwnProperty(oldProperty) && !options.hasOwnProperty(newProperty)) { + winston_1.default.warn(`[cache/init(${options.name})] ${chalk_1.default.white.bgRed.bold('DEPRECATION')} The option ${chalk_1.default.yellow(oldProperty)} has been deprecated as of lru-cache@7.0.0. Please change this to ${chalk_1.default.yellow(newProperty)} instead.`); /* Can pull the types of stale, maxAge, and length from the lru-cache documentation */ - opts[newProp] = opts[oldProp]; - delete opts[oldProp]; + options[newProperty] = options[oldProperty]; + delete options[oldProperty]; } - }); - const lruCache = new lru_cache_1.default(opts); + } + const lruCache = new lru_cache_1.default(options); const cache = {}; - cache.name = opts.name; + cache.name = options.name; cache.hits = 0; cache.misses = 0; - cache.enabled = opts.hasOwnProperty('enabled') ? opts.enabled : true; - // expose properties while keeping backwards compatibility + cache.enabled = options.hasOwnProperty('enabled') ? options.enabled : true; + // Expose properties while keeping backwards compatibility const propertyMap = new Map([ ['length', 'calculatedSize'], ['calculatedSize', 'calculatedSize'], @@ -45,21 +45,21 @@ function cacheCreate(opts) { ['size', 'size'], ['ttl', 'ttl'], ]); - propertyMap.forEach((lruProp, cacheProp) => { - Object.defineProperty(cache, cacheProp, { - get: function () { - return lruCache[lruProp]; + for (const [cacheProperty, lruProperty] of propertyMap.entries()) { + Object.defineProperty(cache, cacheProperty, { + get() { + return lruCache[lruProperty]; }, configurable: true, enumerable: true, }); - }); + } cache.set = function (key, value, ttl) { if (!cache.enabled) { return; } - const opts = ttl ? { ttl: ttl } : {}; - lruCache.set.apply(lruCache, [key, value, opts]); + const options = ttl ? { ttl } : {}; + lruCache.set.apply(lruCache, [key, value, options]); }; cache.get = function (key) { if (!cache.enabled) { @@ -79,7 +79,9 @@ function cacheCreate(opts) { keys = [keys]; } pubsub_1.default.publish(`${cache.name}:lruCache:del`, keys); - keys.forEach(key => lruCache.delete(key)); + for (const key of keys) { + lruCache.delete(key); + } }; cache.delete = cache.del; function localReset() { @@ -97,7 +99,9 @@ function cacheCreate(opts) { }); pubsub_1.default.on(`${cache.name}:lruCache:del`, (keys) => { if (Array.isArray(keys)) { - keys.forEach(key => lruCache.delete(key)); + for (const key of keys) { + lruCache.delete(key); + } } }); cache.getUnCachedKeys = function (keys, cachedData) { @@ -106,7 +110,7 @@ function cacheCreate(opts) { } let data; let isCached; - const unCachedKeys = keys.filter((key) => { + const unCachedKeys = keys.filter(key => { data = cache.get(key); isCached = data !== undefined; if (isCached) { diff --git a/src/cache/lru.ts b/src/cache/lru.ts index d3ee7d1..ed77d37 100644 --- a/src/cache/lru.ts +++ b/src/cache/lru.ts @@ -1,165 +1,177 @@ -// import LRU from 'lru-cache'; +// Import LRU from 'lru-cache'; import LRU from 'lru-cache'; -// lru-cache@7 deprecations +// Lru-cache@7 deprecations import winston from 'winston'; import chalk from 'chalk'; -// pubsub import should occur after import of chalk +// Pubsub import should occur after import of chalk import pubsub from '../pubsub'; /* Can extend the key and value types here */ type keyType = string; type valueType = string; -interface CacheBB { - name ?: string; - hits : number; - misses : number; - enabled ?: boolean; - set : (key : keyType, value : valueType, ttl ?: number) => void; - get : (key : keyType) => undefined | valueType; - reset : () => void; - clear : () => void; - del : (keys : keyType[]) => void; - delete : (keys : keyType[]) => void; - getUnCachedKeys : (keys : keyType[], cachedData : Map) => keyType[]; - dump : () => [ keyType, LRU.Entry ][]; - peek : (key : keyType) => undefined | valueType; -} -type Opts = LRU.Options & {name ?: string; enabled ?: boolean}; - -function cacheCreate(opts : Opts) { - // sometimes we kept passing in `length` with no corresponding `maxSize`. - // This is now enforced in v7; drop superfluous property - if (opts.hasOwnProperty('length') && !opts.hasOwnProperty('maxSize')) { - winston.warn(`[cache/init(${opts.name})] ${chalk.white.bgRed.bold('DEPRECATION')} ${chalk.yellow('length')} was passed in without a corresponding ${chalk.yellow('maxSize')}. Both are now required as of lru-cache@7.0.0.`); - delete opts.length; - } - - const deprecations = new Map([ - ['stale', 'allowStale'], - ['maxAge', 'ttl'], - ['length', 'sizeCalculation'], - ]); - deprecations.forEach((newProp, oldProp) => { - if (opts.hasOwnProperty(oldProp) && !opts.hasOwnProperty(newProp)) { - winston.warn(`[cache/init(${opts.name})] ${chalk.white.bgRed.bold('DEPRECATION')} The option ${chalk.yellow(oldProp)} has been deprecated as of lru-cache@7.0.0. Please change this to ${chalk.yellow(newProp)} instead.`); - /* Can pull the types of stale, maxAge, and length from the lru-cache documentation */ - opts[newProp] = opts[oldProp] as (boolean | number); - delete opts[oldProp]; - } - }); - - const lruCache = new LRU(opts); - - const cache = {} as CacheBB; - cache.name = opts.name; - cache.hits = 0; - cache.misses = 0; - cache.enabled = opts.hasOwnProperty('enabled') ? opts.enabled : true; - - // expose properties while keeping backwards compatibility - const propertyMap = new Map([ - ['length', 'calculatedSize'], - ['calculatedSize', 'calculatedSize'], - ['max', 'max'], - ['maxSize', 'maxSize'], - ['itemCount', 'size'], - ['size', 'size'], - ['ttl', 'ttl'], - ]); - propertyMap.forEach((lruProp, cacheProp) => { - Object.defineProperty(cache, cacheProp, { - get: function () { - return lruCache[lruProp] as valueType; - }, - configurable: true, - enumerable: true, - }); - }); - - cache.set = function (key, value, ttl ?: number) { - if (!cache.enabled) { - return; - } - const opts = ttl ? { ttl: ttl } : {}; - lruCache.set.apply(lruCache, [key, value, opts]); - }; - - cache.get = function (key) { - if (!cache.enabled) { - return undefined; - } - const data = lruCache.get(key); - if (data === undefined) { - cache.misses += 1; - } else { - cache.hits += 1; - } - return data; - }; - - cache.del = function (keys) { - if (!Array.isArray(keys)) { - keys = [keys]; - } - pubsub.publish(`${cache.name}:lruCache:del`, keys); - keys.forEach(key => lruCache.delete(key)); - }; - cache.delete = cache.del; - - function localReset() { - lruCache.clear(); - cache.hits = 0; - cache.misses = 0; - } - - cache.reset = function () { - pubsub.publish(`${cache.name}:lruCache:reset`); - localReset(); - }; - cache.clear = cache.reset; - - pubsub.on(`${cache.name}:lruCache:reset`, () => { - localReset(); - }); - - pubsub.on(`${cache.name}:lruCache:del`, (keys : keyType[]) => { - if (Array.isArray(keys)) { - keys.forEach(key => lruCache.delete(key)); - } - }); - - cache.getUnCachedKeys = function (keys, cachedData) { - if (!cache.enabled) { - return keys; - } - let data : valueType | undefined; - let isCached : boolean; - const unCachedKeys = keys.filter((key) => { - data = cache.get(key); - isCached = data !== undefined; - if (isCached) { - cachedData[key] = data; - } - return !isCached; - }); - - const hits = keys.length - unCachedKeys.length; - const misses = keys.length - hits; - cache.hits += hits; - cache.misses += misses; - return unCachedKeys; - }; - - cache.dump = function () { - return lruCache.dump(); - }; - - cache.peek = function (key) { - return lruCache.peek(key); - }; - - return cache; +type CacheBB = { + name?: string; + hits: number; + misses: number; + enabled?: boolean; + set: (key: keyType, value: valueType, ttl?: number) => void; + get: (key: keyType) => undefined | valueType; + reset: () => void; + clear: () => void; + del: (keys: keyType[]) => void; + delete: (keys: keyType[]) => void; + getUnCachedKeys: (keys: keyType[], cachedData: Map) => keyType[]; + dump: () => Array<[ keyType, LRU.Entry ]>; + peek: (key: keyType) => undefined | valueType; +}; +type Options = LRU.Options & {name?: string; enabled?: boolean}; + +function cacheCreate(options: Options) { + // Sometimes we kept passing in `length` with no corresponding `maxSize`. + // This is now enforced in v7; drop superfluous property + if (options.hasOwnProperty('length') && !options.hasOwnProperty('maxSize')) { + winston.warn(`[cache/init(${options.name})] ${chalk.white.bgRed.bold('DEPRECATION')} ${chalk.yellow('length')} was passed in without a corresponding ${chalk.yellow('maxSize')}. Both are now required as of lru-cache@7.0.0.`); + delete options.length; + } + + const deprecations = new Map([ + ['stale', 'allowStale'], + ['maxAge', 'ttl'], + ['length', 'sizeCalculation'], + ]); + for (const [oldProperty, newProperty] of deprecations.entries()) { + if (options.hasOwnProperty(oldProperty) && !options.hasOwnProperty(newProperty)) { + winston.warn(`[cache/init(${options.name})] ${chalk.white.bgRed.bold('DEPRECATION')} The option ${chalk.yellow(oldProperty)} has been deprecated as of lru-cache@7.0.0. Please change this to ${chalk.yellow(newProperty)} instead.`); + /* Can pull the types of stale, maxAge, and length from the lru-cache documentation */ + options[newProperty] = options[oldProperty] as (boolean | number); + delete options[oldProperty]; + } + } + + const lruCache = new LRU(options); + + const cache = {} as CacheBB; + cache.name = options.name; + cache.hits = 0; + cache.misses = 0; + cache.enabled = options.hasOwnProperty('enabled') ? options.enabled : true; + + // Expose properties while keeping backwards compatibility + const propertyMap = new Map([ + ['length', 'calculatedSize'], + ['calculatedSize', 'calculatedSize'], + ['max', 'max'], + ['maxSize', 'maxSize'], + ['itemCount', 'size'], + ['size', 'size'], + ['ttl', 'ttl'], + ]); + for (const [cacheProperty, lruProperty] of propertyMap.entries()) { + Object.defineProperty(cache, cacheProperty, { + get() { + return lruCache[lruProperty] as valueType; + }, + configurable: true, + enumerable: true, + }); + } + + cache.set = function (key, value, ttl?: number) { + if (!cache.enabled) { + return; + } + + const options = ttl ? {ttl} : {}; + lruCache.set.apply(lruCache, [key, value, options]); + }; + + cache.get = function (key) { + if (!cache.enabled) { + return undefined; + } + + const data = lruCache.get(key); + if (data === undefined) { + cache.misses += 1; + } else { + cache.hits += 1; + } + + return data; + }; + + cache.del = function (keys) { + if (!Array.isArray(keys)) { + keys = [keys]; + } + + pubsub.publish(`${cache.name}:lruCache:del`, keys); + for (const key of keys) { + lruCache.delete(key); + } + }; + + cache.delete = cache.del; + + function localReset() { + lruCache.clear(); + cache.hits = 0; + cache.misses = 0; + } + + cache.reset = function () { + pubsub.publish(`${cache.name}:lruCache:reset`); + localReset(); + }; + + cache.clear = cache.reset; + + pubsub.on(`${cache.name}:lruCache:reset`, () => { + localReset(); + }); + + pubsub.on(`${cache.name}:lruCache:del`, (keys: keyType[]) => { + if (Array.isArray(keys)) { + for (const key of keys) { + lruCache.delete(key); + } + } + }); + + cache.getUnCachedKeys = function (keys, cachedData) { + if (!cache.enabled) { + return keys; + } + + let data: valueType | undefined; + let isCached: boolean; + const unCachedKeys = keys.filter(key => { + data = cache.get(key); + isCached = data !== undefined; + if (isCached) { + cachedData[key] = data; + } + + return !isCached; + }); + + const hits = keys.length - unCachedKeys.length; + const misses = keys.length - hits; + cache.hits += hits; + cache.misses += misses; + return unCachedKeys; + }; + + cache.dump = function () { + return lruCache.dump(); + }; + + cache.peek = function (key) { + return lruCache.peek(key); + }; + + return cache; } export = cacheCreate; diff --git a/src/cache/ttl.js b/src/cache/ttl.js index 51dcdab..c329034 100644 --- a/src/cache/ttl.js +++ b/src/cache/ttl.js @@ -1,119 +1,132 @@ 'use strict'; -module.exports = function (opts) { - const TTLCache = require('@isaacs/ttlcache'); - const pubsub = require('../pubsub'); - - const ttlCache = new TTLCache(opts); - - const cache = {}; - cache.name = opts.name; - cache.hits = 0; - cache.misses = 0; - cache.enabled = opts.hasOwnProperty('enabled') ? opts.enabled : true; - const cacheSet = ttlCache.set; - - // expose properties - const propertyMap = new Map([ - ['max', 'max'], - ['itemCount', 'size'], - ['size', 'size'], - ['ttl', 'ttl'], - ]); - propertyMap.forEach((ttlProp, cacheProp) => { - Object.defineProperty(cache, cacheProp, { - get: function () { - return ttlCache[ttlProp]; - }, - configurable: true, - enumerable: true, - }); - }); - - cache.set = function (key, value, ttl) { - if (!cache.enabled) { - return; - } - const opts = {}; - if (ttl) { - opts.ttl = ttl; - } - cacheSet.apply(ttlCache, [key, value, opts]); - }; - - cache.get = function (key) { - if (!cache.enabled) { - return undefined; - } - const data = ttlCache.get(key); - if (data === undefined) { - cache.misses += 1; - } else { - cache.hits += 1; - } - return data; - }; - - cache.del = function (keys) { - if (!Array.isArray(keys)) { - keys = [keys]; - } - pubsub.publish(`${cache.name}:ttlCache:del`, keys); - keys.forEach(key => ttlCache.delete(key)); - }; - cache.delete = cache.del; - - cache.reset = function () { - pubsub.publish(`${cache.name}:ttlCache:reset`); - localReset(); - }; - cache.clear = cache.reset; - - function localReset() { - ttlCache.clear(); - cache.hits = 0; - cache.misses = 0; - } - - pubsub.on(`${cache.name}:ttlCache:reset`, () => { - localReset(); - }); - - pubsub.on(`${cache.name}:ttlCache:del`, (keys) => { - if (Array.isArray(keys)) { - keys.forEach(key => ttlCache.delete(key)); - } - }); - - cache.getUnCachedKeys = function (keys, cachedData) { - if (!cache.enabled) { - return keys; - } - let data; - let isCached; - const unCachedKeys = keys.filter((key) => { - data = cache.get(key); - isCached = data !== undefined; - if (isCached) { - cachedData[key] = data; - } - return !isCached; - }); - - const hits = keys.length - unCachedKeys.length; - const misses = keys.length - hits; - cache.hits += hits; - cache.misses += misses; - return unCachedKeys; - }; - - cache.dump = function () { - return Array.from(ttlCache.entries()); - }; - - cache.peek = function (key) { - return ttlCache.get(key, { updateAgeOnGet: false }); - }; - - return cache; +module.exports = function (options) { + const TTLCache = require('@isaacs/ttlcache'); + const pubsub = require('../pubsub'); + + const ttlCache = new TTLCache(options); + + const cache = {}; + cache.name = options.name; + cache.hits = 0; + cache.misses = 0; + cache.enabled = options.hasOwnProperty('enabled') ? options.enabled : true; + const cacheSet = ttlCache.set; + + // Expose properties + const propertyMap = new Map([ + ['max', 'max'], + ['itemCount', 'size'], + ['size', 'size'], + ['ttl', 'ttl'], + ]); + for (const [cacheProperty, ttlProperty] of propertyMap.entries()) { + Object.defineProperty(cache, cacheProperty, { + get() { + return ttlCache[ttlProperty]; + }, + configurable: true, + enumerable: true, + }); + } + + cache.set = function (key, value, ttl) { + if (!cache.enabled) { + return; + } + + const options = {}; + if (ttl) { + options.ttl = ttl; + } + + cacheSet.apply(ttlCache, [key, value, options]); + }; + + cache.get = function (key) { + if (!cache.enabled) { + return undefined; + } + + const data = ttlCache.get(key); + if (data === undefined) { + cache.misses += 1; + } else { + cache.hits += 1; + } + + return data; + }; + + cache.del = function (keys) { + if (!Array.isArray(keys)) { + keys = [keys]; + } + + pubsub.publish(`${cache.name}:ttlCache:del`, keys); + for (const key of keys) { + ttlCache.delete(key); + } + }; + + cache.delete = cache.del; + + cache.reset = function () { + pubsub.publish(`${cache.name}:ttlCache:reset`); + localReset(); + }; + + cache.clear = cache.reset; + + function localReset() { + ttlCache.clear(); + cache.hits = 0; + cache.misses = 0; + } + + pubsub.on(`${cache.name}:ttlCache:reset`, () => { + localReset(); + }); + + pubsub.on(`${cache.name}:ttlCache:del`, keys => { + if (Array.isArray(keys)) { + for (const key of keys) { + ttlCache.delete(key); + } + } + }); + + cache.getUnCachedKeys = function (keys, cachedData) { + if (!cache.enabled) { + return keys; + } + + let data; + let isCached; + const unCachedKeys = keys.filter(key => { + data = cache.get(key); + isCached = data !== undefined; + if (isCached) { + cachedData[key] = data; + } + + return !isCached; + }); + + const hits = keys.length - unCachedKeys.length; + const misses = keys.length - hits; + cache.hits += hits; + cache.misses += misses; + return unCachedKeys; + }; + + cache.dump = function () { + return Array.from(ttlCache.entries()); + }; + + cache.peek = function (key) { + return ttlCache.get(key, {updateAgeOnGet: false}); + }; + + return cache; }; diff --git a/src/categories/activeusers.js b/src/categories/activeusers.js index 77f801a..1c64c2a 100644 --- a/src/categories/activeusers.js +++ b/src/categories/activeusers.js @@ -1,17 +1,17 @@ 'use strict'; const _ = require('lodash'); - const posts = require('../posts'); const db = require('../database'); module.exports = function (Categories) { - Categories.getActiveUsers = async function (cids) { - if (!Array.isArray(cids)) { - cids = [cids]; - } - const pids = await db.getSortedSetRevRange(cids.map(cid => `cid:${cid}:pids`), 0, 24); - const postData = await posts.getPostsFields(pids, ['uid']); - return _.uniq(postData.map(post => post.uid).filter(uid => uid)); - }; + Categories.getActiveUsers = async function (cids) { + if (!Array.isArray(cids)) { + cids = [cids]; + } + + const pids = await db.getSortedSetRevRange(cids.map(cid => `cid:${cid}:pids`), 0, 24); + const postData = await posts.getPostsFields(pids, ['uid']); + return _.uniq(postData.map(post => post.uid).filter(Boolean)); + }; }; diff --git a/src/categories/create.js b/src/categories/create.js index 01a3c33..a8917c2 100644 --- a/src/categories/create.js +++ b/src/categories/create.js @@ -2,7 +2,6 @@ const async = require('async'); const _ = require('lodash'); - const db = require('../database'); const plugins = require('../plugins'); const privileges = require('../privileges'); @@ -11,240 +10,237 @@ const slugify = require('../slugify'); const cache = require('../cache'); module.exports = function (Categories) { - Categories.create = async function (data) { - const parentCid = data.parentCid ? data.parentCid : 0; - const [cid, firstChild] = await Promise.all([ - db.incrObjectField('global', 'nextCid'), - db.getSortedSetRangeWithScores(`cid:${parentCid}:children`, 0, 0), - ]); - - data.name = String(data.name || `Category ${cid}`); - const slug = `${cid}/${slugify(data.name)}`; - const smallestOrder = firstChild.length ? firstChild[0].score - 1 : 1; - const order = data.order || smallestOrder; // If no order provided, place it at the top - const colours = Categories.assignColours(); - - let category = { - cid: cid, - name: data.name, - description: data.description ? data.description : '', - descriptionParsed: data.descriptionParsed ? data.descriptionParsed : '', - icon: data.icon ? data.icon : '', - bgColor: data.bgColor || colours[0], - color: data.color || colours[1], - slug: slug, - parentCid: parentCid, - topic_count: 0, - post_count: 0, - disabled: data.disabled ? 1 : 0, - order: order, - link: data.link || '', - numRecentReplies: 1, - class: (data.class ? data.class : 'col-md-3 col-xs-6'), - imageClass: 'cover', - isSection: 0, - subCategoriesPerPage: 10, - }; - - if (data.backgroundImage) { - category.backgroundImage = data.backgroundImage; - } - - const defaultPrivileges = [ - 'groups:find', - 'groups:read', - 'groups:topics:read', - 'groups:topics:create', - 'groups:topics:reply', - 'groups:topics:tag', - 'groups:posts:edit', - 'groups:posts:history', - 'groups:posts:delete', - 'groups:posts:upvote', - 'groups:posts:downvote', - 'groups:topics:delete', - ]; - const modPrivileges = defaultPrivileges.concat([ - 'groups:topics:schedule', - 'groups:posts:view_deleted', - 'groups:purge', - ]); - const guestPrivileges = ['groups:find', 'groups:read', 'groups:topics:read']; - - const result = await plugins.hooks.fire('filter:category.create', { - category: category, - data: data, - defaultPrivileges: defaultPrivileges, - modPrivileges: modPrivileges, - guestPrivileges: guestPrivileges, - }); - category = result.category; - - await db.setObject(`category:${category.cid}`, category); - if (!category.descriptionParsed) { - await Categories.parseDescription(category.cid, category.description); - } - - await db.sortedSetAddBulk([ - ['categories:cid', category.order, category.cid], - [`cid:${parentCid}:children`, category.order, category.cid], - ['categories:name', 0, `${data.name.slice(0, 200).toLowerCase()}:${category.cid}`], - ]); - - await privileges.categories.give(result.defaultPrivileges, category.cid, 'registered-users'); - await privileges.categories.give(result.modPrivileges, category.cid, ['administrators', 'Global Moderators']); - await privileges.categories.give(result.guestPrivileges, category.cid, ['guests', 'spiders']); - - cache.del([ - 'categories:cid', - `cid:${parentCid}:children`, - `cid:${parentCid}:children:all`, - ]); - if (data.cloneFromCid && parseInt(data.cloneFromCid, 10)) { - category = await Categories.copySettingsFrom(data.cloneFromCid, category.cid, !data.parentCid); - } - - if (data.cloneChildren) { - await duplicateCategoriesChildren(category.cid, data.cloneFromCid, data.uid); - } - - plugins.hooks.fire('action:category.create', { category: category }); - return category; - }; - - async function duplicateCategoriesChildren(parentCid, cid, uid) { - let children = await Categories.getChildren([cid], uid); - if (!children.length) { - return; - } - - children = children[0]; - - children.forEach((child) => { - child.parentCid = parentCid; - child.cloneFromCid = child.cid; - child.cloneChildren = true; - child.name = utils.decodeHTMLEntities(child.name); - child.description = utils.decodeHTMLEntities(child.description); - child.uid = uid; - }); - - await async.each(children, Categories.create); - } - - Categories.assignColours = function () { - const backgrounds = ['#AB4642', '#DC9656', '#F7CA88', '#A1B56C', '#86C1B9', '#7CAFC2', '#BA8BAF', '#A16946']; - const text = ['#ffffff', '#ffffff', '#333333', '#ffffff', '#333333', '#ffffff', '#ffffff', '#ffffff']; - const index = Math.floor(Math.random() * backgrounds.length); - return [backgrounds[index], text[index]]; - }; - - Categories.copySettingsFrom = async function (fromCid, toCid, copyParent) { - const [source, destination] = await Promise.all([ - db.getObject(`category:${fromCid}`), - db.getObject(`category:${toCid}`), - ]); - if (!source) { - throw new Error('[[error:invalid-cid]]'); - } - - const oldParent = parseInt(destination.parentCid, 10) || 0; - const newParent = parseInt(source.parentCid, 10) || 0; - if (copyParent && newParent !== parseInt(toCid, 10)) { - await db.sortedSetRemove(`cid:${oldParent}:children`, toCid); - await db.sortedSetAdd(`cid:${newParent}:children`, source.order, toCid); - cache.del([ - `cid:${oldParent}:children`, - `cid:${oldParent}:children:all`, - `cid:${newParent}:children`, - `cid:${newParent}:children:all`, - ]); - } - - destination.description = source.description; - destination.descriptionParsed = source.descriptionParsed; - destination.icon = source.icon; - destination.bgColor = source.bgColor; - destination.color = source.color; - destination.link = source.link; - destination.numRecentReplies = source.numRecentReplies; - destination.class = source.class; - destination.image = source.image; - destination.imageClass = source.imageClass; - destination.minTags = source.minTags; - destination.maxTags = source.maxTags; - - if (copyParent) { - destination.parentCid = source.parentCid || 0; - } - await plugins.hooks.fire('filter:categories.copySettingsFrom', { - source: source, - destination: destination, - copyParent: copyParent, - }); - - await db.setObject(`category:${toCid}`, destination); - - await copyTagWhitelist(fromCid, toCid); - - await Categories.copyPrivilegesFrom(fromCid, toCid); - - return destination; - }; - - async function copyTagWhitelist(fromCid, toCid) { - const data = await db.getSortedSetRangeWithScores(`cid:${fromCid}:tag:whitelist`, 0, -1); - await db.delete(`cid:${toCid}:tag:whitelist`); - await db.sortedSetAdd(`cid:${toCid}:tag:whitelist`, data.map(item => item.score), data.map(item => item.value)); - cache.del(`cid:${toCid}:tag:whitelist`); - } - - Categories.copyPrivilegesFrom = async function (fromCid, toCid, group, filter = []) { - group = group || ''; - let privsToCopy; - if (group) { - const groupPrivilegeList = await privileges.categories.getGroupPrivilegeList(); - privsToCopy = groupPrivilegeList.slice(...filter); - } else { - const privs = await privileges.categories.getPrivilegeList(); - const halfIdx = privs.length / 2; - privsToCopy = privs.slice(0, halfIdx).slice(...filter).concat(privs.slice(halfIdx).slice(...filter)); - } - - const data = await plugins.hooks.fire('filter:categories.copyPrivilegesFrom', { - privileges: privsToCopy, - fromCid: fromCid, - toCid: toCid, - group: group, - }); - if (group) { - await copyPrivilegesByGroup(data.privileges, data.fromCid, data.toCid, group); - } else { - await copyPrivileges(data.privileges, data.fromCid, data.toCid); - } - }; - - async function copyPrivileges(privileges, fromCid, toCid) { - const toGroups = privileges.map(privilege => `group:cid:${toCid}:privileges:${privilege}:members`); - const fromGroups = privileges.map(privilege => `group:cid:${fromCid}:privileges:${privilege}:members`); - - const currentMembers = await db.getSortedSetsMembers(toGroups.concat(fromGroups)); - const copyGroups = _.uniq(_.flatten(currentMembers)); - await async.each(copyGroups, async (group) => { - await copyPrivilegesByGroup(privileges, fromCid, toCid, group); - }); - } - - async function copyPrivilegesByGroup(privilegeList, fromCid, toCid, group) { - const fromGroups = privilegeList.map(privilege => `group:cid:${fromCid}:privileges:${privilege}:members`); - const toGroups = privilegeList.map(privilege => `group:cid:${toCid}:privileges:${privilege}:members`); - const [fromChecks, toChecks] = await Promise.all([ - db.isMemberOfSortedSets(fromGroups, group), - db.isMemberOfSortedSets(toGroups, group), - ]); - const givePrivs = privilegeList.filter((priv, index) => fromChecks[index] && !toChecks[index]); - const rescindPrivs = privilegeList.filter((priv, index) => !fromChecks[index] && toChecks[index]); - await privileges.categories.give(givePrivs, toCid, group); - await privileges.categories.rescind(rescindPrivs, toCid, group); - } + Categories.create = async function (data) { + const parentCid = data.parentCid ? data.parentCid : 0; + const [cid, firstChild] = await Promise.all([ + db.incrObjectField('global', 'nextCid'), + db.getSortedSetRangeWithScores(`cid:${parentCid}:children`, 0, 0), + ]); + + data.name = String(data.name || `Category ${cid}`); + const slug = `${cid}/${slugify(data.name)}`; + const smallestOrder = firstChild.length > 0 ? firstChild[0].score - 1 : 1; + const order = data.order || smallestOrder; // If no order provided, place it at the top + const colours = Categories.assignColours(); + + let category = { + cid, + name: data.name, + description: data.description ? data.description : '', + descriptionParsed: data.descriptionParsed ? data.descriptionParsed : '', + icon: data.icon ? data.icon : '', + bgColor: data.bgColor || colours[0], + color: data.color || colours[1], + slug, + parentCid, + topic_count: 0, + post_count: 0, + disabled: data.disabled ? 1 : 0, + order, + link: data.link || '', + numRecentReplies: 1, + class: (data.class ? data.class : 'col-md-3 col-xs-6'), + imageClass: 'cover', + isSection: 0, + subCategoriesPerPage: 10, + }; + + if (data.backgroundImage) { + category.backgroundImage = data.backgroundImage; + } + + const defaultPrivileges = [ + 'groups:find', + 'groups:read', + 'groups:topics:read', + 'groups:topics:create', + 'groups:topics:reply', + 'groups:topics:tag', + 'groups:posts:edit', + 'groups:posts:history', + 'groups:posts:delete', + 'groups:posts:upvote', + 'groups:posts:downvote', + 'groups:topics:delete', + ]; + const modulePrivileges = defaultPrivileges.concat([ + 'groups:topics:schedule', + 'groups:posts:view_deleted', + 'groups:purge', + ]); + const guestPrivileges = ['groups:find', 'groups:read', 'groups:topics:read']; + + const result = await plugins.hooks.fire('filter:category.create', { + category, + data, + defaultPrivileges, + modPrivileges: modulePrivileges, + guestPrivileges, + }); + category = result.category; + + await db.setObject(`category:${category.cid}`, category); + if (!category.descriptionParsed) { + await Categories.parseDescription(category.cid, category.description); + } + + await db.sortedSetAddBulk([ + ['categories:cid', category.order, category.cid], + [`cid:${parentCid}:children`, category.order, category.cid], + ['categories:name', 0, `${data.name.slice(0, 200).toLowerCase()}:${category.cid}`], + ]); + + await privileges.categories.give(result.defaultPrivileges, category.cid, 'registered-users'); + await privileges.categories.give(result.modPrivileges, category.cid, ['administrators', 'Global Moderators']); + await privileges.categories.give(result.guestPrivileges, category.cid, ['guests', 'spiders']); + + cache.del([ + 'categories:cid', + `cid:${parentCid}:children`, + `cid:${parentCid}:children:all`, + ]); + if (data.cloneFromCid && Number.parseInt(data.cloneFromCid, 10)) { + category = await Categories.copySettingsFrom(data.cloneFromCid, category.cid, !data.parentCid); + } + + if (data.cloneChildren) { + await duplicateCategoriesChildren(category.cid, data.cloneFromCid, data.uid); + } + + plugins.hooks.fire('action:category.create', {category}); + return category; + }; + + async function duplicateCategoriesChildren(parentCid, cid, uid) { + let children = await Categories.getChildren([cid], uid); + if (children.length === 0) { + return; + } + + children = children[0]; + + for (const child of children) { + child.parentCid = parentCid; + child.cloneFromCid = child.cid; + child.cloneChildren = true; + child.name = utils.decodeHTMLEntities(child.name); + child.description = utils.decodeHTMLEntities(child.description); + child.uid = uid; + } + + await async.each(children, Categories.create); + } + + Categories.assignColours = function () { + const backgrounds = ['#AB4642', '#DC9656', '#F7CA88', '#A1B56C', '#86C1B9', '#7CAFC2', '#BA8BAF', '#A16946']; + const text = ['#ffffff', '#ffffff', '#333333', '#ffffff', '#333333', '#ffffff', '#ffffff', '#ffffff']; + const index = Math.floor(Math.random() * backgrounds.length); + return [backgrounds[index], text[index]]; + }; + + Categories.copySettingsFrom = async function (fromCid, toCid, copyParent) { + const [source, destination] = await Promise.all([ + db.getObject(`category:${fromCid}`), + db.getObject(`category:${toCid}`), + ]); + if (!source) { + throw new Error('[[error:invalid-cid]]'); + } + + const oldParent = Number.parseInt(destination.parentCid, 10) || 0; + const newParent = Number.parseInt(source.parentCid, 10) || 0; + if (copyParent && newParent !== Number.parseInt(toCid, 10)) { + await db.sortedSetRemove(`cid:${oldParent}:children`, toCid); + await db.sortedSetAdd(`cid:${newParent}:children`, source.order, toCid); + cache.del([ + `cid:${oldParent}:children`, + `cid:${oldParent}:children:all`, + `cid:${newParent}:children`, + `cid:${newParent}:children:all`, + ]); + } + + destination.description = source.description; + destination.descriptionParsed = source.descriptionParsed; + destination.icon = source.icon; + destination.bgColor = source.bgColor; + destination.color = source.color; + destination.link = source.link; + destination.numRecentReplies = source.numRecentReplies; + destination.class = source.class; + destination.image = source.image; + destination.imageClass = source.imageClass; + destination.minTags = source.minTags; + destination.maxTags = source.maxTags; + + if (copyParent) { + destination.parentCid = source.parentCid || 0; + } + + await plugins.hooks.fire('filter:categories.copySettingsFrom', { + source, + destination, + copyParent, + }); + + await db.setObject(`category:${toCid}`, destination); + + await copyTagInclude(fromCid, toCid); + + await Categories.copyPrivilegesFrom(fromCid, toCid); + + return destination; + }; + + async function copyTagInclude(fromCid, toCid) { + const data = await db.getSortedSetRangeWithScores(`cid:${fromCid}:tag:whitelist`, 0, -1); + await db.delete(`cid:${toCid}:tag:whitelist`); + await db.sortedSetAdd(`cid:${toCid}:tag:whitelist`, data.map(item => item.score), data.map(item => item.value)); + cache.del(`cid:${toCid}:tag:whitelist`); + } + + Categories.copyPrivilegesFrom = async function (fromCid, toCid, group, filter = []) { + group ||= ''; + let privsToCopy; + if (group) { + const groupPrivilegeList = await privileges.categories.getGroupPrivilegeList(); + privsToCopy = groupPrivilegeList.slice(...filter); + } else { + const privs = await privileges.categories.getPrivilegeList(); + const halfIndex = privs.length / 2; + privsToCopy = privs.slice(0, halfIndex).slice(...filter).concat(privs.slice(halfIndex).slice(...filter)); + } + + const data = await plugins.hooks.fire('filter:categories.copyPrivilegesFrom', { + privileges: privsToCopy, + fromCid, + toCid, + group, + }); + await (group ? copyPrivilegesByGroup(data.privileges, data.fromCid, data.toCid, group) : copyPrivileges(data.privileges, data.fromCid, data.toCid)); + }; + + async function copyPrivileges(privileges, fromCid, toCid) { + const toGroups = privileges.map(privilege => `group:cid:${toCid}:privileges:${privilege}:members`); + const fromGroups = privileges.map(privilege => `group:cid:${fromCid}:privileges:${privilege}:members`); + + const currentMembers = await db.getSortedSetsMembers(toGroups.concat(fromGroups)); + const copyGroups = _.uniq(currentMembers.flat()); + await async.each(copyGroups, async group => { + await copyPrivilegesByGroup(privileges, fromCid, toCid, group); + }); + } + + async function copyPrivilegesByGroup(privilegeList, fromCid, toCid, group) { + const fromGroups = privilegeList.map(privilege => `group:cid:${fromCid}:privileges:${privilege}:members`); + const toGroups = privilegeList.map(privilege => `group:cid:${toCid}:privileges:${privilege}:members`); + const [fromChecks, toChecks] = await Promise.all([ + db.isMemberOfSortedSets(fromGroups, group), + db.isMemberOfSortedSets(toGroups, group), + ]); + const givePrivs = privilegeList.filter((priv, index) => fromChecks[index] && !toChecks[index]); + const rescindPrivs = privilegeList.filter((priv, index) => !fromChecks[index] && toChecks[index]); + await privileges.categories.give(givePrivs, toCid, group); + await privileges.categories.rescind(rescindPrivs, toCid, group); + } }; diff --git a/src/categories/data.js b/src/categories/data.js index 5e32f7a..6cbfcd4 100644 --- a/src/categories/data.js +++ b/src/categories/data.js @@ -1,112 +1,123 @@ 'use strict'; const validator = require('validator'); - const db = require('../database'); const meta = require('../meta'); const plugins = require('../plugins'); const utils = require('../utils'); const intFields = [ - 'cid', 'parentCid', 'disabled', 'isSection', 'order', - 'topic_count', 'post_count', 'numRecentReplies', - 'minTags', 'maxTags', 'postQueue', 'subCategoriesPerPage', + 'cid', + 'parentCid', + 'disabled', + 'isSection', + 'order', + 'topic_count', + 'post_count', + 'numRecentReplies', + 'minTags', + 'maxTags', + 'postQueue', + 'subCategoriesPerPage', ]; module.exports = function (Categories) { - Categories.getCategoriesFields = async function (cids, fields) { - if (!Array.isArray(cids) || !cids.length) { - return []; - } - - const keys = cids.map(cid => `category:${cid}`); - const categories = await db.getObjects(keys, fields); - const result = await plugins.hooks.fire('filter:category.getFields', { - cids: cids, - categories: categories, - fields: fields, - keys: keys, - }); - result.categories.forEach(category => modifyCategory(category, fields)); - return result.categories; - }; - - Categories.getCategoryData = async function (cid) { - const categories = await Categories.getCategoriesFields([cid], []); - return categories && categories.length ? categories[0] : null; - }; - - Categories.getCategoriesData = async function (cids) { - return await Categories.getCategoriesFields(cids, []); - }; - - Categories.getCategoryField = async function (cid, field) { - const category = await Categories.getCategoryFields(cid, [field]); - return category ? category[field] : null; - }; - - Categories.getCategoryFields = async function (cid, fields) { - const categories = await Categories.getCategoriesFields([cid], fields); - return categories ? categories[0] : null; - }; - - Categories.getAllCategoryFields = async function (fields) { - const cids = await Categories.getAllCidsFromSet('categories:cid'); - return await Categories.getCategoriesFields(cids, fields); - }; - - Categories.setCategoryField = async function (cid, field, value) { - await db.setObjectField(`category:${cid}`, field, value); - }; - - Categories.incrementCategoryFieldBy = async function (cid, field, value) { - await db.incrObjectFieldBy(`category:${cid}`, field, value); - }; + Categories.getCategoriesFields = async function (cids, fields) { + if (!Array.isArray(cids) || cids.length === 0) { + return []; + } + + const keys = cids.map(cid => `category:${cid}`); + const categories = await db.getObjects(keys, fields); + const result = await plugins.hooks.fire('filter:category.getFields', { + cids, + categories, + fields, + keys, + }); + for (const category of result.categories) { + modifyCategory(category, fields); + } + + return result.categories; + }; + + Categories.getCategoryData = async function (cid) { + const categories = await Categories.getCategoriesFields([cid], []); + return categories && categories.length > 0 ? categories[0] : null; + }; + + Categories.getCategoriesData = async function (cids) { + return await Categories.getCategoriesFields(cids, []); + }; + + Categories.getCategoryField = async function (cid, field) { + const category = await Categories.getCategoryFields(cid, [field]); + return category ? category[field] : null; + }; + + Categories.getCategoryFields = async function (cid, fields) { + const categories = await Categories.getCategoriesFields([cid], fields); + return categories ? categories[0] : null; + }; + + Categories.getAllCategoryFields = async function (fields) { + const cids = await Categories.getAllCidsFromSet('categories:cid'); + return await Categories.getCategoriesFields(cids, fields); + }; + + Categories.setCategoryField = async function (cid, field, value) { + await db.setObjectField(`category:${cid}`, field, value); + }; + + Categories.incrementCategoryFieldBy = async function (cid, field, value) { + await db.incrObjectFieldBy(`category:${cid}`, field, value); + }; }; function defaultIntField(category, fields, fieldName, defaultField) { - if (!fields.length || fields.includes(fieldName)) { - const useDefault = !category.hasOwnProperty(fieldName) || - category[fieldName] === null || - category[fieldName] === '' || - !utils.isNumber(category[fieldName]); - - category[fieldName] = useDefault ? meta.config[defaultField] : category[fieldName]; - } + if (fields.length === 0 || fields.includes(fieldName)) { + const useDefault = !category.hasOwnProperty(fieldName) + || category[fieldName] === null + || category[fieldName] === '' + || !utils.isNumber(category[fieldName]); + + category[fieldName] = useDefault ? meta.config[defaultField] : category[fieldName]; + } } function modifyCategory(category, fields) { - if (!category) { - return; - } - - defaultIntField(category, fields, 'minTags', 'minimumTagsPerTopic'); - defaultIntField(category, fields, 'maxTags', 'maximumTagsPerTopic'); - defaultIntField(category, fields, 'postQueue', 'postQueue'); - - db.parseIntFields(category, intFields, fields); - - const escapeFields = ['name', 'color', 'bgColor', 'backgroundImage', 'imageClass', 'class', 'link']; - escapeFields.forEach((field) => { - if (category.hasOwnProperty(field)) { - category[field] = validator.escape(String(category[field] || '')); - } - }); - - if (category.hasOwnProperty('icon')) { - category.icon = category.icon || 'hidden'; - } - - if (category.hasOwnProperty('post_count')) { - category.totalPostCount = category.post_count; - } - - if (category.hasOwnProperty('topic_count')) { - category.totalTopicCount = category.topic_count; - } - - if (category.description) { - category.description = validator.escape(String(category.description)); - category.descriptionParsed = category.descriptionParsed || category.description; - } + if (!category) { + return; + } + + defaultIntField(category, fields, 'minTags', 'minimumTagsPerTopic'); + defaultIntField(category, fields, 'maxTags', 'maximumTagsPerTopic'); + defaultIntField(category, fields, 'postQueue', 'postQueue'); + + db.parseIntFields(category, intFields, fields); + + const escapeFields = ['name', 'color', 'bgColor', 'backgroundImage', 'imageClass', 'class', 'link']; + for (const field of escapeFields) { + if (category.hasOwnProperty(field)) { + category[field] = validator.escape(String(category[field] || '')); + } + } + + if (category.hasOwnProperty('icon')) { + category.icon = category.icon || 'hidden'; + } + + if (category.hasOwnProperty('post_count')) { + category.totalPostCount = category.post_count; + } + + if (category.hasOwnProperty('topic_count')) { + category.totalTopicCount = category.topic_count; + } + + if (category.description) { + category.description = validator.escape(String(category.description)); + category.descriptionParsed = category.descriptionParsed || category.description; + } } diff --git a/src/categories/delete.js b/src/categories/delete.js index a1b91f4..4f18194 100644 --- a/src/categories/delete.js +++ b/src/categories/delete.js @@ -10,82 +10,83 @@ const privileges = require('../privileges'); const cache = require('../cache'); module.exports = function (Categories) { - Categories.purge = async function (cid, uid) { - await batch.processSortedSet(`cid:${cid}:tids`, async (tids) => { - await async.eachLimit(tids, 10, async (tid) => { - await topics.purgePostsAndTopic(tid, uid); - }); - }, { alwaysStartAt: 0 }); + Categories.purge = async function (cid, uid) { + await batch.processSortedSet(`cid:${cid}:tids`, async tids => { + await async.eachLimit(tids, 10, async tid => { + await topics.purgePostsAndTopic(tid, uid); + }); + }, {alwaysStartAt: 0}); - const pinnedTids = await db.getSortedSetRevRange(`cid:${cid}:tids:pinned`, 0, -1); - await async.eachLimit(pinnedTids, 10, async (tid) => { - await topics.purgePostsAndTopic(tid, uid); - }); - const categoryData = await Categories.getCategoryData(cid); - await purgeCategory(cid, categoryData); - plugins.hooks.fire('action:category.delete', { cid: cid, uid: uid, category: categoryData }); - }; + const pinnedTids = await db.getSortedSetRevRange(`cid:${cid}:tids:pinned`, 0, -1); + await async.eachLimit(pinnedTids, 10, async tid => { + await topics.purgePostsAndTopic(tid, uid); + }); + const categoryData = await Categories.getCategoryData(cid); + await purgeCategory(cid, categoryData); + plugins.hooks.fire('action:category.delete', {cid, uid, category: categoryData}); + }; - async function purgeCategory(cid, categoryData) { - const bulkRemove = [['categories:cid', cid]]; - if (categoryData && categoryData.name) { - bulkRemove.push(['categories:name', `${categoryData.name.slice(0, 200).toLowerCase()}:${cid}`]); - } - await db.sortedSetRemoveBulk(bulkRemove); + async function purgeCategory(cid, categoryData) { + const bulkRemove = [['categories:cid', cid]]; + if (categoryData && categoryData.name) { + bulkRemove.push(['categories:name', `${categoryData.name.slice(0, 200).toLowerCase()}:${cid}`]); + } - await removeFromParent(cid); - await deleteTags(cid); - await db.deleteAll([ - `cid:${cid}:tids`, - `cid:${cid}:tids:pinned`, - `cid:${cid}:tids:posts`, - `cid:${cid}:tids:votes`, - `cid:${cid}:tids:views`, - `cid:${cid}:tids:lastposttime`, - `cid:${cid}:recent_tids`, - `cid:${cid}:pids`, - `cid:${cid}:read_by_uid`, - `cid:${cid}:uid:watch:state`, - `cid:${cid}:children`, - `cid:${cid}:tag:whitelist`, - `category:${cid}`, - ]); - const privilegeList = await privileges.categories.getPrivilegeList(); - await groups.destroy(privilegeList.map(privilege => `cid:${cid}:privileges:${privilege}`)); - } + await db.sortedSetRemoveBulk(bulkRemove); - async function removeFromParent(cid) { - const [parentCid, children] = await Promise.all([ - Categories.getCategoryField(cid, 'parentCid'), - db.getSortedSetRange(`cid:${cid}:children`, 0, -1), - ]); + await removeFromParent(cid); + await deleteTags(cid); + await db.deleteAll([ + `cid:${cid}:tids`, + `cid:${cid}:tids:pinned`, + `cid:${cid}:tids:posts`, + `cid:${cid}:tids:votes`, + `cid:${cid}:tids:views`, + `cid:${cid}:tids:lastposttime`, + `cid:${cid}:recent_tids`, + `cid:${cid}:pids`, + `cid:${cid}:read_by_uid`, + `cid:${cid}:uid:watch:state`, + `cid:${cid}:children`, + `cid:${cid}:tag:whitelist`, + `category:${cid}`, + ]); + const privilegeList = await privileges.categories.getPrivilegeList(); + await groups.destroy(privilegeList.map(privilege => `cid:${cid}:privileges:${privilege}`)); + } - const bulkAdd = []; - const childrenKeys = children.map((cid) => { - bulkAdd.push(['cid:0:children', cid, cid]); - return `category:${cid}`; - }); + async function removeFromParent(cid) { + const [parentCid, children] = await Promise.all([ + Categories.getCategoryField(cid, 'parentCid'), + db.getSortedSetRange(`cid:${cid}:children`, 0, -1), + ]); - await Promise.all([ - db.sortedSetRemove(`cid:${parentCid}:children`, cid), - db.setObjectField(childrenKeys, 'parentCid', 0), - db.sortedSetAddBulk(bulkAdd), - ]); + const bulkAdd = []; + const childrenKeys = children.map(cid => { + bulkAdd.push(['cid:0:children', cid, cid]); + return `category:${cid}`; + }); - cache.del([ - 'categories:cid', - 'cid:0:children', - `cid:${parentCid}:children`, - `cid:${parentCid}:children:all`, - `cid:${cid}:children`, - `cid:${cid}:children:all`, - `cid:${cid}:tag:whitelist`, - ]); - } + await Promise.all([ + db.sortedSetRemove(`cid:${parentCid}:children`, cid), + db.setObjectField(childrenKeys, 'parentCid', 0), + db.sortedSetAddBulk(bulkAdd), + ]); - async function deleteTags(cid) { - const tags = await db.getSortedSetMembers(`cid:${cid}:tags`); - await db.deleteAll(tags.map(tag => `cid:${cid}:tag:${tag}:topics`)); - await db.delete(`cid:${cid}:tags`); - } + cache.del([ + 'categories:cid', + 'cid:0:children', + `cid:${parentCid}:children`, + `cid:${parentCid}:children:all`, + `cid:${cid}:children`, + `cid:${cid}:children:all`, + `cid:${cid}:tag:whitelist`, + ]); + } + + async function deleteTags(cid) { + const tags = await db.getSortedSetMembers(`cid:${cid}:tags`); + await db.deleteAll(tags.map(tag => `cid:${cid}:tag:${tag}:topics`)); + await db.delete(`cid:${cid}:tags`); + } }; diff --git a/src/categories/index.js b/src/categories/index.js index 7330369..c359437 100644 --- a/src/categories/index.js +++ b/src/categories/index.js @@ -2,7 +2,6 @@ 'use strict'; const _ = require('lodash'); - const db = require('../database'); const user = require('../user'); const groups = require('../groups'); @@ -25,385 +24,422 @@ require('./watch')(Categories); require('./search')(Categories); Categories.exists = async function (cids) { - return await db.exists( - Array.isArray(cids) ? cids.map(cid => `category:${cid}`) : `category:${cids}` - ); + return await db.exists( + Array.isArray(cids) ? cids.map(cid => `category:${cid}`) : `category:${cids}`, + ); }; Categories.getCategoryById = async function (data) { - const categories = await Categories.getCategories([data.cid], data.uid); - if (!categories[0]) { - return null; - } - const category = categories[0]; - data.category = category; - - const promises = [ - Categories.getCategoryTopics(data), - Categories.getTopicCount(data), - Categories.getWatchState([data.cid], data.uid), - getChildrenTree(category, data.uid), - ]; - - if (category.parentCid) { - promises.push(Categories.getCategoryData(category.parentCid)); - } - const [topics, topicCount, watchState, , parent] = await Promise.all(promises); - - category.topics = topics.topics; - category.nextStart = topics.nextStart; - category.topic_count = topicCount; - category.isWatched = watchState[0] === Categories.watchStates.watching; - category.isNotWatched = watchState[0] === Categories.watchStates.notwatching; - category.isIgnored = watchState[0] === Categories.watchStates.ignoring; - category.parent = parent; - - calculateTopicPostCount(category); - const result = await plugins.hooks.fire('filter:category.get', { - category: category, - ...data, - }); - return result.category; + const categories = await Categories.getCategories([data.cid], data.uid); + if (!categories[0]) { + return null; + } + + const category = categories[0]; + data.category = category; + + const promises = [ + Categories.getCategoryTopics(data), + Categories.getTopicCount(data), + Categories.getWatchState([data.cid], data.uid), + getChildrenTree(category, data.uid), + ]; + + if (category.parentCid) { + promises.push(Categories.getCategoryData(category.parentCid)); + } + + const [topics, topicCount, watchState, , parent] = await Promise.all(promises); + + category.topics = topics.topics; + category.nextStart = topics.nextStart; + category.topic_count = topicCount; + category.isWatched = watchState[0] === Categories.watchStates.watching; + category.isNotWatched = watchState[0] === Categories.watchStates.notwatching; + category.isIgnored = watchState[0] === Categories.watchStates.ignoring; + category.parent = parent; + + calculateTopicPostCount(category); + const result = await plugins.hooks.fire('filter:category.get', { + category, + ...data, + }); + return result.category; }; Categories.getAllCidsFromSet = async function (key) { - let cids = cache.get(key); - if (cids) { - return cids.slice(); - } - - cids = await db.getSortedSetRange(key, 0, -1); - cids = cids.map(cid => parseInt(cid, 10)); - cache.set(key, cids); - return cids.slice(); + let cids = cache.get(key); + if (cids) { + return cids.slice(); + } + + cids = await db.getSortedSetRange(key, 0, -1); + cids = cids.map(cid => Number.parseInt(cid, 10)); + cache.set(key, cids); + return cids.slice(); }; Categories.getAllCategories = async function (uid) { - const cids = await Categories.getAllCidsFromSet('categories:cid'); - return await Categories.getCategories(cids, uid); + const cids = await Categories.getAllCidsFromSet('categories:cid'); + return await Categories.getCategories(cids, uid); }; Categories.getCidsByPrivilege = async function (set, uid, privilege) { - const cids = await Categories.getAllCidsFromSet(set); - return await privileges.categories.filterCids(privilege, cids, uid); + const cids = await Categories.getAllCidsFromSet(set); + return await privileges.categories.filterCids(privilege, cids, uid); }; Categories.getCategoriesByPrivilege = async function (set, uid, privilege) { - const cids = await Categories.getCidsByPrivilege(set, uid, privilege); - return await Categories.getCategories(cids, uid); + const cids = await Categories.getCidsByPrivilege(set, uid, privilege); + return await Categories.getCategories(cids, uid); }; Categories.getModerators = async function (cid) { - const uids = await Categories.getModeratorUids([cid]); - return await user.getUsersFields(uids[0], ['uid', 'username', 'userslug', 'picture']); + const uids = await Categories.getModeratorUids([cid]); + return await user.getUsersFields(uids[0], ['uid', 'username', 'userslug', 'picture']); }; Categories.getModeratorUids = async function (cids) { - const groupNames = cids.reduce((memo, cid) => { - memo.push(`cid:${cid}:privileges:moderate`); - memo.push(`cid:${cid}:privileges:groups:moderate`); - return memo; - }, []); - - const memberSets = await groups.getMembersOfGroups(groupNames); - // Every other set is actually a list of user groups, not uids, so convert those to members - const sets = memberSets.reduce((memo, set, idx) => { - if (idx % 2) { - memo.groupNames.push(set); - } else { - memo.uids.push(set); - } - - return memo; - }, { groupNames: [], uids: [] }); - - const uniqGroups = _.uniq(_.flatten(sets.groupNames)); - const groupUids = await groups.getMembersOfGroups(uniqGroups); - const map = _.zipObject(uniqGroups, groupUids); - const moderatorUids = cids.map( - (cid, index) => _.uniq(sets.uids[index].concat(_.flatten(sets.groupNames[index].map(g => map[g])))) - ); - return moderatorUids; + const groupNames = cids.reduce((memo, cid) => { + memo.push(`cid:${cid}:privileges:moderate`, `cid:${cid}:privileges:groups:moderate`); + return memo; + }, []); + + const memberSets = await groups.getMembersOfGroups(groupNames); + // Every other set is actually a list of user groups, not uids, so convert those to members + const sets = memberSets.reduce((memo, set, index) => { + if (index % 2) { + memo.groupNames.push(set); + } else { + memo.uids.push(set); + } + + return memo; + }, {groupNames: [], uids: []}); + + const uniqGroups = _.uniq(sets.groupNames.flat()); + const groupUids = await groups.getMembersOfGroups(uniqGroups); + const map = _.zipObject(uniqGroups, groupUids); + const moderatorUids = cids.map( + (cid, index) => _.uniq(sets.uids[index].concat(sets.groupNames[index].flatMap(g => map[g]))), + ); + return moderatorUids; }; Categories.getCategories = async function (cids, uid) { - if (!Array.isArray(cids)) { - throw new Error('[[error:invalid-cid]]'); - } - - if (!cids.length) { - return []; - } - uid = parseInt(uid, 10); - - const [categories, tagWhitelist, hasRead] = await Promise.all([ - Categories.getCategoriesData(cids), - Categories.getTagWhitelist(cids), - Categories.hasReadCategories(cids, uid), - ]); - categories.forEach((category, i) => { - if (category) { - category.tagWhitelist = tagWhitelist[i]; - category['unread-class'] = (category.topic_count === 0 || (hasRead[i] && uid !== 0)) ? '' : 'unread'; - } - }); - return categories; + if (!Array.isArray(cids)) { + throw new TypeError('[[error:invalid-cid]]'); + } + + if (cids.length === 0) { + return []; + } + + uid = Number.parseInt(uid, 10); + + const [categories, tagInclude, hasRead] = await Promise.all([ + Categories.getCategoriesData(cids), + Categories.getTagWhitelist(cids), + Categories.hasReadCategories(cids, uid), + ]); + for (const [i, category] of categories.entries()) { + if (category) { + category.tagWhitelist = tagInclude[i]; + category['unread-class'] = (category.topic_count === 0 || (hasRead[i] && uid !== 0)) ? '' : 'unread'; + } + } + + return categories; }; Categories.getTagWhitelist = async function (cids) { - const cachedData = {}; - - const nonCachedCids = cids.filter((cid) => { - const data = cache.get(`cid:${cid}:tag:whitelist`); - const isInCache = data !== undefined; - if (isInCache) { - cachedData[cid] = data; - } - return !isInCache; - }); - - if (!nonCachedCids.length) { - return cids.map(cid => cachedData[cid]); - } - - const keys = nonCachedCids.map(cid => `cid:${cid}:tag:whitelist`); - const data = await db.getSortedSetsMembers(keys); - - nonCachedCids.forEach((cid, index) => { - cachedData[cid] = data[index]; - cache.set(`cid:${cid}:tag:whitelist`, data[index]); - }); - return cids.map(cid => cachedData[cid]); + const cachedData = {}; + + const nonCachedCids = cids.filter(cid => { + const data = cache.get(`cid:${cid}:tag:whitelist`); + const isInCache = data !== undefined; + if (isInCache) { + cachedData[cid] = data; + } + + return !isInCache; + }); + + if (nonCachedCids.length === 0) { + return cids.map(cid => cachedData[cid]); + } + + const keys = nonCachedCids.map(cid => `cid:${cid}:tag:whitelist`); + const data = await db.getSortedSetsMembers(keys); + + for (const [index, cid] of nonCachedCids.entries()) { + cachedData[cid] = data[index]; + cache.set(`cid:${cid}:tag:whitelist`, data[index]); + } + + return cids.map(cid => cachedData[cid]); }; -// remove system tags from tag whitelist for non privileged user -Categories.filterTagWhitelist = function (tagWhitelist, isAdminOrMod) { - const systemTags = (meta.config.systemTags || '').split(','); - if (!isAdminOrMod && systemTags.length) { - return tagWhitelist.filter(tag => !systemTags.includes(tag)); - } - return tagWhitelist; +// Remove system tags from tag whitelist for non privileged user +Categories.filterTagWhitelist = function (tagInclude, isAdminOrModule) { + const systemTags = (meta.config.systemTags || '').split(','); + if (!isAdminOrModule && systemTags.length > 0) { + return tagInclude.filter(tag => !systemTags.includes(tag)); + } + + return tagInclude; }; function calculateTopicPostCount(category) { - if (!category) { - return; - } - - let postCount = category.post_count; - let topicCount = category.topic_count; - if (Array.isArray(category.children)) { - category.children.forEach((child) => { - calculateTopicPostCount(child); - postCount += parseInt(child.totalPostCount, 10) || 0; - topicCount += parseInt(child.totalTopicCount, 10) || 0; - }); - } - - category.totalPostCount = postCount; - category.totalTopicCount = topicCount; + if (!category) { + return; + } + + let postCount = category.post_count; + let topicCount = category.topic_count; + if (Array.isArray(category.children)) { + for (const child of category.children) { + calculateTopicPostCount(child); + postCount += Number.parseInt(child.totalPostCount, 10) || 0; + topicCount += Number.parseInt(child.totalTopicCount, 10) || 0; + } + } + + category.totalPostCount = postCount; + category.totalTopicCount = topicCount; } + Categories.calculateTopicPostCount = calculateTopicPostCount; Categories.getParents = async function (cids) { - const categoriesData = await Categories.getCategoriesFields(cids, ['parentCid']); - const parentCids = categoriesData.filter(c => c && c.parentCid).map(c => c.parentCid); - if (!parentCids.length) { - return cids.map(() => null); - } - const parentData = await Categories.getCategoriesData(parentCids); - const cidToParent = _.zipObject(parentCids, parentData); - return categoriesData.map(category => cidToParent[category.parentCid]); + const categoriesData = await Categories.getCategoriesFields(cids, ['parentCid']); + const parentCids = categoriesData.filter(c => c && c.parentCid).map(c => c.parentCid); + if (parentCids.length === 0) { + return cids.map(() => null); + } + + const parentData = await Categories.getCategoriesData(parentCids); + const cidToParent = _.zipObject(parentCids, parentData); + return categoriesData.map(category => cidToParent[category.parentCid]); }; Categories.getChildren = async function (cids, uid) { - const categoryData = await Categories.getCategoriesFields(cids, ['parentCid']); - const categories = categoryData.map((category, index) => ({ cid: cids[index], parentCid: category.parentCid })); - await Promise.all(categories.map(c => getChildrenTree(c, uid))); - return categories.map(c => c && c.children); + const categoryData = await Categories.getCategoriesFields(cids, ['parentCid']); + const categories = categoryData.map((category, index) => ({cid: cids[index], parentCid: category.parentCid})); + await Promise.all(categories.map(c => getChildrenTree(c, uid))); + return categories.map(c => c && c.children); }; async function getChildrenTree(category, uid) { - let childrenCids = await Categories.getChildrenCids(category.cid); - childrenCids = await privileges.categories.filterCids('find', childrenCids, uid); - childrenCids = childrenCids.filter(cid => parseInt(category.cid, 10) !== parseInt(cid, 10)); - if (!childrenCids.length) { - category.children = []; - return; - } - let childrenData = await Categories.getCategoriesData(childrenCids); - childrenData = childrenData.filter(Boolean); - childrenCids = childrenData.map(child => child.cid); - const hasRead = await Categories.hasReadCategories(childrenCids, uid); - childrenData.forEach((child, i) => { - child['unread-class'] = (child.topic_count === 0 || (hasRead[i] && uid !== 0)) ? '' : 'unread'; - }); - Categories.getTree([category].concat(childrenData), category.parentCid); + let childrenCids = await Categories.getChildrenCids(category.cid); + childrenCids = await privileges.categories.filterCids('find', childrenCids, uid); + childrenCids = childrenCids.filter(cid => Number.parseInt(category.cid, 10) !== Number.parseInt(cid, 10)); + if (childrenCids.length === 0) { + category.children = []; + return; + } + + let childrenData = await Categories.getCategoriesData(childrenCids); + childrenData = childrenData.filter(Boolean); + childrenCids = childrenData.map(child => child.cid); + const hasRead = await Categories.hasReadCategories(childrenCids, uid); + for (const [i, child] of childrenData.entries()) { + child['unread-class'] = (child.topic_count === 0 || (hasRead[i] && uid !== 0)) ? '' : 'unread'; + } + + Categories.getTree([category].concat(childrenData), category.parentCid); } Categories.getChildrenTree = getChildrenTree; Categories.getParentCids = async function (currentCid) { - let cid = currentCid; - const parents = []; - while (parseInt(cid, 10)) { - // eslint-disable-next-line + let cid = currentCid; + const parents = []; + while (Number.parseInt(cid, 10)) { + // eslint-disable-next-line cid = await Categories.getCategoryField(cid, 'parentCid'); - if (cid) { - parents.unshift(cid); - } - } - return parents; + if (cid) { + parents.unshift(cid); + } + } + + return parents; }; Categories.getChildrenCids = async function (rootCid) { - let allCids = []; - async function recursive(keys) { - let childrenCids = await db.getSortedSetRange(keys, 0, -1); - - childrenCids = childrenCids.filter(cid => !allCids.includes(parseInt(cid, 10))); - if (!childrenCids.length) { - return; - } - keys = childrenCids.map(cid => `cid:${cid}:children`); - childrenCids.forEach(cid => allCids.push(parseInt(cid, 10))); - await recursive(keys); - } - const key = `cid:${rootCid}:children`; - const cacheKey = `${key}:all`; - const childrenCids = cache.get(cacheKey); - if (childrenCids) { - return childrenCids.slice(); - } - - await recursive(key); - allCids = _.uniq(allCids); - cache.set(cacheKey, allCids); - return allCids.slice(); + let allCids = []; + async function recursive(keys) { + let childrenCids = await db.getSortedSetRange(keys, 0, -1); + + childrenCids = childrenCids.filter(cid => !allCids.includes(Number.parseInt(cid, 10))); + if (childrenCids.length === 0) { + return; + } + + keys = childrenCids.map(cid => `cid:${cid}:children`); + for (const cid of childrenCids) { + allCids.push(Number.parseInt(cid, 10)); + } + + await recursive(keys); + } + + const key = `cid:${rootCid}:children`; + const cacheKey = `${key}:all`; + const childrenCids = cache.get(cacheKey); + if (childrenCids) { + return childrenCids.slice(); + } + + await recursive(key); + allCids = _.uniq(allCids); + cache.set(cacheKey, allCids); + return allCids.slice(); }; Categories.flattenCategories = function (allCategories, categoryData) { - categoryData.forEach((category) => { - if (category) { - allCategories.push(category); - - if (Array.isArray(category.children) && category.children.length) { - Categories.flattenCategories(allCategories, category.children); - } - } - }); + for (const category of categoryData) { + if (category) { + allCategories.push(category); + + if (Array.isArray(category.children) && category.children.length > 0) { + Categories.flattenCategories(allCategories, category.children); + } + } + } }; /** - * build tree from flat list of categories + * Build tree from flat list of categories * * @param categories {array} flat list of categories * @param parentCid {number} start from 0 to build full tree */ Categories.getTree = function (categories, parentCid) { - parentCid = parentCid || 0; - const cids = categories.map(category => category && category.cid); - const cidToCategory = {}; - const parents = {}; - cids.forEach((cid, index) => { - if (cid) { - categories[index].children = undefined; - cidToCategory[cid] = categories[index]; - parents[cid] = { ...categories[index] }; - } - }); - - const tree = []; - - categories.forEach((category) => { - if (category) { - category.children = category.children || []; - if (!category.cid) { - return; - } - if (!category.hasOwnProperty('parentCid') || category.parentCid === null) { - category.parentCid = 0; - } - if (category.parentCid === parentCid) { - tree.push(category); - category.parent = parents[parentCid]; - } else { - const parent = cidToCategory[category.parentCid]; - if (parent && parent.cid !== category.cid) { - category.parent = parents[category.parentCid]; - parent.children = parent.children || []; - parent.children.push(category); - } - } - } - }); - function sortTree(tree) { - tree.sort((a, b) => { - if (a.order !== b.order) { - return a.order - b.order; - } - return a.cid - b.cid; - }); - tree.forEach((category) => { - if (category && Array.isArray(category.children)) { - sortTree(category.children); - } - }); - } - sortTree(tree); - - categories.forEach(c => calculateTopicPostCount(c)); - return tree; + parentCid ||= 0; + const cids = categories.map(category => category && category.cid); + const cidToCategory = {}; + const parents = {}; + for (const [index, cid] of cids.entries()) { + if (cid) { + categories[index].children = undefined; + cidToCategory[cid] = categories[index]; + parents[cid] = {...categories[index]}; + } + } + + const tree = []; + + for (const category of categories) { + if (category) { + category.children = category.children || []; + if (!category.cid) { + continue; + } + + if (!category.hasOwnProperty('parentCid') || category.parentCid === null) { + category.parentCid = 0; + } + + if (category.parentCid === parentCid) { + tree.push(category); + category.parent = parents[parentCid]; + } else { + const parent = cidToCategory[category.parentCid]; + if (parent && parent.cid !== category.cid) { + category.parent = parents[category.parentCid]; + parent.children = parent.children || []; + parent.children.push(category); + } + } + } + } + + function sortTree(tree) { + tree.sort((a, b) => { + if (a.order !== b.order) { + return a.order - b.order; + } + + return a.cid - b.cid; + }); + for (const category of tree) { + if (category && Array.isArray(category.children)) { + sortTree(category.children); + } + } + } + + sortTree(tree); + + for (const c of categories) { + calculateTopicPostCount(c); + } + + return tree; }; Categories.buildForSelect = async function (uid, privilege, fields) { - const cids = await Categories.getCidsByPrivilege('categories:cid', uid, privilege); - return await getSelectData(cids, fields); + const cids = await Categories.getCidsByPrivilege('categories:cid', uid, privilege); + return await getSelectData(cids, fields); }; Categories.buildForSelectAll = async function (fields) { - const cids = await Categories.getAllCidsFromSet('categories:cid'); - return await getSelectData(cids, fields); + const cids = await Categories.getAllCidsFromSet('categories:cid'); + return await getSelectData(cids, fields); }; async function getSelectData(cids, fields) { - const categoryData = await Categories.getCategoriesData(cids); - const tree = Categories.getTree(categoryData); - return Categories.buildForSelectCategories(tree, fields); + const categoryData = await Categories.getCategoriesData(cids); + const tree = Categories.getTree(categoryData); + return Categories.buildForSelectCategories(tree, fields); } Categories.buildForSelectCategories = function (categories, fields, parentCid) { - function recursive(category, categoriesData, level, depth) { - const bullet = level ? '• ' : ''; - category.value = category.cid; - category.level = level; - category.text = level + bullet + category.name; - category.depth = depth; - categoriesData.push(category); - if (Array.isArray(category.children)) { - category.children.forEach(child => recursive(child, categoriesData, `    ${level}`, depth + 1)); - } - } - parentCid = parentCid || 0; - const categoriesData = []; - - const rootCategories = categories.filter(category => category && category.parentCid === parentCid); - - rootCategories.forEach(category => recursive(category, categoriesData, '', 0)); - - const pickFields = [ - 'cid', 'name', 'level', 'icon', 'parentCid', - 'color', 'bgColor', 'backgroundImage', 'imageClass', - ]; - fields = fields || []; - if (fields.includes('text') && fields.includes('value')) { - return categoriesData.map(category => _.pick(category, fields)); - } - if (fields.length) { - pickFields.push(...fields); - } - - return categoriesData.map(category => _.pick(category, pickFields)); + function recursive(category, categoriesData, level, depth) { + const bullet = level ? '• ' : ''; + category.value = category.cid; + category.level = level; + category.text = level + bullet + category.name; + category.depth = depth; + categoriesData.push(category); + if (Array.isArray(category.children)) { + for (const child of category.children) { + recursive(child, categoriesData, `    ${level}`, depth + 1); + } + } + } + + parentCid ||= 0; + const categoriesData = []; + + const rootCategories = categories.filter(category => category && category.parentCid === parentCid); + + for (const category of rootCategories) { + recursive(category, categoriesData, '', 0); + } + + const pickFields = [ + 'cid', + 'name', + 'level', + 'icon', + 'parentCid', + 'color', + 'bgColor', + 'backgroundImage', + 'imageClass', + ]; + fields ||= []; + if (fields.includes('text') && fields.includes('value')) { + return categoriesData.map(category => _.pick(category, fields)); + } + + if (fields.length > 0) { + pickFields.push(...fields); + } + + return categoriesData.map(category => _.pick(category, pickFields)); }; require('../promisify')(Categories); diff --git a/src/categories/recentreplies.js b/src/categories/recentreplies.js index e945217..1429c7f 100644 --- a/src/categories/recentreplies.js +++ b/src/categories/recentreplies.js @@ -3,7 +3,6 @@ const winston = require('winston'); const _ = require('lodash'); - const db = require('../database'); const posts = require('../posts'); const topics = require('../topics'); @@ -12,201 +11,216 @@ const plugins = require('../plugins'); const batch = require('../batch'); module.exports = function (Categories) { - Categories.getRecentReplies = async function (cid, uid, start, stop) { - // backwards compatibility, treat start as count - if (stop === undefined && start > 0) { - winston.warn('[Categories.getRecentReplies] 3 params deprecated please use Categories.getRecentReplies(cid, uid, start, stop)'); - stop = start - 1; - start = 0; - } - let pids = await db.getSortedSetRevRange(`cid:${cid}:pids`, start, stop); - pids = await privileges.posts.filter('topics:read', pids, uid); - return await posts.getPostSummaryByPids(pids, uid, { stripTags: true }); - }; - - Categories.updateRecentTid = async function (cid, tid) { - const [count, numRecentReplies] = await Promise.all([ - db.sortedSetCard(`cid:${cid}:recent_tids`), - db.getObjectField(`category:${cid}`, 'numRecentReplies'), - ]); - - if (count >= numRecentReplies) { - const data = await db.getSortedSetRangeWithScores(`cid:${cid}:recent_tids`, 0, count - numRecentReplies); - const shouldRemove = !(data.length === 1 && count === 1 && data[0].value === String(tid)); - if (data.length && shouldRemove) { - await db.sortedSetsRemoveRangeByScore([`cid:${cid}:recent_tids`], '-inf', data[data.length - 1].score); - } - } - if (numRecentReplies > 0) { - await db.sortedSetAdd(`cid:${cid}:recent_tids`, Date.now(), tid); - } - await plugins.hooks.fire('action:categories.updateRecentTid', { cid: cid, tid: tid }); - }; - - Categories.updateRecentTidForCid = async function (cid) { - let postData; - let topicData; - let index = 0; - do { - /* eslint-disable no-await-in-loop */ - const pids = await db.getSortedSetRevRange(`cid:${cid}:pids`, index, index); - if (!pids.length) { - return; - } - postData = await posts.getPostFields(pids[0], ['tid', 'deleted']); - - if (postData && postData.tid && !postData.deleted) { - topicData = await topics.getTopicData(postData.tid); - } - index += 1; - } while (!topicData || topicData.deleted || topicData.scheduled); - - if (postData && postData.tid) { - await Categories.updateRecentTid(cid, postData.tid); - } - }; - - Categories.getRecentTopicReplies = async function (categoryData, uid, query) { - if (!Array.isArray(categoryData) || !categoryData.length) { - return; - } - const categoriesToLoad = - categoryData.filter(c => c && c.numRecentReplies && parseInt(c.numRecentReplies, 10) > 0); - let keys = []; - if (plugins.hooks.hasListeners('filter:categories.getRecentTopicReplies')) { - const result = await plugins.hooks.fire('filter:categories.getRecentTopicReplies', { - categories: categoriesToLoad, - uid: uid, - query: query, - keys: [], - }); - keys = result.keys; - } else { - keys = categoriesToLoad.map(c => `cid:${c.cid}:recent_tids`); - } - - const results = await db.getSortedSetsMembers(keys); - let tids = _.uniq(_.flatten(results).filter(Boolean)); - - tids = await privileges.topics.filterTids('topics:read', tids, uid); - const topics = await getTopics(tids, uid); - assignTopicsToCategories(categoryData, topics); - - bubbleUpChildrenPosts(categoryData); - }; - - async function getTopics(tids, uid) { - const topicData = await topics.getTopicsFields( - tids, - ['tid', 'mainPid', 'slug', 'title', 'teaserPid', 'cid', 'postcount'] - ); - topicData.forEach((topic) => { - if (topic) { - topic.teaserPid = topic.teaserPid || topic.mainPid; - } - }); - const cids = _.uniq(topicData.map(t => t && t.cid).filter(cid => parseInt(cid, 10))); - const getToRoot = async () => await Promise.all(cids.map(Categories.getParentCids)); - const [toRoot, teasers] = await Promise.all([ - getToRoot(), - topics.getTeasers(topicData, uid), - ]); - const cidToRoot = _.zipObject(cids, toRoot); - - teasers.forEach((teaser, index) => { - if (teaser) { - teaser.cid = topicData[index].cid; - teaser.parentCids = cidToRoot[teaser.cid]; - teaser.tid = undefined; - teaser.uid = undefined; - teaser.topic = { - slug: topicData[index].slug, - title: topicData[index].title, - }; - } - }); - return teasers.filter(Boolean); - } - - function assignTopicsToCategories(categories, topics) { - categories.forEach((category) => { - if (category) { - category.posts = topics - .filter(t => t.cid && (t.cid === category.cid || t.parentCids.includes(category.cid))) - .sort((a, b) => b.pid - a.pid) - .slice(0, parseInt(category.numRecentReplies, 10)); - } - }); - topics.forEach((t) => { t.parentCids = undefined; }); - } - - function bubbleUpChildrenPosts(categoryData) { - categoryData.forEach((category) => { - if (category) { - if (category.posts.length) { - return; - } - const posts = []; - getPostsRecursive(category, posts); - - posts.sort((a, b) => b.pid - a.pid); - if (posts.length) { - category.posts = [posts[0]]; - } - } - }); - } - - function getPostsRecursive(category, posts) { - if (Array.isArray(category.posts)) { - category.posts.forEach(p => posts.push(p)); - } - - category.children.forEach(child => getPostsRecursive(child, posts)); - } - - // terrible name, should be topics.moveTopicPosts - Categories.moveRecentReplies = async function (tid, oldCid, cid) { - await updatePostCount(tid, oldCid, cid); - const [pids, topicDeleted] = await Promise.all([ - topics.getPids(tid), - topics.getTopicField(tid, 'deleted'), - ]); - - await batch.processArray(pids, async (pids) => { - const postData = await posts.getPostsFields(pids, ['pid', 'deleted', 'uid', 'timestamp', 'upvotes', 'downvotes']); - - const bulkRemove = []; - const bulkAdd = []; - postData.forEach((post) => { - bulkRemove.push([`cid:${oldCid}:uid:${post.uid}:pids`, post.pid]); - bulkRemove.push([`cid:${oldCid}:uid:${post.uid}:pids:votes`, post.pid]); - bulkAdd.push([`cid:${cid}:uid:${post.uid}:pids`, post.timestamp, post.pid]); - if (post.votes > 0 || post.votes < 0) { - bulkAdd.push([`cid:${cid}:uid:${post.uid}:pids:votes`, post.votes, post.pid]); - } - }); - - const postsToReAdd = postData.filter(p => !p.deleted && !topicDeleted); - const timestamps = postsToReAdd.map(p => p && p.timestamp); - await Promise.all([ - db.sortedSetRemove(`cid:${oldCid}:pids`, pids), - db.sortedSetAdd(`cid:${cid}:pids`, timestamps, postsToReAdd.map(p => p.pid)), - db.sortedSetRemoveBulk(bulkRemove), - db.sortedSetAddBulk(bulkAdd), - ]); - }, { batch: 500 }); - }; - - async function updatePostCount(tid, oldCid, newCid) { - const postCount = await topics.getTopicField(tid, 'postcount'); - if (!postCount) { - return; - } - - await Promise.all([ - db.incrObjectFieldBy(`category:${oldCid}`, 'post_count', -postCount), - db.incrObjectFieldBy(`category:${newCid}`, 'post_count', postCount), - ]); - } + Categories.getRecentReplies = async function (cid, uid, start, stop) { + // Backwards compatibility, treat start as count + if (stop === undefined && start > 0) { + winston.warn('[Categories.getRecentReplies] 3 params deprecated please use Categories.getRecentReplies(cid, uid, start, stop)'); + stop = start - 1; + start = 0; + } + + let pids = await db.getSortedSetRevRange(`cid:${cid}:pids`, start, stop); + pids = await privileges.posts.filter('topics:read', pids, uid); + return await posts.getPostSummaryByPids(pids, uid, {stripTags: true}); + }; + + Categories.updateRecentTid = async function (cid, tid) { + const [count, numberRecentReplies] = await Promise.all([ + db.sortedSetCard(`cid:${cid}:recent_tids`), + db.getObjectField(`category:${cid}`, 'numRecentReplies'), + ]); + + if (count >= numberRecentReplies) { + const data = await db.getSortedSetRangeWithScores(`cid:${cid}:recent_tids`, 0, count - numberRecentReplies); + const shouldRemove = !(data.length === 1 && count === 1 && data[0].value === String(tid)); + if (data.length > 0 && shouldRemove) { + await db.sortedSetsRemoveRangeByScore([`cid:${cid}:recent_tids`], '-inf', data.at(-1).score); + } + } + + if (numberRecentReplies > 0) { + await db.sortedSetAdd(`cid:${cid}:recent_tids`, Date.now(), tid); + } + + await plugins.hooks.fire('action:categories.updateRecentTid', {cid, tid}); + }; + + Categories.updateRecentTidForCid = async function (cid) { + let postData; + let topicData; + let index = 0; + do { + /* eslint-disable no-await-in-loop */ + const pids = await db.getSortedSetRevRange(`cid:${cid}:pids`, index, index); + if (pids.length === 0) { + return; + } + + postData = await posts.getPostFields(pids[0], ['tid', 'deleted']); + + if (postData && postData.tid && !postData.deleted) { + topicData = await topics.getTopicData(postData.tid); + } + + index += 1; + } while (!topicData || topicData.deleted || topicData.scheduled); + + if (postData && postData.tid) { + await Categories.updateRecentTid(cid, postData.tid); + } + }; + + Categories.getRecentTopicReplies = async function (categoryData, uid, query) { + if (!Array.isArray(categoryData) || categoryData.length === 0) { + return; + } + + const categoriesToLoad + = categoryData.filter(c => c && c.numRecentReplies && Number.parseInt(c.numRecentReplies, 10) > 0); + let keys = []; + if (plugins.hooks.hasListeners('filter:categories.getRecentTopicReplies')) { + const result = await plugins.hooks.fire('filter:categories.getRecentTopicReplies', { + categories: categoriesToLoad, + uid, + query, + keys: [], + }); + keys = result.keys; + } else { + keys = categoriesToLoad.map(c => `cid:${c.cid}:recent_tids`); + } + + const results = await db.getSortedSetsMembers(keys); + let tids = _.uniq(results.flat().filter(Boolean)); + + tids = await privileges.topics.filterTids('topics:read', tids, uid); + const topics = await getTopics(tids, uid); + assignTopicsToCategories(categoryData, topics); + + bubbleUpChildrenPosts(categoryData); + }; + + async function getTopics(tids, uid) { + const topicData = await topics.getTopicsFields( + tids, + ['tid', 'mainPid', 'slug', 'title', 'teaserPid', 'cid', 'postcount'], + ); + for (const topic of topicData) { + if (topic) { + topic.teaserPid = topic.teaserPid || topic.mainPid; + } + } + + const cids = _.uniq(topicData.map(t => t && t.cid).filter(cid => Number.parseInt(cid, 10))); + const getToRoot = async () => await Promise.all(cids.map(Categories.getParentCids)); + const [toRoot, teasers] = await Promise.all([ + getToRoot(), + topics.getTeasers(topicData, uid), + ]); + const cidToRoot = _.zipObject(cids, toRoot); + + for (const [index, teaser] of teasers.entries()) { + if (teaser) { + teaser.cid = topicData[index].cid; + teaser.parentCids = cidToRoot[teaser.cid]; + teaser.tid = undefined; + teaser.uid = undefined; + teaser.topic = { + slug: topicData[index].slug, + title: topicData[index].title, + }; + } + } + + return teasers.filter(Boolean); + } + + function assignTopicsToCategories(categories, topics) { + for (const category of categories) { + if (category) { + category.posts = topics + .filter(t => t.cid && (t.cid === category.cid || t.parentCids.includes(category.cid))) + .sort((a, b) => b.pid - a.pid) + .slice(0, Number.parseInt(category.numRecentReplies, 10)); + } + } + + for (const t of topics) { + t.parentCids = undefined; + } + } + + function bubbleUpChildrenPosts(categoryData) { + for (const category of categoryData) { + if (category) { + if (category.posts.length > 0) { + continue; + } + + const posts = []; + getPostsRecursive(category, posts); + + posts.sort((a, b) => b.pid - a.pid); + if (posts.length > 0) { + category.posts = [posts[0]]; + } + } + } + } + + function getPostsRecursive(category, posts) { + if (Array.isArray(category.posts)) { + for (const p of category.posts) { + posts.push(p); + } + } + + for (const child of category.children) { + getPostsRecursive(child, posts); + } + } + + // Terrible name, should be topics.moveTopicPosts + Categories.moveRecentReplies = async function (tid, oldCid, cid) { + await updatePostCount(tid, oldCid, cid); + const [pids, topicDeleted] = await Promise.all([ + topics.getPids(tid), + topics.getTopicField(tid, 'deleted'), + ]); + + await batch.processArray(pids, async pids => { + const postData = await posts.getPostsFields(pids, ['pid', 'deleted', 'uid', 'timestamp', 'upvotes', 'downvotes']); + + const bulkRemove = []; + const bulkAdd = []; + for (const post of postData) { + bulkRemove.push([`cid:${oldCid}:uid:${post.uid}:pids`, post.pid], [`cid:${oldCid}:uid:${post.uid}:pids:votes`, post.pid]); + bulkAdd.push([`cid:${cid}:uid:${post.uid}:pids`, post.timestamp, post.pid]); + if (post.votes > 0 || post.votes < 0) { + bulkAdd.push([`cid:${cid}:uid:${post.uid}:pids:votes`, post.votes, post.pid]); + } + } + + const postsToReAdd = postData.filter(p => !p.deleted && !topicDeleted); + const timestamps = postsToReAdd.map(p => p && p.timestamp); + await Promise.all([ + db.sortedSetRemove(`cid:${oldCid}:pids`, pids), + db.sortedSetAdd(`cid:${cid}:pids`, timestamps, postsToReAdd.map(p => p.pid)), + db.sortedSetRemoveBulk(bulkRemove), + db.sortedSetAddBulk(bulkAdd), + ]); + }, {batch: 500}); + }; + + async function updatePostCount(tid, oldCid, newCid) { + const postCount = await topics.getTopicField(tid, 'postcount'); + if (!postCount) { + return; + } + + await Promise.all([ + db.incrObjectFieldBy(`category:${oldCid}`, 'post_count', -postCount), + db.incrObjectFieldBy(`category:${newCid}`, 'post_count', postCount), + ]); + } }; diff --git a/src/categories/search.js b/src/categories/search.js index 1568940..b06b8c8 100644 --- a/src/categories/search.js +++ b/src/categories/search.js @@ -1,81 +1,82 @@ 'use strict'; const _ = require('lodash'); - const privileges = require('../privileges'); const plugins = require('../plugins'); const db = require('../database'); module.exports = function (Categories) { - Categories.search = async function (data) { - const query = data.query || ''; - const page = data.page || 1; - const uid = data.uid || 0; - const paginate = data.hasOwnProperty('paginate') ? data.paginate : true; + Categories.search = async function (data) { + const query = data.query || ''; + const page = data.page || 1; + const uid = data.uid || 0; + const paginate = data.hasOwnProperty('paginate') ? data.paginate : true; + + const startTime = process.hrtime(); + + let cids = await findCids(query, data.hardCap); - const startTime = process.hrtime(); + const result = await plugins.hooks.fire('filter:categories.search', { + data, + cids, + uid, + }); + cids = await privileges.categories.filterCids('find', result.cids, uid); - let cids = await findCids(query, data.hardCap); + const searchResult = { + matchCount: cids.length, + }; - const result = await plugins.hooks.fire('filter:categories.search', { - data: data, - cids: cids, - uid: uid, - }); - cids = await privileges.categories.filterCids('find', result.cids, uid); + if (paginate) { + const resultsPerPage = data.resultsPerPage || 50; + const start = Math.max(0, page - 1) * resultsPerPage; + const stop = start + resultsPerPage; + searchResult.pageCount = Math.ceil(cids.length / resultsPerPage); + cids = cids.slice(start, stop); + } - const searchResult = { - matchCount: cids.length, - }; + const childrenCids = await getChildrenCids(cids, uid); + const uniqCids = _.uniq(cids.concat(childrenCids)); + const categoryData = await Categories.getCategories(uniqCids, uid); - if (paginate) { - const resultsPerPage = data.resultsPerPage || 50; - const start = Math.max(0, page - 1) * resultsPerPage; - const stop = start + resultsPerPage; - searchResult.pageCount = Math.ceil(cids.length / resultsPerPage); - cids = cids.slice(start, stop); - } + Categories.getTree(categoryData, 0); + await Categories.getRecentTopicReplies(categoryData, uid, data.qs); + for (const category of categoryData) { + if (category && Array.isArray(category.children)) { + category.children = category.children.slice(0, category.subCategoriesPerPage); + for (const child of category.children) { + child.children = undefined; + } + } + } - const childrenCids = await getChildrenCids(cids, uid); - const uniqCids = _.uniq(cids.concat(childrenCids)); - const categoryData = await Categories.getCategories(uniqCids, uid); + categoryData.sort((c1, c2) => { + if (c1.parentCid !== c2.parentCid) { + return c1.parentCid - c2.parentCid; + } - Categories.getTree(categoryData, 0); - await Categories.getRecentTopicReplies(categoryData, uid, data.qs); - categoryData.forEach((category) => { - if (category && Array.isArray(category.children)) { - category.children = category.children.slice(0, category.subCategoriesPerPage); - category.children.forEach((child) => { - child.children = undefined; - }); - } - }); + return c1.order - c2.order; + }); + searchResult.timing = (process.elapsedTimeSince(startTime) / 1000).toFixed(2); + searchResult.categories = categoryData.filter(c => cids.includes(c.cid)); + return searchResult; + }; - categoryData.sort((c1, c2) => { - if (c1.parentCid !== c2.parentCid) { - return c1.parentCid - c2.parentCid; - } - return c1.order - c2.order; - }); - searchResult.timing = (process.elapsedTimeSince(startTime) / 1000).toFixed(2); - searchResult.categories = categoryData.filter(c => cids.includes(c.cid)); - return searchResult; - }; + async function findCids(query, hardCap) { + if (!query || String(query).length < 2) { + return []; + } - async function findCids(query, hardCap) { - if (!query || String(query).length < 2) { - return []; - } - const data = await db.getSortedSetScan({ - key: 'categories:name', - match: `*${String(query).toLowerCase()}*`, - limit: hardCap || 500, - }); - return data.map(data => parseInt(data.split(':').pop(), 10)); - } + const data = await db.getSortedSetScan({ + key: 'categories:name', + match: `*${String(query).toLowerCase()}*`, + limit: hardCap || 500, + }); + return data.map(data => Number.parseInt(data.split(':').pop(), 10)); + } - async function getChildrenCids(cids, uid) { - const childrenCids = await Promise.all(cids.map(cid => Categories.getChildrenCids(cid))); - return await privileges.categories.filterCids('find', _.flatten(childrenCids), uid); - } + async function getChildrenCids(cids, uid) { + const childrenCids = await Promise.all(cids.map(cid => Categories.getChildrenCids(cid))); + return await privileges.categories.filterCids('find', childrenCids.flat(), uid); + } }; diff --git a/src/categories/topics.js b/src/categories/topics.js index 1ebf722..026db5d 100644 --- a/src/categories/topics.js +++ b/src/categories/topics.js @@ -8,193 +8,211 @@ const privileges = require('../privileges'); const user = require('../user'); module.exports = function (Categories) { - Categories.getCategoryTopics = async function (data) { - let results = await plugins.hooks.fire('filter:category.topics.prepare', data); - const tids = await Categories.getTopicIds(results); - let topicsData = await topics.getTopicsByTids(tids, data.uid); - topicsData = await user.blocks.filter(data.uid, topicsData); - - if (!topicsData.length) { - return { topics: [], uid: data.uid }; - } - topics.calculateTopicIndices(topicsData, data.start); - - results = await plugins.hooks.fire('filter:category.topics.get', { cid: data.cid, topics: topicsData, uid: data.uid }); - return { topics: results.topics, nextStart: data.stop + 1 }; - }; - - Categories.getTopicIds = async function (data) { - const dataForPinned = { ...data }; - dataForPinned.start = 0; - dataForPinned.stop = -1; - - const [pinnedTids, set, direction] = await Promise.all([ - Categories.getPinnedTids(dataForPinned), - Categories.buildTopicsSortedSet(data), - Categories.getSortedSetRangeDirection(data.sort), - ]); - - const totalPinnedCount = pinnedTids.length; - const pinnedTidsOnPage = pinnedTids.slice(data.start, data.stop !== -1 ? data.stop + 1 : undefined); - const pinnedCountOnPage = pinnedTidsOnPage.length; - const topicsPerPage = data.stop - data.start + 1; - const normalTidsToGet = Math.max(0, topicsPerPage - pinnedCountOnPage); - - if (!normalTidsToGet && data.stop !== -1) { - return pinnedTidsOnPage; - } - - if (plugins.hooks.hasListeners('filter:categories.getTopicIds')) { - const result = await plugins.hooks.fire('filter:categories.getTopicIds', { - tids: [], - data: data, - pinnedTids: pinnedTidsOnPage, - allPinnedTids: pinnedTids, - totalPinnedCount: totalPinnedCount, - normalTidsToGet: normalTidsToGet, - }); - return result && result.tids; - } - - let { start } = data; - if (start > 0 && totalPinnedCount) { - start -= totalPinnedCount - pinnedCountOnPage; - } - - const stop = data.stop === -1 ? data.stop : start + normalTidsToGet - 1; - let normalTids; - const reverse = direction === 'highest-to-lowest'; - if (Array.isArray(set)) { - const weights = set.map((s, index) => (index ? 0 : 1)); - normalTids = await db[reverse ? 'getSortedSetRevIntersect' : 'getSortedSetIntersect']({ sets: set, start: start, stop: stop, weights: weights }); - } else { - normalTids = await db[reverse ? 'getSortedSetRevRange' : 'getSortedSetRange'](set, start, stop); - } - normalTids = normalTids.filter(tid => !pinnedTids.includes(tid)); - return pinnedTidsOnPage.concat(normalTids); - }; - - Categories.getTopicCount = async function (data) { - if (plugins.hooks.hasListeners('filter:categories.getTopicCount')) { - const result = await plugins.hooks.fire('filter:categories.getTopicCount', { - topicCount: data.category.topic_count, - data: data, - }); - return result && result.topicCount; - } - const set = await Categories.buildTopicsSortedSet(data); - if (Array.isArray(set)) { - return await db.sortedSetIntersectCard(set); - } else if (data.targetUid && set) { - return await db.sortedSetCard(set); - } - return data.category.topic_count; - }; - - Categories.buildTopicsSortedSet = async function (data) { - const { cid } = data; - let set = `cid:${cid}:tids`; - const sort = data.sort || (data.settings && data.settings.categoryTopicSort) || meta.config.categoryTopicSort || 'newest_to_oldest'; - - if (sort === 'most_posts') { - set = `cid:${cid}:tids:posts`; - } else if (sort === 'most_votes') { - set = `cid:${cid}:tids:votes`; - } else if (sort === 'most_views') { - set = `cid:${cid}:tids:views`; - } - - if (data.tag) { - if (Array.isArray(data.tag)) { - set = [set].concat(data.tag.map(tag => `tag:${tag}:topics`)); - } else { - set = [set, `tag:${data.tag}:topics`]; - } - } - - if (data.targetUid) { - set = (Array.isArray(set) ? set : [set]).concat([`cid:${cid}:uid:${data.targetUid}:tids`]); - } - - const result = await plugins.hooks.fire('filter:categories.buildTopicsSortedSet', { - set: set, - data: data, - }); - return result && result.set; - }; - - Categories.getSortedSetRangeDirection = async function (sort) { - sort = sort || 'newest_to_oldest'; - const direction = ['newest_to_oldest', 'most_posts', 'most_votes', 'most_views'].includes(sort) ? 'highest-to-lowest' : 'lowest-to-highest'; - const result = await plugins.hooks.fire('filter:categories.getSortedSetRangeDirection', { - sort: sort, - direction: direction, - }); - return result && result.direction; - }; - - Categories.getAllTopicIds = async function (cid, start, stop) { - return await db.getSortedSetRange([`cid:${cid}:tids:pinned`, `cid:${cid}:tids`], start, stop); - }; - - Categories.getPinnedTids = async function (data) { - if (plugins.hooks.hasListeners('filter:categories.getPinnedTids')) { - const result = await plugins.hooks.fire('filter:categories.getPinnedTids', { - pinnedTids: [], - data: data, - }); - return result && result.pinnedTids; - } - const [allPinnedTids, canSchedule] = await Promise.all([ - db.getSortedSetRevRange(`cid:${data.cid}:tids:pinned`, data.start, data.stop), - privileges.categories.can('topics:schedule', data.cid, data.uid), - ]); - const pinnedTids = canSchedule ? allPinnedTids : await filterScheduledTids(allPinnedTids); - - return await topics.tools.checkPinExpiry(pinnedTids); - }; - - Categories.modifyTopicsByPrivilege = function (topics, privileges) { - if (!Array.isArray(topics) || !topics.length || privileges.view_deleted) { - return; - } - - topics.forEach((topic) => { - if (!topic.scheduled && (topic.deleted || topic.private) && !topic.isOwner) { - if (topic.private) { - topic.title = '[[topic:topic_is_private]]'; - } else { - topic.title = '[[topic:topic_is_deleted]]'; - } - if (topic.hasOwnProperty('titleRaw')) { - topic.titleRaw = '[[topic:topic_is_deleted]]'; - } - topic.slug = topic.tid; - topic.teaser = null; - topic.noAnchor = true; - topic.tags = []; - } - }); - }; - - Categories.onNewPostMade = async function (cid, pinned, postData) { - if (!cid || !postData) { - return; - } - const promises = [ - db.sortedSetAdd(`cid:${cid}:pids`, postData.timestamp, postData.pid), - db.incrObjectField(`category:${cid}`, 'post_count'), - ]; - if (!pinned) { - promises.push(db.sortedSetIncrBy(`cid:${cid}:tids:posts`, 1, postData.tid)); - } - await Promise.all(promises); - await Categories.updateRecentTidForCid(cid); - }; - - async function filterScheduledTids(tids) { - const scores = await db.sortedSetScores('topics:scheduled', tids); - const now = Date.now(); - return tids.filter((tid, index) => tid && (!scores[index] || scores[index] <= now)); - } + Categories.getCategoryTopics = async function (data) { + let results = await plugins.hooks.fire('filter:category.topics.prepare', data); + const tids = await Categories.getTopicIds(results); + let topicsData = await topics.getTopicsByTids(tids, data.uid); + topicsData = await user.blocks.filter(data.uid, topicsData); + + if (topicsData.length === 0) { + return {topics: [], uid: data.uid}; + } + + topics.calculateTopicIndices(topicsData, data.start); + + results = await plugins.hooks.fire('filter:category.topics.get', {cid: data.cid, topics: topicsData, uid: data.uid}); + return {topics: results.topics, nextStart: data.stop + 1}; + }; + + Categories.getTopicIds = async function (data) { + const dataForPinned = {...data}; + dataForPinned.start = 0; + dataForPinned.stop = -1; + + const [pinnedTids, set, direction] = await Promise.all([ + Categories.getPinnedTids(dataForPinned), + Categories.buildTopicsSortedSet(data), + Categories.getSortedSetRangeDirection(data.sort), + ]); + + const totalPinnedCount = pinnedTids.length; + const pinnedTidsOnPage = pinnedTids.slice(data.start, data.stop === -1 ? undefined : data.stop + 1); + const pinnedCountOnPage = pinnedTidsOnPage.length; + const topicsPerPage = data.stop - data.start + 1; + const normalTidsToGet = Math.max(0, topicsPerPage - pinnedCountOnPage); + + if (!normalTidsToGet && data.stop !== -1) { + return pinnedTidsOnPage; + } + + if (plugins.hooks.hasListeners('filter:categories.getTopicIds')) { + const result = await plugins.hooks.fire('filter:categories.getTopicIds', { + tids: [], + data, + pinnedTids: pinnedTidsOnPage, + allPinnedTids: pinnedTids, + totalPinnedCount, + normalTidsToGet, + }); + return result && result.tids; + } + + let {start} = data; + if (start > 0 && totalPinnedCount) { + start -= totalPinnedCount - pinnedCountOnPage; + } + + const stop = data.stop === -1 ? data.stop : start + normalTidsToGet - 1; + let normalTids; + const reverse = direction === 'highest-to-lowest'; + if (Array.isArray(set)) { + const weights = set.map((s, index) => (index ? 0 : 1)); + normalTids = await db[reverse ? 'getSortedSetRevIntersect' : 'getSortedSetIntersect']({ + sets: set, start, stop, weights, + }); + } else { + normalTids = await db[reverse ? 'getSortedSetRevRange' : 'getSortedSetRange'](set, start, stop); + } + + normalTids = normalTids.filter(tid => !pinnedTids.includes(tid)); + return pinnedTidsOnPage.concat(normalTids); + }; + + Categories.getTopicCount = async function (data) { + if (plugins.hooks.hasListeners('filter:categories.getTopicCount')) { + const result = await plugins.hooks.fire('filter:categories.getTopicCount', { + topicCount: data.category.topic_count, + data, + }); + return result && result.topicCount; + } + + const set = await Categories.buildTopicsSortedSet(data); + if (Array.isArray(set)) { + return await db.sortedSetIntersectCard(set); + } + + if (data.targetUid && set) { + return await db.sortedSetCard(set); + } + + return data.category.topic_count; + }; + + Categories.buildTopicsSortedSet = async function (data) { + const {cid} = data; + let set = `cid:${cid}:tids`; + const sort = data.sort || (data.settings && data.settings.categoryTopicSort) || meta.config.categoryTopicSort || 'newest_to_oldest'; + + switch (sort) { + case 'most_posts': { + set = `cid:${cid}:tids:posts`; + + break; + } + + case 'most_votes': { + set = `cid:${cid}:tids:votes`; + + break; + } + + case 'most_views': { + set = `cid:${cid}:tids:views`; + + break; + } + // No default + } + + if (data.tag) { + set = Array.isArray(data.tag) ? [set].concat(data.tag.map(tag => `tag:${tag}:topics`)) : [set, `tag:${data.tag}:topics`]; + } + + if (data.targetUid) { + set = (Array.isArray(set) ? set : [set]).concat([`cid:${cid}:uid:${data.targetUid}:tids`]); + } + + const result = await plugins.hooks.fire('filter:categories.buildTopicsSortedSet', { + set, + data, + }); + return result && result.set; + }; + + Categories.getSortedSetRangeDirection = async function (sort) { + sort ||= 'newest_to_oldest'; + const direction = ['newest_to_oldest', 'most_posts', 'most_votes', 'most_views'].includes(sort) ? 'highest-to-lowest' : 'lowest-to-highest'; + const result = await plugins.hooks.fire('filter:categories.getSortedSetRangeDirection', { + sort, + direction, + }); + return result && result.direction; + }; + + Categories.getAllTopicIds = async function (cid, start, stop) { + return await db.getSortedSetRange([`cid:${cid}:tids:pinned`, `cid:${cid}:tids`], start, stop); + }; + + Categories.getPinnedTids = async function (data) { + if (plugins.hooks.hasListeners('filter:categories.getPinnedTids')) { + const result = await plugins.hooks.fire('filter:categories.getPinnedTids', { + pinnedTids: [], + data, + }); + return result && result.pinnedTids; + } + + const [allPinnedTids, canSchedule] = await Promise.all([ + db.getSortedSetRevRange(`cid:${data.cid}:tids:pinned`, data.start, data.stop), + privileges.categories.can('topics:schedule', data.cid, data.uid), + ]); + const pinnedTids = canSchedule ? allPinnedTids : await filterScheduledTids(allPinnedTids); + + return await topics.tools.checkPinExpiry(pinnedTids); + }; + + Categories.modifyTopicsByPrivilege = function (topics, privileges) { + if (!Array.isArray(topics) || topics.length === 0 || privileges.view_deleted) { + return; + } + + for (const topic of topics) { + if (!topic.scheduled && (topic.deleted || topic.private) && !topic.isOwner) { + topic.title = topic.private ? '[[topic:topic_is_private]]' : '[[topic:topic_is_deleted]]'; + + if (topic.hasOwnProperty('titleRaw')) { + topic.titleRaw = '[[topic:topic_is_deleted]]'; + } + + topic.slug = topic.tid; + topic.teaser = null; + topic.noAnchor = true; + topic.tags = []; + } + } + }; + + Categories.onNewPostMade = async function (cid, pinned, postData) { + if (!cid || !postData) { + return; + } + + const promises = [ + db.sortedSetAdd(`cid:${cid}:pids`, postData.timestamp, postData.pid), + db.incrObjectField(`category:${cid}`, 'post_count'), + ]; + if (!pinned) { + promises.push(db.sortedSetIncrBy(`cid:${cid}:tids:posts`, 1, postData.tid)); + } + + await Promise.all(promises); + await Categories.updateRecentTidForCid(cid); + }; + + async function filterScheduledTids(tids) { + const scores = await db.sortedSetScores('topics:scheduled', tids); + const now = Date.now(); + return tids.filter((tid, index) => tid && (!scores[index] || scores[index] <= now)); + } }; diff --git a/src/categories/unread.js b/src/categories/unread.js index 8d69e0e..4806102 100644 --- a/src/categories/unread.js +++ b/src/categories/unread.js @@ -3,36 +3,39 @@ const db = require('../database'); module.exports = function (Categories) { - Categories.markAsRead = async function (cids, uid) { - if (!Array.isArray(cids) || !cids.length || parseInt(uid, 10) <= 0) { - return; - } - let keys = cids.map(cid => `cid:${cid}:read_by_uid`); - const hasRead = await db.isMemberOfSets(keys, uid); - keys = keys.filter((key, index) => !hasRead[index]); - await db.setsAdd(keys, uid); - }; - - Categories.markAsUnreadForAll = async function (cid) { - if (!parseInt(cid, 10)) { - return; - } - await db.delete(`cid:${cid}:read_by_uid`); - }; - - Categories.hasReadCategories = async function (cids, uid) { - if (parseInt(uid, 10) <= 0) { - return cids.map(() => false); - } - - const sets = cids.map(cid => `cid:${cid}:read_by_uid`); - return await db.isMemberOfSets(sets, uid); - }; - - Categories.hasReadCategory = async function (cid, uid) { - if (parseInt(uid, 10) <= 0) { - return false; - } - return await db.isSetMember(`cid:${cid}:read_by_uid`, uid); - }; + Categories.markAsRead = async function (cids, uid) { + if (!Array.isArray(cids) || cids.length === 0 || Number.parseInt(uid, 10) <= 0) { + return; + } + + let keys = cids.map(cid => `cid:${cid}:read_by_uid`); + const hasRead = await db.isMemberOfSets(keys, uid); + keys = keys.filter((key, index) => !hasRead[index]); + await db.setsAdd(keys, uid); + }; + + Categories.markAsUnreadForAll = async function (cid) { + if (!Number.parseInt(cid, 10)) { + return; + } + + await db.delete(`cid:${cid}:read_by_uid`); + }; + + Categories.hasReadCategories = async function (cids, uid) { + if (Number.parseInt(uid, 10) <= 0) { + return cids.map(() => false); + } + + const sets = cids.map(cid => `cid:${cid}:read_by_uid`); + return await db.isMemberOfSets(sets, uid); + }; + + Categories.hasReadCategory = async function (cid, uid) { + if (Number.parseInt(uid, 10) <= 0) { + return false; + } + + return await db.isSetMember(`cid:${cid}:read_by_uid`, uid); + }; }; diff --git a/src/categories/update.js b/src/categories/update.js index 87a3951..107c4e1 100644 --- a/src/categories/update.js +++ b/src/categories/update.js @@ -9,137 +9,149 @@ const plugins = require('../plugins'); const cache = require('../cache'); module.exports = function (Categories) { - Categories.update = async function (modified) { - const cids = Object.keys(modified); - await Promise.all(cids.map(cid => updateCategory(cid, modified[cid]))); - return cids; - }; - - async function updateCategory(cid, modifiedFields) { - const exists = await Categories.exists(cid); - if (!exists) { - return; - } - - if (modifiedFields.hasOwnProperty('name')) { - const translated = await translator.translate(modifiedFields.name); - modifiedFields.slug = `${cid}/${slugify(translated)}`; - } - const result = await plugins.hooks.fire('filter:category.update', { cid: cid, category: modifiedFields }); - - const { category } = result; - const fields = Object.keys(category); - // move parent to front, so its updated first - const parentCidIndex = fields.indexOf('parentCid'); - if (parentCidIndex !== -1 && fields.length > 1) { - fields.splice(0, 0, fields.splice(parentCidIndex, 1)[0]); - } - - for (const key of fields) { - // eslint-disable-next-line no-await-in-loop - await updateCategoryField(cid, key, category[key]); - } - plugins.hooks.fire('action:category.update', { cid: cid, modified: category }); - } - - async function updateCategoryField(cid, key, value) { - if (key === 'parentCid') { - return await updateParent(cid, value); - } else if (key === 'tagWhitelist') { - return await updateTagWhitelist(cid, value); - } else if (key === 'name') { - return await updateName(cid, value); - } else if (key === 'order') { - return await updateOrder(cid, value); - } - - await db.setObjectField(`category:${cid}`, key, value); - if (key === 'description') { - await Categories.parseDescription(cid, value); - } - } - - async function updateParent(cid, newParent) { - newParent = parseInt(newParent, 10) || 0; - if (parseInt(cid, 10) === newParent) { - throw new Error('[[error:cant-set-self-as-parent]]'); - } - const childrenCids = await Categories.getChildrenCids(cid); - if (childrenCids.includes(newParent)) { - throw new Error('[[error:cant-set-child-as-parent]]'); - } - const categoryData = await Categories.getCategoryFields(cid, ['parentCid', 'order']); - const oldParent = categoryData.parentCid; - if (oldParent === newParent) { - return; - } - await Promise.all([ - db.sortedSetRemove(`cid:${oldParent}:children`, cid), - db.sortedSetAdd(`cid:${newParent}:children`, categoryData.order, cid), - db.setObjectField(`category:${cid}`, 'parentCid', newParent), - ]); - - cache.del([ - `cid:${oldParent}:children`, - `cid:${newParent}:children`, - `cid:${oldParent}:children:all`, - `cid:${newParent}:children:all`, - ]); - } - - async function updateTagWhitelist(cid, tags) { - tags = tags.split(',').map(tag => utils.cleanUpTag(tag, meta.config.maximumTagLength)) - .filter(Boolean); - await db.delete(`cid:${cid}:tag:whitelist`); - const scores = tags.map((tag, index) => index); - await db.sortedSetAdd(`cid:${cid}:tag:whitelist`, scores, tags); - cache.del(`cid:${cid}:tag:whitelist`); - } - - async function updateOrder(cid, order) { - const parentCid = await Categories.getCategoryField(cid, 'parentCid'); - await db.sortedSetsAdd('categories:cid', order, cid); - - const childrenCids = await db.getSortedSetRange( - `cid:${parentCid}:children`, 0, -1 - ); - - const currentIndex = childrenCids.indexOf(String(cid)); - if (currentIndex === -1) { - throw new Error('[[error:no-category]]'); - } - // moves cid to index order - 1 in the array - if (childrenCids.length > 1) { - childrenCids.splice(Math.max(0, order - 1), 0, childrenCids.splice(currentIndex, 1)[0]); - } - - // recalculate orders from array indices - await db.sortedSetAdd( - `cid:${parentCid}:children`, - childrenCids.map((cid, index) => index + 1), - childrenCids - ); - - await db.setObjectBulk( - childrenCids.map((cid, index) => [`category:${cid}`, { order: index + 1 }]) - ); - - cache.del([ - 'categories:cid', - `cid:${parentCid}:children`, - `cid:${parentCid}:children:all`, - ]); - } - - Categories.parseDescription = async function (cid, description) { - const parsedDescription = await plugins.hooks.fire('filter:parse.raw', description); - await Categories.setCategoryField(cid, 'descriptionParsed', parsedDescription); - }; - - async function updateName(cid, newName) { - const oldName = await Categories.getCategoryField(cid, 'name'); - await db.sortedSetRemove('categories:name', `${oldName.slice(0, 200).toLowerCase()}:${cid}`); - await db.sortedSetAdd('categories:name', 0, `${newName.slice(0, 200).toLowerCase()}:${cid}`); - await db.setObjectField(`category:${cid}`, 'name', newName); - } + Categories.update = async function (modified) { + const cids = Object.keys(modified); + await Promise.all(cids.map(cid => updateCategory(cid, modified[cid]))); + return cids; + }; + + async function updateCategory(cid, modifiedFields) { + const exists = await Categories.exists(cid); + if (!exists) { + return; + } + + if (modifiedFields.hasOwnProperty('name')) { + const translated = await translator.translate(modifiedFields.name); + modifiedFields.slug = `${cid}/${slugify(translated)}`; + } + + const result = await plugins.hooks.fire('filter:category.update', {cid, category: modifiedFields}); + + const {category} = result; + const fields = Object.keys(category); + // Move parent to front, so its updated first + const parentCidIndex = fields.indexOf('parentCid'); + if (parentCidIndex !== -1 && fields.length > 1) { + fields.splice(0, 0, fields.splice(parentCidIndex, 1)[0]); + } + + for (const key of fields) { + // eslint-disable-next-line no-await-in-loop + await updateCategoryField(cid, key, category[key]); + } + + plugins.hooks.fire('action:category.update', {cid, modified: category}); + } + + async function updateCategoryField(cid, key, value) { + if (key === 'parentCid') { + return await updateParent(cid, value); + } + + if (key === 'tagWhitelist') { + return await updateTagInclude(cid, value); + } + + if (key === 'name') { + return await updateName(cid, value); + } + + if (key === 'order') { + return await updateOrder(cid, value); + } + + await db.setObjectField(`category:${cid}`, key, value); + if (key === 'description') { + await Categories.parseDescription(cid, value); + } + } + + async function updateParent(cid, newParent) { + newParent = Number.parseInt(newParent, 10) || 0; + if (Number.parseInt(cid, 10) === newParent) { + throw new Error('[[error:cant-set-self-as-parent]]'); + } + + const childrenCids = await Categories.getChildrenCids(cid); + if (childrenCids.includes(newParent)) { + throw new Error('[[error:cant-set-child-as-parent]]'); + } + + const categoryData = await Categories.getCategoryFields(cid, ['parentCid', 'order']); + const oldParent = categoryData.parentCid; + if (oldParent === newParent) { + return; + } + + await Promise.all([ + db.sortedSetRemove(`cid:${oldParent}:children`, cid), + db.sortedSetAdd(`cid:${newParent}:children`, categoryData.order, cid), + db.setObjectField(`category:${cid}`, 'parentCid', newParent), + ]); + + cache.del([ + `cid:${oldParent}:children`, + `cid:${newParent}:children`, + `cid:${oldParent}:children:all`, + `cid:${newParent}:children:all`, + ]); + } + + async function updateTagInclude(cid, tags) { + tags = tags.split(',').map(tag => utils.cleanUpTag(tag, meta.config.maximumTagLength)) + .filter(Boolean); + await db.delete(`cid:${cid}:tag:whitelist`); + const scores = tags.map((tag, index) => index); + await db.sortedSetAdd(`cid:${cid}:tag:whitelist`, scores, tags); + cache.del(`cid:${cid}:tag:whitelist`); + } + + async function updateOrder(cid, order) { + const parentCid = await Categories.getCategoryField(cid, 'parentCid'); + await db.sortedSetsAdd('categories:cid', order, cid); + + const childrenCids = await db.getSortedSetRange( + `cid:${parentCid}:children`, 0, -1, + ); + + const currentIndex = childrenCids.indexOf(String(cid)); + if (currentIndex === -1) { + throw new Error('[[error:no-category]]'); + } + + // Moves cid to index order - 1 in the array + if (childrenCids.length > 1) { + childrenCids.splice(Math.max(0, order - 1), 0, childrenCids.splice(currentIndex, 1)[0]); + } + + // Recalculate orders from array indices + await db.sortedSetAdd( + `cid:${parentCid}:children`, + childrenCids.map((cid, index) => index + 1), + childrenCids, + ); + + await db.setObjectBulk( + childrenCids.map((cid, index) => [`category:${cid}`, {order: index + 1}]), + ); + + cache.del([ + 'categories:cid', + `cid:${parentCid}:children`, + `cid:${parentCid}:children:all`, + ]); + } + + Categories.parseDescription = async function (cid, description) { + const parsedDescription = await plugins.hooks.fire('filter:parse.raw', description); + await Categories.setCategoryField(cid, 'descriptionParsed', parsedDescription); + }; + + async function updateName(cid, newName) { + const oldName = await Categories.getCategoryField(cid, 'name'); + await db.sortedSetRemove('categories:name', `${oldName.slice(0, 200).toLowerCase()}:${cid}`); + await db.sortedSetAdd('categories:name', 0, `${newName.slice(0, 200).toLowerCase()}:${cid}`); + await db.setObjectField(`category:${cid}`, 'name', newName); + } }; diff --git a/src/categories/watch.js b/src/categories/watch.js index c26149d..4103456 100644 --- a/src/categories/watch.js +++ b/src/categories/watch.js @@ -4,51 +4,54 @@ const db = require('../database'); const user = require('../user'); module.exports = function (Categories) { - Categories.watchStates = { - ignoring: 1, - notwatching: 2, - watching: 3, - }; - - Categories.isIgnored = async function (cids, uid) { - if (!(parseInt(uid, 10) > 0)) { - return cids.map(() => false); - } - const states = await Categories.getWatchState(cids, uid); - return states.map(state => state === Categories.watchStates.ignoring); - }; - - Categories.getWatchState = async function (cids, uid) { - if (!(parseInt(uid, 10) > 0)) { - return cids.map(() => Categories.watchStates.notwatching); - } - if (!Array.isArray(cids) || !cids.length) { - return []; - } - const keys = cids.map(cid => `cid:${cid}:uid:watch:state`); - const [userSettings, states] = await Promise.all([ - user.getSettings(uid), - db.sortedSetsScore(keys, uid), - ]); - return states.map(state => state || Categories.watchStates[userSettings.categoryWatchState]); - }; - - Categories.getIgnorers = async function (cid, start, stop) { - const count = (stop === -1) ? -1 : (stop - start + 1); - return await db.getSortedSetRevRangeByScore(`cid:${cid}:uid:watch:state`, start, count, Categories.watchStates.ignoring, Categories.watchStates.ignoring); - }; - - Categories.filterIgnoringUids = async function (cid, uids) { - const states = await Categories.getUidsWatchStates(cid, uids); - const readingUids = uids.filter((uid, index) => uid && states[index] !== Categories.watchStates.ignoring); - return readingUids; - }; - - Categories.getUidsWatchStates = async function (cid, uids) { - const [userSettings, states] = await Promise.all([ - user.getMultipleUserSettings(uids), - db.sortedSetScores(`cid:${cid}:uid:watch:state`, uids), - ]); - return states.map((state, index) => state || Categories.watchStates[userSettings[index].categoryWatchState]); - }; + Categories.watchStates = { + ignoring: 1, + notwatching: 2, + watching: 3, + }; + + Categories.isIgnored = async function (cids, uid) { + if (!(Number.parseInt(uid, 10) > 0)) { + return cids.map(() => false); + } + + const states = await Categories.getWatchState(cids, uid); + return states.map(state => state === Categories.watchStates.ignoring); + }; + + Categories.getWatchState = async function (cids, uid) { + if (!(Number.parseInt(uid, 10) > 0)) { + return cids.map(() => Categories.watchStates.notwatching); + } + + if (!Array.isArray(cids) || cids.length === 0) { + return []; + } + + const keys = cids.map(cid => `cid:${cid}:uid:watch:state`); + const [userSettings, states] = await Promise.all([ + user.getSettings(uid), + db.sortedSetsScore(keys, uid), + ]); + return states.map(state => state || Categories.watchStates[userSettings.categoryWatchState]); + }; + + Categories.getIgnorers = async function (cid, start, stop) { + const count = (stop === -1) ? -1 : (stop - start + 1); + return await db.getSortedSetRevRangeByScore(`cid:${cid}:uid:watch:state`, start, count, Categories.watchStates.ignoring, Categories.watchStates.ignoring); + }; + + Categories.filterIgnoringUids = async function (cid, uids) { + const states = await Categories.getUidsWatchStates(cid, uids); + const readingUids = uids.filter((uid, index) => uid && states[index] !== Categories.watchStates.ignoring); + return readingUids; + }; + + Categories.getUidsWatchStates = async function (cid, uids) { + const [userSettings, states] = await Promise.all([ + user.getMultipleUserSettings(uids), + db.sortedSetScores(`cid:${cid}:uid:watch:state`, uids), + ]); + return states.map((state, index) => state || Categories.watchStates[userSettings[index].categoryWatchState]); + }; }; diff --git a/src/cli/colors.js b/src/cli/colors.js index da17202..b878475 100644 --- a/src/cli/colors.js +++ b/src/cli/colors.js @@ -1,160 +1,166 @@ 'use strict'; -// override commander help formatting functions +// Override commander help formatting functions // to include color styling in the output // so the CLI looks nice -const { Command } = require('commander'); +const {Command} = require('commander'); const chalk = require('chalk'); const colors = [ - // depth = 0, top-level command - { command: 'yellow', option: 'cyan', arg: 'magenta' }, - // depth = 1, second-level commands - { 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' }, + // Depth = 0, top-level command + {command: 'yellow', option: 'cyan', arg: 'magenta'}, + // Depth = 1, second-level commands + {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) { - const nameOutput = arg.name() + (arg.variadic === true ? '...' : ''); +function humanReadableArgumentName(argument) { + const nameOutput = argument.name() + (argument.variadic === true ? '...' : ''); - return arg.required ? `<${nameOutput}>` : `[${nameOutput}]`; + return argument.required ? `<${nameOutput}>` : `[${nameOutput}]`; } function getControlCharacterSpaces(term) { - const matches = term.match(/.\[\d+m/g); - return matches ? matches.length * 5 : 0; + const matches = term.match(/.\[\d+m/g); + return matches ? matches.length * 5 : 0; } -// get depth of command +// Get depth of command // 0 = top, 1 = subcommand of top, etc Command.prototype.depth = function () { - if (this._depth === undefined) { - let depth = 0; - let { parent } = this; - while (parent) { depth += 1; parent = parent.parent; } - - this._depth = depth; - } - return this._depth; + if (this._depth === undefined) { + let depth = 0; + let {parent} = this; + while (parent) { + depth += 1; parent = parent.parent; + } + + this._depth = depth; + } + + return this._depth; }; module.exports = { - commandUsage(cmd) { - const depth = cmd.depth(); - - // Usage - let cmdName = cmd._name; - if (cmd._aliases[0]) { - cmdName = `${cmdName}|${cmd._aliases[0]}`; - } - let parentCmdNames = ''; - let parentCmd = cmd.parent; - let parentDepth = depth - 1; - while (parentCmd) { - parentCmdNames = `${chalk[colors[parentDepth].command](parentCmd.name())} ${parentCmdNames}`; - - parentCmd = parentCmd.parent; - parentDepth -= 1; - } - - // from Command.prototype.usage() - const args = cmd._args.map(arg => chalk[colors[depth].arg](humanReadableArgName(arg))); - const cmdUsage = [].concat( - (cmd.options.length || cmd._hasHelpOption ? chalk[colors[depth].option]('[options]') : []), - (cmd.commands.length ? chalk[colors[depth + 1].command]('[command]') : []), - (cmd._args.length ? args : []) - ).join(' '); - - return `${parentCmdNames}${chalk[colors[depth].command](cmdName)} ${cmdUsage}`; - }, - subcommandTerm(cmd) { - const depth = cmd.depth(); - - // Legacy. Ignores custom usage string, and nested commands. - const args = cmd._args.map(arg => humanReadableArgName(arg)).join(' '); - return chalk[colors[depth].command](cmd._name + ( - cmd._aliases[0] ? `|${cmd._aliases[0]}` : '' - )) + - chalk[colors[depth].option](cmd.options.length ? ' [options]' : '') + // simplistic check for non-help option - chalk[colors[depth].arg](args ? ` ${args}` : ''); - }, - longestOptionTermLength(cmd, helper) { - return helper.visibleOptions(cmd).reduce((max, option) => Math.max( - max, - helper.optionTerm(option).length - getControlCharacterSpaces(helper.optionTerm(option)) - ), 0); - }, - longestSubcommandTermLength(cmd, helper) { - return helper.visibleCommands(cmd).reduce((max, command) => Math.max( - max, - helper.subcommandTerm(command).length - getControlCharacterSpaces(helper.subcommandTerm(command)) - ), 0); - }, - longestArgumentTermLength(cmd, helper) { - return helper.visibleArguments(cmd).reduce((max, argument) => Math.max( - max, - helper.argumentTerm(argument).length - getControlCharacterSpaces(helper.argumentTerm(argument)) - ), 0); - }, - formatHelp(cmd, helper) { - const depth = cmd.depth(); - - const termWidth = helper.padWidth(cmd, helper); - const helpWidth = helper.helpWidth || 80; - const itemIndentWidth = 2; - const itemSeparatorWidth = 2; // between term and description - function formatItem(term, description) { - const padding = ' '.repeat((termWidth + itemSeparatorWidth) - (term.length - getControlCharacterSpaces(term))); - if (description) { - const fullText = `${term}${padding}${description}`; - return helper.wrap(fullText, helpWidth - itemIndentWidth, termWidth + itemSeparatorWidth); - } - return term; - } - function formatList(textArray) { - return textArray.join('\n').replace(/^/gm, ' '.repeat(itemIndentWidth)); - } - - // Usage - let output = [`Usage: ${helper.commandUsage(cmd)}`, '']; - - // Description - const commandDescription = helper.commandDescription(cmd); - if (commandDescription.length > 0) { - output = output.concat([commandDescription, '']); - } - - // Arguments - const argumentList = helper.visibleArguments(cmd).map(argument => formatItem( - chalk[colors[depth].arg](argument.term), - argument.description - )); - if (argumentList.length > 0) { - output = output.concat(['Arguments:', formatList(argumentList), '']); - } - - // Options - const optionList = helper.visibleOptions(cmd).map(option => formatItem( - chalk[colors[depth].option](helper.optionTerm(option)), - helper.optionDescription(option) - )); - if (optionList.length > 0) { - output = output.concat(['Options:', formatList(optionList), '']); - } - - // Commands - const commandList = helper.visibleCommands(cmd).map(cmd => formatItem( - helper.subcommandTerm(cmd), - helper.subcommandDescription(cmd) - )); - if (commandList.length > 0) { - output = output.concat(['Commands:', formatList(commandList), '']); - } - - return output.join('\n'); - }, + commandUsage(cmd) { + const depth = cmd.depth(); + + // Usage + let cmdName = cmd._name; + if (cmd._aliases[0]) { + cmdName = `${cmdName}|${cmd._aliases[0]}`; + } + + let parentCmdNames = ''; + let parentCmd = cmd.parent; + let parentDepth = depth - 1; + while (parentCmd) { + parentCmdNames = `${chalk[colors[parentDepth].command](parentCmd.name())} ${parentCmdNames}`; + + parentCmd = parentCmd.parent; + parentDepth -= 1; + } + + // From Command.prototype.usage() + const arguments_ = cmd._args.map(argument => chalk[colors[depth].arg](humanReadableArgumentName(argument))); + const cmdUsage = [].concat( + (cmd.options.length > 0 || cmd._hasHelpOption ? chalk[colors[depth].option]('[options]') : []), + (cmd.commands.length > 0 ? chalk[colors[depth + 1].command]('[command]') : []), + (cmd._args.length > 0 ? arguments_ : []), + ).join(' '); + + return `${parentCmdNames}${chalk[colors[depth].command](cmdName)} ${cmdUsage}`; + }, + subcommandTerm(cmd) { + const depth = cmd.depth(); + + // Legacy. Ignores custom usage string, and nested commands. + const arguments_ = cmd._args.map(argument => humanReadableArgumentName(argument)).join(' '); + return chalk[colors[depth].command](cmd._name + ( + cmd._aliases[0] ? `|${cmd._aliases[0]}` : '' + )) + + chalk[colors[depth].option](cmd.options.length > 0 ? ' [options]' : '') // Simplistic check for non-help option + + chalk[colors[depth].arg](arguments_ ? ` ${arguments_}` : ''); + }, + longestOptionTermLength(cmd, helper) { + return helper.visibleOptions(cmd).reduce((max, option) => Math.max( + max, + helper.optionTerm(option).length - getControlCharacterSpaces(helper.optionTerm(option)), + ), 0); + }, + longestSubcommandTermLength(cmd, helper) { + return helper.visibleCommands(cmd).reduce((max, command) => Math.max( + max, + helper.subcommandTerm(command).length - getControlCharacterSpaces(helper.subcommandTerm(command)), + ), 0); + }, + longestArgumentTermLength(cmd, helper) { + return helper.visibleArguments(cmd).reduce((max, argument) => Math.max( + max, + helper.argumentTerm(argument).length - getControlCharacterSpaces(helper.argumentTerm(argument)), + ), 0); + }, + formatHelp(cmd, helper) { + const depth = cmd.depth(); + + const termWidth = helper.padWidth(cmd, helper); + const helpWidth = helper.helpWidth || 80; + const itemIndentWidth = 2; + const itemSeparatorWidth = 2; // Between term and description + function formatItem(term, description) { + const padding = ' '.repeat((termWidth + itemSeparatorWidth) - (term.length - getControlCharacterSpaces(term))); + if (description) { + const fullText = `${term}${padding}${description}`; + return helper.wrap(fullText, helpWidth - itemIndentWidth, termWidth + itemSeparatorWidth); + } + + return term; + } + + function formatList(textArray) { + return textArray.join('\n').replaceAll(/^/gm, ' '.repeat(itemIndentWidth)); + } + + // Usage + let output = [`Usage: ${helper.commandUsage(cmd)}`, '']; + + // Description + const commandDescription = helper.commandDescription(cmd); + if (commandDescription.length > 0) { + output = output.concat([commandDescription, '']); + } + + // Arguments + const argumentList = helper.visibleArguments(cmd).map(argument => formatItem( + chalk[colors[depth].arg](argument.term), + argument.description, + )); + if (argumentList.length > 0) { + output = output.concat(['Arguments:', formatList(argumentList), '']); + } + + // Options + const optionList = helper.visibleOptions(cmd).map(option => formatItem( + chalk[colors[depth].option](helper.optionTerm(option)), + helper.optionDescription(option), + )); + if (optionList.length > 0) { + output = output.concat(['Options:', formatList(optionList), '']); + } + + // Commands + const commandList = helper.visibleCommands(cmd).map(cmd => formatItem( + helper.subcommandTerm(cmd), + helper.subcommandDescription(cmd), + )); + if (commandList.length > 0) { + output = output.concat(['Commands:', formatList(commandList), '']); + } + + return output.join('\n'); + }, }; diff --git a/src/cli/manage.js b/src/cli/manage.js index cfb545b..43db29b 100644 --- a/src/cli/manage.js +++ b/src/cli/manage.js @@ -1,204 +1,214 @@ 'use strict'; +const childProcess = require('node:child_process'); 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'); const plugins = require('../plugins'); const events = require('../events'); const analytics = require('../analytics'); +const {pluginNamePattern, themeNamePattern, paths} = require('../constants'); const reset = require('./reset'); -const { pluginNamePattern, themeNamePattern, paths } = require('../constants'); async function install(plugin, options) { - if (!options) { - options = {}; - } - try { - await db.init(); - if (!pluginNamePattern.test(plugin)) { - // Allow omission of `nodebb-plugin-` - plugin = `nodebb-plugin-${plugin}`; - } - - plugin = await plugins.autocomplete(plugin); - - const isInstalled = await plugins.isInstalled(plugin); - if (isInstalled) { - throw new Error('plugin already installed'); - } - const nbbVersion = require(paths.currentPackage).version; - const suggested = await plugins.suggest(plugin, nbbVersion); - if (!suggested.version) { - if (!options.force) { - throw new Error(suggested.message); - } - winston.warn(`${suggested.message} Proceeding with installation anyway due to force option being provided`); - suggested.version = 'latest'; - } - winston.info('Installing Plugin `%s@%s`', plugin, suggested.version); - await plugins.toggleInstall(plugin, suggested.version); - - process.exit(0); - } catch (err) { - winston.error(`An error occurred during plugin installation\n${err.stack}`); - process.exit(1); - } + options ||= {}; + + try { + await db.init(); + if (!pluginNamePattern.test(plugin)) { + // Allow omission of `nodebb-plugin-` + plugin = `nodebb-plugin-${plugin}`; + } + + plugin = await plugins.autocomplete(plugin); + + const isInstalled = await plugins.isInstalled(plugin); + if (isInstalled) { + throw new Error('plugin already installed'); + } + + const nbbVersion = require(paths.currentPackage).version; + const suggested = await plugins.suggest(plugin, nbbVersion); + if (!suggested.version) { + if (!options.force) { + throw new Error(suggested.message); + } + + winston.warn(`${suggested.message} Proceeding with installation anyway due to force option being provided`); + suggested.version = 'latest'; + } + + winston.info('Installing Plugin `%s@%s`', plugin, suggested.version); + await plugins.toggleInstall(plugin, suggested.version); + + process.exit(0); + } catch (error) { + winston.error(`An error occurred during plugin installation\n${error.stack}`); + process.exit(1); + } } async function activate(plugin) { - if (themeNamePattern.test(plugin)) { - await reset.reset({ - theme: plugin, - }); - process.exit(); - } - try { - await db.init(); - if (!pluginNamePattern.test(plugin)) { - // Allow omission of `nodebb-plugin-` - plugin = `nodebb-plugin-${plugin}`; - } - - plugin = await plugins.autocomplete(plugin); - - const isInstalled = await plugins.isInstalled(plugin); - if (!isInstalled) { - throw new Error('plugin not installed'); - } - const isActive = await plugins.isActive(plugin); - if (isActive) { - 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); - await events.log({ - type: 'plugin-activate', - text: plugin, - }); - - process.exit(0); - } catch (err) { - winston.error(`An error occurred during plugin activation\n${err.stack}`); - process.exit(1); - } + if (themeNamePattern.test(plugin)) { + await reset.reset({ + theme: plugin, + }); + process.exit(); + } + + try { + await db.init(); + if (!pluginNamePattern.test(plugin)) { + // Allow omission of `nodebb-plugin-` + plugin = `nodebb-plugin-${plugin}`; + } + + plugin = await plugins.autocomplete(plugin); + + const isInstalled = await plugins.isInstalled(plugin); + if (!isInstalled) { + throw new Error('plugin not installed'); + } + + const isActive = await plugins.isActive(plugin); + if (isActive) { + 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 numberPlugins = await db.sortedSetCard('plugins:active'); + winston.info('Activating plugin `%s`', plugin); + await db.sortedSetAdd('plugins:active', numberPlugins, plugin); + await events.log({ + type: 'plugin-activate', + text: plugin, + }); + + process.exit(0); + } catch (error) { + winston.error(`An error occurred during plugin activation\n${error.stack}`); + process.exit(1); + } } async function listPlugins() { - await db.init(); - const installed = await plugins.showInstalled(); - const installedList = installed.map(plugin => plugin.name); - 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)) { - memo.push({ - id: cur, - active: true, - installed: false, - }); - } - - return memo; - }, [])); - - // Alphabetical sort - combined.sort((a, b) => (a.id > b.id ? 1 : -1)); - - // Pretty output - process.stdout.write('Active plugins:\n'); - combined.forEach((plugin) => { - process.stdout.write(`\t* ${plugin.id}${plugin.version ? `@${plugin.version}` : ''} (`); - process.stdout.write(plugin.installed ? chalk.green('installed') : chalk.red('not installed')); - process.stdout.write(', '); - process.stdout.write(plugin.active ? chalk.green('enabled') : chalk.yellow('disabled')); - process.stdout.write(')\n'); - }); - - process.exit(); + await db.init(); + const installed = await plugins.showInstalled(); + const installedList = new Set(installed.map(plugin => plugin.name)); + const active = await plugins.getActive(); + // Merge the two sets, defer to plugins in `installed` if already present + const combined = installed.concat(active.reduce((memo, current) => { + if (!installedList.has(current)) { + memo.push({ + id: current, + active: true, + installed: false, + }); + } + + return memo; + }, [])); + + // Alphabetical sort + combined.sort((a, b) => (a.id > b.id ? 1 : -1)); + + // Pretty output + process.stdout.write('Active plugins:\n'); + for (const plugin of combined) { + process.stdout.write(`\t* ${plugin.id}${plugin.version ? `@${plugin.version}` : ''} (`); + process.stdout.write(plugin.installed ? chalk.green('installed') : chalk.red('not installed')); + process.stdout.write(', '); + process.stdout.write(plugin.active ? chalk.green('enabled') : chalk.yellow('disabled')); + process.stdout.write(')\n'); + } + + process.exit(); } async function listEvents(count = 10) { - await db.init(); - const eventData = await events.getEvents('', 0, count - 1); - console.log(chalk.bold(`\nDisplaying last ${count} administrative events...`)); - eventData.forEach((event) => { - console.log(` * ${chalk.green(String(event.timestampISO))} ${chalk.yellow(String(event.type))}${event.text ? ` ${event.text}` : ''} (uid: ${event.uid ? event.uid : 0})`); - }); - process.exit(); + await db.init(); + const eventData = await events.getEvents('', 0, count - 1); + console.log(chalk.bold(`\nDisplaying last ${count} administrative events...`)); + for (const event of eventData) { + console.log(` * ${chalk.green(String(event.timestampISO))} ${chalk.yellow(String(event.type))}${event.text ? ` ${event.text}` : ''} (uid: ${event.uid ? event.uid : 0})`); + } + + process.exit(); } async function info() { - console.log(''); - const { version } = require('../../package.json'); - console.log(` version: ${version}`); - - console.log(` Node ver: ${process.version}`); - - const hash = childProcess.execSync('git rev-parse HEAD'); - console.log(` git hash: ${hash}`); - - console.log(` database: ${nconf.get('database')}`); - - await db.init(); - const info = await db.info(db.client); - - switch (nconf.get('database')) { - case 'redis': - console.log(` version: ${info.redis_version}`); - console.log(` disk sync: ${info.rdb_last_bgsave_status}`); - break; - - case 'mongo': - 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); - const graph = new CliGraph({ - height: 12, - width: 25, - center: { - x: 0, - y: 11, - }, - }); - const min = Math.min(...analyticsData); - const max = Math.max(...analyticsData); - - analyticsData.forEach((point, idx) => { - graph.addPoint(idx + 1, Math.round(point / max * 10)); - }); - - console.log(''); - console.log(graph.toString()); - console.log(`Pageviews, last 24h (min: ${min} max: ${max})`); - process.exit(); + console.log(''); + const {version} = require('../../package.json'); + console.log(` version: ${version}`); + + console.log(` Node ver: ${process.version}`); + + const hash = childProcess.execSync('git rev-parse HEAD'); + console.log(` git hash: ${hash}`); + + console.log(` database: ${nconf.get('database')}`); + + await db.init(); + const info = await db.info(db.client); + + switch (nconf.get('database')) { + case 'redis': { + console.log(` version: ${info.redis_version}`); + console.log(` disk sync: ${info.rdb_last_bgsave_status}`); + break; + } + + case 'mongo': { + 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); + const graph = new CliGraph({ + height: 12, + width: 25, + center: { + x: 0, + y: 11, + }, + }); + const min = Math.min(...analyticsData); + const max = Math.max(...analyticsData); + + for (const [index, point] of analyticsData.entries()) { + graph.addPoint(index + 1, Math.round(point / max * 10)); + } + + console.log(''); + console.log(graph.toString()); + console.log(`Pageviews, last 24h (min: ${min} max: ${max})`); + process.exit(); } async function buildWrapper(targets, options) { - try { - await build.build(targets, options); - process.exit(0); - } catch (err) { - winston.error(err.stack); - process.exit(1); - } + try { + await build.build(targets, options); + process.exit(0); + } catch (error) { + winston.error(error.stack); + process.exit(1); + } } exports.build = buildWrapper; diff --git a/src/cli/package-install.js b/src/cli/package-install.js index 3f7e6c5..278db67 100644 --- a/src/cli/package-install.js +++ b/src/cli/package-install.js @@ -1,174 +1,181 @@ 'use strict'; -const path = require('path'); +const path = require('node:path'); +const fs = require('node:fs'); +const cproc = require('node:child_process'); +const {paths, pluginNamePattern} = require('../constants'); -const fs = require('fs'); -const cproc = require('child_process'); - -const { paths, pluginNamePattern } = require('../constants'); - -const pkgInstall = module.exports; +const packageInstall = module.exports; function sortDependencies(dependencies) { - return Object.entries(dependencies) - .sort((a, b) => (a < b ? -1 : 1)) - .reduce((memo, pkg) => { - memo[pkg[0]] = pkg[1]; - return memo; - }, {}); + return Object.entries(dependencies) + .sort((a, b) => (a < b ? -1 : 1)) + .reduce((memo, package_) => { + memo[package_[0]] = package_[1]; + return memo; + }, {}); } -pkgInstall.updatePackageFile = () => { - let oldPackageContents; - - try { - oldPackageContents = JSON.parse(fs.readFileSync(paths.currentPackage, 'utf8')); - } catch (e) { - if (e.code !== 'ENOENT') { - throw e; - } else { - // No local package.json, copy from install/package.json - fs.copyFileSync(paths.installPackage, paths.currentPackage); - return; - } - } - - const _ = require('lodash'); - const defaultPackageContents = JSON.parse(fs.readFileSync(paths.installPackage, 'utf8')); - - let dependencies = {}; - Object.entries(oldPackageContents.dependencies || {}).forEach(([dep, version]) => { - if (pluginNamePattern.test(dep)) { - dependencies[dep] = version; - } - }); - - const { devDependencies } = defaultPackageContents; - - // Sort dependencies alphabetically - dependencies = sortDependencies({ ...dependencies, ...defaultPackageContents.dependencies }); - - const packageContents = { ..._.merge(oldPackageContents, defaultPackageContents), dependencies, devDependencies }; - fs.writeFileSync(paths.currentPackage, JSON.stringify(packageContents, null, 4)); +packageInstall.updatePackageFile = () => { + let oldPackageContents; + + try { + oldPackageContents = JSON.parse(fs.readFileSync(paths.currentPackage, 'utf8')); + } catch (error) { + if (error.code === 'ENOENT') { + // No local package.json, copy from install/package.json + fs.copyFileSync(paths.installPackage, paths.currentPackage); + return; + } + + throw error; + } + + const _ = require('lodash'); + const defaultPackageContents = JSON.parse(fs.readFileSync(paths.installPackage, 'utf8')); + + let dependencies = {}; + for (const [dep, version] of Object.entries(oldPackageContents.dependencies || {})) { + if (pluginNamePattern.test(dep)) { + dependencies[dep] = version; + } + } + + const {devDependencies} = defaultPackageContents; + + // Sort dependencies alphabetically + dependencies = sortDependencies({...dependencies, ...defaultPackageContents.dependencies}); + + const packageContents = {..._.merge(oldPackageContents, defaultPackageContents), dependencies, devDependencies}; + fs.writeFileSync(paths.currentPackage, JSON.stringify(packageContents, null, 4)); }; -pkgInstall.supportedPackageManager = [ - 'npm', - 'cnpm', - 'pnpm', - 'yarn', +packageInstall.supportedPackageManager = [ + 'npm', + 'cnpm', + 'pnpm', + 'yarn', ]; -pkgInstall.getPackageManager = () => { - try { - const packageContents = require(paths.currentPackage); - // This regex technically allows invalid values: - // cnpm isn't supported by corepack and it doesn't enforce a version string being present - const pmRegex = new RegExp(`^(?${pkgInstall.supportedPackageManager.join('|')})@?[\\d\\w\\.\\-]*$`); - const packageManager = packageContents.packageManager ? packageContents.packageManager.match(pmRegex) : false; - if (packageManager) { - return packageManager.groups.packageManager; - } - fs.accessSync(path.join(paths.nodeModules, 'nconf/package.json'), fs.constants.R_OK); - const nconf = require('nconf'); - if (!Object.keys(nconf.stores).length) { - // Quick & dirty nconf setup for when you cannot rely on nconf having been required already - const configFile = path.resolve(__dirname, '../../', nconf.any(['config', 'CONFIG']) || 'config.json'); - nconf.env().file({ // not sure why adding .argv() causes the process to terminate - file: configFile, - }); - } - if (nconf.get('package_manager') && !pkgInstall.supportedPackageManager.includes(nconf.get('package_manager'))) { - nconf.clear('package_manager'); - } - - if (!nconf.get('package_manager')) { - nconf.set('package_manager', getPackageManagerByLockfile()); - } - - return nconf.get('package_manager') || 'npm'; - } catch (e) { - // nconf not installed or other unexpected error/exception - return getPackageManagerByLockfile() || 'npm'; - } +packageInstall.getPackageManager = () => { + try { + const packageContents = require(paths.currentPackage); + // This regex technically allows invalid values: + // cnpm isn't supported by corepack and it doesn't enforce a version string being present + const pmRegex = new RegExp(`^(?${packageInstall.supportedPackageManager.join('|')})@?[\\d\\w\\.\\-]*$`); + const packageManager = packageContents.packageManager ? packageContents.packageManager.match(pmRegex) : false; + if (packageManager) { + return packageManager.groups.packageManager; + } + + fs.accessSync(path.join(paths.nodeModules, 'nconf/package.json'), fs.constants.R_OK); + const nconf = require('nconf'); + if (Object.keys(nconf.stores).length === 0) { + // Quick & dirty nconf setup for when you cannot rely on nconf having been required already + const configFile = path.resolve(__dirname, '../../', nconf.any(['config', 'CONFIG']) || 'config.json'); + nconf.env().file({ // Not sure why adding .argv() causes the process to terminate + file: configFile, + }); + } + + if (nconf.get('package_manager') && !packageInstall.supportedPackageManager.includes(nconf.get('package_manager'))) { + nconf.clear('package_manager'); + } + + if (!nconf.get('package_manager')) { + nconf.set('package_manager', getPackageManagerByLockfile()); + } + + return nconf.get('package_manager') || 'npm'; + } catch { + // Nconf not installed or other unexpected error/exception + return getPackageManagerByLockfile() || 'npm'; + } }; function getPackageManagerByLockfile() { - for (const [packageManager, lockfile] of Object.entries({ npm: 'package-lock.json', yarn: 'yarn.lock', pnpm: 'pnpm-lock.yaml' })) { - try { - fs.accessSync(path.resolve(__dirname, `../../${lockfile}`), fs.constants.R_OK); - return packageManager; - } catch (e) {} - } + for (const [packageManager, lockfile] of Object.entries({npm: 'package-lock.json', yarn: 'yarn.lock', pnpm: 'pnpm-lock.yaml'})) { + try { + fs.accessSync(path.resolve(__dirname, `../../${lockfile}`), fs.constants.R_OK); + return packageManager; + } catch {} + } } -pkgInstall.installAll = () => { - const prod = process.env.NODE_ENV === 'production'; - let command = 'npm install'; - - const supportedPackageManagerList = exports.supportedPackageManager; // load config from src/cli/package-install.js - const packageManager = pkgInstall.getPackageManager(); - if (supportedPackageManagerList.indexOf(packageManager) >= 0) { - switch (packageManager) { - case 'yarn': - command = `yarn${prod ? ' --production' : ''}`; - break; - case 'pnpm': - command = 'pnpm install'; // pnpm checks NODE_ENV - break; - case 'cnpm': - command = `cnpm install ${prod ? ' --production' : ''}`; - break; - default: - command += prod ? ' --omit=dev' : ''; - break; - } - } - - try { - cproc.execSync(command, { - cwd: path.join(__dirname, '../../'), - stdio: [0, 1, 2], - }); - } catch (e) { - console.log('Error installing dependencies!'); - console.log(`message: ${e.message}`); - console.log(`stdout: ${e.stdout}`); - console.log(`stderr: ${e.stderr}`); - throw e; - } +packageInstall.installAll = () => { + const production = process.env.NODE_ENV === 'production'; + let command = 'npm install'; + + const supportedPackageManagerList = exports.supportedPackageManager; // Load config from src/cli/package-install.js + const packageManager = packageInstall.getPackageManager(); + if (supportedPackageManagerList.includes(packageManager)) { + switch (packageManager) { + case 'yarn': { + command = `yarn${production ? ' --production' : ''}`; + break; + } + + case 'pnpm': { + command = 'pnpm install'; // Pnpm checks NODE_ENV + break; + } + + case 'cnpm': { + command = `cnpm install ${production ? ' --production' : ''}`; + break; + } + + default: { + command += production ? ' --omit=dev' : ''; + break; + } + } + } + + try { + cproc.execSync(command, { + cwd: path.join(__dirname, '../../'), + stdio: [0, 1, 2], + }); + } catch (error) { + console.log('Error installing dependencies!'); + console.log(`message: ${error.message}`); + console.log(`stdout: ${error.stdout}`); + console.log(`stderr: ${error.stderr}`); + throw error; + } }; -pkgInstall.preserveExtraneousPlugins = () => { - // Skip if `node_modules/` is not found or inaccessible - try { - fs.accessSync(paths.nodeModules, fs.constants.R_OK); - } catch (e) { - return; - } - - const packages = fs.readdirSync(paths.nodeModules) - .filter(pkgName => pluginNamePattern.test(pkgName)); - - const packageContents = JSON.parse(fs.readFileSync(paths.currentPackage, 'utf8')); - - const extraneous = packages - // only extraneous plugins (ones not in package.json) which are not links - .filter((pkgName) => { - const extraneous = !packageContents.dependencies.hasOwnProperty(pkgName); - const isLink = fs.lstatSync(path.join(paths.nodeModules, pkgName)).isSymbolicLink(); - - return extraneous && !isLink; - }) - // reduce to a map of package names to package versions - .reduce((map, pkgName) => { - const pkgConfig = JSON.parse(fs.readFileSync(path.join(paths.nodeModules, pkgName, 'package.json'), 'utf8')); - map[pkgName] = pkgConfig.version; - return map; - }, {}); - - // Add those packages to package.json - packageContents.dependencies = sortDependencies({ ...packageContents.dependencies, ...extraneous }); - - fs.writeFileSync(paths.currentPackage, JSON.stringify(packageContents, null, 4)); +packageInstall.preserveExtraneousPlugins = () => { + // Skip if `node_modules/` is not found or inaccessible + try { + fs.accessSync(paths.nodeModules, fs.constants.R_OK); + } catch { + return; + } + + const packages = fs.readdirSync(paths.nodeModules) + .filter(packageName => pluginNamePattern.test(packageName)); + + const packageContents = JSON.parse(fs.readFileSync(paths.currentPackage, 'utf8')); + + const extraneous = packages + // Only extraneous plugins (ones not in package.json) which are not links + .filter(packageName => { + const extraneous = !packageContents.dependencies.hasOwnProperty(packageName); + const isLink = fs.lstatSync(path.join(paths.nodeModules, packageName)).isSymbolicLink(); + + return extraneous && !isLink; + }) + // Reduce to a map of package names to package versions + .reduce((map, packageName) => { + const packageConfig = JSON.parse(fs.readFileSync(path.join(paths.nodeModules, packageName, 'package.json'), 'utf8')); + map[packageName] = packageConfig.version; + return map; + }, {}); + + // Add those packages to package.json + packageContents.dependencies = sortDependencies({...packageContents.dependencies, ...extraneous}); + + fs.writeFileSync(paths.currentPackage, JSON.stringify(packageContents, null, 4)); }; diff --git a/src/cli/reset.js b/src/cli/reset.js index 3c3110b..6181142 100644 --- a/src/cli/reset.js +++ b/src/cli/reset.js @@ -1,157 +1,160 @@ 'use strict'; -const path = require('path'); +const path = require('node:path'); +const fs = require('node:fs'); const winston = require('winston'); -const fs = require('fs'); const chalk = require('chalk'); const nconf = require('nconf'); - const db = require('../database'); const events = require('../events'); const meta = require('../meta'); const plugins = require('../plugins'); const widgets = require('../widgets'); const privileges = require('../privileges'); -const { paths, pluginNamePattern, themeNamePattern } = require('../constants'); +const {paths, pluginNamePattern, themeNamePattern} = require('../constants'); exports.reset = async function (options) { - const map = { - theme: async function () { - let themeId = options.theme; - if (themeId === true) { - await resetThemes(); - } else { - if (!themeNamePattern.test(themeId)) { - // Allow omission of `nodebb-theme-` - themeId = `nodebb-theme-${themeId}`; - } - - themeId = await plugins.autocomplete(themeId); - await resetTheme(themeId); - } - }, - plugin: async function () { - let pluginId = options.plugin; - if (pluginId === true) { - await resetPlugins(); - } else { - if (!pluginNamePattern.test(pluginId)) { - // Allow omission of `nodebb-plugin-` - pluginId = `nodebb-plugin-${pluginId}`; - } - - pluginId = await plugins.autocomplete(pluginId); - await resetPlugin(pluginId); - } - }, - widgets: resetWidgets, - settings: resetSettings, - all: async function () { - await resetWidgets(); - await resetThemes(); - await resetPlugin(); - await resetSettings(); - }, - }; - - const tasks = Object.keys(map).filter(x => options[x]).map(x => map[x]); - - if (!tasks.length) { - console.log([ - chalk.yellow('No arguments passed in, so nothing was reset.\n'), - `Use ./nodebb reset ${chalk.red('{-t|-p|-w|-s|-a}')}`, - ' -t\tthemes', - ' -p\tplugins', - ' -w\twidgets', - ' -s\tsettings', - ' -a\tall of the above', - '', - 'Plugin and theme reset flags (-p & -t) can take a single argument', - ' e.g. ./nodebb reset -p nodebb-plugin-mentions, ./nodebb reset -t nodebb-theme-persona', - ' Prefix is optional, e.g. ./nodebb reset -p markdown, ./nodebb reset -t persona', - ].join('\n')); - - process.exit(0); - } - - try { - await db.init(); - for (const task of tasks) { - /* eslint-disable no-await-in-loop */ - await task(); - } - winston.info('[reset] Reset complete. Please run `./nodebb build` to rebuild assets.'); - process.exit(0); - } catch (err) { - winston.error(`[reset] Errors were encountered during reset -- ${err.message}`); - process.exit(1); - } + const map = { + async theme() { + let themeId = options.theme; + if (themeId === true) { + await resetThemes(); + } else { + if (!themeNamePattern.test(themeId)) { + // Allow omission of `nodebb-theme-` + themeId = `nodebb-theme-${themeId}`; + } + + themeId = await plugins.autocomplete(themeId); + await resetTheme(themeId); + } + }, + async plugin() { + let pluginId = options.plugin; + if (pluginId === true) { + await resetPlugins(); + } else { + if (!pluginNamePattern.test(pluginId)) { + // Allow omission of `nodebb-plugin-` + pluginId = `nodebb-plugin-${pluginId}`; + } + + pluginId = await plugins.autocomplete(pluginId); + await resetPlugin(pluginId); + } + }, + widgets: resetWidgets, + settings: resetSettings, + async all() { + await resetWidgets(); + await resetThemes(); + await resetPlugin(); + await resetSettings(); + }, + }; + + const tasks = Object.keys(map).filter(x => options[x]).map(x => map[x]); + + if (tasks.length === 0) { + console.log([ + chalk.yellow('No arguments passed in, so nothing was reset.\n'), + `Use ./nodebb reset ${chalk.red('{-t|-p|-w|-s|-a}')}`, + ' -t\tthemes', + ' -p\tplugins', + ' -w\twidgets', + ' -s\tsettings', + ' -a\tall of the above', + '', + 'Plugin and theme reset flags (-p & -t) can take a single argument', + ' e.g. ./nodebb reset -p nodebb-plugin-mentions, ./nodebb reset -t nodebb-theme-persona', + ' Prefix is optional, e.g. ./nodebb reset -p markdown, ./nodebb reset -t persona', + ].join('\n')); + + process.exit(0); + } + + try { + await db.init(); + for (const task of tasks) { + /* eslint-disable no-await-in-loop */ + await task(); + } + + winston.info('[reset] Reset complete. Please run `./nodebb build` to rebuild assets.'); + process.exit(0); + } catch (error) { + winston.error(`[reset] Errors were encountered during reset -- ${error.message}`); + process.exit(1); + } }; async function resetSettings() { - await privileges.global.give(['groups:local:login'], 'registered-users'); - winston.info('[reset] registered-users given login privilege'); - winston.info('[reset] Settings reset to default'); + await privileges.global.give(['groups:local:login'], 'registered-users'); + winston.info('[reset] registered-users given login privilege'); + winston.info('[reset] Settings reset to default'); } async function resetTheme(themeId) { - try { - await fs.promises.access(path.join(paths.nodeModules, themeId, 'package.json')); - } catch (err) { - winston.warn('[reset] Theme `%s` is not installed on this forum', themeId); - throw new Error('theme-not-found'); - } - await resetThemeTo(themeId); + try { + await fs.promises.access(path.join(paths.nodeModules, themeId, 'package.json')); + } catch { + winston.warn('[reset] Theme `%s` is not installed on this forum', themeId); + throw new Error('theme-not-found'); + } + + await resetThemeTo(themeId); } async function resetThemes() { - await resetThemeTo('nodebb-theme-persona'); + await resetThemeTo('nodebb-theme-persona'); } async function resetThemeTo(themeId) { - await meta.themes.set({ - type: 'local', - id: themeId, - }); - await meta.configs.set('bootswatchSkin', ''); - winston.info(`[reset] Theme reset to ${themeId} and default skin`); + await meta.themes.set({ + type: 'local', + id: themeId, + }); + await meta.configs.set('bootswatchSkin', ''); + winston.info(`[reset] Theme reset to ${themeId} and default skin`); } 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); - await events.log({ - type: 'plugin-deactivate', - text: pluginId, - }); - winston.info('[reset] Plugin `%s` disabled', pluginId); - } else { - winston.warn('[reset] Plugin `%s` was not active on this forum', pluginId); - winston.info('[reset] No action taken.'); - } - } catch (err) { - winston.error(`[reset] Could not disable plugin: ${pluginId} encountered error %s\n${err.stack}`); - throw err; - } + 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); + await events.log({ + type: 'plugin-deactivate', + text: pluginId, + }); + winston.info('[reset] Plugin `%s` disabled', pluginId); + } else { + winston.warn('[reset] Plugin `%s` was not active on this forum', pluginId); + winston.info('[reset] No action taken.'); + } + } catch (error) { + winston.error(`[reset] Could not disable plugin: ${pluginId} encountered error %s\n${error.stack}`); + throw error; + } } 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'); + 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'); } async function resetWidgets() { - await plugins.reload(); - await widgets.reset(); - winston.info('[reset] All Widgets moved to Draft Zone'); + await plugins.reload(); + await widgets.reset(); + winston.info('[reset] All Widgets moved to Draft Zone'); } diff --git a/src/cli/running.js b/src/cli/running.js index 83faa23..1248c11 100644 --- a/src/cli/running.js +++ b/src/cli/running.js @@ -1,121 +1,121 @@ 'use strict'; -const fs = require('fs'); -const childProcess = require('child_process'); +const fs = require('node:fs'); +const childProcess = require('node:child_process'); const chalk = require('chalk'); - const fork = require('../meta/debugFork'); -const { paths } = require('../constants'); +const {paths} = require('../constants'); const cwd = paths.baseDir; function getRunningPid(callback) { - fs.readFile(paths.pidfile, { - encoding: 'utf-8', - }, (err, pid) => { - if (err) { - return callback(err); - } + fs.readFile(paths.pidfile, { + encoding: 'utf-8', + }, (error, pid) => { + if (error) { + return callback(error); + } - pid = parseInt(pid, 10); + pid = Number.parseInt(pid, 10); - try { - process.kill(pid, 0); - callback(null, pid); - } catch (e) { - callback(e); - } - }); + try { + process.kill(pid, 0); + callback(null, pid); + } catch (error) { + callback(error); + } + }); } function start(options) { - if (options.dev) { - process.env.NODE_ENV = 'development'; - fork(paths.loader, ['--no-daemon', '--no-silent'], { - env: process.env, - stdio: 'inherit', - cwd, - }); - return; - } - if (options.log) { - console.log(`\n${[ - chalk.bold('Starting NodeBB with logging output'), - chalk.red('Hit ') + chalk.bold('Ctrl-C ') + chalk.red('to exit'), - 'The NodeBB process will continue to run in the background', - `Use "${chalk.yellow('./nodebb stop')}" to stop the NodeBB server`, - ].join('\n')}`); - } else if (!options.silent) { - console.log(`\n${[ - chalk.bold('Starting NodeBB'), - ` "${chalk.yellow('./nodebb stop')}" to stop the NodeBB server`, - ` "${chalk.yellow('./nodebb log')}" to view server output`, - ` "${chalk.yellow('./nodebb help')}" for more commands\n`, - ].join('\n')}`); - } + if (options.dev) { + process.env.NODE_ENV = 'development'; + fork(paths.loader, ['--no-daemon', '--no-silent'], { + env: process.env, + stdio: 'inherit', + cwd, + }); + return; + } + + if (options.log) { + console.log(`\n${[ + chalk.bold('Starting NodeBB with logging output'), + chalk.red('Hit ') + chalk.bold('Ctrl-C ') + chalk.red('to exit'), + 'The NodeBB process will continue to run in the background', + `Use "${chalk.yellow('./nodebb stop')}" to stop the NodeBB server`, + ].join('\n')}`); + } else if (!options.silent) { + console.log(`\n${[ + chalk.bold('Starting NodeBB'), + ` "${chalk.yellow('./nodebb stop')}" to stop the NodeBB server`, + ` "${chalk.yellow('./nodebb log')}" to view server output`, + ` "${chalk.yellow('./nodebb help')}" for more commands\n`, + ].join('\n')}`); + } - // Spawn a new NodeBB process - const child = fork(paths.loader, process.argv.slice(3), { - env: process.env, - cwd, - }); - if (options.log) { - childProcess.spawn('tail', ['-F', './logs/output.log'], { - stdio: 'inherit', - cwd, - }); - } + // Spawn a new NodeBB process + const child = fork(paths.loader, process.argv.slice(3), { + env: process.env, + cwd, + }); + if (options.log) { + childProcess.spawn('tail', ['-F', './logs/output.log'], { + stdio: 'inherit', + cwd, + }); + } - return child; + return child; } function stop() { - getRunningPid((err, pid) => { - if (!err) { - process.kill(pid, 'SIGTERM'); - console.log('Stopping NodeBB. Goodbye!'); - } else { - console.log('NodeBB is already stopped.'); - } - }); + getRunningPid((error, pid) => { + if (error) { + console.log('NodeBB is already stopped.'); + } else { + process.kill(pid, 'SIGTERM'); + console.log('Stopping NodeBB. Goodbye!'); + } + }); } function restart(options) { - getRunningPid((err, pid) => { - if (!err) { - console.log(chalk.bold('\nRestarting NodeBB')); - process.kill(pid, 'SIGTERM'); + getRunningPid((error, pid) => { + if (error) { + console.warn('NodeBB could not be restarted, as a running instance could not be found.'); + } else { + console.log(chalk.bold('\nRestarting NodeBB')); + process.kill(pid, 'SIGTERM'); - options.silent = true; - start(options); - } else { - console.warn('NodeBB could not be restarted, as a running instance could not be found.'); - } - }); + options.silent = true; + start(options); + } + }); } function status() { - getRunningPid((err, pid) => { - if (!err) { - console.log(`\n${[ - chalk.bold('NodeBB Running ') + chalk.cyan(`(pid ${pid.toString()})`), - `\t"${chalk.yellow('./nodebb stop')}" to stop the NodeBB server`, - `\t"${chalk.yellow('./nodebb log')}" to view server output`, - `\t"${chalk.yellow('./nodebb restart')}" to restart NodeBB\n`, - ].join('\n')}`); - } else { - console.log(chalk.bold('\nNodeBB is not running')); - console.log(`\t"${chalk.yellow('./nodebb start')}" to launch the NodeBB server\n`); - } - }); + getRunningPid((error, pid) => { + if (error) { + console.log(chalk.bold('\nNodeBB is not running')); + console.log(`\t"${chalk.yellow('./nodebb start')}" to launch the NodeBB server\n`); + } else { + console.log(`\n${[ + chalk.bold('NodeBB Running ') + chalk.cyan(`(pid ${pid.toString()})`), + `\t"${chalk.yellow('./nodebb stop')}" to stop the NodeBB server`, + `\t"${chalk.yellow('./nodebb log')}" to view server output`, + `\t"${chalk.yellow('./nodebb restart')}" to restart NodeBB\n`, + ].join('\n')}`); + } + }); } function log() { - console.log(`${chalk.red('\nHit ') + chalk.bold('Ctrl-C ') + chalk.red('to exit\n')}\n`); - childProcess.spawn('tail', ['-F', './logs/output.log'], { - stdio: 'inherit', - cwd, - }); + console.log(`${chalk.red('\nHit ') + chalk.bold('Ctrl-C ') + chalk.red('to exit\n')}\n`); + childProcess.spawn('tail', ['-F', './logs/output.log'], { + stdio: 'inherit', + cwd, + }); } exports.start = start; diff --git a/src/cli/setup.js b/src/cli/setup.js index 360cd24..957ce2c 100644 --- a/src/cli/setup.js +++ b/src/cli/setup.js @@ -1,59 +1,61 @@ 'use strict'; +const path = require('node:path'); const winston = require('winston'); -const path = require('path'); const nconf = require('nconf'); - -const { install } = require('../../install/web'); +const {install} = require('../../install/web'); async function setup(initConfig) { - const { paths } = require('../constants'); - const install = require('../install'); - const build = require('../meta/build'); - const prestart = require('../prestart'); - const pkg = require('../../package.json'); - - winston.info('NodeBB Setup Triggered via Command Line'); - - console.log(`\nWelcome to NodeBB v${pkg.version}!`); - console.log('\nThis looks like a new installation, so you\'ll have to answer a few questions about your environment before we can proceed.'); - console.log('Press enter to accept the default setting (shown in brackets).'); - - install.values = initConfig; - const data = await install.setup(); - let configFile = paths.config; - if (nconf.get('config')) { - configFile = path.resolve(paths.baseDir, nconf.get('config')); - } - - prestart.loadConfig(configFile); - - if (!nconf.get('skip-build')) { - await build.buildAll(); - } - - let separator = ' '; - if (process.stdout.columns > 10) { - for (let x = 0, cols = process.stdout.columns - 10; x < cols; x += 1) { - separator += '='; - } - } - console.log(`\n${separator}\n`); - - if (data.hasOwnProperty('password')) { - console.log('An administrative user was automatically created for you:'); - console.log(` Username: ${data.username}`); - console.log(` Password: ${data.password}`); - console.log(''); - } - console.log('NodeBB Setup Completed. Run "./nodebb start" to manually start your NodeBB server.'); - - // If I am a child process, notify the parent of the returned data before exiting (useful for notifying - // hosts of auto-generated username/password during headless setups) - if (process.send) { - process.send(data); - } - process.exit(); + const {paths} = require('../constants'); + const install = require('../install'); + const build = require('../meta/build'); + const prestart = require('../prestart'); + const pkg = require('../../package.json'); + + winston.info('NodeBB Setup Triggered via Command Line'); + + console.log(`\nWelcome to NodeBB v${pkg.version}!`); + console.log('\nThis looks like a new installation, so you\'ll have to answer a few questions about your environment before we can proceed.'); + console.log('Press enter to accept the default setting (shown in brackets).'); + + install.values = initConfig; + const data = await install.setup(); + let configFile = paths.config; + if (nconf.get('config')) { + configFile = path.resolve(paths.baseDir, nconf.get('config')); + } + + prestart.loadConfig(configFile); + + if (!nconf.get('skip-build')) { + await build.buildAll(); + } + + let separator = ' '; + if (process.stdout.columns > 10) { + for (let x = 0, cols = process.stdout.columns - 10; x < cols; x += 1) { + separator += '='; + } + } + + console.log(`\n${separator}\n`); + + if (data.hasOwnProperty('password')) { + console.log('An administrative user was automatically created for you:'); + console.log(` Username: ${data.username}`); + console.log(` Password: ${data.password}`); + console.log(''); + } + + console.log('NodeBB Setup Completed. Run "./nodebb start" to manually start your NodeBB server.'); + + // If I am a child process, notify the parent of the returned data before exiting (useful for notifying + // hosts of auto-generated username/password during headless setups) + if (process.send) { + process.send(data); + } + + process.exit(); } exports.setup = setup; diff --git a/src/cli/upgrade-plugins.js b/src/cli/upgrade-plugins.js index e83027e..b2407dd 100644 --- a/src/cli/upgrade-plugins.js +++ b/src/cli/upgrade-plugins.js @@ -1,159 +1,160 @@ 'use strict'; +const cproc = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); const prompt = require('prompt'); const request = require('request-promise-native'); -const cproc = require('child_process'); const semver = require('semver'); -const fs = require('fs'); -const path = require('path'); const chalk = require('chalk'); - -const { paths, pluginNamePattern } = require('../constants'); +const {paths, pluginNamePattern} = require('../constants'); const pkgInstall = require('./package-install'); const packageManager = pkgInstall.getPackageManager(); let packageManagerExecutable = packageManager; -const packageManagerInstallArgs = packageManager === 'yarn' ? ['add'] : ['install', '--save']; +const packageManagerInstallArguments = packageManager === 'yarn' ? ['add'] : ['install', '--save']; if (process.platform === 'win32') { - packageManagerExecutable += '.cmd'; + packageManagerExecutable += '.cmd'; } async function getModuleVersions(modules) { - const versionHash = {}; - const batch = require('../batch'); - await batch.processArray(modules, async (moduleNames) => { - await Promise.all(moduleNames.map(async (module) => { - let pkg = await fs.promises.readFile( - path.join(paths.nodeModules, module, 'package.json'), { encoding: 'utf-8' } - ); - pkg = JSON.parse(pkg); - versionHash[module] = pkg.version; - })); - }, { - batch: 50, - }); - - return versionHash; + const versionHash = {}; + const batch = require('../batch'); + await batch.processArray(modules, async moduleNames => { + await Promise.all(moduleNames.map(async module => { + let package_ = await fs.promises.readFile( + path.join(paths.nodeModules, module, 'package.json'), {encoding: 'utf-8'}, + ); + package_ = JSON.parse(package_); + versionHash[module] = package_.version; + })); + }, { + batch: 50, + }); + + return versionHash; } async function getInstalledPlugins() { - let [deps, bundled] = await Promise.all([ - fs.promises.readFile(paths.currentPackage, { encoding: 'utf-8' }), - fs.promises.readFile(paths.installPackage, { encoding: 'utf-8' }), - ]); - - deps = Object.keys(JSON.parse(deps).dependencies) - .filter(pkgName => pluginNamePattern.test(pkgName)); - bundled = Object.keys(JSON.parse(bundled).dependencies) - .filter(pkgName => pluginNamePattern.test(pkgName)); - - - // Whittle down deps to send back only extraneously installed plugins/themes/etc - const checklist = deps.filter((pkgName) => { - if (bundled.includes(pkgName)) { - return false; - } - - // Ignore git repositories - try { - fs.accessSync(path.join(paths.nodeModules, pkgName, '.git')); - return false; - } catch (e) { - return true; - } - }); - - return await getModuleVersions(checklist); + let [deps, bundled] = await Promise.all([ + fs.promises.readFile(paths.currentPackage, {encoding: 'utf-8'}), + fs.promises.readFile(paths.installPackage, {encoding: 'utf-8'}), + ]); + + deps = Object.keys(JSON.parse(deps).dependencies) + .filter(packageName => pluginNamePattern.test(packageName)); + bundled = Object.keys(JSON.parse(bundled).dependencies) + .filter(packageName => pluginNamePattern.test(packageName)); + + // Whittle down deps to send back only extraneously installed plugins/themes/etc + const checklist = deps.filter(packageName => { + if (bundled.includes(packageName)) { + return false; + } + + // Ignore git repositories + try { + fs.accessSync(path.join(paths.nodeModules, packageName, '.git')); + return false; + } catch { + return true; + } + }); + + return await getModuleVersions(checklist); } async function getCurrentVersion() { - let pkg = await fs.promises.readFile(paths.installPackage, { encoding: 'utf-8' }); - pkg = JSON.parse(pkg); - return pkg.version; + let package_ = await fs.promises.readFile(paths.installPackage, {encoding: 'utf-8'}); + package_ = JSON.parse(package_); + return package_.version; } async function getSuggestedModules(nbbVersion, toCheck) { - let body = await request({ - method: 'GET', - url: `https://packages.nodebb.org/api/v1/suggest?version=${nbbVersion}&package[]=${toCheck.join('&package[]=')}`, - json: true, - }); - if (!Array.isArray(body) && toCheck.length === 1) { - body = [body]; - } - return body; + let body = await request({ + method: 'GET', + url: `https://packages.nodebb.org/api/v1/suggest?version=${nbbVersion}&package[]=${toCheck.join('&package[]=')}`, + json: true, + }); + if (!Array.isArray(body) && toCheck.length === 1) { + body = [body]; + } + + return body; } async function checkPlugins() { - process.stdout.write('Checking installed plugins and themes for updates... '); - const [plugins, nbbVersion] = await Promise.all([ - getInstalledPlugins(), - getCurrentVersion(), - ]); - - const toCheck = Object.keys(plugins); - if (!toCheck.length) { - process.stdout.write(chalk.green(' OK')); - return []; // no extraneous plugins installed - } - const suggestedModules = await getSuggestedModules(nbbVersion, toCheck); - process.stdout.write(chalk.green(' OK')); - - let current; - let suggested; - const upgradable = suggestedModules.map((suggestObj) => { - current = plugins[suggestObj.package]; - suggested = suggestObj.version; - - if (suggestObj.code === 'match-found' && semver.gt(suggested, current)) { - return { - name: suggestObj.package, - current: current, - suggested: suggested, - }; - } - return null; - }).filter(Boolean); - - return upgradable; + process.stdout.write('Checking installed plugins and themes for updates... '); + const [plugins, nbbVersion] = await Promise.all([ + getInstalledPlugins(), + getCurrentVersion(), + ]); + + const toCheck = Object.keys(plugins); + if (toCheck.length === 0) { + process.stdout.write(chalk.green(' OK')); + return []; // No extraneous plugins installed + } + + const suggestedModules = await getSuggestedModules(nbbVersion, toCheck); + process.stdout.write(chalk.green(' OK')); + + let current; + let suggested; + const upgradable = suggestedModules.map(suggestObject => { + current = plugins[suggestObject.package]; + suggested = suggestObject.version; + + if (suggestObject.code === 'match-found' && semver.gt(suggested, current)) { + return { + name: suggestObject.package, + current, + suggested, + }; + } + + return null; + }).filter(Boolean); + + return upgradable; } async function upgradePlugins() { - try { - const found = await checkPlugins(); - if (found && found.length) { - process.stdout.write(`\n\nA total of ${chalk.bold(String(found.length))} package(s) can be upgraded:\n\n`); - found.forEach((suggestObj) => { - process.stdout.write(`${chalk.yellow(' * ') + suggestObj.name} (${chalk.yellow(suggestObj.current)} -> ${chalk.green(suggestObj.suggested)})\n`); - }); - } else { - console.log(chalk.green('\nAll packages up-to-date!')); - return; - } - - prompt.message = ''; - prompt.delimiter = ''; - - prompt.start(); - const result = await prompt.get({ - name: 'upgrade', - description: '\nProceed with upgrade (y|n)?', - type: 'string', - }); - - if (['y', 'Y', 'yes', 'YES'].includes(result.upgrade)) { - console.log('\nUpgrading packages...'); - const args = packageManagerInstallArgs.concat(found.map(suggestObj => `${suggestObj.name}@${suggestObj.suggested}`)); - - cproc.execFileSync(packageManagerExecutable, args, { stdio: 'ignore' }); - } else { - console.log(`${chalk.yellow('Package upgrades skipped')}. Check for upgrades at any time by running "${chalk.green('./nodebb upgrade -p')}".`); - } - } catch (err) { - console.log(`${chalk.yellow('Warning')}: An unexpected error occured when attempting to verify plugin upgradability`); - throw err; - } + try { + const found = await checkPlugins(); + if (found && found.length > 0) { + process.stdout.write(`\n\nA total of ${chalk.bold(String(found.length))} package(s) can be upgraded:\n\n`); + for (const suggestObject of found) { + process.stdout.write(`${chalk.yellow(' * ') + suggestObject.name} (${chalk.yellow(suggestObject.current)} -> ${chalk.green(suggestObject.suggested)})\n`); + } + } else { + console.log(chalk.green('\nAll packages up-to-date!')); + return; + } + + prompt.message = ''; + prompt.delimiter = ''; + + prompt.start(); + const result = await prompt.get({ + name: 'upgrade', + description: '\nProceed with upgrade (y|n)?', + type: 'string', + }); + + if (['y', 'Y', 'yes', 'YES'].includes(result.upgrade)) { + console.log('\nUpgrading packages...'); + const arguments_ = packageManagerInstallArguments.concat(found.map(suggestObject => `${suggestObject.name}@${suggestObject.suggested}`)); + + cproc.execFileSync(packageManagerExecutable, arguments_, {stdio: 'ignore'}); + } else { + console.log(`${chalk.yellow('Package upgrades skipped')}. Check for upgrades at any time by running "${chalk.green('./nodebb upgrade -p')}".`); + } + } catch (error) { + console.log(`${chalk.yellow('Warning')}: An unexpected error occured when attempting to verify plugin upgradability`); + throw error; + } } exports.upgradePlugins = upgradePlugins; diff --git a/src/cli/upgrade.js b/src/cli/upgrade.js index 2ac32ad..51714b5 100644 --- a/src/cli/upgrade.js +++ b/src/cli/upgrade.js @@ -2,94 +2,95 @@ const nconf = require('nconf'); const chalk = require('chalk'); - const packageInstall = require('./package-install'); -const { upgradePlugins } = require('./upgrade-plugins'); +const {upgradePlugins} = require('./upgrade-plugins'); const steps = { - package: { - message: 'Updating package.json file with defaults...', - handler: function () { - packageInstall.updatePackageFile(); - packageInstall.preserveExtraneousPlugins(); - process.stdout.write(chalk.green(' OK\n')); - }, - }, - install: { - message: 'Bringing base dependencies up to date...', - handler: function () { - process.stdout.write(chalk.green(' started\n')); - packageInstall.installAll(); - }, - }, - plugins: { - message: 'Checking installed plugins for updates...', - handler: async function () { - await require('../database').init(); - await upgradePlugins(); - }, - }, - schema: { - message: 'Updating NodeBB data store schema...', - handler: async function () { - await require('../database').init(); - await require('../meta').configs.init(); - await require('../upgrade').run(); - }, - }, - build: { - message: 'Rebuilding assets...', - handler: async function () { - await require('../meta/build').buildAll(); - }, - }, + package: { + message: 'Updating package.json file with defaults...', + handler() { + packageInstall.updatePackageFile(); + packageInstall.preserveExtraneousPlugins(); + process.stdout.write(chalk.green(' OK\n')); + }, + }, + install: { + message: 'Bringing base dependencies up to date...', + handler() { + process.stdout.write(chalk.green(' started\n')); + packageInstall.installAll(); + }, + }, + plugins: { + message: 'Checking installed plugins for updates...', + async handler() { + await require('../database').init(); + await upgradePlugins(); + }, + }, + schema: { + message: 'Updating NodeBB data store schema...', + async handler() { + await require('../database').init(); + await require('../meta').configs.init(); + await require('../upgrade').run(); + }, + }, + build: { + message: 'Rebuilding assets...', + async handler() { + await require('../meta/build').buildAll(); + }, + }, }; async function runSteps(tasks) { - try { - for (let i = 0; i < tasks.length; i++) { - const step = steps[tasks[i]]; - if (step && step.message && step.handler) { - process.stdout.write(`\n${chalk.bold(`${i + 1}. `)}${chalk.yellow(step.message)}`); - /* eslint-disable-next-line */ + try { + for (const [i, task] of tasks.entries()) { + const step = steps[task]; + if (step && step.message && step.handler) { + process.stdout.write(`\n${chalk.bold(`${i + 1}. `)}${chalk.yellow(step.message)}`); + /* eslint-disable-next-line */ await step.handler(); - } - } - const message = 'NodeBB Upgrade Complete!'; - // some consoles will return undefined/zero columns, - // so just use 2 spaces in upgrade script if we can't get our column count - const { columns } = process.stdout; - const spaces = columns ? new Array(Math.floor(columns / 2) - (message.length / 2) + 1).join(' ') : ' '; + } + } + + const message = 'NodeBB Upgrade Complete!'; + // Some consoles will return undefined/zero columns, + // so just use 2 spaces in upgrade script if we can't get our column count + const {columns} = process.stdout; + const spaces = columns ? Array.from({length: Math.floor(columns / 2) - (message.length / 2) + 1}).join(' ') : ' '; - console.log(`\n\n${spaces}${chalk.green.bold(message)}\n`); + console.log(`\n\n${spaces}${chalk.green.bold(message)}\n`); - process.exit(); - } catch (err) { - console.error(`Error occurred during upgrade: ${err.stack}`); - throw err; - } + process.exit(); + } catch (error) { + console.error(`Error occurred during upgrade: ${error.stack}`); + throw error; + } } async function runUpgrade(upgrades, options) { - console.log(chalk.cyan('\nUpdating NodeBB...')); - options = options || {}; - // disable mongo timeouts during upgrade - nconf.set('mongo:options:socketTimeoutMS', 0); + console.log(chalk.cyan('\nUpdating NodeBB...')); + options ||= {}; + // Disable mongo timeouts during upgrade + nconf.set('mongo:options:socketTimeoutMS', 0); + + if (upgrades === true) { + let tasks = Object.keys(steps); + if (options.package || options.install + || options.plugins || options.schema || options.build) { + tasks = tasks.filter(key => options[key]); + } - if (upgrades === true) { - let tasks = Object.keys(steps); - if (options.package || options.install || - options.plugins || options.schema || options.build) { - tasks = tasks.filter(key => options[key]); - } - await runSteps(tasks); - return; - } + await runSteps(tasks); + return; + } - await require('../database').init(); - await require('../meta').configs.init(); - await require('../upgrade').runParticular(upgrades); - process.exit(0); + await require('../database').init(); + await require('../meta').configs.init(); + await require('../upgrade').runParticular(upgrades); + process.exit(0); } exports.upgrade = runUpgrade; diff --git a/src/cli/user.js b/src/cli/user.js index 026758e..f936204 100644 --- a/src/cli/user.js +++ b/src/cli/user.js @@ -1,72 +1,72 @@ 'use strict'; -const { Command, Option } = require('commander'); +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('-t, --type [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; + 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((...arguments_) => execute(userCommands.info, arguments_)); + 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((...arguments_) => execute(userCommands.create, arguments_)); + 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((...arguments_) => execute(userCommands.reset, arguments_)); + userCmd + .command('delete') + .description('Delete user(s) and/or their content') + .arguments('') + .addOption( + new Option('-t, --type [operation]', 'Delete user content ([purge]), leave content ([account]), or delete content only ([content])') + .choices(['purge', 'account', 'content']).default('purge'), + ) + .action((...arguments_) => execute(userCommands.deleteUser, arguments_)); + + 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((...arguments_) => execute(userCommands.makeAdmin, arguments_)); + make.command('global-mod') + .description('Make user(s) a global moderator') + .arguments('') + .action((...arguments_) => execute(userCommands.makeGlobalMod, arguments_)); + 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((...arguments_) => execute(userCommands.makeMod, arguments_)); + make.command('regular') + .description('Make user(s) a non-privileged user') + .arguments('') + .action((...arguments_) => execute(userCommands.makeRegular, arguments_)); + + return userCmd; }; -let db; +let database; let user; let groups; let privileges; @@ -75,237 +75,245 @@ 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'); + database = require('../database'); + await database.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(); +async function execute(cmd, arguments_) { + await init(); + try { + await cmd(...arguments_); + } catch (error) { + const userError = error.name === 'UserError'; + winston.error(`[userCmd/${cmd.name}] ${userError ? `${error.message}` : 'Command failed.'}`, userError ? '' : error); + process.exit(1); + } + + process.exit(); } function UserCmdHelpers() { - async function getAdminUidOrFail() { - const adminUid = await user.getFirstAdminUid(); - 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, - }; + async function getAdminUidOrFail() { + const adminUid = await user.getFirstAdminUid(); + if (!adminUid) { + const error = new Error('An admin account does not exists to execute the operation.'); + error.name = 'UserError'; + throw error; + } + + 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 argumentParsers = { + intParse(value, variableName) { + const parsedValue = Number.parseInt(value, 10); + if (isNaN(parsedValue)) { + const error = new Error(`"${variableName}" expected to be a number.`); + error.name = 'UserError'; + throw error; + } + + return parsedValue; + }, + intArrayParse: (values, variableName) => values.map(value => argumentParsers.intParse(value, variableName)), + }; + + return { + argParsers: argumentParsers, + 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}.\ + 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.setUserField(uid, '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, { type }) { - 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 (type) { - 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, - }; + } + + 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.setUserField(uid, '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, {type}) { + uids = argParsers.intArrayParse(uids, 'uids'); + + const userExists = await user.exists(uids); + if (!userExists || userExists.includes(false)) { + return winston.error('[userCmd/reset] A user with given uid does not exists.'); + } + + await database.initSessionStore(); + const adminUid = await getAdminUidOrFail(); + + switch (type) { + 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 makeGlobalModule(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 makeModule(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 database.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: makeGlobalModule, + makeMod: makeModule, + makeRegular, + }; } diff --git a/src/constants.js b/src/constants.js index 19b5844..d2d5491 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,6 +1,6 @@ 'use strict'; -const path = require('path'); +const path = require('node:path'); const baseDir = path.join(__dirname, '../'); const loader = path.join(baseDir, 'loader.js'); @@ -13,15 +13,15 @@ const nodeModules = path.join(baseDir, 'node_modules'); const themes = path.join(baseDir, 'themes'); exports.paths = { - baseDir, - loader, - app, - pidfile, - config, - currentPackage, - installPackage, - nodeModules, - themes, + baseDir, + loader, + app, + pidfile, + config, + currentPackage, + installPackage, + nodeModules, + themes, }; exports.pluginNamePattern = /^(@[\w-]+\/)?nodebb-(theme|plugin|widget|rewards)-[\w-]+$/; diff --git a/src/controllers/404.js b/src/controllers/404.js index bb27aa3..08e2a1f 100644 --- a/src/controllers/404.js +++ b/src/controllers/404.js @@ -3,62 +3,61 @@ const nconf = require('nconf'); const winston = require('winston'); const validator = require('validator'); - const meta = require('../meta'); const plugins = require('../plugins'); const middleware = require('../middleware'); const helpers = require('../middleware/helpers'); -exports.handle404 = function handle404(req, res) { - const relativePath = nconf.get('relative_path'); - const isClientScript = new RegExp(`^${relativePath}\\/assets\\/src\\/.+\\.js(\\?v=\\w+)?$`); - - if (plugins.hooks.hasListeners('action:meta.override404')) { - return plugins.hooks.fire('action:meta.override404', { - req: req, - res: res, - error: {}, - }); - } - - if (isClientScript.test(req.url)) { - res.type('text/javascript').status(404).send('Not Found'); - } else if ( - !res.locals.isAPI && ( - req.path.startsWith(`${relativePath}/assets/uploads`) || - (req.get('accept') && !req.get('accept').includes('text/html')) || - req.path === '/favicon.ico' - ) - ) { - meta.errors.log404(req.path || ''); - res.sendStatus(404); - } else if (req.accepts('html')) { - if (process.env.NODE_ENV === 'development') { - winston.warn(`Route requested but not found: ${req.url}`); - } - - meta.errors.log404(req.path.replace(/^\/api/, '') || ''); - exports.send404(req, res); - } else { - res.status(404).type('txt').send('Not found'); - } +exports.handle404 = function handle404(request, res) { + const relativePath = nconf.get('relative_path'); + const isClientScript = new RegExp(`^${relativePath}\\/assets\\/src\\/.+\\.js(\\?v=\\w+)?$`); + + if (plugins.hooks.hasListeners('action:meta.override404')) { + return plugins.hooks.fire('action:meta.override404', { + req: request, + res, + error: {}, + }); + } + + if (isClientScript.test(request.url)) { + res.type('text/javascript').status(404).send('Not Found'); + } else if ( + !res.locals.isAPI && ( + request.path.startsWith(`${relativePath}/assets/uploads`) + || (request.get('accept') && !request.get('accept').includes('text/html')) + || request.path === '/favicon.ico' + ) + ) { + meta.errors.log404(request.path || ''); + res.sendStatus(404); + } else if (request.accepts('html')) { + if (process.env.NODE_ENV === 'development') { + winston.warn(`Route requested but not found: ${request.url}`); + } + + meta.errors.log404(request.path.replace(/^\/api/, '') || ''); + exports.send404(request, res); + } else { + res.status(404).type('txt').send('Not found'); + } }; -exports.send404 = async function (req, res) { - res.status(404); - const path = String(req.path || ''); - if (res.locals.isAPI) { - return res.json({ - path: validator.escape(path.replace(/^\/api/, '')), - title: '[[global:404.title]]', - bodyClass: helpers.buildBodyClass(req, res), - }); - } - - await middleware.buildHeaderAsync(req, res); - await res.render('404', { - path: validator.escape(path), - title: '[[global:404.title]]', - bodyClass: helpers.buildBodyClass(req, res), - }); +exports.send404 = async function (request, res) { + res.status(404); + const path = String(request.path || ''); + if (res.locals.isAPI) { + return res.json({ + path: validator.escape(path.replace(/^\/api/, '')), + title: '[[global:404.title]]', + bodyClass: helpers.buildBodyClass(request, res), + }); + } + + await middleware.buildHeaderAsync(request, res); + await res.render('404', { + path: validator.escape(path), + title: '[[global:404.title]]', + bodyClass: helpers.buildBodyClass(request, res), + }); }; diff --git a/src/controllers/accounts.js b/src/controllers/accounts.js index af28d9b..eb2911d 100644 --- a/src/controllers/accounts.js +++ b/src/controllers/accounts.js @@ -1,20 +1,20 @@ 'use strict'; const accountsController = { - profile: require('./accounts/profile'), - edit: require('./accounts/edit'), - info: require('./accounts/info'), - categories: require('./accounts/categories'), - settings: require('./accounts/settings'), - groups: require('./accounts/groups'), - follow: require('./accounts/follow'), - posts: require('./accounts/posts'), - notifications: require('./accounts/notifications'), - chats: require('./accounts/chats'), - sessions: require('./accounts/sessions'), - blocks: require('./accounts/blocks'), - uploads: require('./accounts/uploads'), - consent: require('./accounts/consent'), + profile: require('./accounts/profile'), + edit: require('./accounts/edit'), + info: require('./accounts/info'), + categories: require('./accounts/categories'), + settings: require('./accounts/settings'), + groups: require('./accounts/groups'), + follow: require('./accounts/follow'), + posts: require('./accounts/posts'), + notifications: require('./accounts/notifications'), + chats: require('./accounts/chats'), + sessions: require('./accounts/sessions'), + blocks: require('./accounts/blocks'), + uploads: require('./accounts/uploads'), + consent: require('./accounts/consent'), }; module.exports = accountsController; diff --git a/src/controllers/accounts/blocks.js b/src/controllers/accounts/blocks.js index 25e7894..416c210 100644 --- a/src/controllers/accounts/blocks.js +++ b/src/controllers/accounts/blocks.js @@ -1,39 +1,40 @@ 'use strict'; const helpers = require('../helpers'); -const accountHelpers = require('./helpers'); const pagination = require('../../pagination'); const user = require('../../user'); const plugins = require('../../plugins'); +const accountHelpers = require('./helpers'); const blocksController = module.exports; -blocksController.getBlocks = async function (req, res, next) { - const page = parseInt(req.query.page, 10) || 1; - const resultsPerPage = 50; - const start = Math.max(0, page - 1) * resultsPerPage; - const stop = start + resultsPerPage - 1; - - const userData = await accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, req.query); - if (!userData) { - return next(); - } - const uids = await user.blocks.list(userData.uid); - const data = await plugins.hooks.fire('filter:user.getBlocks', { - uids: uids, - uid: userData.uid, - start: start, - stop: stop, - }); - - data.uids = data.uids.slice(start, stop + 1); - userData.users = await user.getUsers(data.uids, req.uid); - userData.title = `[[pages:account/blocks, ${userData.username}]]`; - - const pageCount = Math.ceil(userData.counts.blocks / resultsPerPage); - userData.pagination = pagination.create(page, pageCount); - - userData.breadcrumbs = helpers.buildBreadcrumbs([{ text: userData.username, url: `/user/${userData.userslug}` }, { text: '[[user:blocks]]' }]); - - res.render('account/blocks', userData); +blocksController.getBlocks = async function (request, res, next) { + const page = Number.parseInt(request.query.page, 10) || 1; + const resultsPerPage = 50; + const start = Math.max(0, page - 1) * resultsPerPage; + const stop = start + resultsPerPage - 1; + + const userData = await accountHelpers.getUserDataByUserSlug(request.params.userslug, request.uid, request.query); + if (!userData) { + return next(); + } + + const uids = await user.blocks.list(userData.uid); + const data = await plugins.hooks.fire('filter:user.getBlocks', { + uids, + uid: userData.uid, + start, + stop, + }); + + data.uids = data.uids.slice(start, stop + 1); + userData.users = await user.getUsers(data.uids, request.uid); + userData.title = `[[pages:account/blocks, ${userData.username}]]`; + + const pageCount = Math.ceil(userData.counts.blocks / resultsPerPage); + userData.pagination = pagination.create(page, pageCount); + + userData.breadcrumbs = helpers.buildBreadcrumbs([{text: userData.username, url: `/user/${userData.userslug}`}, {text: '[[user:blocks]]'}]); + + res.render('account/blocks', userData); }; diff --git a/src/controllers/accounts/categories.js b/src/controllers/accounts/categories.js index a0b7dc2..b72ce4b 100644 --- a/src/controllers/accounts/categories.js +++ b/src/controllers/accounts/categories.js @@ -2,43 +2,44 @@ const user = require('../../user'); const categories = require('../../categories'); -const accountHelpers = require('./helpers'); const helpers = require('../helpers'); const pagination = require('../../pagination'); const meta = require('../../meta'); +const accountHelpers = require('./helpers'); const categoriesController = module.exports; -categoriesController.get = async function (req, res, next) { - const userData = await accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, req.query); - if (!userData) { - return next(); - } - const [states, allCategoriesData] = await Promise.all([ - user.getCategoryWatchState(userData.uid), - categories.buildForSelect(userData.uid, 'find', ['descriptionParsed', 'depth', 'slug']), - ]); +categoriesController.get = async function (request, res, next) { + const userData = await accountHelpers.getUserDataByUserSlug(request.params.userslug, request.uid, request.query); + if (!userData) { + return next(); + } + + const [states, allCategoriesData] = await Promise.all([ + user.getCategoryWatchState(userData.uid), + categories.buildForSelect(userData.uid, 'find', ['descriptionParsed', 'depth', 'slug']), + ]); - const pageCount = Math.max(1, Math.ceil(allCategoriesData.length / meta.config.categoriesPerPage)); - const page = Math.min(parseInt(req.query.page, 10) || 1, pageCount); - const start = Math.max(0, (page - 1) * meta.config.categoriesPerPage); - const stop = start + meta.config.categoriesPerPage - 1; - const categoriesData = allCategoriesData.slice(start, stop + 1); + const pageCount = Math.max(1, Math.ceil(allCategoriesData.length / meta.config.categoriesPerPage)); + const page = Math.min(Number.parseInt(request.query.page, 10) || 1, pageCount); + const start = Math.max(0, (page - 1) * meta.config.categoriesPerPage); + const stop = start + meta.config.categoriesPerPage - 1; + const categoriesData = allCategoriesData.slice(start, stop + 1); + for (const category of categoriesData) { + if (category) { + category.isIgnored = states[category.cid] === categories.watchStates.ignoring; + category.isWatched = states[category.cid] === categories.watchStates.watching; + category.isNotWatched = states[category.cid] === categories.watchStates.notwatching; + } + } - categoriesData.forEach((category) => { - if (category) { - category.isIgnored = states[category.cid] === categories.watchStates.ignoring; - category.isWatched = states[category.cid] === categories.watchStates.watching; - category.isNotWatched = states[category.cid] === categories.watchStates.notwatching; - } - }); - userData.categories = categoriesData; - userData.title = `[[pages:account/watched_categories, ${userData.username}]]`; - userData.breadcrumbs = helpers.buildBreadcrumbs([ - { text: userData.username, url: `/user/${userData.userslug}` }, - { text: '[[pages:categories]]' }, - ]); - userData.pagination = pagination.create(page, pageCount, req.query); - res.render('account/categories', userData); + userData.categories = categoriesData; + userData.title = `[[pages:account/watched_categories, ${userData.username}]]`; + userData.breadcrumbs = helpers.buildBreadcrumbs([ + {text: userData.username, url: `/user/${userData.userslug}`}, + {text: '[[pages:categories]]'}, + ]); + userData.pagination = pagination.create(page, pageCount, request.query); + res.render('account/categories', userData); }; diff --git a/src/controllers/accounts/chats.js b/src/controllers/accounts/chats.js index f534eaf..f0cc765 100644 --- a/src/controllers/accounts/chats.js +++ b/src/controllers/accounts/chats.js @@ -8,58 +8,63 @@ const helpers = require('../helpers'); const chatsController = module.exports; -chatsController.get = async function (req, res, next) { - if (meta.config.disableChat) { - return next(); - } +chatsController.get = async function (request, res, next) { + if (meta.config.disableChat) { + return next(); + } - const uid = await user.getUidByUserslug(req.params.userslug); - if (!uid) { - return next(); - } - const canChat = await privileges.global.can('chat', req.uid); - if (!canChat) { - return next(new Error('[[error:no-privileges]]')); - } - const recentChats = await messaging.getRecentChats(req.uid, uid, 0, 19); - if (!recentChats) { - return next(); - } + const uid = await user.getUidByUserslug(request.params.userslug); + if (!uid) { + return next(); + } - if (!req.params.roomid) { - return res.render('chats', { - rooms: recentChats.rooms, - uid: uid, - userslug: req.params.userslug, - nextStart: recentChats.nextStart, - allowed: true, - title: '[[pages:chats]]', - }); - } - const room = await messaging.loadRoom(req.uid, { uid: uid, roomId: req.params.roomid }); - if (!room) { - return next(); - } + const canChat = await privileges.global.can('chat', request.uid); + if (!canChat) { + return next(new Error('[[error:no-privileges]]')); + } - room.rooms = recentChats.rooms; - room.nextStart = recentChats.nextStart; - room.title = room.roomName || room.usernames || '[[pages:chats]]'; - room.uid = uid; - room.userslug = req.params.userslug; + const recentChats = await messaging.getRecentChats(request.uid, uid, 0, 19); + if (!recentChats) { + return next(); + } - room.canViewInfo = await privileges.global.can('view:users:info', uid); + if (!request.params.roomid) { + return res.render('chats', { + rooms: recentChats.rooms, + uid, + userslug: request.params.userslug, + nextStart: recentChats.nextStart, + allowed: true, + title: '[[pages:chats]]', + }); + } - res.render('chats', room); + const room = await messaging.loadRoom(request.uid, {uid, roomId: request.params.roomid}); + if (!room) { + return next(); + } + + room.rooms = recentChats.rooms; + room.nextStart = recentChats.nextStart; + room.title = room.roomName || room.usernames || '[[pages:chats]]'; + room.uid = uid; + room.userslug = request.params.userslug; + + room.canViewInfo = await privileges.global.can('view:users:info', uid); + + res.render('chats', room); }; -chatsController.redirectToChat = async function (req, res, next) { - if (!req.loggedIn) { - return next(); - } - const userslug = await user.getUserField(req.uid, 'userslug'); - if (!userslug) { - return next(); - } - const roomid = parseInt(req.params.roomid, 10); - helpers.redirect(res, `/user/${userslug}/chats${roomid ? `/${roomid}` : ''}`); +chatsController.redirectToChat = async function (request, res, next) { + if (!request.loggedIn) { + return next(); + } + + const userslug = await user.getUserField(request.uid, 'userslug'); + if (!userslug) { + return next(); + } + + const roomid = Number.parseInt(request.params.roomid, 10); + helpers.redirect(res, `/user/${userslug}/chats${roomid ? `/${roomid}` : ''}`); }; diff --git a/src/controllers/accounts/consent.js b/src/controllers/accounts/consent.js index 63ff886..88fe687 100644 --- a/src/controllers/accounts/consent.js +++ b/src/controllers/accounts/consent.js @@ -7,24 +7,25 @@ const accountHelpers = require('./helpers'); const consentController = module.exports; -consentController.get = async function (req, res, next) { - if (!meta.config.gdpr_enabled) { - return next(); - } +consentController.get = async function (request, res, next) { + if (!meta.config.gdpr_enabled) { + return next(); + } - const userData = await accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, req.query); - if (!userData) { - return next(); - } - const consented = await db.getObjectField(`user:${userData.uid}`, 'gdpr_consent'); - userData.gdpr_consent = parseInt(consented, 10) === 1; - userData.digest = { - frequency: meta.config.dailyDigestFreq || 'off', - enabled: meta.config.dailyDigestFreq !== 'off', - }; + const userData = await accountHelpers.getUserDataByUserSlug(request.params.userslug, request.uid, request.query); + if (!userData) { + return next(); + } - userData.title = '[[user:consent.title]]'; - userData.breadcrumbs = helpers.buildBreadcrumbs([{ text: userData.username, url: `/user/${userData.userslug}` }, { text: '[[user:consent.title]]' }]); + const consented = await db.getObjectField(`user:${userData.uid}`, 'gdpr_consent'); + userData.gdpr_consent = Number.parseInt(consented, 10) === 1; + userData.digest = { + frequency: meta.config.dailyDigestFreq || 'off', + enabled: meta.config.dailyDigestFreq !== 'off', + }; - res.render('account/consent', userData); + userData.title = '[[user:consent.title]]'; + userData.breadcrumbs = helpers.buildBreadcrumbs([{text: userData.username, url: `/user/${userData.userslug}`}, {text: '[[user:consent.title]]'}]); + + res.render('account/consent', userData); }; diff --git a/src/controllers/accounts/edit.js b/src/controllers/accounts/edit.js index da3c1ac..019f3f7 100644 --- a/src/controllers/accounts/edit.js +++ b/src/controllers/accounts/edit.js @@ -4,166 +4,173 @@ const user = require('../../user'); const meta = require('../../meta'); const helpers = require('../helpers'); const groups = require('../../groups'); -const accountHelpers = require('./helpers'); const privileges = require('../../privileges'); const file = require('../../file'); +const accountHelpers = require('./helpers'); const editController = module.exports; -editController.get = async function (req, res, next) { - const [userData, canUseSignature] = await Promise.all([ - accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, req.query), - privileges.global.can('signature', req.uid), - ]); - if (!userData) { - return next(); - } - userData.maximumSignatureLength = meta.config.maximumSignatureLength; - userData.maximumAboutMeLength = meta.config.maximumAboutMeLength; - userData.maximumProfileImageSize = meta.config.maximumProfileImageSize; - userData.allowProfilePicture = !userData.isSelf || !!meta.config['reputation:disabled'] || userData.reputation >= meta.config['min:rep:profile-picture']; - userData.allowCoverPicture = !userData.isSelf || !!meta.config['reputation:disabled'] || userData.reputation >= meta.config['min:rep:cover-picture']; - userData.allowProfileImageUploads = meta.config.allowProfileImageUploads; - userData.allowedProfileImageExtensions = user.getAllowedProfileImageExtensions().map(ext => `.${ext}`).join(', '); - userData.allowMultipleBadges = meta.config.allowMultipleBadges === 1; - userData.allowAccountDelete = meta.config.allowAccountDelete === 1; - userData.allowWebsite = !userData.isSelf || !!meta.config['reputation:disabled'] || userData.reputation >= meta.config['min:rep:website']; - userData.allowAboutMe = !userData.isSelf || !!meta.config['reputation:disabled'] || userData.reputation >= meta.config['min:rep:aboutme']; - userData.allowSignature = canUseSignature && (!userData.isSelf || !!meta.config['reputation:disabled'] || userData.reputation >= meta.config['min:rep:signature']); - userData.profileImageDimension = meta.config.profileImageDimension; - userData.defaultAvatar = user.getDefaultAvatar(); - - userData.groups = userData.groups.filter(g => g && g.userTitleEnabled && !groups.isPrivilegeGroup(g.name) && g.name !== 'registered-users'); - - if (!userData.allowMultipleBadges) { - userData.groupTitle = userData.groupTitleArray[0]; - } - - userData.groups.sort((a, b) => { - const i1 = userData.groupTitleArray.indexOf(a.name); - const i2 = userData.groupTitleArray.indexOf(b.name); - if (i1 === -1) { - return 1; - } else if (i2 === -1) { - return -1; - } - return i1 - i2; - }); - userData.groups.forEach((group) => { - group.userTitle = group.userTitle || group.displayName; - group.selected = userData.groupTitleArray.includes(group.name); - }); - userData.groupSelectSize = Math.min(10, Math.max(5, userData.groups.length + 1)); - - userData.title = `[[pages:account/edit, ${userData.username}]]`; - userData.breadcrumbs = helpers.buildBreadcrumbs([ - { - text: userData.username, - url: `/user/${userData.userslug}`, - }, - { - text: '[[user:edit]]', - }, - ]); - userData.editButtons = []; - res.render('account/edit', userData); +editController.get = async function (request, res, next) { + const [userData, canUseSignature] = await Promise.all([ + accountHelpers.getUserDataByUserSlug(request.params.userslug, request.uid, request.query), + privileges.global.can('signature', request.uid), + ]); + if (!userData) { + return next(); + } + + userData.maximumSignatureLength = meta.config.maximumSignatureLength; + userData.maximumAboutMeLength = meta.config.maximumAboutMeLength; + userData.maximumProfileImageSize = meta.config.maximumProfileImageSize; + userData.allowProfilePicture = !userData.isSelf || Boolean(meta.config['reputation:disabled']) || userData.reputation >= meta.config['min:rep:profile-picture']; + userData.allowCoverPicture = !userData.isSelf || Boolean(meta.config['reputation:disabled']) || userData.reputation >= meta.config['min:rep:cover-picture']; + userData.allowProfileImageUploads = meta.config.allowProfileImageUploads; + userData.allowedProfileImageExtensions = user.getAllowedProfileImageExtensions().map(extension => `.${extension}`).join(', '); + userData.allowMultipleBadges = meta.config.allowMultipleBadges === 1; + userData.allowAccountDelete = meta.config.allowAccountDelete === 1; + userData.allowWebsite = !userData.isSelf || Boolean(meta.config['reputation:disabled']) || userData.reputation >= meta.config['min:rep:website']; + userData.allowAboutMe = !userData.isSelf || Boolean(meta.config['reputation:disabled']) || userData.reputation >= meta.config['min:rep:aboutme']; + userData.allowSignature = canUseSignature && (!userData.isSelf || Boolean(meta.config['reputation:disabled']) || userData.reputation >= meta.config['min:rep:signature']); + userData.profileImageDimension = meta.config.profileImageDimension; + userData.defaultAvatar = user.getDefaultAvatar(); + + userData.groups = userData.groups.filter(g => g && g.userTitleEnabled && !groups.isPrivilegeGroup(g.name) && g.name !== 'registered-users'); + + if (!userData.allowMultipleBadges) { + userData.groupTitle = userData.groupTitleArray[0]; + } + + userData.groups.sort((a, b) => { + const i1 = userData.groupTitleArray.indexOf(a.name); + const i2 = userData.groupTitleArray.indexOf(b.name); + if (i1 === -1) { + return 1; + } + + if (i2 === -1) { + return -1; + } + + return i1 - i2; + }); + for (const group of userData.groups) { + group.userTitle = group.userTitle || group.displayName; + group.selected = userData.groupTitleArray.includes(group.name); + } + + userData.groupSelectSize = Math.min(10, Math.max(5, userData.groups.length + 1)); + + userData.title = `[[pages:account/edit, ${userData.username}]]`; + userData.breadcrumbs = helpers.buildBreadcrumbs([ + { + text: userData.username, + url: `/user/${userData.userslug}`, + }, + { + text: '[[user:edit]]', + }, + ]); + userData.editButtons = []; + res.render('account/edit', userData); }; -editController.password = async function (req, res, next) { - await renderRoute('password', req, res, next); +editController.password = async function (request, res, next) { + await renderRoute('password', request, res, next); }; -editController.username = async function (req, res, next) { - await renderRoute('username', req, res, next); +editController.username = async function (request, res, next) { + await renderRoute('username', request, res, next); }; -editController.email = async function (req, res, next) { - const targetUid = await user.getUidByUserslug(req.params.userslug); - if (!targetUid) { - return next(); - } - - const [isAdminOrGlobalMod, canEdit] = await Promise.all([ - user.isAdminOrGlobalMod(req.uid), - privileges.users.canEdit(req.uid, targetUid), - ]); - - if (!isAdminOrGlobalMod && !canEdit) { - return next(); - } - - req.session.returnTo = `/uid/${targetUid}`; - req.session.registration = req.session.registration || {}; - req.session.registration.updateEmail = true; - req.session.registration.uid = targetUid; - helpers.redirect(res, '/register/complete'); +editController.email = async function (request, res, next) { + const targetUid = await user.getUidByUserslug(request.params.userslug); + if (!targetUid) { + return next(); + } + + const [isAdminOrGlobalModule, canEdit] = await Promise.all([ + user.isAdminOrGlobalMod(request.uid), + privileges.users.canEdit(request.uid, targetUid), + ]); + + if (!isAdminOrGlobalModule && !canEdit) { + return next(); + } + + request.session.returnTo = `/uid/${targetUid}`; + request.session.registration = request.session.registration || {}; + request.session.registration.updateEmail = true; + request.session.registration.uid = targetUid; + helpers.redirect(res, '/register/complete'); }; -async function renderRoute(name, req, res, next) { - const userData = await getUserData(req, next); - if (!userData) { - return next(); - } - if (meta.config[`${name}:disableEdit`] && !userData.isAdmin) { - return helpers.notAllowed(req, res); - } - - if (name === 'password') { - userData.minimumPasswordLength = meta.config.minimumPasswordLength; - userData.minimumPasswordStrength = meta.config.minimumPasswordStrength; - } - - userData.title = `[[pages:account/edit/${name}, ${userData.username}]]`; - userData.breadcrumbs = helpers.buildBreadcrumbs([ - { - text: userData.username, - url: `/user/${userData.userslug}`, - }, - { - text: '[[user:edit]]', - url: `/user/${userData.userslug}/edit`, - }, - { - text: `[[user:${name}]]`, - }, - ]); - - res.render(`account/edit/${name}`, userData); +async function renderRoute(name, request, res, next) { + const userData = await getUserData(request, next); + if (!userData) { + return next(); + } + + if (meta.config[`${name}:disableEdit`] && !userData.isAdmin) { + return helpers.notAllowed(request, res); + } + + if (name === 'password') { + userData.minimumPasswordLength = meta.config.minimumPasswordLength; + userData.minimumPasswordStrength = meta.config.minimumPasswordStrength; + } + + userData.title = `[[pages:account/edit/${name}, ${userData.username}]]`; + userData.breadcrumbs = helpers.buildBreadcrumbs([ + { + text: userData.username, + url: `/user/${userData.userslug}`, + }, + { + text: '[[user:edit]]', + url: `/user/${userData.userslug}/edit`, + }, + { + text: `[[user:${name}]]`, + }, + ]); + + res.render(`account/edit/${name}`, userData); } -async function getUserData(req) { - const userData = await accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, req.query); - if (!userData) { - return null; - } +async function getUserData(request) { + const userData = await accountHelpers.getUserDataByUserSlug(request.params.userslug, request.uid, request.query); + if (!userData) { + return null; + } - userData.hasPassword = await user.hasPassword(userData.uid); - return userData; + userData.hasPassword = await user.hasPassword(userData.uid); + return userData; } -editController.uploadPicture = async function (req, res, next) { - const userPhoto = req.files.files[0]; - try { - const updateUid = await user.getUidByUserslug(req.params.userslug); - const isAllowed = await privileges.users.canEdit(req.uid, updateUid); - if (!isAllowed) { - return helpers.notAllowed(req, res); - } - await user.checkMinReputation(req.uid, updateUid, 'min:rep:profile-picture'); - - const image = await user.uploadCroppedPictureFile({ - callerUid: req.uid, - uid: updateUid, - file: userPhoto, - }); - - res.json([{ - name: userPhoto.name, - url: image.url, - }]); - } catch (err) { - next(err); - } finally { - await file.delete(userPhoto.path); - } +editController.uploadPicture = async function (request, res, next) { + const userPhoto = request.files.files[0]; + try { + const updateUid = await user.getUidByUserslug(request.params.userslug); + const isAllowed = await privileges.users.canEdit(request.uid, updateUid); + if (!isAllowed) { + return helpers.notAllowed(request, res); + } + + await user.checkMinReputation(request.uid, updateUid, 'min:rep:profile-picture'); + + const image = await user.uploadCroppedPictureFile({ + callerUid: request.uid, + uid: updateUid, + file: userPhoto, + }); + + res.json([{ + name: userPhoto.name, + url: image.url, + }]); + } catch (error) { + next(error); + } finally { + await file.delete(userPhoto.path); + } }; diff --git a/src/controllers/accounts/follow.js b/src/controllers/accounts/follow.js index e54f584..ec75755 100644 --- a/src/controllers/accounts/follow.js +++ b/src/controllers/accounts/follow.js @@ -2,40 +2,40 @@ const user = require('../../user'); const helpers = require('../helpers'); -const accountHelpers = require('./helpers'); const pagination = require('../../pagination'); +const accountHelpers = require('./helpers'); const followController = module.exports; -followController.getFollowing = async function (req, res, next) { - await getFollow('account/following', 'following', req, res, next); +followController.getFollowing = async function (request, res, next) { + await getFollow('account/following', 'following', request, res, next); }; -followController.getFollowers = async function (req, res, next) { - await getFollow('account/followers', 'followers', req, res, next); +followController.getFollowers = async function (request, res, next) { + await getFollow('account/followers', 'followers', request, res, next); }; -async function getFollow(tpl, name, req, res, next) { - const userData = await accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, req.query); - if (!userData) { - return next(); - } +async function getFollow(tpl, name, request, res, next) { + const userData = await accountHelpers.getUserDataByUserSlug(request.params.userslug, request.uid, request.query); + if (!userData) { + return next(); + } - const page = parseInt(req.query.page, 10) || 1; - const resultsPerPage = 50; - const start = Math.max(0, page - 1) * resultsPerPage; - const stop = start + resultsPerPage - 1; + const page = Number.parseInt(request.query.page, 10) || 1; + const resultsPerPage = 50; + const start = Math.max(0, page - 1) * resultsPerPage; + const stop = start + resultsPerPage - 1; - userData.title = `[[pages:${tpl}, ${userData.username}]]`; + userData.title = `[[pages:${tpl}, ${userData.username}]]`; - const method = name === 'following' ? 'getFollowing' : 'getFollowers'; - userData.users = await user[method](userData.uid, start, stop); + const method = name === 'following' ? 'getFollowing' : 'getFollowers'; + userData.users = await user[method](userData.uid, start, stop); - const count = name === 'following' ? userData.followingCount : userData.followerCount; - const pageCount = Math.ceil(count / resultsPerPage); - userData.pagination = pagination.create(page, pageCount); + const count = name === 'following' ? userData.followingCount : userData.followerCount; + const pageCount = Math.ceil(count / resultsPerPage); + userData.pagination = pagination.create(page, pageCount); - userData.breadcrumbs = helpers.buildBreadcrumbs([{ text: userData.username, url: `/user/${userData.userslug}` }, { text: `[[user:${name}]]` }]); + userData.breadcrumbs = helpers.buildBreadcrumbs([{text: userData.username, url: `/user/${userData.userslug}`}, {text: `[[user:${name}]]`}]); - res.render(tpl, userData); + res.render(tpl, userData); } diff --git a/src/controllers/accounts/groups.js b/src/controllers/accounts/groups.js index 2878130..18f6501 100644 --- a/src/controllers/accounts/groups.js +++ b/src/controllers/accounts/groups.js @@ -6,20 +6,22 @@ const accountHelpers = require('./helpers'); const groupsController = module.exports; -groupsController.get = async function (req, res, next) { - const userData = await accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, req.query); - if (!userData) { - return next(); - } - let groupsData = await groups.getUserGroups([userData.uid]); - groupsData = groupsData[0]; - const groupNames = groupsData.filter(Boolean).map(group => group.name); - const members = await groups.getMemberUsers(groupNames, 0, 3); - groupsData.forEach((group, index) => { - group.members = members[index]; - }); - userData.groups = groupsData; - userData.title = `[[pages:account/groups, ${userData.username}]]`; - userData.breadcrumbs = helpers.buildBreadcrumbs([{ text: userData.username, url: `/user/${userData.userslug}` }, { text: '[[global:header.groups]]' }]); - res.render('account/groups', userData); +groupsController.get = async function (request, res, next) { + const userData = await accountHelpers.getUserDataByUserSlug(request.params.userslug, request.uid, request.query); + if (!userData) { + return next(); + } + + let groupsData = await groups.getUserGroups([userData.uid]); + groupsData = groupsData[0]; + const groupNames = groupsData.filter(Boolean).map(group => group.name); + const members = await groups.getMemberUsers(groupNames, 0, 3); + for (const [index, group] of groupsData.entries()) { + group.members = members[index]; + } + + userData.groups = groupsData; + userData.title = `[[pages:account/groups, ${userData.username}]]`; + userData.breadcrumbs = helpers.buildBreadcrumbs([{text: userData.username, url: `/user/${userData.userslug}`}, {text: '[[global:header.groups]]'}]); + res.render('account/groups', userData); }; diff --git a/src/controllers/accounts/helpers.js b/src/controllers/accounts/helpers.js index fee96fd..bb28cc8 100644 --- a/src/controllers/accounts/helpers.js +++ b/src/controllers/accounts/helpers.js @@ -2,7 +2,6 @@ const validator = require('validator'); const nconf = require('nconf'); - const db = require('../../database'); const user = require('../../user'); const groups = require('../../groups'); @@ -17,251 +16,254 @@ const categories = require('../../categories'); const helpers = module.exports; helpers.getUserDataByUserSlug = async function (userslug, callerUID, query = {}) { - const uid = await user.getUidByUserslug(userslug); - if (!uid) { - return null; - } + const uid = await user.getUidByUserslug(userslug); + if (!uid) { + return null; + } + + const results = await getAllData(uid, callerUID); + if (!results.userData) { + throw new Error('[[error:invalid-uid]]'); + } - const results = await getAllData(uid, callerUID); - if (!results.userData) { - throw new Error('[[error:invalid-uid]]'); - } - await parseAboutMe(results.userData); + await parseAboutMe(results.userData); - let { userData } = results; - const { userSettings } = results; - const { isAdmin } = results; - const { isGlobalModerator } = results; - const { isModerator } = results; - const { canViewInfo } = results; - const isSelf = parseInt(callerUID, 10) === parseInt(userData.uid, 10); + let {userData} = results; + const {userSettings} = results; + const {isAdmin} = results; + const {isGlobalModerator} = results; + const {isModerator} = results; + const {canViewInfo} = results; + const isSelf = Number.parseInt(callerUID, 10) === Number.parseInt(userData.uid, 10); - userData.age = Math.max( - 0, - userData.birthday ? Math.floor((new Date().getTime() - new Date(userData.birthday).getTime()) / 31536000000) : 0 - ); + userData.age = Math.max( + 0, + userData.birthday ? Math.floor((Date.now() - new Date(userData.birthday).getTime()) / 31_536_000_000) : 0, + ); - userData = await user.hidePrivateData(userData, callerUID); - userData.emailClass = userSettings.showemail ? 'hide' : ''; + userData = await user.hidePrivateData(userData, callerUID); + userData.emailClass = userSettings.showemail ? 'hide' : ''; - // If email unconfirmed, hide from result set - if (!userData['email:confirmed']) { - userData.email = ''; - } + // If email unconfirmed, hide from result set + if (!userData['email:confirmed']) { + userData.email = ''; + } - if (isAdmin || isSelf || (canViewInfo && !results.isTargetAdmin)) { - userData.ips = results.ips; - } + if (isAdmin || isSelf || (canViewInfo && !results.isTargetAdmin)) { + userData.ips = results.ips; + } - if (!isAdmin && !isGlobalModerator && !isModerator) { - userData.moderationNote = undefined; - } + if (!isAdmin && !isGlobalModerator && !isModerator) { + userData.moderationNote = undefined; + } - userData.isBlocked = results.isBlocked; - userData.yourid = callerUID; - userData.theirid = userData.uid; - userData.isTargetAdmin = results.isTargetAdmin; - userData.isAdmin = isAdmin; - userData.isGlobalModerator = isGlobalModerator; - userData.isModerator = isModerator; - userData.isAdminOrGlobalModerator = isAdmin || isGlobalModerator; - userData.isAdminOrGlobalModeratorOrModerator = isAdmin || isGlobalModerator || isModerator; - userData.isSelfOrAdminOrGlobalModerator = isSelf || isAdmin || isGlobalModerator; - userData.canEdit = results.canEdit; - userData.canBan = results.canBanUser; - userData.canMute = results.canMuteUser; - userData.canFlag = (await privileges.users.canFlag(callerUID, userData.uid)).flag; - userData.canChangePassword = isAdmin || (isSelf && !meta.config['password:disableEdit']); - userData.isSelf = isSelf; - userData.isFollowing = results.isFollowing; - userData.hasPrivateChat = results.hasPrivateChat; - userData.showHidden = results.canEdit; // remove in v1.19.0 - userData.groups = Array.isArray(results.groups) && results.groups.length ? results.groups[0] : []; - userData.disableSignatures = meta.config.disableSignatures === 1; - userData['reputation:disabled'] = meta.config['reputation:disabled'] === 1; - userData['downvote:disabled'] = meta.config['downvote:disabled'] === 1; - userData['email:confirmed'] = !!userData['email:confirmed']; - userData.profile_links = filterLinks(results.profile_menu.links, { - self: isSelf, - other: !isSelf, - moderator: isModerator, - globalMod: isGlobalModerator, - admin: isAdmin, - canViewInfo: canViewInfo, - }); + userData.isBlocked = results.isBlocked; + userData.yourid = callerUID; + userData.theirid = userData.uid; + userData.isTargetAdmin = results.isTargetAdmin; + userData.isAdmin = isAdmin; + userData.isGlobalModerator = isGlobalModerator; + userData.isModerator = isModerator; + userData.isAdminOrGlobalModerator = isAdmin || isGlobalModerator; + userData.isAdminOrGlobalModeratorOrModerator = isAdmin || isGlobalModerator || isModerator; + userData.isSelfOrAdminOrGlobalModerator = isSelf || isAdmin || isGlobalModerator; + userData.canEdit = results.canEdit; + userData.canBan = results.canBanUser; + userData.canMute = results.canMuteUser; + userData.canFlag = (await privileges.users.canFlag(callerUID, userData.uid)).flag; + userData.canChangePassword = isAdmin || (isSelf && !meta.config['password:disableEdit']); + userData.isSelf = isSelf; + userData.isFollowing = results.isFollowing; + userData.hasPrivateChat = results.hasPrivateChat; + userData.showHidden = results.canEdit; // Remove in v1.19.0 + userData.groups = Array.isArray(results.groups) && results.groups.length > 0 ? results.groups[0] : []; + userData.disableSignatures = meta.config.disableSignatures === 1; + userData['reputation:disabled'] = meta.config['reputation:disabled'] === 1; + userData['downvote:disabled'] = meta.config['downvote:disabled'] === 1; + userData['email:confirmed'] = Boolean(userData['email:confirmed']); + userData.profile_links = filterLinks(results.profile_menu.links, { + self: isSelf, + other: !isSelf, + moderator: isModerator, + globalMod: isGlobalModerator, + admin: isAdmin, + canViewInfo, + }); - userData.sso = results.sso.associations; - userData.banned = Boolean(userData.banned); - userData.muted = parseInt(userData.mutedUntil, 10) > Date.now(); - userData.website = escape(userData.website); - userData.websiteLink = !userData.website.startsWith('http') ? `http://${userData.website}` : userData.website; - userData.websiteName = userData.website.replace(validator.escape('http://'), '').replace(validator.escape('https://'), ''); + userData.sso = results.sso.associations; + userData.banned = Boolean(userData.banned); + userData.muted = Number.parseInt(userData.mutedUntil, 10) > Date.now(); + userData.website = escape(userData.website); + userData.websiteLink = userData.website.startsWith('http') ? userData.website : `http://${userData.website}`; + userData.websiteName = userData.website.replace(validator.escape('http://'), '').replace(validator.escape('https://'), ''); - userData.fullname = escape(userData.fullname); - userData.location = escape(userData.location); - userData.signature = escape(userData.signature); - userData.birthday = validator.escape(String(userData.birthday || '')); - userData.moderationNote = validator.escape(String(userData.moderationNote || '')); + userData.fullname = escape(userData.fullname); + userData.location = escape(userData.location); + userData.signature = escape(userData.signature); + userData.birthday = validator.escape(String(userData.birthday || '')); + userData.moderationNote = validator.escape(String(userData.moderationNote || '')); - if (userData['cover:url']) { - userData['cover:url'] = userData['cover:url'].startsWith('http') ? userData['cover:url'] : (nconf.get('relative_path') + userData['cover:url']); - } else { - userData['cover:url'] = require('../../coverPhoto').getDefaultProfileCover(userData.uid); - } + if (userData['cover:url']) { + userData['cover:url'] = userData['cover:url'].startsWith('http') ? userData['cover:url'] : (nconf.get('relative_path') + userData['cover:url']); + } else { + userData['cover:url'] = require('../../coverPhoto').getDefaultProfileCover(userData.uid); + } - userData['cover:position'] = validator.escape(String(userData['cover:position'] || '50% 50%')); - userData['username:disableEdit'] = !userData.isAdmin && meta.config['username:disableEdit']; - userData['email:disableEdit'] = !userData.isAdmin && meta.config['email:disableEdit']; + userData['cover:position'] = validator.escape(String(userData['cover:position'] || '50% 50%')); + userData['username:disableEdit'] = !userData.isAdmin && meta.config['username:disableEdit']; + userData['email:disableEdit'] = !userData.isAdmin && meta.config['email:disableEdit']; - await getCounts(userData, callerUID); + await getCounts(userData, callerUID); - const hookData = await plugins.hooks.fire('filter:helpers.getUserDataByUserSlug', { - userData: userData, - callerUID: callerUID, - query: query, - }); - return hookData.userData; + const hookData = await plugins.hooks.fire('filter:helpers.getUserDataByUserSlug', { + userData, + callerUID, + query, + }); + return hookData.userData; }; function escape(value) { - return translator.escape(validator.escape(String(value || ''))); + return translator.escape(validator.escape(String(value || ''))); } async function getAllData(uid, callerUID) { - return await utils.promiseParallel({ - userData: user.getUserData(uid), - isTargetAdmin: user.isAdministrator(uid), - userSettings: user.getSettings(uid), - isAdmin: user.isAdministrator(callerUID), - isGlobalModerator: user.isGlobalModerator(callerUID), - isModerator: user.isModeratorOfAnyCategory(callerUID), - isFollowing: user.isFollowing(callerUID, uid), - ips: user.getIPs(uid, 4), - profile_menu: getProfileMenu(uid, callerUID), - groups: groups.getUserGroups([uid]), - sso: plugins.hooks.fire('filter:auth.list', { uid: uid, associations: [] }), - canEdit: privileges.users.canEdit(callerUID, uid), - canBanUser: privileges.users.canBanUser(callerUID, uid), - canMuteUser: privileges.users.canMuteUser(callerUID, uid), - isBlocked: user.blocks.is(uid, callerUID), - canViewInfo: privileges.global.can('view:users:info', callerUID), - hasPrivateChat: messaging.hasPrivateChat(callerUID, uid), - }); + return await utils.promiseParallel({ + userData: user.getUserData(uid), + isTargetAdmin: user.isAdministrator(uid), + userSettings: user.getSettings(uid), + isAdmin: user.isAdministrator(callerUID), + isGlobalModerator: user.isGlobalModerator(callerUID), + isModerator: user.isModeratorOfAnyCategory(callerUID), + isFollowing: user.isFollowing(callerUID, uid), + ips: user.getIPs(uid, 4), + profile_menu: getProfileMenu(uid, callerUID), + groups: groups.getUserGroups([uid]), + sso: plugins.hooks.fire('filter:auth.list', {uid, associations: []}), + canEdit: privileges.users.canEdit(callerUID, uid), + canBanUser: privileges.users.canBanUser(callerUID, uid), + canMuteUser: privileges.users.canMuteUser(callerUID, uid), + isBlocked: user.blocks.is(uid, callerUID), + canViewInfo: privileges.global.can('view:users:info', callerUID), + hasPrivateChat: messaging.hasPrivateChat(callerUID, uid), + }); } async function getCounts(userData, callerUID) { - const { uid } = userData; - const cids = await categories.getCidsByPrivilege('categories:cid', callerUID, 'topics:read'); - const promises = { - posts: db.sortedSetsCardSum(cids.map(c => `cid:${c}:uid:${uid}:pids`)), - best: Promise.all(cids.map(async c => db.sortedSetCount(`cid:${c}:uid:${uid}:pids:votes`, 1, '+inf'))), - controversial: Promise.all(cids.map(async c => db.sortedSetCount(`cid:${c}:uid:${uid}:pids:votes`, '-inf', -1))), - topics: db.sortedSetsCardSum(cids.map(c => `cid:${c}:uid:${uid}:tids`)), - }; - if (userData.isAdmin || userData.isSelf) { - promises.ignored = db.sortedSetCard(`uid:${uid}:ignored_tids`); - promises.watched = db.sortedSetCard(`uid:${uid}:followed_tids`); - promises.upvoted = db.sortedSetCard(`uid:${uid}:upvote`); - promises.downvoted = db.sortedSetCard(`uid:${uid}:downvote`); - promises.bookmarks = db.sortedSetCard(`uid:${uid}:bookmarks`); - promises.uploaded = db.sortedSetCard(`uid:${uid}:uploads`); - promises.categoriesWatched = user.getWatchedCategories(uid); - promises.blocks = user.getUserField(userData.uid, 'blocksCount'); - } - const counts = await utils.promiseParallel(promises); - counts.best = counts.best.reduce((sum, count) => sum + count, 0); - counts.controversial = counts.controversial.reduce((sum, count) => sum + count, 0); - counts.categoriesWatched = counts.categoriesWatched && counts.categoriesWatched.length; - counts.groups = userData.groups.length; - counts.following = userData.followingCount; - counts.followers = userData.followerCount; - userData.blocksCount = counts.blocks || 0; // for backwards compatibility, remove in 1.16.0 - userData.counts = counts; + const {uid} = userData; + const cids = await categories.getCidsByPrivilege('categories:cid', callerUID, 'topics:read'); + const promises = { + posts: db.sortedSetsCardSum(cids.map(c => `cid:${c}:uid:${uid}:pids`)), + best: Promise.all(cids.map(async c => db.sortedSetCount(`cid:${c}:uid:${uid}:pids:votes`, 1, '+inf'))), + controversial: Promise.all(cids.map(async c => db.sortedSetCount(`cid:${c}:uid:${uid}:pids:votes`, '-inf', -1))), + topics: db.sortedSetsCardSum(cids.map(c => `cid:${c}:uid:${uid}:tids`)), + }; + if (userData.isAdmin || userData.isSelf) { + promises.ignored = db.sortedSetCard(`uid:${uid}:ignored_tids`); + promises.watched = db.sortedSetCard(`uid:${uid}:followed_tids`); + promises.upvoted = db.sortedSetCard(`uid:${uid}:upvote`); + promises.downvoted = db.sortedSetCard(`uid:${uid}:downvote`); + promises.bookmarks = db.sortedSetCard(`uid:${uid}:bookmarks`); + promises.uploaded = db.sortedSetCard(`uid:${uid}:uploads`); + promises.categoriesWatched = user.getWatchedCategories(uid); + promises.blocks = user.getUserField(userData.uid, 'blocksCount'); + } + + const counts = await utils.promiseParallel(promises); + counts.best = counts.best.reduce((sum, count) => sum + count, 0); + counts.controversial = counts.controversial.reduce((sum, count) => sum + count, 0); + counts.categoriesWatched = counts.categoriesWatched && counts.categoriesWatched.length; + counts.groups = userData.groups.length; + counts.following = userData.followingCount; + counts.followers = userData.followerCount; + userData.blocksCount = counts.blocks || 0; // For backwards compatibility, remove in 1.16.0 + userData.counts = counts; } async function getProfileMenu(uid, callerUID) { - const links = [{ - id: 'info', - route: 'info', - name: '[[user:account_info]]', - icon: 'fa-info', - visibility: { - self: false, - other: false, - moderator: false, - globalMod: false, - admin: true, - canViewInfo: true, - }, - }, { - id: 'sessions', - route: 'sessions', - name: '[[pages:account/sessions]]', - icon: 'fa-group', - visibility: { - self: true, - other: false, - moderator: false, - globalMod: false, - admin: false, - canViewInfo: false, - }, - }]; + const links = [{ + id: 'info', + route: 'info', + name: '[[user:account_info]]', + icon: 'fa-info', + visibility: { + self: false, + other: false, + moderator: false, + globalMod: false, + admin: true, + canViewInfo: true, + }, + }, { + id: 'sessions', + route: 'sessions', + name: '[[pages:account/sessions]]', + icon: 'fa-group', + visibility: { + self: true, + other: false, + moderator: false, + globalMod: false, + admin: false, + canViewInfo: false, + }, + }]; - if (meta.config.gdpr_enabled) { - links.push({ - id: 'consent', - route: 'consent', - name: '[[user:consent.title]]', - icon: 'fa-thumbs-o-up', - visibility: { - self: true, - other: false, - moderator: false, - globalMod: false, - admin: false, - canViewInfo: false, - }, - }); - } + if (meta.config.gdpr_enabled) { + links.push({ + id: 'consent', + route: 'consent', + name: '[[user:consent.title]]', + icon: 'fa-thumbs-o-up', + visibility: { + self: true, + other: false, + moderator: false, + globalMod: false, + admin: false, + canViewInfo: false, + }, + }); + } - return await plugins.hooks.fire('filter:user.profileMenu', { - uid: uid, - callerUID: callerUID, - links: links, - }); + return await plugins.hooks.fire('filter:user.profileMenu', { + uid, + callerUID, + links, + }); } async function parseAboutMe(userData) { - if (!userData.aboutme) { - userData.aboutme = ''; - userData.aboutmeParsed = ''; - return; - } - userData.aboutme = validator.escape(String(userData.aboutme || '')); - const parsed = await plugins.hooks.fire('filter:parse.aboutme', userData.aboutme); - userData.aboutme = translator.escape(userData.aboutme); - userData.aboutmeParsed = translator.escape(parsed); + if (!userData.aboutme) { + userData.aboutme = ''; + userData.aboutmeParsed = ''; + return; + } + + userData.aboutme = validator.escape(String(userData.aboutme || '')); + const parsed = await plugins.hooks.fire('filter:parse.aboutme', userData.aboutme); + userData.aboutme = translator.escape(userData.aboutme); + userData.aboutmeParsed = translator.escape(parsed); } function filterLinks(links, states) { - return links.filter((link, index) => { - // Default visibility - link.visibility = { - self: true, - other: true, - moderator: true, - globalMod: true, - admin: true, - canViewInfo: true, - ...link.visibility, - }; + return links.filter((link, index) => { + // Default visibility + link.visibility = { + self: true, + other: true, + moderator: true, + globalMod: true, + admin: true, + canViewInfo: true, + ...link.visibility, + }; - const permit = Object.keys(states).some(state => states[state] && link.visibility[state]); + const permit = Object.keys(states).some(state => states[state] && link.visibility[state]); - links[index].public = permit; - return permit; - }); + links[index].public = permit; + return permit; + }); } require('../../promisify')(helpers); diff --git a/src/controllers/accounts/info.js b/src/controllers/accounts/info.js index 2954255..595abbb 100644 --- a/src/controllers/accounts/info.js +++ b/src/controllers/accounts/info.js @@ -3,52 +3,55 @@ const db = require('../../database'); const user = require('../../user'); const helpers = require('../helpers'); -const accountHelpers = require('./helpers'); const pagination = require('../../pagination'); +const accountHelpers = require('./helpers'); const infoController = module.exports; -infoController.get = async function (req, res, next) { - const userData = await accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, req.query); - if (!userData) { - return next(); - } - const page = Math.max(1, req.query.page || 1); - const itemsPerPage = 10; - const start = (page - 1) * itemsPerPage; - const stop = start + itemsPerPage - 1; - - const [history, sessions, usernames, emails, notes] = await Promise.all([ - user.getModerationHistory(userData.uid), - user.auth.getSessions(userData.uid, req.sessionID), - user.getHistory(`user:${userData.uid}:usernames`), - user.getHistory(`user:${userData.uid}:emails`), - getNotes(userData, start, stop), - ]); - - userData.history = history; - userData.sessions = sessions; - userData.usernames = usernames; - userData.emails = emails; - - if (userData.isAdminOrGlobalModeratorOrModerator) { - userData.moderationNotes = notes.notes; - const pageCount = Math.ceil(notes.count / itemsPerPage); - userData.pagination = pagination.create(page, pageCount, req.query); - } - userData.title = '[[pages:account/info]]'; - userData.breadcrumbs = helpers.buildBreadcrumbs([{ text: userData.username, url: `/user/${userData.userslug}` }, { text: '[[user:account_info]]' }]); - - res.render('account/info', userData); +infoController.get = async function (request, res, next) { + const userData = await accountHelpers.getUserDataByUserSlug(request.params.userslug, request.uid, request.query); + if (!userData) { + return next(); + } + + const page = Math.max(1, request.query.page || 1); + const itemsPerPage = 10; + const start = (page - 1) * itemsPerPage; + const stop = start + itemsPerPage - 1; + + const [history, sessions, usernames, emails, notes] = await Promise.all([ + user.getModerationHistory(userData.uid), + user.auth.getSessions(userData.uid, request.sessionID), + user.getHistory(`user:${userData.uid}:usernames`), + user.getHistory(`user:${userData.uid}:emails`), + getNotes(userData, start, stop), + ]); + + userData.history = history; + userData.sessions = sessions; + userData.usernames = usernames; + userData.emails = emails; + + if (userData.isAdminOrGlobalModeratorOrModerator) { + userData.moderationNotes = notes.notes; + const pageCount = Math.ceil(notes.count / itemsPerPage); + userData.pagination = pagination.create(page, pageCount, request.query); + } + + userData.title = '[[pages:account/info]]'; + userData.breadcrumbs = helpers.buildBreadcrumbs([{text: userData.username, url: `/user/${userData.userslug}`}, {text: '[[user:account_info]]'}]); + + res.render('account/info', userData); }; async function getNotes(userData, start, stop) { - if (!userData.isAdminOrGlobalModeratorOrModerator) { - return; - } - const [notes, count] = await Promise.all([ - user.getModerationNotes(userData.uid, start, stop), - db.sortedSetCard(`uid:${userData.uid}:moderation:notes`), - ]); - return { notes: notes, count: count }; + if (!userData.isAdminOrGlobalModeratorOrModerator) { + return; + } + + const [notes, count] = await Promise.all([ + user.getModerationNotes(userData.uid, start, stop), + db.sortedSetCard(`uid:${userData.uid}:moderation:notes`), + ]); + return {notes, count}; } diff --git a/src/controllers/accounts/notifications.js b/src/controllers/accounts/notifications.js index 02ca307..92c8c3d 100644 --- a/src/controllers/accounts/notifications.js +++ b/src/controllers/accounts/notifications.js @@ -7,66 +7,67 @@ const pagination = require('../../pagination'); const notificationsController = module.exports; -notificationsController.get = async function (req, res, next) { - const regularFilters = [ - { name: '[[notifications:all]]', filter: '' }, - { name: '[[global:topics]]', filter: 'new-topic' }, - { name: '[[notifications:replies]]', filter: 'new-reply' }, - { name: '[[notifications:chat]]', filter: 'new-chat' }, - { name: '[[notifications:group-chat]]', filter: 'new-group-chat' }, - { name: '[[notifications:follows]]', filter: 'follow' }, - { name: '[[notifications:upvote]]', filter: 'upvote' }, - ]; +notificationsController.get = async function (request, res, next) { + const regularFilters = [ + {name: '[[notifications:all]]', filter: ''}, + {name: '[[global:topics]]', filter: 'new-topic'}, + {name: '[[notifications:replies]]', filter: 'new-reply'}, + {name: '[[notifications:chat]]', filter: 'new-chat'}, + {name: '[[notifications:group-chat]]', filter: 'new-group-chat'}, + {name: '[[notifications:follows]]', filter: 'follow'}, + {name: '[[notifications:upvote]]', filter: 'upvote'}, + ]; - const moderatorFilters = [ - { name: '[[notifications:new-flags]]', filter: 'new-post-flag' }, - { name: '[[notifications:my-flags]]', filter: 'my-flags' }, - { name: '[[notifications:bans]]', filter: 'ban' }, - ]; + const moderatorFilters = [ + {name: '[[notifications:new-flags]]', filter: 'new-post-flag'}, + {name: '[[notifications:my-flags]]', filter: 'my-flags'}, + {name: '[[notifications:bans]]', filter: 'ban'}, + ]; - const filter = req.query.filter || ''; - const page = Math.max(1, req.query.page || 1); - const itemsPerPage = 20; - const start = (page - 1) * itemsPerPage; - const stop = start + itemsPerPage - 1; + const filter = request.query.filter || ''; + const page = Math.max(1, request.query.page || 1); + const itemsPerPage = 20; + const start = (page - 1) * itemsPerPage; + const stop = start + itemsPerPage - 1; - const [filters, isPrivileged] = await Promise.all([ - plugins.hooks.fire('filter:notifications.addFilters', { - regularFilters: regularFilters, - moderatorFilters: moderatorFilters, - uid: req.uid, - }), - user.isPrivileged(req.uid), - ]); + const [filters, isPrivileged] = await Promise.all([ + plugins.hooks.fire('filter:notifications.addFilters', { + regularFilters, + moderatorFilters, + uid: request.uid, + }), + user.isPrivileged(request.uid), + ]); - let allFilters = filters.regularFilters; - if (isPrivileged) { - allFilters = allFilters.concat([ - { separator: true }, - ]).concat(filters.moderatorFilters); - } - const selectedFilter = allFilters.find((filterData) => { - filterData.selected = filterData.filter === filter; - return filterData.selected; - }); - if (!selectedFilter) { - return next(); - } + let allFilters = filters.regularFilters; + if (isPrivileged) { + allFilters = allFilters.concat([ + {separator: true}, + ]).concat(filters.moderatorFilters); + } - const nids = await user.notifications.getAll(req.uid, selectedFilter.filter); - let notifications = await user.notifications.getNotifications(nids, req.uid); + const selectedFilter = allFilters.find(filterData => { + filterData.selected = filterData.filter === filter; + return filterData.selected; + }); + if (!selectedFilter) { + return next(); + } - const pageCount = Math.max(1, Math.ceil(notifications.length / itemsPerPage)); - notifications = notifications.slice(start, stop + 1); + const nids = await user.notifications.getAll(request.uid, selectedFilter.filter); + let notifications = await user.notifications.getNotifications(nids, request.uid); - res.render('notifications', { - notifications: notifications, - pagination: pagination.create(page, pageCount, req.query), - filters: allFilters, - regularFilters: regularFilters, - moderatorFilters: moderatorFilters, - selectedFilter: selectedFilter, - title: '[[pages:notifications]]', - breadcrumbs: helpers.buildBreadcrumbs([{ text: '[[pages:notifications]]' }]), - }); + const pageCount = Math.max(1, Math.ceil(notifications.length / itemsPerPage)); + notifications = notifications.slice(start, stop + 1); + + res.render('notifications', { + notifications, + pagination: pagination.create(page, pageCount, request.query), + filters: allFilters, + regularFilters, + moderatorFilters, + selectedFilter, + title: '[[pages:notifications]]', + breadcrumbs: helpers.buildBreadcrumbs([{text: '[[pages:notifications]]'}]), + }); }; diff --git a/src/controllers/accounts/posts.js b/src/controllers/accounts/posts.js index 27ab282..2e29e3f 100644 --- a/src/controllers/accounts/posts.js +++ b/src/controllers/accounts/posts.js @@ -8,247 +8,253 @@ const categories = require('../../categories'); const privileges = require('../../privileges'); const pagination = require('../../pagination'); const helpers = require('../helpers'); -const accountHelpers = require('./helpers'); const plugins = require('../../plugins'); const utils = require('../../utils'); +const accountHelpers = require('./helpers'); const postsController = module.exports; const templateToData = { - 'account/bookmarks': { - type: 'posts', - noItemsFoundKey: '[[topic:bookmarks.has_no_bookmarks]]', - crumb: '[[user:bookmarks]]', - getSets: function (callerUid, userData) { - return `uid:${userData.uid}:bookmarks`; - }, - }, - 'account/posts': { - type: 'posts', - noItemsFoundKey: '[[user:has_no_posts]]', - crumb: '[[global:posts]]', - getSets: async function (callerUid, userData) { - const cids = await categories.getCidsByPrivilege('categories:cid', callerUid, 'topics:read'); - return cids.map(c => `cid:${c}:uid:${userData.uid}:pids`); - }, - }, - 'account/upvoted': { - type: 'posts', - noItemsFoundKey: '[[user:has_no_upvoted_posts]]', - crumb: '[[global:upvoted]]', - getSets: function (callerUid, userData) { - return `uid:${userData.uid}:upvote`; - }, - }, - 'account/downvoted': { - type: 'posts', - noItemsFoundKey: '[[user:has_no_downvoted_posts]]', - crumb: '[[global:downvoted]]', - getSets: function (callerUid, userData) { - return `uid:${userData.uid}:downvote`; - }, - }, - 'account/best': { - type: 'posts', - noItemsFoundKey: '[[user:has_no_best_posts]]', - crumb: '[[global:best]]', - getSets: async function (callerUid, userData) { - const cids = await categories.getCidsByPrivilege('categories:cid', callerUid, 'topics:read'); - return cids.map(c => `cid:${c}:uid:${userData.uid}:pids:votes`); - }, - getTopics: async (sets, req, start, stop) => { - let pids = await db.getSortedSetRevRangeByScore(sets, start, stop - start + 1, '+inf', 1); - pids = await privileges.posts.filter('topics:read', pids, req.uid); - const postObjs = await posts.getPostSummaryByPids(pids, req.uid, { stripTags: false }); - return { posts: postObjs, nextStart: stop + 1 }; - }, - getItemCount: async (sets) => { - const counts = await Promise.all(sets.map(set => db.sortedSetCount(set, 1, '+inf'))); - return counts.reduce((acc, val) => acc + val, 0); - }, - }, - 'account/controversial': { - type: 'posts', - noItemsFoundKey: '[[user:has_no_controversial_posts]]', - crumb: '[[global:controversial]]', - getSets: async function (callerUid, userData) { - const cids = await categories.getCidsByPrivilege('categories:cid', callerUid, 'topics:read'); - return cids.map(c => `cid:${c}:uid:${userData.uid}:pids:votes`); - }, - getTopics: async (sets, req, start, stop) => { - let pids = await db.getSortedSetRangeByScore(sets, start, stop - start + 1, '-inf', -1); - pids = await privileges.posts.filter('topics:read', pids, req.uid); - const postObjs = await posts.getPostSummaryByPids(pids, req.uid, { stripTags: false }); - return { posts: postObjs, nextStart: stop + 1 }; - }, - getItemCount: async (sets) => { - const counts = await Promise.all(sets.map(set => db.sortedSetCount(set, '-inf', -1))); - return counts.reduce((acc, val) => acc + val, 0); - }, - }, - 'account/watched': { - type: 'topics', - noItemsFoundKey: '[[user:has_no_watched_topics]]', - crumb: '[[user:watched]]', - getSets: function (callerUid, userData) { - return `uid:${userData.uid}:followed_tids`; - }, - getTopics: async function (set, req, start, stop) { - const { sort } = req.query; - const map = { - votes: 'topics:votes', - posts: 'topics:posts', - views: 'topics:views', - lastpost: 'topics:recent', - firstpost: 'topics:tid', - }; - - if (!sort || !map[sort]) { - return await topics.getTopicsFromSet(set, req.uid, start, stop); - } - const sortSet = map[sort]; - let tids = await db.getSortedSetRevRange(set, 0, -1); - const scores = await db.sortedSetScores(sortSet, tids); - tids = tids.map((tid, i) => ({ tid: tid, score: scores[i] })) - .sort((a, b) => b.score - a.score) - .slice(start, stop + 1) - .map(t => t.tid); - - const topicsData = await topics.getTopics(tids, req.uid); - topics.calculateTopicIndices(topicsData, start); - return { topics: topicsData, nextStart: stop + 1 }; - }, - }, - 'account/ignored': { - type: 'topics', - noItemsFoundKey: '[[user:has_no_ignored_topics]]', - crumb: '[[user:ignored]]', - getSets: function (callerUid, userData) { - return `uid:${userData.uid}:ignored_tids`; - }, - }, - 'account/topics': { - type: 'topics', - noItemsFoundKey: '[[user:has_no_topics]]', - crumb: '[[global:topics]]', - getSets: async function (callerUid, userData) { - const cids = await categories.getCidsByPrivilege('categories:cid', callerUid, 'topics:read'); - return cids.map(c => `cid:${c}:uid:${userData.uid}:tids`); - }, - }, + 'account/bookmarks': { + type: 'posts', + noItemsFoundKey: '[[topic:bookmarks.has_no_bookmarks]]', + crumb: '[[user:bookmarks]]', + getSets(callerUid, userData) { + return `uid:${userData.uid}:bookmarks`; + }, + }, + 'account/posts': { + type: 'posts', + noItemsFoundKey: '[[user:has_no_posts]]', + crumb: '[[global:posts]]', + async getSets(callerUid, userData) { + const cids = await categories.getCidsByPrivilege('categories:cid', callerUid, 'topics:read'); + return cids.map(c => `cid:${c}:uid:${userData.uid}:pids`); + }, + }, + 'account/upvoted': { + type: 'posts', + noItemsFoundKey: '[[user:has_no_upvoted_posts]]', + crumb: '[[global:upvoted]]', + getSets(callerUid, userData) { + return `uid:${userData.uid}:upvote`; + }, + }, + 'account/downvoted': { + type: 'posts', + noItemsFoundKey: '[[user:has_no_downvoted_posts]]', + crumb: '[[global:downvoted]]', + getSets(callerUid, userData) { + return `uid:${userData.uid}:downvote`; + }, + }, + 'account/best': { + type: 'posts', + noItemsFoundKey: '[[user:has_no_best_posts]]', + crumb: '[[global:best]]', + async getSets(callerUid, userData) { + const cids = await categories.getCidsByPrivilege('categories:cid', callerUid, 'topics:read'); + return cids.map(c => `cid:${c}:uid:${userData.uid}:pids:votes`); + }, + async getTopics(sets, request, start, stop) { + let pids = await db.getSortedSetRevRangeByScore(sets, start, stop - start + 1, '+inf', 1); + pids = await privileges.posts.filter('topics:read', pids, request.uid); + const postObjs = await posts.getPostSummaryByPids(pids, request.uid, {stripTags: false}); + return {posts: postObjs, nextStart: stop + 1}; + }, + async getItemCount(sets) { + const counts = await Promise.all(sets.map(set => db.sortedSetCount(set, 1, '+inf'))); + return counts.reduce((accumulator, value) => accumulator + value, 0); + }, + }, + 'account/controversial': { + type: 'posts', + noItemsFoundKey: '[[user:has_no_controversial_posts]]', + crumb: '[[global:controversial]]', + async getSets(callerUid, userData) { + const cids = await categories.getCidsByPrivilege('categories:cid', callerUid, 'topics:read'); + return cids.map(c => `cid:${c}:uid:${userData.uid}:pids:votes`); + }, + async getTopics(sets, request, start, stop) { + let pids = await db.getSortedSetRangeByScore(sets, start, stop - start + 1, '-inf', -1); + pids = await privileges.posts.filter('topics:read', pids, request.uid); + const postObjs = await posts.getPostSummaryByPids(pids, request.uid, {stripTags: false}); + return {posts: postObjs, nextStart: stop + 1}; + }, + async getItemCount(sets) { + const counts = await Promise.all(sets.map(set => db.sortedSetCount(set, '-inf', -1))); + return counts.reduce((accumulator, value) => accumulator + value, 0); + }, + }, + 'account/watched': { + type: 'topics', + noItemsFoundKey: '[[user:has_no_watched_topics]]', + crumb: '[[user:watched]]', + getSets(callerUid, userData) { + return `uid:${userData.uid}:followed_tids`; + }, + async getTopics(set, request, start, stop) { + const {sort} = request.query; + const map = { + votes: 'topics:votes', + posts: 'topics:posts', + views: 'topics:views', + lastpost: 'topics:recent', + firstpost: 'topics:tid', + }; + + if (!sort || !map[sort]) { + return await topics.getTopicsFromSet(set, request.uid, start, stop); + } + + const sortSet = map[sort]; + let tids = await db.getSortedSetRevRange(set, 0, -1); + const scores = await db.sortedSetScores(sortSet, tids); + tids = tids.map((tid, i) => ({tid, score: scores[i]})) + .sort((a, b) => b.score - a.score) + .slice(start, stop + 1) + .map(t => t.tid); + + const topicsData = await topics.getTopics(tids, request.uid); + topics.calculateTopicIndices(topicsData, start); + return {topics: topicsData, nextStart: stop + 1}; + }, + }, + 'account/ignored': { + type: 'topics', + noItemsFoundKey: '[[user:has_no_ignored_topics]]', + crumb: '[[user:ignored]]', + getSets(callerUid, userData) { + return `uid:${userData.uid}:ignored_tids`; + }, + }, + 'account/topics': { + type: 'topics', + noItemsFoundKey: '[[user:has_no_topics]]', + crumb: '[[global:topics]]', + async getSets(callerUid, userData) { + const cids = await categories.getCidsByPrivilege('categories:cid', callerUid, 'topics:read'); + return cids.map(c => `cid:${c}:uid:${userData.uid}:tids`); + }, + }, }; -postsController.getBookmarks = async function (req, res, next) { - await getPostsFromUserSet('account/bookmarks', req, res, next); +postsController.getBookmarks = async function (request, res, next) { + await getPostsFromUserSet('account/bookmarks', request, res, next); }; -postsController.getPosts = async function (req, res, next) { - await getPostsFromUserSet('account/posts', req, res, next); +postsController.getPosts = async function (request, res, next) { + await getPostsFromUserSet('account/posts', request, res, next); }; -postsController.getUpVotedPosts = async function (req, res, next) { - await getPostsFromUserSet('account/upvoted', req, res, next); +postsController.getUpVotedPosts = async function (request, res, next) { + await getPostsFromUserSet('account/upvoted', request, res, next); }; -postsController.getDownVotedPosts = async function (req, res, next) { - await getPostsFromUserSet('account/downvoted', req, res, next); +postsController.getDownVotedPosts = async function (request, res, next) { + await getPostsFromUserSet('account/downvoted', request, res, next); }; -postsController.getBestPosts = async function (req, res, next) { - await getPostsFromUserSet('account/best', req, res, next); +postsController.getBestPosts = async function (request, res, next) { + await getPostsFromUserSet('account/best', request, res, next); }; -postsController.getControversialPosts = async function (req, res, next) { - await getPostsFromUserSet('account/controversial', req, res, next); +postsController.getControversialPosts = async function (request, res, next) { + await getPostsFromUserSet('account/controversial', request, res, next); }; -postsController.getWatchedTopics = async function (req, res, next) { - await getPostsFromUserSet('account/watched', req, res, next); +postsController.getWatchedTopics = async function (request, res, next) { + await getPostsFromUserSet('account/watched', request, res, next); }; -postsController.getIgnoredTopics = async function (req, res, next) { - await getPostsFromUserSet('account/ignored', req, res, next); +postsController.getIgnoredTopics = async function (request, res, next) { + await getPostsFromUserSet('account/ignored', request, res, next); }; -postsController.getTopics = async function (req, res, next) { - await getPostsFromUserSet('account/topics', req, res, next); +postsController.getTopics = async function (request, res, next) { + await getPostsFromUserSet('account/topics', request, res, next); }; -async function getPostsFromUserSet(template, req, res, next) { - const data = templateToData[template]; - const page = Math.max(1, parseInt(req.query.page, 10) || 1); - - const [userData, settings] = await Promise.all([ - accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, req.query), - user.getSettings(req.uid), - ]); - - if (!userData) { - return next(); - } - const itemsPerPage = data.type === 'topics' ? settings.topicsPerPage : settings.postsPerPage; - const start = (page - 1) * itemsPerPage; - const stop = start + itemsPerPage - 1; - const sets = await data.getSets(req.uid, userData); - let result; - if (plugins.hooks.hasListeners('filter:account.getPostsFromUserSet')) { - result = await plugins.hooks.fire('filter:account.getPostsFromUserSet', { - req: req, - template: template, - userData: userData, - settings: settings, - data: data, - start: start, - stop: stop, - itemCount: 0, - itemData: [], - }); - } else { - result = await utils.promiseParallel({ - itemCount: getItemCount(sets, data, settings), - itemData: getItemData(sets, data, req, start, stop), - }); - } - const { itemCount, itemData } = result; - userData[data.type] = itemData[data.type]; - userData.nextStart = itemData.nextStart; - - const pageCount = Math.ceil(itemCount / itemsPerPage); - userData.pagination = pagination.create(page, pageCount, req.query); - - userData.noItemsFoundKey = data.noItemsFoundKey; - userData.title = `[[pages:${template}, ${userData.username}]]`; - userData.breadcrumbs = helpers.buildBreadcrumbs([{ text: userData.username, url: `/user/${userData.userslug}` }, { text: data.crumb }]); - userData.showSort = template === 'account/watched'; - const baseUrl = (req.baseUrl + req.path.replace(/^\/api/, '')); - userData.sortOptions = [ - { url: `${baseUrl}?sort=votes`, name: '[[global:votes]]' }, - { url: `${baseUrl}?sort=posts`, name: '[[global:posts]]' }, - { url: `${baseUrl}?sort=views`, name: '[[global:views]]' }, - { url: `${baseUrl}?sort=lastpost`, name: '[[global:lastpost]]' }, - { url: `${baseUrl}?sort=firstpost`, name: '[[global:firstpost]]' }, - ]; - userData.sortOptions.forEach((option) => { - option.selected = option.url.includes(`sort=${req.query.sort}`); - }); - - res.render(template, userData); +async function getPostsFromUserSet(template, request, res, next) { + const data = templateToData[template]; + const page = Math.max(1, Number.parseInt(request.query.page, 10) || 1); + + const [userData, settings] = await Promise.all([ + accountHelpers.getUserDataByUserSlug(request.params.userslug, request.uid, request.query), + user.getSettings(request.uid), + ]); + + if (!userData) { + return next(); + } + + const itemsPerPage = data.type === 'topics' ? settings.topicsPerPage : settings.postsPerPage; + const start = (page - 1) * itemsPerPage; + const stop = start + itemsPerPage - 1; + const sets = await data.getSets(request.uid, userData); + let result; + if (plugins.hooks.hasListeners('filter:account.getPostsFromUserSet')) { + result = await plugins.hooks.fire('filter:account.getPostsFromUserSet', { + req: request, + template, + userData, + settings, + data, + start, + stop, + itemCount: 0, + itemData: [], + }); + } else { + result = await utils.promiseParallel({ + itemCount: getItemCount(sets, data, settings), + itemData: getItemData(sets, data, request, start, stop), + }); + } + + const {itemCount, itemData} = result; + userData[data.type] = itemData[data.type]; + userData.nextStart = itemData.nextStart; + + const pageCount = Math.ceil(itemCount / itemsPerPage); + userData.pagination = pagination.create(page, pageCount, request.query); + + userData.noItemsFoundKey = data.noItemsFoundKey; + userData.title = `[[pages:${template}, ${userData.username}]]`; + userData.breadcrumbs = helpers.buildBreadcrumbs([{text: userData.username, url: `/user/${userData.userslug}`}, {text: data.crumb}]); + userData.showSort = template === 'account/watched'; + const baseUrl = (request.baseUrl + request.path.replace(/^\/api/, '')); + userData.sortOptions = [ + {url: `${baseUrl}?sort=votes`, name: '[[global:votes]]'}, + {url: `${baseUrl}?sort=posts`, name: '[[global:posts]]'}, + {url: `${baseUrl}?sort=views`, name: '[[global:views]]'}, + {url: `${baseUrl}?sort=lastpost`, name: '[[global:lastpost]]'}, + {url: `${baseUrl}?sort=firstpost`, name: '[[global:firstpost]]'}, + ]; + for (const option of userData.sortOptions) { + option.selected = option.url.includes(`sort=${request.query.sort}`); + } + + res.render(template, userData); } -async function getItemData(sets, data, req, start, stop) { - if (data.getTopics) { - return await data.getTopics(sets, req, start, stop); - } - const method = data.type === 'topics' ? topics.getTopicsFromSet : posts.getPostSummariesFromSet; - return await method(sets, req.uid, start, stop); +async function getItemData(sets, data, request, start, stop) { + if (data.getTopics) { + return await data.getTopics(sets, request, start, stop); + } + + const method = data.type === 'topics' ? topics.getTopicsFromSet : posts.getPostSummariesFromSet; + return await method(sets, request.uid, start, stop); } async function getItemCount(sets, data, settings) { - if (!settings.usePagination) { - return 0; - } - if (data.getItemCount) { - return await data.getItemCount(sets); - } - return await db.sortedSetsCardSum(sets); + if (!settings.usePagination) { + return 0; + } + + if (data.getItemCount) { + return await data.getItemCount(sets); + } + + return await db.sortedSetsCardSum(sets); } diff --git a/src/controllers/accounts/profile.js b/src/controllers/accounts/profile.js index 8a3ab2d..0bc7f7e 100644 --- a/src/controllers/accounts/profile.js +++ b/src/controllers/accounts/profile.js @@ -2,7 +2,6 @@ const nconf = require('nconf'); const _ = require('lodash'); - const db = require('../../database'); const user = require('../../user'); const posts = require('../../posts'); @@ -10,160 +9,161 @@ const categories = require('../../categories'); const plugins = require('../../plugins'); const meta = require('../../meta'); const privileges = require('../../privileges'); -const accountHelpers = require('./helpers'); const helpers = require('../helpers'); const utils = require('../../utils'); +const accountHelpers = require('./helpers'); const profileController = module.exports; -profileController.get = async function (req, res, next) { - const lowercaseSlug = req.params.userslug.toLowerCase(); +profileController.get = async function (request, res, next) { + const lowercaseSlug = request.params.userslug.toLowerCase(); - if (req.params.userslug !== lowercaseSlug) { - if (res.locals.isAPI) { - req.params.userslug = lowercaseSlug; - } else { - return res.redirect(`${nconf.get('relative_path')}/user/${lowercaseSlug}`); - } - } + if (request.params.userslug !== lowercaseSlug) { + if (res.locals.isAPI) { + request.params.userslug = lowercaseSlug; + } else { + return res.redirect(`${nconf.get('relative_path')}/user/${lowercaseSlug}`); + } + } - const userData = await accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, req.query); - if (!userData) { - return next(); - } + const userData = await accountHelpers.getUserDataByUserSlug(request.params.userslug, request.uid, request.query); + if (!userData) { + return next(); + } - await incrementProfileViews(req, userData); + await incrementProfileViews(request, userData); - const [latestPosts, bestPosts] = await Promise.all([ - getLatestPosts(req.uid, userData), - getBestPosts(req.uid, userData), - posts.parseSignature(userData, req.uid), - ]); + const [latestPosts, bestPosts] = await Promise.all([ + getLatestPosts(request.uid, userData), + getBestPosts(request.uid, userData), + posts.parseSignature(userData, request.uid), + ]); - if (meta.config['reputation:disabled']) { - delete userData.reputation; - } + if (meta.config['reputation:disabled']) { + delete userData.reputation; + } - userData.posts = latestPosts; // for backwards compat. - userData.latestPosts = latestPosts; - userData.bestPosts = bestPosts; - userData.breadcrumbs = helpers.buildBreadcrumbs([{ text: userData.username }]); - userData.title = userData.username; - userData.allowCoverPicture = !userData.isSelf || !!meta.config['reputation:disabled'] || userData.reputation >= meta.config['min:rep:cover-picture']; + userData.posts = latestPosts; // For backwards compat. + userData.latestPosts = latestPosts; + userData.bestPosts = bestPosts; + userData.breadcrumbs = helpers.buildBreadcrumbs([{text: userData.username}]); + userData.title = userData.username; + userData.allowCoverPicture = !userData.isSelf || Boolean(meta.config['reputation:disabled']) || userData.reputation >= meta.config['min:rep:cover-picture']; - // Show email changed modal on first access after said change - userData.emailChanged = req.session.emailChanged; - delete req.session.emailChanged; + // Show email changed modal on first access after said change + userData.emailChanged = request.session.emailChanged; + delete request.session.emailChanged; - if (!userData.profileviews) { - userData.profileviews = 1; - } + userData.profileviews ||= 1; - addMetaTags(res, userData); + addMetaTags(res, userData); - userData.selectedGroup = userData.groups.filter(group => group && userData.groupTitleArray.includes(group.name)) - .sort((a, b) => userData.groupTitleArray.indexOf(a.name) - userData.groupTitleArray.indexOf(b.name)); + userData.selectedGroup = userData.groups.filter(group => group && userData.groupTitleArray.includes(group.name)) + .sort((a, b) => userData.groupTitleArray.indexOf(a.name) - userData.groupTitleArray.indexOf(b.name)); - res.render('account/profile', userData); + res.render('account/profile', userData); }; -async function incrementProfileViews(req, userData) { - if (req.uid >= 1) { - req.session.uids_viewed = req.session.uids_viewed || {}; - - if ( - req.uid !== userData.uid && - (!req.session.uids_viewed[userData.uid] || req.session.uids_viewed[userData.uid] < Date.now() - 3600000) - ) { - await user.incrementUserFieldBy(userData.uid, 'profileviews', 1); - req.session.uids_viewed[userData.uid] = Date.now(); - } - } +async function incrementProfileViews(request, userData) { + if (request.uid >= 1) { + request.session.uids_viewed = request.session.uids_viewed || {}; + + if ( + request.uid !== userData.uid + && (!request.session.uids_viewed[userData.uid] || request.session.uids_viewed[userData.uid] < Date.now() - 3_600_000) + ) { + await user.incrementUserFieldBy(userData.uid, 'profileviews', 1); + request.session.uids_viewed[userData.uid] = Date.now(); + } + } } async function getLatestPosts(callerUid, userData) { - return await getPosts(callerUid, userData, 'pids'); + return await getPosts(callerUid, userData, 'pids'); } async function getBestPosts(callerUid, userData) { - return await getPosts(callerUid, userData, 'pids:votes'); + return await getPosts(callerUid, userData, 'pids:votes'); } async function getPosts(callerUid, userData, setSuffix) { - const cids = await categories.getCidsByPrivilege('categories:cid', callerUid, 'topics:read'); - const keys = cids.map(c => `cid:${c}:uid:${userData.uid}:${setSuffix}`); - let hasMorePosts = true; - let start = 0; - const count = 10; - const postData = []; - - const [isAdmin, isModOfCids, canSchedule] = await Promise.all([ - user.isAdministrator(callerUid), - user.isModerator(callerUid, cids), - privileges.categories.isUserAllowedTo('topics:schedule', cids, callerUid), - ]); - const cidToIsMod = _.zipObject(cids, isModOfCids); - const cidToCanSchedule = _.zipObject(cids, canSchedule); - - do { - /* eslint-disable no-await-in-loop */ - let pids = await db.getSortedSetRevRange(keys, start, start + count - 1); - if (!pids.length || pids.length < count) { - hasMorePosts = false; - } - if (pids.length) { - ({ pids } = await plugins.hooks.fire('filter:account.profile.getPids', { - uid: callerUid, - userData, - setSuffix, - pids, - })); - const p = await posts.getPostSummaryByPids(pids, callerUid, { stripTags: false }); - postData.push(...p.filter( - p => p && p.topic && (isAdmin || cidToIsMod[p.topic.cid] || - (p.topic.scheduled && cidToCanSchedule[p.topic.cid]) || (!p.deleted && !p.topic.deleted)) - )); - } - start += count; - } while (postData.length < count && hasMorePosts); - return postData.slice(0, count); + const cids = await categories.getCidsByPrivilege('categories:cid', callerUid, 'topics:read'); + const keys = cids.map(c => `cid:${c}:uid:${userData.uid}:${setSuffix}`); + let hasMorePosts = true; + let start = 0; + const count = 10; + const postData = []; + + const [isAdmin, isModuleOfCids, canSchedule] = await Promise.all([ + user.isAdministrator(callerUid), + user.isModerator(callerUid, cids), + privileges.categories.isUserAllowedTo('topics:schedule', cids, callerUid), + ]); + const cidToIsModule = _.zipObject(cids, isModuleOfCids); + const cidToCanSchedule = _.zipObject(cids, canSchedule); + + do { + /* eslint-disable no-await-in-loop */ + let pids = await db.getSortedSetRevRange(keys, start, start + count - 1); + if (pids.length === 0 || pids.length < count) { + hasMorePosts = false; + } + + if (pids.length > 0) { + ({pids} = await plugins.hooks.fire('filter:account.profile.getPids', { + uid: callerUid, + userData, + setSuffix, + pids, + })); + const p = await posts.getPostSummaryByPids(pids, callerUid, {stripTags: false}); + postData.push(...p.filter( + p => p && p.topic && (isAdmin || cidToIsModule[p.topic.cid] + || (p.topic.scheduled && cidToCanSchedule[p.topic.cid]) || (!p.deleted && !p.topic.deleted)), + )); + } + + start += count; + } while (postData.length < count && hasMorePosts); + + return postData.slice(0, count); } function addMetaTags(res, userData) { - const plainAboutMe = userData.aboutme ? utils.stripHTMLTags(utils.decodeHTMLEntities(userData.aboutme)) : ''; - res.locals.metaTags = [ - { - name: 'title', - content: userData.fullname || userData.username, - noEscape: true, - }, - { - name: 'description', - content: plainAboutMe, - }, - { - property: 'og:title', - content: userData.fullname || userData.username, - noEscape: true, - }, - { - property: 'og:description', - content: plainAboutMe, - }, - ]; - - if (userData.picture) { - res.locals.metaTags.push( - { - property: 'og:image', - content: userData.picture, - noEscape: true, - }, - { - property: 'og:image:url', - content: userData.picture, - noEscape: true, - } - ); - } + const plainAboutMe = userData.aboutme ? utils.stripHTMLTags(utils.decodeHTMLEntities(userData.aboutme)) : ''; + res.locals.metaTags = [ + { + name: 'title', + content: userData.fullname || userData.username, + noEscape: true, + }, + { + name: 'description', + content: plainAboutMe, + }, + { + property: 'og:title', + content: userData.fullname || userData.username, + noEscape: true, + }, + { + property: 'og:description', + content: plainAboutMe, + }, + ]; + + if (userData.picture) { + res.locals.metaTags.push( + { + property: 'og:image', + content: userData.picture, + noEscape: true, + }, + { + property: 'og:image:url', + content: userData.picture, + noEscape: true, + }, + ); + } } diff --git a/src/controllers/accounts/sessions.js b/src/controllers/accounts/sessions.js index 88094f5..803875f 100644 --- a/src/controllers/accounts/sessions.js +++ b/src/controllers/accounts/sessions.js @@ -6,15 +6,15 @@ const accountHelpers = require('./helpers'); const sessionController = module.exports; -sessionController.get = async function (req, res, next) { - const userData = await accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, req.query); - if (!userData) { - return next(); - } +sessionController.get = async function (request, res, next) { + const userData = await accountHelpers.getUserDataByUserSlug(request.params.userslug, request.uid, request.query); + if (!userData) { + return next(); + } - userData.sessions = await user.auth.getSessions(userData.uid, req.sessionID); - userData.title = '[[pages:account/sessions]]'; - userData.breadcrumbs = helpers.buildBreadcrumbs([{ text: userData.username, url: `/user/${userData.userslug}` }, { text: '[[pages:account/sessions]]' }]); + userData.sessions = await user.auth.getSessions(userData.uid, request.sessionID); + userData.title = '[[pages:account/sessions]]'; + userData.breadcrumbs = helpers.buildBreadcrumbs([{text: userData.username, url: `/user/${userData.userslug}`}, {text: '[[pages:account/sessions]]'}]); - res.render('account/sessions', userData); + res.render('account/sessions', userData); }; diff --git a/src/controllers/accounts/settings.js b/src/controllers/accounts/settings.js index 4d2e4cf..b294751 100644 --- a/src/controllers/accounts/settings.js +++ b/src/controllers/accounts/settings.js @@ -1,11 +1,10 @@ 'use strict'; +const util = require('node:util'); const nconf = require('nconf'); const winston = require('winston'); const _ = require('lodash'); const jwt = require('jsonwebtoken'); -const util = require('util'); - const user = require('../../user'); const languages = require('../../languages'); const meta = require('../../meta'); @@ -17,227 +16,234 @@ const accountHelpers = require('./helpers'); const settingsController = module.exports; -settingsController.get = async function (req, res, next) { - const userData = await accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, req.query); - if (!userData) { - return next(); - } - const [settings, languagesData] = await Promise.all([ - user.getSettings(userData.uid), - languages.list(), - ]); - - userData.settings = settings; - userData.languages = languagesData; - if (userData.isAdmin && userData.isSelf) { - userData.acpLanguages = _.cloneDeep(languagesData); - } - - const data = await plugins.hooks.fire('filter:user.customSettings', { - settings: settings, - customSettings: [], - uid: req.uid, - }); - - const [notificationSettings, routes] = await Promise.all([ - getNotificationSettings(userData), - getHomePageRoutes(userData), - ]); - - userData.customSettings = data.customSettings; - userData.homePageRoutes = routes; - userData.notificationSettings = notificationSettings; - userData.disableEmailSubscriptions = meta.config.disableEmailSubscriptions; - - userData.dailyDigestFreqOptions = [ - { value: 'off', name: '[[user:digest_off]]', selected: userData.settings.dailyDigestFreq === 'off' }, - { value: 'day', name: '[[user:digest_daily]]', selected: userData.settings.dailyDigestFreq === 'day' }, - { value: 'week', name: '[[user:digest_weekly]]', selected: userData.settings.dailyDigestFreq === 'week' }, - { value: 'biweek', name: '[[user:digest_biweekly]]', selected: userData.settings.dailyDigestFreq === 'biweek' }, - { value: 'month', name: '[[user:digest_monthly]]', selected: userData.settings.dailyDigestFreq === 'month' }, - ]; - - userData.bootswatchSkinOptions = [ - { name: 'Default', value: '' }, - { name: 'Cerulean', value: 'cerulean' }, - { name: 'Cosmo', value: 'cosmo' }, - { name: 'Cyborg', value: 'cyborg' }, - { name: 'Darkly', value: 'darkly' }, - { name: 'Flatly', value: 'flatly' }, - { name: 'Journal', value: 'journal' }, - { name: 'Lumen', value: 'lumen' }, - { name: 'Paper', value: 'paper' }, - { name: 'Readable', value: 'readable' }, - { name: 'Sandstone', value: 'sandstone' }, - { name: 'Simplex', value: 'simplex' }, - { name: 'Slate', value: 'slate' }, - { name: 'Spacelab', value: 'spacelab' }, - { name: 'Superhero', value: 'superhero' }, - { name: 'United', value: 'united' }, - { name: 'Yeti', value: 'yeti' }, - ]; - - userData.bootswatchSkinOptions.forEach((skin) => { - skin.selected = skin.value === userData.settings.bootswatchSkin; - }); - - userData.languages.forEach((language) => { - language.selected = language.code === userData.settings.userLang; - }); - - if (userData.isAdmin && userData.isSelf) { - userData.acpLanguages.forEach((language) => { - language.selected = language.code === userData.settings.acpLang; - }); - } - - const notifFreqOptions = [ - 'all', - 'first', - 'everyTen', - 'threshold', - 'logarithmic', - 'disabled', - ]; - - userData.upvoteNotifFreq = notifFreqOptions.map( - name => ({ name: name, selected: name === userData.settings.upvoteNotifFreq }) - ); - - userData.categoryWatchState = { [userData.settings.categoryWatchState]: true }; - - userData.disableCustomUserSkins = meta.config.disableCustomUserSkins || 0; - - userData.allowUserHomePage = meta.config.allowUserHomePage === 1 ? 1 : 0; - - userData.hideFullname = meta.config.hideFullname || 0; - userData.hideEmail = meta.config.hideEmail || 0; - - userData.inTopicSearchAvailable = plugins.hooks.hasListeners('filter:topic.search'); - - userData.maxTopicsPerPage = meta.config.maxTopicsPerPage; - userData.maxPostsPerPage = meta.config.maxPostsPerPage; - - userData.title = '[[pages:account/settings]]'; - userData.breadcrumbs = helpers.buildBreadcrumbs([{ text: userData.username, url: `/user/${userData.userslug}` }, { text: '[[user:settings]]' }]); - - res.render('account/settings', userData); +settingsController.get = async function (request, res, next) { + const userData = await accountHelpers.getUserDataByUserSlug(request.params.userslug, request.uid, request.query); + if (!userData) { + return next(); + } + + const [settings, languagesData] = await Promise.all([ + user.getSettings(userData.uid), + languages.list(), + ]); + + userData.settings = settings; + userData.languages = languagesData; + if (userData.isAdmin && userData.isSelf) { + userData.acpLanguages = _.cloneDeep(languagesData); + } + + const data = await plugins.hooks.fire('filter:user.customSettings', { + settings, + customSettings: [], + uid: request.uid, + }); + + const [notificationSettings, routes] = await Promise.all([ + getNotificationSettings(userData), + getHomePageRoutes(userData), + ]); + + userData.customSettings = data.customSettings; + userData.homePageRoutes = routes; + userData.notificationSettings = notificationSettings; + userData.disableEmailSubscriptions = meta.config.disableEmailSubscriptions; + + userData.dailyDigestFreqOptions = [ + {value: 'off', name: '[[user:digest_off]]', selected: userData.settings.dailyDigestFreq === 'off'}, + {value: 'day', name: '[[user:digest_daily]]', selected: userData.settings.dailyDigestFreq === 'day'}, + {value: 'week', name: '[[user:digest_weekly]]', selected: userData.settings.dailyDigestFreq === 'week'}, + {value: 'biweek', name: '[[user:digest_biweekly]]', selected: userData.settings.dailyDigestFreq === 'biweek'}, + {value: 'month', name: '[[user:digest_monthly]]', selected: userData.settings.dailyDigestFreq === 'month'}, + ]; + + userData.bootswatchSkinOptions = [ + {name: 'Default', value: ''}, + {name: 'Cerulean', value: 'cerulean'}, + {name: 'Cosmo', value: 'cosmo'}, + {name: 'Cyborg', value: 'cyborg'}, + {name: 'Darkly', value: 'darkly'}, + {name: 'Flatly', value: 'flatly'}, + {name: 'Journal', value: 'journal'}, + {name: 'Lumen', value: 'lumen'}, + {name: 'Paper', value: 'paper'}, + {name: 'Readable', value: 'readable'}, + {name: 'Sandstone', value: 'sandstone'}, + {name: 'Simplex', value: 'simplex'}, + {name: 'Slate', value: 'slate'}, + {name: 'Spacelab', value: 'spacelab'}, + {name: 'Superhero', value: 'superhero'}, + {name: 'United', value: 'united'}, + {name: 'Yeti', value: 'yeti'}, + ]; + + for (const skin of userData.bootswatchSkinOptions) { + skin.selected = skin.value === userData.settings.bootswatchSkin; + } + + for (const language of userData.languages) { + language.selected = language.code === userData.settings.userLang; + } + + if (userData.isAdmin && userData.isSelf) { + for (const language of userData.acpLanguages) { + language.selected = language.code === userData.settings.acpLang; + } + } + + const notificationFreqOptions = [ + 'all', + 'first', + 'everyTen', + 'threshold', + 'logarithmic', + 'disabled', + ]; + + userData.upvoteNotifFreq = notificationFreqOptions.map( + name => ({name, selected: name === userData.settings.upvoteNotifFreq}), + ); + + userData.categoryWatchState = {[userData.settings.categoryWatchState]: true}; + + userData.disableCustomUserSkins = meta.config.disableCustomUserSkins || 0; + + userData.allowUserHomePage = meta.config.allowUserHomePage === 1 ? 1 : 0; + + userData.hideFullname = meta.config.hideFullname || 0; + userData.hideEmail = meta.config.hideEmail || 0; + + userData.inTopicSearchAvailable = plugins.hooks.hasListeners('filter:topic.search'); + + userData.maxTopicsPerPage = meta.config.maxTopicsPerPage; + userData.maxPostsPerPage = meta.config.maxPostsPerPage; + + userData.title = '[[pages:account/settings]]'; + userData.breadcrumbs = helpers.buildBreadcrumbs([{text: userData.username, url: `/user/${userData.userslug}`}, {text: '[[user:settings]]'}]); + + res.render('account/settings', userData); }; -const unsubscribable = ['digest', 'notification']; +const unsubscribable = new Set(['digest', 'notification']); const jwtVerifyAsync = util.promisify((token, callback) => { - jwt.verify(token, nconf.get('secret'), (err, payload) => callback(err, payload)); + jwt.verify(token, nconf.get('secret'), (error, payload) => callback(error, payload)); }); -const doUnsubscribe = async (payload) => { - if (payload.template === 'digest') { - await Promise.all([ - user.setSetting(payload.uid, 'dailyDigestFreq', 'off'), - user.updateDigestSetting(payload.uid, 'off'), - ]); - } else if (payload.template === 'notification') { - const current = await db.getObjectField(`user:${payload.uid}:settings`, `notificationType_${payload.type}`); - await user.setSetting(payload.uid, `notificationType_${payload.type}`, (current === 'notificationemail' ? 'notification' : 'none')); - } - return true; +const doUnsubscribe = async payload => { + if (payload.template === 'digest') { + await Promise.all([ + user.setSetting(payload.uid, 'dailyDigestFreq', 'off'), + user.updateDigestSetting(payload.uid, 'off'), + ]); + } else if (payload.template === 'notification') { + const current = await db.getObjectField(`user:${payload.uid}:settings`, `notificationType_${payload.type}`); + await user.setSetting(payload.uid, `notificationType_${payload.type}`, (current === 'notificationemail' ? 'notification' : 'none')); + } + + return true; }; -settingsController.unsubscribe = async (req, res) => { - try { - const payload = await jwtVerifyAsync(req.params.token); - if (!payload || !unsubscribable.includes(payload.template)) { - return; - } - await doUnsubscribe(payload); - res.render('unsubscribe', { - payload, - }); - } catch (err) { - res.render('unsubscribe', { - error: err.message, - }); - } +settingsController.unsubscribe = async (request, res) => { + try { + const payload = await jwtVerifyAsync(request.params.token); + if (!payload || !unsubscribable.has(payload.template)) { + return; + } + + await doUnsubscribe(payload); + res.render('unsubscribe', { + payload, + }); + } catch (error) { + res.render('unsubscribe', { + error: error.message, + }); + } }; -settingsController.unsubscribePost = async function (req, res) { - let payload; - try { - payload = await jwtVerifyAsync(req.params.token); - if (!payload || !unsubscribable.includes(payload.template)) { - return res.sendStatus(404); - } - } catch (err) { - return res.sendStatus(403); - } - try { - await doUnsubscribe(payload); - res.sendStatus(200); - } catch (err) { - winston.error(`[settings/unsubscribe] One-click unsubscribe failed with error: ${err.message}`); - res.sendStatus(500); - } +settingsController.unsubscribePost = async function (request, res) { + let payload; + try { + payload = await jwtVerifyAsync(request.params.token); + if (!payload || !unsubscribable.has(payload.template)) { + return res.sendStatus(404); + } + } catch { + return res.sendStatus(403); + } + + try { + await doUnsubscribe(payload); + res.sendStatus(200); + } catch (error) { + winston.error(`[settings/unsubscribe] One-click unsubscribe failed with error: ${error.message}`); + res.sendStatus(500); + } }; async function getNotificationSettings(userData) { - const privilegedTypes = []; - - const privileges = await user.getPrivileges(userData.uid); - if (privileges.isAdmin) { - privilegedTypes.push('notificationType_new-register'); - } - if (privileges.isAdmin || privileges.isGlobalMod || privileges.isModeratorOfAnyCategory) { - privilegedTypes.push('notificationType_post-queue', 'notificationType_new-post-flag'); - } - if (privileges.isAdmin || privileges.isGlobalMod) { - privilegedTypes.push('notificationType_new-user-flag'); - } - const results = await plugins.hooks.fire('filter:user.notificationTypes', { - types: notifications.baseTypes.slice(), - privilegedTypes: privilegedTypes, - }); - - function modifyType(type) { - const setting = userData.settings[type]; - return { - name: type, - label: `[[notifications:${type}]]`, - none: setting === 'none', - notification: setting === 'notification', - email: setting === 'email', - notificationemail: setting === 'notificationemail', - }; - } - - if (meta.config.disableChat) { - results.types = results.types.filter(type => type !== 'notificationType_new-chat'); - } - - return results.types.map(modifyType).concat(results.privilegedTypes.map(modifyType)); + const privilegedTypes = []; + + const privileges = await user.getPrivileges(userData.uid); + if (privileges.isAdmin) { + privilegedTypes.push('notificationType_new-register'); + } + + if (privileges.isAdmin || privileges.isGlobalMod || privileges.isModeratorOfAnyCategory) { + privilegedTypes.push('notificationType_post-queue', 'notificationType_new-post-flag'); + } + + if (privileges.isAdmin || privileges.isGlobalMod) { + privilegedTypes.push('notificationType_new-user-flag'); + } + + const results = await plugins.hooks.fire('filter:user.notificationTypes', { + types: notifications.baseTypes.slice(), + privilegedTypes, + }); + + function modifyType(type) { + const setting = userData.settings[type]; + return { + name: type, + label: `[[notifications:${type}]]`, + none: setting === 'none', + notification: setting === 'notification', + email: setting === 'email', + notificationemail: setting === 'notificationemail', + }; + } + + if (meta.config.disableChat) { + results.types = results.types.filter(type => type !== 'notificationType_new-chat'); + } + + return results.types.map(modifyType).concat(results.privilegedTypes.map(modifyType)); } async function getHomePageRoutes(userData) { - let routes = await helpers.getHomePageRoutes(userData.uid); - - // Set selected for each route - let customIdx; - let hasSelected = false; - routes = routes.map((route, idx) => { - if (route.route === userData.settings.homePageRoute) { - route.selected = true; - hasSelected = true; - } else { - route.selected = false; - } - - if (route.route === 'custom') { - customIdx = idx; - } - - return route; - }); - - if (!hasSelected && customIdx && userData.settings.homePageRoute !== 'none') { - routes[customIdx].selected = true; - } - - return routes; + let routes = await helpers.getHomePageRoutes(userData.uid); + + // Set selected for each route + let customIndex; + let hasSelected = false; + routes = routes.map((route, index) => { + if (route.route === userData.settings.homePageRoute) { + route.selected = true; + hasSelected = true; + } else { + route.selected = false; + } + + if (route.route === 'custom') { + customIndex = index; + } + + return route; + }); + + if (!hasSelected && customIndex && userData.settings.homePageRoute !== 'none') { + routes[customIndex].selected = true; + } + + return routes; } diff --git a/src/controllers/accounts/uploads.js b/src/controllers/accounts/uploads.js index a5b2917..89f1abb 100644 --- a/src/controllers/accounts/uploads.js +++ b/src/controllers/accounts/uploads.js @@ -1,9 +1,7 @@ 'use strict'; -const path = require('path'); - +const path = require('node:path'); const nconf = require('nconf'); - const db = require('../../database'); const helpers = require('../helpers'); const meta = require('../../meta'); @@ -12,29 +10,29 @@ const accountHelpers = require('./helpers'); const uploadsController = module.exports; -uploadsController.get = async function (req, res, next) { - const userData = await accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, req.query); - if (!userData) { - return next(); - } +uploadsController.get = async function (request, res, next) { + const userData = await accountHelpers.getUserDataByUserSlug(request.params.userslug, request.uid, request.query); + if (!userData) { + return next(); + } - const page = Math.max(1, parseInt(req.query.page, 10) || 1); - const itemsPerPage = 25; - const start = (page - 1) * itemsPerPage; - const stop = start + itemsPerPage - 1; - const [itemCount, uploadNames] = await Promise.all([ - db.sortedSetCard(`uid:${userData.uid}:uploads`), - db.getSortedSetRevRange(`uid:${userData.uid}:uploads`, start, stop), - ]); + const page = Math.max(1, Number.parseInt(request.query.page, 10) || 1); + const itemsPerPage = 25; + const start = (page - 1) * itemsPerPage; + const stop = start + itemsPerPage - 1; + const [itemCount, uploadNames] = await Promise.all([ + db.sortedSetCard(`uid:${userData.uid}:uploads`), + db.getSortedSetRevRange(`uid:${userData.uid}:uploads`, start, stop), + ]); - userData.uploads = uploadNames.map(uploadName => ({ - name: uploadName, - url: path.resolve(nconf.get('upload_url'), uploadName), - })); - const pageCount = Math.ceil(itemCount / itemsPerPage); - userData.pagination = pagination.create(page, pageCount, req.query); - userData.privateUploads = meta.config.privateUploads === 1; - userData.title = `[[pages:account/uploads, ${userData.username}]]`; - userData.breadcrumbs = helpers.buildBreadcrumbs([{ text: userData.username, url: `/user/${userData.userslug}` }, { text: '[[global:uploads]]' }]); - res.render('account/uploads', userData); + userData.uploads = uploadNames.map(uploadName => ({ + name: uploadName, + url: path.resolve(nconf.get('upload_url'), uploadName), + })); + const pageCount = Math.ceil(itemCount / itemsPerPage); + userData.pagination = pagination.create(page, pageCount, request.query); + userData.privateUploads = meta.config.privateUploads === 1; + userData.title = `[[pages:account/uploads, ${userData.username}]]`; + userData.breadcrumbs = helpers.buildBreadcrumbs([{text: userData.username, url: `/user/${userData.userslug}`}, {text: '[[global:uploads]]'}]); + res.render('account/uploads', userData); }; diff --git a/src/controllers/admin.js b/src/controllers/admin.js index 0fa1966..2531e0d 100644 --- a/src/controllers/admin.js +++ b/src/controllers/admin.js @@ -4,55 +4,69 @@ const privileges = require('../privileges'); const helpers = require('./helpers'); const adminController = { - dashboard: require('./admin/dashboard'), - categories: require('./admin/categories'), - privileges: require('./admin/privileges'), - adminsMods: require('./admin/admins-mods'), - tags: require('./admin/tags'), - groups: require('./admin/groups'), - digest: require('./admin/digest'), - appearance: require('./admin/appearance'), - extend: { - widgets: require('./admin/widgets'), - rewards: require('./admin/rewards'), - }, - events: require('./admin/events'), - hooks: require('./admin/hooks'), - logs: require('./admin/logs'), - errors: require('./admin/errors'), - database: require('./admin/database'), - cache: require('./admin/cache'), - plugins: require('./admin/plugins'), - settings: require('./admin/settings'), - logger: require('./admin/logger'), - themes: require('./admin/themes'), - users: require('./admin/users'), - uploads: require('./admin/uploads'), - info: require('./admin/info'), + dashboard: require('./admin/dashboard'), + categories: require('./admin/categories'), + privileges: require('./admin/privileges'), + adminsMods: require('./admin/admins-mods'), + tags: require('./admin/tags'), + groups: require('./admin/groups'), + digest: require('./admin/digest'), + appearance: require('./admin/appearance'), + extend: { + widgets: require('./admin/widgets'), + rewards: require('./admin/rewards'), + }, + events: require('./admin/events'), + hooks: require('./admin/hooks'), + logs: require('./admin/logs'), + errors: require('./admin/errors'), + database: require('./admin/database'), + cache: require('./admin/cache'), + plugins: require('./admin/plugins'), + settings: require('./admin/settings'), + logger: require('./admin/logger'), + themes: require('./admin/themes'), + users: require('./admin/users'), + uploads: require('./admin/uploads'), + info: require('./admin/info'), }; -adminController.routeIndex = async (req, res) => { - const privilegeSet = await privileges.admin.get(req.uid); - - if (privilegeSet.superadmin || privilegeSet['admin:dashboard']) { - return adminController.dashboard.get(req, res); - } else if (privilegeSet['admin:categories']) { - return helpers.redirect(res, 'admin/manage/categories'); - } else if (privilegeSet['admin:privileges']) { - return helpers.redirect(res, 'admin/manage/privileges'); - } else if (privilegeSet['admin:users']) { - return helpers.redirect(res, 'admin/manage/users'); - } else if (privilegeSet['admin:groups']) { - return helpers.redirect(res, 'admin/manage/groups'); - } else if (privilegeSet['admin:admins-mods']) { - return helpers.redirect(res, 'admin/manage/admins-mods'); - } else if (privilegeSet['admin:tags']) { - return helpers.redirect(res, 'admin/manage/tags'); - } else if (privilegeSet['admin:settings']) { - return helpers.redirect(res, 'admin/settings/general'); - } - - return helpers.notAllowed(req, res); +adminController.routeIndex = async (request, res) => { + const privilegeSet = await privileges.admin.get(request.uid); + + if (privilegeSet.superadmin || privilegeSet['admin:dashboard']) { + return adminController.dashboard.get(request, res); + } + + if (privilegeSet['admin:categories']) { + return helpers.redirect(res, 'admin/manage/categories'); + } + + if (privilegeSet['admin:privileges']) { + return helpers.redirect(res, 'admin/manage/privileges'); + } + + if (privilegeSet['admin:users']) { + return helpers.redirect(res, 'admin/manage/users'); + } + + if (privilegeSet['admin:groups']) { + return helpers.redirect(res, 'admin/manage/groups'); + } + + if (privilegeSet['admin:admins-mods']) { + return helpers.redirect(res, 'admin/manage/admins-mods'); + } + + if (privilegeSet['admin:tags']) { + return helpers.redirect(res, 'admin/manage/tags'); + } + + if (privilegeSet['admin:settings']) { + return helpers.redirect(res, 'admin/settings/general'); + } + + return helpers.notAllowed(request, res); }; module.exports = adminController; diff --git a/src/controllers/admin/admins-mods.js b/src/controllers/admin/admins-mods.js index 62b9142..7b73437 100644 --- a/src/controllers/admin/admins-mods.js +++ b/src/controllers/admin/admins-mods.js @@ -1,7 +1,6 @@ 'use strict'; const _ = require('lodash'); - const db = require('../../database'); const groups = require('../../groups'); const categories = require('../../categories'); @@ -12,50 +11,51 @@ const categoriesController = require('./categories'); const AdminsMods = module.exports; -AdminsMods.get = async function (req, res) { - const rootCid = parseInt(req.query.cid, 10) || 0; +AdminsMods.get = async function (request, res) { + const rootCid = Number.parseInt(request.query.cid, 10) || 0; - const cidsCount = await db.sortedSetCard(`cid:${rootCid}:children`); + const cidsCount = await db.sortedSetCard(`cid:${rootCid}:children`); - const pageCount = Math.max(1, Math.ceil(cidsCount / meta.config.categoriesPerPage)); - const page = Math.min(parseInt(req.query.page, 10) || 1, pageCount); - const start = Math.max(0, (page - 1) * meta.config.categoriesPerPage); - const stop = start + meta.config.categoriesPerPage - 1; + const pageCount = Math.max(1, Math.ceil(cidsCount / meta.config.categoriesPerPage)); + const page = Math.min(Number.parseInt(request.query.page, 10) || 1, pageCount); + const start = Math.max(0, (page - 1) * meta.config.categoriesPerPage); + const stop = start + meta.config.categoriesPerPage - 1; - const cids = await db.getSortedSetRange(`cid:${rootCid}:children`, start, stop); + const cids = await db.getSortedSetRange(`cid:${rootCid}:children`, start, stop); - const selectedCategory = rootCid ? await categories.getCategoryData(rootCid) : null; - const pageCategories = await categories.getCategoriesData(cids); + const selectedCategory = rootCid ? await categories.getCategoryData(rootCid) : null; + const pageCategories = await categories.getCategoriesData(cids); - const [admins, globalMods, moderators, crumbs] = await Promise.all([ - groups.get('administrators', { uid: req.uid }), - groups.get('Global Moderators', { uid: req.uid }), - getModeratorsOfCategories(pageCategories), - categoriesController.buildBreadCrumbs(selectedCategory, '/admin/manage/admins-mods'), - ]); + const [admins, globalMods, moderators, crumbs] = await Promise.all([ + groups.get('administrators', {uid: request.uid}), + groups.get('Global Moderators', {uid: request.uid}), + getModeratorsOfCategories(pageCategories), + categoriesController.buildBreadCrumbs(selectedCategory, '/admin/manage/admins-mods'), + ]); - res.render('admin/manage/admins-mods', { - admins: admins, - globalMods: globalMods, - categoryMods: moderators, - selectedCategory: selectedCategory, - pagination: pagination.create(page, pageCount, req.query), - breadcrumbs: crumbs, - }); + res.render('admin/manage/admins-mods', { + admins, + globalMods, + categoryMods: moderators, + selectedCategory, + pagination: pagination.create(page, pageCount, request.query), + breadcrumbs: crumbs, + }); }; async function getModeratorsOfCategories(categoryData) { - const [moderatorUids, childrenCounts] = await Promise.all([ - categories.getModeratorUids(categoryData.map(c => c.cid)), - db.sortedSetsCard(categoryData.map(c => `cid:${c.cid}:children`)), - ]); - - const uids = _.uniq(_.flatten(moderatorUids)); - const moderatorData = await user.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture']); - const moderatorMap = _.zipObject(uids, moderatorData); - categoryData.forEach((c, index) => { - c.moderators = moderatorUids[index].map(uid => moderatorMap[uid]); - c.subCategoryCount = childrenCounts[index]; - }); - return categoryData; + const [moderatorUids, childrenCounts] = await Promise.all([ + categories.getModeratorUids(categoryData.map(c => c.cid)), + db.sortedSetsCard(categoryData.map(c => `cid:${c.cid}:children`)), + ]); + + const uids = _.uniq(moderatorUids.flat()); + const moderatorData = await user.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture']); + const moderatorMap = _.zipObject(uids, moderatorData); + for (const [index, c] of categoryData.entries()) { + c.moderators = moderatorUids[index].map(uid => moderatorMap[uid]); + c.subCategoryCount = childrenCounts[index]; + } + + return categoryData; } diff --git a/src/controllers/admin/appearance.js b/src/controllers/admin/appearance.js index d77dc75..27f56c1 100644 --- a/src/controllers/admin/appearance.js +++ b/src/controllers/admin/appearance.js @@ -2,8 +2,8 @@ const appearanceController = module.exports; -appearanceController.get = function (req, res) { - const term = req.params.term ? req.params.term : 'themes'; +appearanceController.get = function (request, res) { + const term = request.params.term ? request.params.term : 'themes'; - res.render(`admin/appearance/${term}`, {}); + res.render(`admin/appearance/${term}`, {}); }; diff --git a/src/controllers/admin/cache.js b/src/controllers/admin/cache.js index 6f5775a..155dde5 100644 --- a/src/controllers/admin/cache.js +++ b/src/controllers/admin/cache.js @@ -5,63 +5,66 @@ const cacheController = module.exports; const utils = require('../../utils'); const plugins = require('../../plugins'); -cacheController.get = async function (req, res) { - const postCache = require('../../posts/cache'); - const groupCache = require('../../groups').cache; - const { objectCache } = require('../../database'); - const localCache = require('../../cache'); +cacheController.get = async function (request, res) { + const postCache = require('../../posts/cache'); + const groupCache = require('../../groups').cache; + const {objectCache} = require('../../database'); + const localCache = require('../../cache'); - function getInfo(cache) { - return { - length: cache.length, - max: cache.max, - maxSize: cache.maxSize, - itemCount: cache.itemCount, - percentFull: cache.name === 'post' ? - ((cache.length / cache.maxSize) * 100).toFixed(2) : - ((cache.itemCount / cache.max) * 100).toFixed(2), - hits: utils.addCommas(String(cache.hits)), - misses: utils.addCommas(String(cache.misses)), - hitRatio: ((cache.hits / (cache.hits + cache.misses) || 0)).toFixed(4), - enabled: cache.enabled, - ttl: cache.ttl, - }; - } - let caches = { - post: postCache, - group: groupCache, - local: localCache, - }; - if (objectCache) { - caches.object = objectCache; - } - caches = await plugins.hooks.fire('filter:admin.cache.get', caches); - for (const [key, value] of Object.entries(caches)) { - caches[key] = getInfo(value); - } + function getInfo(cache) { + return { + length: cache.length, + max: cache.max, + maxSize: cache.maxSize, + itemCount: cache.itemCount, + percentFull: cache.name === 'post' + ? ((cache.length / cache.maxSize) * 100).toFixed(2) + : ((cache.itemCount / cache.max) * 100).toFixed(2), + hits: utils.addCommas(String(cache.hits)), + misses: utils.addCommas(String(cache.misses)), + hitRatio: ((cache.hits / (cache.hits + cache.misses) || 0)).toFixed(4), + enabled: cache.enabled, + ttl: cache.ttl, + }; + } - res.render('admin/advanced/cache', { caches }); + let caches = { + post: postCache, + group: groupCache, + local: localCache, + }; + if (objectCache) { + caches.object = objectCache; + } + + caches = await plugins.hooks.fire('filter:admin.cache.get', caches); + for (const [key, value] of Object.entries(caches)) { + caches[key] = getInfo(value); + } + + res.render('admin/advanced/cache', {caches}); }; -cacheController.dump = async function (req, res, next) { - let caches = { - post: require('../../posts/cache'), - object: require('../../database').objectCache, - group: require('../../groups').cache, - local: require('../../cache'), - }; - caches = await plugins.hooks.fire('filter:admin.cache.get', caches); - if (!caches[req.query.name]) { - return next(); - } +cacheController.dump = async function (request, res, next) { + let caches = { + post: require('../../posts/cache'), + object: require('../../database').objectCache, + group: require('../../groups').cache, + local: require('../../cache'), + }; + caches = await plugins.hooks.fire('filter:admin.cache.get', caches); + if (!caches[request.query.name]) { + return next(); + } + + const data = JSON.stringify(caches[request.query.name].dump(), null, 4); + res.setHeader('Content-disposition', `attachment; filename= ${request.query.name}-cache.json`); + res.setHeader('Content-type', 'application/json'); + res.write(data, error => { + if (error) { + return next(error); + } - const data = JSON.stringify(caches[req.query.name].dump(), null, 4); - res.setHeader('Content-disposition', `attachment; filename= ${req.query.name}-cache.json`); - res.setHeader('Content-type', 'application/json'); - res.write(data, (err) => { - if (err) { - return next(err); - } - res.end(); - }); + res.end(); + }); }; diff --git a/src/controllers/admin/categories.js b/src/controllers/admin/categories.js index d70acbb..2405b83 100644 --- a/src/controllers/admin/categories.js +++ b/src/controllers/admin/categories.js @@ -12,132 +12,146 @@ const pagination = require('../../pagination'); const categoriesController = module.exports; -categoriesController.get = async function (req, res, next) { - const [categoryData, parent, selectedData] = await Promise.all([ - categories.getCategories([req.params.category_id], req.uid), - categories.getParents([req.params.category_id]), - helpers.getSelectedCategory(req.params.category_id), - ]); - - const category = categoryData[0]; - if (!category) { - return next(); - } - - category.parent = parent[0]; - - const data = await plugins.hooks.fire('filter:admin.category.get', { - req: req, - res: res, - category: category, - customClasses: [], - }); - data.category.name = translator.escape(String(data.category.name)); - data.category.description = translator.escape(String(data.category.description)); - - res.render('admin/manage/category', { - category: data.category, - selectedCategory: selectedData.selectedCategory, - customClasses: data.customClasses, - postQueueEnabled: !!meta.config.postQueue, - }); +categoriesController.get = async function (request, res, next) { + const [categoryData, parent, selectedData] = await Promise.all([ + categories.getCategories([request.params.category_id], request.uid), + categories.getParents([request.params.category_id]), + helpers.getSelectedCategory(request.params.category_id), + ]); + + const category = categoryData[0]; + if (!category) { + return next(); + } + + category.parent = parent[0]; + + const data = await plugins.hooks.fire('filter:admin.category.get', { + req: request, + res, + category, + customClasses: [], + }); + data.category.name = translator.escape(String(data.category.name)); + data.category.description = translator.escape(String(data.category.description)); + + res.render('admin/manage/category', { + category: data.category, + selectedCategory: selectedData.selectedCategory, + customClasses: data.customClasses, + postQueueEnabled: Boolean(meta.config.postQueue), + }); }; -categoriesController.getAll = async function (req, res) { - const rootCid = parseInt(req.query.cid, 10) || 0; - async function getRootAndChildren() { - const rootChildren = await categories.getAllCidsFromSet(`cid:${rootCid}:children`); - const childCids = _.flatten(await Promise.all(rootChildren.map(cid => categories.getChildrenCids(cid)))); - return [rootCid].concat(rootChildren.concat(childCids)); - } - - // Categories list will be rendered on client side with recursion, etc. - const cids = await (rootCid ? getRootAndChildren() : categories.getAllCidsFromSet('categories:cid')); - - let rootParent = 0; - if (rootCid) { - rootParent = await categories.getCategoryField(rootCid, 'parentCid') || 0; - } - - const fields = [ - 'cid', 'name', 'icon', 'parentCid', 'disabled', 'link', 'order', - 'color', 'bgColor', 'backgroundImage', 'imageClass', 'subCategoriesPerPage', - ]; - const categoriesData = await categories.getCategoriesFields(cids, fields); - const result = await plugins.hooks.fire('filter:admin.categories.get', { categories: categoriesData, fields: fields }); - let tree = categories.getTree(result.categories, rootParent); - const cidsCount = rootCid && tree[0] ? tree[0].children.length : tree.length; - - const pageCount = Math.max(1, Math.ceil(cidsCount / meta.config.categoriesPerPage)); - const page = Math.min(parseInt(req.query.page, 10) || 1, pageCount); - const start = Math.max(0, (page - 1) * meta.config.categoriesPerPage); - const stop = start + meta.config.categoriesPerPage; - - function trim(c) { - if (c.children) { - c.subCategoriesLeft = Math.max(0, c.children.length - c.subCategoriesPerPage); - c.hasMoreSubCategories = c.children.length > c.subCategoriesPerPage; - c.showMorePage = Math.ceil(c.subCategoriesPerPage / meta.config.categoriesPerPage); - c.children = c.children.slice(0, c.subCategoriesPerPage); - c.children.forEach(c => trim(c)); - } - } - if (rootCid && tree[0] && Array.isArray(tree[0].children)) { - tree[0].children = tree[0].children.slice(start, stop); - tree[0].children.forEach(trim); - } else { - tree = tree.slice(start, stop); - tree.forEach(trim); - } - - let selectedCategory; - if (rootCid) { - selectedCategory = await categories.getCategoryData(rootCid); - } - const crumbs = await buildBreadcrumbs(selectedCategory, '/admin/manage/categories'); - res.render('admin/manage/categories', { - categoriesTree: tree, - selectedCategory: selectedCategory, - breadcrumbs: crumbs, - pagination: pagination.create(page, pageCount, req.query), - categoriesPerPage: meta.config.categoriesPerPage, - }); +categoriesController.getAll = async function (request, res) { + const rootCid = Number.parseInt(request.query.cid, 10) || 0; + async function getRootAndChildren() { + const rootChildren = await categories.getAllCidsFromSet(`cid:${rootCid}:children`); + const childCids = (await Promise.all(rootChildren.map(cid => categories.getChildrenCids(cid)))).flat(); + return [rootCid].concat(rootChildren.concat(childCids)); + } + + // Categories list will be rendered on client side with recursion, etc. + const cids = await (rootCid ? getRootAndChildren() : categories.getAllCidsFromSet('categories:cid')); + + let rootParent = 0; + if (rootCid) { + rootParent = await categories.getCategoryField(rootCid, 'parentCid') || 0; + } + + const fields = [ + 'cid', + 'name', + 'icon', + 'parentCid', + 'disabled', + 'link', + 'order', + 'color', + 'bgColor', + 'backgroundImage', + 'imageClass', + 'subCategoriesPerPage', + ]; + const categoriesData = await categories.getCategoriesFields(cids, fields); + const result = await plugins.hooks.fire('filter:admin.categories.get', {categories: categoriesData, fields}); + let tree = categories.getTree(result.categories, rootParent); + const cidsCount = rootCid && tree[0] ? tree[0].children.length : tree.length; + + const pageCount = Math.max(1, Math.ceil(cidsCount / meta.config.categoriesPerPage)); + const page = Math.min(Number.parseInt(request.query.page, 10) || 1, pageCount); + const start = Math.max(0, (page - 1) * meta.config.categoriesPerPage); + const stop = start + meta.config.categoriesPerPage; + + function trim(c) { + if (c.children) { + c.subCategoriesLeft = Math.max(0, c.children.length - c.subCategoriesPerPage); + c.hasMoreSubCategories = c.children.length > c.subCategoriesPerPage; + c.showMorePage = Math.ceil(c.subCategoriesPerPage / meta.config.categoriesPerPage); + c.children = c.children.slice(0, c.subCategoriesPerPage); + c.children.forEach(c => trim(c)); + } + } + + if (rootCid && tree[0] && Array.isArray(tree[0].children)) { + tree[0].children = tree[0].children.slice(start, stop); + tree[0].children.forEach(trim); + } else { + tree = tree.slice(start, stop); + tree.forEach(trim); + } + + let selectedCategory; + if (rootCid) { + selectedCategory = await categories.getCategoryData(rootCid); + } + + const crumbs = await buildBreadcrumbs(selectedCategory, '/admin/manage/categories'); + res.render('admin/manage/categories', { + categoriesTree: tree, + selectedCategory, + breadcrumbs: crumbs, + pagination: pagination.create(page, pageCount, request.query), + categoriesPerPage: meta.config.categoriesPerPage, + }); }; async function buildBreadcrumbs(categoryData, url) { - if (!categoryData) { - return; - } - const breadcrumbs = [ - { - text: categoryData.name, - url: `${nconf.get('relative_path')}${url}?cid=${categoryData.cid}`, - cid: categoryData.cid, - }, - ]; - const allCrumbs = await helpers.buildCategoryBreadcrumbs(categoryData.parentCid); - const crumbs = allCrumbs.filter(c => c.cid); - - crumbs.forEach((c) => { - c.url = `${url}?cid=${c.cid}`; - }); - crumbs.unshift({ - text: '[[admin/manage/categories:top-level]]', - url: url, - }); - - return crumbs.concat(breadcrumbs); + if (!categoryData) { + return; + } + + const breadcrumbs = [ + { + text: categoryData.name, + url: `${nconf.get('relative_path')}${url}?cid=${categoryData.cid}`, + cid: categoryData.cid, + }, + ]; + const allCrumbs = await helpers.buildCategoryBreadcrumbs(categoryData.parentCid); + const crumbs = allCrumbs.filter(c => c.cid); + + for (const c of crumbs) { + c.url = `${url}?cid=${c.cid}`; + } + + crumbs.unshift({ + text: '[[admin/manage/categories:top-level]]', + url, + }); + + return crumbs.concat(breadcrumbs); } categoriesController.buildBreadCrumbs = buildBreadcrumbs; -categoriesController.getAnalytics = async function (req, res) { - const [name, analyticsData] = await Promise.all([ - categories.getCategoryField(req.params.category_id, 'name'), - analytics.getCategoryAnalytics(req.params.category_id), - ]); - res.render('admin/manage/category-analytics', { - name: name, - analytics: analyticsData, - }); +categoriesController.getAnalytics = async function (request, res) { + const [name, analyticsData] = await Promise.all([ + categories.getCategoryField(request.params.category_id, 'name'), + analytics.getCategoryAnalytics(request.params.category_id), + ]); + res.render('admin/manage/category-analytics', { + name, + analytics: analyticsData, + }); }; diff --git a/src/controllers/admin/dashboard.js b/src/controllers/admin/dashboard.js index d35063b..73d005c 100644 --- a/src/controllers/admin/dashboard.js +++ b/src/controllers/admin/dashboard.js @@ -5,7 +5,6 @@ const semver = require('semver'); const winston = require('winston'); const _ = require('lodash'); const validator = require('validator'); - const versions = require('../../admin/versions'); const db = require('../../database'); const meta = require('../../meta'); @@ -18,327 +17,333 @@ const emailer = require('../../emailer'); const dashboardController = module.exports; -dashboardController.get = async function (req, res) { - const [stats, notices, latestVersion, lastrestart, isAdmin, popularSearches] = await Promise.all([ - getStats(), - getNotices(), - getLatestVersion(), - getLastRestart(), - user.isAdministrator(req.uid), - getPopularSearches(), - ]); - const version = nconf.get('version'); - - res.render('admin/dashboard', { - version: version, - lookupFailed: latestVersion === null, - latestVersion: latestVersion, - upgradeAvailable: latestVersion && semver.gt(latestVersion, version), - currentPrerelease: versions.isPrerelease.test(version), - notices: notices, - stats: stats, - canRestart: !!process.send, - lastrestart: lastrestart, - showSystemControls: isAdmin, - popularSearches: popularSearches, - }); +dashboardController.get = async function (request, res) { + const [stats, notices, latestVersion, lastrestart, isAdmin, popularSearches] = await Promise.all([ + getStats(), + getNotices(), + getLatestVersion(), + getLastRestart(), + user.isAdministrator(request.uid), + getPopularSearches(), + ]); + const version = nconf.get('version'); + + res.render('admin/dashboard', { + version, + lookupFailed: latestVersion === null, + latestVersion, + upgradeAvailable: latestVersion && semver.gt(latestVersion, version), + currentPrerelease: versions.isPrerelease.test(version), + notices, + stats, + canRestart: Boolean(process.send), + lastrestart, + showSystemControls: isAdmin, + popularSearches, + }); }; async function getNotices() { - const notices = [ - { - done: !meta.reloadRequired, - doneText: '[[admin/dashboard:restart-not-required]]', - notDoneText: '[[admin/dashboard:restart-required]]', - }, - { - done: plugins.hooks.hasListeners('filter:search.query'), - doneText: '[[admin/dashboard:search-plugin-installed]]', - notDoneText: '[[admin/dashboard:search-plugin-not-installed]]', - tooltip: '[[admin/dashboard:search-plugin-tooltip]]', - link: '/admin/extend/plugins', - }, - ]; - - if (emailer.fallbackNotFound) { - notices.push({ - done: false, - notDoneText: '[[admin/dashboard:fallback-emailer-not-found]]', - }); - } - - if (global.env !== 'production') { - notices.push({ - done: false, - notDoneText: '[[admin/dashboard:running-in-development]]', - }); - } - - return await plugins.hooks.fire('filter:admin.notices', notices); + const notices = [ + { + done: !meta.reloadRequired, + doneText: '[[admin/dashboard:restart-not-required]]', + notDoneText: '[[admin/dashboard:restart-required]]', + }, + { + done: plugins.hooks.hasListeners('filter:search.query'), + doneText: '[[admin/dashboard:search-plugin-installed]]', + notDoneText: '[[admin/dashboard:search-plugin-not-installed]]', + tooltip: '[[admin/dashboard:search-plugin-tooltip]]', + link: '/admin/extend/plugins', + }, + ]; + + if (emailer.fallbackNotFound) { + notices.push({ + done: false, + notDoneText: '[[admin/dashboard:fallback-emailer-not-found]]', + }); + } + + if (global.env !== 'production') { + notices.push({ + done: false, + notDoneText: '[[admin/dashboard:running-in-development]]', + }); + } + + return await plugins.hooks.fire('filter:admin.notices', notices); } async function getLatestVersion() { - try { - return await versions.getLatestVersion(); - } catch (err) { - winston.error(`[acp] Failed to fetch latest version\n${err.stack}`); - } - return null; + try { + return await versions.getLatestVersion(); + } catch (error) { + winston.error(`[acp] Failed to fetch latest version\n${error.stack}`); + } + + return null; } -dashboardController.getAnalytics = async (req, res, next) => { - // Basic validation - const validUnits = ['days', 'hours']; - const validSets = ['uniquevisitors', 'pageviews', 'pageviews:registered', 'pageviews:bot', 'pageviews:guest']; - const until = req.query.until ? new Date(parseInt(req.query.until, 10)) : Date.now(); - const count = req.query.count || (req.query.units === 'hours' ? 24 : 30); - if (isNaN(until) || !validUnits.includes(req.query.units)) { - return next(new Error('[[error:invalid-data]]')); - } - - // Filter out invalid sets, if no sets, assume all sets - let sets; - if (req.query.sets) { - sets = Array.isArray(req.query.sets) ? req.query.sets : [req.query.sets]; - sets = sets.filter(set => validSets.includes(set)); - } else { - sets = validSets; - } - - const method = req.query.units === 'days' ? analytics.getDailyStatsForSet : analytics.getHourlyStatsForSet; - let payload = await Promise.all(sets.map(set => method(`analytics:${set}`, until, count))); - payload = _.zipObject(sets, payload); - - res.json({ - query: { - set: req.query.set, - units: req.query.units, - until: until, - count: count, - }, - result: payload, - }); +dashboardController.getAnalytics = async (request, res, next) => { + // Basic validation + const validUnits = ['days', 'hours']; + const validSets = ['uniquevisitors', 'pageviews', 'pageviews:registered', 'pageviews:bot', 'pageviews:guest']; + const until = request.query.until ? new Date(Number.parseInt(request.query.until, 10)) : Date.now(); + const count = request.query.count || (request.query.units === 'hours' ? 24 : 30); + if (isNaN(until) || !validUnits.includes(request.query.units)) { + return next(new Error('[[error:invalid-data]]')); + } + + // Filter out invalid sets, if no sets, assume all sets + let sets; + if (request.query.sets) { + sets = Array.isArray(request.query.sets) ? request.query.sets : [request.query.sets]; + sets = sets.filter(set => validSets.includes(set)); + } else { + sets = validSets; + } + + const method = request.query.units === 'days' ? analytics.getDailyStatsForSet : analytics.getHourlyStatsForSet; + let payload = await Promise.all(sets.map(set => method(`analytics:${set}`, until, count))); + payload = _.zipObject(sets, payload); + + res.json({ + query: { + set: request.query.set, + units: request.query.units, + until, + count, + }, + result: payload, + }); }; async function getStats() { - const cache = require('../../cache'); - const cachedStats = cache.get('admin:stats'); - if (cachedStats !== undefined) { - return cachedStats; - } - - let results = await Promise.all([ - getStatsForSet('ip:recent', 'uniqueIPCount'), - getStatsFromAnalytics('logins', 'loginCount'), - getStatsForSet('users:joindate', 'userCount'), - getStatsForSet('posts:pid', 'postCount'), - getStatsForSet('topics:tid', 'topicCount'), - ]); - results[0].name = '[[admin/dashboard:unique-visitors]]'; - - results[1].name = '[[admin/dashboard:logins]]'; - results[1].href = `${nconf.get('relative_path')}/admin/dashboard/logins`; - - results[2].name = '[[admin/dashboard:new-users]]'; - results[2].href = `${nconf.get('relative_path')}/admin/dashboard/users`; - - results[3].name = '[[admin/dashboard:posts]]'; - - results[4].name = '[[admin/dashboard:topics]]'; - results[4].href = `${nconf.get('relative_path')}/admin/dashboard/topics`; - - ({ results } = await plugins.hooks.fire('filter:admin.getStats', { - results, - helpers: { getStatsForSet, getStatsFromAnalytics }, - })); - - cache.set('admin:stats', results, 600000); - return results; + const cache = require('../../cache'); + const cachedStats = cache.get('admin:stats'); + if (cachedStats !== undefined) { + return cachedStats; + } + + let results = await Promise.all([ + getStatsForSet('ip:recent', 'uniqueIPCount'), + getStatsFromAnalytics('logins', 'loginCount'), + getStatsForSet('users:joindate', 'userCount'), + getStatsForSet('posts:pid', 'postCount'), + getStatsForSet('topics:tid', 'topicCount'), + ]); + results[0].name = '[[admin/dashboard:unique-visitors]]'; + + results[1].name = '[[admin/dashboard:logins]]'; + results[1].href = `${nconf.get('relative_path')}/admin/dashboard/logins`; + + results[2].name = '[[admin/dashboard:new-users]]'; + results[2].href = `${nconf.get('relative_path')}/admin/dashboard/users`; + + results[3].name = '[[admin/dashboard:posts]]'; + + results[4].name = '[[admin/dashboard:topics]]'; + results[4].href = `${nconf.get('relative_path')}/admin/dashboard/topics`; + + ({results} = await plugins.hooks.fire('filter:admin.getStats', { + results, + helpers: {getStatsForSet, getStatsFromAnalytics}, + })); + + cache.set('admin:stats', results, 600_000); + return results; } async function getStatsForSet(set, field) { - const terms = { - day: 86400000, - week: 604800000, - month: 2592000000, - }; - - const now = Date.now(); - const results = await utils.promiseParallel({ - yesterday: db.sortedSetCount(set, now - (terms.day * 2), '+inf'), - today: db.sortedSetCount(set, now - terms.day, '+inf'), - lastweek: db.sortedSetCount(set, now - (terms.week * 2), '+inf'), - thisweek: db.sortedSetCount(set, now - terms.week, '+inf'), - lastmonth: db.sortedSetCount(set, now - (terms.month * 2), '+inf'), - thismonth: db.sortedSetCount(set, now - terms.month, '+inf'), - alltime: getGlobalField(field), - }); - - return calculateDeltas(results); + const terms = { + day: 86_400_000, + week: 604_800_000, + month: 2_592_000_000, + }; + + const now = Date.now(); + const results = await utils.promiseParallel({ + yesterday: db.sortedSetCount(set, now - (terms.day * 2), '+inf'), + today: db.sortedSetCount(set, now - terms.day, '+inf'), + lastweek: db.sortedSetCount(set, now - (terms.week * 2), '+inf'), + thisweek: db.sortedSetCount(set, now - terms.week, '+inf'), + lastmonth: db.sortedSetCount(set, now - (terms.month * 2), '+inf'), + thismonth: db.sortedSetCount(set, now - terms.month, '+inf'), + alltime: getGlobalField(field), + }); + + return calculateDeltas(results); } async function getStatsFromAnalytics(set, field) { - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const data = await analytics.getDailyStatsForSet(`analytics:${set}`, today, 60); - const sum = arr => arr.reduce((memo, cur) => memo + cur, 0); - const results = { - yesterday: sum(data.slice(-2)), - today: data.slice(-1)[0], - lastweek: sum(data.slice(-14)), - thisweek: sum(data.slice(-7)), - lastmonth: sum(data.slice(0)), // entire set - thismonth: sum(data.slice(-30)), - alltime: await getGlobalField(field), - }; - - return calculateDeltas(results); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const data = await analytics.getDailyStatsForSet(`analytics:${set}`, today, 60); + const sum = array => array.reduce((memo, current) => memo + current, 0); + const results = { + yesterday: sum(data.slice(-2)), + today: data.at(-1), + lastweek: sum(data.slice(-14)), + thisweek: sum(data.slice(-7)), + lastmonth: sum(data.slice(0)), // Entire set + thismonth: sum(data.slice(-30)), + alltime: await getGlobalField(field), + }; + + return calculateDeltas(results); } function calculateDeltas(results) { - function textClass(num) { - if (num > 0) { - return 'text-success'; - } else if (num < 0) { - return 'text-danger'; - } - return 'text-warning'; - } - - function increasePercent(last, now) { - const percent = last ? (now - last) / last * 100 : 0; - return percent.toFixed(1); - } - results.yesterday -= results.today; - results.dayIncrease = increasePercent(results.yesterday, results.today); - results.dayTextClass = textClass(results.dayIncrease); - - results.lastweek -= results.thisweek; - results.weekIncrease = increasePercent(results.lastweek, results.thisweek); - results.weekTextClass = textClass(results.weekIncrease); - - results.lastmonth -= results.thismonth; - results.monthIncrease = increasePercent(results.lastmonth, results.thismonth); - results.monthTextClass = textClass(results.monthIncrease); - - return results; + function textClass(number_) { + if (number_ > 0) { + return 'text-success'; + } + + if (number_ < 0) { + return 'text-danger'; + } + + return 'text-warning'; + } + + function increasePercent(last, now) { + const percent = last ? (now - last) / last * 100 : 0; + return percent.toFixed(1); + } + + results.yesterday -= results.today; + results.dayIncrease = increasePercent(results.yesterday, results.today); + results.dayTextClass = textClass(results.dayIncrease); + + results.lastweek -= results.thisweek; + results.weekIncrease = increasePercent(results.lastweek, results.thisweek); + results.weekTextClass = textClass(results.weekIncrease); + + results.lastmonth -= results.thismonth; + results.monthIncrease = increasePercent(results.lastmonth, results.thismonth); + results.monthTextClass = textClass(results.monthIncrease); + + return results; } async function getGlobalField(field) { - const count = await db.getObjectField('global', field); - return parseInt(count, 10) || 0; + const count = await db.getObjectField('global', field); + return Number.parseInt(count, 10) || 0; } async function getLastRestart() { - const lastrestart = await db.getObject('lastrestart'); - if (!lastrestart) { - return null; - } - const userData = await user.getUserData(lastrestart.uid); - lastrestart.user = userData; - lastrestart.timestampISO = utils.toISOString(lastrestart.timestamp); - return lastrestart; + const lastrestart = await db.getObject('lastrestart'); + if (!lastrestart) { + return null; + } + + const userData = await user.getUserData(lastrestart.uid); + lastrestart.user = userData; + lastrestart.timestampISO = utils.toISOString(lastrestart.timestamp); + return lastrestart; } async function getPopularSearches() { - const searches = await db.getSortedSetRevRangeWithScores('searches:all', 0, 9); - return searches.map(s => ({ value: validator.escape(String(s.value)), score: s.score })); + const searches = await db.getSortedSetRevRangeWithScores('searches:all', 0, 9); + return searches.map(s => ({value: validator.escape(String(s.value)), score: s.score})); } -dashboardController.getLogins = async (req, res) => { - let stats = await getStats(); - stats = stats.filter(stat => stat.name === '[[admin/dashboard:logins]]').map(({ ...stat }) => { - delete stat.href; - return stat; - }); - const summary = { - day: stats[0].today, - week: stats[0].thisweek, - month: stats[0].thismonth, - }; - - // List recent sessions - const start = Date.now() - (1000 * 60 * 60 * 24 * meta.config.loginDays); - const uids = await db.getSortedSetRangeByScore('users:online', 0, 500, start, Date.now()); - const usersData = await user.getUsersData(uids); - let sessions = await Promise.all(uids.map(async (uid) => { - const sessions = await user.auth.getSessions(uid); - sessions.forEach((session) => { - session.user = usersData[uids.indexOf(uid)]; - }); - - return sessions; - })); - sessions = _.flatten(sessions).sort((a, b) => b.datetime - a.datetime); - - res.render('admin/dashboard/logins', { - set: 'logins', - query: req.query, - stats, - summary, - sessions, - loginDays: meta.config.loginDays, - }); +dashboardController.getLogins = async (request, res) => { + let stats = await getStats(); + stats = stats.filter(stat => stat.name === '[[admin/dashboard:logins]]').map(({...stat}) => { + delete stat.href; + return stat; + }); + const summary = { + day: stats[0].today, + week: stats[0].thisweek, + month: stats[0].thismonth, + }; + + // List recent sessions + const start = Date.now() - (1000 * 60 * 60 * 24 * meta.config.loginDays); + const uids = await db.getSortedSetRangeByScore('users:online', 0, 500, start, Date.now()); + const usersData = await user.getUsersData(uids); + let sessions = await Promise.all(uids.map(async uid => { + const sessions = await user.auth.getSessions(uid); + for (const session of sessions) { + session.user = usersData[uids.indexOf(uid)]; + } + + return sessions; + })); + sessions = sessions.flat().sort((a, b) => b.datetime - a.datetime); + + res.render('admin/dashboard/logins', { + set: 'logins', + query: request.query, + stats, + summary, + sessions, + loginDays: meta.config.loginDays, + }); }; -dashboardController.getUsers = async (req, res) => { - let stats = await getStats(); - stats = stats.filter(stat => stat.name === '[[admin/dashboard:new-users]]').map(({ ...stat }) => { - delete stat.href; - return stat; - }); - const summary = { - day: stats[0].today, - week: stats[0].thisweek, - month: stats[0].thismonth, - }; - - // List of users registered within time frame - const end = parseInt(req.query.until, 10) || Date.now(); - const start = end - (1000 * 60 * 60 * (req.query.units === 'days' ? 24 : 1) * (req.query.count || (req.query.units === 'days' ? 30 : 24))); - const uids = await db.getSortedSetRangeByScore('users:joindate', 0, 500, start, end); - const users = await user.getUsersData(uids); - - res.render('admin/dashboard/users', { - set: 'registrations', - query: req.query, - stats, - summary, - users, - }); +dashboardController.getUsers = async (request, res) => { + let stats = await getStats(); + stats = stats.filter(stat => stat.name === '[[admin/dashboard:new-users]]').map(({...stat}) => { + delete stat.href; + return stat; + }); + const summary = { + day: stats[0].today, + week: stats[0].thisweek, + month: stats[0].thismonth, + }; + + // List of users registered within time frame + const end = Number.parseInt(request.query.until, 10) || Date.now(); + const start = end - (1000 * 60 * 60 * (request.query.units === 'days' ? 24 : 1) * (request.query.count || (request.query.units === 'days' ? 30 : 24))); + const uids = await db.getSortedSetRangeByScore('users:joindate', 0, 500, start, end); + const users = await user.getUsersData(uids); + + res.render('admin/dashboard/users', { + set: 'registrations', + query: request.query, + stats, + summary, + users, + }); }; -dashboardController.getTopics = async (req, res) => { - let stats = await getStats(); - stats = stats.filter(stat => stat.name === '[[admin/dashboard:topics]]').map(({ ...stat }) => { - delete stat.href; - return stat; - }); - const summary = { - day: stats[0].today, - week: stats[0].thisweek, - month: stats[0].thismonth, - }; - - // List of topics created within time frame - const end = parseInt(req.query.until, 10) || Date.now(); - const start = end - (1000 * 60 * 60 * (req.query.units === 'days' ? 24 : 1) * (req.query.count || (req.query.units === 'days' ? 30 : 24))); - const tids = await db.getSortedSetRangeByScore('topics:tid', 0, 500, start, end); - const topicData = await topics.getTopicsByTids(tids); - - res.render('admin/dashboard/topics', { - set: 'topics', - query: req.query, - stats, - summary, - topics: topicData, - }); +dashboardController.getTopics = async (request, res) => { + let stats = await getStats(); + stats = stats.filter(stat => stat.name === '[[admin/dashboard:topics]]').map(({...stat}) => { + delete stat.href; + return stat; + }); + const summary = { + day: stats[0].today, + week: stats[0].thisweek, + month: stats[0].thismonth, + }; + + // List of topics created within time frame + const end = Number.parseInt(request.query.until, 10) || Date.now(); + const start = end - (1000 * 60 * 60 * (request.query.units === 'days' ? 24 : 1) * (request.query.count || (request.query.units === 'days' ? 30 : 24))); + const tids = await db.getSortedSetRangeByScore('topics:tid', 0, 500, start, end); + const topicData = await topics.getTopicsByTids(tids); + + res.render('admin/dashboard/topics', { + set: 'topics', + query: request.query, + stats, + summary, + topics: topicData, + }); }; -dashboardController.getSearches = async (req, res) => { - const searches = await db.getSortedSetRevRangeWithScores('searches:all', 0, 99); - res.render('admin/dashboard/searches', { - searches: searches.map(s => ({ value: validator.escape(String(s.value)), score: s.score })), - }); +dashboardController.getSearches = async (request, res) => { + const searches = await db.getSortedSetRevRangeWithScores('searches:all', 0, 99); + res.render('admin/dashboard/searches', { + searches: searches.map(s => ({value: validator.escape(String(s.value)), score: s.score})), + }); }; diff --git a/src/controllers/admin/database.js b/src/controllers/admin/database.js index 443fdcf..d81e922 100644 --- a/src/controllers/admin/database.js +++ b/src/controllers/admin/database.js @@ -4,20 +4,22 @@ const nconf = require('nconf'); const databaseController = module.exports; -databaseController.get = async function (req, res) { - const results = {}; - if (nconf.get('redis')) { - const rdb = require('../../database/redis'); - results.redis = await rdb.info(rdb.client); - } - if (nconf.get('mongo')) { - const mdb = require('../../database/mongo'); - results.mongo = await mdb.info(mdb.client); - } - if (nconf.get('postgres')) { - const pdb = require('../../database/postgres'); - results.postgres = await pdb.info(pdb.pool); - } +databaseController.get = async function (request, res) { + const results = {}; + if (nconf.get('redis')) { + const rdb = require('../../database/redis'); + results.redis = await rdb.info(rdb.client); + } - res.render('admin/advanced/database', results); + if (nconf.get('mongo')) { + const mdb = require('../../database/mongo'); + results.mongo = await mdb.info(mdb.client); + } + + if (nconf.get('postgres')) { + const pdb = require('../../database/postgres'); + results.postgres = await pdb.info(pdb.pool); + } + + res.render('admin/advanced/database', results); }; diff --git a/src/controllers/admin/digest.js b/src/controllers/admin/digest.js index 30fed9a..20f4858 100644 --- a/src/controllers/admin/digest.js +++ b/src/controllers/admin/digest.js @@ -6,18 +6,18 @@ const pagination = require('../../pagination'); const digestController = module.exports; -digestController.get = async function (req, res) { - const page = parseInt(req.query.page, 10) || 1; - const resultsPerPage = 50; - const start = Math.max(0, page - 1) * resultsPerPage; - const stop = start + resultsPerPage - 1; - const delivery = await digest.getDeliveryTimes(start, stop); +digestController.get = async function (request, res) { + const page = Number.parseInt(request.query.page, 10) || 1; + const resultsPerPage = 50; + const start = Math.max(0, page - 1) * resultsPerPage; + const stop = start + resultsPerPage - 1; + const delivery = await digest.getDeliveryTimes(start, stop); - const pageCount = Math.ceil(delivery.count / resultsPerPage); - res.render('admin/manage/digest', { - title: '[[admin/menu:manage/digest]]', - delivery: delivery.users, - default: meta.config.dailyDigestFreq, - pagination: pagination.create(page, pageCount), - }); + const pageCount = Math.ceil(delivery.count / resultsPerPage); + res.render('admin/manage/digest', { + title: '[[admin/menu:manage/digest]]', + delivery: delivery.users, + default: meta.config.dailyDigestFreq, + pagination: pagination.create(page, pageCount), + }); }; diff --git a/src/controllers/admin/errors.js b/src/controllers/admin/errors.js index 98bdbe8..63be21e 100644 --- a/src/controllers/admin/errors.js +++ b/src/controllers/admin/errors.js @@ -1,25 +1,24 @@ 'use strict'; const json2csvAsync = require('json2csv').parseAsync; - const meta = require('../../meta'); const analytics = require('../../analytics'); const utils = require('../../utils'); const errorsController = module.exports; -errorsController.get = async function (req, res) { - const data = await utils.promiseParallel({ - 'not-found': meta.errors.get(true), - analytics: analytics.getErrorAnalytics(), - }); - res.render('admin/advanced/errors', data); +errorsController.get = async function (request, res) { + const data = await utils.promiseParallel({ + 'not-found': meta.errors.get(true), + analytics: analytics.getErrorAnalytics(), + }); + res.render('admin/advanced/errors', data); }; -errorsController.export = async function (req, res) { - const data = await meta.errors.get(false); - const fields = data.length ? Object.keys(data[0]) : []; - const opts = { fields }; - const csv = await json2csvAsync(data, opts); - res.set('Content-Type', 'text/csv').set('Content-Disposition', 'attachment; filename="404.csv"').send(csv); +errorsController.export = async function (request, res) { + const data = await meta.errors.get(false); + const fields = data.length > 0 ? Object.keys(data[0]) : []; + const options = {fields}; + const csv = await json2csvAsync(data, options); + res.set('Content-Type', 'text/csv').set('Content-Disposition', 'attachment; filename="404.csv"').send(csv); }; diff --git a/src/controllers/admin/events.js b/src/controllers/admin/events.js index 3b00101..29d598e 100644 --- a/src/controllers/admin/events.js +++ b/src/controllers/admin/events.js @@ -6,39 +6,39 @@ const pagination = require('../../pagination'); const eventsController = module.exports; -eventsController.get = async function (req, res) { - const page = parseInt(req.query.page, 10) || 1; - const itemsPerPage = parseInt(req.query.perPage, 10) || 20; - const start = (page - 1) * itemsPerPage; - const stop = start + itemsPerPage - 1; - - // Limit by date - let from = req.query.start ? new Date(req.query.start) || undefined : undefined; - let to = req.query.end ? new Date(req.query.end) || undefined : new Date(); - from = from && from.setHours(0, 0, 0, 0); // setHours returns a unix timestamp (Number, not Date) - to = to && to.setHours(23, 59, 59, 999); // setHours returns a unix timestamp (Number, not Date) - - const currentFilter = req.query.type || ''; - - const [eventCount, eventData, counts] = await Promise.all([ - db.sortedSetCount(`events:time${currentFilter ? `:${currentFilter}` : ''}`, from || '-inf', to), - events.getEvents(currentFilter, start, stop, from || '-inf', to), - db.sortedSetsCard([''].concat(events.types).map(type => `events:time${type ? `:${type}` : ''}`)), - ]); - - const types = [''].concat(events.types).map((type, index) => ({ - value: type, - name: type || 'all', - selected: type === currentFilter, - count: counts[index], - })); - - const pageCount = Math.max(1, Math.ceil(eventCount / itemsPerPage)); - - res.render('admin/advanced/events', { - events: eventData, - pagination: pagination.create(page, pageCount, req.query), - types: types, - query: req.query, - }); +eventsController.get = async function (request, res) { + const page = Number.parseInt(request.query.page, 10) || 1; + const itemsPerPage = Number.parseInt(request.query.perPage, 10) || 20; + const start = (page - 1) * itemsPerPage; + const stop = start + itemsPerPage - 1; + + // Limit by date + let from = request.query.start ? new Date(request.query.start) || undefined : undefined; + let to = request.query.end ? new Date(request.query.end) || undefined : new Date(); + from &&= from.setHours(0, 0, 0, 0); // SetHours returns a unix timestamp (Number, not Date) + to &&= to.setHours(23, 59, 59, 999); // SetHours returns a unix timestamp (Number, not Date) + + const currentFilter = request.query.type || ''; + + const [eventCount, eventData, counts] = await Promise.all([ + db.sortedSetCount(`events:time${currentFilter ? `:${currentFilter}` : ''}`, from || '-inf', to), + events.getEvents(currentFilter, start, stop, from || '-inf', to), + db.sortedSetsCard([''].concat(events.types).map(type => `events:time${type ? `:${type}` : ''}`)), + ]); + + const types = [''].concat(events.types).map((type, index) => ({ + value: type, + name: type || 'all', + selected: type === currentFilter, + count: counts[index], + })); + + const pageCount = Math.max(1, Math.ceil(eventCount / itemsPerPage)); + + res.render('admin/advanced/events', { + events: eventData, + pagination: pagination.create(page, pageCount, request.query), + types, + query: request.query, + }); }; diff --git a/src/controllers/admin/groups.js b/src/controllers/admin/groups.js index 169b49a..6e1112e 100644 --- a/src/controllers/admin/groups.js +++ b/src/controllers/admin/groups.js @@ -2,7 +2,6 @@ const nconf = require('nconf'); const validator = require('validator'); - const db = require('../../database'); const user = require('../../user'); const groups = require('../../groups'); @@ -13,86 +12,88 @@ const slugify = require('../../slugify'); const groupsController = module.exports; -groupsController.list = async function (req, res) { - const page = parseInt(req.query.page, 10) || 1; - const groupsPerPage = 20; +groupsController.list = async function (request, res) { + const page = Number.parseInt(request.query.page, 10) || 1; + const groupsPerPage = 20; - let groupNames = await getGroupNames(); - const pageCount = Math.ceil(groupNames.length / groupsPerPage); - const start = (page - 1) * groupsPerPage; - const stop = start + groupsPerPage - 1; - groupNames = groupNames.slice(start, stop + 1); + let groupNames = await getGroupNames(); + const pageCount = Math.ceil(groupNames.length / groupsPerPage); + const start = (page - 1) * groupsPerPage; + const stop = start + groupsPerPage - 1; + groupNames = groupNames.slice(start, stop + 1); - const groupData = await groups.getGroupsData(groupNames); - res.render('admin/manage/groups', { - groups: groupData, - pagination: pagination.create(page, pageCount), - yourid: req.uid, - }); + const groupData = await groups.getGroupsData(groupNames); + res.render('admin/manage/groups', { + groups: groupData, + pagination: pagination.create(page, pageCount), + yourid: request.uid, + }); }; -groupsController.get = async function (req, res, next) { - const slug = slugify(req.params.name); - const groupName = await groups.getGroupNameByGroupSlug(slug); - const [groupNames, group] = await Promise.all([ - getGroupNames(), - groups.get(groupName, { uid: req.uid, truncateUserList: true, userListCount: 20 }), - ]); +groupsController.get = async function (request, res, next) { + const slug = slugify(request.params.name); + const groupName = await groups.getGroupNameByGroupSlug(slug); + const [groupNames, group] = await Promise.all([ + getGroupNames(), + groups.get(groupName, {uid: request.uid, truncateUserList: true, userListCount: 20}), + ]); + + if (!group || groupName === groups.BANNED_USERS) { + return next(); + } - if (!group || groupName === groups.BANNED_USERS) { - return next(); - } - group.isOwner = true; + group.isOwner = true; - const groupNameData = groupNames.map(name => ({ - encodedName: encodeURIComponent(name), - displayName: validator.escape(String(name)), - selected: name === groupName, - })); + const groupNameData = groupNames.map(name => ({ + encodedName: encodeURIComponent(name), + displayName: validator.escape(String(name)), + selected: name === groupName, + })); - res.render('admin/manage/group', { - group: group, - groupNames: groupNameData, - allowPrivateGroups: meta.config.allowPrivateGroups, - maximumGroupNameLength: meta.config.maximumGroupNameLength, - maximumGroupTitleLength: meta.config.maximumGroupTitleLength, - }); + res.render('admin/manage/group', { + group, + groupNames: groupNameData, + allowPrivateGroups: meta.config.allowPrivateGroups, + maximumGroupNameLength: meta.config.maximumGroupNameLength, + maximumGroupTitleLength: meta.config.maximumGroupTitleLength, + }); }; async function getGroupNames() { - const groupNames = await db.getSortedSetRange('groups:createtime', 0, -1); - return groupNames.filter(name => ( - name !== 'registered-users' && - name !== 'verified-users' && - name !== 'unverified-users' && - name !== groups.BANNED_USERS && - !groups.isPrivilegeGroup(name) - )); + const groupNames = await db.getSortedSetRange('groups:createtime', 0, -1); + return groupNames.filter(name => ( + name !== 'registered-users' + && name !== 'verified-users' + && name !== 'unverified-users' + && name !== groups.BANNED_USERS + && !groups.isPrivilegeGroup(name) + )); } -groupsController.getCSV = async function (req, res) { - const { referer } = req.headers; +groupsController.getCSV = async function (request, res) { + const {referer} = request.headers; + + if (!referer || !referer.replace(nconf.get('url'), '').startsWith('/admin/manage/groups')) { + return res.status(403).send('[[error:invalid-origin]]'); + } - if (!referer || !referer.replace(nconf.get('url'), '').startsWith('/admin/manage/groups')) { - return res.status(403).send('[[error:invalid-origin]]'); - } - await events.log({ - type: 'getGroupCSV', - uid: req.uid, - ip: req.ip, - group: req.params.groupname, - }); - const groupName = req.params.groupname; - const members = (await groups.getMembersOfGroups([groupName]))[0]; - const fields = ['email', 'username', 'uid']; - const userData = await user.getUsersFields(members, fields); - let csvContent = `${fields.join(',')}\n`; - csvContent += userData.reduce((memo, user) => { - memo += `${user.email},${user.username},${user.uid}\n`; - return memo; - }, ''); + await events.log({ + type: 'getGroupCSV', + uid: request.uid, + ip: request.ip, + group: request.params.groupname, + }); + const groupName = request.params.groupname; + const [members] = await groups.getMembersOfGroups([groupName]); + const fields = ['email', 'username', 'uid']; + const userData = await user.getUsersFields(members, fields); + let csvContent = `${fields.join(',')}\n`; + csvContent += userData.reduce((memo, user) => { + memo += `${user.email},${user.username},${user.uid}\n`; + return memo; + }, ''); - res.attachment(`${validator.escape(groupName)}_members.csv`); - res.setHeader('Content-Type', 'text/csv'); - res.end(csvContent); + res.attachment(`${validator.escape(groupName)}_members.csv`); + res.setHeader('Content-Type', 'text/csv'); + res.end(csvContent); }; diff --git a/src/controllers/admin/hooks.js b/src/controllers/admin/hooks.js index eb3cb6c..030fd8f 100644 --- a/src/controllers/admin/hooks.js +++ b/src/controllers/admin/hooks.js @@ -5,28 +5,29 @@ const plugins = require('../../plugins'); const hooksController = module.exports; -hooksController.get = function (req, res) { - const hooks = []; - Object.keys(plugins.loadedHooks).forEach((key, hookIndex) => { - const current = { - hookName: key, - methods: [], - index: `hook-${hookIndex}`, - count: plugins.loadedHooks[key].length, - }; +hooksController.get = function (request, res) { + const hooks = []; + for (const [hookIndex, key] of Object.keys(plugins.loadedHooks).entries()) { + const current = { + hookName: key, + methods: [], + index: `hook-${hookIndex}`, + count: plugins.loadedHooks[key].length, + }; - plugins.loadedHooks[key].forEach((hookData, methodIndex) => { - current.methods.push({ - id: hookData.id, - priority: hookData.priority, - method: hookData.method ? validator.escape(hookData.method.toString()) : 'No plugin function!', - index: `${hookIndex}-code-${methodIndex}`, - }); - }); - hooks.push(current); - }); + for (const [methodIndex, hookData] of plugins.loadedHooks[key].entries()) { + current.methods.push({ + id: hookData.id, + priority: hookData.priority, + method: hookData.method ? validator.escape(hookData.method.toString()) : 'No plugin function!', + index: `${hookIndex}-code-${methodIndex}`, + }); + } - hooks.sort((a, b) => b.count - a.count); + hooks.push(current); + } - res.render('admin/advanced/hooks', { hooks: hooks }); + hooks.sort((a, b) => b.count - a.count); + + res.render('admin/advanced/hooks', {hooks}); }; diff --git a/src/controllers/admin/info.js b/src/controllers/admin/info.js index d2cdc24..bce01ad 100644 --- a/src/controllers/admin/info.js +++ b/src/controllers/admin/info.js @@ -1,10 +1,9 @@ 'use strict'; -const os = require('os'); +const os = require('node:os'); +const {exec} = require('node:child_process'); const winston = require('winston'); const nconf = require('nconf'); -const { exec } = require('child_process'); - const pubsub = require('../../pubsub'); const rooms = require('../../socket.io/admin/rooms'); @@ -14,131 +13,143 @@ let info = {}; let previousUsage = process.cpuUsage(); let usageStartDate = Date.now(); -infoController.get = function (req, res) { - info = {}; - pubsub.publish('sync:node:info:start'); - const timeoutMS = 1000; - setTimeout(() => { - const data = []; - Object.keys(info).forEach(key => data.push(info[key])); - data.sort((a, b) => { - if (a.id < b.id) { - return -1; - } - if (a.id > b.id) { - return 1; - } - return 0; - }); - - let port = nconf.get('port'); - if (!Array.isArray(port) && !isNaN(parseInt(port, 10))) { - port = [port]; - } - - res.render('admin/development/info', { - info: data, - infoJSON: JSON.stringify(data, null, 4), - host: os.hostname(), - port: port, - nodeCount: data.length, - timeout: timeoutMS, - ip: req.ip, - }); - }, timeoutMS); +infoController.get = function (request, res) { + info = {}; + pubsub.publish('sync:node:info:start'); + const timeoutMS = 1000; + setTimeout(() => { + const data = []; + for (const key of Object.keys(info)) { + data.push(info[key]); + } + + data.sort((a, b) => { + if (a.id < b.id) { + return -1; + } + + if (a.id > b.id) { + return 1; + } + + return 0; + }); + + let port = nconf.get('port'); + if (!Array.isArray(port) && !isNaN(Number.parseInt(port, 10))) { + port = [port]; + } + + res.render('admin/development/info', { + info: data, + infoJSON: JSON.stringify(data, null, 4), + host: os.hostname(), + port, + nodeCount: data.length, + timeout: timeoutMS, + ip: request.ip, + }); + }, timeoutMS); }; pubsub.on('sync:node:info:start', async () => { - try { - const data = await getNodeInfo(); - data.id = `${os.hostname()}:${nconf.get('port')}`; - pubsub.publish('sync:node:info:end', { data: data, id: data.id }); - } catch (err) { - winston.error(err.stack); - } + try { + const data = await getNodeInfo(); + data.id = `${os.hostname()}:${nconf.get('port')}`; + pubsub.publish('sync:node:info:end', {data, id: data.id}); + } catch (error) { + winston.error(error.stack); + } }); -pubsub.on('sync:node:info:end', (data) => { - info[data.id] = data.data; +pubsub.on('sync:node:info:end', data => { + info[data.id] = data.data; }); async function getNodeInfo() { - const data = { - process: { - port: nconf.get('port'), - pid: process.pid, - title: process.title, - version: process.version, - memoryUsage: process.memoryUsage(), - uptime: process.uptime(), - cpuUsage: getCpuUsage(), - }, - os: { - hostname: os.hostname(), - type: os.type(), - platform: os.platform(), - arch: os.arch(), - release: os.release(), - load: os.loadavg().map(load => load.toFixed(2)).join(', '), - freemem: os.freemem(), - totalmem: os.totalmem(), - }, - nodebb: { - isCluster: nconf.get('isCluster'), - isPrimary: nconf.get('isPrimary'), - runJobs: nconf.get('runJobs'), - jobsDisabled: nconf.get('jobsDisabled'), - }, - }; - - data.process.memoryUsage.humanReadable = (data.process.memoryUsage.rss / (1024 * 1024 * 1024)).toFixed(3); - data.process.uptimeHumanReadable = humanReadableUptime(data.process.uptime); - data.os.freemem = (data.os.freemem / (1024 * 1024 * 1024)).toFixed(2); - data.os.totalmem = (data.os.totalmem / (1024 * 1024 * 1024)).toFixed(2); - data.os.usedmem = (data.os.totalmem - data.os.freemem).toFixed(2); - const [stats, gitInfo] = await Promise.all([ - rooms.getLocalStats(), - getGitInfo(), - ]); - data.git = gitInfo; - data.stats = stats; - return data; + const data = { + process: { + port: nconf.get('port'), + pid: process.pid, + title: process.title, + version: process.version, + memoryUsage: process.memoryUsage(), + uptime: process.uptime(), + cpuUsage: getCpuUsage(), + }, + os: { + hostname: os.hostname(), + type: os.type(), + platform: os.platform(), + arch: os.arch(), + release: os.release(), + load: os.loadavg().map(load => load.toFixed(2)).join(', '), + freemem: os.freemem(), + totalmem: os.totalmem(), + }, + nodebb: { + isCluster: nconf.get('isCluster'), + isPrimary: nconf.get('isPrimary'), + runJobs: nconf.get('runJobs'), + jobsDisabled: nconf.get('jobsDisabled'), + }, + }; + + data.process.memoryUsage.humanReadable = (data.process.memoryUsage.rss / (1024 * 1024 * 1024)).toFixed(3); + data.process.uptimeHumanReadable = humanReadableUptime(data.process.uptime); + data.os.freemem = (data.os.freemem / (1024 * 1024 * 1024)).toFixed(2); + data.os.totalmem = (data.os.totalmem / (1024 * 1024 * 1024)).toFixed(2); + data.os.usedmem = (data.os.totalmem - data.os.freemem).toFixed(2); + const [stats, gitInfo] = await Promise.all([ + rooms.getLocalStats(), + getGitInfo(), + ]); + data.git = gitInfo; + data.stats = stats; + return data; } function getCpuUsage() { - const newUsage = process.cpuUsage(); - const diff = (newUsage.user + newUsage.system) - (previousUsage.user + previousUsage.system); - const now = Date.now(); - const result = diff / ((now - usageStartDate) * 1000) * 100; - previousUsage = newUsage; - usageStartDate = now; - return result.toFixed(2); + const newUsage = process.cpuUsage(); + const diff = (newUsage.user + newUsage.system) - (previousUsage.user + previousUsage.system); + const now = Date.now(); + const result = diff / ((now - usageStartDate) * 1000) * 100; + previousUsage = newUsage; + usageStartDate = now; + return result.toFixed(2); } function humanReadableUptime(seconds) { - if (seconds < 60) { - return `${Math.floor(seconds)}s`; - } else if (seconds < 3600) { - return `${Math.floor(seconds / 60)}m`; - } else if (seconds < 3600 * 24) { - return `${Math.floor(seconds / (60 * 60))}h`; - } - return `${Math.floor(seconds / (60 * 60 * 24))}d`; + if (seconds < 60) { + return `${Math.floor(seconds)}s`; + } + + if (seconds < 3600) { + return `${Math.floor(seconds / 60)}m`; + } + + if (seconds < 3600 * 24) { + return `${Math.floor(seconds / (60 * 60))}h`; + } + + return `${Math.floor(seconds / (60 * 60 * 24))}d`; } async function getGitInfo() { - function get(cmd, callback) { - exec(cmd, (err, stdout) => { - if (err) { - winston.error(err.stack); - } - callback(null, stdout ? stdout.replace(/\n$/, '') : 'no-git-info'); - }); - } - const getAsync = require('util').promisify(get); - const [hash, branch] = await Promise.all([ - getAsync('git rev-parse HEAD'), - getAsync('git rev-parse --abbrev-ref HEAD'), - ]); - return { hash: hash, hashShort: hash.slice(0, 6), branch: branch }; + function get(cmd, callback) { + exec(cmd, (error, stdout) => { + if (error) { + winston.error(error.stack); + } + + callback(null, stdout ? stdout.replace(/\n$/, '') : 'no-git-info'); + }); + } + + const getAsync = require('node:util').promisify(get); + const [hash, branch] = await Promise.all([ + getAsync('git rev-parse HEAD'), + getAsync('git rev-parse --abbrev-ref HEAD'), + ]); + return {hash, hashShort: hash.slice(0, 6), branch}; } diff --git a/src/controllers/admin/logger.js b/src/controllers/admin/logger.js index ee6af55..865aef3 100644 --- a/src/controllers/admin/logger.js +++ b/src/controllers/admin/logger.js @@ -2,6 +2,6 @@ const loggerController = module.exports; -loggerController.get = function (req, res) { - res.render('admin/development/logger', {}); +loggerController.get = function (request, res) { + res.render('admin/development/logger', {}); }; diff --git a/src/controllers/admin/logs.js b/src/controllers/admin/logs.js index 104df03..98bcb14 100644 --- a/src/controllers/admin/logs.js +++ b/src/controllers/admin/logs.js @@ -2,19 +2,19 @@ const validator = require('validator'); const winston = require('winston'); - const meta = require('../../meta'); const logsController = module.exports; -logsController.get = async function (req, res) { - let logs = ''; - try { - logs = await meta.logs.get(); - } catch (err) { - winston.error(err.stack); - } - res.render('admin/advanced/logs', { - data: validator.escape(logs), - }); +logsController.get = async function (request, res) { + let logs = ''; + try { + logs = await meta.logs.get(); + } catch (error) { + winston.error(error.stack); + } + + res.render('admin/advanced/logs', { + data: validator.escape(logs), + }); }; diff --git a/src/controllers/admin/plugins.js b/src/controllers/admin/plugins.js index 4f2c7ba..c3aae37 100644 --- a/src/controllers/admin/plugins.js +++ b/src/controllers/admin/plugins.js @@ -7,63 +7,64 @@ const meta = require('../../meta'); const pluginsController = module.exports; -pluginsController.get = async function (req, res) { - const [compatible, all, trending] = await Promise.all([ - getCompatiblePlugins(), - getAllPlugins(), - plugins.listTrending(), - ]); +pluginsController.get = async function (request, res) { + const [compatible, all, trending] = await Promise.all([ + getCompatiblePlugins(), + getAllPlugins(), + plugins.listTrending(), + ]); - const compatiblePkgNames = compatible.map(pkgData => pkgData.name); - const installedPlugins = compatible.filter(plugin => plugin && plugin.installed); - const activePlugins = all.filter(plugin => plugin && plugin.installed && plugin.active); + const compatiblePackageNames = new Set(compatible.map(packageData => packageData.name)); + const installedPlugins = compatible.filter(plugin => plugin && plugin.installed); + const activePlugins = all.filter(plugin => plugin && plugin.installed && plugin.active); - const trendingScores = trending.reduce((memo, cur) => { - memo[cur.label] = cur.value; - return memo; - }, {}); - const trendingPlugins = all - .filter(plugin => plugin && Object.keys(trendingScores).includes(plugin.id)) - .sort((a, b) => trendingScores[b.id] - trendingScores[a.id]) - .map((plugin) => { - plugin.downloads = trendingScores[plugin.id]; - return plugin; - }); + const trendingScores = trending.reduce((memo, current) => { + memo[current.label] = current.value; + return memo; + }, {}); + const trendingPlugins = all + .filter(plugin => plugin && Object.keys(trendingScores).includes(plugin.id)) + .sort((a, b) => trendingScores[b.id] - trendingScores[a.id]) + .map(plugin => { + plugin.downloads = trendingScores[plugin.id]; + return plugin; + }); - res.render('admin/extend/plugins', { - installed: installedPlugins, - 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; - } - return count; - }, 0), - download: compatible.filter(plugin => !plugin.installed), - incompatible: all.filter(plugin => !compatiblePkgNames.includes(plugin.name)), - trending: trendingPlugins, - submitPluginUsage: meta.config.submitPluginUsage, - version: nconf.get('version'), - }); + res.render('admin/extend/plugins', { + installed: installedPlugins, + 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; + } + + return count; + }, 0), + download: compatible.filter(plugin => !plugin.installed), + incompatible: all.filter(plugin => !compatiblePackageNames.has(plugin.name)), + trending: trendingPlugins, + submitPluginUsage: meta.config.submitPluginUsage, + version: nconf.get('version'), + }); }; async function getCompatiblePlugins() { - return await getPlugins(true); + return await getPlugins(true); } async function getAllPlugins() { - return await getPlugins(false); + return await getPlugins(false); } async function getPlugins(matching) { - try { - const pluginsData = await plugins.list(matching); - return pluginsData || []; - } catch (err) { - winston.error(err.stack); - return []; - } + try { + const pluginsData = await plugins.list(matching); + return pluginsData || []; + } catch (error) { + winston.error(error.stack); + return []; + } } diff --git a/src/controllers/admin/privileges.js b/src/controllers/admin/privileges.js index 427f252..5cabd50 100644 --- a/src/controllers/admin/privileges.js +++ b/src/controllers/admin/privileges.js @@ -5,48 +5,47 @@ const privileges = require('../../privileges'); const privilegesController = module.exports; -privilegesController.get = async function (req, res) { - const cid = req.params.cid ? parseInt(req.params.cid, 10) || 0 : 0; - const isAdminPriv = req.params.cid === 'admin'; - - let privilegesData; - if (cid > 0) { - privilegesData = await privileges.categories.list(cid); - } else if (cid === 0) { - privilegesData = await (isAdminPriv ? privileges.admin.list(req.uid) : privileges.global.list()); - } - - const categoriesData = [{ - cid: 0, - name: '[[admin/manage/privileges:global]]', - icon: 'fa-list', - }, { - cid: 'admin', - name: '[[admin/manage/privileges:admin]]', - icon: 'fa-lock', - }]; - - let selectedCategory; - categoriesData.forEach((category) => { - if (category) { - category.selected = category.cid === (!isAdminPriv ? cid : 'admin'); - - if (category.selected) { - selectedCategory = category; - } - } - }); - if (!selectedCategory) { - selectedCategory = await categories.getCategoryFields(cid, ['cid', 'name', 'icon', 'bgColor', 'color']); - } - - const group = req.query.group ? req.query.group : ''; - res.render('admin/manage/privileges', { - privileges: privilegesData, - categories: categoriesData, - selectedCategory, - cid, - group, - isAdminPriv, - }); +privilegesController.get = async function (request, res) { + const cid = request.params.cid ? Number.parseInt(request.params.cid, 10) || 0 : 0; + const isAdminPriv = request.params.cid === 'admin'; + + let privilegesData; + if (cid > 0) { + privilegesData = await privileges.categories.list(cid); + } else if (cid === 0) { + privilegesData = await (isAdminPriv ? privileges.admin.list(request.uid) : privileges.global.list()); + } + + const categoriesData = [{ + cid: 0, + name: '[[admin/manage/privileges:global]]', + icon: 'fa-list', + }, { + cid: 'admin', + name: '[[admin/manage/privileges:admin]]', + icon: 'fa-lock', + }]; + + let selectedCategory; + for (const category of categoriesData) { + if (category) { + category.selected = category.cid === (isAdminPriv ? 'admin' : cid); + + if (category.selected) { + selectedCategory = category; + } + } + } + + selectedCategory ||= await categories.getCategoryFields(cid, ['cid', 'name', 'icon', 'bgColor', 'color']); + + const group = request.query.group ? request.query.group : ''; + res.render('admin/manage/privileges', { + privileges: privilegesData, + categories: categoriesData, + selectedCategory, + cid, + group, + isAdminPriv, + }); }; diff --git a/src/controllers/admin/rewards.js b/src/controllers/admin/rewards.js index 644bb79..9392c30 100644 --- a/src/controllers/admin/rewards.js +++ b/src/controllers/admin/rewards.js @@ -4,7 +4,7 @@ const admin = require('../../rewards/admin'); const rewardsController = module.exports; -rewardsController.get = async function (req, res) { - const data = await admin.get(); - res.render('admin/extend/rewards', data); +rewardsController.get = async function (request, res) { + const data = await admin.get(); + res.render('admin/extend/rewards', data); }; diff --git a/src/controllers/admin/settings.js b/src/controllers/admin/settings.js index 9392290..9908d1c 100644 --- a/src/controllers/admin/settings.js +++ b/src/controllers/admin/settings.js @@ -1,7 +1,6 @@ 'use strict'; const validator = require('validator'); - const meta = require('../../meta'); const emailer = require('../../emailer'); const notifications = require('../../notifications'); @@ -9,102 +8,101 @@ const groups = require('../../groups'); const languages = require('../../languages'); const navigationAdmin = require('../../navigation/admin'); const social = require('../../social'); - const helpers = require('../helpers'); const translator = require('../../translator'); const settingsController = module.exports; -settingsController.get = async function (req, res) { - const term = req.params.term || 'general'; - res.render(`admin/settings/${term}`); +settingsController.get = async function (request, res) { + const term = request.params.term || 'general'; + res.render(`admin/settings/${term}`); }; -settingsController.email = async (req, res) => { - const emails = await emailer.getTemplates(meta.config); +settingsController.email = async (request, res) => { + const emails = await emailer.getTemplates(meta.config); - res.render('admin/settings/email', { - emails: emails, - sendable: emails.filter(e => !e.path.includes('_plaintext') && !e.path.includes('partials')).map(tpl => tpl.path), - services: emailer.listServices(), - }); + res.render('admin/settings/email', { + emails, + sendable: emails.filter(e => !e.path.includes('_plaintext') && !e.path.includes('partials')).map(tpl => tpl.path), + services: emailer.listServices(), + }); }; -settingsController.user = async (req, res) => { - const notificationTypes = await notifications.getAllNotificationTypes(); - const notificationSettings = notificationTypes.map(type => ({ - name: type, - label: `[[notifications:${type}]]`, - })); - res.render('admin/settings/user', { - notificationSettings: notificationSettings, - }); +settingsController.user = async (request, res) => { + const notificationTypes = await notifications.getAllNotificationTypes(); + const notificationSettings = notificationTypes.map(type => ({ + name: type, + label: `[[notifications:${type}]]`, + })); + res.render('admin/settings/user', { + notificationSettings, + }); }; -settingsController.post = async (req, res) => { - const groupData = await groups.getNonPrivilegeGroups('groups:createtime', 0, -1); - res.render('admin/settings/post', { - groupsExemptFromPostQueue: groupData, - }); +settingsController.post = async (request, res) => { + const groupData = await groups.getNonPrivilegeGroups('groups:createtime', 0, -1); + res.render('admin/settings/post', { + groupsExemptFromPostQueue: groupData, + }); }; -settingsController.advanced = async (req, res) => { - const groupData = await groups.getNonPrivilegeGroups('groups:createtime', 0, -1); - res.render('admin/settings/advanced', { - groupsExemptFromMaintenanceMode: groupData, - }); +settingsController.advanced = async (request, res) => { + const groupData = await groups.getNonPrivilegeGroups('groups:createtime', 0, -1); + res.render('admin/settings/advanced', { + groupsExemptFromMaintenanceMode: groupData, + }); }; -settingsController.languages = async function (req, res) { - const languageData = await languages.list(); - languageData.forEach((language) => { - language.selected = language.code === meta.config.defaultLang; - }); +settingsController.languages = async function (request, res) { + const languageData = await languages.list(); + for (const language of languageData) { + language.selected = language.code === meta.config.defaultLang; + } - res.render('admin/settings/languages', { - languages: languageData, - autoDetectLang: meta.config.autoDetectLang, - }); + res.render('admin/settings/languages', { + languages: languageData, + autoDetectLang: meta.config.autoDetectLang, + }); }; -settingsController.navigation = async function (req, res) { - const [admin, allGroups] = await Promise.all([ - navigationAdmin.getAdmin(), - groups.getNonPrivilegeGroups('groups:createtime', 0, -1), - ]); - - allGroups.sort((a, b) => b.system - a.system); - - admin.groups = allGroups.map(group => ({ name: group.name, displayName: group.displayName })); - admin.enabled.forEach((enabled, index) => { - enabled.index = index; - enabled.selected = index === 0; - enabled.title = translator.escape(enabled.title); - enabled.text = translator.escape(enabled.text); - enabled.dropdownContent = translator.escape(validator.escape(String(enabled.dropdownContent || ''))); - enabled.groups = admin.groups.map(group => ({ - displayName: group.displayName, - selected: enabled.groups.includes(group.name), - })); - }); - - admin.available.forEach((available) => { - available.groups = admin.groups; - }); - - admin.navigation = admin.enabled.slice(); - - res.render('admin/settings/navigation', admin); +settingsController.navigation = async function (request, res) { + const [admin, allGroups] = await Promise.all([ + navigationAdmin.getAdmin(), + groups.getNonPrivilegeGroups('groups:createtime', 0, -1), + ]); + + allGroups.sort((a, b) => b.system - a.system); + + admin.groups = allGroups.map(group => ({name: group.name, displayName: group.displayName})); + for (const [index, enabled] of admin.enabled.entries()) { + enabled.index = index; + enabled.selected = index === 0; + enabled.title = translator.escape(enabled.title); + enabled.text = translator.escape(enabled.text); + enabled.dropdownContent = translator.escape(validator.escape(String(enabled.dropdownContent || ''))); + enabled.groups = admin.groups.map(group => ({ + displayName: group.displayName, + selected: enabled.groups.includes(group.name), + })); + } + + for (const available of admin.available) { + available.groups = admin.groups; + } + + admin.navigation = admin.enabled.slice(); + + res.render('admin/settings/navigation', admin); }; -settingsController.homepage = async function (req, res) { - const routes = await helpers.getHomePageRoutes(req.uid); - res.render('admin/settings/homepage', { routes: routes }); +settingsController.homepage = async function (request, res) { + const routes = await helpers.getHomePageRoutes(request.uid); + res.render('admin/settings/homepage', {routes}); }; -settingsController.social = async function (req, res) { - const posts = await social.getPostSharing(); - res.render('admin/settings/social', { - posts: posts, - }); +settingsController.social = async function (request, res) { + const posts = await social.getPostSharing(); + res.render('admin/settings/social', { + posts, + }); }; diff --git a/src/controllers/admin/tags.js b/src/controllers/admin/tags.js index 294a8f9..d27ddf6 100644 --- a/src/controllers/admin/tags.js +++ b/src/controllers/admin/tags.js @@ -4,7 +4,7 @@ const topics = require('../../topics'); const tagsController = module.exports; -tagsController.get = async function (req, res) { - const tags = await topics.getTags(0, 199); - res.render('admin/manage/tags', { tags: tags }); +tagsController.get = async function (request, res) { + const tags = await topics.getTags(0, 199); + res.render('admin/manage/tags', {tags}); }; diff --git a/src/controllers/admin/themes.js b/src/controllers/admin/themes.js index ae546e8..3de2be2 100644 --- a/src/controllers/admin/themes.js +++ b/src/controllers/admin/themes.js @@ -1,31 +1,31 @@ 'use strict'; -const path = require('path'); -const fs = require('fs'); - +const path = require('node:path'); +const fs = require('node:fs'); const file = require('../../file'); -const { paths } = require('../../constants'); +const {paths} = require('../../constants'); const themesController = module.exports; const defaultScreenshotPath = path.join(__dirname, '../../../public/images/themes/default.png'); -themesController.get = async function (req, res, next) { - const themeDir = path.join(paths.themes, req.params.theme); - const themeConfigPath = path.join(themeDir, 'theme.json'); +themesController.get = async function (request, res, next) { + const themeDir = path.join(paths.themes, request.params.theme); + const themeConfigPath = path.join(themeDir, 'theme.json'); + + let themeConfig; + try { + themeConfig = await fs.promises.readFile(themeConfigPath, 'utf8'); + themeConfig = JSON.parse(themeConfig); + } catch (error) { + if (error.code === 'ENOENT') { + return next(new Error('invalid-data')); + } - let themeConfig; - try { - themeConfig = await fs.promises.readFile(themeConfigPath, 'utf8'); - themeConfig = JSON.parse(themeConfig); - } catch (err) { - if (err.code === 'ENOENT') { - return next(Error('invalid-data')); - } - return next(err); - } + return next(error); + } - const screenshotPath = themeConfig.screenshot ? path.join(themeDir, themeConfig.screenshot) : defaultScreenshotPath; - const exists = await file.exists(screenshotPath); - res.sendFile(exists ? screenshotPath : defaultScreenshotPath); + const screenshotPath = themeConfig.screenshot ? path.join(themeDir, themeConfig.screenshot) : defaultScreenshotPath; + const exists = await file.exists(screenshotPath); + res.sendFile(exists ? screenshotPath : defaultScreenshotPath); }; diff --git a/src/controllers/admin/uploads.js b/src/controllers/admin/uploads.js index 2f1e010..3e400ee 100644 --- a/src/controllers/admin/uploads.js +++ b/src/controllers/admin/uploads.js @@ -1,9 +1,8 @@ 'use strict'; -const path = require('path'); +const path = require('node:path'); +const fs = require('node:fs'); const nconf = require('nconf'); -const fs = require('fs'); - const meta = require('../../meta'); const posts = require('../../posts'); const file = require('../../file'); @@ -15,259 +14,263 @@ const allowedImageTypes = ['image/png', 'image/jpeg', 'image/pjpeg', 'image/jpg' const uploadsController = module.exports; -uploadsController.get = async function (req, res, next) { - const currentFolder = path.join(nconf.get('upload_path'), req.query.dir || ''); - if (!currentFolder.startsWith(nconf.get('upload_path'))) { - return next(new Error('[[error:invalid-path]]')); - } - const itemsPerPage = 20; - const page = parseInt(req.query.page, 10) || 1; - try { - let files = await fs.promises.readdir(currentFolder); - files = files.filter(filename => filename !== '.gitignore'); - const itemCount = files.length; - const start = Math.max(0, (page - 1) * itemsPerPage); - const stop = start + itemsPerPage; - files = files.slice(start, stop); - - files = await filesToData(currentFolder, files); - - // Float directories to the top - files.sort((a, b) => { - if (a.isDirectory && !b.isDirectory) { - return -1; - } else if (!a.isDirectory && b.isDirectory) { - return 1; - } else if (!a.isDirectory && !b.isDirectory) { - return a.mtime < b.mtime ? -1 : 1; - } - - return 0; - }); - - // Add post usage info if in /files - if (['files', '/files', '/files/'].includes(req.query.dir)) { - const usage = await posts.uploads.getUsage(files); - files.forEach((file, idx) => { - file.inPids = usage[idx].map(pid => parseInt(pid, 10)); - }); - } - res.render('admin/manage/uploads', { - currentFolder: currentFolder.replace(nconf.get('upload_path'), ''), - showPids: files.length && files[0].hasOwnProperty('inPids'), - files: files, - breadcrumbs: buildBreadcrumbs(currentFolder), - pagination: pagination.create(page, Math.ceil(itemCount / itemsPerPage), req.query), - }); - } catch (err) { - next(err); - } +uploadsController.get = async function (request, res, next) { + const currentFolder = path.join(nconf.get('upload_path'), request.query.dir || ''); + if (!currentFolder.startsWith(nconf.get('upload_path'))) { + return next(new Error('[[error:invalid-path]]')); + } + + const itemsPerPage = 20; + const page = Number.parseInt(request.query.page, 10) || 1; + try { + let files = await fs.promises.readdir(currentFolder); + files = files.filter(filename => filename !== '.gitignore'); + const itemCount = files.length; + const start = Math.max(0, (page - 1) * itemsPerPage); + const stop = start + itemsPerPage; + files = files.slice(start, stop); + + files = await filesToData(currentFolder, files); + + // Float directories to the top + files.sort((a, b) => { + if (a.isDirectory && !b.isDirectory) { + return -1; + } + + if (!a.isDirectory && b.isDirectory) { + return 1; + } + + if (!a.isDirectory && !b.isDirectory) { + return a.mtime < b.mtime ? -1 : 1; + } + + return 0; + }); + + // Add post usage info if in /files + if (['files', '/files', '/files/'].includes(request.query.dir)) { + const usage = await posts.uploads.getUsage(files); + for (const [index, file] of files.entries()) { + file.inPids = usage[index].map(pid => Number.parseInt(pid, 10)); + } + } + + res.render('admin/manage/uploads', { + currentFolder: currentFolder.replace(nconf.get('upload_path'), ''), + showPids: files.length && files[0].hasOwnProperty('inPids'), + files, + breadcrumbs: buildBreadcrumbs(currentFolder), + pagination: pagination.create(page, Math.ceil(itemCount / itemsPerPage), request.query), + }); + } catch (error) { + next(error); + } }; function buildBreadcrumbs(currentFolder) { - const crumbs = []; - const parts = currentFolder.replace(nconf.get('upload_path'), '').split(path.sep); - let currentPath = ''; - parts.forEach((part) => { - const dir = path.join(currentPath, part); - crumbs.push({ - text: part || 'Uploads', - url: part ? - (`${nconf.get('relative_path')}/admin/manage/uploads?dir=${dir}`) : - `${nconf.get('relative_path')}/admin/manage/uploads`, - }); - currentPath = dir; - }); - - return crumbs; + const crumbs = []; + const parts = currentFolder.replace(nconf.get('upload_path'), '').split(path.sep); + let currentPath = ''; + for (const part of parts) { + const dir = path.join(currentPath, part); + crumbs.push({ + text: part || 'Uploads', + url: part + ? (`${nconf.get('relative_path')}/admin/manage/uploads?dir=${dir}`) + : `${nconf.get('relative_path')}/admin/manage/uploads`, + }); + currentPath = dir; + } + + return crumbs; } async function filesToData(currentDir, files) { - return await Promise.all(files.map(file => getFileData(currentDir, file))); + return await Promise.all(files.map(file => getFileData(currentDir, file))); } async function getFileData(currentDir, file) { - const pathToFile = path.join(currentDir, file); - const stat = await fs.promises.stat(pathToFile); - let filesInDir = []; - if (stat.isDirectory()) { - filesInDir = await fs.promises.readdir(pathToFile); - } - const url = `${nconf.get('upload_url') + currentDir.replace(nconf.get('upload_path'), '')}/${file}`; - return { - name: file, - path: pathToFile.replace(path.join(nconf.get('upload_path'), '/'), ''), - url: url, - fileCount: Math.max(0, filesInDir.length - 1), // ignore .gitignore - size: stat.size, - sizeHumanReadable: `${(stat.size / 1024).toFixed(1)}KiB`, - isDirectory: stat.isDirectory(), - isFile: stat.isFile(), - mtime: stat.mtimeMs, - }; + const pathToFile = path.join(currentDir, file); + const stat = await fs.promises.stat(pathToFile); + let filesInDir = []; + if (stat.isDirectory()) { + filesInDir = await fs.promises.readdir(pathToFile); + } + + const url = `${nconf.get('upload_url') + currentDir.replace(nconf.get('upload_path'), '')}/${file}`; + return { + name: file, + path: pathToFile.replace(path.join(nconf.get('upload_path'), '/'), ''), + url, + fileCount: Math.max(0, filesInDir.length - 1), // ignore .gitignore + size: stat.size, + sizeHumanReadable: `${(stat.size / 1024).toFixed(1)}KiB`, + isDirectory: stat.isDirectory(), + isFile: stat.isFile(), + mtime: stat.mtimeMs, + }; } -uploadsController.uploadCategoryPicture = async function (req, res, next) { - const uploadedFile = req.files.files[0]; - let params = null; - - try { - params = JSON.parse(req.body.params); - } catch (e) { - file.delete(uploadedFile.path); - return next(new Error('[[error:invalid-json]]')); - } - - if (validateUpload(res, uploadedFile, allowedImageTypes)) { - const filename = `category-${params.cid}${path.extname(uploadedFile.name)}`; - await uploadImage(filename, 'category', uploadedFile, req, res, next); - } +uploadsController.uploadCategoryPicture = async function (request, res, next) { + const uploadedFile = request.files.files[0]; + let parameters = null; + + try { + parameters = JSON.parse(request.body.params); + } catch { + file.delete(uploadedFile.path); + return next(new Error('[[error:invalid-json]]')); + } + + if (validateUpload(res, uploadedFile, allowedImageTypes)) { + const filename = `category-${parameters.cid}${path.extname(uploadedFile.name)}`; + await uploadImage(filename, 'category', uploadedFile, request, res, next); + } }; -uploadsController.uploadFavicon = async function (req, res, next) { - const uploadedFile = req.files.files[0]; - const allowedTypes = ['image/x-icon', 'image/vnd.microsoft.icon']; - - if (validateUpload(res, uploadedFile, allowedTypes)) { - try { - const imageObj = await file.saveFileToLocal('favicon.ico', 'system', uploadedFile.path); - res.json([{ name: uploadedFile.name, url: imageObj.url }]); - } catch (err) { - next(err); - } finally { - file.delete(uploadedFile.path); - } - } +uploadsController.uploadFavicon = async function (request, res, next) { + const uploadedFile = request.files.files[0]; + const allowedTypes = ['image/x-icon', 'image/vnd.microsoft.icon']; + + if (validateUpload(res, uploadedFile, allowedTypes)) { + try { + const imageObject = await file.saveFileToLocal('favicon.ico', 'system', uploadedFile.path); + res.json([{name: uploadedFile.name, url: imageObject.url}]); + } catch (error) { + next(error); + } finally { + file.delete(uploadedFile.path); + } + } }; -uploadsController.uploadTouchIcon = async function (req, res, next) { - const uploadedFile = req.files.files[0]; - const allowedTypes = ['image/png']; - const sizes = [36, 48, 72, 96, 144, 192, 512]; - - if (validateUpload(res, uploadedFile, allowedTypes)) { - try { - const imageObj = await file.saveFileToLocal('touchicon-orig.png', 'system', uploadedFile.path); - // Resize the image into squares for use as touch icons at various DPIs - for (const size of sizes) { - /* eslint-disable no-await-in-loop */ - await image.resizeImage({ - path: uploadedFile.path, - target: path.join(nconf.get('upload_path'), 'system', `touchicon-${size}.png`), - width: size, - height: size, - }); - } - res.json([{ name: uploadedFile.name, url: imageObj.url }]); - } catch (err) { - next(err); - } finally { - file.delete(uploadedFile.path); - } - } +uploadsController.uploadTouchIcon = async function (request, res, next) { + const uploadedFile = request.files.files[0]; + const allowedTypes = ['image/png']; + const sizes = [36, 48, 72, 96, 144, 192, 512]; + + if (validateUpload(res, uploadedFile, allowedTypes)) { + try { + const imageObject = await file.saveFileToLocal('touchicon-orig.png', 'system', uploadedFile.path); + // Resize the image into squares for use as touch icons at various DPIs + for (const size of sizes) { + /* eslint-disable no-await-in-loop */ + await image.resizeImage({ + path: uploadedFile.path, + target: path.join(nconf.get('upload_path'), 'system', `touchicon-${size}.png`), + width: size, + height: size, + }); + } + + res.json([{name: uploadedFile.name, url: imageObject.url}]); + } catch (error) { + next(error); + } finally { + file.delete(uploadedFile.path); + } + } }; - -uploadsController.uploadMaskableIcon = async function (req, res, next) { - const uploadedFile = req.files.files[0]; - const allowedTypes = ['image/png']; - - if (validateUpload(res, uploadedFile, allowedTypes)) { - try { - const imageObj = await file.saveFileToLocal('maskableicon-orig.png', 'system', uploadedFile.path); - res.json([{ name: uploadedFile.name, url: imageObj.url }]); - } catch (err) { - next(err); - } finally { - file.delete(uploadedFile.path); - } - } +uploadsController.uploadMaskableIcon = async function (request, res, next) { + const uploadedFile = request.files.files[0]; + const allowedTypes = ['image/png']; + + if (validateUpload(res, uploadedFile, allowedTypes)) { + try { + const imageObject = await file.saveFileToLocal('maskableicon-orig.png', 'system', uploadedFile.path); + res.json([{name: uploadedFile.name, url: imageObject.url}]); + } catch (error) { + next(error); + } finally { + file.delete(uploadedFile.path); + } + } }; -uploadsController.uploadLogo = async function (req, res, next) { - await upload('site-logo', req, res, next); +uploadsController.uploadLogo = async function (request, res, next) { + await upload('site-logo', request, res, next); }; -uploadsController.uploadFile = async function (req, res, next) { - const uploadedFile = req.files.files[0]; - let params; - try { - params = JSON.parse(req.body.params); - } catch (e) { - file.delete(uploadedFile.path); - return next(new Error('[[error:invalid-json]]')); - } - - try { - const data = await file.saveFileToLocal(uploadedFile.name, params.folder, uploadedFile.path); - res.json([{ url: data.url }]); - } catch (err) { - next(err); - } finally { - file.delete(uploadedFile.path); - } +uploadsController.uploadFile = async function (request, res, next) { + const uploadedFile = request.files.files[0]; + let parameters; + try { + parameters = JSON.parse(request.body.params); + } catch { + file.delete(uploadedFile.path); + return next(new Error('[[error:invalid-json]]')); + } + + try { + const data = await file.saveFileToLocal(uploadedFile.name, parameters.folder, uploadedFile.path); + res.json([{url: data.url}]); + } catch (error) { + next(error); + } finally { + file.delete(uploadedFile.path); + } }; -uploadsController.uploadDefaultAvatar = async function (req, res, next) { - await upload('avatar-default', req, res, next); +uploadsController.uploadDefaultAvatar = async function (request, res, next) { + await upload('avatar-default', request, res, next); }; -uploadsController.uploadOgImage = async function (req, res, next) { - await upload('og:image', req, res, next); +uploadsController.uploadOgImage = async function (request, res, next) { + await upload('og:image', request, res, next); }; -async function upload(name, req, res, next) { - const uploadedFile = req.files.files[0]; +async function upload(name, request, res, next) { + const uploadedFile = request.files.files[0]; - if (validateUpload(res, uploadedFile, allowedImageTypes)) { - const filename = name + path.extname(uploadedFile.name); - await uploadImage(filename, 'system', uploadedFile, req, res, next); - } + if (validateUpload(res, uploadedFile, allowedImageTypes)) { + const filename = name + path.extname(uploadedFile.name); + await uploadImage(filename, 'system', uploadedFile, request, res, next); + } } function validateUpload(res, uploadedFile, allowedTypes) { - if (!allowedTypes.includes(uploadedFile.type)) { - file.delete(uploadedFile.path); - res.json({ error: `[[error:invalid-image-type, ${allowedTypes.join(', ')}]]` }); - return false; - } + if (!allowedTypes.includes(uploadedFile.type)) { + file.delete(uploadedFile.path); + res.json({error: `[[error:invalid-image-type, ${allowedTypes.join(', ')}]]`}); + return false; + } - return true; + return true; } -async function uploadImage(filename, folder, uploadedFile, req, res, next) { - let imageData; - try { - if (plugins.hooks.hasListeners('filter:uploadImage')) { - imageData = await plugins.hooks.fire('filter:uploadImage', { image: uploadedFile, uid: req.uid, folder: folder }); - } else { - imageData = await file.saveFileToLocal(filename, folder, uploadedFile.path); - } - - if (path.basename(filename, path.extname(filename)) === 'site-logo' && folder === 'system') { - const uploadPath = path.join(nconf.get('upload_path'), folder, 'site-logo-x50.png'); - await image.resizeImage({ - path: uploadedFile.path, - target: uploadPath, - height: 50, - }); - await meta.configs.set('brand:emailLogo', path.join(nconf.get('upload_url'), 'system/site-logo-x50.png')); - const size = await image.size(uploadedFile.path); - await meta.configs.setMultiple({ - 'brand:logo:width': size.width, - 'brand:logo:height': size.height, - }); - } else if (path.basename(filename, path.extname(filename)) === 'og:image' && folder === 'system') { - const size = await image.size(uploadedFile.path); - await meta.configs.setMultiple({ - 'og:image:width': size.width, - 'og:image:height': size.height, - }); - } - res.json([{ name: uploadedFile.name, url: imageData.url.startsWith('http') ? imageData.url : nconf.get('relative_path') + imageData.url }]); - } catch (err) { - next(err); - } finally { - file.delete(uploadedFile.path); - } +async function uploadImage(filename, folder, uploadedFile, request, res, next) { + let imageData; + try { + imageData = await (plugins.hooks.hasListeners('filter:uploadImage') ? plugins.hooks.fire('filter:uploadImage', {image: uploadedFile, uid: request.uid, folder}) : file.saveFileToLocal(filename, folder, uploadedFile.path)); + + if (path.basename(filename, path.extname(filename)) === 'site-logo' && folder === 'system') { + const uploadPath = path.join(nconf.get('upload_path'), folder, 'site-logo-x50.png'); + await image.resizeImage({ + path: uploadedFile.path, + target: uploadPath, + height: 50, + }); + await meta.configs.set('brand:emailLogo', path.join(nconf.get('upload_url'), 'system/site-logo-x50.png')); + const size = await image.size(uploadedFile.path); + await meta.configs.setMultiple({ + 'brand:logo:width': size.width, + 'brand:logo:height': size.height, + }); + } else if (path.basename(filename, path.extname(filename)) === 'og:image' && folder === 'system') { + const size = await image.size(uploadedFile.path); + await meta.configs.setMultiple({ + 'og:image:width': size.width, + 'og:image:height': size.height, + }); + } + + res.json([{name: uploadedFile.name, url: imageData.url.startsWith('http') ? imageData.url : nconf.get('relative_path') + imageData.url}]); + } catch (error) { + next(error); + } finally { + file.delete(uploadedFile.path); + } } diff --git a/src/controllers/admin/users.js b/src/controllers/admin/users.js index 7afc65c..442a16e 100644 --- a/src/controllers/admin/users.js +++ b/src/controllers/admin/users.js @@ -1,7 +1,6 @@ 'use strict'; const validator = require('validator'); - const user = require('../../user'); const meta = require('../../meta'); const db = require('../../database'); @@ -14,267 +13,285 @@ const utils = require('../../utils'); const usersController = module.exports; const userFields = [ - 'uid', 'username', 'userslug', 'email', 'postcount', 'joindate', 'banned', - 'reputation', 'picture', 'flags', 'lastonline', 'email:confirmed', + 'uid', + 'username', + 'userslug', + 'email', + 'postcount', + 'joindate', + 'banned', + 'reputation', + 'picture', + 'flags', + 'lastonline', + 'email:confirmed', ]; -usersController.index = async function (req, res) { - if (req.query.query) { - await usersController.search(req, res); - } else { - await getUsers(req, res); - } +usersController.index = async function (request, res) { + await (request.query.query ? usersController.search(request, res) : getUsers(request, res)); }; -async function getUsers(req, res) { - const sortDirection = req.query.sortDirection || 'desc'; - const reverse = sortDirection === 'desc'; - - const page = parseInt(req.query.page, 10) || 1; - let resultsPerPage = parseInt(req.query.resultsPerPage, 10) || 50; - if (![50, 100, 250, 500].includes(resultsPerPage)) { - resultsPerPage = 50; - } - let sortBy = validator.escape(req.query.sortBy || ''); - const filterBy = Array.isArray(req.query.filters || []) ? (req.query.filters || []) : [req.query.filters]; - const start = Math.max(0, page - 1) * resultsPerPage; - const stop = start + resultsPerPage - 1; - - function buildSet() { - const sortToSet = { - postcount: 'users:postcount', - reputation: 'users:reputation', - joindate: 'users:joindate', - lastonline: 'users:online', - flags: 'users:flags', - }; - - const set = []; - if (sortBy) { - set.push(sortToSet[sortBy]); - } - if (filterBy.includes('unverified')) { - set.push('group:unverified-users:members'); - } - if (filterBy.includes('verified')) { - set.push('group:verified-users:members'); - } - if (filterBy.includes('banned')) { - set.push('users:banned'); - } - if (!set.length) { - set.push('users:online'); - sortBy = 'lastonline'; - } - return set.length > 1 ? set : set[0]; - } - - async function getCount(set) { - if (Array.isArray(set)) { - return await db.sortedSetIntersectCard(set); - } - return await db.sortedSetCard(set); - } - - async function getUids(set) { - let uids = []; - if (Array.isArray(set)) { - const weights = set.map((s, index) => (index ? 0 : 1)); - uids = await db[reverse ? 'getSortedSetRevIntersect' : 'getSortedSetIntersect']({ - sets: set, - start: start, - stop: stop, - weights: weights, - }); - } else { - uids = await db[reverse ? 'getSortedSetRevRange' : 'getSortedSetRange'](set, start, stop); - } - return uids; - } - - const set = buildSet(); - const uids = await getUids(set); - const [count, users] = await Promise.all([ - getCount(set), - loadUserInfo(req.uid, uids), - ]); - - await render(req, res, { - users: users.filter(user => user && parseInt(user.uid, 10)), - page: page, - pageCount: Math.max(1, Math.ceil(count / resultsPerPage)), - resultsPerPage: resultsPerPage, - reverse: reverse, - sortBy: sortBy, - }); +async function getUsers(request, res) { + const sortDirection = request.query.sortDirection || 'desc'; + const reverse = sortDirection === 'desc'; + + const page = Number.parseInt(request.query.page, 10) || 1; + let resultsPerPage = Number.parseInt(request.query.resultsPerPage, 10) || 50; + if (![50, 100, 250, 500].includes(resultsPerPage)) { + resultsPerPage = 50; + } + + let sortBy = validator.escape(request.query.sortBy || ''); + const filterBy = Array.isArray(request.query.filters || []) ? (request.query.filters || []) : [request.query.filters]; + const start = Math.max(0, page - 1) * resultsPerPage; + const stop = start + resultsPerPage - 1; + + function buildSet() { + const sortToSet = { + postcount: 'users:postcount', + reputation: 'users:reputation', + joindate: 'users:joindate', + lastonline: 'users:online', + flags: 'users:flags', + }; + + const set = []; + if (sortBy) { + set.push(sortToSet[sortBy]); + } + + if (filterBy.includes('unverified')) { + set.push('group:unverified-users:members'); + } + + if (filterBy.includes('verified')) { + set.push('group:verified-users:members'); + } + + if (filterBy.includes('banned')) { + set.push('users:banned'); + } + + if (set.length === 0) { + set.push('users:online'); + sortBy = 'lastonline'; + } + + return set.length > 1 ? set : set[0]; + } + + async function getCount(set) { + if (Array.isArray(set)) { + return await db.sortedSetIntersectCard(set); + } + + return await db.sortedSetCard(set); + } + + async function getUids(set) { + let uids = []; + if (Array.isArray(set)) { + const weights = set.map((s, index) => (index ? 0 : 1)); + uids = await db[reverse ? 'getSortedSetRevIntersect' : 'getSortedSetIntersect']({ + sets: set, + start, + stop, + weights, + }); + } else { + uids = await db[reverse ? 'getSortedSetRevRange' : 'getSortedSetRange'](set, start, stop); + } + + return uids; + } + + const set = buildSet(); + const uids = await getUids(set); + const [count, users] = await Promise.all([ + getCount(set), + loadUserInfo(request.uid, uids), + ]); + + await render(request, res, { + users: users.filter(user => user && Number.parseInt(user.uid, 10)), + page, + pageCount: Math.max(1, Math.ceil(count / resultsPerPage)), + resultsPerPage, + reverse, + sortBy, + }); } -usersController.search = async function (req, res) { - const sortDirection = req.query.sortDirection || 'desc'; - const reverse = sortDirection === 'desc'; - const page = parseInt(req.query.page, 10) || 1; - let resultsPerPage = parseInt(req.query.resultsPerPage, 10) || 50; - if (![50, 100, 250, 500].includes(resultsPerPage)) { - resultsPerPage = 50; - } - - const searchData = await user.search({ - uid: req.uid, - query: req.query.query, - searchBy: req.query.searchBy, - sortBy: req.query.sortBy, - sortDirection: sortDirection, - filters: req.query.filters, - page: page, - resultsPerPage: resultsPerPage, - findUids: async function (query, searchBy, hardCap) { - if (!query || query.length < 2) { - return []; - } - query = String(query).toLowerCase(); - if (!query.endsWith('*')) { - query += '*'; - } - - const data = await db.getSortedSetScan({ - key: `${searchBy}:sorted`, - match: query, - limit: hardCap || (resultsPerPage * 10), - }); - return data.map(data => data.split(':').pop()); - }, - }); - - const uids = searchData.users.map(user => user && user.uid); - searchData.users = await loadUserInfo(req.uid, uids); - if (req.query.searchBy === 'ip') { - searchData.users.forEach((user) => { - user.ip = user.ips.find(ip => ip.includes(String(req.query.query))); - }); - } - searchData.query = validator.escape(String(req.query.query || '')); - searchData.page = page; - searchData.resultsPerPage = resultsPerPage; - searchData.sortBy = req.query.sortBy; - searchData.reverse = reverse; - await render(req, res, searchData); +usersController.search = async function (request, res) { + const sortDirection = request.query.sortDirection || 'desc'; + const reverse = sortDirection === 'desc'; + const page = Number.parseInt(request.query.page, 10) || 1; + let resultsPerPage = Number.parseInt(request.query.resultsPerPage, 10) || 50; + if (![50, 100, 250, 500].includes(resultsPerPage)) { + resultsPerPage = 50; + } + + const searchData = await user.search({ + uid: request.uid, + query: request.query.query, + searchBy: request.query.searchBy, + sortBy: request.query.sortBy, + sortDirection, + filters: request.query.filters, + page, + resultsPerPage, + async findUids(query, searchBy, hardCap) { + if (!query || query.length < 2) { + return []; + } + + query = String(query).toLowerCase(); + if (!query.endsWith('*')) { + query += '*'; + } + + const data = await db.getSortedSetScan({ + key: `${searchBy}:sorted`, + match: query, + limit: hardCap || (resultsPerPage * 10), + }); + return data.map(data => data.split(':').pop()); + }, + }); + + const uids = searchData.users.map(user => user && user.uid); + searchData.users = await loadUserInfo(request.uid, uids); + if (request.query.searchBy === 'ip') { + for (const user of searchData.users) { + user.ip = user.ips.find(ip => ip.includes(String(request.query.query))); + } + } + + searchData.query = validator.escape(String(request.query.query || '')); + searchData.page = page; + searchData.resultsPerPage = resultsPerPage; + searchData.sortBy = request.query.sortBy; + searchData.reverse = reverse; + await render(request, res, searchData); }; async function loadUserInfo(callerUid, uids) { - async function getIPs() { - return await Promise.all(uids.map(uid => db.getSortedSetRevRange(`uid:${uid}:ip`, 0, -1))); - } - const [isAdmin, userData, lastonline, ips] = await Promise.all([ - user.isAdministrator(uids), - user.getUsersWithFields(uids, userFields, callerUid), - db.sortedSetScores('users:online', uids), - getIPs(), - ]); - userData.forEach((user, index) => { - if (user) { - user.administrator = isAdmin[index]; - user.flags = userData[index].flags || 0; - const timestamp = lastonline[index] || user.joindate; - user.lastonline = timestamp; - user.lastonlineISO = utils.toISOString(timestamp); - user.ips = ips[index]; - user.ip = ips[index] && ips[index][0] ? ips[index][0] : null; - } - }); - return userData; + async function getIPs() { + return await Promise.all(uids.map(uid => db.getSortedSetRevRange(`uid:${uid}:ip`, 0, -1))); + } + + const [isAdmin, userData, lastonline, ips] = await Promise.all([ + user.isAdministrator(uids), + user.getUsersWithFields(uids, userFields, callerUid), + db.sortedSetScores('users:online', uids), + getIPs(), + ]); + for (const [index, user] of userData.entries()) { + if (user) { + user.administrator = isAdmin[index]; + user.flags = userData[index].flags || 0; + const timestamp = lastonline[index] || user.joindate; + user.lastonline = timestamp; + user.lastonlineISO = utils.toISOString(timestamp); + user.ips = ips[index]; + user.ip = ips[index] && ips[index][0] ? ips[index][0] : null; + } + } + + return userData; } -usersController.registrationQueue = async function (req, res) { - const page = parseInt(req.query.page, 10) || 1; - const itemsPerPage = 20; - const start = (page - 1) * 20; - const stop = start + itemsPerPage - 1; - - const data = await utils.promiseParallel({ - registrationQueueCount: db.sortedSetCard('registration:queue'), - users: user.getRegistrationQueue(start, stop), - customHeaders: plugins.hooks.fire('filter:admin.registrationQueue.customHeaders', { headers: [] }), - invites: getInvites(), - }); - const pageCount = Math.max(1, Math.ceil(data.registrationQueueCount / itemsPerPage)); - data.pagination = pagination.create(page, pageCount); - data.customHeaders = data.customHeaders.headers; - res.render('admin/manage/registration', data); +usersController.registrationQueue = async function (request, res) { + const page = Number.parseInt(request.query.page, 10) || 1; + const itemsPerPage = 20; + const start = (page - 1) * 20; + const stop = start + itemsPerPage - 1; + + const data = await utils.promiseParallel({ + registrationQueueCount: db.sortedSetCard('registration:queue'), + users: user.getRegistrationQueue(start, stop), + customHeaders: plugins.hooks.fire('filter:admin.registrationQueue.customHeaders', {headers: []}), + invites: getInvites(), + }); + const pageCount = Math.max(1, Math.ceil(data.registrationQueueCount / itemsPerPage)); + data.pagination = pagination.create(page, pageCount); + data.customHeaders = data.customHeaders.headers; + res.render('admin/manage/registration', data); }; async function getInvites() { - const invitations = await user.getAllInvites(); - const uids = invitations.map(invite => invite.uid); - let usernames = await user.getUsersFields(uids, ['username']); - usernames = usernames.map(user => user.username); - - invitations.forEach((invites, index) => { - invites.username = usernames[index]; - }); - - async function getUsernamesByEmails(emails) { - const uids = await db.sortedSetScores('email:uid', emails.map(email => String(email).toLowerCase())); - const usernames = await user.getUsersFields(uids, ['username']); - return usernames.map(user => user.username); - } - - usernames = await Promise.all(invitations.map(invites => getUsernamesByEmails(invites.invitations))); - - invitations.forEach((invites, index) => { - invites.invitations = invites.invitations.map((email, i) => ({ - email: email, - username: usernames[index][i] === '[[global:guest]]' ? '' : usernames[index][i], - })); - }); - return invitations; + const invitations = await user.getAllInvites(); + const uids = invitations.map(invite => invite.uid); + let usernames = await user.getUsersFields(uids, ['username']); + usernames = usernames.map(user => user.username); + + for (const [index, invites] of invitations.entries()) { + invites.username = usernames[index]; + } + + async function getUsernamesByEmails(emails) { + const uids = await db.sortedSetScores('email:uid', emails.map(email => String(email).toLowerCase())); + const usernames = await user.getUsersFields(uids, ['username']); + return usernames.map(user => user.username); + } + + usernames = await Promise.all(invitations.map(invites => getUsernamesByEmails(invites.invitations))); + + for (const [index, invites] of invitations.entries()) { + invites.invitations = invites.invitations.map((email, i) => ({ + email, + username: usernames[index][i] === '[[global:guest]]' ? '' : usernames[index][i], + })); + } + + return invitations; } -async function render(req, res, data) { - data.pagination = pagination.create(data.page, data.pageCount, req.query); - - const { registrationType } = meta.config; - - data.inviteOnly = registrationType === 'invite-only' || registrationType === 'admin-invite-only'; - data.adminInviteOnly = registrationType === 'admin-invite-only'; - data[`sort_${data.sortBy}`] = true; - if (req.query.searchBy) { - data[`searchBy_${validator.escape(String(req.query.searchBy))}`] = true; - } - const filterBy = Array.isArray(req.query.filters || []) ? (req.query.filters || []) : [req.query.filters]; - filterBy.forEach((filter) => { - data[`filterBy_${validator.escape(String(filter))}`] = true; - }); - data.userCount = parseInt(await db.getObjectField('global', 'userCount'), 10); - if (data.adminInviteOnly) { - data.showInviteButton = await privileges.users.isAdministrator(req.uid); - } else { - data.showInviteButton = await privileges.users.hasInvitePrivilege(req.uid); - } - - res.render('admin/manage/users', data); +async function render(request, res, data) { + data.pagination = pagination.create(data.page, data.pageCount, request.query); + + const {registrationType} = meta.config; + + data.inviteOnly = registrationType === 'invite-only' || registrationType === 'admin-invite-only'; + data.adminInviteOnly = registrationType === 'admin-invite-only'; + data[`sort_${data.sortBy}`] = true; + if (request.query.searchBy) { + data[`searchBy_${validator.escape(String(request.query.searchBy))}`] = true; + } + + const filterBy = Array.isArray(request.query.filters || []) ? (request.query.filters || []) : [request.query.filters]; + for (const filter of filterBy) { + data[`filterBy_${validator.escape(String(filter))}`] = true; + } + + data.userCount = Number.parseInt(await db.getObjectField('global', 'userCount'), 10); + data.showInviteButton = await (data.adminInviteOnly ? privileges.users.isAdministrator(request.uid) : privileges.users.hasInvitePrivilege(request.uid)); + + res.render('admin/manage/users', data); } -usersController.getCSV = async function (req, res, next) { - await events.log({ - type: 'getUsersCSV', - uid: req.uid, - ip: req.ip, - }); - const path = require('path'); - const { baseDir } = require('../../constants').paths; - res.sendFile('users.csv', { - root: path.join(baseDir, 'build/export'), - headers: { - 'Content-Type': 'text/csv', - 'Content-Disposition': 'attachment; filename=users.csv', - }, - }, (err) => { - if (err) { - if (err.code === 'ENOENT') { - res.locals.isAPI = false; - return next(); - } - return next(err); - } - }); +usersController.getCSV = async function (request, res, next) { + await events.log({ + type: 'getUsersCSV', + uid: request.uid, + ip: request.ip, + }); + const path = require('node:path'); + const {baseDir} = require('../../constants').paths; + res.sendFile('users.csv', { + root: path.join(baseDir, 'build/export'), + headers: { + 'Content-Type': 'text/csv', + 'Content-Disposition': 'attachment; filename=users.csv', + }, + }, error => { + if (error) { + if (error.code === 'ENOENT') { + res.locals.isAPI = false; + return next(); + } + + return next(error); + } + }); }; diff --git a/src/controllers/admin/widgets.js b/src/controllers/admin/widgets.js index 8a48963..825b614 100644 --- a/src/controllers/admin/widgets.js +++ b/src/controllers/admin/widgets.js @@ -3,7 +3,7 @@ const widgetsController = module.exports; const admin = require('../../widgets/admin'); -widgetsController.get = async function (req, res) { - const data = await admin.get(); - res.render('admin/extend/widgets', data); +widgetsController.get = async function (request, res) { + const data = await admin.get(); + res.render('admin/extend/widgets', data); }; diff --git a/src/controllers/api.js b/src/controllers/api.js index 1398c45..8dff752 100644 --- a/src/controllers/api.js +++ b/src/controllers/api.js @@ -2,7 +2,6 @@ const validator = require('validator'); const nconf = require('nconf'); - const meta = require('../meta'); const user = require('../user'); const categories = require('../categories'); @@ -19,113 +18,113 @@ const socketioTransports = nconf.get('socket.io:transports') || ['polling', 'web const socketioOrigins = nconf.get('socket.io:origins'); const websocketAddress = nconf.get('socket.io:address') || ''; -apiController.loadConfig = async function (req) { - const config = { - relative_path, - upload_url, - asset_base_url, - assetBaseUrl: asset_base_url, // deprecate in 1.20.x - siteTitle: validator.escape(String(meta.config.title || meta.config.browserTitle || 'NodeBB')), - browserTitle: validator.escape(String(meta.config.browserTitle || meta.config.title || 'NodeBB')), - titleLayout: (meta.config.titleLayout || '{pageTitle} | {browserTitle}').replace(/{/g, '{').replace(/}/g, '}'), - showSiteTitle: meta.config.showSiteTitle === 1, - maintenanceMode: meta.config.maintenanceMode === 1, - minimumTitleLength: meta.config.minimumTitleLength, - maximumTitleLength: meta.config.maximumTitleLength, - minimumPostLength: meta.config.minimumPostLength, - maximumPostLength: meta.config.maximumPostLength, - minimumTagsPerTopic: meta.config.minimumTagsPerTopic || 0, - maximumTagsPerTopic: meta.config.maximumTagsPerTopic || 5, - minimumTagLength: meta.config.minimumTagLength || 3, - maximumTagLength: meta.config.maximumTagLength || 15, - undoTimeout: meta.config.undoTimeout || 0, - useOutgoingLinksPage: meta.config.useOutgoingLinksPage === 1, - outgoingLinksWhitelist: meta.config.useOutgoingLinksPage === 1 ? meta.config['outgoingLinks:whitelist'] : undefined, - allowGuestHandles: meta.config.allowGuestHandles === 1, - allowTopicsThumbnail: meta.config.allowTopicsThumbnail === 1, - usePagination: meta.config.usePagination === 1, - disableChat: meta.config.disableChat === 1, - disableChatMessageEditing: meta.config.disableChatMessageEditing === 1, - maximumChatMessageLength: meta.config.maximumChatMessageLength || 1000, - socketioTransports, - socketioOrigins, - websocketAddress, - maxReconnectionAttempts: meta.config.maxReconnectionAttempts, - reconnectionDelay: meta.config.reconnectionDelay, - topicsPerPage: meta.config.topicsPerPage || 20, - postsPerPage: meta.config.postsPerPage || 20, - maximumFileSize: meta.config.maximumFileSize, - 'theme:id': meta.config['theme:id'], - 'theme:src': meta.config['theme:src'], - defaultLang: meta.config.defaultLang || 'en-GB', - userLang: req.query.lang ? validator.escape(String(req.query.lang)) : (meta.config.defaultLang || 'en-GB'), - loggedIn: !!req.user, - uid: req.uid, - 'cache-buster': meta.config['cache-buster'] || '', - topicPostSort: meta.config.topicPostSort || 'oldest_to_newest', - categoryTopicSort: meta.config.categoryTopicSort || 'newest_to_oldest', - csrf_token: req.uid >= 0 && req.csrfToken && req.csrfToken(), - searchEnabled: plugins.hooks.hasListeners('filter:search.query'), - searchDefaultInQuick: meta.config.searchDefaultInQuick || 'titles', - bootswatchSkin: meta.config.bootswatchSkin || '', - enablePostHistory: meta.config.enablePostHistory === 1, - timeagoCutoff: meta.config.timeagoCutoff !== '' ? Math.max(0, parseInt(meta.config.timeagoCutoff, 10)) : meta.config.timeagoCutoff, - timeagoCodes: languages.timeagoCodes, - cookies: { - enabled: meta.config.cookieConsentEnabled === 1, - message: translator.escape(validator.escape(meta.config.cookieConsentMessage || '[[global:cookies.message]]')).replace(/\\/g, '\\\\'), - dismiss: translator.escape(validator.escape(meta.config.cookieConsentDismiss || '[[global:cookies.accept]]')).replace(/\\/g, '\\\\'), - link: translator.escape(validator.escape(meta.config.cookieConsentLink || '[[global:cookies.learn_more]]')).replace(/\\/g, '\\\\'), - link_url: translator.escape(validator.escape(meta.config.cookieConsentLinkUrl || 'https://www.cookiesandyou.com')).replace(/\\/g, '\\\\'), - }, - thumbs: { - size: meta.config.topicThumbSize, - }, - iconBackgrounds: await user.getIconBackgrounds(req.uid), - emailPrompt: meta.config.emailPrompt, - useragent: req.useragent, - }; +apiController.loadConfig = async function (request) { + const config = { + relative_path, + upload_url, + asset_base_url, + assetBaseUrl: asset_base_url, // Deprecate in 1.20.x + siteTitle: validator.escape(String(meta.config.title || meta.config.browserTitle || 'NodeBB')), + browserTitle: validator.escape(String(meta.config.browserTitle || meta.config.title || 'NodeBB')), + titleLayout: (meta.config.titleLayout || '{pageTitle} | {browserTitle}').replaceAll('{', '{').replaceAll('}', '}'), + showSiteTitle: meta.config.showSiteTitle === 1, + maintenanceMode: meta.config.maintenanceMode === 1, + minimumTitleLength: meta.config.minimumTitleLength, + maximumTitleLength: meta.config.maximumTitleLength, + minimumPostLength: meta.config.minimumPostLength, + maximumPostLength: meta.config.maximumPostLength, + minimumTagsPerTopic: meta.config.minimumTagsPerTopic || 0, + maximumTagsPerTopic: meta.config.maximumTagsPerTopic || 5, + minimumTagLength: meta.config.minimumTagLength || 3, + maximumTagLength: meta.config.maximumTagLength || 15, + undoTimeout: meta.config.undoTimeout || 0, + useOutgoingLinksPage: meta.config.useOutgoingLinksPage === 1, + outgoingLinksWhitelist: meta.config.useOutgoingLinksPage === 1 ? meta.config['outgoingLinks:whitelist'] : undefined, + allowGuestHandles: meta.config.allowGuestHandles === 1, + allowTopicsThumbnail: meta.config.allowTopicsThumbnail === 1, + usePagination: meta.config.usePagination === 1, + disableChat: meta.config.disableChat === 1, + disableChatMessageEditing: meta.config.disableChatMessageEditing === 1, + maximumChatMessageLength: meta.config.maximumChatMessageLength || 1000, + socketioTransports, + socketioOrigins, + websocketAddress, + maxReconnectionAttempts: meta.config.maxReconnectionAttempts, + reconnectionDelay: meta.config.reconnectionDelay, + topicsPerPage: meta.config.topicsPerPage || 20, + postsPerPage: meta.config.postsPerPage || 20, + maximumFileSize: meta.config.maximumFileSize, + 'theme:id': meta.config['theme:id'], + 'theme:src': meta.config['theme:src'], + defaultLang: meta.config.defaultLang || 'en-GB', + userLang: request.query.lang ? validator.escape(String(request.query.lang)) : (meta.config.defaultLang || 'en-GB'), + loggedIn: Boolean(request.user), + uid: request.uid, + 'cache-buster': meta.config['cache-buster'] || '', + topicPostSort: meta.config.topicPostSort || 'oldest_to_newest', + categoryTopicSort: meta.config.categoryTopicSort || 'newest_to_oldest', + csrf_token: request.uid >= 0 && request.csrfToken && request.csrfToken(), + searchEnabled: plugins.hooks.hasListeners('filter:search.query'), + searchDefaultInQuick: meta.config.searchDefaultInQuick || 'titles', + bootswatchSkin: meta.config.bootswatchSkin || '', + enablePostHistory: meta.config.enablePostHistory === 1, + timeagoCutoff: meta.config.timeagoCutoff === '' ? meta.config.timeagoCutoff : Math.max(0, Number.parseInt(meta.config.timeagoCutoff, 10)), + timeagoCodes: languages.timeagoCodes, + cookies: { + enabled: meta.config.cookieConsentEnabled === 1, + message: translator.escape(validator.escape(meta.config.cookieConsentMessage || '[[global:cookies.message]]')).replaceAll('\\', '\\\\'), + dismiss: translator.escape(validator.escape(meta.config.cookieConsentDismiss || '[[global:cookies.accept]]')).replaceAll('\\', '\\\\'), + link: translator.escape(validator.escape(meta.config.cookieConsentLink || '[[global:cookies.learn_more]]')).replaceAll('\\', '\\\\'), + link_url: translator.escape(validator.escape(meta.config.cookieConsentLinkUrl || 'https://www.cookiesandyou.com')).replaceAll('\\', '\\\\'), + }, + thumbs: { + size: meta.config.topicThumbSize, + }, + iconBackgrounds: await user.getIconBackgrounds(request.uid), + emailPrompt: meta.config.emailPrompt, + useragent: request.useragent, + }; - let settings = config; - let isAdminOrGlobalMod; - if (req.loggedIn) { - ([settings, isAdminOrGlobalMod] = await Promise.all([ - user.getSettings(req.uid), - user.isAdminOrGlobalMod(req.uid), - ])); - } + let settings = config; + let isAdminOrGlobalModule; + if (request.loggedIn) { + ([settings, isAdminOrGlobalModule] = await Promise.all([ + user.getSettings(request.uid), + user.isAdminOrGlobalMod(request.uid), + ])); + } - // Handle old skin configs - const oldSkins = ['noskin', 'default']; - settings.bootswatchSkin = oldSkins.includes(settings.bootswatchSkin) ? '' : settings.bootswatchSkin; + // Handle old skin configs + const oldSkins = ['noskin', 'default']; + settings.bootswatchSkin = oldSkins.includes(settings.bootswatchSkin) ? '' : settings.bootswatchSkin; - config.usePagination = settings.usePagination; - config.topicsPerPage = settings.topicsPerPage; - config.postsPerPage = settings.postsPerPage; - config.userLang = validator.escape( - String((req.query.lang ? req.query.lang : null) || settings.userLang || config.defaultLang) - ); - config.acpLang = validator.escape(String((req.query.lang ? req.query.lang : null) || settings.acpLang)); - config.openOutgoingLinksInNewTab = settings.openOutgoingLinksInNewTab; - config.topicPostSort = settings.topicPostSort || config.topicPostSort; - config.categoryTopicSort = settings.categoryTopicSort || config.categoryTopicSort; - config.topicSearchEnabled = settings.topicSearchEnabled || false; - config.bootswatchSkin = (meta.config.disableCustomUserSkins !== 1 && settings.bootswatchSkin && settings.bootswatchSkin !== '') ? settings.bootswatchSkin : ''; + config.usePagination = settings.usePagination; + config.topicsPerPage = settings.topicsPerPage; + config.postsPerPage = settings.postsPerPage; + config.userLang = validator.escape( + String((request.query.lang ? request.query.lang : null) || settings.userLang || config.defaultLang), + ); + config.acpLang = validator.escape(String((request.query.lang ? request.query.lang : null) || settings.acpLang)); + config.openOutgoingLinksInNewTab = settings.openOutgoingLinksInNewTab; + config.topicPostSort = settings.topicPostSort || config.topicPostSort; + config.categoryTopicSort = settings.categoryTopicSort || config.categoryTopicSort; + config.topicSearchEnabled = settings.topicSearchEnabled || false; + config.bootswatchSkin = (meta.config.disableCustomUserSkins !== 1 && settings.bootswatchSkin && settings.bootswatchSkin !== '') ? settings.bootswatchSkin : ''; - // Overrides based on privilege - config.disableChatMessageEditing = isAdminOrGlobalMod ? false : config.disableChatMessageEditing; + // Overrides based on privilege + config.disableChatMessageEditing = isAdminOrGlobalModule ? false : config.disableChatMessageEditing; - return await plugins.hooks.fire('filter:config.get', config); + return await plugins.hooks.fire('filter:config.get', config); }; -apiController.getConfig = async function (req, res) { - const config = await apiController.loadConfig(req); - res.json(config); +apiController.getConfig = async function (request, res) { + const config = await apiController.loadConfig(request); + res.json(config); }; -apiController.getModerators = async function (req, res) { - const moderators = await categories.getModerators(req.params.cid); - res.json({ moderators: moderators }); +apiController.getModerators = async function (request, res) { + const moderators = await categories.getModerators(request.params.cid); + res.json({moderators}); }; require('../promisify')(apiController, ['getConfig', 'getObject', 'getModerators']); diff --git a/src/controllers/authentication.js b/src/controllers/authentication.js index ecd8e6a..32e9eb7 100644 --- a/src/controllers/authentication.js +++ b/src/controllers/authentication.js @@ -1,12 +1,11 @@ 'use strict'; +const util = require('node:util'); const winston = require('winston'); const passport = require('passport'); const nconf = require('nconf'); const validator = require('validator'); const _ = require('lodash'); -const util = require('util'); - const db = require('../database'); const meta = require('../meta'); const analytics = require('../analytics'); @@ -14,497 +13,519 @@ const user = require('../user'); const plugins = require('../plugins'); const utils = require('../utils'); const slugify = require('../slugify'); -const helpers = require('./helpers'); const privileges = require('../privileges'); const sockets = require('../socket.io'); +const helpers = require('./helpers'); const authenticationController = module.exports; -async function registerAndLoginUser(req, res, userData) { - if (!userData.hasOwnProperty('email')) { - userData.updateEmail = true; - } - - const data = await plugins.hooks.fire('filter:register.interstitial', { - req, - userData, - interstitials: [], - }); - - // If interstitials are found, save registration attempt into session and abort - const deferRegistration = data.interstitials.length; - - if (deferRegistration) { - userData.register = true; - req.session.registration = userData; - - if (req.body.noscript === 'true') { - res.redirect(`${nconf.get('relative_path')}/register/complete`); - return; - } - res.json({ next: `${nconf.get('relative_path')}/register/complete` }); - return; - } - const queue = await user.shouldQueueUser(req.ip); - const result = await plugins.hooks.fire('filter:register.shouldQueue', { req: req, res: res, userData: userData, queue: queue }); - if (result.queue) { - return await addToApprovalQueue(req, userData); - } - - const uid = await user.create(userData); - if (res.locals.processLogin) { - await authenticationController.doLogin(req, uid); - } - - // Distinguish registrations through invites from direct ones - if (userData.token) { - // Token has to be verified at this point - await Promise.all([ - user.confirmIfInviteEmailIsUsed(userData.token, userData.email, uid), - user.joinGroupsFromInvitation(uid, userData.token), - ]); - } - await user.deleteInvitationKey(userData.email, userData.token); - const next = req.session.returnTo || `${nconf.get('relative_path')}/`; - const complete = await plugins.hooks.fire('filter:register.complete', { uid: uid, next: next }); - req.session.returnTo = complete.next; - return complete; +async function registerAndLoginUser(request, res, userData) { + if (!userData.hasOwnProperty('email')) { + userData.updateEmail = true; + } + + const data = await plugins.hooks.fire('filter:register.interstitial', { + req: request, + userData, + interstitials: [], + }); + + // If interstitials are found, save registration attempt into session and abort + const deferRegistration = data.interstitials.length; + + if (deferRegistration) { + userData.register = true; + request.session.registration = userData; + + if (request.body.noscript === 'true') { + res.redirect(`${nconf.get('relative_path')}/register/complete`); + return; + } + + res.json({next: `${nconf.get('relative_path')}/register/complete`}); + return; + } + + const queue = await user.shouldQueueUser(request.ip); + const result = await plugins.hooks.fire('filter:register.shouldQueue', { + req: request, res, userData, queue, + }); + if (result.queue) { + return await addToApprovalQueue(request, userData); + } + + const uid = await user.create(userData); + if (res.locals.processLogin) { + await authenticationController.doLogin(request, uid); + } + + // Distinguish registrations through invites from direct ones + if (userData.token) { + // Token has to be verified at this point + await Promise.all([ + user.confirmIfInviteEmailIsUsed(userData.token, userData.email, uid), + user.joinGroupsFromInvitation(uid, userData.token), + ]); + } + + await user.deleteInvitationKey(userData.email, userData.token); + const next = request.session.returnTo || `${nconf.get('relative_path')}/`; + const complete = await plugins.hooks.fire('filter:register.complete', {uid, next}); + request.session.returnTo = complete.next; + return complete; } -authenticationController.register = async function (req, res) { - const registrationType = meta.config.registrationType || 'normal'; - - if (registrationType === 'disabled') { - return res.sendStatus(403); - } - - const userData = req.body; - try { - if (userData.token || registrationType === 'invite-only' || registrationType === 'admin-invite-only') { - await user.verifyInvitation(userData); - } - - if ( - !userData.username || - userData.username.length < meta.config.minimumUsernameLength || - slugify(userData.username).length < meta.config.minimumUsernameLength - ) { - throw new Error('[[error:username-too-short]]'); - } - - if (userData.username.length > meta.config.maximumUsernameLength) { - throw new Error('[[error:username-too-long]]'); - } - - if (userData.password !== userData['password-confirm']) { - throw new Error('[[user:change_password_error_match]]'); - } - - if (userData.password.length > 512) { - throw new Error('[[error:password-too-long]]'); - } - - if (!userData['account-type'] || - (userData['account-type'] !== 'student' && userData['account-type'] !== 'instructor')) { - throw new Error('Invalid account type'); - } - - user.isPasswordValid(userData.password); - - res.locals.processLogin = true; // set it to false in plugin if you wish to just register only - await plugins.hooks.fire('filter:register.check', { req: req, res: res, userData: userData }); - - const data = await registerAndLoginUser(req, res, userData); - if (data) { - if (data.uid && req.body.userLang) { - await user.setSetting(data.uid, 'userLang', req.body.userLang); - } - res.json(data); - } - } catch (err) { - helpers.noScriptErrors(req, res, err.message, 400); - } +authenticationController.register = async function (request, res) { + const registrationType = meta.config.registrationType || 'normal'; + + if (registrationType === 'disabled') { + return res.sendStatus(403); + } + + const userData = request.body; + try { + if (userData.token || registrationType === 'invite-only' || registrationType === 'admin-invite-only') { + await user.verifyInvitation(userData); + } + + if ( + !userData.username + || userData.username.length < meta.config.minimumUsernameLength + || slugify(userData.username).length < meta.config.minimumUsernameLength + ) { + throw new Error('[[error:username-too-short]]'); + } + + if (userData.username.length > meta.config.maximumUsernameLength) { + throw new Error('[[error:username-too-long]]'); + } + + if (userData.password !== userData['password-confirm']) { + throw new Error('[[user:change_password_error_match]]'); + } + + if (userData.password.length > 512) { + throw new Error('[[error:password-too-long]]'); + } + + if (!userData['account-type'] + || (userData['account-type'] !== 'student' && userData['account-type'] !== 'instructor')) { + throw new Error('Invalid account type'); + } + + user.isPasswordValid(userData.password); + + res.locals.processLogin = true; // Set it to false in plugin if you wish to just register only + await plugins.hooks.fire('filter:register.check', {req: request, res, userData}); + + const data = await registerAndLoginUser(request, res, userData); + if (data) { + if (data.uid && request.body.userLang) { + await user.setSetting(data.uid, 'userLang', request.body.userLang); + } + + res.json(data); + } + } catch (error) { + helpers.noScriptErrors(request, res, error.message, 400); + } }; -async function addToApprovalQueue(req, userData) { - userData.ip = req.ip; - await user.addToApprovalQueue(userData); - let message = '[[register:registration-added-to-queue]]'; - if (meta.config.showAverageApprovalTime) { - const average_time = await db.getObjectField('registration:queue:approval:times', 'average'); - if (average_time > 0) { - message += ` [[register:registration-queue-average-time, ${Math.floor(average_time / 60)}, ${Math.floor(average_time % 60)}]]`; - } - } - if (meta.config.autoApproveTime > 0) { - message += ` [[register:registration-queue-auto-approve-time, ${meta.config.autoApproveTime}]]`; - } - return { message: message }; +async function addToApprovalQueue(request, userData) { + userData.ip = request.ip; + await user.addToApprovalQueue(userData); + let message = '[[register:registration-added-to-queue]]'; + if (meta.config.showAverageApprovalTime) { + const average_time = await db.getObjectField('registration:queue:approval:times', 'average'); + if (average_time > 0) { + message += ` [[register:registration-queue-average-time, ${Math.floor(average_time / 60)}, ${Math.floor(average_time % 60)}]]`; + } + } + + if (meta.config.autoApproveTime > 0) { + message += ` [[register:registration-queue-auto-approve-time, ${meta.config.autoApproveTime}]]`; + } + + return {message}; } -authenticationController.registerComplete = async function (req, res) { - try { - // For the interstitials that respond, execute the callback with the form body - const data = await plugins.hooks.fire('filter:register.interstitial', { - req, - userData: req.session.registration, - interstitials: [], - }); - - const callbacks = data.interstitials.reduce((memo, cur) => { - if (cur.hasOwnProperty('callback') && typeof cur.callback === 'function') { - req.body.files = req.files; - if ( - (cur.callback.constructor && cur.callback.constructor.name === 'AsyncFunction') || - cur.callback.length === 2 // non-async function w/o callback - ) { - memo.push(cur.callback); - } else { - memo.push(util.promisify(cur.callback)); - } - } - - return memo; - }, []); - - const done = function (data) { - delete req.session.registration; - const relative_path = nconf.get('relative_path'); - if (data && data.message) { - return res.redirect(`${relative_path}/?register=${encodeURIComponent(data.message)}`); - } - - if (req.session.returnTo) { - res.redirect(relative_path + req.session.returnTo.replace(new RegExp(`^${relative_path}`), '')); - } else { - res.redirect(`${relative_path}/`); - } - }; - - const results = await Promise.allSettled(callbacks.map(async (cb) => { - await cb(req.session.registration, req.body); - })); - const errors = results.map(result => result.status === 'rejected' && result.reason && result.reason.message).filter(Boolean); - if (errors.length) { - req.flash('errors', errors); - return req.session.save(() => { - res.redirect(`${nconf.get('relative_path')}/register/complete`); - }); - } - - if (req.session.registration.register === true) { - res.locals.processLogin = true; - req.body.noscript = 'true'; // trigger full page load on error - - const data = await registerAndLoginUser(req, res, req.session.registration); - if (!data) { - return winston.warn('[register] Interstitial callbacks processed with no errors, but one or more interstitials remain. This is likely an issue with one of the interstitials not properly handling a null case or invalid value.'); - } - done(data); - } else { - // Update user hash, clear registration data in session - const payload = req.session.registration; - const { uid } = payload; - delete payload.uid; - delete payload.returnTo; - - Object.keys(payload).forEach((prop) => { - if (typeof payload[prop] === 'boolean') { - payload[prop] = payload[prop] ? 1 : 0; - } - }); - - await user.setUserFields(uid, payload); - done(); - } - } catch (err) { - delete req.session.registration; - res.redirect(`${nconf.get('relative_path')}/?register=${encodeURIComponent(err.message)}`); - } +authenticationController.registerComplete = async function (request, res) { + try { + // For the interstitials that respond, execute the callback with the form body + const data = await plugins.hooks.fire('filter:register.interstitial', { + req: request, + userData: request.session.registration, + interstitials: [], + }); + + const callbacks = data.interstitials.reduce((memo, current) => { + if (current.hasOwnProperty('callback') && typeof current.callback === 'function') { + request.body.files = request.files; + if ( + (current.callback.constructor && current.callback.constructor.name === 'AsyncFunction') + || current.callback.length === 2 // Non-async function w/o callback + ) { + memo.push(current.callback); + } else { + memo.push(util.promisify(current.callback)); + } + } + + return memo; + }, []); + + const done = function (data) { + delete request.session.registration; + const relative_path = nconf.get('relative_path'); + if (data && data.message) { + return res.redirect(`${relative_path}/?register=${encodeURIComponent(data.message)}`); + } + + if (request.session.returnTo) { + res.redirect(relative_path + request.session.returnTo.replace(new RegExp(`^${relative_path}`), '')); + } else { + res.redirect(`${relative_path}/`); + } + }; + + const results = await Promise.allSettled(callbacks.map(async callback => { + await callback(request.session.registration, request.body); + })); + const errors = results.map(result => result.status === 'rejected' && result.reason && result.reason.message).filter(Boolean); + if (errors.length > 0) { + request.flash('errors', errors); + return request.session.save(() => { + res.redirect(`${nconf.get('relative_path')}/register/complete`); + }); + } + + if (request.session.registration.register === true) { + res.locals.processLogin = true; + request.body.noscript = 'true'; // Trigger full page load on error + + const data = await registerAndLoginUser(request, res, request.session.registration); + if (!data) { + return winston.warn('[register] Interstitial callbacks processed with no errors, but one or more interstitials remain. This is likely an issue with one of the interstitials not properly handling a null case or invalid value.'); + } + + done(data); + } else { + // Update user hash, clear registration data in session + const payload = request.session.registration; + const {uid} = payload; + delete payload.uid; + delete payload.returnTo; + + for (const property of Object.keys(payload)) { + if (typeof payload[property] === 'boolean') { + payload[property] = payload[property] ? 1 : 0; + } + } + + await user.setUserFields(uid, payload); + done(); + } + } catch (error) { + delete request.session.registration; + res.redirect(`${nconf.get('relative_path')}/?register=${encodeURIComponent(error.message)}`); + } }; -authenticationController.registerAbort = function (req, res) { - if (req.uid) { - // Clear interstitial data and continue on... - delete req.session.registration; - res.redirect(nconf.get('relative_path') + (req.session.returnTo || '/')); - } else { - // End the session and redirect to home - req.session.destroy(() => { - res.clearCookie(nconf.get('sessionKey'), meta.configs.cookie.get()); - res.redirect(`${nconf.get('relative_path')}/`); - }); - } +authenticationController.registerAbort = function (request, res) { + if (request.uid) { + // Clear interstitial data and continue on... + delete request.session.registration; + res.redirect(nconf.get('relative_path') + (request.session.returnTo || '/')); + } else { + // End the session and redirect to home + request.session.destroy(() => { + res.clearCookie(nconf.get('sessionKey'), meta.configs.cookie.get()); + res.redirect(`${nconf.get('relative_path')}/`); + }); + } }; -authenticationController.login = async (req, res, next) => { - let { strategy } = await plugins.hooks.fire('filter:login.override', { req, strategy: 'local' }); - if (!passport._strategy(strategy)) { - winston.error(`[auth/override] Requested login strategy "${strategy}" not found, reverting back to local login strategy.`); - strategy = 'local'; - } - - if (plugins.hooks.hasListeners('action:auth.overrideLogin')) { - return continueLogin(strategy, req, res, next); - } - - const loginWith = meta.config.allowLoginWith || 'username-email'; - req.body.username = String(req.body.username).trim(); - const errorHandler = res.locals.noScriptErrors || helpers.noScriptErrors; - try { - await plugins.hooks.fire('filter:login.check', { req: req, res: res, userData: req.body }); - } catch (err) { - return errorHandler(req, res, err.message, 403); - } - try { - const isEmailLogin = loginWith.includes('email') && req.body.username && utils.isEmailValid(req.body.username); - const isUsernameLogin = loginWith.includes('username') && !validator.isEmail(req.body.username); - if (isEmailLogin) { - const username = await user.getUsernameByEmail(req.body.username); - if (username !== '[[global:guest]]') { - req.body.username = username; - } - } - if (isEmailLogin || isUsernameLogin) { - continueLogin(strategy, req, res, next); - } else { - errorHandler(req, res, `[[error:wrong-login-type-${loginWith}]]`, 400); - } - } catch (err) { - return errorHandler(req, res, err.message, 500); - } +authenticationController.login = async (request, res, next) => { + let {strategy} = await plugins.hooks.fire('filter:login.override', {req: request, strategy: 'local'}); + if (!passport._strategy(strategy)) { + winston.error(`[auth/override] Requested login strategy "${strategy}" not found, reverting back to local login strategy.`); + strategy = 'local'; + } + + if (plugins.hooks.hasListeners('action:auth.overrideLogin')) { + return continueLogin(strategy, request, res, next); + } + + const loginWith = meta.config.allowLoginWith || 'username-email'; + request.body.username = String(request.body.username).trim(); + const errorHandler = res.locals.noScriptErrors || helpers.noScriptErrors; + try { + await plugins.hooks.fire('filter:login.check', {req: request, res, userData: request.body}); + } catch (error) { + return errorHandler(request, res, error.message, 403); + } + + try { + const isEmailLogin = loginWith.includes('email') && request.body.username && utils.isEmailValid(request.body.username); + const isUsernameLogin = loginWith.includes('username') && !validator.isEmail(request.body.username); + if (isEmailLogin) { + const username = await user.getUsernameByEmail(request.body.username); + if (username !== '[[global:guest]]') { + request.body.username = username; + } + } + + if (isEmailLogin || isUsernameLogin) { + continueLogin(strategy, request, res, next); + } else { + errorHandler(request, res, `[[error:wrong-login-type-${loginWith}]]`, 400); + } + } catch (error) { + return errorHandler(request, res, error.message, 500); + } }; -function continueLogin(strategy, req, res, next) { - passport.authenticate(strategy, async (err, userData, info) => { - if (err) { - plugins.hooks.fire('action:login.continue', { req, strategy, userData, error: err }); - return helpers.noScriptErrors(req, res, err.data || err.message, 403); - } - - if (!userData) { - if (info instanceof Error) { - info = info.message; - } else if (typeof info === 'object') { - info = '[[error:invalid-username-or-password]]'; - } - - plugins.hooks.fire('action:login.continue', { req, strategy, userData, error: new Error(info) }); - return helpers.noScriptErrors(req, res, info, 403); - } - - // Alter user cookie depending on passed-in option - if (req.body.remember === 'on') { - const duration = meta.getSessionTTLSeconds() * 1000; - req.session.cookie.maxAge = duration; - req.session.cookie.expires = new Date(Date.now() + duration); - } else { - req.session.cookie.maxAge = false; - req.session.cookie.expires = false; - } - - plugins.hooks.fire('action:login.continue', { req, strategy, userData, error: null }); - - if (userData.passwordExpiry && userData.passwordExpiry < Date.now()) { - winston.verbose(`[auth] Triggering password reset for uid ${userData.uid} due to password policy`); - req.session.passwordExpired = true; - - const code = await user.reset.generate(userData.uid); - (res.locals.redirectAfterLogin || redirectAfterLogin)(req, res, `${nconf.get('relative_path')}/reset/${code}`); - } else { - delete req.query.lang; - await authenticationController.doLogin(req, userData.uid); - let destination; - if (req.session.returnTo) { - destination = req.session.returnTo.startsWith('http') ? - req.session.returnTo : - nconf.get('relative_path') + req.session.returnTo; - delete req.session.returnTo; - } else { - destination = `${nconf.get('relative_path')}/`; - } - - (res.locals.redirectAfterLogin || redirectAfterLogin)(req, res, destination); - } - })(req, res, next); +function continueLogin(strategy, request, res, next) { + passport.authenticate(strategy, async (error, userData, info) => { + if (error) { + plugins.hooks.fire('action:login.continue', { + req: request, strategy, userData, error, + }); + return helpers.noScriptErrors(request, res, error.data || error.message, 403); + } + + if (!userData) { + if (info instanceof Error) { + info = info.message; + } else if (typeof info === 'object') { + info = '[[error:invalid-username-or-password]]'; + } + + plugins.hooks.fire('action:login.continue', { + req: request, strategy, userData, error: new Error(info), + }); + return helpers.noScriptErrors(request, res, info, 403); + } + + // Alter user cookie depending on passed-in option + if (request.body.remember === 'on') { + const duration = meta.getSessionTTLSeconds() * 1000; + request.session.cookie.maxAge = duration; + request.session.cookie.expires = new Date(Date.now() + duration); + } else { + request.session.cookie.maxAge = false; + request.session.cookie.expires = false; + } + + plugins.hooks.fire('action:login.continue', { + req: request, strategy, userData, error: null, + }); + + if (userData.passwordExpiry && userData.passwordExpiry < Date.now()) { + winston.verbose(`[auth] Triggering password reset for uid ${userData.uid} due to password policy`); + request.session.passwordExpired = true; + + const code = await user.reset.generate(userData.uid); + (res.locals.redirectAfterLogin || redirectAfterLogin)(request, res, `${nconf.get('relative_path')}/reset/${code}`); + } else { + delete request.query.lang; + await authenticationController.doLogin(request, userData.uid); + let destination; + if (request.session.returnTo) { + destination = request.session.returnTo.startsWith('http') + ? request.session.returnTo + : nconf.get('relative_path') + request.session.returnTo; + delete request.session.returnTo; + } else { + destination = `${nconf.get('relative_path')}/`; + } + + (res.locals.redirectAfterLogin || redirectAfterLogin)(request, res, destination); + } + })(request, res, next); } -function redirectAfterLogin(req, res, destination) { - if (req.body.noscript === 'true') { - res.redirect(`${destination}?loggedin`); - } else { - res.status(200).send({ - next: destination, - }); - } +function redirectAfterLogin(request, res, destination) { + if (request.body.noscript === 'true') { + res.redirect(`${destination}?loggedin`); + } else { + res.status(200).send({ + next: destination, + }); + } } -authenticationController.doLogin = async function (req, uid) { - if (!uid) { - return; - } - const loginAsync = util.promisify(req.login).bind(req); - await loginAsync({ uid: uid }, { keepSessionInfo: req.res.locals !== false }); - await authenticationController.onSuccessfulLogin(req, uid); +authenticationController.doLogin = async function (request, uid) { + if (!uid) { + return; + } + + const loginAsync = util.promisify(request.login).bind(request); + await loginAsync({uid}, {keepSessionInfo: request.res.locals !== false}); + await authenticationController.onSuccessfulLogin(request, uid); }; -authenticationController.onSuccessfulLogin = async function (req, uid) { - /* +authenticationController.onSuccessfulLogin = async function (request, uid) { + /* * Older code required that this method be called from within the SSO plugin. * That behaviour is no longer required, onSuccessfulLogin is now automatically * called in NodeBB core. However, if already called, return prematurely */ - if (req.loggedIn && !req.session.forceLogin) { - return true; - } - - try { - const uuid = utils.generateUUID(); - - req.uid = uid; - req.loggedIn = true; - await meta.blacklist.test(req.ip); - await user.logIP(uid, req.ip); - await user.bans.unbanIfExpired([uid]); - await user.reset.cleanByUid(uid); - - req.session.meta = {}; - - delete req.session.forceLogin; - // Associate IP used during login with user account - req.session.meta.ip = req.ip; - - // Associate metadata retrieved via user-agent - req.session.meta = _.extend(req.session.meta, { - uuid: uuid, - datetime: Date.now(), - platform: req.useragent.platform, - browser: req.useragent.browser, - version: req.useragent.version, - }); - await Promise.all([ - new Promise((resolve) => { - req.session.save(resolve); - }), - user.auth.addSession(uid, req.sessionID), - user.updateLastOnlineTime(uid), - user.updateOnlineUsers(uid), - analytics.increment('logins'), - db.incrObjectFieldBy('global', 'loginCount', 1), - ]); - if (uid > 0) { - await db.setObjectField(`uid:${uid}:sessionUUID:sessionId`, uuid, req.sessionID); - } - - // Force session check for all connected socket.io clients with the same session id - sockets.in(`sess_${req.sessionID}`).emit('checkSession', uid); - - plugins.hooks.fire('action:user.loggedIn', { uid: uid, req: req }); - } catch (err) { - req.session.destroy(); - throw err; - } + if (request.loggedIn && !request.session.forceLogin) { + return true; + } + + try { + const uuid = utils.generateUUID(); + + request.uid = uid; + request.loggedIn = true; + await meta.blacklist.test(request.ip); + await user.logIP(uid, request.ip); + await user.bans.unbanIfExpired([uid]); + await user.reset.cleanByUid(uid); + + request.session.meta = {}; + + delete request.session.forceLogin; + // Associate IP used during login with user account + request.session.meta.ip = request.ip; + + // Associate metadata retrieved via user-agent + request.session.meta = _.extend(request.session.meta, { + uuid, + datetime: Date.now(), + platform: request.useragent.platform, + browser: request.useragent.browser, + version: request.useragent.version, + }); + await Promise.all([ + new Promise(resolve => { + request.session.save(resolve); + }), + user.auth.addSession(uid, request.sessionID), + user.updateLastOnlineTime(uid), + user.updateOnlineUsers(uid), + analytics.increment('logins'), + db.incrObjectFieldBy('global', 'loginCount', 1), + ]); + if (uid > 0) { + await db.setObjectField(`uid:${uid}:sessionUUID:sessionId`, uuid, request.sessionID); + } + + // Force session check for all connected socket.io clients with the same session id + sockets.in(`sess_${request.sessionID}`).emit('checkSession', uid); + + plugins.hooks.fire('action:user.loggedIn', {uid, req: request}); + } catch (error) { + request.session.destroy(); + throw error; + } }; -authenticationController.localLogin = async function (req, username, password, next) { - if (!username) { - return next(new Error('[[error:invalid-username]]')); - } - - if (!password || !utils.isPasswordValid(password)) { - return next(new Error('[[error:invalid-password]]')); - } - - if (password.length > 512) { - return next(new Error('[[error:password-too-long]]')); - } - - const userslug = slugify(username); - const uid = await user.getUidByUserslug(userslug); - try { - const [userData, isAdminOrGlobalMod, canLoginIfBanned] = await Promise.all([ - user.getUserFields(uid, ['uid', 'passwordExpiry']), - user.isAdminOrGlobalMod(uid), - user.bans.canLoginIfBanned(uid), - ]); - - userData.isAdminOrGlobalMod = isAdminOrGlobalMod; - - if (!canLoginIfBanned) { - return next(await getBanError(uid)); - } - - // Doing this after the ban check, because user's privileges might change after a ban expires - const hasLoginPrivilege = await privileges.global.can('local:login', uid); - if (parseInt(uid, 10) && !hasLoginPrivilege) { - return next(new Error('[[error:local-login-disabled]]')); - } - - const passwordMatch = await user.isPasswordCorrect(uid, password, req.ip); - if (!passwordMatch) { - return next(new Error('[[error:invalid-login-credentials]]')); - } - - next(null, userData, '[[success:authentication-successful]]'); - } catch (err) { - next(err); - } +authenticationController.localLogin = async function (request, username, password, next) { + if (!username) { + return next(new Error('[[error:invalid-username]]')); + } + + if (!password || !utils.isPasswordValid(password)) { + return next(new Error('[[error:invalid-password]]')); + } + + if (password.length > 512) { + return next(new Error('[[error:password-too-long]]')); + } + + const userslug = slugify(username); + const uid = await user.getUidByUserslug(userslug); + try { + const [userData, isAdminOrGlobalModule, canLoginIfBanned] = await Promise.all([ + user.getUserFields(uid, ['uid', 'passwordExpiry']), + user.isAdminOrGlobalMod(uid), + user.bans.canLoginIfBanned(uid), + ]); + + userData.isAdminOrGlobalMod = isAdminOrGlobalModule; + + if (!canLoginIfBanned) { + return next(await getBanError(uid)); + } + + // Doing this after the ban check, because user's privileges might change after a ban expires + const hasLoginPrivilege = await privileges.global.can('local:login', uid); + if (Number.parseInt(uid, 10) && !hasLoginPrivilege) { + return next(new Error('[[error:local-login-disabled]]')); + } + + const passwordMatch = await user.isPasswordCorrect(uid, password, request.ip); + if (!passwordMatch) { + return next(new Error('[[error:invalid-login-credentials]]')); + } + + next(null, userData, '[[success:authentication-successful]]'); + } catch (error) { + next(error); + } }; -const destroyAsync = util.promisify((req, callback) => req.session.destroy(callback)); -const logoutAsync = util.promisify((req, callback) => req.logout(callback)); - -authenticationController.logout = async function (req, res, next) { - if (!req.loggedIn || !req.sessionID) { - res.clearCookie(nconf.get('sessionKey'), meta.configs.cookie.get()); - return res.status(200).send('not-logged-in'); - } - const { uid } = req; - const { sessionID } = req; - - try { - await user.auth.revokeSession(sessionID, uid); - await logoutAsync(req); - - await destroyAsync(req); - res.clearCookie(nconf.get('sessionKey'), meta.configs.cookie.get()); - - await user.setUserField(uid, 'lastonline', Date.now() - (meta.config.onlineCutoff * 60000)); - await db.sortedSetAdd('users:online', Date.now() - (meta.config.onlineCutoff * 60000), uid); - await plugins.hooks.fire('static:user.loggedOut', { req: req, res: res, uid: uid, sessionID: sessionID }); - - // Force session check for all connected socket.io clients with the same session id - sockets.in(`sess_${sessionID}`).emit('checkSession', 0); - const payload = { - next: `${nconf.get('relative_path')}/`, - }; - plugins.hooks.fire('filter:user.logout', payload); - - if (req.body.noscript === 'true') { - return res.redirect(payload.next); - } - res.status(200).send(payload); - } catch (err) { - next(err); - } +const destroyAsync = util.promisify((request, callback) => request.session.destroy(callback)); +const logoutAsync = util.promisify((request, callback) => request.logout(callback)); + +authenticationController.logout = async function (request, res, next) { + if (!request.loggedIn || !request.sessionID) { + res.clearCookie(nconf.get('sessionKey'), meta.configs.cookie.get()); + return res.status(200).send('not-logged-in'); + } + + const {uid} = request; + const {sessionID} = request; + + try { + await user.auth.revokeSession(sessionID, uid); + await logoutAsync(request); + + await destroyAsync(request); + res.clearCookie(nconf.get('sessionKey'), meta.configs.cookie.get()); + + await user.setUserField(uid, 'lastonline', Date.now() - (meta.config.onlineCutoff * 60_000)); + await db.sortedSetAdd('users:online', Date.now() - (meta.config.onlineCutoff * 60_000), uid); + await plugins.hooks.fire('static:user.loggedOut', { + req: request, res, uid, sessionID, + }); + + // Force session check for all connected socket.io clients with the same session id + sockets.in(`sess_${sessionID}`).emit('checkSession', 0); + const payload = { + next: `${nconf.get('relative_path')}/`, + }; + plugins.hooks.fire('filter:user.logout', payload); + + if (request.body.noscript === 'true') { + return res.redirect(payload.next); + } + + res.status(200).send(payload); + } catch (error) { + next(error); + } }; async function getBanError(uid) { - try { - const banInfo = await user.getLatestBanInfo(uid); - - if (!banInfo.reason) { - banInfo.reason = '[[user:info.banned-no-reason]]'; - } - const err = new Error(banInfo.reason); - err.data = banInfo; - return err; - } catch (err) { - if (err.message === 'no-ban-info') { - return new Error('[[error:user-banned]]'); - } - throw err; - } + try { + const banInfo = await user.getLatestBanInfo(uid); + + banInfo.reason ||= '[[user:info.banned-no-reason]]'; + + const error = new Error(banInfo.reason); + error.data = banInfo; + return error; + } catch (error) { + if (error.message === 'no-ban-info') { + return new Error('[[error:user-banned]]'); + } + + throw error; + } } require('../promisify')(authenticationController, ['register', 'registerComplete', 'registerAbort', 'login', 'localLogin', 'logout']); diff --git a/src/controllers/career.js b/src/controllers/career.js index beb8b6e..f1ea8ac 100644 --- a/src/controllers/career.js +++ b/src/controllers/career.js @@ -2,7 +2,7 @@ const careerController = module.exports; -careerController.get = async function (req, res) { - const careerData = {}; - res.render('career', careerData); +careerController.get = async function (request, res) { + const careerData = {}; + res.render('career', careerData); }; diff --git a/src/controllers/categories.js b/src/controllers/categories.js index 88925f9..744f77c 100644 --- a/src/controllers/categories.js +++ b/src/controllers/categories.js @@ -2,60 +2,59 @@ const nconf = require('nconf'); const _ = require('lodash'); - const categories = require('../categories'); const meta = require('../meta'); const pagination = require('../pagination'); -const helpers = require('./helpers'); const privileges = require('../privileges'); +const helpers = require('./helpers'); const categoriesController = module.exports; -categoriesController.list = async function (req, res) { - res.locals.metaTags = [{ - name: 'title', - content: String(meta.config.title || 'NodeBB'), - }, { - property: 'og:type', - content: 'website', - }]; - - const allRootCids = await categories.getAllCidsFromSet('cid:0:children'); - const rootCids = await privileges.categories.filterCids('find', allRootCids, req.uid); - const pageCount = Math.max(1, Math.ceil(rootCids.length / meta.config.categoriesPerPage)); - const page = Math.min(parseInt(req.query.page, 10) || 1, pageCount); - const start = Math.max(0, (page - 1) * meta.config.categoriesPerPage); - const stop = start + meta.config.categoriesPerPage - 1; - const pageCids = rootCids.slice(start, stop + 1); - - const allChildCids = _.flatten(await Promise.all(pageCids.map(categories.getChildrenCids))); - const childCids = await privileges.categories.filterCids('find', allChildCids, req.uid); - const categoryData = await categories.getCategories(pageCids.concat(childCids), req.uid); - const tree = categories.getTree(categoryData, 0); - await categories.getRecentTopicReplies(categoryData, req.uid, req.query); - - const data = { - title: meta.config.homePageTitle || '[[pages:home]]', - selectCategoryLabel: '[[pages:categories]]', - categories: tree, - pagination: pagination.create(page, pageCount, req.query), - }; - - data.categories.forEach((category) => { - if (category) { - helpers.trimChildren(category); - helpers.setCategoryTeaser(category); - } - }); - - if (req.originalUrl.startsWith(`${nconf.get('relative_path')}/api/categories`) || req.originalUrl.startsWith(`${nconf.get('relative_path')}/categories`)) { - data.title = '[[pages:categories]]'; - data.breadcrumbs = helpers.buildBreadcrumbs([{ text: data.title }]); - res.locals.metaTags.push({ - property: 'og:title', - content: '[[pages:categories]]', - }); - } - - res.render('categories', data); +categoriesController.list = async function (request, res) { + res.locals.metaTags = [{ + name: 'title', + content: String(meta.config.title || 'NodeBB'), + }, { + property: 'og:type', + content: 'website', + }]; + + const allRootCids = await categories.getAllCidsFromSet('cid:0:children'); + const rootCids = await privileges.categories.filterCids('find', allRootCids, request.uid); + const pageCount = Math.max(1, Math.ceil(rootCids.length / meta.config.categoriesPerPage)); + const page = Math.min(Number.parseInt(request.query.page, 10) || 1, pageCount); + const start = Math.max(0, (page - 1) * meta.config.categoriesPerPage); + const stop = start + meta.config.categoriesPerPage - 1; + const pageCids = rootCids.slice(start, stop + 1); + + const allChildCids = (await Promise.all(pageCids.map(categories.getChildrenCids))).flat(); + const childCids = await privileges.categories.filterCids('find', allChildCids, request.uid); + const categoryData = await categories.getCategories(pageCids.concat(childCids), request.uid); + const tree = categories.getTree(categoryData, 0); + await categories.getRecentTopicReplies(categoryData, request.uid, request.query); + + const data = { + title: meta.config.homePageTitle || '[[pages:home]]', + selectCategoryLabel: '[[pages:categories]]', + categories: tree, + pagination: pagination.create(page, pageCount, request.query), + }; + + for (const category of data.categories) { + if (category) { + helpers.trimChildren(category); + helpers.setCategoryTeaser(category); + } + } + + if (request.originalUrl.startsWith(`${nconf.get('relative_path')}/api/categories`) || request.originalUrl.startsWith(`${nconf.get('relative_path')}/categories`)) { + data.title = '[[pages:categories]]'; + data.breadcrumbs = helpers.buildBreadcrumbs([{text: data.title}]); + res.locals.metaTags.push({ + property: 'og:title', + content: '[[pages:categories]]', + }); + } + + res.render('categories', data); }; diff --git a/src/controllers/category.js b/src/controllers/category.js index 5d9a59b..037ca85 100644 --- a/src/controllers/category.js +++ b/src/controllers/category.js @@ -1,206 +1,207 @@ 'use strict'; - -const nconf = require('nconf'); +const qs = require('node:querystring'); const validator = require('validator'); -const qs = require('querystring'); - +const nconf = require('nconf'); const db = require('../database'); const privileges = require('../privileges'); const user = require('../user'); const categories = require('../categories'); const meta = require('../meta'); const pagination = require('../pagination'); -const helpers = require('./helpers'); const utils = require('../utils'); const translator = require('../translator'); const analytics = require('../analytics'); +const helpers = require('./helpers'); const categoryController = module.exports; const url = nconf.get('url'); const relative_path = nconf.get('relative_path'); -categoryController.get = async function (req, res, next) { - const cid = req.params.category_id; - - let currentPage = parseInt(req.query.page, 10) || 1; - let topicIndex = utils.isNumber(req.params.topic_index) ? parseInt(req.params.topic_index, 10) - 1 : 0; - if ((req.params.topic_index && !utils.isNumber(req.params.topic_index)) || !utils.isNumber(cid)) { - return next(); - } - - const [categoryFields, userPrivileges, userSettings, rssToken] = await Promise.all([ - categories.getCategoryFields(cid, ['slug', 'disabled', 'link']), - privileges.categories.get(cid, req.uid), - user.getSettings(req.uid), - user.auth.getFeedToken(req.uid), - ]); - - if (!categoryFields.slug || - (categoryFields && categoryFields.disabled) || - (userSettings.usePagination && currentPage < 1)) { - return next(); - } - if (topicIndex < 0) { - return helpers.redirect(res, `/category/${categoryFields.slug}?${qs.stringify(req.query)}`); - } - - if (!userPrivileges.read) { - return helpers.notAllowed(req, res); - } - - if (!res.locals.isAPI && !req.params.slug && (categoryFields.slug && categoryFields.slug !== `${cid}/`)) { - return helpers.redirect(res, `/category/${categoryFields.slug}?${qs.stringify(req.query)}`, true); - } - - if (categoryFields.link) { - await db.incrObjectField(`category:${cid}`, 'timesClicked'); - return helpers.redirect(res, validator.unescape(categoryFields.link)); - } - - if (!userSettings.usePagination) { - topicIndex = Math.max(0, topicIndex - (Math.ceil(userSettings.topicsPerPage / 2) - 1)); - } else if (!req.query.page) { - const index = Math.max(parseInt((topicIndex || 0), 10), 0); - currentPage = Math.ceil((index + 1) / userSettings.topicsPerPage); - topicIndex = 0; - } - - const targetUid = await user.getUidByUserslug(req.query.author); - const start = ((currentPage - 1) * userSettings.topicsPerPage) + topicIndex; - const stop = start + userSettings.topicsPerPage - 1; - - const categoryData = await categories.getCategoryById({ - uid: req.uid, - cid: cid, - start: start, - stop: stop, - sort: req.query.sort || userSettings.categoryTopicSort, - settings: userSettings, - query: req.query, - tag: req.query.tag, - targetUid: targetUid, - }); - if (!categoryData) { - return next(); - } - - if (topicIndex > Math.max(categoryData.topic_count - 1, 0)) { - return helpers.redirect(res, `/category/${categoryData.slug}/${categoryData.topic_count}?${qs.stringify(req.query)}`); - } - const pageCount = Math.max(1, Math.ceil(categoryData.topic_count / userSettings.topicsPerPage)); - if (userSettings.usePagination && currentPage > pageCount) { - return next(); - } - - categories.modifyTopicsByPrivilege(categoryData.topics, userPrivileges); - categoryData.tagWhitelist = categories.filterTagWhitelist(categoryData.tagWhitelist, userPrivileges.isAdminOrMod); - - await buildBreadcrumbs(req, categoryData); - if (categoryData.children.length) { - const allCategories = []; - categories.flattenCategories(allCategories, categoryData.children); - await categories.getRecentTopicReplies(allCategories, req.uid, req.query); - categoryData.subCategoriesLeft = Math.max(0, categoryData.children.length - categoryData.subCategoriesPerPage); - categoryData.hasMoreSubCategories = categoryData.children.length > categoryData.subCategoriesPerPage; - categoryData.nextSubCategoryStart = categoryData.subCategoriesPerPage; - categoryData.children = categoryData.children.slice(0, categoryData.subCategoriesPerPage); - categoryData.children.forEach((child) => { - if (child) { - helpers.trimChildren(child); - helpers.setCategoryTeaser(child); - } - }); - } - - categoryData.title = translator.escape(categoryData.name); - categoryData.selectCategoryLabel = '[[category:subcategories]]'; - categoryData.description = translator.escape(categoryData.description); - categoryData.privileges = userPrivileges; - categoryData.showSelect = userPrivileges.editable; - categoryData.showTopicTools = userPrivileges.editable; - categoryData.topicIndex = topicIndex; - categoryData.rssFeedUrl = `${url}/category/${categoryData.cid}.rss`; - if (parseInt(req.uid, 10)) { - categories.markAsRead([cid], req.uid); - categoryData.rssFeedUrl += `?uid=${req.uid}&token=${rssToken}`; - } - - addTags(categoryData, res); - - categoryData['feeds:disableRSS'] = meta.config['feeds:disableRSS'] || 0; - categoryData['reputation:disabled'] = meta.config['reputation:disabled']; - categoryData.pagination = pagination.create(currentPage, pageCount, req.query); - categoryData.pagination.rel.forEach((rel) => { - rel.href = `${url}/category/${categoryData.slug}${rel.href}`; - res.locals.linkTags.push(rel); - }); - - analytics.increment([`pageviews:byCid:${categoryData.cid}`]); - - res.render('category', categoryData); +categoryController.get = async function (request, res, next) { + const cid = request.params.category_id; + + let currentPage = Number.parseInt(request.query.page, 10) || 1; + let topicIndex = utils.isNumber(request.params.topic_index) ? Number.parseInt(request.params.topic_index, 10) - 1 : 0; + if ((request.params.topic_index && !utils.isNumber(request.params.topic_index)) || !utils.isNumber(cid)) { + return next(); + } + + const [categoryFields, userPrivileges, userSettings, rssToken] = await Promise.all([ + categories.getCategoryFields(cid, ['slug', 'disabled', 'link']), + privileges.categories.get(cid, request.uid), + user.getSettings(request.uid), + user.auth.getFeedToken(request.uid), + ]); + + if (!categoryFields.slug + || (categoryFields && categoryFields.disabled) + || (userSettings.usePagination && currentPage < 1)) { + return next(); + } + + if (topicIndex < 0) { + return helpers.redirect(res, `/category/${categoryFields.slug}?${qs.stringify(request.query)}`); + } + + if (!userPrivileges.read) { + return helpers.notAllowed(request, res); + } + + if (!res.locals.isAPI && !request.params.slug && (categoryFields.slug && categoryFields.slug !== `${cid}/`)) { + return helpers.redirect(res, `/category/${categoryFields.slug}?${qs.stringify(request.query)}`, true); + } + + if (categoryFields.link) { + await db.incrObjectField(`category:${cid}`, 'timesClicked'); + return helpers.redirect(res, validator.unescape(categoryFields.link)); + } + + if (!userSettings.usePagination) { + topicIndex = Math.max(0, topicIndex - (Math.ceil(userSettings.topicsPerPage / 2) - 1)); + } else if (!request.query.page) { + const index = Math.max(Number.parseInt((topicIndex || 0), 10), 0); + currentPage = Math.ceil((index + 1) / userSettings.topicsPerPage); + topicIndex = 0; + } + + const targetUid = await user.getUidByUserslug(request.query.author); + const start = ((currentPage - 1) * userSettings.topicsPerPage) + topicIndex; + const stop = start + userSettings.topicsPerPage - 1; + + const categoryData = await categories.getCategoryById({ + uid: request.uid, + cid, + start, + stop, + sort: request.query.sort || userSettings.categoryTopicSort, + settings: userSettings, + query: request.query, + tag: request.query.tag, + targetUid, + }); + if (!categoryData) { + return next(); + } + + if (topicIndex > Math.max(categoryData.topic_count - 1, 0)) { + return helpers.redirect(res, `/category/${categoryData.slug}/${categoryData.topic_count}?${qs.stringify(request.query)}`); + } + + const pageCount = Math.max(1, Math.ceil(categoryData.topic_count / userSettings.topicsPerPage)); + if (userSettings.usePagination && currentPage > pageCount) { + return next(); + } + + categories.modifyTopicsByPrivilege(categoryData.topics, userPrivileges); + categoryData.tagWhitelist = categories.filterTagWhitelist(categoryData.tagWhitelist, userPrivileges.isAdminOrMod); + + await buildBreadcrumbs(request, categoryData); + if (categoryData.children.length > 0) { + const allCategories = []; + categories.flattenCategories(allCategories, categoryData.children); + await categories.getRecentTopicReplies(allCategories, request.uid, request.query); + categoryData.subCategoriesLeft = Math.max(0, categoryData.children.length - categoryData.subCategoriesPerPage); + categoryData.hasMoreSubCategories = categoryData.children.length > categoryData.subCategoriesPerPage; + categoryData.nextSubCategoryStart = categoryData.subCategoriesPerPage; + categoryData.children = categoryData.children.slice(0, categoryData.subCategoriesPerPage); + for (const child of categoryData.children) { + if (child) { + helpers.trimChildren(child); + helpers.setCategoryTeaser(child); + } + } + } + + categoryData.title = translator.escape(categoryData.name); + categoryData.selectCategoryLabel = '[[category:subcategories]]'; + categoryData.description = translator.escape(categoryData.description); + categoryData.privileges = userPrivileges; + categoryData.showSelect = userPrivileges.editable; + categoryData.showTopicTools = userPrivileges.editable; + categoryData.topicIndex = topicIndex; + categoryData.rssFeedUrl = `${url}/category/${categoryData.cid}.rss`; + if (Number.parseInt(request.uid, 10)) { + categories.markAsRead([cid], request.uid); + categoryData.rssFeedUrl += `?uid=${request.uid}&token=${rssToken}`; + } + + addTags(categoryData, res); + + categoryData['feeds:disableRSS'] = meta.config['feeds:disableRSS'] || 0; + categoryData['reputation:disabled'] = meta.config['reputation:disabled']; + categoryData.pagination = pagination.create(currentPage, pageCount, request.query); + for (const rel of categoryData.pagination.rel) { + rel.href = `${url}/category/${categoryData.slug}${rel.href}`; + res.locals.linkTags.push(rel); + } + + analytics.increment([`pageviews:byCid:${categoryData.cid}`]); + + res.render('category', categoryData); }; -async function buildBreadcrumbs(req, categoryData) { - const breadcrumbs = [ - { - text: categoryData.name, - url: `${relative_path}/category/${categoryData.slug}`, - cid: categoryData.cid, - }, - ]; - const crumbs = await helpers.buildCategoryBreadcrumbs(categoryData.parentCid); - if (req.originalUrl.startsWith(`${relative_path}/api/category`) || req.originalUrl.startsWith(`${relative_path}/category`)) { - categoryData.breadcrumbs = crumbs.concat(breadcrumbs); - } +async function buildBreadcrumbs(request, categoryData) { + const breadcrumbs = [ + { + text: categoryData.name, + url: `${relative_path}/category/${categoryData.slug}`, + cid: categoryData.cid, + }, + ]; + const crumbs = await helpers.buildCategoryBreadcrumbs(categoryData.parentCid); + if (request.originalUrl.startsWith(`${relative_path}/api/category`) || request.originalUrl.startsWith(`${relative_path}/category`)) { + categoryData.breadcrumbs = crumbs.concat(breadcrumbs); + } } function addTags(categoryData, res) { - res.locals.metaTags = [ - { - name: 'title', - content: categoryData.name, - noEscape: true, - }, - { - property: 'og:title', - content: categoryData.name, - noEscape: true, - }, - { - name: 'description', - content: categoryData.description, - noEscape: true, - }, - { - property: 'og:type', - content: 'website', - }, - ]; - - if (categoryData.backgroundImage) { - if (!categoryData.backgroundImage.startsWith('http')) { - categoryData.backgroundImage = url + categoryData.backgroundImage; - } - res.locals.metaTags.push({ - property: 'og:image', - content: categoryData.backgroundImage, - }); - } - - res.locals.linkTags = [ - { - rel: 'up', - href: url, - }, - ]; - - if (!categoryData['feeds:disableRSS']) { - res.locals.linkTags.push({ - rel: 'alternate', - type: 'application/rss+xml', - href: categoryData.rssFeedUrl, - }); - } + res.locals.metaTags = [ + { + name: 'title', + content: categoryData.name, + noEscape: true, + }, + { + property: 'og:title', + content: categoryData.name, + noEscape: true, + }, + { + name: 'description', + content: categoryData.description, + noEscape: true, + }, + { + property: 'og:type', + content: 'website', + }, + ]; + + if (categoryData.backgroundImage) { + if (!categoryData.backgroundImage.startsWith('http')) { + categoryData.backgroundImage = url + categoryData.backgroundImage; + } + + res.locals.metaTags.push({ + property: 'og:image', + content: categoryData.backgroundImage, + }); + } + + res.locals.linkTags = [ + { + rel: 'up', + href: url, + }, + ]; + + if (!categoryData['feeds:disableRSS']) { + res.locals.linkTags.push({ + rel: 'alternate', + type: 'application/rss+xml', + href: categoryData.rssFeedUrl, + }); + } } diff --git a/src/controllers/composer.js b/src/controllers/composer.js index ab7f52b..379a938 100644 --- a/src/controllers/composer.js +++ b/src/controllers/composer.js @@ -20,20 +20,20 @@ const user_1 = __importDefault(require("../user")); const plugins_1 = __importDefault(require("../plugins")); const topics_1 = __importDefault(require("../topics")); const posts_1 = __importDefault(require("../posts")); -const helpers_1 = __importDefault(require("./helpers")); -function get(req, res, callback) { +const helpers_js_1 = __importDefault(require("./helpers.js")); +function get(request, res, callback) { return __awaiter(this, void 0, void 0, function* () { res.locals.metaTags = Object.assign(Object.assign({}, res.locals.metaTags), { name: 'robots', content: 'noindex' }); const data = yield plugins_1.default.hooks.fire('filter:composer.build', { - req: req, - res: res, + req: request, + res, next: callback, templateData: {}, }); if (res.headersSent) { return; } - if (!data || !data.templateData) { + if (!(data === null || data === void 0 ? void 0 : data.templateData)) { return callback(new Error('[[error:invalid-data]]')); } if (data.templateData.disabled) { @@ -48,32 +48,32 @@ function get(req, res, callback) { }); } exports.get = get; -function post(req, res) { +function post(request, res) { return __awaiter(this, void 0, void 0, function* () { - const { body } = req; + const { body } = request; const data = { - uid: req.uid, - req: req, + uid: request.uid, + req: request, timestamp: Date.now(), content: body.content, fromQueue: false, }; - req.body.noscript = 'true'; + request.body.noscript = 'true'; if (!data.content) { - return yield helpers_1.default.noScriptErrors(req, res, '[[error:invalid-data]]', 400); + return yield helpers_js_1.default.noScriptErrors(request, res, '[[error:invalid-data]]', 400); } - function queueOrPost(postFn, data) { + function queueOrPost(postFunction, data) { return __awaiter(this, void 0, void 0, function* () { // The next line calls a function in a module that has not been updated to TS yet - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - const shouldQueue = yield posts_1.default.shouldQueue(req.uid, data); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const shouldQueue = yield posts_1.default.shouldQueue(request.uid, data); if (shouldQueue) { delete data.req; // The next line calls a function in a module that has not been updated to TS yet - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + // eslint-disable-next-line @typescript-eslint/no-unsafe-call return yield posts_1.default.addToQueue(data); } - return yield postFn(data); + return postFunction(data); }); } try { @@ -97,14 +97,14 @@ function post(req, res) { } const uid = result.uid ? result.uid : result.topicData.uid; // The next line calls a function in a module that has not been updated to TS yet - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + // eslint-disable-next-line @typescript-eslint/no-unsafe-call user_1.default.updateOnlineUsers(uid); const path = result.pid ? `/post/${result.pid}` : `/topic/${result.topicData.slug}`; res.redirect(nconf_1.default.get('relative_path') + path); } - catch (err) { - if (err instanceof Error) { - yield helpers_1.default.noScriptErrors(req, res, err.message, 400); + catch (error) { + if (error instanceof Error) { + yield helpers_js_1.default.noScriptErrors(request, res, error.message, 400); } } }); diff --git a/src/controllers/composer.ts b/src/controllers/composer.ts index 7df1c0a..a6a2990 100644 --- a/src/controllers/composer.ts +++ b/src/controllers/composer.ts @@ -2,140 +2,142 @@ // It is meant to serve as an example to assist you with your HW1 translation import nconf from 'nconf'; - -import { Request, Response, NextFunction } from 'express'; -import { TopicObject } from '../types'; - +import {type Request, type Response, type NextFunction} from 'express'; +import {type TopicObject} from '../types'; import user from '../user'; import plugins from '../plugins'; import topics from '../topics'; import posts from '../posts'; -import helpers from './helpers'; +import helpers from './helpers.js'; type ComposerBuildData = { - templateData: TemplateData -} + templateData: TemplateData; +}; type TemplateData = { - title: string, - disabled: boolean -} + title: string; + disabled: boolean; +}; type Locals = { - metaTags: { [key: string]: string }; -} - -export async function get(req: Request, res: Response, callback: NextFunction): Promise { - res.locals.metaTags = { - ...res.locals.metaTags, - name: 'robots', - content: 'noindex', - }; - - const data: ComposerBuildData = await plugins.hooks.fire('filter:composer.build', { - req: req, - res: res, - next: callback, - templateData: {}, - }) as ComposerBuildData; - - if (res.headersSent) { - return; - } - if (!data || !data.templateData) { - return callback(new Error('[[error:invalid-data]]')); - } - - if (data.templateData.disabled) { - res.render('', { - title: '[[modules:composer.compose]]', - }); - } else { - data.templateData.title = '[[modules:composer.compose]]'; - res.render('compose', data.templateData); - } + metaTags: Record; +}; + +export async function get(request: Request, res: Response, Locals>, callback: NextFunction): Promise { + res.locals.metaTags = { + ...res.locals.metaTags, + name: 'robots', + content: 'noindex', + }; + + const data: ComposerBuildData = await plugins.hooks.fire('filter:composer.build', { + req: request, + res, + next: callback, + templateData: {}, + }) as ComposerBuildData; + + if (res.headersSent) { + return; + } + + if (!data?.templateData) { + return callback(new Error('[[error:invalid-data]]')); + } + + if (data.templateData.disabled) { + res.render('', { + title: '[[modules:composer.compose]]', + }); + } else { + data.templateData.title = '[[modules:composer.compose]]'; + res.render('compose', data.templateData); + } } type ComposerData = { - uid: number, - req: Request, - timestamp: number, - content: string, - fromQueue: boolean, - tid?: number, - cid?: number, - title?: string, - tags?: string[], - thumb?: string, - noscript?: string -} + uid: number; + req: Request, Record, ComposerData>; + timestamp: number; + content: string; + fromQueue: boolean; + tid?: number; + cid?: number; + title?: string; + tags?: string[]; + thumb?: string; + noscript?: string; +}; type QueueResult = { - uid: number, - queued: boolean, - topicData: TopicObject, - pid: number -} - -type PostFnType = (data: ComposerData) => Promise; - -export async function post(req: Request & { uid: number }, res: Response): Promise { - const { body } = req; - const data: ComposerData = { - uid: req.uid, - req: req, - timestamp: Date.now(), - content: body.content, - fromQueue: false, - }; - req.body.noscript = 'true'; - - if (!data.content) { - return await helpers.noScriptErrors(req, res, '[[error:invalid-data]]', 400) as Promise; - } - - async function queueOrPost(postFn: PostFnType, data: ComposerData): Promise { - // The next line calls a function in a module that has not been updated to TS yet - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - const shouldQueue: boolean = await posts.shouldQueue(req.uid, data) as boolean; - if (shouldQueue) { - delete data.req; - - // The next line calls a function in a module that has not been updated to TS yet - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - return await posts.addToQueue(data) as QueueResult; - } - return await postFn(data); - } - - try { - let result: QueueResult; - if (body.tid) { - data.tid = body.tid; - result = await queueOrPost(topics.reply as PostFnType, data); - } else if (body.cid) { - data.cid = body.cid; - data.title = body.title; - data.tags = []; - data.thumb = ''; - result = await queueOrPost(topics.post as PostFnType, data); - } else { - throw new Error('[[error:invalid-data]]'); - } - if (result.queued) { - return res.redirect(`${nconf.get('relative_path') as string || '/'}?noScriptMessage=[[success:post-queued]]`); - } - const uid: number = result.uid ? result.uid : result.topicData.uid; - - // The next line calls a function in a module that has not been updated to TS yet - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - user.updateOnlineUsers(uid); - - const path: string = result.pid ? `/post/${result.pid}` : `/topic/${result.topicData.slug}`; - res.redirect((nconf.get('relative_path') as string) + path); - } catch (err: unknown) { - if (err instanceof Error) { - await helpers.noScriptErrors(req, res, err.message, 400); - } - } + uid: number; + queued: boolean; + topicData: TopicObject; + pid: number; +}; + +type PostFunctionType = (data: ComposerData) => Promise; + +export async function post(request: Request, Record, ComposerData> & {uid: number}, res: Response): Promise { + const {body} = request; + const data: ComposerData = { + uid: request.uid, + req: request, + timestamp: Date.now(), + content: body.content, + fromQueue: false, + }; + request.body.noscript = 'true'; + + if (!data.content) { + return await helpers.noScriptErrors(request, res, '[[error:invalid-data]]', 400) as Promise; + } + + async function queueOrPost(postFunction: PostFunctionType, data: ComposerData): Promise { + // The next line calls a function in a module that has not been updated to TS yet + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const shouldQueue: boolean = await posts.shouldQueue(request.uid, data) as boolean; + if (shouldQueue) { + delete data.req; + + // The next line calls a function in a module that has not been updated to TS yet + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + return await posts.addToQueue(data) as QueueResult; + } + + return postFunction(data); + } + + try { + let result: QueueResult; + if (body.tid) { + data.tid = body.tid; + result = await queueOrPost(topics.reply as PostFunctionType, data); + } else if (body.cid) { + data.cid = body.cid; + data.title = body.title; + data.tags = []; + data.thumb = ''; + result = await queueOrPost(topics.post as PostFunctionType, data); + } else { + throw new Error('[[error:invalid-data]]'); + } + + if (result.queued) { + return res.redirect(`${nconf.get('relative_path') as string || '/'}?noScriptMessage=[[success:post-queued]]`); + } + + const uid: number = result.uid ? result.uid : result.topicData.uid; + + // The next line calls a function in a module that has not been updated to TS yet + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + user.updateOnlineUsers(uid); + + const path: string = result.pid ? `/post/${result.pid}` : `/topic/${result.topicData.slug}`; + res.redirect((nconf.get('relative_path') as string) + path); + } catch (error: unknown) { + if (error instanceof Error) { + await helpers.noScriptErrors(request, res, error.message, 400); + } + } } diff --git a/src/controllers/composer_original.js b/src/controllers/composer_original.js index 1e835a3..67e76b5 100644 --- a/src/controllers/composer_original.js +++ b/src/controllers/composer_original.js @@ -7,89 +7,93 @@ 'use strict'; const nconf = require('nconf'); - const user = require('../user'); const plugins = require('../plugins'); const topics = require('../topics'); const posts = require('../posts'); const helpers = require('./helpers'); -exports.get = async function (req, res, callback) { - res.locals.metaTags = { - ...res.locals.metaTags, - name: 'robots', - content: 'noindex', - }; +exports.get = async function (request, res, callback) { + res.locals.metaTags = { + ...res.locals.metaTags, + name: 'robots', + content: 'noindex', + }; + + const data = await plugins.hooks.fire('filter:composer.build', { + req: request, + res, + next: callback, + templateData: {}, + }); - const data = await plugins.hooks.fire('filter:composer.build', { - req: req, - res: res, - next: callback, - templateData: {}, - }); + if (res.headersSent) { + return; + } - if (res.headersSent) { - return; - } - if (!data || !data.templateData) { - return callback(new Error('[[error:invalid-data]]')); - } + if (!data || !data.templateData) { + return callback(new Error('[[error:invalid-data]]')); + } - if (data.templateData.disabled) { - res.render('', { - title: '[[modules:composer.compose]]', - }); - } else { - data.templateData.title = '[[modules:composer.compose]]'; - res.render('compose', data.templateData); - } + if (data.templateData.disabled) { + res.render('', { + title: '[[modules:composer.compose]]', + }); + } else { + data.templateData.title = '[[modules:composer.compose]]'; + res.render('compose', data.templateData); + } }; -exports.post = async function (req, res) { - const { body } = req; - const data = { - uid: req.uid, - req: req, - timestamp: Date.now(), - content: body.content, - fromQueue: false, - }; - req.body.noscript = 'true'; +exports.post = async function (request, res) { + const {body} = request; + const data = { + uid: request.uid, + req: request, + timestamp: Date.now(), + content: body.content, + fromQueue: false, + }; + request.body.noscript = 'true'; + + if (!data.content) { + return helpers.noScriptErrors(request, res, '[[error:invalid-data]]', 400); + } + + async function queueOrPost(postFunction, data) { + const shouldQueue = await posts.shouldQueue(request.uid, data); + if (shouldQueue) { + delete data.req; + return await posts.addToQueue(data); + } + + return await postFunction(data); + } + + try { + let result; + if (body.tid) { + data.tid = body.tid; + result = await queueOrPost(topics.reply, data); + } else if (body.cid) { + data.cid = body.cid; + data.title = body.title; + data.tags = []; + data.thumb = ''; + result = await queueOrPost(topics.post, data); + } else { + throw new Error('[[error:invalid-data]]'); + } - if (!data.content) { - return helpers.noScriptErrors(req, res, '[[error:invalid-data]]', 400); - } - async function queueOrPost(postFn, data) { - const shouldQueue = await posts.shouldQueue(req.uid, data); - if (shouldQueue) { - delete data.req; - return await posts.addToQueue(data); - } - return await postFn(data); - } + if (result.queued) { + return res.redirect(`${nconf.get('relative_path') || '/'}?noScriptMessage=[[success:post-queued]]`); + } - try { - let result; - if (body.tid) { - data.tid = body.tid; - result = await queueOrPost(topics.reply, data); - } else if (body.cid) { - data.cid = body.cid; - data.title = body.title; - data.tags = []; - data.thumb = ''; - result = await queueOrPost(topics.post, data); - } else { - throw new Error('[[error:invalid-data]]'); - } - if (result.queued) { - return res.redirect(`${nconf.get('relative_path') || '/'}?noScriptMessage=[[success:post-queued]]`); - } - const uid = result.uid ? result.uid : result.topicData.uid; - user.updateOnlineUsers(uid); - const path = result.pid ? `/post/${result.pid}` : `/topic/${result.topicData.slug}`; - res.redirect(nconf.get('relative_path') + path); - } catch (err) { - helpers.noScriptErrors(req, res, err.message, 400); - } + const uid = result.uid ? result.uid : result.topicData.uid; + user.updateOnlineUsers(uid); + const path = result.pid ? `/post/${result.pid}` : `/topic/${result.topicData.slug}`; + res.redirect(nconf.get('relative_path') + path); + } catch (error) { + helpers.noScriptErrors(request, res, error.message, 400); + } }; diff --git a/src/controllers/errors.js b/src/controllers/errors.js index 90df864..ff22be5 100644 --- a/src/controllers/errors.js +++ b/src/controllers/errors.js @@ -9,103 +9,106 @@ const middleware = require('../middleware'); const middlewareHelpers = require('../middleware/helpers'); const helpers = require('./helpers'); -exports.handleURIErrors = async function handleURIErrors(err, req, res, next) { - // Handle cases where malformed URIs are passed in - if (err instanceof URIError) { - const cleanPath = req.path.replace(new RegExp(`^${nconf.get('relative_path')}`), ''); - const tidMatch = cleanPath.match(/^\/topic\/(\d+)\//); - const cidMatch = cleanPath.match(/^\/category\/(\d+)\//); +exports.handleURIErrors = async function handleURIErrors(error, request, res, next) { + // Handle cases where malformed URIs are passed in + if (error instanceof URIError) { + const cleanPath = request.path.replace(new RegExp(`^${nconf.get('relative_path')}`), ''); + const tidMatch = cleanPath.match(/^\/topic\/(\d+)\//); + const cidMatch = cleanPath.match(/^\/category\/(\d+)\//); - if (tidMatch) { - res.redirect(nconf.get('relative_path') + tidMatch[0]); - } else if (cidMatch) { - res.redirect(nconf.get('relative_path') + cidMatch[0]); - } else { - winston.warn(`[controller] Bad request: ${req.path}`); - if (req.path.startsWith(`${nconf.get('relative_path')}/api`)) { - res.status(400).json({ - error: '[[global:400.title]]', - }); - } else { - await middleware.buildHeaderAsync(req, res); - res.status(400).render('400', { error: validator.escape(String(err.message)) }); - } - } - } else { - next(err); - } + if (tidMatch) { + res.redirect(nconf.get('relative_path') + tidMatch[0]); + } else if (cidMatch) { + res.redirect(nconf.get('relative_path') + cidMatch[0]); + } else { + winston.warn(`[controller] Bad request: ${request.path}`); + if (request.path.startsWith(`${nconf.get('relative_path')}/api`)) { + res.status(400).json({ + error: '[[global:400.title]]', + }); + } else { + await middleware.buildHeaderAsync(request, res); + res.status(400).render('400', {error: validator.escape(String(error.message))}); + } + } + } else { + next(error); + } }; -// this needs to have four arguments or express treats it as `(req, res, next)` +// This needs to have four arguments or express treats it as `(req, res, next)` // don't remove `next`! -exports.handleErrors = async function handleErrors(err, req, res, next) { // eslint-disable-line no-unused-vars - const cases = { - EBADCSRFTOKEN: function () { - winston.error(`${req.method} ${req.originalUrl}\n${err.message}`); - res.sendStatus(403); - }, - 'blacklisted-ip': function () { - res.status(403).type('text/plain').send(err.message); - }, - }; - const defaultHandler = async function () { - if (res.headersSent) { - return; - } - // Display NodeBB error page - const status = parseInt(err.status, 10); - if ((status === 302 || status === 308) && err.path) { - return res.locals.isAPI ? res.set('X-Redirect', err.path).status(200).json(err.path) : res.redirect(nconf.get('relative_path') + err.path); - } +exports.handleErrors = async function handleErrors(error, request, res, next) { // eslint-disable-line no-unused-vars + const cases = { + EBADCSRFTOKEN() { + winston.error(`${request.method} ${request.originalUrl}\n${error.message}`); + res.sendStatus(403); + }, + 'blacklisted-ip'() { + res.status(403).type('text/plain').send(error.message); + }, + }; + const defaultHandler = async function () { + if (res.headersSent) { + return; + } - const path = String(req.path || ''); + // Display NodeBB error page + const status = Number.parseInt(error.status, 10); + if ((status === 302 || status === 308) && error.path) { + return res.locals.isAPI ? res.set('X-Redirect', error.path).status(200).json(error.path) : res.redirect(nconf.get('relative_path') + error.path); + } - if (path.startsWith(`${nconf.get('relative_path')}/api/v3`)) { - let status = 500; - if (err.message.startsWith('[[')) { - status = 400; - err.message = await translator.translate(err.message); - } - return helpers.formatApiResponse(status, res, err); - } + const path = String(request.path || ''); - winston.error(`${req.method} ${req.originalUrl}\n${err.stack}`); - res.status(status || 500); - const data = { - path: validator.escape(path), - error: validator.escape(String(err.message)), - bodyClass: middlewareHelpers.buildBodyClass(req, res), - }; - if (res.locals.isAPI) { - res.json(data); - } else { - await middleware.buildHeaderAsync(req, res); - res.render('500', data); - } - }; - const data = await getErrorHandlers(cases); - try { - if (data.cases.hasOwnProperty(err.code)) { - data.cases[err.code](err, req, res, defaultHandler); - } else { - await defaultHandler(); - } - } catch (_err) { - winston.error(`${req.method} ${req.originalUrl}\n${_err.stack}`); - if (!res.headersSent) { - res.status(500).send(_err.message); - } - } + if (path.startsWith(`${nconf.get('relative_path')}/api/v3`)) { + let status = 500; + if (error.message.startsWith('[[')) { + status = 400; + error.message = await translator.translate(error.message); + } + + return helpers.formatApiResponse(status, res, error); + } + + winston.error(`${request.method} ${request.originalUrl}\n${error.stack}`); + res.status(status || 500); + const data = { + path: validator.escape(path), + error: validator.escape(String(error.message)), + bodyClass: middlewareHelpers.buildBodyClass(request, res), + }; + if (res.locals.isAPI) { + res.json(data); + } else { + await middleware.buildHeaderAsync(request, res); + res.render('500', data); + } + }; + + const data = await getErrorHandlers(cases); + try { + if (data.cases.hasOwnProperty(error.code)) { + data.cases[error.code](error, request, res, defaultHandler); + } else { + await defaultHandler(); + } + } catch (error) { + winston.error(`${request.method} ${request.originalUrl}\n${error.stack}`); + if (!res.headersSent) { + res.status(500).send(error.message); + } + } }; async function getErrorHandlers(cases) { - try { - return await plugins.hooks.fire('filter:error.handle', { - cases: cases, - }); - } catch (err) { - // Assume defaults - winston.warn(`[errors/handle] Unable to retrieve plugin handlers for errors: ${err.message}`); - return { cases }; - } + try { + return await plugins.hooks.fire('filter:error.handle', { + cases, + }); + } catch (error) { + // Assume defaults + winston.warn(`[errors/handle] Unable to retrieve plugin handlers for errors: ${error.message}`); + return {cases}; + } } diff --git a/src/controllers/globalmods.js b/src/controllers/globalmods.js index 7534b4c..168028c 100644 --- a/src/controllers/globalmods.js +++ b/src/controllers/globalmods.js @@ -8,29 +8,29 @@ const helpers = require('./helpers'); const globalModsController = module.exports; -globalModsController.ipBlacklist = async function (req, res, next) { - const isAdminOrGlobalMod = await user.isAdminOrGlobalMod(req.uid); - if (!isAdminOrGlobalMod) { - return next(); - } +globalModsController.ipBlacklist = async function (request, res, next) { + const isAdminOrGlobalModule = await user.isAdminOrGlobalMod(request.uid); + if (!isAdminOrGlobalModule) { + return next(); + } - const [rules, analyticsData] = await Promise.all([ - meta.blacklist.get(), - analytics.getBlacklistAnalytics(), - ]); - res.render('ip-blacklist', { - title: '[[pages:ip-blacklist]]', - rules: rules, - analytics: analyticsData, - breadcrumbs: helpers.buildBreadcrumbs([{ text: '[[pages:ip-blacklist]]' }]), - }); + const [rules, analyticsData] = await Promise.all([ + meta.blacklist.get(), + analytics.getBlacklistAnalytics(), + ]); + res.render('ip-blacklist', { + title: '[[pages:ip-blacklist]]', + rules, + analytics: analyticsData, + breadcrumbs: helpers.buildBreadcrumbs([{text: '[[pages:ip-blacklist]]'}]), + }); }; +globalModsController.registrationQueue = async function (request, res, next) { + const isAdminOrGlobalModule = await user.isAdminOrGlobalMod(request.uid); + if (!isAdminOrGlobalModule) { + return next(); + } -globalModsController.registrationQueue = async function (req, res, next) { - const isAdminOrGlobalMod = await user.isAdminOrGlobalMod(req.uid); - if (!isAdminOrGlobalMod) { - return next(); - } - await usersController.registrationQueue(req, res); + await usersController.registrationQueue(request, res); }; diff --git a/src/controllers/groups.js b/src/controllers/groups.js index fdcb461..cff590a 100644 --- a/src/controllers/groups.js +++ b/src/controllers/groups.js @@ -2,119 +2,125 @@ const validator = require('validator'); const nconf = require('nconf'); - const meta = require('../meta'); const groups = require('../groups'); const user = require('../user'); -const helpers = require('./helpers'); const pagination = require('../pagination'); const privileges = require('../privileges'); +const helpers = require('./helpers'); const groupsController = module.exports; -groupsController.list = async function (req, res) { - const sort = req.query.sort || 'alpha'; - - const [groupData, allowGroupCreation] = await Promise.all([ - groups.getGroupsBySort(sort, 0, 14), - privileges.global.can('group:create', req.uid), - ]); - - res.render('groups/list', { - groups: groupData, - allowGroupCreation: allowGroupCreation, - nextStart: 15, - title: '[[pages:groups]]', - breadcrumbs: helpers.buildBreadcrumbs([{ text: '[[pages:groups]]' }]), - }); +groupsController.list = async function (request, res) { + const sort = request.query.sort || 'alpha'; + + const [groupData, allowGroupCreation] = await Promise.all([ + groups.getGroupsBySort(sort, 0, 14), + privileges.global.can('group:create', request.uid), + ]); + + res.render('groups/list', { + groups: groupData, + allowGroupCreation, + nextStart: 15, + title: '[[pages:groups]]', + breadcrumbs: helpers.buildBreadcrumbs([{text: '[[pages:groups]]'}]), + }); }; -groupsController.details = async function (req, res, next) { - const lowercaseSlug = req.params.slug.toLowerCase(); - if (req.params.slug !== lowercaseSlug) { - if (res.locals.isAPI) { - req.params.slug = lowercaseSlug; - } else { - return res.redirect(`${nconf.get('relative_path')}/groups/${lowercaseSlug}`); - } - } - const groupName = await groups.getGroupNameByGroupSlug(req.params.slug); - if (!groupName) { - return next(); - } - const [exists, isHidden, isAdmin, isGlobalMod] = await Promise.all([ - groups.exists(groupName), - groups.isHidden(groupName), - user.isAdministrator(req.uid), - user.isGlobalModerator(req.uid), - ]); - if (!exists) { - return next(); - } - if (isHidden && !isAdmin && !isGlobalMod) { - const [isMember, isInvited] = await Promise.all([ - groups.isMember(req.uid, groupName), - groups.isInvited(req.uid, groupName), - ]); - if (!isMember && !isInvited) { - return next(); - } - } - const [groupData, posts] = await Promise.all([ - groups.get(groupName, { - uid: req.uid, - truncateUserList: true, - userListCount: 20, - }), - groups.getLatestMemberPosts(groupName, 10, req.uid), - ]); - if (!groupData) { - return next(); - } - groupData.isOwner = groupData.isOwner || isAdmin || (isGlobalMod && !groupData.system); - - res.render('groups/details', { - title: `[[pages:group, ${groupData.displayName}]]`, - group: groupData, - posts: posts, - isAdmin: isAdmin, - isGlobalMod: isGlobalMod, - allowPrivateGroups: meta.config.allowPrivateGroups, - breadcrumbs: helpers.buildBreadcrumbs([{ text: '[[pages:groups]]', url: '/groups' }, { text: groupData.displayName }]), - }); +groupsController.details = async function (request, res, next) { + const lowercaseSlug = request.params.slug.toLowerCase(); + if (request.params.slug !== lowercaseSlug) { + if (res.locals.isAPI) { + request.params.slug = lowercaseSlug; + } else { + return res.redirect(`${nconf.get('relative_path')}/groups/${lowercaseSlug}`); + } + } + + const groupName = await groups.getGroupNameByGroupSlug(request.params.slug); + if (!groupName) { + return next(); + } + + const [exists, isHidden, isAdmin, isGlobalModule] = await Promise.all([ + groups.exists(groupName), + groups.isHidden(groupName), + user.isAdministrator(request.uid), + user.isGlobalModerator(request.uid), + ]); + if (!exists) { + return next(); + } + + if (isHidden && !isAdmin && !isGlobalModule) { + const [isMember, isInvited] = await Promise.all([ + groups.isMember(request.uid, groupName), + groups.isInvited(request.uid, groupName), + ]); + if (!isMember && !isInvited) { + return next(); + } + } + + const [groupData, posts] = await Promise.all([ + groups.get(groupName, { + uid: request.uid, + truncateUserList: true, + userListCount: 20, + }), + groups.getLatestMemberPosts(groupName, 10, request.uid), + ]); + if (!groupData) { + return next(); + } + + groupData.isOwner = groupData.isOwner || isAdmin || (isGlobalModule && !groupData.system); + + res.render('groups/details', { + title: `[[pages:group, ${groupData.displayName}]]`, + group: groupData, + posts, + isAdmin, + isGlobalMod: isGlobalModule, + allowPrivateGroups: meta.config.allowPrivateGroups, + breadcrumbs: helpers.buildBreadcrumbs([{text: '[[pages:groups]]', url: '/groups'}, {text: groupData.displayName}]), + }); }; -groupsController.members = async function (req, res, next) { - const page = parseInt(req.query.page, 10) || 1; - const usersPerPage = 50; - const start = Math.max(0, (page - 1) * usersPerPage); - const stop = start + usersPerPage - 1; - const groupName = await groups.getGroupNameByGroupSlug(req.params.slug); - if (!groupName) { - return next(); - } - const [groupData, isAdminOrGlobalMod, isMember, isHidden] = await Promise.all([ - groups.getGroupData(groupName), - user.isAdminOrGlobalMod(req.uid), - groups.isMember(req.uid, groupName), - groups.isHidden(groupName), - ]); - - if (isHidden && !isMember && !isAdminOrGlobalMod) { - return next(); - } - const users = await user.getUsersFromSet(`group:${groupName}:members`, req.uid, start, stop); - - const breadcrumbs = helpers.buildBreadcrumbs([ - { text: '[[pages:groups]]', url: '/groups' }, - { text: validator.escape(String(groupName)), url: `/groups/${req.params.slug}` }, - { text: '[[groups:details.members]]' }, - ]); - - const pageCount = Math.max(1, Math.ceil(groupData.memberCount / usersPerPage)); - res.render('groups/members', { - users: users, - pagination: pagination.create(page, pageCount, req.query), - breadcrumbs: breadcrumbs, - }); +groupsController.members = async function (request, res, next) { + const page = Number.parseInt(request.query.page, 10) || 1; + const usersPerPage = 50; + const start = Math.max(0, (page - 1) * usersPerPage); + const stop = start + usersPerPage - 1; + const groupName = await groups.getGroupNameByGroupSlug(request.params.slug); + if (!groupName) { + return next(); + } + + const [groupData, isAdminOrGlobalModule, isMember, isHidden] = await Promise.all([ + groups.getGroupData(groupName), + user.isAdminOrGlobalMod(request.uid), + groups.isMember(request.uid, groupName), + groups.isHidden(groupName), + ]); + + if (isHidden && !isMember && !isAdminOrGlobalModule) { + return next(); + } + + const users = await user.getUsersFromSet(`group:${groupName}:members`, request.uid, start, stop); + + const breadcrumbs = helpers.buildBreadcrumbs([ + {text: '[[pages:groups]]', url: '/groups'}, + {text: validator.escape(String(groupName)), url: `/groups/${request.params.slug}`}, + {text: '[[groups:details.members]]'}, + ]); + + const pageCount = Math.max(1, Math.ceil(groupData.memberCount / usersPerPage)); + res.render('groups/members', { + users, + pagination: pagination.create(page, pageCount, request.query), + breadcrumbs, + }); }; diff --git a/src/controllers/helpers.js b/src/controllers/helpers.js index 56430f8..45e1230 100644 --- a/src/controllers/helpers.js +++ b/src/controllers/helpers.js @@ -1,11 +1,10 @@ 'use strict'; +const querystring = require('node:querystring'); const nconf = require('nconf'); const validator = require('validator'); -const querystring = require('querystring'); const _ = require('lodash'); const chalk = require('chalk'); - const translator = require('../translator'); const user = require('../user'); const privileges = require('../privileges'); @@ -20,556 +19,582 @@ const helpers = module.exports; const relative_path = nconf.get('relative_path'); const url = nconf.get('url'); -helpers.noScriptErrors = async function (req, res, error, httpStatus) { - if (req.body.noscript !== 'true') { - if (typeof error === 'string') { - return res.status(httpStatus).send(error); - } - return res.status(httpStatus).json(error); - } - const middleware = require('../middleware'); - const httpStatusString = httpStatus.toString(); - await middleware.buildHeaderAsync(req, res); - res.status(httpStatus).render(httpStatusString, { - path: req.path, - loggedIn: req.loggedIn, - error: error, - returnLink: true, - title: `[[global:${httpStatusString}.title]]`, - }); +helpers.noScriptErrors = async function (request, res, error, httpStatus) { + if (request.body.noscript !== 'true') { + if (typeof error === 'string') { + return res.status(httpStatus).send(error); + } + + return res.status(httpStatus).json(error); + } + + const middleware = require('../middleware'); + const httpStatusString = httpStatus.toString(); + await middleware.buildHeaderAsync(request, res); + res.status(httpStatus).render(httpStatusString, { + path: request.path, + loggedIn: request.loggedIn, + error, + returnLink: true, + title: `[[global:${httpStatusString}.title]]`, + }); }; helpers.terms = { - daily: 'day', - weekly: 'week', - monthly: 'month', + daily: 'day', + weekly: 'week', + monthly: 'month', }; helpers.buildQueryString = function (query, key, value) { - const queryObj = { ...query }; - if (value) { - queryObj[key] = value; - } else { - delete queryObj[key]; - } - delete queryObj._; - return Object.keys(queryObj).length ? `?${querystring.stringify(queryObj)}` : ''; + const queryObject = {...query}; + if (value) { + queryObject[key] = value; + } else { + delete queryObject[key]; + } + + delete queryObject._; + return Object.keys(queryObject).length > 0 ? `?${querystring.stringify(queryObject)}` : ''; }; -helpers.addLinkTags = function (params) { - params.res.locals.linkTags = params.res.locals.linkTags || []; - params.res.locals.linkTags.push({ - rel: 'canonical', - href: `${url}/${params.url}`, - }); - - params.tags.forEach((rel) => { - rel.href = `${url}/${params.url}${rel.href}`; - params.res.locals.linkTags.push(rel); - }); +helpers.addLinkTags = function (parameters) { + parameters.res.locals.linkTags = parameters.res.locals.linkTags || []; + parameters.res.locals.linkTags.push({ + rel: 'canonical', + href: `${url}/${parameters.url}`, + }); + + for (const rel of parameters.tags) { + rel.href = `${url}/${parameters.url}${rel.href}`; + parameters.res.locals.linkTags.push(rel); + } }; helpers.buildFilters = function (url, filter, query) { - return [{ - name: '[[unread:all-topics]]', - url: url + helpers.buildQueryString(query, 'filter', ''), - selected: filter === '', - filter: '', - icon: 'fa-book', - }, { - name: '[[unread:new-topics]]', - url: url + helpers.buildQueryString(query, 'filter', 'new'), - selected: filter === 'new', - filter: 'new', - icon: 'fa-clock-o', - }, { - name: '[[unread:watched-topics]]', - url: url + helpers.buildQueryString(query, 'filter', 'watched'), - selected: filter === 'watched', - filter: 'watched', - icon: 'fa-bell-o', - }, { - name: '[[unread:unreplied-topics]]', - url: url + helpers.buildQueryString(query, 'filter', 'unreplied'), - selected: filter === 'unreplied', - filter: 'unreplied', - icon: 'fa-reply', - }]; + return [{ + name: '[[unread:all-topics]]', + url: url + helpers.buildQueryString(query, 'filter', ''), + selected: filter === '', + filter: '', + icon: 'fa-book', + }, { + name: '[[unread:new-topics]]', + url: url + helpers.buildQueryString(query, 'filter', 'new'), + selected: filter === 'new', + filter: 'new', + icon: 'fa-clock-o', + }, { + name: '[[unread:watched-topics]]', + url: url + helpers.buildQueryString(query, 'filter', 'watched'), + selected: filter === 'watched', + filter: 'watched', + icon: 'fa-bell-o', + }, { + name: '[[unread:unreplied-topics]]', + url: url + helpers.buildQueryString(query, 'filter', 'unreplied'), + selected: filter === 'unreplied', + filter: 'unreplied', + icon: 'fa-reply', + }]; }; helpers.buildTerms = function (url, term, query) { - return [{ - name: '[[recent:alltime]]', - url: url + helpers.buildQueryString(query, 'term', ''), - selected: term === 'alltime', - term: 'alltime', - }, { - name: '[[recent:day]]', - url: url + helpers.buildQueryString(query, 'term', 'daily'), - selected: term === 'day', - term: 'day', - }, { - name: '[[recent:week]]', - url: url + helpers.buildQueryString(query, 'term', 'weekly'), - selected: term === 'week', - term: 'week', - }, { - name: '[[recent:month]]', - url: url + helpers.buildQueryString(query, 'term', 'monthly'), - selected: term === 'month', - term: 'month', - }]; + return [{ + name: '[[recent:alltime]]', + url: url + helpers.buildQueryString(query, 'term', ''), + selected: term === 'alltime', + term: 'alltime', + }, { + name: '[[recent:day]]', + url: url + helpers.buildQueryString(query, 'term', 'daily'), + selected: term === 'day', + term: 'day', + }, { + name: '[[recent:week]]', + url: url + helpers.buildQueryString(query, 'term', 'weekly'), + selected: term === 'week', + term: 'week', + }, { + name: '[[recent:month]]', + url: url + helpers.buildQueryString(query, 'term', 'monthly'), + selected: term === 'month', + term: 'month', + }]; }; -helpers.notAllowed = async function (req, res, error) { - ({ error } = await plugins.hooks.fire('filter:helpers.notAllowed', { req, res, error })); - - await plugins.hooks.fire('response:helpers.notAllowed', { req, res, error }); - if (res.headersSent) { - return; - } - - if (req.loggedIn || req.uid === -1) { - if (res.locals.isAPI) { - if (req.originalUrl.startsWith(`${relative_path}/api/v3`)) { - helpers.formatApiResponse(403, res, error); - } else { - res.status(403).json({ - path: req.path.replace(/^\/api/, ''), - loggedIn: req.loggedIn, - error: error, - title: '[[global:403.title]]', - bodyClass: middlewareHelpers.buildBodyClass(req, res), - }); - } - } else { - const middleware = require('../middleware'); - await middleware.buildHeaderAsync(req, res); - res.status(403).render('403', { - path: req.path, - loggedIn: req.loggedIn, - error, - title: '[[global:403.title]]', - }); - } - } else if (res.locals.isAPI) { - req.session.returnTo = req.url.replace(/^\/api/, ''); - helpers.formatApiResponse(401, res, error); - } else { - req.session.returnTo = req.url; - res.redirect(`${relative_path}/login${req.path.startsWith('/admin') ? '?local=1' : ''}`); - } +helpers.notAllowed = async function (request, res, error) { + ({error} = await plugins.hooks.fire('filter:helpers.notAllowed', {req: request, res, error})); + + await plugins.hooks.fire('response:helpers.notAllowed', {req: request, res, error}); + if (res.headersSent) { + return; + } + + if (request.loggedIn || request.uid === -1) { + if (res.locals.isAPI) { + if (request.originalUrl.startsWith(`${relative_path}/api/v3`)) { + helpers.formatApiResponse(403, res, error); + } else { + res.status(403).json({ + path: request.path.replace(/^\/api/, ''), + loggedIn: request.loggedIn, + error, + title: '[[global:403.title]]', + bodyClass: middlewareHelpers.buildBodyClass(request, res), + }); + } + } else { + const middleware = require('../middleware'); + await middleware.buildHeaderAsync(request, res); + res.status(403).render('403', { + path: request.path, + loggedIn: request.loggedIn, + error, + title: '[[global:403.title]]', + }); + } + } else if (res.locals.isAPI) { + request.session.returnTo = request.url.replace(/^\/api/, ''); + helpers.formatApiResponse(401, res, error); + } else { + request.session.returnTo = request.url; + res.redirect(`${relative_path}/login${request.path.startsWith('/admin') ? '?local=1' : ''}`); + } }; helpers.redirect = function (res, url, permanent) { - // this is used by sso plugins to redirect to the auth route - // { external: '/auth/sso' } or { external: 'https://domain/auth/sso' } - if (url.hasOwnProperty('external')) { - const redirectUrl = encodeURI(prependRelativePath(url.external)); - if (res.locals.isAPI) { - res.set('X-Redirect', redirectUrl).status(200).json({ external: redirectUrl }); - } else { - res.redirect(permanent ? 308 : 307, redirectUrl); - } - return; - } - - if (res.locals.isAPI) { - url = encodeURI(url); - res.set('X-Redirect', url).status(200).json(url); - } else { - res.redirect(permanent ? 308 : 307, encodeURI(prependRelativePath(url))); - } + // This is used by sso plugins to redirect to the auth route + // { external: '/auth/sso' } or { external: 'https://domain/auth/sso' } + if (url.hasOwnProperty('external')) { + const redirectUrl = encodeURI(prependRelativePath(url.external)); + if (res.locals.isAPI) { + res.set('X-Redirect', redirectUrl).status(200).json({external: redirectUrl}); + } else { + res.redirect(permanent ? 308 : 307, redirectUrl); + } + + return; + } + + if (res.locals.isAPI) { + url = encodeURI(url); + res.set('X-Redirect', url).status(200).json(url); + } else { + res.redirect(permanent ? 308 : 307, encodeURI(prependRelativePath(url))); + } }; function prependRelativePath(url) { - return url.startsWith('http://') || url.startsWith('https://') ? - url : relative_path + url; + return url.startsWith('http://') || url.startsWith('https://') + ? url : relative_path + url; } helpers.buildCategoryBreadcrumbs = async function (cid) { - const breadcrumbs = []; - - while (parseInt(cid, 10)) { - /* eslint-disable no-await-in-loop */ - const data = await categories.getCategoryFields(cid, ['name', 'slug', 'parentCid', 'disabled', 'isSection']); - if (!data.disabled && !data.isSection) { - breadcrumbs.unshift({ - text: String(data.name), - url: `${relative_path}/category/${data.slug}`, - cid: cid, - }); - } - cid = data.parentCid; - } - if (meta.config.homePageRoute && meta.config.homePageRoute !== 'categories') { - breadcrumbs.unshift({ - text: '[[global:header.categories]]', - url: `${relative_path}/categories`, - }); - } - - breadcrumbs.unshift({ - text: '[[global:home]]', - url: `${relative_path}/`, - }); - - return breadcrumbs; + const breadcrumbs = []; + + while (Number.parseInt(cid, 10)) { + /* eslint-disable no-await-in-loop */ + const data = await categories.getCategoryFields(cid, ['name', 'slug', 'parentCid', 'disabled', 'isSection']); + if (!data.disabled && !data.isSection) { + breadcrumbs.unshift({ + text: String(data.name), + url: `${relative_path}/category/${data.slug}`, + cid, + }); + } + + cid = data.parentCid; + } + + if (meta.config.homePageRoute && meta.config.homePageRoute !== 'categories') { + breadcrumbs.unshift({ + text: '[[global:header.categories]]', + url: `${relative_path}/categories`, + }); + } + + breadcrumbs.unshift({ + text: '[[global:home]]', + url: `${relative_path}/`, + }); + + return breadcrumbs; }; helpers.buildBreadcrumbs = function (crumbs) { - const breadcrumbs = [ - { - text: '[[global:home]]', - url: `${relative_path}/`, - }, - ]; - - crumbs.forEach((crumb) => { - if (crumb) { - if (crumb.url) { - crumb.url = `${utils.isRelativeUrl(crumb.url) ? relative_path : ''}${crumb.url}`; - } - breadcrumbs.push(crumb); - } - }); - - return breadcrumbs; + const breadcrumbs = [ + { + text: '[[global:home]]', + url: `${relative_path}/`, + }, + ]; + + for (const crumb of crumbs) { + if (crumb) { + crumb.url &&= `${utils.isRelativeUrl(crumb.url) ? relative_path : ''}${crumb.url}`; + + breadcrumbs.push(crumb); + } + } + + return breadcrumbs; }; helpers.buildTitle = function (pageTitle) { - const titleLayout = meta.config.titleLayout || '{pageTitle} | {browserTitle}'; + const titleLayout = meta.config.titleLayout || '{pageTitle} | {browserTitle}'; - const browserTitle = validator.escape(String(meta.config.browserTitle || meta.config.title || 'NodeBB')); - pageTitle = pageTitle || ''; - const title = titleLayout.replace('{pageTitle}', () => pageTitle).replace('{browserTitle}', () => browserTitle); - return title; + const browserTitle = validator.escape(String(meta.config.browserTitle || meta.config.title || 'NodeBB')); + pageTitle ||= ''; + const title = titleLayout.replace('{pageTitle}', () => pageTitle).replace('{browserTitle}', () => browserTitle); + return title; }; helpers.getCategories = async function (set, uid, privilege, selectedCid) { - const cids = await categories.getCidsByPrivilege(set, uid, privilege); - return await getCategoryData(cids, uid, selectedCid, Object.values(categories.watchStates), privilege); + const cids = await categories.getCidsByPrivilege(set, uid, privilege); + return await getCategoryData(cids, uid, selectedCid, Object.values(categories.watchStates), privilege); }; helpers.getCategoriesByStates = async function (uid, selectedCid, states, privilege = 'topics:read') { - const cids = await categories.getAllCidsFromSet('categories:cid'); - return await getCategoryData(cids, uid, selectedCid, states, privilege); + const cids = await categories.getAllCidsFromSet('categories:cid'); + return await getCategoryData(cids, uid, selectedCid, states, privilege); }; async function getCategoryData(cids, uid, selectedCid, states, privilege) { - const [visibleCategories, selectData] = await Promise.all([ - helpers.getVisibleCategories({ - cids, uid, states, privilege, showLinks: false, - }), - helpers.getSelectedCategory(selectedCid), - ]); - - const categoriesData = categories.buildForSelectCategories(visibleCategories, ['disabledClass']); - - categoriesData.forEach((category) => { - category.selected = selectData.selectedCids.includes(category.cid); - }); - selectData.selectedCids.sort((a, b) => a - b); - return { - categories: categoriesData, - selectedCategory: selectData.selectedCategory, - selectedCids: selectData.selectedCids, - }; + const [visibleCategories, selectData] = await Promise.all([ + helpers.getVisibleCategories({ + cids, uid, states, privilege, showLinks: false, + }), + helpers.getSelectedCategory(selectedCid), + ]); + + const categoriesData = categories.buildForSelectCategories(visibleCategories, ['disabledClass']); + + for (const category of categoriesData) { + category.selected = selectData.selectedCids.includes(category.cid); + } + + selectData.selectedCids.sort((a, b) => a - b); + return { + categories: categoriesData, + selectedCategory: selectData.selectedCategory, + selectedCids: selectData.selectedCids, + }; } -helpers.getVisibleCategories = async function (params) { - const { cids, uid, privilege } = params; - const states = params.states || [categories.watchStates.watching, categories.watchStates.notwatching]; - const showLinks = !!params.showLinks; - - let [allowed, watchState, categoriesData, isAdmin, isModerator] = await Promise.all([ - privileges.categories.isUserAllowedTo(privilege, cids, uid), - categories.getWatchState(cids, uid), - categories.getCategoriesData(cids), - user.isAdministrator(uid), - user.isModerator(uid, cids), - ]); - - const filtered = await plugins.hooks.fire('filter:helpers.getVisibleCategories', { - uid: uid, - allowed: allowed, - watchState: watchState, - categoriesData: categoriesData, - isModerator: isModerator, - isAdmin: isAdmin, - }); - ({ allowed, watchState, categoriesData, isModerator, isAdmin } = filtered); - - categories.getTree(categoriesData, params.parentCid); - - const cidToAllowed = _.zipObject(cids, allowed.map((allowed, i) => isAdmin || isModerator[i] || allowed)); - const cidToCategory = _.zipObject(cids, categoriesData); - const cidToWatchState = _.zipObject(cids, watchState); - - return categoriesData.filter((c) => { - if (!c) { - return false; - } - const hasVisibleChildren = checkVisibleChildren(c, cidToAllowed, cidToWatchState, states); - const isCategoryVisible = ( - cidToAllowed[c.cid] && - (showLinks || !c.link) && - !c.disabled && - states.includes(cidToWatchState[c.cid]) - ); - const shouldBeRemoved = !hasVisibleChildren && !isCategoryVisible; - const shouldBeDisaplayedAsDisabled = hasVisibleChildren && !isCategoryVisible; - - if (shouldBeDisaplayedAsDisabled) { - c.disabledClass = true; - } - - if (shouldBeRemoved && c.parent && c.parent.cid && cidToCategory[c.parent.cid]) { - cidToCategory[c.parent.cid].children = - cidToCategory[c.parent.cid].children.filter(child => child.cid !== c.cid); - } - - return !shouldBeRemoved; - }); +helpers.getVisibleCategories = async function (parameters) { + const {cids, uid, privilege} = parameters; + const states = parameters.states || [categories.watchStates.watching, categories.watchStates.notwatching]; + const showLinks = Boolean(parameters.showLinks); + + let [allowed, watchState, categoriesData, isAdmin, isModerator] = await Promise.all([ + privileges.categories.isUserAllowedTo(privilege, cids, uid), + categories.getWatchState(cids, uid), + categories.getCategoriesData(cids), + user.isAdministrator(uid), + user.isModerator(uid, cids), + ]); + + const filtered = await plugins.hooks.fire('filter:helpers.getVisibleCategories', { + uid, + allowed, + watchState, + categoriesData, + isModerator, + isAdmin, + }); + ({allowed, watchState, categoriesData, isModerator, isAdmin} = filtered); + + categories.getTree(categoriesData, parameters.parentCid); + + const cidToAllowed = _.zipObject(cids, allowed.map((allowed, i) => isAdmin || isModerator[i] || allowed)); + const cidToCategory = _.zipObject(cids, categoriesData); + const cidToWatchState = _.zipObject(cids, watchState); + + return categoriesData.filter(c => { + if (!c) { + return false; + } + + const hasVisibleChildren = checkVisibleChildren(c, cidToAllowed, cidToWatchState, states); + const isCategoryVisible = ( + cidToAllowed[c.cid] + && (showLinks || !c.link) + && !c.disabled + && states.includes(cidToWatchState[c.cid]) + ); + const shouldBeRemoved = !hasVisibleChildren && !isCategoryVisible; + const shouldBeDisaplayedAsDisabled = hasVisibleChildren && !isCategoryVisible; + + if (shouldBeDisaplayedAsDisabled) { + c.disabledClass = true; + } + + if (shouldBeRemoved && c.parent && c.parent.cid && cidToCategory[c.parent.cid]) { + cidToCategory[c.parent.cid].children + = cidToCategory[c.parent.cid].children.filter(child => child.cid !== c.cid); + } + + return !shouldBeRemoved; + }); }; helpers.getSelectedCategory = async function (cids) { - if (cids && !Array.isArray(cids)) { - cids = [cids]; - } - cids = cids && cids.map(cid => parseInt(cid, 10)); - let selectedCategories = await categories.getCategoriesData(cids); - const selectedCids = selectedCategories.map(c => c && c.cid).filter(Boolean); - if (selectedCategories.length > 1) { - selectedCategories = { - icon: 'fa-plus', - name: '[[unread:multiple-categories-selected]]', - bgColor: '#ddd', - }; - } else if (selectedCategories.length === 1 && selectedCategories[0]) { - selectedCategories = selectedCategories[0]; - } else { - selectedCategories = null; - } - return { - selectedCids: selectedCids, - selectedCategory: selectedCategories, - }; + if (cids && !Array.isArray(cids)) { + cids = [cids]; + } + + cids &&= cids.map(cid => Number.parseInt(cid, 10)); + let selectedCategories = await categories.getCategoriesData(cids); + const selectedCids = selectedCategories.map(c => c && c.cid).filter(Boolean); + if (selectedCategories.length > 1) { + selectedCategories = { + icon: 'fa-plus', + name: '[[unread:multiple-categories-selected]]', + bgColor: '#ddd', + }; + } else if (selectedCategories.length === 1 && selectedCategories[0]) { + selectedCategories = selectedCategories[0]; + } else { + selectedCategories = null; + } + + return { + selectedCids, + selectedCategory: selectedCategories, + }; }; helpers.trimChildren = function (category) { - if (category && Array.isArray(category.children)) { - category.children = category.children.slice(0, category.subCategoriesPerPage); - category.children.forEach((child) => { - if (category.isSection) { - helpers.trimChildren(child); - } else { - child.children = undefined; - } - }); - } + if (category && Array.isArray(category.children)) { + category.children = category.children.slice(0, category.subCategoriesPerPage); + for (const child of category.children) { + if (category.isSection) { + helpers.trimChildren(child); + } else { + child.children = undefined; + } + } + } }; helpers.setCategoryTeaser = function (category) { - if (Array.isArray(category.posts) && category.posts.length && category.posts[0]) { - category.teaser = { - url: `${nconf.get('relative_path')}/post/${category.posts[0].pid}`, - timestampISO: category.posts[0].timestampISO, - pid: category.posts[0].pid, - topic: category.posts[0].topic, - }; - } + if (Array.isArray(category.posts) && category.posts.length > 0 && category.posts[0]) { + category.teaser = { + url: `${nconf.get('relative_path')}/post/${category.posts[0].pid}`, + timestampISO: category.posts[0].timestampISO, + pid: category.posts[0].pid, + topic: category.posts[0].topic, + }; + } }; function checkVisibleChildren(c, cidToAllowed, cidToWatchState, states) { - if (!c || !Array.isArray(c.children)) { - return false; - } - return c.children.some(c => !c.disabled && ( - (cidToAllowed[c.cid] && states.includes(cidToWatchState[c.cid])) || - checkVisibleChildren(c, cidToAllowed, cidToWatchState, states) - )); + if (!c || !Array.isArray(c.children)) { + return false; + } + + return c.children.some(c => !c.disabled && ( + (cidToAllowed[c.cid] && states.includes(cidToWatchState[c.cid])) + || checkVisibleChildren(c, cidToAllowed, cidToWatchState, states) + )); } helpers.getHomePageRoutes = async function (uid) { - const routes = [ - { - route: 'categories', - name: 'Categories', - }, - { - route: 'unread', - name: 'Unread', - }, - { - route: 'recent', - name: 'Recent', - }, - { - route: 'top', - name: 'Top', - }, - { - route: 'popular', - name: 'Popular', - }, - { - route: 'custom', - name: 'Custom', - }, - ]; - const data = await plugins.hooks.fire('filter:homepage.get', { - uid: uid, - routes: routes, - }); - return data.routes; + const routes = [ + { + route: 'categories', + name: 'Categories', + }, + { + route: 'unread', + name: 'Unread', + }, + { + route: 'recent', + name: 'Recent', + }, + { + route: 'top', + name: 'Top', + }, + { + route: 'popular', + name: 'Popular', + }, + { + route: 'custom', + name: 'Custom', + }, + ]; + const data = await plugins.hooks.fire('filter:homepage.get', { + uid, + routes, + }); + return data.routes; }; helpers.formatApiResponse = async (statusCode, res, payload) => { - if (res.req.method === 'HEAD') { - return res.sendStatus(statusCode); - } - - if (String(statusCode).startsWith('2')) { - if (res.req.loggedIn) { - res.set('cache-control', 'private'); - } - - let code = 'ok'; - let message = 'OK'; - switch (statusCode) { - case 202: - code = 'accepted'; - message = 'Accepted'; - break; - - case 204: - code = 'no-content'; - message = 'No Content'; - break; - } - - res.status(statusCode).json({ - status: { code, message }, - response: payload || {}, - }); - } else if (payload instanceof Error) { - const { message } = payload; - const response = {}; - - // Update status code based on some common error codes - switch (message) { - case '[[error:user-banned]]': - Object.assign(response, await generateBannedResponse(res)); - // intentional fall through - - case '[[error:no-privileges]]': - statusCode = 403; - break; - - case '[[error:invalid-uid]]': - statusCode = 401; - break; - } - - if (message.startsWith('[[error:required-parameters-missing, ')) { - const params = message.slice('[[error:required-parameters-missing, '.length, -2).split(' '); - Object.assign(response, { params }); - } - - const returnPayload = await helpers.generateError(statusCode, message, res); - returnPayload.response = response; - - if (global.env === 'development') { - returnPayload.stack = payload.stack; - process.stdout.write(`[${chalk.yellow('api')}] Exception caught, error with stack trace follows:\n`); - process.stdout.write(payload.stack); - } - res.status(statusCode).json(returnPayload); - } else if (!payload) { - // Non-2xx statusCode, generate predefined error - const returnPayload = await helpers.generateError(statusCode, null, res); - res.status(statusCode).json(returnPayload); - } + if (res.req.method === 'HEAD') { + return res.sendStatus(statusCode); + } + + if (String(statusCode).startsWith('2')) { + if (res.req.loggedIn) { + res.set('cache-control', 'private'); + } + + let code = 'ok'; + let message = 'OK'; + switch (statusCode) { + case 202: { + code = 'accepted'; + message = 'Accepted'; + break; + } + + case 204: { + code = 'no-content'; + message = 'No Content'; + break; + } + } + + res.status(statusCode).json({ + status: {code, message}, + response: payload || {}, + }); + } else if (payload instanceof Error) { + const {message} = payload; + const response = {}; + + // Update status code based on some common error codes + switch (message) { + case '[[error:user-banned]]': { + Object.assign(response, await generateBannedResponse(res)); + } + // Intentional fall through + + case '[[error:no-privileges]]': { + statusCode = 403; + break; + } + + case '[[error:invalid-uid]]': { + statusCode = 401; + break; + } + } + + if (message.startsWith('[[error:required-parameters-missing, ')) { + const parameters = message.slice('[[error:required-parameters-missing, '.length, -2).split(' '); + Object.assign(response, {params: parameters}); + } + + const returnPayload = await helpers.generateError(statusCode, message, res); + returnPayload.response = response; + + if (global.env === 'development') { + returnPayload.stack = payload.stack; + process.stdout.write(`[${chalk.yellow('api')}] Exception caught, error with stack trace follows:\n`); + process.stdout.write(payload.stack); + } + + res.status(statusCode).json(returnPayload); + } else if (!payload) { + // Non-2xx statusCode, generate predefined error + const returnPayload = await helpers.generateError(statusCode, null, res); + res.status(statusCode).json(returnPayload); + } }; async function generateBannedResponse(res) { - const response = {}; - const [reason, expiry] = await Promise.all([ - user.bans.getReason(res.req.uid), - user.getUserField(res.req.uid, 'banned:expire'), - ]); - - response.reason = reason; - if (expiry) { - Object.assign(response, { - expiry, - expiryISO: new Date(expiry).toISOString(), - expiryLocaleString: new Date(expiry).toLocaleString(), - }); - } - - return response; + const response = {}; + const [reason, expiry] = await Promise.all([ + user.bans.getReason(res.req.uid), + user.getUserField(res.req.uid, 'banned:expire'), + ]); + + response.reason = reason; + if (expiry) { + Object.assign(response, { + expiry, + expiryISO: new Date(expiry).toISOString(), + expiryLocaleString: new Date(expiry).toLocaleString(), + }); + } + + return response; } helpers.generateError = async (statusCode, message, res) => { - async function translateMessage(message) { - const { req } = res; - const settings = req.query.lang ? null : await user.getSettings(req.uid); - const language = String(req.query.lang || settings.userLang || meta.config.defaultLang); - return await translator.translate(message, language); - } - if (message && message.startsWith('[[')) { - message = await translateMessage(message); - } - - const payload = { - status: { - code: 'internal-server-error', - message: message || await translateMessage(`[[error:api.${statusCode}]]`), - }, - response: {}, - }; - - switch (statusCode) { - case 400: - payload.status.code = 'bad-request'; - break; - - case 401: - payload.status.code = 'not-authorised'; - break; - - case 403: - payload.status.code = 'forbidden'; - break; - - case 404: - payload.status.code = 'not-found'; - break; - - case 426: - payload.status.code = 'upgrade-required'; - break; - - case 429: - payload.status.code = 'too-many-requests'; - break; - - case 500: - payload.status.code = 'internal-server-error'; - break; - - case 501: - payload.status.code = 'not-implemented'; - break; - - case 503: - payload.status.code = 'service-unavailable'; - break; - } - - return payload; + async function translateMessage(message) { + const {req} = res; + const settings = req.query.lang ? null : await user.getSettings(req.uid); + const language = String(req.query.lang || settings.userLang || meta.config.defaultLang); + return await translator.translate(message, language); + } + + if (message && message.startsWith('[[')) { + message = await translateMessage(message); + } + + const payload = { + status: { + code: 'internal-server-error', + message: message || await translateMessage(`[[error:api.${statusCode}]]`), + }, + response: {}, + }; + + switch (statusCode) { + case 400: { + payload.status.code = 'bad-request'; + break; + } + + case 401: { + payload.status.code = 'not-authorised'; + break; + } + + case 403: { + payload.status.code = 'forbidden'; + break; + } + + case 404: { + payload.status.code = 'not-found'; + break; + } + + case 426: { + payload.status.code = 'upgrade-required'; + break; + } + + case 429: { + payload.status.code = 'too-many-requests'; + break; + } + + case 500: { + payload.status.code = 'internal-server-error'; + break; + } + + case 501: { + payload.status.code = 'not-implemented'; + break; + } + + case 503: { + payload.status.code = 'service-unavailable'; + break; + } + } + + return payload; }; require('../promisify')(helpers); diff --git a/src/controllers/home.js b/src/controllers/home.js index 134af52..dbf163e 100644 --- a/src/controllers/home.js +++ b/src/controllers/home.js @@ -1,64 +1,65 @@ 'use strict'; -const url = require('url'); - +const url = require('node:url'); const plugins = require('../plugins'); const meta = require('../meta'); const user = require('../user'); function adminHomePageRoute() { - return ((meta.config.homePageRoute === 'custom' ? meta.config.homePageCustom : meta.config.homePageRoute) || 'categories').replace(/^\//, ''); + return ((meta.config.homePageRoute === 'custom' ? meta.config.homePageCustom : meta.config.homePageRoute) || 'categories').replace(/^\//, ''); } async function getUserHomeRoute(uid) { - const settings = await user.getSettings(uid); - let route = adminHomePageRoute(); + const settings = await user.getSettings(uid); + let route = adminHomePageRoute(); - if (settings.homePageRoute !== 'undefined' && settings.homePageRoute !== 'none') { - route = (settings.homePageRoute || route).replace(/^\/+/, ''); - } + if (settings.homePageRoute !== 'undefined' && settings.homePageRoute !== 'none') { + route = (settings.homePageRoute || route).replace(/^\/+/, ''); + } - return route; + return route; } -async function rewrite(req, res, next) { - if (req.path !== '/' && req.path !== '/api/' && req.path !== '/api') { - return next(); - } - let route = adminHomePageRoute(); - if (meta.config.allowUserHomePage) { - route = await getUserHomeRoute(req.uid, next); - } +async function rewrite(request, res, next) { + if (request.path !== '/' && request.path !== '/api/' && request.path !== '/api') { + return next(); + } + + let route = adminHomePageRoute(); + if (meta.config.allowUserHomePage) { + route = await getUserHomeRoute(request.uid, next); + } + + let parsedUrl; + try { + parsedUrl = url.parse(route, true); + } catch (error) { + return next(error); + } - let parsedUrl; - try { - parsedUrl = url.parse(route, true); - } catch (err) { - return next(err); - } + const {pathname} = parsedUrl; + const hook = `action:homepage.get:${pathname}`; + if (plugins.hooks.hasListeners(hook)) { + res.locals.homePageRoute = pathname; + } else { + request.url = request.path + (request.path.endsWith('/') ? '' : '/') + pathname; + } - const { pathname } = parsedUrl; - const hook = `action:homepage.get:${pathname}`; - if (!plugins.hooks.hasListeners(hook)) { - req.url = req.path + (!req.path.endsWith('/') ? '/' : '') + pathname; - } else { - res.locals.homePageRoute = pathname; - } - req.query = Object.assign(parsedUrl.query, req.query); + request.query = Object.assign(parsedUrl.query, request.query); - next(); + next(); } exports.rewrite = rewrite; -function pluginHook(req, res, next) { - const hook = `action:homepage.get:${res.locals.homePageRoute}`; +function pluginHook(request, res, next) { + const hook = `action:homepage.get:${res.locals.homePageRoute}`; - plugins.hooks.fire(hook, { - req: req, - res: res, - next: next, - }); + plugins.hooks.fire(hook, { + req: request, + res, + next, + }); } exports.pluginHook = pluginHook; diff --git a/src/controllers/index.js b/src/controllers/index.js index b2f816c..952d9d6 100644 --- a/src/controllers/index.js +++ b/src/controllers/index.js @@ -2,7 +2,6 @@ const nconf = require('nconf'); const validator = require('validator'); - const meta = require('../meta'); const user = require('../user'); const plugins = require('../plugins'); @@ -41,335 +40,354 @@ Controllers.composer = require('./composer'); Controllers.write = require('./write'); -Controllers.reset = async function (req, res) { - if (meta.config['password:disableEdit']) { - return helpers.notAllowed(req, res); - } - - res.locals.metaTags = { - ...res.locals.metaTags, - name: 'robots', - content: 'noindex', - }; - - const renderReset = function (code, valid) { - res.render('reset_code', { - valid: valid, - displayExpiryNotice: req.session.passwordExpired, - code: code, - minimumPasswordLength: meta.config.minimumPasswordLength, - minimumPasswordStrength: meta.config.minimumPasswordStrength, - breadcrumbs: helpers.buildBreadcrumbs([ - { - text: '[[reset_password:reset_password]]', - url: '/reset', - }, - { - text: '[[reset_password:update_password]]', - }, - ]), - title: '[[pages:reset]]', - }); - delete req.session.passwordExpired; - }; - - if (req.params.code) { - req.session.reset_code = req.params.code; - } - - if (req.session.reset_code) { - // Validate and save to local variable before removing from session - const valid = await user.reset.validate(req.session.reset_code); - renderReset(req.session.reset_code, valid); - delete req.session.reset_code; - } else { - res.render('reset', { - code: null, - breadcrumbs: helpers.buildBreadcrumbs([{ - text: '[[reset_password:reset_password]]', - }]), - title: '[[pages:reset]]', - }); - } +Controllers.reset = async function (request, res) { + if (meta.config['password:disableEdit']) { + return helpers.notAllowed(request, res); + } + + res.locals.metaTags = { + ...res.locals.metaTags, + name: 'robots', + content: 'noindex', + }; + + const renderReset = function (code, valid) { + res.render('reset_code', { + valid, + displayExpiryNotice: request.session.passwordExpired, + code, + minimumPasswordLength: meta.config.minimumPasswordLength, + minimumPasswordStrength: meta.config.minimumPasswordStrength, + breadcrumbs: helpers.buildBreadcrumbs([ + { + text: '[[reset_password:reset_password]]', + url: '/reset', + }, + { + text: '[[reset_password:update_password]]', + }, + ]), + title: '[[pages:reset]]', + }); + delete request.session.passwordExpired; + }; + + if (request.params.code) { + request.session.reset_code = request.params.code; + } + + if (request.session.reset_code) { + // Validate and save to local variable before removing from session + const valid = await user.reset.validate(request.session.reset_code); + renderReset(request.session.reset_code, valid); + delete request.session.reset_code; + } else { + res.render('reset', { + code: null, + breadcrumbs: helpers.buildBreadcrumbs([{ + text: '[[reset_password:reset_password]]', + }]), + title: '[[pages:reset]]', + }); + } }; -Controllers.login = async function (req, res) { - const data = { loginFormEntry: [] }; - const loginStrategies = require('../routes/authentication').getLoginStrategies(); - const registrationType = meta.config.registrationType || 'normal'; - const allowLoginWith = (meta.config.allowLoginWith || 'username-email'); - - let errorText; - if (req.query.error === 'csrf-invalid') { - errorText = '[[error:csrf-invalid]]'; - } else if (req.query.error) { - errorText = validator.escape(String(req.query.error)); - } - - if (req.headers['x-return-to']) { - req.session.returnTo = req.headers['x-return-to']; - } - - // Occasionally, x-return-to is passed a full url. - req.session.returnTo = req.session.returnTo && req.session.returnTo.replace(nconf.get('base_url'), '').replace(nconf.get('relative_path'), ''); - - data.alternate_logins = loginStrategies.length > 0; - data.authentication = loginStrategies; - data.allowRegistration = registrationType === 'normal'; - data.allowLoginWith = `[[login:${allowLoginWith}]]`; - data.breadcrumbs = helpers.buildBreadcrumbs([{ - text: '[[global:login]]', - }]); - data.error = req.flash('error')[0] || errorText; - data.title = '[[pages:login]]'; - data.allowPasswordReset = !meta.config['password:disableEdit']; - - const hasLoginPrivilege = await privileges.global.canGroup('local:login', 'registered-users'); - data.allowLocalLogin = hasLoginPrivilege || parseInt(req.query.local, 10) === 1; - - if (!data.allowLocalLogin && !data.allowRegistration && data.alternate_logins && data.authentication.length === 1) { - return helpers.redirect(res, { external: data.authentication[0].url }); - } - - // Re-auth challenge, pre-fill username - if (req.loggedIn) { - const userData = await user.getUserFields(req.uid, ['username']); - data.username = userData.username; - data.alternate_logins = false; - } - res.render('login', data); +Controllers.login = async function (request, res) { + const data = {loginFormEntry: []}; + const loginStrategies = require('../routes/authentication').getLoginStrategies(); + const registrationType = meta.config.registrationType || 'normal'; + const allowLoginWith = (meta.config.allowLoginWith || 'username-email'); + + let errorText; + if (request.query.error === 'csrf-invalid') { + errorText = '[[error:csrf-invalid]]'; + } else if (request.query.error) { + errorText = validator.escape(String(request.query.error)); + } + + if (request.headers['x-return-to']) { + request.session.returnTo = request.headers['x-return-to']; + } + + // Occasionally, x-return-to is passed a full url. + request.session.returnTo = request.session.returnTo && request.session.returnTo.replace(nconf.get('base_url'), '').replace(nconf.get('relative_path'), ''); + + data.alternate_logins = loginStrategies.length > 0; + data.authentication = loginStrategies; + data.allowRegistration = registrationType === 'normal'; + data.allowLoginWith = `[[login:${allowLoginWith}]]`; + data.breadcrumbs = helpers.buildBreadcrumbs([{ + text: '[[global:login]]', + }]); + data.error = request.flash('error')[0] || errorText; + data.title = '[[pages:login]]'; + data.allowPasswordReset = !meta.config['password:disableEdit']; + + const hasLoginPrivilege = await privileges.global.canGroup('local:login', 'registered-users'); + data.allowLocalLogin = hasLoginPrivilege || Number.parseInt(request.query.local, 10) === 1; + + if (!data.allowLocalLogin && !data.allowRegistration && data.alternate_logins && data.authentication.length === 1) { + return helpers.redirect(res, {external: data.authentication[0].url}); + } + + // Re-auth challenge, pre-fill username + if (request.loggedIn) { + const userData = await user.getUserFields(request.uid, ['username']); + data.username = userData.username; + data.alternate_logins = false; + } + + res.render('login', data); }; -Controllers.register = async function (req, res, next) { - const registrationType = meta.config.registrationType || 'normal'; - - if (registrationType === 'disabled') { - return setImmediate(next); - } - - let errorText; - const returnTo = (req.headers['x-return-to'] || '').replace(nconf.get('base_url') + nconf.get('relative_path'), ''); - if (req.query.error === 'csrf-invalid') { - errorText = '[[error:csrf-invalid]]'; - } - try { - if (registrationType === 'invite-only' || registrationType === 'admin-invite-only') { - try { - await user.verifyInvitation(req.query); - } catch (e) { - return res.render('400', { - error: e.message, - }); - } - } - - if (returnTo) { - req.session.returnTo = returnTo; - } - - const loginStrategies = require('../routes/authentication').getLoginStrategies(); - res.render('register', { - 'register_window:spansize': loginStrategies.length ? 'col-md-6' : 'col-md-12', - alternate_logins: !!loginStrategies.length, - authentication: loginStrategies, - - minimumUsernameLength: meta.config.minimumUsernameLength, - maximumUsernameLength: meta.config.maximumUsernameLength, - minimumPasswordLength: meta.config.minimumPasswordLength, - minimumPasswordStrength: meta.config.minimumPasswordStrength, - breadcrumbs: helpers.buildBreadcrumbs([{ - text: '[[register:register]]', - }]), - regFormEntry: [ - { - label: 'Account Type', - styleName: 'account-type', - html: ` +Controllers.register = async function (request, res, next) { + const registrationType = meta.config.registrationType || 'normal'; + + if (registrationType === 'disabled') { + return setImmediate(next); + } + + let errorText; + const returnTo = (request.headers['x-return-to'] || '').replace(nconf.get('base_url') + nconf.get('relative_path'), ''); + if (request.query.error === 'csrf-invalid') { + errorText = '[[error:csrf-invalid]]'; + } + + try { + if (registrationType === 'invite-only' || registrationType === 'admin-invite-only') { + try { + await user.verifyInvitation(request.query); + } catch (error) { + return res.render('400', { + error: error.message, + }); + } + } + + if (returnTo) { + request.session.returnTo = returnTo; + } + + const loginStrategies = require('../routes/authentication').getLoginStrategies(); + res.render('register', { + 'register_window:spansize': loginStrategies.length > 0 ? 'col-md-6' : 'col-md-12', + alternate_logins: loginStrategies.length > 0, + authentication: loginStrategies, + + minimumUsernameLength: meta.config.minimumUsernameLength, + maximumUsernameLength: meta.config.maximumUsernameLength, + minimumPasswordLength: meta.config.minimumPasswordLength, + minimumPasswordStrength: meta.config.minimumPasswordStrength, + breadcrumbs: helpers.buildBreadcrumbs([{ + text: '[[register:register]]', + }]), + regFormEntry: [ + { + label: 'Account Type', + styleName: 'account-type', + html: ` `, - }, - ], - error: req.flash('error')[0] || errorText, - title: '[[pages:register]]', - }); - } catch (err) { - next(err); - } + }, + ], + error: request.flash('error')[0] || errorText, + title: '[[pages:register]]', + }); + } catch (error) { + next(error); + } }; -Controllers.registerInterstitial = async function (req, res, next) { - if (!req.session.hasOwnProperty('registration')) { - return res.redirect(`${nconf.get('relative_path')}/register`); - } - try { - const data = await plugins.hooks.fire('filter:register.interstitial', { - req, - userData: req.session.registration, - interstitials: [], - }); - - if (!data.interstitials.length) { - // No interstitials, redirect to home - const returnTo = req.session.returnTo || req.session.registration.returnTo; - delete req.session.registration; - return helpers.redirect(res, returnTo || '/'); - } - - const errors = req.flash('errors'); - const renders = data.interstitials.map( - interstitial => req.app.renderAsync(interstitial.template, { ...interstitial.data || {}, errors }) - ); - const sections = await Promise.all(renders); - - res.render('registerComplete', { - title: '[[pages:registration-complete]]', - register: data.userData.register, - sections, - errors, - }); - } catch (err) { - next(err); - } +Controllers.registerInterstitial = async function (request, res, next) { + if (!request.session.hasOwnProperty('registration')) { + return res.redirect(`${nconf.get('relative_path')}/register`); + } + + try { + const data = await plugins.hooks.fire('filter:register.interstitial', { + req: request, + userData: request.session.registration, + interstitials: [], + }); + + if (data.interstitials.length === 0) { + // No interstitials, redirect to home + const returnTo = request.session.returnTo || request.session.registration.returnTo; + delete request.session.registration; + return helpers.redirect(res, returnTo || '/'); + } + + const errors = request.flash('errors'); + const renders = data.interstitials.map( + interstitial => request.app.renderAsync(interstitial.template, {...interstitial.data, errors}), + ); + const sections = await Promise.all(renders); + + res.render('registerComplete', { + title: '[[pages:registration-complete]]', + register: data.userData.register, + sections, + errors, + }); + } catch (error) { + next(error); + } }; -Controllers.confirmEmail = async (req, res, next) => { - try { - await user.email.confirmByCode(req.params.code, req.session.id); - } catch (e) { - if (e.message === '[[error:invalid-data]]') { - return next(); - } +Controllers.confirmEmail = async (request, res, next) => { + try { + await user.email.confirmByCode(request.params.code, request.session.id); + } catch (error) { + if (error.message === '[[error:invalid-data]]') { + return next(); + } - throw e; - } + throw error; + } - res.render('confirm', { - title: '[[pages:confirm]]', - }); + res.render('confirm', { + title: '[[pages:confirm]]', + }); }; -Controllers.robots = function (req, res) { - res.set('Content-Type', 'text/plain'); - - if (meta.config['robots:txt']) { - res.send(meta.config['robots:txt']); - } else { - res.send(`${'User-agent: *\n' + - 'Disallow: '}${nconf.get('relative_path')}/admin/\n` + - `Disallow: ${nconf.get('relative_path')}/reset/\n` + - `Disallow: ${nconf.get('relative_path')}/compose\n` + - `Sitemap: ${nconf.get('url')}/sitemap.xml`); - } +Controllers.robots = function (request, res) { + res.set('Content-Type', 'text/plain'); + + if (meta.config['robots:txt']) { + res.send(meta.config['robots:txt']); + } else { + res.send(`${'User-agent: *\n' + + 'Disallow: '}${nconf.get('relative_path')}/admin/\n` + + `Disallow: ${nconf.get('relative_path')}/reset/\n` + + `Disallow: ${nconf.get('relative_path')}/compose\n` + + `Sitemap: ${nconf.get('url')}/sitemap.xml`); + } }; -Controllers.manifest = async function (req, res) { - const manifest = { - name: meta.config.title || 'NodeBB', - short_name: meta.config['title:short'] || meta.config.title || 'NodeBB', - start_url: nconf.get('url'), - display: 'standalone', - orientation: 'portrait', - theme_color: meta.config.themeColor || '#ffffff', - background_color: meta.config.backgroundColor || '#ffffff', - icons: [], - }; - - if (meta.config['brand:touchIcon']) { - manifest.icons.push({ - src: `${nconf.get('relative_path')}/assets/uploads/system/touchicon-36.png`, - sizes: '36x36', - type: 'image/png', - density: 0.75, - }, { - src: `${nconf.get('relative_path')}/assets/uploads/system/touchicon-48.png`, - sizes: '48x48', - type: 'image/png', - density: 1.0, - }, { - src: `${nconf.get('relative_path')}/assets/uploads/system/touchicon-72.png`, - sizes: '72x72', - type: 'image/png', - density: 1.5, - }, { - src: `${nconf.get('relative_path')}/assets/uploads/system/touchicon-96.png`, - sizes: '96x96', - type: 'image/png', - density: 2.0, - }, { - src: `${nconf.get('relative_path')}/assets/uploads/system/touchicon-144.png`, - sizes: '144x144', - type: 'image/png', - density: 3.0, - }, { - src: `${nconf.get('relative_path')}/assets/uploads/system/touchicon-192.png`, - sizes: '192x192', - type: 'image/png', - density: 4.0, - }, { - src: `${nconf.get('relative_path')}/assets/uploads/system/touchicon-512.png`, - sizes: '512x512', - type: 'image/png', - density: 10.0, - }); - } - - - if (meta.config['brand:maskableIcon']) { - manifest.icons.push({ - src: `${nconf.get('relative_path')}/assets/uploads/system/maskableicon-orig.png`, - type: 'image/png', - purpose: 'maskable', - }); - } else if (meta.config['brand:touchIcon']) { - manifest.icons.push({ - src: `${nconf.get('relative_path')}/assets/uploads/system/touchicon-orig.png`, - type: 'image/png', - purpose: 'maskable', - }); - } - - const data = await plugins.hooks.fire('filter:manifest.build', { - req: req, - res: res, - manifest: manifest, - }); - res.status(200).json(data.manifest); +Controllers.manifest = async function (request, res) { + const manifest = { + name: meta.config.title || 'NodeBB', + short_name: meta.config['title:short'] || meta.config.title || 'NodeBB', + start_url: nconf.get('url'), + display: 'standalone', + orientation: 'portrait', + theme_color: meta.config.themeColor || '#ffffff', + background_color: meta.config.backgroundColor || '#ffffff', + icons: [], + }; + + if (meta.config['brand:touchIcon']) { + manifest.icons.push({ + src: `${nconf.get('relative_path')}/assets/uploads/system/touchicon-36.png`, + sizes: '36x36', + type: 'image/png', + density: 0.75, + }, { + src: `${nconf.get('relative_path')}/assets/uploads/system/touchicon-48.png`, + sizes: '48x48', + type: 'image/png', + density: 1, + }, { + src: `${nconf.get('relative_path')}/assets/uploads/system/touchicon-72.png`, + sizes: '72x72', + type: 'image/png', + density: 1.5, + }, { + src: `${nconf.get('relative_path')}/assets/uploads/system/touchicon-96.png`, + sizes: '96x96', + type: 'image/png', + density: 2, + }, { + src: `${nconf.get('relative_path')}/assets/uploads/system/touchicon-144.png`, + sizes: '144x144', + type: 'image/png', + density: 3, + }, { + src: `${nconf.get('relative_path')}/assets/uploads/system/touchicon-192.png`, + sizes: '192x192', + type: 'image/png', + density: 4, + }, { + src: `${nconf.get('relative_path')}/assets/uploads/system/touchicon-512.png`, + sizes: '512x512', + type: 'image/png', + density: 10, + }); + } + + if (meta.config['brand:maskableIcon']) { + manifest.icons.push({ + src: `${nconf.get('relative_path')}/assets/uploads/system/maskableicon-orig.png`, + type: 'image/png', + purpose: 'maskable', + }); + } else if (meta.config['brand:touchIcon']) { + manifest.icons.push({ + src: `${nconf.get('relative_path')}/assets/uploads/system/touchicon-orig.png`, + type: 'image/png', + purpose: 'maskable', + }); + } + + const data = await plugins.hooks.fire('filter:manifest.build', { + req: request, + res, + manifest, + }); + res.status(200).json(data.manifest); }; -Controllers.outgoing = function (req, res, next) { - const url = req.query.url || ''; - const allowedProtocols = [ - 'http', 'https', 'ftp', 'ftps', 'mailto', 'news', 'irc', 'gopher', - 'nntp', 'feed', 'telnet', 'mms', 'rtsp', 'svn', 'tel', 'fax', 'xmpp', 'webcal', - ]; - const parsed = require('url').parse(url); - - if (!url || !parsed.protocol || !allowedProtocols.includes(parsed.protocol.slice(0, -1))) { - return next(); - } - - res.render('outgoing', { - outgoing: validator.escape(String(url)), - title: meta.config.title, - breadcrumbs: helpers.buildBreadcrumbs([{ - text: '[[notifications:outgoing_link]]', - }]), - }); +Controllers.outgoing = function (request, res, next) { + const url = request.query.url || ''; + const allowedProtocols = [ + 'http', + 'https', + 'ftp', + 'ftps', + 'mailto', + 'news', + 'irc', + 'gopher', + 'nntp', + 'feed', + 'telnet', + 'mms', + 'rtsp', + 'svn', + 'tel', + 'fax', + 'xmpp', + 'webcal', + ]; + const parsed = require('node:url').parse(url); + + if (!url || !parsed.protocol || !allowedProtocols.includes(parsed.protocol.slice(0, -1))) { + return next(); + } + + res.render('outgoing', { + outgoing: validator.escape(String(url)), + title: meta.config.title, + breadcrumbs: helpers.buildBreadcrumbs([{ + text: '[[notifications:outgoing_link]]', + }]), + }); }; -Controllers.termsOfUse = async function (req, res, next) { - if (!meta.config.termsOfUse) { - return next(); - } - const termsOfUse = await plugins.hooks.fire('filter:parse.post', { - postData: { - content: meta.config.termsOfUse || '', - }, - }); - res.render('tos', { - termsOfUse: termsOfUse.postData.content, - }); +Controllers.termsOfUse = async function (request, res, next) { + if (!meta.config.termsOfUse) { + return next(); + } + + const termsOfUse = await plugins.hooks.fire('filter:parse.post', { + postData: { + content: meta.config.termsOfUse || '', + }, + }); + res.render('tos', { + termsOfUse: termsOfUse.postData.content, + }); }; diff --git a/src/controllers/mods.js b/src/controllers/mods.js index 638d1fc..26d767f 100644 --- a/src/controllers/mods.js +++ b/src/controllers/mods.js @@ -13,188 +13,192 @@ const helpers = require('./helpers'); const modsController = module.exports; modsController.flags = {}; -modsController.flags.list = async function (req, res) { - const validFilters = ['assignee', 'state', 'reporterId', 'type', 'targetUid', 'cid', 'quick', 'page', 'perPage']; - const validSorts = ['newest', 'oldest', 'reports', 'upvotes', 'downvotes', 'replies']; - - const results = await Promise.all([ - user.isAdminOrGlobalMod(req.uid), - user.getModeratedCids(req.uid), - plugins.hooks.fire('filter:flags.validateFilters', { filters: validFilters }), - plugins.hooks.fire('filter:flags.validateSort', { sorts: validSorts }), - ]); - const [isAdminOrGlobalMod, moderatedCids,, { sorts }] = results; - let [,, { filters }] = results; - - if (!(isAdminOrGlobalMod || !!moderatedCids.length)) { - return helpers.notAllowed(req, res); - } - - if (!isAdminOrGlobalMod && moderatedCids.length) { - res.locals.cids = moderatedCids.map(cid => String(cid)); - } - - // Parse query string params for filters, eliminate non-valid filters - filters = filters.reduce((memo, cur) => { - if (req.query.hasOwnProperty(cur)) { - if (typeof req.query[cur] === 'string' && req.query[cur].trim() !== '') { - memo[cur] = req.query[cur].trim(); - } else if (Array.isArray(req.query[cur]) && req.query[cur].length) { - memo[cur] = req.query[cur]; - } - } - - return memo; - }, {}); - - let hasFilter = !!Object.keys(filters).length; - - if (res.locals.cids) { - if (!filters.cid) { - // If mod and no cid filter, add filter for their modded categories - filters.cid = res.locals.cids; - } else if (Array.isArray(filters.cid)) { - // Remove cids they do not moderate - filters.cid = filters.cid.filter(cid => res.locals.cids.includes(String(cid))); - } else if (!res.locals.cids.includes(String(filters.cid))) { - filters.cid = res.locals.cids; - hasFilter = false; - } - } - - // Pagination doesn't count as a filter - if ( - (Object.keys(filters).length === 1 && filters.hasOwnProperty('page')) || - (Object.keys(filters).length === 2 && filters.hasOwnProperty('page') && filters.hasOwnProperty('perPage')) - ) { - hasFilter = false; - } - - // Parse sort from query string - let sort; - if (req.query.sort) { - sort = sorts.includes(req.query.sort) ? req.query.sort : null; - } - if (sort === 'newest') { - sort = undefined; - } - hasFilter = hasFilter || !!sort; - - const [flagsData, analyticsData, selectData] = await Promise.all([ - flags.list({ - filters: filters, - sort: sort, - uid: req.uid, - query: req.query, - }), - analytics.getDailyStatsForSet('analytics:flags', Date.now(), 30), - helpers.getSelectedCategory(filters.cid), - ]); - - res.render('flags/list', { - flags: flagsData.flags, - analytics: analyticsData, - selectedCategory: selectData.selectedCategory, - hasFilter: hasFilter, - filters: filters, - expanded: !!(filters.assignee || filters.reporterId || filters.targetUid), - sort: sort || 'newest', - title: '[[pages:flags]]', - pagination: pagination.create(flagsData.page, flagsData.pageCount, req.query), - breadcrumbs: helpers.buildBreadcrumbs([{ text: '[[pages:flags]]' }]), - }); +modsController.flags.list = async function (request, res) { + const validFilters = ['assignee', 'state', 'reporterId', 'type', 'targetUid', 'cid', 'quick', 'page', 'perPage']; + const validSorts = ['newest', 'oldest', 'reports', 'upvotes', 'downvotes', 'replies']; + + const results = await Promise.all([ + user.isAdminOrGlobalMod(request.uid), + user.getModeratedCids(request.uid), + plugins.hooks.fire('filter:flags.validateFilters', {filters: validFilters}), + plugins.hooks.fire('filter:flags.validateSort', {sorts: validSorts}), + ]); + const [isAdminOrGlobalModule, moderatedCids,, {sorts}] = results; + let {filters} = results[2]; + + if (!(isAdminOrGlobalModule || moderatedCids.length > 0)) { + return helpers.notAllowed(request, res); + } + + if (!isAdminOrGlobalModule && moderatedCids.length > 0) { + res.locals.cids = moderatedCids.map(String); + } + + // Parse query string params for filters, eliminate non-valid filters + filters = filters.reduce((memo, current) => { + if (request.query.hasOwnProperty(current)) { + if (typeof request.query[current] === 'string' && request.query[current].trim() !== '') { + memo[current] = request.query[current].trim(); + } else if (Array.isArray(request.query[current]) && request.query[current].length > 0) { + memo[current] = request.query[current]; + } + } + + return memo; + }, {}); + + let hasFilter = Object.keys(filters).length > 0; + + if (res.locals.cids) { + if (!filters.cid) { + // If mod and no cid filter, add filter for their modded categories + filters.cid = res.locals.cids; + } else if (Array.isArray(filters.cid)) { + // Remove cids they do not moderate + filters.cid = filters.cid.filter(cid => res.locals.cids.includes(String(cid))); + } else if (!res.locals.cids.includes(String(filters.cid))) { + filters.cid = res.locals.cids; + hasFilter = false; + } + } + + // Pagination doesn't count as a filter + if ( + (Object.keys(filters).length === 1 && filters.hasOwnProperty('page')) + || (Object.keys(filters).length === 2 && filters.hasOwnProperty('page') && filters.hasOwnProperty('perPage')) + ) { + hasFilter = false; + } + + // Parse sort from query string + let sort; + if (request.query.sort) { + sort = sorts.includes(request.query.sort) ? request.query.sort : null; + } + + if (sort === 'newest') { + sort = undefined; + } + + hasFilter ||= Boolean(sort); + + const [flagsData, analyticsData, selectData] = await Promise.all([ + flags.list({ + filters, + sort, + uid: request.uid, + query: request.query, + }), + analytics.getDailyStatsForSet('analytics:flags', Date.now(), 30), + helpers.getSelectedCategory(filters.cid), + ]); + + res.render('flags/list', { + flags: flagsData.flags, + analytics: analyticsData, + selectedCategory: selectData.selectedCategory, + hasFilter, + filters, + expanded: Boolean(filters.assignee || filters.reporterId || filters.targetUid), + sort: sort || 'newest', + title: '[[pages:flags]]', + pagination: pagination.create(flagsData.page, flagsData.pageCount, request.query), + breadcrumbs: helpers.buildBreadcrumbs([{text: '[[pages:flags]]'}]), + }); }; -modsController.flags.detail = async function (req, res, next) { - const results = await utils.promiseParallel({ - isAdminOrGlobalMod: user.isAdminOrGlobalMod(req.uid), - moderatedCids: user.getModeratedCids(req.uid), - flagData: flags.get(req.params.flagId), - assignees: user.getAdminsandGlobalModsandModerators(), - privileges: Promise.all(['global', 'admin'].map(async type => privileges[type].get(req.uid))), - }); - results.privileges = { ...results.privileges[0], ...results.privileges[1] }; - - if (!results.flagData || (!(results.isAdminOrGlobalMod || !!results.moderatedCids.length))) { - return next(); // 404 - } - - results.flagData.history = results.isAdminOrGlobalMod ? (await flags.getHistory(req.params.flagId)) : null; - - if (results.flagData.type === 'user') { - results.flagData.type_path = 'uid'; - } else if (results.flagData.type === 'post') { - results.flagData.type_path = 'post'; - } - - res.render('flags/detail', Object.assign(results.flagData, { - assignees: results.assignees, - type_bool: ['post', 'user', 'empty'].reduce((memo, cur) => { - if (cur !== 'empty') { - memo[cur] = results.flagData.type === cur && ( - !results.flagData.target || - !!Object.keys(results.flagData.target).length - ); - } else { - memo[cur] = !Object.keys(results.flagData.target).length; - } - - return memo; - }, {}), - states: Object.fromEntries(flags._states), - title: `[[pages:flag-details, ${req.params.flagId}]]`, - privileges: results.privileges, - breadcrumbs: helpers.buildBreadcrumbs([ - { text: '[[pages:flags]]', url: '/flags' }, - { text: `[[pages:flag-details, ${req.params.flagId}]]` }, - ]), - })); +modsController.flags.detail = async function (request, res, next) { + const results = await utils.promiseParallel({ + isAdminOrGlobalMod: user.isAdminOrGlobalMod(request.uid), + moderatedCids: user.getModeratedCids(request.uid), + flagData: flags.get(request.params.flagId), + assignees: user.getAdminsandGlobalModsandModerators(), + privileges: Promise.all(['global', 'admin'].map(async type => privileges[type].get(request.uid))), + }); + results.privileges = {...results.privileges[0], ...results.privileges[1]}; + + if (!results.flagData || (!(results.isAdminOrGlobalMod || results.moderatedCids.length > 0))) { + return next(); // 404 + } + + results.flagData.history = results.isAdminOrGlobalMod ? (await flags.getHistory(request.params.flagId)) : null; + + if (results.flagData.type === 'user') { + results.flagData.type_path = 'uid'; + } else if (results.flagData.type === 'post') { + results.flagData.type_path = 'post'; + } + + res.render('flags/detail', Object.assign(results.flagData, { + assignees: results.assignees, + type_bool: ['post', 'user', 'empty'].reduce((memo, current) => { + if (current === 'empty') { + memo[current] = Object.keys(results.flagData.target).length === 0; + } else { + memo[current] = results.flagData.type === current && ( + !results.flagData.target + || Object.keys(results.flagData.target).length > 0 + ); + } + + return memo; + }, {}), + states: Object.fromEntries(flags._states), + title: `[[pages:flag-details, ${request.params.flagId}]]`, + privileges: results.privileges, + breadcrumbs: helpers.buildBreadcrumbs([ + {text: '[[pages:flags]]', url: '/flags'}, + {text: `[[pages:flag-details, ${request.params.flagId}]]`}, + ]), + })); }; -modsController.postQueue = async function (req, res, next) { - if (!req.loggedIn) { - return next(); - } - const { id } = req.params; - const { cid } = req.query; - const page = parseInt(req.query.page, 10) || 1; - const postsPerPage = 20; - - let postData = await posts.getQueuedPosts({ id: id }); - const [isAdmin, isGlobalMod, moderatedCids, categoriesData] = await Promise.all([ - user.isAdministrator(req.uid), - user.isGlobalModerator(req.uid), - user.getModeratedCids(req.uid), - helpers.getSelectedCategory(cid), - ]); - - postData = postData.filter(p => p && - (!categoriesData.selectedCids.length || categoriesData.selectedCids.includes(p.category.cid)) && - (isAdmin || isGlobalMod || moderatedCids.includes(Number(p.category.cid)) || req.uid === p.user.uid)); - - ({ posts: postData } = await plugins.hooks.fire('filter:post-queue.get', { - posts: postData, - req: req, - })); - - const pageCount = Math.max(1, Math.ceil(postData.length / postsPerPage)); - const start = (page - 1) * postsPerPage; - const stop = start + postsPerPage - 1; - postData = postData.slice(start, stop + 1); - const crumbs = [{ text: '[[pages:post-queue]]', url: id ? '/post-queue' : undefined }]; - if (id && postData.length) { - const text = postData[0].data.tid ? '[[post-queue:reply]]' : '[[post-queue:topic]]'; - crumbs.push({ text: text }); - } - res.render('post-queue', { - title: '[[pages:post-queue]]', - posts: postData, - isAdmin: isAdmin, - canAccept: isAdmin || isGlobalMod || !!moderatedCids.length, - ...categoriesData, - allCategoriesUrl: `post-queue${helpers.buildQueryString(req.query, 'cid', '')}`, - pagination: pagination.create(page, pageCount), - breadcrumbs: helpers.buildBreadcrumbs(crumbs), - singlePost: !!id, - }); +modsController.postQueue = async function (request, res, next) { + if (!request.loggedIn) { + return next(); + } + + const {id} = request.params; + const {cid} = request.query; + const page = Number.parseInt(request.query.page, 10) || 1; + const postsPerPage = 20; + + let postData = await posts.getQueuedPosts({id}); + const [isAdmin, isGlobalModule, moderatedCids, categoriesData] = await Promise.all([ + user.isAdministrator(request.uid), + user.isGlobalModerator(request.uid), + user.getModeratedCids(request.uid), + helpers.getSelectedCategory(cid), + ]); + + postData = postData.filter(p => p + && (categoriesData.selectedCids.length === 0 || categoriesData.selectedCids.includes(p.category.cid)) + && (isAdmin || isGlobalModule || moderatedCids.includes(Number(p.category.cid)) || request.uid === p.user.uid)); + + ({posts: postData} = await plugins.hooks.fire('filter:post-queue.get', { + posts: postData, + req: request, + })); + + const pageCount = Math.max(1, Math.ceil(postData.length / postsPerPage)); + const start = (page - 1) * postsPerPage; + const stop = start + postsPerPage - 1; + postData = postData.slice(start, stop + 1); + const crumbs = [{text: '[[pages:post-queue]]', url: id ? '/post-queue' : undefined}]; + if (id && postData.length > 0) { + const text = postData[0].data.tid ? '[[post-queue:reply]]' : '[[post-queue:topic]]'; + crumbs.push({text}); + } + + res.render('post-queue', { + title: '[[pages:post-queue]]', + posts: postData, + isAdmin, + canAccept: isAdmin || isGlobalModule || moderatedCids.length > 0, + ...categoriesData, + allCategoriesUrl: `post-queue${helpers.buildQueryString(request.query, 'cid', '')}`, + pagination: pagination.create(page, pageCount), + breadcrumbs: helpers.buildBreadcrumbs(crumbs), + singlePost: Boolean(id), + }); }; diff --git a/src/controllers/osd.js b/src/controllers/osd.js index 8c06a93..40ca3be 100644 --- a/src/controllers/osd.js +++ b/src/controllers/osd.js @@ -2,56 +2,55 @@ const xml = require('xml'); const nconf = require('nconf'); - const plugins = require('../plugins'); const meta = require('../meta'); -module.exports.handle = function (req, res, next) { - if (plugins.hooks.hasListeners('filter:search.query')) { - res.type('application/opensearchdescription+xml').send(generateXML()); - } else { - next(); - } +module.exports.handle = function (request, res, next) { + if (plugins.hooks.hasListeners('filter:search.query')) { + res.type('application/opensearchdescription+xml').send(generateXML()); + } else { + next(); + } }; function generateXML() { - return xml([{ - OpenSearchDescription: [ - { - _attr: { - xmlns: 'http://a9.com/-/spec/opensearch/1.1/', - 'xmlns:moz': 'http://www.mozilla.org/2006/browser/search/', - }, - }, - { ShortName: trimToLength(String(meta.config.title || meta.config.browserTitle || 'NodeBB'), 16) }, - { Description: trimToLength(String(meta.config.description || ''), 1024) }, - { InputEncoding: 'UTF-8' }, - { - Image: [ - { - _attr: { - width: '16', - height: '16', - type: 'image/x-icon', - }, - }, - `${nconf.get('url')}/favicon.ico`, - ], - }, - { - Url: { - _attr: { - type: 'text/html', - method: 'get', - template: `${nconf.get('url')}/search?term={searchTerms}&in=titlesposts`, - }, - }, - }, - { 'moz:SearchForm': `${nconf.get('url')}/search` }, - ], - }], { declaration: true, indent: '\t' }); + return xml([{ + OpenSearchDescription: [ + { + _attr: { + xmlns: 'http://a9.com/-/spec/opensearch/1.1/', + 'xmlns:moz': 'http://www.mozilla.org/2006/browser/search/', + }, + }, + {ShortName: trimToLength(String(meta.config.title || meta.config.browserTitle || 'NodeBB'), 16)}, + {Description: trimToLength(String(meta.config.description || ''), 1024)}, + {InputEncoding: 'UTF-8'}, + { + Image: [ + { + _attr: { + width: '16', + height: '16', + type: 'image/x-icon', + }, + }, + `${nconf.get('url')}/favicon.ico`, + ], + }, + { + Url: { + _attr: { + type: 'text/html', + method: 'get', + template: `${nconf.get('url')}/search?term={searchTerms}&in=titlesposts`, + }, + }, + }, + {'moz:SearchForm': `${nconf.get('url')}/search`}, + ], + }], {declaration: true, indent: '\t'}); } function trimToLength(string, length) { - return string.trim().substring(0, length).trim(); + return string.trim().slice(0, Math.max(0, length)).trim(); } diff --git a/src/controllers/ping.js b/src/controllers/ping.js index 68ca1d0..80297ae 100644 --- a/src/controllers/ping.js +++ b/src/controllers/ping.js @@ -3,11 +3,11 @@ const nconf = require('nconf'); const db = require('../database'); -module.exports.ping = async function (req, res, next) { - try { - await db.getObject('config'); - res.status(200).send(req.path === `${nconf.get('relative_path')}/sping` ? 'healthy' : '200'); - } catch (err) { - next(err); - } +module.exports.ping = async function (request, res, next) { + try { + await db.getObject('config'); + res.status(200).send(request.path === `${nconf.get('relative_path')}/sping` ? 'healthy' : '200'); + } catch (error) { + next(error); + } }; diff --git a/src/controllers/popular.js b/src/controllers/popular.js index eac0264..f621164 100644 --- a/src/controllers/popular.js +++ b/src/controllers/popular.js @@ -3,28 +3,29 @@ const nconf = require('nconf'); const validator = require('validator'); - const helpers = require('./helpers'); const recentController = require('./recent'); const popularController = module.exports; -popularController.get = async function (req, res, next) { - const data = await recentController.getData(req, 'popular', 'posts'); - if (!data) { - return next(); - } - const term = helpers.terms[req.query.term] || 'alltime'; - if (req.originalUrl.startsWith(`${nconf.get('relative_path')}/api/popular`) || req.originalUrl.startsWith(`${nconf.get('relative_path')}/popular`)) { - data.title = `[[pages:popular-${term}]]`; - const breadcrumbs = [{ text: '[[global:header.popular]]' }]; - data.breadcrumbs = helpers.buildBreadcrumbs(breadcrumbs); - } +popularController.get = async function (request, res, next) { + const data = await recentController.getData(request, 'popular', 'posts'); + if (!data) { + return next(); + } + + const term = helpers.terms[request.query.term] || 'alltime'; + if (request.originalUrl.startsWith(`${nconf.get('relative_path')}/api/popular`) || request.originalUrl.startsWith(`${nconf.get('relative_path')}/popular`)) { + data.title = `[[pages:popular-${term}]]`; + const breadcrumbs = [{text: '[[global:header.popular]]'}]; + data.breadcrumbs = helpers.buildBreadcrumbs(breadcrumbs); + } + + const feedQs = data.rssFeedUrl.split('?')[1]; + data.rssFeedUrl = `${nconf.get('relative_path')}/popular/${validator.escape(String(request.query.term || 'alltime'))}.rss`; + if (request.loggedIn) { + data.rssFeedUrl += `?${feedQs}`; + } - const feedQs = data.rssFeedUrl.split('?')[1]; - data.rssFeedUrl = `${nconf.get('relative_path')}/popular/${validator.escape(String(req.query.term || 'alltime'))}.rss`; - if (req.loggedIn) { - data.rssFeedUrl += `?${feedQs}`; - } - res.render('popular', data); + res.render('popular', data); }; diff --git a/src/controllers/posts.js b/src/controllers/posts.js index 4222cc6..c1eabdc 100644 --- a/src/controllers/posts.js +++ b/src/controllers/posts.js @@ -1,39 +1,39 @@ 'use strict'; -const querystring = require('querystring'); - +const querystring = require('node:querystring'); const posts = require('../posts'); const privileges = require('../privileges'); const helpers = require('./helpers'); const postsController = module.exports; -postsController.redirectToPost = async function (req, res, next) { - const pid = parseInt(req.params.pid, 10); - if (!pid) { - return next(); - } +postsController.redirectToPost = async function (request, res, next) { + const pid = Number.parseInt(request.params.pid, 10); + if (!pid) { + return next(); + } + + const [canRead, path] = await Promise.all([ + privileges.posts.can('topics:read', pid, request.uid), + posts.generatePostPath(pid, request.uid), + ]); + if (!path) { + return next(); + } - const [canRead, path] = await Promise.all([ - privileges.posts.can('topics:read', pid, req.uid), - posts.generatePostPath(pid, req.uid), - ]); - if (!path) { - return next(); - } - if (!canRead) { - return helpers.notAllowed(req, res); - } + if (!canRead) { + return helpers.notAllowed(request, res); + } - const qs = querystring.stringify(req.query); - helpers.redirect(res, qs ? `${path}?${qs}` : path); + const qs = querystring.stringify(request.query); + helpers.redirect(res, qs ? `${path}?${qs}` : path); }; -postsController.getRecentPosts = async function (req, res) { - const page = parseInt(req.query.page, 10) || 1; - const postsPerPage = 20; - const start = Math.max(0, (page - 1) * postsPerPage); - const stop = start + postsPerPage - 1; - const data = await posts.getRecentPosts(req.uid, start, stop, req.params.term); - res.json(data); +postsController.getRecentPosts = async function (request, res) { + const page = Number.parseInt(request.query.page, 10) || 1; + const postsPerPage = 20; + const start = Math.max(0, (page - 1) * postsPerPage); + const stop = start + postsPerPage - 1; + const data = await posts.getRecentPosts(request.uid, start, stop, request.params.term); + res.json(data); }; diff --git a/src/controllers/recent.js b/src/controllers/recent.js index 1a588c2..7af9047 100644 --- a/src/controllers/recent.js +++ b/src/controllers/recent.js @@ -2,98 +2,99 @@ 'use strict'; const nconf = require('nconf'); - const user = require('../user'); const categories = require('../categories'); const topics = require('../topics'); const meta = require('../meta'); -const helpers = require('./helpers'); const pagination = require('../pagination'); const privileges = require('../privileges'); +const helpers = require('./helpers'); const recentController = module.exports; const relative_path = nconf.get('relative_path'); -recentController.get = async function (req, res, next) { - const data = await recentController.getData(req, 'recent', 'recent'); - if (!data) { - return next(); - } - res.render('recent', data); +recentController.get = async function (request, res, next) { + const data = await recentController.getData(request, 'recent', 'recent'); + if (!data) { + return next(); + } + + res.render('recent', data); }; -recentController.getData = async function (req, url, sort) { - const page = parseInt(req.query.page, 10) || 1; - let term = helpers.terms[req.query.term]; - const { cid, tags } = req.query; - const filter = req.query.filter || ''; - - if (!term && req.query.term) { - return null; - } - term = term || 'alltime'; - - const [settings, categoryData, rssToken, canPost, isPrivileged] = await Promise.all([ - user.getSettings(req.uid), - helpers.getSelectedCategory(cid), - user.auth.getFeedToken(req.uid), - canPostTopic(req.uid), - user.isPrivileged(req.uid), - ]); - - const start = Math.max(0, (page - 1) * settings.topicsPerPage); - const stop = start + settings.topicsPerPage - 1; - - const data = await topics.getSortedTopics({ - cids: cid, - tags: tags, - uid: req.uid, - start: start, - stop: stop, - filter: filter, - term: term, - sort: sort, - floatPinned: req.query.pinned, - query: req.query, - }); - - const isDisplayedAsHome = !(req.originalUrl.startsWith(`${relative_path}/api/${url}`) || req.originalUrl.startsWith(`${relative_path}/${url}`)); - const baseUrl = isDisplayedAsHome ? '' : url; - - if (isDisplayedAsHome) { - data.title = meta.config.homePageTitle || '[[pages:home]]'; - } else { - data.title = `[[pages:${url}]]`; - data.breadcrumbs = helpers.buildBreadcrumbs([{ text: `[[${url}:title]]` }]); - } - - data.canPost = canPost; - data.showSelect = isPrivileged; - data.showTopicTools = isPrivileged; - data.allCategoriesUrl = baseUrl + helpers.buildQueryString(req.query, 'cid', ''); - data.selectedCategory = categoryData.selectedCategory; - data.selectedCids = categoryData.selectedCids; - data['feeds:disableRSS'] = meta.config['feeds:disableRSS'] || 0; - data.rssFeedUrl = `${relative_path}/${url}.rss`; - if (req.loggedIn) { - data.rssFeedUrl += `?uid=${req.uid}&token=${rssToken}`; - } - - data.filters = helpers.buildFilters(baseUrl, filter, req.query); - data.selectedFilter = data.filters.find(filter => filter && filter.selected); - data.terms = helpers.buildTerms(baseUrl, term, req.query); - data.selectedTerm = data.terms.find(term => term && term.selected); - - const pageCount = Math.max(1, Math.ceil(data.topicCount / settings.topicsPerPage)); - data.pagination = pagination.create(page, pageCount, req.query); - helpers.addLinkTags({ url: url, res: req.res, tags: data.pagination.rel }); - return data; +recentController.getData = async function (request, url, sort) { + const page = Number.parseInt(request.query.page, 10) || 1; + let term = helpers.terms[request.query.term]; + const {cid, tags} = request.query; + const filter = request.query.filter || ''; + + if (!term && request.query.term) { + return null; + } + + term ||= 'alltime'; + + const [settings, categoryData, rssToken, canPost, isPrivileged] = await Promise.all([ + user.getSettings(request.uid), + helpers.getSelectedCategory(cid), + user.auth.getFeedToken(request.uid), + canPostTopic(request.uid), + user.isPrivileged(request.uid), + ]); + + const start = Math.max(0, (page - 1) * settings.topicsPerPage); + const stop = start + settings.topicsPerPage - 1; + + const data = await topics.getSortedTopics({ + cids: cid, + tags, + uid: request.uid, + start, + stop, + filter, + term, + sort, + floatPinned: request.query.pinned, + query: request.query, + }); + + const isDisplayedAsHome = !(request.originalUrl.startsWith(`${relative_path}/api/${url}`) || request.originalUrl.startsWith(`${relative_path}/${url}`)); + const baseUrl = isDisplayedAsHome ? '' : url; + + if (isDisplayedAsHome) { + data.title = meta.config.homePageTitle || '[[pages:home]]'; + } else { + data.title = `[[pages:${url}]]`; + data.breadcrumbs = helpers.buildBreadcrumbs([{text: `[[${url}:title]]`}]); + } + + data.canPost = canPost; + data.showSelect = isPrivileged; + data.showTopicTools = isPrivileged; + data.allCategoriesUrl = baseUrl + helpers.buildQueryString(request.query, 'cid', ''); + data.selectedCategory = categoryData.selectedCategory; + data.selectedCids = categoryData.selectedCids; + data['feeds:disableRSS'] = meta.config['feeds:disableRSS'] || 0; + data.rssFeedUrl = `${relative_path}/${url}.rss`; + if (request.loggedIn) { + data.rssFeedUrl += `?uid=${request.uid}&token=${rssToken}`; + } + + data.filters = helpers.buildFilters(baseUrl, filter, request.query); + data.selectedFilter = data.filters.find(filter => filter && filter.selected); + data.terms = helpers.buildTerms(baseUrl, term, request.query); + data.selectedTerm = data.terms.find(term => term && term.selected); + + const pageCount = Math.max(1, Math.ceil(data.topicCount / settings.topicsPerPage)); + data.pagination = pagination.create(page, pageCount, request.query); + helpers.addLinkTags({url, res: request.res, tags: data.pagination.rel}); + return data; }; async function canPostTopic(uid) { - let cids = await categories.getAllCidsFromSet('categories:cid'); - cids = await privileges.categories.filterCids('topics:create', cids, uid); - return cids.length > 0; + let cids = await categories.getAllCidsFromSet('categories:cid'); + cids = await privileges.categories.filterCids('topics:create', cids, uid); + return cids.length > 0; } require('../promisify')(recentController, ['get']); diff --git a/src/controllers/search.js b/src/controllers/search.js index 12d19aa..7d17431 100644 --- a/src/controllers/search.js +++ b/src/controllers/search.js @@ -2,7 +2,6 @@ 'use strict'; const validator = require('validator'); - const db = require('../database'); const meta = require('../meta'); const plugins = require('../plugins'); @@ -15,132 +14,135 @@ const helpers = require('./helpers'); const searchController = module.exports; -searchController.search = async function (req, res, next) { - if (!plugins.hooks.hasListeners('filter:search.query')) { - return next(); - } - const page = Math.max(1, parseInt(req.query.page, 10)) || 1; - - const searchOnly = parseInt(req.query.searchOnly, 10) === 1; - - const userPrivileges = await utils.promiseParallel({ - 'search:users': privileges.global.can('search:users', req.uid), - 'search:content': privileges.global.can('search:content', req.uid), - 'search:tags': privileges.global.can('search:tags', req.uid), - }); - req.query.in = req.query.in || meta.config.searchDefaultIn || 'titlesposts'; - let allowed = (req.query.in === 'users' && userPrivileges['search:users']) || - (req.query.in === 'tags' && userPrivileges['search:tags']) || - (req.query.in === 'categories') || - (['titles', 'titlesposts', 'posts'].includes(req.query.in) && userPrivileges['search:content']); - ({ allowed } = await plugins.hooks.fire('filter:search.isAllowed', { - uid: req.uid, - query: req.query, - allowed, - })); - if (!allowed) { - return helpers.notAllowed(req, res); - } - - if (req.query.categories && !Array.isArray(req.query.categories)) { - req.query.categories = [req.query.categories]; - } - if (req.query.hasTags && !Array.isArray(req.query.hasTags)) { - req.query.hasTags = [req.query.hasTags]; - } - - const data = { - query: req.query.term, - searchIn: req.query.in, - matchWords: req.query.matchWords || 'all', - postedBy: req.query.by, - categories: req.query.categories, - searchChildren: req.query.searchChildren, - hasTags: req.query.hasTags, - replies: req.query.replies, - repliesFilter: req.query.repliesFilter, - topicName: req.query.topicName, - timeRange: req.query.timeRange, - timeFilter: req.query.timeFilter, - sortBy: req.query.sortBy || meta.config.searchDefaultSortBy || '', - sortDirection: req.query.sortDirection, - page: page, - itemsPerPage: req.query.itemsPerPage, - uid: req.uid, - qs: req.query, - }; - - const [searchData, categoriesData] = await Promise.all([ - search.search(data), - buildCategories(req.uid, searchOnly), - recordSearch(data), - ]); - - searchData.pagination = pagination.create(page, searchData.pageCount, req.query); - searchData.multiplePages = searchData.pageCount > 1; - searchData.search_query = validator.escape(String(req.query.term || '')); - searchData.term = req.query.term; - - if (searchOnly) { - return res.json(searchData); - } - - searchData.allCategories = categoriesData; - searchData.allCategoriesCount = Math.max(10, Math.min(20, categoriesData.length)); - - searchData.breadcrumbs = helpers.buildBreadcrumbs([{ text: '[[global:search]]' }]); - searchData.expandSearch = !req.query.term; - - searchData.showAsPosts = !req.query.showAs || req.query.showAs === 'posts'; - searchData.showAsTopics = req.query.showAs === 'topics'; - searchData.title = '[[global:header.search]]'; - - searchData.searchDefaultSortBy = meta.config.searchDefaultSortBy || ''; - searchData.searchDefaultIn = meta.config.searchDefaultIn || 'titlesposts'; - searchData.privileges = userPrivileges; - - res.render('search', searchData); +searchController.search = async function (request, res, next) { + if (!plugins.hooks.hasListeners('filter:search.query')) { + return next(); + } + + const page = Math.max(1, Number.parseInt(request.query.page, 10)) || 1; + + const searchOnly = Number.parseInt(request.query.searchOnly, 10) === 1; + + const userPrivileges = await utils.promiseParallel({ + 'search:users': privileges.global.can('search:users', request.uid), + 'search:content': privileges.global.can('search:content', request.uid), + 'search:tags': privileges.global.can('search:tags', request.uid), + }); + request.query.in = request.query.in || meta.config.searchDefaultIn || 'titlesposts'; + let allowed = (request.query.in === 'users' && userPrivileges['search:users']) + || (request.query.in === 'tags' && userPrivileges['search:tags']) + || (request.query.in === 'categories') + || (['titles', 'titlesposts', 'posts'].includes(request.query.in) && userPrivileges['search:content']); + ({allowed} = await plugins.hooks.fire('filter:search.isAllowed', { + uid: request.uid, + query: request.query, + allowed, + })); + if (!allowed) { + return helpers.notAllowed(request, res); + } + + if (request.query.categories && !Array.isArray(request.query.categories)) { + request.query.categories = [request.query.categories]; + } + + if (request.query.hasTags && !Array.isArray(request.query.hasTags)) { + request.query.hasTags = [request.query.hasTags]; + } + + const data = { + query: request.query.term, + searchIn: request.query.in, + matchWords: request.query.matchWords || 'all', + postedBy: request.query.by, + categories: request.query.categories, + searchChildren: request.query.searchChildren, + hasTags: request.query.hasTags, + replies: request.query.replies, + repliesFilter: request.query.repliesFilter, + topicName: request.query.topicName, + timeRange: request.query.timeRange, + timeFilter: request.query.timeFilter, + sortBy: request.query.sortBy || meta.config.searchDefaultSortBy || '', + sortDirection: request.query.sortDirection, + page, + itemsPerPage: request.query.itemsPerPage, + uid: request.uid, + qs: request.query, + }; + + const [searchData, categoriesData] = await Promise.all([ + search.search(data), + buildCategories(request.uid, searchOnly), + recordSearch(data), + ]); + + searchData.pagination = pagination.create(page, searchData.pageCount, request.query); + searchData.multiplePages = searchData.pageCount > 1; + searchData.search_query = validator.escape(String(request.query.term || '')); + searchData.term = request.query.term; + + if (searchOnly) { + return res.json(searchData); + } + + searchData.allCategories = categoriesData; + searchData.allCategoriesCount = Math.max(10, Math.min(20, categoriesData.length)); + + searchData.breadcrumbs = helpers.buildBreadcrumbs([{text: '[[global:search]]'}]); + searchData.expandSearch = !request.query.term; + + searchData.showAsPosts = !request.query.showAs || request.query.showAs === 'posts'; + searchData.showAsTopics = request.query.showAs === 'topics'; + searchData.title = '[[global:header.search]]'; + + searchData.searchDefaultSortBy = meta.config.searchDefaultSortBy || ''; + searchData.searchDefaultIn = meta.config.searchDefaultIn || 'titlesposts'; + searchData.privileges = userPrivileges; + + res.render('search', searchData); }; const searches = {}; async function recordSearch(data) { - const { query, searchIn } = data; - if (query) { - const cleanedQuery = String(query).trim().toLowerCase().slice(0, 255); - if (['titles', 'titlesposts', 'posts'].includes(searchIn) && cleanedQuery.length > 2) { - searches[data.uid] = searches[data.uid] || { timeoutId: 0, queries: [] }; - searches[data.uid].queries.push(cleanedQuery); - if (searches[data.uid].timeoutId) { - clearTimeout(searches[data.uid].timeoutId); - } - searches[data.uid].timeoutId = setTimeout(async () => { - if (searches[data.uid] && searches[data.uid].queries) { - const copy = searches[data.uid].queries.slice(); - const filtered = searches[data.uid].queries.filter( - q => !copy.find(query => query.startsWith(q) && query.length > q.length) - ); - delete searches[data.uid]; - await Promise.all(filtered.map(query => db.sortedSetIncrBy('searches:all', 1, query))); - } - }, 5000); - } - } + const {query, searchIn} = data; + if (query) { + const cleanedQuery = String(query).trim().toLowerCase().slice(0, 255); + if (['titles', 'titlesposts', 'posts'].includes(searchIn) && cleanedQuery.length > 2) { + searches[data.uid] = searches[data.uid] || {timeoutId: 0, queries: []}; + searches[data.uid].queries.push(cleanedQuery); + if (searches[data.uid].timeoutId) { + clearTimeout(searches[data.uid].timeoutId); + } + + searches[data.uid].timeoutId = setTimeout(async () => { + if (searches[data.uid] && searches[data.uid].queries) { + const copy = searches[data.uid].queries.slice(); + const filtered = searches[data.uid].queries.filter( + q => !copy.find(query => query.startsWith(q) && query.length > q.length), + ); + delete searches[data.uid]; + await Promise.all(filtered.map(query => db.sortedSetIncrBy('searches:all', 1, query))); + } + }, 5000); + } + } } async function buildCategories(uid, searchOnly) { - if (searchOnly) { - return []; - } - - const cids = await categories.getCidsByPrivilege('categories:cid', uid, 'read'); - let categoriesData = await categories.getCategoriesData(cids); - categoriesData = categoriesData.filter(category => category && !category.link); - categoriesData = categories.getTree(categoriesData); - categoriesData = categories.buildForSelectCategories(categoriesData, ['text', 'value']); - - return [ - { value: 'all', text: '[[unread:all_categories]]' }, - { value: 'watched', text: '[[category:watched-categories]]' }, - ].concat(categoriesData); + if (searchOnly) { + return []; + } + + const cids = await categories.getCidsByPrivilege('categories:cid', uid, 'read'); + let categoriesData = await categories.getCategoriesData(cids); + categoriesData = categoriesData.filter(category => category && !category.link); + categoriesData = categories.getTree(categoriesData); + categoriesData = categories.buildForSelectCategories(categoriesData, ['text', 'value']); + + return [ + {value: 'all', text: '[[unread:all_categories]]'}, + {value: 'watched', text: '[[category:watched-categories]]'}, + ].concat(categoriesData); } diff --git a/src/controllers/sitemap.js b/src/controllers/sitemap.js index a3d3878..74cc624 100644 --- a/src/controllers/sitemap.js +++ b/src/controllers/sitemap.js @@ -5,36 +5,39 @@ const meta = require('../meta'); const sitemapController = module.exports; -sitemapController.render = async function (req, res, next) { - if (meta.config['feeds:disableSitemap']) { - return setImmediate(next); - } - const tplData = await sitemap.render(); - const xml = await req.app.renderAsync('sitemap', tplData); - res.header('Content-Type', 'application/xml'); - res.send(xml); +sitemapController.render = async function (request, res, next) { + if (meta.config['feeds:disableSitemap']) { + return setImmediate(next); + } + + const tplData = await sitemap.render(); + const xml = await request.app.renderAsync('sitemap', tplData); + res.header('Content-Type', 'application/xml'); + res.send(xml); }; -sitemapController.getPages = function (req, res, next) { - sendSitemap(sitemap.getPages, res, next); +sitemapController.getPages = function (request, res, next) { + sendSitemap(sitemap.getPages, res, next); }; -sitemapController.getCategories = function (req, res, next) { - sendSitemap(sitemap.getCategories, res, next); +sitemapController.getCategories = function (request, res, next) { + sendSitemap(sitemap.getCategories, res, next); }; -sitemapController.getTopicPage = function (req, res, next) { - sendSitemap(async () => await sitemap.getTopicPage(parseInt(req.params[0], 10)), res, next); +sitemapController.getTopicPage = function (request, res, next) { + sendSitemap(async () => await sitemap.getTopicPage(Number.parseInt(request.params[0], 10)), res, next); }; async function sendSitemap(method, res, callback) { - if (meta.config['feeds:disableSitemap']) { - return setImmediate(callback); - } - const xml = await method(); - if (!xml) { - return callback(); - } - res.header('Content-Type', 'application/xml'); - res.send(xml); + if (meta.config['feeds:disableSitemap']) { + return setImmediate(callback); + } + + const xml = await method(); + if (!xml) { + return callback(); + } + + res.header('Content-Type', 'application/xml'); + res.send(xml); } diff --git a/src/controllers/tags.js b/src/controllers/tags.js index 45195eb..911f2ac 100644 --- a/src/controllers/tags.js +++ b/src/controllers/tags.js @@ -2,7 +2,6 @@ const validator = require('validator'); const nconf = require('nconf'); - const meta = require('../meta'); const user = require('../user'); const categories = require('../categories'); @@ -14,70 +13,70 @@ const helpers = require('./helpers'); const tagsController = module.exports; -tagsController.getTag = async function (req, res) { - const tag = validator.escape(utils.cleanUpTag(req.params.tag, meta.config.maximumTagLength)); - const page = parseInt(req.query.page, 10) || 1; - const cid = Array.isArray(req.query.cid) || !req.query.cid ? req.query.cid : [req.query.cid]; +tagsController.getTag = async function (request, res) { + const tag = validator.escape(utils.cleanUpTag(request.params.tag, meta.config.maximumTagLength)); + const page = Number.parseInt(request.query.page, 10) || 1; + const cid = Array.isArray(request.query.cid) || !request.query.cid ? request.query.cid : [request.query.cid]; - const templateData = { - topics: [], - tag: tag, - breadcrumbs: helpers.buildBreadcrumbs([{ text: '[[tags:tags]]', url: '/tags' }, { text: tag }]), - title: `[[pages:tag, ${tag}]]`, - }; - const [settings, cids, categoryData, isPrivileged] = await Promise.all([ - user.getSettings(req.uid), - cid || categories.getCidsByPrivilege('categories:cid', req.uid, 'topics:read'), - helpers.getSelectedCategory(cid), - user.isPrivileged(req.uid), - ]); - const start = Math.max(0, (page - 1) * settings.topicsPerPage); - const stop = start + settings.topicsPerPage - 1; + const templateData = { + topics: [], + tag, + breadcrumbs: helpers.buildBreadcrumbs([{text: '[[tags:tags]]', url: '/tags'}, {text: tag}]), + title: `[[pages:tag, ${tag}]]`, + }; + const [settings, cids, categoryData, isPrivileged] = await Promise.all([ + user.getSettings(request.uid), + cid || categories.getCidsByPrivilege('categories:cid', request.uid, 'topics:read'), + helpers.getSelectedCategory(cid), + user.isPrivileged(request.uid), + ]); + const start = Math.max(0, (page - 1) * settings.topicsPerPage); + const stop = start + settings.topicsPerPage - 1; - const [topicCount, tids] = await Promise.all([ - topics.getTagTopicCount(tag, cids), - topics.getTagTidsByCids(tag, cids, start, stop), - ]); + const [topicCount, tids] = await Promise.all([ + topics.getTagTopicCount(tag, cids), + topics.getTagTidsByCids(tag, cids, start, stop), + ]); - templateData.topics = await topics.getTopics(tids, req.uid); - templateData.showSelect = isPrivileged; - templateData.showTopicTools = isPrivileged; - templateData.allCategoriesUrl = `tags/${tag}${helpers.buildQueryString(req.query, 'cid', '')}`; - templateData.selectedCategory = categoryData.selectedCategory; - templateData.selectedCids = categoryData.selectedCids; - topics.calculateTopicIndices(templateData.topics, start); - res.locals.metaTags = [ - { - name: 'title', - content: tag, - }, - { - property: 'og:title', - content: tag, - }, - ]; + templateData.topics = await topics.getTopics(tids, request.uid); + templateData.showSelect = isPrivileged; + templateData.showTopicTools = isPrivileged; + templateData.allCategoriesUrl = `tags/${tag}${helpers.buildQueryString(request.query, 'cid', '')}`; + templateData.selectedCategory = categoryData.selectedCategory; + templateData.selectedCids = categoryData.selectedCids; + topics.calculateTopicIndices(templateData.topics, start); + res.locals.metaTags = [ + { + name: 'title', + content: tag, + }, + { + property: 'og:title', + content: tag, + }, + ]; - const pageCount = Math.max(1, Math.ceil(topicCount / settings.topicsPerPage)); - templateData.pagination = pagination.create(page, pageCount, req.query); - helpers.addLinkTags({ url: `tags/${tag}`, res: req.res, tags: templateData.pagination.rel }); + const pageCount = Math.max(1, Math.ceil(topicCount / settings.topicsPerPage)); + templateData.pagination = pagination.create(page, pageCount, request.query); + helpers.addLinkTags({url: `tags/${tag}`, res: request.res, tags: templateData.pagination.rel}); - templateData['feeds:disableRSS'] = meta.config['feeds:disableRSS']; - templateData.rssFeedUrl = `${nconf.get('relative_path')}/tags/${tag}.rss`; - res.render('tag', templateData); + templateData['feeds:disableRSS'] = meta.config['feeds:disableRSS']; + templateData.rssFeedUrl = `${nconf.get('relative_path')}/tags/${tag}.rss`; + res.render('tag', templateData); }; -tagsController.getTags = async function (req, res) { - const cids = await categories.getCidsByPrivilege('categories:cid', req.uid, 'topics:read'); - const [canSearch, tags] = await Promise.all([ - privileges.global.can('search:tags', req.uid), - topics.getCategoryTagsData(cids, 0, 99), - ]); +tagsController.getTags = async function (request, res) { + const cids = await categories.getCidsByPrivilege('categories:cid', request.uid, 'topics:read'); + const [canSearch, tags] = await Promise.all([ + privileges.global.can('search:tags', request.uid), + topics.getCategoryTagsData(cids, 0, 99), + ]); - res.render('tags', { - tags: tags.filter(Boolean), - displayTagSearch: canSearch, - nextStart: 100, - breadcrumbs: helpers.buildBreadcrumbs([{ text: '[[tags:tags]]' }]), - title: '[[pages:tags]]', - }); + res.render('tags', { + tags: tags.filter(Boolean), + displayTagSearch: canSearch, + nextStart: 100, + breadcrumbs: helpers.buildBreadcrumbs([{text: '[[tags:tags]]'}]), + title: '[[pages:tags]]', + }); }; diff --git a/src/controllers/top.js b/src/controllers/top.js index 799630e..bba816a 100644 --- a/src/controllers/top.js +++ b/src/controllers/top.js @@ -3,26 +3,27 @@ const nconf = require('nconf'); const validator = require('validator'); - const helpers = require('./helpers'); const recentController = require('./recent'); const topController = module.exports; -topController.get = async function (req, res, next) { - const data = await recentController.getData(req, 'top', 'votes'); - if (!data) { - return next(); - } - const term = helpers.terms[req.query.term] || 'alltime'; - if (req.originalUrl.startsWith(`${nconf.get('relative_path')}/api/top`) || req.originalUrl.startsWith(`${nconf.get('relative_path')}/top`)) { - data.title = `[[pages:top-${term}]]`; - } +topController.get = async function (request, res, next) { + const data = await recentController.getData(request, 'top', 'votes'); + if (!data) { + return next(); + } + + const term = helpers.terms[request.query.term] || 'alltime'; + if (request.originalUrl.startsWith(`${nconf.get('relative_path')}/api/top`) || request.originalUrl.startsWith(`${nconf.get('relative_path')}/top`)) { + data.title = `[[pages:top-${term}]]`; + } + + const feedQs = data.rssFeedUrl.split('?')[1]; + data.rssFeedUrl = `${nconf.get('relative_path')}/top/${validator.escape(String(request.query.term || 'alltime'))}.rss`; + if (request.loggedIn) { + data.rssFeedUrl += `?${feedQs}`; + } - const feedQs = data.rssFeedUrl.split('?')[1]; - data.rssFeedUrl = `${nconf.get('relative_path')}/top/${validator.escape(String(req.query.term || 'alltime'))}.rss`; - if (req.loggedIn) { - data.rssFeedUrl += `?${feedQs}`; - } - res.render('top', data); + res.render('top', data); }; diff --git a/src/controllers/topics.js b/src/controllers/topics.js index 886662a..5b2d7ee 100644 --- a/src/controllers/topics.js +++ b/src/controllers/topics.js @@ -1,21 +1,19 @@ 'use strict'; // For JS requirement -const assert = require('assert'); - +const assert = require('node:assert'); +const qs = require('node:querystring'); const nconf = require('nconf'); -const qs = require('querystring'); - const user = require('../user'); const meta = require('../meta'); const topics = require('../topics'); const categories = require('../categories'); const posts = require('../posts'); const privileges = require('../privileges'); -const helpers = require('./helpers'); const pagination = require('../pagination'); const utils = require('../utils'); const analytics = require('../analytics'); +const helpers = require('./helpers'); const topicsController = module.exports; @@ -23,364 +21,379 @@ const url = nconf.get('url'); const relative_path = nconf.get('relative_path'); const upload_url = nconf.get('upload_url'); -topicsController.get = async function getTopic(req, res, next) { - const tid = req.params.topic_id; - - if ( - (req.params.post_index && !utils.isNumber(req.params.post_index) && req.params.post_index !== 'unread') || - !utils.isNumber(tid) - ) { - return next(); - } - let postIndex = parseInt(req.params.post_index, 10) || 1; - const [ - userPrivileges, - settings, - topicData, - rssToken, - ] = await Promise.all([ - privileges.topics.get(tid, req.uid), - user.getSettings(req.uid), - topics.getTopicData(tid), - user.auth.getFeedToken(req.uid), - ]); - - let currentPage = parseInt(req.query.page, 10) || 1; - const pageCount = Math.max(1, Math.ceil((topicData && topicData.postcount) / settings.postsPerPage)); - const invalidPagination = (settings.usePagination && (currentPage < 1 || currentPage > pageCount)); - if ( - !topicData || - userPrivileges.disabled || - invalidPagination || - (topicData.scheduled && !userPrivileges.view_scheduled) - ) { - return next(); - } - - if (!userPrivileges['topics:read'] || (!topicData.scheduled && topicData.deleted && !userPrivileges.view_deleted)) { - return helpers.notAllowed(req, res); - } - - if (req.params.post_index === 'unread') { - postIndex = await topics.getUserBookmark(tid, req.uid); - } - - if (!res.locals.isAPI && (!req.params.slug || topicData.slug !== `${tid}/${req.params.slug}`) && (topicData.slug && topicData.slug !== `${tid}/`)) { - return helpers.redirect(res, `/topic/${topicData.slug}${postIndex ? `/${postIndex}` : ''}${generateQueryString(req.query)}`, true); - } - - if (utils.isNumber(postIndex) && topicData.postcount > 0 && (postIndex < 1 || postIndex > topicData.postcount)) { - return helpers.redirect(res, `/topic/${tid}/${req.params.slug}${postIndex > topicData.postcount ? `/${topicData.postcount}` : ''}${generateQueryString(req.query)}`); - } - postIndex = Math.max(1, postIndex); - const sort = req.query.sort || settings.topicPostSort; - const set = sort === 'most_votes' ? `tid:${tid}:posts:votes` : `tid:${tid}:posts`; - const reverse = sort === 'newest_to_oldest' || sort === 'most_votes'; - if (settings.usePagination && !req.query.page) { - currentPage = calculatePageFromIndex(postIndex, settings); - } - const { start, stop } = calculateStartStop(currentPage, postIndex, settings); - - await topics.getTopicWithPosts(topicData, set, req.uid, start, stop, reverse); - - if (currentPage !== 1) { - // Pinned posts should only appear on the first page. - topicData.pinnedPosts = []; - } - - topics.modifyPostsByPrivilege(topicData, userPrivileges); - topicData.tagWhitelist = categories.filterTagWhitelist(topicData.tagWhitelist, userPrivileges.isAdminOrMod); - - topicData.privileges = userPrivileges; - topicData.topicStaleDays = meta.config.topicStaleDays; - topicData['reputation:disabled'] = meta.config['reputation:disabled']; - topicData['downvote:disabled'] = meta.config['downvote:disabled']; - topicData['feeds:disableRSS'] = meta.config['feeds:disableRSS'] || 0; - topicData['signatures:hideDuplicates'] = meta.config['signatures:hideDuplicates']; - topicData.bookmarkThreshold = meta.config.bookmarkThreshold; - topicData.necroThreshold = meta.config.necroThreshold; - topicData.postEditDuration = meta.config.postEditDuration; - topicData.postDeleteDuration = meta.config.postDeleteDuration; - topicData.scrollToMyPost = settings.scrollToMyPost; - topicData.updateUrlWithPostIndex = settings.updateUrlWithPostIndex; - topicData.allowMultipleBadges = meta.config.allowMultipleBadges === 1; - topicData.privateUploads = meta.config.privateUploads === 1; - topicData.showPostPreviewsOnHover = meta.config.showPostPreviewsOnHover === 1; - topicData.rssFeedUrl = `${relative_path}/topic/${topicData.tid}.rss`; - if (req.loggedIn) { - topicData.rssFeedUrl += `?uid=${req.uid}&token=${rssToken}`; - } - - topicData.postIndex = postIndex; - - await Promise.all([ - buildBreadcrumbs(topicData), - addOldCategory(topicData, userPrivileges), - addTags(topicData, req, res), - incrementViewCount(req, tid), - markAsRead(req, tid), - analytics.increment([`pageviews:byCid:${topicData.category.cid}`]), - ]); - - topicData.pagination = pagination.create(currentPage, pageCount, req.query); - topicData.pagination.rel.forEach((rel) => { - rel.href = `${url}/topic/${topicData.slug}${rel.href}`; - res.locals.linkTags.push(rel); - }); - - // Ensure that pinned posts are added as a list to the result in some form - assert(topicData.hasOwnProperty('pinnedPosts'), 'topicData does not have a pinned posts field'); - assert(typeof (topicData.pinnedPosts) === typeof ([])); - - res.render('topic', topicData); +topicsController.get = async function getTopic(request, res, next) { + const tid = request.params.topic_id; + + if ( + (request.params.post_index && !utils.isNumber(request.params.post_index) && request.params.post_index !== 'unread') + || !utils.isNumber(tid) + ) { + return next(); + } + + let postIndex = Number.parseInt(request.params.post_index, 10) || 1; + const [ + userPrivileges, + settings, + topicData, + rssToken, + ] = await Promise.all([ + privileges.topics.get(tid, request.uid), + user.getSettings(request.uid), + topics.getTopicData(tid), + user.auth.getFeedToken(request.uid), + ]); + + let currentPage = Number.parseInt(request.query.page, 10) || 1; + const pageCount = Math.max(1, Math.ceil((topicData && topicData.postcount) / settings.postsPerPage)); + const invalidPagination = (settings.usePagination && (currentPage < 1 || currentPage > pageCount)); + if ( + !topicData + || userPrivileges.disabled + || invalidPagination + || (topicData.scheduled && !userPrivileges.view_scheduled) + ) { + return next(); + } + + if (!userPrivileges['topics:read'] || (!topicData.scheduled && topicData.deleted && !userPrivileges.view_deleted)) { + return helpers.notAllowed(request, res); + } + + if (request.params.post_index === 'unread') { + postIndex = await topics.getUserBookmark(tid, request.uid); + } + + if (!res.locals.isAPI && (!request.params.slug || topicData.slug !== `${tid}/${request.params.slug}`) && (topicData.slug && topicData.slug !== `${tid}/`)) { + return helpers.redirect(res, `/topic/${topicData.slug}${postIndex ? `/${postIndex}` : ''}${generateQueryString(request.query)}`, true); + } + + if (utils.isNumber(postIndex) && topicData.postcount > 0 && (postIndex < 1 || postIndex > topicData.postcount)) { + return helpers.redirect(res, `/topic/${tid}/${request.params.slug}${postIndex > topicData.postcount ? `/${topicData.postcount}` : ''}${generateQueryString(request.query)}`); + } + + postIndex = Math.max(1, postIndex); + const sort = request.query.sort || settings.topicPostSort; + const set = sort === 'most_votes' ? `tid:${tid}:posts:votes` : `tid:${tid}:posts`; + const reverse = sort === 'newest_to_oldest' || sort === 'most_votes'; + if (settings.usePagination && !request.query.page) { + currentPage = calculatePageFromIndex(postIndex, settings); + } + + const {start, stop} = calculateStartStop(currentPage, postIndex, settings); + + await topics.getTopicWithPosts(topicData, set, request.uid, start, stop, reverse); + + if (currentPage !== 1) { + // Pinned posts should only appear on the first page. + topicData.pinnedPosts = []; + } + + topics.modifyPostsByPrivilege(topicData, userPrivileges); + topicData.tagWhitelist = categories.filterTagWhitelist(topicData.tagWhitelist, userPrivileges.isAdminOrMod); + + topicData.privileges = userPrivileges; + topicData.topicStaleDays = meta.config.topicStaleDays; + topicData['reputation:disabled'] = meta.config['reputation:disabled']; + topicData['downvote:disabled'] = meta.config['downvote:disabled']; + topicData['feeds:disableRSS'] = meta.config['feeds:disableRSS'] || 0; + topicData['signatures:hideDuplicates'] = meta.config['signatures:hideDuplicates']; + topicData.bookmarkThreshold = meta.config.bookmarkThreshold; + topicData.necroThreshold = meta.config.necroThreshold; + topicData.postEditDuration = meta.config.postEditDuration; + topicData.postDeleteDuration = meta.config.postDeleteDuration; + topicData.scrollToMyPost = settings.scrollToMyPost; + topicData.updateUrlWithPostIndex = settings.updateUrlWithPostIndex; + topicData.allowMultipleBadges = meta.config.allowMultipleBadges === 1; + topicData.privateUploads = meta.config.privateUploads === 1; + topicData.showPostPreviewsOnHover = meta.config.showPostPreviewsOnHover === 1; + topicData.rssFeedUrl = `${relative_path}/topic/${topicData.tid}.rss`; + if (request.loggedIn) { + topicData.rssFeedUrl += `?uid=${request.uid}&token=${rssToken}`; + } + + topicData.postIndex = postIndex; + + await Promise.all([ + buildBreadcrumbs(topicData), + addOldCategory(topicData, userPrivileges), + addTags(topicData, request, res), + incrementViewCount(request, tid), + markAsRead(request, tid), + analytics.increment([`pageviews:byCid:${topicData.category.cid}`]), + ]); + + topicData.pagination = pagination.create(currentPage, pageCount, request.query); + for (const rel of topicData.pagination.rel) { + rel.href = `${url}/topic/${topicData.slug}${rel.href}`; + res.locals.linkTags.push(rel); + } + + // Ensure that pinned posts are added as a list to the result in some form + assert(topicData.hasOwnProperty('pinnedPosts'), 'topicData does not have a pinned posts field'); + assert(typeof (topicData.pinnedPosts) === typeof ([])); + + res.render('topic', topicData); }; function generateQueryString(query) { - const qString = qs.stringify(query); - return qString.length ? `?${qString}` : ''; + const qString = qs.stringify(query); + return qString.length > 0 ? `?${qString}` : ''; } function calculatePageFromIndex(postIndex, settings) { - return 1 + Math.floor((postIndex - 1) / settings.postsPerPage); + return 1 + Math.floor((postIndex - 1) / settings.postsPerPage); } function calculateStartStop(page, postIndex, settings) { - let startSkip = 0; - - if (!settings.usePagination) { - if (postIndex > 1) { - page = 1; - } - startSkip = Math.max(0, postIndex - Math.ceil(settings.postsPerPage / 2)); - } - - const start = ((page - 1) * settings.postsPerPage) + startSkip; - const stop = start + settings.postsPerPage - 1; - return { start: Math.max(0, start), stop: Math.max(0, stop) }; + let startSkip = 0; + + if (!settings.usePagination) { + if (postIndex > 1) { + page = 1; + } + + startSkip = Math.max(0, postIndex - Math.ceil(settings.postsPerPage / 2)); + } + + const start = ((page - 1) * settings.postsPerPage) + startSkip; + const stop = start + settings.postsPerPage - 1; + return {start: Math.max(0, start), stop: Math.max(0, stop)}; } -async function incrementViewCount(req, tid) { - const allow = req.uid > 0 || (meta.config.guestsIncrementTopicViews && req.uid === 0); - if (allow) { - req.session.tids_viewed = req.session.tids_viewed || {}; - const now = Date.now(); - const interval = meta.config.incrementTopicViewsInterval * 60000; - if (!req.session.tids_viewed[tid] || req.session.tids_viewed[tid] < now - interval) { - await topics.increaseViewCount(tid); - req.session.tids_viewed[tid] = now; - } - } +async function incrementViewCount(request, tid) { + const allow = request.uid > 0 || (meta.config.guestsIncrementTopicViews && request.uid === 0); + if (allow) { + request.session.tids_viewed = request.session.tids_viewed || {}; + const now = Date.now(); + const interval = meta.config.incrementTopicViewsInterval * 60_000; + if (!request.session.tids_viewed[tid] || request.session.tids_viewed[tid] < now - interval) { + await topics.increaseViewCount(tid); + request.session.tids_viewed[tid] = now; + } + } } -async function markAsRead(req, tid) { - if (req.loggedIn) { - const markedRead = await topics.markAsRead([tid], req.uid); - const promises = [topics.markTopicNotificationsRead([tid], req.uid)]; - if (markedRead) { - promises.push(topics.pushUnreadCount(req.uid)); - } - await Promise.all(promises); - } +async function markAsRead(request, tid) { + if (request.loggedIn) { + const markedRead = await topics.markAsRead([tid], request.uid); + const promises = [topics.markTopicNotificationsRead([tid], request.uid)]; + if (markedRead) { + promises.push(topics.pushUnreadCount(request.uid)); + } + + await Promise.all(promises); + } } async function buildBreadcrumbs(topicData) { - const breadcrumbs = [ - { - text: topicData.category.name, - url: `${relative_path}/category/${topicData.category.slug}`, - cid: topicData.category.cid, - }, - { - text: topicData.title, - }, - ]; - const parentCrumbs = await helpers.buildCategoryBreadcrumbs(topicData.category.parentCid); - topicData.breadcrumbs = parentCrumbs.concat(breadcrumbs); + const breadcrumbs = [ + { + text: topicData.category.name, + url: `${relative_path}/category/${topicData.category.slug}`, + cid: topicData.category.cid, + }, + { + text: topicData.title, + }, + ]; + const parentCrumbs = await helpers.buildCategoryBreadcrumbs(topicData.category.parentCid); + topicData.breadcrumbs = parentCrumbs.concat(breadcrumbs); } async function addOldCategory(topicData, userPrivileges) { - if (userPrivileges.isAdminOrMod && topicData.oldCid) { - topicData.oldCategory = await categories.getCategoryFields( - topicData.oldCid, ['cid', 'name', 'icon', 'bgColor', 'color', 'slug'] - ); - } + if (userPrivileges.isAdminOrMod && topicData.oldCid) { + topicData.oldCategory = await categories.getCategoryFields( + topicData.oldCid, ['cid', 'name', 'icon', 'bgColor', 'color', 'slug'], + ); + } } -async function addTags(topicData, req, res) { - const postIndex = parseInt(req.params.post_index, 10) || 0; - const postAtIndex = topicData.posts.find(p => parseInt(p.index, 10) === parseInt(Math.max(0, postIndex - 1), 10)); - let description = ''; - if (postAtIndex && postAtIndex.content) { - description = utils.stripHTMLTags(utils.decodeHTMLEntities(postAtIndex.content)); - } - - if (description.length > 255) { - description = `${description.slice(0, 255)}...`; - } - description = description.replace(/\n/g, ' '); - - res.locals.metaTags = [ - { - name: 'title', - content: topicData.titleRaw, - }, - { - name: 'description', - content: description, - }, - { - property: 'og:title', - content: topicData.titleRaw, - }, - { - property: 'og:description', - content: description, - }, - { - property: 'og:type', - content: 'article', - }, - { - property: 'article:published_time', - content: utils.toISOString(topicData.timestamp), - }, - { - property: 'article:modified_time', - content: utils.toISOString(topicData.lastposttime), - }, - { - property: 'article:section', - content: topicData.category ? topicData.category.name : '', - }, - ]; - - await addOGImageTags(res, topicData, postAtIndex); - - res.locals.linkTags = [ - { - rel: 'canonical', - href: `${url}/topic/${topicData.slug}`, - }, - ]; - - if (!topicData['feeds:disableRSS']) { - res.locals.linkTags.push({ - rel: 'alternate', - type: 'application/rss+xml', - href: topicData.rssFeedUrl, - }); - } - - if (topicData.category) { - res.locals.linkTags.push({ - rel: 'up', - href: `${url}/category/${topicData.category.slug}`, - }); - } +async function addTags(topicData, request, res) { + const postIndex = Number.parseInt(request.params.post_index, 10) || 0; + const postAtIndex = topicData.posts.find(p => Number.parseInt(p.index, 10) === Number.parseInt(Math.max(0, postIndex - 1), 10)); + let description = ''; + if (postAtIndex && postAtIndex.content) { + description = utils.stripHTMLTags(utils.decodeHTMLEntities(postAtIndex.content)); + } + + if (description.length > 255) { + description = `${description.slice(0, 255)}...`; + } + + description = description.replaceAll('\n', ' '); + + res.locals.metaTags = [ + { + name: 'title', + content: topicData.titleRaw, + }, + { + name: 'description', + content: description, + }, + { + property: 'og:title', + content: topicData.titleRaw, + }, + { + property: 'og:description', + content: description, + }, + { + property: 'og:type', + content: 'article', + }, + { + property: 'article:published_time', + content: utils.toISOString(topicData.timestamp), + }, + { + property: 'article:modified_time', + content: utils.toISOString(topicData.lastposttime), + }, + { + property: 'article:section', + content: topicData.category ? topicData.category.name : '', + }, + ]; + + await addOGImageTags(res, topicData, postAtIndex); + + res.locals.linkTags = [ + { + rel: 'canonical', + href: `${url}/topic/${topicData.slug}`, + }, + ]; + + if (!topicData['feeds:disableRSS']) { + res.locals.linkTags.push({ + rel: 'alternate', + type: 'application/rss+xml', + href: topicData.rssFeedUrl, + }); + } + + if (topicData.category) { + res.locals.linkTags.push({ + rel: 'up', + href: `${url}/category/${topicData.category.slug}`, + }); + } } async function addOGImageTags(res, topicData, postAtIndex) { - const uploads = postAtIndex ? await posts.uploads.listWithSizes(postAtIndex.pid) : []; - const images = uploads.map((upload) => { - upload.name = `${url + upload_url}/${upload.name}`; - return upload; - }); - if (topicData.thumbs) { - const path = require('path'); - const thumbs = topicData.thumbs.filter( - t => t && images.every(img => path.normalize(img.name) !== path.normalize(url + t.url)) - ); - images.push(...thumbs.map(thumbObj => ({ name: url + thumbObj.url }))); - } - if (topicData.category.backgroundImage && (!postAtIndex || !postAtIndex.index)) { - images.push(topicData.category.backgroundImage); - } - if (postAtIndex && postAtIndex.user && postAtIndex.user.picture) { - images.push(postAtIndex.user.picture); - } - images.forEach(path => addOGImageTag(res, path)); + const uploads = postAtIndex ? await posts.uploads.listWithSizes(postAtIndex.pid) : []; + const images = uploads.map(upload => { + upload.name = `${url + upload_url}/${upload.name}`; + return upload; + }); + if (topicData.thumbs) { + const path = require('node:path'); + const thumbs = topicData.thumbs.filter( + t => t && images.every(img => path.normalize(img.name) !== path.normalize(url + t.url)), + ); + images.push(...thumbs.map(thumbObject => ({name: url + thumbObject.url}))); + } + + if (topicData.category.backgroundImage && (!postAtIndex || !postAtIndex.index)) { + images.push(topicData.category.backgroundImage); + } + + if (postAtIndex && postAtIndex.user && postAtIndex.user.picture) { + images.push(postAtIndex.user.picture); + } + + for (const path of images) { + addOGImageTag(res, path); + } } function addOGImageTag(res, image) { - let imageUrl; - if (typeof image === 'string' && !image.startsWith('http')) { - imageUrl = url + image.replace(new RegExp(`^${relative_path}`), ''); - } else if (typeof image === 'object') { - imageUrl = image.name; - } else { - imageUrl = image; - } - - res.locals.metaTags.push({ - property: 'og:image', - content: imageUrl, - noEscape: true, - }, { - property: 'og:image:url', - content: imageUrl, - noEscape: true, - }); - - if (typeof image === 'object' && image.width && image.height) { - res.locals.metaTags.push({ - property: 'og:image:width', - content: String(image.width), - }, { - property: 'og:image:height', - content: String(image.height), - }); - } + let imageUrl; + if (typeof image === 'string' && !image.startsWith('http')) { + imageUrl = url + image.replace(new RegExp(`^${relative_path}`), ''); + } else if (typeof image === 'object') { + imageUrl = image.name; + } else { + imageUrl = image; + } + + res.locals.metaTags.push({ + property: 'og:image', + content: imageUrl, + noEscape: true, + }, { + property: 'og:image:url', + content: imageUrl, + noEscape: true, + }); + + if (typeof image === 'object' && image.width && image.height) { + res.locals.metaTags.push({ + property: 'og:image:width', + content: String(image.width), + }, { + property: 'og:image:height', + content: String(image.height), + }); + } } -topicsController.teaser = async function (req, res, next) { - const tid = req.params.topic_id; - if (!utils.isNumber(tid)) { - return next(); - } - const canRead = await privileges.topics.can('topics:read', tid, req.uid); - if (!canRead) { - return res.status(403).json('[[error:no-privileges]]'); - } - const pid = await topics.getLatestUndeletedPid(tid); - if (!pid) { - return res.status(404).json('not-found'); - } - const postData = await posts.getPostSummaryByPids([pid], req.uid, { stripTags: false }); - if (!postData.length) { - return res.status(404).json('not-found'); - } - res.json(postData[0]); +topicsController.teaser = async function (request, res, next) { + const tid = request.params.topic_id; + if (!utils.isNumber(tid)) { + return next(); + } + + const canRead = await privileges.topics.can('topics:read', tid, request.uid); + if (!canRead) { + return res.status(403).json('[[error:no-privileges]]'); + } + + const pid = await topics.getLatestUndeletedPid(tid); + if (!pid) { + return res.status(404).json('not-found'); + } + + const postData = await posts.getPostSummaryByPids([pid], request.uid, {stripTags: false}); + if (postData.length === 0) { + return res.status(404).json('not-found'); + } + + res.json(postData[0]); }; -topicsController.pagination = async function (req, res, next) { - const tid = req.params.topic_id; - const currentPage = parseInt(req.query.page, 10) || 1; +topicsController.pagination = async function (request, res, next) { + const tid = request.params.topic_id; + const currentPage = Number.parseInt(request.query.page, 10) || 1; - if (!utils.isNumber(tid)) { - return next(); - } + if (!utils.isNumber(tid)) { + return next(); + } - const [userPrivileges, settings, topic] = await Promise.all([ - privileges.topics.get(tid, req.uid), - user.getSettings(req.uid), - topics.getTopicData(tid), - ]); + const [userPrivileges, settings, topic] = await Promise.all([ + privileges.topics.get(tid, request.uid), + user.getSettings(request.uid), + topics.getTopicData(tid), + ]); - if (!topic) { - return next(); - } + if (!topic) { + return next(); + } - if (!userPrivileges.read || !privileges.topics.canViewDeletedScheduled(topic, userPrivileges)) { - return helpers.notAllowed(req, res); - } + if (!userPrivileges.read || !privileges.topics.canViewDeletedScheduled(topic, userPrivileges)) { + return helpers.notAllowed(request, res); + } - const postCount = topic.postcount; - const pageCount = Math.max(1, Math.ceil(postCount / settings.postsPerPage)); + const postCount = topic.postcount; + const pageCount = Math.max(1, Math.ceil(postCount / settings.postsPerPage)); - const paginationData = pagination.create(currentPage, pageCount); - paginationData.rel.forEach((rel) => { - rel.href = `${url}/topic/${topic.slug}${rel.href}`; - }); + const paginationData = pagination.create(currentPage, pageCount); + for (const rel of paginationData.rel) { + rel.href = `${url}/topic/${topic.slug}${rel.href}`; + } - res.json({ pagination: paginationData }); + res.json({pagination: paginationData}); }; diff --git a/src/controllers/unread.js b/src/controllers/unread.js index 66601ca..929e5b0 100644 --- a/src/controllers/unread.js +++ b/src/controllers/unread.js @@ -1,9 +1,8 @@ 'use strict'; +const querystring = require('node:querystring'); const nconf = require('nconf'); -const querystring = require('querystring'); - const meta = require('../meta'); const pagination = require('../pagination'); const user = require('../user'); @@ -13,66 +12,67 @@ const helpers = require('./helpers'); const unreadController = module.exports; const relative_path = nconf.get('relative_path'); -unreadController.get = async function (req, res) { - const { cid } = req.query; - const filter = req.query.filter || ''; +unreadController.get = async function (request, res) { + const {cid} = request.query; + const filter = request.query.filter || ''; + + const [categoryData, userSettings, isPrivileged] = await Promise.all([ + helpers.getSelectedCategory(cid), + user.getSettings(request.uid), + user.isPrivileged(request.uid), + ]); - const [categoryData, userSettings, isPrivileged] = await Promise.all([ - helpers.getSelectedCategory(cid), - user.getSettings(req.uid), - user.isPrivileged(req.uid), - ]); + const page = Number.parseInt(request.query.page, 10) || 1; + const start = Math.max(0, (page - 1) * userSettings.topicsPerPage); + const stop = start + userSettings.topicsPerPage - 1; + const data = await topics.getUnreadTopics({ + cid, + uid: request.uid, + start, + stop, + filter, + query: request.query, + }); - const page = parseInt(req.query.page, 10) || 1; - const start = Math.max(0, (page - 1) * userSettings.topicsPerPage); - const stop = start + userSettings.topicsPerPage - 1; - const data = await topics.getUnreadTopics({ - cid: cid, - uid: req.uid, - start: start, - stop: stop, - filter: filter, - query: req.query, - }); + const isDisplayedAsHome = !(request.originalUrl.startsWith(`${relative_path}/api/unread`) || request.originalUrl.startsWith(`${relative_path}/unread`)); + const baseUrl = isDisplayedAsHome ? '' : 'unread'; - const isDisplayedAsHome = !(req.originalUrl.startsWith(`${relative_path}/api/unread`) || req.originalUrl.startsWith(`${relative_path}/unread`)); - const baseUrl = isDisplayedAsHome ? '' : 'unread'; + if (isDisplayedAsHome) { + data.title = meta.config.homePageTitle || '[[pages:home]]'; + } else { + data.title = '[[pages:unread]]'; + data.breadcrumbs = helpers.buildBreadcrumbs([{text: '[[unread:title]]'}]); + } - if (isDisplayedAsHome) { - data.title = meta.config.homePageTitle || '[[pages:home]]'; - } else { - data.title = '[[pages:unread]]'; - data.breadcrumbs = helpers.buildBreadcrumbs([{ text: '[[unread:title]]' }]); - } + data.pageCount = Math.max(1, Math.ceil(data.topicCount / userSettings.topicsPerPage)); + data.pagination = pagination.create(page, data.pageCount, request.query); + helpers.addLinkTags({url: 'unread', res: request.res, tags: data.pagination.rel}); - data.pageCount = Math.max(1, Math.ceil(data.topicCount / userSettings.topicsPerPage)); - data.pagination = pagination.create(page, data.pageCount, req.query); - helpers.addLinkTags({ url: 'unread', res: req.res, tags: data.pagination.rel }); + if (userSettings.usePagination && (page < 1 || page > data.pageCount)) { + request.query.page = Math.max(1, Math.min(data.pageCount, page)); + return helpers.redirect(res, `/unread?${querystring.stringify(request.query)}`); + } - if (userSettings.usePagination && (page < 1 || page > data.pageCount)) { - req.query.page = Math.max(1, Math.min(data.pageCount, page)); - return helpers.redirect(res, `/unread?${querystring.stringify(req.query)}`); - } - data.showSelect = true; - data.showTopicTools = isPrivileged; - data.allCategoriesUrl = `${baseUrl}${helpers.buildQueryString(req.query, 'cid', '')}`; - data.selectedCategory = categoryData.selectedCategory; - data.selectedCids = categoryData.selectedCids; - data.selectCategoryLabel = '[[unread:mark_as_read]]'; - data.selectCategoryIcon = 'fa-inbox'; - data.showCategorySelectLabel = true; - data.filters = helpers.buildFilters(baseUrl, filter, req.query); - data.selectedFilter = data.filters.find(filter => filter && filter.selected); + data.showSelect = true; + data.showTopicTools = isPrivileged; + data.allCategoriesUrl = `${baseUrl}${helpers.buildQueryString(request.query, 'cid', '')}`; + data.selectedCategory = categoryData.selectedCategory; + data.selectedCids = categoryData.selectedCids; + data.selectCategoryLabel = '[[unread:mark_as_read]]'; + data.selectCategoryIcon = 'fa-inbox'; + data.showCategorySelectLabel = true; + data.filters = helpers.buildFilters(baseUrl, filter, request.query); + data.selectedFilter = data.filters.find(filter => filter && filter.selected); - res.render('unread', data); + res.render('unread', data); }; -unreadController.unreadTotal = async function (req, res, next) { - const filter = req.query.filter || ''; - try { - const unreadCount = await topics.getTotalUnread(req.uid, filter); - res.json(unreadCount); - } catch (err) { - next(err); - } +unreadController.unreadTotal = async function (request, res, next) { + const filter = request.query.filter || ''; + try { + const unreadCount = await topics.getTotalUnread(request.uid, filter); + res.json(unreadCount); + } catch (error) { + next(error); + } }; diff --git a/src/controllers/uploads.js b/src/controllers/uploads.js index d1eee4a..e456561 100644 --- a/src/controllers/uploads.js +++ b/src/controllers/uploads.js @@ -1,203 +1,209 @@ 'use strict'; -const path = require('path'); +const path = require('node:path'); const nconf = require('nconf'); const validator = require('validator'); - const user = require('../user'); const meta = require('../meta'); const file = require('../file'); const plugins = require('../plugins'); const image = require('../image'); const privileges = require('../privileges'); - const helpers = require('./helpers'); const uploadsController = module.exports; -uploadsController.upload = async function (req, res, filesIterator) { - let files; - try { - files = req.files.files; - } catch (e) { - return helpers.formatApiResponse(400, res); - } - - // These checks added because of odd behaviour by request: https://github.com/request/request/issues/2445 - if (!Array.isArray(files)) { - return helpers.formatApiResponse(500, res, new Error('[[error:invalid-file]]')); - } - if (Array.isArray(files[0])) { - files = files[0]; - } - - try { - const images = []; - for (const fileObj of files) { - /* eslint-disable no-await-in-loop */ - images.push(await filesIterator(fileObj)); - } - - helpers.formatApiResponse(200, res, { images }); - - return images; - } catch (err) { - return helpers.formatApiResponse(500, res, err); - } finally { - deleteTempFiles(files); - } +uploadsController.upload = async function (request, res, filesIterator) { + let files; + try { + files = request.files.files; + } catch { + return helpers.formatApiResponse(400, res); + } + + // These checks added because of odd behaviour by request: https://github.com/request/request/issues/2445 + if (!Array.isArray(files)) { + return helpers.formatApiResponse(500, res, new Error('[[error:invalid-file]]')); + } + + if (Array.isArray(files[0])) { + files = files[0]; + } + + try { + const images = []; + for (const fileObject of files) { + /* eslint-disable no-await-in-loop */ + images.push(await filesIterator(fileObject)); + } + + helpers.formatApiResponse(200, res, {images}); + + return images; + } catch (error) { + return helpers.formatApiResponse(500, res, error); + } finally { + deleteTemporaryFiles(files); + } }; -uploadsController.uploadPost = async function (req, res) { - await uploadsController.upload(req, res, async (uploadedFile) => { - const isImage = uploadedFile.type.match(/image./); - if (isImage) { - return await uploadAsImage(req, uploadedFile); - } - return await uploadAsFile(req, uploadedFile); - }); +uploadsController.uploadPost = async function (request, res) { + await uploadsController.upload(request, res, async uploadedFile => { + const isImage = uploadedFile.type.match(/image./); + if (isImage) { + return await uploadAsImage(request, uploadedFile); + } + + return await uploadAsFile(request, uploadedFile); + }); }; -async function uploadAsImage(req, uploadedFile) { - const canUpload = await privileges.global.can('upload:post:image', req.uid); - if (!canUpload) { - throw new Error('[[error:no-privileges]]'); - } - await image.checkDimensions(uploadedFile.path); - await image.stripEXIF(uploadedFile.path); - - if (plugins.hooks.hasListeners('filter:uploadImage')) { - return await plugins.hooks.fire('filter:uploadImage', { - image: uploadedFile, - uid: req.uid, - folder: 'files', - }); - } - await image.isFileTypeAllowed(uploadedFile.path); - - let fileObj = await uploadsController.uploadFile(req.uid, uploadedFile); - // sharp can't save svgs skip resize for them - const isSVG = uploadedFile.type === 'image/svg+xml'; - if (isSVG || meta.config.resizeImageWidth === 0 || meta.config.resizeImageWidthThreshold === 0) { - return fileObj; - } - - fileObj = await resizeImage(fileObj); - return { url: fileObj.url }; +async function uploadAsImage(request, uploadedFile) { + const canUpload = await privileges.global.can('upload:post:image', request.uid); + if (!canUpload) { + throw new Error('[[error:no-privileges]]'); + } + + await image.checkDimensions(uploadedFile.path); + await image.stripEXIF(uploadedFile.path); + + if (plugins.hooks.hasListeners('filter:uploadImage')) { + return await plugins.hooks.fire('filter:uploadImage', { + image: uploadedFile, + uid: request.uid, + folder: 'files', + }); + } + + await image.isFileTypeAllowed(uploadedFile.path); + + let fileObject = await uploadsController.uploadFile(request.uid, uploadedFile); + // Sharp can't save svgs skip resize for them + const isSVG = uploadedFile.type === 'image/svg+xml'; + if (isSVG || meta.config.resizeImageWidth === 0 || meta.config.resizeImageWidthThreshold === 0) { + return fileObject; + } + + fileObject = await resizeImage(fileObject); + return {url: fileObject.url}; } -async function uploadAsFile(req, uploadedFile) { - const canUpload = await privileges.global.can('upload:post:file', req.uid); - if (!canUpload) { - throw new Error('[[error:no-privileges]]'); - } - - const fileObj = await uploadsController.uploadFile(req.uid, uploadedFile); - return { - url: fileObj.url, - name: fileObj.name, - }; +async function uploadAsFile(request, uploadedFile) { + const canUpload = await privileges.global.can('upload:post:file', request.uid); + if (!canUpload) { + throw new Error('[[error:no-privileges]]'); + } + + const fileObject = await uploadsController.uploadFile(request.uid, uploadedFile); + return { + url: fileObject.url, + name: fileObject.name, + }; } -async function resizeImage(fileObj) { - const imageData = await image.size(fileObj.path); - if ( - imageData.width < meta.config.resizeImageWidthThreshold || - meta.config.resizeImageWidth > meta.config.resizeImageWidthThreshold - ) { - return fileObj; - } - - await image.resizeImage({ - path: fileObj.path, - target: file.appendToFileName(fileObj.path, '-resized'), - width: meta.config.resizeImageWidth, - quality: meta.config.resizeImageQuality, - }); - // Return the resized version to the composer/postData - fileObj.url = file.appendToFileName(fileObj.url, '-resized'); - - return fileObj; +async function resizeImage(fileObject) { + const imageData = await image.size(fileObject.path); + if ( + imageData.width < meta.config.resizeImageWidthThreshold + || meta.config.resizeImageWidth > meta.config.resizeImageWidthThreshold + ) { + return fileObject; + } + + await image.resizeImage({ + path: fileObject.path, + target: file.appendToFileName(fileObject.path, '-resized'), + width: meta.config.resizeImageWidth, + quality: meta.config.resizeImageQuality, + }); + // Return the resized version to the composer/postData + fileObject.url = file.appendToFileName(fileObject.url, '-resized'); + + return fileObject; } -uploadsController.uploadThumb = async function (req, res) { - if (!meta.config.allowTopicsThumbnail) { - deleteTempFiles(req.files.files); - return helpers.formatApiResponse(503, res, new Error('[[error:topic-thumbnails-are-disabled]]')); - } - - return await uploadsController.upload(req, res, async (uploadedFile) => { - if (!uploadedFile.type.match(/image./)) { - throw new Error('[[error:invalid-file]]'); - } - await image.isFileTypeAllowed(uploadedFile.path); - const dimensions = await image.checkDimensions(uploadedFile.path); - - if (dimensions.width > parseInt(meta.config.topicThumbSize, 10)) { - await image.resizeImage({ - path: uploadedFile.path, - width: meta.config.topicThumbSize, - }); - } - if (plugins.hooks.hasListeners('filter:uploadImage')) { - return await plugins.hooks.fire('filter:uploadImage', { - image: uploadedFile, - uid: req.uid, - folder: 'files', - }); - } - - return await uploadsController.uploadFile(req.uid, uploadedFile); - }); +uploadsController.uploadThumb = async function (request, res) { + if (!meta.config.allowTopicsThumbnail) { + deleteTemporaryFiles(request.files.files); + return helpers.formatApiResponse(503, res, new Error('[[error:topic-thumbnails-are-disabled]]')); + } + + return await uploadsController.upload(request, res, async uploadedFile => { + if (!/image./.test(uploadedFile.type)) { + throw new Error('[[error:invalid-file]]'); + } + + await image.isFileTypeAllowed(uploadedFile.path); + const dimensions = await image.checkDimensions(uploadedFile.path); + + if (dimensions.width > Number.parseInt(meta.config.topicThumbSize, 10)) { + await image.resizeImage({ + path: uploadedFile.path, + width: meta.config.topicThumbSize, + }); + } + + if (plugins.hooks.hasListeners('filter:uploadImage')) { + return await plugins.hooks.fire('filter:uploadImage', { + image: uploadedFile, + uid: request.uid, + folder: 'files', + }); + } + + return await uploadsController.uploadFile(request.uid, uploadedFile); + }); }; uploadsController.uploadFile = async function (uid, uploadedFile) { - if (plugins.hooks.hasListeners('filter:uploadFile')) { - return await plugins.hooks.fire('filter:uploadFile', { - file: uploadedFile, - uid: uid, - folder: 'files', - }); - } - - if (!uploadedFile) { - throw new Error('[[error:invalid-file]]'); - } - - if (uploadedFile.size > meta.config.maximumFileSize * 1024) { - throw new Error(`[[error:file-too-big, ${meta.config.maximumFileSize}]]`); - } - - const allowed = file.allowedExtensions(); - - const extension = path.extname(uploadedFile.name).toLowerCase(); - if (allowed.length > 0 && (!extension || extension === '.' || !allowed.includes(extension))) { - throw new Error(`[[error:invalid-file-type, ${allowed.join(', ')}]]`); - } - - return await saveFileToLocal(uid, 'files', uploadedFile); + if (plugins.hooks.hasListeners('filter:uploadFile')) { + return await plugins.hooks.fire('filter:uploadFile', { + file: uploadedFile, + uid, + folder: 'files', + }); + } + + if (!uploadedFile) { + throw new Error('[[error:invalid-file]]'); + } + + if (uploadedFile.size > meta.config.maximumFileSize * 1024) { + throw new Error(`[[error:file-too-big, ${meta.config.maximumFileSize}]]`); + } + + const allowed = file.allowedExtensions(); + + const extension = path.extname(uploadedFile.name).toLowerCase(); + if (allowed.length > 0 && (!extension || extension === '.' || !allowed.includes(extension))) { + throw new Error(`[[error:invalid-file-type, ${allowed.join(', ')}]]`); + } + + return await saveFileToLocal(uid, 'files', uploadedFile); }; async function saveFileToLocal(uid, folder, uploadedFile) { - const name = uploadedFile.name || 'upload'; - const extension = path.extname(name) || ''; + const name = uploadedFile.name || 'upload'; + const extension = path.extname(name) || ''; - const filename = `${Date.now()}-${validator.escape(name.slice(0, -extension.length)).slice(0, 255)}${extension}`; + const filename = `${Date.now()}-${validator.escape(name.slice(0, -extension.length)).slice(0, 255)}${extension}`; - const upload = await file.saveFileToLocal(filename, folder, uploadedFile.path); - const storedFile = { - url: nconf.get('relative_path') + upload.url, - path: upload.path, - name: uploadedFile.name, - }; + const upload = await file.saveFileToLocal(filename, folder, uploadedFile.path); + const storedFile = { + url: nconf.get('relative_path') + upload.url, + path: upload.path, + name: uploadedFile.name, + }; - await user.associateUpload(uid, upload.url.replace(`${nconf.get('upload_url')}/`, '')); - const data = await plugins.hooks.fire('filter:uploadStored', { uid: uid, uploadedFile: uploadedFile, storedFile: storedFile }); - return data.storedFile; + await user.associateUpload(uid, upload.url.replace(`${nconf.get('upload_url')}/`, '')); + const data = await plugins.hooks.fire('filter:uploadStored', {uid, uploadedFile, storedFile}); + return data.storedFile; } -function deleteTempFiles(files) { - files.forEach(fileObj => file.delete(fileObj.path)); +function deleteTemporaryFiles(files) { + for (const fileObject of files) { + file.delete(fileObject.path); + } } require('../promisify')(uploadsController, ['upload', 'uploadPost', 'uploadThumb']); diff --git a/src/controllers/user.js b/src/controllers/user.js index 673a8cf..0f3e176 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -1,118 +1,141 @@ 'use strict'; -const path = require('path'); +const path = require('node:path'); const winston = require('winston'); - const user = require('../user'); const privileges = require('../privileges'); const accountHelpers = require('./accounts/helpers'); const userController = module.exports; -userController.getCurrentUser = async function (req, res) { - if (!req.loggedIn) { - return res.status(401).json('not-authorized'); - } - const userslug = await user.getUserField(req.uid, 'userslug'); - const userData = await accountHelpers.getUserDataByUserSlug(userslug, req.uid, req.query); - res.json(userData); +userController.getCurrentUser = async function (request, res) { + if (!request.loggedIn) { + return res.status(401).json('not-authorized'); + } + + const userslug = await user.getUserField(request.uid, 'userslug'); + const userData = await accountHelpers.getUserDataByUserSlug(userslug, request.uid, request.query); + res.json(userData); }; -userController.getUserByUID = async function (req, res, next) { - await byType('uid', req, res, next); +userController.getUserByUID = async function (request, res, next) { + await byType('uid', request, res, next); }; -userController.getUserByUsername = async function (req, res, next) { - await byType('username', req, res, next); +userController.getUserByUsername = async function (request, res, next) { + await byType('username', request, res, next); }; -userController.getUserByEmail = async function (req, res, next) { - await byType('email', req, res, next); +userController.getUserByEmail = async function (request, res, next) { + await byType('email', request, res, next); }; -async function byType(type, req, res, next) { - const userData = await userController.getUserDataByField(req.uid, type, req.params[type]); - if (!userData) { - return next(); - } - res.json(userData); +async function byType(type, request, res, next) { + const userData = await userController.getUserDataByField(request.uid, type, request.params[type]); + if (!userData) { + return next(); + } + + res.json(userData); } userController.getUserDataByField = async function (callerUid, field, fieldValue) { - let uid = null; - if (field === 'uid') { - uid = fieldValue; - } else if (field === 'username') { - uid = await user.getUidByUsername(fieldValue); - } else if (field === 'email') { - uid = await user.getUidByEmail(fieldValue); - if (uid) { - const isPrivileged = await user.isAdminOrGlobalMod(callerUid); - const settings = await user.getSettings(uid); - if (!isPrivileged && (settings && !settings.showemail)) { - uid = 0; - } - } - } - if (!uid) { - return null; - } - return await userController.getUserDataByUID(callerUid, uid); + let uid = null; + switch (field) { + case 'uid': { + uid = fieldValue; + + break; + } + + case 'username': { + uid = await user.getUidByUsername(fieldValue); + + break; + } + + case 'email': { + uid = await user.getUidByEmail(fieldValue); + if (uid) { + const isPrivileged = await user.isAdminOrGlobalMod(callerUid); + const settings = await user.getSettings(uid); + if (!isPrivileged && (settings && !settings.showemail)) { + uid = 0; + } + } + + break; + } + // No default + } + + if (!uid) { + return null; + } + + return await userController.getUserDataByUID(callerUid, uid); }; userController.getUserDataByUID = async function (callerUid, uid) { - if (!parseInt(uid, 10)) { - throw new Error('[[error:no-user]]'); - } - const canView = await privileges.global.can('view:users', callerUid); - if (!canView) { - throw new Error('[[error:no-privileges]]'); - } - - let userData = await user.getUserData(uid); - if (!userData) { - throw new Error('[[error:no-user]]'); - } - - userData = await user.hidePrivateData(userData, callerUid); - - return userData; + if (!Number.parseInt(uid, 10)) { + throw new Error('[[error:no-user]]'); + } + + const canView = await privileges.global.can('view:users', callerUid); + if (!canView) { + throw new Error('[[error:no-privileges]]'); + } + + let userData = await user.getUserData(uid); + if (!userData) { + throw new Error('[[error:no-user]]'); + } + + userData = await user.hidePrivateData(userData, callerUid); + + return userData; }; -userController.exportPosts = async function (req, res, next) { - sendExport(`${res.locals.uid}_posts.csv`, 'text/csv', res, next); +userController.exportPosts = async function (request, res, next) { + sendExport(`${res.locals.uid}_posts.csv`, 'text/csv', res, next); }; -userController.exportUploads = function (req, res, next) { - sendExport(`${res.locals.uid}_uploads.zip`, 'application/zip', res, next); +userController.exportUploads = function (request, res, next) { + sendExport(`${res.locals.uid}_uploads.zip`, 'application/zip', res, next); }; -userController.exportProfile = async function (req, res, next) { - sendExport(`${res.locals.uid}_profile.json`, 'application/json', res, next); +userController.exportProfile = async function (request, res, next) { + sendExport(`${res.locals.uid}_profile.json`, 'application/json', res, next); }; // DEPRECATED; Remove in NodeBB v3.0.0 function sendExport(filename, type, res, next) { - winston.warn(`[users/export] Access via page API is deprecated, use GET /api/v3/users/:uid/exports/:type instead.`); - - res.sendFile(filename, { - root: path.join(__dirname, '../../build/export'), - headers: { - 'Content-Type': type, - 'Content-Disposition': `attachment; filename=${filename}`, - }, - }, (err) => { - if (err) { - if (err.code === 'ENOENT') { - res.locals.isAPI = false; - return next(); - } - return next(err); - } - }); + winston.warn('[users/export] Access via page API is deprecated, use GET /api/v3/users/:uid/exports/:type instead.'); + + res.sendFile(filename, { + root: path.join(__dirname, '../../build/export'), + headers: { + 'Content-Type': type, + 'Content-Disposition': `attachment; filename=${filename}`, + }, + }, error => { + if (error) { + if (error.code === 'ENOENT') { + res.locals.isAPI = false; + return next(); + } + + return next(error); + } + }); } require('../promisify')(userController, [ - 'getCurrentUser', 'getUserByUID', 'getUserByUsername', 'getUserByEmail', - 'exportPosts', 'exportUploads', 'exportProfile', + 'getCurrentUser', + 'getUserByUID', + 'getUserByUsername', + 'getUserByEmail', + 'exportPosts', + 'exportUploads', + 'exportProfile', ]); diff --git a/src/controllers/users.js b/src/controllers/users.js index acc4e30..08f4cdc 100644 --- a/src/controllers/users.js +++ b/src/controllers/users.js @@ -2,211 +2,218 @@ const user = require('../user'); const meta = require('../meta'); - const db = require('../database'); const pagination = require('../pagination'); const privileges = require('../privileges'); -const helpers = require('./helpers'); const api = require('../api'); const utils = require('../utils'); +const helpers = require('./helpers'); const usersController = module.exports; -usersController.index = async function (req, res, next) { - const section = req.query.section || 'joindate'; - const sectionToController = { - joindate: usersController.getUsersSortedByJoinDate, - online: usersController.getOnlineUsers, - 'sort-posts': usersController.getUsersSortedByPosts, - 'sort-reputation': usersController.getUsersSortedByReputation, - banned: usersController.getBannedUsers, - flagged: usersController.getFlaggedUsers, - }; - - if (req.query.query) { - await usersController.search(req, res, next); - } else if (sectionToController[section]) { - await sectionToController[section](req, res, next); - } else { - await usersController.getUsersSortedByJoinDate(req, res, next); - } +usersController.index = async function (request, res, next) { + const section = request.query.section || 'joindate'; + const sectionToController = { + joindate: usersController.getUsersSortedByJoinDate, + online: usersController.getOnlineUsers, + 'sort-posts': usersController.getUsersSortedByPosts, + 'sort-reputation': usersController.getUsersSortedByReputation, + banned: usersController.getBannedUsers, + flagged: usersController.getFlaggedUsers, + }; + + if (request.query.query) { + await usersController.search(request, res, next); + } else if (sectionToController[section]) { + await sectionToController[section](request, res, next); + } else { + await usersController.getUsersSortedByJoinDate(request, res, next); + } }; -usersController.search = async function (req, res) { - const searchData = await api.users.search(req, req.query); +usersController.search = async function (request, res) { + const searchData = await api.users.search(request, request.query); - const section = req.query.section || 'joindate'; + const section = request.query.section || 'joindate'; - searchData.pagination = pagination.create(req.query.page, searchData.pageCount, req.query); - searchData[`section_${section}`] = true; - searchData.displayUserSearch = true; - await render(req, res, searchData); + searchData.pagination = pagination.create(request.query.page, searchData.pageCount, request.query); + searchData[`section_${section}`] = true; + searchData.displayUserSearch = true; + await render(request, res, searchData); }; -usersController.getOnlineUsers = async function (req, res) { - const [userData, guests] = await Promise.all([ - usersController.getUsers('users:online', req.uid, req.query), - require('../socket.io/admin/rooms').getTotalGuestCount(), - ]); - - let hiddenCount = 0; - if (!userData.isAdminOrGlobalMod) { - userData.users = userData.users.filter((user) => { - const showUser = user && (user.uid === req.uid || user.userStatus !== 'offline'); - if (!showUser) { - hiddenCount += 1; - } - return showUser; - }); - } - - userData.anonymousUserCount = guests + hiddenCount; - userData.timeagoCutoff = 1000 * 60 * 60 * 24; - - await render(req, res, userData); +usersController.getOnlineUsers = async function (request, res) { + const [userData, guests] = await Promise.all([ + usersController.getUsers('users:online', request.uid, request.query), + require('../socket.io/admin/rooms').getTotalGuestCount(), + ]); + + let hiddenCount = 0; + if (!userData.isAdminOrGlobalMod) { + userData.users = userData.users.filter(user => { + const showUser = user && (user.uid === request.uid || user.userStatus !== 'offline'); + if (!showUser) { + hiddenCount += 1; + } + + return showUser; + }); + } + + userData.anonymousUserCount = guests + hiddenCount; + userData.timeagoCutoff = 1000 * 60 * 60 * 24; + + await render(request, res, userData); }; -usersController.getUsersSortedByPosts = async function (req, res) { - await usersController.renderUsersPage('users:postcount', req, res); +usersController.getUsersSortedByPosts = async function (request, res) { + await usersController.renderUsersPage('users:postcount', request, res); }; -usersController.getUsersSortedByReputation = async function (req, res, next) { - if (meta.config['reputation:disabled']) { - return next(); - } - await usersController.renderUsersPage('users:reputation', req, res); +usersController.getUsersSortedByReputation = async function (request, res, next) { + if (meta.config['reputation:disabled']) { + return next(); + } + + await usersController.renderUsersPage('users:reputation', request, res); }; -usersController.getUsersSortedByJoinDate = async function (req, res) { - await usersController.renderUsersPage('users:joindate', req, res); +usersController.getUsersSortedByJoinDate = async function (request, res) { + await usersController.renderUsersPage('users:joindate', request, res); }; -usersController.getBannedUsers = async function (req, res) { - await renderIfAdminOrGlobalMod('users:banned', req, res); +usersController.getBannedUsers = async function (request, res) { + await renderIfAdminOrGlobalModule('users:banned', request, res); }; -usersController.getFlaggedUsers = async function (req, res) { - await renderIfAdminOrGlobalMod('users:flags', req, res); +usersController.getFlaggedUsers = async function (request, res) { + await renderIfAdminOrGlobalModule('users:flags', request, res); }; -async function renderIfAdminOrGlobalMod(set, req, res) { - const isAdminOrGlobalMod = await user.isAdminOrGlobalMod(req.uid); - if (!isAdminOrGlobalMod) { - return helpers.notAllowed(req, res); - } - await usersController.renderUsersPage(set, req, res); +async function renderIfAdminOrGlobalModule(set, request, res) { + const isAdminOrGlobalModule = await user.isAdminOrGlobalMod(request.uid); + if (!isAdminOrGlobalModule) { + return helpers.notAllowed(request, res); + } + + await usersController.renderUsersPage(set, request, res); } -usersController.renderUsersPage = async function (set, req, res) { - const userData = await usersController.getUsers(set, req.uid, req.query); - await render(req, res, userData); +usersController.renderUsersPage = async function (set, request, res) { + const userData = await usersController.getUsers(set, request.uid, request.query); + await render(request, res, userData); }; usersController.getUsers = async function (set, uid, query) { - const setToData = { - 'users:postcount': { title: '[[pages:users/sort-posts]]', crumb: '[[users:top_posters]]' }, - 'users:reputation': { title: '[[pages:users/sort-reputation]]', crumb: '[[users:most_reputation]]' }, - 'users:joindate': { title: '[[pages:users/latest]]', crumb: '[[global:users]]' }, - 'users:online': { title: '[[pages:users/online]]', crumb: '[[global:online]]' }, - 'users:banned': { title: '[[pages:users/banned]]', crumb: '[[user:banned]]' }, - 'users:flags': { title: '[[pages:users/most-flags]]', crumb: '[[users:most_flags]]' }, - }; - - if (!setToData[set]) { - setToData[set] = { title: '', crumb: '' }; - } - - const breadcrumbs = [{ text: setToData[set].crumb }]; - - if (set !== 'users:joindate') { - breadcrumbs.unshift({ text: '[[global:users]]', url: '/users' }); - } - - const page = parseInt(query.page, 10) || 1; - const resultsPerPage = meta.config.userSearchResultsPerPage; - const start = Math.max(0, page - 1) * resultsPerPage; - const stop = start + resultsPerPage - 1; - - const [isAdmin, isGlobalMod, canSearch, usersData] = await Promise.all([ - user.isAdministrator(uid), - user.isGlobalModerator(uid), - privileges.global.can('search:users', uid), - usersController.getUsersAndCount(set, uid, start, stop), - ]); - const pageCount = Math.ceil(usersData.count / resultsPerPage); - return { - users: usersData.users, - pagination: pagination.create(page, pageCount, query), - userCount: usersData.count, - title: setToData[set].title || '[[pages:users/latest]]', - breadcrumbs: helpers.buildBreadcrumbs(breadcrumbs), - isAdminOrGlobalMod: isAdmin || isGlobalMod, - isAdmin: isAdmin, - isGlobalMod: isGlobalMod, - displayUserSearch: canSearch, - [`section_${query.section || 'joindate'}`]: true, - }; + const setToData = { + 'users:postcount': {title: '[[pages:users/sort-posts]]', crumb: '[[users:top_posters]]'}, + 'users:reputation': {title: '[[pages:users/sort-reputation]]', crumb: '[[users:most_reputation]]'}, + 'users:joindate': {title: '[[pages:users/latest]]', crumb: '[[global:users]]'}, + 'users:online': {title: '[[pages:users/online]]', crumb: '[[global:online]]'}, + 'users:banned': {title: '[[pages:users/banned]]', crumb: '[[user:banned]]'}, + 'users:flags': {title: '[[pages:users/most-flags]]', crumb: '[[users:most_flags]]'}, + }; + + setToData[set] ||= {title: '', crumb: ''}; + + const breadcrumbs = [{text: setToData[set].crumb}]; + + if (set !== 'users:joindate') { + breadcrumbs.unshift({text: '[[global:users]]', url: '/users'}); + } + + const page = Number.parseInt(query.page, 10) || 1; + const resultsPerPage = meta.config.userSearchResultsPerPage; + const start = Math.max(0, page - 1) * resultsPerPage; + const stop = start + resultsPerPage - 1; + + const [isAdmin, isGlobalModule, canSearch, usersData] = await Promise.all([ + user.isAdministrator(uid), + user.isGlobalModerator(uid), + privileges.global.can('search:users', uid), + usersController.getUsersAndCount(set, uid, start, stop), + ]); + const pageCount = Math.ceil(usersData.count / resultsPerPage); + return { + users: usersData.users, + pagination: pagination.create(page, pageCount, query), + userCount: usersData.count, + title: setToData[set].title || '[[pages:users/latest]]', + breadcrumbs: helpers.buildBreadcrumbs(breadcrumbs), + isAdminOrGlobalMod: isAdmin || isGlobalModule, + isAdmin, + isGlobalMod: isGlobalModule, + displayUserSearch: canSearch, + [`section_${query.section || 'joindate'}`]: true, + }; }; usersController.getUsersAndCount = async function (set, uid, start, stop) { - async function getCount() { - if (set === 'users:online') { - return await db.sortedSetCount('users:online', Date.now() - 86400000, '+inf'); - } else if (set === 'users:banned' || set === 'users:flags') { - return await db.sortedSetCard(set); - } - return await db.getObjectField('global', 'userCount'); - } - async function getUsers() { - if (set === 'users:online') { - const count = parseInt(stop, 10) === -1 ? stop : stop - start + 1; - const data = await db.getSortedSetRevRangeByScoreWithScores(set, start, count, '+inf', Date.now() - 86400000); - const uids = data.map(d => d.value); - const scores = data.map(d => d.score); - const [userStatus, userData] = await Promise.all([ - db.getObjectsFields(uids.map(uid => `user:${uid}`), ['status']), - user.getUsers(uids, uid), - ]); - - userData.forEach((user, i) => { - if (user) { - user.lastonline = scores[i]; - user.lastonlineISO = utils.toISOString(user.lastonline); - user.userStatus = userStatus[i].status || 'online'; - } - }); - return userData; - } - return await user.getUsersFromSet(set, uid, start, stop); - } - const [usersData, count] = await Promise.all([ - getUsers(), - getCount(), - ]); - return { - users: usersData.filter(user => user && parseInt(user.uid, 10)), - count: count, - }; + async function getCount() { + if (set === 'users:online') { + return await db.sortedSetCount('users:online', Date.now() - 86_400_000, '+inf'); + } + + if (set === 'users:banned' || set === 'users:flags') { + return await db.sortedSetCard(set); + } + + return await db.getObjectField('global', 'userCount'); + } + + async function getUsers() { + if (set === 'users:online') { + const count = Number.parseInt(stop, 10) === -1 ? stop : stop - start + 1; + const data = await db.getSortedSetRevRangeByScoreWithScores(set, start, count, '+inf', Date.now() - 86_400_000); + const uids = data.map(d => d.value); + const scores = data.map(d => d.score); + const [userStatus, userData] = await Promise.all([ + db.getObjectsFields(uids.map(uid => `user:${uid}`), ['status']), + user.getUsers(uids, uid), + ]); + + for (const [i, user] of userData.entries()) { + if (user) { + user.lastonline = scores[i]; + user.lastonlineISO = utils.toISOString(user.lastonline); + user.userStatus = userStatus[i].status || 'online'; + } + } + + return userData; + } + + return await user.getUsersFromSet(set, uid, start, stop); + } + + const [usersData, count] = await Promise.all([ + getUsers(), + getCount(), + ]); + return { + users: usersData.filter(user => user && Number.parseInt(user.uid, 10)), + count, + }; }; -async function render(req, res, data) { - const { registrationType } = meta.config; +async function render(request, res, data) { + const {registrationType} = meta.config; - data.maximumInvites = meta.config.maximumInvites; - data.inviteOnly = registrationType === 'invite-only' || registrationType === 'admin-invite-only'; - data.adminInviteOnly = registrationType === 'admin-invite-only'; - data.invites = await user.getInvitesNumber(req.uid); + data.maximumInvites = meta.config.maximumInvites; + data.inviteOnly = registrationType === 'invite-only' || registrationType === 'admin-invite-only'; + data.adminInviteOnly = registrationType === 'admin-invite-only'; + data.invites = await user.getInvitesNumber(request.uid); - data.showInviteButton = false; - if (data.adminInviteOnly) { - data.showInviteButton = await privileges.users.isAdministrator(req.uid); - } else if (req.loggedIn) { - const canInvite = await privileges.users.hasInvitePrivilege(req.uid); - data.showInviteButton = canInvite && (!data.maximumInvites || data.invites < data.maximumInvites); - } + data.showInviteButton = false; + if (data.adminInviteOnly) { + data.showInviteButton = await privileges.users.isAdministrator(request.uid); + } else if (request.loggedIn) { + const canInvite = await privileges.users.hasInvitePrivilege(request.uid); + data.showInviteButton = canInvite && (!data.maximumInvites || data.invites < data.maximumInvites); + } - data['reputation:disabled'] = meta.config['reputation:disabled']; + data['reputation:disabled'] = meta.config['reputation:disabled']; - res.append('X-Total-Count', data.userCount); - res.render('users', data); + res.append('X-Total-Count', data.userCount); + res.render('users', data); } diff --git a/src/controllers/write/admin.js b/src/controllers/write/admin.js index e5963ba..2227428 100644 --- a/src/controllers/write/admin.js +++ b/src/controllers/write/admin.js @@ -3,40 +3,36 @@ const meta = require('../../meta'); const privileges = require('../../privileges'); const analytics = require('../../analytics'); - const helpers = require('../helpers'); const Admin = module.exports; -Admin.updateSetting = async (req, res) => { - const ok = await privileges.admin.can('admin:settings', req.uid); +Admin.updateSetting = async (request, res) => { + const ok = await privileges.admin.can('admin:settings', request.uid); - if (!ok) { - return helpers.formatApiResponse(403, res); - } + if (!ok) { + return helpers.formatApiResponse(403, res); + } - await meta.configs.set(req.params.setting, req.body.value); - helpers.formatApiResponse(200, res); + await meta.configs.set(request.params.setting, request.body.value); + helpers.formatApiResponse(200, res); }; -Admin.getAnalyticsKeys = async (req, res) => { - let keys = await analytics.getKeys(); +Admin.getAnalyticsKeys = async (request, res) => { + let keys = await analytics.getKeys(); - // Sort keys alphabetically - keys = keys.sort((a, b) => (a < b ? -1 : 1)); + // Sort keys alphabetically + keys = keys.sort((a, b) => (a < b ? -1 : 1)); - helpers.formatApiResponse(200, res, { keys }); + helpers.formatApiResponse(200, res, {keys}); }; -Admin.getAnalyticsData = async (req, res) => { - // Default returns views from past 24 hours, by hour - if (!req.query.amount) { - if (req.query.units === 'days') { - req.query.amount = 30; - } else { - req.query.amount = 24; - } - } - const getStats = req.query.units === 'days' ? analytics.getDailyStatsForSet : analytics.getHourlyStatsForSet; - helpers.formatApiResponse(200, res, await getStats(`analytics:${req.params.set}`, parseInt(req.query.until, 10) || Date.now(), req.query.amount)); +Admin.getAnalyticsData = async (request, res) => { + // Default returns views from past 24 hours, by hour + if (!request.query.amount) { + request.query.amount = request.query.units === 'days' ? 30 : 24; + } + + const getStats = request.query.units === 'days' ? analytics.getDailyStatsForSet : analytics.getHourlyStatsForSet; + helpers.formatApiResponse(200, res, await getStats(`analytics:${request.params.set}`, Number.parseInt(request.query.until, 10) || Date.now(), request.query.amount)); }; diff --git a/src/controllers/write/categories.js b/src/controllers/write/categories.js index be85a74..91e2943 100644 --- a/src/controllers/write/categories.js +++ b/src/controllers/write/categories.js @@ -3,80 +3,80 @@ const privileges = require('../../privileges'); const categories = require('../../categories'); const api = require('../../api'); - const helpers = require('../helpers'); const Categories = module.exports; -const hasAdminPrivilege = async (uid) => { - const ok = await privileges.admin.can(`admin:categories`, uid); - if (!ok) { - throw new Error('[[error:no-privileges]]'); - } +const hasAdminPrivilege = async uid => { + const ok = await privileges.admin.can('admin:categories', uid); + if (!ok) { + throw new Error('[[error:no-privileges]]'); + } }; -Categories.get = async (req, res) => { - helpers.formatApiResponse(200, res, await api.categories.get(req, req.params)); +Categories.get = async (request, res) => { + helpers.formatApiResponse(200, res, await api.categories.get(request, request.params)); }; -Categories.create = async (req, res) => { - await hasAdminPrivilege(req.uid); +Categories.create = async (request, res) => { + await hasAdminPrivilege(request.uid); - const response = await api.categories.create(req, req.body); - helpers.formatApiResponse(200, res, response); + const response = await api.categories.create(request, request.body); + helpers.formatApiResponse(200, res, response); }; -Categories.update = async (req, res) => { - await hasAdminPrivilege(req.uid); +Categories.update = async (request, res) => { + await hasAdminPrivilege(request.uid); - const payload = {}; - payload[req.params.cid] = req.body; - await api.categories.update(req, payload); - const categoryObjs = await categories.getCategories([req.params.cid]); - helpers.formatApiResponse(200, res, categoryObjs[0]); + const payload = {}; + payload[request.params.cid] = request.body; + await api.categories.update(request, payload); + const categoryObjs = await categories.getCategories([request.params.cid]); + helpers.formatApiResponse(200, res, categoryObjs[0]); }; -Categories.delete = async (req, res) => { - await hasAdminPrivilege(req.uid); +Categories.delete = async (request, res) => { + await hasAdminPrivilege(request.uid); - await api.categories.delete(req, { cid: req.params.cid }); - helpers.formatApiResponse(200, res); + await api.categories.delete(request, {cid: request.params.cid}); + helpers.formatApiResponse(200, res); }; -Categories.getPrivileges = async (req, res) => { - if (!await privileges.admin.can('admin:privileges', req.uid)) { - throw new Error('[[error:no-privileges]]'); - } +Categories.getPrivileges = async (request, res) => { + if (!await privileges.admin.can('admin:privileges', request.uid)) { + throw new Error('[[error:no-privileges]]'); + } - const privilegeSet = await api.categories.getPrivileges(req, req.params.cid); - helpers.formatApiResponse(200, res, privilegeSet); + const privilegeSet = await api.categories.getPrivileges(request, request.params.cid); + helpers.formatApiResponse(200, res, privilegeSet); }; -Categories.setPrivilege = async (req, res) => { - if (!await privileges.admin.can('admin:privileges', req.uid)) { - throw new Error('[[error:no-privileges]]'); - } +Categories.setPrivilege = async (request, res) => { + if (!await privileges.admin.can('admin:privileges', request.uid)) { + throw new Error('[[error:no-privileges]]'); + } - await api.categories.setPrivilege(req, { - ...req.params, - member: req.body.member, - set: req.method === 'PUT', - }); + await api.categories.setPrivilege(request, { + ...request.params, + member: request.body.member, + set: request.method === 'PUT', + }); - const privilegeSet = await api.categories.getPrivileges(req, req.params.cid); - helpers.formatApiResponse(200, res, privilegeSet); + const privilegeSet = await api.categories.getPrivileges(request, request.params.cid); + helpers.formatApiResponse(200, res, privilegeSet); }; -Categories.setModerator = async (req, res) => { - if (!await privileges.admin.can('admin:admins-mods', req.uid)) { - throw new Error('[[error:no-privileges]]'); - } - const privilegeList = await privileges.categories.getUserPrivilegeList(); - await api.categories.setPrivilege(req, { - cid: req.params.cid, - privilege: privilegeList, - member: req.params.uid, - set: req.method === 'PUT', - }); - helpers.formatApiResponse(200, res); +Categories.setModerator = async (request, res) => { + if (!await privileges.admin.can('admin:admins-mods', request.uid)) { + throw new Error('[[error:no-privileges]]'); + } + + const privilegeList = await privileges.categories.getUserPrivilegeList(); + await api.categories.setPrivilege(request, { + cid: request.params.cid, + privilege: privilegeList, + member: request.params.uid, + set: request.method === 'PUT', + }); + helpers.formatApiResponse(200, res); }; diff --git a/src/controllers/write/chats.js b/src/controllers/write/chats.js index 38bec34..37bb67f 100644 --- a/src/controllers/write/chats.js +++ b/src/controllers/write/chats.js @@ -2,128 +2,127 @@ const api = require('../../api'); const messaging = require('../../messaging'); - const helpers = require('../helpers'); const Chats = module.exports; -Chats.list = async (req, res) => { - const page = (isFinite(req.query.page) && parseInt(req.query.page, 10)) || 1; - const perPage = (isFinite(req.query.perPage) && parseInt(req.query.perPage, 10)) || 20; - const start = Math.max(0, page - 1) * perPage; - const stop = start + perPage; - const { rooms } = await messaging.getRecentChats(req.uid, req.uid, start, stop); +Chats.list = async (request, res) => { + const page = (isFinite(request.query.page) && Number.parseInt(request.query.page, 10)) || 1; + const perPage = (isFinite(request.query.perPage) && Number.parseInt(request.query.perPage, 10)) || 20; + const start = Math.max(0, page - 1) * perPage; + const stop = start + perPage; + const {rooms} = await messaging.getRecentChats(request.uid, request.uid, start, stop); - helpers.formatApiResponse(200, res, { rooms }); + helpers.formatApiResponse(200, res, {rooms}); }; -Chats.create = async (req, res) => { - const roomObj = await api.chats.create(req, req.body); - helpers.formatApiResponse(200, res, roomObj); +Chats.create = async (request, res) => { + const roomObject = await api.chats.create(request, request.body); + helpers.formatApiResponse(200, res, roomObject); }; -Chats.exists = async (req, res) => { - helpers.formatApiResponse(200, res); +Chats.exists = async (request, res) => { + helpers.formatApiResponse(200, res); }; -Chats.get = async (req, res) => { - const roomObj = await messaging.loadRoom(req.uid, { - uid: req.query.uid || req.uid, - roomId: req.params.roomId, - }); +Chats.get = async (request, res) => { + const roomObject = await messaging.loadRoom(request.uid, { + uid: request.query.uid || request.uid, + roomId: request.params.roomId, + }); - helpers.formatApiResponse(200, res, roomObj); + helpers.formatApiResponse(200, res, roomObject); }; -Chats.post = async (req, res) => { - const messageObj = await api.chats.post(req, { - ...req.body, - roomId: req.params.roomId, - }); +Chats.post = async (request, res) => { + const messageObject = await api.chats.post(request, { + ...request.body, + roomId: request.params.roomId, + }); - helpers.formatApiResponse(200, res, messageObj); + helpers.formatApiResponse(200, res, messageObject); }; -Chats.rename = async (req, res) => { - const roomObj = await api.chats.rename(req, { - ...req.body, - roomId: req.params.roomId, - }); +Chats.rename = async (request, res) => { + const roomObject = await api.chats.rename(request, { + ...request.body, + roomId: request.params.roomId, + }); - helpers.formatApiResponse(200, res, roomObj); + helpers.formatApiResponse(200, res, roomObject); }; -Chats.users = async (req, res) => { - const users = await api.chats.users(req, { - ...req.params, - }); - helpers.formatApiResponse(200, res, users); +Chats.users = async (request, res) => { + const users = await api.chats.users(request, { + ...request.params, + }); + helpers.formatApiResponse(200, res, users); }; -Chats.invite = async (req, res) => { - const users = await api.chats.invite(req, { - ...req.body, - roomId: req.params.roomId, - }); +Chats.invite = async (request, res) => { + const users = await api.chats.invite(request, { + ...request.body, + roomId: request.params.roomId, + }); - helpers.formatApiResponse(200, res, users); + helpers.formatApiResponse(200, res, users); }; -Chats.kick = async (req, res) => { - const users = await api.chats.kick(req, { - ...req.body, - roomId: req.params.roomId, - }); +Chats.kick = async (request, res) => { + const users = await api.chats.kick(request, { + ...request.body, + roomId: request.params.roomId, + }); - helpers.formatApiResponse(200, res, users); + helpers.formatApiResponse(200, res, users); }; -Chats.kickUser = async (req, res) => { - req.body.uids = [req.params.uid]; - const users = await api.chats.kick(req, { - ...req.body, - roomId: req.params.roomId, - }); +Chats.kickUser = async (request, res) => { + request.body.uids = [request.params.uid]; + const users = await api.chats.kick(request, { + ...request.body, + roomId: request.params.roomId, + }); - helpers.formatApiResponse(200, res, users); + helpers.formatApiResponse(200, res, users); }; Chats.messages = {}; -Chats.messages.list = async (req, res) => { - const messages = await messaging.getMessages({ - callerUid: req.uid, - uid: req.query.uid || req.uid, - roomId: req.params.roomId, - start: parseInt(req.query.start, 10) || 0, - count: 50, - }); - - helpers.formatApiResponse(200, res, { messages }); +Chats.messages.list = async (request, res) => { + const messages = await messaging.getMessages({ + callerUid: request.uid, + uid: request.query.uid || request.uid, + roomId: request.params.roomId, + start: Number.parseInt(request.query.start, 10) || 0, + count: 50, + }); + + helpers.formatApiResponse(200, res, {messages}); }; -Chats.messages.get = async (req, res) => { - const messages = await messaging.getMessagesData([req.params.mid], req.uid, req.params.roomId, false); - helpers.formatApiResponse(200, res, messages.pop()); +Chats.messages.get = async (request, res) => { + const messages = await messaging.getMessagesData([request.params.mid], request.uid, request.params.roomId, false); + helpers.formatApiResponse(200, res, messages.pop()); }; -Chats.messages.edit = async (req, res) => { - await messaging.canEdit(req.params.mid, req.uid); - await messaging.editMessage(req.uid, req.params.mid, req.params.roomId, req.body.message); +Chats.messages.edit = async (request, res) => { + await messaging.canEdit(request.params.mid, request.uid); + await messaging.editMessage(request.uid, request.params.mid, request.params.roomId, request.body.message); - const messages = await messaging.getMessagesData([req.params.mid], req.uid, req.params.roomId, false); - helpers.formatApiResponse(200, res, messages.pop()); + const messages = await messaging.getMessagesData([request.params.mid], request.uid, request.params.roomId, false); + helpers.formatApiResponse(200, res, messages.pop()); }; -Chats.messages.delete = async (req, res) => { - await messaging.canDelete(req.params.mid, req.uid); - await messaging.deleteMessage(req.params.mid, req.uid); +Chats.messages.delete = async (request, res) => { + await messaging.canDelete(request.params.mid, request.uid); + await messaging.deleteMessage(request.params.mid, request.uid); - helpers.formatApiResponse(200, res); + helpers.formatApiResponse(200, res); }; -Chats.messages.restore = async (req, res) => { - await messaging.canDelete(req.params.mid, req.uid); - await messaging.restoreMessage(req.params.mid, req.uid); +Chats.messages.restore = async (request, res) => { + await messaging.canDelete(request.params.mid, request.uid); + await messaging.restoreMessage(request.params.mid, request.uid); - helpers.formatApiResponse(200, res); + helpers.formatApiResponse(200, res); }; diff --git a/src/controllers/write/files.js b/src/controllers/write/files.js index f3f33db..acb214b 100644 --- a/src/controllers/write/files.js +++ b/src/controllers/write/files.js @@ -1,16 +1,16 @@ 'use strict'; -const fs = require('fs').promises; +const fs = require('node:fs').promises; const helpers = require('../helpers'); const Files = module.exports; -Files.delete = async (req, res) => { - await fs.unlink(res.locals.cleanedPath); - helpers.formatApiResponse(200, res); +Files.delete = async (request, res) => { + await fs.unlink(res.locals.cleanedPath); + helpers.formatApiResponse(200, res); }; -Files.createFolder = async (req, res) => { - await fs.mkdir(res.locals.folderPath); - helpers.formatApiResponse(200, res); +Files.createFolder = async (request, res) => { + await fs.mkdir(res.locals.folderPath); + helpers.formatApiResponse(200, res); }; diff --git a/src/controllers/write/flags.js b/src/controllers/write/flags.js index e199019..7b9329e 100644 --- a/src/controllers/write/flags.js +++ b/src/controllers/write/flags.js @@ -7,47 +7,47 @@ const helpers = require('../helpers'); const Flags = module.exports; -Flags.create = async (req, res) => { - const flagObj = await api.flags.create(req, { ...req.body }); - helpers.formatApiResponse(200, res, await user.isPrivileged(req.uid) ? flagObj : undefined); +Flags.create = async (request, res) => { + const flagObject = await api.flags.create(request, {...request.body}); + helpers.formatApiResponse(200, res, await user.isPrivileged(request.uid) ? flagObject : undefined); }; -Flags.get = async (req, res) => { - const isPrivileged = await user.isPrivileged(req.uid); - if (!isPrivileged) { - return helpers.formatApiResponse(403, res); - } +Flags.get = async (request, res) => { + const isPrivileged = await user.isPrivileged(request.uid); + if (!isPrivileged) { + return helpers.formatApiResponse(403, res); + } - helpers.formatApiResponse(200, res, await flags.get(req.params.flagId)); + helpers.formatApiResponse(200, res, await flags.get(request.params.flagId)); }; -Flags.update = async (req, res) => { - const history = await api.flags.update(req, { - flagId: req.params.flagId, - ...req.body, - }); +Flags.update = async (request, res) => { + const history = await api.flags.update(request, { + flagId: request.params.flagId, + ...request.body, + }); - helpers.formatApiResponse(200, res, { history }); + helpers.formatApiResponse(200, res, {history}); }; -Flags.delete = async (req, res) => { - await flags.purge([req.params.flagId]); - helpers.formatApiResponse(200, res); +Flags.delete = async (request, res) => { + await flags.purge([request.params.flagId]); + helpers.formatApiResponse(200, res); }; -Flags.appendNote = async (req, res) => { - const payload = await api.flags.appendNote(req, { - flagId: req.params.flagId, - ...req.body, - }); +Flags.appendNote = async (request, res) => { + const payload = await api.flags.appendNote(request, { + flagId: request.params.flagId, + ...request.body, + }); - helpers.formatApiResponse(200, res, payload); + helpers.formatApiResponse(200, res, payload); }; -Flags.deleteNote = async (req, res) => { - const payload = await api.flags.deleteNote(req, { - ...req.params, - }); +Flags.deleteNote = async (request, res) => { + const payload = await api.flags.deleteNote(request, { + ...request.params, + }); - helpers.formatApiResponse(200, res, payload); + helpers.formatApiResponse(200, res, payload); }; diff --git a/src/controllers/write/groups.js b/src/controllers/write/groups.js index 26b5e6f..a5933e8 100644 --- a/src/controllers/write/groups.js +++ b/src/controllers/write/groups.js @@ -1,49 +1,48 @@ 'use strict'; const api = require('../../api'); - const helpers = require('../helpers'); const Groups = module.exports; -Groups.exists = async (req, res) => { - helpers.formatApiResponse(200, res); +Groups.exists = async (request, res) => { + helpers.formatApiResponse(200, res); }; -Groups.create = async (req, res) => { - const groupObj = await api.groups.create(req, req.body); - helpers.formatApiResponse(200, res, groupObj); +Groups.create = async (request, res) => { + const groupObject = await api.groups.create(request, request.body); + helpers.formatApiResponse(200, res, groupObject); }; -Groups.update = async (req, res) => { - const groupObj = await api.groups.update(req, { - ...req.body, - slug: req.params.slug, - }); - helpers.formatApiResponse(200, res, groupObj); +Groups.update = async (request, res) => { + const groupObject = await api.groups.update(request, { + ...request.body, + slug: request.params.slug, + }); + helpers.formatApiResponse(200, res, groupObject); }; -Groups.delete = async (req, res) => { - await api.groups.delete(req, req.params); - helpers.formatApiResponse(200, res); +Groups.delete = async (request, res) => { + await api.groups.delete(request, request.params); + helpers.formatApiResponse(200, res); }; -Groups.join = async (req, res) => { - await api.groups.join(req, req.params); - helpers.formatApiResponse(200, res); +Groups.join = async (request, res) => { + await api.groups.join(request, request.params); + helpers.formatApiResponse(200, res); }; -Groups.leave = async (req, res) => { - await api.groups.leave(req, req.params); - helpers.formatApiResponse(200, res); +Groups.leave = async (request, res) => { + await api.groups.leave(request, request.params); + helpers.formatApiResponse(200, res); }; -Groups.grant = async (req, res) => { - await api.groups.grant(req, req.params); - helpers.formatApiResponse(200, res); +Groups.grant = async (request, res) => { + await api.groups.grant(request, request.params); + helpers.formatApiResponse(200, res); }; -Groups.rescind = async (req, res) => { - await api.groups.rescind(req, req.params); - helpers.formatApiResponse(200, res); +Groups.rescind = async (request, res) => { + await api.groups.rescind(request, request.params); + helpers.formatApiResponse(200, res); }; diff --git a/src/controllers/write/posts.js b/src/controllers/write/posts.js index 7bbd4a7..12b110d 100644 --- a/src/controllers/write/posts.js +++ b/src/controllers/write/posts.js @@ -1,165 +1,163 @@ 'use strict'; // For JS requirement -const assert = require('assert'); - +const assert = require('node:assert'); const posts = require('../../posts'); const privileges = require('../../privileges'); - const api = require('../../api'); const helpers = require('../helpers'); const apiHelpers = require('../../api/helpers'); const Posts = module.exports; -Posts.get = async (req, res) => { - helpers.formatApiResponse(200, res, await api.posts.get(req, { pid: req.params.pid })); +Posts.get = async (request, res) => { + helpers.formatApiResponse(200, res, await api.posts.get(request, {pid: request.params.pid})); }; -Posts.edit = async (req, res) => { - const editResult = await api.posts.edit(req, { - ...req.body, - pid: req.params.pid, - uid: req.uid, - req: apiHelpers.buildReqObject(req), - }); +Posts.edit = async (request, res) => { + const editResult = await api.posts.edit(request, { + ...request.body, + pid: request.params.pid, + uid: request.uid, + req: apiHelpers.buildReqObject(request), + }); - helpers.formatApiResponse(200, res, editResult); + helpers.formatApiResponse(200, res, editResult); }; -Posts.purge = async (req, res) => { - await api.posts.purge(req, { pid: req.params.pid }); - helpers.formatApiResponse(200, res); +Posts.purge = async (request, res) => { + await api.posts.purge(request, {pid: request.params.pid}); + helpers.formatApiResponse(200, res); }; -Posts.restore = async (req, res) => { - await api.posts.restore(req, { pid: req.params.pid }); - helpers.formatApiResponse(200, res); +Posts.restore = async (request, res) => { + await api.posts.restore(request, {pid: request.params.pid}); + helpers.formatApiResponse(200, res); }; -Posts.delete = async (req, res) => { - await api.posts.delete(req, { pid: req.params.pid }); - helpers.formatApiResponse(200, res); +Posts.delete = async (request, res) => { + await api.posts.delete(request, {pid: request.params.pid}); + helpers.formatApiResponse(200, res); }; -Posts.move = async (req, res) => { - await api.posts.move(req, { - pid: req.params.pid, - tid: req.body.tid, - }); - helpers.formatApiResponse(200, res); +Posts.move = async (request, res) => { + await api.posts.move(request, { + pid: request.params.pid, + tid: request.body.tid, + }); + helpers.formatApiResponse(200, res); }; -async function mock(req) { - const tid = await posts.getPostField(req.params.pid, 'tid'); - return { pid: req.params.pid, room_id: `topic_${tid}` }; +async function mock(request) { + const tid = await posts.getPostField(request.params.pid, 'tid'); + return {pid: request.params.pid, room_id: `topic_${tid}`}; } -Posts.vote = async (req, res) => { - const data = await mock(req); - if (req.body.delta > 0) { - await api.posts.upvote(req, data); - } else if (req.body.delta < 0) { - await api.posts.downvote(req, data); - } else { - await api.posts.unvote(req, data); - } +Posts.vote = async (request, res) => { + const data = await mock(request); + if (request.body.delta > 0) { + await api.posts.upvote(request, data); + } else if (request.body.delta < 0) { + await api.posts.downvote(request, data); + } else { + await api.posts.unvote(request, data); + } - helpers.formatApiResponse(200, res); + helpers.formatApiResponse(200, res); }; -Posts.unvote = async (req, res) => { - const data = await mock(req); - await api.posts.unvote(req, data); - helpers.formatApiResponse(200, res); +Posts.unvote = async (request, res) => { + const data = await mock(request); + await api.posts.unvote(request, data); + helpers.formatApiResponse(200, res); }; -Posts.bookmark = async (req, res) => { - const data = await mock(req); - await api.posts.bookmark(req, data); - helpers.formatApiResponse(200, res); +Posts.bookmark = async (request, res) => { + const data = await mock(request); + await api.posts.bookmark(request, data); + helpers.formatApiResponse(200, res); }; -Posts.unbookmark = async (req, res) => { - const data = await mock(req); - await api.posts.unbookmark(req, data); - helpers.formatApiResponse(200, res); +Posts.unbookmark = async (request, res) => { + const data = await mock(request); + await api.posts.unbookmark(request, data); + helpers.formatApiResponse(200, res); }; -Posts.pin = async (req, res) => { - /* +Posts.pin = async (request, res) => { + /* Parameters: a request object with information about the post to pin, and a response object to write the response to Returns: nothing, but writes into res. */ - const data = await mock(req); + const data = await mock(request); - /* + /* Test that request has the needed fields */ - assert(data.hasOwnProperty('pid'), 'Pin request has no pid field'); - assert(!(isNaN(data.pid))); + assert(data.hasOwnProperty('pid'), 'Pin request has no pid field'); + assert(!(isNaN(data.pid))); - await api.posts.pin(req, data); - helpers.formatApiResponse(200, res); + await api.posts.pin(request, data); + helpers.formatApiResponse(200, res); }; -Posts.unpin = async (req, res) => { - /* +Posts.unpin = async (request, res) => { + /* Parameters: a request object with information about the post to unpin, and a response object to write the response to Returns: nothing, but writes into res. */ - const data = await mock(req); + const data = await mock(request); - /* + /* Test that request has the needed fields */ - assert(data.hasOwnProperty('pid'), 'Unpin request has no pid field'); - assert(!(isNaN(data.pid))); + assert(data.hasOwnProperty('pid'), 'Unpin request has no pid field'); + assert(!(isNaN(data.pid))); - await api.posts.unpin(req, data); - helpers.formatApiResponse(200, res); + await api.posts.unpin(request, data); + helpers.formatApiResponse(200, res); }; -Posts.resolve = async (req, res) => { - const data = await mock(req); - await api.posts.resolve(req, data); - helpers.formatApiResponse(200, res); +Posts.resolve = async (request, res) => { + const data = await mock(request); + await api.posts.resolve(request, data); + helpers.formatApiResponse(200, res); }; -Posts.getDiffs = async (req, res) => { - helpers.formatApiResponse(200, res, await api.posts.getDiffs(req, { ...req.params })); +Posts.getDiffs = async (request, res) => { + helpers.formatApiResponse(200, res, await api.posts.getDiffs(request, {...request.params})); }; -Posts.loadDiff = async (req, res) => { - helpers.formatApiResponse(200, res, await api.posts.loadDiff(req, { ...req.params })); +Posts.loadDiff = async (request, res) => { + helpers.formatApiResponse(200, res, await api.posts.loadDiff(request, {...request.params})); }; -Posts.restoreDiff = async (req, res) => { - helpers.formatApiResponse(200, res, await api.posts.restoreDiff(req, { ...req.params })); +Posts.restoreDiff = async (request, res) => { + helpers.formatApiResponse(200, res, await api.posts.restoreDiff(request, {...request.params})); }; -Posts.deleteDiff = async (req, res) => { - if (!parseInt(req.params.pid, 10)) { - throw new Error('[[error:invalid-data]]'); - } +Posts.deleteDiff = async (request, res) => { + if (!Number.parseInt(request.params.pid, 10)) { + throw new Error('[[error:invalid-data]]'); + } - const cid = await posts.getCidByPid(req.params.pid); - const [isAdmin, isModerator] = await Promise.all([ - privileges.users.isAdministrator(req.uid), - privileges.users.isModerator(req.uid, cid), - ]); + const cid = await posts.getCidByPid(request.params.pid); + const [isAdmin, isModerator] = await Promise.all([ + privileges.users.isAdministrator(request.uid), + privileges.users.isModerator(request.uid, cid), + ]); - if (!(isAdmin || isModerator)) { - return helpers.formatApiResponse(403, res, new Error('[[error:no-privileges]]')); - } + if (!(isAdmin || isModerator)) { + return helpers.formatApiResponse(403, res, new Error('[[error:no-privileges]]')); + } - await posts.diffs.delete(req.params.pid, req.params.timestamp, req.uid); + await posts.diffs.delete(request.params.pid, request.params.timestamp, request.uid); - helpers.formatApiResponse(200, res, await api.posts.getDiffs(req, { ...req.params })); + helpers.formatApiResponse(200, res, await api.posts.getDiffs(request, {...request.params})); }; diff --git a/src/controllers/write/topics.js b/src/controllers/write/topics.js index d960b1e..c8f25f2 100644 --- a/src/controllers/write/topics.js +++ b/src/controllers/write/topics.js @@ -1,252 +1,254 @@ 'use strict'; const validator = require('validator'); - const db = require('../../database'); const api = require('../../api'); const topics = require('../../topics'); const privileges = require('../../privileges'); - const helpers = require('../helpers'); const middleware = require('../../middleware'); const uploadsController = require('../uploads'); const Topics = module.exports; -Topics.get = async (req, res) => { - helpers.formatApiResponse(200, res, await api.topics.get(req, req.params)); -}; - -Topics.create = async (req, res) => { - const id = await lockPosting(req, '[[error:already-posting]]'); - try { - const payload = await api.topics.create(req, req.body); - if (payload.queued) { - helpers.formatApiResponse(202, res, payload); - } else { - helpers.formatApiResponse(200, res, payload); - } - } finally { - await db.deleteObjectField('locks', id); - } -}; - -Topics.reply = async (req, res) => { - const id = await lockPosting(req, '[[error:already-posting]]'); - try { - const payload = await api.topics.reply(req, { ...req.body, tid: req.params.tid }); - helpers.formatApiResponse(200, res, payload); - } finally { - await db.deleteObjectField('locks', id); - } -}; - -async function lockPosting(req, error) { - const id = req.uid > 0 ? req.uid : req.sessionID; - const value = `posting${id}`; - const count = await db.incrObjectField('locks', value); - if (count > 1) { - throw new Error(error); - } - return value; +Topics.get = async (request, res) => { + helpers.formatApiResponse(200, res, await api.topics.get(request, request.params)); +}; + +Topics.create = async (request, res) => { + const id = await lockPosting(request, '[[error:already-posting]]'); + try { + const payload = await api.topics.create(request, request.body); + if (payload.queued) { + helpers.formatApiResponse(202, res, payload); + } else { + helpers.formatApiResponse(200, res, payload); + } + } finally { + await db.deleteObjectField('locks', id); + } +}; + +Topics.reply = async (request, res) => { + const id = await lockPosting(request, '[[error:already-posting]]'); + try { + const payload = await api.topics.reply(request, {...request.body, tid: request.params.tid}); + helpers.formatApiResponse(200, res, payload); + } finally { + await db.deleteObjectField('locks', id); + } +}; + +async function lockPosting(request, error) { + const id = request.uid > 0 ? request.uid : request.sessionID; + const value = `posting${id}`; + const count = await db.incrObjectField('locks', value); + if (count > 1) { + throw new Error(error); + } + + return value; } -Topics.delete = async (req, res) => { - await api.topics.delete(req, { tids: [req.params.tid] }); - helpers.formatApiResponse(200, res); +Topics.delete = async (request, res) => { + await api.topics.delete(request, {tids: [request.params.tid]}); + helpers.formatApiResponse(200, res); }; -Topics.restore = async (req, res) => { - await api.topics.restore(req, { tids: [req.params.tid] }); - helpers.formatApiResponse(200, res); +Topics.restore = async (request, res) => { + await api.topics.restore(request, {tids: [request.params.tid]}); + helpers.formatApiResponse(200, res); }; -Topics.private = async (req, res) => { - await api.topics.private(req, { tids: [req.params.tid] }); - helpers.formatApiResponse(200, res); +Topics.private = async (request, res) => { + await api.topics.private(request, {tids: [request.params.tid]}); + helpers.formatApiResponse(200, res); }; -Topics.public = async (req, res) => { - await api.topics.public(req, { tids: [req.params.tid] }); - helpers.formatApiResponse(200, res); +Topics.public = async (request, res) => { + await api.topics.public(request, {tids: [request.params.tid]}); + helpers.formatApiResponse(200, res); }; -Topics.purge = async (req, res) => { - await api.topics.purge(req, { tids: [req.params.tid] }); - helpers.formatApiResponse(200, res); +Topics.purge = async (request, res) => { + await api.topics.purge(request, {tids: [request.params.tid]}); + helpers.formatApiResponse(200, res); }; -Topics.pin = async (req, res) => { - // Pin expiry was not available w/ sockets hence not included in api lib method - if (req.body.expiry) { - await topics.tools.setPinExpiry(req.params.tid, req.body.expiry, req.uid); - } - await api.topics.pin(req, { tids: [req.params.tid] }); +Topics.pin = async (request, res) => { + // Pin expiry was not available w/ sockets hence not included in api lib method + if (request.body.expiry) { + await topics.tools.setPinExpiry(request.params.tid, request.body.expiry, request.uid); + } + + await api.topics.pin(request, {tids: [request.params.tid]}); - helpers.formatApiResponse(200, res); + helpers.formatApiResponse(200, res); }; -Topics.unpin = async (req, res) => { - await api.topics.unpin(req, { tids: [req.params.tid] }); - helpers.formatApiResponse(200, res); +Topics.unpin = async (request, res) => { + await api.topics.unpin(request, {tids: [request.params.tid]}); + helpers.formatApiResponse(200, res); }; -Topics.lock = async (req, res) => { - await api.topics.lock(req, { tids: [req.params.tid] }); - helpers.formatApiResponse(200, res); +Topics.lock = async (request, res) => { + await api.topics.lock(request, {tids: [request.params.tid]}); + helpers.formatApiResponse(200, res); }; -Topics.unlock = async (req, res) => { - await api.topics.unlock(req, { tids: [req.params.tid] }); - helpers.formatApiResponse(200, res); +Topics.unlock = async (request, res) => { + await api.topics.unlock(request, {tids: [request.params.tid]}); + helpers.formatApiResponse(200, res); }; -Topics.follow = async (req, res) => { - await api.topics.follow(req, req.params); - helpers.formatApiResponse(200, res); +Topics.follow = async (request, res) => { + await api.topics.follow(request, request.params); + helpers.formatApiResponse(200, res); }; -Topics.ignore = async (req, res) => { - await api.topics.ignore(req, req.params); - helpers.formatApiResponse(200, res); +Topics.ignore = async (request, res) => { + await api.topics.ignore(request, request.params); + helpers.formatApiResponse(200, res); }; -Topics.unfollow = async (req, res) => { - await api.topics.unfollow(req, req.params); - helpers.formatApiResponse(200, res); +Topics.unfollow = async (request, res) => { + await api.topics.unfollow(request, request.params); + helpers.formatApiResponse(200, res); }; -Topics.addTags = async (req, res) => { - if (!await privileges.topics.canEdit(req.params.tid, req.user.uid)) { - return helpers.formatApiResponse(403, res); - } - const cid = await topics.getTopicField(req.params.tid, 'cid'); - await topics.validateTags(req.body.tags, cid, req.user.uid, req.params.tid); - const tags = await topics.filterTags(req.body.tags); +Topics.addTags = async (request, res) => { + if (!await privileges.topics.canEdit(request.params.tid, request.user.uid)) { + return helpers.formatApiResponse(403, res); + } - await topics.addTags(tags, [req.params.tid]); - helpers.formatApiResponse(200, res); + const cid = await topics.getTopicField(request.params.tid, 'cid'); + await topics.validateTags(request.body.tags, cid, request.user.uid, request.params.tid); + const tags = await topics.filterTags(request.body.tags); + + await topics.addTags(tags, [request.params.tid]); + helpers.formatApiResponse(200, res); }; -Topics.deleteTags = async (req, res) => { - if (!await privileges.topics.canEdit(req.params.tid, req.user.uid)) { - return helpers.formatApiResponse(403, res); - } +Topics.deleteTags = async (request, res) => { + if (!await privileges.topics.canEdit(request.params.tid, request.user.uid)) { + return helpers.formatApiResponse(403, res); + } - await topics.deleteTopicTags(req.params.tid); - helpers.formatApiResponse(200, res); + await topics.deleteTopicTags(request.params.tid); + helpers.formatApiResponse(200, res); }; -Topics.getThumbs = async (req, res) => { - if (isFinite(req.params.tid)) { // post_uuids can be passed in occasionally, in that case no checks are necessary - const [exists, canRead] = await Promise.all([ - topics.exists(req.params.tid), - privileges.topics.can('topics:read', req.params.tid, req.uid), - ]); - if (!exists || !canRead) { - return helpers.formatApiResponse(403, res); - } - } +Topics.getThumbs = async (request, res) => { + if (isFinite(request.params.tid)) { // Post_uuids can be passed in occasionally, in that case no checks are necessary + const [exists, canRead] = await Promise.all([ + topics.exists(request.params.tid), + privileges.topics.can('topics:read', request.params.tid, request.uid), + ]); + if (!exists || !canRead) { + return helpers.formatApiResponse(403, res); + } + } - helpers.formatApiResponse(200, res, await topics.thumbs.get(req.params.tid)); + helpers.formatApiResponse(200, res, await topics.thumbs.get(request.params.tid)); }; -Topics.addThumb = async (req, res) => { - await checkThumbPrivileges({ tid: req.params.tid, uid: req.user.uid, res }); - if (res.headersSent) { - return; - } +Topics.addThumb = async (request, res) => { + await checkThumbPrivileges({tid: request.params.tid, uid: request.user.uid, res}); + if (res.headersSent) { + return; + } - const files = await uploadsController.uploadThumb(req, res); // response is handled here + const files = await uploadsController.uploadThumb(request, res); // Response is handled here - // Add uploaded files to topic zset - if (files && files.length) { - await Promise.all(files.map(async (fileObj) => { - await topics.thumbs.associate({ - id: req.params.tid, - path: fileObj.path || fileObj.url, - }); - })); - } + // Add uploaded files to topic zset + if (files && files.length > 0) { + await Promise.all(files.map(async fileObject => { + await topics.thumbs.associate({ + id: request.params.tid, + path: fileObject.path || fileObject.url, + }); + })); + } }; -Topics.migrateThumbs = async (req, res) => { - await Promise.all([ - checkThumbPrivileges({ tid: req.params.tid, uid: req.user.uid, res }), - checkThumbPrivileges({ tid: req.body.tid, uid: req.user.uid, res }), - ]); - if (res.headersSent) { - return; - } +Topics.migrateThumbs = async (request, res) => { + await Promise.all([ + checkThumbPrivileges({tid: request.params.tid, uid: request.user.uid, res}), + checkThumbPrivileges({tid: request.body.tid, uid: request.user.uid, res}), + ]); + if (res.headersSent) { + return; + } - await topics.thumbs.migrate(req.params.tid, req.body.tid); - helpers.formatApiResponse(200, res); + await topics.thumbs.migrate(request.params.tid, request.body.tid); + helpers.formatApiResponse(200, res); }; -Topics.deleteThumb = async (req, res) => { - if (!req.body.path.startsWith('http')) { - await middleware.assert.path(req, res, () => {}); - if (res.headersSent) { - return; - } - } +Topics.deleteThumb = async (request, res) => { + if (!request.body.path.startsWith('http')) { + await middleware.assert.path(request, res, () => {}); + if (res.headersSent) { + return; + } + } - await checkThumbPrivileges({ tid: req.params.tid, uid: req.user.uid, res }); - if (res.headersSent) { - return; - } + await checkThumbPrivileges({tid: request.params.tid, uid: request.user.uid, res}); + if (res.headersSent) { + return; + } - await topics.thumbs.delete(req.params.tid, req.body.path); - helpers.formatApiResponse(200, res, await topics.thumbs.get(req.params.tid)); + await topics.thumbs.delete(request.params.tid, request.body.path); + helpers.formatApiResponse(200, res, await topics.thumbs.get(request.params.tid)); }; -Topics.reorderThumbs = async (req, res) => { - await checkThumbPrivileges({ tid: req.params.tid, uid: req.user.uid, res }); - if (res.headersSent) { - return; - } - - const exists = await topics.thumbs.exists(req.params.tid, req.body.path); - if (!exists) { - return helpers.formatApiResponse(404, res); - } - - await topics.thumbs.associate({ - id: req.params.tid, - path: req.body.path, - score: req.body.order, - }); - helpers.formatApiResponse(200, res); -}; - -async function checkThumbPrivileges({ tid, uid, res }) { - // req.params.tid could be either a tid (pushing a new thumb to an existing topic) - // or a post UUID (a new topic being composed) - const isUUID = validator.isUUID(tid); - - // Sanity-check the tid if it's strictly not a uuid - if (!isUUID && (isNaN(parseInt(tid, 10)) || !await topics.exists(tid))) { - return helpers.formatApiResponse(404, res, new Error('[[error:no-topic]]')); - } - - // While drafts are not protected, tids are - if (!isUUID && !await privileges.topics.canEdit(tid, uid)) { - return helpers.formatApiResponse(403, res, new Error('[[error:no-privileges]]')); - } +Topics.reorderThumbs = async (request, res) => { + await checkThumbPrivileges({tid: request.params.tid, uid: request.user.uid, res}); + if (res.headersSent) { + return; + } + + const exists = await topics.thumbs.exists(request.params.tid, request.body.path); + if (!exists) { + return helpers.formatApiResponse(404, res); + } + + await topics.thumbs.associate({ + id: request.params.tid, + path: request.body.path, + score: request.body.order, + }); + helpers.formatApiResponse(200, res); +}; + +async function checkThumbPrivileges({tid, uid, res}) { + // Req.params.tid could be either a tid (pushing a new thumb to an existing topic) + // or a post UUID (a new topic being composed) + const isUUID = validator.isUUID(tid); + + // Sanity-check the tid if it's strictly not a uuid + if (!isUUID && (isNaN(Number.parseInt(tid, 10)) || !await topics.exists(tid))) { + return helpers.formatApiResponse(404, res, new Error('[[error:no-topic]]')); + } + + // While drafts are not protected, tids are + if (!isUUID && !await privileges.topics.canEdit(tid, uid)) { + return helpers.formatApiResponse(403, res, new Error('[[error:no-privileges]]')); + } } -Topics.getEvents = async (req, res) => { - if (!await privileges.topics.can('topics:read', req.params.tid, req.uid)) { - return helpers.formatApiResponse(403, res); - } +Topics.getEvents = async (request, res) => { + if (!await privileges.topics.can('topics:read', request.params.tid, request.uid)) { + return helpers.formatApiResponse(403, res); + } - helpers.formatApiResponse(200, res, await topics.events.get(req.params.tid, req.uid)); + helpers.formatApiResponse(200, res, await topics.events.get(request.params.tid, request.uid)); }; -Topics.deleteEvent = async (req, res) => { - if (!await privileges.topics.isAdminOrMod(req.params.tid, req.uid)) { - return helpers.formatApiResponse(403, res); - } - await topics.events.purge(req.params.tid, [req.params.eventId]); - helpers.formatApiResponse(200, res); +Topics.deleteEvent = async (request, res) => { + if (!await privileges.topics.isAdminOrMod(request.params.tid, request.uid)) { + return helpers.formatApiResponse(403, res); + } + + await topics.events.purge(request.params.tid, [request.params.eventId]); + helpers.formatApiResponse(200, res); }; diff --git a/src/controllers/write/users.js b/src/controllers/write/users.js index 84dfc15..059df76 100644 --- a/src/controllers/write/users.js +++ b/src/controllers/write/users.js @@ -1,11 +1,10 @@ 'use strict'; -const util = require('util'); +const util = require('node:util'); const nconf = require('nconf'); -const path = require('path'); -const crypto = require('crypto'); -const fs = require('fs').promises; - +const path = require('node:path'); +const crypto = require('node:crypto'); +const fs = require('node:fs').promises; const db = require('../../database'); const api = require('../../api'); const groups = require('../../groups'); @@ -13,344 +12,344 @@ const meta = require('../../meta'); const privileges = require('../../privileges'); const user = require('../../user'); const utils = require('../../utils'); - const helpers = require('../helpers'); const Users = module.exports; const exportMetadata = new Map([ - ['posts', ['csv', 'text/csv']], - ['uploads', ['zip', 'application/zip']], - ['profile', ['json', 'application/json']], + ['posts', ['csv', 'text/csv']], + ['uploads', ['zip', 'application/zip']], + ['profile', ['json', 'application/json']], ]); const hasAdminPrivilege = async (uid, privilege) => { - const ok = await privileges.admin.can(`admin:${privilege}`, uid); - if (!ok) { - throw new Error('[[error:no-privileges]]'); - } + const ok = await privileges.admin.can(`admin:${privilege}`, uid); + if (!ok) { + throw new Error('[[error:no-privileges]]'); + } }; -Users.redirectBySlug = async (req, res) => { - const uid = await user.getUidByUserslug(req.params.userslug); +Users.redirectBySlug = async (request, res) => { + const uid = await user.getUidByUserslug(request.params.userslug); - if (uid) { - const path = req.path.split('/').slice(3).join('/'); - const urlObj = new URL(nconf.get('url') + req.url); - res.redirect(308, nconf.get('relative_path') + encodeURI(`/api/v3/users/${uid}/${path}${urlObj.search}`)); - } else { - helpers.formatApiResponse(404, res); - } + if (uid) { + const path = request.path.split('/').slice(3).join('/'); + const urlObject = new URL(nconf.get('url') + request.url); + res.redirect(308, nconf.get('relative_path') + encodeURI(`/api/v3/users/${uid}/${path}${urlObject.search}`)); + } else { + helpers.formatApiResponse(404, res); + } }; -Users.create = async (req, res) => { - await hasAdminPrivilege(req.uid, 'users'); - const userObj = await api.users.create(req, req.body); - helpers.formatApiResponse(200, res, userObj); +Users.create = async (request, res) => { + await hasAdminPrivilege(request.uid, 'users'); + const userObject = await api.users.create(request, request.body); + helpers.formatApiResponse(200, res, userObject); }; -Users.exists = async (req, res) => { - helpers.formatApiResponse(200, res); +Users.exists = async (request, res) => { + helpers.formatApiResponse(200, res); }; -Users.get = async (req, res) => { - const userData = await user.getUserData(req.params.uid); - const publicUserData = await user.hidePrivateData(userData, req.uid); - helpers.formatApiResponse(200, res, publicUserData); +Users.get = async (request, res) => { + const userData = await user.getUserData(request.params.uid); + const publicUserData = await user.hidePrivateData(userData, request.uid); + helpers.formatApiResponse(200, res, publicUserData); }; -Users.update = async (req, res) => { - const userObj = await api.users.update(req, { ...req.body, uid: req.params.uid }); - helpers.formatApiResponse(200, res, userObj); +Users.update = async (request, res) => { + const userObject = await api.users.update(request, {...request.body, uid: request.params.uid}); + helpers.formatApiResponse(200, res, userObject); }; -Users.delete = async (req, res) => { - await api.users.delete(req, { ...req.params, password: req.body.password }); - helpers.formatApiResponse(200, res); +Users.delete = async (request, res) => { + await api.users.delete(request, {...request.params, password: request.body.password}); + helpers.formatApiResponse(200, res); }; -Users.deleteContent = async (req, res) => { - await api.users.deleteContent(req, { ...req.params, password: req.body.password }); - helpers.formatApiResponse(200, res); +Users.deleteContent = async (request, res) => { + await api.users.deleteContent(request, {...request.params, password: request.body.password}); + helpers.formatApiResponse(200, res); }; -Users.deleteAccount = async (req, res) => { - await api.users.deleteAccount(req, { ...req.params, password: req.body.password }); - helpers.formatApiResponse(200, res); +Users.deleteAccount = async (request, res) => { + await api.users.deleteAccount(request, {...request.params, password: request.body.password}); + helpers.formatApiResponse(200, res); }; -Users.deleteMany = async (req, res) => { - await hasAdminPrivilege(req.uid, 'users'); - await api.users.deleteMany(req, req.body); - helpers.formatApiResponse(200, res); +Users.deleteMany = async (request, res) => { + await hasAdminPrivilege(request.uid, 'users'); + await api.users.deleteMany(request, request.body); + helpers.formatApiResponse(200, res); }; -Users.changePicture = async (req, res) => { - await api.users.changePicture(req, { ...req.body, uid: req.params.uid }); - helpers.formatApiResponse(200, res); +Users.changePicture = async (request, res) => { + await api.users.changePicture(request, {...request.body, uid: request.params.uid}); + helpers.formatApiResponse(200, res); }; -Users.updateSettings = async (req, res) => { - const settings = await api.users.updateSettings(req, { ...req.body, uid: req.params.uid }); - helpers.formatApiResponse(200, res, settings); +Users.updateSettings = async (request, res) => { + const settings = await api.users.updateSettings(request, {...request.body, uid: request.params.uid}); + helpers.formatApiResponse(200, res, settings); }; -Users.changePassword = async (req, res) => { - await api.users.changePassword(req, { ...req.body, uid: req.params.uid }); - helpers.formatApiResponse(200, res); +Users.changePassword = async (request, res) => { + await api.users.changePassword(request, {...request.body, uid: request.params.uid}); + helpers.formatApiResponse(200, res); }; -Users.follow = async (req, res) => { - await api.users.follow(req, req.params); - helpers.formatApiResponse(200, res); +Users.follow = async (request, res) => { + await api.users.follow(request, request.params); + helpers.formatApiResponse(200, res); }; -Users.unfollow = async (req, res) => { - await api.users.unfollow(req, req.params); - helpers.formatApiResponse(200, res); +Users.unfollow = async (request, res) => { + await api.users.unfollow(request, request.params); + helpers.formatApiResponse(200, res); }; -Users.ban = async (req, res) => { - await api.users.ban(req, { ...req.body, uid: req.params.uid }); - helpers.formatApiResponse(200, res); +Users.ban = async (request, res) => { + await api.users.ban(request, {...request.body, uid: request.params.uid}); + helpers.formatApiResponse(200, res); }; -Users.unban = async (req, res) => { - await api.users.unban(req, { ...req.body, uid: req.params.uid }); - helpers.formatApiResponse(200, res); +Users.unban = async (request, res) => { + await api.users.unban(request, {...request.body, uid: request.params.uid}); + helpers.formatApiResponse(200, res); }; -Users.mute = async (req, res) => { - await api.users.mute(req, { ...req.body, uid: req.params.uid }); - helpers.formatApiResponse(200, res); +Users.mute = async (request, res) => { + await api.users.mute(request, {...request.body, uid: request.params.uid}); + helpers.formatApiResponse(200, res); }; -Users.unmute = async (req, res) => { - await api.users.unmute(req, { ...req.body, uid: req.params.uid }); - helpers.formatApiResponse(200, res); +Users.unmute = async (request, res) => { + await api.users.unmute(request, {...request.body, uid: request.params.uid}); + helpers.formatApiResponse(200, res); }; -Users.generateToken = async (req, res) => { - await hasAdminPrivilege(req.uid, 'settings'); - if (parseInt(req.params.uid, 10) !== parseInt(req.user.uid, 10)) { - return helpers.formatApiResponse(401, res); - } - - const settings = await meta.settings.get('core.api'); - settings.tokens = settings.tokens || []; - - const newToken = { - token: utils.generateUUID(), - uid: req.user.uid, - description: req.body.description || '', - timestamp: Date.now(), - }; - settings.tokens.push(newToken); - await meta.settings.set('core.api', settings); - helpers.formatApiResponse(200, res, newToken); +Users.generateToken = async (request, res) => { + await hasAdminPrivilege(request.uid, 'settings'); + if (Number.parseInt(request.params.uid, 10) !== Number.parseInt(request.user.uid, 10)) { + return helpers.formatApiResponse(401, res); + } + + const settings = await meta.settings.get('core.api'); + settings.tokens = settings.tokens || []; + + const newToken = { + token: utils.generateUUID(), + uid: request.user.uid, + description: request.body.description || '', + timestamp: Date.now(), + }; + settings.tokens.push(newToken); + await meta.settings.set('core.api', settings); + helpers.formatApiResponse(200, res, newToken); }; -Users.deleteToken = async (req, res) => { - await hasAdminPrivilege(req.uid, 'settings'); - if (parseInt(req.params.uid, 10) !== parseInt(req.user.uid, 10)) { - return helpers.formatApiResponse(401, res); - } - - const settings = await meta.settings.get('core.api'); - const beforeLen = settings.tokens.length; - settings.tokens = settings.tokens.filter(tokenObj => tokenObj.token !== req.params.token); - if (beforeLen !== settings.tokens.length) { - await meta.settings.set('core.api', settings); - helpers.formatApiResponse(200, res); - } else { - helpers.formatApiResponse(404, res); - } +Users.deleteToken = async (request, res) => { + await hasAdminPrivilege(request.uid, 'settings'); + if (Number.parseInt(request.params.uid, 10) !== Number.parseInt(request.user.uid, 10)) { + return helpers.formatApiResponse(401, res); + } + + const settings = await meta.settings.get('core.api'); + const beforeLength = settings.tokens.length; + settings.tokens = settings.tokens.filter(tokenObject => tokenObject.token !== request.params.token); + if (beforeLength === settings.tokens.length) { + helpers.formatApiResponse(404, res); + } else { + await meta.settings.set('core.api', settings); + helpers.formatApiResponse(200, res); + } }; const getSessionAsync = util.promisify((sid, callback) => { - db.sessionStore.get(sid, (err, sessionObj) => callback(err, sessionObj || null)); + db.sessionStore.get(sid, (error, sessionObject) => callback(error, sessionObject || null)); }); -Users.revokeSession = async (req, res) => { - // Only admins or global mods (besides the user themselves) can revoke sessions - if (parseInt(req.params.uid, 10) !== req.uid && !await user.isAdminOrGlobalMod(req.uid)) { - return helpers.formatApiResponse(404, res); - } - - const sids = await db.getSortedSetRange(`uid:${req.params.uid}:sessions`, 0, -1); - let _id; - for (const sid of sids) { - /* eslint-disable no-await-in-loop */ - const sessionObj = await getSessionAsync(sid); - if (sessionObj && sessionObj.meta && sessionObj.meta.uuid === req.params.uuid) { - _id = sid; - break; - } - } - - if (!_id) { - throw new Error('[[error:no-session-found]]'); - } - - await user.auth.revokeSession(_id, req.params.uid); - helpers.formatApiResponse(200, res); +Users.revokeSession = async (request, res) => { + // Only admins or global mods (besides the user themselves) can revoke sessions + if (Number.parseInt(request.params.uid, 10) !== request.uid && !await user.isAdminOrGlobalMod(request.uid)) { + return helpers.formatApiResponse(404, res); + } + + const sids = await db.getSortedSetRange(`uid:${request.params.uid}:sessions`, 0, -1); + let _id; + for (const sid of sids) { + /* eslint-disable no-await-in-loop */ + const sessionObject = await getSessionAsync(sid); + if (sessionObject && sessionObject.meta && sessionObject.meta.uuid === request.params.uuid) { + _id = sid; + break; + } + } + + if (!_id) { + throw new Error('[[error:no-session-found]]'); + } + + await user.auth.revokeSession(_id, request.params.uid); + helpers.formatApiResponse(200, res); }; -Users.invite = async (req, res) => { - const { emails, groupsToJoin = [] } = req.body; - - if (!emails || !Array.isArray(groupsToJoin)) { - return helpers.formatApiResponse(400, res, new Error('[[error:invalid-data]]')); - } - - // For simplicity, this API route is restricted to self-use only. This can change if needed. - if (parseInt(req.user.uid, 10) !== parseInt(req.params.uid, 10)) { - return helpers.formatApiResponse(403, res, new Error('[[error:no-privileges]]')); - } - - const canInvite = await privileges.users.hasInvitePrivilege(req.uid); - if (!canInvite) { - return helpers.formatApiResponse(403, res, new Error('[[error:no-privileges]]')); - } - - const { registrationType } = meta.config; - const isAdmin = await user.isAdministrator(req.uid); - if (registrationType === 'admin-invite-only' && !isAdmin) { - return helpers.formatApiResponse(403, res, new Error('[[error:no-privileges]]')); - } - - const inviteGroups = (await groups.getUserInviteGroups(req.uid)).map(group => group.name); - const cannotInvite = groupsToJoin.some(group => !inviteGroups.includes(group)); - if (groupsToJoin.length > 0 && cannotInvite) { - return helpers.formatApiResponse(403, res, new Error('[[error:no-privileges]]')); - } - - const max = meta.config.maximumInvites; - const emailsArr = emails.split(',').map(email => email.trim()).filter(Boolean); - - for (const email of emailsArr) { - /* eslint-disable no-await-in-loop */ - let invites = 0; - if (max) { - invites = await user.getInvitesNumber(req.uid); - } - if (!isAdmin && max && invites >= max) { - return helpers.formatApiResponse(403, res, new Error(`[[error:invite-maximum-met, ${invites}, ${max}]]`)); - } - - await user.sendInvitationEmail(req.uid, email, groupsToJoin); - } - - return helpers.formatApiResponse(200, res); +Users.invite = async (request, res) => { + const {emails, groupsToJoin = []} = request.body; + + if (!emails || !Array.isArray(groupsToJoin)) { + return helpers.formatApiResponse(400, res, new Error('[[error:invalid-data]]')); + } + + // For simplicity, this API route is restricted to self-use only. This can change if needed. + if (Number.parseInt(request.user.uid, 10) !== Number.parseInt(request.params.uid, 10)) { + return helpers.formatApiResponse(403, res, new Error('[[error:no-privileges]]')); + } + + const canInvite = await privileges.users.hasInvitePrivilege(request.uid); + if (!canInvite) { + return helpers.formatApiResponse(403, res, new Error('[[error:no-privileges]]')); + } + + const {registrationType} = meta.config; + const isAdmin = await user.isAdministrator(request.uid); + if (registrationType === 'admin-invite-only' && !isAdmin) { + return helpers.formatApiResponse(403, res, new Error('[[error:no-privileges]]')); + } + + const inviteGroups = new Set((await groups.getUserInviteGroups(request.uid)).map(group => group.name)); + const cannotInvite = groupsToJoin.some(group => !inviteGroups.has(group)); + if (groupsToJoin.length > 0 && cannotInvite) { + return helpers.formatApiResponse(403, res, new Error('[[error:no-privileges]]')); + } + + const max = meta.config.maximumInvites; + const emailsArray = emails.split(',').map(email => email.trim()).filter(Boolean); + + for (const email of emailsArray) { + /* eslint-disable no-await-in-loop */ + let invites = 0; + if (max) { + invites = await user.getInvitesNumber(request.uid); + } + + if (!isAdmin && max && invites >= max) { + return helpers.formatApiResponse(403, res, new Error(`[[error:invite-maximum-met, ${invites}, ${max}]]`)); + } + + await user.sendInvitationEmail(request.uid, email, groupsToJoin); + } + + return helpers.formatApiResponse(200, res); }; -Users.getInviteGroups = async function (req, res) { - if (parseInt(req.params.uid, 10) !== parseInt(req.user.uid, 10)) { - return helpers.formatApiResponse(401, res); - } +Users.getInviteGroups = async function (request, res) { + if (Number.parseInt(request.params.uid, 10) !== Number.parseInt(request.user.uid, 10)) { + return helpers.formatApiResponse(401, res); + } - const userInviteGroups = await groups.getUserInviteGroups(req.params.uid); - return helpers.formatApiResponse(200, res, userInviteGroups.map(group => group.displayName)); + const userInviteGroups = await groups.getUserInviteGroups(request.params.uid); + return helpers.formatApiResponse(200, res, userInviteGroups.map(group => group.displayName)); }; -Users.listEmails = async (req, res) => { - const [isPrivileged, { showemail }] = await Promise.all([ - user.isPrivileged(req.uid), - user.getSettings(req.params.uid), - ]); - const isSelf = req.uid === parseInt(req.params.uid, 10); - - if (isSelf || isPrivileged || showemail) { - const emails = await db.getSortedSetRangeByScore('email:uid', 0, 500, req.params.uid, req.params.uid); - helpers.formatApiResponse(200, res, { emails }); - } else { - helpers.formatApiResponse(204, res); - } +Users.listEmails = async (request, res) => { + const [isPrivileged, {showemail}] = await Promise.all([ + user.isPrivileged(request.uid), + user.getSettings(request.params.uid), + ]); + const isSelf = request.uid === Number.parseInt(request.params.uid, 10); + + if (isSelf || isPrivileged || showemail) { + const emails = await db.getSortedSetRangeByScore('email:uid', 0, 500, request.params.uid, request.params.uid); + helpers.formatApiResponse(200, res, {emails}); + } else { + helpers.formatApiResponse(204, res); + } }; -Users.getEmail = async (req, res) => { - const [isPrivileged, { showemail }, exists] = await Promise.all([ - user.isPrivileged(req.uid), - user.getSettings(req.params.uid), - db.isSortedSetMember('email:uid', req.params.email.toLowerCase()), - ]); - const isSelf = req.uid === parseInt(req.params.uid, 10); - - if (exists && (isSelf || isPrivileged || showemail)) { - helpers.formatApiResponse(204, res); - } else { - helpers.formatApiResponse(404, res); - } +Users.getEmail = async (request, res) => { + const [isPrivileged, {showemail}, exists] = await Promise.all([ + user.isPrivileged(request.uid), + user.getSettings(request.params.uid), + db.isSortedSetMember('email:uid', request.params.email.toLowerCase()), + ]); + const isSelf = request.uid === Number.parseInt(request.params.uid, 10); + + if (exists && (isSelf || isPrivileged || showemail)) { + helpers.formatApiResponse(204, res); + } else { + helpers.formatApiResponse(404, res); + } }; -Users.confirmEmail = async (req, res) => { - const [pending, current, canManage] = await Promise.all([ - user.email.isValidationPending(req.params.uid, req.params.email), - user.getUserField(req.params.uid, 'email'), - privileges.admin.can('admin:users', req.uid), - ]); - - if (!canManage) { - return helpers.notAllowed(req, res); - } - - if (pending) { // has active confirmation request - const code = await db.get(`confirm:byUid:${req.params.uid}`); - await user.email.confirmByCode(code, req.session.id); - helpers.formatApiResponse(200, res); - } else if (current && current === req.params.email) { // email in user hash (i.e. email passed into user.create) - await user.email.confirmByUid(req.params.uid); - helpers.formatApiResponse(200, res); - } else { - helpers.formatApiResponse(404, res); - } +Users.confirmEmail = async (request, res) => { + const [pending, current, canManage] = await Promise.all([ + user.email.isValidationPending(request.params.uid, request.params.email), + user.getUserField(request.params.uid, 'email'), + privileges.admin.can('admin:users', request.uid), + ]); + + if (!canManage) { + return helpers.notAllowed(request, res); + } + + if (pending) { // Has active confirmation request + const code = await db.get(`confirm:byUid:${request.params.uid}`); + await user.email.confirmByCode(code, request.session.id); + helpers.formatApiResponse(200, res); + } else if (current && current === request.params.email) { // Email in user hash (i.e. email passed into user.create) + await user.email.confirmByUid(request.params.uid); + helpers.formatApiResponse(200, res); + } else { + helpers.formatApiResponse(404, res); + } }; -const prepareExport = async (req, res) => { - const [extension] = exportMetadata.get(req.params.type); - const filename = `${req.params.uid}_${req.params.type}.${extension}`; - try { - const stat = await fs.stat(path.join(__dirname, '../../../build/export', filename)); - const modified = new Date(stat.mtimeMs); - res.set('Last-Modified', modified.toUTCString()); - res.set('ETag', `"${crypto.createHash('md5').update(String(stat.mtimeMs)).digest('hex')}"`); - res.status(204); - return true; - } catch (e) { - res.status(404); - return false; - } +const prepareExport = async (request, res) => { + const [extension] = exportMetadata.get(request.params.type); + const filename = `${request.params.uid}_${request.params.type}.${extension}`; + try { + const stat = await fs.stat(path.join(__dirname, '../../../build/export', filename)); + const modified = new Date(stat.mtimeMs); + res.set('Last-Modified', modified.toUTCString()); + res.set('ETag', `"${crypto.createHash('md5').update(String(stat.mtimeMs)).digest('hex')}"`); + res.status(204); + return true; + } catch { + res.status(404); + return false; + } }; -Users.checkExportByType = async (req, res) => { - await prepareExport(req, res); - res.end(); +Users.checkExportByType = async (request, res) => { + await prepareExport(request, res); + res.end(); }; -Users.getExportByType = async (req, res) => { - const [extension, mime] = exportMetadata.get(req.params.type); - const filename = `${req.params.uid}_${req.params.type}.${extension}`; - - const exists = await prepareExport(req, res); - if (!exists) { - return res.end(); - } - - res.status(200); - res.sendFile(filename, { - root: path.join(__dirname, '../../../build/export'), - headers: { - 'Content-Type': mime, - 'Content-Disposition': `attachment; filename=${filename}`, - }, - }, (err) => { - if (err) { - throw err; - } - }); +Users.getExportByType = async (request, res) => { + const [extension, mime] = exportMetadata.get(request.params.type); + const filename = `${request.params.uid}_${request.params.type}.${extension}`; + + const exists = await prepareExport(request, res); + if (!exists) { + return res.end(); + } + + res.status(200); + res.sendFile(filename, { + root: path.join(__dirname, '../../../build/export'), + headers: { + 'Content-Type': mime, + 'Content-Disposition': `attachment; filename=${filename}`, + }, + }, error => { + if (error) { + throw error; + } + }); }; -Users.generateExportsByType = async (req, res) => { - await api.users.generateExport(req, req.params); - helpers.formatApiResponse(202, res); +Users.generateExportsByType = async (request, res) => { + await api.users.generateExport(request, request.params); + helpers.formatApiResponse(202, res); }; diff --git a/src/controllers/write/utilities.js b/src/controllers/write/utilities.js index 432ca56..7688e7d 100644 --- a/src/controllers/write/utilities.js +++ b/src/controllers/write/utilities.js @@ -7,27 +7,28 @@ const helpers = require('../helpers'); const Utilities = module.exports; Utilities.ping = {}; -Utilities.ping.get = (req, res) => { - helpers.formatApiResponse(200, res, { - pong: true, - }); +Utilities.ping.get = (request, res) => { + helpers.formatApiResponse(200, res, { + pong: true, + }); }; -Utilities.ping.post = (req, res) => { - helpers.formatApiResponse(200, res, { - uid: req.user.uid, - received: req.body, - }); +Utilities.ping.post = (request, res) => { + helpers.formatApiResponse(200, res, { + uid: request.user.uid, + received: request.body, + }); }; -Utilities.login = (req, res) => { - res.locals.redirectAfterLogin = async (req, res) => { - const userData = (await user.getUsers([req.uid], req.uid)).pop(); - helpers.formatApiResponse(200, res, userData); - }; - res.locals.noScriptErrors = (req, res, err, statusCode) => { - helpers.formatApiResponse(statusCode, res, new Error(err)); - }; +Utilities.login = (request, res) => { + res.locals.redirectAfterLogin = async (request_, res) => { + const userData = (await user.getUsers([request_.uid], request_.uid)).pop(); + helpers.formatApiResponse(200, res, userData); + }; - authenticationController.login(req, res); + res.locals.noScriptErrors = (request_, res, error, statusCode) => { + helpers.formatApiResponse(statusCode, res, new Error(error)); + }; + + authenticationController.login(request, res); }; diff --git a/src/coverPhoto.js b/src/coverPhoto.js index a0e2e9f..7f59a37 100644 --- a/src/coverPhoto.js +++ b/src/coverPhoto.js @@ -1,6 +1,5 @@ 'use strict'; - const nconf = require('nconf'); const meta = require('./meta'); @@ -9,32 +8,34 @@ const relative_path = nconf.get('relative_path'); const coverPhoto = module.exports; coverPhoto.getDefaultGroupCover = function (groupName) { - return getCover('groups', groupName); + return getCover('groups', groupName); }; coverPhoto.getDefaultProfileCover = function (uid) { - return getCover('profile', parseInt(uid, 10)); + return getCover('profile', Number.parseInt(uid, 10)); }; function getCover(type, id) { - const defaultCover = `${relative_path}/assets/images/cover-default.png`; - if (meta.config[`${type}:defaultCovers`]) { - const covers = String(meta.config[`${type}:defaultCovers`]).trim().split(/[\s,]+/g); - let coverPhoto = defaultCover; - if (!covers.length) { - return coverPhoto; - } - - if (typeof id === 'string') { - id = (id.charCodeAt(0) + id.charCodeAt(1)) % covers.length; - } else { - id %= covers.length; - } - if (covers[id]) { - coverPhoto = covers[id].startsWith('http') ? covers[id] : (relative_path + covers[id]); - } - return coverPhoto; - } - - return defaultCover; + const defaultCover = `${relative_path}/assets/images/cover-default.png`; + if (meta.config[`${type}:defaultCovers`]) { + const covers = String(meta.config[`${type}:defaultCovers`]).trim().split(/[\s,]+/g); + let coverPhoto = defaultCover; + if (covers.length === 0) { + return coverPhoto; + } + + if (typeof id === 'string') { + id = (id.charCodeAt(0) + id.charCodeAt(1)) % covers.length; + } else { + id %= covers.length; + } + + if (covers[id]) { + coverPhoto = covers[id].startsWith('http') ? covers[id] : (relative_path + covers[id]); + } + + return coverPhoto; + } + + return defaultCover; } diff --git a/src/database/cache.js b/src/database/cache.js index cdd9622..b68bf98 100644 --- a/src/database/cache.js +++ b/src/database/cache.js @@ -1,10 +1,10 @@ 'use strict'; module.exports.create = function (name) { - const cacheCreate = require('../cache/lru'); - return cacheCreate({ - name: `${name}-object`, - max: 40000, - ttl: 0, - }); + const cacheCreate = require('../cache/lru'); + return cacheCreate({ + name: `${name}-object`, + max: 40_000, + ttl: 0, + }); }; diff --git a/src/database/helpers.js b/src/database/helpers.js index 48e7fa1..66993d7 100644 --- a/src/database/helpers.js +++ b/src/database/helpers.js @@ -3,26 +3,29 @@ const helpers = module.exports; helpers.mergeBatch = function (batchData, start, stop, sort) { - function getFirst() { - let selectedArray = batchData[0]; - for (let i = 1; i < batchData.length; i++) { - if (batchData[i].length && ( - !selectedArray.length || - (sort === 1 && batchData[i][0].score < selectedArray[0].score) || - (sort === -1 && batchData[i][0].score > selectedArray[0].score) - )) { - selectedArray = batchData[i]; - } - } - return selectedArray.length ? selectedArray.shift() : null; - } - let item = null; - const result = []; - do { - item = getFirst(batchData); - if (item) { - result.push(item); - } - } while (item && (result.length < (stop - start + 1) || stop === -1)); - return result; + function getFirst() { + let selectedArray = batchData[0]; + for (let i = 1; i < batchData.length; i++) { + if (batchData[i].length > 0 && ( + selectedArray.length === 0 + || (sort === 1 && batchData[i][0].score < selectedArray[0].score) + || (sort === -1 && batchData[i][0].score > selectedArray[0].score) + )) { + selectedArray = batchData[i]; + } + } + + return selectedArray.length > 0 ? selectedArray.shift() : null; + } + + let item = null; + const result = []; + do { + item = getFirst(batchData); + if (item) { + result.push(item); + } + } while (item && (result.length < (stop - start + 1) || stop === -1)); + + return result; }; diff --git a/src/database/index.js b/src/database/index.js index 917a15b..54b0b37 100644 --- a/src/database/index.js +++ b/src/database/index.js @@ -6,32 +6,32 @@ const databaseName = nconf.get('database'); const winston = require('winston'); if (!databaseName) { - winston.error(new Error('Database type not set! Run ./nodebb setup')); - process.exit(); + winston.error(new Error('Database type not set! Run ./nodebb setup')); + process.exit(); } const primaryDB = require(`./${databaseName}`); primaryDB.parseIntFields = function (data, intFields, requestedFields) { - intFields.forEach((field) => { - if (!requestedFields || !requestedFields.length || requestedFields.includes(field)) { - data[field] = parseInt(data[field], 10) || 0; - } - }); + for (const field of intFields) { + if (!requestedFields || requestedFields.length === 0 || requestedFields.includes(field)) { + data[field] = Number.parseInt(data[field], 10) || 0; + } + } }; primaryDB.initSessionStore = async function () { - const sessionStoreConfig = nconf.get('session_store') || nconf.get('redis') || nconf.get(databaseName); - let sessionStoreDB = primaryDB; + const sessionStoreConfig = nconf.get('session_store') || nconf.get('redis') || nconf.get(databaseName); + let sessionStoreDB = primaryDB; - if (nconf.get('session_store')) { - sessionStoreDB = require(`./${sessionStoreConfig.name}`); - } else if (nconf.get('redis')) { - // if redis is specified, use it as session store over others - sessionStoreDB = require('./redis'); - } + if (nconf.get('session_store')) { + sessionStoreDB = require(`./${sessionStoreConfig.name}`); + } else if (nconf.get('redis')) { + // If redis is specified, use it as session store over others + sessionStoreDB = require('./redis'); + } - primaryDB.sessionStore = await sessionStoreDB.createSessionStore(sessionStoreConfig); + primaryDB.sessionStore = await sessionStoreDB.createSessionStore(sessionStoreConfig); }; module.exports = primaryDB; diff --git a/src/database/mongo.js b/src/database/mongo.js index 8cf4714..8692de7 100644 --- a/src/database/mongo.js +++ b/src/database/mongo.js @@ -1,7 +1,6 @@ 'use strict'; - const winston = require('winston'); const nconf = require('nconf'); const semver = require('semver'); @@ -15,167 +14,171 @@ const connection = require('./mongo/connection'); const mongoModule = module.exports; function isUriNotSpecified() { - return !prompt.history('mongo:uri').value; + return !prompt.history('mongo:uri').value; } mongoModule.questions = [ - { - name: 'mongo:uri', - description: 'MongoDB connection URI: (leave blank if you wish to specify host, port, username/password and database individually)\nFormat: mongodb://[username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[database][?options]]', - default: nconf.get('mongo:uri') || '', - hideOnWebInstall: true, - }, - { - name: 'mongo:host', - description: 'Host IP or address of your MongoDB instance', - default: nconf.get('mongo:host') || '127.0.0.1', - ask: isUriNotSpecified, - }, - { - name: 'mongo:port', - description: 'Host port of your MongoDB instance', - default: nconf.get('mongo:port') || 27017, - ask: isUriNotSpecified, - }, - { - name: 'mongo:username', - description: 'MongoDB username', - default: nconf.get('mongo:username') || '', - ask: isUriNotSpecified, - }, - { - name: 'mongo:password', - description: 'Password of your MongoDB database', - default: nconf.get('mongo:password') || '', - hidden: true, - ask: isUriNotSpecified, - before: function (value) { value = value || nconf.get('mongo:password') || ''; return value; }, - }, - { - name: 'mongo:database', - description: 'MongoDB database name', - default: nconf.get('mongo:database') || 'nodebb', - ask: isUriNotSpecified, - }, + { + name: 'mongo:uri', + description: 'MongoDB connection URI: (leave blank if you wish to specify host, port, username/password and database individually)\nFormat: mongodb://[username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[database][?options]]', + default: nconf.get('mongo:uri') || '', + hideOnWebInstall: true, + }, + { + name: 'mongo:host', + description: 'Host IP or address of your MongoDB instance', + default: nconf.get('mongo:host') || '127.0.0.1', + ask: isUriNotSpecified, + }, + { + name: 'mongo:port', + description: 'Host port of your MongoDB instance', + default: nconf.get('mongo:port') || 27_017, + ask: isUriNotSpecified, + }, + { + name: 'mongo:username', + description: 'MongoDB username', + default: nconf.get('mongo:username') || '', + ask: isUriNotSpecified, + }, + { + name: 'mongo:password', + description: 'Password of your MongoDB database', + default: nconf.get('mongo:password') || '', + hidden: true, + ask: isUriNotSpecified, + before(value) { + value ||= nconf.get('mongo:password') || ''; return value; + }, + }, + { + name: 'mongo:database', + description: 'MongoDB database name', + default: nconf.get('mongo:database') || 'nodebb', + ask: isUriNotSpecified, + }, ]; mongoModule.init = async function () { - client = await connection.connect(nconf.get('mongo')); - mongoModule.client = client.db(); + client = await connection.connect(nconf.get('mongo')); + mongoModule.client = client.db(); }; mongoModule.createSessionStore = async function (options) { - const MongoStore = require('connect-mongo'); - const meta = require('../meta'); + const MongoStore = require('connect-mongo'); + const meta = require('../meta'); - const store = MongoStore.create({ - clientPromise: connection.connect(options), - ttl: meta.getSessionTTLSeconds(), - }); + const store = MongoStore.create({ + clientPromise: connection.connect(options), + ttl: meta.getSessionTTLSeconds(), + }); - return store; + return store; }; mongoModule.createIndices = async function () { - if (!mongoModule.client) { - winston.warn('[database/createIndices] database not initialized'); - return; - } - - winston.info('[database] Checking database indices.'); - const collection = mongoModule.client.collection('objects'); - await collection.createIndex({ _key: 1, score: -1 }, { background: true }); - await collection.createIndex({ _key: 1, value: -1 }, { background: true, unique: true, sparse: true }); - await collection.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0, background: true }); - winston.info('[database] Checking database indices done!'); + if (!mongoModule.client) { + winston.warn('[database/createIndices] database not initialized'); + return; + } + + winston.info('[database] Checking database indices.'); + const collection = mongoModule.client.collection('objects'); + await collection.createIndex({_key: 1, score: -1}, {background: true}); + await collection.createIndex({_key: 1, value: -1}, {background: true, unique: true, sparse: true}); + await collection.createIndex({expireAt: 1}, {expireAfterSeconds: 0, background: true}); + winston.info('[database] Checking database indices done!'); }; mongoModule.checkCompatibility = function (callback) { - const mongoPkg = require('mongodb/package.json'); - mongoModule.checkCompatibilityVersion(mongoPkg.version, callback); + const mongoPkg = require('mongodb/package.json'); + mongoModule.checkCompatibilityVersion(mongoPkg.version, callback); }; mongoModule.checkCompatibilityVersion = function (version, callback) { - if (semver.lt(version, '2.0.0')) { - return callback(new Error('The `mongodb` package is out-of-date, please run `./nodebb setup` again.')); - } + if (semver.lt(version, '2.0.0')) { + return callback(new Error('The `mongodb` package is out-of-date, please run `./nodebb setup` again.')); + } - callback(); + callback(); }; -mongoModule.info = async function (db) { - if (!db) { - const client = await connection.connect(nconf.get('mongo')); - db = client.db(); - } - mongoModule.client = mongoModule.client || db; - let serverStatusError = ''; - - async function getServerStatus() { - try { - return await db.command({ serverStatus: 1 }); - } catch (err) { - serverStatusError = err.message; - // Override mongo error with more human-readable error - if (err.name === 'MongoError' && err.codeName === 'Unauthorized') { - serverStatusError = '[[admin/advanced/database:mongo.unauthorized]]'; - } - winston.error(err.stack); - } - } - - let [serverStatus, stats, listCollections] = await Promise.all([ - getServerStatus(), - db.command({ dbStats: 1 }), - getCollectionStats(db), - ]); - stats = stats || {}; - serverStatus = serverStatus || {}; - stats.serverStatusError = serverStatusError; - const scale = 1024 * 1024 * 1024; - - listCollections = listCollections.map(collectionInfo => ({ - name: collectionInfo.ns, - count: collectionInfo.count, - size: collectionInfo.size, - avgObjSize: collectionInfo.avgObjSize, - storageSize: collectionInfo.storageSize, - totalIndexSize: collectionInfo.totalIndexSize, - indexSizes: collectionInfo.indexSizes, - })); - - stats.mem = serverStatus.mem || { resident: 0, virtual: 0, mapped: 0 }; - stats.mem.resident = (stats.mem.resident / 1024).toFixed(3); - stats.mem.virtual = (stats.mem.virtual / 1024).toFixed(3); - stats.mem.mapped = (stats.mem.mapped / 1024).toFixed(3); - stats.collectionData = listCollections; - stats.network = serverStatus.network || { bytesIn: 0, bytesOut: 0, numRequests: 0 }; - stats.network.bytesIn = (stats.network.bytesIn / scale).toFixed(3); - stats.network.bytesOut = (stats.network.bytesOut / scale).toFixed(3); - stats.network.numRequests = utils.addCommas(stats.network.numRequests); - stats.raw = JSON.stringify(stats, null, 4); - - stats.avgObjSize = stats.avgObjSize.toFixed(2); - stats.dataSize = (stats.dataSize / scale).toFixed(3); - stats.storageSize = (stats.storageSize / scale).toFixed(3); - stats.fileSize = stats.fileSize ? (stats.fileSize / scale).toFixed(3) : 0; - stats.indexSize = (stats.indexSize / scale).toFixed(3); - stats.storageEngine = serverStatus.storageEngine ? serverStatus.storageEngine.name : 'mmapv1'; - stats.host = serverStatus.host; - stats.version = serverStatus.version; - stats.uptime = serverStatus.uptime; - stats.mongo = true; - return stats; +mongoModule.info = async function (database) { + if (!database) { + const client = await connection.connect(nconf.get('mongo')); + database = client.db(); + } + + mongoModule.client = mongoModule.client || database; + let serverStatusError = ''; + + async function getServerStatus() { + try { + return await database.command({serverStatus: 1}); + } catch (error) { + serverStatusError = error.message; + // Override mongo error with more human-readable error + if (error.name === 'MongoError' && error.codeName === 'Unauthorized') { + serverStatusError = '[[admin/advanced/database:mongo.unauthorized]]'; + } + + winston.error(error.stack); + } + } + + let [serverStatus, stats, listCollections] = await Promise.all([ + getServerStatus(), + database.command({dbStats: 1}), + getCollectionStats(database), + ]); + stats ||= {}; + serverStatus ||= {}; + stats.serverStatusError = serverStatusError; + const scale = 1024 * 1024 * 1024; + + listCollections = listCollections.map(collectionInfo => ({ + name: collectionInfo.ns, + count: collectionInfo.count, + size: collectionInfo.size, + avgObjSize: collectionInfo.avgObjSize, + storageSize: collectionInfo.storageSize, + totalIndexSize: collectionInfo.totalIndexSize, + indexSizes: collectionInfo.indexSizes, + })); + + stats.mem = serverStatus.mem || {resident: 0, virtual: 0, mapped: 0}; + stats.mem.resident = (stats.mem.resident / 1024).toFixed(3); + stats.mem.virtual = (stats.mem.virtual / 1024).toFixed(3); + stats.mem.mapped = (stats.mem.mapped / 1024).toFixed(3); + stats.collectionData = listCollections; + stats.network = serverStatus.network || {bytesIn: 0, bytesOut: 0, numRequests: 0}; + stats.network.bytesIn = (stats.network.bytesIn / scale).toFixed(3); + stats.network.bytesOut = (stats.network.bytesOut / scale).toFixed(3); + stats.network.numRequests = utils.addCommas(stats.network.numRequests); + stats.raw = JSON.stringify(stats, null, 4); + + stats.avgObjSize = stats.avgObjSize.toFixed(2); + stats.dataSize = (stats.dataSize / scale).toFixed(3); + stats.storageSize = (stats.storageSize / scale).toFixed(3); + stats.fileSize = stats.fileSize ? (stats.fileSize / scale).toFixed(3) : 0; + stats.indexSize = (stats.indexSize / scale).toFixed(3); + stats.storageEngine = serverStatus.storageEngine ? serverStatus.storageEngine.name : 'mmapv1'; + stats.host = serverStatus.host; + stats.version = serverStatus.version; + stats.uptime = serverStatus.uptime; + stats.mongo = true; + return stats; }; -async function getCollectionStats(db) { - const items = await db.listCollections().toArray(); - return await Promise.all(items.map(collection => db.collection(collection.name).stats())); +async function getCollectionStats(database) { + const items = await database.listCollections().toArray(); + return await Promise.all(items.map(collection => database.collection(collection.name).stats())); } mongoModule.close = function (callback) { - callback = callback || function () {}; - client.close(err => callback(err)); + callback ||= function () {}; + client.close(error => callback(error)); }; require('./mongo/main')(mongoModule); diff --git a/src/database/mongo/connection.js b/src/database/mongo/connection.js index 314677e..0931bae 100644 --- a/src/database/mongo/connection.js +++ b/src/database/mongo/connection.js @@ -1,62 +1,59 @@ 'use strict'; const nconf = require('nconf'); - const winston = require('winston'); const _ = require('lodash'); const connection = module.exports; connection.getConnectionString = function (mongo) { - mongo = mongo || nconf.get('mongo'); - let usernamePassword = ''; - const uri = mongo.uri || ''; - if (mongo.username && mongo.password) { - usernamePassword = `${mongo.username}:${encodeURIComponent(mongo.password)}@`; - } else if (!uri.includes('@') || !uri.slice(uri.indexOf('://') + 3, uri.indexOf('@'))) { - winston.warn('You have no mongo username/password setup!'); - } - - // Sensible defaults for Mongo, if not set - if (!mongo.host) { - mongo.host = '127.0.0.1'; - } - if (!mongo.port) { - mongo.port = 27017; - } - const dbName = mongo.database; - if (dbName === undefined || dbName === '') { - winston.warn('You have no database name, using "nodebb"'); - mongo.database = 'nodebb'; - } - - const hosts = mongo.host.split(','); - const ports = mongo.port.toString().split(','); - const servers = []; - - for (let i = 0; i < hosts.length; i += 1) { - servers.push(`${hosts[i]}:${ports[i]}`); - } - - return uri || `mongodb://${usernamePassword}${servers.join()}/${mongo.database}`; + mongo ||= nconf.get('mongo'); + let usernamePassword = ''; + const uri = mongo.uri || ''; + if (mongo.username && mongo.password) { + usernamePassword = `${mongo.username}:${encodeURIComponent(mongo.password)}@`; + } else if (!uri.includes('@') || !uri.slice(uri.indexOf('://') + 3, uri.indexOf('@'))) { + winston.warn('You have no mongo username/password setup!'); + } + + // Sensible defaults for Mongo, if not set + mongo.host ||= '127.0.0.1'; + + mongo.port ||= 27_017; + + const databaseName = mongo.database; + if (databaseName === undefined || databaseName === '') { + winston.warn('You have no database name, using "nodebb"'); + mongo.database = 'nodebb'; + } + + const hosts = mongo.host.split(','); + const ports = mongo.port.toString().split(','); + const servers = []; + + for (const [i, host] of hosts.entries()) { + servers.push(`${host}:${ports[i]}`); + } + + return uri || `mongodb://${usernamePassword}${servers.join(',')}/${mongo.database}`; }; connection.getConnectionOptions = function (mongo) { - mongo = mongo || nconf.get('mongo'); - const connOptions = { - maxPoolSize: 10, - minPoolSize: 3, - connectTimeoutMS: 90000, - }; - - return _.merge(connOptions, mongo.options || {}); + mongo ||= nconf.get('mongo'); + const connOptions = { + maxPoolSize: 10, + minPoolSize: 3, + connectTimeoutMS: 90_000, + }; + + return _.merge(connOptions, mongo.options || {}); }; connection.connect = async function (options) { - const mongoClient = require('mongodb').MongoClient; + const mongoClient = require('mongodb').MongoClient; - const connString = connection.getConnectionString(options); - const connOptions = connection.getConnectionOptions(options); + const connString = connection.getConnectionString(options); + const connOptions = connection.getConnectionOptions(options); - return await mongoClient.connect(connString, connOptions); + return await mongoClient.connect(connString, connOptions); }; diff --git a/src/database/mongo/hash.js b/src/database/mongo/hash.js index 50bee32..6021116 100644 --- a/src/database/mongo/hash.js +++ b/src/database/mongo/hash.js @@ -1,282 +1,296 @@ 'use strict'; module.exports = function (module) { - const helpers = require('./helpers'); - - const cache = require('../cache').create('mongo'); - - module.objectCache = cache; - - module.setObject = async function (key, data) { - const isArray = Array.isArray(key); - if (!key || !data || (isArray && !key.length)) { - return; - } - - const writeData = helpers.serializeData(data); - if (!Object.keys(writeData).length) { - return; - } - try { - if (isArray) { - const bulk = module.client.collection('objects').initializeUnorderedBulkOp(); - key.forEach(key => bulk.find({ _key: key }).upsert().updateOne({ $set: writeData })); - await bulk.execute(); - } else { - await module.client.collection('objects').updateOne({ _key: key }, { $set: writeData }, { upsert: true }); - } - } catch (err) { - if (err && err.message.startsWith('E11000 duplicate key error')) { - return await module.setObject(key, data); - } - throw err; - } - - cache.del(key); - }; - - module.setObjectBulk = async function (...args) { - let data = args[0]; - if (!Array.isArray(data) || !data.length) { - return; - } - if (Array.isArray(args[1])) { - console.warn('[deprecated] db.setObjectBulk(keys, data) usage is deprecated, please use db.setObjectBulk(data)'); - // conver old format to new format for backwards compatibility - data = args[0].map((key, i) => [key, args[1][i]]); - } - - try { - let bulk; - data.forEach((item) => { - const writeData = helpers.serializeData(item[1]); - if (Object.keys(writeData).length) { - if (!bulk) { - bulk = module.client.collection('objects').initializeUnorderedBulkOp(); - } - bulk.find({ _key: item[0] }).upsert().updateOne({ $set: writeData }); - } - }); - if (bulk) { - await bulk.execute(); - } - } catch (err) { - if (err && err.message.startsWith('E11000 duplicate key error')) { - return await module.setObjectBulk(data); - } - throw err; - } - - cache.del(data.map(item => item[0])); - }; - - module.setObjectField = async function (key, field, value) { - if (!field) { - return; - } - const data = {}; - data[field] = value; - await module.setObject(key, data); - }; - - module.getObject = async function (key, fields = []) { - if (!key) { - return null; - } - - const data = await module.getObjects([key], fields); - return data && data.length ? data[0] : null; - }; - - module.getObjects = async function (keys, fields = []) { - return await module.getObjectsFields(keys, fields); - }; - - module.getObjectField = async function (key, field) { - if (!key) { - return null; - } - const cachedData = {}; - cache.getUnCachedKeys([key], cachedData); - if (cachedData[key]) { - return cachedData[key].hasOwnProperty(field) ? cachedData[key][field] : null; - } - field = helpers.fieldToString(field); - const item = await module.client.collection('objects').findOne({ _key: key }, { projection: { _id: 0, [field]: 1 } }); - if (!item) { - return null; - } - return item.hasOwnProperty(field) ? item[field] : null; - }; - - module.getObjectFields = async function (key, fields) { - if (!key) { - return null; - } - const data = await module.getObjectsFields([key], fields); - return data ? data[0] : null; - }; - - module.getObjectsFields = async function (keys, fields) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } - const cachedData = {}; - const unCachedKeys = cache.getUnCachedKeys(keys, cachedData); - let data = []; - if (unCachedKeys.length >= 1) { - data = await module.client.collection('objects').find( - { _key: unCachedKeys.length === 1 ? unCachedKeys[0] : { $in: unCachedKeys } }, - { projection: { _id: 0 } } - ).toArray(); - data = data.map(helpers.deserializeData); - } - - const map = helpers.toMap(data); - unCachedKeys.forEach((key) => { - cachedData[key] = map[key] || null; - cache.set(key, cachedData[key]); - }); - - if (!Array.isArray(fields) || !fields.length) { - return keys.map(key => (cachedData[key] ? { ...cachedData[key] } : null)); - } - return keys.map((key) => { - const item = cachedData[key] || {}; - const result = {}; - fields.forEach((field) => { - result[field] = item[field] !== undefined ? item[field] : null; - }); - return result; - }); - }; - - module.getObjectKeys = async function (key) { - const data = await module.getObject(key); - return data ? Object.keys(data) : []; - }; - - module.getObjectValues = async function (key) { - const data = await module.getObject(key); - return data ? Object.values(data) : []; - }; - - module.isObjectField = async function (key, field) { - const data = await module.isObjectFields(key, [field]); - return Array.isArray(data) && data.length ? data[0] : false; - }; - - module.isObjectFields = async function (key, fields) { - if (!key) { - return; - } - - const data = {}; - fields.forEach((field) => { - field = helpers.fieldToString(field); - if (field) { - data[field] = 1; - } - }); - - const item = await module.client.collection('objects').findOne({ _key: key }, { projection: data }); - const results = fields.map(f => !!item && item[f] !== undefined && item[f] !== null); - return results; - }; - - module.deleteObjectField = async function (key, field) { - await module.deleteObjectFields(key, [field]); - }; - - module.deleteObjectFields = async function (key, fields) { - if (!key || (Array.isArray(key) && !key.length) || !Array.isArray(fields) || !fields.length) { - return; - } - fields = fields.filter(Boolean); - if (!fields.length) { - return; - } - - const data = {}; - fields.forEach((field) => { - field = helpers.fieldToString(field); - data[field] = ''; - }); - if (Array.isArray(key)) { - await module.client.collection('objects').updateMany({ _key: { $in: key } }, { $unset: data }); - } else { - await module.client.collection('objects').updateOne({ _key: key }, { $unset: data }); - } - - cache.del(key); - }; - - module.incrObjectField = async function (key, field) { - return await module.incrObjectFieldBy(key, field, 1); - }; - - module.decrObjectField = async function (key, field) { - return await module.incrObjectFieldBy(key, field, -1); - }; - - module.incrObjectFieldBy = async function (key, field, value) { - value = parseInt(value, 10); - if (!key || isNaN(value)) { - return null; - } - - const increment = {}; - field = helpers.fieldToString(field); - increment[field] = value; - - if (Array.isArray(key)) { - const bulk = module.client.collection('objects').initializeUnorderedBulkOp(); - key.forEach((key) => { - bulk.find({ _key: key }).upsert().update({ $inc: increment }); - }); - await bulk.execute(); - cache.del(key); - const result = await module.getObjectsFields(key, [field]); - return result.map(data => data && data[field]); - } - try { - const result = await module.client.collection('objects').findOneAndUpdate({ - _key: key, - }, { - $inc: increment, - }, { - returnDocument: 'after', - upsert: true, - }); - cache.del(key); - return result && result.value ? result.value[field] : null; - } catch (err) { - // if there is duplicate key error retry the upsert - // https://github.com/NodeBB/NodeBB/issues/4467 - // https://jira.mongodb.org/browse/SERVER-14322 - // https://docs.mongodb.org/manual/reference/command/findAndModify/#upsert-and-unique-index - if (err && err.message.startsWith('E11000 duplicate key error')) { - return await module.incrObjectFieldBy(key, field, value); - } - throw err; - } - }; - - module.incrObjectFieldByBulk = async function (data) { - if (!Array.isArray(data) || !data.length) { - return; - } - - const bulk = module.client.collection('objects').initializeUnorderedBulkOp(); - - data.forEach((item) => { - const increment = {}; - for (const [field, value] of Object.entries(item[1])) { - increment[helpers.fieldToString(field)] = value; - } - bulk.find({ _key: item[0] }).upsert().update({ $inc: increment }); - }); - await bulk.execute(); - cache.del(data.map(item => item[0])); - }; + const helpers = require('./helpers'); + + const cache = require('../cache').create('mongo'); + + module.objectCache = cache; + + module.setObject = async function (key, data) { + const isArray = Array.isArray(key); + if (!key || !data || (isArray && key.length === 0)) { + return; + } + + const writeData = helpers.serializeData(data); + if (Object.keys(writeData).length === 0) { + return; + } + + try { + if (isArray) { + const bulk = module.client.collection('objects').initializeUnorderedBulkOp(); + key.forEach(key => bulk.find({_key: key}).upsert().updateOne({$set: writeData})); + await bulk.execute(); + } else { + await module.client.collection('objects').updateOne({_key: key}, {$set: writeData}, {upsert: true}); + } + } catch (error) { + if (error && error.message.startsWith('E11000 duplicate key error')) { + return await module.setObject(key, data); + } + + throw error; + } + + cache.del(key); + }; + + module.setObjectBulk = async function (...arguments_) { + let data = arguments_[0]; + if (!Array.isArray(data) || data.length === 0) { + return; + } + + if (Array.isArray(arguments_[1])) { + console.warn('[deprecated] db.setObjectBulk(keys, data) usage is deprecated, please use db.setObjectBulk(data)'); + // Conver old format to new format for backwards compatibility + data = arguments_[0].map((key, i) => [key, arguments_[1][i]]); + } + + try { + let bulk; + for (const item of data) { + const writeData = helpers.serializeData(item[1]); + if (Object.keys(writeData).length > 0) { + bulk ||= module.client.collection('objects').initializeUnorderedBulkOp(); + + bulk.find({_key: item[0]}).upsert().updateOne({$set: writeData}); + } + } + + if (bulk) { + await bulk.execute(); + } + } catch (error) { + if (error && error.message.startsWith('E11000 duplicate key error')) { + return await module.setObjectBulk(data); + } + + throw error; + } + + cache.del(data.map(item => item[0])); + }; + + module.setObjectField = async function (key, field, value) { + if (!field) { + return; + } + + const data = {}; + data[field] = value; + await module.setObject(key, data); + }; + + module.getObject = async function (key, fields = []) { + if (!key) { + return null; + } + + const data = await module.getObjects([key], fields); + return data && data.length > 0 ? data[0] : null; + }; + + module.getObjects = async function (keys, fields = []) { + return await module.getObjectsFields(keys, fields); + }; + + module.getObjectField = async function (key, field) { + if (!key) { + return null; + } + + const cachedData = {}; + cache.getUnCachedKeys([key], cachedData); + if (cachedData[key]) { + return cachedData[key].hasOwnProperty(field) ? cachedData[key][field] : null; + } + + field = helpers.fieldToString(field); + const item = await module.client.collection('objects').findOne({_key: key}, {projection: {_id: 0, [field]: 1}}); + if (!item) { + return null; + } + + return item.hasOwnProperty(field) ? item[field] : null; + }; + + module.getObjectFields = async function (key, fields) { + if (!key) { + return null; + } + + const data = await module.getObjectsFields([key], fields); + return data ? data[0] : null; + }; + + module.getObjectsFields = async function (keys, fields) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } + + const cachedData = {}; + const unCachedKeys = cache.getUnCachedKeys(keys, cachedData); + let data = []; + if (unCachedKeys.length > 0) { + data = await module.client.collection('objects').find( + {_key: unCachedKeys.length === 1 ? unCachedKeys[0] : {$in: unCachedKeys}}, + {projection: {_id: 0}}, + ).toArray(); + data = data.map(helpers.deserializeData); + } + + const map = helpers.toMap(data); + for (const key of unCachedKeys) { + cachedData[key] = map[key] || null; + cache.set(key, cachedData[key]); + } + + if (!Array.isArray(fields) || fields.length === 0) { + return keys.map(key => (cachedData[key] ? {...cachedData[key]} : null)); + } + + return keys.map(key => { + const item = cachedData[key] || {}; + const result = {}; + for (const field of fields) { + result[field] = item[field] === undefined ? null : item[field]; + } + + return result; + }); + }; + + module.getObjectKeys = async function (key) { + const data = await module.getObject(key); + return data ? Object.keys(data) : []; + }; + + module.getObjectValues = async function (key) { + const data = await module.getObject(key); + return data ? Object.values(data) : []; + }; + + module.isObjectField = async function (key, field) { + const data = await module.isObjectFields(key, [field]); + return Array.isArray(data) && data.length > 0 ? data[0] : false; + }; + + module.isObjectFields = async function (key, fields) { + if (!key) { + return; + } + + const data = {}; + for (let field of fields) { + field = helpers.fieldToString(field); + if (field) { + data[field] = 1; + } + } + + const item = await module.client.collection('objects').findOne({_key: key}, {projection: data}); + const results = fields.map(f => Boolean(item) && item[f] !== undefined && item[f] !== null); + return results; + }; + + module.deleteObjectField = async function (key, field) { + await module.deleteObjectFields(key, [field]); + }; + + module.deleteObjectFields = async function (key, fields) { + if (!key || (Array.isArray(key) && key.length === 0) || !Array.isArray(fields) || fields.length === 0) { + return; + } + + fields = fields.filter(Boolean); + if (fields.length === 0) { + return; + } + + const data = {}; + for (let field of fields) { + field = helpers.fieldToString(field); + data[field] = ''; + } + + await (Array.isArray(key) ? module.client.collection('objects').updateMany({_key: {$in: key}}, {$unset: data}) : module.client.collection('objects').updateOne({_key: key}, {$unset: data})); + + cache.del(key); + }; + + module.incrObjectField = async function (key, field) { + return await module.incrObjectFieldBy(key, field, 1); + }; + + module.decrObjectField = async function (key, field) { + return await module.incrObjectFieldBy(key, field, -1); + }; + + module.incrObjectFieldBy = async function (key, field, value) { + value = Number.parseInt(value, 10); + if (!key || isNaN(value)) { + return null; + } + + const increment = {}; + field = helpers.fieldToString(field); + increment[field] = value; + + if (Array.isArray(key)) { + const bulk = module.client.collection('objects').initializeUnorderedBulkOp(); + key.forEach(key => { + bulk.find({_key: key}).upsert().update({$inc: increment}); + }); + await bulk.execute(); + cache.del(key); + const result = await module.getObjectsFields(key, [field]); + return result.map(data => data && data[field]); + } + + try { + const result = await module.client.collection('objects').findOneAndUpdate({ + _key: key, + }, { + $inc: increment, + }, { + returnDocument: 'after', + upsert: true, + }); + cache.del(key); + return result && result.value ? result.value[field] : null; + } catch (error) { + // If there is duplicate key error retry the upsert + // https://github.com/NodeBB/NodeBB/issues/4467 + // https://jira.mongodb.org/browse/SERVER-14322 + // https://docs.mongodb.org/manual/reference/command/findAndModify/#upsert-and-unique-index + if (error && error.message.startsWith('E11000 duplicate key error')) { + return await module.incrObjectFieldBy(key, field, value); + } + + throw error; + } + }; + + module.incrObjectFieldByBulk = async function (data) { + if (!Array.isArray(data) || data.length === 0) { + return; + } + + const bulk = module.client.collection('objects').initializeUnorderedBulkOp(); + + for (const item of data) { + const increment = {}; + for (const [field, value] of Object.entries(item[1])) { + increment[helpers.fieldToString(field)] = value; + } + + bulk.find({_key: item[0]}).upsert().update({$inc: increment}); + } + + await bulk.execute(); + cache.del(data.map(item => item[0])); + }; }; diff --git a/src/database/mongo/helpers.js b/src/database/mongo/helpers.js index 9edf83a..8313cf3 100644 --- a/src/database/mongo/helpers.js +++ b/src/database/mongo/helpers.js @@ -6,62 +6,68 @@ const utils = require('../../utils'); helpers.noop = function () {}; helpers.toMap = function (data) { - const map = {}; - for (let i = 0; i < data.length; i += 1) { - map[data[i]._key] = data[i]; - delete data[i]._key; - } - return map; + const map = {}; + for (const datum of data) { + map[datum._key] = datum; + delete datum._key; + } + + return map; }; helpers.fieldToString = function (field) { - if (field === null || field === undefined) { - return field; - } - - if (typeof field !== 'string') { - field = field.toString(); - } - // if there is a '.' in the field name it inserts subdocument in mongo, replace '.'s with \uff0E - return field.replace(/\./g, '\uff0E'); + if (field === null || field === undefined) { + return field; + } + + if (typeof field !== 'string') { + field = field.toString(); + } + + // If there is a '.' in the field name it inserts subdocument in mongo, replace '.'s with \uff0E + return field.replaceAll('.', '\uFF0E'); }; helpers.serializeData = function (data) { - const serialized = {}; - for (const [field, value] of Object.entries(data)) { - if (field !== '') { - serialized[helpers.fieldToString(field)] = value; - } - } - return serialized; + const serialized = {}; + for (const [field, value] of Object.entries(data)) { + if (field !== '') { + serialized[helpers.fieldToString(field)] = value; + } + } + + return serialized; }; helpers.deserializeData = function (data) { - const deserialized = {}; - for (const [field, value] of Object.entries(data)) { - deserialized[field.replace(/\uff0E/g, '.')] = value; - } - return deserialized; -}; + const deserialized = {}; + for (const [field, value] of Object.entries(data)) { + deserialized[field.replaceAll('.', '.')] = value; + } -helpers.valueToString = function (value) { - return String(value); + return deserialized; }; +helpers.valueToString = String; + helpers.buildMatchQuery = function (match) { - let _match = match; - if (match.startsWith('*')) { - _match = _match.substring(1); - } - if (match.endsWith('*')) { - _match = _match.substring(0, _match.length - 1); - } - _match = utils.escapeRegexChars(_match); - if (!match.startsWith('*')) { - _match = `^${_match}`; - } - if (!match.endsWith('*')) { - _match += '$'; - } - return _match; + let _match = match; + if (match.startsWith('*')) { + _match = _match.slice(1); + } + + if (match.endsWith('*')) { + _match = _match.slice(0, Math.max(0, _match.length - 1)); + } + + _match = utils.escapeRegexChars(_match); + if (!match.startsWith('*')) { + _match = `^${_match}`; + } + + if (!match.endsWith('*')) { + _match += '$'; + } + + return _match; }; diff --git a/src/database/mongo/list.js b/src/database/mongo/list.js index 59b1678..c2c6c99 100644 --- a/src/database/mongo/list.js +++ b/src/database/mongo/list.js @@ -1,99 +1,96 @@ 'use strict'; module.exports = function (module) { - const helpers = require('./helpers'); - - module.listPrepend = async function (key, value) { - if (!key) { - return; - } - value = Array.isArray(value) ? value : [value]; - value.reverse(); - const exists = await module.isObjectField(key, 'array'); - if (exists) { - await listPush(key, value, { $position: 0 }); - } else { - await module.listAppend(key, value); - } - }; - - module.listAppend = async function (key, value) { - if (!key) { - return; - } - value = Array.isArray(value) ? value : [value]; - await listPush(key, value); - }; - - async function listPush(key, values, position) { - values = values.map(helpers.valueToString); - await module.client.collection('objects').updateOne({ - _key: key, - }, { - $push: { - array: { - $each: values, - ...(position || {}), - }, - }, - }, { - upsert: true, - }); - } - - module.listRemoveLast = async function (key) { - if (!key) { - return; - } - const value = await module.getListRange(key, -1, -1); - module.client.collection('objects').updateOne({ _key: key }, { $pop: { array: 1 } }); - return (value && value.length) ? value[0] : null; - }; - - module.listRemoveAll = async function (key, value) { - if (!key) { - return; - } - const isArray = Array.isArray(value); - if (isArray) { - value = value.map(helpers.valueToString); - } else { - value = helpers.valueToString(value); - } - - await module.client.collection('objects').updateOne({ - _key: key, - }, { - $pull: { array: isArray ? { $in: value } : value }, - }); - }; - - module.listTrim = async function (key, start, stop) { - if (!key) { - return; - } - const value = await module.getListRange(key, start, stop); - await module.client.collection('objects').updateOne({ _key: key }, { $set: { array: value } }); - }; - - module.getListRange = async function (key, start, stop) { - if (!key) { - return; - } - - const data = await module.client.collection('objects').findOne({ _key: key }, { array: 1 }); - if (!(data && data.array)) { - return []; - } - - return data.array.slice(start, stop !== -1 ? stop + 1 : undefined); - }; - - module.listLength = async function (key) { - const result = await module.client.collection('objects').aggregate([ - { $match: { _key: key } }, - { $project: { count: { $size: '$array' } } }, - ]).toArray(); - return Array.isArray(result) && result.length && result[0].count; - }; + const helpers = require('./helpers'); + + module.listPrepend = async function (key, value) { + if (!key) { + return; + } + + value = Array.isArray(value) ? value : [value]; + value.reverse(); + const exists = await module.isObjectField(key, 'array'); + await (exists ? listPush(key, value, {$position: 0}) : module.listAppend(key, value)); + }; + + module.listAppend = async function (key, value) { + if (!key) { + return; + } + + value = Array.isArray(value) ? value : [value]; + await listPush(key, value); + }; + + async function listPush(key, values, position) { + values = values.map(helpers.valueToString); + await module.client.collection('objects').updateOne({ + _key: key, + }, { + $push: { + array: { + $each: values, + ...position, + }, + }, + }, { + upsert: true, + }); + } + + module.listRemoveLast = async function (key) { + if (!key) { + return; + } + + const value = await module.getListRange(key, -1, -1); + module.client.collection('objects').updateOne({_key: key}, {$pop: {array: 1}}); + return (value && value.length > 0) ? value[0] : null; + }; + + module.listRemoveAll = async function (key, value) { + if (!key) { + return; + } + + const isArray = Array.isArray(value); + value = isArray ? value.map(helpers.valueToString) : helpers.valueToString(value); + + await module.client.collection('objects').updateOne({ + _key: key, + }, { + $pull: {array: isArray ? {$in: value} : value}, + }); + }; + + module.listTrim = async function (key, start, stop) { + if (!key) { + return; + } + + const value = await module.getListRange(key, start, stop); + await module.client.collection('objects').updateOne({_key: key}, {$set: {array: value}}); + }; + + module.getListRange = async function (key, start, stop) { + if (!key) { + return; + } + + const data = await module.client.collection('objects').findOne({_key: key}, {array: 1}); + if (!(data && data.array)) { + return []; + } + + return data.array.slice(start, stop === -1 ? undefined : stop + 1); + }; + + module.listLength = async function (key) { + const result = await module.client.collection('objects').aggregate([ + {$match: {_key: key}}, + {$project: {count: {$size: '$array'}}}, + ]).toArray(); + return Array.isArray(result) && result.length && result[0].count; + }; }; diff --git a/src/database/mongo/main.js b/src/database/mongo/main.js index b81fa60..7d9fb0f 100644 --- a/src/database/mongo/main.js +++ b/src/database/mongo/main.js @@ -1,150 +1,163 @@ 'use strict'; module.exports = function (module) { - const helpers = require('./helpers'); - module.flushdb = async function () { - await module.client.dropDatabase(); - }; - - module.emptydb = async function () { - await module.client.collection('objects').deleteMany({}); - module.objectCache.reset(); - }; - - module.exists = async function (key) { - if (!key) { - return; - } - - if (Array.isArray(key)) { - const data = await module.client.collection('objects').find({ - _key: { $in: key }, - }, { _id: 0, _key: 1 }).toArray(); - - const map = {}; - data.forEach((item) => { - map[item._key] = true; - }); - - return key.map(key => !!map[key]); - } - - const item = await module.client.collection('objects').findOne({ - _key: key, - }, { _id: 0, _key: 1 }); - return item !== undefined && item !== null; - }; - - module.scan = async function (params) { - const match = helpers.buildMatchQuery(params.match); - return await module.client.collection('objects').distinct( - '_key', { _key: { $regex: new RegExp(match) } } - ); - }; - - module.delete = async function (key) { - if (!key) { - return; - } - await module.client.collection('objects').deleteMany({ _key: key }); - module.objectCache.del(key); - }; - - module.deleteAll = async function (keys) { - if (!Array.isArray(keys) || !keys.length) { - return; - } - await module.client.collection('objects').deleteMany({ _key: { $in: keys } }); - module.objectCache.del(keys); - }; - - module.get = async function (key) { - if (!key) { - return; - } - - const objectData = await module.client.collection('objects').findOne({ _key: key }, { projection: { _id: 0 } }); - - // fallback to old field name 'value' for backwards compatibility #6340 - let value = null; - if (objectData) { - if (objectData.hasOwnProperty('data')) { - value = objectData.data; - } else if (objectData.hasOwnProperty('value')) { - value = objectData.value; - } - } - return value; - }; - - module.set = async function (key, value) { - if (!key) { - return; - } - await module.setObject(key, { data: value }); - }; - - module.increment = async function (key) { - if (!key) { - return; - } - const result = await module.client.collection('objects').findOneAndUpdate({ - _key: key, - }, { - $inc: { data: 1 }, - }, { - returnDocument: 'after', - upsert: true, - }); - return result && result.value ? result.value.data : null; - }; - - module.rename = async function (oldKey, newKey) { - await module.client.collection('objects').updateMany({ _key: oldKey }, { $set: { _key: newKey } }); - module.objectCache.del([oldKey, newKey]); - }; - - module.type = async function (key) { - const data = await module.client.collection('objects').findOne({ _key: key }); - if (!data) { - return null; - } - delete data.expireAt; - const keys = Object.keys(data); - if (keys.length === 4 && data.hasOwnProperty('_key') && data.hasOwnProperty('score') && data.hasOwnProperty('value')) { - return 'zset'; - } else if (keys.length === 3 && data.hasOwnProperty('_key') && data.hasOwnProperty('members')) { - return 'set'; - } else if (keys.length === 3 && data.hasOwnProperty('_key') && data.hasOwnProperty('array')) { - return 'list'; - } else if (keys.length === 3 && data.hasOwnProperty('_key') && data.hasOwnProperty('data')) { - return 'string'; - } - return 'hash'; - }; - - module.expire = async function (key, seconds) { - await module.expireAt(key, Math.round(Date.now() / 1000) + seconds); - }; - - module.expireAt = async function (key, timestamp) { - await module.setObjectField(key, 'expireAt', new Date(timestamp * 1000)); - }; - - module.pexpire = async function (key, ms) { - await module.pexpireAt(key, Date.now() + parseInt(ms, 10)); - }; - - module.pexpireAt = async function (key, timestamp) { - timestamp = Math.min(timestamp, 8640000000000000); - await module.setObjectField(key, 'expireAt', new Date(timestamp)); - }; - - module.ttl = async function (key) { - return Math.round((await module.getObjectField(key, 'expireAt') - Date.now()) / 1000); - }; - - module.pttl = async function (key) { - return await module.getObjectField(key, 'expireAt') - Date.now(); - }; + const helpers = require('./helpers'); + module.flushdb = async function () { + await module.client.dropDatabase(); + }; + + module.emptydb = async function () { + await module.client.collection('objects').deleteMany({}); + module.objectCache.reset(); + }; + + module.exists = async function (key) { + if (!key) { + return; + } + + if (Array.isArray(key)) { + const data = await module.client.collection('objects').find({ + _key: {$in: key}, + }, {_id: 0, _key: 1}).toArray(); + + const map = {}; + for (const item of data) { + map[item._key] = true; + } + + return key.map(key => Boolean(map[key])); + } + + const item = await module.client.collection('objects').findOne({ + _key: key, + }, {_id: 0, _key: 1}); + return item !== undefined && item !== null; + }; + + module.scan = async function (parameters) { + const match = helpers.buildMatchQuery(parameters.match); + return await module.client.collection('objects').distinct( + '_key', {_key: {$regex: new RegExp(match)}}, + ); + }; + + module.delete = async function (key) { + if (!key) { + return; + } + + await module.client.collection('objects').deleteMany({_key: key}); + module.objectCache.del(key); + }; + + module.deleteAll = async function (keys) { + if (!Array.isArray(keys) || keys.length === 0) { + return; + } + + await module.client.collection('objects').deleteMany({_key: {$in: keys}}); + module.objectCache.del(keys); + }; + + module.get = async function (key) { + if (!key) { + return; + } + + const objectData = await module.client.collection('objects').findOne({_key: key}, {projection: {_id: 0}}); + + // Fallback to old field name 'value' for backwards compatibility #6340 + let value = null; + if (objectData) { + if (objectData.hasOwnProperty('data')) { + value = objectData.data; + } else if (objectData.hasOwnProperty('value')) { + value = objectData.value; + } + } + + return value; + }; + + module.set = async function (key, value) { + if (!key) { + return; + } + + await module.setObject(key, {data: value}); + }; + + module.increment = async function (key) { + if (!key) { + return; + } + + const result = await module.client.collection('objects').findOneAndUpdate({ + _key: key, + }, { + $inc: {data: 1}, + }, { + returnDocument: 'after', + upsert: true, + }); + return result && result.value ? result.value.data : null; + }; + + module.rename = async function (oldKey, newKey) { + await module.client.collection('objects').updateMany({_key: oldKey}, {$set: {_key: newKey}}); + module.objectCache.del([oldKey, newKey]); + }; + + module.type = async function (key) { + const data = await module.client.collection('objects').findOne({_key: key}); + if (!data) { + return null; + } + + delete data.expireAt; + const keys = Object.keys(data); + if (keys.length === 4 && data.hasOwnProperty('_key') && data.hasOwnProperty('score') && data.hasOwnProperty('value')) { + return 'zset'; + } + + if (keys.length === 3 && data.hasOwnProperty('_key') && data.hasOwnProperty('members')) { + return 'set'; + } + + if (keys.length === 3 && data.hasOwnProperty('_key') && data.hasOwnProperty('array')) { + return 'list'; + } + + if (keys.length === 3 && data.hasOwnProperty('_key') && data.hasOwnProperty('data')) { + return 'string'; + } + + return 'hash'; + }; + + module.expire = async function (key, seconds) { + await module.expireAt(key, Math.round(Date.now() / 1000) + seconds); + }; + + module.expireAt = async function (key, timestamp) { + await module.setObjectField(key, 'expireAt', new Date(timestamp * 1000)); + }; + + module.pexpire = async function (key, ms) { + await module.pexpireAt(key, Date.now() + Number.parseInt(ms, 10)); + }; + + module.pexpireAt = async function (key, timestamp) { + timestamp = Math.min(timestamp, 8_640_000_000_000_000); + await module.setObjectField(key, 'expireAt', new Date(timestamp)); + }; + + module.ttl = async function (key) { + return Math.round((await module.getObjectField(key, 'expireAt') - Date.now()) / 1000); + }; + + module.pttl = async function (key) { + return await module.getObjectField(key, 'expireAt') - Date.now(); + }; }; diff --git a/src/database/mongo/sets.js b/src/database/mongo/sets.js index 8cfdacb..3f0d6a1 100644 --- a/src/database/mongo/sets.js +++ b/src/database/mongo/sets.js @@ -1,199 +1,209 @@ 'use strict'; module.exports = function (module) { - const _ = require('lodash'); - const helpers = require('./helpers'); - - module.setAdd = async function (key, value) { - if (!Array.isArray(value)) { - value = [value]; - } - if (!value.length) { - return; - } - value = value.map(v => helpers.valueToString(v)); - - await module.client.collection('objects').updateOne({ - _key: key, - }, { - $addToSet: { - members: { - $each: value, - }, - }, - }, { - upsert: true, - }); - }; - - module.setsAdd = async function (keys, value) { - if (!Array.isArray(keys) || !keys.length) { - return; - } - - if (!Array.isArray(value)) { - value = [value]; - } - - value = value.map(v => helpers.valueToString(v)); - - const bulk = module.client.collection('objects').initializeUnorderedBulkOp(); - - for (let i = 0; i < keys.length; i += 1) { - bulk.find({ _key: keys[i] }).upsert().updateOne({ - $addToSet: { - members: { - $each: value, - }, - }, - }); - } - try { - await bulk.execute(); - } catch (err) { - if (err && err.message.startsWith('E11000 duplicate key error')) { - return await module.setsAdd(keys, value); - } - throw err; - } - }; - - module.setRemove = async function (key, value) { - if (!Array.isArray(value)) { - value = [value]; - } - - value = value.map(v => helpers.valueToString(v)); - - await module.client.collection('objects').updateMany({ - _key: Array.isArray(key) ? { $in: key } : key, - }, { - $pullAll: { members: value }, - }); - }; - - module.setsRemove = async function (keys, value) { - if (!Array.isArray(keys) || !keys.length) { - return; - } - value = helpers.valueToString(value); - - await module.client.collection('objects').updateMany({ - _key: { $in: keys }, - }, { - $pull: { members: value }, - }); - }; - - module.isSetMember = async function (key, value) { - if (!key) { - return false; - } - value = helpers.valueToString(value); - - const item = await module.client.collection('objects').findOne({ - _key: key, members: value, - }, { - projection: { _id: 0, members: 0 }, - }); - return item !== null && item !== undefined; - }; - - module.isSetMembers = async function (key, values) { - if (!key || !Array.isArray(values) || !values.length) { - return []; - } - values = values.map(v => helpers.valueToString(v)); - - const result = await module.client.collection('objects').findOne({ - _key: key, - }, { - projection: { _id: 0, _key: 0 }, - }); - const membersSet = new Set(result && Array.isArray(result.members) ? result.members : []); - return values.map(v => membersSet.has(v)); - }; - - module.isMemberOfSets = async function (sets, value) { - if (!Array.isArray(sets) || !sets.length) { - return []; - } - value = helpers.valueToString(value); - - const result = await module.client.collection('objects').find({ - _key: { $in: sets }, members: value, - }, { - projection: { _id: 0, members: 0 }, - }).toArray(); - - const map = {}; - result.forEach((item) => { - map[item._key] = true; - }); - - return sets.map(set => !!map[set]); - }; - - module.getSetMembers = async function (key) { - if (!key) { - return []; - } - - const data = await module.client.collection('objects').findOne({ - _key: key, - }, { - projection: { _id: 0, _key: 0 }, - }); - return data ? data.members : []; - }; - - module.getSetsMembers = async function (keys) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } - const data = await module.client.collection('objects').find({ - _key: { $in: keys }, - }, { - projection: { _id: 0 }, - }).toArray(); - - const sets = {}; - data.forEach((set) => { - sets[set._key] = set.members || []; - }); - - return keys.map(k => sets[k] || []); - }; - - module.setCount = async function (key) { - if (!key) { - return 0; - } - const data = await module.client.collection('objects').aggregate([ - { $match: { _key: key } }, - { $project: { _id: 0, count: { $size: '$members' } } }, - ]).toArray(); - return Array.isArray(data) && data.length ? data[0].count : 0; - }; - - module.setsCount = async function (keys) { - const data = await module.client.collection('objects').aggregate([ - { $match: { _key: { $in: keys } } }, - { $project: { _id: 0, _key: 1, count: { $size: '$members' } } }, - ]).toArray(); - const map = _.keyBy(data, '_key'); - return keys.map(key => (map.hasOwnProperty(key) ? map[key].count : 0)); - }; - - module.setRemoveRandom = async function (key) { - const data = await module.client.collection('objects').findOne({ _key: key }); - if (!data) { - return; - } - - const randomIndex = Math.floor(Math.random() * data.members.length); - const value = data.members[randomIndex]; - await module.setRemove(data._key, value); - return value; - }; + const _ = require('lodash'); + const helpers = require('./helpers'); + + module.setAdd = async function (key, value) { + if (!Array.isArray(value)) { + value = [value]; + } + + if (value.length === 0) { + return; + } + + value = value.map(v => helpers.valueToString(v)); + + await module.client.collection('objects').updateOne({ + _key: key, + }, { + $addToSet: { + members: { + $each: value, + }, + }, + }, { + upsert: true, + }); + }; + + module.setsAdd = async function (keys, value) { + if (!Array.isArray(keys) || keys.length === 0) { + return; + } + + if (!Array.isArray(value)) { + value = [value]; + } + + value = value.map(v => helpers.valueToString(v)); + + const bulk = module.client.collection('objects').initializeUnorderedBulkOp(); + + for (const key of keys) { + bulk.find({_key: key}).upsert().updateOne({ + $addToSet: { + members: { + $each: value, + }, + }, + }); + } + + try { + await bulk.execute(); + } catch (error) { + if (error && error.message.startsWith('E11000 duplicate key error')) { + return await module.setsAdd(keys, value); + } + + throw error; + } + }; + + module.setRemove = async function (key, value) { + if (!Array.isArray(value)) { + value = [value]; + } + + value = value.map(v => helpers.valueToString(v)); + + await module.client.collection('objects').updateMany({ + _key: Array.isArray(key) ? {$in: key} : key, + }, { + $pullAll: {members: value}, + }); + }; + + module.setsRemove = async function (keys, value) { + if (!Array.isArray(keys) || keys.length === 0) { + return; + } + + value = helpers.valueToString(value); + + await module.client.collection('objects').updateMany({ + _key: {$in: keys}, + }, { + $pull: {members: value}, + }); + }; + + module.isSetMember = async function (key, value) { + if (!key) { + return false; + } + + value = helpers.valueToString(value); + + const item = await module.client.collection('objects').findOne({ + _key: key, members: value, + }, { + projection: {_id: 0, members: 0}, + }); + return item !== null && item !== undefined; + }; + + module.isSetMembers = async function (key, values) { + if (!key || !Array.isArray(values) || values.length === 0) { + return []; + } + + values = values.map(v => helpers.valueToString(v)); + + const result = await module.client.collection('objects').findOne({ + _key: key, + }, { + projection: {_id: 0, _key: 0}, + }); + const membersSet = new Set(result && Array.isArray(result.members) ? result.members : []); + return values.map(v => membersSet.has(v)); + }; + + module.isMemberOfSets = async function (sets, value) { + if (!Array.isArray(sets) || sets.length === 0) { + return []; + } + + value = helpers.valueToString(value); + + const result = await module.client.collection('objects').find({ + _key: {$in: sets}, members: value, + }, { + projection: {_id: 0, members: 0}, + }).toArray(); + + const map = {}; + for (const item of result) { + map[item._key] = true; + } + + return sets.map(set => Boolean(map[set])); + }; + + module.getSetMembers = async function (key) { + if (!key) { + return []; + } + + const data = await module.client.collection('objects').findOne({ + _key: key, + }, { + projection: {_id: 0, _key: 0}, + }); + return data ? data.members : []; + }; + + module.getSetsMembers = async function (keys) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } + + const data = await module.client.collection('objects').find({ + _key: {$in: keys}, + }, { + projection: {_id: 0}, + }).toArray(); + + const sets = {}; + for (const set of data) { + sets[set._key] = set.members || []; + } + + return keys.map(k => sets[k] || []); + }; + + module.setCount = async function (key) { + if (!key) { + return 0; + } + + const data = await module.client.collection('objects').aggregate([ + {$match: {_key: key}}, + {$project: {_id: 0, count: {$size: '$members'}}}, + ]).toArray(); + return Array.isArray(data) && data.length > 0 ? data[0].count : 0; + }; + + module.setsCount = async function (keys) { + const data = await module.client.collection('objects').aggregate([ + {$match: {_key: {$in: keys}}}, + {$project: {_id: 0, _key: 1, count: {$size: '$members'}}}, + ]).toArray(); + const map = _.keyBy(data, '_key'); + return keys.map(key => (map.hasOwnProperty(key) ? map[key].count : 0)); + }; + + module.setRemoveRandom = async function (key) { + const data = await module.client.collection('objects').findOne({_key: key}); + if (!data) { + return; + } + + const randomIndex = Math.floor(Math.random() * data.members.length); + const value = data.members[randomIndex]; + await module.setRemove(data._key, value); + return value; + }; }; diff --git a/src/database/mongo/sorted.js b/src/database/mongo/sorted.js index b1a5979..701408d 100644 --- a/src/database/mongo/sorted.js +++ b/src/database/mongo/sorted.js @@ -4,566 +4,595 @@ const _ = require('lodash'); const utils = require('../../utils'); module.exports = function (module) { - const helpers = require('./helpers'); - const dbHelpers = require('../helpers'); - - const util = require('util'); - const sleep = util.promisify(setTimeout); - - require('./sorted/add')(module); - require('./sorted/remove')(module); - require('./sorted/union')(module); - require('./sorted/intersect')(module); - - module.getSortedSetRange = async function (key, start, stop) { - return await getSortedSetRange(key, start, stop, '-inf', '+inf', 1, false); - }; - - module.getSortedSetRevRange = async function (key, start, stop) { - return await getSortedSetRange(key, start, stop, '-inf', '+inf', -1, false); - }; - - module.getSortedSetRangeWithScores = async function (key, start, stop) { - return await getSortedSetRange(key, start, stop, '-inf', '+inf', 1, true); - }; - - module.getSortedSetRevRangeWithScores = async function (key, start, stop) { - return await getSortedSetRange(key, start, stop, '-inf', '+inf', -1, true); - }; - - async function getSortedSetRange(key, start, stop, min, max, sort, withScores) { - if (!key) { - return; - } - const isArray = Array.isArray(key); - if ((start < 0 && start > stop) || (isArray && !key.length)) { - return []; - } - const query = { _key: key }; - if (isArray) { - if (key.length > 1) { - query._key = { $in: key }; - } else { - query._key = key[0]; - } - } - - if (min !== '-inf') { - query.score = { $gte: min }; - } - if (max !== '+inf') { - query.score = query.score || {}; - query.score.$lte = max; - } - - if (max === min) { - query.score = max; - } - - const fields = { _id: 0, _key: 0 }; - if (!withScores) { - fields.score = 0; - } - - let reverse = false; - if (start === 0 && stop < -1) { - reverse = true; - sort *= -1; - start = Math.abs(stop + 1); - stop = -1; - } else if (start < 0 && stop > start) { - const tmp1 = Math.abs(stop + 1); - stop = Math.abs(start + 1); - start = tmp1; - } - - let limit = stop - start + 1; - if (limit <= 0) { - limit = 0; - } - - let result = []; - async function doQuery(_key, fields, skip, limit) { - return await module.client.collection('objects').find({ ...query, ...{ _key: _key } }, { projection: fields }) - .sort({ score: sort }) - .skip(skip) - .limit(limit) - .toArray(); - } - - if (isArray && key.length > 100) { - const batches = []; - const batch = require('../../batch'); - const batchSize = Math.ceil(key.length / Math.ceil(key.length / 100)); - await batch.processArray(key, async currentBatch => batches.push(currentBatch), { batch: batchSize }); - const batchData = await Promise.all(batches.map( - batch => doQuery({ $in: batch }, { _id: 0, _key: 0 }, 0, stop + 1) - )); - result = dbHelpers.mergeBatch(batchData, 0, stop, sort); - if (start > 0) { - result = result.slice(start, stop !== -1 ? stop + 1 : undefined); - } - } else { - result = await doQuery(query._key, fields, start, limit); - } - - if (reverse) { - result.reverse(); - } - if (!withScores) { - result = result.map(item => item.value); - } - - return result; - } - - module.getSortedSetRangeByScore = async function (key, start, count, min, max) { - return await getSortedSetRangeByScore(key, start, count, min, max, 1, false); - }; - - module.getSortedSetRevRangeByScore = async function (key, start, count, max, min) { - return await getSortedSetRangeByScore(key, start, count, min, max, -1, false); - }; - - module.getSortedSetRangeByScoreWithScores = async function (key, start, count, min, max) { - return await getSortedSetRangeByScore(key, start, count, min, max, 1, true); - }; - - module.getSortedSetRevRangeByScoreWithScores = async function (key, start, count, max, min) { - return await getSortedSetRangeByScore(key, start, count, min, max, -1, true); - }; - - async function getSortedSetRangeByScore(key, start, count, min, max, sort, withScores) { - if (parseInt(count, 10) === 0) { - return []; - } - const stop = (parseInt(count, 10) === -1) ? -1 : (start + count - 1); - return await getSortedSetRange(key, start, stop, min, max, sort, withScores); - } - - module.sortedSetCount = async function (key, min, max) { - if (!key) { - return; - } - - const query = { _key: key }; - if (min !== '-inf') { - query.score = { $gte: min }; - } - if (max !== '+inf') { - query.score = query.score || {}; - query.score.$lte = max; - } - - const count = await module.client.collection('objects').countDocuments(query); - return count || 0; - }; - - module.sortedSetCard = async function (key) { - if (!key) { - return 0; - } - const count = await module.client.collection('objects').countDocuments({ _key: key }); - return parseInt(count, 10) || 0; - }; - - module.sortedSetsCard = async function (keys) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } - const promises = keys.map(k => module.sortedSetCard(k)); - return await Promise.all(promises); - }; - - module.sortedSetsCardSum = async function (keys) { - if (!keys || (Array.isArray(keys) && !keys.length)) { - return 0; - } - - const count = await module.client.collection('objects').countDocuments({ _key: Array.isArray(keys) ? { $in: keys } : keys }); - return parseInt(count, 10) || 0; - }; - - module.sortedSetRank = async function (key, value) { - return await getSortedSetRank(false, key, value); - }; - - module.sortedSetRevRank = async function (key, value) { - return await getSortedSetRank(true, key, value); - }; - - async function getSortedSetRank(reverse, key, value) { - if (!key) { - return; - } - value = helpers.valueToString(value); - const score = await module.sortedSetScore(key, value); - if (score === null) { - return null; - } - - return await module.client.collection('objects').countDocuments({ - $or: [ - { - _key: key, - score: reverse ? { $gt: score } : { $lt: score }, - }, - { - _key: key, - score: score, - value: reverse ? { $gt: value } : { $lt: value }, - }, - ], - }); - } - - module.sortedSetsRanks = async function (keys, values) { - return await sortedSetsRanks(module.sortedSetRank, keys, values); - }; - - module.sortedSetsRevRanks = async function (keys, values) { - return await sortedSetsRanks(module.sortedSetRevRank, keys, values); - }; - - async function sortedSetsRanks(method, keys, values) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } - const data = new Array(values.length); - for (let i = 0; i < values.length; i += 1) { - data[i] = { key: keys[i], value: values[i] }; - } - const promises = data.map(item => method(item.key, item.value)); - return await Promise.all(promises); - } - - module.sortedSetRanks = async function (key, values) { - return await sortedSetRanks(false, key, values); - }; - - module.sortedSetRevRanks = async function (key, values) { - return await sortedSetRanks(true, key, values); - }; - - async function sortedSetRanks(reverse, key, values) { - if (values.length === 1) { - return [await getSortedSetRank(reverse, key, values[0])]; - } - const sortedSet = await module[reverse ? 'getSortedSetRevRange' : 'getSortedSetRange'](key, 0, -1); - return values.map((value) => { - if (!value) { - return null; - } - const index = sortedSet.indexOf(value.toString()); - return index !== -1 ? index : null; - }); - } - - module.sortedSetScore = async function (key, value) { - if (!key) { - return null; - } - value = helpers.valueToString(value); - const result = await module.client.collection('objects').findOne({ _key: key, value: value }, { projection: { _id: 0, _key: 0, value: 0 } }); - return result ? result.score : null; - }; - - module.sortedSetsScore = async function (keys, value) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } - value = helpers.valueToString(value); - const result = await module.client.collection('objects').find({ _key: { $in: keys }, value: value }, { projection: { _id: 0, value: 0 } }).toArray(); - const map = {}; - result.forEach((item) => { - if (item) { - map[item._key] = item; - } - }); - - return keys.map(key => (map[key] ? map[key].score : null)); - }; - - module.sortedSetScores = async function (key, values) { - if (!key) { - return null; - } - if (!values.length) { - return []; - } - values = values.map(helpers.valueToString); - const result = await module.client.collection('objects').find({ _key: key, value: { $in: values } }, { projection: { _id: 0, _key: 0 } }).toArray(); - - const valueToScore = {}; - result.forEach((item) => { - if (item) { - valueToScore[item.value] = item.score; - } - }); - - return values.map(v => (utils.isNumber(valueToScore[v]) ? valueToScore[v] : null)); - }; - - module.isSortedSetMember = async function (key, value) { - if (!key) { - return; - } - value = helpers.valueToString(value); - const result = await module.client.collection('objects').findOne({ - _key: key, value: value, - }, { - projection: { _id: 0, value: 1 }, - }); - return !!result; - }; - - module.isSortedSetMembers = async function (key, values) { - if (!key) { - return; - } - if (!values.length) { - return []; - } - values = values.map(helpers.valueToString); - const results = await module.client.collection('objects').find({ - _key: key, value: { $in: values }, - }, { - projection: { _id: 0, value: 1 }, - }).toArray(); - - const isMember = {}; - results.forEach((item) => { - if (item) { - isMember[item.value] = true; - } - }); - - return values.map(value => !!isMember[value]); - }; - - module.isMemberOfSortedSets = async function (keys, value) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } - value = helpers.valueToString(value); - const results = await module.client.collection('objects').find({ - _key: { $in: keys }, value: value, - }, { - projection: { _id: 0, _key: 1, value: 1 }, - }).toArray(); - - const isMember = {}; - results.forEach((item) => { - if (item) { - isMember[item._key] = true; - } - }); - - return keys.map(key => !!isMember[key]); - }; - - module.getSortedSetMembers = async function (key) { - const data = await module.getSortedSetsMembers([key]); - return data && data[0]; - }; - - module.getSortedSetsMembers = async function (keys) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } - const arrayOfKeys = keys.length > 1; - const projection = { _id: 0, value: 1 }; - if (arrayOfKeys) { - projection._key = 1; - } - const data = await module.client.collection('objects').find({ - _key: arrayOfKeys ? { $in: keys } : keys[0], - }, { projection: projection }).toArray(); - - if (!arrayOfKeys) { - return [data.map(item => item.value)]; - } - const sets = {}; - data.forEach((item) => { - sets[item._key] = sets[item._key] || []; - sets[item._key].push(item.value); - }); - - return keys.map(k => sets[k] || []); - }; - - module.sortedSetIncrBy = async function (key, increment, value) { - if (!key) { - return; - } - const data = {}; - value = helpers.valueToString(value); - data.score = parseFloat(increment); - - try { - const result = await module.client.collection('objects').findOneAndUpdate({ - _key: key, - value: value, - }, { - $inc: data, - }, { - returnDocument: 'after', - upsert: true, - }); - return result && result.value ? result.value.score : null; - } catch (err) { - // if there is duplicate key error retry the upsert - // https://github.com/NodeBB/NodeBB/issues/4467 - // https://jira.mongodb.org/browse/SERVER-14322 - // https://docs.mongodb.org/manual/reference/command/findAndModify/#upsert-and-unique-index - if (err && err.message.startsWith('E11000 duplicate key error')) { - return await module.sortedSetIncrBy(key, increment, value); - } - throw err; - } - }; - - module.sortedSetIncrByBulk = async function (data) { - const bulk = module.client.collection('objects').initializeUnorderedBulkOp(); - data.forEach((item) => { - bulk.find({ _key: item[0], value: helpers.valueToString(item[2]) }) - .upsert() - .update({ $inc: { score: parseFloat(item[1]) } }); - }); - await bulk.execute(); - const result = await module.client.collection('objects').find({ - _key: { $in: _.uniq(data.map(i => i[0])) }, - value: { $in: _.uniq(data.map(i => i[2])) }, - }, { - projection: { _id: 0, _key: 1, value: 1, score: 1 }, - }).toArray(); - - const map = {}; - result.forEach((item) => { - map[`${item._key}:${item.value}`] = item.score; - }); - return data.map(item => map[`${item[0]}:${item[2]}`]); - }; - - module.getSortedSetRangeByLex = async function (key, min, max, start, count) { - return await sortedSetLex(key, min, max, 1, start, count); - }; - - module.getSortedSetRevRangeByLex = async function (key, max, min, start, count) { - return await sortedSetLex(key, min, max, -1, start, count); - }; - - module.sortedSetLexCount = async function (key, min, max) { - const data = await sortedSetLex(key, min, max, 1, 0, 0); - return data ? data.length : null; - }; - - async function sortedSetLex(key, min, max, sort, start, count) { - const query = { _key: key }; - start = start !== undefined ? start : 0; - count = count !== undefined ? count : 0; - buildLexQuery(query, min, max); - - const data = await module.client.collection('objects').find(query, { projection: { _id: 0, value: 1 } }) - .sort({ value: sort }) - .skip(start) - .limit(count === -1 ? 0 : count) - .toArray(); - - return data.map(item => item && item.value); - } - - module.sortedSetRemoveRangeByLex = async function (key, min, max) { - const query = { _key: key }; - buildLexQuery(query, min, max); - - await module.client.collection('objects').deleteMany(query); - }; - - function buildLexQuery(query, min, max) { - if (min !== '-') { - if (min.match(/^\(/)) { - query.value = { $gt: min.slice(1) }; - } else if (min.match(/^\[/)) { - query.value = { $gte: min.slice(1) }; - } else { - query.value = { $gte: min }; - } - } - if (max !== '+') { - query.value = query.value || {}; - if (max.match(/^\(/)) { - query.value.$lt = max.slice(1); - } else if (max.match(/^\[/)) { - query.value.$lte = max.slice(1); - } else { - query.value.$lte = max; - } - } - } - - module.getSortedSetScan = async function (params) { - const project = { _id: 0, value: 1 }; - if (params.withScores) { - project.score = 1; - } - - const match = helpers.buildMatchQuery(params.match); - let regex; - try { - regex = new RegExp(match); - } catch (err) { - return []; - } - - const cursor = module.client.collection('objects').find({ - _key: params.key, value: { $regex: regex }, - }, { projection: project }); - - if (params.limit) { - cursor.limit(params.limit); - } - - const data = await cursor.toArray(); - if (!params.withScores) { - return data.map(d => d.value); - } - return data; - }; - - module.processSortedSet = async function (setKey, processFn, options) { - let done = false; - const ids = []; - const project = { _id: 0, _key: 0 }; - - if (!options.withScores) { - project.score = 0; - } - const cursor = await module.client.collection('objects').find({ _key: setKey }, { projection: project }) - .sort({ score: 1 }) - .batchSize(options.batch); - - if (processFn && processFn.constructor && processFn.constructor.name !== 'AsyncFunction') { - processFn = util.promisify(processFn); - } - - while (!done) { - /* eslint-disable no-await-in-loop */ - const item = await cursor.next(); - if (item === null) { - done = true; - } else { - ids.push(options.withScores ? item : item.value); - } - - if (ids.length >= options.batch || (done && ids.length !== 0)) { - await processFn(ids); - - ids.length = 0; - if (options.interval) { - await sleep(options.interval); - } - } - } - }; + const helpers = require('./helpers'); + const dbHelpers = require('../helpers'); + + const util = require('node:util'); + const sleep = util.promisify(setTimeout); + + require('./sorted/add')(module); + require('./sorted/remove')(module); + require('./sorted/union')(module); + require('./sorted/intersect')(module); + + module.getSortedSetRange = async function (key, start, stop) { + return await getSortedSetRange(key, start, stop, '-inf', '+inf', 1, false); + }; + + module.getSortedSetRevRange = async function (key, start, stop) { + return await getSortedSetRange(key, start, stop, '-inf', '+inf', -1, false); + }; + + module.getSortedSetRangeWithScores = async function (key, start, stop) { + return await getSortedSetRange(key, start, stop, '-inf', '+inf', 1, true); + }; + + module.getSortedSetRevRangeWithScores = async function (key, start, stop) { + return await getSortedSetRange(key, start, stop, '-inf', '+inf', -1, true); + }; + + async function getSortedSetRange(key, start, stop, min, max, sort, withScores) { + if (!key) { + return; + } + + const isArray = Array.isArray(key); + if ((start < 0 && start > stop) || (isArray && key.length === 0)) { + return []; + } + + const query = {_key: key}; + if (isArray) { + query._key = key.length > 1 ? {$in: key} : key[0]; + } + + if (min !== '-inf') { + query.score = {$gte: min}; + } + + if (max !== '+inf') { + query.score = query.score || {}; + query.score.$lte = max; + } + + if (max === min) { + query.score = max; + } + + const fields = {_id: 0, _key: 0}; + if (!withScores) { + fields.score = 0; + } + + let reverse = false; + if (start === 0 && stop < -1) { + reverse = true; + sort *= -1; + start = Math.abs(stop + 1); + stop = -1; + } else if (start < 0 && stop > start) { + const temporary1 = Math.abs(stop + 1); + stop = Math.abs(start + 1); + start = temporary1; + } + + let limit = stop - start + 1; + if (limit <= 0) { + limit = 0; + } + + let result = []; + async function doQuery(_key, fields, skip, limit) { + return await module.client.collection('objects').find({...query, _key}, {projection: fields}) + .sort({score: sort}) + .skip(skip) + .limit(limit) + .toArray(); + } + + if (isArray && key.length > 100) { + const batches = []; + const batch = require('../../batch'); + const batchSize = Math.ceil(key.length / Math.ceil(key.length / 100)); + await batch.processArray(key, async currentBatch => batches.push(currentBatch), {batch: batchSize}); + const batchData = await Promise.all(batches.map( + batch => doQuery({$in: batch}, {_id: 0, _key: 0}, 0, stop + 1), + )); + result = dbHelpers.mergeBatch(batchData, 0, stop, sort); + if (start > 0) { + result = result.slice(start, stop === -1 ? undefined : stop + 1); + } + } else { + result = await doQuery(query._key, fields, start, limit); + } + + if (reverse) { + result.reverse(); + } + + if (!withScores) { + result = result.map(item => item.value); + } + + return result; + } + + module.getSortedSetRangeByScore = async function (key, start, count, min, max) { + return await getSortedSetRangeByScore(key, start, count, min, max, 1, false); + }; + + module.getSortedSetRevRangeByScore = async function (key, start, count, max, min) { + return await getSortedSetRangeByScore(key, start, count, min, max, -1, false); + }; + + module.getSortedSetRangeByScoreWithScores = async function (key, start, count, min, max) { + return await getSortedSetRangeByScore(key, start, count, min, max, 1, true); + }; + + module.getSortedSetRevRangeByScoreWithScores = async function (key, start, count, max, min) { + return await getSortedSetRangeByScore(key, start, count, min, max, -1, true); + }; + + async function getSortedSetRangeByScore(key, start, count, min, max, sort, withScores) { + if (Number.parseInt(count, 10) === 0) { + return []; + } + + const stop = (Number.parseInt(count, 10) === -1) ? -1 : (start + count - 1); + return await getSortedSetRange(key, start, stop, min, max, sort, withScores); + } + + module.sortedSetCount = async function (key, min, max) { + if (!key) { + return; + } + + const query = {_key: key}; + if (min !== '-inf') { + query.score = {$gte: min}; + } + + if (max !== '+inf') { + query.score = query.score || {}; + query.score.$lte = max; + } + + const count = await module.client.collection('objects').countDocuments(query); + return count || 0; + }; + + module.sortedSetCard = async function (key) { + if (!key) { + return 0; + } + + const count = await module.client.collection('objects').countDocuments({_key: key}); + return Number.parseInt(count, 10) || 0; + }; + + module.sortedSetsCard = async function (keys) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } + + const promises = keys.map(k => module.sortedSetCard(k)); + return await Promise.all(promises); + }; + + module.sortedSetsCardSum = async function (keys) { + if (!keys || (Array.isArray(keys) && keys.length === 0)) { + return 0; + } + + const count = await module.client.collection('objects').countDocuments({_key: Array.isArray(keys) ? {$in: keys} : keys}); + return Number.parseInt(count, 10) || 0; + }; + + module.sortedSetRank = async function (key, value) { + return await getSortedSetRank(false, key, value); + }; + + module.sortedSetRevRank = async function (key, value) { + return await getSortedSetRank(true, key, value); + }; + + async function getSortedSetRank(reverse, key, value) { + if (!key) { + return; + } + + value = helpers.valueToString(value); + const score = await module.sortedSetScore(key, value); + if (score === null) { + return null; + } + + return await module.client.collection('objects').countDocuments({ + $or: [ + { + _key: key, + score: reverse ? {$gt: score} : {$lt: score}, + }, + { + _key: key, + score, + value: reverse ? {$gt: value} : {$lt: value}, + }, + ], + }); + } + + module.sortedSetsRanks = async function (keys, values) { + return await sortedSetsRanks(module.sortedSetRank, keys, values); + }; + + module.sortedSetsRevRanks = async function (keys, values) { + return await sortedSetsRanks(module.sortedSetRevRank, keys, values); + }; + + async function sortedSetsRanks(method, keys, values) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } + + const data = Array.from({length: values.length}); + for (const [i, value] of values.entries()) { + data[i] = {key: keys[i], value}; + } + + const promises = data.map(item => method(item.key, item.value)); + return await Promise.all(promises); + } + + module.sortedSetRanks = async function (key, values) { + return await sortedSetRanks(false, key, values); + }; + + module.sortedSetRevRanks = async function (key, values) { + return await sortedSetRanks(true, key, values); + }; + + async function sortedSetRanks(reverse, key, values) { + if (values.length === 1) { + return [await getSortedSetRank(reverse, key, values[0])]; + } + + const sortedSet = await module[reverse ? 'getSortedSetRevRange' : 'getSortedSetRange'](key, 0, -1); + return values.map(value => { + if (!value) { + return null; + } + + const index = sortedSet.indexOf(value.toString()); + return index === -1 ? null : index; + }); + } + + module.sortedSetScore = async function (key, value) { + if (!key) { + return null; + } + + value = helpers.valueToString(value); + const result = await module.client.collection('objects').findOne({_key: key, value}, {projection: {_id: 0, _key: 0, value: 0}}); + return result ? result.score : null; + }; + + module.sortedSetsScore = async function (keys, value) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } + + value = helpers.valueToString(value); + const result = await module.client.collection('objects').find({_key: {$in: keys}, value}, {projection: {_id: 0, value: 0}}).toArray(); + const map = {}; + for (const item of result) { + if (item) { + map[item._key] = item; + } + } + + return keys.map(key => (map[key] ? map[key].score : null)); + }; + + module.sortedSetScores = async function (key, values) { + if (!key) { + return null; + } + + if (values.length === 0) { + return []; + } + + values = values.map(helpers.valueToString); + const result = await module.client.collection('objects').find({_key: key, value: {$in: values}}, {projection: {_id: 0, _key: 0}}).toArray(); + + const valueToScore = {}; + for (const item of result) { + if (item) { + valueToScore[item.value] = item.score; + } + } + + return values.map(v => (utils.isNumber(valueToScore[v]) ? valueToScore[v] : null)); + }; + + module.isSortedSetMember = async function (key, value) { + if (!key) { + return; + } + + value = helpers.valueToString(value); + const result = await module.client.collection('objects').findOne({ + _key: key, value, + }, { + projection: {_id: 0, value: 1}, + }); + return Boolean(result); + }; + + module.isSortedSetMembers = async function (key, values) { + if (!key) { + return; + } + + if (values.length === 0) { + return []; + } + + values = values.map(helpers.valueToString); + const results = await module.client.collection('objects').find({ + _key: key, value: {$in: values}, + }, { + projection: {_id: 0, value: 1}, + }).toArray(); + + const isMember = {}; + for (const item of results) { + if (item) { + isMember[item.value] = true; + } + } + + return values.map(value => Boolean(isMember[value])); + }; + + module.isMemberOfSortedSets = async function (keys, value) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } + + value = helpers.valueToString(value); + const results = await module.client.collection('objects').find({ + _key: {$in: keys}, value, + }, { + projection: {_id: 0, _key: 1, value: 1}, + }).toArray(); + + const isMember = {}; + for (const item of results) { + if (item) { + isMember[item._key] = true; + } + } + + return keys.map(key => Boolean(isMember[key])); + }; + + module.getSortedSetMembers = async function (key) { + const data = await module.getSortedSetsMembers([key]); + return data && data[0]; + }; + + module.getSortedSetsMembers = async function (keys) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } + + const arrayOfKeys = keys.length > 1; + const projection = {_id: 0, value: 1}; + if (arrayOfKeys) { + projection._key = 1; + } + + const data = await module.client.collection('objects').find({ + _key: arrayOfKeys ? {$in: keys} : keys[0], + }, {projection}).toArray(); + + if (!arrayOfKeys) { + return [data.map(item => item.value)]; + } + + const sets = {}; + for (const item of data) { + sets[item._key] = sets[item._key] || []; + sets[item._key].push(item.value); + } + + return keys.map(k => sets[k] || []); + }; + + module.sortedSetIncrBy = async function (key, increment, value) { + if (!key) { + return; + } + + const data = {}; + value = helpers.valueToString(value); + data.score = Number.parseFloat(increment); + + try { + const result = await module.client.collection('objects').findOneAndUpdate({ + _key: key, + value, + }, { + $inc: data, + }, { + returnDocument: 'after', + upsert: true, + }); + return result && result.value ? result.value.score : null; + } catch (error) { + // If there is duplicate key error retry the upsert + // https://github.com/NodeBB/NodeBB/issues/4467 + // https://jira.mongodb.org/browse/SERVER-14322 + // https://docs.mongodb.org/manual/reference/command/findAndModify/#upsert-and-unique-index + if (error && error.message.startsWith('E11000 duplicate key error')) { + return await module.sortedSetIncrBy(key, increment, value); + } + + throw error; + } + }; + + module.sortedSetIncrByBulk = async function (data) { + const bulk = module.client.collection('objects').initializeUnorderedBulkOp(); + for (const item of data) { + bulk.find({_key: item[0], value: helpers.valueToString(item[2])}) + .upsert() + .update({$inc: {score: Number.parseFloat(item[1])}}); + } + + await bulk.execute(); + const result = await module.client.collection('objects').find({ + _key: {$in: _.uniq(data.map(i => i[0]))}, + value: {$in: _.uniq(data.map(i => i[2]))}, + }, { + projection: { + _id: 0, _key: 1, value: 1, score: 1, + }, + }).toArray(); + + const map = {}; + for (const item of result) { + map[`${item._key}:${item.value}`] = item.score; + } + + return data.map(item => map[`${item[0]}:${item[2]}`]); + }; + + module.getSortedSetRangeByLex = async function (key, min, max, start, count) { + return await sortedSetLex(key, min, max, 1, start, count); + }; + + module.getSortedSetRevRangeByLex = async function (key, max, min, start, count) { + return await sortedSetLex(key, min, max, -1, start, count); + }; + + module.sortedSetLexCount = async function (key, min, max) { + const data = await sortedSetLex(key, min, max, 1, 0, 0); + return data ? data.length : null; + }; + + async function sortedSetLex(key, min, max, sort, start, count) { + const query = {_key: key}; + start = start === undefined ? 0 : start; + count = count === undefined ? 0 : count; + buildLexQuery(query, min, max); + + const data = await module.client.collection('objects').find(query, {projection: {_id: 0, value: 1}}) + .sort({value: sort}) + .skip(start) + .limit(count === -1 ? 0 : count) + .toArray(); + + return data.map(item => item && item.value); + } + + module.sortedSetRemoveRangeByLex = async function (key, min, max) { + const query = {_key: key}; + buildLexQuery(query, min, max); + + await module.client.collection('objects').deleteMany(query); + }; + + function buildLexQuery(query, min, max) { + if (min !== '-') { + if (/^\(/.test(min)) { + query.value = {$gt: min.slice(1)}; + } else if (/^\[/.test(min)) { + query.value = {$gte: min.slice(1)}; + } else { + query.value = {$gte: min}; + } + } + + if (max !== '+') { + query.value = query.value || {}; + if (/^\(/.test(max)) { + query.value.$lt = max.slice(1); + } else if (/^\[/.test(max)) { + query.value.$lte = max.slice(1); + } else { + query.value.$lte = max; + } + } + } + + module.getSortedSetScan = async function (parameters) { + const project = {_id: 0, value: 1}; + if (parameters.withScores) { + project.score = 1; + } + + const match = helpers.buildMatchQuery(parameters.match); + let regex; + try { + regex = new RegExp(match); + } catch { + return []; + } + + const cursor = module.client.collection('objects').find({ + _key: parameters.key, value: {$regex: regex}, + }, {projection: project}); + + if (parameters.limit) { + cursor.limit(parameters.limit); + } + + const data = await cursor.toArray(); + if (!parameters.withScores) { + return data.map(d => d.value); + } + + return data; + }; + + module.processSortedSet = async function (setKey, processFunction, options) { + let done = false; + const ids = []; + const project = {_id: 0, _key: 0}; + + if (!options.withScores) { + project.score = 0; + } + + const cursor = await module.client.collection('objects').find({_key: setKey}, {projection: project}) + .sort({score: 1}) + .batchSize(options.batch); + + if (processFunction && processFunction.constructor && processFunction.constructor.name !== 'AsyncFunction') { + processFunction = util.promisify(processFunction); + } + + while (!done) { + /* eslint-disable no-await-in-loop */ + const item = await cursor.next(); + if (item === null) { + done = true; + } else { + ids.push(options.withScores ? item : item.value); + } + + if (ids.length >= options.batch || (done && ids.length > 0)) { + await processFunction(ids); + + ids.length = 0; + if (options.interval) { + await sleep(options.interval); + } + } + } + }; }; diff --git a/src/database/mongo/sorted/add.js b/src/database/mongo/sorted/add.js index 8e9bd36..dd65d77 100644 --- a/src/database/mongo/sorted/add.js +++ b/src/database/mongo/sorted/add.js @@ -1,91 +1,104 @@ 'use strict'; module.exports = function (module) { - const helpers = require('../helpers'); - const utils = require('../../../utils'); - - module.sortedSetAdd = async function (key, score, value) { - if (!key) { - return; - } - if (Array.isArray(score) && Array.isArray(value)) { - return await sortedSetAddBulk(key, score, value); - } - if (!utils.isNumber(score)) { - throw new Error(`[[error:invalid-score, ${score}]]`); - } - value = helpers.valueToString(value); - - try { - await module.client.collection('objects').updateOne({ _key: key, value: value }, { $set: { score: parseFloat(score) } }, { upsert: true }); - } catch (err) { - if (err && err.message.startsWith('E11000 duplicate key error')) { - return await module.sortedSetAdd(key, score, value); - } - throw err; - } - }; - - async function sortedSetAddBulk(key, scores, values) { - if (!scores.length || !values.length) { - return; - } - if (scores.length !== values.length) { - throw new Error('[[error:invalid-data]]'); - } - for (let i = 0; i < scores.length; i += 1) { - if (!utils.isNumber(scores[i])) { - throw new Error(`[[error:invalid-score, ${scores[i]}]]`); - } - } - values = values.map(helpers.valueToString); - - const bulk = module.client.collection('objects').initializeUnorderedBulkOp(); - for (let i = 0; i < scores.length; i += 1) { - bulk.find({ _key: key, value: values[i] }).upsert().updateOne({ $set: { score: parseFloat(scores[i]) } }); - } - await bulk.execute(); - } - - module.sortedSetsAdd = async function (keys, scores, value) { - if (!Array.isArray(keys) || !keys.length) { - return; - } - const isArrayOfScores = Array.isArray(scores); - if ((!isArrayOfScores && !utils.isNumber(scores)) || - (isArrayOfScores && scores.map(s => utils.isNumber(s)).includes(false))) { - throw new Error(`[[error:invalid-score, ${scores}]]`); - } - - if (isArrayOfScores && scores.length !== keys.length) { - throw new Error('[[error:invalid-data]]'); - } - - value = helpers.valueToString(value); - - const bulk = module.client.collection('objects').initializeUnorderedBulkOp(); - for (let i = 0; i < keys.length; i += 1) { - bulk - .find({ _key: keys[i], value: value }) - .upsert() - .updateOne({ $set: { score: parseFloat(isArrayOfScores ? scores[i] : scores) } }); - } - await bulk.execute(); - }; - - module.sortedSetAddBulk = async function (data) { - if (!Array.isArray(data) || !data.length) { - return; - } - const bulk = module.client.collection('objects').initializeUnorderedBulkOp(); - data.forEach((item) => { - if (!utils.isNumber(item[1])) { - throw new Error(`[[error:invalid-score, ${item[1]}]]`); - } - bulk.find({ _key: item[0], value: String(item[2]) }) - .upsert() - .updateOne({ $set: { score: parseFloat(item[1]) } }); - }); - await bulk.execute(); - }; + const helpers = require('../helpers'); + const utils = require('../../../utils'); + + module.sortedSetAdd = async function (key, score, value) { + if (!key) { + return; + } + + if (Array.isArray(score) && Array.isArray(value)) { + return await sortedSetAddBulk(key, score, value); + } + + if (!utils.isNumber(score)) { + throw new TypeError(`[[error:invalid-score, ${score}]]`); + } + + value = helpers.valueToString(value); + + try { + await module.client.collection('objects').updateOne({_key: key, value}, {$set: {score: Number.parseFloat(score)}}, {upsert: true}); + } catch (error) { + if (error && error.message.startsWith('E11000 duplicate key error')) { + return await module.sortedSetAdd(key, score, value); + } + + throw error; + } + }; + + async function sortedSetAddBulk(key, scores, values) { + if (scores.length === 0 || values.length === 0) { + return; + } + + if (scores.length !== values.length) { + throw new Error('[[error:invalid-data]]'); + } + + for (const score of scores) { + if (!utils.isNumber(score)) { + throw new TypeError(`[[error:invalid-score, ${score}]]`); + } + } + + values = values.map(helpers.valueToString); + + const bulk = module.client.collection('objects').initializeUnorderedBulkOp(); + for (const [i, score] of scores.entries()) { + bulk.find({_key: key, value: values[i]}).upsert().updateOne({$set: {score: Number.parseFloat(score)}}); + } + + await bulk.execute(); + } + + module.sortedSetsAdd = async function (keys, scores, value) { + if (!Array.isArray(keys) || keys.length === 0) { + return; + } + + const isArrayOfScores = Array.isArray(scores); + if ((!isArrayOfScores && !utils.isNumber(scores)) + || (isArrayOfScores && scores.map(s => utils.isNumber(s)).includes(false))) { + throw new Error(`[[error:invalid-score, ${scores}]]`); + } + + if (isArrayOfScores && scores.length !== keys.length) { + throw new Error('[[error:invalid-data]]'); + } + + value = helpers.valueToString(value); + + const bulk = module.client.collection('objects').initializeUnorderedBulkOp(); + for (const [i, key] of keys.entries()) { + bulk + .find({_key: key, value}) + .upsert() + .updateOne({$set: {score: Number.parseFloat(isArrayOfScores ? scores[i] : scores)}}); + } + + await bulk.execute(); + }; + + module.sortedSetAddBulk = async function (data) { + if (!Array.isArray(data) || data.length === 0) { + return; + } + + const bulk = module.client.collection('objects').initializeUnorderedBulkOp(); + for (const item of data) { + if (!utils.isNumber(item[1])) { + throw new TypeError(`[[error:invalid-score, ${item[1]}]]`); + } + + bulk.find({_key: item[0], value: String(item[2])}) + .upsert() + .updateOne({$set: {score: Number.parseFloat(item[1])}}); + } + + await bulk.execute(); + }; }; diff --git a/src/database/mongo/sorted/intersect.js b/src/database/mongo/sorted/intersect.js index b4d9e19..ec4861b 100644 --- a/src/database/mongo/sorted/intersect.js +++ b/src/database/mongo/sorted/intersect.js @@ -1,219 +1,236 @@ 'use strict'; module.exports = function (module) { - module.sortedSetIntersectCard = async function (keys) { - if (!Array.isArray(keys) || !keys.length) { - return 0; - } - const objects = module.client.collection('objects'); - const counts = await countSets(keys, 50000); - if (counts.minCount === 0) { - return 0; - } - let items = await objects.find({ _key: counts.smallestSet }, { - projection: { _id: 0, value: 1 }, - }).batchSize(counts.minCount + 1).toArray(); - - const otherSets = keys.filter(s => s !== counts.smallestSet); - for (let i = 0; i < otherSets.length; i++) { - /* eslint-disable no-await-in-loop */ - const query = { _key: otherSets[i], value: { $in: items.map(i => i.value) } }; - if (i === otherSets.length - 1) { - return await objects.countDocuments(query); - } - items = await objects.find(query, { projection: { _id: 0, value: 1 } }) - .batchSize(items.length + 1).toArray(); - } - }; - - async function countSets(sets, limit) { - const objects = module.client.collection('objects'); - const counts = await Promise.all( - sets.map(s => objects.countDocuments({ _key: s }, { - limit: limit || 25000, - })) - ); - const minCount = Math.min(...counts); - const index = counts.indexOf(minCount); - const smallestSet = sets[index]; - return { - minCount: minCount, - smallestSet: smallestSet, - }; - } - - module.getSortedSetIntersect = async function (params) { - params.sort = 1; - return await getSortedSetRevIntersect(params); - }; - - module.getSortedSetRevIntersect = async function (params) { - params.sort = -1; - return await getSortedSetRevIntersect(params); - }; - - async function getSortedSetRevIntersect(params) { - params.start = params.hasOwnProperty('start') ? params.start : 0; - params.stop = params.hasOwnProperty('stop') ? params.stop : -1; - params.weights = params.weights || []; - - params.limit = params.stop - params.start + 1; - if (params.limit <= 0) { - params.limit = 0; - } - params.counts = await countSets(params.sets); - if (params.counts.minCount === 0) { - return []; - } - - const simple = params.weights.filter(w => w === 1).length === 1 && params.limit !== 0; - if (params.counts.minCount < 25000 && simple) { - return await intersectSingle(params); - } else if (simple) { - return await intersectBatch(params); - } - return await intersectAggregate(params); - } - - async function intersectSingle(params) { - const objects = module.client.collection('objects'); - const sortSet = params.sets[params.weights.indexOf(1)]; - if (sortSet === params.counts.smallestSet) { - return await intersectBatch(params); - } - - const cursorSmall = objects.find({ _key: params.counts.smallestSet }, { - projection: { _id: 0, value: 1 }, - }); - if (params.counts.minCount > 1) { - cursorSmall.batchSize(params.counts.minCount + 1); - } - let items = await cursorSmall.toArray(); - const project = { _id: 0, value: 1 }; - if (params.withScores) { - project.score = 1; - } - const otherSets = params.sets.filter(s => s !== params.counts.smallestSet); - // move sortSet to the end of array - otherSets.push(otherSets.splice(otherSets.indexOf(sortSet), 1)[0]); - for (let i = 0; i < otherSets.length; i++) { - /* eslint-disable no-await-in-loop */ - const cursor = objects.find({ _key: otherSets[i], value: { $in: items.map(i => i.value) } }); - cursor.batchSize(items.length + 1); - // at the last step sort by sortSet - if (i === otherSets.length - 1) { - cursor.project(project).sort({ score: params.sort }).skip(params.start).limit(params.limit); - } else { - cursor.project({ _id: 0, value: 1 }); - } - items = await cursor.toArray(); - } - if (!params.withScores) { - items = items.map(i => i.value); - } - return items; - } - - async function intersectBatch(params) { - const project = { _id: 0, value: 1 }; - if (params.withScores) { - project.score = 1; - } - const sortSet = params.sets[params.weights.indexOf(1)]; - const batchSize = 10000; - const cursor = await module.client.collection('objects') - .find({ _key: sortSet }, { projection: project }) - .sort({ score: params.sort }) - .batchSize(batchSize); - - const otherSets = params.sets.filter(s => s !== sortSet); - let inters = []; - let done = false; - while (!done) { - /* eslint-disable no-await-in-loop */ - const items = []; - while (items.length < batchSize) { - const nextItem = await cursor.next(); - if (!nextItem) { - done = true; - break; - } - items.push(nextItem); - } - - const members = await Promise.all(otherSets.map(async (s) => { - const data = await module.client.collection('objects').find({ - _key: s, value: { $in: items.map(i => i.value) }, - }, { - projection: { _id: 0, value: 1 }, - }).batchSize(items.length + 1).toArray(); - return new Set(data.map(i => i.value)); - })); - inters = inters.concat(items.filter(item => members.every(arr => arr.has(item.value)))); - if (inters.length >= params.stop) { - done = true; - inters = inters.slice(params.start, params.stop + 1); - } - } - if (!params.withScores) { - inters = inters.map(item => item.value); - } - return inters; - } - - async function intersectAggregate(params) { - const aggregate = {}; - - if (params.aggregate) { - aggregate[`$${params.aggregate.toLowerCase()}`] = '$score'; - } else { - aggregate.$sum = '$score'; - } - const pipeline = [{ $match: { _key: { $in: params.sets } } }]; - - params.weights.forEach((weight, index) => { - if (weight !== 1) { - pipeline.push({ - $project: { - value: 1, - score: { - $cond: { - if: { - $eq: ['$_key', params.sets[index]], - }, - then: { - $multiply: ['$score', weight], - }, - else: '$score', - }, - }, - }, - }); - } - }); - - pipeline.push({ $group: { _id: { value: '$value' }, totalScore: aggregate, count: { $sum: 1 } } }); - pipeline.push({ $match: { count: params.sets.length } }); - pipeline.push({ $sort: { totalScore: params.sort } }); - - if (params.start) { - pipeline.push({ $skip: params.start }); - } - - if (params.limit > 0) { - pipeline.push({ $limit: params.limit }); - } - - const project = { _id: 0, value: '$_id.value' }; - if (params.withScores) { - project.score = '$totalScore'; - } - pipeline.push({ $project: project }); - - let data = await module.client.collection('objects').aggregate(pipeline).toArray(); - if (!params.withScores) { - data = data.map(item => item.value); - } - return data; - } + module.sortedSetIntersectCard = async function (keys) { + if (!Array.isArray(keys) || keys.length === 0) { + return 0; + } + + const objects = module.client.collection('objects'); + const counts = await countSets(keys, 50_000); + if (counts.minCount === 0) { + return 0; + } + + let items = await objects.find({_key: counts.smallestSet}, { + projection: {_id: 0, value: 1}, + }).batchSize(counts.minCount + 1).toArray(); + + const otherSets = keys.filter(s => s !== counts.smallestSet); + for (let i = 0; i < otherSets.length; i++) { + /* eslint-disable no-await-in-loop */ + const query = {_key: otherSets[i], value: {$in: items.map(i => i.value)}}; + if (i === otherSets.length - 1) { + return await objects.countDocuments(query); + } + + items = await objects.find(query, {projection: {_id: 0, value: 1}}) + .batchSize(items.length + 1).toArray(); + } + }; + + async function countSets(sets, limit) { + const objects = module.client.collection('objects'); + const counts = await Promise.all( + sets.map(s => objects.countDocuments({_key: s}, { + limit: limit || 25_000, + })), + ); + const minCount = Math.min(...counts); + const index = counts.indexOf(minCount); + const smallestSet = sets[index]; + return { + minCount, + smallestSet, + }; + } + + module.getSortedSetIntersect = async function (parameters) { + parameters.sort = 1; + return await getSortedSetRevIntersect(parameters); + }; + + module.getSortedSetRevIntersect = async function (parameters) { + parameters.sort = -1; + return await getSortedSetRevIntersect(parameters); + }; + + async function getSortedSetRevIntersect(parameters) { + parameters.start = parameters.hasOwnProperty('start') ? parameters.start : 0; + parameters.stop = parameters.hasOwnProperty('stop') ? parameters.stop : -1; + parameters.weights = parameters.weights || []; + + parameters.limit = parameters.stop - parameters.start + 1; + if (parameters.limit <= 0) { + parameters.limit = 0; + } + + parameters.counts = await countSets(parameters.sets); + if (parameters.counts.minCount === 0) { + return []; + } + + const simple = parameters.weights.filter(w => w === 1).length === 1 && parameters.limit !== 0; + if (parameters.counts.minCount < 25_000 && simple) { + return await intersectSingle(parameters); + } + + if (simple) { + return await intersectBatch(parameters); + } + + return await intersectAggregate(parameters); + } + + async function intersectSingle(parameters) { + const objects = module.client.collection('objects'); + const sortSet = parameters.sets[parameters.weights.indexOf(1)]; + if (sortSet === parameters.counts.smallestSet) { + return await intersectBatch(parameters); + } + + const cursorSmall = objects.find({_key: parameters.counts.smallestSet}, { + projection: {_id: 0, value: 1}, + }); + if (parameters.counts.minCount > 1) { + cursorSmall.batchSize(parameters.counts.minCount + 1); + } + + let items = await cursorSmall.toArray(); + const project = {_id: 0, value: 1}; + if (parameters.withScores) { + project.score = 1; + } + + const otherSets = parameters.sets.filter(s => s !== parameters.counts.smallestSet); + // Move sortSet to the end of array + otherSets.push(otherSets.splice(otherSets.indexOf(sortSet), 1)[0]); + for (let i = 0; i < otherSets.length; i++) { + /* eslint-disable no-await-in-loop */ + const cursor = objects.find({_key: otherSets[i], value: {$in: items.map(i => i.value)}}); + cursor.batchSize(items.length + 1); + // At the last step sort by sortSet + if (i === otherSets.length - 1) { + cursor.project(project).sort({score: parameters.sort}).skip(parameters.start).limit(parameters.limit); + } else { + cursor.project({_id: 0, value: 1}); + } + + items = await cursor.toArray(); + } + + if (!parameters.withScores) { + items = items.map(i => i.value); + } + + return items; + } + + async function intersectBatch(parameters) { + const project = {_id: 0, value: 1}; + if (parameters.withScores) { + project.score = 1; + } + + const sortSet = parameters.sets[parameters.weights.indexOf(1)]; + const batchSize = 10_000; + const cursor = await module.client.collection('objects') + .find({_key: sortSet}, {projection: project}) + .sort({score: parameters.sort}) + .batchSize(batchSize); + + const otherSets = parameters.sets.filter(s => s !== sortSet); + let inters = []; + let done = false; + while (!done) { + /* eslint-disable no-await-in-loop */ + const items = []; + while (items.length < batchSize) { + const nextItem = await cursor.next(); + if (!nextItem) { + done = true; + break; + } + + items.push(nextItem); + } + + const members = await Promise.all(otherSets.map(async s => { + const data = await module.client.collection('objects').find({ + _key: s, value: {$in: items.map(i => i.value)}, + }, { + projection: {_id: 0, value: 1}, + }).batchSize(items.length + 1).toArray(); + return new Set(data.map(i => i.value)); + })); + inters = inters.concat(items.filter(item => members.every(array => array.has(item.value)))); + if (inters.length >= parameters.stop) { + done = true; + inters = inters.slice(parameters.start, parameters.stop + 1); + } + } + + if (!parameters.withScores) { + inters = inters.map(item => item.value); + } + + return inters; + } + + async function intersectAggregate(parameters) { + const aggregate = {}; + + if (parameters.aggregate) { + aggregate[`$${parameters.aggregate.toLowerCase()}`] = '$score'; + } else { + aggregate.$sum = '$score'; + } + + const pipeline = [{$match: {_key: {$in: parameters.sets}}}]; + + for (const [index, weight] of parameters.weights.entries()) { + if (weight !== 1) { + pipeline.push({ + $project: { + value: 1, + score: { + $cond: { + if: { + $eq: ['$_key', parameters.sets[index]], + }, + then: { + $multiply: ['$score', weight], + }, + else: '$score', + }, + }, + }, + }); + } + } + + pipeline.push({$group: {_id: {value: '$value'}, totalScore: aggregate, count: {$sum: 1}}}, {$match: {count: parameters.sets.length}}, {$sort: {totalScore: parameters.sort}}); + + if (parameters.start) { + pipeline.push({$skip: parameters.start}); + } + + if (parameters.limit > 0) { + pipeline.push({$limit: parameters.limit}); + } + + const project = {_id: 0, value: '$_id.value'}; + if (parameters.withScores) { + project.score = '$totalScore'; + } + + pipeline.push({$project: project}); + + let data = await module.client.collection('objects').aggregate(pipeline).toArray(); + if (!parameters.withScores) { + data = data.map(item => item.value); + } + + return data; + } }; diff --git a/src/database/mongo/sorted/remove.js b/src/database/mongo/sorted/remove.js index 891b743..c9b763a 100644 --- a/src/database/mongo/sorted/remove.js +++ b/src/database/mongo/sorted/remove.js @@ -1,63 +1,68 @@ 'use strict'; module.exports = function (module) { - const helpers = require('../helpers'); - - module.sortedSetRemove = async function (key, value) { - if (!key) { - return; - } - const isValueArray = Array.isArray(value); - if (!value || (isValueArray && !value.length)) { - return; - } - - if (isValueArray) { - value = value.map(helpers.valueToString); - } else { - value = helpers.valueToString(value); - } - - await module.client.collection('objects').deleteMany({ - _key: Array.isArray(key) ? { $in: key } : key, - value: isValueArray ? { $in: value } : value, - }); - }; - - module.sortedSetsRemove = async function (keys, value) { - if (!Array.isArray(keys) || !keys.length) { - return; - } - value = helpers.valueToString(value); - - await module.client.collection('objects').deleteMany({ _key: { $in: keys }, value: value }); - }; - - module.sortedSetsRemoveRangeByScore = async function (keys, min, max) { - if (!Array.isArray(keys) || !keys.length) { - return; - } - const query = { _key: { $in: keys } }; - if (keys.length === 1) { - query._key = keys[0]; - } - if (min !== '-inf') { - query.score = { $gte: parseFloat(min) }; - } - if (max !== '+inf') { - query.score = query.score || {}; - query.score.$lte = parseFloat(max); - } - - await module.client.collection('objects').deleteMany(query); - }; - - module.sortedSetRemoveBulk = async function (data) { - if (!Array.isArray(data) || !data.length) { - return; - } - const bulk = module.client.collection('objects').initializeUnorderedBulkOp(); - data.forEach(item => bulk.find({ _key: item[0], value: String(item[1]) }).delete()); - await bulk.execute(); - }; + const helpers = require('../helpers'); + + module.sortedSetRemove = async function (key, value) { + if (!key) { + return; + } + + const isValueArray = Array.isArray(value); + if (!value || (isValueArray && value.length === 0)) { + return; + } + + value = isValueArray ? value.map(helpers.valueToString) : helpers.valueToString(value); + + await module.client.collection('objects').deleteMany({ + _key: Array.isArray(key) ? {$in: key} : key, + value: isValueArray ? {$in: value} : value, + }); + }; + + module.sortedSetsRemove = async function (keys, value) { + if (!Array.isArray(keys) || keys.length === 0) { + return; + } + + value = helpers.valueToString(value); + + await module.client.collection('objects').deleteMany({_key: {$in: keys}, value}); + }; + + module.sortedSetsRemoveRangeByScore = async function (keys, min, max) { + if (!Array.isArray(keys) || keys.length === 0) { + return; + } + + const query = {_key: {$in: keys}}; + if (keys.length === 1) { + query._key = keys[0]; + } + + if (min !== '-inf') { + query.score = {$gte: Number.parseFloat(min)}; + } + + if (max !== '+inf') { + query.score = query.score || {}; + query.score.$lte = Number.parseFloat(max); + } + + await module.client.collection('objects').deleteMany(query); + }; + + module.sortedSetRemoveBulk = async function (data) { + if (!Array.isArray(data) || data.length === 0) { + return; + } + + const bulk = module.client.collection('objects').initializeUnorderedBulkOp(); + for (const item of data) { + bulk.find({_key: item[0], value: String(item[1])}).delete(); + } + + await bulk.execute(); + }; }; diff --git a/src/database/mongo/sorted/union.js b/src/database/mongo/sorted/union.js index 30cd38d..7440666 100644 --- a/src/database/mongo/sorted/union.js +++ b/src/database/mongo/sorted/union.js @@ -1,69 +1,72 @@ 'use strict'; module.exports = function (module) { - module.sortedSetUnionCard = async function (keys) { - if (!Array.isArray(keys) || !keys.length) { - return 0; - } + module.sortedSetUnionCard = async function (keys) { + if (!Array.isArray(keys) || keys.length === 0) { + return 0; + } - const data = await module.client.collection('objects').aggregate([ - { $match: { _key: { $in: keys } } }, - { $group: { _id: { value: '$value' } } }, - { $group: { _id: null, count: { $sum: 1 } } }, - ]).toArray(); - return Array.isArray(data) && data.length ? data[0].count : 0; - }; + const data = await module.client.collection('objects').aggregate([ + {$match: {_key: {$in: keys}}}, + {$group: {_id: {value: '$value'}}}, + {$group: {_id: null, count: {$sum: 1}}}, + ]).toArray(); + return Array.isArray(data) && data.length > 0 ? data[0].count : 0; + }; - module.getSortedSetUnion = async function (params) { - params.sort = 1; - return await getSortedSetUnion(params); - }; + module.getSortedSetUnion = async function (parameters) { + parameters.sort = 1; + return await getSortedSetUnion(parameters); + }; - module.getSortedSetRevUnion = async function (params) { - params.sort = -1; - return await getSortedSetUnion(params); - }; + module.getSortedSetRevUnion = async function (parameters) { + parameters.sort = -1; + return await getSortedSetUnion(parameters); + }; - async function getSortedSetUnion(params) { - if (!Array.isArray(params.sets) || !params.sets.length) { - return; - } - let limit = params.stop - params.start + 1; - if (limit <= 0) { - limit = 0; - } + async function getSortedSetUnion(parameters) { + if (!Array.isArray(parameters.sets) || parameters.sets.length === 0) { + return; + } - const aggregate = {}; - if (params.aggregate) { - aggregate[`$${params.aggregate.toLowerCase()}`] = '$score'; - } else { - aggregate.$sum = '$score'; - } + let limit = parameters.stop - parameters.start + 1; + if (limit <= 0) { + limit = 0; + } - const pipeline = [ - { $match: { _key: { $in: params.sets } } }, - { $group: { _id: { value: '$value' }, totalScore: aggregate } }, - { $sort: { totalScore: params.sort } }, - ]; + const aggregate = {}; + if (parameters.aggregate) { + aggregate[`$${parameters.aggregate.toLowerCase()}`] = '$score'; + } else { + aggregate.$sum = '$score'; + } - if (params.start) { - pipeline.push({ $skip: params.start }); - } + const pipeline = [ + {$match: {_key: {$in: parameters.sets}}}, + {$group: {_id: {value: '$value'}, totalScore: aggregate}}, + {$sort: {totalScore: parameters.sort}}, + ]; - if (limit > 0) { - pipeline.push({ $limit: limit }); - } + if (parameters.start) { + pipeline.push({$skip: parameters.start}); + } - const project = { _id: 0, value: '$_id.value' }; - if (params.withScores) { - project.score = '$totalScore'; - } - pipeline.push({ $project: project }); + if (limit > 0) { + pipeline.push({$limit: limit}); + } - let data = await module.client.collection('objects').aggregate(pipeline).toArray(); - if (!params.withScores) { - data = data.map(item => item.value); - } - return data; - } + const project = {_id: 0, value: '$_id.value'}; + if (parameters.withScores) { + project.score = '$totalScore'; + } + + pipeline.push({$project: project}); + + let data = await module.client.collection('objects').aggregate(pipeline).toArray(); + if (!parameters.withScores) { + data = data.map(item => item.value); + } + + return data; + } }; diff --git a/src/database/mongo/transaction.js b/src/database/mongo/transaction.js index 1e98aac..f914a2d 100644 --- a/src/database/mongo/transaction.js +++ b/src/database/mongo/transaction.js @@ -1,8 +1,8 @@ 'use strict'; module.exports = function (module) { - // TODO - module.transaction = function (perform, callback) { - perform(module.client, callback); - }; + // TODO + module.transaction = function (perform, callback) { + perform(module.client, callback); + }; }; diff --git a/src/database/postgres.js b/src/database/postgres.js index 69f5ab5..bddbc6d 100644 --- a/src/database/postgres.js +++ b/src/database/postgres.js @@ -5,66 +5,66 @@ const async = require('async'); const nconf = require('nconf'); const session = require('express-session'); const semver = require('semver'); - const connection = require('./postgres/connection'); const postgresModule = module.exports; postgresModule.questions = [ - { - name: 'postgres:host', - description: 'Host IP or address of your PostgreSQL instance', - default: nconf.get('postgres:host') || '127.0.0.1', - }, - { - name: 'postgres:port', - description: 'Host port of your PostgreSQL instance', - default: nconf.get('postgres:port') || 5432, - }, - { - name: 'postgres:username', - description: 'PostgreSQL username', - default: nconf.get('postgres:username') || '', - }, - { - name: 'postgres:password', - description: 'Password of your PostgreSQL database', - hidden: true, - default: nconf.get('postgres:password') || '', - before: function (value) { value = value || nconf.get('postgres:password') || ''; return value; }, - }, - { - name: 'postgres:database', - description: 'PostgreSQL database name', - default: nconf.get('postgres:database') || 'nodebb', - }, - { - name: 'postgres:ssl', - description: 'Enable SSL for PostgreSQL database access', - default: nconf.get('postgres:ssl') || false, - }, + { + name: 'postgres:host', + description: 'Host IP or address of your PostgreSQL instance', + default: nconf.get('postgres:host') || '127.0.0.1', + }, + { + name: 'postgres:port', + description: 'Host port of your PostgreSQL instance', + default: nconf.get('postgres:port') || 5432, + }, + { + name: 'postgres:username', + description: 'PostgreSQL username', + default: nconf.get('postgres:username') || '', + }, + { + name: 'postgres:password', + description: 'Password of your PostgreSQL database', + hidden: true, + default: nconf.get('postgres:password') || '', + before(value) { + value ||= nconf.get('postgres:password') || ''; return value; + }, + }, + { + name: 'postgres:database', + description: 'PostgreSQL database name', + default: nconf.get('postgres:database') || 'nodebb', + }, + { + name: 'postgres:ssl', + description: 'Enable SSL for PostgreSQL database access', + default: nconf.get('postgres:ssl') || false, + }, ]; postgresModule.init = async function () { - const { Pool } = require('pg'); - const connOptions = connection.getConnectionOptions(); - const pool = new Pool(connOptions); - postgresModule.pool = pool; - postgresModule.client = pool; - const client = await pool.connect(); - try { - await checkUpgrade(client); - } catch (err) { - winston.error(`NodeBB could not connect to your PostgreSQL database. PostgreSQL returned the following error: ${err.message}`); - throw err; - } finally { - client.release(); - } + const {Pool} = require('pg'); + const connOptions = connection.getConnectionOptions(); + const pool = new Pool(connOptions); + postgresModule.pool = pool; + postgresModule.client = pool; + const client = await pool.connect(); + try { + await checkUpgrade(client); + } catch (error) { + winston.error(`NodeBB could not connect to your PostgreSQL database. PostgreSQL returned the following error: ${error.message}`); + throw error; + } finally { + client.release(); + } }; - async function checkUpgrade(client) { - const res = await client.query(` + const res = await client.query(` SELECT EXISTS(SELECT * FROM "information_schema"."columns" WHERE "table_schema" = 'public' @@ -80,18 +80,18 @@ SELECT EXISTS(SELECT * WHERE "routine_schema" = 'public' AND "routine_name" = 'nodebb_get_sorted_set_members') c`); - if (res.rows[0].a && res.rows[0].b && res.rows[0].c) { - return; - } + if (res.rows[0].a && res.rows[0].b && res.rows[0].c) { + return; + } - await client.query(`BEGIN`); - try { - if (!res.rows[0].b) { - await client.query(` + await client.query('BEGIN'); + try { + if (!res.rows[0].b) { + await client.query(` CREATE TYPE LEGACY_OBJECT_TYPE AS ENUM ( 'hash', 'zset', 'set', 'list', 'string' )`); - await client.query(` + await client.query(` CREATE TABLE "legacy_object" ( "_key" TEXT NOT NULL PRIMARY KEY, @@ -99,7 +99,7 @@ CREATE TABLE "legacy_object" ( "expireAt" TIMESTAMPTZ DEFAULT NULL, UNIQUE ( "_key", "type" ) )`); - await client.query(` + await client.query(` CREATE TABLE "legacy_hash" ( "_key" TEXT NOT NULL PRIMARY KEY, @@ -113,7 +113,7 @@ CREATE TABLE "legacy_hash" ( ON UPDATE CASCADE ON DELETE CASCADE )`); - await client.query(` + await client.query(` CREATE TABLE "legacy_zset" ( "_key" TEXT NOT NULL, "value" TEXT NOT NULL, @@ -128,7 +128,7 @@ CREATE TABLE "legacy_zset" ( ON UPDATE CASCADE ON DELETE CASCADE )`); - await client.query(` + await client.query(` CREATE TABLE "legacy_set" ( "_key" TEXT NOT NULL, "member" TEXT NOT NULL, @@ -142,7 +142,7 @@ CREATE TABLE "legacy_set" ( ON UPDATE CASCADE ON DELETE CASCADE )`); - await client.query(` + await client.query(` CREATE TABLE "legacy_list" ( "_key" TEXT NOT NULL PRIMARY KEY, @@ -156,7 +156,7 @@ CREATE TABLE "legacy_list" ( ON UPDATE CASCADE ON DELETE CASCADE )`); - await client.query(` + await client.query(` CREATE TABLE "legacy_string" ( "_key" TEXT NOT NULL PRIMARY KEY, @@ -171,8 +171,8 @@ CREATE TABLE "legacy_string" ( ON DELETE CASCADE )`); - if (res.rows[0].a) { - await client.query(` + if (res.rows[0].a) { + await client.query(` INSERT INTO "legacy_object" ("_key", "type", "expireAt") SELECT DISTINCT "data"->>'_key', CASE WHEN (SELECT COUNT(*) @@ -200,7 +200,7 @@ SELECT DISTINCT "data"->>'_key', ELSE NULL END FROM "objects"`); - await client.query(` + await client.query(` INSERT INTO "legacy_hash" ("_key", "data") SELECT "data"->>'_key', "data" - '_key' - 'expireAt' @@ -217,7 +217,7 @@ SELECT "data"->>'_key', AND ("data" ? 'score')) ELSE TRUE END`); - await client.query(` + await client.query(` INSERT INTO "legacy_zset" ("_key", "value", "score") SELECT "data"->>'_key', "data"->>'value', @@ -227,7 +227,7 @@ SELECT "data"->>'_key', FROM jsonb_object_keys("data" - 'expireAt')) = 3 AND ("data" ? 'value') AND ("data" ? 'score')`); - await client.query(` + await client.query(` INSERT INTO "legacy_set" ("_key", "member") SELECT "data"->>'_key', jsonb_array_elements_text("data"->'members') @@ -235,7 +235,7 @@ SELECT "data"->>'_key', WHERE (SELECT COUNT(*) FROM jsonb_object_keys("data" - 'expireAt')) = 2 AND ("data" ? 'members')`); - await client.query(` + await client.query(` INSERT INTO "legacy_list" ("_key", "array") SELECT "data"->>'_key', ARRAY(SELECT t @@ -245,7 +245,7 @@ SELECT "data"->>'_key', WHERE (SELECT COUNT(*) FROM jsonb_object_keys("data" - 'expireAt')) = 2 AND ("data" ? 'array')`); - await client.query(` + await client.query(` INSERT INTO "legacy_string" ("_key", "data") SELECT "data"->>'_key', CASE WHEN "data" ? 'value' @@ -257,19 +257,20 @@ SELECT "data"->>'_key', FROM jsonb_object_keys("data" - 'expireAt')) = 2 AND (("data" ? 'value') OR ("data" ? 'data'))`); - await client.query(`DROP TABLE "objects" CASCADE`); - await client.query(`DROP FUNCTION "fun__objects__expireAt"() CASCADE`); - } - await client.query(` + await client.query('DROP TABLE "objects" CASCADE'); + await client.query('DROP FUNCTION "fun__objects__expireAt"() CASCADE'); + } + + await client.query(` CREATE VIEW "legacy_object_live" AS SELECT "_key", "type" FROM "legacy_object" WHERE "expireAt" IS NULL OR "expireAt" > CURRENT_TIMESTAMP`); - } + } - if (!res.rows[0].c) { - await client.query(` + if (!res.rows[0].c) { + await client.query(` CREATE FUNCTION "nodebb_get_sorted_set_members"(TEXT) RETURNS TEXT[] AS $$ SELECT array_agg(z."value" ORDER BY z."score" ASC) FROM "legacy_object_live" o @@ -281,33 +282,34 @@ $$ LANGUAGE sql STABLE STRICT PARALLEL SAFE`); - } - } catch (ex) { - await client.query(`ROLLBACK`); - throw ex; - } - await client.query(`COMMIT`); + } + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } + + await client.query('COMMIT'); } postgresModule.createSessionStore = async function (options) { - const meta = require('../meta'); + const meta = require('../meta'); - function done(db) { - const sessionStore = require('connect-pg-simple')(session); - return new sessionStore({ - pool: db, - ttl: meta.getSessionTTLSeconds(), - pruneSessionInterval: nconf.get('isPrimary') ? 60 : false, - }); - } + function done(database) { + const sessionStore = require('connect-pg-simple')(session); + return new sessionStore({ + pool: database, + ttl: meta.getSessionTTLSeconds(), + pruneSessionInterval: nconf.get('isPrimary') ? 60 : false, + }); + } - const db = await connection.connect(options); + const database = await connection.connect(options); - if (!nconf.get('isPrimary')) { - return done(db); - } + if (!nconf.get('isPrimary')) { + return done(database); + } - await db.query(` + await database.query(` CREATE TABLE IF NOT EXISTS "session" ( "sid" CHAR(32) NOT NULL COLLATE "C" @@ -322,62 +324,62 @@ ALTER TABLE "session" ALTER "sid" SET STORAGE MAIN, CLUSTER ON "session_expire_idx";`); - return done(db); + return done(database); }; postgresModule.createIndices = function (callback) { - if (!postgresModule.pool) { - winston.warn('[database/createIndices] database not initialized'); - return callback(); - } + if (!postgresModule.pool) { + winston.warn('[database/createIndices] database not initialized'); + return callback(); + } + + const query = postgresModule.pool.query.bind(postgresModule.pool); - const query = postgresModule.pool.query.bind(postgresModule.pool); + winston.info('[database] Checking database indices.'); + async.series([ + async.apply(query, 'CREATE INDEX IF NOT EXISTS "idx__legacy_zset__key__score" ON "legacy_zset"("_key" ASC, "score" DESC)'), + async.apply(query, 'CREATE INDEX IF NOT EXISTS "idx__legacy_object__expireAt" ON "legacy_object"("expireAt" ASC)'), + ], error => { + if (error) { + winston.error(`Error creating index ${error.message}`); + return callback(error); + } - winston.info('[database] Checking database indices.'); - async.series([ - async.apply(query, `CREATE INDEX IF NOT EXISTS "idx__legacy_zset__key__score" ON "legacy_zset"("_key" ASC, "score" DESC)`), - async.apply(query, `CREATE INDEX IF NOT EXISTS "idx__legacy_object__expireAt" ON "legacy_object"("expireAt" ASC)`), - ], (err) => { - if (err) { - winston.error(`Error creating index ${err.message}`); - return callback(err); - } - winston.info('[database] Checking database indices done!'); - callback(); - }); + winston.info('[database] Checking database indices done!'); + callback(); + }); }; postgresModule.checkCompatibility = function (callback) { - const postgresPkg = require('pg/package.json'); - postgresModule.checkCompatibilityVersion(postgresPkg.version, callback); + const postgresPkg = require('pg/package.json'); + postgresModule.checkCompatibilityVersion(postgresPkg.version, callback); }; postgresModule.checkCompatibilityVersion = function (version, callback) { - if (semver.lt(version, '7.0.0')) { - return callback(new Error('The `pg` package is out-of-date, please run `./nodebb setup` again.')); - } + if (semver.lt(version, '7.0.0')) { + return callback(new Error('The `pg` package is out-of-date, please run `./nodebb setup` again.')); + } - callback(); + callback(); }; -postgresModule.info = async function (db) { - if (!db) { - db = await connection.connect(nconf.get('postgres')); - } - postgresModule.pool = postgresModule.pool || db; - const res = await db.query(` +postgresModule.info = async function (database) { + database ||= await connection.connect(nconf.get('postgres')); + + postgresModule.pool = postgresModule.pool || database; + const res = await database.query(` SELECT true "postgres", current_setting('server_version') "version", EXTRACT(EPOCH FROM NOW() - pg_postmaster_start_time()) * 1000 "uptime" `); - return { - ...res.rows[0], - raw: JSON.stringify(res.rows[0], null, 4), - }; + return { + ...res.rows[0], + raw: JSON.stringify(res.rows[0], null, 4), + }; }; postgresModule.close = async function () { - await postgresModule.pool.end(); + await postgresModule.pool.end(); }; require('./postgres/main')(postgresModule); diff --git a/src/database/postgres/connection.js b/src/database/postgres/connection.js index d81b294..8a0dc74 100644 --- a/src/database/postgres/connection.js +++ b/src/database/postgres/connection.js @@ -7,38 +7,36 @@ const _ = require('lodash'); const connection = module.exports; connection.getConnectionOptions = function (postgres) { - postgres = postgres || nconf.get('postgres'); - // Sensible defaults for PostgreSQL, if not set - if (!postgres.host) { - postgres.host = '127.0.0.1'; - } - if (!postgres.port) { - postgres.port = 5432; - } - const dbName = postgres.database; - if (dbName === undefined || dbName === '') { - winston.warn('You have no database name, using "nodebb"'); - postgres.database = 'nodebb'; - } - - const connOptions = { - host: postgres.host, - port: postgres.port, - user: postgres.username, - password: postgres.password, - database: postgres.database, - ssl: String(postgres.ssl) === 'true', - }; - - return _.merge(connOptions, postgres.options || {}); + postgres ||= nconf.get('postgres'); + // Sensible defaults for PostgreSQL, if not set + postgres.host ||= '127.0.0.1'; + + postgres.port ||= 5432; + + const databaseName = postgres.database; + if (databaseName === undefined || databaseName === '') { + winston.warn('You have no database name, using "nodebb"'); + postgres.database = 'nodebb'; + } + + const connOptions = { + host: postgres.host, + port: postgres.port, + user: postgres.username, + password: postgres.password, + database: postgres.database, + ssl: String(postgres.ssl) === 'true', + }; + + return _.merge(connOptions, postgres.options || {}); }; connection.connect = async function (options) { - const { Pool } = require('pg'); - const connOptions = connection.getConnectionOptions(options); - const db = new Pool(connOptions); - await db.connect(); - return db; + const {Pool} = require('pg'); + const connOptions = connection.getConnectionOptions(options); + const database = new Pool(connOptions); + await database.connect(); + return database; }; require('../../promisify')(connection); diff --git a/src/database/postgres/hash.js b/src/database/postgres/hash.js index 724fdcb..328fe01 100644 --- a/src/database/postgres/hash.js +++ b/src/database/postgres/hash.js @@ -1,120 +1,127 @@ 'use strict'; module.exports = function (module) { - const helpers = require('./helpers'); - - module.setObject = async function (key, data) { - if (!key || !data) { - return; - } - - if (data.hasOwnProperty('')) { - delete data['']; - } - if (!Object.keys(data).length) { - return; - } - await module.transaction(async (client) => { - const dataString = JSON.stringify(data); - - if (Array.isArray(key)) { - await helpers.ensureLegacyObjectsType(client, key, 'hash'); - await client.query({ - name: 'setObjectKeys', - text: ` + const helpers = require('./helpers'); + + module.setObject = async function (key, data) { + if (!key || !data) { + return; + } + + if (data.hasOwnProperty('')) { + delete data['']; + } + + if (Object.keys(data).length === 0) { + return; + } + + await module.transaction(async client => { + const dataString = JSON.stringify(data); + + if (Array.isArray(key)) { + await helpers.ensureLegacyObjectsType(client, key, 'hash'); + await client.query({ + name: 'setObjectKeys', + text: ` INSERT INTO "legacy_hash" ("_key", "data") SELECT k, $2::TEXT::JSONB FROM UNNEST($1::TEXT[]) vs(k) ON CONFLICT ("_key") DO UPDATE SET "data" = "legacy_hash"."data" || $2::TEXT::JSONB`, - values: [key, dataString], - }); - } else { - await helpers.ensureLegacyObjectType(client, key, 'hash'); - await client.query({ - name: 'setObject', - text: ` + values: [key, dataString], + }); + } else { + await helpers.ensureLegacyObjectType(client, key, 'hash'); + await client.query({ + name: 'setObject', + text: ` INSERT INTO "legacy_hash" ("_key", "data") VALUES ($1::TEXT, $2::TEXT::JSONB) ON CONFLICT ("_key") DO UPDATE SET "data" = "legacy_hash"."data" || $2::TEXT::JSONB`, - values: [key, dataString], - }); - } - }); - }; - - module.setObjectBulk = async function (...args) { - let data = args[0]; - if (!Array.isArray(data) || !data.length) { - return; - } - if (Array.isArray(args[1])) { - console.warn('[deprecated] db.setObjectBulk(keys, data) usage is deprecated, please use db.setObjectBulk(data)'); - // conver old format to new format for backwards compatibility - data = args[0].map((key, i) => [key, args[1][i]]); - } - await module.transaction(async (client) => { - data = data.filter((item) => { - if (item[1].hasOwnProperty('')) { - delete item[1]['']; - } - return !!Object.keys(item[1]).length; - }); - const keys = data.map(item => item[0]); - if (!keys.length) { - return; - } - - await helpers.ensureLegacyObjectsType(client, keys, 'hash'); - const dataStrings = data.map(item => JSON.stringify(item[1])); - await client.query({ - name: 'setObjectBulk', - text: ` + values: [key, dataString], + }); + } + }); + }; + + module.setObjectBulk = async function (...arguments_) { + let data = arguments_[0]; + if (!Array.isArray(data) || data.length === 0) { + return; + } + + if (Array.isArray(arguments_[1])) { + console.warn('[deprecated] db.setObjectBulk(keys, data) usage is deprecated, please use db.setObjectBulk(data)'); + // Conver old format to new format for backwards compatibility + data = arguments_[0].map((key, i) => [key, arguments_[1][i]]); + } + + await module.transaction(async client => { + data = data.filter(item => { + if (item[1].hasOwnProperty('')) { + delete item[1]['']; + } + + return Object.keys(item[1]).length > 0; + }); + const keys = data.map(item => item[0]); + if (keys.length === 0) { + return; + } + + await helpers.ensureLegacyObjectsType(client, keys, 'hash'); + const dataStrings = data.map(item => JSON.stringify(item[1])); + await client.query({ + name: 'setObjectBulk', + text: ` INSERT INTO "legacy_hash" ("_key", "data") SELECT k, d FROM UNNEST($1::TEXT[], $2::TEXT::JSONB[]) vs(k, d) ON CONFLICT ("_key") DO UPDATE SET "data" = "legacy_hash"."data" || EXCLUDED.data`, - values: [keys, dataStrings], - }); - }); - }; - - module.setObjectField = async function (key, field, value) { - if (!field) { - return; - } - - await module.transaction(async (client) => { - const valueString = JSON.stringify(value); - if (Array.isArray(key)) { - await module.setObject(key, { [field]: value }); - } else { - await helpers.ensureLegacyObjectType(client, key, 'hash'); - await client.query({ - name: 'setObjectField', - text: ` + values: [keys, dataStrings], + }); + }); + }; + + module.setObjectField = async function (key, field, value) { + if (!field) { + return; + } + + await module.transaction(async client => { + const valueString = JSON.stringify(value); + if (Array.isArray(key)) { + await module.setObject(key, {[field]: value}); + } else { + await helpers.ensureLegacyObjectType(client, key, 'hash'); + await client.query({ + name: 'setObjectField', + text: ` INSERT INTO "legacy_hash" ("_key", "data") VALUES ($1::TEXT, jsonb_build_object($2::TEXT, $3::TEXT::JSONB)) ON CONFLICT ("_key") DO UPDATE SET "data" = jsonb_set("legacy_hash"."data", ARRAY[$2::TEXT], $3::TEXT::JSONB)`, - values: [key, field, valueString], - }); - } - }); - }; - - module.getObject = async function (key, fields = []) { - if (!key) { - return null; - } - if (fields.length) { - return await module.getObjectFields(key, fields); - } - const res = await module.pool.query({ - name: 'getObject', - text: ` + values: [key, field, valueString], + }); + } + }); + }; + + module.getObject = async function (key, fields = []) { + if (!key) { + return null; + } + + if (fields.length > 0) { + return await module.getObjectFields(key, fields); + } + + const res = await module.pool.query({ + name: 'getObject', + text: ` SELECT h."data" FROM "legacy_object_live" o INNER JOIN "legacy_hash" h @@ -122,22 +129,24 @@ SELECT h."data" AND o."type" = h."type" WHERE o."_key" = $1::TEXT LIMIT 1`, - values: [key], - }); - - return res.rows.length ? res.rows[0].data : null; - }; - - module.getObjects = async function (keys, fields = []) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } - if (fields.length) { - return await module.getObjectsFields(keys, fields); - } - const res = await module.pool.query({ - name: 'getObjects', - text: ` + values: [key], + }); + + return res.rows.length > 0 ? res.rows[0].data : null; + }; + + module.getObjects = async function (keys, fields = []) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } + + if (fields.length > 0) { + return await module.getObjectsFields(keys, fields); + } + + const res = await module.pool.query({ + name: 'getObjects', + text: ` SELECT h."data" FROM UNNEST($1::TEXT[]) WITH ORDINALITY k("_key", i) LEFT OUTER JOIN "legacy_object_live" o @@ -146,20 +155,20 @@ SELECT h."data" ON o."_key" = h."_key" AND o."type" = h."type" ORDER BY k.i ASC`, - values: [keys], - }); + values: [keys], + }); - return res.rows.map(row => row.data); - }; + return res.rows.map(row => row.data); + }; - module.getObjectField = async function (key, field) { - if (!key) { - return null; - } + module.getObjectField = async function (key, field) { + if (!key) { + return null; + } - const res = await module.pool.query({ - name: 'getObjectField', - text: ` + const res = await module.pool.query({ + name: 'getObjectField', + text: ` SELECT h."data"->>$2::TEXT f FROM "legacy_object_live" o INNER JOIN "legacy_hash" h @@ -167,22 +176,24 @@ SELECT h."data"->>$2::TEXT f AND o."type" = h."type" WHERE o."_key" = $1::TEXT LIMIT 1`, - values: [key, field], - }); - - return res.rows.length ? res.rows[0].f : null; - }; - - module.getObjectFields = async function (key, fields) { - if (!key) { - return null; - } - if (!Array.isArray(fields) || !fields.length) { - return await module.getObject(key); - } - const res = await module.pool.query({ - name: 'getObjectFields', - text: ` + values: [key, field], + }); + + return res.rows.length > 0 ? res.rows[0].f : null; + }; + + module.getObjectFields = async function (key, fields) { + if (!key) { + return null; + } + + if (!Array.isArray(fields) || fields.length === 0) { + return await module.getObject(key); + } + + const res = await module.pool.query({ + name: 'getObjectFields', + text: ` SELECT (SELECT jsonb_object_agg(f, d."value") FROM UNNEST($2::TEXT[]) f LEFT OUTER JOIN jsonb_each(h."data") d @@ -192,32 +203,33 @@ SELECT (SELECT jsonb_object_agg(f, d."value") ON o."_key" = h."_key" AND o."type" = h."type" WHERE o."_key" = $1::TEXT`, - values: [key, fields], - }); - - if (res.rows.length) { - return res.rows[0].d; - } - - const obj = {}; - fields.forEach((f) => { - obj[f] = null; - }); - - return obj; - }; - - module.getObjectsFields = async function (keys, fields) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } - - if (!Array.isArray(fields) || !fields.length) { - return await module.getObjects(keys); - } - const res = await module.pool.query({ - name: 'getObjectsFields', - text: ` + values: [key, fields], + }); + + if (res.rows.length > 0) { + return res.rows[0].d; + } + + const object = {}; + for (const f of fields) { + object[f] = null; + } + + return object; + }; + + module.getObjectsFields = async function (keys, fields) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } + + if (!Array.isArray(fields) || fields.length === 0) { + return await module.getObjects(keys); + } + + const res = await module.pool.query({ + name: 'getObjectsFields', + text: ` SELECT (SELECT jsonb_object_agg(f, d."value") FROM UNNEST($2::TEXT[]) f LEFT OUTER JOIN jsonb_each(h."data") d @@ -229,20 +241,20 @@ SELECT (SELECT jsonb_object_agg(f, d."value") ON o."_key" = h."_key" AND o."type" = h."type" ORDER BY k.i ASC`, - values: [keys, fields], - }); + values: [keys, fields], + }); - return res.rows.map(row => row.d); - }; + return res.rows.map(row => row.d); + }; - module.getObjectKeys = async function (key) { - if (!key) { - return; - } + module.getObjectKeys = async function (key) { + if (!key) { + return; + } - const res = await module.pool.query({ - name: 'getObjectKeys', - text: ` + const res = await module.pool.query({ + name: 'getObjectKeys', + text: ` SELECT ARRAY(SELECT jsonb_object_keys(h."data")) k FROM "legacy_object_live" o INNER JOIN "legacy_hash" h @@ -250,25 +262,25 @@ SELECT ARRAY(SELECT jsonb_object_keys(h."data")) k AND o."type" = h."type" WHERE o."_key" = $1::TEXT LIMIT 1`, - values: [key], - }); + values: [key], + }); - return res.rows.length ? res.rows[0].k : []; - }; + return res.rows.length > 0 ? res.rows[0].k : []; + }; - module.getObjectValues = async function (key) { - const data = await module.getObject(key); - return data ? Object.values(data) : []; - }; + module.getObjectValues = async function (key) { + const data = await module.getObject(key); + return data ? Object.values(data) : []; + }; - module.isObjectField = async function (key, field) { - if (!key) { - return; - } + module.isObjectField = async function (key, field) { + if (!key) { + return; + } - const res = await module.pool.query({ - name: 'isObjectField', - text: ` + const res = await module.pool.query({ + name: 'isObjectField', + text: ` SELECT (h."data" ? $2::TEXT AND h."data"->>$2::TEXT IS NOT NULL) b FROM "legacy_object_live" o INNER JOIN "legacy_hash" h @@ -276,113 +288,111 @@ SELECT (h."data" ? $2::TEXT AND h."data"->>$2::TEXT IS NOT NULL) b AND o."type" = h."type" WHERE o."_key" = $1::TEXT LIMIT 1`, - values: [key, field], - }); - - return res.rows.length ? res.rows[0].b : false; - }; - - module.isObjectFields = async function (key, fields) { - if (!key) { - return; - } - - const data = await module.getObjectFields(key, fields); - if (!data) { - return fields.map(() => false); - } - return fields.map(field => data.hasOwnProperty(field) && data[field] !== null); - }; - - module.deleteObjectField = async function (key, field) { - await module.deleteObjectFields(key, [field]); - }; - - module.deleteObjectFields = async function (key, fields) { - if (!key || (Array.isArray(key) && !key.length) || !Array.isArray(fields) || !fields.length) { - return; - } - - if (Array.isArray(key)) { - await module.pool.query({ - name: 'deleteObjectFieldsKeys', - text: ` + values: [key, field], + }); + + return res.rows.length > 0 ? res.rows[0].b : false; + }; + + module.isObjectFields = async function (key, fields) { + if (!key) { + return; + } + + const data = await module.getObjectFields(key, fields); + if (!data) { + return fields.map(() => false); + } + + return fields.map(field => data.hasOwnProperty(field) && data[field] !== null); + }; + + module.deleteObjectField = async function (key, field) { + await module.deleteObjectFields(key, [field]); + }; + + module.deleteObjectFields = async function (key, fields) { + if (!key || (Array.isArray(key) && key.length === 0) || !Array.isArray(fields) || fields.length === 0) { + return; + } + + if (Array.isArray(key)) { + await module.pool.query({ + name: 'deleteObjectFieldsKeys', + text: ` UPDATE "legacy_hash" SET "data" = COALESCE((SELECT jsonb_object_agg("key", "value") FROM jsonb_each("data") WHERE "key" <> ALL ($2::TEXT[])), '{}') WHERE "_key" = ANY($1::TEXT[])`, - values: [key, fields], - }); - } else { - await module.pool.query({ - name: 'deleteObjectFields', - text: ` + values: [key, fields], + }); + } else { + await module.pool.query({ + name: 'deleteObjectFields', + text: ` UPDATE "legacy_hash" SET "data" = COALESCE((SELECT jsonb_object_agg("key", "value") FROM jsonb_each("data") WHERE "key" <> ALL ($2::TEXT[])), '{}') WHERE "_key" = $1::TEXT`, - values: [key, fields], - }); - } - }; - - module.incrObjectField = async function (key, field) { - return await module.incrObjectFieldBy(key, field, 1); - }; - - module.decrObjectField = async function (key, field) { - return await module.incrObjectFieldBy(key, field, -1); - }; - - module.incrObjectFieldBy = async function (key, field, value) { - value = parseInt(value, 10); - - if (!key || isNaN(value)) { - return null; - } - - return await module.transaction(async (client) => { - if (Array.isArray(key)) { - await helpers.ensureLegacyObjectsType(client, key, 'hash'); - } else { - await helpers.ensureLegacyObjectType(client, key, 'hash'); - } - - const res = await client.query(Array.isArray(key) ? { - name: 'incrObjectFieldByMulti', - text: ` + values: [key, fields], + }); + } + }; + + module.incrObjectField = async function (key, field) { + return await module.incrObjectFieldBy(key, field, 1); + }; + + module.decrObjectField = async function (key, field) { + return await module.incrObjectFieldBy(key, field, -1); + }; + + module.incrObjectFieldBy = async function (key, field, value) { + value = Number.parseInt(value, 10); + + if (!key || isNaN(value)) { + return null; + } + + return await module.transaction(async client => { + await (Array.isArray(key) ? helpers.ensureLegacyObjectsType(client, key, 'hash') : helpers.ensureLegacyObjectType(client, key, 'hash')); + + const res = await client.query(Array.isArray(key) ? { + name: 'incrObjectFieldByMulti', + text: ` INSERT INTO "legacy_hash" ("_key", "data") SELECT UNNEST($1::TEXT[]), jsonb_build_object($2::TEXT, $3::NUMERIC) ON CONFLICT ("_key") DO UPDATE SET "data" = jsonb_set("legacy_hash"."data", ARRAY[$2::TEXT], to_jsonb(COALESCE(("legacy_hash"."data"->>$2::TEXT)::NUMERIC, 0) + $3::NUMERIC)) RETURNING ("data"->>$2::TEXT)::NUMERIC v`, - values: [key, field, value], - } : { - name: 'incrObjectFieldBy', - text: ` + values: [key, field, value], + } : { + name: 'incrObjectFieldBy', + text: ` INSERT INTO "legacy_hash" ("_key", "data") VALUES ($1::TEXT, jsonb_build_object($2::TEXT, $3::NUMERIC)) ON CONFLICT ("_key") DO UPDATE SET "data" = jsonb_set("legacy_hash"."data", ARRAY[$2::TEXT], to_jsonb(COALESCE(("legacy_hash"."data"->>$2::TEXT)::NUMERIC, 0) + $3::NUMERIC)) RETURNING ("data"->>$2::TEXT)::NUMERIC v`, - values: [key, field, value], - }); - return Array.isArray(key) ? res.rows.map(r => parseFloat(r.v)) : parseFloat(res.rows[0].v); - }); - }; - - module.incrObjectFieldByBulk = async function (data) { - if (!Array.isArray(data) || !data.length) { - return; - } - // TODO: perf? - await Promise.all(data.map(async (item) => { - for (const [field, value] of Object.entries(item[1])) { - // eslint-disable-next-line no-await-in-loop - await module.incrObjectFieldBy(item[0], field, value); - } - })); - }; + values: [key, field, value], + }); + return Array.isArray(key) ? res.rows.map(r => Number.parseFloat(r.v)) : Number.parseFloat(res.rows[0].v); + }); + }; + + module.incrObjectFieldByBulk = async function (data) { + if (!Array.isArray(data) || data.length === 0) { + return; + } + + // TODO: perf? + await Promise.all(data.map(async item => { + for (const [field, value] of Object.entries(item[1])) { + // eslint-disable-next-line no-await-in-loop + await module.incrObjectFieldBy(item[0], field, value); + } + })); + }; }; diff --git a/src/database/postgres/helpers.js b/src/database/postgres/helpers.js index f2e9cc7..7380c77 100644 --- a/src/database/postgres/helpers.js +++ b/src/database/postgres/helpers.js @@ -2,96 +2,95 @@ const helpers = module.exports; -helpers.valueToString = function (value) { - return String(value); -}; +helpers.valueToString = String; helpers.removeDuplicateValues = function (values, ...others) { - for (let i = 0; i < values.length; i++) { - if (values.lastIndexOf(values[i]) !== i) { - values.splice(i, 1); - for (let j = 0; j < others.length; j++) { - others[j].splice(i, 1); - } - i -= 1; - } - } + for (let i = 0; i < values.length; i++) { + if (values.lastIndexOf(values[i]) !== i) { + values.splice(i, 1); + for (const other of others) { + other.splice(i, 1); + } + + i -= 1; + } + } }; -helpers.ensureLegacyObjectType = async function (db, key, type) { - await db.query({ - name: 'ensureLegacyObjectTypeBefore', - text: ` +helpers.ensureLegacyObjectType = async function (database, key, type) { + await database.query({ + name: 'ensureLegacyObjectTypeBefore', + text: ` DELETE FROM "legacy_object" WHERE "expireAt" IS NOT NULL AND "expireAt" <= CURRENT_TIMESTAMP`, - }); + }); - await db.query({ - name: 'ensureLegacyObjectType1', - text: ` + await database.query({ + name: 'ensureLegacyObjectType1', + text: ` INSERT INTO "legacy_object" ("_key", "type") VALUES ($1::TEXT, $2::TEXT::LEGACY_OBJECT_TYPE) ON CONFLICT DO NOTHING`, - values: [key, type], - }); + values: [key, type], + }); - const res = await db.query({ - name: 'ensureLegacyObjectType2', - text: ` + const res = await database.query({ + name: 'ensureLegacyObjectType2', + text: ` SELECT "type" FROM "legacy_object_live" WHERE "_key" = $1::TEXT`, - values: [key], - }); + values: [key], + }); - if (res.rows[0].type !== type) { - throw new Error(`database: cannot insert ${JSON.stringify(key)} as ${type} because it already exists as ${res.rows[0].type}`); - } + if (res.rows[0].type !== type) { + throw new Error(`database: cannot insert ${JSON.stringify(key)} as ${type} because it already exists as ${res.rows[0].type}`); + } }; -helpers.ensureLegacyObjectsType = async function (db, keys, type) { - await db.query({ - name: 'ensureLegacyObjectTypeBefore', - text: ` +helpers.ensureLegacyObjectsType = async function (database, keys, type) { + await database.query({ + name: 'ensureLegacyObjectTypeBefore', + text: ` DELETE FROM "legacy_object" WHERE "expireAt" IS NOT NULL AND "expireAt" <= CURRENT_TIMESTAMP`, - }); + }); - await db.query({ - name: 'ensureLegacyObjectsType1', - text: ` + await database.query({ + name: 'ensureLegacyObjectsType1', + text: ` INSERT INTO "legacy_object" ("_key", "type") SELECT k, $2::TEXT::LEGACY_OBJECT_TYPE FROM UNNEST($1::TEXT[]) k ON CONFLICT DO NOTHING`, - values: [keys, type], - }); + values: [keys, type], + }); - const res = await db.query({ - name: 'ensureLegacyObjectsType2', - text: ` + const res = await database.query({ + name: 'ensureLegacyObjectsType2', + text: ` SELECT "_key", "type" FROM "legacy_object_live" WHERE "_key" = ANY($1::TEXT[])`, - values: [keys], - }); + values: [keys], + }); - const invalid = res.rows.filter(r => r.type !== type); + const invalid = res.rows.filter(r => r.type !== type); - if (invalid.length) { - const parts = invalid.map(r => `${JSON.stringify(r._key)} is ${r.type}`); - throw new Error(`database: cannot insert multiple objects as ${type} because they already exist: ${parts.join(', ')}`); - } + if (invalid.length > 0) { + const parts = invalid.map(r => `${JSON.stringify(r._key)} is ${r.type}`); + throw new Error(`database: cannot insert multiple objects as ${type} because they already exist: ${parts.join(', ')}`); + } - const missing = keys.filter(k => !res.rows.some(r => r._key === k)); + const missing = keys.filter(k => !res.rows.some(r => r._key === k)); - if (missing.length) { - throw new Error(`database: failed to insert keys for objects: ${JSON.stringify(missing)}`); - } + if (missing.length > 0) { + throw new Error(`database: failed to insert keys for objects: ${JSON.stringify(missing)}`); + } }; helpers.noop = function () {}; diff --git a/src/database/postgres/list.js b/src/database/postgres/list.js index db94f6e..49897c3 100644 --- a/src/database/postgres/list.js +++ b/src/database/postgres/list.js @@ -1,57 +1,58 @@ 'use strict'; module.exports = function (module) { - const helpers = require('./helpers'); - - module.listPrepend = async function (key, value) { - if (!key) { - return; - } - - await module.transaction(async (client) => { - await helpers.ensureLegacyObjectType(client, key, 'list'); - value = Array.isArray(value) ? value : [value]; - value.reverse(); - await client.query({ - name: 'listPrependValues', - text: ` + const helpers = require('./helpers'); + + module.listPrepend = async function (key, value) { + if (!key) { + return; + } + + await module.transaction(async client => { + await helpers.ensureLegacyObjectType(client, key, 'list'); + value = Array.isArray(value) ? value : [value]; + value.reverse(); + await client.query({ + name: 'listPrependValues', + text: ` INSERT INTO "legacy_list" ("_key", "array") VALUES ($1::TEXT, $2::TEXT[]) ON CONFLICT ("_key") DO UPDATE SET "array" = EXCLUDED.array || "legacy_list"."array"`, - values: [key, value], - }); - }); - }; - - module.listAppend = async function (key, value) { - if (!key) { - return; - } - await module.transaction(async (client) => { - value = Array.isArray(value) ? value : [value]; - - await helpers.ensureLegacyObjectType(client, key, 'list'); - await client.query({ - name: 'listAppend', - text: ` + values: [key, value], + }); + }); + }; + + module.listAppend = async function (key, value) { + if (!key) { + return; + } + + await module.transaction(async client => { + value = Array.isArray(value) ? value : [value]; + + await helpers.ensureLegacyObjectType(client, key, 'list'); + await client.query({ + name: 'listAppend', + text: ` INSERT INTO "legacy_list" ("_key", "array") VALUES ($1::TEXT, $2::TEXT[]) ON CONFLICT ("_key") DO UPDATE SET "array" = "legacy_list"."array" || EXCLUDED.array`, - values: [key, value], - }); - }); - }; - - module.listRemoveLast = async function (key) { - if (!key) { - return; - } - - const res = await module.pool.query({ - name: 'listRemoveLast', - text: ` + values: [key, value], + }); + }); + }; + + module.listRemoveLast = async function (key) { + if (!key) { + return; + } + + const res = await module.pool.query({ + name: 'listRemoveLast', + text: ` WITH A AS ( SELECT l.* FROM "legacy_object_live" o @@ -65,44 +66,46 @@ UPDATE "legacy_list" l FROM A WHERE A."_key" = l."_key" RETURNING A."array"[array_length(A."array", 1)] v`, - values: [key], - }); - - return res.rows.length ? res.rows[0].v : null; - }; - - module.listRemoveAll = async function (key, value) { - if (!key) { - return; - } - // TODO: remove all values with one query - if (Array.isArray(value)) { - await Promise.all(value.map(v => module.listRemoveAll(key, v))); - return; - } - await module.pool.query({ - name: 'listRemoveAll', - text: ` + values: [key], + }); + + return res.rows.length > 0 ? res.rows[0].v : null; + }; + + module.listRemoveAll = async function (key, value) { + if (!key) { + return; + } + + // TODO: remove all values with one query + if (Array.isArray(value)) { + await Promise.all(value.map(v => module.listRemoveAll(key, v))); + return; + } + + await module.pool.query({ + name: 'listRemoveAll', + text: ` UPDATE "legacy_list" l SET "array" = array_remove(l."array", $2::TEXT) FROM "legacy_object_live" o WHERE o."_key" = l."_key" AND o."type" = l."type" AND o."_key" = $1::TEXT`, - values: [key, value], - }); - }; + values: [key, value], + }); + }; - module.listTrim = async function (key, start, stop) { - if (!key) { - return; - } + module.listTrim = async function (key, start, stop) { + if (!key) { + return; + } - stop += 1; + stop += 1; - await module.pool.query(stop > 0 ? { - name: 'listTrim', - text: ` + await module.pool.query(stop > 0 ? { + name: 'listTrim', + text: ` UPDATE "legacy_list" l SET "array" = ARRAY(SELECT m.m FROM UNNEST(l."array") WITH ORDINALITY m(m, i) @@ -113,10 +116,10 @@ UPDATE "legacy_list" l WHERE o."_key" = l."_key" AND o."type" = l."type" AND o."_key" = $1::TEXT`, - values: [key, start, stop], - } : { - name: 'listTrimBack', - text: ` + values: [key, start, stop], + } : { + name: 'listTrimBack', + text: ` UPDATE "legacy_list" l SET "array" = ARRAY(SELECT m.m FROM UNNEST(l."array") WITH ORDINALITY m(m, i) @@ -127,20 +130,20 @@ UPDATE "legacy_list" l WHERE o."_key" = l."_key" AND o."type" = l."type" AND o."_key" = $1::TEXT`, - values: [key, start, stop], - }); - }; + values: [key, start, stop], + }); + }; - module.getListRange = async function (key, start, stop) { - if (!key) { - return; - } + module.getListRange = async function (key, start, stop) { + if (!key) { + return; + } - stop += 1; + stop += 1; - const res = await module.pool.query(stop > 0 ? { - name: 'getListRange', - text: ` + const res = await module.pool.query(stop > 0 ? { + name: 'getListRange', + text: ` SELECT ARRAY(SELECT m.m FROM UNNEST(l."array") WITH ORDINALITY m(m, i) ORDER BY m.i ASC @@ -151,10 +154,10 @@ SELECT ARRAY(SELECT m.m ON o."_key" = l."_key" AND o."type" = l."type" WHERE o."_key" = $1::TEXT`, - values: [key, start, stop], - } : { - name: 'getListRangeBack', - text: ` + values: [key, start, stop], + } : { + name: 'getListRangeBack', + text: ` SELECT ARRAY(SELECT m.m FROM UNNEST(l."array") WITH ORDINALITY m(m, i) ORDER BY m.i ASC @@ -165,25 +168,25 @@ SELECT ARRAY(SELECT m.m ON o."_key" = l."_key" AND o."type" = l."type" WHERE o."_key" = $1::TEXT`, - values: [key, start, stop], - }); + values: [key, start, stop], + }); - return res.rows.length ? res.rows[0].l : []; - }; + return res.rows.length > 0 ? res.rows[0].l : []; + }; - module.listLength = async function (key) { - const res = await module.pool.query({ - name: 'listLength', - text: ` + module.listLength = async function (key) { + const res = await module.pool.query({ + name: 'listLength', + text: ` SELECT array_length(l."array", 1) l FROM "legacy_object_live" o INNER JOIN "legacy_list" l ON o."_key" = l."_key" AND o."type" = l."type" WHERE o."_key" = $1::TEXT`, - values: [key], - }); + values: [key], + }); - return res.rows.length ? res.rows[0].l : 0; - }; + return res.rows.length > 0 ? res.rows[0].l : 0; + }; }; diff --git a/src/database/postgres/main.js b/src/database/postgres/main.js index 5a6957f..aa46f18 100644 --- a/src/database/postgres/main.js +++ b/src/database/postgres/main.js @@ -1,111 +1,114 @@ 'use strict'; module.exports = function (module) { - const helpers = require('./helpers'); - - module.flushdb = async function () { - await module.pool.query(`DROP SCHEMA "public" CASCADE`); - await module.pool.query(`CREATE SCHEMA "public"`); - }; - - module.emptydb = async function () { - await module.pool.query(`DELETE FROM "legacy_object"`); - }; - - module.exists = async function (key) { - if (!key) { - return; - } - - // Redis/Mongo consider empty zsets as non-existent, match that behaviour - const type = await module.type(key); - if (type === 'zset') { - if (Array.isArray(key)) { - const members = await Promise.all(key.map(key => module.getSortedSetRange(key, 0, 0))); - return members.map(member => member.length > 0); - } - const members = await module.getSortedSetRange(key, 0, 0); - return members.length > 0; - } - - if (Array.isArray(key)) { - const res = await module.pool.query({ - name: 'existsArray', - text: ` + const helpers = require('./helpers'); + + module.flushdb = async function () { + await module.pool.query('DROP SCHEMA "public" CASCADE'); + await module.pool.query('CREATE SCHEMA "public"'); + }; + + module.emptydb = async function () { + await module.pool.query('DELETE FROM "legacy_object"'); + }; + + module.exists = async function (key) { + if (!key) { + return; + } + + // Redis/Mongo consider empty zsets as non-existent, match that behaviour + const type = await module.type(key); + if (type === 'zset') { + if (Array.isArray(key)) { + const members = await Promise.all(key.map(key => module.getSortedSetRange(key, 0, 0))); + return members.map(member => member.length > 0); + } + + const members = await module.getSortedSetRange(key, 0, 0); + return members.length > 0; + } + + if (Array.isArray(key)) { + const res = await module.pool.query({ + name: 'existsArray', + text: ` SELECT o."_key" k FROM "legacy_object_live" o WHERE o."_key" = ANY($1::TEXT[])`, - values: [key], - }); - return key.map(k => res.rows.some(r => r.k === k)); - } - const res = await module.pool.query({ - name: 'exists', - text: ` + values: [key], + }); + return key.map(k => res.rows.some(r => r.k === k)); + } + + const res = await module.pool.query({ + name: 'exists', + text: ` SELECT EXISTS(SELECT * FROM "legacy_object_live" WHERE "_key" = $1::TEXT LIMIT 1) e`, - values: [key], - }); - return res.rows[0].e; - }; - - module.scan = async function (params) { - let { match } = params; - if (match.startsWith('*')) { - match = `%${match.substring(1)}`; - } - if (match.endsWith('*')) { - match = `${match.substring(0, match.length - 1)}%`; - } - - const res = await module.pool.query({ - text: ` + values: [key], + }); + return res.rows[0].e; + }; + + module.scan = async function (parameters) { + let {match} = parameters; + if (match.startsWith('*')) { + match = `%${match.slice(1)}`; + } + + if (match.endsWith('*')) { + match = `${match.slice(0, Math.max(0, match.length - 1))}%`; + } + + const res = await module.pool.query({ + text: ` SELECT o."_key" FROM "legacy_object_live" o WHERE o."_key" LIKE '${match}'`, - }); + }); - return res.rows.map(r => r._key); - }; + return res.rows.map(r => r._key); + }; - module.delete = async function (key) { - if (!key) { - return; - } + module.delete = async function (key) { + if (!key) { + return; + } - await module.pool.query({ - name: 'delete', - text: ` + await module.pool.query({ + name: 'delete', + text: ` DELETE FROM "legacy_object" WHERE "_key" = $1::TEXT`, - values: [key], - }); - }; - - module.deleteAll = async function (keys) { - if (!Array.isArray(keys) || !keys.length) { - return; - } - - await module.pool.query({ - name: 'deleteAll', - text: ` + values: [key], + }); + }; + + module.deleteAll = async function (keys) { + if (!Array.isArray(keys) || keys.length === 0) { + return; + } + + await module.pool.query({ + name: 'deleteAll', + text: ` DELETE FROM "legacy_object" WHERE "_key" = ANY($1::TEXT[])`, - values: [keys], - }); - }; - - module.get = async function (key) { - if (!key) { - return; - } - - const res = await module.pool.query({ - name: 'get', - text: ` + values: [keys], + }); + }; + + module.get = async function (key) { + if (!key) { + return; + } + + const res = await module.pool.query({ + name: 'get', + text: ` SELECT s."data" t FROM "legacy_object_live" o INNER JOIN "legacy_string" s @@ -113,132 +116,132 @@ SELECT s."data" t AND o."type" = s."type" WHERE o."_key" = $1::TEXT LIMIT 1`, - values: [key], - }); - - return res.rows.length ? res.rows[0].t : null; - }; - - module.set = async function (key, value) { - if (!key) { - return; - } - - await module.transaction(async (client) => { - await helpers.ensureLegacyObjectType(client, key, 'string'); - await client.query({ - name: 'set', - text: ` + values: [key], + }); + + return res.rows.length > 0 ? res.rows[0].t : null; + }; + + module.set = async function (key, value) { + if (!key) { + return; + } + + await module.transaction(async client => { + await helpers.ensureLegacyObjectType(client, key, 'string'); + await client.query({ + name: 'set', + text: ` INSERT INTO "legacy_string" ("_key", "data") VALUES ($1::TEXT, $2::TEXT) ON CONFLICT ("_key") DO UPDATE SET "data" = $2::TEXT`, - values: [key, value], - }); - }); - }; - - module.increment = async function (key) { - if (!key) { - return; - } - - return await module.transaction(async (client) => { - await helpers.ensureLegacyObjectType(client, key, 'string'); - const res = await client.query({ - name: 'increment', - text: ` + values: [key, value], + }); + }); + }; + + module.increment = async function (key) { + if (!key) { + return; + } + + return await module.transaction(async client => { + await helpers.ensureLegacyObjectType(client, key, 'string'); + const res = await client.query({ + name: 'increment', + text: ` INSERT INTO "legacy_string" ("_key", "data") VALUES ($1::TEXT, '1') ON CONFLICT ("_key") DO UPDATE SET "data" = ("legacy_string"."data"::NUMERIC + 1)::TEXT RETURNING "data" d`, - values: [key], - }); - return parseFloat(res.rows[0].d); - }); - }; - - module.rename = async function (oldKey, newKey) { - await module.transaction(async (client) => { - await client.query({ - name: 'deleteRename', - text: ` + values: [key], + }); + return Number.parseFloat(res.rows[0].d); + }); + }; + + module.rename = async function (oldKey, newKey) { + await module.transaction(async client => { + await client.query({ + name: 'deleteRename', + text: ` DELETE FROM "legacy_object" WHERE "_key" = $1::TEXT`, - values: [newKey], - }); - await client.query({ - name: 'rename', - text: ` + values: [newKey], + }); + await client.query({ + name: 'rename', + text: ` UPDATE "legacy_object" SET "_key" = $2::TEXT WHERE "_key" = $1::TEXT`, - values: [oldKey, newKey], - }); - }); - }; - - module.type = async function (key) { - const res = await module.pool.query({ - name: 'type', - text: ` + values: [oldKey, newKey], + }); + }); + }; + + module.type = async function (key) { + const res = await module.pool.query({ + name: 'type', + text: ` SELECT "type"::TEXT t FROM "legacy_object_live" WHERE "_key" = $1::TEXT LIMIT 1`, - values: [key], - }); + values: [key], + }); - return res.rows.length ? res.rows[0].t : null; - }; + return res.rows.length > 0 ? res.rows[0].t : null; + }; - async function doExpire(key, date) { - await module.pool.query({ - name: 'expire', - text: ` + async function doExpire(key, date) { + await module.pool.query({ + name: 'expire', + text: ` UPDATE "legacy_object" SET "expireAt" = $2::TIMESTAMPTZ WHERE "_key" = $1::TEXT`, - values: [key, date], - }); - } - - module.expire = async function (key, seconds) { - await doExpire(key, new Date(((Date.now() / 1000) + seconds) * 1000)); - }; - - module.expireAt = async function (key, timestamp) { - await doExpire(key, new Date(timestamp * 1000)); - }; - - module.pexpire = async function (key, ms) { - await doExpire(key, new Date(Date.now() + parseInt(ms, 10))); - }; - - module.pexpireAt = async function (key, timestamp) { - await doExpire(key, new Date(timestamp)); - }; - - async function getExpire(key) { - const res = await module.pool.query({ - name: 'ttl', - text: ` + values: [key, date], + }); + } + + module.expire = async function (key, seconds) { + await doExpire(key, new Date(((Date.now() / 1000) + seconds) * 1000)); + }; + + module.expireAt = async function (key, timestamp) { + await doExpire(key, new Date(timestamp * 1000)); + }; + + module.pexpire = async function (key, ms) { + await doExpire(key, new Date(Date.now() + Number.parseInt(ms, 10))); + }; + + module.pexpireAt = async function (key, timestamp) { + await doExpire(key, new Date(timestamp)); + }; + + async function getExpire(key) { + const res = await module.pool.query({ + name: 'ttl', + text: ` SELECT "expireAt"::TEXT FROM "legacy_object" WHERE "_key" = $1::TEXT LIMIT 1`, - values: [key], - }); + values: [key], + }); - return res.rows.length ? new Date(res.rows[0].expireAt).getTime() : null; - } + return res.rows.length > 0 ? new Date(res.rows[0].expireAt).getTime() : null; + } - module.ttl = async function (key) { - return Math.round((await getExpire(key) - Date.now()) / 1000); - }; + module.ttl = async function (key) { + return Math.round((await getExpire(key) - Date.now()) / 1000); + }; - module.pttl = async function (key) { - return await getExpire(key) - Date.now(); - }; + module.pttl = async function (key) { + return await getExpire(key) - Date.now(); + }; }; diff --git a/src/database/postgres/sets.js b/src/database/postgres/sets.js index 5ff9369..d4f6d03 100644 --- a/src/database/postgres/sets.js +++ b/src/database/postgres/sets.js @@ -3,99 +3,101 @@ const _ = require('lodash'); module.exports = function (module) { - const helpers = require('./helpers'); - - module.setAdd = async function (key, value) { - if (!Array.isArray(value)) { - value = [value]; - } - if (!value.length) { - return; - } - await module.transaction(async (client) => { - await helpers.ensureLegacyObjectType(client, key, 'set'); - await client.query({ - name: 'setAdd', - text: ` + const helpers = require('./helpers'); + + module.setAdd = async function (key, value) { + if (!Array.isArray(value)) { + value = [value]; + } + + if (value.length === 0) { + return; + } + + await module.transaction(async client => { + await helpers.ensureLegacyObjectType(client, key, 'set'); + await client.query({ + name: 'setAdd', + text: ` INSERT INTO "legacy_set" ("_key", "member") SELECT $1::TEXT, m FROM UNNEST($2::TEXT[]) m ON CONFLICT ("_key", "member") DO NOTHING`, - values: [key, value], - }); - }); - }; - - module.setsAdd = async function (keys, value) { - if (!Array.isArray(keys) || !keys.length) { - return; - } - - if (!Array.isArray(value)) { - value = [value]; - } - - keys = _.uniq(keys); - - await module.transaction(async (client) => { - await helpers.ensureLegacyObjectsType(client, keys, 'set'); - await client.query({ - name: 'setsAdd', - text: ` + values: [key, value], + }); + }); + }; + + module.setsAdd = async function (keys, value) { + if (!Array.isArray(keys) || keys.length === 0) { + return; + } + + if (!Array.isArray(value)) { + value = [value]; + } + + keys = _.uniq(keys); + + await module.transaction(async client => { + await helpers.ensureLegacyObjectsType(client, keys, 'set'); + await client.query({ + name: 'setsAdd', + text: ` INSERT INTO "legacy_set" ("_key", "member") SELECT k, m FROM UNNEST($1::TEXT[]) k CROSS JOIN UNNEST($2::TEXT[]) m ON CONFLICT ("_key", "member") DO NOTHING`, - values: [keys, value], - }); - }); - }; - - module.setRemove = async function (key, value) { - if (!Array.isArray(key)) { - key = [key]; - } - - if (!Array.isArray(value)) { - value = [value]; - } - - await module.pool.query({ - name: 'setRemove', - text: ` + values: [keys, value], + }); + }); + }; + + module.setRemove = async function (key, value) { + if (!Array.isArray(key)) { + key = [key]; + } + + if (!Array.isArray(value)) { + value = [value]; + } + + await module.pool.query({ + name: 'setRemove', + text: ` DELETE FROM "legacy_set" WHERE "_key" = ANY($1::TEXT[]) AND "member" = ANY($2::TEXT[])`, - values: [key, value], - }); - }; - - module.setsRemove = async function (keys, value) { - if (!Array.isArray(keys) || !keys.length) { - return; - } - - await module.pool.query({ - name: 'setsRemove', - text: ` + values: [key, value], + }); + }; + + module.setsRemove = async function (keys, value) { + if (!Array.isArray(keys) || keys.length === 0) { + return; + } + + await module.pool.query({ + name: 'setsRemove', + text: ` DELETE FROM "legacy_set" WHERE "_key" = ANY($1::TEXT[]) AND "member" = $2::TEXT`, - values: [keys, value], - }); - }; - - module.isSetMember = async function (key, value) { - if (!key) { - return false; - } - - const res = await module.pool.query({ - name: 'isSetMember', - text: ` + values: [keys, value], + }); + }; + + module.isSetMember = async function (key, value) { + if (!key) { + return false; + } + + const res = await module.pool.query({ + name: 'isSetMember', + text: ` SELECT 1 FROM "legacy_object_live" o INNER JOIN "legacy_set" s @@ -103,22 +105,22 @@ SELECT 1 AND o."type" = s."type" WHERE o."_key" = $1::TEXT AND s."member" = $2::TEXT`, - values: [key, value], - }); + values: [key, value], + }); - return !!res.rows.length; - }; + return res.rows.length > 0; + }; - module.isSetMembers = async function (key, values) { - if (!key || !Array.isArray(values) || !values.length) { - return []; - } + module.isSetMembers = async function (key, values) { + if (!key || !Array.isArray(values) || values.length === 0) { + return []; + } - values = values.map(helpers.valueToString); + values = values.map(helpers.valueToString); - const res = await module.pool.query({ - name: 'isSetMembers', - text: ` + const res = await module.pool.query({ + name: 'isSetMembers', + text: ` SELECT s."member" m FROM "legacy_object_live" o INNER JOIN "legacy_set" s @@ -126,22 +128,22 @@ SELECT s."member" m AND o."type" = s."type" WHERE o."_key" = $1::TEXT AND s."member" = ANY($2::TEXT[])`, - values: [key, values], - }); + values: [key, values], + }); - return values.map(v => res.rows.some(r => r.m === v)); - }; + return values.map(v => res.rows.some(r => r.m === v)); + }; - module.isMemberOfSets = async function (sets, value) { - if (!Array.isArray(sets) || !sets.length) { - return []; - } + module.isMemberOfSets = async function (sets, value) { + if (!Array.isArray(sets) || sets.length === 0) { + return []; + } - value = helpers.valueToString(value); + value = helpers.valueToString(value); - const res = await module.pool.query({ - name: 'isMemberOfSets', - text: ` + const res = await module.pool.query({ + name: 'isMemberOfSets', + text: ` SELECT o."_key" k FROM "legacy_object_live" o INNER JOIN "legacy_set" s @@ -149,40 +151,40 @@ SELECT o."_key" k AND o."type" = s."type" WHERE o."_key" = ANY($1::TEXT[]) AND s."member" = $2::TEXT`, - values: [sets, value], - }); + values: [sets, value], + }); - return sets.map(s => res.rows.some(r => r.k === s)); - }; + return sets.map(s => res.rows.some(r => r.k === s)); + }; - module.getSetMembers = async function (key) { - if (!key) { - return []; - } + module.getSetMembers = async function (key) { + if (!key) { + return []; + } - const res = await module.pool.query({ - name: 'getSetMembers', - text: ` + const res = await module.pool.query({ + name: 'getSetMembers', + text: ` SELECT s."member" m FROM "legacy_object_live" o INNER JOIN "legacy_set" s ON o."_key" = s."_key" AND o."type" = s."type" WHERE o."_key" = $1::TEXT`, - values: [key], - }); + values: [key], + }); - return res.rows.map(r => r.m); - }; + return res.rows.map(r => r.m); + }; - module.getSetsMembers = async function (keys) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } + module.getSetsMembers = async function (keys) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } - const res = await module.pool.query({ - name: 'getSetsMembers', - text: ` + const res = await module.pool.query({ + name: 'getSetsMembers', + text: ` SELECT o."_key" k, array_agg(s."member") m FROM "legacy_object_live" o @@ -191,36 +193,36 @@ SELECT o."_key" k, AND o."type" = s."type" WHERE o."_key" = ANY($1::TEXT[]) GROUP BY o."_key"`, - values: [keys], - }); + values: [keys], + }); - return keys.map(k => (res.rows.find(r => r.k === k) || { m: [] }).m); - }; + return keys.map(k => (res.rows.find(r => r.k === k) || {m: []}).m); + }; - module.setCount = async function (key) { - if (!key) { - return 0; - } + module.setCount = async function (key) { + if (!key) { + return 0; + } - const res = await module.pool.query({ - name: 'setCount', - text: ` + const res = await module.pool.query({ + name: 'setCount', + text: ` SELECT COUNT(*) c FROM "legacy_object_live" o INNER JOIN "legacy_set" s ON o."_key" = s."_key" AND o."type" = s."type" WHERE o."_key" = $1::TEXT`, - values: [key], - }); + values: [key], + }); - return parseInt(res.rows[0].c, 10); - }; + return Number.parseInt(res.rows[0].c, 10); + }; - module.setsCount = async function (keys) { - const res = await module.pool.query({ - name: 'setsCount', - text: ` + module.setsCount = async function (keys) { + const res = await module.pool.query({ + name: 'setsCount', + text: ` SELECT o."_key" k, COUNT(*) c FROM "legacy_object_live" o @@ -229,16 +231,16 @@ SELECT o."_key" k, AND o."type" = s."type" WHERE o."_key" = ANY($1::TEXT[]) GROUP BY o."_key"`, - values: [keys], - }); + values: [keys], + }); - return keys.map(k => (res.rows.find(r => r.k === k) || { c: 0 }).c); - }; + return keys.map(k => (res.rows.find(r => r.k === k) || {c: 0}).c); + }; - module.setRemoveRandom = async function (key) { - const res = await module.pool.query({ - name: 'setRemoveRandom', - text: ` + module.setRemoveRandom = async function (key) { + const res = await module.pool.query({ + name: 'setRemoveRandom', + text: ` WITH A AS ( SELECT s."member" FROM "legacy_object_live" o @@ -254,8 +256,8 @@ DELETE FROM "legacy_set" s WHERE s."_key" = $1::TEXT AND s."member" = A."member" RETURNING A."member" m`, - values: [key], - }); - return res.rows.length ? res.rows[0].m : null; - }; + values: [key], + }); + return res.rows.length > 0 ? res.rows[0].m : null; + }; }; diff --git a/src/database/postgres/sorted.js b/src/database/postgres/sorted.js index eff5e80..c3b37f9 100644 --- a/src/database/postgres/sorted.js +++ b/src/database/postgres/sorted.js @@ -1,66 +1,66 @@ 'use strict'; module.exports = function (module) { - const helpers = require('./helpers'); - const util = require('util'); - const Cursor = require('pg-cursor'); - Cursor.prototype.readAsync = util.promisify(Cursor.prototype.read); - const sleep = util.promisify(setTimeout); - - require('./sorted/add')(module); - require('./sorted/remove')(module); - require('./sorted/union')(module); - require('./sorted/intersect')(module); - - module.getSortedSetRange = async function (key, start, stop) { - return await getSortedSetRange(key, start, stop, 1, false); - }; - - module.getSortedSetRevRange = async function (key, start, stop) { - return await getSortedSetRange(key, start, stop, -1, false); - }; - - module.getSortedSetRangeWithScores = async function (key, start, stop) { - return await getSortedSetRange(key, start, stop, 1, true); - }; - - module.getSortedSetRevRangeWithScores = async function (key, start, stop) { - return await getSortedSetRange(key, start, stop, -1, true); - }; - - async function getSortedSetRange(key, start, stop, sort, withScores) { - if (!key) { - return; - } - - if (!Array.isArray(key)) { - key = [key]; - } - - if (start < 0 && start > stop) { - return []; - } - - let reverse = false; - if (start === 0 && stop < -1) { - reverse = true; - sort *= -1; - start = Math.abs(stop + 1); - stop = -1; - } else if (start < 0 && stop > start) { - const tmp1 = Math.abs(stop + 1); - stop = Math.abs(start + 1); - start = tmp1; - } - - let limit = stop - start + 1; - if (limit <= 0) { - limit = null; - } - - const res = await module.pool.query({ - name: `getSortedSetRangeWithScores${sort > 0 ? 'Asc' : 'Desc'}`, - text: ` + const helpers = require('./helpers'); + const util = require('node:util'); + const Cursor = require('pg-cursor'); + Cursor.prototype.readAsync = util.promisify(Cursor.prototype.read); + const sleep = util.promisify(setTimeout); + + require('./sorted/add')(module); + require('./sorted/remove')(module); + require('./sorted/union')(module); + require('./sorted/intersect')(module); + + module.getSortedSetRange = async function (key, start, stop) { + return await getSortedSetRange(key, start, stop, 1, false); + }; + + module.getSortedSetRevRange = async function (key, start, stop) { + return await getSortedSetRange(key, start, stop, -1, false); + }; + + module.getSortedSetRangeWithScores = async function (key, start, stop) { + return await getSortedSetRange(key, start, stop, 1, true); + }; + + module.getSortedSetRevRangeWithScores = async function (key, start, stop) { + return await getSortedSetRange(key, start, stop, -1, true); + }; + + async function getSortedSetRange(key, start, stop, sort, withScores) { + if (!key) { + return; + } + + if (!Array.isArray(key)) { + key = [key]; + } + + if (start < 0 && start > stop) { + return []; + } + + let reverse = false; + if (start === 0 && stop < -1) { + reverse = true; + sort *= -1; + start = Math.abs(stop + 1); + stop = -1; + } else if (start < 0 && stop > start) { + const temporary1 = Math.abs(stop + 1); + stop = Math.abs(start + 1); + start = temporary1; + } + + let limit = stop - start + 1; + if (limit <= 0) { + limit = null; + } + + const res = await module.pool.query({ + name: `getSortedSetRangeWithScores${sort > 0 ? 'Asc' : 'Desc'}`, + text: ` SELECT z."value", z."score" FROM "legacy_object_live" o @@ -71,61 +71,58 @@ SELECT z."value", ORDER BY z."score" ${sort > 0 ? 'ASC' : 'DESC'} LIMIT $3::INTEGER OFFSET $2::INTEGER`, - values: [key, start, limit], - }); - - if (reverse) { - res.rows.reverse(); - } - - if (withScores) { - res.rows = res.rows.map(r => ({ value: r.value, score: parseFloat(r.score) })); - } else { - res.rows = res.rows.map(r => r.value); - } - - return res.rows; - } - - module.getSortedSetRangeByScore = async function (key, start, count, min, max) { - return await getSortedSetRangeByScore(key, start, count, min, max, 1, false); - }; - - module.getSortedSetRevRangeByScore = async function (key, start, count, max, min) { - return await getSortedSetRangeByScore(key, start, count, min, max, -1, false); - }; - - module.getSortedSetRangeByScoreWithScores = async function (key, start, count, min, max) { - return await getSortedSetRangeByScore(key, start, count, min, max, 1, true); - }; - - module.getSortedSetRevRangeByScoreWithScores = async function (key, start, count, max, min) { - return await getSortedSetRangeByScore(key, start, count, min, max, -1, true); - }; - - async function getSortedSetRangeByScore(key, start, count, min, max, sort, withScores) { - if (!key) { - return; - } - - if (!Array.isArray(key)) { - key = [key]; - } - - if (parseInt(count, 10) === -1) { - count = null; - } - - if (min === '-inf') { - min = null; - } - if (max === '+inf') { - max = null; - } - - const res = await module.pool.query({ - name: `getSortedSetRangeByScoreWithScores${sort > 0 ? 'Asc' : 'Desc'}`, - text: ` + values: [key, start, limit], + }); + + if (reverse) { + res.rows.reverse(); + } + + res.rows = withScores ? res.rows.map(r => ({value: r.value, score: Number.parseFloat(r.score)})) : res.rows.map(r => r.value); + + return res.rows; + } + + module.getSortedSetRangeByScore = async function (key, start, count, min, max) { + return await getSortedSetRangeByScore(key, start, count, min, max, 1, false); + }; + + module.getSortedSetRevRangeByScore = async function (key, start, count, max, min) { + return await getSortedSetRangeByScore(key, start, count, min, max, -1, false); + }; + + module.getSortedSetRangeByScoreWithScores = async function (key, start, count, min, max) { + return await getSortedSetRangeByScore(key, start, count, min, max, 1, true); + }; + + module.getSortedSetRevRangeByScoreWithScores = async function (key, start, count, max, min) { + return await getSortedSetRangeByScore(key, start, count, min, max, -1, true); + }; + + async function getSortedSetRangeByScore(key, start, count, min, max, sort, withScores) { + if (!key) { + return; + } + + if (!Array.isArray(key)) { + key = [key]; + } + + if (Number.parseInt(count, 10) === -1) { + count = null; + } + + if (min === '-inf') { + min = null; + } + + if (max === '+inf') { + max = null; + } + + const res = await module.pool.query({ + name: `getSortedSetRangeByScoreWithScores${sort > 0 ? 'Asc' : 'Desc'}`, + text: ` SELECT z."value", z."score" FROM "legacy_object_live" o @@ -138,33 +135,30 @@ SELECT z."value", ORDER BY z."score" ${sort > 0 ? 'ASC' : 'DESC'} LIMIT $3::INTEGER OFFSET $2::INTEGER`, - values: [key, start, count, min, max], - }); - - if (withScores) { - res.rows = res.rows.map(r => ({ value: r.value, score: parseFloat(r.score) })); - } else { - res.rows = res.rows.map(r => r.value); - } - - return res.rows; - } - - module.sortedSetCount = async function (key, min, max) { - if (!key) { - return; - } - - if (min === '-inf') { - min = null; - } - if (max === '+inf') { - max = null; - } - - const res = await module.pool.query({ - name: 'sortedSetCount', - text: ` + values: [key, start, count, min, max], + }); + + res.rows = withScores ? res.rows.map(r => ({value: r.value, score: Number.parseFloat(r.score)})) : res.rows.map(r => r.value); + + return res.rows; + } + + module.sortedSetCount = async function (key, min, max) { + if (!key) { + return; + } + + if (min === '-inf') { + min = null; + } + + if (max === '+inf') { + max = null; + } + + const res = await module.pool.query({ + name: 'sortedSetCount', + text: ` SELECT COUNT(*) c FROM "legacy_object_live" o INNER JOIN "legacy_zset" z @@ -173,40 +167,40 @@ SELECT COUNT(*) c WHERE o."_key" = $1::TEXT AND (z."score" >= $2::NUMERIC OR $2::NUMERIC IS NULL) AND (z."score" <= $3::NUMERIC OR $3::NUMERIC IS NULL)`, - values: [key, min, max], - }); + values: [key, min, max], + }); - return parseInt(res.rows[0].c, 10); - }; + return Number.parseInt(res.rows[0].c, 10); + }; - module.sortedSetCard = async function (key) { - if (!key) { - return 0; - } + module.sortedSetCard = async function (key) { + if (!key) { + return 0; + } - const res = await module.pool.query({ - name: 'sortedSetCard', - text: ` + const res = await module.pool.query({ + name: 'sortedSetCard', + text: ` SELECT COUNT(*) c FROM "legacy_object_live" o INNER JOIN "legacy_zset" z ON o."_key" = z."_key" AND o."type" = z."type" WHERE o."_key" = $1::TEXT`, - values: [key], - }); + values: [key], + }); - return parseInt(res.rows[0].c, 10); - }; + return Number.parseInt(res.rows[0].c, 10); + }; - module.sortedSetsCard = async function (keys) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } + module.sortedSetsCard = async function (keys) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } - const res = await module.pool.query({ - name: 'sortedSetsCard', - text: ` + const res = await module.pool.query({ + name: 'sortedSetsCard', + text: ` SELECT o."_key" k, COUNT(*) c FROM "legacy_object_live" o @@ -215,39 +209,41 @@ SELECT o."_key" k, AND o."type" = z."type" WHERE o."_key" = ANY($1::TEXT[]) GROUP BY o."_key"`, - values: [keys], - }); - - return keys.map(k => parseInt((res.rows.find(r => r.k === k) || { c: 0 }).c, 10)); - }; - - module.sortedSetsCardSum = async function (keys) { - if (!keys || (Array.isArray(keys) && !keys.length)) { - return 0; - } - if (!Array.isArray(keys)) { - keys = [keys]; - } - const counts = await module.sortedSetsCard(keys); - const sum = counts.reduce((acc, val) => acc + val, 0); - return sum; - }; - - module.sortedSetRank = async function (key, value) { - const result = await getSortedSetRank('ASC', [key], [value]); - return result ? result[0] : null; - }; - - module.sortedSetRevRank = async function (key, value) { - const result = await getSortedSetRank('DESC', [key], [value]); - return result ? result[0] : null; - }; - - async function getSortedSetRank(sort, keys, values) { - values = values.map(helpers.valueToString); - const res = await module.pool.query({ - name: `getSortedSetRank${sort}`, - text: ` + values: [keys], + }); + + return keys.map(k => Number.parseInt((res.rows.find(r => r.k === k) || {c: 0}).c, 10)); + }; + + module.sortedSetsCardSum = async function (keys) { + if (!keys || (Array.isArray(keys) && keys.length === 0)) { + return 0; + } + + if (!Array.isArray(keys)) { + keys = [keys]; + } + + const counts = await module.sortedSetsCard(keys); + const sum = counts.reduce((accumulator, value) => accumulator + value, 0); + return sum; + }; + + module.sortedSetRank = async function (key, value) { + const result = await getSortedSetRank('ASC', [key], [value]); + return result ? result[0] : null; + }; + + module.sortedSetRevRank = async function (key, value) { + const result = await getSortedSetRank('DESC', [key], [value]); + return result ? result[0] : null; + }; + + async function getSortedSetRank(sort, keys, values) { + values = values.map(helpers.valueToString); + const res = await module.pool.query({ + name: `getSortedSetRank${sort}`, + text: ` SELECT (SELECT r FROM (SELECT z."value" v, RANK() OVER (PARTITION BY o."_key" @@ -261,54 +257,54 @@ SELECT (SELECT r WHERE v = kvi.v) r FROM UNNEST($1::TEXT[], $2::TEXT[]) WITH ORDINALITY kvi(k, v, i) ORDER BY kvi.i ASC`, - values: [keys, values], - }); + values: [keys, values], + }); - return res.rows.map(r => (r.r === null ? null : parseFloat(r.r))); - } + return res.rows.map(r => (r.r === null ? null : Number.parseFloat(r.r))); + } - module.sortedSetsRanks = async function (keys, values) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } + module.sortedSetsRanks = async function (keys, values) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } - return await getSortedSetRank('ASC', keys, values); - }; + return await getSortedSetRank('ASC', keys, values); + }; - module.sortedSetsRevRanks = async function (keys, values) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } + module.sortedSetsRevRanks = async function (keys, values) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } - return await getSortedSetRank('DESC', keys, values); - }; + return await getSortedSetRank('DESC', keys, values); + }; - module.sortedSetRanks = async function (key, values) { - if (!Array.isArray(values) || !values.length) { - return []; - } + module.sortedSetRanks = async function (key, values) { + if (!Array.isArray(values) || values.length === 0) { + return []; + } - return await getSortedSetRank('ASC', new Array(values.length).fill(key), values); - }; + return await getSortedSetRank('ASC', Array.from({length: values.length}).fill(key), values); + }; - module.sortedSetRevRanks = async function (key, values) { - if (!Array.isArray(values) || !values.length) { - return []; - } + module.sortedSetRevRanks = async function (key, values) { + if (!Array.isArray(values) || values.length === 0) { + return []; + } - return await getSortedSetRank('DESC', new Array(values.length).fill(key), values); - }; + return await getSortedSetRank('DESC', Array.from({length: values.length}).fill(key), values); + }; - module.sortedSetScore = async function (key, value) { - if (!key) { - return null; - } + module.sortedSetScore = async function (key, value) { + if (!key) { + return null; + } - value = helpers.valueToString(value); + value = helpers.valueToString(value); - const res = await module.pool.query({ - name: 'sortedSetScore', - text: ` + const res = await module.pool.query({ + name: 'sortedSetScore', + text: ` SELECT z."score" s FROM "legacy_object_live" o INNER JOIN "legacy_zset" z @@ -316,24 +312,25 @@ SELECT z."score" s AND o."type" = z."type" WHERE o."_key" = $1::TEXT AND z."value" = $2::TEXT`, - values: [key, value], - }); - if (res.rows.length) { - return parseFloat(res.rows[0].s); - } - return null; - }; - - module.sortedSetsScore = async function (keys, value) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } - - value = helpers.valueToString(value); - - const res = await module.pool.query({ - name: 'sortedSetsScore', - text: ` + values: [key, value], + }); + if (res.rows.length > 0) { + return Number.parseFloat(res.rows[0].s); + } + + return null; + }; + + module.sortedSetsScore = async function (keys, value) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } + + value = helpers.valueToString(value); + + const res = await module.pool.query({ + name: 'sortedSetsScore', + text: ` SELECT o."_key" k, z."score" s FROM "legacy_object_live" o @@ -342,27 +339,29 @@ SELECT o."_key" k, AND o."type" = z."type" WHERE o."_key" = ANY($1::TEXT[]) AND z."value" = $2::TEXT`, - values: [keys, value], - }); - - return keys.map((k) => { - const s = res.rows.find(r => r.k === k); - return s ? parseFloat(s.s) : null; - }); - }; - - module.sortedSetScores = async function (key, values) { - if (!key) { - return null; - } - if (!values.length) { - return []; - } - values = values.map(helpers.valueToString); - - const res = await module.pool.query({ - name: 'sortedSetScores', - text: ` + values: [keys, value], + }); + + return keys.map(k => { + const s = res.rows.find(r => r.k === k); + return s ? Number.parseFloat(s.s) : null; + }); + }; + + module.sortedSetScores = async function (key, values) { + if (!key) { + return null; + } + + if (values.length === 0) { + return []; + } + + values = values.map(helpers.valueToString); + + const res = await module.pool.query({ + name: 'sortedSetScores', + text: ` SELECT z."value" v, z."score" s FROM "legacy_object_live" o @@ -371,25 +370,25 @@ SELECT z."value" v, AND o."type" = z."type" WHERE o."_key" = $1::TEXT AND z."value" = ANY($2::TEXT[])`, - values: [key, values], - }); + values: [key, values], + }); - return values.map((v) => { - const s = res.rows.find(r => r.v === v); - return s ? parseFloat(s.s) : null; - }); - }; + return values.map(v => { + const s = res.rows.find(r => r.v === v); + return s ? Number.parseFloat(s.s) : null; + }); + }; - module.isSortedSetMember = async function (key, value) { - if (!key) { - return; - } + module.isSortedSetMember = async function (key, value) { + if (!key) { + return; + } - value = helpers.valueToString(value); + value = helpers.valueToString(value); - const res = await module.pool.query({ - name: 'isSortedSetMember', - text: ` + const res = await module.pool.query({ + name: 'isSortedSetMember', + text: ` SELECT 1 FROM "legacy_object_live" o INNER JOIN "legacy_zset" z @@ -397,25 +396,26 @@ SELECT 1 AND o."type" = z."type" WHERE o."_key" = $1::TEXT AND z."value" = $2::TEXT`, - values: [key, value], - }); + values: [key, value], + }); - return !!res.rows.length; - }; + return res.rows.length > 0; + }; - module.isSortedSetMembers = async function (key, values) { - if (!key) { - return; - } + module.isSortedSetMembers = async function (key, values) { + if (!key) { + return; + } - if (!values.length) { - return []; - } - values = values.map(helpers.valueToString); + if (values.length === 0) { + return []; + } - const res = await module.pool.query({ - name: 'isSortedSetMembers', - text: ` + values = values.map(helpers.valueToString); + + const res = await module.pool.query({ + name: 'isSortedSetMembers', + text: ` SELECT z."value" v FROM "legacy_object_live" o INNER JOIN "legacy_zset" z @@ -423,22 +423,22 @@ SELECT z."value" v AND o."type" = z."type" WHERE o."_key" = $1::TEXT AND z."value" = ANY($2::TEXT[])`, - values: [key, values], - }); + values: [key, values], + }); - return values.map(v => res.rows.some(r => r.v === v)); - }; + return values.map(v => res.rows.some(r => r.v === v)); + }; - module.isMemberOfSortedSets = async function (keys, value) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } + module.isMemberOfSortedSets = async function (keys, value) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } - value = helpers.valueToString(value); + value = helpers.valueToString(value); - const res = await module.pool.query({ - name: 'isMemberOfSortedSets', - text: ` + const res = await module.pool.query({ + name: 'isMemberOfSortedSets', + text: ` SELECT o."_key" k FROM "legacy_object_live" o INNER JOIN "legacy_zset" z @@ -446,99 +446,98 @@ SELECT o."_key" k AND o."type" = z."type" WHERE o."_key" = ANY($1::TEXT[]) AND z."value" = $2::TEXT`, - values: [keys, value], - }); + values: [keys, value], + }); - return keys.map(k => res.rows.some(r => r.k === k)); - }; + return keys.map(k => res.rows.some(r => r.k === k)); + }; - module.getSortedSetMembers = async function (key) { - const data = await module.getSortedSetsMembers([key]); - return data && data[0]; - }; + module.getSortedSetMembers = async function (key) { + const data = await module.getSortedSetsMembers([key]); + return data && data[0]; + }; - module.getSortedSetsMembers = async function (keys) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } + module.getSortedSetsMembers = async function (keys) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } - const res = await module.pool.query({ - name: 'getSortedSetsMembers', - text: ` + const res = await module.pool.query({ + name: 'getSortedSetsMembers', + text: ` SELECT "_key" k, "nodebb_get_sorted_set_members"("_key") m FROM UNNEST($1::TEXT[]) "_key";`, - values: [keys], - }); + values: [keys], + }); - return keys.map(k => (res.rows.find(r => r.k === k) || {}).m || []); - }; + return keys.map(k => (res.rows.find(r => r.k === k) || {}).m || []); + }; - module.sortedSetIncrBy = async function (key, increment, value) { - if (!key) { - return; - } + module.sortedSetIncrBy = async function (key, increment, value) { + if (!key) { + return; + } - value = helpers.valueToString(value); - increment = parseFloat(increment); + value = helpers.valueToString(value); + increment = Number.parseFloat(increment); - return await module.transaction(async (client) => { - await helpers.ensureLegacyObjectType(client, key, 'zset'); - const res = await client.query({ - name: 'sortedSetIncrBy', - text: ` + return await module.transaction(async client => { + await helpers.ensureLegacyObjectType(client, key, 'zset'); + const res = await client.query({ + name: 'sortedSetIncrBy', + text: ` INSERT INTO "legacy_zset" ("_key", "value", "score") VALUES ($1::TEXT, $2::TEXT, $3::NUMERIC) ON CONFLICT ("_key", "value") DO UPDATE SET "score" = "legacy_zset"."score" + $3::NUMERIC RETURNING "score" s`, - values: [key, value, increment], - }); - return parseFloat(res.rows[0].s); - }); - }; - - module.sortedSetIncrByBulk = async function (data) { - // TODO: perf single query? - return await Promise.all(data.map(item => module.sortedSetIncrBy(item[0], item[1], item[2]))); - }; - - module.getSortedSetRangeByLex = async function (key, min, max, start, count) { - return await sortedSetLex(key, min, max, 1, start, count); - }; - - module.getSortedSetRevRangeByLex = async function (key, max, min, start, count) { - return await sortedSetLex(key, min, max, -1, start, count); - }; - - module.sortedSetLexCount = async function (key, min, max) { - const q = buildLexQuery(key, min, max); - - const res = await module.pool.query({ - name: `sortedSetLexCount${q.suffix}`, - text: ` + values: [key, value, increment], + }); + return Number.parseFloat(res.rows[0].s); + }); + }; + + module.sortedSetIncrByBulk = async function (data) { + // TODO: perf single query? + return await Promise.all(data.map(item => module.sortedSetIncrBy(item[0], item[1], item[2]))); + }; + + module.getSortedSetRangeByLex = async function (key, min, max, start, count) { + return await sortedSetLex(key, min, max, 1, start, count); + }; + + module.getSortedSetRevRangeByLex = async function (key, max, min, start, count) { + return await sortedSetLex(key, min, max, -1, start, count); + }; + + module.sortedSetLexCount = async function (key, min, max) { + const q = buildLexQuery(key, min, max); + + const res = await module.pool.query({ + name: `sortedSetLexCount${q.suffix}`, + text: ` SELECT COUNT(*) c FROM "legacy_object_live" o INNER JOIN "legacy_zset" z ON o."_key" = z."_key" AND o."type" = z."type" WHERE ${q.where}`, - values: q.values, - }); - - return parseInt(res.rows[0].c, 10); - }; - - async function sortedSetLex(key, min, max, sort, start, count) { - start = start !== undefined ? start : 0; - count = count !== undefined ? count : 0; - - const q = buildLexQuery(key, min, max); - q.values.push(start); - q.values.push(count <= 0 ? null : count); - const res = await module.pool.query({ - name: `sortedSetLex${sort > 0 ? 'Asc' : 'Desc'}${q.suffix}`, - text: ` + values: q.values, + }); + + return Number.parseInt(res.rows[0].c, 10); + }; + + async function sortedSetLex(key, min, max, sort, start, count) { + start = start === undefined ? 0 : start; + count = count === undefined ? 0 : count; + + const q = buildLexQuery(key, min, max); + q.values.push(start, count <= 0 ? null : count); + const res = await module.pool.query({ + name: `sortedSetLex${sort > 0 ? 'Asc' : 'Desc'}${q.suffix}`, + text: ` SELECT z."value" v FROM "legacy_object_live" o INNER JOIN "legacy_zset" z @@ -548,80 +547,80 @@ SELECT z."value" v ORDER BY z."value" ${sort > 0 ? 'ASC' : 'DESC'} LIMIT $${q.values.length}::INTEGER OFFSET $${q.values.length - 1}::INTEGER`, - values: q.values, - }); + values: q.values, + }); - return res.rows.map(r => r.v); - } + return res.rows.map(r => r.v); + } - module.sortedSetRemoveRangeByLex = async function (key, min, max) { - const q = buildLexQuery(key, min, max); - await module.pool.query({ - name: `sortedSetRemoveRangeByLex${q.suffix}`, - text: ` + module.sortedSetRemoveRangeByLex = async function (key, min, max) { + const q = buildLexQuery(key, min, max); + await module.pool.query({ + name: `sortedSetRemoveRangeByLex${q.suffix}`, + text: ` DELETE FROM "legacy_zset" z USING "legacy_object_live" o WHERE o."_key" = z."_key" AND o."type" = z."type" AND ${q.where}`, - values: q.values, - }); - }; - - function buildLexQuery(key, min, max) { - const q = { - suffix: '', - where: `o."_key" = $1::TEXT`, - values: [key], - }; - - if (min !== '-') { - if (min.match(/^\(/)) { - q.values.push(min.slice(1)); - q.suffix += 'GT'; - q.where += ` AND z."value" > $${q.values.length}::TEXT COLLATE "C"`; - } else if (min.match(/^\[/)) { - q.values.push(min.slice(1)); - q.suffix += 'GE'; - q.where += ` AND z."value" >= $${q.values.length}::TEXT COLLATE "C"`; - } else { - q.values.push(min); - q.suffix += 'GE'; - q.where += ` AND z."value" >= $${q.values.length}::TEXT COLLATE "C"`; - } - } - - if (max !== '+') { - if (max.match(/^\(/)) { - q.values.push(max.slice(1)); - q.suffix += 'LT'; - q.where += ` AND z."value" < $${q.values.length}::TEXT COLLATE "C"`; - } else if (max.match(/^\[/)) { - q.values.push(max.slice(1)); - q.suffix += 'LE'; - q.where += ` AND z."value" <= $${q.values.length}::TEXT COLLATE "C"`; - } else { - q.values.push(max); - q.suffix += 'LE'; - q.where += ` AND z."value" <= $${q.values.length}::TEXT COLLATE "C"`; - } - } - - return q; - } - - module.getSortedSetScan = async function (params) { - let { match } = params; - if (match.startsWith('*')) { - match = `%${match.substring(1)}`; - } - - if (match.endsWith('*')) { - match = `${match.substring(0, match.length - 1)}%`; - } - - const res = await module.pool.query({ - text: ` + values: q.values, + }); + }; + + function buildLexQuery(key, min, max) { + const q = { + suffix: '', + where: 'o."_key" = $1::TEXT', + values: [key], + }; + + if (min !== '-') { + if (/^\(/.test(min)) { + q.values.push(min.slice(1)); + q.suffix += 'GT'; + q.where += ` AND z."value" > $${q.values.length}::TEXT COLLATE "C"`; + } else if (/^\[/.test(min)) { + q.values.push(min.slice(1)); + q.suffix += 'GE'; + q.where += ` AND z."value" >= $${q.values.length}::TEXT COLLATE "C"`; + } else { + q.values.push(min); + q.suffix += 'GE'; + q.where += ` AND z."value" >= $${q.values.length}::TEXT COLLATE "C"`; + } + } + + if (max !== '+') { + if (/^\(/.test(max)) { + q.values.push(max.slice(1)); + q.suffix += 'LT'; + q.where += ` AND z."value" < $${q.values.length}::TEXT COLLATE "C"`; + } else if (/^\[/.test(max)) { + q.values.push(max.slice(1)); + q.suffix += 'LE'; + q.where += ` AND z."value" <= $${q.values.length}::TEXT COLLATE "C"`; + } else { + q.values.push(max); + q.suffix += 'LE'; + q.where += ` AND z."value" <= $${q.values.length}::TEXT COLLATE "C"`; + } + } + + return q; + } + + module.getSortedSetScan = async function (parameters) { + let {match} = parameters; + if (match.startsWith('*')) { + match = `%${match.slice(1)}`; + } + + if (match.endsWith('*')) { + match = `${match.slice(0, Math.max(0, match.length - 1))}%`; + } + + const res = await module.pool.query({ + text: ` SELECT z."value", z."score" FROM "legacy_object_live" o @@ -631,18 +630,19 @@ SELECT z."value", WHERE o."_key" = $1::TEXT AND z."value" LIKE '${match}' LIMIT $2::INTEGER`, - values: [params.key, params.limit], - }); - if (!params.withScores) { - return res.rows.map(r => r.value); - } - return res.rows.map(r => ({ value: r.value, score: parseFloat(r.score) })); - }; - - module.processSortedSet = async function (setKey, process, options) { - const client = await module.pool.connect(); - const batchSize = (options || {}).batch || 100; - const cursor = client.query(new Cursor(` + values: [parameters.key, parameters.limit], + }); + if (!parameters.withScores) { + return res.rows.map(r => r.value); + } + + return res.rows.map(r => ({value: r.value, score: Number.parseFloat(r.score)})); + }; + + module.processSortedSet = async function (setKey, process, options) { + const client = await module.pool.connect(); + const batchSize = (options || {}).batch || 100; + const cursor = client.query(new Cursor(` SELECT z."value", z."score" FROM "legacy_object_live" o INNER JOIN "legacy_zset" z @@ -651,32 +651,30 @@ SELECT z."value", z."score" WHERE o."_key" = $1::TEXT ORDER BY z."score" ASC, z."value" ASC`, [setKey])); - if (process && process.constructor && process.constructor.name !== 'AsyncFunction') { - process = util.promisify(process); - } - - while (true) { - /* eslint-disable no-await-in-loop */ - let rows = await cursor.readAsync(batchSize); - if (!rows.length) { - client.release(); - return; - } - - if (options.withScores) { - rows = rows.map(r => ({ value: r.value, score: parseFloat(r.score) })); - } else { - rows = rows.map(r => r.value); - } - try { - await process(rows); - } catch (err) { - await client.release(); - throw err; - } - if (options.interval) { - await sleep(options.interval); - } - } - }; + if (process && process.constructor && process.constructor.name !== 'AsyncFunction') { + process = util.promisify(process); + } + + while (true) { + /* eslint-disable no-await-in-loop */ + let rows = await cursor.readAsync(batchSize); + if (rows.length === 0) { + client.release(); + return; + } + + rows = options.withScores ? rows.map(r => ({value: r.value, score: Number.parseFloat(r.score)})) : rows.map(r => r.value); + + try { + await process(rows); + } catch (error) { + await client.release(); + throw error; + } + + if (options.interval) { + await sleep(options.interval); + } + } + }; }; diff --git a/src/database/postgres/sorted/add.js b/src/database/postgres/sorted/add.js index 6271ff1..434f5c5 100644 --- a/src/database/postgres/sorted/add.js +++ b/src/database/postgres/sorted/add.js @@ -1,91 +1,97 @@ 'use strict'; module.exports = function (module) { - const helpers = require('../helpers'); - const utils = require('../../../utils'); - - module.sortedSetAdd = async function (key, score, value) { - if (!key) { - return; - } - - if (Array.isArray(score) && Array.isArray(value)) { - return await sortedSetAddBulk(key, score, value); - } - if (!utils.isNumber(score)) { - throw new Error(`[[error:invalid-score, ${score}]]`); - } - value = helpers.valueToString(value); - score = parseFloat(score); - - await module.transaction(async (client) => { - await helpers.ensureLegacyObjectType(client, key, 'zset'); - await client.query({ - name: 'sortedSetAdd', - text: ` + const helpers = require('../helpers'); + const utils = require('../../../utils'); + + module.sortedSetAdd = async function (key, score, value) { + if (!key) { + return; + } + + if (Array.isArray(score) && Array.isArray(value)) { + return await sortedSetAddBulk(key, score, value); + } + + if (!utils.isNumber(score)) { + throw new TypeError(`[[error:invalid-score, ${score}]]`); + } + + value = helpers.valueToString(value); + score = Number.parseFloat(score); + + await module.transaction(async client => { + await helpers.ensureLegacyObjectType(client, key, 'zset'); + await client.query({ + name: 'sortedSetAdd', + text: ` INSERT INTO "legacy_zset" ("_key", "value", "score") VALUES ($1::TEXT, $2::TEXT, $3::NUMERIC) ON CONFLICT ("_key", "value") DO UPDATE SET "score" = $3::NUMERIC`, - values: [key, value, score], - }); - }); - }; - - async function sortedSetAddBulk(key, scores, values) { - if (!scores.length || !values.length) { - return; - } - if (scores.length !== values.length) { - throw new Error('[[error:invalid-data]]'); - } - for (let i = 0; i < scores.length; i += 1) { - if (!utils.isNumber(scores[i])) { - throw new Error(`[[error:invalid-score, ${scores[i]}]]`); - } - } - values = values.map(helpers.valueToString); - scores = scores.map(score => parseFloat(score)); - - helpers.removeDuplicateValues(values, scores); - - await module.transaction(async (client) => { - await helpers.ensureLegacyObjectType(client, key, 'zset'); - await client.query({ - name: 'sortedSetAddBulk', - text: ` + values: [key, value, score], + }); + }); + }; + + async function sortedSetAddBulk(key, scores, values) { + if (scores.length === 0 || values.length === 0) { + return; + } + + if (scores.length !== values.length) { + throw new Error('[[error:invalid-data]]'); + } + + for (const score of scores) { + if (!utils.isNumber(score)) { + throw new TypeError(`[[error:invalid-score, ${score}]]`); + } + } + + values = values.map(helpers.valueToString); + scores = scores.map(score => Number.parseFloat(score)); + + helpers.removeDuplicateValues(values, scores); + + await module.transaction(async client => { + await helpers.ensureLegacyObjectType(client, key, 'zset'); + await client.query({ + name: 'sortedSetAddBulk', + text: ` INSERT INTO "legacy_zset" ("_key", "value", "score") SELECT $1::TEXT, v, s FROM UNNEST($2::TEXT[], $3::NUMERIC[]) vs(v, s) ON CONFLICT ("_key", "value") DO UPDATE SET "score" = EXCLUDED."score"`, - values: [key, values, scores], - }); - }); - } - - module.sortedSetsAdd = async function (keys, scores, value) { - if (!Array.isArray(keys) || !keys.length) { - return; - } - const isArrayOfScores = Array.isArray(scores); - if ((!isArrayOfScores && !utils.isNumber(scores)) || - (isArrayOfScores && scores.map(s => utils.isNumber(s)).includes(false))) { - throw new Error(`[[error:invalid-score, ${scores}]]`); - } - - if (isArrayOfScores && scores.length !== keys.length) { - throw new Error('[[error:invalid-data]]'); - } - - value = helpers.valueToString(value); - scores = isArrayOfScores ? scores.map(score => parseFloat(score)) : parseFloat(scores); - - await module.transaction(async (client) => { - await helpers.ensureLegacyObjectsType(client, keys, 'zset'); - await client.query({ - name: isArrayOfScores ? 'sortedSetsAddScores' : 'sortedSetsAdd', - text: isArrayOfScores ? ` + values: [key, values, scores], + }); + }); + } + + module.sortedSetsAdd = async function (keys, scores, value) { + if (!Array.isArray(keys) || keys.length === 0) { + return; + } + + const isArrayOfScores = Array.isArray(scores); + if ((!isArrayOfScores && !utils.isNumber(scores)) + || (isArrayOfScores && scores.map(s => utils.isNumber(s)).includes(false))) { + throw new Error(`[[error:invalid-score, ${scores}]]`); + } + + if (isArrayOfScores && scores.length !== keys.length) { + throw new Error('[[error:invalid-data]]'); + } + + value = helpers.valueToString(value); + scores = isArrayOfScores ? scores.map(score => Number.parseFloat(score)) : Number.parseFloat(scores); + + await module.transaction(async client => { + await helpers.ensureLegacyObjectsType(client, keys, 'zset'); + await client.query({ + name: isArrayOfScores ? 'sortedSetsAddScores' : 'sortedSetsAdd', + text: isArrayOfScores ? ` INSERT INTO "legacy_zset" ("_key", "value", "score") SELECT k, $2::TEXT, s FROM UNNEST($1::TEXT[], $3::NUMERIC[]) vs(k, s) @@ -96,38 +102,41 @@ INSERT INTO "legacy_zset" ("_key", "value", "score") FROM UNNEST($1::TEXT[]) k ON CONFLICT ("_key", "value") DO UPDATE SET "score" = $3::NUMERIC`, - values: [keys, value, scores], - }); - }); - }; - - module.sortedSetAddBulk = async function (data) { - if (!Array.isArray(data) || !data.length) { - return; - } - const keys = []; - const values = []; - const scores = []; - data.forEach((item) => { - if (!utils.isNumber(item[1])) { - throw new Error(`[[error:invalid-score, ${item[1]}]]`); - } - keys.push(item[0]); - scores.push(item[1]); - values.push(item[2]); - }); - await module.transaction(async (client) => { - await helpers.ensureLegacyObjectsType(client, keys, 'zset'); - await client.query({ - name: 'sortedSetAddBulk2', - text: ` + values: [keys, value, scores], + }); + }); + }; + + module.sortedSetAddBulk = async function (data) { + if (!Array.isArray(data) || data.length === 0) { + return; + } + + const keys = []; + const values = []; + const scores = []; + for (const item of data) { + if (!utils.isNumber(item[1])) { + throw new TypeError(`[[error:invalid-score, ${item[1]}]]`); + } + + keys.push(item[0]); + scores.push(item[1]); + values.push(item[2]); + } + + await module.transaction(async client => { + await helpers.ensureLegacyObjectsType(client, keys, 'zset'); + await client.query({ + name: 'sortedSetAddBulk2', + text: ` INSERT INTO "legacy_zset" ("_key", "value", "score") SELECT k, v, s FROM UNNEST($1::TEXT[], $2::TEXT[], $3::NUMERIC[]) vs(k, v, s) ON CONFLICT ("_key", "value") DO UPDATE SET "score" = EXCLUDED."score"`, - values: [keys, values, scores], - }); - }); - }; + values: [keys, values, scores], + }); + }); + }; }; diff --git a/src/database/postgres/sorted/intersect.js b/src/database/postgres/sorted/intersect.js index 934bcd1..3c82209 100644 --- a/src/database/postgres/sorted/intersect.js +++ b/src/database/postgres/sorted/intersect.js @@ -1,14 +1,14 @@ 'use strict'; module.exports = function (module) { - module.sortedSetIntersectCard = async function (keys) { - if (!Array.isArray(keys) || !keys.length) { - return 0; - } + module.sortedSetIntersectCard = async function (keys) { + if (!Array.isArray(keys) || keys.length === 0) { + return 0; + } - const res = await module.pool.query({ - name: 'sortedSetIntersectCard', - text: ` + const res = await module.pool.query({ + name: 'sortedSetIntersectCard', + text: ` WITH A AS (SELECT z."value" v, COUNT(*) c FROM "legacy_object_live" o @@ -20,44 +20,45 @@ WITH A AS (SELECT z."value" v, SELECT COUNT(*) c FROM A WHERE A.c = array_length($1::TEXT[], 1)`, - values: [keys], - }); + values: [keys], + }); - return parseInt(res.rows[0].c, 10); - }; + return Number.parseInt(res.rows[0].c, 10); + }; - module.getSortedSetIntersect = async function (params) { - params.sort = 1; - return await getSortedSetIntersect(params); - }; + module.getSortedSetIntersect = async function (parameters) { + parameters.sort = 1; + return await getSortedSetIntersect(parameters); + }; - module.getSortedSetRevIntersect = async function (params) { - params.sort = -1; - return await getSortedSetIntersect(params); - }; + module.getSortedSetRevIntersect = async function (parameters) { + parameters.sort = -1; + return await getSortedSetIntersect(parameters); + }; - async function getSortedSetIntersect(params) { - const { sets } = params; - const start = params.hasOwnProperty('start') ? params.start : 0; - const stop = params.hasOwnProperty('stop') ? params.stop : -1; - let weights = params.weights || []; - const aggregate = params.aggregate || 'SUM'; + async function getSortedSetIntersect(parameters) { + const {sets} = parameters; + const start = parameters.hasOwnProperty('start') ? parameters.start : 0; + const stop = parameters.hasOwnProperty('stop') ? parameters.stop : -1; + let weights = parameters.weights || []; + const aggregate = parameters.aggregate || 'SUM'; - if (sets.length < weights.length) { - weights = weights.slice(0, sets.length); - } - while (sets.length > weights.length) { - weights.push(1); - } + if (sets.length < weights.length) { + weights = weights.slice(0, sets.length); + } - let limit = stop - start + 1; - if (limit <= 0) { - limit = null; - } + while (sets.length > weights.length) { + weights.push(1); + } - const res = await module.pool.query({ - name: `getSortedSetIntersect${aggregate}${params.sort > 0 ? 'Asc' : 'Desc'}WithScores`, - text: ` + let limit = stop - start + 1; + if (limit <= 0) { + limit = null; + } + + const res = await module.pool.query({ + name: `getSortedSetIntersect${aggregate}${parameters.sort > 0 ? 'Asc' : 'Desc'}WithScores`, + text: ` WITH A AS (SELECT z."value", ${aggregate}(z."score" * k."weight") "score", COUNT(*) c @@ -72,21 +73,21 @@ SELECT A."value", A."score" FROM A WHERE c = array_length($1::TEXT[], 1) - ORDER BY A."score" ${params.sort > 0 ? 'ASC' : 'DESC'} + ORDER BY A."score" ${parameters.sort > 0 ? 'ASC' : 'DESC'} LIMIT $4::INTEGER OFFSET $3::INTEGER`, - values: [sets, weights, start, limit], - }); + values: [sets, weights, start, limit], + }); - if (params.withScores) { - res.rows = res.rows.map(r => ({ - value: r.value, - score: parseFloat(r.score), - })); - } else { - res.rows = res.rows.map(r => r.value); - } + if (parameters.withScores) { + res.rows = res.rows.map(r => ({ + value: r.value, + score: Number.parseFloat(r.score), + })); + } else { + res.rows = res.rows.map(r => r.value); + } - return res.rows; - } + return res.rows; + } }; diff --git a/src/database/postgres/sorted/remove.js b/src/database/postgres/sorted/remove.js index eb9baa9..8bd3308 100644 --- a/src/database/postgres/sorted/remove.js +++ b/src/database/postgres/sorted/remove.js @@ -1,91 +1,95 @@ 'use strict'; module.exports = function (module) { - const helpers = require('../helpers'); - - module.sortedSetRemove = async function (key, value) { - if (!key) { - return; - } - const isValueArray = Array.isArray(value); - if (!value || (isValueArray && !value.length)) { - return; - } - - if (!Array.isArray(key)) { - key = [key]; - } - - if (!isValueArray) { - value = [value]; - } - value = value.map(helpers.valueToString); - await module.pool.query({ - name: 'sortedSetRemove', - text: ` + const helpers = require('../helpers'); + + module.sortedSetRemove = async function (key, value) { + if (!key) { + return; + } + + const isValueArray = Array.isArray(value); + if (!value || (isValueArray && value.length === 0)) { + return; + } + + if (!Array.isArray(key)) { + key = [key]; + } + + if (!isValueArray) { + value = [value]; + } + + value = value.map(helpers.valueToString); + await module.pool.query({ + name: 'sortedSetRemove', + text: ` DELETE FROM "legacy_zset" WHERE "_key" = ANY($1::TEXT[]) AND "value" = ANY($2::TEXT[])`, - values: [key, value], - }); - }; + values: [key, value], + }); + }; - module.sortedSetsRemove = async function (keys, value) { - if (!Array.isArray(keys) || !keys.length) { - return; - } + module.sortedSetsRemove = async function (keys, value) { + if (!Array.isArray(keys) || keys.length === 0) { + return; + } - value = helpers.valueToString(value); + value = helpers.valueToString(value); - await module.pool.query({ - name: 'sortedSetsRemove', - text: ` + await module.pool.query({ + name: 'sortedSetsRemove', + text: ` DELETE FROM "legacy_zset" WHERE "_key" = ANY($1::TEXT[]) AND "value" = $2::TEXT`, - values: [keys, value], - }); - }; - - module.sortedSetsRemoveRangeByScore = async function (keys, min, max) { - if (!Array.isArray(keys) || !keys.length) { - return; - } - - if (min === '-inf') { - min = null; - } - if (max === '+inf') { - max = null; - } - - await module.pool.query({ - name: 'sortedSetsRemoveRangeByScore', - text: ` + values: [keys, value], + }); + }; + + module.sortedSetsRemoveRangeByScore = async function (keys, min, max) { + if (!Array.isArray(keys) || keys.length === 0) { + return; + } + + if (min === '-inf') { + min = null; + } + + if (max === '+inf') { + max = null; + } + + await module.pool.query({ + name: 'sortedSetsRemoveRangeByScore', + text: ` DELETE FROM "legacy_zset" WHERE "_key" = ANY($1::TEXT[]) AND ("score" >= $2::NUMERIC OR $2::NUMERIC IS NULL) AND ("score" <= $3::NUMERIC OR $3::NUMERIC IS NULL)`, - values: [keys, min, max], - }); - }; - - module.sortedSetRemoveBulk = async function (data) { - if (!Array.isArray(data) || !data.length) { - return; - } - const keys = data.map(d => d[0]); - const values = data.map(d => d[1]); - - await module.pool.query({ - name: 'sortedSetRemoveBulk', - text: ` + values: [keys, min, max], + }); + }; + + module.sortedSetRemoveBulk = async function (data) { + if (!Array.isArray(data) || data.length === 0) { + return; + } + + const keys = data.map(d => d[0]); + const values = data.map(d => d[1]); + + await module.pool.query({ + name: 'sortedSetRemoveBulk', + text: ` DELETE FROM "legacy_zset" WHERE (_key, value) IN ( SELECT k, v FROM UNNEST($1::TEXT[], $2::TEXT[]) vs(k, v) )`, - values: [keys, values], - }); - }; + values: [keys, values], + }); + }; }; diff --git a/src/database/postgres/sorted/union.js b/src/database/postgres/sorted/union.js index 9277269..a90113f 100644 --- a/src/database/postgres/sorted/union.js +++ b/src/database/postgres/sorted/union.js @@ -1,57 +1,58 @@ 'use strict'; module.exports = function (module) { - module.sortedSetUnionCard = async function (keys) { - if (!Array.isArray(keys) || !keys.length) { - return 0; - } + module.sortedSetUnionCard = async function (keys) { + if (!Array.isArray(keys) || keys.length === 0) { + return 0; + } - const res = await module.pool.query({ - name: 'sortedSetUnionCard', - text: ` + const res = await module.pool.query({ + name: 'sortedSetUnionCard', + text: ` SELECT COUNT(DISTINCT z."value") c FROM "legacy_object_live" o INNER JOIN "legacy_zset" z ON o."_key" = z."_key" AND o."type" = z."type" WHERE o."_key" = ANY($1::TEXT[])`, - values: [keys], - }); - return res.rows[0].c; - }; + values: [keys], + }); + return res.rows[0].c; + }; - module.getSortedSetUnion = async function (params) { - params.sort = 1; - return await getSortedSetUnion(params); - }; + module.getSortedSetUnion = async function (parameters) { + parameters.sort = 1; + return await getSortedSetUnion(parameters); + }; - module.getSortedSetRevUnion = async function (params) { - params.sort = -1; - return await getSortedSetUnion(params); - }; + module.getSortedSetRevUnion = async function (parameters) { + parameters.sort = -1; + return await getSortedSetUnion(parameters); + }; - async function getSortedSetUnion(params) { - const { sets } = params; - const start = params.hasOwnProperty('start') ? params.start : 0; - const stop = params.hasOwnProperty('stop') ? params.stop : -1; - let weights = params.weights || []; - const aggregate = params.aggregate || 'SUM'; + async function getSortedSetUnion(parameters) { + const {sets} = parameters; + const start = parameters.hasOwnProperty('start') ? parameters.start : 0; + const stop = parameters.hasOwnProperty('stop') ? parameters.stop : -1; + let weights = parameters.weights || []; + const aggregate = parameters.aggregate || 'SUM'; - if (sets.length < weights.length) { - weights = weights.slice(0, sets.length); - } - while (sets.length > weights.length) { - weights.push(1); - } + if (sets.length < weights.length) { + weights = weights.slice(0, sets.length); + } - let limit = stop - start + 1; - if (limit <= 0) { - limit = null; - } + while (sets.length > weights.length) { + weights.push(1); + } - const res = await module.pool.query({ - name: `getSortedSetUnion${aggregate}${params.sort > 0 ? 'Asc' : 'Desc'}WithScores`, - text: ` + let limit = stop - start + 1; + if (limit <= 0) { + limit = null; + } + + const res = await module.pool.query({ + name: `getSortedSetUnion${aggregate}${parameters.sort > 0 ? 'Asc' : 'Desc'}WithScores`, + text: ` WITH A AS (SELECT z."value", ${aggregate}(z."score" * k."weight") "score" FROM UNNEST($1::TEXT[], $2::NUMERIC[]) k("_key", "weight") @@ -64,20 +65,21 @@ WITH A AS (SELECT z."value", SELECT A."value", A."score" FROM A - ORDER BY A."score" ${params.sort > 0 ? 'ASC' : 'DESC'} + ORDER BY A."score" ${parameters.sort > 0 ? 'ASC' : 'DESC'} LIMIT $4::INTEGER OFFSET $3::INTEGER`, - values: [sets, weights, start, limit], - }); + values: [sets, weights, start, limit], + }); + + if (parameters.withScores) { + res.rows = res.rows.map(r => ({ + value: r.value, + score: Number.parseFloat(r.score), + })); + } else { + res.rows = res.rows.map(r => r.value); + } - if (params.withScores) { - res.rows = res.rows.map(r => ({ - value: r.value, - score: parseFloat(r.score), - })); - } else { - res.rows = res.rows.map(r => r.value); - } - return res.rows; - } + return res.rows; + } }; diff --git a/src/database/postgres/transaction.js b/src/database/postgres/transaction.js index 1dcb521..f56e014 100644 --- a/src/database/postgres/transaction.js +++ b/src/database/postgres/transaction.js @@ -1,32 +1,35 @@ 'use strict'; module.exports = function (module) { - module.transaction = async function (perform, txClient) { - let res; - if (txClient) { - await txClient.query(`SAVEPOINT nodebb_subtx`); - try { - res = await perform(txClient); - } catch (err) { - await txClient.query(`ROLLBACK TO SAVEPOINT nodebb_subtx`); - throw err; - } - await txClient.query(`RELEASE SAVEPOINT nodebb_subtx`); - return res; - } - // see https://node-postgres.com/features/transactions#a-pooled-client-with-async-await - const client = await module.pool.connect(); + module.transaction = async function (perform, txClient) { + let res; + if (txClient) { + await txClient.query('SAVEPOINT nodebb_subtx'); + try { + res = await perform(txClient); + } catch (error) { + await txClient.query('ROLLBACK TO SAVEPOINT nodebb_subtx'); + throw error; + } - try { - await client.query('BEGIN'); - res = await perform(client); - await client.query('COMMIT'); - } catch (err) { - await client.query('ROLLBACK'); - throw err; - } finally { - client.release(); - } - return res; - }; + await txClient.query('RELEASE SAVEPOINT nodebb_subtx'); + return res; + } + + // See https://node-postgres.com/features/transactions#a-pooled-client-with-async-await + const client = await module.pool.connect(); + + try { + await client.query('BEGIN'); + res = await perform(client); + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + + return res; + }; }; diff --git a/src/database/redis.js b/src/database/redis.js index 8f74c08..e1ecc85 100644 --- a/src/database/redis.js +++ b/src/database/redis.js @@ -3,110 +3,110 @@ const nconf = require('nconf'); const semver = require('semver'); const session = require('express-session'); - const connection = require('./redis/connection'); const redisModule = module.exports; redisModule.questions = [ - { - name: 'redis:host', - description: 'Host IP or address of your Redis instance', - default: nconf.get('redis:host') || '127.0.0.1', - }, - { - name: 'redis:port', - description: 'Host port of your Redis instance', - default: nconf.get('redis:port') || 6379, - }, - { - name: 'redis:password', - description: 'Password of your Redis database', - hidden: true, - default: nconf.get('redis:password') || '', - before: function (value) { value = value || nconf.get('redis:password') || ''; return value; }, - }, - { - name: 'redis:database', - description: 'Which database to use (0..n)', - default: nconf.get('redis:database') || 0, - }, + { + name: 'redis:host', + description: 'Host IP or address of your Redis instance', + default: nconf.get('redis:host') || '127.0.0.1', + }, + { + name: 'redis:port', + description: 'Host port of your Redis instance', + default: nconf.get('redis:port') || 6379, + }, + { + name: 'redis:password', + description: 'Password of your Redis database', + hidden: true, + default: nconf.get('redis:password') || '', + before(value) { + value ||= nconf.get('redis:password') || ''; return value; + }, + }, + { + name: 'redis:database', + description: 'Which database to use (0..n)', + default: nconf.get('redis:database') || 0, + }, ]; - redisModule.init = async function () { - redisModule.client = await connection.connect(nconf.get('redis')); + redisModule.client = await connection.connect(nconf.get('redis')); }; redisModule.createSessionStore = async function (options) { - const meta = require('../meta'); - const sessionStore = require('connect-redis')(session); - const client = await connection.connect(options); - const store = new sessionStore({ - client: client, - ttl: meta.getSessionTTLSeconds(), - }); - return store; + const meta = require('../meta'); + const sessionStore = require('connect-redis')(session); + const client = await connection.connect(options); + const store = new sessionStore({ + client, + ttl: meta.getSessionTTLSeconds(), + }); + return store; }; redisModule.checkCompatibility = async function () { - const info = await redisModule.info(redisModule.client); - await redisModule.checkCompatibilityVersion(info.redis_version); + const info = await redisModule.info(redisModule.client); + await redisModule.checkCompatibilityVersion(info.redis_version); }; redisModule.checkCompatibilityVersion = function (version, callback) { - if (semver.lt(version, '2.8.9')) { - callback(new Error('Your Redis version is not new enough to support NodeBB, please upgrade Redis to v2.8.9 or higher.')); - } - callback(); + if (semver.lt(version, '2.8.9')) { + callback(new Error('Your Redis version is not new enough to support NodeBB, please upgrade Redis to v2.8.9 or higher.')); + } + + callback(); }; redisModule.close = async function () { - await redisModule.client.quit(); + await redisModule.client.quit(); }; redisModule.info = async function (cxn) { - if (!cxn) { - cxn = await connection.connect(nconf.get('redis')); - } - redisModule.client = redisModule.client || cxn; - const data = await cxn.info(); - const lines = data.toString().split('\r\n').sort(); - const redisData = {}; - lines.forEach((line) => { - const parts = line.split(':'); - if (parts[1]) { - redisData[parts[0]] = parts[1]; - } - }); - - const keyInfo = redisData[`db${nconf.get('redis:database')}`]; - if (keyInfo) { - const split = keyInfo.split(','); - redisData.keys = (split[0] || '').replace('keys=', ''); - redisData.expires = (split[1] || '').replace('expires=', ''); - redisData.avg_ttl = (split[2] || '').replace('avg_ttl=', ''); - } - - redisData.instantaneous_input = (redisData.instantaneous_input_kbps / 1024).toFixed(3); - redisData.instantaneous_output = (redisData.instantaneous_output_kbps / 1024).toFixed(3); - - redisData.total_net_input = (redisData.total_net_input_bytes / (1024 * 1024 * 1024)).toFixed(3); - redisData.total_net_output = (redisData.total_net_output_bytes / (1024 * 1024 * 1024)).toFixed(3); - - redisData.used_memory_human = (redisData.used_memory / (1024 * 1024 * 1024)).toFixed(3); - redisData.raw = JSON.stringify(redisData, null, 4); - redisData.redis = true; - return redisData; + cxn ||= await connection.connect(nconf.get('redis')); + + redisModule.client = redisModule.client || cxn; + const data = await cxn.info(); + const lines = data.toString().split('\r\n').sort(); + const redisData = {}; + for (const line of lines) { + const parts = line.split(':'); + if (parts[1]) { + redisData[parts[0]] = parts[1]; + } + } + + const keyInfo = redisData[`db${nconf.get('redis:database')}`]; + if (keyInfo) { + const split = keyInfo.split(','); + redisData.keys = (split[0] || '').replace('keys=', ''); + redisData.expires = (split[1] || '').replace('expires=', ''); + redisData.avg_ttl = (split[2] || '').replace('avg_ttl=', ''); + } + + redisData.instantaneous_input = (redisData.instantaneous_input_kbps / 1024).toFixed(3); + redisData.instantaneous_output = (redisData.instantaneous_output_kbps / 1024).toFixed(3); + + redisData.total_net_input = (redisData.total_net_input_bytes / (1024 * 1024 * 1024)).toFixed(3); + redisData.total_net_output = (redisData.total_net_output_bytes / (1024 * 1024 * 1024)).toFixed(3); + + redisData.used_memory_human = (redisData.used_memory / (1024 * 1024 * 1024)).toFixed(3); + redisData.raw = JSON.stringify(redisData, null, 4); + redisData.redis = true; + return redisData; }; redisModule.socketAdapter = async function () { - const redisAdapter = require('@socket.io/redis-adapter'); - const pub = await connection.connect(nconf.get('redis')); - const sub = await connection.connect(nconf.get('redis')); - return redisAdapter(pub, sub, { - key: `db:${nconf.get('redis:database')}:adapter_key`, - }); + const redisAdapter = require('@socket.io/redis-adapter'); + const pub = await connection.connect(nconf.get('redis')); + const sub = await connection.connect(nconf.get('redis')); + return redisAdapter(pub, sub, { + key: `db:${nconf.get('redis:database')}:adapter_key`, + }); }; require('./redis/main')(redisModule); diff --git a/src/database/redis/connection.js b/src/database/redis/connection.js index 8876fa4..bf3d99d 100644 --- a/src/database/redis/connection.js +++ b/src/database/redis/connection.js @@ -7,56 +7,56 @@ const winston = require('winston'); const connection = module.exports; connection.connect = async function (options) { - return new Promise((resolve, reject) => { - options = options || nconf.get('redis'); - const redis_socket_or_host = options.host; - - let cxn; - if (options.cluster) { - cxn = new Redis.Cluster(options.cluster, options.options); - } else if (options.sentinels) { - cxn = new Redis({ - sentinels: options.sentinels, - ...options.options, - }); - } else if (redis_socket_or_host && String(redis_socket_or_host).indexOf('/') >= 0) { - // If redis.host contains a path name character, use the unix dom sock connection. ie, /tmp/redis.sock - cxn = new Redis({ - ...options.options, - path: redis_socket_or_host, - password: options.password, - db: options.database, - }); - } else { - // Else, connect over tcp/ip - cxn = new Redis({ - ...options.options, - host: redis_socket_or_host, - port: options.port, - password: options.password, - db: options.database, - }); - } - - const dbIdx = parseInt(options.database, 10); - if (!(dbIdx >= 0)) { - throw new Error('[[error:no-database-selected]]'); - } - - cxn.on('error', (err) => { - winston.error(err.stack); - reject(err); - }); - cxn.on('ready', () => { - // back-compat with node_redis - cxn.batch = cxn.pipeline; - resolve(cxn); - }); - - if (options.password) { - cxn.auth(options.password); - } - }); + return new Promise((resolve, reject) => { + options ||= nconf.get('redis'); + const redis_socket_or_host = options.host; + + let cxn; + if (options.cluster) { + cxn = new Redis.Cluster(options.cluster, options.options); + } else if (options.sentinels) { + cxn = new Redis({ + sentinels: options.sentinels, + ...options.options, + }); + } else if (redis_socket_or_host && String(redis_socket_or_host).includes('/')) { + // If redis.host contains a path name character, use the unix dom sock connection. ie, /tmp/redis.sock + cxn = new Redis({ + ...options.options, + path: redis_socket_or_host, + password: options.password, + db: options.database, + }); + } else { + // Else, connect over tcp/ip + cxn = new Redis({ + ...options.options, + host: redis_socket_or_host, + port: options.port, + password: options.password, + db: options.database, + }); + } + + const databaseIndex = Number.parseInt(options.database, 10); + if (!(databaseIndex >= 0)) { + throw new Error('[[error:no-database-selected]]'); + } + + cxn.on('error', error => { + winston.error(error.stack); + reject(error); + }); + cxn.on('ready', () => { + // Back-compat with node_redis + cxn.batch = cxn.pipeline; + resolve(cxn); + }); + + if (options.password) { + cxn.auth(options.password); + } + }); }; require('../../promisify')(connection); diff --git a/src/database/redis/hash.js b/src/database/redis/hash.js index e82a7ba..e84ba90 100644 --- a/src/database/redis/hash.js +++ b/src/database/redis/hash.js @@ -1,237 +1,271 @@ 'use strict'; module.exports = function (module) { - const helpers = require('./helpers'); - - const cache = require('../cache').create('redis'); - - module.objectCache = cache; - - module.setObject = async function (key, data) { - if (!key || !data) { - return; - } - - if (data.hasOwnProperty('')) { - delete data['']; - } - - Object.keys(data).forEach((key) => { - if (data[key] === undefined || data[key] === null) { - delete data[key]; - } - }); - - if (!Object.keys(data).length) { - return; - } - if (Array.isArray(key)) { - const batch = module.client.batch(); - key.forEach(k => batch.hmset(k, data)); - await helpers.execBatch(batch); - } else { - await module.client.hmset(key, data); - } - - cache.del(key); - }; - - module.setObjectBulk = async function (...args) { - let data = args[0]; - if (!Array.isArray(data) || !data.length) { - return; - } - if (Array.isArray(args[1])) { - console.warn('[deprecated] db.setObjectBulk(keys, data) usage is deprecated, please use db.setObjectBulk(data)'); - // conver old format to new format for backwards compatibility - data = args[0].map((key, i) => [key, args[1][i]]); - } - - const batch = module.client.batch(); - data.forEach((item) => { - if (Object.keys(item[1]).length) { - batch.hmset(item[0], item[1]); - } - }); - await helpers.execBatch(batch); - cache.del(data.map(item => item[0])); - }; - - module.setObjectField = async function (key, field, value) { - if (!field) { - return; - } - if (Array.isArray(key)) { - const batch = module.client.batch(); - key.forEach(k => batch.hset(k, field, value)); - await helpers.execBatch(batch); - } else { - await module.client.hset(key, field, value); - } - - cache.del(key); - }; - - module.getObject = async function (key, fields = []) { - if (!key) { - return null; - } - - const data = await module.getObjectsFields([key], fields); - return data && data.length ? data[0] : null; - }; - - module.getObjects = async function (keys, fields = []) { - return await module.getObjectsFields(keys, fields); - }; - - module.getObjectField = async function (key, field) { - if (!key) { - return null; - } - const cachedData = {}; - cache.getUnCachedKeys([key], cachedData); - if (cachedData[key]) { - return cachedData[key].hasOwnProperty(field) ? cachedData[key][field] : null; - } - return await module.client.hget(key, String(field)); - }; - - module.getObjectFields = async function (key, fields) { - if (!key) { - return null; - } - const results = await module.getObjectsFields([key], fields); - return results ? results[0] : null; - }; - - module.getObjectsFields = async function (keys, fields) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } - - const cachedData = {}; - const unCachedKeys = cache.getUnCachedKeys(keys, cachedData); - - let data = []; - if (unCachedKeys.length > 1) { - const batch = module.client.batch(); - unCachedKeys.forEach(k => batch.hgetall(k)); - data = await helpers.execBatch(batch); - } else if (unCachedKeys.length === 1) { - data = [await module.client.hgetall(unCachedKeys[0])]; - } - - // convert empty objects into null for back-compat with node_redis - data = data.map((elem) => { - if (!Object.keys(elem).length) { - return null; - } - return elem; - }); - - unCachedKeys.forEach((key, i) => { - cachedData[key] = data[i] || null; - cache.set(key, cachedData[key]); - }); - - if (!Array.isArray(fields) || !fields.length) { - return keys.map(key => (cachedData[key] ? { ...cachedData[key] } : null)); - } - return keys.map((key) => { - const item = cachedData[key] || {}; - const result = {}; - fields.forEach((field) => { - result[field] = item[field] !== undefined ? item[field] : null; - }); - return result; - }); - }; - - module.getObjectKeys = async function (key) { - return await module.client.hkeys(key); - }; - - module.getObjectValues = async function (key) { - return await module.client.hvals(key); - }; - - module.isObjectField = async function (key, field) { - const exists = await module.client.hexists(key, field); - return exists === 1; - }; - - module.isObjectFields = async function (key, fields) { - const batch = module.client.batch(); - fields.forEach(f => batch.hexists(String(key), String(f))); - const results = await helpers.execBatch(batch); - return Array.isArray(results) ? helpers.resultsToBool(results) : null; - }; - - module.deleteObjectField = async function (key, field) { - if (key === undefined || key === null || field === undefined || field === null) { - return; - } - await module.client.hdel(key, field); - cache.del(key); - }; - - module.deleteObjectFields = async function (key, fields) { - if (!key || (Array.isArray(key) && !key.length) || !Array.isArray(fields) || !fields.length) { - return; - } - fields = fields.filter(Boolean); - if (!fields.length) { - return; - } - if (Array.isArray(key)) { - const batch = module.client.batch(); - key.forEach(k => batch.hdel(k, fields)); - await helpers.execBatch(batch); - } else { - await module.client.hdel(key, fields); - } - - cache.del(key); - }; - - module.incrObjectField = async function (key, field) { - return await module.incrObjectFieldBy(key, field, 1); - }; - - module.decrObjectField = async function (key, field) { - return await module.incrObjectFieldBy(key, field, -1); - }; - - module.incrObjectFieldBy = async function (key, field, value) { - value = parseInt(value, 10); - if (!key || isNaN(value)) { - return null; - } - let result; - if (Array.isArray(key)) { - const batch = module.client.batch(); - key.forEach(k => batch.hincrby(k, field, value)); - result = await helpers.execBatch(batch); - } else { - result = await module.client.hincrby(key, field, value); - } - cache.del(key); - return Array.isArray(result) ? result.map(value => parseInt(value, 10)) : parseInt(result, 10); - }; - - module.incrObjectFieldByBulk = async function (data) { - if (!Array.isArray(data) || !data.length) { - return; - } - - const batch = module.client.batch(); - data.forEach((item) => { - for (const [field, value] of Object.entries(item[1])) { - batch.hincrby(item[0], field, value); - } - }); - await helpers.execBatch(batch); - cache.del(data.map(item => item[0])); - }; + const helpers = require('./helpers'); + + const cache = require('../cache').create('redis'); + + module.objectCache = cache; + + module.setObject = async function (key, data) { + if (!key || !data) { + return; + } + + if (data.hasOwnProperty('')) { + delete data['']; + } + + for (const key of Object.keys(data)) { + if (data[key] === undefined || data[key] === null) { + delete data[key]; + } + } + + if (Object.keys(data).length === 0) { + return; + } + + if (Array.isArray(key)) { + const batch = module.client.batch(); + for (const k of key) { + batch.hmset(k, data); + } + + await helpers.execBatch(batch); + } else { + await module.client.hmset(key, data); + } + + cache.del(key); + }; + + module.setObjectBulk = async function (...arguments_) { + let data = arguments_[0]; + if (!Array.isArray(data) || data.length === 0) { + return; + } + + if (Array.isArray(arguments_[1])) { + console.warn('[deprecated] db.setObjectBulk(keys, data) usage is deprecated, please use db.setObjectBulk(data)'); + // Conver old format to new format for backwards compatibility + data = arguments_[0].map((key, i) => [key, arguments_[1][i]]); + } + + const batch = module.client.batch(); + for (const item of data) { + if (Object.keys(item[1]).length > 0) { + batch.hmset(item[0], item[1]); + } + } + + await helpers.execBatch(batch); + cache.del(data.map(item => item[0])); + }; + + module.setObjectField = async function (key, field, value) { + if (!field) { + return; + } + + if (Array.isArray(key)) { + const batch = module.client.batch(); + for (const k of key) { + batch.hset(k, field, value); + } + + await helpers.execBatch(batch); + } else { + await module.client.hset(key, field, value); + } + + cache.del(key); + }; + + module.getObject = async function (key, fields = []) { + if (!key) { + return null; + } + + const data = await module.getObjectsFields([key], fields); + return data && data.length > 0 ? data[0] : null; + }; + + module.getObjects = async function (keys, fields = []) { + return await module.getObjectsFields(keys, fields); + }; + + module.getObjectField = async function (key, field) { + if (!key) { + return null; + } + + const cachedData = {}; + cache.getUnCachedKeys([key], cachedData); + if (cachedData[key]) { + return cachedData[key].hasOwnProperty(field) ? cachedData[key][field] : null; + } + + return await module.client.hget(key, String(field)); + }; + + module.getObjectFields = async function (key, fields) { + if (!key) { + return null; + } + + const results = await module.getObjectsFields([key], fields); + return results ? results[0] : null; + }; + + module.getObjectsFields = async function (keys, fields) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } + + const cachedData = {}; + const unCachedKeys = cache.getUnCachedKeys(keys, cachedData); + + let data = []; + if (unCachedKeys.length > 1) { + const batch = module.client.batch(); + for (const k of unCachedKeys) { + batch.hgetall(k); + } + + data = await helpers.execBatch(batch); + } else if (unCachedKeys.length === 1) { + data = [await module.client.hgetall(unCachedKeys[0])]; + } + + // Convert empty objects into null for back-compat with node_redis + data = data.map(element => { + if (Object.keys(element).length === 0) { + return null; + } + + return element; + }); + + for (const [i, key] of unCachedKeys.entries()) { + cachedData[key] = data[i] || null; + cache.set(key, cachedData[key]); + } + + if (!Array.isArray(fields) || fields.length === 0) { + return keys.map(key => (cachedData[key] ? {...cachedData[key]} : null)); + } + + return keys.map(key => { + const item = cachedData[key] || {}; + const result = {}; + for (const field of fields) { + result[field] = item[field] === undefined ? null : item[field]; + } + + return result; + }); + }; + + module.getObjectKeys = async function (key) { + return await module.client.hkeys(key); + }; + + module.getObjectValues = async function (key) { + return await module.client.hvals(key); + }; + + module.isObjectField = async function (key, field) { + const exists = await module.client.hexists(key, field); + return exists === 1; + }; + + module.isObjectFields = async function (key, fields) { + const batch = module.client.batch(); + for (const f of fields) { + batch.hexists(String(key), String(f)); + } + + const results = await helpers.execBatch(batch); + return Array.isArray(results) ? helpers.resultsToBool(results) : null; + }; + + module.deleteObjectField = async function (key, field) { + if (key === undefined || key === null || field === undefined || field === null) { + return; + } + + await module.client.hdel(key, field); + cache.del(key); + }; + + module.deleteObjectFields = async function (key, fields) { + if (!key || (Array.isArray(key) && key.length === 0) || !Array.isArray(fields) || fields.length === 0) { + return; + } + + fields = fields.filter(Boolean); + if (fields.length === 0) { + return; + } + + if (Array.isArray(key)) { + const batch = module.client.batch(); + for (const k of key) { + batch.hdel(k, fields); + } + + await helpers.execBatch(batch); + } else { + await module.client.hdel(key, fields); + } + + cache.del(key); + }; + + module.incrObjectField = async function (key, field) { + return await module.incrObjectFieldBy(key, field, 1); + }; + + module.decrObjectField = async function (key, field) { + return await module.incrObjectFieldBy(key, field, -1); + }; + + module.incrObjectFieldBy = async function (key, field, value) { + value = Number.parseInt(value, 10); + if (!key || isNaN(value)) { + return null; + } + + let result; + if (Array.isArray(key)) { + const batch = module.client.batch(); + for (const k of key) { + batch.hincrby(k, field, value); + } + + result = await helpers.execBatch(batch); + } else { + result = await module.client.hincrby(key, field, value); + } + + cache.del(key); + return Array.isArray(result) ? result.map(value => Number.parseInt(value, 10)) : Number.parseInt(result, 10); + }; + + module.incrObjectFieldByBulk = async function (data) { + if (!Array.isArray(data) || data.length === 0) { + return; + } + + const batch = module.client.batch(); + for (const item of data) { + for (const [field, value] of Object.entries(item[1])) { + batch.hincrby(item[0], field, value); + } + } + + await helpers.execBatch(batch); + cache.del(data.map(item => item[0])); + }; }; diff --git a/src/database/redis/helpers.js b/src/database/redis/helpers.js index d9b8a68..4cab939 100644 --- a/src/database/redis/helpers.js +++ b/src/database/redis/helpers.js @@ -5,26 +5,29 @@ const helpers = module.exports; helpers.noop = function () {}; helpers.execBatch = async function (batch) { - const results = await batch.exec(); - return results.map(([err, res]) => { - if (err) { - throw err; - } - return res; - }); + const results = await batch.exec(); + return results.map(([error, res]) => { + if (error) { + throw error; + } + + return res; + }); }; helpers.resultsToBool = function (results) { - for (let i = 0; i < results.length; i += 1) { - results[i] = results[i] === 1; - } - return results; + for (let i = 0; i < results.length; i += 1) { + results[i] = results[i] === 1; + } + + return results; }; helpers.zsetToObjectArray = function (data) { - const objects = new Array(data.length / 2); - for (let i = 0, k = 0; i < objects.length; i += 1, k += 2) { - objects[i] = { value: data[k], score: parseFloat(data[k + 1]) }; - } - return objects; + const objects = Array.from({length: data.length / 2}); + for (let i = 0, k = 0; i < objects.length; i += 1, k += 2) { + objects[i] = {value: data[k], score: Number.parseFloat(data[k + 1])}; + } + + return objects; }; diff --git a/src/database/redis/list.js b/src/database/redis/list.js index 135be1c..2c6f971 100644 --- a/src/database/redis/list.js +++ b/src/database/redis/list.js @@ -1,57 +1,63 @@ 'use strict'; module.exports = function (module) { - const helpers = require('./helpers'); - - module.listPrepend = async function (key, value) { - if (!key) { - return; - } - await module.client.lpush(key, value); - }; - - module.listAppend = async function (key, value) { - if (!key) { - return; - } - await module.client.rpush(key, value); - }; - - module.listRemoveLast = async function (key) { - if (!key) { - return; - } - return await module.client.rpop(key); - }; - - module.listRemoveAll = async function (key, value) { - if (!key) { - return; - } - if (Array.isArray(value)) { - const batch = module.client.batch(); - value.forEach(value => batch.lrem(key, 0, value)); - await helpers.execBatch(batch); - } else { - await module.client.lrem(key, 0, value); - } - }; - - module.listTrim = async function (key, start, stop) { - if (!key) { - return; - } - await module.client.ltrim(key, start, stop); - }; - - module.getListRange = async function (key, start, stop) { - if (!key) { - return; - } - return await module.client.lrange(key, start, stop); - }; - - module.listLength = async function (key) { - return await module.client.llen(key); - }; + const helpers = require('./helpers'); + + module.listPrepend = async function (key, value) { + if (!key) { + return; + } + + await module.client.lpush(key, value); + }; + + module.listAppend = async function (key, value) { + if (!key) { + return; + } + + await module.client.rpush(key, value); + }; + + module.listRemoveLast = async function (key) { + if (!key) { + return; + } + + return await module.client.rpop(key); + }; + + module.listRemoveAll = async function (key, value) { + if (!key) { + return; + } + + if (Array.isArray(value)) { + const batch = module.client.batch(); + value.forEach(value => batch.lrem(key, 0, value)); + await helpers.execBatch(batch); + } else { + await module.client.lrem(key, 0, value); + } + }; + + module.listTrim = async function (key, start, stop) { + if (!key) { + return; + } + + await module.client.ltrim(key, start, stop); + }; + + module.getListRange = async function (key, start, stop) { + if (!key) { + return; + } + + return await module.client.lrange(key, start, stop); + }; + + module.listLength = async function (key) { + return await module.client.llen(key); + }; }; diff --git a/src/database/redis/main.js b/src/database/redis/main.js index 140bce5..d9a5a95 100644 --- a/src/database/redis/main.js +++ b/src/database/redis/main.js @@ -1,111 +1,115 @@ 'use strict'; module.exports = function (module) { - const helpers = require('./helpers'); - - module.flushdb = async function () { - await module.client.send_command('flushdb', []); - }; - - module.emptydb = async function () { - await module.flushdb(); - module.objectCache.reset(); - }; - - module.exists = async function (key) { - if (Array.isArray(key)) { - const batch = module.client.batch(); - key.forEach(key => batch.exists(key)); - const data = await helpers.execBatch(batch); - return data.map(exists => exists === 1); - } - const exists = await module.client.exists(key); - return exists === 1; - }; - - module.scan = async function (params) { - let cursor = '0'; - let returnData = []; - const seen = {}; - do { - /* eslint-disable no-await-in-loop */ - const res = await module.client.scan(cursor, 'MATCH', params.match, 'COUNT', 10000); - cursor = res[0]; - const values = res[1].filter((value) => { - const isSeen = !!seen[value]; - if (!isSeen) { - seen[value] = 1; - } - return !isSeen; - }); - returnData = returnData.concat(values); - } while (cursor !== '0'); - return returnData; - }; - - module.delete = async function (key) { - await module.client.del(key); - module.objectCache.del(key); - }; - - module.deleteAll = async function (keys) { - if (!Array.isArray(keys) || !keys.length) { - return; - } - await module.client.del(keys); - module.objectCache.del(keys); - }; - - module.get = async function (key) { - return await module.client.get(key); - }; - - module.set = async function (key, value) { - await module.client.set(key, value); - }; - - module.increment = async function (key) { - return await module.client.incr(key); - }; - - module.rename = async function (oldKey, newKey) { - try { - await module.client.rename(oldKey, newKey); - } catch (err) { - if (err && err.message !== 'ERR no such key') { - throw err; - } - } - - module.objectCache.del([oldKey, newKey]); - }; - - module.type = async function (key) { - const type = await module.client.type(key); - return type !== 'none' ? type : null; - }; - - module.expire = async function (key, seconds) { - await module.client.expire(key, seconds); - }; - - module.expireAt = async function (key, timestamp) { - await module.client.expireat(key, timestamp); - }; - - module.pexpire = async function (key, ms) { - await module.client.pexpire(key, ms); - }; - - module.pexpireAt = async function (key, timestamp) { - await module.client.pexpireat(key, timestamp); - }; - - module.ttl = async function (key) { - return await module.client.ttl(key); - }; - - module.pttl = async function (key) { - return await module.client.pttl(key); - }; + const helpers = require('./helpers'); + + module.flushdb = async function () { + await module.client.send_command('flushdb', []); + }; + + module.emptydb = async function () { + await module.flushdb(); + module.objectCache.reset(); + }; + + module.exists = async function (key) { + if (Array.isArray(key)) { + const batch = module.client.batch(); + key.forEach(key => batch.exists(key)); + const data = await helpers.execBatch(batch); + return data.map(exists => exists === 1); + } + + const exists = await module.client.exists(key); + return exists === 1; + }; + + module.scan = async function (parameters) { + let cursor = '0'; + let returnData = []; + const seen = {}; + do { + /* eslint-disable no-await-in-loop */ + const res = await module.client.scan(cursor, 'MATCH', parameters.match, 'COUNT', 10_000); + cursor = res[0]; + const values = res[1].filter(value => { + const isSeen = Boolean(seen[value]); + if (!isSeen) { + seen[value] = 1; + } + + return !isSeen; + }); + returnData = returnData.concat(values); + } while (cursor !== '0'); + + return returnData; + }; + + module.delete = async function (key) { + await module.client.del(key); + module.objectCache.del(key); + }; + + module.deleteAll = async function (keys) { + if (!Array.isArray(keys) || keys.length === 0) { + return; + } + + await module.client.del(keys); + module.objectCache.del(keys); + }; + + module.get = async function (key) { + return await module.client.get(key); + }; + + module.set = async function (key, value) { + await module.client.set(key, value); + }; + + module.increment = async function (key) { + return await module.client.incr(key); + }; + + module.rename = async function (oldKey, newKey) { + try { + await module.client.rename(oldKey, newKey); + } catch (error) { + if (error && error.message !== 'ERR no such key') { + throw error; + } + } + + module.objectCache.del([oldKey, newKey]); + }; + + module.type = async function (key) { + const type = await module.client.type(key); + return type === 'none' ? null : type; + }; + + module.expire = async function (key, seconds) { + await module.client.expire(key, seconds); + }; + + module.expireAt = async function (key, timestamp) { + await module.client.expireat(key, timestamp); + }; + + module.pexpire = async function (key, ms) { + await module.client.pexpire(key, ms); + }; + + module.pexpireAt = async function (key, timestamp) { + await module.client.pexpireat(key, timestamp); + }; + + module.ttl = async function (key) { + return await module.client.ttl(key); + }; + + module.pttl = async function (key) { + return await module.client.pttl(key); + }; }; diff --git a/src/database/redis/pubsub.js b/src/database/redis/pubsub.js index b39d24a..21a189a 100644 --- a/src/database/redis/pubsub.js +++ b/src/database/redis/pubsub.js @@ -1,49 +1,52 @@ 'use strict'; +const util = require('node:util'); +const {EventEmitter} = require('node:events'); const nconf = require('nconf'); -const util = require('util'); const winston = require('winston'); -const { EventEmitter } = require('events'); const connection = require('./connection'); let channelName; const PubSub = function () { - const self = this; - channelName = `db:${nconf.get('redis:database')}:pubsub_channel`; - self.queue = []; - connection.connect().then((client) => { - self.subClient = client; - self.subClient.subscribe(channelName); - self.subClient.on('message', (channel, message) => { - if (channel !== channelName) { - return; - } - - try { - const msg = JSON.parse(message); - self.emit(msg.event, msg.data); - } catch (err) { - winston.error(err.stack); - } - }); - }); - - connection.connect().then((client) => { - self.pubClient = client; - self.queue.forEach(payload => client.publish(channelName, payload)); - self.queue.length = 0; - }); + const self = this; + channelName = `db:${nconf.get('redis:database')}:pubsub_channel`; + self.queue = []; + connection.connect().then(client => { + self.subClient = client; + self.subClient.subscribe(channelName); + self.subClient.on('message', (channel, message) => { + if (channel !== channelName) { + return; + } + + try { + const message_ = JSON.parse(message); + self.emit(message_.event, message_.data); + } catch (error) { + winston.error(error.stack); + } + }); + }); + + connection.connect().then(client => { + self.pubClient = client; + for (const payload of self.queue) { + client.publish(channelName, payload); + } + + self.queue.length = 0; + }); }; util.inherits(PubSub, EventEmitter); PubSub.prototype.publish = function (event, data) { - const payload = JSON.stringify({ event: event, data: data }); - if (this.pubClient) { - this.pubClient.publish(channelName, payload); - } else { - this.queue.push(payload); - } + const payload = JSON.stringify({event, data}); + if (this.pubClient) { + this.pubClient.publish(channelName, payload); + } else { + this.queue.push(payload); + } }; module.exports = new PubSub(); diff --git a/src/database/redis/sets.js b/src/database/redis/sets.js index d0ea63c..76b791a 100644 --- a/src/database/redis/sets.js +++ b/src/database/redis/sets.js @@ -1,91 +1,117 @@ 'use strict'; module.exports = function (module) { - const helpers = require('./helpers'); - - module.setAdd = async function (key, value) { - if (!Array.isArray(value)) { - value = [value]; - } - if (!value.length) { - return; - } - await module.client.sadd(key, value); - }; - - module.setsAdd = async function (keys, value) { - if (!Array.isArray(keys) || !keys.length) { - return; - } - const batch = module.client.batch(); - keys.forEach(k => batch.sadd(String(k), String(value))); - await helpers.execBatch(batch); - }; - - module.setRemove = async function (key, value) { - if (!Array.isArray(value)) { - value = [value]; - } - if (!Array.isArray(key)) { - key = [key]; - } - if (!value.length) { - return; - } - - const batch = module.client.batch(); - key.forEach(k => batch.srem(String(k), value)); - await helpers.execBatch(batch); - }; - - module.setsRemove = async function (keys, value) { - const batch = module.client.batch(); - keys.forEach(k => batch.srem(String(k), value)); - await helpers.execBatch(batch); - }; - - module.isSetMember = async function (key, value) { - const result = await module.client.sismember(key, value); - return result === 1; - }; - - module.isSetMembers = async function (key, values) { - const batch = module.client.batch(); - values.forEach(v => batch.sismember(String(key), String(v))); - const results = await helpers.execBatch(batch); - return results ? helpers.resultsToBool(results) : null; - }; - - module.isMemberOfSets = async function (sets, value) { - const batch = module.client.batch(); - sets.forEach(s => batch.sismember(String(s), String(value))); - const results = await helpers.execBatch(batch); - return results ? helpers.resultsToBool(results) : null; - }; - - module.getSetMembers = async function (key) { - return await module.client.smembers(key); - }; - - module.getSetsMembers = async function (keys) { - const batch = module.client.batch(); - keys.forEach(k => batch.smembers(String(k))); - return await helpers.execBatch(batch); - }; - - module.setCount = async function (key) { - return await module.client.scard(key); - }; - - module.setsCount = async function (keys) { - const batch = module.client.batch(); - keys.forEach(k => batch.scard(String(k))); - return await helpers.execBatch(batch); - }; - - module.setRemoveRandom = async function (key) { - return await module.client.spop(key); - }; - - return module; + const helpers = require('./helpers'); + + module.setAdd = async function (key, value) { + if (!Array.isArray(value)) { + value = [value]; + } + + if (value.length === 0) { + return; + } + + await module.client.sadd(key, value); + }; + + module.setsAdd = async function (keys, value) { + if (!Array.isArray(keys) || keys.length === 0) { + return; + } + + const batch = module.client.batch(); + for (const k of keys) { + batch.sadd(String(k), String(value)); + } + + await helpers.execBatch(batch); + }; + + module.setRemove = async function (key, value) { + if (!Array.isArray(value)) { + value = [value]; + } + + if (!Array.isArray(key)) { + key = [key]; + } + + if (value.length === 0) { + return; + } + + const batch = module.client.batch(); + for (const k of key) { + batch.srem(String(k), value); + } + + await helpers.execBatch(batch); + }; + + module.setsRemove = async function (keys, value) { + const batch = module.client.batch(); + for (const k of keys) { + batch.srem(String(k), value); + } + + await helpers.execBatch(batch); + }; + + module.isSetMember = async function (key, value) { + const result = await module.client.sismember(key, value); + return result === 1; + }; + + module.isSetMembers = async function (key, values) { + const batch = module.client.batch(); + for (const v of values) { + batch.sismember(String(key), String(v)); + } + + const results = await helpers.execBatch(batch); + return results ? helpers.resultsToBool(results) : null; + }; + + module.isMemberOfSets = async function (sets, value) { + const batch = module.client.batch(); + for (const s of sets) { + batch.sismember(String(s), String(value)); + } + + const results = await helpers.execBatch(batch); + return results ? helpers.resultsToBool(results) : null; + }; + + module.getSetMembers = async function (key) { + return await module.client.smembers(key); + }; + + module.getSetsMembers = async function (keys) { + const batch = module.client.batch(); + for (const k of keys) { + batch.smembers(String(k)); + } + + return await helpers.execBatch(batch); + }; + + module.setCount = async function (key) { + return await module.client.scard(key); + }; + + module.setsCount = async function (keys) { + const batch = module.client.batch(); + for (const k of keys) { + batch.scard(String(k)); + } + + return await helpers.execBatch(batch); + }; + + module.setRemoveRandom = async function (key) { + return await module.client.spop(key); + }; + + return module; }; diff --git a/src/database/redis/sorted.js b/src/database/redis/sorted.js index a19fa9e..a3098fb 100644 --- a/src/database/redis/sorted.js +++ b/src/database/redis/sorted.js @@ -1,325 +1,366 @@ 'use strict'; module.exports = function (module) { - const utils = require('../../utils'); - const helpers = require('./helpers'); - const dbHelpers = require('../helpers'); - - require('./sorted/add')(module); - require('./sorted/remove')(module); - require('./sorted/union')(module); - require('./sorted/intersect')(module); - - module.getSortedSetRange = async function (key, start, stop) { - return await sortedSetRange('zrange', key, start, stop, '-inf', '+inf', false); - }; - - module.getSortedSetRevRange = async function (key, start, stop) { - return await sortedSetRange('zrevrange', key, start, stop, '-inf', '+inf', false); - }; - - module.getSortedSetRangeWithScores = async function (key, start, stop) { - return await sortedSetRange('zrange', key, start, stop, '-inf', '+inf', true); - }; - - module.getSortedSetRevRangeWithScores = async function (key, start, stop) { - return await sortedSetRange('zrevrange', key, start, stop, '-inf', '+inf', true); - }; - - async function sortedSetRange(method, key, start, stop, min, max, withScores) { - if (Array.isArray(key)) { - if (!key.length) { - return []; - } - const batch = module.client.batch(); - key.forEach(key => batch[method](genParams(method, key, 0, stop, min, max, true))); - const data = await helpers.execBatch(batch); - - const batchData = data.map(setData => helpers.zsetToObjectArray(setData)); - - let objects = dbHelpers.mergeBatch(batchData, 0, stop, method === 'zrange' ? 1 : -1); - - if (start > 0) { - objects = objects.slice(start, stop !== -1 ? stop + 1 : undefined); - } - if (!withScores) { - objects = objects.map(item => item.value); - } - return objects; - } - - const params = genParams(method, key, start, stop, min, max, withScores); - const data = await module.client[method](params); - if (!withScores) { - return data; - } - const objects = helpers.zsetToObjectArray(data); - return objects; - } - - function genParams(method, key, start, stop, min, max, withScores) { - const params = { - zrevrange: [key, start, stop], - zrange: [key, start, stop], - zrangebyscore: [key, min, max], - zrevrangebyscore: [key, max, min], - }; - if (withScores) { - params[method].push('WITHSCORES'); - } - - if (method === 'zrangebyscore' || method === 'zrevrangebyscore') { - const count = stop !== -1 ? stop - start + 1 : stop; - params[method].push('LIMIT', start, count); - } - return params[method]; - } - - module.getSortedSetRangeByScore = async function (key, start, count, min, max) { - return await sortedSetRangeByScore('zrangebyscore', key, start, count, min, max, false); - }; - - module.getSortedSetRevRangeByScore = async function (key, start, count, max, min) { - return await sortedSetRangeByScore('zrevrangebyscore', key, start, count, min, max, false); - }; - - module.getSortedSetRangeByScoreWithScores = async function (key, start, count, min, max) { - return await sortedSetRangeByScore('zrangebyscore', key, start, count, min, max, true); - }; - - module.getSortedSetRevRangeByScoreWithScores = async function (key, start, count, max, min) { - return await sortedSetRangeByScore('zrevrangebyscore', key, start, count, min, max, true); - }; - - async function sortedSetRangeByScore(method, key, start, count, min, max, withScores) { - if (parseInt(count, 10) === 0) { - return []; - } - const stop = (parseInt(count, 10) === -1) ? -1 : (start + count - 1); - return await sortedSetRange(method, key, start, stop, min, max, withScores); - } - - module.sortedSetCount = async function (key, min, max) { - return await module.client.zcount(key, min, max); - }; - - module.sortedSetCard = async function (key) { - return await module.client.zcard(key); - }; - - module.sortedSetsCard = async function (keys) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } - const batch = module.client.batch(); - keys.forEach(k => batch.zcard(String(k))); - return await helpers.execBatch(batch); - }; - - module.sortedSetsCardSum = async function (keys) { - if (!keys || (Array.isArray(keys) && !keys.length)) { - return 0; - } - if (!Array.isArray(keys)) { - keys = [keys]; - } - const counts = await module.sortedSetsCard(keys); - const sum = counts.reduce((acc, val) => acc + val, 0); - return sum; - }; - - module.sortedSetRank = async function (key, value) { - return await module.client.zrank(key, value); - }; - - module.sortedSetRevRank = async function (key, value) { - return await module.client.zrevrank(key, value); - }; - - module.sortedSetsRanks = async function (keys, values) { - const batch = module.client.batch(); - for (let i = 0; i < values.length; i += 1) { - batch.zrank(keys[i], String(values[i])); - } - return await helpers.execBatch(batch); - }; - - module.sortedSetsRevRanks = async function (keys, values) { - const batch = module.client.batch(); - for (let i = 0; i < values.length; i += 1) { - batch.zrevrank(keys[i], String(values[i])); - } - return await helpers.execBatch(batch); - }; - - module.sortedSetRanks = async function (key, values) { - const batch = module.client.batch(); - for (let i = 0; i < values.length; i += 1) { - batch.zrank(key, String(values[i])); - } - return await helpers.execBatch(batch); - }; - - module.sortedSetRevRanks = async function (key, values) { - const batch = module.client.batch(); - for (let i = 0; i < values.length; i += 1) { - batch.zrevrank(key, String(values[i])); - } - return await helpers.execBatch(batch); - }; - - module.sortedSetScore = async function (key, value) { - if (!key || value === undefined) { - return null; - } - - const score = await module.client.zscore(key, value); - return score === null ? score : parseFloat(score); - }; - - module.sortedSetsScore = async function (keys, value) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } - const batch = module.client.batch(); - keys.forEach(key => batch.zscore(String(key), String(value))); - const scores = await helpers.execBatch(batch); - return scores.map(d => (d === null ? d : parseFloat(d))); - }; - - module.sortedSetScores = async function (key, values) { - if (!values.length) { - return []; - } - const batch = module.client.batch(); - values.forEach(value => batch.zscore(String(key), String(value))); - const scores = await helpers.execBatch(batch); - return scores.map(d => (d === null ? d : parseFloat(d))); - }; - - module.isSortedSetMember = async function (key, value) { - const score = await module.sortedSetScore(key, value); - return utils.isNumber(score); - }; - - module.isSortedSetMembers = async function (key, values) { - if (!values.length) { - return []; - } - const batch = module.client.batch(); - values.forEach(v => batch.zscore(key, String(v))); - const results = await helpers.execBatch(batch); - return results.map(utils.isNumber); - }; - - module.isMemberOfSortedSets = async function (keys, value) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } - const batch = module.client.batch(); - keys.forEach(k => batch.zscore(k, String(value))); - const results = await helpers.execBatch(batch); - return results.map(utils.isNumber); - }; - - module.getSortedSetMembers = async function (key) { - return await module.client.zrange(key, 0, -1); - }; - - module.getSortedSetsMembers = async function (keys) { - if (!Array.isArray(keys) || !keys.length) { - return []; - } - const batch = module.client.batch(); - keys.forEach(k => batch.zrange(k, 0, -1)); - return await helpers.execBatch(batch); - }; - - module.sortedSetIncrBy = async function (key, increment, value) { - const newValue = await module.client.zincrby(key, increment, value); - return parseFloat(newValue); - }; - - module.sortedSetIncrByBulk = async function (data) { - const multi = module.client.multi(); - data.forEach((item) => { - multi.zincrby(item[0], item[1], item[2]); - }); - const result = await multi.exec(); - return result.map(item => item && parseFloat(item[1])); - }; - - module.getSortedSetRangeByLex = async function (key, min, max, start, count) { - return await sortedSetLex('zrangebylex', false, key, min, max, start, count); - }; - - module.getSortedSetRevRangeByLex = async function (key, max, min, start, count) { - return await sortedSetLex('zrevrangebylex', true, key, max, min, start, count); - }; - - module.sortedSetRemoveRangeByLex = async function (key, min, max) { - await sortedSetLex('zremrangebylex', false, key, min, max); - }; - - module.sortedSetLexCount = async function (key, min, max) { - return await sortedSetLex('zlexcount', false, key, min, max); - }; - - async function sortedSetLex(method, reverse, key, min, max, start, count) { - let minmin; - let maxmax; - if (reverse) { - minmin = '+'; - maxmax = '-'; - } else { - minmin = '-'; - maxmax = '+'; - } - - if (min !== minmin && !min.match(/^[[(]/)) { - min = `[${min}`; - } - if (max !== maxmax && !max.match(/^[[(]/)) { - max = `[${max}`; - } - const args = [key, min, max]; - if (count) { - args.push('LIMIT', start, count); - } - return await module.client[method](args); - } - - module.getSortedSetScan = async function (params) { - let cursor = '0'; - - const returnData = []; - let done = false; - const seen = {}; - do { - /* eslint-disable no-await-in-loop */ - const res = await module.client.zscan(params.key, cursor, 'MATCH', params.match, 'COUNT', 5000); - cursor = res[0]; - done = cursor === '0'; - const data = res[1]; - - for (let i = 0; i < data.length; i += 2) { - const value = data[i]; - if (!seen[value]) { - seen[value] = 1; - - if (params.withScores) { - returnData.push({ value: value, score: parseFloat(data[i + 1]) }); - } else { - returnData.push(value); - } - if (params.limit && returnData.length >= params.limit) { - done = true; - break; - } - } - } - } while (!done); - - return returnData; - }; + const utils = require('../../utils'); + const helpers = require('./helpers'); + const dbHelpers = require('../helpers'); + + require('./sorted/add')(module); + require('./sorted/remove')(module); + require('./sorted/union')(module); + require('./sorted/intersect')(module); + + module.getSortedSetRange = async function (key, start, stop) { + return await sortedSetRange('zrange', key, start, stop, '-inf', '+inf', false); + }; + + module.getSortedSetRevRange = async function (key, start, stop) { + return await sortedSetRange('zrevrange', key, start, stop, '-inf', '+inf', false); + }; + + module.getSortedSetRangeWithScores = async function (key, start, stop) { + return await sortedSetRange('zrange', key, start, stop, '-inf', '+inf', true); + }; + + module.getSortedSetRevRangeWithScores = async function (key, start, stop) { + return await sortedSetRange('zrevrange', key, start, stop, '-inf', '+inf', true); + }; + + async function sortedSetRange(method, key, start, stop, min, max, withScores) { + if (Array.isArray(key)) { + if (key.length === 0) { + return []; + } + + const batch = module.client.batch(); + key.forEach(key => batch[method](genParams(method, key, 0, stop, min, max, true))); + const data = await helpers.execBatch(batch); + + const batchData = data.map(setData => helpers.zsetToObjectArray(setData)); + + let objects = dbHelpers.mergeBatch(batchData, 0, stop, method === 'zrange' ? 1 : -1); + + if (start > 0) { + objects = objects.slice(start, stop === -1 ? undefined : stop + 1); + } + + if (!withScores) { + objects = objects.map(item => item.value); + } + + return objects; + } + + const parameters = genParams(method, key, start, stop, min, max, withScores); + const data = await module.client[method](parameters); + if (!withScores) { + return data; + } + + const objects = helpers.zsetToObjectArray(data); + return objects; + } + + function genParams(method, key, start, stop, min, max, withScores) { + const parameters = { + zrevrange: [key, start, stop], + zrange: [key, start, stop], + zrangebyscore: [key, min, max], + zrevrangebyscore: [key, max, min], + }; + if (withScores) { + parameters[method].push('WITHSCORES'); + } + + if (method === 'zrangebyscore' || method === 'zrevrangebyscore') { + const count = stop === -1 ? stop : stop - start + 1; + parameters[method].push('LIMIT', start, count); + } + + return parameters[method]; + } + + module.getSortedSetRangeByScore = async function (key, start, count, min, max) { + return await sortedSetRangeByScore('zrangebyscore', key, start, count, min, max, false); + }; + + module.getSortedSetRevRangeByScore = async function (key, start, count, max, min) { + return await sortedSetRangeByScore('zrevrangebyscore', key, start, count, min, max, false); + }; + + module.getSortedSetRangeByScoreWithScores = async function (key, start, count, min, max) { + return await sortedSetRangeByScore('zrangebyscore', key, start, count, min, max, true); + }; + + module.getSortedSetRevRangeByScoreWithScores = async function (key, start, count, max, min) { + return await sortedSetRangeByScore('zrevrangebyscore', key, start, count, min, max, true); + }; + + async function sortedSetRangeByScore(method, key, start, count, min, max, withScores) { + if (Number.parseInt(count, 10) === 0) { + return []; + } + + const stop = (Number.parseInt(count, 10) === -1) ? -1 : (start + count - 1); + return await sortedSetRange(method, key, start, stop, min, max, withScores); + } + + module.sortedSetCount = async function (key, min, max) { + return await module.client.zcount(key, min, max); + }; + + module.sortedSetCard = async function (key) { + return await module.client.zcard(key); + }; + + module.sortedSetsCard = async function (keys) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } + + const batch = module.client.batch(); + for (const k of keys) { + batch.zcard(String(k)); + } + + return await helpers.execBatch(batch); + }; + + module.sortedSetsCardSum = async function (keys) { + if (!keys || (Array.isArray(keys) && keys.length === 0)) { + return 0; + } + + if (!Array.isArray(keys)) { + keys = [keys]; + } + + const counts = await module.sortedSetsCard(keys); + const sum = counts.reduce((accumulator, value) => accumulator + value, 0); + return sum; + }; + + module.sortedSetRank = async function (key, value) { + return await module.client.zrank(key, value); + }; + + module.sortedSetRevRank = async function (key, value) { + return await module.client.zrevrank(key, value); + }; + + module.sortedSetsRanks = async function (keys, values) { + const batch = module.client.batch(); + for (const [i, value] of values.entries()) { + batch.zrank(keys[i], String(value)); + } + + return await helpers.execBatch(batch); + }; + + module.sortedSetsRevRanks = async function (keys, values) { + const batch = module.client.batch(); + for (const [i, value] of values.entries()) { + batch.zrevrank(keys[i], String(value)); + } + + return await helpers.execBatch(batch); + }; + + module.sortedSetRanks = async function (key, values) { + const batch = module.client.batch(); + for (const value of values) { + batch.zrank(key, String(value)); + } + + return await helpers.execBatch(batch); + }; + + module.sortedSetRevRanks = async function (key, values) { + const batch = module.client.batch(); + for (const value of values) { + batch.zrevrank(key, String(value)); + } + + return await helpers.execBatch(batch); + }; + + module.sortedSetScore = async function (key, value) { + if (!key || value === undefined) { + return null; + } + + const score = await module.client.zscore(key, value); + return score === null ? score : Number.parseFloat(score); + }; + + module.sortedSetsScore = async function (keys, value) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } + + const batch = module.client.batch(); + for (const key of keys) { + batch.zscore(String(key), String(value)); + } + + const scores = await helpers.execBatch(batch); + return scores.map(d => (d === null ? d : Number.parseFloat(d))); + }; + + module.sortedSetScores = async function (key, values) { + if (values.length === 0) { + return []; + } + + const batch = module.client.batch(); + for (const value of values) { + batch.zscore(String(key), String(value)); + } + + const scores = await helpers.execBatch(batch); + return scores.map(d => (d === null ? d : Number.parseFloat(d))); + }; + + module.isSortedSetMember = async function (key, value) { + const score = await module.sortedSetScore(key, value); + return utils.isNumber(score); + }; + + module.isSortedSetMembers = async function (key, values) { + if (values.length === 0) { + return []; + } + + const batch = module.client.batch(); + for (const v of values) { + batch.zscore(key, String(v)); + } + + const results = await helpers.execBatch(batch); + return results.map(utils.isNumber); + }; + + module.isMemberOfSortedSets = async function (keys, value) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } + + const batch = module.client.batch(); + for (const k of keys) { + batch.zscore(k, String(value)); + } + + const results = await helpers.execBatch(batch); + return results.map(utils.isNumber); + }; + + module.getSortedSetMembers = async function (key) { + return await module.client.zrange(key, 0, -1); + }; + + module.getSortedSetsMembers = async function (keys) { + if (!Array.isArray(keys) || keys.length === 0) { + return []; + } + + const batch = module.client.batch(); + for (const k of keys) { + batch.zrange(k, 0, -1); + } + + return await helpers.execBatch(batch); + }; + + module.sortedSetIncrBy = async function (key, increment, value) { + const newValue = await module.client.zincrby(key, increment, value); + return Number.parseFloat(newValue); + }; + + module.sortedSetIncrByBulk = async function (data) { + const multi = module.client.multi(); + for (const item of data) { + multi.zincrby(item[0], item[1], item[2]); + } + + const result = await multi.exec(); + return result.map(item => item && Number.parseFloat(item[1])); + }; + + module.getSortedSetRangeByLex = async function (key, min, max, start, count) { + return await sortedSetLex('zrangebylex', false, key, min, max, start, count); + }; + + module.getSortedSetRevRangeByLex = async function (key, max, min, start, count) { + return await sortedSetLex('zrevrangebylex', true, key, max, min, start, count); + }; + + module.sortedSetRemoveRangeByLex = async function (key, min, max) { + await sortedSetLex('zremrangebylex', false, key, min, max); + }; + + module.sortedSetLexCount = async function (key, min, max) { + return await sortedSetLex('zlexcount', false, key, min, max); + }; + + async function sortedSetLex(method, reverse, key, min, max, start, count) { + let minmin; + let maxmax; + if (reverse) { + minmin = '+'; + maxmax = '-'; + } else { + minmin = '-'; + maxmax = '+'; + } + + if (min !== minmin && !/^[[(]/.test(min)) { + min = `[${min}`; + } + + if (max !== maxmax && !/^[[(]/.test(max)) { + max = `[${max}`; + } + + const arguments_ = [key, min, max]; + if (count) { + arguments_.push('LIMIT', start, count); + } + + return await module.client[method](arguments_); + } + + module.getSortedSetScan = async function (parameters) { + let cursor = '0'; + + const returnData = []; + let done = false; + const seen = {}; + do { + /* eslint-disable no-await-in-loop */ + const res = await module.client.zscan(parameters.key, cursor, 'MATCH', parameters.match, 'COUNT', 5000); + cursor = res[0]; + done = cursor === '0'; + const data = res[1]; + + for (let i = 0; i < data.length; i += 2) { + const value = data[i]; + if (!seen[value]) { + seen[value] = 1; + + if (parameters.withScores) { + returnData.push({value, score: Number.parseFloat(data[i + 1])}); + } else { + returnData.push(value); + } + + if (parameters.limit && returnData.length >= parameters.limit) { + done = true; + break; + } + } + } + } while (!done); + + return returnData; + }; }; diff --git a/src/database/redis/sorted/add.js b/src/database/redis/sorted/add.js index e77f17e..1552dd3 100644 --- a/src/database/redis/sorted/add.js +++ b/src/database/redis/sorted/add.js @@ -1,76 +1,87 @@ 'use strict'; module.exports = function (module) { - const helpers = require('../helpers'); - const utils = require('../../../utils'); - - module.sortedSetAdd = async function (key, score, value) { - if (!key) { - return; - } - if (Array.isArray(score) && Array.isArray(value)) { - return await sortedSetAddMulti(key, score, value); - } - if (!utils.isNumber(score)) { - throw new Error(`[[error:invalid-score, ${score}]]`); - } - await module.client.zadd(key, score, String(value)); - }; - - async function sortedSetAddMulti(key, scores, values) { - if (!scores.length || !values.length) { - return; - } - - if (scores.length !== values.length) { - throw new Error('[[error:invalid-data]]'); - } - for (let i = 0; i < scores.length; i += 1) { - if (!utils.isNumber(scores[i])) { - throw new Error(`[[error:invalid-score, ${scores[i]}]]`); - } - } - const args = [key]; - for (let i = 0; i < scores.length; i += 1) { - args.push(scores[i], String(values[i])); - } - await module.client.zadd(args); - } - - module.sortedSetsAdd = async function (keys, scores, value) { - if (!Array.isArray(keys) || !keys.length) { - return; - } - const isArrayOfScores = Array.isArray(scores); - if ((!isArrayOfScores && !utils.isNumber(scores)) || - (isArrayOfScores && scores.map(s => utils.isNumber(s)).includes(false))) { - throw new Error(`[[error:invalid-score, ${scores}]]`); - } - - if (isArrayOfScores && scores.length !== keys.length) { - throw new Error('[[error:invalid-data]]'); - } - - const batch = module.client.batch(); - for (let i = 0; i < keys.length; i += 1) { - if (keys[i]) { - batch.zadd(keys[i], isArrayOfScores ? scores[i] : scores, String(value)); - } - } - await helpers.execBatch(batch); - }; - - module.sortedSetAddBulk = async function (data) { - if (!Array.isArray(data) || !data.length) { - return; - } - const batch = module.client.batch(); - data.forEach((item) => { - if (!utils.isNumber(item[1])) { - throw new Error(`[[error:invalid-score, ${item[1]}]]`); - } - batch.zadd(item[0], item[1], item[2]); - }); - await helpers.execBatch(batch); - }; + const helpers = require('../helpers'); + const utils = require('../../../utils'); + + module.sortedSetAdd = async function (key, score, value) { + if (!key) { + return; + } + + if (Array.isArray(score) && Array.isArray(value)) { + return await sortedSetAddMulti(key, score, value); + } + + if (!utils.isNumber(score)) { + throw new TypeError(`[[error:invalid-score, ${score}]]`); + } + + await module.client.zadd(key, score, String(value)); + }; + + async function sortedSetAddMulti(key, scores, values) { + if (scores.length === 0 || values.length === 0) { + return; + } + + if (scores.length !== values.length) { + throw new Error('[[error:invalid-data]]'); + } + + for (const score of scores) { + if (!utils.isNumber(score)) { + throw new TypeError(`[[error:invalid-score, ${score}]]`); + } + } + + const arguments_ = [key]; + for (const [i, score] of scores.entries()) { + arguments_.push(score, String(values[i])); + } + + await module.client.zadd(arguments_); + } + + module.sortedSetsAdd = async function (keys, scores, value) { + if (!Array.isArray(keys) || keys.length === 0) { + return; + } + + const isArrayOfScores = Array.isArray(scores); + if ((!isArrayOfScores && !utils.isNumber(scores)) + || (isArrayOfScores && scores.map(s => utils.isNumber(s)).includes(false))) { + throw new Error(`[[error:invalid-score, ${scores}]]`); + } + + if (isArrayOfScores && scores.length !== keys.length) { + throw new Error('[[error:invalid-data]]'); + } + + const batch = module.client.batch(); + for (const [i, key] of keys.entries()) { + if (key) { + batch.zadd(key, isArrayOfScores ? scores[i] : scores, String(value)); + } + } + + await helpers.execBatch(batch); + }; + + module.sortedSetAddBulk = async function (data) { + if (!Array.isArray(data) || data.length === 0) { + return; + } + + const batch = module.client.batch(); + for (const item of data) { + if (!utils.isNumber(item[1])) { + throw new TypeError(`[[error:invalid-score, ${item[1]}]]`); + } + + batch.zadd(item[0], item[1], item[2]); + } + + await helpers.execBatch(batch); + }; }; diff --git a/src/database/redis/sorted/intersect.js b/src/database/redis/sorted/intersect.js index 56757e6..0d97f31 100644 --- a/src/database/redis/sorted/intersect.js +++ b/src/database/redis/sorted/intersect.js @@ -2,65 +2,67 @@ 'use strict'; module.exports = function (module) { - const helpers = require('../helpers'); - module.sortedSetIntersectCard = async function (keys) { - if (!Array.isArray(keys) || !keys.length) { - return 0; - } - const tempSetName = `temp_${Date.now()}`; + const helpers = require('../helpers'); + module.sortedSetIntersectCard = async function (keys) { + if (!Array.isArray(keys) || keys.length === 0) { + return 0; + } - const interParams = [tempSetName, keys.length].concat(keys); + const temporarySetName = `temp_${Date.now()}`; - const multi = module.client.multi(); - multi.zinterstore(interParams); - multi.zcard(tempSetName); - multi.del(tempSetName); - const results = await helpers.execBatch(multi); - return results[1] || 0; - }; + const interParameters = [temporarySetName, keys.length].concat(keys); - module.getSortedSetIntersect = async function (params) { - params.method = 'zrange'; - return await getSortedSetRevIntersect(params); - }; + const multi = module.client.multi(); + multi.zinterstore(interParameters); + multi.zcard(temporarySetName); + multi.del(temporarySetName); + const results = await helpers.execBatch(multi); + return results[1] || 0; + }; - module.getSortedSetRevIntersect = async function (params) { - params.method = 'zrevrange'; - return await getSortedSetRevIntersect(params); - }; + module.getSortedSetIntersect = async function (parameters) { + parameters.method = 'zrange'; + return await getSortedSetRevIntersect(parameters); + }; - async function getSortedSetRevIntersect(params) { - const { sets } = params; - const start = params.hasOwnProperty('start') ? params.start : 0; - const stop = params.hasOwnProperty('stop') ? params.stop : -1; - const weights = params.weights || []; + module.getSortedSetRevIntersect = async function (parameters) { + parameters.method = 'zrevrange'; + return await getSortedSetRevIntersect(parameters); + }; - const tempSetName = `temp_${Date.now()}`; + async function getSortedSetRevIntersect(parameters) { + const {sets} = parameters; + const start = parameters.hasOwnProperty('start') ? parameters.start : 0; + const stop = parameters.hasOwnProperty('stop') ? parameters.stop : -1; + const weights = parameters.weights || []; - let interParams = [tempSetName, sets.length].concat(sets); - if (weights.length) { - interParams = interParams.concat(['WEIGHTS'].concat(weights)); - } + const temporarySetName = `temp_${Date.now()}`; - if (params.aggregate) { - interParams = interParams.concat(['AGGREGATE', params.aggregate]); - } + let interParameters = [temporarySetName, sets.length].concat(sets); + if (weights.length > 0) { + interParameters = interParameters.concat(['WEIGHTS'].concat(weights)); + } - const rangeParams = [tempSetName, start, stop]; - if (params.withScores) { - rangeParams.push('WITHSCORES'); - } + if (parameters.aggregate) { + interParameters = interParameters.concat(['AGGREGATE', parameters.aggregate]); + } - const multi = module.client.multi(); - multi.zinterstore(interParams); - multi[params.method](rangeParams); - multi.del(tempSetName); - let results = await helpers.execBatch(multi); + const rangeParameters = [temporarySetName, start, stop]; + if (parameters.withScores) { + rangeParameters.push('WITHSCORES'); + } - if (!params.withScores) { - return results ? results[1] : null; - } - results = results[1] || []; - return helpers.zsetToObjectArray(results); - } + const multi = module.client.multi(); + multi.zinterstore(interParameters); + multi[parameters.method](rangeParameters); + multi.del(temporarySetName); + let results = await helpers.execBatch(multi); + + if (!parameters.withScores) { + return results ? results[1] : null; + } + + results = results[1] || []; + return helpers.zsetToObjectArray(results); + } }; diff --git a/src/database/redis/sorted/remove.js b/src/database/redis/sorted/remove.js index 232076e..b94c781 100644 --- a/src/database/redis/sorted/remove.js +++ b/src/database/redis/sorted/remove.js @@ -2,45 +2,57 @@ 'use strict'; module.exports = function (module) { - const helpers = require('../helpers'); - - module.sortedSetRemove = async function (key, value) { - if (!key) { - return; - } - const isValueArray = Array.isArray(value); - if (!value || (isValueArray && !value.length)) { - return; - } - if (!isValueArray) { - value = [value]; - } - - if (Array.isArray(key)) { - const batch = module.client.batch(); - key.forEach(k => batch.zrem(k, value)); - await helpers.execBatch(batch); - } else { - await module.client.zrem(key, value); - } - }; - - module.sortedSetsRemove = async function (keys, value) { - await module.sortedSetRemove(keys, value); - }; - - module.sortedSetsRemoveRangeByScore = async function (keys, min, max) { - const batch = module.client.batch(); - keys.forEach(k => batch.zremrangebyscore(k, min, max)); - await helpers.execBatch(batch); - }; - - module.sortedSetRemoveBulk = async function (data) { - if (!Array.isArray(data) || !data.length) { - return; - } - const batch = module.client.batch(); - data.forEach(item => batch.zrem(item[0], item[1])); - await helpers.execBatch(batch); - }; + const helpers = require('../helpers'); + + module.sortedSetRemove = async function (key, value) { + if (!key) { + return; + } + + const isValueArray = Array.isArray(value); + if (!value || (isValueArray && value.length === 0)) { + return; + } + + if (!isValueArray) { + value = [value]; + } + + if (Array.isArray(key)) { + const batch = module.client.batch(); + for (const k of key) { + batch.zrem(k, value); + } + + await helpers.execBatch(batch); + } else { + await module.client.zrem(key, value); + } + }; + + module.sortedSetsRemove = async function (keys, value) { + await module.sortedSetRemove(keys, value); + }; + + module.sortedSetsRemoveRangeByScore = async function (keys, min, max) { + const batch = module.client.batch(); + for (const k of keys) { + batch.zremrangebyscore(k, min, max); + } + + await helpers.execBatch(batch); + }; + + module.sortedSetRemoveBulk = async function (data) { + if (!Array.isArray(data) || data.length === 0) { + return; + } + + const batch = module.client.batch(); + for (const item of data) { + batch.zrem(item[0], item[1]); + } + + await helpers.execBatch(batch); + }; }; diff --git a/src/database/redis/sorted/union.js b/src/database/redis/sorted/union.js index 3db6425..053966c 100644 --- a/src/database/redis/sorted/union.js +++ b/src/database/redis/sorted/union.js @@ -2,51 +2,53 @@ 'use strict'; module.exports = function (module) { - const helpers = require('../helpers'); - module.sortedSetUnionCard = async function (keys) { - const tempSetName = `temp_${Date.now()}`; - if (!keys.length) { - return 0; - } - const multi = module.client.multi(); - multi.zunionstore([tempSetName, keys.length].concat(keys)); - multi.zcard(tempSetName); - multi.del(tempSetName); - const results = await helpers.execBatch(multi); - return Array.isArray(results) && results.length ? results[1] : 0; - }; - - module.getSortedSetUnion = async function (params) { - params.method = 'zrange'; - return await module.sortedSetUnion(params); - }; - - module.getSortedSetRevUnion = async function (params) { - params.method = 'zrevrange'; - return await module.sortedSetUnion(params); - }; - - module.sortedSetUnion = async function (params) { - if (!params.sets.length) { - return []; - } - - const tempSetName = `temp_${Date.now()}`; - - const rangeParams = [tempSetName, params.start, params.stop]; - if (params.withScores) { - rangeParams.push('WITHSCORES'); - } - - const multi = module.client.multi(); - multi.zunionstore([tempSetName, params.sets.length].concat(params.sets)); - multi[params.method](rangeParams); - multi.del(tempSetName); - let results = await helpers.execBatch(multi); - if (!params.withScores) { - return results ? results[1] : null; - } - results = results[1] || []; - return helpers.zsetToObjectArray(results); - }; + const helpers = require('../helpers'); + module.sortedSetUnionCard = async function (keys) { + const temporarySetName = `temp_${Date.now()}`; + if (keys.length === 0) { + return 0; + } + + const multi = module.client.multi(); + multi.zunionstore([temporarySetName, keys.length].concat(keys)); + multi.zcard(temporarySetName); + multi.del(temporarySetName); + const results = await helpers.execBatch(multi); + return Array.isArray(results) && results.length > 0 ? results[1] : 0; + }; + + module.getSortedSetUnion = async function (parameters) { + parameters.method = 'zrange'; + return await module.sortedSetUnion(parameters); + }; + + module.getSortedSetRevUnion = async function (parameters) { + parameters.method = 'zrevrange'; + return await module.sortedSetUnion(parameters); + }; + + module.sortedSetUnion = async function (parameters) { + if (parameters.sets.length === 0) { + return []; + } + + const temporarySetName = `temp_${Date.now()}`; + + const rangeParameters = [temporarySetName, parameters.start, parameters.stop]; + if (parameters.withScores) { + rangeParameters.push('WITHSCORES'); + } + + const multi = module.client.multi(); + multi.zunionstore([temporarySetName, parameters.sets.length].concat(parameters.sets)); + multi[parameters.method](rangeParameters); + multi.del(temporarySetName); + let results = await helpers.execBatch(multi); + if (!parameters.withScores) { + return results ? results[1] : null; + } + + results = results[1] || []; + return helpers.zsetToObjectArray(results); + }; }; diff --git a/src/database/redis/transaction.js b/src/database/redis/transaction.js index 1e98aac..f914a2d 100644 --- a/src/database/redis/transaction.js +++ b/src/database/redis/transaction.js @@ -1,8 +1,8 @@ 'use strict'; module.exports = function (module) { - // TODO - module.transaction = function (perform, callback) { - perform(module.client, callback); - }; + // TODO + module.transaction = function (perform, callback) { + perform(module.client, callback); + }; }; diff --git a/src/emailer.js b/src/emailer.js index 24f6dec..6174e4b 100644 --- a/src/emailer.js +++ b/src/emailer.js @@ -1,17 +1,16 @@ 'use strict'; +const url = require('node:url'); +const path = require('node:path'); +const fs = require('node:fs'); const winston = require('winston'); const nconf = require('nconf'); const Benchpress = require('benchpressjs'); const nodemailer = require('nodemailer'); const wellKnownServices = require('nodemailer/lib/well-known/services'); -const { htmlToText } = require('html-to-text'); -const url = require('url'); -const path = require('path'); -const fs = require('fs'); +const {htmlToText} = require('html-to-text'); const _ = require('lodash'); const jwt = require('jsonwebtoken'); - const User = require('./user'); const Plugins = require('./plugins'); const meta = require('./meta'); @@ -22,347 +21,355 @@ const file = require('./file'); const viewsDir = nconf.get('views_dir'); const Emailer = module.exports; -let prevConfig; +let previousConfig; let app; Emailer.fallbackNotFound = false; Emailer.transports = { - sendmail: nodemailer.createTransport({ - sendmail: true, - newline: 'unix', - }), - smtp: undefined, + sendmail: nodemailer.createTransport({ + sendmail: true, + newline: 'unix', + }), + smtp: undefined, }; Emailer.listServices = () => Object.keys(wellKnownServices); Emailer._defaultPayload = {}; -const smtpSettingsChanged = (config) => { - const settings = [ - 'email:smtpTransport:enabled', - 'email:smtpTransport:pool', - 'email:smtpTransport:user', - 'email:smtpTransport:pass', - 'email:smtpTransport:service', - 'email:smtpTransport:port', - 'email:smtpTransport:host', - 'email:smtpTransport:security', - ]; - // config only has these properties if settings are saved on /admin/settings/email - return settings.some(key => config.hasOwnProperty(key) && config[key] !== prevConfig[key]); +const smtpSettingsChanged = config => { + const settings = [ + 'email:smtpTransport:enabled', + 'email:smtpTransport:pool', + 'email:smtpTransport:user', + 'email:smtpTransport:pass', + 'email:smtpTransport:service', + 'email:smtpTransport:port', + 'email:smtpTransport:host', + 'email:smtpTransport:security', + ]; + // Config only has these properties if settings are saved on /admin/settings/email + return settings.some(key => config.hasOwnProperty(key) && config[key] !== previousConfig[key]); }; const getHostname = () => { - const configUrl = nconf.get('url'); - const parsed = url.parse(configUrl); - return parsed.hostname; + const configUrl = nconf.get('url'); + const parsed = url.parse(configUrl); + return parsed.hostname; }; -const buildCustomTemplates = async (config) => { - try { - // If the new config contains any email override values, re-compile those templates - const toBuild = Object - .keys(config) - .filter(prop => prop.startsWith('email:custom:')) - .map(key => key.split(':')[2]); - - if (!toBuild.length) { - return; - } - - const [templates, allPaths] = await Promise.all([ - Emailer.getTemplates(config), - file.walk(viewsDir), - ]); - - const templatesToBuild = templates.filter(template => toBuild.includes(template.path)); - const paths = _.fromPairs(allPaths.map((p) => { - const relative = path.relative(viewsDir, p).replace(/\\/g, '/'); - return [relative, p]; - })); - - await Promise.all(templatesToBuild.map(async (template) => { - const source = await meta.templates.processImports(paths, template.path, template.text); - const compiled = await Benchpress.precompile(source, { filename: template.path }); - await fs.promises.writeFile(template.fullpath.replace(/\.tpl$/, '.js'), compiled); - })); - - Benchpress.flush(); - winston.verbose('[emailer] Built custom email templates'); - } catch (err) { - winston.error(`[emailer] Failed to build custom email templates\n${err.stack}`); - } +const buildCustomTemplates = async config => { + try { + // If the new config contains any email override values, re-compile those templates + const toBuild = Object + .keys(config) + .filter(property => property.startsWith('email:custom:')) + .map(key => key.split(':')[2]); + + if (toBuild.length === 0) { + return; + } + + const [templates, allPaths] = await Promise.all([ + Emailer.getTemplates(config), + file.walk(viewsDir), + ]); + + const templatesToBuild = templates.filter(template => toBuild.includes(template.path)); + const paths = Object.fromEntries(allPaths.map(p => { + const relative = path.relative(viewsDir, p).replaceAll('\\', '/'); + return [relative, p]; + })); + + await Promise.all(templatesToBuild.map(async template => { + const source = await meta.templates.processImports(paths, template.path, template.text); + const compiled = await Benchpress.precompile(source, {filename: template.path}); + await fs.promises.writeFile(template.fullpath.replace(/\.tpl$/, '.js'), compiled); + })); + + Benchpress.flush(); + winston.verbose('[emailer] Built custom email templates'); + } catch (error) { + winston.error(`[emailer] Failed to build custom email templates\n${error.stack}`); + } }; -Emailer.getTemplates = async (config) => { - const emailsPath = path.join(viewsDir, 'emails'); - let emails = await file.walk(emailsPath); - emails = emails.filter(email => !email.endsWith('.js')); - - const templates = await Promise.all(emails.map(async (email) => { - const path = email.replace(emailsPath, '').slice(1).replace('.tpl', ''); - const original = await fs.promises.readFile(email, 'utf8'); - - return { - path: path, - fullpath: email, - text: config[`email:custom:${path}`] || original, - original: original, - isCustom: !!config[`email:custom:${path}`], - }; - })); - return templates; +Emailer.getTemplates = async config => { + const emailsPath = path.join(viewsDir, 'emails'); + let emails = await file.walk(emailsPath); + emails = emails.filter(email => !email.endsWith('.js')); + + const templates = await Promise.all(emails.map(async email => { + const path = email.replace(emailsPath, '').slice(1).replace('.tpl', ''); + const original = await fs.promises.readFile(email, 'utf8'); + + return { + path, + fullpath: email, + text: config[`email:custom:${path}`] || original, + original, + isCustom: Boolean(config[`email:custom:${path}`]), + }; + })); + return templates; }; -Emailer.setupFallbackTransport = (config) => { - winston.verbose('[emailer] Setting up fallback transport'); - // Enable SMTP transport if enabled in ACP - if (parseInt(config['email:smtpTransport:enabled'], 10) === 1) { - const smtpOptions = { - name: getHostname(), - pool: config['email:smtpTransport:pool'], - }; - - if (config['email:smtpTransport:user'] || config['email:smtpTransport:pass']) { - smtpOptions.auth = { - user: config['email:smtpTransport:user'], - pass: config['email:smtpTransport:pass'], - }; - } - - if (config['email:smtpTransport:service'] === 'nodebb-custom-smtp') { - smtpOptions.port = config['email:smtpTransport:port']; - smtpOptions.host = config['email:smtpTransport:host']; - - if (config['email:smtpTransport:security'] === 'NONE') { - smtpOptions.secure = false; - smtpOptions.requireTLS = false; - smtpOptions.ignoreTLS = true; - } else if (config['email:smtpTransport:security'] === 'STARTTLS') { - smtpOptions.secure = false; - smtpOptions.requireTLS = true; - smtpOptions.ignoreTLS = false; - } else { - // meta.config['email:smtpTransport:security'] === 'ENCRYPTED' or undefined - smtpOptions.secure = true; - smtpOptions.requireTLS = true; - smtpOptions.ignoreTLS = false; - } - } else { - smtpOptions.service = String(config['email:smtpTransport:service']); - } - - Emailer.transports.smtp = nodemailer.createTransport(smtpOptions); - Emailer.fallbackTransport = Emailer.transports.smtp; - } else { - Emailer.fallbackTransport = Emailer.transports.sendmail; - } +Emailer.setupFallbackTransport = config => { + winston.verbose('[emailer] Setting up fallback transport'); + // Enable SMTP transport if enabled in ACP + if (Number.parseInt(config['email:smtpTransport:enabled'], 10) === 1) { + const smtpOptions = { + name: getHostname(), + pool: config['email:smtpTransport:pool'], + }; + + if (config['email:smtpTransport:user'] || config['email:smtpTransport:pass']) { + smtpOptions.auth = { + user: config['email:smtpTransport:user'], + pass: config['email:smtpTransport:pass'], + }; + } + + if (config['email:smtpTransport:service'] === 'nodebb-custom-smtp') { + smtpOptions.port = config['email:smtpTransport:port']; + smtpOptions.host = config['email:smtpTransport:host']; + + if (config['email:smtpTransport:security'] === 'NONE') { + smtpOptions.secure = false; + smtpOptions.requireTLS = false; + smtpOptions.ignoreTLS = true; + } else if (config['email:smtpTransport:security'] === 'STARTTLS') { + smtpOptions.secure = false; + smtpOptions.requireTLS = true; + smtpOptions.ignoreTLS = false; + } else { + // Meta.config['email:smtpTransport:security'] === 'ENCRYPTED' or undefined + smtpOptions.secure = true; + smtpOptions.requireTLS = true; + smtpOptions.ignoreTLS = false; + } + } else { + smtpOptions.service = String(config['email:smtpTransport:service']); + } + + Emailer.transports.smtp = nodemailer.createTransport(smtpOptions); + Emailer.fallbackTransport = Emailer.transports.smtp; + } else { + Emailer.fallbackTransport = Emailer.transports.sendmail; + } }; -Emailer.registerApp = (expressApp) => { - app = expressApp; - - let logo = null; - if (meta.config.hasOwnProperty('brand:emailLogo')) { - logo = (!meta.config['brand:emailLogo'].startsWith('http') ? nconf.get('url') : '') + meta.config['brand:emailLogo']; - } - - Emailer._defaultPayload = { - url: nconf.get('url'), - site_title: meta.config.title || 'NodeBB', - logo: { - src: logo, - height: meta.config['brand:emailLogo:height'], - width: meta.config['brand:emailLogo:width'], - }, - }; - - Emailer.setupFallbackTransport(meta.config); - buildCustomTemplates(meta.config); - - // need to shallow clone the config object - // otherwise prevConfig holds reference to meta.config object, - // which is updated before the pubsub handler is called - prevConfig = { ...meta.config }; - - pubsub.on('config:update', (config) => { - // config object only contains properties for the specific acp settings page - // not the entire meta.config object - if (config) { - // Update default payload if new logo is uploaded - if (config.hasOwnProperty('brand:emailLogo')) { - Emailer._defaultPayload.logo.src = config['brand:emailLogo']; - } - if (config.hasOwnProperty('brand:emailLogo:height')) { - Emailer._defaultPayload.logo.height = config['brand:emailLogo:height']; - } - if (config.hasOwnProperty('brand:emailLogo:width')) { - Emailer._defaultPayload.logo.width = config['brand:emailLogo:width']; - } - - if (smtpSettingsChanged(config)) { - Emailer.setupFallbackTransport(config); - } - buildCustomTemplates(config); - - prevConfig = { ...prevConfig, ...config }; - } - }); - - return Emailer; +Emailer.registerApp = expressApp => { + app = expressApp; + + let logo = null; + if (meta.config.hasOwnProperty('brand:emailLogo')) { + logo = (meta.config['brand:emailLogo'].startsWith('http') ? '' : nconf.get('url')) + meta.config['brand:emailLogo']; + } + + Emailer._defaultPayload = { + url: nconf.get('url'), + site_title: meta.config.title || 'NodeBB', + logo: { + src: logo, + height: meta.config['brand:emailLogo:height'], + width: meta.config['brand:emailLogo:width'], + }, + }; + + Emailer.setupFallbackTransport(meta.config); + buildCustomTemplates(meta.config); + + // Need to shallow clone the config object + // otherwise prevConfig holds reference to meta.config object, + // which is updated before the pubsub handler is called + previousConfig = {...meta.config}; + + pubsub.on('config:update', config => { + // Config object only contains properties for the specific acp settings page + // not the entire meta.config object + if (config) { + // Update default payload if new logo is uploaded + if (config.hasOwnProperty('brand:emailLogo')) { + Emailer._defaultPayload.logo.src = config['brand:emailLogo']; + } + + if (config.hasOwnProperty('brand:emailLogo:height')) { + Emailer._defaultPayload.logo.height = config['brand:emailLogo:height']; + } + + if (config.hasOwnProperty('brand:emailLogo:width')) { + Emailer._defaultPayload.logo.width = config['brand:emailLogo:width']; + } + + if (smtpSettingsChanged(config)) { + Emailer.setupFallbackTransport(config); + } + + buildCustomTemplates(config); + + previousConfig = {...previousConfig, ...config}; + } + }); + + return Emailer; }; -Emailer.send = async (template, uid, params) => { - if (!app) { - throw Error('[emailer] App not ready!'); - } - - let userData = await User.getUserFields(uid, ['email', 'username', 'email:confirmed', 'banned']); - - // 'welcome' and 'verify-email' explicitly used passed-in email address - if (['welcome', 'verify-email'].includes(template)) { - userData.email = params.email; - } - - ({ template, userData, params } = await Plugins.hooks.fire('filter:email.prepare', { template, uid, userData, params })); - - if (!meta.config.sendEmailToBanned && template !== 'banned') { - if (userData.banned) { - winston.warn(`[emailer/send] User ${userData.username} (uid: ${uid}) is banned; not sending email due to system config.`); - return; - } - } - - if (!userData || !userData.email) { - if (process.env.NODE_ENV === 'development') { - winston.warn(`uid : ${uid} has no email, not sending "${template}" email.`); - } - return; - } - - const allowedTpls = ['verify-email', 'welcome', 'registration_accepted', 'reset', 'reset_notify']; - if (!meta.config.includeUnverifiedEmails && !userData['email:confirmed'] && !allowedTpls.includes(template)) { - if (process.env.NODE_ENV === 'development') { - winston.warn(`uid : ${uid} (${userData.email}) has not confirmed email, not sending "${template}" email.`); - } - return; - } - const userSettings = await User.getSettings(uid); - // Combined passed-in payload with default values - params = { ...Emailer._defaultPayload, ...params }; - params.uid = uid; - params.username = userData.username; - params.rtl = await translator.translate('[[language:dir]]', userSettings.userLang) === 'rtl'; - - const result = await Plugins.hooks.fire('filter:email.cancel', { - cancel: false, // set to true in plugin to cancel sending email - template: template, - params: params, - }); - - if (result.cancel) { - return; - } - await Emailer.sendToEmail(template, userData.email, userSettings.userLang, params); +Emailer.send = async (template, uid, parameters) => { + if (!app) { + throw new Error('[emailer] App not ready!'); + } + + let userData = await User.getUserFields(uid, ['email', 'username', 'email:confirmed', 'banned']); + + // 'welcome' and 'verify-email' explicitly used passed-in email address + if (['welcome', 'verify-email'].includes(template)) { + userData.email = parameters.email; + } + + ({template, userData, params: parameters} = await Plugins.hooks.fire('filter:email.prepare', { + template, uid, userData, params: parameters, + })); + + if (!meta.config.sendEmailToBanned && template !== 'banned' && userData.banned) { + winston.warn(`[emailer/send] User ${userData.username} (uid: ${uid}) is banned; not sending email due to system config.`); + return; + } + + if (!userData || !userData.email) { + if (process.env.NODE_ENV === 'development') { + winston.warn(`uid : ${uid} has no email, not sending "${template}" email.`); + } + + return; + } + + const allowedTpls = ['verify-email', 'welcome', 'registration_accepted', 'reset', 'reset_notify']; + if (!meta.config.includeUnverifiedEmails && !userData['email:confirmed'] && !allowedTpls.includes(template)) { + if (process.env.NODE_ENV === 'development') { + winston.warn(`uid : ${uid} (${userData.email}) has not confirmed email, not sending "${template}" email.`); + } + + return; + } + + const userSettings = await User.getSettings(uid); + // Combined passed-in payload with default values + parameters = {...Emailer._defaultPayload, ...parameters}; + parameters.uid = uid; + parameters.username = userData.username; + parameters.rtl = await translator.translate('[[language:dir]]', userSettings.userLang) === 'rtl'; + + const result = await Plugins.hooks.fire('filter:email.cancel', { + cancel: false, // Set to true in plugin to cancel sending email + template, + params: parameters, + }); + + if (result.cancel) { + return; + } + + await Emailer.sendToEmail(template, userData.email, userSettings.userLang, parameters); }; -Emailer.sendToEmail = async (template, email, language, params) => { - const lang = language || meta.config.defaultLang || 'en-GB'; - const unsubscribable = ['digest', 'notification']; - - // Digests and notifications can be one-click unsubbed - let payload = { - template: template, - uid: params.uid, - }; - - if (unsubscribable.includes(template)) { - if (template === 'notification') { - payload.type = params.notification.type; - } - payload = jwt.sign(payload, nconf.get('secret'), { - expiresIn: '30d', - }); - - const unsubUrl = [nconf.get('url'), 'email', 'unsubscribe', payload].join('/'); - params.headers = { - 'List-Id': `<${[template, params.uid, getHostname()].join('.')}>`, - 'List-Unsubscribe': `<${unsubUrl}>`, - 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', - ...params.headers, - }; - params.unsubUrl = unsubUrl; - } - - const result = await Plugins.hooks.fire('filter:email.params', { - template: template, - email: email, - language: lang, - params: params, - }); - - template = result.template; - email = result.email; - params = result.params; - - const [html, subject] = await Promise.all([ - Emailer.renderAndTranslate(template, params, result.language), - translator.translate(params.subject, result.language), - ]); - - const data = await Plugins.hooks.fire('filter:email.modify', { - _raw: params, - to: email, - from: meta.config['email:from'] || `no-reply@${getHostname()}`, - from_name: meta.config['email:from_name'] || 'NodeBB', - subject: `[${meta.config.title}] ${_.unescape(subject)}`, - html: html, - plaintext: htmlToText(html, { - tags: { img: { format: 'skip' } }, - }), - template: template, - uid: params.uid, - pid: params.pid, - fromUid: params.fromUid, - headers: params.headers, - rtl: params.rtl, - }); - const usingFallback = !Plugins.hooks.hasListeners('filter:email.send') && - !Plugins.hooks.hasListeners('static:email.send'); - try { - if (Plugins.hooks.hasListeners('filter:email.send')) { - // Deprecated, remove in v1.19.0 - await Plugins.hooks.fire('filter:email.send', data); - } else if (Plugins.hooks.hasListeners('static:email.send')) { - await Plugins.hooks.fire('static:email.send', data); - } else { - await Emailer.sendViaFallback(data); - } - } catch (err) { - if (err.code === 'ENOENT' && usingFallback) { - Emailer.fallbackNotFound = true; - throw new Error('[[error:sendmail-not-found]]'); - } else { - throw err; - } - } +Emailer.sendToEmail = async (template, email, language, parameters) => { + const lang = language || meta.config.defaultLang || 'en-GB'; + const unsubscribable = ['digest', 'notification']; + + // Digests and notifications can be one-click unsubbed + let payload = { + template, + uid: parameters.uid, + }; + + if (unsubscribable.includes(template)) { + if (template === 'notification') { + payload.type = parameters.notification.type; + } + + payload = jwt.sign(payload, nconf.get('secret'), { + expiresIn: '30d', + }); + + const unsubUrl = [nconf.get('url'), 'email', 'unsubscribe', payload].join('/'); + parameters.headers = { + 'List-Id': `<${[template, parameters.uid, getHostname()].join('.')}>`, + 'List-Unsubscribe': `<${unsubUrl}>`, + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + ...parameters.headers, + }; + parameters.unsubUrl = unsubUrl; + } + + const result = await Plugins.hooks.fire('filter:email.params', { + template, + email, + language: lang, + params: parameters, + }); + + template = result.template; + email = result.email; + parameters = result.params; + + const [html, subject] = await Promise.all([ + Emailer.renderAndTranslate(template, parameters, result.language), + translator.translate(parameters.subject, result.language), + ]); + + const data = await Plugins.hooks.fire('filter:email.modify', { + _raw: parameters, + to: email, + from: meta.config['email:from'] || `no-reply@${getHostname()}`, + from_name: meta.config['email:from_name'] || 'NodeBB', + subject: `[${meta.config.title}] ${_.unescape(subject)}`, + html, + plaintext: htmlToText(html, { + tags: {img: {format: 'skip'}}, + }), + template, + uid: parameters.uid, + pid: parameters.pid, + fromUid: parameters.fromUid, + headers: parameters.headers, + rtl: parameters.rtl, + }); + const usingFallback = !Plugins.hooks.hasListeners('filter:email.send') + && !Plugins.hooks.hasListeners('static:email.send'); + try { + if (Plugins.hooks.hasListeners('filter:email.send')) { + // Deprecated, remove in v1.19.0 + await Plugins.hooks.fire('filter:email.send', data); + } else if (Plugins.hooks.hasListeners('static:email.send')) { + await Plugins.hooks.fire('static:email.send', data); + } else { + await Emailer.sendViaFallback(data); + } + } catch (error) { + if (error.code === 'ENOENT' && usingFallback) { + Emailer.fallbackNotFound = true; + throw new Error('[[error:sendmail-not-found]]'); + } else { + throw error; + } + } }; -Emailer.sendViaFallback = async (data) => { - // Some minor alterations to the data to conform to nodemailer standard - data.text = data.plaintext; - delete data.plaintext; +Emailer.sendViaFallback = async data => { + // Some minor alterations to the data to conform to nodemailer standard + data.text = data.plaintext; + delete data.plaintext; - // NodeMailer uses a combined "from" - data.from = `${data.from_name}<${data.from}>`; - delete data.from_name; - await Emailer.fallbackTransport.sendMail(data); + // NodeMailer uses a combined "from" + data.from = `${data.from_name}<${data.from}>`; + delete data.from_name; + await Emailer.fallbackTransport.sendMail(data); }; -Emailer.renderAndTranslate = async (template, params, lang) => { - const html = await app.renderAsync(`emails/${template}`, params); - return await translator.translate(html, lang); +Emailer.renderAndTranslate = async (template, parameters, lang) => { + const html = await app.renderAsync(`emails/${template}`, parameters); + return await translator.translate(html, lang); }; require('./promisify')(Emailer, ['transports']); diff --git a/src/events.js b/src/events.js index c53af4a..ea0637e 100644 --- a/src/events.js +++ b/src/events.js @@ -3,7 +3,6 @@ const validator = require('validator'); const _ = require('lodash'); - const db = require('./database'); const batch = require('./batch'); const user = require('./user'); @@ -13,69 +12,69 @@ const plugins = require('./plugins'); const events = module.exports; events.types = [ - 'plugin-activate', - 'plugin-deactivate', - 'plugin-install', - 'plugin-uninstall', - 'restart', - 'build', - 'config-change', - 'settings-change', - 'category-purge', - 'privilege-change', - 'post-delete', - 'post-restore', - 'post-purge', - 'post-edit', - 'post-move', - 'post-change-owner', - 'post-queue-reply-accept', - 'post-queue-topic-accept', - 'post-queue-reply-reject', - 'post-queue-topic-reject', - 'topic-delete', - 'topic-restore', - 'topic-purge', - 'topic-rename', - 'topic-merge', - 'topic-fork', - 'topic-move', - 'topic-move-all', - 'password-reset', - 'user-makeAdmin', - 'user-removeAdmin', - 'user-ban', - 'user-unban', - 'user-mute', - 'user-unmute', - 'user-delete', - 'user-deleteAccount', - 'user-deleteContent', - 'password-change', - 'email-confirmation-sent', - 'email-change', - 'username-change', - 'ip-blacklist-save', - 'ip-blacklist-addRule', - 'registration-approved', - 'registration-rejected', - 'group-join', - 'group-request-membership', - 'group-add-member', - 'group-leave', - 'group-owner-grant', - 'group-owner-rescind', - 'group-accept-membership', - 'group-reject-membership', - 'group-invite', - 'group-invite-accept', - 'group-invite-reject', - 'group-kick', - 'theme-set', - 'export:uploads', - 'account-locked', - 'getUsersCSV', - // To add new types from plugins, just Array.push() to this array + 'plugin-activate', + 'plugin-deactivate', + 'plugin-install', + 'plugin-uninstall', + 'restart', + 'build', + 'config-change', + 'settings-change', + 'category-purge', + 'privilege-change', + 'post-delete', + 'post-restore', + 'post-purge', + 'post-edit', + 'post-move', + 'post-change-owner', + 'post-queue-reply-accept', + 'post-queue-topic-accept', + 'post-queue-reply-reject', + 'post-queue-topic-reject', + 'topic-delete', + 'topic-restore', + 'topic-purge', + 'topic-rename', + 'topic-merge', + 'topic-fork', + 'topic-move', + 'topic-move-all', + 'password-reset', + 'user-makeAdmin', + 'user-removeAdmin', + 'user-ban', + 'user-unban', + 'user-mute', + 'user-unmute', + 'user-delete', + 'user-deleteAccount', + 'user-deleteContent', + 'password-change', + 'email-confirmation-sent', + 'email-change', + 'username-change', + 'ip-blacklist-save', + 'ip-blacklist-addRule', + 'registration-approved', + 'registration-rejected', + 'group-join', + 'group-request-membership', + 'group-add-member', + 'group-leave', + 'group-owner-grant', + 'group-owner-rescind', + 'group-accept-membership', + 'group-reject-membership', + 'group-invite', + 'group-invite-accept', + 'group-invite-reject', + 'group-kick', + 'theme-set', + 'export:uploads', + 'account-locked', + 'getUsersCSV', + // To add new types from plugins, just Array.push() to this array ]; /** @@ -83,92 +82,96 @@ events.types = [ * Everything else gets stringified and shown as pretty JSON string */ events.log = async function (data) { - const eid = await db.incrObjectField('global', 'nextEid'); - data.timestamp = Date.now(); - data.eid = eid; - - await Promise.all([ - db.sortedSetsAdd([ - 'events:time', - `events:time:${data.type}`, - ], data.timestamp, eid), - db.setObject(`event:${eid}`, data), - ]); - plugins.hooks.fire('action:events.log', { data: data }); + const eid = await db.incrObjectField('global', 'nextEid'); + data.timestamp = Date.now(); + data.eid = eid; + + await Promise.all([ + db.sortedSetsAdd([ + 'events:time', + `events:time:${data.type}`, + ], data.timestamp, eid), + db.setObject(`event:${eid}`, data), + ]); + plugins.hooks.fire('action:events.log', {data}); }; events.getEvents = async function (filter, start, stop, from, to) { - // from/to optional - if (from === undefined) { - from = 0; - } - if (to === undefined) { - to = Date.now(); - } - - const eids = await db.getSortedSetRevRangeByScore(`events:time${filter ? `:${filter}` : ''}`, start, stop - start + 1, to, from); - let eventsData = await db.getObjects(eids.map(eid => `event:${eid}`)); - eventsData = eventsData.filter(Boolean); - await addUserData(eventsData, 'uid', 'user'); - await addUserData(eventsData, 'targetUid', 'targetUser'); - eventsData.forEach((event) => { - Object.keys(event).forEach((key) => { - if (typeof event[key] === 'string') { - event[key] = validator.escape(String(event[key] || '')); - } - }); - const e = utils.merge(event); - e.eid = undefined; - e.uid = undefined; - e.type = undefined; - e.ip = undefined; - e.user = undefined; - event.jsonString = JSON.stringify(e, null, 4); - event.timestampISO = new Date(parseInt(event.timestamp, 10)).toUTCString(); - }); - return eventsData; + // From/to optional + if (from === undefined) { + from = 0; + } + + if (to === undefined) { + to = Date.now(); + } + + const eids = await db.getSortedSetRevRangeByScore(`events:time${filter ? `:${filter}` : ''}`, start, stop - start + 1, to, from); + let eventsData = await db.getObjects(eids.map(eid => `event:${eid}`)); + eventsData = eventsData.filter(Boolean); + await addUserData(eventsData, 'uid', 'user'); + await addUserData(eventsData, 'targetUid', 'targetUser'); + for (const event of eventsData) { + for (const key of Object.keys(event)) { + if (typeof event[key] === 'string') { + event[key] = validator.escape(String(event[key] || '')); + } + } + + const e = utils.merge(event); + e.eid = undefined; + e.uid = undefined; + e.type = undefined; + e.ip = undefined; + e.user = undefined; + event.jsonString = JSON.stringify(e, null, 4); + event.timestampISO = new Date(Number.parseInt(event.timestamp, 10)).toUTCString(); + } + + return eventsData; }; async function addUserData(eventsData, field, objectName) { - const uids = _.uniq(eventsData.map(event => event && event[field])); - - if (!uids.length) { - return eventsData; - } - - const [isAdmin, userData] = await Promise.all([ - user.isAdministrator(uids), - user.getUsersFields(uids, ['username', 'userslug', 'picture']), - ]); - - const map = {}; - userData.forEach((user, index) => { - user.isAdmin = isAdmin[index]; - map[user.uid] = user; - }); - - eventsData.forEach((event) => { - if (map[event[field]]) { - event[objectName] = map[event[field]]; - } - }); - return eventsData; + const uids = _.uniq(eventsData.map(event => event && event[field])); + + if (uids.length === 0) { + return eventsData; + } + + const [isAdmin, userData] = await Promise.all([ + user.isAdministrator(uids), + user.getUsersFields(uids, ['username', 'userslug', 'picture']), + ]); + + const map = {}; + for (const [index, user] of userData.entries()) { + user.isAdmin = isAdmin[index]; + map[user.uid] = user; + } + + for (const event of eventsData) { + if (map[event[field]]) { + event[objectName] = map[event[field]]; + } + } + + return eventsData; } events.deleteEvents = async function (eids) { - const keys = eids.map(eid => `event:${eid}`); - const eventData = await db.getObjectsFields(keys, ['type']); - const sets = _.uniq(['events:time'].concat(eventData.map(e => `events:time:${e.type}`))); - await Promise.all([ - db.deleteAll(keys), - db.sortedSetRemove(sets, eids), - ]); + const keys = eids.map(eid => `event:${eid}`); + const eventData = await db.getObjectsFields(keys, ['type']); + const sets = _.uniq(['events:time'].concat(eventData.map(e => `events:time:${e.type}`))); + await Promise.all([ + db.deleteAll(keys), + db.sortedSetRemove(sets, eids), + ]); }; events.deleteAll = async function () { - await batch.processSortedSet('events:time', async (eids) => { - await events.deleteEvents(eids); - }, { alwaysStartAt: 0, batch: 500 }); + await batch.processSortedSet('events:time', async eids => { + await events.deleteEvents(eids); + }, {alwaysStartAt: 0, batch: 500}); }; require('./promisify')(events); diff --git a/src/file.js b/src/file.js index def9e8f..2e24d4c 100644 --- a/src/file.js +++ b/src/file.js @@ -1,158 +1,161 @@ 'use strict'; -const fs = require('fs'); +const fs = require('node:fs'); +const path = require('node:path'); const nconf = require('nconf'); -const path = require('path'); const winston = require('winston'); const mkdirp = require('mkdirp'); const mime = require('mime'); const graceful = require('graceful-fs'); - const slugify = require('./slugify'); graceful.gracefulify(fs); const file = module.exports; -file.saveFileToLocal = async function (filename, folder, tempPath) { - /* - * remarkable doesn't allow spaces in hyperlinks, once that's fixed, remove this. +file.saveFileToLocal = async function (filename, folder, temporaryPath) { + /* + * Remarkable doesn't allow spaces in hyperlinks, once that's fixed, remove this. */ - filename = filename.split('.').map(name => slugify(name)).join('.'); - - const uploadPath = path.join(nconf.get('upload_path'), folder, filename); - if (!uploadPath.startsWith(nconf.get('upload_path'))) { - throw new Error('[[error:invalid-path]]'); - } - - winston.verbose(`Saving file ${filename} to : ${uploadPath}`); - await mkdirp(path.dirname(uploadPath)); - await fs.promises.copyFile(tempPath, uploadPath); - return { - url: `/assets/uploads/${folder ? `${folder}/` : ''}${filename}`, - path: uploadPath, - }; + filename = filename.split('.').map(name => slugify(name)).join('.'); + + const uploadPath = path.join(nconf.get('upload_path'), folder, filename); + if (!uploadPath.startsWith(nconf.get('upload_path'))) { + throw new Error('[[error:invalid-path]]'); + } + + winston.verbose(`Saving file ${filename} to : ${uploadPath}`); + await mkdirp(path.dirname(uploadPath)); + await fs.promises.copyFile(temporaryPath, uploadPath); + return { + url: `/assets/uploads/${folder ? `${folder}/` : ''}${filename}`, + path: uploadPath, + }; }; file.base64ToLocal = async function (imageData, uploadPath) { - const buffer = Buffer.from(imageData.slice(imageData.indexOf('base64') + 7), 'base64'); - uploadPath = path.join(nconf.get('upload_path'), uploadPath); + const buffer = Buffer.from(imageData.slice(imageData.indexOf('base64') + 7), 'base64'); + uploadPath = path.join(nconf.get('upload_path'), uploadPath); - await fs.promises.writeFile(uploadPath, buffer, { - encoding: 'base64', - }); - return uploadPath; + await fs.promises.writeFile(uploadPath, buffer, { + encoding: 'base64', + }); + return uploadPath; }; // https://stackoverflow.com/a/31205878/583363 file.appendToFileName = function (filename, string) { - const dotIndex = filename.lastIndexOf('.'); - if (dotIndex === -1) { - return filename + string; - } - return filename.substring(0, dotIndex) + string + filename.substring(dotIndex); + const dotIndex = filename.lastIndexOf('.'); + if (dotIndex === -1) { + return filename + string; + } + + return filename.slice(0, Math.max(0, dotIndex)) + string + filename.slice(Math.max(0, dotIndex)); }; file.allowedExtensions = function () { - const meta = require('./meta'); - let allowedExtensions = (meta.config.allowedFileExtensions || '').trim(); - if (!allowedExtensions) { - return []; - } - allowedExtensions = allowedExtensions.split(','); - allowedExtensions = allowedExtensions.filter(Boolean).map((extension) => { - extension = extension.trim(); - if (!extension.startsWith('.')) { - extension = `.${extension}`; - } - return extension.toLowerCase(); - }); - - if (allowedExtensions.includes('.jpg') && !allowedExtensions.includes('.jpeg')) { - allowedExtensions.push('.jpeg'); - } - - return allowedExtensions; + const meta = require('./meta'); + let allowedExtensions = (meta.config.allowedFileExtensions || '').trim(); + if (!allowedExtensions) { + return []; + } + + allowedExtensions = allowedExtensions.split(','); + allowedExtensions = allowedExtensions.filter(Boolean).map(extension => { + extension = extension.trim(); + if (!extension.startsWith('.')) { + extension = `.${extension}`; + } + + return extension.toLowerCase(); + }); + + if (allowedExtensions.includes('.jpg') && !allowedExtensions.includes('.jpeg')) { + allowedExtensions.push('.jpeg'); + } + + return allowedExtensions; }; file.exists = async function (path) { - try { - await fs.promises.stat(path); - } catch (err) { - if (err.code === 'ENOENT') { - return false; - } - throw err; - } - return true; + try { + await fs.promises.stat(path); + } catch (error) { + if (error.code === 'ENOENT') { + return false; + } + + throw error; + } + + return true; }; file.existsSync = function (path) { - try { - fs.statSync(path); - } catch (err) { - if (err.code === 'ENOENT') { - return false; - } - throw err; - } - - return true; + try { + fs.statSync(path); + } catch (error) { + if (error.code === 'ENOENT') { + return false; + } + + throw error; + } + + return true; }; file.delete = async function (path) { - if (!path) { - return; - } - try { - await fs.promises.unlink(path); - } catch (err) { - if (err.code === 'ENOENT') { - winston.verbose(`[file] Attempted to delete non-existent file: ${path}`); - return; - } - - winston.warn(err); - } + if (!path) { + return; + } + + try { + await fs.promises.unlink(path); + } catch (error) { + if (error.code === 'ENOENT') { + winston.verbose(`[file] Attempted to delete non-existent file: ${path}`); + return; + } + + winston.warn(error); + } }; -file.link = async function link(filePath, destPath, relative) { - if (relative && process.platform !== 'win32') { - filePath = path.relative(path.dirname(destPath), filePath); - } +file.link = async function link(filePath, destinationPath, relative) { + if (relative && process.platform !== 'win32') { + filePath = path.relative(path.dirname(destinationPath), filePath); + } - if (process.platform === 'win32') { - await fs.promises.link(filePath, destPath); - } else { - await fs.promises.symlink(filePath, destPath, 'file'); - } + await (process.platform === 'win32' ? fs.promises.link(filePath, destinationPath) : fs.promises.symlink(filePath, destinationPath, 'file')); }; -file.linkDirs = async function linkDirs(sourceDir, destDir, relative) { - if (relative && process.platform !== 'win32') { - sourceDir = path.relative(path.dirname(destDir), sourceDir); - } +file.linkDirs = async function linkDirectories(sourceDir, destDir, relative) { + if (relative && process.platform !== 'win32') { + sourceDir = path.relative(path.dirname(destDir), sourceDir); + } - const type = (process.platform === 'win32') ? 'junction' : 'dir'; - await fs.promises.symlink(sourceDir, destDir, type); + const type = (process.platform === 'win32') ? 'junction' : 'dir'; + await fs.promises.symlink(sourceDir, destDir, type); }; file.typeToExtension = function (type) { - let extension = ''; - if (type) { - extension = `.${mime.getExtension(type)}`; - } - return extension; + let extension = ''; + if (type) { + extension = `.${mime.getExtension(type)}`; + } + + return extension; }; // Adapted from http://stackoverflow.com/questions/5827612/node-js-fs-readdir-recursive-directory-search file.walk = async function (dir) { - const subdirs = await fs.promises.readdir(dir); - const files = await Promise.all(subdirs.map(async (subdir) => { - const res = path.resolve(dir, subdir); - return (await fs.promises.stat(res)).isDirectory() ? file.walk(res) : res; - })); - return files.reduce((a, f) => a.concat(f), []); + const subdirs = await fs.promises.readdir(dir); + const files = await Promise.all(subdirs.map(async subdir => { + const res = path.resolve(dir, subdir); + return (await fs.promises.stat(res)).isDirectory() ? file.walk(res) : res; + })); + return files.flat(); }; require('./promisify')(file); diff --git a/src/flags.js b/src/flags.js index 2930c5c..a0ae02e 100644 --- a/src/flags.js +++ b/src/flags.js @@ -3,7 +3,6 @@ const _ = require('lodash'); const winston = require('winston'); const validator = require('validator'); - const db = require('./database'); const user = require('./user'); const groups = require('./groups'); @@ -21,936 +20,972 @@ const batch = require('./batch'); const Flags = module.exports; Flags._states = new Map([ - ['open', { - label: '[[flags:state-open]]', - class: 'danger', - }], - ['wip', { - label: '[[flags:state-wip]]', - class: 'warning', - }], - ['resolved', { - label: '[[flags:state-resolved]]', - class: 'success', - }], - ['rejected', { - label: '[[flags:state-rejected]]', - class: 'secondary', - }], + ['open', { + label: '[[flags:state-open]]', + class: 'danger', + }], + ['wip', { + label: '[[flags:state-wip]]', + class: 'warning', + }], + ['resolved', { + label: '[[flags:state-resolved]]', + class: 'success', + }], + ['rejected', { + label: '[[flags:state-rejected]]', + class: 'secondary', + }], ]); Flags.init = async function () { - // Query plugins for custom filter strategies and merge into core filter strategies - function prepareSets(sets, orSets, prefix, value) { - if (!Array.isArray(value)) { - sets.push(prefix + value); - } else if (value.length) { - if (value.length === 1) { - sets.push(prefix + value[0]); - } else { - orSets.push(value.map(x => prefix + x)); - } - } - } - - const hookData = { - filters: { - type: function (sets, orSets, key) { - prepareSets(sets, orSets, 'flags:byType:', key); - }, - state: function (sets, orSets, key) { - prepareSets(sets, orSets, 'flags:byState:', key); - }, - reporterId: function (sets, orSets, key) { - prepareSets(sets, orSets, 'flags:byReporter:', key); - }, - assignee: function (sets, orSets, key) { - prepareSets(sets, orSets, 'flags:byAssignee:', key); - }, - targetUid: function (sets, orSets, key) { - prepareSets(sets, orSets, 'flags:byTargetUid:', key); - }, - cid: function (sets, orSets, key) { - prepareSets(sets, orSets, 'flags:byCid:', key); - }, - page: function () { /* noop */ }, - perPage: function () { /* noop */ }, - quick: function (sets, orSets, key, uid) { - switch (key) { - case 'mine': - sets.push(`flags:byAssignee:${uid}`); - break; - - case 'unresolved': - prepareSets(sets, orSets, 'flags:byState:', ['open', 'wip']); - break; - } - }, - }, - states: Flags._states, - helpers: { - prepareSets: prepareSets, - }, - }; - - try { - ({ filters: Flags._filters } = await plugins.hooks.fire('filter:flags.getFilters', hookData)); - ({ filters: Flags._filters, states: Flags._states } = await plugins.hooks.fire('filter:flags.init', hookData)); - } catch (err) { - winston.error(`[flags/init] Could not retrieve filters\n${err.stack}`); - Flags._filters = {}; - } + // Query plugins for custom filter strategies and merge into core filter strategies + function prepareSets(sets, orSets, prefix, value) { + if (!Array.isArray(value)) { + sets.push(prefix + value); + } else if (value.length > 0) { + if (value.length === 1) { + sets.push(prefix + value[0]); + } else { + orSets.push(value.map(x => prefix + x)); + } + } + } + + const hookData = { + filters: { + type(sets, orSets, key) { + prepareSets(sets, orSets, 'flags:byType:', key); + }, + state(sets, orSets, key) { + prepareSets(sets, orSets, 'flags:byState:', key); + }, + reporterId(sets, orSets, key) { + prepareSets(sets, orSets, 'flags:byReporter:', key); + }, + assignee(sets, orSets, key) { + prepareSets(sets, orSets, 'flags:byAssignee:', key); + }, + targetUid(sets, orSets, key) { + prepareSets(sets, orSets, 'flags:byTargetUid:', key); + }, + cid(sets, orSets, key) { + prepareSets(sets, orSets, 'flags:byCid:', key); + }, + page() { /* noop */ }, + perPage() { /* noop */ }, + quick(sets, orSets, key, uid) { + switch (key) { + case 'mine': { + sets.push(`flags:byAssignee:${uid}`); + break; + } + + case 'unresolved': { + prepareSets(sets, orSets, 'flags:byState:', ['open', 'wip']); + break; + } + } + }, + }, + states: Flags._states, + helpers: { + prepareSets, + }, + }; + + try { + ({filters: Flags._filters} = await plugins.hooks.fire('filter:flags.getFilters', hookData)); + ({filters: Flags._filters, states: Flags._states} = await plugins.hooks.fire('filter:flags.init', hookData)); + } catch (error) { + winston.error(`[flags/init] Could not retrieve filters\n${error.stack}`); + Flags._filters = {}; + } }; Flags.get = async function (flagId) { - const [base, notes, reports] = await Promise.all([ - db.getObject(`flag:${flagId}`), - Flags.getNotes(flagId), - Flags.getReports(flagId), - ]); - if (!base) { - return; - } - const flagObj = { - state: 'open', - assignee: null, - ...base, - datetimeISO: utils.toISOString(base.datetime), - target_readable: `${base.type.charAt(0).toUpperCase() + base.type.slice(1)} ${base.targetId}`, - target: await Flags.getTarget(base.type, base.targetId, 0), - notes, - reports, - }; - - const data = await plugins.hooks.fire('filter:flags.get', { - flag: flagObj, - }); - return data.flag; + const [base, notes, reports] = await Promise.all([ + db.getObject(`flag:${flagId}`), + Flags.getNotes(flagId), + Flags.getReports(flagId), + ]); + if (!base) { + return; + } + + const flagObject = { + state: 'open', + assignee: null, + ...base, + datetimeISO: utils.toISOString(base.datetime), + target_readable: `${base.type.charAt(0).toUpperCase() + base.type.slice(1)} ${base.targetId}`, + target: await Flags.getTarget(base.type, base.targetId, 0), + notes, + reports, + }; + + const data = await plugins.hooks.fire('filter:flags.get', { + flag: flagObject, + }); + return data.flag; }; -Flags.getCount = async function ({ uid, filters, query }) { - filters = filters || {}; - const flagIds = await Flags.getFlagIdsWithFilters({ filters, uid, query }); - return flagIds.length; +Flags.getCount = async function ({uid, filters, query}) { + filters ||= {}; + const flagIds = await Flags.getFlagIdsWithFilters({filters, uid, query}); + return flagIds.length; }; -Flags.getFlagIdsWithFilters = async function ({ filters, uid, query }) { - let sets = []; - const orSets = []; - - // Default filter - filters.page = filters.hasOwnProperty('page') ? Math.abs(parseInt(filters.page, 10) || 1) : 1; - filters.perPage = filters.hasOwnProperty('perPage') ? Math.abs(parseInt(filters.perPage, 10) || 20) : 20; - - for (const type of Object.keys(filters)) { - if (Flags._filters.hasOwnProperty(type)) { - Flags._filters[type](sets, orSets, filters[type], uid); - } else { - winston.warn(`[flags/list] No flag filter type found: ${type}`); - } - } - sets = (sets.length || orSets.length) ? sets : ['flags:datetime']; // No filter default - - let flagIds = []; - if (sets.length === 1) { - flagIds = await db.getSortedSetRevRange(sets[0], 0, -1); - } else if (sets.length > 1) { - flagIds = await db.getSortedSetRevIntersect({ sets: sets, start: 0, stop: -1, aggregate: 'MAX' }); - } - - if (orSets.length) { - let _flagIds = await Promise.all(orSets.map(async orSet => await db.getSortedSetRevUnion({ sets: orSet, start: 0, stop: -1, aggregate: 'MAX' }))); - - // Each individual orSet is ANDed together to construct the final list of flagIds - _flagIds = _.intersection(..._flagIds); - - // Merge with flagIds returned by sets - if (sets.length) { - // If flag ids are already present, return a subset of flags that are in both sets - flagIds = _.intersection(flagIds, _flagIds); - } else { - // Otherwise, return all flags returned via orSets - flagIds = _.union(flagIds, _flagIds); - } - } - - const result = await plugins.hooks.fire('filter:flags.getFlagIdsWithFilters', { - filters, - uid, - query, - flagIds, - }); - return result.flagIds; +Flags.getFlagIdsWithFilters = async function ({filters, uid, query}) { + let sets = []; + const orSets = []; + + // Default filter + filters.page = filters.hasOwnProperty('page') ? Math.abs(Number.parseInt(filters.page, 10) || 1) : 1; + filters.perPage = filters.hasOwnProperty('perPage') ? Math.abs(Number.parseInt(filters.perPage, 10) || 20) : 20; + + for (const type of Object.keys(filters)) { + if (Flags._filters.hasOwnProperty(type)) { + Flags._filters[type](sets, orSets, filters[type], uid); + } else { + winston.warn(`[flags/list] No flag filter type found: ${type}`); + } + } + + sets = (sets.length > 0 || orSets.length > 0) ? sets : ['flags:datetime']; // No filter default + + let flagIds = []; + if (sets.length === 1) { + flagIds = await db.getSortedSetRevRange(sets[0], 0, -1); + } else if (sets.length > 1) { + flagIds = await db.getSortedSetRevIntersect({ + sets, start: 0, stop: -1, aggregate: 'MAX', + }); + } + + if (orSets.length > 0) { + let _flagIds = await Promise.all(orSets.map(async orSet => await db.getSortedSetRevUnion({ + sets: orSet, start: 0, stop: -1, aggregate: 'MAX', + }))); + + // Each individual orSet is ANDed together to construct the final list of flagIds + _flagIds = _.intersection(..._flagIds); + + // Merge with flagIds returned by sets + if (sets.length > 0) { + // If flag ids are already present, return a subset of flags that are in both sets + flagIds = _.intersection(flagIds, _flagIds); + } else { + // Otherwise, return all flags returned via orSets + flagIds = _.union(flagIds, _flagIds); + } + } + + const result = await plugins.hooks.fire('filter:flags.getFlagIdsWithFilters', { + filters, + uid, + query, + flagIds, + }); + return result.flagIds; }; Flags.list = async function (data) { - const filters = data.filters || {}; - let flagIds = await Flags.getFlagIdsWithFilters({ - filters, - uid: data.uid, - query: data.query, - }); - flagIds = await Flags.sort(flagIds, data.sort); - - // Create subset for parsing based on page number (n=20) - const flagsPerPage = Math.abs(parseInt(filters.perPage, 10) || 1); - const pageCount = Math.ceil(flagIds.length / flagsPerPage); - flagIds = flagIds.slice((filters.page - 1) * flagsPerPage, filters.page * flagsPerPage); - - const reportCounts = await db.sortedSetsCard(flagIds.map(flagId => `flag:${flagId}:reports`)); - - const flags = await Promise.all(flagIds.map(async (flagId, idx) => { - let flagObj = await db.getObject(`flag:${flagId}`); - flagObj = { - state: 'open', - assignee: null, - heat: reportCounts[idx], - ...flagObj, - }; - flagObj.labelClass = Flags._states.get(flagObj.state).class; - - return Object.assign(flagObj, { - target_readable: `${flagObj.type.charAt(0).toUpperCase() + flagObj.type.slice(1)} ${flagObj.targetId}`, - datetimeISO: utils.toISOString(flagObj.datetime), - }); - })); - - const payload = await plugins.hooks.fire('filter:flags.list', { - flags: flags, - page: filters.page, - uid: data.uid, - }); - - return { - flags: payload.flags, - page: payload.page, - pageCount: pageCount, - }; + const filters = data.filters || {}; + let flagIds = await Flags.getFlagIdsWithFilters({ + filters, + uid: data.uid, + query: data.query, + }); + flagIds = await Flags.sort(flagIds, data.sort); + + // Create subset for parsing based on page number (n=20) + const flagsPerPage = Math.abs(Number.parseInt(filters.perPage, 10) || 1); + const pageCount = Math.ceil(flagIds.length / flagsPerPage); + flagIds = flagIds.slice((filters.page - 1) * flagsPerPage, filters.page * flagsPerPage); + + const reportCounts = await db.sortedSetsCard(flagIds.map(flagId => `flag:${flagId}:reports`)); + + const flags = await Promise.all(flagIds.map(async (flagId, index) => { + let flagObject = await db.getObject(`flag:${flagId}`); + flagObject = { + state: 'open', + assignee: null, + heat: reportCounts[index], + ...flagObject, + }; + flagObject.labelClass = Flags._states.get(flagObject.state).class; + + return Object.assign(flagObject, { + target_readable: `${flagObject.type.charAt(0).toUpperCase() + flagObject.type.slice(1)} ${flagObject.targetId}`, + datetimeISO: utils.toISOString(flagObject.datetime), + }); + })); + + const payload = await plugins.hooks.fire('filter:flags.list', { + flags, + page: filters.page, + uid: data.uid, + }); + + return { + flags: payload.flags, + page: payload.page, + pageCount, + }; }; Flags.sort = async function (flagIds, sort) { - const filterPosts = async (flagIds) => { - const keys = flagIds.map(id => `flag:${id}`); - const types = await db.getObjectsFields(keys, ['type']); - return flagIds.filter((id, idx) => types[idx].type === 'post'); - }; - - switch (sort) { - // 'newest' is not handled because that is default - case 'oldest': - flagIds = flagIds.reverse(); - break; - - case 'reports': { - const keys = flagIds.map(id => `flag:${id}:reports`); - const heat = await db.sortedSetsCard(keys); - const mapped = heat.map((el, i) => ({ - index: i, heat: el, - })); - mapped.sort((a, b) => b.heat - a.heat); - flagIds = mapped.map(obj => flagIds[obj.index]); - break; - } - - case 'upvotes': // fall-through - case 'downvotes': - case 'replies': { - flagIds = await filterPosts(flagIds); - const keys = flagIds.map(id => `flag:${id}`); - const pids = (await db.getObjectsFields(keys, ['targetId'])).map(obj => obj.targetId); - const votes = (await posts.getPostsFields(pids, [sort])).map(obj => parseInt(obj[sort], 10) || 0); - const sortRef = flagIds.reduce((memo, cur, idx) => { - memo[cur] = votes[idx]; - return memo; - }, {}); - - flagIds = flagIds.sort((a, b) => sortRef[b] - sortRef[a]); - } - } - - return flagIds; + const filterPosts = async flagIds => { + const keys = flagIds.map(id => `flag:${id}`); + const types = await db.getObjectsFields(keys, ['type']); + return flagIds.filter((id, index) => types[index].type === 'post'); + }; + + switch (sort) { + // 'newest' is not handled because that is default + case 'oldest': { + flagIds = flagIds.reverse(); + break; + } + + case 'reports': { + const keys = flagIds.map(id => `flag:${id}:reports`); + const heat = await db.sortedSetsCard(keys); + const mapped = heat.map((element, i) => ({ + index: i, heat: element, + })); + mapped.sort((a, b) => b.heat - a.heat); + flagIds = mapped.map(object => flagIds[object.index]); + break; + } + + case 'upvotes': // Fall-through + case 'downvotes': + case 'replies': { + flagIds = await filterPosts(flagIds); + const keys = flagIds.map(id => `flag:${id}`); + const pids = (await db.getObjectsFields(keys, ['targetId'])).map(object => object.targetId); + const votes = (await posts.getPostsFields(pids, [sort])).map(object => Number.parseInt(object[sort], 10) || 0); + const sortReference = flagIds.reduce((memo, current, index) => { + memo[current] = votes[index]; + return memo; + }, {}); + + flagIds = flagIds.sort((a, b) => sortReference[b] - sortReference[a]); + } + } + + return flagIds; }; Flags.validate = async function (payload) { - const [target, reporter] = await Promise.all([ - Flags.getTarget(payload.type, payload.id, payload.uid), - user.getUserData(payload.uid), - ]); - - if (!target) { - throw new Error('[[error:invalid-data]]'); - } else if (target.deleted) { - throw new Error('[[error:post-deleted]]'); - } else if (!reporter || !reporter.userslug) { - throw new Error('[[error:no-user]]'); - } else if (reporter.banned) { - throw new Error('[[error:user-banned]]'); - } - - // Disallow flagging of profiles/content of privileged users - const [targetPrivileged, reporterPrivileged] = await Promise.all([ - user.isPrivileged(target.uid), - user.isPrivileged(reporter.uid), - ]); - if (targetPrivileged && !reporterPrivileged) { - throw new Error('[[error:cant-flag-privileged]]'); - } - - if (payload.type === 'post') { - const editable = await privileges.posts.canEdit(payload.id, payload.uid); - if (!editable.flag && !meta.config['reputation:disabled'] && reporter.reputation < meta.config['min:rep:flag']) { - throw new Error(`[[error:not-enough-reputation-to-flag, ${meta.config['min:rep:flag']}]]`); - } - } else if (payload.type === 'user') { - if (parseInt(payload.id, 10) === parseInt(payload.uid, 10)) { - throw new Error('[[error:cant-flag-self]]'); - } - const editable = await privileges.users.canEdit(payload.uid, payload.id); - if (!editable && !meta.config['reputation:disabled'] && reporter.reputation < meta.config['min:rep:flag']) { - throw new Error(`[[error:not-enough-reputation-to-flag, ${meta.config['min:rep:flag']}]]`); - } - } else { - throw new Error('[[error:invalid-data]]'); - } + const [target, reporter] = await Promise.all([ + Flags.getTarget(payload.type, payload.id, payload.uid), + user.getUserData(payload.uid), + ]); + + if (!target) { + throw new Error('[[error:invalid-data]]'); + } else if (target.deleted) { + throw new Error('[[error:post-deleted]]'); + } else if (!reporter || !reporter.userslug) { + throw new Error('[[error:no-user]]'); + } else if (reporter.banned) { + throw new Error('[[error:user-banned]]'); + } + + // Disallow flagging of profiles/content of privileged users + const [targetPrivileged, reporterPrivileged] = await Promise.all([ + user.isPrivileged(target.uid), + user.isPrivileged(reporter.uid), + ]); + if (targetPrivileged && !reporterPrivileged) { + throw new Error('[[error:cant-flag-privileged]]'); + } + + if (payload.type === 'post') { + const editable = await privileges.posts.canEdit(payload.id, payload.uid); + if (!editable.flag && !meta.config['reputation:disabled'] && reporter.reputation < meta.config['min:rep:flag']) { + throw new Error(`[[error:not-enough-reputation-to-flag, ${meta.config['min:rep:flag']}]]`); + } + } else if (payload.type === 'user') { + if (Number.parseInt(payload.id, 10) === Number.parseInt(payload.uid, 10)) { + throw new Error('[[error:cant-flag-self]]'); + } + + const editable = await privileges.users.canEdit(payload.uid, payload.id); + if (!editable && !meta.config['reputation:disabled'] && reporter.reputation < meta.config['min:rep:flag']) { + throw new Error(`[[error:not-enough-reputation-to-flag, ${meta.config['min:rep:flag']}]]`); + } + } else { + throw new Error('[[error:invalid-data]]'); + } }; Flags.getNotes = async function (flagId) { - let notes = await db.getSortedSetRevRangeWithScores(`flag:${flagId}:notes`, 0, -1); - notes = await modifyNotes(notes); - return notes; + let notes = await db.getSortedSetRevRangeWithScores(`flag:${flagId}:notes`, 0, -1); + notes = await modifyNotes(notes); + return notes; }; Flags.getNote = async function (flagId, datetime) { - datetime = parseInt(datetime, 10); - if (isNaN(datetime)) { - throw new Error('[[error:invalid-data]]'); - } - - let notes = await db.getSortedSetRangeByScoreWithScores(`flag:${flagId}:notes`, 0, 1, datetime, datetime); - if (!notes.length) { - throw new Error('[[error:invalid-data]]'); - } - - notes = await modifyNotes(notes); - return notes[0]; + datetime = Number.parseInt(datetime, 10); + if (isNaN(datetime)) { + throw new TypeError('[[error:invalid-data]]'); + } + + let notes = await db.getSortedSetRangeByScoreWithScores(`flag:${flagId}:notes`, 0, 1, datetime, datetime); + if (notes.length === 0) { + throw new Error('[[error:invalid-data]]'); + } + + notes = await modifyNotes(notes); + return notes[0]; }; Flags.getFlagIdByTarget = async function (type, id) { - let method; - switch (type) { - case 'post': - method = posts.getPostField; - break; - - case 'user': - method = user.getUserField; - break; - - default: - throw new Error('[[error:invalid-data]]'); - } - - return await method(id, 'flagId'); + let method; + switch (type) { + case 'post': { + method = posts.getPostField; + break; + } + + case 'user': { + method = user.getUserField; + break; + } + + default: { + throw new Error('[[error:invalid-data]]'); + } + } + + return await method(id, 'flagId'); }; async function modifyNotes(notes) { - const uids = []; - notes = notes.map((note) => { - const noteObj = JSON.parse(note.value); - uids.push(noteObj[0]); - return { - uid: noteObj[0], - content: noteObj[1], - datetime: note.score, - datetimeISO: utils.toISOString(note.score), - }; - }); - const userData = await user.getUsersFields(uids, ['username', 'userslug', 'picture']); - return notes.map((note, idx) => { - note.user = userData[idx]; - note.content = validator.escape(note.content); - return note; - }); + const uids = []; + notes = notes.map(note => { + const noteObject = JSON.parse(note.value); + uids.push(noteObject[0]); + return { + uid: noteObject[0], + content: noteObject[1], + datetime: note.score, + datetimeISO: utils.toISOString(note.score), + }; + }); + const userData = await user.getUsersFields(uids, ['username', 'userslug', 'picture']); + return notes.map((note, index) => { + note.user = userData[index]; + note.content = validator.escape(note.content); + return note; + }); } Flags.deleteNote = async function (flagId, datetime) { - datetime = parseInt(datetime, 10); - if (isNaN(datetime)) { - throw new Error('[[error:invalid-data]]'); - } + datetime = Number.parseInt(datetime, 10); + if (isNaN(datetime)) { + throw new TypeError('[[error:invalid-data]]'); + } - const note = await db.getSortedSetRangeByScore(`flag:${flagId}:notes`, 0, 1, datetime, datetime); - if (!note.length) { - throw new Error('[[error:invalid-data]]'); - } + const note = await db.getSortedSetRangeByScore(`flag:${flagId}:notes`, 0, 1, datetime, datetime); + if (note.length === 0) { + throw new Error('[[error:invalid-data]]'); + } - await db.sortedSetRemove(`flag:${flagId}:notes`, note[0]); + await db.sortedSetRemove(`flag:${flagId}:notes`, note[0]); }; Flags.create = async function (type, id, uid, reason, timestamp, forceFlag = false) { - let doHistoryAppend = false; - if (!timestamp) { - timestamp = Date.now(); - doHistoryAppend = true; - } - const [flagExists, targetExists,, targetFlagged, targetUid, targetCid] = await Promise.all([ - // Sanity checks - Flags.exists(type, id, uid), - Flags.targetExists(type, id), - Flags.canFlag(type, id, uid, forceFlag), - Flags.targetFlagged(type, id), - - // Extra data for zset insertion - Flags.getTargetUid(type, id), - Flags.getTargetCid(type, id), - ]); - if (!forceFlag && flagExists) { - throw new Error(`[[error:${type}-already-flagged]]`); - } else if (!targetExists) { - throw new Error('[[error:invalid-data]]'); - } - - // If the flag already exists, just add the report - if (targetFlagged) { - const flagId = await Flags.getFlagIdByTarget(type, id); - await Promise.all([ - Flags.addReport(flagId, type, id, uid, reason, timestamp), - Flags.update(flagId, uid, { state: 'open' }), - ]); - - return await Flags.get(flagId); - } - - const flagId = await db.incrObjectField('global', 'nextFlagId'); - const batched = []; - - batched.push( - db.setObject(`flag:${flagId}`, { - flagId: flagId, - type: type, - targetId: id, - targetUid: targetUid, - datetime: timestamp, - }), - Flags.addReport(flagId, type, id, uid, reason, timestamp), - db.sortedSetAdd('flags:datetime', timestamp, flagId), // by time, the default - db.sortedSetAdd(`flags:byType:${type}`, timestamp, flagId), // by flag type - db.sortedSetIncrBy('flags:byTarget', 1, [type, id].join(':')), // by flag target (score is count) - analytics.increment('flags') // some fancy analytics - ); - - if (targetUid) { - batched.push(db.sortedSetAdd(`flags:byTargetUid:${targetUid}`, timestamp, flagId)); // by target uid - } - - if (targetCid) { - batched.push(db.sortedSetAdd(`flags:byCid:${targetCid}`, timestamp, flagId)); // by target cid - } - - if (type === 'post') { - batched.push( - db.sortedSetAdd(`flags:byPid:${id}`, timestamp, flagId), // by target pid - posts.setPostField(id, 'flagId', flagId) - ); - - if (targetUid && parseInt(targetUid, 10) !== parseInt(uid, 10)) { - batched.push(user.incrementUserFlagsBy(targetUid, 1)); - } - } else if (type === 'user') { - batched.push(user.setUserField(id, 'flagId', flagId)); - } - - // Run all the database calls in one single batched call... - await Promise.all(batched); - - if (doHistoryAppend) { - await Flags.update(flagId, uid, { state: 'open' }); - } - - const flagObj = await Flags.get(flagId); - - plugins.hooks.fire('action:flags.create', { flag: flagObj }); - return flagObj; + let doHistoryAppend = false; + if (!timestamp) { + timestamp = Date.now(); + doHistoryAppend = true; + } + + const [flagExists, targetExists,, targetFlagged, targetUid, targetCid] = await Promise.all([ + // Sanity checks + Flags.exists(type, id, uid), + Flags.targetExists(type, id), + Flags.canFlag(type, id, uid, forceFlag), + Flags.targetFlagged(type, id), + + // Extra data for zset insertion + Flags.getTargetUid(type, id), + Flags.getTargetCid(type, id), + ]); + if (!forceFlag && flagExists) { + throw new Error(`[[error:${type}-already-flagged]]`); + } else if (!targetExists) { + throw new Error('[[error:invalid-data]]'); + } + + // If the flag already exists, just add the report + if (targetFlagged) { + const flagId = await Flags.getFlagIdByTarget(type, id); + await Promise.all([ + Flags.addReport(flagId, type, id, uid, reason, timestamp), + Flags.update(flagId, uid, {state: 'open'}), + ]); + + return await Flags.get(flagId); + } + + const flagId = await db.incrObjectField('global', 'nextFlagId'); + const batched = []; + + batched.push( + db.setObject(`flag:${flagId}`, { + flagId, + type, + targetId: id, + targetUid, + datetime: timestamp, + }), + Flags.addReport(flagId, type, id, uid, reason, timestamp), + db.sortedSetAdd('flags:datetime', timestamp, flagId), // By time, the default + db.sortedSetAdd(`flags:byType:${type}`, timestamp, flagId), // By flag type + db.sortedSetIncrBy('flags:byTarget', 1, [type, id].join(':')), // By flag target (score is count) + analytics.increment('flags'), // Some fancy analytics + ); + + if (targetUid) { + batched.push(db.sortedSetAdd(`flags:byTargetUid:${targetUid}`, timestamp, flagId)); // By target uid + } + + if (targetCid) { + batched.push(db.sortedSetAdd(`flags:byCid:${targetCid}`, timestamp, flagId)); // By target cid + } + + if (type === 'post') { + batched.push( + db.sortedSetAdd(`flags:byPid:${id}`, timestamp, flagId), // By target pid + posts.setPostField(id, 'flagId', flagId), + ); + + if (targetUid && Number.parseInt(targetUid, 10) !== Number.parseInt(uid, 10)) { + batched.push(user.incrementUserFlagsBy(targetUid, 1)); + } + } else if (type === 'user') { + batched.push(user.setUserField(id, 'flagId', flagId)); + } + + // Run all the database calls in one single batched call... + await Promise.all(batched); + + if (doHistoryAppend) { + await Flags.update(flagId, uid, {state: 'open'}); + } + + const flagObject = await Flags.get(flagId); + + plugins.hooks.fire('action:flags.create', {flag: flagObject}); + return flagObject; }; Flags.purge = async function (flagIds) { - const flagData = (await db.getObjects(flagIds.map(flagId => `flag:${flagId}`))).filter(Boolean); - const postFlags = flagData.filter(flagObj => flagObj.type === 'post'); - const userFlags = flagData.filter(flagObj => flagObj.type === 'user'); - const assignedFlags = flagData.filter(flagObj => !!flagObj.assignee); - - const [allReports, cids] = await Promise.all([ - db.getSortedSetsMembers(flagData.map(flagObj => `flag:${flagObj.flagId}:reports`)), - categories.getAllCidsFromSet('categories:cid'), - ]); - const allReporterUids = allReports.map(flagReports => flagReports.map(report => report && report.split(';')[0])); - const removeReporters = []; - flagData.forEach((flagObj, i) => { - if (Array.isArray(allReporterUids[i])) { - allReporterUids[i].forEach((uid) => { - removeReporters.push([`flags:hash`, [flagObj.type, flagObj.targetId, uid].join(':')]); - removeReporters.push([`flags:byReporter:${uid}`, flagObj.flagId]); - }); - } - }); - await Promise.all([ - db.sortedSetRemoveBulk([ - ...flagData.map(flagObj => ([`flags:byType:${flagObj.type}`, flagObj.flagId])), - ...flagData.map(flagObj => ([`flags:byState:${flagObj.state}`, flagObj.flagId])), - ...removeReporters, - ...postFlags.map(flagObj => ([`flags:byPid:${flagObj.targetId}`, flagObj.flagId])), - ...assignedFlags.map(flagObj => ([`flags:byAssignee:${flagObj.assignee}`, flagObj.flagId])), - ...userFlags.map(flagObj => ([`flags:byTargetUid:${flagObj.targetUid}`, flagObj.flagId])), - ]), - db.deleteObjectFields(postFlags.map(flagObj => `post:${flagObj.targetId}`, ['flagId'])), - db.deleteObjectFields(userFlags.map(flagObj => `user:${flagObj.targetId}`, ['flagId'])), - db.deleteAll([ - ...flagIds.map(flagId => `flag:${flagId}`), - ...flagIds.map(flagId => `flag:${flagId}:notes`), - ...flagIds.map(flagId => `flag:${flagId}:reports`), - ...flagIds.map(flagId => `flag:${flagId}:history`), - ]), - db.sortedSetRemove(cids.map(cid => `flags:byCid:${cid}`), flagIds), - db.sortedSetRemove('flags:datetime', flagIds), - db.sortedSetRemove( - 'flags:byTarget', - flagData.map(flagObj => [flagObj.type, flagObj.targetId].join(':')) - ), - ]); + const flagData = (await db.getObjects(flagIds.map(flagId => `flag:${flagId}`))).filter(Boolean); + const postFlags = flagData.filter(flagObject => flagObject.type === 'post'); + const userFlags = flagData.filter(flagObject => flagObject.type === 'user'); + const assignedFlags = flagData.filter(flagObject => Boolean(flagObject.assignee)); + + const [allReports, cids] = await Promise.all([ + db.getSortedSetsMembers(flagData.map(flagObject => `flag:${flagObject.flagId}:reports`)), + categories.getAllCidsFromSet('categories:cid'), + ]); + const allReporterUids = allReports.map(flagReports => flagReports.map(report => report && report.split(';')[0])); + const removeReporters = []; + for (const [i, flagObject] of flagData.entries()) { + if (Array.isArray(allReporterUids[i])) { + for (const uid of allReporterUids[i]) { + removeReporters.push(['flags:hash', [flagObject.type, flagObject.targetId, uid].join(':')], [`flags:byReporter:${uid}`, flagObject.flagId]); + } + } + } + + await Promise.all([ + db.sortedSetRemoveBulk([ + ...flagData.map(flagObject => ([`flags:byType:${flagObject.type}`, flagObject.flagId])), + ...flagData.map(flagObject => ([`flags:byState:${flagObject.state}`, flagObject.flagId])), + ...removeReporters, + ...postFlags.map(flagObject => ([`flags:byPid:${flagObject.targetId}`, flagObject.flagId])), + ...assignedFlags.map(flagObject => ([`flags:byAssignee:${flagObject.assignee}`, flagObject.flagId])), + ...userFlags.map(flagObject => ([`flags:byTargetUid:${flagObject.targetUid}`, flagObject.flagId])), + ]), + db.deleteObjectFields(postFlags.map(flagObject => `post:${flagObject.targetId}`)), + db.deleteObjectFields(userFlags.map(flagObject => `user:${flagObject.targetId}`)), + db.deleteAll([ + ...flagIds.map(flagId => `flag:${flagId}`), + ...flagIds.map(flagId => `flag:${flagId}:notes`), + ...flagIds.map(flagId => `flag:${flagId}:reports`), + ...flagIds.map(flagId => `flag:${flagId}:history`), + ]), + db.sortedSetRemove(cids.map(cid => `flags:byCid:${cid}`), flagIds), + db.sortedSetRemove('flags:datetime', flagIds), + db.sortedSetRemove( + 'flags:byTarget', + flagData.map(flagObject => [flagObject.type, flagObject.targetId].join(':')), + ), + ]); }; Flags.getReports = async function (flagId) { - const payload = await db.getSortedSetRevRangeWithScores(`flag:${flagId}:reports`, 0, -1); - const [reports, uids] = payload.reduce((memo, cur) => { - const value = cur.value.split(';'); - memo[1].push(value.shift()); - cur.value = validator.escape(String(value.join(';'))); - memo[0].push(cur); - - return memo; - }, [[], []]); - - await Promise.all(reports.map(async (report, idx) => { - report.timestamp = report.score; - report.timestampISO = new Date(report.score).toISOString(); - delete report.score; - report.reporter = await user.getUserFields(uids[idx], ['username', 'userslug', 'picture', 'reputation']); - })); - - return reports; + const payload = await db.getSortedSetRevRangeWithScores(`flag:${flagId}:reports`, 0, -1); + const [reports, uids] = payload.reduce((memo, current) => { + const value = current.value.split(';'); + memo[1].push(value.shift()); + current.value = validator.escape(String(value.join(';'))); + memo[0].push(current); + + return memo; + }, [[], []]); + + await Promise.all(reports.map(async (report, index) => { + report.timestamp = report.score; + report.timestampISO = new Date(report.score).toISOString(); + delete report.score; + report.reporter = await user.getUserFields(uids[index], ['username', 'userslug', 'picture', 'reputation']); + })); + + return reports; }; Flags.addReport = async function (flagId, type, id, uid, reason, timestamp) { - await db.sortedSetAddBulk([ - [`flags:byReporter:${uid}`, timestamp, flagId], - [`flag:${flagId}:reports`, timestamp, [uid, reason].join(';')], + await db.sortedSetAddBulk([ + [`flags:byReporter:${uid}`, timestamp, flagId], + [`flag:${flagId}:reports`, timestamp, [uid, reason].join(';')], - ['flags:hash', flagId, [type, id, uid].join(':')], - ]); + ['flags:hash', flagId, [type, id, uid].join(':')], + ]); - plugins.hooks.fire('action:flags.addReport', { flagId, type, id, uid, reason, timestamp }); + plugins.hooks.fire('action:flags.addReport', { + flagId, type, id, uid, reason, timestamp, + }); }; Flags.exists = async function (type, id, uid) { - return await db.isSortedSetMember('flags:hash', [type, id, uid].join(':')); + return await db.isSortedSetMember('flags:hash', [type, id, uid].join(':')); }; Flags.canView = async (flagId, uid) => { - const exists = await db.isSortedSetMember('flags:datetime', flagId); - if (!exists) { - return false; - } + const exists = await db.isSortedSetMember('flags:datetime', flagId); + if (!exists) { + return false; + } - const [{ type, targetId }, isAdminOrGlobalMod] = await Promise.all([ - db.getObject(`flag:${flagId}`), - user.isAdminOrGlobalMod(uid), - ]); + const [{type, targetId}, isAdminOrGlobalModule] = await Promise.all([ + db.getObject(`flag:${flagId}`), + user.isAdminOrGlobalMod(uid), + ]); - if (type === 'post') { - const cid = await Flags.getTargetCid(type, targetId); - const isModerator = await user.isModerator(uid, cid); + if (type === 'post') { + const cid = await Flags.getTargetCid(type, targetId); + const isModerator = await user.isModerator(uid, cid); - return isAdminOrGlobalMod || isModerator; - } + return isAdminOrGlobalModule || isModerator; + } - return isAdminOrGlobalMod; + return isAdminOrGlobalModule; }; Flags.canFlag = async function (type, id, uid, skipLimitCheck = false) { - const limit = meta.config['flags:limitPerTarget']; - if (!skipLimitCheck && limit > 0) { - const score = await db.sortedSetScore('flags:byTarget', `${type}:${id}`); - if (score >= limit) { - throw new Error(`[[error:${type}-flagged-too-many-times]]`); - } - } - - const canRead = await privileges.posts.can('topics:read', id, uid); - switch (type) { - case 'user': - return true; - - case 'post': - if (!canRead) { - throw new Error('[[error:no-privileges]]'); - } - break; - - default: - throw new Error('[[error:invalid-data]]'); - } + const limit = meta.config['flags:limitPerTarget']; + if (!skipLimitCheck && limit > 0) { + const score = await db.sortedSetScore('flags:byTarget', `${type}:${id}`); + if (score >= limit) { + throw new Error(`[[error:${type}-flagged-too-many-times]]`); + } + } + + const canRead = await privileges.posts.can('topics:read', id, uid); + switch (type) { + case 'user': { + return true; + } + + case 'post': { + if (!canRead) { + throw new Error('[[error:no-privileges]]'); + } + + break; + } + + default: { + throw new Error('[[error:invalid-data]]'); + } + } }; Flags.getTarget = async function (type, id, uid) { - if (type === 'user') { - const userData = await user.getUserData(id); - return userData && userData.uid ? userData : {}; - } - if (type === 'post') { - let postData = await posts.getPostData(id); - if (!postData) { - return {}; - } - postData = await posts.parsePost(postData); - postData = await topics.addPostData([postData], uid); - return postData[0]; - } - throw new Error('[[error:invalid-data]]'); + if (type === 'user') { + const userData = await user.getUserData(id); + return userData && userData.uid ? userData : {}; + } + + if (type === 'post') { + let postData = await posts.getPostData(id); + if (!postData) { + return {}; + } + + postData = await posts.parsePost(postData); + postData = await topics.addPostData([postData], uid); + return postData[0]; + } + + throw new Error('[[error:invalid-data]]'); }; Flags.targetExists = async function (type, id) { - if (type === 'post') { - return await posts.exists(id); - } else if (type === 'user') { - return await user.exists(id); - } - throw new Error('[[error:invalid-data]]'); + if (type === 'post') { + return await posts.exists(id); + } + + if (type === 'user') { + return await user.exists(id); + } + + throw new Error('[[error:invalid-data]]'); }; Flags.targetFlagged = async function (type, id) { - return await db.sortedSetScore('flags:byTarget', [type, id].join(':')) >= 1; + return await db.sortedSetScore('flags:byTarget', [type, id].join(':')) >= 1; }; Flags.getTargetUid = async function (type, id) { - if (type === 'post') { - return await posts.getPostField(id, 'uid'); - } - return id; + if (type === 'post') { + return await posts.getPostField(id, 'uid'); + } + + return id; }; Flags.getTargetCid = async function (type, id) { - if (type === 'post') { - return await posts.getCidByPid(id); - } - return null; + if (type === 'post') { + return await posts.getCidByPid(id); + } + + return null; }; Flags.update = async function (flagId, uid, changeset) { - const current = await db.getObjectFields(`flag:${flagId}`, ['uid', 'state', 'assignee', 'type', 'targetId']); - if (!current.type) { - return; - } - const now = changeset.datetime || Date.now(); - const notifyAssignee = async function (assigneeId) { - if (assigneeId === '' || parseInt(uid, 10) === parseInt(assigneeId, 10)) { - return; - } - const notifObj = await notifications.create({ - type: 'my-flags', - bodyShort: `[[notifications:flag_assigned_to_you, ${flagId}]]`, - bodyLong: '', - path: `/flags/${flagId}`, - nid: `flags:assign:${flagId}:uid:${assigneeId}`, - from: uid, - }); - await notifications.push(notifObj, [assigneeId]); - }; - const isAssignable = async function (assigneeId) { - let allowed = false; - allowed = await user.isAdminOrGlobalMod(assigneeId); - - // Mods are also allowed to be assigned, if flag target is post in uid's moderated cid - if (!allowed && current.type === 'post') { - const cid = await posts.getCidByPid(current.targetId); - allowed = await user.isModerator(assigneeId, cid); - } - - return allowed; - }; - - // Retrieve existing flag data to compare for history-saving/reference purposes - const tasks = []; - for (const prop of Object.keys(changeset)) { - if (current[prop] === changeset[prop]) { - delete changeset[prop]; - } else if (prop === 'state') { - if (!Flags._states.has(changeset[prop])) { - delete changeset[prop]; - } else { - tasks.push(db.sortedSetAdd(`flags:byState:${changeset[prop]}`, now, flagId)); - tasks.push(db.sortedSetRemove(`flags:byState:${current[prop]}`, flagId)); - if (changeset[prop] === 'resolved' && meta.config['flags:actionOnResolve'] === 'rescind') { - tasks.push(notifications.rescind(`flag:${current.type}:${current.targetId}`)); - } - if (changeset[prop] === 'rejected' && meta.config['flags:actionOnReject'] === 'rescind') { - tasks.push(notifications.rescind(`flag:${current.type}:${current.targetId}`)); - } - } - } else if (prop === 'assignee') { - if (changeset[prop] === '') { - tasks.push(db.sortedSetRemove(`flags:byAssignee:${changeset[prop]}`, flagId)); - /* eslint-disable-next-line */ - } else if (!await isAssignable(parseInt(changeset[prop], 10))) { - delete changeset[prop]; - } else { - tasks.push(db.sortedSetAdd(`flags:byAssignee:${changeset[prop]}`, now, flagId)); - tasks.push(notifyAssignee(changeset[prop])); - } - } - } - - if (!Object.keys(changeset).length) { - return; - } - - tasks.push(db.setObject(`flag:${flagId}`, changeset)); - tasks.push(Flags.appendHistory(flagId, uid, changeset)); - await Promise.all(tasks); - - plugins.hooks.fire('action:flags.update', { flagId: flagId, changeset: changeset, uid: uid }); + const current = await db.getObjectFields(`flag:${flagId}`, ['uid', 'state', 'assignee', 'type', 'targetId']); + if (!current.type) { + return; + } + + const now = changeset.datetime || Date.now(); + const notifyAssignee = async function (assigneeId) { + if (assigneeId === '' || Number.parseInt(uid, 10) === Number.parseInt(assigneeId, 10)) { + return; + } + + const notificationObject = await notifications.create({ + type: 'my-flags', + bodyShort: `[[notifications:flag_assigned_to_you, ${flagId}]]`, + bodyLong: '', + path: `/flags/${flagId}`, + nid: `flags:assign:${flagId}:uid:${assigneeId}`, + from: uid, + }); + await notifications.push(notificationObject, [assigneeId]); + }; + + const isAssignable = async function (assigneeId) { + let allowed = false; + allowed = await user.isAdminOrGlobalMod(assigneeId); + + // Mods are also allowed to be assigned, if flag target is post in uid's moderated cid + if (!allowed && current.type === 'post') { + const cid = await posts.getCidByPid(current.targetId); + allowed = await user.isModerator(assigneeId, cid); + } + + return allowed; + }; + + // Retrieve existing flag data to compare for history-saving/reference purposes + const tasks = []; + for (const property of Object.keys(changeset)) { + if (current[property] === changeset[property]) { + delete changeset[property]; + } else if (property === 'state') { + if (Flags._states.has(changeset[property])) { + tasks.push(db.sortedSetAdd(`flags:byState:${changeset[property]}`, now, flagId)); + tasks.push(db.sortedSetRemove(`flags:byState:${current[property]}`, flagId)); + if (changeset[property] === 'resolved' && meta.config['flags:actionOnResolve'] === 'rescind') { + tasks.push(notifications.rescind(`flag:${current.type}:${current.targetId}`)); + } + + if (changeset[property] === 'rejected' && meta.config['flags:actionOnReject'] === 'rescind') { + tasks.push(notifications.rescind(`flag:${current.type}:${current.targetId}`)); + } + } else { + delete changeset[property]; + } + } else if (property === 'assignee') { + if (changeset[property] === '') { + tasks.push(db.sortedSetRemove(`flags:byAssignee:${changeset[property]}`, flagId)); + /* eslint-disable-next-line */ + } else if (!await isAssignable(parseInt(changeset[property], 10))) { + delete changeset[property]; + } else { + tasks.push(db.sortedSetAdd(`flags:byAssignee:${changeset[property]}`, now, flagId)); + tasks.push(notifyAssignee(changeset[property])); + } + } + } + + if (Object.keys(changeset).length === 0) { + return; + } + + tasks.push(db.setObject(`flag:${flagId}`, changeset)); + tasks.push(Flags.appendHistory(flagId, uid, changeset)); + await Promise.all(tasks); + + plugins.hooks.fire('action:flags.update', {flagId, changeset, uid}); }; Flags.resolveFlag = async function (type, id, uid) { - const flagId = await Flags.getFlagIdByTarget(type, id); - if (parseInt(flagId, 10)) { - await Flags.update(flagId, uid, { state: 'resolved' }); - } + const flagId = await Flags.getFlagIdByTarget(type, id); + if (Number.parseInt(flagId, 10)) { + await Flags.update(flagId, uid, {state: 'resolved'}); + } }; Flags.resolveUserPostFlags = async function (uid, callerUid) { - if (meta.config['flags:autoResolveOnBan']) { - await batch.processSortedSet(`uid:${uid}:posts`, async (pids) => { - let postData = await posts.getPostsFields(pids, ['pid', 'flagId']); - postData = postData.filter(p => p && p.flagId); - for (const postObj of postData) { - if (parseInt(postObj.flagId, 10)) { - // eslint-disable-next-line no-await-in-loop - await Flags.update(postObj.flagId, callerUid, { state: 'resolved' }); - } - } - }, { - batch: 500, - }); - } + if (meta.config['flags:autoResolveOnBan']) { + await batch.processSortedSet(`uid:${uid}:posts`, async pids => { + let postData = await posts.getPostsFields(pids, ['pid', 'flagId']); + postData = postData.filter(p => p && p.flagId); + for (const postObject of postData) { + if (Number.parseInt(postObject.flagId, 10)) { + // eslint-disable-next-line no-await-in-loop + await Flags.update(postObject.flagId, callerUid, {state: 'resolved'}); + } + } + }, { + batch: 500, + }); + } }; Flags.getHistory = async function (flagId) { - const uids = []; - let history = await db.getSortedSetRevRangeWithScores(`flag:${flagId}:history`, 0, -1); - const targetUid = await db.getObjectField(`flag:${flagId}`, 'targetUid'); - - history = history.map((entry) => { - entry.value = JSON.parse(entry.value); - - uids.push(entry.value[0]); - - // Deserialise changeset - const changeset = entry.value[1]; - if (changeset.hasOwnProperty('state')) { - changeset.state = changeset.state === undefined ? '' : `[[flags:state-${changeset.state}]]`; - } - - return { - uid: entry.value[0], - fields: changeset, - datetime: entry.score, - datetimeISO: utils.toISOString(entry.score), - }; - }); - - // Append ban history and username change data - history = await mergeBanHistory(history, targetUid, uids); - history = await mergeMuteHistory(history, targetUid, uids); - history = await mergeUsernameEmailChanges(history, targetUid, uids); - - const userData = await user.getUsersFields(uids, ['username', 'userslug', 'picture']); - history.forEach((event, idx) => { event.user = userData[idx]; }); - - // Resort by date - history = history.sort((a, b) => b.datetime - a.datetime); - - return history; + const uids = []; + let history = await db.getSortedSetRevRangeWithScores(`flag:${flagId}:history`, 0, -1); + const targetUid = await db.getObjectField(`flag:${flagId}`, 'targetUid'); + + history = history.map(entry => { + entry.value = JSON.parse(entry.value); + + uids.push(entry.value[0]); + + // Deserialise changeset + const changeset = entry.value[1]; + if (changeset.hasOwnProperty('state')) { + changeset.state = changeset.state === undefined ? '' : `[[flags:state-${changeset.state}]]`; + } + + return { + uid: entry.value[0], + fields: changeset, + datetime: entry.score, + datetimeISO: utils.toISOString(entry.score), + }; + }); + + // Append ban history and username change data + history = await mergeBanHistory(history, targetUid, uids); + history = await mergeMuteHistory(history, targetUid, uids); + history = await mergeUsernameEmailChanges(history, targetUid, uids); + + const userData = await user.getUsersFields(uids, ['username', 'userslug', 'picture']); + for (const [index, event] of history.entries()) { + event.user = userData[index]; + } + + // Resort by date + history = history.sort((a, b) => b.datetime - a.datetime); + + return history; }; Flags.appendHistory = async function (flagId, uid, changeset) { - const datetime = changeset.datetime || Date.now(); - delete changeset.datetime; - const payload = JSON.stringify([uid, changeset, datetime]); - await db.sortedSetAdd(`flag:${flagId}:history`, datetime, payload); + const datetime = changeset.datetime || Date.now(); + delete changeset.datetime; + const payload = JSON.stringify([uid, changeset, datetime]); + await db.sortedSetAdd(`flag:${flagId}:history`, datetime, payload); }; Flags.appendNote = async function (flagId, uid, note, datetime) { - if (datetime) { - try { - await Flags.deleteNote(flagId, datetime); - } catch (e) { - // Do not throw if note doesn't exist - if (!e.message === '[[error:invalid-data]]') { - throw e; - } - } - } - datetime = datetime || Date.now(); - - const payload = JSON.stringify([uid, note]); - await db.sortedSetAdd(`flag:${flagId}:notes`, datetime, payload); - await Flags.appendHistory(flagId, uid, { - notes: null, - datetime: datetime, - }); + if (datetime) { + try { + await Flags.deleteNote(flagId, datetime); + } catch (error) { + // Do not throw if note doesn't exist + if (!error.message === '[[error:invalid-data]]') { + throw error; + } + } + } + + datetime ||= Date.now(); + + const payload = JSON.stringify([uid, note]); + await db.sortedSetAdd(`flag:${flagId}:notes`, datetime, payload); + await Flags.appendHistory(flagId, uid, { + notes: null, + datetime, + }); }; -Flags.notify = async function (flagObj, uid, notifySelf = false) { - const [admins, globalMods] = await Promise.all([ - groups.getMembers('administrators', 0, -1), - groups.getMembers('Global Moderators', 0, -1), - ]); - let uids = admins.concat(globalMods); - let notifObj = null; - - const { displayname } = flagObj.reports[flagObj.reports.length - 1].reporter; - - if (flagObj.type === 'post') { - const [title, cid] = await Promise.all([ - topics.getTitleByPid(flagObj.targetId), - posts.getCidByPid(flagObj.targetId), - ]); - - const modUids = await categories.getModeratorUids([cid]); - const titleEscaped = utils.decodeHTMLEntities(title).replace(/%/g, '%').replace(/,/g, ','); - - notifObj = await notifications.create({ - type: 'new-post-flag', - bodyShort: `[[notifications:user_flagged_post_in, ${displayname}, ${titleEscaped}]]`, - bodyLong: await plugins.hooks.fire('filter:parse.raw', String(flagObj.description || '')), - pid: flagObj.targetId, - path: `/flags/${flagObj.flagId}`, - nid: `flag:post:${flagObj.targetId}`, - from: uid, - mergeId: `notifications:user_flagged_post_in|${flagObj.targetId}`, - topicTitle: title, - }); - uids = uids.concat(modUids[0]); - } else if (flagObj.type === 'user') { - const targetDisplayname = flagObj.target && flagObj.target.user ? flagObj.target.user.displayname : '[[global:guest]]'; - notifObj = await notifications.create({ - type: 'new-user-flag', - bodyShort: `[[notifications:user_flagged_user, ${displayname}, ${targetDisplayname}]]`, - bodyLong: await plugins.hooks.fire('filter:parse.raw', String(flagObj.description || '')), - path: `/flags/${flagObj.flagId}`, - nid: `flag:user:${flagObj.targetId}`, - from: uid, - mergeId: `notifications:user_flagged_user|${flagObj.targetId}`, - }); - } else { - throw new Error('[[error:invalid-data]]'); - } - - plugins.hooks.fire('action:flags.notify', { - flag: flagObj, - notification: notifObj, - from: uid, - to: uids, - }); - if (!notifySelf) { - uids = uids.filter(_uid => parseInt(_uid, 10) !== parseInt(uid, 10)); - } - await notifications.push(notifObj, uids); +Flags.notify = async function (flagObject, uid, notifySelf = false) { + const [admins, globalMods] = await Promise.all([ + groups.getMembers('administrators', 0, -1), + groups.getMembers('Global Moderators', 0, -1), + ]); + let uids = admins.concat(globalMods); + let notificationObject = null; + + const {displayname} = flagObject.reports.at(-1).reporter; + + if (flagObject.type === 'post') { + const [title, cid] = await Promise.all([ + topics.getTitleByPid(flagObject.targetId), + posts.getCidByPid(flagObject.targetId), + ]); + + const moduleUids = await categories.getModeratorUids([cid]); + const titleEscaped = utils.decodeHTMLEntities(title).replaceAll('%', '%').replaceAll(',', ','); + + notificationObject = await notifications.create({ + type: 'new-post-flag', + bodyShort: `[[notifications:user_flagged_post_in, ${displayname}, ${titleEscaped}]]`, + bodyLong: await plugins.hooks.fire('filter:parse.raw', String(flagObject.description || '')), + pid: flagObject.targetId, + path: `/flags/${flagObject.flagId}`, + nid: `flag:post:${flagObject.targetId}`, + from: uid, + mergeId: `notifications:user_flagged_post_in|${flagObject.targetId}`, + topicTitle: title, + }); + uids = uids.concat(moduleUids[0]); + } else if (flagObject.type === 'user') { + const targetDisplayname = flagObject.target && flagObject.target.user ? flagObject.target.user.displayname : '[[global:guest]]'; + notificationObject = await notifications.create({ + type: 'new-user-flag', + bodyShort: `[[notifications:user_flagged_user, ${displayname}, ${targetDisplayname}]]`, + bodyLong: await plugins.hooks.fire('filter:parse.raw', String(flagObject.description || '')), + path: `/flags/${flagObject.flagId}`, + nid: `flag:user:${flagObject.targetId}`, + from: uid, + mergeId: `notifications:user_flagged_user|${flagObject.targetId}`, + }); + } else { + throw new Error('[[error:invalid-data]]'); + } + + plugins.hooks.fire('action:flags.notify', { + flag: flagObject, + notification: notificationObject, + from: uid, + to: uids, + }); + if (!notifySelf) { + uids = uids.filter(_uid => Number.parseInt(_uid, 10) !== Number.parseInt(uid, 10)); + } + + await notifications.push(notificationObject, uids); }; async function mergeBanHistory(history, targetUid, uids) { - return await mergeBanMuteHistory(history, uids, { - set: `uid:${targetUid}:bans:timestamp`, - label: '[[user:banned]]', - reasonDefault: '[[user:info.banned-no-reason]]', - expiryKey: '[[user:info.banned-expiry]]', - }); + return await mergeBanMuteHistory(history, uids, { + set: `uid:${targetUid}:bans:timestamp`, + label: '[[user:banned]]', + reasonDefault: '[[user:info.banned-no-reason]]', + expiryKey: '[[user:info.banned-expiry]]', + }); } async function mergeMuteHistory(history, targetUid, uids) { - return await mergeBanMuteHistory(history, uids, { - set: `uid:${targetUid}:mutes:timestamp`, - label: '[[user:muted]]', - reasonDefault: '[[user:info.muted-no-reason]]', - expiryKey: '[[user:info.muted-expiry]]', - }); + return await mergeBanMuteHistory(history, uids, { + set: `uid:${targetUid}:mutes:timestamp`, + label: '[[user:muted]]', + reasonDefault: '[[user:info.muted-no-reason]]', + expiryKey: '[[user:info.muted-expiry]]', + }); } -async function mergeBanMuteHistory(history, uids, params) { - let recentObjs = await db.getSortedSetRevRange(params.set, 0, 19); - recentObjs = await db.getObjects(recentObjs); - - return history.concat(recentObjs.reduce((memo, cur) => { - uids.push(cur.fromUid); - memo.push({ - uid: cur.fromUid, - meta: [ - { - key: params.label, - value: validator.escape(String(cur.reason || params.reasonDefault)), - labelClass: 'danger', - }, - { - key: params.expiryKey, - value: new Date(parseInt(cur.expire, 10)).toISOString(), - labelClass: 'default', - }, - ], - datetime: parseInt(cur.timestamp, 10), - datetimeISO: utils.toISOString(parseInt(cur.timestamp, 10)), - }); - - return memo; - }, [])); +async function mergeBanMuteHistory(history, uids, parameters) { + let recentObjs = await db.getSortedSetRevRange(parameters.set, 0, 19); + recentObjs = await db.getObjects(recentObjs); + + return history.concat(recentObjs.reduce((memo, current) => { + uids.push(current.fromUid); + memo.push({ + uid: current.fromUid, + meta: [ + { + key: parameters.label, + value: validator.escape(String(current.reason || parameters.reasonDefault)), + labelClass: 'danger', + }, + { + key: parameters.expiryKey, + value: new Date(Number.parseInt(current.expire, 10)).toISOString(), + labelClass: 'default', + }, + ], + datetime: Number.parseInt(current.timestamp, 10), + datetimeISO: utils.toISOString(Number.parseInt(current.timestamp, 10)), + }); + + return memo; + }, [])); } async function mergeUsernameEmailChanges(history, targetUid, uids) { - const usernameChanges = await user.getHistory(`user:${targetUid}:usernames`); - const emailChanges = await user.getHistory(`user:${targetUid}:emails`); - - return history.concat(usernameChanges.reduce((memo, changeObj) => { - uids.push(targetUid); - memo.push({ - uid: targetUid, - meta: [ - { - key: '[[user:change_username]]', - value: changeObj.value, - labelClass: 'primary', - }, - ], - datetime: changeObj.timestamp, - datetimeISO: changeObj.timestampISO, - }); - - return memo; - }, [])).concat(emailChanges.reduce((memo, changeObj) => { - uids.push(targetUid); - memo.push({ - uid: targetUid, - meta: [ - { - key: '[[user:change_email]]', - value: changeObj.value, - labelClass: 'primary', - }, - ], - datetime: changeObj.timestamp, - datetimeISO: changeObj.timestampISO, - }); - - return memo; - }, [])); + const usernameChanges = await user.getHistory(`user:${targetUid}:usernames`); + const emailChanges = await user.getHistory(`user:${targetUid}:emails`); + + return history.concat(usernameChanges.reduce((memo, changeObject) => { + uids.push(targetUid); + memo.push({ + uid: targetUid, + meta: [ + { + key: '[[user:change_username]]', + value: changeObject.value, + labelClass: 'primary', + }, + ], + datetime: changeObject.timestamp, + datetimeISO: changeObject.timestampISO, + }); + + return memo; + }, [])).concat(emailChanges.reduce((memo, changeObject) => { + uids.push(targetUid); + memo.push({ + uid: targetUid, + meta: [ + { + key: '[[user:change_email]]', + value: changeObject.value, + labelClass: 'primary', + }, + ], + datetime: changeObject.timestamp, + datetimeISO: changeObject.timestampISO, + }); + + return memo; + }, [])); } require('./promisify')(Flags); diff --git a/src/groups/cache.js b/src/groups/cache.js index 86262ed..3bb377f 100644 --- a/src/groups/cache.js +++ b/src/groups/cache.js @@ -3,17 +3,18 @@ const cacheCreate = require('../cache/lru'); module.exports = function (Groups) { - Groups.cache = cacheCreate({ - name: 'group', - max: 40000, - ttl: 0, - }); + Groups.cache = cacheCreate({ + name: 'group', + max: 40_000, + ttl: 0, + }); - Groups.clearCache = function (uid, groupNames) { - if (!Array.isArray(groupNames)) { - groupNames = [groupNames]; - } - const keys = groupNames.map(name => `${uid}:${name}`); - Groups.cache.del(keys); - }; + Groups.clearCache = function (uid, groupNames) { + if (!Array.isArray(groupNames)) { + groupNames = [groupNames]; + } + + const keys = groupNames.map(name => `${uid}:${name}`); + Groups.cache.del(keys); + }; }; diff --git a/src/groups/cover.js b/src/groups/cover.js index b8d16c8..534eb7f 100644 --- a/src/groups/cover.js +++ b/src/groups/cover.js @@ -1,80 +1,79 @@ 'use strict'; -const path = require('path'); - +const path = require('node:path'); const nconf = require('nconf'); - const db = require('../database'); const image = require('../image'); const file = require('../file'); module.exports = function (Groups) { - const allowedTypes = ['image/png', 'image/jpeg', 'image/bmp']; - Groups.updateCoverPosition = async function (groupName, position) { - if (!groupName) { - throw new Error('[[error:invalid-data]]'); - } - await Groups.setGroupField(groupName, 'cover:position', position); - }; + const allowedTypes = new Set(['image/png', 'image/jpeg', 'image/bmp']); + Groups.updateCoverPosition = async function (groupName, position) { + if (!groupName) { + throw new Error('[[error:invalid-data]]'); + } + + await Groups.setGroupField(groupName, 'cover:position', position); + }; + + Groups.updateCover = async function (uid, data) { + let temporaryPath = data.file ? data.file.path : ''; + try { + // Position only? That's fine + if (!data.imageData && !data.file && data.position) { + return await Groups.updateCoverPosition(data.groupName, data.position); + } + + const type = data.file ? data.file.type : image.mimeFromBase64(data.imageData); + if (!type || !allowedTypes.has(type)) { + throw new Error('[[error:invalid-image]]'); + } - Groups.updateCover = async function (uid, data) { - let tempPath = data.file ? data.file.path : ''; - try { - // Position only? That's fine - if (!data.imageData && !data.file && data.position) { - return await Groups.updateCoverPosition(data.groupName, data.position); - } - const type = data.file ? data.file.type : image.mimeFromBase64(data.imageData); - if (!type || !allowedTypes.includes(type)) { - throw new Error('[[error:invalid-image]]'); - } + temporaryPath ||= await image.writeImageDataToTempFile(data.imageData); - if (!tempPath) { - tempPath = await image.writeImageDataToTempFile(data.imageData); - } + const filename = `groupCover-${data.groupName}${path.extname(temporaryPath)}`; + const uploadData = await image.uploadImage(filename, 'files', { + path: temporaryPath, + uid, + name: 'groupCover', + }); + const {url} = uploadData; + await Groups.setGroupField(data.groupName, 'cover:url', url); - const filename = `groupCover-${data.groupName}${path.extname(tempPath)}`; - const uploadData = await image.uploadImage(filename, 'files', { - path: tempPath, - uid: uid, - name: 'groupCover', - }); - const { url } = uploadData; - await Groups.setGroupField(data.groupName, 'cover:url', url); + await image.resizeImage({ + path: temporaryPath, + width: 358, + }); + const thumbUploadData = await image.uploadImage(`groupCoverThumb-${data.groupName}${path.extname(temporaryPath)}`, 'files', { + path: temporaryPath, + uid, + name: 'groupCover', + }); + await Groups.setGroupField(data.groupName, 'cover:thumb:url', thumbUploadData.url); - await image.resizeImage({ - path: tempPath, - width: 358, - }); - const thumbUploadData = await image.uploadImage(`groupCoverThumb-${data.groupName}${path.extname(tempPath)}`, 'files', { - path: tempPath, - uid: uid, - name: 'groupCover', - }); - await Groups.setGroupField(data.groupName, 'cover:thumb:url', thumbUploadData.url); + if (data.position) { + await Groups.updateCoverPosition(data.groupName, data.position); + } - if (data.position) { - await Groups.updateCoverPosition(data.groupName, data.position); - } + return {url}; + } finally { + file.delete(temporaryPath); + } + }; - return { url: url }; - } finally { - file.delete(tempPath); - } - }; + Groups.removeCover = async function (data) { + const fields = ['cover:url', 'cover:thumb:url']; + const values = await Groups.getGroupFields(data.groupName, fields); + await Promise.all(fields.map(field => { + if (!values[field] || !values[field].startsWith(`${nconf.get('relative_path')}/assets/uploads/files/`)) { + return; + } - Groups.removeCover = async function (data) { - const fields = ['cover:url', 'cover:thumb:url']; - const values = await Groups.getGroupFields(data.groupName, fields); - await Promise.all(fields.map((field) => { - if (!values[field] || !values[field].startsWith(`${nconf.get('relative_path')}/assets/uploads/files/`)) { - return; - } - const filename = values[field].split('/').pop(); - const filePath = path.join(nconf.get('upload_path'), 'files', filename); - return file.delete(filePath); - })); + const filename = values[field].split('/').pop(); + const filePath = path.join(nconf.get('upload_path'), 'files', filename); + return file.delete(filePath); + })); - await db.deleteObjectFields(`group:${data.groupName}`, ['cover:url', 'cover:thumb:url', 'cover:position']); - }; + await db.deleteObjectFields(`group:${data.groupName}`, ['cover:url', 'cover:thumb:url', 'cover:position']); + }; }; diff --git a/src/groups/create.js b/src/groups/create.js index da538aa..49a523b 100644 --- a/src/groups/create.js +++ b/src/groups/create.js @@ -6,90 +6,91 @@ const slugify = require('../slugify'); const db = require('../database'); module.exports = function (Groups) { - Groups.create = async function (data) { - const isSystem = isSystemGroup(data); - const timestamp = data.timestamp || Date.now(); - let disableJoinRequests = parseInt(data.disableJoinRequests, 10) === 1 ? 1 : 0; - if (data.name === 'administrators') { - disableJoinRequests = 1; - } - const disableLeave = parseInt(data.disableLeave, 10) === 1 ? 1 : 0; - const isHidden = parseInt(data.hidden, 10) === 1; - - Groups.validateGroupName(data.name); - - const exists = await meta.userOrGroupExists(data.name); - if (exists) { - throw new Error('[[error:group-already-exists]]'); - } - - const memberCount = data.hasOwnProperty('ownerUid') ? 1 : 0; - const isPrivate = data.hasOwnProperty('private') && data.private !== undefined ? parseInt(data.private, 10) === 1 : true; - let groupData = { - name: data.name, - slug: slugify(data.name), - createtime: timestamp, - userTitle: data.userTitle || data.name, - userTitleEnabled: parseInt(data.userTitleEnabled, 10) === 1 ? 1 : 0, - description: data.description || '', - memberCount: memberCount, - hidden: isHidden ? 1 : 0, - system: isSystem ? 1 : 0, - private: isPrivate ? 1 : 0, - disableJoinRequests: disableJoinRequests, - disableLeave: disableLeave, - }; - - await plugins.hooks.fire('filter:group.create', { group: groupData, data: data }); - - await db.sortedSetAdd('groups:createtime', groupData.createtime, groupData.name); - await db.setObject(`group:${groupData.name}`, groupData); - - if (data.hasOwnProperty('ownerUid')) { - await db.setAdd(`group:${groupData.name}:owners`, data.ownerUid); - await db.sortedSetAdd(`group:${groupData.name}:members`, timestamp, data.ownerUid); - } - - if (!isHidden && !isSystem) { - await db.sortedSetAddBulk([ - ['groups:visible:createtime', timestamp, groupData.name], - ['groups:visible:memberCount', groupData.memberCount, groupData.name], - ['groups:visible:name', 0, `${groupData.name.toLowerCase()}:${groupData.name}`], - ]); - } - - await db.setObjectField('groupslug:groupname', groupData.slug, groupData.name); - - groupData = await Groups.getGroupData(groupData.name); - plugins.hooks.fire('action:group.create', { group: groupData }); - return groupData; - }; - - function isSystemGroup(data) { - return data.system === true || parseInt(data.system, 10) === 1 || - Groups.systemGroups.includes(data.name) || - Groups.isPrivilegeGroup(data.name); - } - - Groups.validateGroupName = function (name) { - if (!name) { - throw new Error('[[error:group-name-too-short]]'); - } - - if (typeof name !== 'string') { - throw new Error('[[error:invalid-group-name]]'); - } - - if (!Groups.isPrivilegeGroup(name) && name.length > meta.config.maximumGroupNameLength) { - throw new Error('[[error:group-name-too-long]]'); - } - - if (name === 'guests' || (!Groups.isPrivilegeGroup(name) && name.includes(':'))) { - throw new Error('[[error:invalid-group-name]]'); - } - - if (name.includes('/') || !slugify(name)) { - throw new Error('[[error:invalid-group-name]]'); - } - }; + Groups.create = async function (data) { + const isSystem = isSystemGroup(data); + const timestamp = data.timestamp || Date.now(); + let disableJoinRequests = Number.parseInt(data.disableJoinRequests, 10) === 1 ? 1 : 0; + if (data.name === 'administrators') { + disableJoinRequests = 1; + } + + const disableLeave = Number.parseInt(data.disableLeave, 10) === 1 ? 1 : 0; + const isHidden = Number.parseInt(data.hidden, 10) === 1; + + Groups.validateGroupName(data.name); + + const exists = await meta.userOrGroupExists(data.name); + if (exists) { + throw new Error('[[error:group-already-exists]]'); + } + + const memberCount = data.hasOwnProperty('ownerUid') ? 1 : 0; + const isPrivate = data.hasOwnProperty('private') && data.private !== undefined ? Number.parseInt(data.private, 10) === 1 : true; + let groupData = { + name: data.name, + slug: slugify(data.name), + createtime: timestamp, + userTitle: data.userTitle || data.name, + userTitleEnabled: Number.parseInt(data.userTitleEnabled, 10) === 1 ? 1 : 0, + description: data.description || '', + memberCount, + hidden: isHidden ? 1 : 0, + system: isSystem ? 1 : 0, + private: isPrivate ? 1 : 0, + disableJoinRequests, + disableLeave, + }; + + await plugins.hooks.fire('filter:group.create', {group: groupData, data}); + + await db.sortedSetAdd('groups:createtime', groupData.createtime, groupData.name); + await db.setObject(`group:${groupData.name}`, groupData); + + if (data.hasOwnProperty('ownerUid')) { + await db.setAdd(`group:${groupData.name}:owners`, data.ownerUid); + await db.sortedSetAdd(`group:${groupData.name}:members`, timestamp, data.ownerUid); + } + + if (!isHidden && !isSystem) { + await db.sortedSetAddBulk([ + ['groups:visible:createtime', timestamp, groupData.name], + ['groups:visible:memberCount', groupData.memberCount, groupData.name], + ['groups:visible:name', 0, `${groupData.name.toLowerCase()}:${groupData.name}`], + ]); + } + + await db.setObjectField('groupslug:groupname', groupData.slug, groupData.name); + + groupData = await Groups.getGroupData(groupData.name); + plugins.hooks.fire('action:group.create', {group: groupData}); + return groupData; + }; + + function isSystemGroup(data) { + return data.system === true || Number.parseInt(data.system, 10) === 1 + || Groups.systemGroups.includes(data.name) + || Groups.isPrivilegeGroup(data.name); + } + + Groups.validateGroupName = function (name) { + if (!name) { + throw new Error('[[error:group-name-too-short]]'); + } + + if (typeof name !== 'string') { + throw new TypeError('[[error:invalid-group-name]]'); + } + + if (!Groups.isPrivilegeGroup(name) && name.length > meta.config.maximumGroupNameLength) { + throw new Error('[[error:group-name-too-long]]'); + } + + if (name === 'guests' || (!Groups.isPrivilegeGroup(name) && name.includes(':'))) { + throw new Error('[[error:invalid-group-name]]'); + } + + if (name.includes('/') || !slugify(name)) { + throw new Error('[[error:invalid-group-name]]'); + } + }; }; diff --git a/src/groups/data.js b/src/groups/data.js index 78f0c57..d3b0642 100644 --- a/src/groups/data.js +++ b/src/groups/data.js @@ -2,107 +2,115 @@ const validator = require('validator'); const nconf = require('nconf'); - const db = require('../database'); const plugins = require('../plugins'); const utils = require('../utils'); const translator = require('../translator'); const intFields = [ - 'createtime', 'memberCount', 'hidden', 'system', 'private', - 'userTitleEnabled', 'disableJoinRequests', 'disableLeave', + 'createtime', + 'memberCount', + 'hidden', + 'system', + 'private', + 'userTitleEnabled', + 'disableJoinRequests', + 'disableLeave', ]; module.exports = function (Groups) { - Groups.getGroupsFields = async function (groupNames, fields) { - if (!Array.isArray(groupNames) || !groupNames.length) { - return []; - } - - const ephemeralIdx = groupNames.reduce((memo, cur, idx) => { - if (Groups.ephemeralGroups.includes(cur)) { - memo.push(idx); - } - return memo; - }, []); - - const keys = groupNames.map(groupName => `group:${groupName}`); - const groupData = await db.getObjects(keys, fields); - if (ephemeralIdx.length) { - ephemeralIdx.forEach((idx) => { - groupData[idx] = Groups.getEphemeralGroup(groupNames[idx]); - }); - } - - groupData.forEach(group => modifyGroup(group, fields)); - - const results = await plugins.hooks.fire('filter:groups.get', { groups: groupData }); - return results.groups; - }; - - Groups.getGroupsData = async function (groupNames) { - return await Groups.getGroupsFields(groupNames, []); - }; - - Groups.getGroupData = async function (groupName) { - const groupsData = await Groups.getGroupsData([groupName]); - return Array.isArray(groupsData) && groupsData[0] ? groupsData[0] : null; - }; - - Groups.getGroupField = async function (groupName, field) { - const groupData = await Groups.getGroupFields(groupName, [field]); - return groupData ? groupData[field] : null; - }; - - Groups.getGroupFields = async function (groupName, fields) { - const groups = await Groups.getGroupsFields([groupName], fields); - return groups ? groups[0] : null; - }; - - Groups.setGroupField = async function (groupName, field, value) { - await db.setObjectField(`group:${groupName}`, field, value); - plugins.hooks.fire('action:group.set', { field: field, value: value, type: 'set' }); - }; + Groups.getGroupsFields = async function (groupNames, fields) { + if (!Array.isArray(groupNames) || groupNames.length === 0) { + return []; + } + + const ephemeralIndex = groupNames.reduce((memo, current, index) => { + if (Groups.ephemeralGroups.includes(current)) { + memo.push(index); + } + + return memo; + }, []); + + const keys = groupNames.map(groupName => `group:${groupName}`); + const groupData = await db.getObjects(keys, fields); + if (ephemeralIndex.length > 0) { + for (const index of ephemeralIndex) { + groupData[index] = Groups.getEphemeralGroup(groupNames[index]); + } + } + + for (const group of groupData) { + modifyGroup(group, fields); + } + + const results = await plugins.hooks.fire('filter:groups.get', {groups: groupData}); + return results.groups; + }; + + Groups.getGroupsData = async function (groupNames) { + return await Groups.getGroupsFields(groupNames, []); + }; + + Groups.getGroupData = async function (groupName) { + const groupsData = await Groups.getGroupsData([groupName]); + return Array.isArray(groupsData) && groupsData[0] ? groupsData[0] : null; + }; + + Groups.getGroupField = async function (groupName, field) { + const groupData = await Groups.getGroupFields(groupName, [field]); + return groupData ? groupData[field] : null; + }; + + Groups.getGroupFields = async function (groupName, fields) { + const groups = await Groups.getGroupsFields([groupName], fields); + return groups ? groups[0] : null; + }; + + Groups.setGroupField = async function (groupName, field, value) { + await db.setObjectField(`group:${groupName}`, field, value); + plugins.hooks.fire('action:group.set', {field, value, type: 'set'}); + }; }; function modifyGroup(group, fields) { - if (group) { - db.parseIntFields(group, intFields, fields); - - escapeGroupData(group); - group.userTitleEnabled = ([null, undefined].includes(group.userTitleEnabled)) ? 1 : group.userTitleEnabled; - group.labelColor = validator.escape(String(group.labelColor || '#000000')); - group.textColor = validator.escape(String(group.textColor || '#ffffff')); - group.icon = validator.escape(String(group.icon || '')); - group.createtimeISO = utils.toISOString(group.createtime); - group.private = ([null, undefined].includes(group.private)) ? 1 : group.private; - group.memberPostCids = group.memberPostCids || ''; - group.memberPostCidsArray = group.memberPostCids.split(',').map(cid => parseInt(cid, 10)).filter(Boolean); - - group['cover:thumb:url'] = group['cover:thumb:url'] || group['cover:url']; - - if (group['cover:url']) { - group['cover:url'] = group['cover:url'].startsWith('http') ? group['cover:url'] : (nconf.get('relative_path') + group['cover:url']); - } else { - group['cover:url'] = require('../coverPhoto').getDefaultGroupCover(group.name); - } - - if (group['cover:thumb:url']) { - group['cover:thumb:url'] = group['cover:thumb:url'].startsWith('http') ? group['cover:thumb:url'] : (nconf.get('relative_path') + group['cover:thumb:url']); - } else { - group['cover:thumb:url'] = require('../coverPhoto').getDefaultGroupCover(group.name); - } - - group['cover:position'] = validator.escape(String(group['cover:position'] || '50% 50%')); - } + if (group) { + db.parseIntFields(group, intFields, fields); + + escapeGroupData(group); + group.userTitleEnabled = ([null, undefined].includes(group.userTitleEnabled)) ? 1 : group.userTitleEnabled; + group.labelColor = validator.escape(String(group.labelColor || '#000000')); + group.textColor = validator.escape(String(group.textColor || '#ffffff')); + group.icon = validator.escape(String(group.icon || '')); + group.createtimeISO = utils.toISOString(group.createtime); + group.private = ([null, undefined].includes(group.private)) ? 1 : group.private; + group.memberPostCids = group.memberPostCids || ''; + group.memberPostCidsArray = group.memberPostCids.split(',').map(cid => Number.parseInt(cid, 10)).filter(Boolean); + + group['cover:thumb:url'] = group['cover:thumb:url'] || group['cover:url']; + + if (group['cover:url']) { + group['cover:url'] = group['cover:url'].startsWith('http') ? group['cover:url'] : (nconf.get('relative_path') + group['cover:url']); + } else { + group['cover:url'] = require('../coverPhoto').getDefaultGroupCover(group.name); + } + + if (group['cover:thumb:url']) { + group['cover:thumb:url'] = group['cover:thumb:url'].startsWith('http') ? group['cover:thumb:url'] : (nconf.get('relative_path') + group['cover:thumb:url']); + } else { + group['cover:thumb:url'] = require('../coverPhoto').getDefaultGroupCover(group.name); + } + + group['cover:position'] = validator.escape(String(group['cover:position'] || '50% 50%')); + } } function escapeGroupData(group) { - if (group) { - group.nameEncoded = encodeURIComponent(group.name); - group.displayName = validator.escape(String(group.name)); - group.description = validator.escape(String(group.description || '')); - group.userTitle = validator.escape(String(group.userTitle || '')); - group.userTitleEscaped = translator.escape(group.userTitle); - } + if (group) { + group.nameEncoded = encodeURIComponent(group.name); + group.displayName = validator.escape(String(group.name)); + group.description = validator.escape(String(group.description || '')); + group.userTitle = validator.escape(String(group.userTitle || '')); + group.userTitleEscaped = translator.escape(group.userTitle); + } } diff --git a/src/groups/delete.js b/src/groups/delete.js index 1b2eb36..935f7c3 100644 --- a/src/groups/delete.js +++ b/src/groups/delete.js @@ -6,52 +6,54 @@ const db = require('../database'); const batch = require('../batch'); module.exports = function (Groups) { - Groups.destroy = async function (groupNames) { - if (!Array.isArray(groupNames)) { - groupNames = [groupNames]; - } + Groups.destroy = async function (groupNames) { + if (!Array.isArray(groupNames)) { + groupNames = [groupNames]; + } - let groupsData = await Groups.getGroupsData(groupNames); - groupsData = groupsData.filter(Boolean); - if (!groupsData.length) { - return; - } - const keys = []; - groupNames.forEach((groupName) => { - keys.push( - `group:${groupName}`, - `group:${groupName}:members`, - `group:${groupName}:pending`, - `group:${groupName}:invited`, - `group:${groupName}:owners`, - `group:${groupName}:member:pids` - ); - }); - const sets = groupNames.map(groupName => `${groupName.toLowerCase()}:${groupName}`); - const fields = groupNames.map(groupName => slugify(groupName)); + let groupsData = await Groups.getGroupsData(groupNames); + groupsData = groupsData.filter(Boolean); + if (groupsData.length === 0) { + return; + } - await Promise.all([ - db.deleteAll(keys), - db.sortedSetRemove([ - 'groups:createtime', - 'groups:visible:createtime', - 'groups:visible:memberCount', - ], groupNames), - db.sortedSetRemove('groups:visible:name', sets), - db.deleteObjectFields('groupslug:groupname', fields), - removeGroupsFromPrivilegeGroups(groupNames), - ]); - Groups.cache.reset(); - plugins.hooks.fire('action:groups.destroy', { groups: groupsData }); - }; + const keys = []; + for (const groupName of groupNames) { + keys.push( + `group:${groupName}`, + `group:${groupName}:members`, + `group:${groupName}:pending`, + `group:${groupName}:invited`, + `group:${groupName}:owners`, + `group:${groupName}:member:pids`, + ); + } - async function removeGroupsFromPrivilegeGroups(groupNames) { - await batch.processSortedSet('groups:createtime', async (otherGroups) => { - const privilegeGroups = otherGroups.filter(group => Groups.isPrivilegeGroup(group)); - const keys = privilegeGroups.map(group => `group:${group}:members`); - await db.sortedSetRemove(keys, groupNames); - }, { - batch: 500, - }); - } + const sets = groupNames.map(groupName => `${groupName.toLowerCase()}:${groupName}`); + const fields = groupNames.map(groupName => slugify(groupName)); + + await Promise.all([ + db.deleteAll(keys), + db.sortedSetRemove([ + 'groups:createtime', + 'groups:visible:createtime', + 'groups:visible:memberCount', + ], groupNames), + db.sortedSetRemove('groups:visible:name', sets), + db.deleteObjectFields('groupslug:groupname', fields), + removeGroupsFromPrivilegeGroups(groupNames), + ]); + Groups.cache.reset(); + plugins.hooks.fire('action:groups.destroy', {groups: groupsData}); + }; + + async function removeGroupsFromPrivilegeGroups(groupNames) { + await batch.processSortedSet('groups:createtime', async otherGroups => { + const privilegeGroups = otherGroups.filter(group => Groups.isPrivilegeGroup(group)); + const keys = privilegeGroups.map(group => `group:${group}:members`); + await db.sortedSetRemove(keys, groupNames); + }, { + batch: 500, + }); + } }; diff --git a/src/groups/index.js b/src/groups/index.js index fa37b43..cbfc776 100644 --- a/src/groups/index.js +++ b/src/groups/index.js @@ -27,221 +27,226 @@ Groups.BANNED_USERS = 'banned-users'; Groups.ephemeralGroups = ['guests', 'spiders']; Groups.systemGroups = [ - 'registered-users', - 'verified-users', - 'unverified-users', - Groups.BANNED_USERS, - 'administrators', - 'Global Moderators', + 'registered-users', + 'verified-users', + 'unverified-users', + Groups.BANNED_USERS, + 'administrators', + 'Global Moderators', ]; Groups.getEphemeralGroup = function (groupName) { - return { - name: groupName, - slug: slugify(groupName), - description: '', - hidden: 0, - system: 1, - }; + return { + name: groupName, + slug: slugify(groupName), + description: '', + hidden: 0, + system: 1, + }; }; Groups.removeEphemeralGroups = function (groups) { - for (let x = groups.length; x >= 0; x -= 1) { - if (Groups.ephemeralGroups.includes(groups[x])) { - groups.splice(x, 1); - } - } + for (let x = groups.length; x >= 0; x -= 1) { + if (Groups.ephemeralGroups.includes(groups[x])) { + groups.splice(x, 1); + } + } - return groups; + return groups; }; const isPrivilegeGroupRegex = /^cid:\d+:privileges:[\w\-:]+$/; Groups.isPrivilegeGroup = function (groupName) { - return isPrivilegeGroupRegex.test(groupName); + return isPrivilegeGroupRegex.test(groupName); }; Groups.getGroupsFromSet = async function (set, start, stop) { - let groupNames; - if (set === 'groups:visible:name') { - groupNames = await db.getSortedSetRangeByLex(set, '-', '+', start, stop - start + 1); - } else { - groupNames = await db.getSortedSetRevRange(set, start, stop); - } - if (set === 'groups:visible:name') { - groupNames = groupNames.map(name => name.split(':')[1]); - } + let groupNames; + groupNames = await (set === 'groups:visible:name' ? db.getSortedSetRangeByLex(set, '-', '+', start, stop - start + 1) : db.getSortedSetRevRange(set, start, stop)); - return await Groups.getGroupsAndMembers(groupNames); + if (set === 'groups:visible:name') { + groupNames = groupNames.map(name => name.split(':')[1]); + } + + return await Groups.getGroupsAndMembers(groupNames); }; Groups.getGroupsBySort = async function (sort, start, stop) { - let set = 'groups:visible:name'; - if (sort === 'count') { - set = 'groups:visible:memberCount'; - } else if (sort === 'date') { - set = 'groups:visible:createtime'; - } - return await Groups.getGroupsFromSet(set, start, stop); + let set = 'groups:visible:name'; + if (sort === 'count') { + set = 'groups:visible:memberCount'; + } else if (sort === 'date') { + set = 'groups:visible:createtime'; + } + + return await Groups.getGroupsFromSet(set, start, stop); }; Groups.getNonPrivilegeGroups = async function (set, start, stop) { - let groupNames = await db.getSortedSetRevRange(set, start, stop); - groupNames = groupNames.concat(Groups.ephemeralGroups).filter(groupName => !Groups.isPrivilegeGroup(groupName)); - const groupsData = await Groups.getGroupsData(groupNames); - return groupsData.filter(Boolean); + let groupNames = await db.getSortedSetRevRange(set, start, stop); + groupNames = groupNames.concat(Groups.ephemeralGroups).filter(groupName => !Groups.isPrivilegeGroup(groupName)); + const groupsData = await Groups.getGroupsData(groupNames); + return groupsData.filter(Boolean); }; Groups.getGroups = async function (set, start, stop) { - return await db.getSortedSetRevRange(set, start, stop); + return await db.getSortedSetRevRange(set, start, stop); }; Groups.getGroupsAndMembers = async function (groupNames) { - const [groups, members] = await Promise.all([ - Groups.getGroupsData(groupNames), - Groups.getMemberUsers(groupNames, 0, 9), - ]); - groups.forEach((group, index) => { - if (group) { - group.members = members[index] || []; - group.truncated = group.memberCount > group.members.length; - } - }); - return groups; + const [groups, members] = await Promise.all([ + Groups.getGroupsData(groupNames), + Groups.getMemberUsers(groupNames, 0, 9), + ]); + for (const [index, group] of groups.entries()) { + if (group) { + group.members = members[index] || []; + group.truncated = group.memberCount > group.members.length; + } + } + + return groups; }; Groups.get = async function (groupName, options) { - if (!groupName) { - throw new Error('[[error:invalid-group]]'); - } - - let stop = -1; - - if (options.truncateUserList) { - stop = (parseInt(options.userListCount, 10) || 4) - 1; - } - - const [groupData, members, pending, invited, isMember, isPending, isInvited, isOwner] = await Promise.all([ - Groups.getGroupData(groupName), - Groups.getOwnersAndMembers(groupName, options.uid, 0, stop), - Groups.getUsersFromSet(`group:${groupName}:pending`, ['username', 'userslug', 'picture']), - Groups.getUsersFromSet(`group:${groupName}:invited`, ['username', 'userslug', 'picture']), - Groups.isMember(options.uid, groupName), - Groups.isPending(options.uid, groupName), - Groups.isInvited(options.uid, groupName), - Groups.ownership.isOwner(options.uid, groupName), - ]); - - if (!groupData) { - return null; - } - const descriptionParsed = await plugins.hooks.fire('filter:parse.raw', String(groupData.description || '')); - groupData.descriptionParsed = descriptionParsed; - groupData.members = members; - groupData.membersNextStart = stop + 1; - groupData.pending = pending.filter(Boolean); - groupData.invited = invited.filter(Boolean); - groupData.isMember = isMember; - groupData.isPending = isPending; - groupData.isInvited = isInvited; - groupData.isOwner = isOwner; - const results = await plugins.hooks.fire('filter:group.get', { group: groupData }); - return results.group; + if (!groupName) { + throw new Error('[[error:invalid-group]]'); + } + + let stop = -1; + + if (options.truncateUserList) { + stop = (Number.parseInt(options.userListCount, 10) || 4) - 1; + } + + const [groupData, members, pending, invited, isMember, isPending, isInvited, isOwner] = await Promise.all([ + Groups.getGroupData(groupName), + Groups.getOwnersAndMembers(groupName, options.uid, 0, stop), + Groups.getUsersFromSet(`group:${groupName}:pending`, ['username', 'userslug', 'picture']), + Groups.getUsersFromSet(`group:${groupName}:invited`, ['username', 'userslug', 'picture']), + Groups.isMember(options.uid, groupName), + Groups.isPending(options.uid, groupName), + Groups.isInvited(options.uid, groupName), + Groups.ownership.isOwner(options.uid, groupName), + ]); + + if (!groupData) { + return null; + } + + const descriptionParsed = await plugins.hooks.fire('filter:parse.raw', String(groupData.description || '')); + groupData.descriptionParsed = descriptionParsed; + groupData.members = members; + groupData.membersNextStart = stop + 1; + groupData.pending = pending.filter(Boolean); + groupData.invited = invited.filter(Boolean); + groupData.isMember = isMember; + groupData.isPending = isPending; + groupData.isInvited = isInvited; + groupData.isOwner = isOwner; + const results = await plugins.hooks.fire('filter:group.get', {group: groupData}); + return results.group; }; Groups.getOwners = async function (groupName) { - return await db.getSetMembers(`group:${groupName}:owners`); + return await db.getSetMembers(`group:${groupName}:owners`); }; Groups.getOwnersAndMembers = async function (groupName, uid, start, stop) { - const ownerUids = await db.getSetMembers(`group:${groupName}:owners`); - const countToReturn = stop - start + 1; - const ownerUidsOnPage = ownerUids.slice(start, stop !== -1 ? stop + 1 : undefined); - const owners = await user.getUsers(ownerUidsOnPage, uid); - owners.forEach((user) => { - if (user) { - user.isOwner = true; - } - }); - - let done = false; - let returnUsers = owners; - let memberStart = start - ownerUids.length; - let memberStop = memberStart + countToReturn - 1; - memberStart = Math.max(0, memberStart); - memberStop = Math.max(0, memberStop); - async function addMembers(start, stop) { - let batch = await user.getUsersFromSet(`group:${groupName}:members`, uid, start, stop); - if (!batch.length) { - done = true; - } - batch = batch.filter(user => user && user.uid && !ownerUids.includes(user.uid.toString())); - returnUsers = returnUsers.concat(batch); - } - - if (stop === -1) { - await addMembers(memberStart, -1); - } else { - while (returnUsers.length < countToReturn && !done) { - /* eslint-disable no-await-in-loop */ - await addMembers(memberStart, memberStop); - memberStart = memberStop + 1; - memberStop = memberStart + countToReturn - 1; - } - } - returnUsers = countToReturn > 0 ? returnUsers.slice(0, countToReturn) : returnUsers; - const result = await plugins.hooks.fire('filter:group.getOwnersAndMembers', { - users: returnUsers, - uid: uid, - start: start, - stop: stop, - }); - return result.users; + const ownerUids = await db.getSetMembers(`group:${groupName}:owners`); + const countToReturn = stop - start + 1; + const ownerUidsOnPage = ownerUids.slice(start, stop === -1 ? undefined : stop + 1); + const owners = await user.getUsers(ownerUidsOnPage, uid); + for (const user of owners) { + if (user) { + user.isOwner = true; + } + } + + let done = false; + let returnUsers = owners; + let memberStart = start - ownerUids.length; + let memberStop = memberStart + countToReturn - 1; + memberStart = Math.max(0, memberStart); + memberStop = Math.max(0, memberStop); + async function addMembers(start, stop) { + let batch = await user.getUsersFromSet(`group:${groupName}:members`, uid, start, stop); + if (batch.length === 0) { + done = true; + } + + batch = batch.filter(user => user && user.uid && !ownerUids.includes(user.uid.toString())); + returnUsers = returnUsers.concat(batch); + } + + if (stop === -1) { + await addMembers(memberStart, -1); + } else { + while (returnUsers.length < countToReturn && !done) { + /* eslint-disable no-await-in-loop */ + await addMembers(memberStart, memberStop); + memberStart = memberStop + 1; + memberStop = memberStart + countToReturn - 1; + } + } + + returnUsers = countToReturn > 0 ? returnUsers.slice(0, countToReturn) : returnUsers; + const result = await plugins.hooks.fire('filter:group.getOwnersAndMembers', { + users: returnUsers, + uid, + start, + stop, + }); + return result.users; }; Groups.getByGroupslug = async function (slug, options) { - options = options || {}; - const groupName = await db.getObjectField('groupslug:groupname', slug); - if (!groupName) { - throw new Error('[[error:no-group]]'); - } - return await Groups.get(groupName, options); + options ||= {}; + const groupName = await db.getObjectField('groupslug:groupname', slug); + if (!groupName) { + throw new Error('[[error:no-group]]'); + } + + return await Groups.get(groupName, options); }; Groups.getGroupNameByGroupSlug = async function (slug) { - return await db.getObjectField('groupslug:groupname', slug); + return await db.getObjectField('groupslug:groupname', slug); }; Groups.isPrivate = async function (groupName) { - return await isFieldOn(groupName, 'private'); + return await isFieldOn(groupName, 'private'); }; Groups.isHidden = async function (groupName) { - return await isFieldOn(groupName, 'hidden'); + return await isFieldOn(groupName, 'hidden'); }; async function isFieldOn(groupName, field) { - const value = await db.getObjectField(`group:${groupName}`, field); - return parseInt(value, 10) === 1; + const value = await db.getObjectField(`group:${groupName}`, field); + return Number.parseInt(value, 10) === 1; } Groups.exists = async function (name) { - if (Array.isArray(name)) { - const slugs = name.map(groupName => slugify(groupName)); - const isMembersOfRealGroups = await db.isSortedSetMembers('groups:createtime', name); - const isMembersOfEphemeralGroups = slugs.map(slug => Groups.ephemeralGroups.includes(slug)); - return name.map((n, index) => isMembersOfRealGroups[index] || isMembersOfEphemeralGroups[index]); - } - const slug = slugify(name); - const isMemberOfRealGroups = await db.isSortedSetMember('groups:createtime', name); - const isMemberOfEphemeralGroups = Groups.ephemeralGroups.includes(slug); - return isMemberOfRealGroups || isMemberOfEphemeralGroups; + if (Array.isArray(name)) { + const slugs = name.map(groupName => slugify(groupName)); + const isMembersOfRealGroups = await db.isSortedSetMembers('groups:createtime', name); + const isMembersOfEphemeralGroups = slugs.map(slug => Groups.ephemeralGroups.includes(slug)); + return name.map((n, index) => isMembersOfRealGroups[index] || isMembersOfEphemeralGroups[index]); + } + + const slug = slugify(name); + const isMemberOfRealGroups = await db.isSortedSetMember('groups:createtime', name); + const isMemberOfEphemeralGroups = Groups.ephemeralGroups.includes(slug); + return isMemberOfRealGroups || isMemberOfEphemeralGroups; }; Groups.existsBySlug = async function (slug) { - if (Array.isArray(slug)) { - return await db.isObjectFields('groupslug:groupname', slug); - } - return await db.isObjectField('groupslug:groupname', slug); + if (Array.isArray(slug)) { + return await db.isObjectFields('groupslug:groupname', slug); + } + + return await db.isObjectField('groupslug:groupname', slug); }; require('../promisify')(Groups); diff --git a/src/groups/invite.js b/src/groups/invite.js index 92f0983..383187b 100644 --- a/src/groups/invite.js +++ b/src/groups/invite.js @@ -1,7 +1,6 @@ 'use strict'; const _ = require('lodash'); - const db = require('../database'); const user = require('../user'); const slugify = require('../slugify'); @@ -9,109 +8,114 @@ const plugins = require('../plugins'); const notifications = require('../notifications'); module.exports = function (Groups) { - Groups.requestMembership = async function (groupName, uid) { - await inviteOrRequestMembership(groupName, uid, 'request'); - const { displayname } = await user.getUserFields(uid, ['username']); - - const [notification, owners] = await Promise.all([ - notifications.create({ - type: 'group-request-membership', - bodyShort: `[[groups:request.notification_title, ${displayname}]]`, - bodyLong: `[[groups:request.notification_text, ${displayname}, ${groupName}]]`, - nid: `group:${groupName}:uid:${uid}:request`, - path: `/groups/${slugify(groupName)}`, - from: uid, - }), - Groups.getOwners(groupName), - ]); - - await notifications.push(notification, owners); - }; - - Groups.acceptMembership = async function (groupName, uid) { - await db.setsRemove([`group:${groupName}:pending`, `group:${groupName}:invited`], uid); - await Groups.join(groupName, uid); - - const notification = await notifications.create({ - type: 'group-invite', - bodyShort: `[[groups:membership.accept.notification_title, ${groupName}]]`, - nid: `group:${groupName}:uid:${uid}:invite-accepted`, - path: `/groups/${slugify(groupName)}`, - }); - await notifications.push(notification, [uid]); - }; - - Groups.rejectMembership = async function (groupNames, uid) { - if (!Array.isArray(groupNames)) { - groupNames = [groupNames]; - } - const sets = []; - groupNames.forEach(groupName => sets.push(`group:${groupName}:pending`, `group:${groupName}:invited`)); - await db.setsRemove(sets, uid); - }; - - Groups.invite = async function (groupName, uids) { - uids = Array.isArray(uids) ? uids : [uids]; - uids = await inviteOrRequestMembership(groupName, uids, 'invite'); - - const notificationData = await Promise.all(uids.map(uid => notifications.create({ - type: 'group-invite', - bodyShort: `[[groups:invited.notification_title, ${groupName}]]`, - bodyLong: '', - nid: `group:${groupName}:uid:${uid}:invite`, - path: `/groups/${slugify(groupName)}`, - }))); - - await Promise.all(uids.map((uid, index) => notifications.push(notificationData[index], uid))); - }; - - async function inviteOrRequestMembership(groupName, uids, type) { - uids = Array.isArray(uids) ? uids : [uids]; - uids = uids.filter(uid => parseInt(uid, 10) > 0); - const [exists, isMember, isPending, isInvited] = await Promise.all([ - Groups.exists(groupName), - Groups.isMembers(uids, groupName), - Groups.isPending(uids, groupName), - Groups.isInvited(uids, groupName), - ]); - - if (!exists) { - throw new Error('[[error:no-group]]'); - } - - uids = uids.filter((uid, i) => !isMember[i] && ((type === 'invite' && !isInvited[i]) || (type === 'request' && !isPending[i]))); - - const set = type === 'invite' ? `group:${groupName}:invited` : `group:${groupName}:pending`; - await db.setAdd(set, uids); - const hookName = type === 'invite' ? 'inviteMember' : 'requestMembership'; - plugins.hooks.fire(`action:group.${hookName}`, { - groupName: groupName, - uids: uids, - }); - return uids; - } - - Groups.isInvited = async function (uids, groupName) { - return await checkInvitePending(uids, `group:${groupName}:invited`); - }; - - Groups.isPending = async function (uids, groupName) { - return await checkInvitePending(uids, `group:${groupName}:pending`); - }; - - async function checkInvitePending(uids, set) { - const isArray = Array.isArray(uids); - uids = isArray ? uids : [uids]; - const checkUids = uids.filter(uid => parseInt(uid, 10) > 0); - const isMembers = await db.isSetMembers(set, checkUids); - const map = _.zipObject(checkUids, isMembers); - return isArray ? uids.map(uid => !!map[uid]) : !!map[uids[0]]; - } - - Groups.getPending = async function (groupName) { - if (!groupName) { - return []; - } - return await db.getSetMembers(`group:${groupName}:pending`); - }; + Groups.requestMembership = async function (groupName, uid) { + await inviteOrRequestMembership(groupName, uid, 'request'); + const {displayname} = await user.getUserFields(uid, ['username']); + + const [notification, owners] = await Promise.all([ + notifications.create({ + type: 'group-request-membership', + bodyShort: `[[groups:request.notification_title, ${displayname}]]`, + bodyLong: `[[groups:request.notification_text, ${displayname}, ${groupName}]]`, + nid: `group:${groupName}:uid:${uid}:request`, + path: `/groups/${slugify(groupName)}`, + from: uid, + }), + Groups.getOwners(groupName), + ]); + + await notifications.push(notification, owners); + }; + + Groups.acceptMembership = async function (groupName, uid) { + await db.setsRemove([`group:${groupName}:pending`, `group:${groupName}:invited`], uid); + await Groups.join(groupName, uid); + + const notification = await notifications.create({ + type: 'group-invite', + bodyShort: `[[groups:membership.accept.notification_title, ${groupName}]]`, + nid: `group:${groupName}:uid:${uid}:invite-accepted`, + path: `/groups/${slugify(groupName)}`, + }); + await notifications.push(notification, [uid]); + }; + + Groups.rejectMembership = async function (groupNames, uid) { + if (!Array.isArray(groupNames)) { + groupNames = [groupNames]; + } + + const sets = []; + for (const groupName of groupNames) { + sets.push(`group:${groupName}:pending`, `group:${groupName}:invited`); + } + + await db.setsRemove(sets, uid); + }; + + Groups.invite = async function (groupName, uids) { + uids = Array.isArray(uids) ? uids : [uids]; + uids = await inviteOrRequestMembership(groupName, uids, 'invite'); + + const notificationData = await Promise.all(uids.map(uid => notifications.create({ + type: 'group-invite', + bodyShort: `[[groups:invited.notification_title, ${groupName}]]`, + bodyLong: '', + nid: `group:${groupName}:uid:${uid}:invite`, + path: `/groups/${slugify(groupName)}`, + }))); + + await Promise.all(uids.map((uid, index) => notifications.push(notificationData[index], uid))); + }; + + async function inviteOrRequestMembership(groupName, uids, type) { + uids = Array.isArray(uids) ? uids : [uids]; + uids = uids.filter(uid => Number.parseInt(uid, 10) > 0); + const [exists, isMember, isPending, isInvited] = await Promise.all([ + Groups.exists(groupName), + Groups.isMembers(uids, groupName), + Groups.isPending(uids, groupName), + Groups.isInvited(uids, groupName), + ]); + + if (!exists) { + throw new Error('[[error:no-group]]'); + } + + uids = uids.filter((uid, i) => !isMember[i] && ((type === 'invite' && !isInvited[i]) || (type === 'request' && !isPending[i]))); + + const set = type === 'invite' ? `group:${groupName}:invited` : `group:${groupName}:pending`; + await db.setAdd(set, uids); + const hookName = type === 'invite' ? 'inviteMember' : 'requestMembership'; + plugins.hooks.fire(`action:group.${hookName}`, { + groupName, + uids, + }); + return uids; + } + + Groups.isInvited = async function (uids, groupName) { + return await checkInvitePending(uids, `group:${groupName}:invited`); + }; + + Groups.isPending = async function (uids, groupName) { + return await checkInvitePending(uids, `group:${groupName}:pending`); + }; + + async function checkInvitePending(uids, set) { + const isArray = Array.isArray(uids); + uids = isArray ? uids : [uids]; + const checkUids = uids.filter(uid => Number.parseInt(uid, 10) > 0); + const isMembers = await db.isSetMembers(set, checkUids); + const map = _.zipObject(checkUids, isMembers); + return isArray ? uids.map(uid => Boolean(map[uid])) : Boolean(map[uids[0]]); + } + + Groups.getPending = async function (groupName) { + if (!groupName) { + return []; + } + + return await db.getSetMembers(`group:${groupName}:pending`); + }; }; diff --git a/src/groups/join.js b/src/groups/join.js index 1c08d4d..f3d1a1e 100644 --- a/src/groups/join.js +++ b/src/groups/join.js @@ -1,109 +1,111 @@ 'use strict'; const winston = require('winston'); - const db = require('../database'); const user = require('../user'); const plugins = require('../plugins'); const cache = require('../cache'); module.exports = function (Groups) { - Groups.join = async function (groupNames, uid) { - if (!groupNames) { - throw new Error('[[error:invalid-data]]'); - } - if (Array.isArray(groupNames) && !groupNames.length) { - return; - } - if (!Array.isArray(groupNames)) { - groupNames = [groupNames]; - } - - if (!uid) { - throw new Error('[[error:invalid-uid]]'); - } - - const [isMembers, exists, isAdmin] = await Promise.all([ - Groups.isMemberOfGroups(uid, groupNames), - Groups.exists(groupNames), - user.isAdministrator(uid), - ]); - - const groupsToCreate = groupNames.filter((groupName, index) => groupName && !exists[index]); - const groupsToJoin = groupNames.filter((groupName, index) => !isMembers[index]); - - if (!groupsToJoin.length) { - return; - } - await createNonExistingGroups(groupsToCreate); - - const promises = [ - db.sortedSetsAdd(groupsToJoin.map(groupName => `group:${groupName}:members`), Date.now(), uid), - db.incrObjectField(groupsToJoin.map(groupName => `group:${groupName}`), 'memberCount'), - ]; - if (isAdmin) { - promises.push(db.setsAdd(groupsToJoin.map(groupName => `group:${groupName}:owners`), uid)); - } - - await Promise.all(promises); - - Groups.clearCache(uid, groupsToJoin); - cache.del(groupsToJoin.map(name => `group:${name}:members`)); - - const groupData = await Groups.getGroupsFields(groupsToJoin, ['name', 'hidden', 'memberCount']); - const visibleGroups = groupData.filter(groupData => groupData && !groupData.hidden); - - if (visibleGroups.length) { - await db.sortedSetAdd( - 'groups:visible:memberCount', - visibleGroups.map(groupData => groupData.memberCount), - visibleGroups.map(groupData => groupData.name) - ); - } - - await setGroupTitleIfNotSet(groupsToJoin, uid); - - plugins.hooks.fire('action:group.join', { - groupNames: groupsToJoin, - uid: uid, - }); - }; - - async function createNonExistingGroups(groupsToCreate) { - if (!groupsToCreate.length) { - return; - } - - for (const groupName of groupsToCreate) { - try { - // eslint-disable-next-line no-await-in-loop - await Groups.create({ - name: groupName, - hidden: 1, - }); - } catch (err) { - if (err && err.message !== '[[error:group-already-exists]]') { - winston.error(`[groups.join] Could not create new hidden group (${groupName})\n${err.stack}`); - throw err; - } - } - } - } - - async function setGroupTitleIfNotSet(groupNames, uid) { - const ignore = ['registered-users', 'verified-users', 'unverified-users', Groups.BANNED_USERS]; - groupNames = groupNames.filter( - groupName => !ignore.includes(groupName) && !Groups.isPrivilegeGroup(groupName) - ); - if (!groupNames.length) { - return; - } - - const currentTitle = await db.getObjectField(`user:${uid}`, 'groupTitle'); - if (currentTitle || currentTitle === '') { - return; - } - - await user.setUserField(uid, 'groupTitle', JSON.stringify(groupNames)); - } + Groups.join = async function (groupNames, uid) { + if (!groupNames) { + throw new Error('[[error:invalid-data]]'); + } + + if (Array.isArray(groupNames) && groupNames.length === 0) { + return; + } + + if (!Array.isArray(groupNames)) { + groupNames = [groupNames]; + } + + if (!uid) { + throw new Error('[[error:invalid-uid]]'); + } + + const [isMembers, exists, isAdmin] = await Promise.all([ + Groups.isMemberOfGroups(uid, groupNames), + Groups.exists(groupNames), + user.isAdministrator(uid), + ]); + + const groupsToCreate = groupNames.filter((groupName, index) => groupName && !exists[index]); + const groupsToJoin = groupNames.filter((groupName, index) => !isMembers[index]); + + if (groupsToJoin.length === 0) { + return; + } + + await createNonExistingGroups(groupsToCreate); + + const promises = [ + db.sortedSetsAdd(groupsToJoin.map(groupName => `group:${groupName}:members`), Date.now(), uid), + db.incrObjectField(groupsToJoin.map(groupName => `group:${groupName}`), 'memberCount'), + ]; + if (isAdmin) { + promises.push(db.setsAdd(groupsToJoin.map(groupName => `group:${groupName}:owners`), uid)); + } + + await Promise.all(promises); + + Groups.clearCache(uid, groupsToJoin); + cache.del(groupsToJoin.map(name => `group:${name}:members`)); + + const groupData = await Groups.getGroupsFields(groupsToJoin, ['name', 'hidden', 'memberCount']); + const visibleGroups = groupData.filter(groupData => groupData && !groupData.hidden); + + if (visibleGroups.length > 0) { + await db.sortedSetAdd( + 'groups:visible:memberCount', + visibleGroups.map(groupData => groupData.memberCount), + visibleGroups.map(groupData => groupData.name), + ); + } + + await setGroupTitleIfNotSet(groupsToJoin, uid); + + plugins.hooks.fire('action:group.join', { + groupNames: groupsToJoin, + uid, + }); + }; + + async function createNonExistingGroups(groupsToCreate) { + if (groupsToCreate.length === 0) { + return; + } + + for (const groupName of groupsToCreate) { + try { + // eslint-disable-next-line no-await-in-loop + await Groups.create({ + name: groupName, + hidden: 1, + }); + } catch (error) { + if (error && error.message !== '[[error:group-already-exists]]') { + winston.error(`[groups.join] Could not create new hidden group (${groupName})\n${error.stack}`); + throw error; + } + } + } + } + + async function setGroupTitleIfNotSet(groupNames, uid) { + const ignore = new Set(['registered-users', 'verified-users', 'unverified-users', Groups.BANNED_USERS]); + groupNames = groupNames.filter( + groupName => !ignore.has(groupName) && !Groups.isPrivilegeGroup(groupName), + ); + if (groupNames.length === 0) { + return; + } + + const currentTitle = await db.getObjectField(`user:${uid}`, 'groupTitle'); + if (currentTitle || currentTitle === '') { + return; + } + + await user.setUserField(uid, 'groupTitle', JSON.stringify(groupNames)); + } }; diff --git a/src/groups/leave.js b/src/groups/leave.js index 4ad8441..8f1a844 100644 --- a/src/groups/leave.js +++ b/src/groups/leave.js @@ -6,95 +6,95 @@ const plugins = require('../plugins'); const cache = require('../cache'); module.exports = function (Groups) { - Groups.leave = async function (groupNames, uid) { - if (Array.isArray(groupNames) && !groupNames.length) { - return; - } - if (!Array.isArray(groupNames)) { - groupNames = [groupNames]; - } - - const isMembers = await Groups.isMemberOfGroups(uid, groupNames); - - const groupsToLeave = groupNames.filter((groupName, index) => isMembers[index]); - if (!groupsToLeave.length) { - return; - } - - await Promise.all([ - db.sortedSetRemove(groupsToLeave.map(groupName => `group:${groupName}:members`), uid), - db.setRemove(groupsToLeave.map(groupName => `group:${groupName}:owners`), uid), - db.decrObjectField(groupsToLeave.map(groupName => `group:${groupName}`), 'memberCount'), - ]); - - Groups.clearCache(uid, groupsToLeave); - cache.del(groupsToLeave.map(name => `group:${name}:members`)); - - const groupData = await Groups.getGroupsFields(groupsToLeave, ['name', 'hidden', 'memberCount']); - if (!groupData) { - return; - } - - const emptyPrivilegeGroups = groupData.filter(g => g && Groups.isPrivilegeGroup(g.name) && g.memberCount === 0); - const visibleGroups = groupData.filter(g => g && !g.hidden); - - const promises = []; - if (emptyPrivilegeGroups.length) { - promises.push(Groups.destroy, emptyPrivilegeGroups); - } - if (visibleGroups.length) { - promises.push( - db.sortedSetAdd, - 'groups:visible:memberCount', - visibleGroups.map(groupData => groupData.memberCount), - visibleGroups.map(groupData => groupData.name) - ); - } - - await Promise.all(promises); - - await clearGroupTitleIfSet(groupsToLeave, uid); - - plugins.hooks.fire('action:group.leave', { - groupNames: groupsToLeave, - uid: uid, - }); - }; - - async function clearGroupTitleIfSet(groupNames, uid) { - groupNames = groupNames.filter(groupName => groupName !== 'registered-users' && !Groups.isPrivilegeGroup(groupName)); - if (!groupNames.length) { - return; - } - const userData = await user.getUserData(uid); - if (!userData) { - return; - } - - const newTitleArray = userData.groupTitleArray.filter(groupTitle => !groupNames.includes(groupTitle)); - if (newTitleArray.length) { - await db.setObjectField(`user:${uid}`, 'groupTitle', JSON.stringify(newTitleArray)); - } else { - await db.deleteObjectField(`user:${uid}`, 'groupTitle'); - } - } - - Groups.leaveAllGroups = async function (uid) { - const groups = await db.getSortedSetRange('groups:createtime', 0, -1); - await Promise.all([ - Groups.leave(groups, uid), - Groups.rejectMembership(groups, uid), - ]); - }; - - Groups.kick = async function (uid, groupName, isOwner) { - if (isOwner) { - // If the owners set only contains one member, error out! - const numOwners = await db.setCount(`group:${groupName}:owners`); - if (numOwners <= 1) { - throw new Error('[[error:group-needs-owner]]'); - } - } - await Groups.leave(groupName, uid); - }; + Groups.leave = async function (groupNames, uid) { + if (Array.isArray(groupNames) && groupNames.length === 0) { + return; + } + + if (!Array.isArray(groupNames)) { + groupNames = [groupNames]; + } + + const isMembers = await Groups.isMemberOfGroups(uid, groupNames); + + const groupsToLeave = groupNames.filter((groupName, index) => isMembers[index]); + if (groupsToLeave.length === 0) { + return; + } + + await Promise.all([ + db.sortedSetRemove(groupsToLeave.map(groupName => `group:${groupName}:members`), uid), + db.setRemove(groupsToLeave.map(groupName => `group:${groupName}:owners`), uid), + db.decrObjectField(groupsToLeave.map(groupName => `group:${groupName}`), 'memberCount'), + ]); + + Groups.clearCache(uid, groupsToLeave); + cache.del(groupsToLeave.map(name => `group:${name}:members`)); + + const groupData = await Groups.getGroupsFields(groupsToLeave, ['name', 'hidden', 'memberCount']); + if (!groupData) { + return; + } + + const emptyPrivilegeGroups = groupData.filter(g => g && Groups.isPrivilegeGroup(g.name) && g.memberCount === 0); + const visibleGroups = groupData.filter(g => g && !g.hidden); + + const promises = []; + if (emptyPrivilegeGroups.length > 0) { + promises.push(Groups.destroy, emptyPrivilegeGroups); + } + + if (visibleGroups.length > 0) { + promises.push( + db.sortedSetAdd, + 'groups:visible:memberCount', + visibleGroups.map(groupData => groupData.memberCount), + visibleGroups.map(groupData => groupData.name), + ); + } + + await Promise.all(promises); + + await clearGroupTitleIfSet(groupsToLeave, uid); + + plugins.hooks.fire('action:group.leave', { + groupNames: groupsToLeave, + uid, + }); + }; + + async function clearGroupTitleIfSet(groupNames, uid) { + groupNames = groupNames.filter(groupName => groupName !== 'registered-users' && !Groups.isPrivilegeGroup(groupName)); + if (groupNames.length === 0) { + return; + } + + const userData = await user.getUserData(uid); + if (!userData) { + return; + } + + const newTitleArray = userData.groupTitleArray.filter(groupTitle => !groupNames.includes(groupTitle)); + await (newTitleArray.length > 0 ? db.setObjectField(`user:${uid}`, 'groupTitle', JSON.stringify(newTitleArray)) : db.deleteObjectField(`user:${uid}`, 'groupTitle')); + } + + Groups.leaveAllGroups = async function (uid) { + const groups = await db.getSortedSetRange('groups:createtime', 0, -1); + await Promise.all([ + Groups.leave(groups, uid), + Groups.rejectMembership(groups, uid), + ]); + }; + + Groups.kick = async function (uid, groupName, isOwner) { + if (isOwner) { + // If the owners set only contains one member, error out! + const numberOwners = await db.setCount(`group:${groupName}:owners`); + if (numberOwners <= 1) { + throw new Error('[[error:group-needs-owner]]'); + } + } + + await Groups.leave(groupName, uid); + }; }; diff --git a/src/groups/membership.js b/src/groups/membership.js index 472b215..66fcdda 100644 --- a/src/groups/membership.js +++ b/src/groups/membership.js @@ -1,175 +1,186 @@ 'use strict'; const _ = require('lodash'); - const db = require('../database'); const user = require('../user'); const cache = require('../cache'); module.exports = function (Groups) { - Groups.getMembers = async function (groupName, start, stop) { - return await db.getSortedSetRevRange(`group:${groupName}:members`, start, stop); - }; - - Groups.getMemberUsers = async function (groupNames, start, stop) { - async function get(groupName) { - const uids = await Groups.getMembers(groupName, start, stop); - return await user.getUsersFields(uids, ['uid', 'username', 'picture', 'userslug']); - } - return await Promise.all(groupNames.map(name => get(name))); - }; - - Groups.getMembersOfGroups = async function (groupNames) { - return await db.getSortedSetsMembers(groupNames.map(name => `group:${name}:members`)); - }; - - Groups.isMember = async function (uid, groupName) { - if (!uid || parseInt(uid, 10) <= 0 || !groupName) { - return false; - } - - const cacheKey = `${uid}:${groupName}`; - let isMember = Groups.cache.get(cacheKey); - if (isMember !== undefined) { - return isMember; - } - isMember = await db.isSortedSetMember(`group:${groupName}:members`, uid); - Groups.cache.set(cacheKey, isMember); - return isMember; - }; - - Groups.isMembers = async function (uids, groupName) { - if (!groupName || !uids.length) { - return uids.map(() => false); - } - - if (groupName === 'guests') { - return uids.map(uid => parseInt(uid, 10) === 0); - } - - const cachedData = {}; - const nonCachedUids = uids.filter(uid => filterNonCached(cachedData, uid, groupName)); - - if (!nonCachedUids.length) { - return uids.map(uid => cachedData[`${uid}:${groupName}`]); - } - - const isMembers = await db.isSortedSetMembers(`group:${groupName}:members`, nonCachedUids); - nonCachedUids.forEach((uid, index) => { - cachedData[`${uid}:${groupName}`] = isMembers[index]; - Groups.cache.set(`${uid}:${groupName}`, isMembers[index]); - }); - return uids.map(uid => cachedData[`${uid}:${groupName}`]); - }; - - Groups.isMemberOfGroups = async function (uid, groups) { - if (!uid || parseInt(uid, 10) <= 0 || !groups.length) { - return groups.map(groupName => groupName === 'guests'); - } - const cachedData = {}; - const nonCachedGroups = groups.filter(groupName => filterNonCached(cachedData, uid, groupName)); - - if (!nonCachedGroups.length) { - return groups.map(groupName => cachedData[`${uid}:${groupName}`]); - } - const nonCachedGroupsMemberSets = nonCachedGroups.map(groupName => `group:${groupName}:members`); - const isMembers = await db.isMemberOfSortedSets(nonCachedGroupsMemberSets, uid); - nonCachedGroups.forEach((groupName, index) => { - cachedData[`${uid}:${groupName}`] = isMembers[index]; - Groups.cache.set(`${uid}:${groupName}`, isMembers[index]); - }); - - return groups.map(groupName => cachedData[`${uid}:${groupName}`]); - }; - - function filterNonCached(cachedData, uid, groupName) { - const isMember = Groups.cache.get(`${uid}:${groupName}`); - const isInCache = isMember !== undefined; - if (isInCache) { - cachedData[`${uid}:${groupName}`] = isMember; - } - return !isInCache; - } - - Groups.isMemberOfAny = async function (uid, groups) { - if (!groups.length) { - return false; - } - const isMembers = await Groups.isMemberOfGroups(uid, groups); - return isMembers.includes(true); - }; - - Groups.getMemberCount = async function (groupName) { - const count = await db.getObjectField(`group:${groupName}`, 'memberCount'); - return parseInt(count, 10); - }; - - Groups.isMemberOfGroupList = async function (uid, groupListKey) { - let groupNames = await getGroupNames(groupListKey); - groupNames = Groups.removeEphemeralGroups(groupNames); - if (!groupNames.length) { - return false; - } - - const isMembers = await Groups.isMemberOfGroups(uid, groupNames); - return isMembers.includes(true); - }; - - Groups.isMemberOfGroupsList = async function (uid, groupListKeys) { - const members = await getGroupNames(groupListKeys); - - let uniqueGroups = _.uniq(_.flatten(members)); - uniqueGroups = Groups.removeEphemeralGroups(uniqueGroups); - - const isMembers = await Groups.isMemberOfGroups(uid, uniqueGroups); - const isGroupMember = _.zipObject(uniqueGroups, isMembers); - - return members.map(groupNames => !!groupNames.find(name => isGroupMember[name])); - }; - - Groups.isMembersOfGroupList = async function (uids, groupListKey) { - const results = uids.map(() => false); - - let groupNames = await getGroupNames(groupListKey); - groupNames = Groups.removeEphemeralGroups(groupNames); - if (!groupNames.length) { - return results; - } - const isGroupMembers = await Promise.all(groupNames.map(name => Groups.isMembers(uids, name))); - - isGroupMembers.forEach((isMembers) => { - results.forEach((isMember, index) => { - if (!isMember && isMembers[index]) { - results[index] = true; - } - }); - }); - return results; - }; - - async function getGroupNames(keys) { - const isArray = Array.isArray(keys); - keys = isArray ? keys : [keys]; - - const cachedData = {}; - const nonCachedKeys = keys.filter((groupName) => { - const groupMembers = cache.get(`group:${groupName}:members`); - const isInCache = groupMembers !== undefined; - if (isInCache) { - cachedData[groupName] = groupMembers; - } - return !isInCache; - }); - - if (!nonCachedKeys.length) { - return isArray ? keys.map(groupName => cachedData[groupName]) : cachedData[keys[0]]; - } - const groupMembers = await db.getSortedSetsMembers(nonCachedKeys.map(name => `group:${name}:members`)); - - nonCachedKeys.forEach((groupName, index) => { - cachedData[groupName] = groupMembers[index]; - cache.set(`group:${groupName}:members`, groupMembers[index]); - }); - return isArray ? keys.map(groupName => cachedData[groupName]) : cachedData[keys[0]]; - } + Groups.getMembers = async function (groupName, start, stop) { + return await db.getSortedSetRevRange(`group:${groupName}:members`, start, stop); + }; + + Groups.getMemberUsers = async function (groupNames, start, stop) { + async function get(groupName) { + const uids = await Groups.getMembers(groupName, start, stop); + return await user.getUsersFields(uids, ['uid', 'username', 'picture', 'userslug']); + } + + return await Promise.all(groupNames.map(name => get(name))); + }; + + Groups.getMembersOfGroups = async function (groupNames) { + return await db.getSortedSetsMembers(groupNames.map(name => `group:${name}:members`)); + }; + + Groups.isMember = async function (uid, groupName) { + if (!uid || Number.parseInt(uid, 10) <= 0 || !groupName) { + return false; + } + + const cacheKey = `${uid}:${groupName}`; + let isMember = Groups.cache.get(cacheKey); + if (isMember !== undefined) { + return isMember; + } + + isMember = await db.isSortedSetMember(`group:${groupName}:members`, uid); + Groups.cache.set(cacheKey, isMember); + return isMember; + }; + + Groups.isMembers = async function (uids, groupName) { + if (!groupName || uids.length === 0) { + return uids.map(() => false); + } + + if (groupName === 'guests') { + return uids.map(uid => Number.parseInt(uid, 10) === 0); + } + + const cachedData = {}; + const nonCachedUids = uids.filter(uid => filterNonCached(cachedData, uid, groupName)); + + if (nonCachedUids.length === 0) { + return uids.map(uid => cachedData[`${uid}:${groupName}`]); + } + + const isMembers = await db.isSortedSetMembers(`group:${groupName}:members`, nonCachedUids); + for (const [index, uid] of nonCachedUids.entries()) { + cachedData[`${uid}:${groupName}`] = isMembers[index]; + Groups.cache.set(`${uid}:${groupName}`, isMembers[index]); + } + + return uids.map(uid => cachedData[`${uid}:${groupName}`]); + }; + + Groups.isMemberOfGroups = async function (uid, groups) { + if (!uid || Number.parseInt(uid, 10) <= 0 || groups.length === 0) { + return groups.map(groupName => groupName === 'guests'); + } + + const cachedData = {}; + const nonCachedGroups = groups.filter(groupName => filterNonCached(cachedData, uid, groupName)); + + if (nonCachedGroups.length === 0) { + return groups.map(groupName => cachedData[`${uid}:${groupName}`]); + } + + const nonCachedGroupsMemberSets = nonCachedGroups.map(groupName => `group:${groupName}:members`); + const isMembers = await db.isMemberOfSortedSets(nonCachedGroupsMemberSets, uid); + for (const [index, groupName] of nonCachedGroups.entries()) { + cachedData[`${uid}:${groupName}`] = isMembers[index]; + Groups.cache.set(`${uid}:${groupName}`, isMembers[index]); + } + + return groups.map(groupName => cachedData[`${uid}:${groupName}`]); + }; + + function filterNonCached(cachedData, uid, groupName) { + const isMember = Groups.cache.get(`${uid}:${groupName}`); + const isInCache = isMember !== undefined; + if (isInCache) { + cachedData[`${uid}:${groupName}`] = isMember; + } + + return !isInCache; + } + + Groups.isMemberOfAny = async function (uid, groups) { + if (groups.length === 0) { + return false; + } + + const isMembers = await Groups.isMemberOfGroups(uid, groups); + return isMembers.includes(true); + }; + + Groups.getMemberCount = async function (groupName) { + const count = await db.getObjectField(`group:${groupName}`, 'memberCount'); + return Number.parseInt(count, 10); + }; + + Groups.isMemberOfGroupList = async function (uid, groupListKey) { + let groupNames = await getGroupNames(groupListKey); + groupNames = Groups.removeEphemeralGroups(groupNames); + if (groupNames.length === 0) { + return false; + } + + const isMembers = await Groups.isMemberOfGroups(uid, groupNames); + return isMembers.includes(true); + }; + + Groups.isMemberOfGroupsList = async function (uid, groupListKeys) { + const members = await getGroupNames(groupListKeys); + + let uniqueGroups = _.uniq(members.flat()); + uniqueGroups = Groups.removeEphemeralGroups(uniqueGroups); + + const isMembers = await Groups.isMemberOfGroups(uid, uniqueGroups); + const isGroupMember = _.zipObject(uniqueGroups, isMembers); + + return members.map(groupNames => Boolean(groupNames.find(name => isGroupMember[name]))); + }; + + Groups.isMembersOfGroupList = async function (uids, groupListKey) { + const results = uids.map(() => false); + + let groupNames = await getGroupNames(groupListKey); + groupNames = Groups.removeEphemeralGroups(groupNames); + if (groupNames.length === 0) { + return results; + } + + const isGroupMembers = await Promise.all(groupNames.map(name => Groups.isMembers(uids, name))); + + for (const isMembers of isGroupMembers) { + for (const [index, isMember] of results.entries()) { + if (!isMember && isMembers[index]) { + results[index] = true; + } + } + } + + return results; + }; + + async function getGroupNames(keys) { + const isArray = Array.isArray(keys); + keys = isArray ? keys : [keys]; + + const cachedData = {}; + const nonCachedKeys = keys.filter(groupName => { + const groupMembers = cache.get(`group:${groupName}:members`); + const isInCache = groupMembers !== undefined; + if (isInCache) { + cachedData[groupName] = groupMembers; + } + + return !isInCache; + }); + + if (nonCachedKeys.length === 0) { + return isArray ? keys.map(groupName => cachedData[groupName]) : cachedData[keys[0]]; + } + + const groupMembers = await db.getSortedSetsMembers(nonCachedKeys.map(name => `group:${name}:members`)); + + for (const [index, groupName] of nonCachedKeys.entries()) { + cachedData[groupName] = groupMembers[index]; + cache.set(`group:${groupName}:members`, groupMembers[index]); + } + + return isArray ? keys.map(groupName => cachedData[groupName]) : cachedData[keys[0]]; + } }; diff --git a/src/groups/ownership.js b/src/groups/ownership.js index 02fc1d0..82fdb75 100644 --- a/src/groups/ownership.js +++ b/src/groups/ownership.js @@ -4,36 +4,38 @@ const db = require('../database'); const plugins = require('../plugins'); module.exports = function (Groups) { - Groups.ownership = {}; - - Groups.ownership.isOwner = async function (uid, groupName) { - if (!(parseInt(uid, 10) > 0)) { - return false; - } - return await db.isSetMember(`group:${groupName}:owners`, uid); - }; - - Groups.ownership.isOwners = async function (uids, groupName) { - if (!Array.isArray(uids)) { - return []; - } - - return await db.isSetMembers(`group:${groupName}:owners`, uids); - }; - - Groups.ownership.grant = async function (toUid, groupName) { - await db.setAdd(`group:${groupName}:owners`, toUid); - plugins.hooks.fire('action:group.grantOwnership', { uid: toUid, groupName: groupName }); - }; - - Groups.ownership.rescind = async function (toUid, groupName) { - // If the owners set only contains one member (and toUid is that member), error out! - const numOwners = await db.setCount(`group:${groupName}:owners`); - const isOwner = await db.isSortedSetMember(`group:${groupName}:owners`); - if (numOwners <= 1 && isOwner) { - throw new Error('[[error:group-needs-owner]]'); - } - await db.setRemove(`group:${groupName}:owners`, toUid); - plugins.hooks.fire('action:group.rescindOwnership', { uid: toUid, groupName: groupName }); - }; + Groups.ownership = {}; + + Groups.ownership.isOwner = async function (uid, groupName) { + if (!(Number.parseInt(uid, 10) > 0)) { + return false; + } + + return await db.isSetMember(`group:${groupName}:owners`, uid); + }; + + Groups.ownership.isOwners = async function (uids, groupName) { + if (!Array.isArray(uids)) { + return []; + } + + return await db.isSetMembers(`group:${groupName}:owners`, uids); + }; + + Groups.ownership.grant = async function (toUid, groupName) { + await db.setAdd(`group:${groupName}:owners`, toUid); + plugins.hooks.fire('action:group.grantOwnership', {uid: toUid, groupName}); + }; + + Groups.ownership.rescind = async function (toUid, groupName) { + // If the owners set only contains one member (and toUid is that member), error out! + const numberOwners = await db.setCount(`group:${groupName}:owners`); + const isOwner = await db.isSortedSetMember(`group:${groupName}:owners`); + if (numberOwners <= 1 && isOwner) { + throw new Error('[[error:group-needs-owner]]'); + } + + await db.setRemove(`group:${groupName}:owners`, toUid); + plugins.hooks.fire('action:group.rescindOwnership', {uid: toUid, groupName}); + }; }; diff --git a/src/groups/posts.js b/src/groups/posts.js index 47ef6c9..7bbfbb9 100644 --- a/src/groups/posts.js +++ b/src/groups/posts.js @@ -1,44 +1,45 @@ 'use strict'; const db = require('../database'); -const groups = require('.'); const privileges = require('../privileges'); const posts = require('../posts'); +const groups = require('.'); module.exports = function (Groups) { - Groups.onNewPostMade = async function (postData) { - if (!parseInt(postData.uid, 10)) { - return; - } - - let groupNames = await Groups.getUserGroupMembership('groups:visible:createtime', [postData.uid]); - groupNames = groupNames[0]; - - // Only process those groups that have the cid in its memberPostCids setting (or no setting at all) - const groupData = await groups.getGroupsFields(groupNames, ['memberPostCids']); - groupNames = groupNames.filter((groupName, idx) => ( - !groupData[idx].memberPostCidsArray.length || - groupData[idx].memberPostCidsArray.includes(postData.cid) - )); - - const keys = groupNames.map(groupName => `group:${groupName}:member:pids`); - await db.sortedSetsAdd(keys, postData.timestamp, postData.pid); - await Promise.all(groupNames.map(name => truncateMemberPosts(name))); - }; - - async function truncateMemberPosts(groupName) { - let lastPid = await db.getSortedSetRevRange(`group:${groupName}:member:pids`, 10, 10); - lastPid = lastPid[0]; - if (!parseInt(lastPid, 10)) { - return; - } - const score = await db.sortedSetScore(`group:${groupName}:member:pids`, lastPid); - await db.sortedSetsRemoveRangeByScore([`group:${groupName}:member:pids`], '-inf', score); - } - - Groups.getLatestMemberPosts = async function (groupName, max, uid) { - let pids = await db.getSortedSetRevRange(`group:${groupName}:member:pids`, 0, max - 1); - pids = await privileges.posts.filter('topics:read', pids, uid); - return await posts.getPostSummaryByPids(pids, uid, { stripTags: false }); - }; + Groups.onNewPostMade = async function (postData) { + if (!Number.parseInt(postData.uid, 10)) { + return; + } + + let groupNames = await Groups.getUserGroupMembership('groups:visible:createtime', [postData.uid]); + groupNames = groupNames[0]; + + // Only process those groups that have the cid in its memberPostCids setting (or no setting at all) + const groupData = await groups.getGroupsFields(groupNames, ['memberPostCids']); + groupNames = groupNames.filter((groupName, index) => ( + groupData[index].memberPostCidsArray.length === 0 + || groupData[index].memberPostCidsArray.includes(postData.cid) + )); + + const keys = groupNames.map(groupName => `group:${groupName}:member:pids`); + await db.sortedSetsAdd(keys, postData.timestamp, postData.pid); + await Promise.all(groupNames.map(name => truncateMemberPosts(name))); + }; + + async function truncateMemberPosts(groupName) { + let lastPid = await db.getSortedSetRevRange(`group:${groupName}:member:pids`, 10, 10); + lastPid = lastPid[0]; + if (!Number.parseInt(lastPid, 10)) { + return; + } + + const score = await db.sortedSetScore(`group:${groupName}:member:pids`, lastPid); + await db.sortedSetsRemoveRangeByScore([`group:${groupName}:member:pids`], '-inf', score); + } + + Groups.getLatestMemberPosts = async function (groupName, max, uid) { + let pids = await db.getSortedSetRevRange(`group:${groupName}:member:pids`, 0, max - 1); + pids = await privileges.posts.filter('topics:read', pids, uid); + return await posts.getPostSummaryByPids(pids, uid, {stripTags: false}); + }; }; diff --git a/src/groups/search.js b/src/groups/search.js index b6751c5..9db7c84 100644 --- a/src/groups/search.js +++ b/src/groups/search.js @@ -4,81 +4,87 @@ const user = require('../user'); const db = require('../database'); module.exports = function (Groups) { - Groups.search = async function (query, options) { - if (!query) { - return []; - } - query = String(query).toLowerCase(); - let groupNames = await db.getSortedSetRange('groups:createtime', 0, -1); - if (!options.hideEphemeralGroups) { - groupNames = Groups.ephemeralGroups.concat(groupNames); - } - groupNames = groupNames.filter(name => name.toLowerCase().includes(query) && - name !== Groups.BANNED_USERS && // hide banned-users in searches - !Groups.isPrivilegeGroup(name)); - groupNames = groupNames.slice(0, 100); - - let groupsData; - if (options.showMembers) { - groupsData = await Groups.getGroupsAndMembers(groupNames); - } else { - groupsData = await Groups.getGroupsData(groupNames); - } - groupsData = groupsData.filter(Boolean); - if (options.filterHidden) { - groupsData = groupsData.filter(group => !group.hidden); - } - return Groups.sort(options.sort, groupsData); - }; - - Groups.sort = function (strategy, groups) { - switch (strategy) { - case 'count': - groups.sort((a, b) => a.slug > b.slug) - .sort((a, b) => b.memberCount - a.memberCount); - break; - - case 'date': - groups.sort((a, b) => b.createtime - a.createtime); - break; - - case 'alpha': // intentional fall-through - default: - groups.sort((a, b) => (a.slug > b.slug ? 1 : -1)); - } - - return groups; - }; - - Groups.searchMembers = async function (data) { - if (!data.query) { - const users = await Groups.getOwnersAndMembers(data.groupName, data.uid, 0, 19); - return { users: users }; - } - - const results = await user.search({ - ...data, - paginate: false, - hardCap: -1, - }); - - const uids = results.users.map(user => user && user.uid); - const isOwners = await Groups.ownership.isOwners(uids, data.groupName); - - results.users.forEach((user, index) => { - if (user) { - user.isOwner = isOwners[index]; - } - }); - - results.users.sort((a, b) => { - if (a.isOwner && !b.isOwner) { - return -1; - } else if (!a.isOwner && b.isOwner) { - return 1; - } - return 0; - }); - return results; - }; + Groups.search = async function (query, options) { + if (!query) { + return []; + } + + query = String(query).toLowerCase(); + let groupNames = await db.getSortedSetRange('groups:createtime', 0, -1); + if (!options.hideEphemeralGroups) { + groupNames = Groups.ephemeralGroups.concat(groupNames); + } + + groupNames = groupNames.filter(name => name.toLowerCase().includes(query) + && name !== Groups.BANNED_USERS // Hide banned-users in searches + && !Groups.isPrivilegeGroup(name)); + groupNames = groupNames.slice(0, 100); + + let groupsData; + groupsData = await (options.showMembers ? Groups.getGroupsAndMembers(groupNames) : Groups.getGroupsData(groupNames)); + + groupsData = groupsData.filter(Boolean); + if (options.filterHidden) { + groupsData = groupsData.filter(group => !group.hidden); + } + + return Groups.sort(options.sort, groupsData); + }; + + Groups.sort = function (strategy, groups) { + switch (strategy) { + case 'count': { + groups.sort((a, b) => a.slug > b.slug) + .sort((a, b) => b.memberCount - a.memberCount); + break; + } + + case 'date': { + groups.sort((a, b) => b.createtime - a.createtime); + break; + } + + case 'alpha': // Intentional fall-through + default: { + groups.sort((a, b) => (a.slug > b.slug ? 1 : -1)); + } + } + + return groups; + }; + + Groups.searchMembers = async function (data) { + if (!data.query) { + const users = await Groups.getOwnersAndMembers(data.groupName, data.uid, 0, 19); + return {users}; + } + + const results = await user.search({ + ...data, + paginate: false, + hardCap: -1, + }); + + const uids = results.users.map(user => user && user.uid); + const isOwners = await Groups.ownership.isOwners(uids, data.groupName); + + for (const [index, user] of results.users.entries()) { + if (user) { + user.isOwner = isOwners[index]; + } + } + + results.users.sort((a, b) => { + if (a.isOwner && !b.isOwner) { + return -1; + } + + if (!a.isOwner && b.isOwner) { + return 1; + } + + return 0; + }); + return results; + }; }; diff --git a/src/groups/update.js b/src/groups/update.js index fb65911..9576d0b 100644 --- a/src/groups/update.js +++ b/src/groups/update.js @@ -1,7 +1,6 @@ 'use strict'; const winston = require('winston'); - const categories = require('../categories'); const plugins = require('../plugins'); const slugify = require('../slugify'); @@ -11,281 +10,290 @@ const batch = require('../batch'); const meta = require('../meta'); const cache = require('../cache'); - module.exports = function (Groups) { - Groups.update = async function (groupName, values) { - const exists = await db.exists(`group:${groupName}`); - if (!exists) { - throw new Error('[[error:no-group]]'); - } - - ({ values } = await plugins.hooks.fire('filter:group.update', { - groupName: groupName, - values: values, - })); - - // Cast some values as bool (if not boolean already) - // 'true' and '1' = true, everything else false - ['userTitleEnabled', 'private', 'hidden', 'disableJoinRequests', 'disableLeave'].forEach((prop) => { - if (values.hasOwnProperty(prop) && typeof values[prop] !== 'boolean') { - values[prop] = values[prop] === 'true' || parseInt(values[prop], 10) === 1; - } - }); - - const payload = { - description: values.description || '', - icon: values.icon || '', - labelColor: values.labelColor || '#000000', - textColor: values.textColor || '#ffffff', - }; - - if (values.hasOwnProperty('userTitle')) { - payload.userTitle = values.userTitle || ''; - } - - if (values.hasOwnProperty('userTitleEnabled')) { - payload.userTitleEnabled = values.userTitleEnabled ? '1' : '0'; - } - - if (values.hasOwnProperty('hidden')) { - payload.hidden = values.hidden ? '1' : '0'; - } - - if (values.hasOwnProperty('private')) { - payload.private = values.private ? '1' : '0'; - } - - if (values.hasOwnProperty('disableJoinRequests')) { - payload.disableJoinRequests = values.disableJoinRequests ? '1' : '0'; - } - - if (values.hasOwnProperty('disableLeave')) { - payload.disableLeave = values.disableLeave ? '1' : '0'; - } - - if (values.hasOwnProperty('name')) { - await checkNameChange(groupName, values.name); - } - - if (values.hasOwnProperty('private')) { - await updatePrivacy(groupName, values.private); - } - - if (values.hasOwnProperty('hidden')) { - await updateVisibility(groupName, values.hidden); - } - - if (values.hasOwnProperty('memberPostCids')) { - const validCids = await categories.getCidsByPrivilege('categories:cid', groupName, 'topics:read'); - const cidsArray = values.memberPostCids.split(',').map(cid => parseInt(cid.trim(), 10)).filter(Boolean); - payload.memberPostCids = cidsArray.filter(cid => validCids.includes(cid)).join(',') || ''; - } - - await db.setObject(`group:${groupName}`, payload); - await Groups.renameGroup(groupName, values.name); - - plugins.hooks.fire('action:group.update', { - name: groupName, - values: values, - }); - }; - - async function updateVisibility(groupName, hidden) { - if (hidden) { - await db.sortedSetRemoveBulk([ - ['groups:visible:createtime', groupName], - ['groups:visible:memberCount', groupName], - ['groups:visible:name', `${groupName.toLowerCase()}:${groupName}`], - ]); - return; - } - const groupData = await db.getObjectFields(`group:${groupName}`, ['createtime', 'memberCount']); - await db.sortedSetAddBulk([ - ['groups:visible:createtime', groupData.createtime, groupName], - ['groups:visible:memberCount', groupData.memberCount, groupName], - ['groups:visible:name', 0, `${groupName.toLowerCase()}:${groupName}`], - ]); - } - - Groups.hide = async function (groupName) { - await showHide(groupName, 'hidden'); - }; - - Groups.show = async function (groupName) { - await showHide(groupName, 'show'); - }; - - async function showHide(groupName, hidden) { - hidden = hidden === 'hidden'; - await Promise.all([ - db.setObjectField(`group:${groupName}`, 'hidden', hidden ? 1 : 0), - updateVisibility(groupName, hidden), - ]); - } - - async function updatePrivacy(groupName, isPrivate) { - const groupData = await Groups.getGroupFields(groupName, ['private']); - const currentlyPrivate = groupData.private === 1; - if (!currentlyPrivate || currentlyPrivate === isPrivate) { - return; - } - const pendingUids = await db.getSetMembers(`group:${groupName}:pending`); - if (!pendingUids.length) { - return; - } - - winston.verbose(`[groups.update] Group is now public, automatically adding ${pendingUids.length} new members, who were pending prior.`); - - for (const uid of pendingUids) { - /* eslint-disable no-await-in-loop */ - await Groups.join(groupName, uid); - } - await db.delete(`group:${groupName}:pending`); - } - - async function checkNameChange(currentName, newName) { - if (Groups.isPrivilegeGroup(newName)) { - throw new Error('[[error:invalid-group-name]]'); - } - const currentSlug = slugify(currentName); - const newSlug = slugify(newName); - if (currentName === newName || currentSlug === newSlug) { - return; - } - Groups.validateGroupName(newName); - const [group, exists] = await Promise.all([ - Groups.getGroupData(currentName), - Groups.existsBySlug(newSlug), - ]); - - if (exists) { - throw new Error('[[error:group-already-exists]]'); - } - - if (!group) { - throw new Error('[[error:no-group]]'); - } - - if (group.system) { - throw new Error('[[error:not-allowed-to-rename-system-group]]'); - } - } - - Groups.renameGroup = async function (oldName, newName) { - if (oldName === newName || !newName || String(newName).length === 0) { - return; - } - const group = await db.getObject(`group:${oldName}`); - if (!group) { - return; - } - - const exists = await Groups.exists(newName); - if (exists) { - throw new Error('[[error:group-already-exists]]'); - } - - await updateMemberGroupTitles(oldName, newName); - await updateNavigationItems(oldName, newName); - await updateWidgets(oldName, newName); - await updateConfig(oldName, newName); - await db.setObject(`group:${oldName}`, { name: newName, slug: slugify(newName) }); - await db.deleteObjectField('groupslug:groupname', group.slug); - await db.setObjectField('groupslug:groupname', slugify(newName), newName); - - const allGroups = await db.getSortedSetRange('groups:createtime', 0, -1); - const keys = allGroups.map(group => `group:${group}:members`); - await renameGroupsMember(keys, oldName, newName); - cache.del(keys); - - await db.rename(`group:${oldName}`, `group:${newName}`); - await db.rename(`group:${oldName}:members`, `group:${newName}:members`); - await db.rename(`group:${oldName}:owners`, `group:${newName}:owners`); - await db.rename(`group:${oldName}:pending`, `group:${newName}:pending`); - await db.rename(`group:${oldName}:invited`, `group:${newName}:invited`); - await db.rename(`group:${oldName}:member:pids`, `group:${newName}:member:pids`); - - await renameGroupsMember(['groups:createtime', 'groups:visible:createtime', 'groups:visible:memberCount'], oldName, newName); - await renameGroupsMember(['groups:visible:name'], `${oldName.toLowerCase()}:${oldName}`, `${newName.toLowerCase()}:${newName}`); - - plugins.hooks.fire('action:group.rename', { - old: oldName, - new: newName, - }); - Groups.cache.reset(); - }; - - async function updateMemberGroupTitles(oldName, newName) { - await batch.processSortedSet(`group:${oldName}:members`, async (uids) => { - let usersData = await user.getUsersData(uids); - usersData = usersData.filter(userData => userData && userData.groupTitleArray.includes(oldName)); - - usersData.forEach((userData) => { - userData.newTitleArray = userData.groupTitleArray - .map(oldTitle => (oldTitle === oldName ? newName : oldTitle)); - }); - - await Promise.all(usersData.map(u => user.setUserField(u.uid, 'groupTitle', JSON.stringify(u.newTitleArray)))); - }, {}); - } - - async function renameGroupsMember(keys, oldName, newName) { - const isMembers = await db.isMemberOfSortedSets(keys, oldName); - keys = keys.filter((key, index) => isMembers[index]); - if (!keys.length) { - return; - } - const scores = await db.sortedSetsScore(keys, oldName); - await db.sortedSetsRemove(keys, oldName); - await db.sortedSetsAdd(keys, scores, newName); - } - - async function updateNavigationItems(oldName, newName) { - const navigation = require('../navigation/admin'); - const navItems = await navigation.get(); - navItems.forEach((navItem) => { - if (navItem && Array.isArray(navItem.groups) && navItem.groups.includes(oldName)) { - navItem.groups.splice(navItem.groups.indexOf(oldName), 1, newName); - } - }); - navigation.unescapeFields(navItems); - await navigation.save(navItems); - } - - async function updateWidgets(oldName, newName) { - const admin = require('../widgets/admin'); - const widgets = require('../widgets'); - - const data = await admin.get(); - - data.areas.forEach((area) => { - area.widgets = area.data; - area.widgets.forEach((widget) => { - if (widget && widget.data && Array.isArray(widget.data.groups) && - widget.data.groups.includes(oldName)) { - widget.data.groups.splice(widget.data.groups.indexOf(oldName), 1, newName); - } - }); - }); - for (const area of data.areas) { - if (area.data.length) { - await widgets.setArea(area); - } - } - } - - async function updateConfig(oldName, newName) { - if (meta.config.groupsExemptFromPostQueue.includes(oldName)) { - meta.config.groupsExemptFromPostQueue.splice( - meta.config.groupsExemptFromPostQueue.indexOf(oldName), 1, newName - ); - await meta.configs.set('groupsExemptFromPostQueue', meta.config.groupsExemptFromPostQueue); - } - if (meta.config.groupsExemptFromMaintenanceMode.includes(oldName)) { - meta.config.groupsExemptFromMaintenanceMode.splice( - meta.config.groupsExemptFromMaintenanceMode.indexOf(oldName), 1, newName - ); - await meta.configs.set('groupsExemptFromMaintenanceMode', meta.config.groupsExemptFromMaintenanceMode); - } - } + Groups.update = async function (groupName, values) { + const exists = await db.exists(`group:${groupName}`); + if (!exists) { + throw new Error('[[error:no-group]]'); + } + + ({values} = await plugins.hooks.fire('filter:group.update', { + groupName, + values, + })); + + // Cast some values as bool (if not boolean already) + // 'true' and '1' = true, everything else false + for (const property of ['userTitleEnabled', 'private', 'hidden', 'disableJoinRequests', 'disableLeave']) { + if (values.hasOwnProperty(property) && typeof values[property] !== 'boolean') { + values[property] = values[property] === 'true' || Number.parseInt(values[property], 10) === 1; + } + } + + const payload = { + description: values.description || '', + icon: values.icon || '', + labelColor: values.labelColor || '#000000', + textColor: values.textColor || '#ffffff', + }; + + if (values.hasOwnProperty('userTitle')) { + payload.userTitle = values.userTitle || ''; + } + + if (values.hasOwnProperty('userTitleEnabled')) { + payload.userTitleEnabled = values.userTitleEnabled ? '1' : '0'; + } + + if (values.hasOwnProperty('hidden')) { + payload.hidden = values.hidden ? '1' : '0'; + } + + if (values.hasOwnProperty('private')) { + payload.private = values.private ? '1' : '0'; + } + + if (values.hasOwnProperty('disableJoinRequests')) { + payload.disableJoinRequests = values.disableJoinRequests ? '1' : '0'; + } + + if (values.hasOwnProperty('disableLeave')) { + payload.disableLeave = values.disableLeave ? '1' : '0'; + } + + if (values.hasOwnProperty('name')) { + await checkNameChange(groupName, values.name); + } + + if (values.hasOwnProperty('private')) { + await updatePrivacy(groupName, values.private); + } + + if (values.hasOwnProperty('hidden')) { + await updateVisibility(groupName, values.hidden); + } + + if (values.hasOwnProperty('memberPostCids')) { + const validCids = await categories.getCidsByPrivilege('categories:cid', groupName, 'topics:read'); + const cidsArray = values.memberPostCids.split(',').map(cid => Number.parseInt(cid.trim(), 10)).filter(Boolean); + payload.memberPostCids = cidsArray.filter(cid => validCids.includes(cid)).join(',') || ''; + } + + await db.setObject(`group:${groupName}`, payload); + await Groups.renameGroup(groupName, values.name); + + plugins.hooks.fire('action:group.update', { + name: groupName, + values, + }); + }; + + async function updateVisibility(groupName, hidden) { + if (hidden) { + await db.sortedSetRemoveBulk([ + ['groups:visible:createtime', groupName], + ['groups:visible:memberCount', groupName], + ['groups:visible:name', `${groupName.toLowerCase()}:${groupName}`], + ]); + return; + } + + const groupData = await db.getObjectFields(`group:${groupName}`, ['createtime', 'memberCount']); + await db.sortedSetAddBulk([ + ['groups:visible:createtime', groupData.createtime, groupName], + ['groups:visible:memberCount', groupData.memberCount, groupName], + ['groups:visible:name', 0, `${groupName.toLowerCase()}:${groupName}`], + ]); + } + + Groups.hide = async function (groupName) { + await showHide(groupName, 'hidden'); + }; + + Groups.show = async function (groupName) { + await showHide(groupName, 'show'); + }; + + async function showHide(groupName, hidden) { + hidden = hidden === 'hidden'; + await Promise.all([ + db.setObjectField(`group:${groupName}`, 'hidden', hidden ? 1 : 0), + updateVisibility(groupName, hidden), + ]); + } + + async function updatePrivacy(groupName, isPrivate) { + const groupData = await Groups.getGroupFields(groupName, ['private']); + const currentlyPrivate = groupData.private === 1; + if (!currentlyPrivate || currentlyPrivate === isPrivate) { + return; + } + + const pendingUids = await db.getSetMembers(`group:${groupName}:pending`); + if (pendingUids.length === 0) { + return; + } + + winston.verbose(`[groups.update] Group is now public, automatically adding ${pendingUids.length} new members, who were pending prior.`); + + for (const uid of pendingUids) { + /* eslint-disable no-await-in-loop */ + await Groups.join(groupName, uid); + } + + await db.delete(`group:${groupName}:pending`); + } + + async function checkNameChange(currentName, newName) { + if (Groups.isPrivilegeGroup(newName)) { + throw new Error('[[error:invalid-group-name]]'); + } + + const currentSlug = slugify(currentName); + const newSlug = slugify(newName); + if (currentName === newName || currentSlug === newSlug) { + return; + } + + Groups.validateGroupName(newName); + const [group, exists] = await Promise.all([ + Groups.getGroupData(currentName), + Groups.existsBySlug(newSlug), + ]); + + if (exists) { + throw new Error('[[error:group-already-exists]]'); + } + + if (!group) { + throw new Error('[[error:no-group]]'); + } + + if (group.system) { + throw new Error('[[error:not-allowed-to-rename-system-group]]'); + } + } + + Groups.renameGroup = async function (oldName, newName) { + if (oldName === newName || !newName || String(newName).length === 0) { + return; + } + + const group = await db.getObject(`group:${oldName}`); + if (!group) { + return; + } + + const exists = await Groups.exists(newName); + if (exists) { + throw new Error('[[error:group-already-exists]]'); + } + + await updateMemberGroupTitles(oldName, newName); + await updateNavigationItems(oldName, newName); + await updateWidgets(oldName, newName); + await updateConfig(oldName, newName); + await db.setObject(`group:${oldName}`, {name: newName, slug: slugify(newName)}); + await db.deleteObjectField('groupslug:groupname', group.slug); + await db.setObjectField('groupslug:groupname', slugify(newName), newName); + + const allGroups = await db.getSortedSetRange('groups:createtime', 0, -1); + const keys = allGroups.map(group => `group:${group}:members`); + await renameGroupsMember(keys, oldName, newName); + cache.del(keys); + + await db.rename(`group:${oldName}`, `group:${newName}`); + await db.rename(`group:${oldName}:members`, `group:${newName}:members`); + await db.rename(`group:${oldName}:owners`, `group:${newName}:owners`); + await db.rename(`group:${oldName}:pending`, `group:${newName}:pending`); + await db.rename(`group:${oldName}:invited`, `group:${newName}:invited`); + await db.rename(`group:${oldName}:member:pids`, `group:${newName}:member:pids`); + + await renameGroupsMember(['groups:createtime', 'groups:visible:createtime', 'groups:visible:memberCount'], oldName, newName); + await renameGroupsMember(['groups:visible:name'], `${oldName.toLowerCase()}:${oldName}`, `${newName.toLowerCase()}:${newName}`); + + plugins.hooks.fire('action:group.rename', { + old: oldName, + new: newName, + }); + Groups.cache.reset(); + }; + + async function updateMemberGroupTitles(oldName, newName) { + await batch.processSortedSet(`group:${oldName}:members`, async uids => { + let usersData = await user.getUsersData(uids); + usersData = usersData.filter(userData => userData && userData.groupTitleArray.includes(oldName)); + + for (const userData of usersData) { + userData.newTitleArray = userData.groupTitleArray + .map(oldTitle => (oldTitle === oldName ? newName : oldTitle)); + } + + await Promise.all(usersData.map(u => user.setUserField(u.uid, 'groupTitle', JSON.stringify(u.newTitleArray)))); + }, {}); + } + + async function renameGroupsMember(keys, oldName, newName) { + const isMembers = await db.isMemberOfSortedSets(keys, oldName); + keys = keys.filter((key, index) => isMembers[index]); + if (keys.length === 0) { + return; + } + + const scores = await db.sortedSetsScore(keys, oldName); + await db.sortedSetsRemove(keys, oldName); + await db.sortedSetsAdd(keys, scores, newName); + } + + async function updateNavigationItems(oldName, newName) { + const navigation = require('../navigation/admin'); + const navItems = await navigation.get(); + for (const navItem of navItems) { + if (navItem && Array.isArray(navItem.groups) && navItem.groups.includes(oldName)) { + navItem.groups.splice(navItem.groups.indexOf(oldName), 1, newName); + } + } + + navigation.unescapeFields(navItems); + await navigation.save(navItems); + } + + async function updateWidgets(oldName, newName) { + const admin = require('../widgets/admin'); + const widgets = require('../widgets'); + + const data = await admin.get(); + + for (const area of data.areas) { + area.widgets = area.data; + for (const widget of area.widgets) { + if (widget && widget.data && Array.isArray(widget.data.groups) + && widget.data.groups.includes(oldName)) { + widget.data.groups.splice(widget.data.groups.indexOf(oldName), 1, newName); + } + } + } + + for (const area of data.areas) { + if (area.data.length > 0) { + await widgets.setArea(area); + } + } + } + + async function updateConfig(oldName, newName) { + if (meta.config.groupsExemptFromPostQueue.includes(oldName)) { + meta.config.groupsExemptFromPostQueue.splice( + meta.config.groupsExemptFromPostQueue.indexOf(oldName), 1, newName, + ); + await meta.configs.set('groupsExemptFromPostQueue', meta.config.groupsExemptFromPostQueue); + } + + if (meta.config.groupsExemptFromMaintenanceMode.includes(oldName)) { + meta.config.groupsExemptFromMaintenanceMode.splice( + meta.config.groupsExemptFromMaintenanceMode.indexOf(oldName), 1, newName, + ); + await meta.configs.set('groupsExemptFromMaintenanceMode', meta.config.groupsExemptFromMaintenanceMode); + } + } }; diff --git a/src/groups/user.js b/src/groups/user.js index 3561423..e53b51a 100644 --- a/src/groups/user.js +++ b/src/groups/user.js @@ -4,64 +4,65 @@ const db = require('../database'); const user = require('../user'); module.exports = function (Groups) { - Groups.getUsersFromSet = async function (set, fields) { - const uids = await db.getSetMembers(set); + Groups.getUsersFromSet = async function (set, fields) { + const uids = await db.getSetMembers(set); - if (fields) { - return await user.getUsersFields(uids, fields); - } - return await user.getUsersData(uids); - }; + if (fields) { + return await user.getUsersFields(uids, fields); + } - Groups.getUserGroups = async function (uids) { - return await Groups.getUserGroupsFromSet('groups:visible:createtime', uids); - }; + return await user.getUsersData(uids); + }; - Groups.getUserGroupsFromSet = async function (set, uids) { - const memberOf = await Groups.getUserGroupMembership(set, uids); - return await Promise.all(memberOf.map(memberOf => Groups.getGroupsData(memberOf))); - }; + Groups.getUserGroups = async function (uids) { + return await Groups.getUserGroupsFromSet('groups:visible:createtime', uids); + }; - Groups.getUserGroupMembership = async function (set, uids) { - const groupNames = await db.getSortedSetRevRange(set, 0, -1); - return await Promise.all(uids.map(uid => findUserGroups(uid, groupNames))); - }; + Groups.getUserGroupsFromSet = async function (set, uids) { + const memberOf = await Groups.getUserGroupMembership(set, uids); + return await Promise.all(memberOf.map(memberOf => Groups.getGroupsData(memberOf))); + }; - async function findUserGroups(uid, groupNames) { - const isMembers = await Groups.isMemberOfGroups(uid, groupNames); - return groupNames.filter((name, i) => isMembers[i]); - } + Groups.getUserGroupMembership = async function (set, uids) { + const groupNames = await db.getSortedSetRevRange(set, 0, -1); + return await Promise.all(uids.map(uid => findUserGroups(uid, groupNames))); + }; - Groups.getUserInviteGroups = async function (uid) { - let allGroups = await Groups.getNonPrivilegeGroups('groups:createtime', 0, -1); - allGroups = allGroups.filter(group => !Groups.ephemeralGroups.includes(group.name)); + async function findUserGroups(uid, groupNames) { + const isMembers = await Groups.isMemberOfGroups(uid, groupNames); + return groupNames.filter((name, i) => isMembers[i]); + } - const publicGroups = allGroups.filter(group => group.hidden === 0 && group.system === 0 && group.private === 0); - const adminModGroups = [ - { name: 'administrators', displayName: 'administrators' }, - { name: 'Global Moderators', displayName: 'Global Moderators' }, - ]; - // Private (but not hidden) - const privateGroups = allGroups.filter(group => group.hidden === 0 && - group.system === 0 && group.private === 1); + Groups.getUserInviteGroups = async function (uid) { + let allGroups = await Groups.getNonPrivilegeGroups('groups:createtime', 0, -1); + allGroups = allGroups.filter(group => !Groups.ephemeralGroups.includes(group.name)); - const [ownership, isAdmin, isGlobalMod] = await Promise.all([ - Promise.all(privateGroups.map(group => Groups.ownership.isOwner(uid, group.name))), - user.isAdministrator(uid), - user.isGlobalModerator(uid), - ]); - const ownGroups = privateGroups.filter((group, index) => ownership[index]); + const publicGroups = allGroups.filter(group => group.hidden === 0 && group.system === 0 && group.private === 0); + const adminModuleGroups = [ + {name: 'administrators', displayName: 'administrators'}, + {name: 'Global Moderators', displayName: 'Global Moderators'}, + ]; + // Private (but not hidden) + const privateGroups = allGroups.filter(group => group.hidden === 0 + && group.system === 0 && group.private === 1); - let inviteGroups = []; - if (isAdmin) { - inviteGroups = inviteGroups.concat(adminModGroups).concat(privateGroups); - } else if (isGlobalMod) { - inviteGroups = inviteGroups.concat(privateGroups); - } else { - inviteGroups = inviteGroups.concat(ownGroups); - } + const [ownership, isAdmin, isGlobalModule] = await Promise.all([ + Promise.all(privateGroups.map(group => Groups.ownership.isOwner(uid, group.name))), + user.isAdministrator(uid), + user.isGlobalModerator(uid), + ]); + const ownGroups = privateGroups.filter((group, index) => ownership[index]); - return inviteGroups - .concat(publicGroups); - }; + let inviteGroups = []; + if (isAdmin) { + inviteGroups = inviteGroups.concat(adminModuleGroups).concat(privateGroups); + } else if (isGlobalModule) { + inviteGroups = inviteGroups.concat(privateGroups); + } else { + inviteGroups = inviteGroups.concat(ownGroups); + } + + return inviteGroups + .concat(publicGroups); + }; }; diff --git a/src/helpers.js b/src/helpers.js index b072c4e..b75e950 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -1,7 +1,7 @@ 'use strict'; module.exports = require('../public/src/modules/helpers.common')( - require('./utils'), - require('benchpressjs'), - require('nconf').get('relative_path'), + require('./utils'), + require('benchpressjs'), + require('nconf').get('relative_path'), ); diff --git a/src/image.js b/src/image.js index 4a70190..d6621f9 100644 --- a/src/image.js +++ b/src/image.js @@ -1,11 +1,10 @@ 'use strict'; -const os = require('os'); -const fs = require('fs'); -const path = require('path'); -const crypto = require('crypto'); +const os = require('node:os'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); const winston = require('winston'); - const file = require('./file'); const plugins = require('./plugins'); const meta = require('./meta'); @@ -13,170 +12,177 @@ const meta = require('./meta'); const image = module.exports; function requireSharp() { - const sharp = require('sharp'); - if (os.platform() === 'win32') { - // https://github.com/lovell/sharp/issues/1259 - sharp.cache(false); - } - return sharp; + const sharp = require('sharp'); + if (os.platform() === 'win32') { + // https://github.com/lovell/sharp/issues/1259 + sharp.cache(false); + } + + return sharp; } image.isFileTypeAllowed = async function (path) { - const plugins = require('./plugins'); - if (plugins.hooks.hasListeners('filter:image.isFileTypeAllowed')) { - return await plugins.hooks.fire('filter:image.isFileTypeAllowed', path); - } - const sharp = require('sharp'); - await sharp(path, { - failOnError: true, - }).metadata(); + const plugins = require('./plugins'); + if (plugins.hooks.hasListeners('filter:image.isFileTypeAllowed')) { + return await plugins.hooks.fire('filter:image.isFileTypeAllowed', path); + } + + const sharp = require('sharp'); + await sharp(path, { + failOnError: true, + }).metadata(); }; image.resizeImage = async function (data) { - if (plugins.hooks.hasListeners('filter:image.resize')) { - await plugins.hooks.fire('filter:image.resize', { - path: data.path, - target: data.target, - width: data.width, - height: data.height, - quality: data.quality, - }); - } else { - const sharp = requireSharp(); - const buffer = await fs.promises.readFile(data.path); - const sharpImage = sharp(buffer, { - failOnError: true, - animated: data.path.endsWith('gif'), - }); - const metadata = await sharpImage.metadata(); - - sharpImage.rotate(); // auto-orients based on exif data - sharpImage.resize(data.hasOwnProperty('width') ? data.width : null, data.hasOwnProperty('height') ? data.height : null); - - if (data.quality) { - switch (metadata.format) { - case 'jpeg': { - sharpImage.jpeg({ - quality: data.quality, - mozjpeg: true, - }); - break; - } - - case 'png': { - sharpImage.png({ - quality: data.quality, - compressionLevel: 9, - }); - break; - } - } - } - - await sharpImage.toFile(data.target || data.path); - } + if (plugins.hooks.hasListeners('filter:image.resize')) { + await plugins.hooks.fire('filter:image.resize', { + path: data.path, + target: data.target, + width: data.width, + height: data.height, + quality: data.quality, + }); + } else { + const sharp = requireSharp(); + const buffer = await fs.promises.readFile(data.path); + const sharpImage = sharp(buffer, { + failOnError: true, + animated: data.path.endsWith('gif'), + }); + const metadata = await sharpImage.metadata(); + + sharpImage.rotate(); // Auto-orients based on exif data + sharpImage.resize(data.hasOwnProperty('width') ? data.width : null, data.hasOwnProperty('height') ? data.height : null); + + if (data.quality) { + switch (metadata.format) { + case 'jpeg': { + sharpImage.jpeg({ + quality: data.quality, + mozjpeg: true, + }); + break; + } + + case 'png': { + sharpImage.png({ + quality: data.quality, + compressionLevel: 9, + }); + break; + } + } + } + + await sharpImage.toFile(data.target || data.path); + } }; image.normalise = async function (path) { - if (plugins.hooks.hasListeners('filter:image.normalise')) { - await plugins.hooks.fire('filter:image.normalise', { - path: path, - }); - } else { - const sharp = requireSharp(); - await sharp(path, { failOnError: true }).png().toFile(`${path}.png`); - } - return `${path}.png`; + if (plugins.hooks.hasListeners('filter:image.normalise')) { + await plugins.hooks.fire('filter:image.normalise', { + path, + }); + } else { + const sharp = requireSharp(); + await sharp(path, {failOnError: true}).png().toFile(`${path}.png`); + } + + return `${path}.png`; }; image.size = async function (path) { - let imageData; - if (plugins.hooks.hasListeners('filter:image.size')) { - imageData = await plugins.hooks.fire('filter:image.size', { - path: path, - }); - } else { - const sharp = requireSharp(); - imageData = await sharp(path, { failOnError: true }).metadata(); - } - return imageData ? { width: imageData.width, height: imageData.height } : undefined; + let imageData; + if (plugins.hooks.hasListeners('filter:image.size')) { + imageData = await plugins.hooks.fire('filter:image.size', { + path, + }); + } else { + const sharp = requireSharp(); + imageData = await sharp(path, {failOnError: true}).metadata(); + } + + return imageData ? {width: imageData.width, height: imageData.height} : undefined; }; image.stripEXIF = async function (path) { - if (!meta.config.stripEXIFData || path.endsWith('.gif') || path.endsWith('.svg')) { - return; - } - try { - if (plugins.hooks.hasListeners('filter:image.stripEXIF')) { - await plugins.hooks.fire('filter:image.stripEXIF', { - path: path, - }); - return; - } - const buffer = await fs.promises.readFile(path); - const sharp = requireSharp(); - await sharp(buffer, { failOnError: true }).rotate().toFile(path); - } catch (err) { - winston.error(err.stack); - } + if (!meta.config.stripEXIFData || path.endsWith('.gif') || path.endsWith('.svg')) { + return; + } + + try { + if (plugins.hooks.hasListeners('filter:image.stripEXIF')) { + await plugins.hooks.fire('filter:image.stripEXIF', { + path, + }); + return; + } + + const buffer = await fs.promises.readFile(path); + const sharp = requireSharp(); + await sharp(buffer, {failOnError: true}).rotate().toFile(path); + } catch (error) { + winston.error(error.stack); + } }; image.checkDimensions = async function (path) { - const meta = require('./meta'); - const result = await image.size(path); + const meta = require('./meta'); + const result = await image.size(path); - if (result.width > meta.config.rejectImageWidth || result.height > meta.config.rejectImageHeight) { - throw new Error('[[error:invalid-image-dimensions]]'); - } + if (result.width > meta.config.rejectImageWidth || result.height > meta.config.rejectImageHeight) { + throw new Error('[[error:invalid-image-dimensions]]'); + } - return result; + return result; }; image.convertImageToBase64 = async function (path) { - return await fs.promises.readFile(path, 'base64'); + return await fs.promises.readFile(path, 'base64'); }; image.mimeFromBase64 = function (imageData) { - return imageData.slice(5, imageData.indexOf('base64') - 1); + return imageData.slice(5, imageData.indexOf('base64') - 1); }; image.extensionFromBase64 = function (imageData) { - return file.typeToExtension(image.mimeFromBase64(imageData)); + return file.typeToExtension(image.mimeFromBase64(imageData)); }; image.writeImageDataToTempFile = async function (imageData) { - const filename = crypto.createHash('md5').update(imageData).digest('hex'); + const filename = crypto.createHash('md5').update(imageData).digest('hex'); - const type = image.mimeFromBase64(imageData); - const extension = file.typeToExtension(type); + const type = image.mimeFromBase64(imageData); + const extension = file.typeToExtension(type); - const filepath = path.join(os.tmpdir(), filename + extension); + const filepath = path.join(os.tmpdir(), filename + extension); - const buffer = Buffer.from(imageData.slice(imageData.indexOf('base64') + 7), 'base64'); + const buffer = Buffer.from(imageData.slice(imageData.indexOf('base64') + 7), 'base64'); - await fs.promises.writeFile(filepath, buffer, { encoding: 'base64' }); - return filepath; + await fs.promises.writeFile(filepath, buffer, {encoding: 'base64'}); + return filepath; }; image.sizeFromBase64 = function (imageData) { - return Buffer.from(imageData.slice(imageData.indexOf('base64') + 7), 'base64').length; + return Buffer.from(imageData.slice(imageData.indexOf('base64') + 7), 'base64').length; }; image.uploadImage = async function (filename, folder, imageData) { - if (plugins.hooks.hasListeners('filter:uploadImage')) { - return await plugins.hooks.fire('filter:uploadImage', { - image: imageData, - uid: imageData.uid, - folder: folder, - }); - } - await image.isFileTypeAllowed(imageData.path); - const upload = await file.saveFileToLocal(filename, folder, imageData.path); - return { - url: upload.url, - path: upload.path, - name: imageData.name, - }; + if (plugins.hooks.hasListeners('filter:uploadImage')) { + return await plugins.hooks.fire('filter:uploadImage', { + image: imageData, + uid: imageData.uid, + folder, + }); + } + + await image.isFileTypeAllowed(imageData.path); + const upload = await file.saveFileToLocal(filename, folder, imageData.path); + return { + url: upload.url, + path: upload.path, + name: imageData.name, + }; }; require('./promisify')(image); diff --git a/src/install.js b/src/install.js index 5bf7863..f9303de 100644 --- a/src/install.js +++ b/src/install.js @@ -1,618 +1,636 @@ 'use strict'; -const fs = require('fs'); -const url = require('url'); -const path = require('path'); +const fs = require('node:fs'); +const url = require('node:url'); +const path = require('node:path'); const prompt = require('prompt'); const winston = require('winston'); const nconf = require('nconf'); const _ = require('lodash'); - const utils = require('./utils'); const install = module.exports; const questions = {}; questions.main = [ - { - name: 'url', - description: 'URL used to access this NodeBB', - default: + { + name: 'url', + description: 'URL used to access this NodeBB', + default: nconf.get('url') || 'http://127.0.0.1:4567', - pattern: /^http(?:s)?:\/\//, - message: 'Base URL must begin with \'http://\' or \'https://\'', - }, - { - name: 'secret', - description: 'Please enter a NodeBB secret', - default: nconf.get('secret') || utils.generateUUID(), - }, - { - name: 'submitPluginUsage', - description: 'Would you like to submit anonymous plugin usage to nbbpm?', - default: 'yes', - }, - { - name: 'database', - description: 'Which database to use', - default: nconf.get('database') || 'mongo', - }, + pattern: /^https?:\/\//, + message: 'Base URL must begin with \'http://\' or \'https://\'', + }, + { + name: 'secret', + description: 'Please enter a NodeBB secret', + default: nconf.get('secret') || utils.generateUUID(), + }, + { + name: 'submitPluginUsage', + description: 'Would you like to submit anonymous plugin usage to nbbpm?', + default: 'yes', + }, + { + name: 'database', + description: 'Which database to use', + default: nconf.get('database') || 'mongo', + }, ]; questions.optional = [ - { - name: 'port', - default: nconf.get('port') || 4567, - }, + { + name: 'port', + default: nconf.get('port') || 4567, + }, ]; function checkSetupFlagEnv() { - let setupVal = install.values; - - const envConfMap = { - NODEBB_URL: 'url', - NODEBB_PORT: 'port', - NODEBB_ADMIN_USERNAME: 'admin:username', - NODEBB_ADMIN_PASSWORD: 'admin:password', - NODEBB_ADMIN_EMAIL: 'admin:email', - NODEBB_DB: 'database', - NODEBB_DB_HOST: 'host', - NODEBB_DB_PORT: 'port', - NODEBB_DB_USER: 'username', - NODEBB_DB_PASSWORD: 'password', - NODEBB_DB_NAME: 'database', - NODEBB_DB_SSL: 'ssl', - }; - - // Set setup values from env vars (if set) - const envKeys = Object.keys(process.env); - if (Object.keys(envConfMap).some(key => envKeys.includes(key))) { - winston.info('[install/checkSetupFlagEnv] checking env vars for setup info...'); - setupVal = setupVal || {}; - - Object.entries(process.env).forEach(([evName, evValue]) => { // get setup values from env - if (evName.startsWith('NODEBB_DB_')) { - setupVal[`${process.env.NODEBB_DB}:${envConfMap[evName]}`] = evValue; - } else if (evName.startsWith('NODEBB_')) { - setupVal[envConfMap[evName]] = evValue; - } - }); - - setupVal['admin:password:confirm'] = setupVal['admin:password']; - } - - // try to get setup values from json, if successful this overwrites all values set by env - // TODO: better behaviour would be to support overrides per value, i.e. in order of priority (generic pattern): - // flag, env, config file, default - try { - if (nconf.get('setup')) { - const setupJSON = JSON.parse(nconf.get('setup')); - setupVal = { ...setupVal, ...setupJSON }; - } - } catch (err) { - winston.error('[install/checkSetupFlagEnv] invalid json in nconf.get(\'setup\'), ignoring setup values from json'); - } - - if (setupVal && typeof setupVal === 'object') { - if (setupVal['admin:username'] && setupVal['admin:password'] && setupVal['admin:password:confirm'] && setupVal['admin:email']) { - install.values = setupVal; - } else { - winston.error('[install/checkSetupFlagEnv] required values are missing for automated setup:'); - if (!setupVal['admin:username']) { - winston.error(' admin:username'); - } - if (!setupVal['admin:password']) { - winston.error(' admin:password'); - } - if (!setupVal['admin:password:confirm']) { - winston.error(' admin:password:confirm'); - } - if (!setupVal['admin:email']) { - winston.error(' admin:email'); - } - - process.exit(); - } - } else if (nconf.get('database')) { - install.values = install.values || {}; - install.values.database = nconf.get('database'); - } + let setupValue = install.values; + + const envConfigMap = { + NODEBB_URL: 'url', + NODEBB_PORT: 'port', + NODEBB_ADMIN_USERNAME: 'admin:username', + NODEBB_ADMIN_PASSWORD: 'admin:password', + NODEBB_ADMIN_EMAIL: 'admin:email', + NODEBB_DB: 'database', + NODEBB_DB_HOST: 'host', + NODEBB_DB_PORT: 'port', + NODEBB_DB_USER: 'username', + NODEBB_DB_PASSWORD: 'password', + NODEBB_DB_NAME: 'database', + NODEBB_DB_SSL: 'ssl', + }; + + // Set setup values from env vars (if set) + const envKeys = Object.keys(process.env); + if (Object.keys(envConfigMap).some(key => envKeys.includes(key))) { + winston.info('[install/checkSetupFlagEnv] checking env vars for setup info...'); + setupValue ||= {}; + + for (const [eventName, eventValue] of Object.entries(process.env)) { // Get setup values from env + if (eventName.startsWith('NODEBB_DB_')) { + setupValue[`${process.env.NODEBB_DB}:${envConfigMap[eventName]}`] = eventValue; + } else if (eventName.startsWith('NODEBB_')) { + setupValue[envConfigMap[eventName]] = eventValue; + } + } + + setupValue['admin:password:confirm'] = setupValue['admin:password']; + } + + // Try to get setup values from json, if successful this overwrites all values set by env + // TODO: better behaviour would be to support overrides per value, i.e. in order of priority (generic pattern): + // flag, env, config file, default + try { + if (nconf.get('setup')) { + const setupJSON = JSON.parse(nconf.get('setup')); + setupValue = {...setupValue, ...setupJSON}; + } + } catch { + winston.error('[install/checkSetupFlagEnv] invalid json in nconf.get(\'setup\'), ignoring setup values from json'); + } + + if (setupValue && typeof setupValue === 'object') { + if (setupValue['admin:username'] && setupValue['admin:password'] && setupValue['admin:password:confirm'] && setupValue['admin:email']) { + install.values = setupValue; + } else { + winston.error('[install/checkSetupFlagEnv] required values are missing for automated setup:'); + if (!setupValue['admin:username']) { + winston.error(' admin:username'); + } + + if (!setupValue['admin:password']) { + winston.error(' admin:password'); + } + + if (!setupValue['admin:password:confirm']) { + winston.error(' admin:password:confirm'); + } + + if (!setupValue['admin:email']) { + winston.error(' admin:email'); + } + + process.exit(); + } + } else if (nconf.get('database')) { + install.values = install.values || {}; + install.values.database = nconf.get('database'); + } } function checkCIFlag() { - let ciVals; - try { - ciVals = JSON.parse(nconf.get('ci')); - } catch (e) { - ciVals = undefined; - } - - if (ciVals && ciVals instanceof Object) { - if (ciVals.hasOwnProperty('host') && ciVals.hasOwnProperty('port') && ciVals.hasOwnProperty('database')) { - install.ciVals = ciVals; - } else { - winston.error('[install/checkCIFlag] required values are missing for automated CI integration:'); - if (!ciVals.hasOwnProperty('host')) { - winston.error(' host'); - } - if (!ciVals.hasOwnProperty('port')) { - winston.error(' port'); - } - if (!ciVals.hasOwnProperty('database')) { - winston.error(' database'); - } - - process.exit(); - } - } + let ciVals; + try { + ciVals = JSON.parse(nconf.get('ci')); + } catch { + ciVals = undefined; + } + + if (ciVals && ciVals instanceof Object) { + if (ciVals.hasOwnProperty('host') && ciVals.hasOwnProperty('port') && ciVals.hasOwnProperty('database')) { + install.ciVals = ciVals; + } else { + winston.error('[install/checkCIFlag] required values are missing for automated CI integration:'); + if (!ciVals.hasOwnProperty('host')) { + winston.error(' host'); + } + + if (!ciVals.hasOwnProperty('port')) { + winston.error(' port'); + } + + if (!ciVals.hasOwnProperty('database')) { + winston.error(' database'); + } + + process.exit(); + } + } } async function setupConfig() { - const configureDatabases = require('../install/databases'); - - // prompt prepends "prompt: " to questions, let's clear that. - prompt.start(); - prompt.message = ''; - prompt.delimiter = ''; - prompt.colors = false; - let config = {}; - - if (install.values) { - // Use provided values, fall back to defaults - const redisQuestions = require('./database/redis').questions; - const mongoQuestions = require('./database/mongo').questions; - const postgresQuestions = require('./database/postgres').questions; - const allQuestions = [ - ...questions.main, - ...questions.optional, - ...redisQuestions, - ...mongoQuestions, - ...postgresQuestions, - ]; - - allQuestions.forEach((question) => { - if (install.values.hasOwnProperty(question.name)) { - config[question.name] = install.values[question.name]; - } else if (question.hasOwnProperty('default')) { - config[question.name] = question.default; - } else { - config[question.name] = undefined; - } - }); - } else { - config = await prompt.get(questions.main); - } - await configureDatabases(config); - await completeConfigSetup(config); + const configureDatabases = require('../install/databases'); + + // Prompt prepends "prompt: " to questions, let's clear that. + prompt.start(); + prompt.message = ''; + prompt.delimiter = ''; + prompt.colors = false; + let config = {}; + + if (install.values) { + // Use provided values, fall back to defaults + const redisQuestions = require('./database/redis').questions; + const mongoQuestions = require('./database/mongo').questions; + const postgresQuestions = require('./database/postgres').questions; + const allQuestions = [ + ...questions.main, + ...questions.optional, + ...redisQuestions, + ...mongoQuestions, + ...postgresQuestions, + ]; + + for (const question of allQuestions) { + if (install.values.hasOwnProperty(question.name)) { + config[question.name] = install.values[question.name]; + } else if (question.hasOwnProperty('default')) { + config[question.name] = question.default; + } else { + config[question.name] = undefined; + } + } + } else { + config = await prompt.get(questions.main); + } + + await configureDatabases(config); + await completeConfigSetup(config); } async function completeConfigSetup(config) { - // Add CI object - if (install.ciVals) { - config.test_database = { ...install.ciVals }; - } - - // Add package_manager object if set - if (nconf.get('package_manager')) { - config.package_manager = nconf.get('package_manager'); - } - nconf.overrides(config); - const db = require('./database'); - await db.init(); - if (db.hasOwnProperty('createIndices')) { - await db.createIndices(); - } - - // Sanity-check/fix url/port - if (!/^http(?:s)?:\/\//.test(config.url)) { - config.url = `http://${config.url}`; - } - - // If port is explicitly passed via install vars, use it. Otherwise, glean from url if set. - const urlObj = url.parse(config.url); - if (urlObj.port && (!install.values || !install.values.hasOwnProperty('port'))) { - config.port = urlObj.port; - } - - // Remove trailing slash from non-subfolder installs - if (urlObj.path === '/') { - urlObj.path = ''; - urlObj.pathname = ''; - } - - config.url = url.format(urlObj); - - // ref: https://github.com/indexzero/nconf/issues/300 - delete config.type; - - const meta = require('./meta'); - await meta.configs.set('submitPluginUsage', config.submitPluginUsage === 'yes' ? 1 : 0); - delete config.submitPluginUsage; - - await install.save(config); + // Add CI object + if (install.ciVals) { + config.test_database = {...install.ciVals}; + } + + // Add package_manager object if set + if (nconf.get('package_manager')) { + config.package_manager = nconf.get('package_manager'); + } + + nconf.overrides(config); + const db = require('./database'); + await db.init(); + if (db.hasOwnProperty('createIndices')) { + await db.createIndices(); + } + + // Sanity-check/fix url/port + if (!/^https?:\/\//.test(config.url)) { + config.url = `http://${config.url}`; + } + + // If port is explicitly passed via install vars, use it. Otherwise, glean from url if set. + const urlObject = url.parse(config.url); + if (urlObject.port && (!install.values || !install.values.hasOwnProperty('port'))) { + config.port = urlObject.port; + } + + // Remove trailing slash from non-subfolder installs + if (urlObject.path === '/') { + urlObject.path = ''; + urlObject.pathname = ''; + } + + config.url = url.format(urlObject); + + // Ref: https://github.com/indexzero/nconf/issues/300 + delete config.type; + + const meta = require('./meta'); + await meta.configs.set('submitPluginUsage', config.submitPluginUsage === 'yes' ? 1 : 0); + delete config.submitPluginUsage; + + await install.save(config); } async function setupDefaultConfigs() { - console.log('Populating database with default configs, if not already set...'); - const meta = require('./meta'); - const defaults = require(path.join(__dirname, '../', 'install/data/defaults.json')); + console.log('Populating database with default configs, if not already set...'); + const meta = require('./meta'); + const defaults = require(path.join(__dirname, '../', 'install/data/defaults.json')); - await meta.configs.setOnEmpty(defaults); - await meta.configs.init(); + await meta.configs.setOnEmpty(defaults); + await meta.configs.init(); } async function enableDefaultTheme() { - const meta = require('./meta'); - - const id = await meta.configs.get('theme:id'); - if (id) { - console.log('Previous theme detected, skipping enabling default theme'); - return; - } - - const defaultTheme = nconf.get('defaultTheme') || 'nodebb-theme-persona'; - console.log(`Enabling default theme: ${defaultTheme}`); - await meta.themes.set({ - type: 'local', - id: defaultTheme, - }); + const meta = require('./meta'); + + const id = await meta.configs.get('theme:id'); + if (id) { + console.log('Previous theme detected, skipping enabling default theme'); + return; + } + + const defaultTheme = nconf.get('defaultTheme') || 'nodebb-theme-persona'; + console.log(`Enabling default theme: ${defaultTheme}`); + await meta.themes.set({ + type: 'local', + id: defaultTheme, + }); } async function createDefaultUserGroups() { - const groups = require('./groups'); - async function createGroup(name) { - await groups.create({ - name: name, - hidden: 1, - private: 1, - system: 1, - disableLeave: 1, - disableJoinRequests: 1, - }); - } - - const [verifiedExists, unverifiedExists, bannedExists] = await groups.exists([ - 'verified-users', 'unverified-users', 'banned-users', - ]); - if (!verifiedExists) { - await createGroup('verified-users'); - } - - if (!unverifiedExists) { - await createGroup('unverified-users'); - } - - if (!bannedExists) { - await createGroup('banned-users'); - } + const groups = require('./groups'); + async function createGroup(name) { + await groups.create({ + name, + hidden: 1, + private: 1, + system: 1, + disableLeave: 1, + disableJoinRequests: 1, + }); + } + + const [verifiedExists, unverifiedExists, bannedExists] = await groups.exists([ + 'verified-users', 'unverified-users', 'banned-users', + ]); + if (!verifiedExists) { + await createGroup('verified-users'); + } + + if (!unverifiedExists) { + await createGroup('unverified-users'); + } + + if (!bannedExists) { + await createGroup('banned-users'); + } } async function createAdministrator() { - const Groups = require('./groups'); - const memberCount = await Groups.getMemberCount('administrators'); - if (memberCount > 0) { - console.log('Administrator found, skipping Admin setup'); - return; - } - return await createAdmin(); + const Groups = require('./groups'); + const memberCount = await Groups.getMemberCount('administrators'); + if (memberCount > 0) { + console.log('Administrator found, skipping Admin setup'); + return; + } + + return await createAdmin(); } async function createAdmin() { - const User = require('./user'); - const Groups = require('./groups'); - let password; - - winston.warn('No administrators have been detected, running initial user setup\n'); - - let questions = [{ - name: 'username', - description: 'Administrator username', - required: true, - type: 'string', - }, { - name: 'email', - description: 'Administrator email address', - pattern: /.+@.+/, - required: true, - }]; - const passwordQuestions = [{ - name: 'password', - description: 'Password', - required: true, - hidden: true, - type: 'string', - }, { - name: 'password:confirm', - description: 'Confirm Password', - required: true, - hidden: true, - type: 'string', - }]; - - async function success(results) { - if (!results) { - throw new Error('aborted'); - } - - if (results['password:confirm'] !== results.password) { - winston.warn('Passwords did not match, please try again'); - return await retryPassword(results); - } - - try { - User.isPasswordValid(results.password); - } catch (err) { - const [namespace, key] = err.message.slice(2, -2).split(':', 2); - if (namespace && key && err.message.startsWith('[[') && err.message.endsWith(']]')) { - const lang = require(path.join(__dirname, `../public/language/en-GB/${namespace}`)); - if (lang && lang[key]) { - err.message = lang[key]; - } - } - - winston.warn(`Password error, please try again. ${err.message}`); - return await retryPassword(results); - } - - const adminUid = await User.create({ - username: results.username, - password: results.password, - email: results.email, - }); - await Groups.join('administrators', adminUid); - await Groups.show('administrators'); - await Groups.ownership.grant(adminUid, 'administrators'); - - return password ? results : undefined; - } - - async function retryPassword(originalResults) { - // Ask only the password questions - const results = await prompt.get(passwordQuestions); - - // Update the original data with newly collected password - originalResults.password = results.password; - originalResults['password:confirm'] = results['password:confirm']; - - // Send back to success to handle - return await success(originalResults); - } - - // Add the password questions - questions = questions.concat(passwordQuestions); - - if (!install.values) { - const results = await prompt.get(questions); - return await success(results); - } - // If automated setup did not provide a user password, generate one, - // it will be shown to the user upon setup completion - if (!install.values.hasOwnProperty('admin:password') && !nconf.get('admin:password')) { - console.log('Password was not provided during automated setup, generating one...'); - password = utils.generateUUID().slice(0, 8); - } - - const results = { - username: install.values['admin:username'] || nconf.get('admin:username') || 'admin', - email: install.values['admin:email'] || nconf.get('admin:email') || '', - password: install.values['admin:password'] || nconf.get('admin:password') || password, - 'password:confirm': install.values['admin:password:confirm'] || nconf.get('admin:password') || password, - }; - - return await success(results); + const User = require('./user'); + const Groups = require('./groups'); + let password; + + winston.warn('No administrators have been detected, running initial user setup\n'); + + let questions = [{ + name: 'username', + description: 'Administrator username', + required: true, + type: 'string', + }, { + name: 'email', + description: 'Administrator email address', + pattern: /.+@.+/, + required: true, + }]; + const passwordQuestions = [{ + name: 'password', + description: 'Password', + required: true, + hidden: true, + type: 'string', + }, { + name: 'password:confirm', + description: 'Confirm Password', + required: true, + hidden: true, + type: 'string', + }]; + + async function success(results) { + if (!results) { + throw new Error('aborted'); + } + + if (results['password:confirm'] !== results.password) { + winston.warn('Passwords did not match, please try again'); + return await retryPassword(results); + } + + try { + User.isPasswordValid(results.password); + } catch (error) { + const [namespace, key] = error.message.slice(2, -2).split(':', 2); + if (namespace && key && error.message.startsWith('[[') && error.message.endsWith(']]')) { + const lang = require(path.join(__dirname, `../public/language/en-GB/${namespace}`)); + if (lang && lang[key]) { + error.message = lang[key]; + } + } + + winston.warn(`Password error, please try again. ${error.message}`); + return await retryPassword(results); + } + + const adminUid = await User.create({ + username: results.username, + password: results.password, + email: results.email, + }); + await Groups.join('administrators', adminUid); + await Groups.show('administrators'); + await Groups.ownership.grant(adminUid, 'administrators'); + + return password ? results : undefined; + } + + async function retryPassword(originalResults) { + // Ask only the password questions + const results = await prompt.get(passwordQuestions); + + // Update the original data with newly collected password + originalResults.password = results.password; + originalResults['password:confirm'] = results['password:confirm']; + + // Send back to success to handle + return await success(originalResults); + } + + // Add the password questions + questions = questions.concat(passwordQuestions); + + if (!install.values) { + const results = await prompt.get(questions); + return await success(results); + } + + // If automated setup did not provide a user password, generate one, + // it will be shown to the user upon setup completion + if (!install.values.hasOwnProperty('admin:password') && !nconf.get('admin:password')) { + console.log('Password was not provided during automated setup, generating one...'); + password = utils.generateUUID().slice(0, 8); + } + + const results = { + username: install.values['admin:username'] || nconf.get('admin:username') || 'admin', + email: install.values['admin:email'] || nconf.get('admin:email') || '', + password: install.values['admin:password'] || nconf.get('admin:password') || password, + 'password:confirm': install.values['admin:password:confirm'] || nconf.get('admin:password') || password, + }; + + return await success(results); } async function createGlobalModeratorsGroup() { - const groups = require('./groups'); - const exists = await groups.exists('Global Moderators'); - if (exists) { - winston.info('Global Moderators group found, skipping creation!'); - } else { - await groups.create({ - name: 'Global Moderators', - userTitle: 'Global Moderator', - description: 'Forum wide moderators', - hidden: 0, - private: 1, - disableJoinRequests: 1, - }); - } - await groups.show('Global Moderators'); + const groups = require('./groups'); + const exists = await groups.exists('Global Moderators'); + if (exists) { + winston.info('Global Moderators group found, skipping creation!'); + } else { + await groups.create({ + name: 'Global Moderators', + userTitle: 'Global Moderator', + description: 'Forum wide moderators', + hidden: 0, + private: 1, + disableJoinRequests: 1, + }); + } + + await groups.show('Global Moderators'); } async function giveGlobalPrivileges() { - const privileges = require('./privileges'); - const defaultPrivileges = [ - 'groups:chat', 'groups:upload:post:image', 'groups:signature', 'groups:search:content', - 'groups:search:users', 'groups:search:tags', 'groups:view:users', 'groups:view:tags', 'groups:view:groups', - 'groups:local:login', - ]; - await privileges.global.give(defaultPrivileges, 'registered-users'); - await privileges.global.give(defaultPrivileges.concat([ - 'groups:ban', 'groups:upload:post:file', 'groups:view:users:info', - ]), 'Global Moderators'); - await privileges.global.give(['groups:view:users', 'groups:view:tags', 'groups:view:groups'], 'guests'); - await privileges.global.give(['groups:view:users', 'groups:view:tags', 'groups:view:groups'], 'spiders'); + const privileges = require('./privileges'); + const defaultPrivileges = [ + 'groups:chat', + 'groups:upload:post:image', + 'groups:signature', + 'groups:search:content', + 'groups:search:users', + 'groups:search:tags', + 'groups:view:users', + 'groups:view:tags', + 'groups:view:groups', + 'groups:local:login', + ]; + await privileges.global.give(defaultPrivileges, 'registered-users'); + await privileges.global.give(defaultPrivileges.concat([ + 'groups:ban', 'groups:upload:post:file', 'groups:view:users:info', + ]), 'Global Moderators'); + await privileges.global.give(['groups:view:users', 'groups:view:tags', 'groups:view:groups'], 'guests'); + await privileges.global.give(['groups:view:users', 'groups:view:tags', 'groups:view:groups'], 'spiders'); } async function createCategories() { - const Categories = require('./categories'); - const db = require('./database'); - const cids = await db.getSortedSetRange('categories:cid', 0, -1); - if (Array.isArray(cids) && cids.length) { - console.log(`Categories OK. Found ${cids.length} categories.`); - return; - } - - console.log('No categories found, populating instance with default categories'); - - const default_categories = JSON.parse( - await fs.promises.readFile(path.join(__dirname, '../', 'install/data/categories.json'), 'utf8') - ); - for (const categoryData of default_categories) { - // eslint-disable-next-line no-await-in-loop - await Categories.create(categoryData); - } + const Categories = require('./categories'); + const db = require('./database'); + const cids = await db.getSortedSetRange('categories:cid', 0, -1); + if (Array.isArray(cids) && cids.length > 0) { + console.log(`Categories OK. Found ${cids.length} categories.`); + return; + } + + console.log('No categories found, populating instance with default categories'); + + const default_categories = JSON.parse( + await fs.promises.readFile(path.join(__dirname, '../', 'install/data/categories.json'), 'utf8'), + ); + for (const categoryData of default_categories) { + // eslint-disable-next-line no-await-in-loop + await Categories.create(categoryData); + } } async function createMenuItems() { - const db = require('./database'); - - const exists = await db.exists('navigation:enabled'); - if (exists) { - return; - } - const navigation = require('./navigation/admin'); - const data = require('../install/data/navigation.json'); - await navigation.save(data); + const db = require('./database'); + + const exists = await db.exists('navigation:enabled'); + if (exists) { + return; + } + + const navigation = require('./navigation/admin'); + const data = require('../install/data/navigation.json'); + await navigation.save(data); } async function createWelcomePost() { - const db = require('./database'); - const Topics = require('./topics'); - - const [content, numTopics] = await Promise.all([ - fs.promises.readFile(path.join(__dirname, '../', 'install/data/welcome.md'), 'utf8'), - db.getObjectField('global', 'topicCount'), - ]); - - if (!parseInt(numTopics, 10)) { - console.log('Creating welcome post!'); - await Topics.post({ - uid: 1, - cid: 2, - title: 'Welcome to your NodeBB!', - content: content, - }); - } + const db = require('./database'); + const Topics = require('./topics'); + + const [content, numberTopics] = await Promise.all([ + fs.promises.readFile(path.join(__dirname, '../', 'install/data/welcome.md'), 'utf8'), + db.getObjectField('global', 'topicCount'), + ]); + + if (!Number.parseInt(numberTopics, 10)) { + console.log('Creating welcome post!'); + await Topics.post({ + uid: 1, + cid: 2, + title: 'Welcome to your NodeBB!', + content, + }); + } } async function enableDefaultPlugins() { - console.log('Enabling default plugins'); - - let defaultEnabled = [ - 'nodebb-plugin-composer-default', - 'nodebb-plugin-markdown', - 'nodebb-plugin-mentions', - 'nodebb-widget-essentials', - 'nodebb-rewards-essentials', - 'nodebb-plugin-emoji', - 'nodebb-plugin-emoji-android', - ]; - let customDefaults = nconf.get('defaultplugins') || nconf.get('defaultPlugins'); - - winston.info(`[install/defaultPlugins] customDefaults ${String(customDefaults)}`); - - if (customDefaults && customDefaults.length) { - try { - customDefaults = Array.isArray(customDefaults) ? customDefaults : JSON.parse(customDefaults); - defaultEnabled = defaultEnabled.concat(customDefaults); - } catch (e) { - // Invalid value received - winston.info('[install/enableDefaultPlugins] Invalid defaultPlugins value received. Ignoring.'); - } - } - - defaultEnabled = _.uniq(defaultEnabled); - - winston.info('[install/enableDefaultPlugins] activating default plugins', defaultEnabled); - - const db = require('./database'); - const order = defaultEnabled.map((plugin, index) => index); - await db.sortedSetAdd('plugins:active', order, defaultEnabled); + console.log('Enabling default plugins'); + + let defaultEnabled = [ + 'nodebb-plugin-composer-default', + 'nodebb-plugin-markdown', + 'nodebb-plugin-mentions', + 'nodebb-widget-essentials', + 'nodebb-rewards-essentials', + 'nodebb-plugin-emoji', + 'nodebb-plugin-emoji-android', + ]; + let customDefaults = nconf.get('defaultplugins') || nconf.get('defaultPlugins'); + + winston.info(`[install/defaultPlugins] customDefaults ${String(customDefaults)}`); + + if (customDefaults && customDefaults.length > 0) { + try { + customDefaults = Array.isArray(customDefaults) ? customDefaults : JSON.parse(customDefaults); + defaultEnabled = defaultEnabled.concat(customDefaults); + } catch { + // Invalid value received + winston.info('[install/enableDefaultPlugins] Invalid defaultPlugins value received. Ignoring.'); + } + } + + defaultEnabled = _.uniq(defaultEnabled); + + winston.info('[install/enableDefaultPlugins] activating default plugins', defaultEnabled); + + const db = require('./database'); + const order = defaultEnabled.map((plugin, index) => index); + await db.sortedSetAdd('plugins:active', order, defaultEnabled); } async function setCopyrightWidget() { - const db = require('./database'); - const [footerJSON, footer] = await Promise.all([ - fs.promises.readFile(path.join(__dirname, '../', 'install/data/footer.json'), 'utf8'), - db.getObjectField('widgets:global', 'footer'), - ]); - - if (!footer && footerJSON) { - await db.setObjectField('widgets:global', 'footer', footerJSON); - } + const db = require('./database'); + const [footerJSON, footer] = await Promise.all([ + fs.promises.readFile(path.join(__dirname, '../', 'install/data/footer.json'), 'utf8'), + db.getObjectField('widgets:global', 'footer'), + ]); + + if (!footer && footerJSON) { + await db.setObjectField('widgets:global', 'footer', footerJSON); + } } async function copyFavicon() { - const file = require('./file'); - const pathToIco = path.join(nconf.get('upload_path'), 'system', 'favicon.ico'); - const defaultIco = path.join(nconf.get('base_dir'), 'public', 'favicon.ico'); - const targetExists = await file.exists(pathToIco); - const defaultExists = await file.exists(defaultIco); - - if (defaultExists && !targetExists) { - try { - await fs.promises.copyFile(defaultIco, pathToIco); - } catch (err) { - winston.error(`Cannot copy favicon.ico\n${err.stack}`); - } - } + const file = require('./file'); + const pathToIco = path.join(nconf.get('upload_path'), 'system', 'favicon.ico'); + const defaultIco = path.join(nconf.get('base_dir'), 'public', 'favicon.ico'); + const targetExists = await file.exists(pathToIco); + const defaultExists = await file.exists(defaultIco); + + if (defaultExists && !targetExists) { + try { + await fs.promises.copyFile(defaultIco, pathToIco); + } catch (error) { + winston.error(`Cannot copy favicon.ico\n${error.stack}`); + } + } } async function checkUpgrade() { - const upgrade = require('./upgrade'); - try { - await upgrade.check(); - } catch (err) { - if (err.message === 'schema-out-of-date') { - await upgrade.run(); - return; - } - throw err; - } + const upgrade = require('./upgrade'); + try { + await upgrade.check(); + } catch (error) { + if (error.message === 'schema-out-of-date') { + await upgrade.run(); + return; + } + + throw error; + } } install.setup = async function () { - try { - checkSetupFlagEnv(); - checkCIFlag(); - await setupConfig(); - await setupDefaultConfigs(); - await enableDefaultTheme(); - await createCategories(); - await createDefaultUserGroups(); - const adminInfo = await createAdministrator(); - await createGlobalModeratorsGroup(); - await giveGlobalPrivileges(); - await createMenuItems(); - await createWelcomePost(); - await enableDefaultPlugins(); - await setCopyrightWidget(); - await copyFavicon(); - await checkUpgrade(); - - const data = { - ...adminInfo, - }; - return data; - } catch (err) { - if (err) { - winston.warn(`NodeBB Setup Aborted.\n ${err.stack}`); - process.exit(1); - } - } + try { + checkSetupFlagEnv(); + checkCIFlag(); + await setupConfig(); + await setupDefaultConfigs(); + await enableDefaultTheme(); + await createCategories(); + await createDefaultUserGroups(); + const adminInfo = await createAdministrator(); + await createGlobalModeratorsGroup(); + await giveGlobalPrivileges(); + await createMenuItems(); + await createWelcomePost(); + await enableDefaultPlugins(); + await setCopyrightWidget(); + await copyFavicon(); + await checkUpgrade(); + + const data = { + ...adminInfo, + }; + return data; + } catch (error) { + if (error) { + winston.warn(`NodeBB Setup Aborted.\n ${error.stack}`); + process.exit(1); + } + } }; -install.save = async function (server_conf) { - let serverConfigPath = path.join(__dirname, '../config.json'); - - if (nconf.get('config')) { - serverConfigPath = path.resolve(__dirname, '../', nconf.get('config')); - } - - let currentConfig = {}; - try { - currentConfig = require(serverConfigPath); - } catch (err) { - if (err.code !== 'MODULE_NOT_FOUND') { - throw err; - } - } - - await fs.promises.writeFile(serverConfigPath, JSON.stringify({ ...currentConfig, ...server_conf }, null, 4)); - console.log('Configuration Saved OK'); - nconf.file({ - file: serverConfigPath, - }); +install.save = async function (server_config) { + let serverConfigPath = path.join(__dirname, '../config.json'); + + if (nconf.get('config')) { + serverConfigPath = path.resolve(__dirname, '../', nconf.get('config')); + } + + let currentConfig = {}; + try { + currentConfig = require(serverConfigPath); + } catch (error) { + if (error.code !== 'MODULE_NOT_FOUND') { + throw error; + } + } + + await fs.promises.writeFile(serverConfigPath, JSON.stringify({...currentConfig, ...server_config}, null, 4)); + console.log('Configuration Saved OK'); + nconf.file({ + file: serverConfigPath, + }); }; diff --git a/src/languages.js b/src/languages.js index eeb01d0..aa541f4 100644 --- a/src/languages.js +++ b/src/languages.js @@ -1,9 +1,9 @@ 'use strict'; -const fs = require('fs'); -const path = require('path'); +const fs = require('node:fs'); +const path = require('node:path'); const utils = require('./utils'); -const { paths } = require('./constants'); +const {paths} = require('./constants'); const plugins = require('./plugins'); const Languages = module.exports; @@ -13,75 +13,80 @@ const files = fs.readdirSync(path.join(paths.nodeModules, '/timeago/locales')); Languages.timeagoCodes = files.filter(f => f.startsWith('jquery.timeago')).map(f => f.split('.')[2]); Languages.get = async function (language, namespace) { - const pathToLanguageFile = path.join(languagesPath, language, `${namespace}.json`); - if (!pathToLanguageFile.startsWith(languagesPath)) { - throw new Error('[[error:invalid-path]]'); - } - const data = await fs.promises.readFile(pathToLanguageFile, 'utf8'); - const parsed = JSON.parse(data) || {}; - const result = await plugins.hooks.fire('filter:languages.get', { - language, - namespace, - data: parsed, - }); - return result.data; + const pathToLanguageFile = path.join(languagesPath, language, `${namespace}.json`); + if (!pathToLanguageFile.startsWith(languagesPath)) { + throw new Error('[[error:invalid-path]]'); + } + + const data = await fs.promises.readFile(pathToLanguageFile, 'utf8'); + const parsed = JSON.parse(data) || {}; + const result = await plugins.hooks.fire('filter:languages.get', { + language, + namespace, + data: parsed, + }); + return result.data; }; let codeCache = null; Languages.listCodes = async function () { - if (codeCache && codeCache.length) { - return codeCache; - } - try { - const file = await fs.promises.readFile(path.join(languagesPath, 'metadata.json'), 'utf8'); - const parsed = JSON.parse(file); - - codeCache = parsed.languages; - return parsed.languages; - } catch (err) { - if (err.code === 'ENOENT') { - return []; - } - throw err; - } + if (codeCache && codeCache.length > 0) { + return codeCache; + } + + try { + const file = await fs.promises.readFile(path.join(languagesPath, 'metadata.json'), 'utf8'); + const parsed = JSON.parse(file); + + codeCache = parsed.languages; + return parsed.languages; + } catch (error) { + if (error.code === 'ENOENT') { + return []; + } + + throw error; + } }; let listCache = null; Languages.list = async function () { - if (listCache && listCache.length) { - return listCache; - } - - const codes = await Languages.listCodes(); - - let languages = await Promise.all(codes.map(async (folder) => { - try { - const configPath = path.join(languagesPath, folder, 'language.json'); - const file = await fs.promises.readFile(configPath, 'utf8'); - const lang = JSON.parse(file); - return lang; - } catch (err) { - if (err.code === 'ENOENT') { - return; - } - throw err; - } - })); - - // filter out invalid ones - languages = languages.filter(lang => lang && lang.code && lang.name && lang.dir); - - listCache = languages; - return languages; + if (listCache && listCache.length > 0) { + return listCache; + } + + const codes = await Languages.listCodes(); + + let languages = await Promise.all(codes.map(async folder => { + try { + const configPath = path.join(languagesPath, folder, 'language.json'); + const file = await fs.promises.readFile(configPath, 'utf8'); + const lang = JSON.parse(file); + return lang; + } catch (error) { + if (error.code === 'ENOENT') { + return; + } + + throw error; + } + })); + + // Filter out invalid ones + languages = languages.filter(lang => lang && lang.code && lang.name && lang.dir); + + listCache = languages; + return languages; }; Languages.userTimeagoCode = async function (userLang) { - const languageCodes = await Languages.listCodes(); - const timeagoCode = utils.userLangToTimeagoCode(userLang); - if (languageCodes.includes(userLang) && Languages.timeagoCodes.includes(timeagoCode)) { - return timeagoCode; - } - return ''; + const languageCodes = await Languages.listCodes(); + const timeagoCode = utils.userLangToTimeagoCode(userLang); + if (languageCodes.includes(userLang) && Languages.timeagoCodes.includes(timeagoCode)) { + return timeagoCode; + } + + return ''; }; require('./promisify')(Languages); diff --git a/src/logger.js b/src/logger.js index 6963b9a..7d1de29 100644 --- a/src/logger.js +++ b/src/logger.js @@ -4,214 +4,208 @@ * Logger module: ability to dynamically turn on/off logging for http requests & socket.io events */ -const fs = require('fs'); -const path = require('path'); +const fs = require('node:fs'); +const path = require('node:path'); +const util = require('node:util'); const winston = require('winston'); -const util = require('util'); const morgan = require('morgan'); - const file = require('./file'); const meta = require('./meta'); - -const opts = { - /* - * state used by Logger +const options = { + /* + * State used by Logger */ - express: { - app: {}, - set: 0, - ofn: null, - }, - streams: { - log: { f: process.stdout }, - }, + express: { + app: {}, + set: 0, + ofn: null, + }, + streams: { + log: {f: process.stdout}, + }, }; /* -- Logger -- */ const Logger = module.exports; Logger.init = function (app) { - opts.express.app = app; - /* Open log file stream & initialize express logging if meta.config.logger* variables are set */ - Logger.setup(); + options.express.app = app; + /* Open log file stream & initialize express logging if meta.config.logger* variables are set */ + Logger.setup(); }; Logger.setup = function () { - Logger.setup_one('loggerPath', meta.config.loggerPath); + Logger.setup_one('loggerPath', meta.config.loggerPath); }; Logger.setup_one = function (key, value) { - /* + /* * 1. Open the logger stream: stdout or file * 2. Re-initialize the express logger hijack */ - if (key === 'loggerPath') { - Logger.setup_one_log(value); - Logger.express_open(); - } + if (key === 'loggerPath') { + Logger.setup_one_log(value); + Logger.express_open(); + } }; Logger.setup_one_log = function (value) { - /* + /* * If logging is currently enabled, create a stream. * Otherwise, close the current stream */ - if (meta.config.loggerStatus > 0 || meta.config.loggerIOStatus) { - const stream = Logger.open(value); - if (stream) { - opts.streams.log.f = stream; - } else { - opts.streams.log.f = process.stdout; - } - } else { - Logger.close(opts.streams.log); - } + if (meta.config.loggerStatus > 0 || meta.config.loggerIOStatus) { + const stream = Logger.open(value); + options.streams.log.f = stream ? stream : process.stdout; + } else { + Logger.close(options.streams.log); + } }; Logger.open = function (value) { - /* Open the streams to log to: either a path or stdout */ - let stream; - if (value) { - if (file.existsSync(value)) { - const stats = fs.statSync(value); - if (stats) { - if (stats.isDirectory()) { - stream = fs.createWriteStream(path.join(value, 'nodebb.log'), { flags: 'a' }); - } else { - stream = fs.createWriteStream(value, { flags: 'a' }); - } - } - } else { - stream = fs.createWriteStream(value, { flags: 'a' }); - } - - if (stream) { - stream.on('error', (err) => { - winston.error(err.stack); - }); - } - } else { - stream = process.stdout; - } - return stream; + /* Open the streams to log to: either a path or stdout */ + let stream; + if (value) { + if (file.existsSync(value)) { + const stats = fs.statSync(value); + if (stats) { + stream = stats.isDirectory() ? fs.createWriteStream(path.join(value, 'nodebb.log'), {flags: 'a'}) : fs.createWriteStream(value, {flags: 'a'}); + } + } else { + stream = fs.createWriteStream(value, {flags: 'a'}); + } + + if (stream) { + stream.on('error', error => { + winston.error(error.stack); + }); + } + } else { + stream = process.stdout; + } + + return stream; }; Logger.close = function (stream) { - if (stream.f !== process.stdout && stream.f) { - stream.end(); - } - stream.f = null; + if (stream.f !== process.stdout && stream.f) { + stream.end(); + } + + stream.f = null; }; Logger.monitorConfig = function (socket, data) { - /* + /* * This monitor's when a user clicks "save" in the Logger section of the admin panel */ - Logger.setup_one(data.key, data.value); - Logger.io_close(socket); - Logger.io(socket); + Logger.setup_one(data.key, data.value); + Logger.io_close(socket); + Logger.io(socket); }; Logger.express_open = function () { - if (opts.express.set !== 1) { - opts.express.set = 1; - opts.express.app.use(Logger.expressLogger); - } - /* + if (options.express.set !== 1) { + options.express.set = 1; + options.express.app.use(Logger.expressLogger); + } + + /* * Always initialize "ofn" (original function) with the original logger function */ - opts.express.ofn = morgan('combined', { stream: opts.streams.log.f }); + options.express.ofn = morgan('combined', {stream: options.streams.log.f}); }; -Logger.expressLogger = function (req, res, next) { - /* +Logger.expressLogger = function (request, res, next) { + /* * The new express.logger * * This hijack allows us to turn logger on/off dynamically within express */ - if (meta.config.loggerStatus > 0) { - return opts.express.ofn(req, res, next); - } - return next(); + if (meta.config.loggerStatus > 0) { + return options.express.ofn(request, res, next); + } + + return next(); }; -Logger.prepare_io_string = function (_type, _uid, _args) { - /* +Logger.prepare_io_string = function (_type, _uid, _arguments) { + /* * This prepares the output string for intercepted socket.io events * * The format is: io: */ - try { - return `io: ${_uid} ${_type} ${util.inspect(Array.prototype.slice.call(_args), { depth: 3 })}\n`; - } catch (err) { - winston.info('Logger.prepare_io_string: Failed', err); - return 'error'; - } + try { + return `io: ${_uid} ${_type} ${util.inspect(Array.prototype.slice.call(_arguments), {depth: 3})}\n`; + } catch (error) { + winston.info('Logger.prepare_io_string: Failed', error); + return 'error'; + } }; Logger.io_close = function (socket) { - /* + /* * Restore all hijacked sockets to their original emit/on functions */ - if (!socket || !socket.io || !socket.io.sockets || !socket.io.sockets.sockets) { - return; - } + if (!socket || !socket.io || !socket.io.sockets || !socket.io.sockets.sockets) { + return; + } - const clientsMap = socket.io.sockets.sockets; + const clientsMap = socket.io.sockets.sockets; - for (const [, client] of clientsMap) { - if (client.oEmit && client.oEmit !== client.emit) { - client.emit = client.oEmit; - } + for (const [, client] of clientsMap) { + if (client.oEmit && client.oEmit !== client.emit) { + client.emit = client.oEmit; + } - if (client.$onevent && client.$onevent !== client.onevent) { - client.onevent = client.$onevent; - } - } + if (client.$onevent && client.$onevent !== client.onevent) { + client.onevent = client.$onevent; + } + } }; Logger.io = function (socket) { - /* + /* * Go through all of the currently established sockets & hook their .emit/.on */ - if (!socket || !socket.io || !socket.io.sockets || !socket.io.sockets.sockets) { - return; - } + if (!socket || !socket.io || !socket.io.sockets || !socket.io.sockets.sockets) { + return; + } - const clientsMap = socket.io.sockets.sockets; - for (const [, socketObj] of clientsMap) { - Logger.io_one(socketObj, socketObj.uid); - } + const clientsMap = socket.io.sockets.sockets; + for (const [, socketObject] of clientsMap) { + Logger.io_one(socketObject, socketObject.uid); + } }; Logger.io_one = function (socket, uid) { - /* + /* * This function replaces a socket's .emit/.on functions in order to intercept events */ - function override(method, name, errorMsg) { - return (...args) => { - if (opts.streams.log.f) { - opts.streams.log.f.write(Logger.prepare_io_string(name, uid, args)); - } - - try { - method.apply(socket, args); - } catch (err) { - winston.info(errorMsg, err); - } - }; - } - - if (socket && meta.config.loggerIOStatus > 0) { - // courtesy of: http://stackoverflow.com/a/9674248 - socket.oEmit = socket.emit; - const { emit } = socket; - socket.emit = override(emit, 'emit', 'Logger.io_one: emit.apply: Failed'); - - socket.$onvent = socket.onevent; - const $onevent = socket.onevent; - socket.onevent = override($onevent, 'on', 'Logger.io_one: $emit.apply: Failed'); - } + function override(method, name, errorMessage) { + return (...arguments_) => { + if (options.streams.log.f) { + options.streams.log.f.write(Logger.prepare_io_string(name, uid, arguments_)); + } + + try { + method.apply(socket, arguments_); + } catch (error) { + winston.info(errorMessage, error); + } + }; + } + + if (socket && meta.config.loggerIOStatus > 0) { + // Courtesy of: http://stackoverflow.com/a/9674248 + socket.oEmit = socket.emit; + const {emit} = socket; + socket.emit = override(emit, 'emit', 'Logger.io_one: emit.apply: Failed'); + + socket.$onvent = socket.onevent; + const $onevent = socket.onevent; + socket.onevent = override($onevent, 'on', 'Logger.io_one: $emit.apply: Failed'); + } }; diff --git a/src/messaging/create.js b/src/messaging/create.js index 2a655e6..6b0f027 100644 --- a/src/messaging/create.js +++ b/src/messaging/create.js @@ -6,97 +6,99 @@ const db = require('../database'); const user = require('../user'); module.exports = function (Messaging) { - Messaging.sendMessage = async (data) => { - await Messaging.checkContent(data.content); - const inRoom = await Messaging.isUserInRoom(data.uid, data.roomId); - if (!inRoom) { - throw new Error('[[error:not-allowed]]'); - } - - return await Messaging.addMessage(data); - }; - - Messaging.checkContent = async (content) => { - if (!content) { - throw new Error('[[error:invalid-chat-message]]'); - } - - const maximumChatMessageLength = meta.config.maximumChatMessageLength || 1000; - content = String(content).trim(); - let { length } = content; - ({ content, length } = await plugins.hooks.fire('filter:messaging.checkContent', { content, length })); - if (!content) { - throw new Error('[[error:invalid-chat-message]]'); - } - if (length > maximumChatMessageLength) { - throw new Error(`[[error:chat-message-too-long, ${maximumChatMessageLength}]]`); - } - }; - - Messaging.addMessage = async (data) => { - const mid = await db.incrObjectField('global', 'nextMid'); - const timestamp = data.timestamp || Date.now(); - let message = { - content: String(data.content), - timestamp: timestamp, - fromuid: data.uid, - roomId: data.roomId, - deleted: 0, - system: data.system || 0, - }; - - if (data.ip) { - message.ip = data.ip; - } - - message = await plugins.hooks.fire('filter:messaging.save', message); - await db.setObject(`message:${mid}`, message); - const isNewSet = await Messaging.isNewSet(data.uid, data.roomId, timestamp); - let uids = await db.getSortedSetRange(`chat:room:${data.roomId}:uids`, 0, -1); - uids = await user.blocks.filterUids(data.uid, uids); - - await Promise.all([ - Messaging.addRoomToUsers(data.roomId, uids, timestamp), - Messaging.addMessageToUsers(data.roomId, uids, mid, timestamp), - Messaging.markUnread(uids.filter(uid => uid !== String(data.uid)), data.roomId), - ]); - - const messages = await Messaging.getMessagesData([mid], data.uid, data.roomId, true); - if (!messages || !messages[0]) { - return null; - } - - messages[0].newSet = isNewSet; - messages[0].mid = mid; - messages[0].roomId = data.roomId; - plugins.hooks.fire('action:messaging.save', { message: messages[0], data: data }); - return messages[0]; - }; - - Messaging.addSystemMessage = async (content, uid, roomId) => { - const message = await Messaging.addMessage({ - content: content, - uid: uid, - roomId: roomId, - system: 1, - }); - Messaging.notifyUsersInRoom(uid, roomId, message); - }; - - Messaging.addRoomToUsers = async (roomId, uids, timestamp) => { - if (!uids.length) { - return; - } - - const keys = uids.map(uid => `uid:${uid}:chat:rooms`); - await db.sortedSetsAdd(keys, timestamp, roomId); - }; - - Messaging.addMessageToUsers = async (roomId, uids, mid, timestamp) => { - if (!uids.length) { - return; - } - const keys = uids.map(uid => `uid:${uid}:chat:room:${roomId}:mids`); - await db.sortedSetsAdd(keys, timestamp, mid); - }; + Messaging.sendMessage = async data => { + await Messaging.checkContent(data.content); + const inRoom = await Messaging.isUserInRoom(data.uid, data.roomId); + if (!inRoom) { + throw new Error('[[error:not-allowed]]'); + } + + return await Messaging.addMessage(data); + }; + + Messaging.checkContent = async content => { + if (!content) { + throw new Error('[[error:invalid-chat-message]]'); + } + + const maximumChatMessageLength = meta.config.maximumChatMessageLength || 1000; + content = String(content).trim(); + let {length} = content; + ({content, length} = await plugins.hooks.fire('filter:messaging.checkContent', {content, length})); + if (!content) { + throw new Error('[[error:invalid-chat-message]]'); + } + + if (length > maximumChatMessageLength) { + throw new Error(`[[error:chat-message-too-long, ${maximumChatMessageLength}]]`); + } + }; + + Messaging.addMessage = async data => { + const mid = await db.incrObjectField('global', 'nextMid'); + const timestamp = data.timestamp || Date.now(); + let message = { + content: String(data.content), + timestamp, + fromuid: data.uid, + roomId: data.roomId, + deleted: 0, + system: data.system || 0, + }; + + if (data.ip) { + message.ip = data.ip; + } + + message = await plugins.hooks.fire('filter:messaging.save', message); + await db.setObject(`message:${mid}`, message); + const isNewSet = await Messaging.isNewSet(data.uid, data.roomId, timestamp); + let uids = await db.getSortedSetRange(`chat:room:${data.roomId}:uids`, 0, -1); + uids = await user.blocks.filterUids(data.uid, uids); + + await Promise.all([ + Messaging.addRoomToUsers(data.roomId, uids, timestamp), + Messaging.addMessageToUsers(data.roomId, uids, mid, timestamp), + Messaging.markUnread(uids.filter(uid => uid !== String(data.uid)), data.roomId), + ]); + + const messages = await Messaging.getMessagesData([mid], data.uid, data.roomId, true); + if (!messages || !messages[0]) { + return null; + } + + messages[0].newSet = isNewSet; + messages[0].mid = mid; + messages[0].roomId = data.roomId; + plugins.hooks.fire('action:messaging.save', {message: messages[0], data}); + return messages[0]; + }; + + Messaging.addSystemMessage = async (content, uid, roomId) => { + const message = await Messaging.addMessage({ + content, + uid, + roomId, + system: 1, + }); + Messaging.notifyUsersInRoom(uid, roomId, message); + }; + + Messaging.addRoomToUsers = async (roomId, uids, timestamp) => { + if (uids.length === 0) { + return; + } + + const keys = uids.map(uid => `uid:${uid}:chat:rooms`); + await db.sortedSetsAdd(keys, timestamp, roomId); + }; + + Messaging.addMessageToUsers = async (roomId, uids, mid, timestamp) => { + if (uids.length === 0) { + return; + } + + const keys = uids.map(uid => `uid:${uid}:chat:room:${roomId}:mids`); + await db.sortedSetsAdd(keys, timestamp, mid); + }; }; diff --git a/src/messaging/data.js b/src/messaging/data.js index 7a7d80e..d0620b5 100644 --- a/src/messaging/data.js +++ b/src/messaging/data.js @@ -1,7 +1,6 @@ 'use strict'; const validator = require('validator'); - const db = require('../database'); const user = require('../user'); const utils = require('../utils'); @@ -10,147 +9,149 @@ const plugins = require('../plugins'); const intFields = ['timestamp', 'edited', 'fromuid', 'roomId', 'deleted', 'system']; module.exports = function (Messaging) { - Messaging.newMessageCutoff = 1000 * 60 * 3; - - Messaging.getMessagesFields = async (mids, fields) => { - if (!Array.isArray(mids) || !mids.length) { - return []; - } - - const keys = mids.map(mid => `message:${mid}`); - const messages = await db.getObjects(keys, fields); - - return await Promise.all(messages.map( - async (message, idx) => modifyMessage(message, fields, parseInt(mids[idx], 10)) - )); - }; - - Messaging.getMessageField = async (mid, field) => { - const fields = await Messaging.getMessageFields(mid, [field]); - return fields ? fields[field] : null; - }; - - Messaging.getMessageFields = async (mid, fields) => { - const messages = await Messaging.getMessagesFields([mid], fields); - return messages ? messages[0] : null; - }; - - Messaging.setMessageField = async (mid, field, content) => { - await db.setObjectField(`message:${mid}`, field, content); - }; - - Messaging.setMessageFields = async (mid, data) => { - await db.setObject(`message:${mid}`, data); - }; - - Messaging.getMessagesData = async (mids, uid, roomId, isNew) => { - let messages = await Messaging.getMessagesFields(mids, []); - messages = await user.blocks.filter(uid, 'fromuid', messages); - messages = messages - .map((msg, idx) => { - if (msg) { - msg.messageId = parseInt(mids[idx], 10); - msg.ip = undefined; - } - return msg; - }) - .filter(Boolean); - - const users = await user.getUsersFields( - messages.map(msg => msg && msg.fromuid), - ['uid', 'username', 'userslug', 'picture', 'status', 'banned'] - ); - - messages.forEach((message, index) => { - message.fromUser = users[index]; - message.fromUser.banned = !!message.fromUser.banned; - message.fromUser.deleted = message.fromuid !== message.fromUser.uid && message.fromUser.uid === 0; - - const self = message.fromuid === parseInt(uid, 10); - message.self = self ? 1 : 0; - - message.newSet = false; - message.roomId = String(message.roomId || roomId); - message.deleted = !!message.deleted; - message.system = !!message.system; - }); - - messages = await Promise.all(messages.map(async (message) => { - if (message.system) { - message.content = validator.escape(String(message.content)); - message.cleanedContent = utils.stripHTMLTags(utils.decodeHTMLEntities(message.content)); - return message; - } - - const result = await Messaging.parse(message.content, message.fromuid, uid, roomId, isNew); - message.content = result; - message.cleanedContent = utils.stripHTMLTags(utils.decodeHTMLEntities(result)); - return message; - })); - - if (messages.length > 1) { - // Add a spacer in between messages with time gaps between them - messages = messages.map((message, index) => { - // Compare timestamps with the previous message, and check if a spacer needs to be added - if (index > 0 && message.timestamp > messages[index - 1].timestamp + Messaging.newMessageCutoff) { - // If it's been 5 minutes, this is a new set of messages - message.newSet = true; - } else if (index > 0 && message.fromuid !== messages[index - 1].fromuid) { - // If the previous message was from the other person, this is also a new set - message.newSet = true; - } else if (index === 0) { - message.newSet = true; - } - - return message; - }); - } else if (messages.length === 1) { - // For single messages, we don't know the context, so look up the previous message and compare - const key = `uid:${uid}:chat:room:${roomId}:mids`; - const index = await db.sortedSetRank(key, messages[0].messageId); - if (index > 0) { - const mid = await db.getSortedSetRange(key, index - 1, index - 1); - const fields = await Messaging.getMessageFields(mid, ['fromuid', 'timestamp']); - if ((messages[0].timestamp > fields.timestamp + Messaging.newMessageCutoff) || - (messages[0].fromuid !== fields.fromuid)) { - // If it's been 5 minutes, this is a new set of messages - messages[0].newSet = true; - } - } else { - messages[0].newSet = true; - } - } else { - messages = []; - } - - const data = await plugins.hooks.fire('filter:messaging.getMessages', { - messages: messages, - uid: uid, - roomId: roomId, - isNew: isNew, - mids: mids, - }); - - return data && data.messages; - }; + Messaging.newMessageCutoff = 1000 * 60 * 3; + + Messaging.getMessagesFields = async (mids, fields) => { + if (!Array.isArray(mids) || mids.length === 0) { + return []; + } + + const keys = mids.map(mid => `message:${mid}`); + const messages = await db.getObjects(keys, fields); + + return await Promise.all(messages.map( + async (message, index) => modifyMessage(message, fields, Number.parseInt(mids[index], 10)), + )); + }; + + Messaging.getMessageField = async (mid, field) => { + const fields = await Messaging.getMessageFields(mid, [field]); + return fields ? fields[field] : null; + }; + + Messaging.getMessageFields = async (mid, fields) => { + const messages = await Messaging.getMessagesFields([mid], fields); + return messages ? messages[0] : null; + }; + + Messaging.setMessageField = async (mid, field, content) => { + await db.setObjectField(`message:${mid}`, field, content); + }; + + Messaging.setMessageFields = async (mid, data) => { + await db.setObject(`message:${mid}`, data); + }; + + Messaging.getMessagesData = async (mids, uid, roomId, isNew) => { + let messages = await Messaging.getMessagesFields(mids, []); + messages = await user.blocks.filter(uid, 'fromuid', messages); + messages = messages + .map((message, index) => { + if (message) { + message.messageId = Number.parseInt(mids[index], 10); + message.ip = undefined; + } + + return message; + }) + .filter(Boolean); + + const users = await user.getUsersFields( + messages.map(message => message && message.fromuid), + ['uid', 'username', 'userslug', 'picture', 'status', 'banned'], + ); + + for (const [index, message] of messages.entries()) { + message.fromUser = users[index]; + message.fromUser.banned = Boolean(message.fromUser.banned); + message.fromUser.deleted = message.fromuid !== message.fromUser.uid && message.fromUser.uid === 0; + + const self = message.fromuid === Number.parseInt(uid, 10); + message.self = self ? 1 : 0; + + message.newSet = false; + message.roomId = String(message.roomId || roomId); + message.deleted = Boolean(message.deleted); + message.system = Boolean(message.system); + } + + messages = await Promise.all(messages.map(async message => { + if (message.system) { + message.content = validator.escape(String(message.content)); + message.cleanedContent = utils.stripHTMLTags(utils.decodeHTMLEntities(message.content)); + return message; + } + + const result = await Messaging.parse(message.content, message.fromuid, uid, roomId, isNew); + message.content = result; + message.cleanedContent = utils.stripHTMLTags(utils.decodeHTMLEntities(result)); + return message; + })); + + if (messages.length > 1) { + // Add a spacer in between messages with time gaps between them + messages = messages.map((message, index) => { + // Compare timestamps with the previous message, and check if a spacer needs to be added + if (index > 0 && message.timestamp > messages[index - 1].timestamp + Messaging.newMessageCutoff) { + // If it's been 5 minutes, this is a new set of messages + message.newSet = true; + } else if (index > 0 && message.fromuid !== messages[index - 1].fromuid) { + // If the previous message was from the other person, this is also a new set + message.newSet = true; + } else if (index === 0) { + message.newSet = true; + } + + return message; + }); + } else if (messages.length === 1) { + // For single messages, we don't know the context, so look up the previous message and compare + const key = `uid:${uid}:chat:room:${roomId}:mids`; + const index = await db.sortedSetRank(key, messages[0].messageId); + if (index > 0) { + const mid = await db.getSortedSetRange(key, index - 1, index - 1); + const fields = await Messaging.getMessageFields(mid, ['fromuid', 'timestamp']); + if ((messages[0].timestamp > fields.timestamp + Messaging.newMessageCutoff) + || (messages[0].fromuid !== fields.fromuid)) { + // If it's been 5 minutes, this is a new set of messages + messages[0].newSet = true; + } + } else { + messages[0].newSet = true; + } + } else { + messages = []; + } + + const data = await plugins.hooks.fire('filter:messaging.getMessages', { + messages, + uid, + roomId, + isNew, + mids, + }); + + return data && data.messages; + }; }; async function modifyMessage(message, fields, mid) { - if (message) { - db.parseIntFields(message, intFields, fields); - if (message.hasOwnProperty('timestamp')) { - message.timestampISO = utils.toISOString(message.timestamp); - } - if (message.hasOwnProperty('edited')) { - message.editedISO = utils.toISOString(message.edited); - } - } - - const payload = await plugins.hooks.fire('filter:messaging.getFields', { - mid: mid, - message: message, - fields: fields, - }); - - return payload.message; + if (message) { + db.parseIntFields(message, intFields, fields); + if (message.hasOwnProperty('timestamp')) { + message.timestampISO = utils.toISOString(message.timestamp); + } + + if (message.hasOwnProperty('edited')) { + message.editedISO = utils.toISOString(message.edited); + } + } + + const payload = await plugins.hooks.fire('filter:messaging.getFields', { + mid, + message, + fields, + }); + + return payload.message; } diff --git a/src/messaging/delete.js b/src/messaging/delete.js index 09eb67a..f41e74a 100644 --- a/src/messaging/delete.js +++ b/src/messaging/delete.js @@ -3,31 +3,31 @@ const sockets = require('../socket.io'); module.exports = function (Messaging) { - Messaging.deleteMessage = async (mid, uid) => await doDeleteRestore(mid, 1, uid); - Messaging.restoreMessage = async (mid, uid) => await doDeleteRestore(mid, 0, uid); + Messaging.deleteMessage = async (mid, uid) => await doDeleteRestore(mid, 1, uid); + Messaging.restoreMessage = async (mid, uid) => await doDeleteRestore(mid, 0, uid); - async function doDeleteRestore(mid, state, uid) { - const field = state ? 'deleted' : 'restored'; - const { deleted, roomId } = await Messaging.getMessageFields(mid, ['deleted', 'roomId']); - if (deleted === state) { - throw new Error(`[[error:chat-${field}-already]]`); - } + async function doDeleteRestore(mid, state, uid) { + const field = state ? 'deleted' : 'restored'; + const {deleted, roomId} = await Messaging.getMessageFields(mid, ['deleted', 'roomId']); + if (deleted === state) { + throw new Error(`[[error:chat-${field}-already]]`); + } - await Messaging.setMessageField(mid, 'deleted', state); + await Messaging.setMessageField(mid, 'deleted', state); - const [uids, messages] = await Promise.all([ - Messaging.getUidsInRoom(roomId, 0, -1), - Messaging.getMessagesData([mid], uid, roomId, true), - ]); + const [uids, messages] = await Promise.all([ + Messaging.getUidsInRoom(roomId, 0, -1), + Messaging.getMessagesData([mid], uid, roomId, true), + ]); - uids.forEach((_uid) => { - if (parseInt(_uid, 10) !== parseInt(uid, 10)) { - if (state === 1) { - sockets.in(`uid_${_uid}`).emit('event:chats.delete', mid); - } else if (state === 0) { - sockets.in(`uid_${_uid}`).emit('event:chats.restore', messages[0]); - } - } - }); - } + for (const _uid of uids) { + if (Number.parseInt(_uid, 10) !== Number.parseInt(uid, 10)) { + if (state === 1) { + sockets.in(`uid_${_uid}`).emit('event:chats.delete', mid); + } else if (state === 0) { + sockets.in(`uid_${_uid}`).emit('event:chats.restore', messages[0]); + } + } + } + } }; diff --git a/src/messaging/edit.js b/src/messaging/edit.js index 95492a2..7bbe99a 100644 --- a/src/messaging/edit.js +++ b/src/messaging/edit.js @@ -4,89 +4,88 @@ const meta = require('../meta'); const user = require('../user'); const plugins = require('../plugins'); const privileges = require('../privileges'); - const sockets = require('../socket.io'); - module.exports = function (Messaging) { - Messaging.editMessage = async (uid, mid, roomId, content) => { - await Messaging.checkContent(content); - const raw = await Messaging.getMessageField(mid, 'content'); - if (raw === content) { - return; - } - - const payload = await plugins.hooks.fire('filter:messaging.edit', { - content: content, - edited: Date.now(), - }); - - if (!String(payload.content).trim()) { - throw new Error('[[error:invalid-chat-message]]'); - } - await Messaging.setMessageFields(mid, payload); - - // Propagate this change to users in the room - const [uids, messages] = await Promise.all([ - Messaging.getUidsInRoom(roomId, 0, -1), - Messaging.getMessagesData([mid], uid, roomId, true), - ]); - - uids.forEach((uid) => { - sockets.in(`uid_${uid}`).emit('event:chats.edit', { - messages: messages, - }); - }); - }; - - const canEditDelete = async (messageId, uid, type) => { - let durationConfig = ''; - if (type === 'edit') { - durationConfig = 'chatEditDuration'; - } else if (type === 'delete') { - durationConfig = 'chatDeleteDuration'; - } - - const exists = await Messaging.messageExists(messageId); - if (!exists) { - throw new Error('[[error:invalid-mid]]'); - } - - const isAdminOrGlobalMod = await user.isAdminOrGlobalMod(uid); - - if (meta.config.disableChat) { - throw new Error('[[error:chat-disabled]]'); - } else if (!isAdminOrGlobalMod && meta.config.disableChatMessageEditing) { - throw new Error('[[error:chat-message-editing-disabled]]'); - } - - const userData = await user.getUserFields(uid, ['banned']); - if (userData.banned) { - throw new Error('[[error:user-banned]]'); - } - - const canChat = await privileges.global.can('chat', uid); - if (!canChat) { - throw new Error('[[error:no-privileges]]'); - } - - const messageData = await Messaging.getMessageFields(messageId, ['fromuid', 'timestamp', 'system']); - if (isAdminOrGlobalMod && !messageData.system) { - return; - } - - const chatConfigDuration = meta.config[durationConfig]; - if (chatConfigDuration && Date.now() - messageData.timestamp > chatConfigDuration * 1000) { - throw new Error(`[[error:chat-${type}-duration-expired, ${meta.config[durationConfig]}]]`); - } - - if (messageData.fromuid === parseInt(uid, 10) && !messageData.system) { - return; - } - - throw new Error(`[[error:cant-${type}-chat-message]]`); - }; - - Messaging.canEdit = async (messageId, uid) => await canEditDelete(messageId, uid, 'edit'); - Messaging.canDelete = async (messageId, uid) => await canEditDelete(messageId, uid, 'delete'); + Messaging.editMessage = async (uid, mid, roomId, content) => { + await Messaging.checkContent(content); + const raw = await Messaging.getMessageField(mid, 'content'); + if (raw === content) { + return; + } + + const payload = await plugins.hooks.fire('filter:messaging.edit', { + content, + edited: Date.now(), + }); + + if (!String(payload.content).trim()) { + throw new Error('[[error:invalid-chat-message]]'); + } + + await Messaging.setMessageFields(mid, payload); + + // Propagate this change to users in the room + const [uids, messages] = await Promise.all([ + Messaging.getUidsInRoom(roomId, 0, -1), + Messaging.getMessagesData([mid], uid, roomId, true), + ]); + + for (const uid of uids) { + sockets.in(`uid_${uid}`).emit('event:chats.edit', { + messages, + }); + } + }; + + const canEditDelete = async (messageId, uid, type) => { + let durationConfig = ''; + if (type === 'edit') { + durationConfig = 'chatEditDuration'; + } else if (type === 'delete') { + durationConfig = 'chatDeleteDuration'; + } + + const exists = await Messaging.messageExists(messageId); + if (!exists) { + throw new Error('[[error:invalid-mid]]'); + } + + const isAdminOrGlobalModule = await user.isAdminOrGlobalMod(uid); + + if (meta.config.disableChat) { + throw new Error('[[error:chat-disabled]]'); + } else if (!isAdminOrGlobalModule && meta.config.disableChatMessageEditing) { + throw new Error('[[error:chat-message-editing-disabled]]'); + } + + const userData = await user.getUserFields(uid, ['banned']); + if (userData.banned) { + throw new Error('[[error:user-banned]]'); + } + + const canChat = await privileges.global.can('chat', uid); + if (!canChat) { + throw new Error('[[error:no-privileges]]'); + } + + const messageData = await Messaging.getMessageFields(messageId, ['fromuid', 'timestamp', 'system']); + if (isAdminOrGlobalModule && !messageData.system) { + return; + } + + const chatConfigDuration = meta.config[durationConfig]; + if (chatConfigDuration && Date.now() - messageData.timestamp > chatConfigDuration * 1000) { + throw new Error(`[[error:chat-${type}-duration-expired, ${meta.config[durationConfig]}]]`); + } + + if (messageData.fromuid === Number.parseInt(uid, 10) && !messageData.system) { + return; + } + + throw new Error(`[[error:cant-${type}-chat-message]]`); + }; + + Messaging.canEdit = async (messageId, uid) => await canEditDelete(messageId, uid, 'edit'); + Messaging.canDelete = async (messageId, uid) => await canEditDelete(messageId, uid, 'delete'); }; diff --git a/src/messaging/index.js b/src/messaging/index.js index 88213ea..de916dd 100644 --- a/src/messaging/index.js +++ b/src/messaging/index.js @@ -1,8 +1,6 @@ 'use strict'; - const validator = require('validator'); - const db = require('../database'); const user = require('../user'); const privileges = require('../privileges'); @@ -22,285 +20,293 @@ require('./notifications')(Messaging); Messaging.messageExists = async mid => db.exists(`message:${mid}`); -Messaging.getMessages = async (params) => { - const isNew = params.isNew || false; - const start = params.hasOwnProperty('start') ? params.start : 0; - const stop = parseInt(start, 10) + ((params.count || 50) - 1); - - const indices = {}; - const ok = await canGet('filter:messaging.canGetMessages', params.callerUid, params.uid); - if (!ok) { - return; - } - - const mids = await db.getSortedSetRevRange(`uid:${params.uid}:chat:room:${params.roomId}:mids`, start, stop); - if (!mids.length) { - return []; - } - mids.forEach((mid, index) => { - indices[mid] = start + index; - }); - mids.reverse(); - - const messageData = await Messaging.getMessagesData(mids, params.uid, params.roomId, isNew); - messageData.forEach((messageData) => { - messageData.index = indices[messageData.messageId.toString()]; - messageData.isOwner = messageData.fromuid === parseInt(params.uid, 10); - if (messageData.deleted && !messageData.isOwner) { - messageData.content = '[[modules:chat.message-deleted]]'; - messageData.cleanedContent = messageData.content; - } - }); - - return messageData; +Messaging.getMessages = async parameters => { + const isNew = parameters.isNew || false; + const start = parameters.hasOwnProperty('start') ? parameters.start : 0; + const stop = Number.parseInt(start, 10) + ((parameters.count || 50) - 1); + + const indices = {}; + const ok = await canGet('filter:messaging.canGetMessages', parameters.callerUid, parameters.uid); + if (!ok) { + return; + } + + const mids = await db.getSortedSetRevRange(`uid:${parameters.uid}:chat:room:${parameters.roomId}:mids`, start, stop); + if (mids.length === 0) { + return []; + } + + for (const [index, mid] of mids.entries()) { + indices[mid] = start + index; + } + + mids.reverse(); + + const messageData = await Messaging.getMessagesData(mids, parameters.uid, parameters.roomId, isNew); + messageData.forEach(messageData => { + messageData.index = indices[messageData.messageId.toString()]; + messageData.isOwner = messageData.fromuid === Number.parseInt(parameters.uid, 10); + if (messageData.deleted && !messageData.isOwner) { + messageData.content = '[[modules:chat.message-deleted]]'; + messageData.cleanedContent = messageData.content; + } + }); + + return messageData; }; async function canGet(hook, callerUid, uid) { - const data = await plugins.hooks.fire(hook, { - callerUid: callerUid, - uid: uid, - canGet: parseInt(callerUid, 10) === parseInt(uid, 10), - }); + const data = await plugins.hooks.fire(hook, { + callerUid, + uid, + canGet: Number.parseInt(callerUid, 10) === Number.parseInt(uid, 10), + }); - return data ? data.canGet : false; + return data ? data.canGet : false; } Messaging.parse = async (message, fromuid, uid, roomId, isNew) => { - const parsed = await plugins.hooks.fire('filter:parse.raw', String(message || '')); - let messageData = { - message: message, - parsed: parsed, - fromuid: fromuid, - uid: uid, - roomId: roomId, - isNew: isNew, - parsedMessage: parsed, - }; - - messageData = await plugins.hooks.fire('filter:messaging.parse', messageData); - return messageData ? messageData.parsedMessage : ''; + const parsed = await plugins.hooks.fire('filter:parse.raw', String(message || '')); + let messageData = { + message, + parsed, + fromuid, + uid, + roomId, + isNew, + parsedMessage: parsed, + }; + + messageData = await plugins.hooks.fire('filter:messaging.parse', messageData); + return messageData ? messageData.parsedMessage : ''; }; Messaging.isNewSet = async (uid, roomId, timestamp) => { - const setKey = `uid:${uid}:chat:room:${roomId}:mids`; - const messages = await db.getSortedSetRevRangeWithScores(setKey, 0, 0); - if (messages && messages.length) { - return parseInt(timestamp, 10) > parseInt(messages[0].score, 10) + Messaging.newMessageCutoff; - } - return true; + const setKey = `uid:${uid}:chat:room:${roomId}:mids`; + const messages = await db.getSortedSetRevRangeWithScores(setKey, 0, 0); + if (messages && messages.length > 0) { + return Number.parseInt(timestamp, 10) > Number.parseInt(messages[0].score, 10) + Messaging.newMessageCutoff; + } + + return true; }; Messaging.getRecentChats = async (callerUid, uid, start, stop) => { - const ok = await canGet('filter:messaging.canGetRecentChats', callerUid, uid); - if (!ok) { - return null; - } - - const roomIds = await db.getSortedSetRevRange(`uid:${uid}:chat:rooms`, start, stop); - const results = await utils.promiseParallel({ - roomData: Messaging.getRoomsData(roomIds), - unread: db.isSortedSetMembers(`uid:${uid}:chat:rooms:unread`, roomIds), - users: Promise.all(roomIds.map(async (roomId) => { - let uids = await db.getSortedSetRevRange(`chat:room:${roomId}:uids`, 0, 9); - uids = uids.filter(_uid => _uid && parseInt(_uid, 10) !== parseInt(uid, 10)); - return await user.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture', 'status', 'lastonline']); - })), - teasers: Promise.all(roomIds.map(async roomId => Messaging.getTeaser(uid, roomId))), - }); - - results.roomData.forEach((room, index) => { - if (room) { - room.users = results.users[index]; - room.groupChat = room.hasOwnProperty('groupChat') ? room.groupChat : room.users.length > 2; - room.unread = results.unread[index]; - room.teaser = results.teasers[index]; - - room.users.forEach((userData) => { - if (userData && parseInt(userData.uid, 10)) { - userData.status = user.getStatus(userData); - } - }); - room.users = room.users.filter(user => user && parseInt(user.uid, 10)); - room.lastUser = room.users[0]; - - room.usernames = Messaging.generateUsernames(room.users, uid); - } - }); - - results.roomData = results.roomData.filter(Boolean); - const ref = { rooms: results.roomData, nextStart: stop + 1 }; - return await plugins.hooks.fire('filter:messaging.getRecentChats', { - rooms: ref.rooms, - nextStart: ref.nextStart, - uid: uid, - callerUid: callerUid, - }); + const ok = await canGet('filter:messaging.canGetRecentChats', callerUid, uid); + if (!ok) { + return null; + } + + const roomIds = await db.getSortedSetRevRange(`uid:${uid}:chat:rooms`, start, stop); + const results = await utils.promiseParallel({ + roomData: Messaging.getRoomsData(roomIds), + unread: db.isSortedSetMembers(`uid:${uid}:chat:rooms:unread`, roomIds), + users: Promise.all(roomIds.map(async roomId => { + let uids = await db.getSortedSetRevRange(`chat:room:${roomId}:uids`, 0, 9); + uids = uids.filter(_uid => _uid && Number.parseInt(_uid, 10) !== Number.parseInt(uid, 10)); + return await user.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture', 'status', 'lastonline']); + })), + teasers: Promise.all(roomIds.map(async roomId => Messaging.getTeaser(uid, roomId))), + }); + + for (const [index, room] of results.roomData.entries()) { + if (room) { + room.users = results.users[index]; + room.groupChat = room.hasOwnProperty('groupChat') ? room.groupChat : room.users.length > 2; + room.unread = results.unread[index]; + room.teaser = results.teasers[index]; + + for (const userData of room.users) { + if (userData && Number.parseInt(userData.uid, 10)) { + userData.status = user.getStatus(userData); + } + } + + room.users = room.users.filter(user => user && Number.parseInt(user.uid, 10)); + room.lastUser = room.users[0]; + + room.usernames = Messaging.generateUsernames(room.users, uid); + } + } + + results.roomData = results.roomData.filter(Boolean); + const reference = {rooms: results.roomData, nextStart: stop + 1}; + return await plugins.hooks.fire('filter:messaging.getRecentChats', { + rooms: reference.rooms, + nextStart: reference.nextStart, + uid, + callerUid, + }); }; -Messaging.generateUsernames = (users, excludeUid) => users.filter(user => user && parseInt(user.uid, 10) !== excludeUid) - .map(user => user.username).join(', '); +Messaging.generateUsernames = (users, excludeUid) => users.filter(user => user && Number.parseInt(user.uid, 10) !== excludeUid) + .map(user => user.username).join(', '); Messaging.getTeaser = async (uid, roomId) => { - const mid = await Messaging.getLatestUndeletedMessage(uid, roomId); - if (!mid) { - return null; - } - const teaser = await Messaging.getMessageFields(mid, ['fromuid', 'content', 'timestamp']); - if (!teaser.fromuid) { - return null; - } - const blocked = await user.blocks.is(teaser.fromuid, uid); - if (blocked) { - return null; - } - - teaser.user = await user.getUserFields(teaser.fromuid, ['uid', 'username', 'userslug', 'picture', 'status', 'lastonline']); - if (teaser.content) { - teaser.content = utils.stripHTMLTags(utils.decodeHTMLEntities(teaser.content)); - teaser.content = validator.escape(String(teaser.content)); - } - - const payload = await plugins.hooks.fire('filter:messaging.getTeaser', { teaser: teaser }); - return payload.teaser; + const mid = await Messaging.getLatestUndeletedMessage(uid, roomId); + if (!mid) { + return null; + } + + const teaser = await Messaging.getMessageFields(mid, ['fromuid', 'content', 'timestamp']); + if (!teaser.fromuid) { + return null; + } + + const blocked = await user.blocks.is(teaser.fromuid, uid); + if (blocked) { + return null; + } + + teaser.user = await user.getUserFields(teaser.fromuid, ['uid', 'username', 'userslug', 'picture', 'status', 'lastonline']); + if (teaser.content) { + teaser.content = utils.stripHTMLTags(utils.decodeHTMLEntities(teaser.content)); + teaser.content = validator.escape(String(teaser.content)); + } + + const payload = await plugins.hooks.fire('filter:messaging.getTeaser', {teaser}); + return payload.teaser; }; Messaging.getLatestUndeletedMessage = async (uid, roomId) => { - let done = false; - let latestMid = null; - let index = 0; - let mids; - - while (!done) { - /* eslint-disable no-await-in-loop */ - mids = await db.getSortedSetRevRange(`uid:${uid}:chat:room:${roomId}:mids`, index, index); - if (mids.length) { - const states = await Messaging.getMessageFields(mids[0], ['deleted', 'system']); - done = !states.deleted && !states.system; - if (done) { - latestMid = mids[0]; - } - index += 1; - } else { - done = true; - } - } - - return latestMid; + let done = false; + let latestMid = null; + let index = 0; + let mids; + + while (!done) { + /* eslint-disable no-await-in-loop */ + mids = await db.getSortedSetRevRange(`uid:${uid}:chat:room:${roomId}:mids`, index, index); + if (mids.length > 0) { + const states = await Messaging.getMessageFields(mids[0], ['deleted', 'system']); + done = !states.deleted && !states.system; + if (done) { + latestMid = mids[0]; + } + + index += 1; + } else { + done = true; + } + } + + return latestMid; }; Messaging.canMessageUser = async (uid, toUid) => { - if (meta.config.disableChat || uid <= 0) { - throw new Error('[[error:chat-disabled]]'); - } - - if (parseInt(uid, 10) === parseInt(toUid, 10)) { - throw new Error('[[error:cant-chat-with-yourself]]'); - } - const [exists, canChat] = await Promise.all([ - user.exists(toUid), - privileges.global.can('chat', uid), - checkReputation(uid), - ]); - - if (!exists) { - throw new Error('[[error:no-user]]'); - } - - if (!canChat) { - throw new Error('[[error:no-privileges]]'); - } - - const [settings, isAdmin, isModerator, isFollowing, isBlocked] = await Promise.all([ - user.getSettings(toUid), - user.isAdministrator(uid), - user.isModeratorOfAnyCategory(uid), - user.isFollowing(toUid, uid), - user.blocks.is(uid, toUid), - ]); - - if (isBlocked || (settings.restrictChat && !isAdmin && !isModerator && !isFollowing)) { - throw new Error('[[error:chat-restricted]]'); - } - - await plugins.hooks.fire('static:messaging.canMessageUser', { - uid: uid, - toUid: toUid, - }); + if (meta.config.disableChat || uid <= 0) { + throw new Error('[[error:chat-disabled]]'); + } + + if (Number.parseInt(uid, 10) === Number.parseInt(toUid, 10)) { + throw new Error('[[error:cant-chat-with-yourself]]'); + } + + const [exists, canChat] = await Promise.all([ + user.exists(toUid), + privileges.global.can('chat', uid), + checkReputation(uid), + ]); + + if (!exists) { + throw new Error('[[error:no-user]]'); + } + + if (!canChat) { + throw new Error('[[error:no-privileges]]'); + } + + const [settings, isAdmin, isModerator, isFollowing, isBlocked] = await Promise.all([ + user.getSettings(toUid), + user.isAdministrator(uid), + user.isModeratorOfAnyCategory(uid), + user.isFollowing(toUid, uid), + user.blocks.is(uid, toUid), + ]); + + if (isBlocked || (settings.restrictChat && !isAdmin && !isModerator && !isFollowing)) { + throw new Error('[[error:chat-restricted]]'); + } + + await plugins.hooks.fire('static:messaging.canMessageUser', { + uid, + toUid, + }); }; Messaging.canMessageRoom = async (uid, roomId) => { - if (meta.config.disableChat || uid <= 0) { - throw new Error('[[error:chat-disabled]]'); - } - - const [inRoom, canChat] = await Promise.all([ - Messaging.isUserInRoom(uid, roomId), - privileges.global.can('chat', uid), - checkReputation(uid), - ]); - - if (!inRoom) { - throw new Error('[[error:not-in-room]]'); - } - - if (!canChat) { - throw new Error('[[error:no-privileges]]'); - } - - await plugins.hooks.fire('static:messaging.canMessageRoom', { - uid: uid, - roomId: roomId, - }); + if (meta.config.disableChat || uid <= 0) { + throw new Error('[[error:chat-disabled]]'); + } + + const [inRoom, canChat] = await Promise.all([ + Messaging.isUserInRoom(uid, roomId), + privileges.global.can('chat', uid), + checkReputation(uid), + ]); + + if (!inRoom) { + throw new Error('[[error:not-in-room]]'); + } + + if (!canChat) { + throw new Error('[[error:no-privileges]]'); + } + + await plugins.hooks.fire('static:messaging.canMessageRoom', { + uid, + roomId, + }); }; async function checkReputation(uid) { - if (meta.config['min:rep:chat'] > 0) { - const reputation = await user.getUserField(uid, 'reputation'); - if (meta.config['min:rep:chat'] > reputation) { - throw new Error(`[[error:not-enough-reputation-to-chat, ${meta.config['min:rep:chat']}]]`); - } - } + if (meta.config['min:rep:chat'] > 0) { + const reputation = await user.getUserField(uid, 'reputation'); + if (meta.config['min:rep:chat'] > reputation) { + throw new Error(`[[error:not-enough-reputation-to-chat, ${meta.config['min:rep:chat']}]]`); + } + } } Messaging.hasPrivateChat = async (uid, withUid) => { - if (parseInt(uid, 10) === parseInt(withUid, 10)) { - return 0; - } - - const results = await utils.promiseParallel({ - myRooms: db.getSortedSetRevRange(`uid:${uid}:chat:rooms`, 0, -1), - theirRooms: db.getSortedSetRevRange(`uid:${withUid}:chat:rooms`, 0, -1), - }); - const roomIds = results.myRooms.filter(roomId => roomId && results.theirRooms.includes(roomId)); - - if (!roomIds.length) { - return 0; - } - - let index = 0; - let roomId = 0; - while (index < roomIds.length && !roomId) { - /* eslint-disable no-await-in-loop */ - const count = await Messaging.getUserCountInRoom(roomIds[index]); - if (count === 2) { - roomId = roomIds[index]; - } else { - index += 1; - } - } - - return roomId; + if (Number.parseInt(uid, 10) === Number.parseInt(withUid, 10)) { + return 0; + } + + const results = await utils.promiseParallel({ + myRooms: db.getSortedSetRevRange(`uid:${uid}:chat:rooms`, 0, -1), + theirRooms: db.getSortedSetRevRange(`uid:${withUid}:chat:rooms`, 0, -1), + }); + const roomIds = results.myRooms.filter(roomId => roomId && results.theirRooms.includes(roomId)); + + if (roomIds.length === 0) { + return 0; + } + + let index = 0; + let roomId = 0; + while (index < roomIds.length && !roomId) { + /* eslint-disable no-await-in-loop */ + const count = await Messaging.getUserCountInRoom(roomIds[index]); + if (count === 2) { + roomId = roomIds[index]; + } else { + index += 1; + } + } + + return roomId; }; Messaging.canViewMessage = async (mids, roomId, uid) => { - let single = false; - if (!Array.isArray(mids) && isFinite(mids)) { - mids = [mids]; - single = true; - } - - const canView = await db.isSortedSetMembers(`uid:${uid}:chat:room:${roomId}:mids`, mids); - return single ? canView.pop() : canView; + let single = false; + if (!Array.isArray(mids) && isFinite(mids)) { + mids = [mids]; + single = true; + } + + const canView = await db.isSortedSetMembers(`uid:${uid}:chat:room:${roomId}:mids`, mids); + return single ? canView.pop() : canView; }; require('../promisify')(Messaging); diff --git a/src/messaging/notifications.js b/src/messaging/notifications.js index 915ccf5..1120d1b 100644 --- a/src/messaging/notifications.js +++ b/src/messaging/notifications.js @@ -1,7 +1,6 @@ 'use strict'; const winston = require('winston'); - const user = require('../user'); const notifications = require('../notifications'); const sockets = require('../socket.io'); @@ -9,74 +8,76 @@ const plugins = require('../plugins'); const meta = require('../meta'); module.exports = function (Messaging) { - Messaging.notifyQueue = {}; // Only used to notify a user of a new chat message, see Messaging.notifyUser + Messaging.notifyQueue = {}; // Only used to notify a user of a new chat message, see Messaging.notifyUser + + Messaging.notifyUsersInRoom = async (fromUid, roomId, messageObject) => { + let uids = await Messaging.getUidsInRoom(roomId, 0, -1); + uids = await user.blocks.filterUids(fromUid, uids); + + let data = { + roomId, + fromUid, + message: messageObject, + uids, + }; + data = await plugins.hooks.fire('filter:messaging.notify', data); + if (!data || !data.uids || data.uids.length === 0) { + return; + } - Messaging.notifyUsersInRoom = async (fromUid, roomId, messageObj) => { - let uids = await Messaging.getUidsInRoom(roomId, 0, -1); - uids = await user.blocks.filterUids(fromUid, uids); + uids = data.uids; + for (const uid of uids) { + data.self = Number.parseInt(uid, 10) === Number.parseInt(fromUid, 10) ? 1 : 0; + Messaging.pushUnreadCount(uid); + sockets.in(`uid_${uid}`).emit('event:chats.receive', data); + } - let data = { - roomId: roomId, - fromUid: fromUid, - message: messageObj, - uids: uids, - }; - data = await plugins.hooks.fire('filter:messaging.notify', data); - if (!data || !data.uids || !data.uids.length) { - return; - } + if (messageObject.system) { + return; + } - uids = data.uids; - uids.forEach((uid) => { - data.self = parseInt(uid, 10) === parseInt(fromUid, 10) ? 1 : 0; - Messaging.pushUnreadCount(uid); - sockets.in(`uid_${uid}`).emit('event:chats.receive', data); - }); - if (messageObj.system) { - return; - } - // Delayed notifications - let queueObj = Messaging.notifyQueue[`${fromUid}:${roomId}`]; - if (queueObj) { - queueObj.message.content += `\n${messageObj.content}`; - clearTimeout(queueObj.timeout); - } else { - queueObj = { - message: messageObj, - }; - Messaging.notifyQueue[`${fromUid}:${roomId}`] = queueObj; - } + // Delayed notifications + let queueObject = Messaging.notifyQueue[`${fromUid}:${roomId}`]; + if (queueObject) { + queueObject.message.content += `\n${messageObject.content}`; + clearTimeout(queueObject.timeout); + } else { + queueObject = { + message: messageObject, + }; + Messaging.notifyQueue[`${fromUid}:${roomId}`] = queueObject; + } - queueObj.timeout = setTimeout(async () => { - try { - await sendNotifications(fromUid, uids, roomId, queueObj.message); - } catch (err) { - winston.error(`[messaging/notifications] Unabled to send notification\n${err.stack}`); - } - }, meta.config.notificationSendDelay * 1000); - }; + queueObject.timeout = setTimeout(async () => { + try { + await sendNotifications(fromUid, uids, roomId, queueObject.message); + } catch (error) { + winston.error(`[messaging/notifications] Unabled to send notification\n${error.stack}`); + } + }, meta.config.notificationSendDelay * 1000); + }; - async function sendNotifications(fromuid, uids, roomId, messageObj) { - const isOnline = await user.isOnline(uids); - uids = uids.filter((uid, index) => !isOnline[index] && parseInt(fromuid, 10) !== parseInt(uid, 10)); - if (!uids.length) { - return; - } + async function sendNotifications(fromuid, uids, roomId, messageObject) { + const isOnline = await user.isOnline(uids); + uids = uids.filter((uid, index) => !isOnline[index] && Number.parseInt(fromuid, 10) !== Number.parseInt(uid, 10)); + if (uids.length === 0) { + return; + } - const { displayname } = messageObj.fromUser; + const {displayname} = messageObject.fromUser; - const isGroupChat = await Messaging.isGroupChat(roomId); - const notification = await notifications.create({ - type: isGroupChat ? 'new-group-chat' : 'new-chat', - subject: `[[email:notif.chat.subject, ${displayname}]]`, - bodyShort: `[[notifications:new_message_from, ${displayname}]]`, - bodyLong: messageObj.content, - nid: `chat_${fromuid}_${roomId}`, - from: fromuid, - path: `/chats/${messageObj.roomId}`, - }); + const isGroupChat = await Messaging.isGroupChat(roomId); + const notification = await notifications.create({ + type: isGroupChat ? 'new-group-chat' : 'new-chat', + subject: `[[email:notif.chat.subject, ${displayname}]]`, + bodyShort: `[[notifications:new_message_from, ${displayname}]]`, + bodyLong: messageObject.content, + nid: `chat_${fromuid}_${roomId}`, + from: fromuid, + path: `/chats/${messageObject.roomId}`, + }); - delete Messaging.notifyQueue[`${fromuid}:${roomId}`]; - notifications.push(notification, uids); - } + delete Messaging.notifyQueue[`${fromuid}:${roomId}`]; + notifications.push(notification, uids); + } }; diff --git a/src/messaging/rooms.js b/src/messaging/rooms.js index b0bf9b6..6f6b9df 100644 --- a/src/messaging/rooms.js +++ b/src/messaging/rooms.js @@ -1,7 +1,6 @@ 'use strict'; const validator = require('validator'); - const db = require('../database'); const user = require('../user'); const plugins = require('../plugins'); @@ -9,253 +8,264 @@ const privileges = require('../privileges'); const meta = require('../meta'); module.exports = function (Messaging) { - Messaging.getRoomData = async (roomId) => { - const data = await db.getObject(`chat:room:${roomId}`); - if (!data) { - throw new Error('[[error:no-chat-room]]'); - } - - modifyRoomData([data]); - return data; - }; - - Messaging.getRoomsData = async (roomIds) => { - const roomData = await db.getObjects(roomIds.map(roomId => `chat:room:${roomId}`)); - modifyRoomData(roomData); - return roomData; - }; - - function modifyRoomData(rooms) { - rooms.forEach((data) => { - if (data) { - data.roomName = data.roomName || ''; - data.roomName = validator.escape(String(data.roomName)); - if (data.hasOwnProperty('groupChat')) { - data.groupChat = parseInt(data.groupChat, 10) === 1; - } - } - }); - } - - Messaging.newRoom = async (uid, toUids) => { - const now = Date.now(); - const roomId = await db.incrObjectField('global', 'nextChatRoomId'); - const room = { - owner: uid, - roomId: roomId, - }; - - await Promise.all([ - db.setObject(`chat:room:${roomId}`, room), - db.sortedSetAdd(`chat:room:${roomId}:uids`, now, uid), - ]); - await Promise.all([ - Messaging.addUsersToRoom(uid, toUids, roomId), - Messaging.addRoomToUsers(roomId, [uid].concat(toUids), now), - ]); - // chat owner should also get the user-join system message - await Messaging.addSystemMessage('user-join', uid, roomId); - - return roomId; - }; - - Messaging.isUserInRoom = async (uid, roomId) => { - const inRoom = await db.isSortedSetMember(`chat:room:${roomId}:uids`, uid); - const data = await plugins.hooks.fire('filter:messaging.isUserInRoom', { uid: uid, roomId: roomId, inRoom: inRoom }); - return data.inRoom; - }; - - Messaging.roomExists = async roomId => db.exists(`chat:room:${roomId}:uids`); - - Messaging.getUserCountInRoom = async roomId => db.sortedSetCard(`chat:room:${roomId}:uids`); - - Messaging.isRoomOwner = async (uids, roomId) => { - const isArray = Array.isArray(uids); - if (!isArray) { - uids = [uids]; - } - const owner = await db.getObjectField(`chat:room:${roomId}`, 'owner'); - const isOwners = uids.map(uid => parseInt(uid, 10) === parseInt(owner, 10)); - - const result = await Promise.all(isOwners.map(async (isOwner, index) => { - const payload = await plugins.hooks.fire('filter:messaging.isRoomOwner', { uid: uids[index], roomId, owner, isOwner }); - return payload.isOwner; - })); - return isArray ? result : result[0]; - }; - - Messaging.addUsersToRoom = async function (uid, uids, roomId) { - const inRoom = await Messaging.isUserInRoom(uid, roomId); - const payload = await plugins.hooks.fire('filter:messaging.addUsersToRoom', { uid, uids, roomId, inRoom }); - - if (!payload.inRoom) { - throw new Error('[[error:cant-add-users-to-chat-room]]'); - } - - const now = Date.now(); - const timestamps = payload.uids.map(() => now); - await db.sortedSetAdd(`chat:room:${payload.roomId}:uids`, timestamps, payload.uids); - await updateGroupChatField([payload.roomId]); - await Promise.all(payload.uids.map(uid => Messaging.addSystemMessage('user-join', uid, payload.roomId))); - }; - - Messaging.removeUsersFromRoom = async (uid, uids, roomId) => { - const [isOwner, userCount] = await Promise.all([ - Messaging.isRoomOwner(uid, roomId), - Messaging.getUserCountInRoom(roomId), - ]); - const payload = await plugins.hooks.fire('filter:messaging.removeUsersFromRoom', { uid, uids, roomId, isOwner, userCount }); - - if (!payload.isOwner) { - throw new Error('[[error:cant-remove-users-from-chat-room]]'); - } - - await Messaging.leaveRoom(payload.uids, payload.roomId); - }; - - Messaging.isGroupChat = async function (roomId) { - return (await Messaging.getRoomData(roomId)).groupChat; - }; - - async function updateGroupChatField(roomIds) { - const userCounts = await db.sortedSetsCard(roomIds.map(roomId => `chat:room:${roomId}:uids`)); - const groupChats = roomIds.filter((roomId, index) => userCounts[index] > 2); - const privateChats = roomIds.filter((roomId, index) => userCounts[index] <= 2); - await db.setObjectBulk([ - ...groupChats.map(id => [`chat:room:${id}`, { groupChat: 1 }]), - ...privateChats.map(id => [`chat:room:${id}`, { groupChat: 0 }]), - ]); - } - - Messaging.leaveRoom = async (uids, roomId) => { - const isInRoom = await Promise.all(uids.map(uid => Messaging.isUserInRoom(uid, roomId))); - uids = uids.filter((uid, index) => isInRoom[index]); - - const keys = uids - .map(uid => `uid:${uid}:chat:rooms`) - .concat(uids.map(uid => `uid:${uid}:chat:rooms:unread`)); - - await Promise.all([ - db.sortedSetRemove(`chat:room:${roomId}:uids`, uids), - db.sortedSetsRemove(keys, roomId), - ]); - - await Promise.all(uids.map(uid => Messaging.addSystemMessage('user-leave', uid, roomId))); - await updateOwner(roomId); - await updateGroupChatField([roomId]); - }; - - Messaging.leaveRooms = async (uid, roomIds) => { - const isInRoom = await Promise.all(roomIds.map(roomId => Messaging.isUserInRoom(uid, roomId))); - roomIds = roomIds.filter((roomId, index) => isInRoom[index]); - - const roomKeys = roomIds.map(roomId => `chat:room:${roomId}:uids`); - await Promise.all([ - db.sortedSetsRemove(roomKeys, uid), - db.sortedSetRemove([ - `uid:${uid}:chat:rooms`, - `uid:${uid}:chat:rooms:unread`, - ], roomIds), - ]); - - await Promise.all( - roomIds.map(roomId => updateOwner(roomId)) - .concat(roomIds.map(roomId => Messaging.addSystemMessage('user-leave', uid, roomId))) - ); - await updateGroupChatField(roomIds); - }; - - async function updateOwner(roomId) { - const uids = await db.getSortedSetRange(`chat:room:${roomId}:uids`, 0, 0); - const newOwner = uids[0] || 0; - await db.setObjectField(`chat:room:${roomId}`, 'owner', newOwner); - } - - Messaging.getUidsInRoom = async (roomId, start, stop) => db.getSortedSetRevRange(`chat:room:${roomId}:uids`, start, stop); - - Messaging.getUsersInRoom = async (roomId, start, stop) => { - const uids = await Messaging.getUidsInRoom(roomId, start, stop); - const [users, isOwners] = await Promise.all([ - user.getUsersFields(uids, ['uid', 'username', 'picture', 'status']), - Messaging.isRoomOwner(uids, roomId), - ]); - - return users.map((user, index) => { - user.isOwner = isOwners[index]; - return user; - }); - }; - - Messaging.renameRoom = async function (uid, roomId, newName) { - if (!newName) { - throw new Error('[[error:invalid-data]]'); - } - newName = newName.trim(); - if (newName.length > 75) { - throw new Error('[[error:chat-room-name-too-long]]'); - } - - const payload = await plugins.hooks.fire('filter:chat.renameRoom', { - uid: uid, - roomId: roomId, - newName: newName, - }); - const isOwner = await Messaging.isRoomOwner(payload.uid, payload.roomId); - if (!isOwner) { - throw new Error('[[error:no-privileges]]'); - } - - await db.setObjectField(`chat:room:${payload.roomId}`, 'roomName', payload.newName); - await Messaging.addSystemMessage(`room-rename, ${payload.newName.replace(',', ',')}`, payload.uid, payload.roomId); - - plugins.hooks.fire('action:chat.renameRoom', { - roomId: payload.roomId, - newName: payload.newName, - }); - }; - - Messaging.canReply = async (roomId, uid) => { - const inRoom = await db.isSortedSetMember(`chat:room:${roomId}:uids`, uid); - const data = await plugins.hooks.fire('filter:messaging.canReply', { uid: uid, roomId: roomId, inRoom: inRoom, canReply: inRoom }); - return data.canReply; - }; - - Messaging.loadRoom = async (uid, data) => { - const canChat = await privileges.global.can('chat', uid); - if (!canChat) { - throw new Error('[[error:no-privileges]]'); - } - const inRoom = await Messaging.isUserInRoom(uid, data.roomId); - if (!inRoom) { - return null; - } - - const [room, canReply, users, messages, isAdminOrGlobalMod] = await Promise.all([ - Messaging.getRoomData(data.roomId), - Messaging.canReply(data.roomId, uid), - Messaging.getUsersInRoom(data.roomId, 0, -1), - Messaging.getMessages({ - callerUid: uid, - uid: data.uid || uid, - roomId: data.roomId, - isNew: false, - }), - user.isAdminOrGlobalMod(uid), - ]); - - room.messages = messages; - room.isOwner = await Messaging.isRoomOwner(uid, room.roomId); - room.users = users.filter(user => user && parseInt(user.uid, 10) && - parseInt(user.uid, 10) !== parseInt(uid, 10)); - room.canReply = canReply; - room.groupChat = room.hasOwnProperty('groupChat') ? room.groupChat : users.length > 2; - room.usernames = Messaging.generateUsernames(users, uid); - room.maximumUsersInChatRoom = meta.config.maximumUsersInChatRoom; - room.maximumChatMessageLength = meta.config.maximumChatMessageLength; - room.showUserInput = !room.maximumUsersInChatRoom || room.maximumUsersInChatRoom > 2; - room.isAdminOrGlobalMod = isAdminOrGlobalMod; - - const payload = await plugins.hooks.fire('filter:messaging.loadRoom', { uid, data, room }); - return payload.room; - }; + Messaging.getRoomData = async roomId => { + const data = await db.getObject(`chat:room:${roomId}`); + if (!data) { + throw new Error('[[error:no-chat-room]]'); + } + + modifyRoomData([data]); + return data; + }; + + Messaging.getRoomsData = async roomIds => { + const roomData = await db.getObjects(roomIds.map(roomId => `chat:room:${roomId}`)); + modifyRoomData(roomData); + return roomData; + }; + + function modifyRoomData(rooms) { + for (const data of rooms) { + if (data) { + data.roomName = data.roomName || ''; + data.roomName = validator.escape(String(data.roomName)); + if (data.hasOwnProperty('groupChat')) { + data.groupChat = Number.parseInt(data.groupChat, 10) === 1; + } + } + } + } + + Messaging.newRoom = async (uid, toUids) => { + const now = Date.now(); + const roomId = await db.incrObjectField('global', 'nextChatRoomId'); + const room = { + owner: uid, + roomId, + }; + + await Promise.all([ + db.setObject(`chat:room:${roomId}`, room), + db.sortedSetAdd(`chat:room:${roomId}:uids`, now, uid), + ]); + await Promise.all([ + Messaging.addUsersToRoom(uid, toUids, roomId), + Messaging.addRoomToUsers(roomId, [uid].concat(toUids), now), + ]); + // Chat owner should also get the user-join system message + await Messaging.addSystemMessage('user-join', uid, roomId); + + return roomId; + }; + + Messaging.isUserInRoom = async (uid, roomId) => { + const inRoom = await db.isSortedSetMember(`chat:room:${roomId}:uids`, uid); + const data = await plugins.hooks.fire('filter:messaging.isUserInRoom', {uid, roomId, inRoom}); + return data.inRoom; + }; + + Messaging.roomExists = async roomId => db.exists(`chat:room:${roomId}:uids`); + + Messaging.getUserCountInRoom = async roomId => db.sortedSetCard(`chat:room:${roomId}:uids`); + + Messaging.isRoomOwner = async (uids, roomId) => { + const isArray = Array.isArray(uids); + if (!isArray) { + uids = [uids]; + } + + const owner = await db.getObjectField(`chat:room:${roomId}`, 'owner'); + const isOwners = uids.map(uid => Number.parseInt(uid, 10) === Number.parseInt(owner, 10)); + + const result = await Promise.all(isOwners.map(async (isOwner, index) => { + const payload = await plugins.hooks.fire('filter:messaging.isRoomOwner', { + uid: uids[index], roomId, owner, isOwner, + }); + return payload.isOwner; + })); + return isArray ? result : result[0]; + }; + + Messaging.addUsersToRoom = async function (uid, uids, roomId) { + const inRoom = await Messaging.isUserInRoom(uid, roomId); + const payload = await plugins.hooks.fire('filter:messaging.addUsersToRoom', { + uid, uids, roomId, inRoom, + }); + + if (!payload.inRoom) { + throw new Error('[[error:cant-add-users-to-chat-room]]'); + } + + const now = Date.now(); + const timestamps = payload.uids.map(() => now); + await db.sortedSetAdd(`chat:room:${payload.roomId}:uids`, timestamps, payload.uids); + await updateGroupChatField([payload.roomId]); + await Promise.all(payload.uids.map(uid => Messaging.addSystemMessage('user-join', uid, payload.roomId))); + }; + + Messaging.removeUsersFromRoom = async (uid, uids, roomId) => { + const [isOwner, userCount] = await Promise.all([ + Messaging.isRoomOwner(uid, roomId), + Messaging.getUserCountInRoom(roomId), + ]); + const payload = await plugins.hooks.fire('filter:messaging.removeUsersFromRoom', { + uid, uids, roomId, isOwner, userCount, + }); + + if (!payload.isOwner) { + throw new Error('[[error:cant-remove-users-from-chat-room]]'); + } + + await Messaging.leaveRoom(payload.uids, payload.roomId); + }; + + Messaging.isGroupChat = async function (roomId) { + return (await Messaging.getRoomData(roomId)).groupChat; + }; + + async function updateGroupChatField(roomIds) { + const userCounts = await db.sortedSetsCard(roomIds.map(roomId => `chat:room:${roomId}:uids`)); + const groupChats = roomIds.filter((roomId, index) => userCounts[index] > 2); + const privateChats = roomIds.filter((roomId, index) => userCounts[index] <= 2); + await db.setObjectBulk([ + ...groupChats.map(id => [`chat:room:${id}`, {groupChat: 1}]), + ...privateChats.map(id => [`chat:room:${id}`, {groupChat: 0}]), + ]); + } + + Messaging.leaveRoom = async (uids, roomId) => { + const isInRoom = await Promise.all(uids.map(uid => Messaging.isUserInRoom(uid, roomId))); + uids = uids.filter((uid, index) => isInRoom[index]); + + const keys = uids + .map(uid => `uid:${uid}:chat:rooms`) + .concat(uids.map(uid => `uid:${uid}:chat:rooms:unread`)); + + await Promise.all([ + db.sortedSetRemove(`chat:room:${roomId}:uids`, uids), + db.sortedSetsRemove(keys, roomId), + ]); + + await Promise.all(uids.map(uid => Messaging.addSystemMessage('user-leave', uid, roomId))); + await updateOwner(roomId); + await updateGroupChatField([roomId]); + }; + + Messaging.leaveRooms = async (uid, roomIds) => { + const isInRoom = await Promise.all(roomIds.map(roomId => Messaging.isUserInRoom(uid, roomId))); + roomIds = roomIds.filter((roomId, index) => isInRoom[index]); + + const roomKeys = roomIds.map(roomId => `chat:room:${roomId}:uids`); + await Promise.all([ + db.sortedSetsRemove(roomKeys, uid), + db.sortedSetRemove([ + `uid:${uid}:chat:rooms`, + `uid:${uid}:chat:rooms:unread`, + ], roomIds), + ]); + + await Promise.all( + roomIds.map(roomId => updateOwner(roomId)) + .concat(roomIds.map(roomId => Messaging.addSystemMessage('user-leave', uid, roomId))), + ); + await updateGroupChatField(roomIds); + }; + + async function updateOwner(roomId) { + const uids = await db.getSortedSetRange(`chat:room:${roomId}:uids`, 0, 0); + const newOwner = uids[0] || 0; + await db.setObjectField(`chat:room:${roomId}`, 'owner', newOwner); + } + + Messaging.getUidsInRoom = async (roomId, start, stop) => db.getSortedSetRevRange(`chat:room:${roomId}:uids`, start, stop); + + Messaging.getUsersInRoom = async (roomId, start, stop) => { + const uids = await Messaging.getUidsInRoom(roomId, start, stop); + const [users, isOwners] = await Promise.all([ + user.getUsersFields(uids, ['uid', 'username', 'picture', 'status']), + Messaging.isRoomOwner(uids, roomId), + ]); + + return users.map((user, index) => { + user.isOwner = isOwners[index]; + return user; + }); + }; + + Messaging.renameRoom = async function (uid, roomId, newName) { + if (!newName) { + throw new Error('[[error:invalid-data]]'); + } + + newName = newName.trim(); + if (newName.length > 75) { + throw new Error('[[error:chat-room-name-too-long]]'); + } + + const payload = await plugins.hooks.fire('filter:chat.renameRoom', { + uid, + roomId, + newName, + }); + const isOwner = await Messaging.isRoomOwner(payload.uid, payload.roomId); + if (!isOwner) { + throw new Error('[[error:no-privileges]]'); + } + + await db.setObjectField(`chat:room:${payload.roomId}`, 'roomName', payload.newName); + await Messaging.addSystemMessage(`room-rename, ${payload.newName.replace(',', ',')}`, payload.uid, payload.roomId); + + plugins.hooks.fire('action:chat.renameRoom', { + roomId: payload.roomId, + newName: payload.newName, + }); + }; + + Messaging.canReply = async (roomId, uid) => { + const inRoom = await db.isSortedSetMember(`chat:room:${roomId}:uids`, uid); + const data = await plugins.hooks.fire('filter:messaging.canReply', { + uid, roomId, inRoom, canReply: inRoom, + }); + return data.canReply; + }; + + Messaging.loadRoom = async (uid, data) => { + const canChat = await privileges.global.can('chat', uid); + if (!canChat) { + throw new Error('[[error:no-privileges]]'); + } + + const inRoom = await Messaging.isUserInRoom(uid, data.roomId); + if (!inRoom) { + return null; + } + + const [room, canReply, users, messages, isAdminOrGlobalModule] = await Promise.all([ + Messaging.getRoomData(data.roomId), + Messaging.canReply(data.roomId, uid), + Messaging.getUsersInRoom(data.roomId, 0, -1), + Messaging.getMessages({ + callerUid: uid, + uid: data.uid || uid, + roomId: data.roomId, + isNew: false, + }), + user.isAdminOrGlobalMod(uid), + ]); + + room.messages = messages; + room.isOwner = await Messaging.isRoomOwner(uid, room.roomId); + room.users = users.filter(user => user && Number.parseInt(user.uid, 10) + && Number.parseInt(user.uid, 10) !== Number.parseInt(uid, 10)); + room.canReply = canReply; + room.groupChat = room.hasOwnProperty('groupChat') ? room.groupChat : users.length > 2; + room.usernames = Messaging.generateUsernames(users, uid); + room.maximumUsersInChatRoom = meta.config.maximumUsersInChatRoom; + room.maximumChatMessageLength = meta.config.maximumChatMessageLength; + room.showUserInput = !room.maximumUsersInChatRoom || room.maximumUsersInChatRoom > 2; + room.isAdminOrGlobalMod = isAdminOrGlobalModule; + + const payload = await plugins.hooks.fire('filter:messaging.loadRoom', {uid, data, room}); + return payload.room; + }; }; diff --git a/src/messaging/unread.js b/src/messaging/unread.js index ce5fb86..5c17d64 100644 --- a/src/messaging/unread.js +++ b/src/messaging/unread.js @@ -4,36 +4,38 @@ const db = require('../database'); const sockets = require('../socket.io'); module.exports = function (Messaging) { - Messaging.getUnreadCount = async (uid) => { - if (parseInt(uid, 10) <= 0) { - return 0; - } - - return await db.sortedSetCard(`uid:${uid}:chat:rooms:unread`); - }; - - Messaging.pushUnreadCount = async (uid) => { - if (parseInt(uid, 10) <= 0) { - return; - } - const unreadCount = await Messaging.getUnreadCount(uid); - sockets.in(`uid_${uid}`).emit('event:unread.updateChatCount', unreadCount); - }; - - Messaging.markRead = async (uid, roomId) => { - await db.sortedSetRemove(`uid:${uid}:chat:rooms:unread`, roomId); - }; - - Messaging.markAllRead = async (uid) => { - await db.delete(`uid:${uid}:chat:rooms:unread`); - }; - - Messaging.markUnread = async (uids, roomId) => { - const exists = await Messaging.roomExists(roomId); - if (!exists) { - return; - } - const keys = uids.map(uid => `uid:${uid}:chat:rooms:unread`); - return await db.sortedSetsAdd(keys, Date.now(), roomId); - }; + Messaging.getUnreadCount = async uid => { + if (Number.parseInt(uid, 10) <= 0) { + return 0; + } + + return await db.sortedSetCard(`uid:${uid}:chat:rooms:unread`); + }; + + Messaging.pushUnreadCount = async uid => { + if (Number.parseInt(uid, 10) <= 0) { + return; + } + + const unreadCount = await Messaging.getUnreadCount(uid); + sockets.in(`uid_${uid}`).emit('event:unread.updateChatCount', unreadCount); + }; + + Messaging.markRead = async (uid, roomId) => { + await db.sortedSetRemove(`uid:${uid}:chat:rooms:unread`, roomId); + }; + + Messaging.markAllRead = async uid => { + await db.delete(`uid:${uid}:chat:rooms:unread`); + }; + + Messaging.markUnread = async (uids, roomId) => { + const exists = await Messaging.roomExists(roomId); + if (!exists) { + return; + } + + const keys = uids.map(uid => `uid:${uid}:chat:rooms:unread`); + return await db.sortedSetsAdd(keys, Date.now(), roomId); + }; }; diff --git a/src/meta/aliases.js b/src/meta/aliases.js index 509a4d7..0d1ba9b 100644 --- a/src/meta/aliases.js +++ b/src/meta/aliases.js @@ -4,40 +4,40 @@ const _ = require('lodash'); const chalk = require('chalk'); const aliases = { - 'plugin static dirs': ['staticdirs'], - 'requirejs modules': ['rjs', 'modules'], - 'client js bundle': ['clientjs', 'clientscript', 'clientscripts'], - 'admin js bundle': ['adminjs', 'adminscript', 'adminscripts'], - javascript: ['js'], - 'client side styles': [ - 'clientcss', 'clientless', 'clientstyles', 'clientstyle', - ], - 'admin control panel styles': [ - 'admincss', 'adminless', 'adminstyles', 'adminstyle', 'acpcss', 'acpless', 'acpstyles', 'acpstyle', - ], - styles: ['css', 'less', 'style'], - templates: ['tpl'], - languages: ['lang', 'i18n'], + 'plugin static dirs': ['staticdirs'], + 'requirejs modules': ['rjs', 'modules'], + 'client js bundle': ['clientjs', 'clientscript', 'clientscripts'], + 'admin js bundle': ['adminjs', 'adminscript', 'adminscripts'], + javascript: ['js'], + 'client side styles': [ + 'clientcss', 'clientless', 'clientstyles', 'clientstyle', + ], + 'admin control panel styles': [ + 'admincss', 'adminless', 'adminstyles', 'adminstyle', 'acpcss', 'acpless', 'acpstyles', 'acpstyle', + ], + styles: ['css', 'less', 'style'], + templates: ['tpl'], + languages: ['lang', 'i18n'], }; exports.aliases = aliases; function buildTargets() { - let length = 0; - const output = Object.keys(aliases).map((name) => { - const arr = aliases[name]; - if (name.length > length) { - length = name.length; - } + let length = 0; + const output = Object.keys(aliases).map(name => { + const array = aliases[name]; + if (name.length > length) { + length = name.length; + } - return [name, arr.join(', ')]; - }).map(tuple => ` ${chalk.magenta(_.padEnd(`"${tuple[0]}"`, length + 2))} | ${tuple[1]}`).join('\n'); - process.stdout.write( - '\n\n Build targets:\n' + - `${chalk.green(`\n ${_.padEnd('Target', length + 2)} | Aliases`)}` + - `${chalk.blue('\n ------------------------------------------------------\n')}` + - `${output}\n\n` - ); + return [name, array.join(', ')]; + }).map(tuple => ` ${chalk.magenta(_.padEnd(`"${tuple[0]}"`, length + 2))} | ${tuple[1]}`).join('\n'); + process.stdout.write( + '\n\n Build targets:\n' + + `${chalk.green(`\n ${_.padEnd('Target', length + 2)} | Aliases`)}` + + `${chalk.blue('\n ------------------------------------------------------\n')}` + + `${output}\n\n`, + ); } exports.buildTargets = buildTargets; diff --git a/src/meta/blacklist.js b/src/meta/blacklist.js index b892574..11deedc 100644 --- a/src/meta/blacklist.js +++ b/src/meta/blacklist.js @@ -4,168 +4,172 @@ const ipaddr = require('ipaddr.js'); const winston = require('winston'); const _ = require('lodash'); const validator = require('validator'); - const db = require('../database'); const pubsub = require('../pubsub'); const plugins = require('../plugins'); const analytics = require('../analytics'); -const Blacklist = module.exports; -Blacklist._rules = {}; +const Exclude = module.exports; +Exclude._rules = {}; -Blacklist.load = async function () { - let rules = await Blacklist.get(); - rules = Blacklist.validate(rules); +Exclude.load = async function () { + let rules = await Exclude.get(); + rules = Exclude.validate(rules); - winston.verbose(`[meta/blacklist] Loading ${rules.valid.length} blacklist rule(s)${rules.duplicateCount > 0 ? `, ignored ${rules.duplicateCount} duplicate(s)` : ''}`); - if (rules.invalid.length) { - winston.warn(`[meta/blacklist] ${rules.invalid.length} invalid blacklist rule(s) were ignored.`); - } + winston.verbose(`[meta/blacklist] Loading ${rules.valid.length} blacklist rule(s)${rules.duplicateCount > 0 ? `, ignored ${rules.duplicateCount} duplicate(s)` : ''}`); + if (rules.invalid.length > 0) { + winston.warn(`[meta/blacklist] ${rules.invalid.length} invalid blacklist rule(s) were ignored.`); + } - Blacklist._rules = { - ipv4: rules.ipv4, - ipv6: rules.ipv6, - cidr: rules.cidr, - cidr6: rules.cidr6, - }; + Exclude._rules = { + ipv4: rules.ipv4, + ipv6: rules.ipv6, + cidr: rules.cidr, + cidr6: rules.cidr6, + }; }; -pubsub.on('blacklist:reload', Blacklist.load); +pubsub.on('blacklist:reload', Exclude.load); -Blacklist.save = async function (rules) { - await db.setObject('ip-blacklist-rules', { rules: rules }); - await Blacklist.load(); - pubsub.publish('blacklist:reload'); +Exclude.save = async function (rules) { + await db.setObject('ip-blacklist-rules', {rules}); + await Exclude.load(); + pubsub.publish('blacklist:reload'); }; -Blacklist.get = async function () { - const data = await db.getObject('ip-blacklist-rules'); - return data && data.rules; +Exclude.get = async function () { + const data = await db.getObject('ip-blacklist-rules'); + return data && data.rules; }; -Blacklist.test = async function (clientIp) { - // Some handy test addresses - // clientIp = '2001:db8:85a3:0:0:8a2e:370:7334'; // IPv6 - // clientIp = '127.0.15.1'; // IPv4 - // clientIp = '127.0.15.1:3443'; // IPv4 with port strip port to not fail - if (!clientIp) { - return; - } - clientIp = clientIp.split(':').length === 2 ? clientIp.split(':')[0] : clientIp; - - let addr; - try { - addr = ipaddr.parse(clientIp); - } catch (err) { - winston.error(`[meta/blacklist] Error parsing client IP : ${clientIp}`); - throw err; - } - - if ( - !Blacklist._rules.ipv4.includes(clientIp) && // not explicitly specified in ipv4 list - !Blacklist._rules.ipv6.includes(clientIp) && // not explicitly specified in ipv6 list - !Blacklist._rules.cidr.some((subnet) => { - const cidr = ipaddr.parseCIDR(subnet); - if (addr.kind() !== cidr[0].kind()) { - return false; - } - return addr.match(cidr); - }) // not in a blacklisted IPv4 or IPv6 cidr range - ) { - try { - // To return test failure, pass back an error in callback - await plugins.hooks.fire('filter:blacklist.test', { ip: clientIp }); - } catch (err) { - analytics.increment('blacklist'); - throw err; - } - } else { - const err = new Error('[[error:blacklisted-ip]]'); - err.code = 'blacklisted-ip'; - - analytics.increment('blacklist'); - throw err; - } +Exclude.test = async function (clientIp) { + // Some handy test addresses + // clientIp = '2001:db8:85a3:0:0:8a2e:370:7334'; // IPv6 + // clientIp = '127.0.15.1'; // IPv4 + // clientIp = '127.0.15.1:3443'; // IPv4 with port strip port to not fail + if (!clientIp) { + return; + } + + clientIp = clientIp.split(':').length === 2 ? clientIp.split(':')[0] : clientIp; + + let addr; + try { + addr = ipaddr.parse(clientIp); + } catch (error) { + winston.error(`[meta/blacklist] Error parsing client IP : ${clientIp}`); + throw error; + } + + if ( + !Exclude._rules.ipv4.includes(clientIp) // Not explicitly specified in ipv4 list + && !Exclude._rules.ipv6.includes(clientIp) // Not explicitly specified in ipv6 list + && !Exclude._rules.cidr.some(subnet => { + const cidr = ipaddr.parseCIDR(subnet); + if (addr.kind() !== cidr[0].kind()) { + return false; + } + + return addr.match(cidr); + }) // Not in a blacklisted IPv4 or IPv6 cidr range + ) { + try { + // To return test failure, pass back an error in callback + await plugins.hooks.fire('filter:blacklist.test', {ip: clientIp}); + } catch (error) { + analytics.increment('blacklist'); + throw error; + } + } else { + const error = new Error('[[error:blacklisted-ip]]'); + error.code = 'blacklisted-ip'; + + analytics.increment('blacklist'); + throw error; + } }; -Blacklist.validate = function (rules) { - rules = (rules || '').split('\n'); - const ipv4 = []; - const ipv6 = []; - const cidr = []; - const invalid = []; - let duplicateCount = 0; - - const inlineCommentMatch = /#.*$/; - const whitelist = ['127.0.0.1', '::1', '::ffff:0:127.0.0.1']; - - // Filter out blank lines and lines starting with the hash character (comments) - // Also trim inputs and remove inline comments - rules = rules.map((rule) => { - rule = rule.replace(inlineCommentMatch, '').trim(); - return rule.length && !rule.startsWith('#') ? rule : null; - }).filter(Boolean); - - // Filter out duplicates - const uniqRules = _.uniq(rules); - duplicateCount += rules.length - uniqRules.length; - rules = uniqRules; - - // Filter out invalid rules - rules = rules.filter((rule) => { - let addr; - let isRange = false; - try { - addr = ipaddr.parse(rule); - } catch (e) { - // Do nothing - } - - try { - addr = ipaddr.parseCIDR(rule); - isRange = true; - } catch (e) { - // Do nothing - } - - if (!addr || whitelist.includes(rule)) { - invalid.push(validator.escape(rule)); - return false; - } - - if (!isRange) { - if (addr.kind() === 'ipv4' && ipaddr.IPv4.isValid(rule)) { - ipv4.push(rule); - return true; - } - if (addr.kind() === 'ipv6' && ipaddr.IPv6.isValid(rule)) { - ipv6.push(rule); - return true; - } - } else { - cidr.push(rule); - return true; - } - return false; - }); - - return { - numRules: rules.length + invalid.length, - ipv4: ipv4, - ipv6: ipv6, - cidr: cidr, - valid: rules, - invalid: invalid, - duplicateCount: duplicateCount, - }; +Exclude.validate = function (rules) { + rules = (rules || '').split('\n'); + const ipv4 = []; + const ipv6 = []; + const cidr = []; + const invalid = []; + let duplicateCount = 0; + + const inlineCommentMatch = /#.*$/; + const include = new Set(['127.0.0.1', '::1', '::ffff:0:127.0.0.1']); + + // Filter out blank lines and lines starting with the hash character (comments) + // Also trim inputs and remove inline comments + rules = rules.map(rule => { + rule = rule.replace(inlineCommentMatch, '').trim(); + return rule.length > 0 && !rule.startsWith('#') ? rule : null; + }).filter(Boolean); + + // Filter out duplicates + const uniqRules = _.uniq(rules); + duplicateCount += rules.length - uniqRules.length; + rules = uniqRules; + + // Filter out invalid rules + rules = rules.filter(rule => { + let addr; + let isRange = false; + try { + addr = ipaddr.parse(rule); + } catch { + // Do nothing + } + + try { + addr = ipaddr.parseCIDR(rule); + isRange = true; + } catch { + // Do nothing + } + + if (!addr || include.has(rule)) { + invalid.push(validator.escape(rule)); + return false; + } + + if (isRange) { + cidr.push(rule); + return true; + } + + if (addr.kind() === 'ipv4' && ipaddr.IPv4.isValid(rule)) { + ipv4.push(rule); + return true; + } + + if (addr.kind() === 'ipv6' && ipaddr.IPv6.isValid(rule)) { + ipv6.push(rule); + return true; + } + + return false; + }); + + return { + numRules: rules.length + invalid.length, + ipv4, + ipv6, + cidr, + valid: rules, + invalid, + duplicateCount, + }; }; -Blacklist.addRule = async function (rule) { - const { valid } = Blacklist.validate(rule); - if (!valid.length) { - throw new Error('[[error:invalid-rule]]'); - } - let rules = await Blacklist.get(); - rules = `${rules}\n${valid[0]}`; - await Blacklist.save(rules); +Exclude.addRule = async function (rule) { + const {valid} = Exclude.validate(rule); + if (valid.length === 0) { + throw new Error('[[error:invalid-rule]]'); + } + + let rules = await Exclude.get(); + rules = `${rules}\n${valid[0]}`; + await Exclude.save(rules); }; diff --git a/src/meta/build.js b/src/meta/build.js index 00b91fe..2b4790a 100644 --- a/src/meta/build.js +++ b/src/meta/build.js @@ -1,264 +1,265 @@ 'use strict'; -const os = require('os'); +const os = require('node:os'); +const path = require('node:path'); +const {exec} = require('node:child_process'); +const util = require('node:util'); const winston = require('winston'); const nconf = require('nconf'); const _ = require('lodash'); -const path = require('path'); const mkdirp = require('mkdirp'); const chalk = require('chalk'); -const { exec } = require('child_process'); -const util = require('util'); - const cacheBuster = require('./cacheBuster'); -const { aliases } = require('./aliases'); +const {aliases} = require('./aliases'); let meta; const targetHandlers = { - 'plugin static dirs': async function () { - await meta.js.linkStatics(); - }, - 'requirejs modules': async function (parallel) { - await meta.js.buildModules(parallel); - }, - 'client js bundle': async function (parallel) { - await meta.js.buildBundle('client', parallel); - }, - 'admin js bundle': async function (parallel) { - await meta.js.buildBundle('admin', parallel); - }, - javascript: [ - 'plugin static dirs', - 'requirejs modules', - 'client js bundle', - 'admin js bundle', - ], - 'client side styles': async function (parallel) { - await meta.css.buildBundle('client', parallel); - }, - 'admin control panel styles': async function (parallel) { - await meta.css.buildBundle('admin', parallel); - }, - styles: [ - 'client side styles', - 'admin control panel styles', - ], - templates: async function () { - await meta.templates.compile(); - }, - languages: async function () { - await meta.languages.build(); - }, + async 'plugin static dirs'() { + await meta.js.linkStatics(); + }, + async 'requirejs modules'(parallel) { + await meta.js.buildModules(parallel); + }, + async 'client js bundle'(parallel) { + await meta.js.buildBundle('client', parallel); + }, + async 'admin js bundle'(parallel) { + await meta.js.buildBundle('admin', parallel); + }, + javascript: [ + 'plugin static dirs', + 'requirejs modules', + 'client js bundle', + 'admin js bundle', + ], + async 'client side styles'(parallel) { + await meta.css.buildBundle('client', parallel); + }, + async 'admin control panel styles'(parallel) { + await meta.css.buildBundle('admin', parallel); + }, + styles: [ + 'client side styles', + 'admin control panel styles', + ], + async templates() { + await meta.templates.compile(); + }, + async languages() { + await meta.languages.build(); + }, }; -const aliasMap = Object.keys(aliases).reduce((prev, key) => { - const arr = aliases[key]; - arr.forEach((alias) => { - prev[alias] = key; - }); - prev[key] = key; - return prev; +const aliasMap = Object.keys(aliases).reduce((previous, key) => { + const array = aliases[key]; + for (const alias of array) { + previous[alias] = key; + } + + previous[key] = key; + return previous; }, {}); async function beforeBuild(targets) { - const db = require('../database'); - process.stdout.write(`${chalk.green(' started')}\n`); - try { - await db.init(); - meta = require('./index'); - await meta.themes.setupPaths(); - const plugins = require('../plugins'); - await plugins.prepareForBuild(targets); - await mkdirp(path.join(__dirname, '../../build/public')); - } catch (err) { - winston.error(`[build] Encountered error preparing for build\n${err.stack}`); - throw err; - } + const db = require('../database'); + process.stdout.write(`${chalk.green(' started')}\n`); + try { + await db.init(); + meta = require('./index'); + await meta.themes.setupPaths(); + const plugins = require('../plugins'); + await plugins.prepareForBuild(targets); + await mkdirp(path.join(__dirname, '../../build/public')); + } catch (error) { + winston.error(`[build] Encountered error preparing for build\n${error.stack}`); + throw error; + } } const allTargets = Object.keys(targetHandlers).filter(name => typeof targetHandlers[name] === 'function'); async function buildTargets(targets, parallel, options) { - const length = Math.max(...targets.map(name => name.length)); - const jsTargets = targets.filter(target => targetHandlers.javascript.includes(target)); - const otherTargets = targets.filter(target => !targetHandlers.javascript.includes(target)); - - // Compile TypeScript into JavaScript - winston.info(`[build] Building TypeScript files`); - const execAsync = util.promisify(exec); - await execAsync('npx tsc'); - winston.info(`[build] TypeScript building complete`); - - async function buildJSTargets() { - await Promise.all( - jsTargets.map( - target => step(target, parallel, `${_.padStart(target, length)} `) - ) - ); - // run webpack after jstargets are done, no need to wait for css/templates etc. - if (options.webpack || options.watch) { - await exports.webpack(options); - } - } - if (parallel) { - await Promise.all([ - buildJSTargets(), - ...otherTargets.map( - target => step(target, parallel, `${_.padStart(target, length)} `) - ), - ]); - } else { - for (const target of targets) { - // eslint-disable-next-line no-await-in-loop - await step(target, parallel, `${_.padStart(target, length)} `); - } - if (options.webpack || options.watch) { - await exports.webpack(options); - } - } + const length = Math.max(...targets.map(name => name.length)); + const jsTargets = targets.filter(target => targetHandlers.javascript.includes(target)); + const otherTargets = targets.filter(target => !targetHandlers.javascript.includes(target)); + + // Compile TypeScript into JavaScript + winston.info('[build] Building TypeScript files'); + const execAsync = util.promisify(exec); + await execAsync('npx tsc'); + winston.info('[build] TypeScript building complete'); + + async function buildJSTargets() { + await Promise.all( + jsTargets.map( + target => step(target, parallel, `${_.padStart(target, length)} `), + ), + ); + // Run webpack after jstargets are done, no need to wait for css/templates etc. + if (options.webpack || options.watch) { + await exports.webpack(options); + } + } + + if (parallel) { + await Promise.all([ + buildJSTargets(), + ...otherTargets.map( + target => step(target, parallel, `${_.padStart(target, length)} `), + ), + ]); + } else { + for (const target of targets) { + // eslint-disable-next-line no-await-in-loop + await step(target, parallel, `${_.padStart(target, length)} `); + } + + if (options.webpack || options.watch) { + await exports.webpack(options); + } + } } -async function step(target, parallel, targetStr) { - const startTime = Date.now(); - winston.info(`[build] ${targetStr} build started`); - try { - await targetHandlers[target](parallel); - const time = (Date.now() - startTime) / 1000; - - winston.info(`[build] ${targetStr} build completed in ${time}sec`); - } catch (err) { - winston.error(`[build] ${targetStr} build failed`); - throw err; - } +async function step(target, parallel, targetString) { + const startTime = Date.now(); + winston.info(`[build] ${targetString} build started`); + try { + await targetHandlers[target](parallel); + const time = (Date.now() - startTime) / 1000; + + winston.info(`[build] ${targetString} build completed in ${time}sec`); + } catch (error) { + winston.error(`[build] ${targetString} build failed`); + throw error; + } } exports.build = async function (targets, options) { - if (!options) { - options = {}; - } - - if (targets === true) { - targets = allTargets; - } else if (!Array.isArray(targets)) { - targets = targets.split(','); - } - - let series = nconf.get('series') || options.series; - if (series === undefined) { - // Detect # of CPUs and select strategy as appropriate - winston.verbose('[build] Querying CPU core count for build strategy'); - const cpus = os.cpus(); - series = cpus.length < 4; - winston.verbose(`[build] System returned ${cpus.length} cores, opting for ${series ? 'series' : 'parallel'} build strategy`); - } - - targets = targets - // get full target name - .map((target) => { - target = target.toLowerCase().replace(/-/g, ''); - if (!aliasMap[target]) { - winston.warn(`[build] Unknown target: ${target}`); - if (target.includes(',')) { - winston.warn('[build] Are you specifying multiple targets? Separate them with spaces:'); - winston.warn('[build] e.g. `./nodebb build adminjs tpl`'); - } - - return false; - } - - return aliasMap[target]; - }) - // filter nonexistent targets - .filter(Boolean); - - // map multitargets to their sets - targets = _.uniq(_.flatMap(targets, target => ( - Array.isArray(targetHandlers[target]) ? - targetHandlers[target] : - target - ))); - - winston.verbose(`[build] building the following targets: ${targets.join(', ')}`); - - if (!targets) { - winston.info('[build] No valid targets supplied. Aborting.'); - return; - } - - try { - await beforeBuild(targets); - const threads = parseInt(nconf.get('threads'), 10); - if (threads) { - require('./minifier').maxThreads = threads - 1; - } - - if (!series) { - winston.info('[build] Building in parallel mode'); - } else { - winston.info('[build] Building in series mode'); - } - - const startTime = Date.now(); - await buildTargets(targets, !series, options); - - const totalTime = (Date.now() - startTime) / 1000; - await cacheBuster.write(); - winston.info(`[build] Asset compilation successful. Completed in ${totalTime}sec.`); - } catch (err) { - winston.error(`[build] Encountered error during build step\n${err.stack ? err.stack : err}`); - throw err; - } + options ||= {}; + + if (targets === true) { + targets = allTargets; + } else if (!Array.isArray(targets)) { + targets = targets.split(','); + } + + let series = nconf.get('series') || options.series; + if (series === undefined) { + // Detect # of CPUs and select strategy as appropriate + winston.verbose('[build] Querying CPU core count for build strategy'); + const cpus = os.cpus(); + series = cpus.length < 4; + winston.verbose(`[build] System returned ${cpus.length} cores, opting for ${series ? 'series' : 'parallel'} build strategy`); + } + + targets = targets + // Get full target name + .map(target => { + target = target.toLowerCase().replaceAll('-', ''); + if (!aliasMap[target]) { + winston.warn(`[build] Unknown target: ${target}`); + if (target.includes(',')) { + winston.warn('[build] Are you specifying multiple targets? Separate them with spaces:'); + winston.warn('[build] e.g. `./nodebb build adminjs tpl`'); + } + + return false; + } + + return aliasMap[target]; + }) + // Filter nonexistent targets + .filter(Boolean); + + // Map multitargets to their sets + targets = _.uniq(_.flatMap(targets, target => ( + Array.isArray(targetHandlers[target]) + ? targetHandlers[target] + : target + ))); + + winston.verbose(`[build] building the following targets: ${targets.join(', ')}`); + + if (!targets) { + winston.info('[build] No valid targets supplied. Aborting.'); + return; + } + + try { + await beforeBuild(targets); + const threads = Number.parseInt(nconf.get('threads'), 10); + if (threads) { + require('./minifier').maxThreads = threads - 1; + } + + if (series) { + winston.info('[build] Building in series mode'); + } else { + winston.info('[build] Building in parallel mode'); + } + + const startTime = Date.now(); + await buildTargets(targets, !series, options); + + const totalTime = (Date.now() - startTime) / 1000; + await cacheBuster.write(); + winston.info(`[build] Asset compilation successful. Completed in ${totalTime}sec.`); + } catch (error) { + winston.error(`[build] Encountered error during build step\n${error.stack ? error.stack : error}`); + throw error; + } }; function getWebpackConfig() { - return require(process.env.NODE_ENV !== 'development' ? '../../webpack.prod' : '../../webpack.dev'); + return require(process.env.NODE_ENV === 'development' ? '../../webpack.dev' : '../../webpack.prod'); } exports.webpack = async function (options) { - winston.info(`[build] ${(options.watch ? 'Watching' : 'Bundling')} with Webpack.`); - const webpack = require('webpack'); - const fs = require('fs'); - const util = require('util'); - const plugins = require('../plugins/data'); - - const activePlugins = (await plugins.getActive()).map(p => p.id); - if (!activePlugins.includes('nodebb-plugin-composer-default')) { - activePlugins.push('nodebb-plugin-composer-default'); - } - await fs.promises.writeFile(path.resolve(__dirname, '../../build/active_plugins.json'), JSON.stringify(activePlugins)); - - const webpackCfg = getWebpackConfig(); - const compiler = webpack(webpackCfg); - const webpackRun = util.promisify(compiler.run).bind(compiler); - const webpackWatch = util.promisify(compiler.watch).bind(compiler); - try { - let stats; - if (options.watch) { - stats = await webpackWatch(webpackCfg.watchOptions); - compiler.hooks.assetEmitted.tap('nbbWatchPlugin', (file) => { - console.log(`webpack:assetEmitted > ${webpackCfg.output.publicPath}${file}`); - }); - } else { - stats = await webpackRun(); - } - - if (stats.hasErrors() || stats.hasWarnings()) { - console.log(stats.toString('minimal')); - } else { - const statsJson = stats.toJson(); - winston.info(`[build] ${(options.watch ? 'Watching' : 'Bundling')} took ${statsJson.time} ms`); - } - } catch (err) { - console.error(err.stack || err); - if (err.details) { - console.error(err.details); - } - } + winston.info(`[build] ${(options.watch ? 'Watching' : 'Bundling')} with Webpack.`); + const webpack = require('webpack'); + const fs = require('node:fs'); + const util = require('node:util'); + const plugins = require('../plugins/data'); + + const activePlugins = (await plugins.getActive()).map(p => p.id); + if (!activePlugins.includes('nodebb-plugin-composer-default')) { + activePlugins.push('nodebb-plugin-composer-default'); + } + + await fs.promises.writeFile(path.resolve(__dirname, '../../build/active_plugins.json'), JSON.stringify(activePlugins)); + + const webpackCfg = getWebpackConfig(); + const compiler = webpack(webpackCfg); + const webpackRun = util.promisify(compiler.run).bind(compiler); + const webpackWatch = util.promisify(compiler.watch).bind(compiler); + try { + let stats; + if (options.watch) { + stats = await webpackWatch(webpackCfg.watchOptions); + compiler.hooks.assetEmitted.tap('nbbWatchPlugin', file => { + console.log(`webpack:assetEmitted > ${webpackCfg.output.publicPath}${file}`); + }); + } else { + stats = await webpackRun(); + } + + if (stats.hasErrors() || stats.hasWarnings()) { + console.log(stats.toString('minimal')); + } else { + const statsJson = stats.toJson(); + winston.info(`[build] ${(options.watch ? 'Watching' : 'Bundling')} took ${statsJson.time} ms`); + } + } catch (error) { + console.error(error.stack || error); + if (error.details) { + console.error(error.details); + } + } }; exports.buildAll = async function () { - await exports.build(allTargets, { webpack: true }); + await exports.build(allTargets, {webpack: true}); }; require('../promisify')(exports); diff --git a/src/meta/cacheBuster.js b/src/meta/cacheBuster.js index e24f8aa..93b24f1 100644 --- a/src/meta/cacheBuster.js +++ b/src/meta/cacheBuster.js @@ -1,7 +1,7 @@ 'use strict'; -const fs = require('fs'); -const path = require('path'); +const fs = require('node:fs'); +const path = require('node:path'); const mkdirp = require('mkdirp'); const winston = require('winston'); @@ -9,33 +9,34 @@ const filePath = path.join(__dirname, '../../build/cache-buster'); let cached; -// cache buster is an 11-character, lowercase, alphanumeric string +// Cache buster is an 11-character, lowercase, alphanumeric string function generate() { - return (Math.random() * 1e18).toString(32).slice(0, 11); + return (Math.random() * 1e18).toString(32).slice(0, 11); } exports.write = async function write() { - await mkdirp(path.dirname(filePath)); - await fs.promises.writeFile(filePath, generate()); + await mkdirp(path.dirname(filePath)); + await fs.promises.writeFile(filePath, generate()); }; exports.read = async function read() { - if (cached) { - return cached; - } - try { - const buster = await fs.promises.readFile(filePath, 'utf8'); - if (!buster || buster.length !== 11) { - winston.warn(`[cache-buster] cache buster string invalid: expected /[a-z0-9]{11}/, got \`${buster}\``); - return generate(); - } - - cached = buster; - return cached; - } catch (err) { - winston.warn('[cache-buster] could not read cache buster', err); - return generate(); - } + if (cached) { + return cached; + } + + try { + const buster = await fs.promises.readFile(filePath, 'utf8'); + if (!buster || buster.length !== 11) { + winston.warn(`[cache-buster] cache buster string invalid: expected /[a-z0-9]{11}/, got \`${buster}\``); + return generate(); + } + + cached = buster; + return cached; + } catch (error) { + winston.warn('[cache-buster] could not read cache buster', error); + return generate(); + } }; require('../promisify')(exports); diff --git a/src/meta/configs.js b/src/meta/configs.js index f0c28d4..c092113 100644 --- a/src/meta/configs.js +++ b/src/meta/configs.js @@ -1,289 +1,299 @@ 'use strict'; +const path = require('node:path'); const nconf = require('nconf'); -const path = require('path'); const winston = require('winston'); - const db = require('../database'); const pubsub = require('../pubsub'); const plugins = require('../plugins'); const utils = require('../utils'); -const Meta = require('./index'); -const cacheBuster = require('./cacheBuster'); const defaults = require('../../install/data/defaults.json'); +const cacheBuster = require('./cacheBuster'); +const Meta = require('./index'); const Configs = module.exports; Meta.config = {}; -// called after data is loaded from db +// Called after data is loaded from db function deserialize(config) { - const deserialized = {}; - Object.keys(config).forEach((key) => { - const defaultType = typeof defaults[key]; - const type = typeof config[key]; - const number = parseFloat(config[key]); - - if (defaultType === 'string' && type === 'number') { - deserialized[key] = String(config[key]); - } else if (defaultType === 'number' && type === 'string') { - if (!isNaN(number) && isFinite(config[key])) { - deserialized[key] = number; - } else { - deserialized[key] = defaults[key]; - } - } else if (config[key] === 'true') { - deserialized[key] = true; - } else if (config[key] === 'false') { - deserialized[key] = false; - } else if (config[key] === null) { - deserialized[key] = defaults[key]; - } else if (defaultType === 'undefined' && !isNaN(number) && isFinite(config[key])) { - deserialized[key] = number; - } else if (Array.isArray(defaults[key]) && !Array.isArray(config[key])) { - try { - deserialized[key] = JSON.parse(config[key] || '[]'); - } catch (err) { - winston.error(err.stack); - deserialized[key] = defaults[key]; - } - } else { - deserialized[key] = config[key]; - } - }); - return deserialized; + const deserialized = {}; + for (const key of Object.keys(config)) { + const defaultType = typeof defaults[key]; + const type = typeof config[key]; + const number = Number.parseFloat(config[key]); + + if (defaultType === 'string' && type === 'number') { + deserialized[key] = String(config[key]); + } else if (defaultType === 'number' && type === 'string') { + deserialized[key] = !isNaN(number) && isFinite(config[key]) ? number : defaults[key]; + } else { + switch (config[key]) { + case 'true': { + deserialized[key] = true; + + break; + } + + case 'false': { + deserialized[key] = false; + + break; + } + + case null: { + deserialized[key] = defaults[key]; + + break; + } + + default: { if (defaultType === 'undefined' && !isNaN(number) && isFinite(config[key])) { + deserialized[key] = number; + } else if (Array.isArray(defaults[key]) && !Array.isArray(config[key])) { + try { + deserialized[key] = JSON.parse(config[key] || '[]'); + } catch (error) { + winston.error(error.stack); + deserialized[key] = defaults[key]; + } + } else { + deserialized[key] = config[key]; + } + } + } + } + } + + return deserialized; } -// called before data is saved to db +// Called before data is saved to db function serialize(config) { - const serialized = {}; - Object.keys(config).forEach((key) => { - const defaultType = typeof defaults[key]; - const type = typeof config[key]; - const number = parseFloat(config[key]); - - if (defaultType === 'string' && type === 'number') { - serialized[key] = String(config[key]); - } else if (defaultType === 'number' && type === 'string') { - if (!isNaN(number) && isFinite(config[key])) { - serialized[key] = number; - } else { - serialized[key] = defaults[key]; - } - } else if (config[key] === null) { - serialized[key] = defaults[key]; - } else if (defaultType === 'undefined' && !isNaN(number) && isFinite(config[key])) { - serialized[key] = number; - } else if (Array.isArray(defaults[key]) && Array.isArray(config[key])) { - serialized[key] = JSON.stringify(config[key]); - } else { - serialized[key] = config[key]; - } - }); - return serialized; + const serialized = {}; + for (const key of Object.keys(config)) { + const defaultType = typeof defaults[key]; + const type = typeof config[key]; + const number = Number.parseFloat(config[key]); + + if (defaultType === 'string' && type === 'number') { + serialized[key] = String(config[key]); + } else if (defaultType === 'number' && type === 'string') { + serialized[key] = !isNaN(number) && isFinite(config[key]) ? number : defaults[key]; + } else if (config[key] === null) { + serialized[key] = defaults[key]; + } else if (defaultType === 'undefined' && !isNaN(number) && isFinite(config[key])) { + serialized[key] = number; + } else if (Array.isArray(defaults[key]) && Array.isArray(config[key])) { + serialized[key] = JSON.stringify(config[key]); + } else { + serialized[key] = config[key]; + } + } + + return serialized; } Configs.deserialize = deserialize; Configs.serialize = serialize; Configs.init = async function () { - const config = await Configs.list(); - const buster = await cacheBuster.read(); - config['cache-buster'] = `v=${buster || Date.now()}`; - Meta.config = config; + const config = await Configs.list(); + const buster = await cacheBuster.read(); + config['cache-buster'] = `v=${buster || Date.now()}`; + Meta.config = config; }; Configs.list = async function () { - return await Configs.getFields([]); + return await Configs.getFields([]); }; Configs.get = async function (field) { - const values = await Configs.getFields([field]); - return (values.hasOwnProperty(field) && values[field] !== undefined) ? values[field] : null; + const values = await Configs.getFields([field]); + return (values.hasOwnProperty(field) && values[field] !== undefined) ? values[field] : null; }; Configs.getFields = async function (fields) { - let values; - if (fields.length) { - values = await db.getObjectFields('config', fields); - } else { - values = await db.getObject('config'); - } - - values = { ...defaults, ...(values ? deserialize(values) : {}) }; - - if (!fields.length) { - values.version = nconf.get('version'); - values.registry = nconf.get('registry'); - } - return values; + let values; + values = await (fields.length > 0 ? db.getObjectFields('config', fields) : db.getObject('config')); + + values = {...defaults, ...(values ? deserialize(values) : {})}; + + if (fields.length === 0) { + values.version = nconf.get('version'); + values.registry = nconf.get('registry'); + } + + return values; }; Configs.set = async function (field, value) { - if (!field) { - throw new Error('[[error:invalid-data]]'); - } + if (!field) { + throw new Error('[[error:invalid-data]]'); + } - await Configs.setMultiple({ - [field]: value, - }); + await Configs.setMultiple({ + [field]: value, + }); }; Configs.setMultiple = async function (data) { - await processConfig(data); - data = serialize(data); - await db.setObject('config', data); - updateConfig(deserialize(data)); + await processConfig(data); + data = serialize(data); + await db.setObject('config', data); + updateConfig(deserialize(data)); }; Configs.setOnEmpty = async function (values) { - const data = await db.getObject('config'); - values = serialize(values); - const config = { ...values, ...(data ? serialize(data) : {}) }; - await db.setObject('config', config); + const data = await db.getObject('config'); + values = serialize(values); + const config = {...values, ...(data ? serialize(data) : {})}; + await db.setObject('config', config); }; Configs.remove = async function (field) { - await db.deleteObjectField('config', field); + await db.deleteObjectField('config', field); }; Configs.registerHooks = () => { - plugins.hooks.register('core', { - hook: 'filter:settings.set', - method: async ({ plugin, settings, quiet }) => { - if (plugin === 'core.api' && Array.isArray(settings.tokens)) { - // Generate tokens if not present already - settings.tokens.forEach((set) => { - if (set.token === '') { - set.token = utils.generateUUID(); - } - - if (isNaN(parseInt(set.uid, 10))) { - set.uid = 0; - } - }); - } - - return { plugin, settings, quiet }; - }, - }); - - plugins.hooks.register('core', { - hook: 'filter:settings.get', - method: async ({ plugin, values }) => { - if (plugin === 'core.api' && Array.isArray(values.tokens)) { - values.tokens = values.tokens.map((tokenObj) => { - tokenObj.uid = parseInt(tokenObj.uid, 10); - if (tokenObj.timestamp) { - tokenObj.timestampISO = new Date(parseInt(tokenObj.timestamp, 10)).toISOString(); - } - - return tokenObj; - }); - } - - return { plugin, values }; - }, - }); + plugins.hooks.register('core', { + hook: 'filter:settings.set', + async method({plugin, settings, quiet}) { + if (plugin === 'core.api' && Array.isArray(settings.tokens)) { + // Generate tokens if not present already + for (const set of settings.tokens) { + if (set.token === '') { + set.token = utils.generateUUID(); + } + + if (isNaN(Number.parseInt(set.uid, 10))) { + set.uid = 0; + } + } + } + + return {plugin, settings, quiet}; + }, + }); + + plugins.hooks.register('core', { + hook: 'filter:settings.get', + async method({plugin, values}) { + if (plugin === 'core.api' && Array.isArray(values.tokens)) { + values.tokens = values.tokens.map(tokenObject => { + tokenObject.uid = Number.parseInt(tokenObject.uid, 10); + if (tokenObject.timestamp) { + tokenObject.timestampISO = new Date(Number.parseInt(tokenObject.timestamp, 10)).toISOString(); + } + + return tokenObject; + }); + } + + return {plugin, values}; + }, + }); }; Configs.cookie = { - get: () => { - const cookie = {}; + get() { + const cookie = {}; - if (nconf.get('cookieDomain') || Meta.config.cookieDomain) { - cookie.domain = nconf.get('cookieDomain') || Meta.config.cookieDomain; - } + if (nconf.get('cookieDomain') || Meta.config.cookieDomain) { + cookie.domain = nconf.get('cookieDomain') || Meta.config.cookieDomain; + } - if (nconf.get('secure')) { - cookie.secure = true; - } + if (nconf.get('secure')) { + cookie.secure = true; + } - const relativePath = nconf.get('relative_path'); - if (relativePath !== '') { - cookie.path = relativePath; - } + const relativePath = nconf.get('relative_path'); + if (relativePath !== '') { + cookie.path = relativePath; + } - // Ideally configurable from ACP, but cannot be "Strict" as then top-level access will treat it as guest. - cookie.sameSite = 'Lax'; + // Ideally configurable from ACP, but cannot be "Strict" as then top-level access will treat it as guest. + cookie.sameSite = 'Lax'; - return cookie; - }, + return cookie; + }, }; async function processConfig(data) { - ensureInteger(data, 'maximumUsernameLength', 1); - ensureInteger(data, 'minimumUsernameLength', 1); - ensureInteger(data, 'minimumPasswordLength', 1); - ensureInteger(data, 'maximumAboutMeLength', 0); - if (data.minimumUsernameLength > data.maximumUsernameLength) { - throw new Error('[[error:invalid-data]]'); - } - - await Promise.all([ - saveRenderedCss(data), - getLogoSize(data), - ]); + ensureInteger(data, 'maximumUsernameLength', 1); + ensureInteger(data, 'minimumUsernameLength', 1); + ensureInteger(data, 'minimumPasswordLength', 1); + ensureInteger(data, 'maximumAboutMeLength', 0); + if (data.minimumUsernameLength > data.maximumUsernameLength) { + throw new Error('[[error:invalid-data]]'); + } + + await Promise.all([ + saveRenderedCss(data), + getLogoSize(data), + ]); } function ensureInteger(data, field, min) { - if (data.hasOwnProperty(field)) { - data[field] = parseInt(data[field], 10); - if (!(data[field] >= min)) { - throw new Error('[[error:invalid-data]]'); - } - } + if (data.hasOwnProperty(field)) { + data[field] = Number.parseInt(data[field], 10); + if (!(data[field] >= min)) { + throw new Error('[[error:invalid-data]]'); + } + } } async function saveRenderedCss(data) { - if (!data.customCSS) { - return; - } - const less = require('less'); - const lessObject = await less.render(data.customCSS, { - compress: true, - javascriptEnabled: false, - }); - data.renderedCustomCSS = lessObject.css; + if (!data.customCSS) { + return; + } + + const less = require('less'); + const lessObject = await less.render(data.customCSS, { + compress: true, + javascriptEnabled: false, + }); + data.renderedCustomCSS = lessObject.css; } async function getLogoSize(data) { - const image = require('../image'); - if (!data['brand:logo']) { - return; - } - let size; - try { - size = await image.size(path.join(nconf.get('upload_path'), 'system', 'site-logo-x50.png')); - } catch (err) { - if (err.code === 'ENOENT') { - // For whatever reason the x50 logo wasn't generated, gracefully error out - winston.warn('[logo] The email-safe logo doesn\'t seem to have been created, please re-upload your site logo.'); - size = { - height: 0, - width: 0, - }; - } else { - throw err; - } - } - data['brand:emailLogo'] = nconf.get('url') + path.join(nconf.get('upload_url'), 'system', 'site-logo-x50.png'); - data['brand:emailLogo:height'] = size.height; - data['brand:emailLogo:width'] = size.width; + const image = require('../image'); + if (!data['brand:logo']) { + return; + } + + let size; + try { + size = await image.size(path.join(nconf.get('upload_path'), 'system', 'site-logo-x50.png')); + } catch (error) { + if (error.code === 'ENOENT') { + // For whatever reason the x50 logo wasn't generated, gracefully error out + winston.warn('[logo] The email-safe logo doesn\'t seem to have been created, please re-upload your site logo.'); + size = { + height: 0, + width: 0, + }; + } else { + throw error; + } + } + + data['brand:emailLogo'] = nconf.get('url') + path.join(nconf.get('upload_url'), 'system', 'site-logo-x50.png'); + data['brand:emailLogo:height'] = size.height; + data['brand:emailLogo:width'] = size.width; } function updateConfig(config) { - updateLocalConfig(config); - pubsub.publish('config:update', config); + updateLocalConfig(config); + pubsub.publish('config:update', config); } function updateLocalConfig(config) { - Object.assign(Meta.config, config); + Object.assign(Meta.config, config); } -pubsub.on('config:update', (config) => { - if (typeof config === 'object' && Meta.config) { - updateLocalConfig(config); - } +pubsub.on('config:update', config => { + if (typeof config === 'object' && Meta.config) { + updateLocalConfig(config); + } }); diff --git a/src/meta/css.js b/src/meta/css.js index 0516400..ed10a7e 100644 --- a/src/meta/css.js +++ b/src/meta/css.js @@ -1,10 +1,10 @@ 'use strict'; -const winston = require('winston'); +const fs = require('node:fs'); +const util = require('node:util'); +const path = require('node:path'); const nconf = require('nconf'); -const fs = require('fs'); -const util = require('util'); -const path = require('path'); +const winston = require('winston'); const rimraf = require('rimraf'); const rimrafAsync = util.promisify(rimraf); @@ -17,138 +17,154 @@ const minifier = require('./minifier'); const CSS = module.exports; CSS.supportedSkins = [ - 'cerulean', 'cyborg', 'flatly', 'journal', 'lumen', 'paper', 'simplex', - 'spacelab', 'united', 'cosmo', 'darkly', 'readable', 'sandstone', - 'slate', 'superhero', 'yeti', + 'cerulean', + 'cyborg', + 'flatly', + 'journal', + 'lumen', + 'paper', + 'simplex', + 'spacelab', + 'united', + 'cosmo', + 'darkly', + 'readable', + 'sandstone', + 'slate', + 'superhero', + 'yeti', ]; const buildImports = { - client: function (source) { - return `@import "./theme";\n${source}\n${[ - '@import "../public/vendor/fontawesome/less/regular.less";', - '@import "../public/vendor/fontawesome/less/solid.less";', - '@import "../public/vendor/fontawesome/less/brands.less";', - '@import "../public/vendor/fontawesome/less/fontawesome.less";', - '@import "../public/vendor/fontawesome/less/v4-shims.less";', - '@import "../public/vendor/fontawesome/less/nodebb-shims.less";', - '@import "../../public/less/jquery-ui.less";', - '@import (inline) "../node_modules/@adactive/bootstrap-tagsinput/src/bootstrap-tagsinput.css";', - '@import (inline) "../node_modules/cropperjs/dist/cropper.css";', - '@import "../../public/less/flags.less";', - '@import "../../public/less/generics.less";', - '@import "../../public/less/mixins.less";', - '@import "../../public/less/global.less";', - '@import "../../public/less/modals.less";', - ].map(str => str.replace(/\//g, path.sep)).join('\n')}`; - }, - admin: function (source) { - return `${source}\n${[ - '@import "../public/vendor/fontawesome/less/regular.less";', - '@import "../public/vendor/fontawesome/less/solid.less";', - '@import "../public/vendor/fontawesome/less/brands.less";', - '@import "../public/vendor/fontawesome/less/fontawesome.less";', - '@import "../public/vendor/fontawesome/less/v4-shims.less";', - '@import "../public/vendor/fontawesome/less/nodebb-shims.less";', - '@import "../public/less/admin/admin";', - '@import "../public/less/generics.less";', - '@import "../../public/less/jquery-ui.less";', - '@import (inline) "../node_modules/@adactive/bootstrap-tagsinput/src/bootstrap-tagsinput.css";', - '@import (inline) "../public/vendor/mdl/material.css";', - ].map(str => str.replace(/\//g, path.sep)).join('\n')}`; - }, + client(source) { + return `@import "./theme";\n${source}\n${[ + '@import "../public/vendor/fontawesome/less/regular.less";', + '@import "../public/vendor/fontawesome/less/solid.less";', + '@import "../public/vendor/fontawesome/less/brands.less";', + '@import "../public/vendor/fontawesome/less/fontawesome.less";', + '@import "../public/vendor/fontawesome/less/v4-shims.less";', + '@import "../public/vendor/fontawesome/less/nodebb-shims.less";', + '@import "../../public/less/jquery-ui.less";', + '@import (inline) "../node_modules/@adactive/bootstrap-tagsinput/src/bootstrap-tagsinput.css";', + '@import (inline) "../node_modules/cropperjs/dist/cropper.css";', + '@import "../../public/less/flags.less";', + '@import "../../public/less/generics.less";', + '@import "../../public/less/mixins.less";', + '@import "../../public/less/global.less";', + '@import "../../public/less/modals.less";', + ].map(string_ => string_.replaceAll('/', path.sep)).join('\n')}`; + }, + admin(source) { + return `${source}\n${[ + '@import "../public/vendor/fontawesome/less/regular.less";', + '@import "../public/vendor/fontawesome/less/solid.less";', + '@import "../public/vendor/fontawesome/less/brands.less";', + '@import "../public/vendor/fontawesome/less/fontawesome.less";', + '@import "../public/vendor/fontawesome/less/v4-shims.less";', + '@import "../public/vendor/fontawesome/less/nodebb-shims.less";', + '@import "../public/less/admin/admin";', + '@import "../public/less/generics.less";', + '@import "../../public/less/jquery-ui.less";', + '@import (inline) "../node_modules/@adactive/bootstrap-tagsinput/src/bootstrap-tagsinput.css";', + '@import (inline) "../public/vendor/mdl/material.css";', + ].map(string_ => string_.replaceAll('/', path.sep)).join('\n')}`; + }, }; async function filterMissingFiles(filepaths) { - const exists = await Promise.all( - filepaths.map(async (filepath) => { - const exists = await file.exists(path.join(__dirname, '../../node_modules', filepath)); - if (!exists) { - winston.warn(`[meta/css] File not found! ${filepath}`); - } - return exists; - }) - ); - return filepaths.filter((filePath, i) => exists[i]); + const exists = await Promise.all( + filepaths.map(async filepath => { + const exists = await file.exists(path.join(__dirname, '../../node_modules', filepath)); + if (!exists) { + winston.warn(`[meta/css] File not found! ${filepath}`); + } + + return exists; + }), + ); + return filepaths.filter((filePath, i) => exists[i]); } async function getImports(files, prefix, extension) { - const pluginDirectories = []; - let source = ''; - - files.forEach((styleFile) => { - if (styleFile.endsWith(extension)) { - source += `${prefix + path.sep + styleFile}";`; - } else { - pluginDirectories.push(styleFile); - } - }); - await Promise.all(pluginDirectories.map(async (directory) => { - const styleFiles = await file.walk(directory); - styleFiles.forEach((styleFile) => { - source += `${prefix + path.sep + styleFile}";`; - }); - })); - return source; + const pluginDirectories = []; + let source = ''; + + for (const styleFile of files) { + if (styleFile.endsWith(extension)) { + source += `${prefix + path.sep + styleFile}";`; + } else { + pluginDirectories.push(styleFile); + } + } + + await Promise.all(pluginDirectories.map(async directory => { + const styleFiles = await file.walk(directory); + for (const styleFile of styleFiles) { + source += `${prefix + path.sep + styleFile}";`; + } + })); + return source; } async function getBundleMetadata(target) { - const paths = [ - path.join(__dirname, '../../node_modules'), - path.join(__dirname, '../../public/less'), - path.join(__dirname, '../../public/vendor/fontawesome/less'), - ]; - - // Skin support - let skin; - if (target.startsWith('client-')) { - skin = target.split('-')[1]; - - if (CSS.supportedSkins.includes(skin)) { - target = 'client'; - } - } - let skinImport = []; - if (target === 'client') { - const themeData = await db.getObjectFields('config', ['theme:type', 'theme:id', 'bootswatchSkin']); - const themeId = (themeData['theme:id'] || 'nodebb-theme-persona'); - const baseThemePath = path.join(nconf.get('themes_path'), (themeData['theme:type'] && themeData['theme:type'] === 'local' ? themeId : 'nodebb-theme-vanilla')); - paths.unshift(baseThemePath); - - themeData.bootswatchSkin = skin || themeData.bootswatchSkin; - if (themeData && themeData.bootswatchSkin) { - skinImport.push(`\n@import "./@nodebb/bootswatch/${themeData.bootswatchSkin}/variables.less";`); - skinImport.push(`\n@import "./@nodebb/bootswatch/${themeData.bootswatchSkin}/bootswatch.less";`); - } - skinImport = skinImport.join(''); - } - - const [lessImports, cssImports, acpLessImports] = await Promise.all([ - filterGetImports(plugins.lessFiles, '\n@import ".', '.less'), - filterGetImports(plugins.cssFiles, '\n@import (inline) ".', '.css'), - target === 'client' ? '' : filterGetImports(plugins.acpLessFiles, '\n@import ".', '.less'), - ]); - - async function filterGetImports(files, prefix, extension) { - const filteredFiles = await filterMissingFiles(files); - return await getImports(filteredFiles, prefix, extension); - } - - let imports = `${skinImport}\n${cssImports}\n${lessImports}\n${acpLessImports}`; - imports = buildImports[target](imports); - - return { paths: paths, imports: imports }; + const paths = [ + path.join(__dirname, '../../node_modules'), + path.join(__dirname, '../../public/less'), + path.join(__dirname, '../../public/vendor/fontawesome/less'), + ]; + + // Skin support + let skin; + if (target.startsWith('client-')) { + skin = target.split('-')[1]; + + if (CSS.supportedSkins.includes(skin)) { + target = 'client'; + } + } + + let skinImport = []; + if (target === 'client') { + const themeData = await db.getObjectFields('config', ['theme:type', 'theme:id', 'bootswatchSkin']); + const themeId = (themeData['theme:id'] || 'nodebb-theme-persona'); + const baseThemePath = path.join(nconf.get('themes_path'), (themeData['theme:type'] && themeData['theme:type'] === 'local' ? themeId : 'nodebb-theme-vanilla')); + paths.unshift(baseThemePath); + + themeData.bootswatchSkin = skin || themeData.bootswatchSkin; + if (themeData && themeData.bootswatchSkin) { + skinImport.push(`\n@import "./@nodebb/bootswatch/${themeData.bootswatchSkin}/variables.less";`, `\n@import "./@nodebb/bootswatch/${themeData.bootswatchSkin}/bootswatch.less";`); + } + + skinImport = skinImport.join(''); + } + + const [lessImports, cssImports, acpLessImports] = await Promise.all([ + filterGetImports(plugins.lessFiles, '\n@import ".', '.less'), + filterGetImports(plugins.cssFiles, '\n@import (inline) ".', '.css'), + target === 'client' ? '' : filterGetImports(plugins.acpLessFiles, '\n@import ".', '.less'), + ]); + + async function filterGetImports(files, prefix, extension) { + const filteredFiles = await filterMissingFiles(files); + return await getImports(filteredFiles, prefix, extension); + } + + let imports = `${skinImport}\n${cssImports}\n${lessImports}\n${acpLessImports}`; + imports = buildImports[target](imports); + + return {paths, imports}; } CSS.buildBundle = async function (target, fork) { - if (target === 'client') { - await rimrafAsync(path.join(__dirname, '../../build/public/client*')); - } + if (target === 'client') { + await rimrafAsync(path.join(__dirname, '../../build/public/client*')); + } - const data = await getBundleMetadata(target); - const minify = process.env.NODE_ENV !== 'development'; - const bundle = await minifier.css.bundle(data.imports, data.paths, minify, fork); + const data = await getBundleMetadata(target); + const minify = process.env.NODE_ENV !== 'development'; + const bundle = await minifier.css.bundle(data.imports, data.paths, minify, fork); - const filename = `${target}.css`; - await fs.promises.writeFile(path.join(__dirname, '../../build/public', filename), bundle.code); - return bundle.code; + const filename = `${target}.css`; + await fs.promises.writeFile(path.join(__dirname, '../../build/public', filename), bundle.code); + return bundle.code; }; diff --git a/src/meta/debugFork.js b/src/meta/debugFork.js index 27c6563..e8aaf8a 100644 --- a/src/meta/debugFork.js +++ b/src/meta/debugFork.js @@ -1,37 +1,38 @@ 'use strict'; -const { fork } = require('child_process'); +const {fork} = require('node:child_process'); -let debugArg = process.execArgv.find(arg => /^--(debug|inspect)/.test(arg)); -const debugging = !!debugArg; +let debugArgument = process.execArgv.find(argument => /^--(debug|inspect)/.test(argument)); +const debugging = Boolean(debugArgument); -debugArg = debugArg ? debugArg.replace('-brk', '').split('=') : ['--debug', 5859]; -let lastAddress = parseInt(debugArg[1], 10); +debugArgument = debugArgument ? debugArgument.replace('-brk', '').split('=') : ['--debug', 5859]; +let lastAddress = Number.parseInt(debugArgument[1], 10); /** - * child-process.fork, but safe for use in debuggers + * Child-process.fork, but safe for use in debuggers * @param {string} modulePath * @param {string[]} [args] * @param {any} [options] */ -function debugFork(modulePath, args, options) { - let execArgv = []; - if (global.v8debug || debugging) { - lastAddress += 1; +function debugFork(modulePath, arguments_, options) { + let execArgv = []; + if (global.v8debug || debugging) { + lastAddress += 1; - execArgv = [`${debugArg[0]}=${lastAddress}`, '--nolazy']; - } + execArgv = [`${debugArgument[0]}=${lastAddress}`, '--nolazy']; + } - if (!Array.isArray(args)) { - options = args; - args = []; - } + if (!Array.isArray(arguments_)) { + options = arguments_; + arguments_ = []; + } - options = options || {}; - options = { ...options, execArgv: execArgv }; + options ||= {}; + options = {...options, execArgv}; - return fork(modulePath, args, options); + return fork(modulePath, arguments_, options); } + debugFork.debugging = debugging; module.exports = debugFork; diff --git a/src/meta/dependencies.js b/src/meta/dependencies.js index b51c1c1..7071d86 100644 --- a/src/meta/dependencies.js +++ b/src/meta/dependencies.js @@ -1,14 +1,12 @@ 'use strict'; -const path = require('path'); -const fs = require('fs'); - +const path = require('node:path'); +const fs = require('node:fs'); const semver = require('semver'); const winston = require('winston'); const chalk = require('chalk'); - const pkg = require('../../package.json'); -const { paths, pluginNamePattern } = require('../constants'); +const {paths, pluginNamePattern} = require('../constants'); const Dependencies = module.exports; @@ -16,57 +14,61 @@ let depsMissing = false; let depsOutdated = false; Dependencies.check = async function () { - const modules = Object.keys(pkg.dependencies); + const modules = Object.keys(pkg.dependencies); - winston.verbose('Checking dependencies for outdated modules'); + winston.verbose('Checking dependencies for outdated modules'); - await Promise.all(modules.map(module => Dependencies.checkModule(module))); + await Promise.all(modules.map(module => Dependencies.checkModule(module))); - if (depsMissing) { - throw new Error('dependencies-missing'); - } else if (depsOutdated && global.env !== 'development') { - throw new Error('dependencies-out-of-date'); - } + if (depsMissing) { + throw new Error('dependencies-missing'); + } else if (depsOutdated && global.env !== 'development') { + throw new Error('dependencies-out-of-date'); + } }; Dependencies.checkModule = async function (moduleName) { - try { - let pkgData = await fs.promises.readFile(path.join(paths.nodeModules, moduleName, 'package.json'), 'utf8'); - pkgData = Dependencies.parseModuleData(moduleName, pkgData); + try { + let packageData = await fs.promises.readFile(path.join(paths.nodeModules, moduleName, 'package.json'), 'utf8'); + packageData = Dependencies.parseModuleData(moduleName, packageData); + + const satisfies = Dependencies.doesSatisfy(packageData, pkg.dependencies[moduleName]); + return satisfies; + } catch (error) { + if (error.code === 'ENOENT' && pluginNamePattern.test(moduleName)) { + winston.warn(`[meta/dependencies] Bundled plugin ${moduleName} not found, skipping dependency check.`); + return true; + } - const satisfies = Dependencies.doesSatisfy(pkgData, pkg.dependencies[moduleName]); - return satisfies; - } catch (err) { - if (err.code === 'ENOENT' && pluginNamePattern.test(moduleName)) { - winston.warn(`[meta/dependencies] Bundled plugin ${moduleName} not found, skipping dependency check.`); - return true; - } - throw err; - } + throw error; + } }; -Dependencies.parseModuleData = function (moduleName, pkgData) { - try { - pkgData = JSON.parse(pkgData); - } catch (e) { - winston.warn(`[${chalk.red('missing')}] ${chalk.bold(moduleName)} is a required dependency but could not be found\n`); - depsMissing = true; - return null; - } - return pkgData; +Dependencies.parseModuleData = function (moduleName, packageData) { + try { + packageData = JSON.parse(packageData); + } catch { + winston.warn(`[${chalk.red('missing')}] ${chalk.bold(moduleName)} is a required dependency but could not be found\n`); + depsMissing = true; + return null; + } + + return packageData; }; Dependencies.doesSatisfy = function (moduleData, packageJSONVersion) { - if (!moduleData) { - return false; - } - const versionOk = !semver.validRange(packageJSONVersion) || - semver.satisfies(moduleData.version, packageJSONVersion); - const githubRepo = moduleData._resolved && moduleData._resolved.includes('//github.com'); - const satisfies = versionOk || githubRepo; - if (!satisfies) { - winston.warn(`[${chalk.yellow('outdated')}] ${chalk.bold(moduleData.name)} installed v${moduleData.version}, package.json requires ${packageJSONVersion}\n`); - depsOutdated = true; - } - return satisfies; + if (!moduleData) { + return false; + } + + const versionOk = !semver.validRange(packageJSONVersion) + || semver.satisfies(moduleData.version, packageJSONVersion); + const githubRepo = moduleData._resolved && moduleData._resolved.includes('//github.com'); + const satisfies = versionOk || githubRepo; + if (!satisfies) { + winston.warn(`[${chalk.yellow('outdated')}] ${chalk.bold(moduleData.name)} installed v${moduleData.version}, package.json requires ${packageJSONVersion}\n`); + depsOutdated = true; + } + + return satisfies; }; diff --git a/src/meta/errors.js b/src/meta/errors.js index 34c19be..07d05e9 100644 --- a/src/meta/errors.js +++ b/src/meta/errors.js @@ -3,7 +3,6 @@ const winston = require('winston'); const validator = require('validator'); const cronJob = require('cron').CronJob; - const db = require('../database'); const analytics = require('../analytics'); @@ -12,45 +11,47 @@ const Errors = module.exports; let counters = {}; new cronJob('0 * * * * *', (() => { - Errors.writeData(); + Errors.writeData(); }), null, true); Errors.writeData = async function () { - try { - const _counters = { ...counters }; - counters = {}; - const keys = Object.keys(_counters); - if (!keys.length) { - return; - } - - for (const key of keys) { - /* eslint-disable no-await-in-loop */ - await db.sortedSetIncrBy('errors:404', _counters[key], key); - } - } catch (err) { - winston.error(err.stack); - } + try { + const _counters = {...counters}; + counters = {}; + const keys = Object.keys(_counters); + if (keys.length === 0) { + return; + } + + for (const key of keys) { + /* eslint-disable no-await-in-loop */ + await db.sortedSetIncrBy('errors:404', _counters[key], key); + } + } catch (error) { + winston.error(error.stack); + } }; Errors.log404 = function (route) { - if (!route) { - return; - } - route = route.slice(0, 512).replace(/\/$/, ''); // remove trailing slashes - analytics.increment('errors:404'); - counters[route] = counters[route] || 0; - counters[route] += 1; + if (!route) { + return; + } + + route = route.slice(0, 512).replace(/\/$/, ''); // Remove trailing slashes + analytics.increment('errors:404'); + counters[route] = counters[route] || 0; + counters[route] += 1; }; Errors.get = async function (escape) { - const data = await db.getSortedSetRevRangeWithScores('errors:404', 0, 199); - data.forEach((nfObject) => { - nfObject.value = escape ? validator.escape(String(nfObject.value || '')) : nfObject.value; - }); - return data; + const data = await db.getSortedSetRevRangeWithScores('errors:404', 0, 199); + for (const nfObject of data) { + nfObject.value = escape ? validator.escape(String(nfObject.value || '')) : nfObject.value; + } + + return data; }; Errors.clear = async function () { - await db.delete('errors:404'); + await db.delete('errors:404'); }; diff --git a/src/meta/index.js b/src/meta/index.js index 5f5ee85..2e0a7c7 100644 --- a/src/meta/index.js +++ b/src/meta/index.js @@ -1,9 +1,8 @@ 'use strict'; +const os = require('node:os'); const winston = require('winston'); -const os = require('os'); const nconf = require('nconf'); - const pubsub = require('../pubsub'); const slugify = require('../slugify'); @@ -24,50 +23,50 @@ Meta.templates = require('./templates'); Meta.blacklist = require('./blacklist'); Meta.languages = require('./languages'); - /* Assorted */ Meta.userOrGroupExists = async function (slug) { - if (!slug) { - throw new Error('[[error:invalid-data]]'); - } - const user = require('../user'); - const groups = require('../groups'); - slug = slugify(slug); - const [userExists, groupExists] = await Promise.all([ - user.existsBySlug(slug), - groups.existsBySlug(slug), - ]); - return userExists || groupExists; + if (!slug) { + throw new Error('[[error:invalid-data]]'); + } + + const user = require('../user'); + const groups = require('../groups'); + slug = slugify(slug); + const [userExists, groupExists] = await Promise.all([ + user.existsBySlug(slug), + groups.existsBySlug(slug), + ]); + return userExists || groupExists; }; if (nconf.get('isPrimary')) { - pubsub.on('meta:restart', (data) => { - if (data.hostname !== os.hostname()) { - restart(); - } - }); + pubsub.on('meta:restart', data => { + if (data.hostname !== os.hostname()) { + restart(); + } + }); } Meta.restart = function () { - pubsub.publish('meta:restart', { hostname: os.hostname() }); - restart(); + pubsub.publish('meta:restart', {hostname: os.hostname()}); + restart(); }; function restart() { - if (process.send) { - process.send({ - action: 'restart', - }); - } else { - winston.error('[meta.restart] Could not restart, are you sure NodeBB was started with `./nodebb start`?'); - } + if (process.send) { + process.send({ + action: 'restart', + }); + } else { + winston.error('[meta.restart] Could not restart, are you sure NodeBB was started with `./nodebb start`?'); + } } Meta.getSessionTTLSeconds = function () { - const ttlDays = 60 * 60 * 24 * Meta.config.loginDays; - const ttlSeconds = Meta.config.loginSeconds; - const ttl = ttlSeconds || ttlDays || 1209600; // Default to 14 days - return ttl; + const ttlDays = 60 * 60 * 24 * Meta.config.loginDays; + const ttlSeconds = Meta.config.loginSeconds; + const ttl = ttlSeconds || ttlDays || 1_209_600; // Default to 14 days + return ttl; }; require('../promisify')(Meta); diff --git a/src/meta/js.js b/src/meta/js.js index 52b4b1e..f878268 100644 --- a/src/meta/js.js +++ b/src/meta/js.js @@ -1,8 +1,8 @@ 'use strict'; -const path = require('path'); -const fs = require('fs'); -const util = require('util'); +const path = require('node:path'); +const fs = require('node:fs'); +const util = require('node:util'); const mkdirp = require('mkdirp'); const rimraf = require('rimraf'); @@ -15,126 +15,123 @@ const minifier = require('./minifier'); const JS = module.exports; JS.scripts = { - base: [ - 'node_modules/@adactive/bootstrap-tagsinput/src/bootstrap-tagsinput.js', - 'node_modules/jquery-serializeobject/jquery.serializeObject.js', - 'node_modules/jquery-deserialize/src/jquery.deserialize.js', - 'public/vendor/bootbox/wrapper.js', - ], - - // plugins add entries into this object, - // they get linked into /build/public/src/modules - modules: { - '../admin/plugins/persona.js': 'themes/nodebb-theme-persona/public/admin.js', - 'persona/quickreply.js': 'themes/nodebb-theme-persona/public/modules/quickreply.js', - '../client/account/theme.js': 'themes/nodebb-theme-persona/public/settings.js', - }, + base: [ + 'node_modules/@adactive/bootstrap-tagsinput/src/bootstrap-tagsinput.js', + 'node_modules/jquery-serializeobject/jquery.serializeObject.js', + 'node_modules/jquery-deserialize/src/jquery.deserialize.js', + 'public/vendor/bootbox/wrapper.js', + ], + + // Plugins add entries into this object, + // they get linked into /build/public/src/modules + modules: { + '../admin/plugins/persona.js': 'themes/nodebb-theme-persona/public/admin.js', + 'persona/quickreply.js': 'themes/nodebb-theme-persona/public/modules/quickreply.js', + '../client/account/theme.js': 'themes/nodebb-theme-persona/public/settings.js', + }, }; const basePath = path.resolve(__dirname, '../..'); async function linkModules() { - const { modules } = JS.scripts; - - await Promise.all([ - mkdirp(path.join(__dirname, '../../build/public/src/admin/plugins')), - mkdirp(path.join(__dirname, '../../build/public/src/client/plugins')), - ]); - - await Promise.all(Object.keys(modules).map(async (relPath) => { - const srcPath = path.join(__dirname, '../../', modules[relPath]); - const destPath = path.join(__dirname, '../../build/public/src/modules', relPath); - const [stats] = await Promise.all([ - fs.promises.stat(srcPath), - mkdirp(path.dirname(destPath)), - ]); - if (stats.isDirectory()) { - await file.linkDirs(srcPath, destPath, true); - } else { - await fs.promises.copyFile(srcPath, destPath); - } - })); + const {modules} = JS.scripts; + + await Promise.all([ + mkdirp(path.join(__dirname, '../../build/public/src/admin/plugins')), + mkdirp(path.join(__dirname, '../../build/public/src/client/plugins')), + ]); + + await Promise.all(Object.keys(modules).map(async relPath => { + const sourcePath = path.join(__dirname, '../../', modules[relPath]); + const destinationPath = path.join(__dirname, '../../build/public/src/modules', relPath); + const [stats] = await Promise.all([ + fs.promises.stat(sourcePath), + mkdirp(path.dirname(destinationPath)), + ]); + await (stats.isDirectory() ? file.linkDirs(sourcePath, destinationPath, true) : fs.promises.copyFile(sourcePath, destinationPath)); + })); } -const moduleDirs = ['modules', 'admin', 'client']; +const moduleDirectories = ['modules', 'admin', 'client']; async function clearModules() { - const builtPaths = moduleDirs.map( - p => path.join(__dirname, '../../build/public/src', p) - ); - await Promise.all( - builtPaths.map(builtPath => rimrafAsync(builtPath)) - ); + const builtPaths = moduleDirectories.map( + p => path.join(__dirname, '../../build/public/src', p), + ); + await Promise.all( + builtPaths.map(builtPath => rimrafAsync(builtPath)), + ); } JS.buildModules = async function () { - await clearModules(); + await clearModules(); - const fse = require('fs-extra'); - await fse.copy( - path.join(__dirname, `../../public/src`), - path.join(__dirname, `../../build/public/src`) - ); + const fse = require('fs-extra'); + await fse.copy( + path.join(__dirname, '../../public/src'), + path.join(__dirname, '../../build/public/src'), + ); - await linkModules(); + await linkModules(); }; JS.linkStatics = async function () { - await rimrafAsync(path.join(__dirname, '../../build/public/plugins')); + await rimrafAsync(path.join(__dirname, '../../build/public/plugins')); - await Promise.all(Object.keys(plugins.staticDirs).map(async (mappedPath) => { - const sourceDir = plugins.staticDirs[mappedPath]; - const destDir = path.join(__dirname, '../../build/public/plugins', mappedPath); + await Promise.all(Object.keys(plugins.staticDirs).map(async mappedPath => { + const sourceDir = plugins.staticDirs[mappedPath]; + const destDir = path.join(__dirname, '../../build/public/plugins', mappedPath); - await mkdirp(path.dirname(destDir)); - await file.linkDirs(sourceDir, destDir, true); - })); + await mkdirp(path.dirname(destDir)); + await file.linkDirs(sourceDir, destDir, true); + })); }; async function getBundleScriptList(target) { - const pluginDirectories = []; - - if (target === 'admin') { - target = 'acp'; - } - let pluginScripts = plugins[`${target}Scripts`].filter((path) => { - if (path.endsWith('.js')) { - return true; - } - - pluginDirectories.push(path); - return false; - }); - - await Promise.all(pluginDirectories.map(async (directory) => { - const scripts = await file.walk(directory); - pluginScripts = pluginScripts.concat(scripts); - })); - - pluginScripts = JS.scripts.base.concat(pluginScripts).map((script) => { - const srcPath = path.resolve(basePath, script).replace(/\\/g, '/'); - return { - srcPath: srcPath, - filename: path.relative(basePath, srcPath).replace(/\\/g, '/'), - }; - }); - - return pluginScripts; + const pluginDirectories = []; + + if (target === 'admin') { + target = 'acp'; + } + + let pluginScripts = plugins[`${target}Scripts`].filter(path => { + if (path.endsWith('.js')) { + return true; + } + + pluginDirectories.push(path); + return false; + }); + + await Promise.all(pluginDirectories.map(async directory => { + const scripts = await file.walk(directory); + pluginScripts = pluginScripts.concat(scripts); + })); + + pluginScripts = JS.scripts.base.concat(pluginScripts).map(script => { + const sourcePath = path.resolve(basePath, script).replaceAll('\\', '/'); + return { + srcPath: sourcePath, + filename: path.relative(basePath, sourcePath).replaceAll('\\', '/'), + }; + }); + + return pluginScripts; } JS.buildBundle = async function (target, fork) { - const filename = `scripts-${target}.js`; - const files = await getBundleScriptList(target); - const minify = false; // webpack will minify in prod - const filePath = path.join(__dirname, '../../build/public', filename); - - await minifier.js.bundle({ - files: files, - filename: filename, - destPath: filePath, - }, minify, fork); + const filename = `scripts-${target}.js`; + const files = await getBundleScriptList(target); + const minify = false; // Webpack will minify in prod + const filePath = path.join(__dirname, '../../build/public', filename); + + await minifier.js.bundle({ + files, + filename, + destPath: filePath, + }, minify, fork); }; JS.killMinifier = function () { - minifier.killAll(); + minifier.killAll(); }; diff --git a/src/meta/languages.js b/src/meta/languages.js index 05406a1..d4ee302 100644 --- a/src/meta/languages.js +++ b/src/meta/languages.js @@ -1,10 +1,10 @@ 'use strict'; -const _ = require('lodash'); +const path = require('node:path'); +const fs = require('node:fs'); +const util = require('node:util'); const nconf = require('nconf'); -const path = require('path'); -const fs = require('fs'); -const util = require('util'); +const _ = require('lodash'); let mkdirp = require('mkdirp'); mkdirp = mkdirp.hasOwnProperty('native') ? mkdirp : util.promisify(mkdirp); @@ -14,130 +14,130 @@ const rimrafAsync = util.promisify(rimraf); const file = require('../file'); const Plugins = require('../plugins'); -const { paths } = require('../constants'); +const {paths} = require('../constants'); const buildLanguagesPath = path.join(paths.baseDir, 'build/public/language'); const coreLanguagesPath = path.join(paths.baseDir, 'public/language'); async function getTranslationMetadata() { - const paths = await file.walk(coreLanguagesPath); - let languages = []; - let namespaces = []; - - paths.forEach((p) => { - if (!p.endsWith('.json')) { - return; - } - - const rel = path.relative(coreLanguagesPath, p).split(/[/\\]/); - const language = rel.shift().replace('_', '-').replace('@', '-x-'); - const namespace = rel.join('/').replace(/\.json$/, ''); - - if (!language || !namespace) { - return; - } - - languages.push(language); - namespaces.push(namespace); - }); - - - languages = _.union(languages, Plugins.languageData.languages).sort().filter(Boolean); - namespaces = _.union(namespaces, Plugins.languageData.namespaces).sort().filter(Boolean); - const configLangs = nconf.get('languages'); - if (process.env.NODE_ENV === 'development' && Array.isArray(configLangs) && configLangs.length) { - languages = configLangs; - } - // save a list of languages to `${buildLanguagesPath}/metadata.json` - // avoids readdirs later on - await mkdirp(buildLanguagesPath); - const result = { - languages: languages, - namespaces: namespaces, - }; - await fs.promises.writeFile(path.join(buildLanguagesPath, 'metadata.json'), JSON.stringify(result)); - return result; + const paths = await file.walk(coreLanguagesPath); + let languages = []; + let namespaces = []; + + for (const p of paths) { + if (!p.endsWith('.json')) { + continue; + } + + const rel = path.relative(coreLanguagesPath, p).split(/[/\\]/); + const language = rel.shift().replace('_', '-').replace('@', '-x-'); + const namespace = rel.join('/').replace(/\.json$/, ''); + + if (!language || !namespace) { + continue; + } + + languages.push(language); + namespaces.push(namespace); + } + + languages = _.union(languages, Plugins.languageData.languages).sort().filter(Boolean); + namespaces = _.union(namespaces, Plugins.languageData.namespaces).sort().filter(Boolean); + const configLangs = nconf.get('languages'); + if (process.env.NODE_ENV === 'development' && Array.isArray(configLangs) && configLangs.length > 0) { + languages = configLangs; + } + + // Save a list of languages to `${buildLanguagesPath}/metadata.json` + // avoids readdirs later on + await mkdirp(buildLanguagesPath); + const result = { + languages, + namespaces, + }; + await fs.promises.writeFile(path.join(buildLanguagesPath, 'metadata.json'), JSON.stringify(result)); + return result; } async function writeLanguageFile(language, namespace, translations) { - const dev = process.env.NODE_ENV === 'development'; - const filePath = path.join(buildLanguagesPath, language, `${namespace}.json`); + const development = process.env.NODE_ENV === 'development'; + const filePath = path.join(buildLanguagesPath, language, `${namespace}.json`); - await mkdirp(path.dirname(filePath)); - await fs.promises.writeFile(filePath, JSON.stringify(translations, null, dev ? 2 : 0)); + await mkdirp(path.dirname(filePath)); + await fs.promises.writeFile(filePath, JSON.stringify(translations, null, development ? 2 : 0)); } -// for each language and namespace combination, +// For each language and namespace combination, // run through core and all plugins to generate // a full translation hash -async function buildTranslations(ref) { - const { namespaces } = ref; - const { languages } = ref; - const plugins = _.values(Plugins.pluginsData).filter(plugin => typeof plugin.languages === 'string'); +async function buildTranslations(reference) { + const {namespaces} = reference; + const {languages} = reference; + const plugins = _.values(Plugins.pluginsData).filter(plugin => typeof plugin.languages === 'string'); - const promises = []; + const promises = []; - namespaces.forEach((namespace) => { - languages.forEach((language) => { - promises.push(buildNamespaceLanguage(language, namespace, plugins)); - }); - }); + for (const namespace of namespaces) { + for (const language of languages) { + promises.push(buildNamespaceLanguage(language, namespace, plugins)); + } + } - await Promise.all(promises); + await Promise.all(promises); } async function buildNamespaceLanguage(lang, namespace, plugins) { - const translations = {}; - // core first - await assignFileToTranslations(translations, path.join(coreLanguagesPath, lang, `${namespace}.json`)); + const translations = {}; + // Core first + await assignFileToTranslations(translations, path.join(coreLanguagesPath, lang, `${namespace}.json`)); - await Promise.all(plugins.map(pluginData => addPlugin(translations, pluginData, lang, namespace))); + await Promise.all(plugins.map(pluginData => addPlugin(translations, pluginData, lang, namespace))); - if (Object.keys(translations).length) { - await writeLanguageFile(lang, namespace, translations); - } + if (Object.keys(translations).length > 0) { + await writeLanguageFile(lang, namespace, translations); + } } async function addPlugin(translations, pluginData, lang, namespace) { - // if plugin doesn't have this namespace no need to continue - if (pluginData.languageData && !pluginData.languageData.namespaces.includes(namespace)) { - return; - } - - const pathToPluginLanguageFolder = path.join(paths.nodeModules, pluginData.id, pluginData.languages); - const defaultLang = pluginData.defaultLang || 'en-GB'; - - // for each plugin, fallback in this order: - // 1. correct language string (en-GB) - // 2. old language string (en_GB) - // 3. corrected plugin defaultLang (en-US) - // 4. old plugin defaultLang (en_US) - const langs = _.uniq([ - defaultLang.replace('-', '_').replace('-x-', '@'), - defaultLang.replace('_', '-').replace('@', '-x-'), - lang.replace('-', '_').replace('-x-', '@'), - lang, - ]); - - for (const language of langs) { - /* eslint-disable no-await-in-loop */ - await assignFileToTranslations(translations, path.join(pathToPluginLanguageFolder, language, `${namespace}.json`)); - } + // If plugin doesn't have this namespace no need to continue + if (pluginData.languageData && !pluginData.languageData.namespaces.includes(namespace)) { + return; + } + + const pathToPluginLanguageFolder = path.join(paths.nodeModules, pluginData.id, pluginData.languages); + const defaultLang = pluginData.defaultLang || 'en-GB'; + + // For each plugin, fallback in this order: + // 1. correct language string (en-GB) + // 2. old language string (en_GB) + // 3. corrected plugin defaultLang (en-US) + // 4. old plugin defaultLang (en_US) + const langs = _.uniq([ + defaultLang.replace('-', '_').replace('-x-', '@'), + defaultLang.replace('_', '-').replace('@', '-x-'), + lang.replace('-', '_').replace('-x-', '@'), + lang, + ]); + + for (const language of langs) { + /* eslint-disable no-await-in-loop */ + await assignFileToTranslations(translations, path.join(pathToPluginLanguageFolder, language, `${namespace}.json`)); + } } async function assignFileToTranslations(translations, path) { - try { - const fileData = await fs.promises.readFile(path, 'utf8'); - Object.assign(translations, JSON.parse(fileData)); - } catch (err) { - if (err.code !== 'ENOENT') { - throw err; - } - } + try { + const fileData = await fs.promises.readFile(path, 'utf8'); + Object.assign(translations, JSON.parse(fileData)); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } } exports.build = async function buildLanguages() { - await rimrafAsync(buildLanguagesPath); - const data = await getTranslationMetadata(); - await buildTranslations(data); + await rimrafAsync(buildLanguagesPath); + const data = await getTranslationMetadata(); + await buildTranslations(data); }; diff --git a/src/meta/logs.js b/src/meta/logs.js index 4202e8d..26a9bbf 100644 --- a/src/meta/logs.js +++ b/src/meta/logs.js @@ -1,16 +1,16 @@ 'use strict'; -const path = require('path'); -const fs = require('fs'); +const path = require('node:path'); +const fs = require('node:fs'); const Logs = module.exports; Logs.path = path.resolve(__dirname, '../../logs/output.log'); Logs.get = async function () { - return await fs.promises.readFile(Logs.path, 'utf-8'); + return await fs.promises.readFile(Logs.path, 'utf8'); }; Logs.clear = async function () { - await fs.promises.truncate(Logs.path, 0); + await fs.promises.truncate(Logs.path, 0); }; diff --git a/src/meta/minifier.js b/src/meta/minifier.js index 539ab23..19506bf 100644 --- a/src/meta/minifier.js +++ b/src/meta/minifier.js @@ -1,7 +1,7 @@ 'use strict'; -const fs = require('fs'); -const os = require('os'); +const fs = require('node:fs'); +const os = require('node:os'); const uglify = require('uglify-es'); const async = require('async'); const winston = require('winston'); @@ -9,9 +9,8 @@ const less = require('less'); const postcss = require('postcss'); const autoprefixer = require('autoprefixer'); const clean = require('postcss-clean'); - const fork = require('./debugFork'); -require('../file'); // for graceful-fs +require('../file'); // For graceful-fs const Minifier = module.exports; @@ -21,236 +20,241 @@ const free = []; let maxThreads = 0; Object.defineProperty(Minifier, 'maxThreads', { - get: function () { - return maxThreads; - }, - set: function (val) { - maxThreads = val; - if (!process.env.minifier_child) { - winston.verbose(`[minifier] utilizing a maximum of ${maxThreads} additional threads`); - } - }, - configurable: true, - enumerable: true, + get() { + return maxThreads; + }, + set(value) { + maxThreads = value; + if (!process.env.minifier_child) { + winston.verbose(`[minifier] utilizing a maximum of ${maxThreads} additional threads`); + } + }, + configurable: true, + enumerable: true, }); Minifier.maxThreads = os.cpus().length - 1; Minifier.killAll = function () { - pool.forEach((child) => { - child.kill('SIGTERM'); - }); + for (const child of pool) { + child.kill('SIGTERM'); + } - pool.length = 0; - free.length = 0; + pool.length = 0; + free.length = 0; }; function getChild() { - if (free.length) { - return free.shift(); - } - - const proc = fork(__filename, [], { - cwd: __dirname, - env: { - minifier_child: true, - }, - }); - pool.push(proc); - - return proc; + if (free.length > 0) { + return free.shift(); + } + + const process_ = fork(__filename, [], { + cwd: __dirname, + env: { + minifier_child: true, + }, + }); + pool.push(process_); + + return process_; } -function freeChild(proc) { - proc.removeAllListeners(); - free.push(proc); +function freeChild(process_) { + process_.removeAllListeners(); + free.push(process_); } -function removeChild(proc) { - const i = pool.indexOf(proc); - if (i !== -1) { - pool.splice(i, 1); - } +function removeChild(process_) { + const i = pool.indexOf(process_); + if (i !== -1) { + pool.splice(i, 1); + } } function forkAction(action) { - return new Promise((resolve, reject) => { - const proc = getChild(); - proc.on('message', (message) => { - freeChild(proc); - - if (message.type === 'error') { - return reject(new Error(message.message)); - } - - if (message.type === 'end') { - resolve(message.result); - } - }); - proc.on('error', (err) => { - proc.kill(); - removeChild(proc); - reject(err); - }); - - proc.send({ - type: 'action', - action: action, - }); - }); + return new Promise((resolve, reject) => { + const process_ = getChild(); + process_.on('message', message => { + freeChild(process_); + + if (message.type === 'error') { + return reject(new Error(message.message)); + } + + if (message.type === 'end') { + resolve(message.result); + } + }); + process_.on('error', error => { + process_.kill(); + removeChild(process_); + reject(error); + }); + + process_.send({ + type: 'action', + action, + }); + }); } const actions = {}; if (process.env.minifier_child) { - process.on('message', async (message) => { - if (message.type === 'action') { - const { action } = message; - if (typeof actions[action.act] !== 'function') { - process.send({ - type: 'error', - message: 'Unknown action', - }); - return; - } - try { - const result = await actions[action.act](action); - process.send({ - type: 'end', - result: result, - }); - } catch (err) { - process.send({ - type: 'error', - message: err.stack || err.message || 'unknown error', - }); - } - } - }); + process.on('message', async message => { + if (message.type === 'action') { + const {action} = message; + if (typeof actions[action.act] !== 'function') { + process.send({ + type: 'error', + message: 'Unknown action', + }); + return; + } + + try { + const result = await actions[action.act](action); + process.send({ + type: 'end', + result, + }); + } catch (error) { + process.send({ + type: 'error', + message: error.stack || error.message || 'unknown error', + }); + } + } + }); } async function executeAction(action, fork) { - if (fork && (pool.length - free.length) < Minifier.maxThreads) { - return await forkAction(action); - } - if (typeof actions[action.act] !== 'function') { - throw new Error('Unknown action'); - } - return await actions[action.act](action); + if (fork && (pool.length - free.length) < Minifier.maxThreads) { + return await forkAction(action); + } + + if (typeof actions[action.act] !== 'function') { + throw new TypeError('Unknown action'); + } + + return await actions[action.act](action); } actions.concat = async function concat(data) { - if (data.files && data.files.length) { - const files = await async.mapLimit(data.files, 1000, async ref => await fs.promises.readFile(ref.srcPath, 'utf8')); - const output = files.join('\n;'); - await fs.promises.writeFile(data.destPath, output); - } + if (data.files && data.files.length > 0) { + const files = await async.mapLimit(data.files, 1000, async reference => await fs.promises.readFile(reference.srcPath, 'utf8')); + const output = files.join('\n;'); + await fs.promises.writeFile(data.destPath, output); + } }; actions.minifyJS_batch = async function minifyJS_batch(data) { - await async.eachLimit(data.files, 100, async (fileObj) => { - const source = await fs.promises.readFile(fileObj.srcPath, 'utf8'); - const filesToMinify = [ - { - srcPath: fileObj.srcPath, - filename: fileObj.filename, - source: source, - }, - ]; - - await minifyAndSave({ - files: filesToMinify, - destPath: fileObj.destPath, - filename: fileObj.filename, - }); - }); + await async.eachLimit(data.files, 100, async fileObject => { + const source = await fs.promises.readFile(fileObject.srcPath, 'utf8'); + const filesToMinify = [ + { + srcPath: fileObject.srcPath, + filename: fileObject.filename, + source, + }, + ]; + + await minifyAndSave({ + files: filesToMinify, + destPath: fileObject.destPath, + filename: fileObject.filename, + }); + }); }; actions.minifyJS = async function minifyJS(data) { - const filesToMinify = await async.mapLimit(data.files, 1000, async (fileObj) => { - const source = await fs.promises.readFile(fileObj.srcPath, 'utf8'); - return { - srcPath: fileObj.srcPath, - filename: fileObj.filename, - source: source, - }; - }); - await minifyAndSave({ - files: filesToMinify, - destPath: data.destPath, - filename: data.filename, - }); + const filesToMinify = await async.mapLimit(data.files, 1000, async fileObject => { + const source = await fs.promises.readFile(fileObject.srcPath, 'utf8'); + return { + srcPath: fileObject.srcPath, + filename: fileObject.filename, + source, + }; + }); + await minifyAndSave({ + files: filesToMinify, + destPath: data.destPath, + filename: data.filename, + }); }; async function minifyAndSave(data) { - const scripts = {}; - data.files.forEach((ref) => { - if (ref && ref.filename && ref.source) { - scripts[ref.filename] = ref.source; - } - }); - - const minified = uglify.minify(scripts, { - sourceMap: { - filename: data.filename, - url: `${String(data.filename).split(/[/\\]/).pop()}.map`, - includeSources: true, - }, - compress: false, - }); - - if (minified.error) { - throw new Error(`Error minifying ${minified.error.filename}\n${minified.error.stack}`); - } - await Promise.all([ - fs.promises.writeFile(data.destPath, minified.code), - fs.promises.writeFile(`${data.destPath}.map`, minified.map), - ]); + const scripts = {}; + for (const reference of data.files) { + if (reference && reference.filename && reference.source) { + scripts[reference.filename] = reference.source; + } + } + + const minified = uglify.minify(scripts, { + sourceMap: { + filename: data.filename, + url: `${String(data.filename).split(/[/\\]/).pop()}.map`, + includeSources: true, + }, + compress: false, + }); + + if (minified.error) { + throw new Error(`Error minifying ${minified.error.filename}\n${minified.error.stack}`); + } + + await Promise.all([ + fs.promises.writeFile(data.destPath, minified.code), + fs.promises.writeFile(`${data.destPath}.map`, minified.map), + ]); } Minifier.js = {}; Minifier.js.bundle = async function (data, minify, fork) { - return await executeAction({ - act: minify ? 'minifyJS' : 'concat', - files: data.files, - filename: data.filename, - destPath: data.destPath, - }, fork); + return await executeAction({ + act: minify ? 'minifyJS' : 'concat', + files: data.files, + filename: data.filename, + destPath: data.destPath, + }, fork); }; Minifier.js.minifyBatch = async function (scripts, fork) { - return await executeAction({ - act: 'minifyJS_batch', - files: scripts, - }, fork); + return await executeAction({ + act: 'minifyJS_batch', + files: scripts, + }, fork); }; actions.buildCSS = async function buildCSS(data) { - const lessOutput = await less.render(data.source, { - paths: data.paths, - javascriptEnabled: false, - }); - - const postcssArgs = [autoprefixer]; - if (data.minify) { - postcssArgs.push(clean({ - processImportFrom: ['local'], - })); - } - const result = await postcss(postcssArgs).process(lessOutput.css, { - from: undefined, - }); - return { code: result.css }; + const lessOutput = await less.render(data.source, { + paths: data.paths, + javascriptEnabled: false, + }); + + const postcssArguments = [autoprefixer]; + if (data.minify) { + postcssArguments.push(clean({ + processImportFrom: ['local'], + })); + } + + const result = await postcss(postcssArguments).process(lessOutput.css, { + from: undefined, + }); + return {code: result.css}; }; Minifier.css = {}; Minifier.css.bundle = async function (source, paths, minify, fork) { - return await executeAction({ - act: 'buildCSS', - source: source, - paths: paths, - minify: minify, - }, fork); + return await executeAction({ + act: 'buildCSS', + source, + paths, + minify, + }, fork); }; require('../promisify')(exports); diff --git a/src/meta/settings.js b/src/meta/settings.js index e6567ec..93f4cbd 100644 --- a/src/meta/settings.js +++ b/src/meta/settings.js @@ -1,127 +1,128 @@ 'use strict'; const _ = require('lodash'); - const db = require('../database'); const plugins = require('../plugins'); -const Meta = require('./index'); const pubsub = require('../pubsub'); const cache = require('../cache'); +const Meta = require('./index'); const Settings = module.exports; Settings.get = async function (hash) { - const cached = cache.get(`settings:${hash}`); - if (cached) { - return _.cloneDeep(cached); - } - const [data, sortedLists] = await Promise.all([ - db.getObject(`settings:${hash}`), - db.getSetMembers(`settings:${hash}:sorted-lists`), - ]); - const values = data || {}; - await Promise.all(sortedLists.map(async (list) => { - const members = await db.getSortedSetRange(`settings:${hash}:sorted-list:${list}`, 0, -1); - const keys = members.map(order => `settings:${hash}:sorted-list:${list}:${order}`); - - values[list] = []; - - const objects = await db.getObjects(keys); - objects.forEach((obj) => { - values[list].push(obj); - }); - })); - - const result = await plugins.hooks.fire('filter:settings.get', { plugin: hash, values: values }); - cache.set(`settings:${hash}`, result.values); - return _.cloneDeep(result.values); + const cached = cache.get(`settings:${hash}`); + if (cached) { + return _.cloneDeep(cached); + } + + const [data, sortedLists] = await Promise.all([ + db.getObject(`settings:${hash}`), + db.getSetMembers(`settings:${hash}:sorted-lists`), + ]); + const values = data || {}; + await Promise.all(sortedLists.map(async list => { + const members = await db.getSortedSetRange(`settings:${hash}:sorted-list:${list}`, 0, -1); + const keys = members.map(order => `settings:${hash}:sorted-list:${list}:${order}`); + + values[list] = []; + + const objects = await db.getObjects(keys); + for (const object of objects) { + values[list].push(object); + } + })); + + const result = await plugins.hooks.fire('filter:settings.get', {plugin: hash, values}); + cache.set(`settings:${hash}`, result.values); + return _.cloneDeep(result.values); }; Settings.getOne = async function (hash, field) { - const data = await Settings.get(hash); - return data[field] !== undefined ? data[field] : null; + const data = await Settings.get(hash); + return data[field] === undefined ? null : data[field]; }; Settings.set = async function (hash, values, quiet) { - quiet = quiet || false; - - ({ plugin: hash, settings: values, quiet } = await plugins.hooks.fire('filter:settings.set', { plugin: hash, settings: values, quiet })); - - const sortedListData = {}; - for (const [key, value] of Object.entries(values)) { - if (Array.isArray(value) && typeof value[0] !== 'string') { - sortedListData[key] = value; - delete values[key]; - } - } - const sortedLists = Object.keys(sortedListData); - - if (sortedLists.length) { - // Remove provided (but empty) sorted lists from the hash set - await db.setRemove(`settings:${hash}:sorted-lists`, sortedLists.filter(list => !sortedListData[list].length)); - await db.setAdd(`settings:${hash}:sorted-lists`, sortedLists); - - await Promise.all(sortedLists.map(async (list) => { - const numItems = await db.sortedSetCard(`settings:${hash}:sorted-list:${list}`); - const deleteKeys = [`settings:${hash}:sorted-list:${list}`]; - for (let x = 0; x < numItems; x++) { - deleteKeys.push(`settings:${hash}:sorted-list:${list}:${x}`); - } - await db.deleteAll(deleteKeys); - })); - - const sortedSetData = []; - const objectData = []; - sortedLists.forEach((list) => { - const arr = sortedListData[list]; - arr.forEach((data, order) => { - sortedSetData.push([`settings:${hash}:sorted-list:${list}`, order, order]); - objectData.push([`settings:${hash}:sorted-list:${list}:${order}`, data]); - }); - }); - - await Promise.all([ - db.sortedSetAddBulk(sortedSetData), - db.setObjectBulk(objectData), - ]); - } - - if (Object.keys(values).length) { - await db.setObject(`settings:${hash}`, values); - } - - cache.del(`settings:${hash}`); - - plugins.hooks.fire('action:settings.set', { - plugin: hash, - settings: { ...values, ...sortedListData }, // Add back sorted list data to values hash - quiet, - }); - - pubsub.publish(`action:settings.set.${hash}`, values); - if (!Meta.reloadRequired && !quiet) { - Meta.reloadRequired = true; - } + quiet ||= false; + + ({plugin: hash, settings: values, quiet} = await plugins.hooks.fire('filter:settings.set', {plugin: hash, settings: values, quiet})); + + const sortedListData = {}; + for (const [key, value] of Object.entries(values)) { + if (Array.isArray(value) && typeof value[0] !== 'string') { + sortedListData[key] = value; + delete values[key]; + } + } + + const sortedLists = Object.keys(sortedListData); + + if (sortedLists.length > 0) { + // Remove provided (but empty) sorted lists from the hash set + await db.setRemove(`settings:${hash}:sorted-lists`, sortedLists.filter(list => sortedListData[list].length === 0)); + await db.setAdd(`settings:${hash}:sorted-lists`, sortedLists); + + await Promise.all(sortedLists.map(async list => { + const numberItems = await db.sortedSetCard(`settings:${hash}:sorted-list:${list}`); + const deleteKeys = [`settings:${hash}:sorted-list:${list}`]; + for (let x = 0; x < numberItems; x++) { + deleteKeys.push(`settings:${hash}:sorted-list:${list}:${x}`); + } + + await db.deleteAll(deleteKeys); + })); + + const sortedSetData = []; + const objectData = []; + for (const list of sortedLists) { + const array = sortedListData[list]; + for (const [order, data] of array.entries()) { + sortedSetData.push([`settings:${hash}:sorted-list:${list}`, order, order]); + objectData.push([`settings:${hash}:sorted-list:${list}:${order}`, data]); + } + } + + await Promise.all([ + db.sortedSetAddBulk(sortedSetData), + db.setObjectBulk(objectData), + ]); + } + + if (Object.keys(values).length > 0) { + await db.setObject(`settings:${hash}`, values); + } + + cache.del(`settings:${hash}`); + + plugins.hooks.fire('action:settings.set', { + plugin: hash, + settings: {...values, ...sortedListData}, // Add back sorted list data to values hash + quiet, + }); + + pubsub.publish(`action:settings.set.${hash}`, values); + if (!Meta.reloadRequired && !quiet) { + Meta.reloadRequired = true; + } }; Settings.setOne = async function (hash, field, value) { - const data = {}; - data[field] = value; - await Settings.set(hash, data); + const data = {}; + data[field] = value; + await Settings.set(hash, data); }; Settings.setOnEmpty = async function (hash, values) { - const settings = await Settings.get(hash) || {}; - const empty = {}; - - Object.keys(values).forEach((key) => { - if (!settings.hasOwnProperty(key)) { - empty[key] = values[key]; - } - }); - - - if (Object.keys(empty).length) { - await Settings.set(hash, empty); - } + const settings = await Settings.get(hash) || {}; + const empty = {}; + + for (const key of Object.keys(values)) { + if (!settings.hasOwnProperty(key)) { + empty[key] = values[key]; + } + } + + if (Object.keys(empty).length > 0) { + await Settings.set(hash, empty); + } }; diff --git a/src/meta/tags.js b/src/meta/tags.js index fc2bd10..4fe93dc 100644 --- a/src/meta/tags.js +++ b/src/meta/tags.js @@ -2,10 +2,9 @@ const nconf = require('nconf'); const winston = require('winston'); - const plugins = require('../plugins'); -const Meta = require('./index'); const utils = require('../utils'); +const Meta = require('./index'); const Tags = module.exports; @@ -13,257 +12,257 @@ const url = nconf.get('url'); const relative_path = nconf.get('relative_path'); const upload_url = nconf.get('upload_url'); -Tags.parse = async (req, data, meta, link) => { - // Meta tags - const defaultTags = [{ - name: 'viewport', - content: 'width=device-width, initial-scale=1.0', - }, { - name: 'content-type', - content: 'text/html; charset=UTF-8', - noEscape: true, - }, { - name: 'apple-mobile-web-app-capable', - content: 'yes', - }, { - name: 'mobile-web-app-capable', - content: 'yes', - }, { - property: 'og:site_name', - content: Meta.config.title || 'NodeBB', - }, { - name: 'msapplication-badge', - content: `frequency=30; polling-uri=${url}/sitemap.xml`, - noEscape: true, - }, { - name: 'theme-color', - content: Meta.config.themeColor || '#ffffff', - }]; +Tags.parse = async (request, data, meta, link) => { + // Meta tags + const defaultTags = [{ + name: 'viewport', + content: 'width=device-width, initial-scale=1.0', + }, { + name: 'content-type', + content: 'text/html; charset=UTF-8', + noEscape: true, + }, { + name: 'apple-mobile-web-app-capable', + content: 'yes', + }, { + name: 'mobile-web-app-capable', + content: 'yes', + }, { + property: 'og:site_name', + content: Meta.config.title || 'NodeBB', + }, { + name: 'msapplication-badge', + content: `frequency=30; polling-uri=${url}/sitemap.xml`, + noEscape: true, + }, { + name: 'theme-color', + content: Meta.config.themeColor || '#ffffff', + }]; - if (Meta.config.keywords) { - defaultTags.push({ - name: 'keywords', - content: Meta.config.keywords, - }); - } + if (Meta.config.keywords) { + defaultTags.push({ + name: 'keywords', + content: Meta.config.keywords, + }); + } - if (Meta.config['brand:logo']) { - defaultTags.push({ - name: 'msapplication-square150x150logo', - content: Meta.config['brand:logo'], - noEscape: true, - }); - } + if (Meta.config['brand:logo']) { + defaultTags.push({ + name: 'msapplication-square150x150logo', + content: Meta.config['brand:logo'], + noEscape: true, + }); + } - const faviconPath = `${relative_path}/assets/uploads/system/favicon.ico`; - const cacheBuster = `${Meta.config['cache-buster'] ? `?${Meta.config['cache-buster']}` : ''}`; + const faviconPath = `${relative_path}/assets/uploads/system/favicon.ico`; + const cacheBuster = `${Meta.config['cache-buster'] ? `?${Meta.config['cache-buster']}` : ''}`; - // Link Tags - const defaultLinks = [{ - rel: 'icon', - type: 'image/x-icon', - href: `${faviconPath}${cacheBuster}`, - }, { - rel: 'manifest', - href: `${relative_path}/manifest.webmanifest`, - crossorigin: `use-credentials`, - }]; + // Link Tags + const defaultLinks = [{ + rel: 'icon', + type: 'image/x-icon', + href: `${faviconPath}${cacheBuster}`, + }, { + rel: 'manifest', + href: `${relative_path}/manifest.webmanifest`, + crossorigin: 'use-credentials', + }]; - if (plugins.hooks.hasListeners('filter:search.query')) { - defaultLinks.push({ - rel: 'search', - type: 'application/opensearchdescription+xml', - title: utils.escapeHTML(String(Meta.config.title || Meta.config.browserTitle || 'NodeBB')), - href: `${relative_path}/osd.xml`, - }); - } + if (plugins.hooks.hasListeners('filter:search.query')) { + defaultLinks.push({ + rel: 'search', + type: 'application/opensearchdescription+xml', + title: utils.escapeHTML(String(Meta.config.title || Meta.config.browserTitle || 'NodeBB')), + href: `${relative_path}/osd.xml`, + }); + } - // Touch icons for mobile-devices - if (Meta.config['brand:touchIcon']) { - defaultLinks.push({ - rel: 'apple-touch-icon', - href: `${relative_path + upload_url}/system/touchicon-orig.png`, - }, { - rel: 'icon', - sizes: '36x36', - href: `${relative_path + upload_url}/system/touchicon-36.png`, - }, { - rel: 'icon', - sizes: '48x48', - href: `${relative_path + upload_url}/system/touchicon-48.png`, - }, { - rel: 'icon', - sizes: '72x72', - href: `${relative_path + upload_url}/system/touchicon-72.png`, - }, { - rel: 'icon', - sizes: '96x96', - href: `${relative_path + upload_url}/system/touchicon-96.png`, - }, { - rel: 'icon', - sizes: '144x144', - href: `${relative_path + upload_url}/system/touchicon-144.png`, - }, { - rel: 'icon', - sizes: '192x192', - href: `${relative_path + upload_url}/system/touchicon-192.png`, - }); - } else { - defaultLinks.push({ - rel: 'apple-touch-icon', - href: `${relative_path}/assets/images/touch/512.png`, - }, { - rel: 'icon', - sizes: '36x36', - href: `${relative_path}/assets/images/touch/36.png`, - }, { - rel: 'icon', - sizes: '48x48', - href: `${relative_path}/assets/images/touch/48.png`, - }, { - rel: 'icon', - sizes: '72x72', - href: `${relative_path}/assets/images/touch/72.png`, - }, { - rel: 'icon', - sizes: '96x96', - href: `${relative_path}/assets/images/touch/96.png`, - }, { - rel: 'icon', - sizes: '144x144', - href: `${relative_path}/assets/images/touch/144.png`, - }, { - rel: 'icon', - sizes: '192x192', - href: `${relative_path}/assets/images/touch/192.png`, - }, { - rel: 'icon', - sizes: '512x512', - href: `${relative_path}/assets/images/touch/512.png`, - }); - } + // Touch icons for mobile-devices + if (Meta.config['brand:touchIcon']) { + defaultLinks.push({ + rel: 'apple-touch-icon', + href: `${relative_path + upload_url}/system/touchicon-orig.png`, + }, { + rel: 'icon', + sizes: '36x36', + href: `${relative_path + upload_url}/system/touchicon-36.png`, + }, { + rel: 'icon', + sizes: '48x48', + href: `${relative_path + upload_url}/system/touchicon-48.png`, + }, { + rel: 'icon', + sizes: '72x72', + href: `${relative_path + upload_url}/system/touchicon-72.png`, + }, { + rel: 'icon', + sizes: '96x96', + href: `${relative_path + upload_url}/system/touchicon-96.png`, + }, { + rel: 'icon', + sizes: '144x144', + href: `${relative_path + upload_url}/system/touchicon-144.png`, + }, { + rel: 'icon', + sizes: '192x192', + href: `${relative_path + upload_url}/system/touchicon-192.png`, + }); + } else { + defaultLinks.push({ + rel: 'apple-touch-icon', + href: `${relative_path}/assets/images/touch/512.png`, + }, { + rel: 'icon', + sizes: '36x36', + href: `${relative_path}/assets/images/touch/36.png`, + }, { + rel: 'icon', + sizes: '48x48', + href: `${relative_path}/assets/images/touch/48.png`, + }, { + rel: 'icon', + sizes: '72x72', + href: `${relative_path}/assets/images/touch/72.png`, + }, { + rel: 'icon', + sizes: '96x96', + href: `${relative_path}/assets/images/touch/96.png`, + }, { + rel: 'icon', + sizes: '144x144', + href: `${relative_path}/assets/images/touch/144.png`, + }, { + rel: 'icon', + sizes: '192x192', + href: `${relative_path}/assets/images/touch/192.png`, + }, { + rel: 'icon', + sizes: '512x512', + href: `${relative_path}/assets/images/touch/512.png`, + }); + } - const results = await utils.promiseParallel({ - tags: plugins.hooks.fire('filter:meta.getMetaTags', { req: req, data: data, tags: defaultTags }), - links: plugins.hooks.fire('filter:meta.getLinkTags', { req: req, data: data, links: defaultLinks }), - }); + const results = await utils.promiseParallel({ + tags: plugins.hooks.fire('filter:meta.getMetaTags', {req: request, data, tags: defaultTags}), + links: plugins.hooks.fire('filter:meta.getLinkTags', {req: request, data, links: defaultLinks}), + }); - meta = results.tags.tags.concat(meta || []).map((tag) => { - if (!tag || typeof tag.content !== 'string') { - winston.warn('Invalid meta tag. ', tag); - return tag; - } + meta = results.tags.tags.concat(meta || []).map(tag => { + if (!tag || typeof tag.content !== 'string') { + winston.warn('Invalid meta tag. ', tag); + return tag; + } - if (!tag.noEscape) { - const attributes = Object.keys(tag); - attributes.forEach((attr) => { - tag[attr] = utils.escapeHTML(String(tag[attr])); - }); - } + if (!tag.noEscape) { + const attributes = Object.keys(tag); + for (const attribute of attributes) { + tag[attribute] = utils.escapeHTML(String(tag[attribute])); + } + } - return tag; - }); + return tag; + }); - await addSiteOGImage(meta); + await addSiteOGImage(meta); - addIfNotExists(meta, 'property', 'og:title', Meta.config.title || 'NodeBB'); - const ogUrl = url + (req.originalUrl !== '/' ? stripRelativePath(req.originalUrl) : ''); - addIfNotExists(meta, 'property', 'og:url', ogUrl); - addIfNotExists(meta, 'name', 'description', Meta.config.description); - addIfNotExists(meta, 'property', 'og:description', Meta.config.description); + addIfNotExists(meta, 'property', 'og:title', Meta.config.title || 'NodeBB'); + const ogUrl = url + (request.originalUrl === '/' ? '' : stripRelativePath(request.originalUrl)); + addIfNotExists(meta, 'property', 'og:url', ogUrl); + addIfNotExists(meta, 'name', 'description', Meta.config.description); + addIfNotExists(meta, 'property', 'og:description', Meta.config.description); - link = results.links.links.concat(link || []).map((tag) => { - if (!tag.noEscape) { - const attributes = Object.keys(tag); - attributes.forEach((attr) => { - tag[attr] = utils.escapeHTML(String(tag[attr])); - }); - } + link = results.links.links.concat(link || []).map(tag => { + if (!tag.noEscape) { + const attributes = Object.keys(tag); + for (const attribute of attributes) { + tag[attribute] = utils.escapeHTML(String(tag[attribute])); + } + } - return tag; - }); + return tag; + }); - return { meta, link }; + return {meta, link}; }; function addIfNotExists(meta, keyName, tagName, value) { - let exists = false; - meta.forEach((tag) => { - if (tag[keyName] === tagName) { - exists = true; - } - }); + let exists = false; + for (const tag of meta) { + if (tag[keyName] === tagName) { + exists = true; + } + } - if (!exists && value) { - const data = { - content: utils.escapeHTML(String(value)), - }; - data[keyName] = tagName; - meta.push(data); - } + if (!exists && value) { + const data = { + content: utils.escapeHTML(String(value)), + }; + data[keyName] = tagName; + meta.push(data); + } } function stripRelativePath(url) { - if (url.startsWith(relative_path)) { - return url.slice(relative_path.length); - } + if (url.startsWith(relative_path)) { + return url.slice(relative_path.length); + } - return url; + return url; } async function addSiteOGImage(meta) { - const key = Meta.config['og:image'] ? 'og:image' : 'brand:logo'; - let ogImage = stripRelativePath(Meta.config[key] || ''); - if (ogImage && !ogImage.startsWith('http')) { - ogImage = url + ogImage; - } + const key = Meta.config['og:image'] ? 'og:image' : 'brand:logo'; + let ogImage = stripRelativePath(Meta.config[key] || ''); + if (ogImage && !ogImage.startsWith('http')) { + ogImage = url + ogImage; + } - const { images } = await plugins.hooks.fire('filter:meta.addSiteOGImage', { - images: [{ - url: ogImage || `${url}/assets/images/logo@3x.png`, - width: ogImage ? Meta.config[`${key}:width`] : 963, - height: ogImage ? Meta.config[`${key}:height`] : 225, - }], - }); + const {images} = await plugins.hooks.fire('filter:meta.addSiteOGImage', { + images: [{ + url: ogImage || `${url}/assets/images/logo@3x.png`, + width: ogImage ? Meta.config[`${key}:width`] : 963, + height: ogImage ? Meta.config[`${key}:height`] : 225, + }], + }); - const properties = ['url', 'secure_url', 'type', 'width', 'height', 'alt']; - images.forEach((image) => { - for (const property of properties) { - if (image.hasOwnProperty(property)) { - switch (property) { - case 'url': { - meta.push({ - property: 'og:image', - content: image.url, - noEscape: true, - }, { - property: 'og:image:url', - content: image.url, - noEscape: true, - }); - break; - } + const properties = ['url', 'secure_url', 'type', 'width', 'height', 'alt']; + for (const image of images) { + for (const property of properties) { + if (image.hasOwnProperty(property)) { + switch (property) { + case 'url': { + meta.push({ + property: 'og:image', + content: image.url, + noEscape: true, + }, { + property: 'og:image:url', + content: image.url, + noEscape: true, + }); + break; + } - case 'secure_url': { - meta.push({ - property: `og:${property}`, - content: image[property], - noEscape: true, - }); - break; - } + case 'secure_url': { + meta.push({ + property: `og:${property}`, + content: image[property], + noEscape: true, + }); + break; + } - case 'type': - case 'alt': - case 'width': - case 'height': { - meta.push({ - property: `og:image:${property}`, - content: String(image[property]), - }); - } - } - } - } - }); + case 'type': + case 'alt': + case 'width': + case 'height': { + meta.push({ + property: `og:image:${property}`, + content: String(image[property]), + }); + } + } + } + } + } } diff --git a/src/meta/templates.js b/src/meta/templates.js index 39e06b8..67ac5ee 100644 --- a/src/meta/templates.js +++ b/src/meta/templates.js @@ -1,139 +1,142 @@ 'use strict'; -const util = require('util'); +const util = require('node:util'); let mkdirp = require('mkdirp'); mkdirp = mkdirp.hasOwnProperty('native') ? mkdirp : util.promisify(mkdirp); const rimraf = require('rimraf'); const winston = require('winston'); -const path = require('path'); -const fs = require('fs'); - +const path = require('node:path'); +const fs = require('node:fs'); const nconf = require('nconf'); const _ = require('lodash'); const Benchpress = require('benchpressjs'); - const plugins = require('../plugins'); const file = require('../file'); -const { themeNamePattern, paths } = require('../constants'); +const {themeNamePattern, paths} = require('../constants'); const viewsPath = nconf.get('views_dir'); const Templates = module.exports; async function processImports(paths, templatePath, source) { - const regex = //; + const regex = //; - const matches = source.match(regex); + const matches = source.match(regex); - if (!matches) { - return source; - } + if (!matches) { + return source; + } - const partial = matches[1]; - if (paths[partial] && templatePath !== partial) { - const partialSource = await fs.promises.readFile(paths[partial], 'utf8'); - source = source.replace(regex, partialSource); - return await processImports(paths, templatePath, source); - } + const partial = matches[1]; + if (paths[partial] && templatePath !== partial) { + const partialSource = await fs.promises.readFile(paths[partial], 'utf8'); + source = source.replace(regex, partialSource); + return await processImports(paths, templatePath, source); + } - winston.warn(`[meta/templates] Partial not loaded: ${matches[1]}`); - source = source.replace(regex, ''); + winston.warn(`[meta/templates] Partial not loaded: ${matches[1]}`); + source = source.replace(regex, ''); - return await processImports(paths, templatePath, source); + return await processImports(paths, templatePath, source); } + Templates.processImports = processImports; -async function getTemplateDirs(activePlugins) { - const pluginTemplates = activePlugins.map((id) => { - if (themeNamePattern.test(id)) { - return nconf.get('theme_templates_path'); - } - if (!plugins.pluginsData[id]) { - return ''; - } - return path.join(paths.nodeModules, id, plugins.pluginsData[id].templates || 'templates'); - }).filter(Boolean); +async function getTemplateDirectories(activePlugins) { + const pluginTemplates = activePlugins.map(id => { + if (themeNamePattern.test(id)) { + return nconf.get('theme_templates_path'); + } + + if (!plugins.pluginsData[id]) { + return ''; + } - let themeConfig = require(nconf.get('theme_config')); - let theme = themeConfig.baseTheme; + return path.join(paths.nodeModules, id, plugins.pluginsData[id].templates || 'templates'); + }).filter(Boolean); - let themePath; - let themeTemplates = []; - while (theme) { - themePath = path.join(nconf.get('themes_path'), theme); - themeConfig = require(path.join(themePath, 'theme.json')); + let themeConfig = require(nconf.get('theme_config')); + let theme = themeConfig.baseTheme; - themeTemplates.push(path.join(themePath, themeConfig.templates || 'templates')); - theme = themeConfig.baseTheme; - } + let themePath; + let themeTemplates = []; + while (theme) { + themePath = path.join(nconf.get('themes_path'), theme); + themeConfig = require(path.join(themePath, 'theme.json')); - themeTemplates.push(nconf.get('base_templates_path')); - themeTemplates = _.uniq(themeTemplates.reverse()); + themeTemplates.push(path.join(themePath, themeConfig.templates || 'templates')); + theme = themeConfig.baseTheme; + } - const coreTemplatesPath = nconf.get('core_templates_path'); + themeTemplates.push(nconf.get('base_templates_path')); + themeTemplates = _.uniq(themeTemplates.reverse()); - let templateDirs = _.uniq([coreTemplatesPath].concat(themeTemplates, pluginTemplates)); + const coreTemplatesPath = nconf.get('core_templates_path'); - templateDirs = await Promise.all(templateDirs.map(async path => (await file.exists(path) ? path : false))); - return templateDirs.filter(Boolean); + let templateDirectories = _.uniq([coreTemplatesPath].concat(themeTemplates, pluginTemplates)); + + templateDirectories = await Promise.all(templateDirectories.map(async path => (await file.exists(path) ? path : false))); + return templateDirectories.filter(Boolean); } -async function getTemplateFiles(dirs) { - const buckets = await Promise.all(dirs.map(async (dir) => { - let files = await file.walk(dir); - files = files.filter(path => path.endsWith('.tpl')).map(file => ({ - name: path.relative(dir, file).replace(/\\/g, '/'), - path: file, - })); - return files; - })); - - const dict = {}; - buckets.forEach((files) => { - files.forEach((file) => { - dict[file.name] = file.path; - }); - }); - - return dict; +async function getTemplateFiles(directories) { + const buckets = await Promise.all(directories.map(async dir => { + let files = await file.walk(dir); + files = files.filter(path => path.endsWith('.tpl')).map(file => ({ + name: path.relative(dir, file).replaceAll('\\', '/'), + path: file, + })); + return files; + })); + + const dictionary = {}; + for (const files of buckets) { + for (const file of files) { + dictionary[file.name] = file.path; + } + } + + return dictionary; } async function compileTemplate(filename, source) { - let paths = await file.walk(viewsPath); - paths = _.fromPairs(paths.map((p) => { - const relative = path.relative(viewsPath, p).replace(/\\/g, '/'); - return [relative, p]; - })); - - source = await processImports(paths, filename, source); - const compiled = await Benchpress.precompile(source, { filename }); - return await fs.promises.writeFile(path.join(viewsPath, filename.replace(/\.tpl$/, '.js')), compiled); + let paths = await file.walk(viewsPath); + paths = Object.fromEntries(paths.map(p => { + const relative = path.relative(viewsPath, p).replaceAll('\\', '/'); + return [relative, p]; + })); + + source = await processImports(paths, filename, source); + const compiled = await Benchpress.precompile(source, {filename}); + return await fs.promises.writeFile(path.join(viewsPath, filename.replace(/\.tpl$/, '.js')), compiled); } + Templates.compileTemplate = compileTemplate; async function compile() { - const _rimraf = util.promisify(rimraf); + const _rimraf = util.promisify(rimraf); - await _rimraf(viewsPath); - await mkdirp(viewsPath); + await _rimraf(viewsPath); + await mkdirp(viewsPath); - let files = await plugins.getActive(); - files = await getTemplateDirs(files); - files = await getTemplateFiles(files); + let files = await plugins.getActive(); + files = await getTemplateDirectories(files); + files = await getTemplateFiles(files); - await Promise.all(Object.keys(files).map(async (name) => { - const filePath = files[name]; - let imported = await fs.promises.readFile(filePath, 'utf8'); - imported = await processImports(files, name, imported); + await Promise.all(Object.keys(files).map(async name => { + const filePath = files[name]; + let imported = await fs.promises.readFile(filePath, 'utf8'); + imported = await processImports(files, name, imported); - await mkdirp(path.join(viewsPath, path.dirname(name))); + await mkdirp(path.join(viewsPath, path.dirname(name))); - await fs.promises.writeFile(path.join(viewsPath, name), imported); - const compiled = await Benchpress.precompile(imported, { filename: name }); - await fs.promises.writeFile(path.join(viewsPath, name.replace(/\.tpl$/, '.js')), compiled); - })); + await fs.promises.writeFile(path.join(viewsPath, name), imported); + const compiled = await Benchpress.precompile(imported, {filename: name}); + await fs.promises.writeFile(path.join(viewsPath, name.replace(/\.tpl$/, '.js')), compiled); + })); - winston.verbose('[meta/templates] Successfully compiled templates.'); + winston.verbose('[meta/templates] Successfully compiled templates.'); } + Templates.compile = compile; diff --git a/src/meta/themes.js b/src/meta/themes.js index d5e37ca..b621d67 100644 --- a/src/meta/themes.js +++ b/src/meta/themes.js @@ -1,167 +1,165 @@ 'use strict'; -const path = require('path'); +const path = require('node:path'); +const fs = require('node:fs'); const nconf = require('nconf'); const winston = require('winston'); const _ = require('lodash'); -const fs = require('fs'); - const file = require('../file'); -const Meta = require('./index'); const events = require('../events'); const utils = require('../utils'); -const { themeNamePattern } = require('../constants'); +const {themeNamePattern} = require('../constants'); +const Meta = require('./index'); 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); + const themePath = nconf.get('themes_path'); + if (typeof themePath !== 'string') { + return []; + } + + let themes = await getThemes(themePath); + themes = themes.flat().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 configObject = JSON.parse(configFile); + const packageObject = JSON.parse(packageFile); + + configObject.id = packageObject.name; + + // Minor adjustments for API output + configObject.type = 'local'; + configObject.screenshot_url = configObject.screenshot ? `${nconf.get('relative_path')}/css/previews/${encodeURIComponent(configObject.id)}` : `${nconf.get('relative_path')}/assets/images/themes/default.png`; + + return configObject; + } catch (error) { + if (error.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; - } - })); + let directories = await fs.promises.readdir(themePath); + directories = directories.filter(dir => themeNamePattern.test(dir) || dir.startsWith('@')); + return await Promise.all(directories.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 (error) { + if (error.code === 'ENOENT') { + return false; + } + + throw error; + } + })); } -Themes.set = async (data) => { - switch (data.type) { - case 'local': { - const current = await Meta.configs.get('theme: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); - - // 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.set = async data => { + switch (data.type) { + case 'local': { + const current = await Meta.configs.get('theme: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); + + // 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: Number.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 data = await utils.promiseParallel({ + themesData: Themes.get(), + currentThemeId: Meta.configs.get('theme:id'), + }); - const themeId = data.currentThemeId || 'nodebb-theme-persona'; + const themeId = data.currentThemeId || 'nodebb-theme-persona'; - if (process.env.NODE_ENV === 'development') { - winston.info(`[themes] Using theme ${themeId}`); - } + if (process.env.NODE_ENV === 'development') { + winston.info(`[themes] Using theme ${themeId}`); + } - const themeObj = data.themesData.find(themeObj => themeObj.id === themeId); + const themeObject = data.themesData.find(themeObject_ => themeObject_.id === themeId); - if (!themeObj) { - throw new Error('[[error:theme-not-found]]'); - } + if (!themeObject) { + throw new Error('[[error:theme-not-found]]'); + } - Themes.setPath(themeObj); + Themes.setPath(themeObject); }; -Themes.setPath = function (themeObj) { - // Theme's templates path - let themePath = nconf.get('base_templates_path'); - const fallback = path.join(nconf.get('themes_path'), themeObj.id, 'templates'); +Themes.setPath = function (themeObject) { + // Theme's templates path + let themePath = nconf.get('base_templates_path'); + const fallback = path.join(nconf.get('themes_path'), themeObject.id, 'templates'); - if (themeObj.templates) { - themePath = path.join(nconf.get('themes_path'), themeObj.id, themeObj.templates); - } else if (file.existsSync(fallback)) { - themePath = fallback; - } + if (themeObject.templates) { + themePath = path.join(nconf.get('themes_path'), themeObject.id, themeObject.templates); + } else if (file.existsSync(fallback)) { + themePath = fallback; + } - nconf.set('theme_templates_path', themePath); - nconf.set('theme_config', path.join(nconf.get('themes_path'), themeObj.id, 'theme.json')); + nconf.set('theme_templates_path', themePath); + nconf.set('theme_config', path.join(nconf.get('themes_path'), themeObject.id, 'theme.json')); }; diff --git a/src/middleware/admin.js b/src/middleware/admin.js index e3f11f2..ac77155 100644 --- a/src/middleware/admin.js +++ b/src/middleware/admin.js @@ -4,7 +4,6 @@ const winston = require('winston'); const jsesc = require('jsesc'); const nconf = require('nconf'); const semver = require('semver'); - const user = require('../user'); const meta = require('../meta'); const plugins = require('../plugins'); @@ -14,163 +13,166 @@ const versions = require('../admin/versions'); const helpers = require('./helpers'); const controllers = { - api: require('../controllers/api'), - helpers: require('../controllers/helpers'), + api: require('../controllers/api'), + helpers: require('../controllers/helpers'), }; const middleware = module.exports; -middleware.buildHeader = helpers.try(async (req, res, next) => { - res.locals.renderAdminHeader = true; - if (req.method === 'GET') { - await require('./index').applyCSRFasync(req, res); - } +middleware.buildHeader = helpers.try(async (request, res, next) => { + res.locals.renderAdminHeader = true; + if (request.method === 'GET') { + await require('./index').applyCSRFasync(request, res); + } - res.locals.config = await controllers.api.loadConfig(req); - next(); + res.locals.config = await controllers.api.loadConfig(request); + next(); }); -middleware.renderHeader = async (req, res, data) => { - const custom_header = { - plugins: [], - authentication: [], - }; - res.locals.config = res.locals.config || {}; - - const results = await utils.promiseParallel({ - userData: user.getUserFields(req.uid, ['username', 'userslug', 'email', 'picture', 'email:confirmed']), - scripts: getAdminScripts(), - custom_header: plugins.hooks.fire('filter:admin.header.build', custom_header), - configs: meta.configs.list(), - latestVersion: getLatestVersion(), - privileges: privileges.admin.get(req.uid), - tags: meta.tags.parse(req, {}, [], []), - }); - - const { userData } = results; - userData.uid = req.uid; - userData['email:confirmed'] = userData['email:confirmed'] === 1; - userData.privileges = results.privileges; - - let acpPath = req.path.slice(1).split('/'); - acpPath.forEach((path, i) => { - acpPath[i] = path.charAt(0).toUpperCase() + path.slice(1); - }); - acpPath = acpPath.join(' > '); - - const version = nconf.get('version'); - - res.locals.config.userLang = res.locals.config.acpLang || res.locals.config.userLang; - let templateValues = { - config: res.locals.config, - configJSON: jsesc(JSON.stringify(res.locals.config), { isScriptContext: true }), - relative_path: res.locals.config.relative_path, - adminConfigJSON: encodeURIComponent(JSON.stringify(results.configs)), - metaTags: results.tags.meta, - linkTags: results.tags.link, - user: userData, - userJSON: jsesc(JSON.stringify(userData), { isScriptContext: true }), - plugins: results.custom_header.plugins, - authentication: results.custom_header.authentication, - scripts: results.scripts, - 'cache-buster': meta.config['cache-buster'] || '', - env: !!process.env.NODE_ENV, - title: `${acpPath || 'Dashboard'} | NodeBB Admin Control Panel`, - bodyClass: data.bodyClass, - version: version, - latestVersion: results.latestVersion, - upgradeAvailable: results.latestVersion && semver.gt(results.latestVersion, version), - showManageMenu: results.privileges.superadmin || ['categories', 'privileges', 'users', 'admins-mods', 'groups', 'tags', 'settings'].some(priv => results.privileges[`admin:${priv}`]), - }; - - templateValues.template = { name: res.locals.template }; - templateValues.template[res.locals.template] = true; - ({ templateData: templateValues } = await plugins.hooks.fire('filter:middleware.renderAdminHeader', { - req, - res, - templateData: templateValues, - data, - })); - - return await req.app.renderAsync('admin/header', templateValues); +middleware.renderHeader = async (request, res, data) => { + const custom_header = { + plugins: [], + authentication: [], + }; + res.locals.config = res.locals.config || {}; + + const results = await utils.promiseParallel({ + userData: user.getUserFields(request.uid, ['username', 'userslug', 'email', 'picture', 'email:confirmed']), + scripts: getAdminScripts(), + custom_header: plugins.hooks.fire('filter:admin.header.build', custom_header), + configs: meta.configs.list(), + latestVersion: getLatestVersion(), + privileges: privileges.admin.get(request.uid), + tags: meta.tags.parse(request, {}, [], []), + }); + + const {userData} = results; + userData.uid = request.uid; + userData['email:confirmed'] = userData['email:confirmed'] === 1; + userData.privileges = results.privileges; + + let acpPath = request.path.slice(1).split('/'); + for (const [i, path] of acpPath.entries()) { + acpPath[i] = path.charAt(0).toUpperCase() + path.slice(1); + } + + acpPath = acpPath.join(' > '); + + const version = nconf.get('version'); + + res.locals.config.userLang = res.locals.config.acpLang || res.locals.config.userLang; + let templateValues = { + config: res.locals.config, + configJSON: jsesc(JSON.stringify(res.locals.config), {isScriptContext: true}), + relative_path: res.locals.config.relative_path, + adminConfigJSON: encodeURIComponent(JSON.stringify(results.configs)), + metaTags: results.tags.meta, + linkTags: results.tags.link, + user: userData, + userJSON: jsesc(JSON.stringify(userData), {isScriptContext: true}), + plugins: results.custom_header.plugins, + authentication: results.custom_header.authentication, + scripts: results.scripts, + 'cache-buster': meta.config['cache-buster'] || '', + env: Boolean(process.env.NODE_ENV), + title: `${acpPath || 'Dashboard'} | NodeBB Admin Control Panel`, + bodyClass: data.bodyClass, + version, + latestVersion: results.latestVersion, + upgradeAvailable: results.latestVersion && semver.gt(results.latestVersion, version), + showManageMenu: results.privileges.superadmin || ['categories', 'privileges', 'users', 'admins-mods', 'groups', 'tags', 'settings'].some(priv => results.privileges[`admin:${priv}`]), + }; + + templateValues.template = {name: res.locals.template}; + templateValues.template[res.locals.template] = true; + ({templateData: templateValues} = await plugins.hooks.fire('filter:middleware.renderAdminHeader', { + req: request, + res, + templateData: templateValues, + data, + })); + + return await request.app.renderAsync('admin/header', templateValues); }; async function getAdminScripts() { - const scripts = await plugins.hooks.fire('filter:admin.scripts.get', []); - return scripts.map(script => ({ src: script })); + const scripts = await plugins.hooks.fire('filter:admin.scripts.get', []); + return scripts.map(script => ({src: script})); } async function getLatestVersion() { - try { - const result = await versions.getLatestVersion(); - return result; - } catch (err) { - winston.error(`[acp] Failed to fetch latest version${err.stack}`); - } - return null; + try { + const result = await versions.getLatestVersion(); + return result; + } catch (error) { + winston.error(`[acp] Failed to fetch latest version${error.stack}`); + } + + return null; } -middleware.renderFooter = async function (req, res, data) { - return await req.app.renderAsync('admin/footer', data); +middleware.renderFooter = async function (request, res, data) { + return await request.app.renderAsync('admin/footer', data); }; -middleware.checkPrivileges = helpers.try(async (req, res, next) => { - // Kick out guests, obviously - if (req.uid <= 0) { - return controllers.helpers.notAllowed(req, res); - } - - // Otherwise, check for privilege based on page (if not in mapping, deny access) - const path = req.path.replace(/^(\/api)?(\/v3)?\/admin\/?/g, ''); - if (path) { - const privilege = privileges.admin.resolve(path); - if (!await privileges.admin.can(privilege, req.uid)) { - return controllers.helpers.notAllowed(req, res); - } - } else { - // If accessing /admin, check for any valid admin privs - const privilegeSet = await privileges.admin.get(req.uid); - if (!Object.values(privilegeSet).some(Boolean)) { - return controllers.helpers.notAllowed(req, res); - } - } - - // If user does not have password - const hasPassword = await user.hasPassword(req.uid); - if (!hasPassword) { - return next(); - } - - // Reject if they need to re-login (due to ACP timeout), otherwise extend logout timer - const loginTime = req.session.meta ? req.session.meta.datetime : 0; - const adminReloginDuration = meta.config.adminReloginDuration * 60000; - const disabled = meta.config.adminReloginDuration === 0; - if (disabled || (loginTime && parseInt(loginTime, 10) > Date.now() - adminReloginDuration)) { - const timeLeft = parseInt(loginTime, 10) - (Date.now() - adminReloginDuration); - if (req.session.meta && timeLeft < Math.min(60000, adminReloginDuration)) { - req.session.meta.datetime += Math.min(60000, adminReloginDuration); - } - - return next(); - } - - let returnTo = req.path; - if (nconf.get('relative_path')) { - returnTo = req.path.replace(new RegExp(`^${nconf.get('relative_path')}`), ''); - } - returnTo = returnTo.replace(/^\/api/, ''); - - req.session.returnTo = returnTo; - req.session.forceLogin = 1; - - await plugins.hooks.fire('response:auth.relogin', { req, res }); - if (res.headersSent) { - return; - } - - if (res.locals.isAPI) { - res.status(401).json({}); - } else { - res.redirect(`${nconf.get('relative_path')}/login?local=1`); - } +middleware.checkPrivileges = helpers.try(async (request, res, next) => { + // Kick out guests, obviously + if (request.uid <= 0) { + return controllers.helpers.notAllowed(request, res); + } + + // Otherwise, check for privilege based on page (if not in mapping, deny access) + const path = request.path.replaceAll(/^(\/api)?(\/v3)?\/admin\/?/g, ''); + if (path) { + const privilege = privileges.admin.resolve(path); + if (!await privileges.admin.can(privilege, request.uid)) { + return controllers.helpers.notAllowed(request, res); + } + } else { + // If accessing /admin, check for any valid admin privs + const privilegeSet = await privileges.admin.get(request.uid); + if (!Object.values(privilegeSet).some(Boolean)) { + return controllers.helpers.notAllowed(request, res); + } + } + + // If user does not have password + const hasPassword = await user.hasPassword(request.uid); + if (!hasPassword) { + return next(); + } + + // Reject if they need to re-login (due to ACP timeout), otherwise extend logout timer + const loginTime = request.session.meta ? request.session.meta.datetime : 0; + const adminReloginDuration = meta.config.adminReloginDuration * 60_000; + const disabled = meta.config.adminReloginDuration === 0; + if (disabled || (loginTime && Number.parseInt(loginTime, 10) > Date.now() - adminReloginDuration)) { + const timeLeft = Number.parseInt(loginTime, 10) - (Date.now() - adminReloginDuration); + if (request.session.meta && timeLeft < Math.min(60_000, adminReloginDuration)) { + request.session.meta.datetime += Math.min(60_000, adminReloginDuration); + } + + return next(); + } + + let returnTo = request.path; + if (nconf.get('relative_path')) { + returnTo = request.path.replace(new RegExp(`^${nconf.get('relative_path')}`), ''); + } + + returnTo = returnTo.replace(/^\/api/, ''); + + request.session.returnTo = returnTo; + request.session.forceLogin = 1; + + await plugins.hooks.fire('response:auth.relogin', {req: request, res}); + if (res.headersSent) { + return; + } + + if (res.locals.isAPI) { + res.status(401).json({}); + } else { + res.redirect(`${nconf.get('relative_path')}/login?local=1`); + } }); diff --git a/src/middleware/assert.js b/src/middleware/assert.js index a9130ed..af5d3b1 100644 --- a/src/middleware/assert.js +++ b/src/middleware/assert.js @@ -5,9 +5,8 @@ * payload and throw an error otherwise. */ -const path = require('path'); +const path = require('node:path'); const nconf = require('nconf'); - const file = require('../file'); const user = require('../user'); const groups = require('../groups'); @@ -16,126 +15,126 @@ const posts = require('../posts'); const messaging = require('../messaging'); const flags = require('../flags'); const slugify = require('../slugify'); - -const helpers = require('./helpers'); const controllerHelpers = require('../controllers/helpers'); +const helpers = require('./helpers'); const Assert = module.exports; -Assert.user = helpers.try(async (req, res, next) => { - if (!await user.exists(req.params.uid)) { - return controllerHelpers.formatApiResponse(404, res, new Error('[[error:no-user]]')); - } +Assert.user = helpers.try(async (request, res, next) => { + if (!await user.exists(request.params.uid)) { + return controllerHelpers.formatApiResponse(404, res, new Error('[[error:no-user]]')); + } - next(); + next(); }); -Assert.group = helpers.try(async (req, res, next) => { - const name = await groups.getGroupNameByGroupSlug(req.params.slug); - if (!name || !await groups.exists(name)) { - return controllerHelpers.formatApiResponse(404, res, new Error('[[error:no-group]]')); - } +Assert.group = helpers.try(async (request, res, next) => { + const name = await groups.getGroupNameByGroupSlug(request.params.slug); + if (!name || !await groups.exists(name)) { + return controllerHelpers.formatApiResponse(404, res, new Error('[[error:no-group]]')); + } - next(); + next(); }); -Assert.topic = helpers.try(async (req, res, next) => { - if (!await topics.exists(req.params.tid)) { - return controllerHelpers.formatApiResponse(404, res, new Error('[[error:no-topic]]')); - } +Assert.topic = helpers.try(async (request, res, next) => { + if (!await topics.exists(request.params.tid)) { + return controllerHelpers.formatApiResponse(404, res, new Error('[[error:no-topic]]')); + } - next(); + next(); }); -Assert.post = helpers.try(async (req, res, next) => { - if (!await posts.exists(req.params.pid)) { - return controllerHelpers.formatApiResponse(404, res, new Error('[[error:no-post]]')); - } +Assert.post = helpers.try(async (request, res, next) => { + if (!await posts.exists(request.params.pid)) { + return controllerHelpers.formatApiResponse(404, res, new Error('[[error:no-post]]')); + } - next(); + next(); }); -Assert.flag = helpers.try(async (req, res, next) => { - const canView = await flags.canView(req.params.flagId, req.uid); - if (!canView) { - return controllerHelpers.formatApiResponse(404, res, new Error('[[error:no-flag]]')); - } +Assert.flag = helpers.try(async (request, res, next) => { + const canView = await flags.canView(request.params.flagId, request.uid); + if (!canView) { + return controllerHelpers.formatApiResponse(404, res, new Error('[[error:no-flag]]')); + } - next(); + next(); }); -Assert.path = helpers.try(async (req, res, next) => { - // file: URL support - if (req.body.path.startsWith('file:///')) { - req.body.path = new URL(req.body.path).pathname; - } +Assert.path = helpers.try(async (request, res, next) => { + // File: URL support + if (request.body.path.startsWith('file:///')) { + request.body.path = new URL(request.body.path).pathname; + } - // Strip upload_url if found - if (req.body.path.startsWith(nconf.get('upload_url'))) { - req.body.path = req.body.path.slice(nconf.get('upload_url').length); - } + // Strip upload_url if found + if (request.body.path.startsWith(nconf.get('upload_url'))) { + request.body.path = request.body.path.slice(nconf.get('upload_url').length); + } - const pathToFile = path.join(nconf.get('upload_path'), req.body.path); - res.locals.cleanedPath = pathToFile; + const pathToFile = path.join(nconf.get('upload_path'), request.body.path); + res.locals.cleanedPath = pathToFile; - // Guard against path traversal - if (!pathToFile.startsWith(nconf.get('upload_path'))) { - return controllerHelpers.formatApiResponse(403, res, new Error('[[error:invalid-path]]')); - } + // Guard against path traversal + if (!pathToFile.startsWith(nconf.get('upload_path'))) { + return controllerHelpers.formatApiResponse(403, res, new Error('[[error:invalid-path]]')); + } - if (!await file.exists(pathToFile)) { - return controllerHelpers.formatApiResponse(404, res, new Error('[[error:invalid-path]]')); - } + if (!await file.exists(pathToFile)) { + return controllerHelpers.formatApiResponse(404, res, new Error('[[error:invalid-path]]')); + } - next(); + next(); }); -Assert.folderName = helpers.try(async (req, res, next) => { - const folderName = slugify(path.basename(req.body.folderName.trim())); - const folderPath = path.join(res.locals.cleanedPath, folderName); +Assert.folderName = helpers.try(async (request, res, next) => { + const folderName = slugify(path.basename(request.body.folderName.trim())); + const folderPath = path.join(res.locals.cleanedPath, folderName); + + // Slugify removes invalid characters, folderName may become empty + if (!folderName) { + return controllerHelpers.formatApiResponse(403, res, new Error('[[error:invalid-path]]')); + } - // slugify removes invalid characters, folderName may become empty - if (!folderName) { - return controllerHelpers.formatApiResponse(403, res, new Error('[[error:invalid-path]]')); - } - if (await file.exists(folderPath)) { - return controllerHelpers.formatApiResponse(403, res, new Error('[[error:folder-exists]]')); - } + if (await file.exists(folderPath)) { + return controllerHelpers.formatApiResponse(403, res, new Error('[[error:folder-exists]]')); + } - res.locals.folderPath = folderPath; + res.locals.folderPath = folderPath; - next(); + next(); }); -Assert.room = helpers.try(async (req, res, next) => { - if (!isFinite(req.params.roomId)) { - return controllerHelpers.formatApiResponse(400, res, new Error('[[error:invalid-data]]')); - } +Assert.room = helpers.try(async (request, res, next) => { + if (!isFinite(request.params.roomId)) { + return controllerHelpers.formatApiResponse(400, res, new Error('[[error:invalid-data]]')); + } - const [exists, inRoom] = await Promise.all([ - await messaging.roomExists(req.params.roomId), - await messaging.isUserInRoom(req.uid, req.params.roomId), - ]); + const [exists, inRoom] = await Promise.all([ + await messaging.roomExists(request.params.roomId), + await messaging.isUserInRoom(request.uid, request.params.roomId), + ]); - if (!exists) { - return controllerHelpers.formatApiResponse(404, res, new Error('[[error:chat-room-does-not-exist]]')); - } + if (!exists) { + return controllerHelpers.formatApiResponse(404, res, new Error('[[error:chat-room-does-not-exist]]')); + } - if (!inRoom) { - return controllerHelpers.formatApiResponse(403, res, new Error('[[error:no-privileges]]')); - } + if (!inRoom) { + return controllerHelpers.formatApiResponse(403, res, new Error('[[error:no-privileges]]')); + } - next(); + next(); }); -Assert.message = helpers.try(async (req, res, next) => { - if ( - !isFinite(req.params.mid) || - !(await messaging.messageExists(req.params.mid)) || - !(await messaging.canViewMessage(req.params.mid, req.params.roomId, req.uid)) - ) { - return controllerHelpers.formatApiResponse(400, res, new Error('[[error:invalid-mid]]')); - } +Assert.message = helpers.try(async (request, res, next) => { + if ( + !isFinite(request.params.mid) + || !(await messaging.messageExists(request.params.mid)) + || !(await messaging.canViewMessage(request.params.mid, request.params.roomId, request.uid)) + ) { + return controllerHelpers.formatApiResponse(400, res, new Error('[[error:invalid-mid]]')); + } - next(); + next(); }); diff --git a/src/middleware/expose.js b/src/middleware/expose.js index f6251a1..8c21d08 100644 --- a/src/middleware/expose.js +++ b/src/middleware/expose.js @@ -10,40 +10,40 @@ const privileges = require('../privileges'); const utils = require('../utils'); module.exports = function (middleware) { - middleware.exposeAdmin = async (req, res, next) => { - // Unlike `requireAdmin`, this middleware just checks the uid, and sets `isAdmin` in `res.locals` - res.locals.isAdmin = false; - - if (!req.user) { - return next(); - } - - res.locals.isAdmin = await user.isAdministrator(req.user.uid); - next(); - }; - - middleware.exposePrivileges = async (req, res, next) => { - // Exposes a hash of user's ranks (admin, gmod, etc.) - const hash = await utils.promiseParallel({ - isAdmin: user.isAdministrator(req.user.uid), - isGmod: user.isGlobalModerator(req.user.uid), - isPrivileged: user.isPrivileged(req.user.uid), - }); - - if (req.params.uid) { - hash.isSelf = parseInt(req.params.uid, 10) === req.user.uid; - } - - res.locals.privileges = hash; - next(); - }; - - middleware.exposePrivilegeSet = async (req, res, next) => { - // Exposes a user's global/admin privilege set - res.locals.privileges = { - ...await privileges.global.get(req.user.uid), - ...await privileges.admin.get(req.user.uid), - }; - next(); - }; + middleware.exposeAdmin = async (request, res, next) => { + // Unlike `requireAdmin`, this middleware just checks the uid, and sets `isAdmin` in `res.locals` + res.locals.isAdmin = false; + + if (!request.user) { + return next(); + } + + res.locals.isAdmin = await user.isAdministrator(request.user.uid); + next(); + }; + + middleware.exposePrivileges = async (request, res, next) => { + // Exposes a hash of user's ranks (admin, gmod, etc.) + const hash = await utils.promiseParallel({ + isAdmin: user.isAdministrator(request.user.uid), + isGmod: user.isGlobalModerator(request.user.uid), + isPrivileged: user.isPrivileged(request.user.uid), + }); + + if (request.params.uid) { + hash.isSelf = Number.parseInt(request.params.uid, 10) === request.user.uid; + } + + res.locals.privileges = hash; + next(); + }; + + middleware.exposePrivilegeSet = async (request, res, next) => { + // Exposes a user's global/admin privilege set + res.locals.privileges = { + ...await privileges.global.get(request.user.uid), + ...await privileges.admin.get(request.user.uid), + }; + next(); + }; }; diff --git a/src/middleware/header.js b/src/middleware/header.js index 54513ca..14f559e 100644 --- a/src/middleware/header.js +++ b/src/middleware/header.js @@ -1,11 +1,10 @@ 'use strict'; +const util = require('node:util'); const nconf = require('nconf'); const jsesc = require('jsesc'); const _ = require('lodash'); const validator = require('validator'); -const util = require('util'); - const user = require('../user'); const topics = require('../topics'); const messaging = require('../messaging'); @@ -20,245 +19,248 @@ const utils = require('../utils'); const helpers = require('./helpers'); const controllers = { - api: require('../controllers/api'), - helpers: require('../controllers/helpers'), + api: require('../controllers/api'), + helpers: require('../controllers/helpers'), }; const middleware = module.exports; const relative_path = nconf.get('relative_path'); -middleware.buildHeader = helpers.try(async (req, res, next) => { - res.locals.renderHeader = true; - res.locals.isAPI = false; - if (req.method === 'GET') { - await require('./index').applyCSRFasync(req, res); - } - const [config, canLoginIfBanned] = await Promise.all([ - controllers.api.loadConfig(req), - user.bans.canLoginIfBanned(req.uid), - plugins.hooks.fire('filter:middleware.buildHeader', { req: req, locals: res.locals }), - ]); - - if (!canLoginIfBanned && req.loggedIn) { - req.logout(() => { - res.redirect('/'); - }); - return; - } - - res.locals.config = config; - next(); +middleware.buildHeader = helpers.try(async (request, res, next) => { + res.locals.renderHeader = true; + res.locals.isAPI = false; + if (request.method === 'GET') { + await require('./index').applyCSRFasync(request, res); + } + + const [config, canLoginIfBanned] = await Promise.all([ + controllers.api.loadConfig(request), + user.bans.canLoginIfBanned(request.uid), + plugins.hooks.fire('filter:middleware.buildHeader', {req: request, locals: res.locals}), + ]); + + if (!canLoginIfBanned && request.loggedIn) { + request.logout(() => { + res.redirect('/'); + }); + return; + } + + res.locals.config = config; + next(); }); middleware.buildHeaderAsync = util.promisify(middleware.buildHeader); -middleware.renderHeader = async function renderHeader(req, res, data) { - const registrationType = meta.config.registrationType || 'normal'; - res.locals.config = res.locals.config || {}; - const templateValues = { - title: meta.config.title || '', - 'title:url': meta.config['title:url'] || '', - description: meta.config.description || '', - 'cache-buster': meta.config['cache-buster'] || '', - 'brand:logo': meta.config['brand:logo'] || '', - 'brand:logo:url': meta.config['brand:logo:url'] || '', - 'brand:logo:alt': meta.config['brand:logo:alt'] || '', - 'brand:logo:display': meta.config['brand:logo'] ? '' : 'hide', - allowRegistration: registrationType === 'normal', - searchEnabled: plugins.hooks.hasListeners('filter:search.query'), - postQueueEnabled: !!meta.config.postQueue, - config: res.locals.config, - relative_path, - bodyClass: data.bodyClass, - }; - - templateValues.configJSON = jsesc(JSON.stringify(res.locals.config), { isScriptContext: true }); - - const results = await utils.promiseParallel({ - isAdmin: user.isAdministrator(req.uid), - isGlobalMod: user.isGlobalModerator(req.uid), - isModerator: user.isModeratorOfAnyCategory(req.uid), - privileges: privileges.global.get(req.uid), - user: user.getUserData(req.uid), - isEmailConfirmSent: req.uid <= 0 ? false : await user.email.isValidationPending(req.uid), - languageDirection: translator.translate('[[language:dir]]', res.locals.config.userLang), - timeagoCode: languages.userTimeagoCode(res.locals.config.userLang), - browserTitle: translator.translate(controllers.helpers.buildTitle(translator.unescape(data.title))), - navigation: navigation.get(req.uid), - }); - - const unreadData = { - '': {}, - new: {}, - watched: {}, - unreplied: {}, - }; - - results.user.unreadData = unreadData; - results.user.isAdmin = results.isAdmin; - results.user.isGlobalMod = results.isGlobalMod; - results.user.isMod = !!results.isModerator; - results.user.privileges = results.privileges; - results.user.timeagoCode = results.timeagoCode; - results.user[results.user.status] = true; - - results.user.email = String(results.user.email); - results.user['email:confirmed'] = results.user['email:confirmed'] === 1; - results.user.isEmailConfirmSent = !!results.isEmailConfirmSent; - - templateValues.bootswatchSkin = (parseInt(meta.config.disableCustomUserSkins, 10) !== 1 ? res.locals.config.bootswatchSkin : '') || meta.config.bootswatchSkin || ''; - templateValues.browserTitle = results.browserTitle; - ({ - navigation: templateValues.navigation, - unreadCount: templateValues.unreadCount, - } = await appendUnreadCounts({ - uid: req.uid, - query: req.query, - navigation: results.navigation, - unreadData, - })); - templateValues.isAdmin = results.user.isAdmin; - templateValues.isGlobalMod = results.user.isGlobalMod; - templateValues.showModMenu = results.user.isAdmin || results.user.isGlobalMod || results.user.isMod; - templateValues.canChat = results.privileges.chat && meta.config.disableChat !== 1; - templateValues.user = results.user; - templateValues.userJSON = jsesc(JSON.stringify(results.user), { isScriptContext: true }); - templateValues.useCustomCSS = meta.config.useCustomCSS && meta.config.customCSS; - templateValues.customCSS = templateValues.useCustomCSS ? (meta.config.renderedCustomCSS || '') : ''; - templateValues.useCustomHTML = meta.config.useCustomHTML; - templateValues.customHTML = templateValues.useCustomHTML ? meta.config.customHTML : ''; - templateValues.maintenanceHeader = meta.config.maintenanceMode && !results.isAdmin; - templateValues.defaultLang = meta.config.defaultLang || 'en-GB'; - templateValues.userLang = res.locals.config.userLang; - templateValues.languageDirection = results.languageDirection; - if (req.query.noScriptMessage) { - templateValues.noScriptMessage = validator.escape(String(req.query.noScriptMessage)); - } - - templateValues.template = { name: res.locals.template }; - templateValues.template[res.locals.template] = true; - - if (data.hasOwnProperty('_header')) { - templateValues.metaTags = data._header.tags.meta; - templateValues.linkTags = data._header.tags.link; - } - - if (req.route && req.route.path === '/') { - modifyTitle(templateValues); - } - - const hookReturn = await plugins.hooks.fire('filter:middleware.renderHeader', { - req: req, - res: res, - templateValues: templateValues, - data: data, - }); - - return await req.app.renderAsync('header', hookReturn.templateValues); +middleware.renderHeader = async function renderHeader(request, res, data) { + const registrationType = meta.config.registrationType || 'normal'; + res.locals.config = res.locals.config || {}; + const templateValues = { + title: meta.config.title || '', + 'title:url': meta.config['title:url'] || '', + description: meta.config.description || '', + 'cache-buster': meta.config['cache-buster'] || '', + 'brand:logo': meta.config['brand:logo'] || '', + 'brand:logo:url': meta.config['brand:logo:url'] || '', + 'brand:logo:alt': meta.config['brand:logo:alt'] || '', + 'brand:logo:display': meta.config['brand:logo'] ? '' : 'hide', + allowRegistration: registrationType === 'normal', + searchEnabled: plugins.hooks.hasListeners('filter:search.query'), + postQueueEnabled: Boolean(meta.config.postQueue), + config: res.locals.config, + relative_path, + bodyClass: data.bodyClass, + }; + + templateValues.configJSON = jsesc(JSON.stringify(res.locals.config), {isScriptContext: true}); + + const results = await utils.promiseParallel({ + isAdmin: user.isAdministrator(request.uid), + isGlobalMod: user.isGlobalModerator(request.uid), + isModerator: user.isModeratorOfAnyCategory(request.uid), + privileges: privileges.global.get(request.uid), + user: user.getUserData(request.uid), + isEmailConfirmSent: request.uid <= 0 ? false : await user.email.isValidationPending(request.uid), + languageDirection: translator.translate('[[language:dir]]', res.locals.config.userLang), + timeagoCode: languages.userTimeagoCode(res.locals.config.userLang), + browserTitle: translator.translate(controllers.helpers.buildTitle(translator.unescape(data.title))), + navigation: navigation.get(request.uid), + }); + + const unreadData = { + '': {}, + new: {}, + watched: {}, + unreplied: {}, + }; + + results.user.unreadData = unreadData; + results.user.isAdmin = results.isAdmin; + results.user.isGlobalMod = results.isGlobalMod; + results.user.isMod = Boolean(results.isModerator); + results.user.privileges = results.privileges; + results.user.timeagoCode = results.timeagoCode; + results.user[results.user.status] = true; + + results.user.email = String(results.user.email); + results.user['email:confirmed'] = results.user['email:confirmed'] === 1; + results.user.isEmailConfirmSent = Boolean(results.isEmailConfirmSent); + + templateValues.bootswatchSkin = (Number.parseInt(meta.config.disableCustomUserSkins, 10) === 1 ? '' : res.locals.config.bootswatchSkin) || meta.config.bootswatchSkin || ''; + templateValues.browserTitle = results.browserTitle; + ({ + navigation: templateValues.navigation, + unreadCount: templateValues.unreadCount, + } = await appendUnreadCounts({ + uid: request.uid, + query: request.query, + navigation: results.navigation, + unreadData, + })); + templateValues.isAdmin = results.user.isAdmin; + templateValues.isGlobalMod = results.user.isGlobalMod; + templateValues.showModMenu = results.user.isAdmin || results.user.isGlobalMod || results.user.isMod; + templateValues.canChat = results.privileges.chat && meta.config.disableChat !== 1; + templateValues.user = results.user; + templateValues.userJSON = jsesc(JSON.stringify(results.user), {isScriptContext: true}); + templateValues.useCustomCSS = meta.config.useCustomCSS && meta.config.customCSS; + templateValues.customCSS = templateValues.useCustomCSS ? (meta.config.renderedCustomCSS || '') : ''; + templateValues.useCustomHTML = meta.config.useCustomHTML; + templateValues.customHTML = templateValues.useCustomHTML ? meta.config.customHTML : ''; + templateValues.maintenanceHeader = meta.config.maintenanceMode && !results.isAdmin; + templateValues.defaultLang = meta.config.defaultLang || 'en-GB'; + templateValues.userLang = res.locals.config.userLang; + templateValues.languageDirection = results.languageDirection; + if (request.query.noScriptMessage) { + templateValues.noScriptMessage = validator.escape(String(request.query.noScriptMessage)); + } + + templateValues.template = {name: res.locals.template}; + templateValues.template[res.locals.template] = true; + + if (data.hasOwnProperty('_header')) { + templateValues.metaTags = data._header.tags.meta; + templateValues.linkTags = data._header.tags.link; + } + + if (request.route && request.route.path === '/') { + modifyTitle(templateValues); + } + + const hookReturn = await plugins.hooks.fire('filter:middleware.renderHeader', { + req: request, + res, + templateValues, + data, + }); + + return await request.app.renderAsync('header', hookReturn.templateValues); }; -async function appendUnreadCounts({ uid, navigation, unreadData, query }) { - const originalRoutes = navigation.map(nav => nav.originalRoute); - const calls = { - unreadData: topics.getUnreadData({ uid: uid, query: query }), - unreadChatCount: messaging.getUnreadCount(uid), - unreadNotificationCount: user.notifications.getUnreadCount(uid), - unreadFlagCount: (async function () { - if (originalRoutes.includes('/flags') && await user.isPrivileged(uid)) { - return flags.getCount({ - uid, - query, - filters: { - quick: 'unresolved', - cid: (await user.isAdminOrGlobalMod(uid)) ? [] : (await user.getModeratedCids(uid)), - }, - }); - } - return 0; - }()), - }; - const results = await utils.promiseParallel(calls); - - const unreadCounts = results.unreadData.counts; - const unreadCount = { - topic: unreadCounts[''] || 0, - newTopic: unreadCounts.new || 0, - watchedTopic: unreadCounts.watched || 0, - unrepliedTopic: unreadCounts.unreplied || 0, - mobileUnread: 0, - unreadUrl: '/unread', - chat: results.unreadChatCount || 0, - notification: results.unreadNotificationCount || 0, - flags: results.unreadFlagCount || 0, - }; - - Object.keys(unreadCount).forEach((key) => { - if (unreadCount[key] > 99) { - unreadCount[key] = '99+'; - } - }); - - const { tidsByFilter } = results.unreadData; - navigation = navigation.map((item) => { - function modifyNavItem(item, route, filter, content) { - if (item && item.originalRoute === route) { - unreadData[filter] = _.zipObject(tidsByFilter[filter], tidsByFilter[filter].map(() => true)); - item.content = content; - unreadCount.mobileUnread = content; - unreadCount.unreadUrl = route; - if (unreadCounts[filter] > 0) { - item.iconClass += ' unread-count'; - } - } - } - modifyNavItem(item, '/unread', '', unreadCount.topic); - modifyNavItem(item, '/unread?filter=new', 'new', unreadCount.newTopic); - modifyNavItem(item, '/unread?filter=watched', 'watched', unreadCount.watchedTopic); - modifyNavItem(item, '/unread?filter=unreplied', 'unreplied', unreadCount.unrepliedTopic); - - ['flags'].forEach((prop) => { - if (item && item.originalRoute === `/${prop}` && unreadCount[prop] > 0) { - item.iconClass += ' unread-count'; - item.content = unreadCount.flags; - } - }); - - return item; - }); - - return { navigation, unreadCount }; +async function appendUnreadCounts({uid, navigation, unreadData, query}) { + const originalRoutes = new Set(navigation.map(nav => nav.originalRoute)); + const calls = { + unreadData: topics.getUnreadData({uid, query}), + unreadChatCount: messaging.getUnreadCount(uid), + unreadNotificationCount: user.notifications.getUnreadCount(uid), + unreadFlagCount: (async function () { + if (originalRoutes.has('/flags') && await user.isPrivileged(uid)) { + return flags.getCount({ + uid, + query, + filters: { + quick: 'unresolved', + cid: (await user.isAdminOrGlobalMod(uid)) ? [] : (await user.getModeratedCids(uid)), + }, + }); + } + + return 0; + })(), + }; + const results = await utils.promiseParallel(calls); + + const unreadCounts = results.unreadData.counts; + const unreadCount = { + topic: unreadCounts[''] || 0, + newTopic: unreadCounts.new || 0, + watchedTopic: unreadCounts.watched || 0, + unrepliedTopic: unreadCounts.unreplied || 0, + mobileUnread: 0, + unreadUrl: '/unread', + chat: results.unreadChatCount || 0, + notification: results.unreadNotificationCount || 0, + flags: results.unreadFlagCount || 0, + }; + + for (const key of Object.keys(unreadCount)) { + if (unreadCount[key] > 99) { + unreadCount[key] = '99+'; + } + } + + const {tidsByFilter} = results.unreadData; + navigation = navigation.map(item => { + function modifyNavItem(item, route, filter, content) { + if (item && item.originalRoute === route) { + unreadData[filter] = _.zipObject(tidsByFilter[filter], tidsByFilter[filter].map(() => true)); + item.content = content; + unreadCount.mobileUnread = content; + unreadCount.unreadUrl = route; + if (unreadCounts[filter] > 0) { + item.iconClass += ' unread-count'; + } + } + } + + modifyNavItem(item, '/unread', '', unreadCount.topic); + modifyNavItem(item, '/unread?filter=new', 'new', unreadCount.newTopic); + modifyNavItem(item, '/unread?filter=watched', 'watched', unreadCount.watchedTopic); + modifyNavItem(item, '/unread?filter=unreplied', 'unreplied', unreadCount.unrepliedTopic); + + for (const property of ['flags']) { + if (item && item.originalRoute === `/${property}` && unreadCount[property] > 0) { + item.iconClass += ' unread-count'; + item.content = unreadCount.flags; + } + } + + return item; + }); + + return {navigation, unreadCount}; } -middleware.renderFooter = async function renderFooter(req, res, templateValues) { - const data = await plugins.hooks.fire('filter:middleware.renderFooter', { - req: req, - res: res, - templateValues: templateValues, - }); +middleware.renderFooter = async function renderFooter(request, res, templateValues) { + const data = await plugins.hooks.fire('filter:middleware.renderFooter', { + req: request, + res, + templateValues, + }); - const scripts = await plugins.hooks.fire('filter:scripts.get', []); + const scripts = await plugins.hooks.fire('filter:scripts.get', []); - data.templateValues.scripts = scripts.map(script => ({ src: script })); + data.templateValues.scripts = scripts.map(script => ({src: script})); - data.templateValues.useCustomJS = meta.config.useCustomJS; - data.templateValues.customJS = data.templateValues.useCustomJS ? meta.config.customJS : ''; - data.templateValues.isSpider = req.uid === -1; + data.templateValues.useCustomJS = meta.config.useCustomJS; + data.templateValues.customJS = data.templateValues.useCustomJS ? meta.config.customJS : ''; + data.templateValues.isSpider = request.uid === -1; - return await req.app.renderAsync('footer', data.templateValues); + return await request.app.renderAsync('footer', data.templateValues); }; -function modifyTitle(obj) { - const title = controllers.helpers.buildTitle(meta.config.homePageTitle || '[[pages:home]]'); - obj.browserTitle = title; +function modifyTitle(object) { + const title = controllers.helpers.buildTitle(meta.config.homePageTitle || '[[pages:home]]'); + object.browserTitle = title; - if (obj.metaTags) { - obj.metaTags.forEach((tag, i) => { - if (tag.property === 'og:title') { - obj.metaTags[i].content = title; - } - }); - } + if (object.metaTags) { + for (const [i, tag] of object.metaTags.entries()) { + if (tag.property === 'og:title') { + object.metaTags[i].content = title; + } + } + } - return title; + return title; } diff --git a/src/middleware/headers.js b/src/middleware/headers.js index 014e899..8405ab3 100644 --- a/src/middleware/headers.js +++ b/src/middleware/headers.js @@ -1,116 +1,118 @@ 'use strict'; -const os = require('os'); +const os = require('node:os'); const winston = require('winston'); const _ = require('lodash'); - const meta = require('../meta'); const languages = require('../languages'); -const helpers = require('./helpers'); const plugins = require('../plugins'); +const helpers = require('./helpers'); module.exports = function (middleware) { - middleware.addHeaders = helpers.try((req, res, next) => { - const headers = { - 'X-Powered-By': encodeURI(meta.config['powered-by'] || 'NodeBB'), - 'Access-Control-Allow-Methods': encodeURI(meta.config['access-control-allow-methods'] || ''), - 'Access-Control-Allow-Headers': encodeURI(meta.config['access-control-allow-headers'] || ''), - }; - - if (meta.config['csp-frame-ancestors']) { - headers['Content-Security-Policy'] = `frame-ancestors ${meta.config['csp-frame-ancestors']}`; - if (meta.config['csp-frame-ancestors'] === '\'none\'') { - headers['X-Frame-Options'] = 'DENY'; - } - } else { - headers['Content-Security-Policy'] = 'frame-ancestors \'self\''; - headers['X-Frame-Options'] = 'SAMEORIGIN'; - } - - if (meta.config['access-control-allow-origin']) { - let origins = meta.config['access-control-allow-origin'].split(','); - origins = origins.map(origin => origin && origin.trim()); - - if (origins.includes(req.get('origin'))) { - headers['Access-Control-Allow-Origin'] = encodeURI(req.get('origin')); - headers.Vary = headers.Vary ? `${headers.Vary}, Origin` : 'Origin'; - } - } - - if (meta.config['access-control-allow-origin-regex']) { - let originsRegex = meta.config['access-control-allow-origin-regex'].split(','); - originsRegex = originsRegex.map((origin) => { - try { - origin = new RegExp(origin.trim()); - } catch (err) { - winston.error(`[middleware.addHeaders] Invalid RegExp For access-control-allow-origin ${origin}`); - origin = null; - } - return origin; - }); - - originsRegex.forEach((regex) => { - if (regex && regex.test(req.get('origin'))) { - headers['Access-Control-Allow-Origin'] = encodeURI(req.get('origin')); - headers.Vary = headers.Vary ? `${headers.Vary}, Origin` : 'Origin'; - } - }); - } - - if (meta.config['permissions-policy']) { - headers['Permissions-Policy'] = meta.config['permissions-policy']; - } - - if (meta.config['access-control-allow-credentials']) { - headers['Access-Control-Allow-Credentials'] = meta.config['access-control-allow-credentials']; - } - - if (process.env.NODE_ENV === 'development') { - headers['X-Upstream-Hostname'] = os.hostname(); - } - - for (const [key, value] of Object.entries(headers)) { - if (value) { - res.setHeader(key, value); - } - } - - next(); - }); - - middleware.autoLocale = helpers.try(async (req, res, next) => { - await plugins.hooks.fire('filter:middleware.autoLocale', { - req: req, - res: res, - }); - if (req.query.lang) { - const langs = await listCodes(); - if (!langs.includes(req.query.lang)) { - req.query.lang = meta.config.defaultLang; - } - return next(); - } - - if (meta.config.autoDetectLang && req.uid === 0) { - const langs = await listCodes(); - const lang = req.acceptsLanguages(langs); - if (!lang) { - return next(); - } - req.query.lang = lang; - } - - next(); - }); - - async function listCodes() { - const defaultLang = meta.config.defaultLang || 'en-GB'; - try { - const codes = await languages.listCodes(); - return _.uniq([defaultLang, ...codes]); - } catch (err) { - winston.error(`[middleware/autoLocale] Could not retrieve languages codes list! ${err.stack}`); - return [defaultLang]; - } - } + middleware.addHeaders = helpers.try((request, res, next) => { + const headers = { + 'X-Powered-By': encodeURI(meta.config['powered-by'] || 'NodeBB'), + 'Access-Control-Allow-Methods': encodeURI(meta.config['access-control-allow-methods'] || ''), + 'Access-Control-Allow-Headers': encodeURI(meta.config['access-control-allow-headers'] || ''), + }; + + if (meta.config['csp-frame-ancestors']) { + headers['Content-Security-Policy'] = `frame-ancestors ${meta.config['csp-frame-ancestors']}`; + if (meta.config['csp-frame-ancestors'] === '\'none\'') { + headers['X-Frame-Options'] = 'DENY'; + } + } else { + headers['Content-Security-Policy'] = 'frame-ancestors \'self\''; + headers['X-Frame-Options'] = 'SAMEORIGIN'; + } + + if (meta.config['access-control-allow-origin']) { + let origins = meta.config['access-control-allow-origin'].split(','); + origins = origins.map(origin => origin && origin.trim()); + + if (origins.includes(request.get('origin'))) { + headers['Access-Control-Allow-Origin'] = encodeURI(request.get('origin')); + headers.Vary = headers.Vary ? `${headers.Vary}, Origin` : 'Origin'; + } + } + + if (meta.config['access-control-allow-origin-regex']) { + let originsRegex = meta.config['access-control-allow-origin-regex'].split(','); + originsRegex = originsRegex.map(origin => { + try { + origin = new RegExp(origin.trim()); + } catch { + winston.error(`[middleware.addHeaders] Invalid RegExp For access-control-allow-origin ${origin}`); + origin = null; + } + + return origin; + }); + + for (const regex of originsRegex) { + if (regex && regex.test(request.get('origin'))) { + headers['Access-Control-Allow-Origin'] = encodeURI(request.get('origin')); + headers.Vary = headers.Vary ? `${headers.Vary}, Origin` : 'Origin'; + } + } + } + + if (meta.config['permissions-policy']) { + headers['Permissions-Policy'] = meta.config['permissions-policy']; + } + + if (meta.config['access-control-allow-credentials']) { + headers['Access-Control-Allow-Credentials'] = meta.config['access-control-allow-credentials']; + } + + if (process.env.NODE_ENV === 'development') { + headers['X-Upstream-Hostname'] = os.hostname(); + } + + for (const [key, value] of Object.entries(headers)) { + if (value) { + res.setHeader(key, value); + } + } + + next(); + }); + + middleware.autoLocale = helpers.try(async (request, res, next) => { + await plugins.hooks.fire('filter:middleware.autoLocale', { + req: request, + res, + }); + if (request.query.lang) { + const langs = await listCodes(); + if (!langs.includes(request.query.lang)) { + request.query.lang = meta.config.defaultLang; + } + + return next(); + } + + if (meta.config.autoDetectLang && request.uid === 0) { + const langs = await listCodes(); + const lang = request.acceptsLanguages(langs); + if (!lang) { + return next(); + } + + request.query.lang = lang; + } + + next(); + }); + + async function listCodes() { + const defaultLang = meta.config.defaultLang || 'en-GB'; + try { + const codes = await languages.listCodes(); + return _.uniq([defaultLang, ...codes]); + } catch (error) { + winston.error(`[middleware/autoLocale] Could not retrieve languages codes list! ${error.stack}`); + return [defaultLang]; + } + } }; diff --git a/src/middleware/helpers.js b/src/middleware/helpers.js index 0a78e44..a180a31 100644 --- a/src/middleware/helpers.js +++ b/src/middleware/helpers.js @@ -3,66 +3,68 @@ const winston = require('winston'); const validator = require('validator'); const slugify = require('../slugify'); - const meta = require('../meta'); const helpers = module.exports; helpers.try = function (middleware) { - if (middleware && middleware.constructor && middleware.constructor.name === 'AsyncFunction') { - return async function (req, res, next) { - try { - await middleware(req, res, next); - } catch (err) { - next(err); - } - }; - } - return function (req, res, next) { - try { - middleware(req, res, next); - } catch (err) { - next(err); - } - }; + if (middleware && middleware.constructor && middleware.constructor.name === 'AsyncFunction') { + return async function (request, res, next) { + try { + await middleware(request, res, next); + } catch (error) { + next(error); + } + }; + } + + return function (request, res, next) { + try { + middleware(request, res, next); + } catch (error) { + next(error); + } + }; }; -helpers.buildBodyClass = function (req, res, templateData = {}) { - const clean = req.path.replace(/^\/api/, '').replace(/^\/|\/$/g, ''); - const parts = clean.split('/').slice(0, 3); - parts.forEach((p, index) => { - try { - p = slugify(decodeURIComponent(p)); - } catch (err) { - winston.error(`Error decoding URI: ${p}`); - winston.error(err.stack); - p = ''; - } - p = validator.escape(String(p)); - parts[index] = index ? `${parts[0]}-${p}` : `page-${p || 'home'}`; - }); +helpers.buildBodyClass = function (request, res, templateData = {}) { + const clean = request.path.replace(/^\/api/, '').replaceAll(/^\/|\/$/g, ''); + const parts = clean.split('/').slice(0, 3); + for (let [index, p] of parts.entries()) { + try { + p = slugify(decodeURIComponent(p)); + } catch (error) { + winston.error(`Error decoding URI: ${p}`); + winston.error(error.stack); + p = ''; + } + + p = validator.escape(String(p)); + parts[index] = index ? `${parts[0]}-${p}` : `page-${p || 'home'}`; + } + + if (templateData.template && templateData.template.topic) { + parts.push(`page-topic-category-${templateData.category.cid}`); + parts.push(`page-topic-category-${slugify(templateData.category.name)}`); + } - if (templateData.template && templateData.template.topic) { - parts.push(`page-topic-category-${templateData.category.cid}`); - parts.push(`page-topic-category-${slugify(templateData.category.name)}`); - } + if (Array.isArray(templateData.breadcrumbs)) { + for (const crumb of templateData.breadcrumbs) { + if (crumb && crumb.hasOwnProperty('cid')) { + parts.push(`parent-category-${crumb.cid}`); + } + } + } - if (Array.isArray(templateData.breadcrumbs)) { - templateData.breadcrumbs.forEach((crumb) => { - if (crumb && crumb.hasOwnProperty('cid')) { - parts.push(`parent-category-${crumb.cid}`); - } - }); - } + parts.push(`page-status-${res.statusCode}`); - parts.push(`page-status-${res.statusCode}`); + parts.push(`theme-${meta.config['theme:id'].split('-')[2]}`); - parts.push(`theme-${meta.config['theme:id'].split('-')[2]}`); + if (request.loggedIn) { + parts.push('user-loggedin'); + } else { + parts.push('user-guest'); + } - if (req.loggedIn) { - parts.push('user-loggedin'); - } else { - parts.push('user-guest'); - } - return parts.join(' '); + return parts.join(' '); }; diff --git a/src/middleware/index.js b/src/middleware/index.js index 3a51c8e..0c8bd20 100644 --- a/src/middleware/index.js +++ b/src/middleware/index.js @@ -1,13 +1,12 @@ 'use strict'; +const path = require('node:path'); +const util = require('node:util'); const async = require('async'); -const path = require('path'); const csrf = require('csurf'); const validator = require('validator'); const nconf = require('nconf'); const toobusy = require('toobusy-js'); -const util = require('util'); - const plugins = require('../plugins'); const meta = require('../meta'); const user = require('../user'); @@ -18,12 +17,12 @@ const cacheCreate = require('../cache/lru'); const helpers = require('./helpers'); const controllers = { - api: require('../controllers/api'), - helpers: require('../controllers/helpers'), + api: require('../controllers/api'), + helpers: require('../controllers/helpers'), }; const delayCache = cacheCreate({ - ttl: 1000 * 60, + ttl: 1000 * 60, }); const middleware = module.exports; @@ -31,31 +30,32 @@ const middleware = module.exports; const relative_path = nconf.get('relative_path'); middleware.regexes = { - timestampedUpload: /^\d+-.+$/, + timestampedUpload: /^\d+-.+$/, }; const csrfMiddleware = csrf(); -middleware.applyCSRF = function (req, res, next) { - if (req.uid >= 0) { - csrfMiddleware(req, res, next); - } else { - next(); - } +middleware.applyCSRF = function (request, res, next) { + if (request.uid >= 0) { + csrfMiddleware(request, res, next); + } else { + next(); + } }; + middleware.applyCSRFasync = util.promisify(middleware.applyCSRF); -middleware.ensureLoggedIn = (req, res, next) => { - if (!req.loggedIn) { - return controllers.helpers.notAllowed(req, res); - } +middleware.ensureLoggedIn = (request, res, next) => { + if (!request.loggedIn) { + return controllers.helpers.notAllowed(request, res); + } - setImmediate(next); + setImmediate(next); }; Object.assign(middleware, { - admin: require('./admin'), - ...require('./header'), + admin: require('./admin'), + ...require('./header'), }); require('./render')(middleware); require('./maintenance')(middleware); @@ -65,190 +65,193 @@ require('./headers')(middleware); require('./expose')(middleware); middleware.assert = require('./assert'); -middleware.stripLeadingSlashes = function stripLeadingSlashes(req, res, next) { - const target = req.originalUrl.replace(relative_path, ''); - if (target.startsWith('//')) { - return res.redirect(relative_path + target.replace(/^\/+/, '/')); - } - next(); +middleware.stripLeadingSlashes = function stripLeadingSlashes(request, res, next) { + const target = request.originalUrl.replace(relative_path, ''); + if (target.startsWith('//')) { + return res.redirect(relative_path + target.replace(/^\/+/, '/')); + } + + next(); }; -middleware.pageView = helpers.try(async (req, res, next) => { - if (req.loggedIn) { - await Promise.all([ - user.updateOnlineUsers(req.uid), - user.updateLastOnlineTime(req.uid), - ]); - } - next(); - await analytics.pageView({ ip: req.ip, uid: req.uid }); - plugins.hooks.fire('action:middleware.pageView', { req: req }); +middleware.pageView = helpers.try(async (request, res, next) => { + if (request.loggedIn) { + await Promise.all([ + user.updateOnlineUsers(request.uid), + user.updateLastOnlineTime(request.uid), + ]); + } + + next(); + await analytics.pageView({ip: request.ip, uid: request.uid}); + plugins.hooks.fire('action:middleware.pageView', {req: request}); }); -middleware.pluginHooks = helpers.try(async (req, res, next) => { - // TODO: Deprecate in v2.0 - await async.each(plugins.loadedHooks['filter:router.page'] || [], (hookObj, next) => { - hookObj.method(req, res, next); - }); +middleware.pluginHooks = helpers.try(async (request, res, next) => { + // TODO: Deprecate in v2.0 + await async.each(plugins.loadedHooks['filter:router.page'] || [], (hookObject, next) => { + hookObject.method(request, res, next); + }); - await plugins.hooks.fire('response:router.page', { - req: req, - res: res, - }); + await plugins.hooks.fire('response:router.page', { + req: request, + res, + }); - if (!res.headersSent) { - next(); - } + if (!res.headersSent) { + next(); + } }); -middleware.validateFiles = function validateFiles(req, res, next) { - if (!Array.isArray(req.files.files) || !req.files.files.length) { - return next(new Error(['[[error:invalid-files]]'])); - } +middleware.validateFiles = function validateFiles(request, res, next) { + if (!Array.isArray(request.files.files) || request.files.files.length === 0) { + return next(new Error(['[[error:invalid-files]]'])); + } - next(); + next(); }; -middleware.prepareAPI = function prepareAPI(req, res, next) { - res.locals.isAPI = true; - next(); +middleware.prepareAPI = function prepareAPI(request, res, next) { + res.locals.isAPI = true; + next(); }; -middleware.routeTouchIcon = function routeTouchIcon(req, res) { - if (meta.config['brand:touchIcon'] && validator.isURL(meta.config['brand:touchIcon'])) { - return res.redirect(meta.config['brand:touchIcon']); - } - let iconPath = ''; - if (meta.config['brand:touchIcon']) { - iconPath = path.join(nconf.get('upload_path'), meta.config['brand:touchIcon'].replace(/assets\/uploads/, '')); - } else { - iconPath = path.join(nconf.get('base_dir'), 'public/images/touch/512.png'); - } - - return res.sendFile(iconPath, { - maxAge: req.app.enabled('cache') ? 5184000000 : 0, - }); +middleware.routeTouchIcon = function routeTouchIcon(request, res) { + if (meta.config['brand:touchIcon'] && validator.isURL(meta.config['brand:touchIcon'])) { + return res.redirect(meta.config['brand:touchIcon']); + } + + let iconPath = ''; + iconPath = meta.config['brand:touchIcon'] ? path.join(nconf.get('upload_path'), meta.config['brand:touchIcon'].replace(/assets\/uploads/, '')) : path.join(nconf.get('base_dir'), 'public/images/touch/512.png'); + + return res.sendFile(iconPath, { + maxAge: request.app.enabled('cache') ? 5_184_000_000 : 0, + }); }; -middleware.privateTagListing = helpers.try(async (req, res, next) => { - const canView = await privileges.global.can('view:tags', req.uid); - if (!canView) { - return controllers.helpers.notAllowed(req, res); - } - next(); +middleware.privateTagListing = helpers.try(async (request, res, next) => { + const canView = await privileges.global.can('view:tags', request.uid); + if (!canView) { + return controllers.helpers.notAllowed(request, res); + } + + next(); }); -middleware.exposeGroupName = helpers.try(async (req, res, next) => { - await expose('groupName', groups.getGroupNameByGroupSlug, 'slug', req, res, next); +middleware.exposeGroupName = helpers.try(async (request, res, next) => { + await expose('groupName', groups.getGroupNameByGroupSlug, 'slug', request, res, next); }); -middleware.exposeUid = helpers.try(async (req, res, next) => { - await expose('uid', user.getUidByUserslug, 'userslug', req, res, next); +middleware.exposeUid = helpers.try(async (request, res, next) => { + await expose('uid', user.getUidByUserslug, 'userslug', request, res, next); }); -async function expose(exposedField, method, field, req, res, next) { - if (!req.params.hasOwnProperty(field)) { - return next(); - } - res.locals[exposedField] = await method(req.params[field]); - next(); +async function expose(exposedField, method, field, request, res, next) { + if (!request.params.hasOwnProperty(field)) { + return next(); + } + + res.locals[exposedField] = await method(request.params[field]); + next(); } -middleware.privateUploads = function privateUploads(req, res, next) { - if (req.loggedIn || !meta.config.privateUploads) { - return next(); - } - - if (req.path.startsWith(`${nconf.get('relative_path')}/assets/uploads/files`)) { - const extensions = (meta.config.privateUploadsExtensions || '').split(',').filter(Boolean); - let ext = path.extname(req.path); - ext = ext ? ext.replace(/^\./, '') : ext; - if (!extensions.length || extensions.includes(ext)) { - return res.status(403).json('not-allowed'); - } - } - next(); +middleware.privateUploads = function privateUploads(request, res, next) { + if (request.loggedIn || !meta.config.privateUploads) { + return next(); + } + + if (request.path.startsWith(`${nconf.get('relative_path')}/assets/uploads/files`)) { + const extensions = (meta.config.privateUploadsExtensions || '').split(',').filter(Boolean); + let extension = path.extname(request.path); + extension = extension ? extension.replace(/^\./, '') : extension; + if (extensions.length === 0 || extensions.includes(extension)) { + return res.status(403).json('not-allowed'); + } + } + + next(); }; -middleware.busyCheck = function busyCheck(req, res, next) { - if (global.env === 'production' && meta.config.eventLoopCheckEnabled && toobusy()) { - analytics.increment('errors:503'); - res.status(503).type('text/html').sendFile(path.join(__dirname, '../../public/503.html')); - } else { - setImmediate(next); - } +middleware.busyCheck = function busyCheck(request, res, next) { + if (global.env === 'production' && meta.config.eventLoopCheckEnabled && toobusy()) { + analytics.increment('errors:503'); + res.status(503).type('text/html').sendFile(path.join(__dirname, '../../public/503.html')); + } else { + setImmediate(next); + } }; -middleware.applyBlacklist = async function applyBlacklist(req, res, next) { - try { - await meta.blacklist.test(req.ip); - next(); - } catch (err) { - next(err); - } +middleware.applyBlacklist = async function applyExclude(request, res, next) { + try { + await meta.blacklist.test(request.ip); + next(); + } catch (error) { + next(error); + } }; -middleware.delayLoading = function delayLoading(req, res, next) { - // Introduces an artificial delay during load so that brute force attacks are effectively mitigated +middleware.delayLoading = function delayLoading(request, res, next) { + // Introduces an artificial delay during load so that brute force attacks are effectively mitigated + + // Add IP to cache so if too many requests are made, subsequent requests are blocked for a minute + let timesSeen = delayCache.get(request.ip) || 0; + if (timesSeen > 10) { + return res.sendStatus(429); + } - // Add IP to cache so if too many requests are made, subsequent requests are blocked for a minute - let timesSeen = delayCache.get(req.ip) || 0; - if (timesSeen > 10) { - return res.sendStatus(429); - } - delayCache.set(req.ip, timesSeen += 1); + delayCache.set(request.ip, timesSeen += 1); - setTimeout(next, 1000); + setTimeout(next, 1000); }; -middleware.buildSkinAsset = helpers.try(async (req, res, next) => { - // If this middleware is reached, a skin was requested, so it is built on-demand - const target = path.basename(req.originalUrl).match(/(client-[a-z]+)/); - if (!target) { - return next(); - } - - await plugins.prepareForBuild(['client side styles']); - const css = await meta.css.buildBundle(target[0], true); - require('../meta/minifier').killAll(); - res.status(200).type('text/css').send(css); +middleware.buildSkinAsset = helpers.try(async (request, res, next) => { + // If this middleware is reached, a skin was requested, so it is built on-demand + const target = path.basename(request.originalUrl).match(/(client-[a-z]+)/); + if (!target) { + return next(); + } + + await plugins.prepareForBuild(['client side styles']); + const css = await meta.css.buildBundle(target[0], true); + require('../meta/minifier').killAll(); + res.status(200).type('text/css').send(css); }); -middleware.addUploadHeaders = function addUploadHeaders(req, res, next) { - // Trim uploaded files' timestamps when downloading + force download if html - let basename = path.basename(req.path); - const extname = path.extname(req.path); - if (req.path.startsWith('/uploads/files/') && middleware.regexes.timestampedUpload.test(basename)) { - basename = basename.slice(14); - res.header('Content-Disposition', `${extname.startsWith('.htm') ? 'attachment' : 'inline'}; filename="${basename}"`); - } +middleware.addUploadHeaders = function addUploadHeaders(request, res, next) { + // Trim uploaded files' timestamps when downloading + force download if html + let basename = path.basename(request.path); + const extname = path.extname(request.path); + if (request.path.startsWith('/uploads/files/') && middleware.regexes.timestampedUpload.test(basename)) { + basename = basename.slice(14); + res.header('Content-Disposition', `${extname.startsWith('.htm') ? 'attachment' : 'inline'}; filename="${basename}"`); + } - next(); + next(); }; -middleware.validateAuth = helpers.try(async (req, res, next) => { - try { - await plugins.hooks.fire('static:auth.validate', { - user: res.locals.user, - strategy: res.locals.strategy, - }); - next(); - } catch (err) { - const regenerateSession = util.promisify(cb => req.session.regenerate(cb)); - await regenerateSession(); - req.uid = 0; - req.loggedIn = false; - next(err); - } +middleware.validateAuth = helpers.try(async (request, res, next) => { + try { + await plugins.hooks.fire('static:auth.validate', { + user: res.locals.user, + strategy: res.locals.strategy, + }); + next(); + } catch (error) { + const regenerateSession = util.promisify(callback => request.session.regenerate(callback)); + await regenerateSession(); + request.uid = 0; + request.loggedIn = false; + next(error); + } }); -middleware.checkRequired = function (fields, req, res, next) { - // Used in API calls to ensure that necessary parameters/data values are present - const missing = fields.filter(field => !req.body.hasOwnProperty(field)); +middleware.checkRequired = function (fields, request, res, next) { + // Used in API calls to ensure that necessary parameters/data values are present + const missing = fields.filter(field => !request.body.hasOwnProperty(field)); - if (!missing.length) { - return next(); - } + if (missing.length === 0) { + return next(); + } - controllers.helpers.formatApiResponse(400, res, new Error(`[[error:required-parameters-missing, ${missing.join(' ')}]]`)); + controllers.helpers.formatApiResponse(400, res, new Error(`[[error:required-parameters-missing, ${missing.join(' ')}]]`)); }; diff --git a/src/middleware/maintenance.js b/src/middleware/maintenance.js index 7e8b1c0..8a22a95 100644 --- a/src/middleware/maintenance.js +++ b/src/middleware/maintenance.js @@ -1,6 +1,6 @@ 'use strict'; -const util = require('util'); +const util = require('node:util'); const nconf = require('nconf'); const meta = require('../meta'); const user = require('../user'); @@ -8,39 +8,40 @@ const groups = require('../groups'); const helpers = require('./helpers'); module.exports = function (middleware) { - middleware.maintenanceMode = helpers.try(async (req, res, next) => { - if (!meta.config.maintenanceMode) { - return next(); - } - - const hooksAsync = util.promisify(middleware.pluginHooks); - await hooksAsync(req, res); - - const url = req.url.replace(nconf.get('relative_path'), ''); - if (url.startsWith('/login') || url.startsWith('/api/login')) { - return next(); - } - - const [isAdmin, isMemberOfExempt] = await Promise.all([ - user.isAdministrator(req.uid), - groups.isMemberOfAny(req.uid, meta.config.groupsExemptFromMaintenanceMode), - ]); - - if (isAdmin || isMemberOfExempt) { - return next(); - } - - res.status(meta.config.maintenanceModeStatus); - - const data = { - site_title: meta.config.title || 'NodeBB', - message: meta.config.maintenanceModeMessage, - }; - - if (res.locals.isAPI) { - return res.json(data); - } - await middleware.buildHeaderAsync(req, res); - res.render('503', data); - }); + middleware.maintenanceMode = helpers.try(async (request, res, next) => { + if (!meta.config.maintenanceMode) { + return next(); + } + + const hooksAsync = util.promisify(middleware.pluginHooks); + await hooksAsync(request, res); + + const url = request.url.replace(nconf.get('relative_path'), ''); + if (url.startsWith('/login') || url.startsWith('/api/login')) { + return next(); + } + + const [isAdmin, isMemberOfExempt] = await Promise.all([ + user.isAdministrator(request.uid), + groups.isMemberOfAny(request.uid, meta.config.groupsExemptFromMaintenanceMode), + ]); + + if (isAdmin || isMemberOfExempt) { + return next(); + } + + res.status(meta.config.maintenanceModeStatus); + + const data = { + site_title: meta.config.title || 'NodeBB', + message: meta.config.maintenanceModeMessage, + }; + + if (res.locals.isAPI) { + return res.json(data); + } + + await middleware.buildHeaderAsync(request, res); + res.render('503', data); + }); }; diff --git a/src/middleware/ratelimit.js b/src/middleware/ratelimit.js index 6c5ad57..3243ed4 100644 --- a/src/middleware/ratelimit.js +++ b/src/middleware/ratelimit.js @@ -5,28 +5,28 @@ const winston = require('winston'); const ratelimit = module.exports; const allowedCalls = 100; -const timeframe = 10000; +const timeframe = 10_000; ratelimit.isFlooding = function (socket) { - socket.callsPerSecond = socket.callsPerSecond || 0; - socket.elapsedTime = socket.elapsedTime || 0; - socket.lastCallTime = socket.lastCallTime || Date.now(); + socket.callsPerSecond = socket.callsPerSecond || 0; + socket.elapsedTime = socket.elapsedTime || 0; + socket.lastCallTime = socket.lastCallTime || Date.now(); - socket.callsPerSecond += 1; + socket.callsPerSecond += 1; - const now = Date.now(); - socket.elapsedTime += now - socket.lastCallTime; + const now = Date.now(); + socket.elapsedTime += now - socket.lastCallTime; - if (socket.callsPerSecond > allowedCalls && socket.elapsedTime < timeframe) { - winston.warn(`Flooding detected! Calls : ${socket.callsPerSecond}, Duration : ${socket.elapsedTime}`); - return true; - } + if (socket.callsPerSecond > allowedCalls && socket.elapsedTime < timeframe) { + winston.warn(`Flooding detected! Calls : ${socket.callsPerSecond}, Duration : ${socket.elapsedTime}`); + return true; + } - if (socket.elapsedTime >= timeframe) { - socket.elapsedTime = 0; - socket.callsPerSecond = 0; - } + if (socket.elapsedTime >= timeframe) { + socket.elapsedTime = 0; + socket.callsPerSecond = 0; + } - socket.lastCallTime = now; - return false; + socket.lastCallTime = now; + return false; }; diff --git a/src/middleware/render.js b/src/middleware/render.js index b68eff2..00fd6da 100644 --- a/src/middleware/render.js +++ b/src/middleware/render.js @@ -2,7 +2,6 @@ const nconf = require('nconf'); const validator = require('validator'); - const plugins = require('../plugins'); const meta = require('../meta'); const translator = require('../translator'); @@ -13,125 +12,134 @@ const helpers = require('./helpers'); const relative_path = nconf.get('relative_path'); module.exports = function (middleware) { - middleware.processRender = function processRender(req, res, next) { - // res.render post-processing, modified from here: https://gist.github.com/mrlannigan/5051687 - const { render } = res; - - res.render = async function renderOverride(template, options, fn) { - const self = this; - const { req } = this; - async function renderMethod(template, options, fn) { - options = options || {}; - if (typeof options === 'function') { - fn = options; - options = {}; - } - - options.loggedIn = req.uid > 0; - options.relative_path = relative_path; - options.template = { name: template, [template]: true }; - options.url = (req.baseUrl + req.path.replace(/^\/api/, '')); - options.bodyClass = helpers.buildBodyClass(req, res, options); - - if (req.loggedIn) { - res.set('cache-control', 'private'); - } - - const buildResult = await plugins.hooks.fire(`filter:${template}.build`, { req: req, res: res, templateData: options }); - if (res.headersSent) { - return; - } - const templateToRender = buildResult.templateData.templateToRender || template; - - const renderResult = await plugins.hooks.fire('filter:middleware.render', { req: req, res: res, templateData: buildResult.templateData }); - if (res.headersSent) { - return; - } - options = renderResult.templateData; - options._header = { - tags: await meta.tags.parse(req, renderResult, res.locals.metaTags, res.locals.linkTags), - }; - options.widgets = await widgets.render(req.uid, { - template: `${template}.tpl`, - url: options.url, - templateData: options, - req: req, - res: res, - }); - res.locals.template = template; - options._locals = undefined; - - if (res.locals.isAPI) { - if (req.route && req.route.path === '/api/') { - options.title = '[[pages:home]]'; - } - req.app.set('json spaces', global.env === 'development' || req.query.pretty ? 4 : 0); - return res.json(options); - } - const optionsString = JSON.stringify(options).replace(/<\//g, '<\\/'); - const results = await utils.promiseParallel({ - header: renderHeaderFooter('renderHeader', req, res, options), - content: renderContent(render, templateToRender, req, res, options), - footer: renderHeaderFooter('renderFooter', req, res, options), - }); - - const str = `${results.header + - (res.locals.postHeader || '') + - results.content - }${ - res.locals.preFooter || '' - }${results.footer}`; - - if (typeof fn !== 'function') { - self.send(str); - } else { - fn(null, str); - } - } - - try { - await renderMethod(template, options, fn); - } catch (err) { - next(err); - } - }; - - next(); - }; - - async function renderContent(render, tpl, req, res, options) { - return new Promise((resolve, reject) => { - render.call(res, tpl, options, async (err, str) => { - if (err) reject(err); - else resolve(await translate(str, getLang(req, res))); - }); - }); - } - - async function renderHeaderFooter(method, req, res, options) { - let str = ''; - if (res.locals.renderHeader) { - str = await middleware[method](req, res, options); - } else if (res.locals.renderAdminHeader) { - str = await middleware.admin[method](req, res, options); - } else { - str = ''; - } - return await translate(str, getLang(req, res)); - } - - function getLang(req, res) { - let language = (res.locals.config && res.locals.config.userLang) || 'en-GB'; - if (res.locals.renderAdminHeader) { - language = (res.locals.config && res.locals.config.acpLang) || 'en-GB'; - } - return req.query.lang ? validator.escape(String(req.query.lang)) : language; - } - - async function translate(str, language) { - const translated = await translator.translate(str, language); - return translator.unescape(translated); - } + middleware.processRender = function processRender(request, res, next) { + // Res.render post-processing, modified from here: https://gist.github.com/mrlannigan/5051687 + const {render} = res; + + res.render = async function renderOverride(template, options, function_) { + const self = this; + const {req} = this; + async function renderMethod(template, options, function__) { + options ||= {}; + if (typeof options === 'function') { + function__ = options; + options = {}; + } + + options.loggedIn = req.uid > 0; + options.relative_path = relative_path; + options.template = {name: template, [template]: true}; + options.url = (req.baseUrl + req.path.replace(/^\/api/, '')); + options.bodyClass = helpers.buildBodyClass(req, res, options); + + if (req.loggedIn) { + res.set('cache-control', 'private'); + } + + const buildResult = await plugins.hooks.fire(`filter:${template}.build`, {req, res, templateData: options}); + if (res.headersSent) { + return; + } + + const templateToRender = buildResult.templateData.templateToRender || template; + + const renderResult = await plugins.hooks.fire('filter:middleware.render', {req, res, templateData: buildResult.templateData}); + if (res.headersSent) { + return; + } + + options = renderResult.templateData; + options._header = { + tags: await meta.tags.parse(req, renderResult, res.locals.metaTags, res.locals.linkTags), + }; + options.widgets = await widgets.render(req.uid, { + template: `${template}.tpl`, + url: options.url, + templateData: options, + req, + res, + }); + res.locals.template = template; + options._locals = undefined; + + if (res.locals.isAPI) { + if (req.route && req.route.path === '/api/') { + options.title = '[[pages:home]]'; + } + + req.app.set('json spaces', global.env === 'development' || req.query.pretty ? 4 : 0); + return res.json(options); + } + + const optionsString = JSON.stringify(options).replaceAll('${ + optionsString + }${ + res.locals.preFooter || '' + }${results.footer}`; + + if (typeof function__ === 'function') { + function__(null, string_); + } else { + self.send(string_); + } + } + + try { + await renderMethod(template, options, function_); + } catch (error) { + next(error); + } + }; + + next(); + }; + + async function renderContent(render, tpl, request, res, options) { + return new Promise((resolve, reject) => { + render.call(res, tpl, options, async (error, string_) => { + if (error) { + reject(error); + } else { + resolve(await translate(string_, getLang(request, res))); + } + }); + }); + } + + async function renderHeaderFooter(method, request, res, options) { + let string_ = ''; + if (res.locals.renderHeader) { + string_ = await middleware[method](request, res, options); + } else if (res.locals.renderAdminHeader) { + string_ = await middleware.admin[method](request, res, options); + } else { + string_ = ''; + } + + return await translate(string_, getLang(request, res)); + } + + function getLang(request, res) { + let language = (res.locals.config && res.locals.config.userLang) || 'en-GB'; + if (res.locals.renderAdminHeader) { + language = (res.locals.config && res.locals.config.acpLang) || 'en-GB'; + } + + return request.query.lang ? validator.escape(String(request.query.lang)) : language; + } + + async function translate(string_, language) { + const translated = await translator.translate(string_, language); + return translator.unescape(translated); + } }; diff --git a/src/middleware/uploads.js b/src/middleware/uploads.js index 8b2082a..c6f8d18 100644 --- a/src/middleware/uploads.js +++ b/src/middleware/uploads.js @@ -2,28 +2,29 @@ const cacheCreate = require('../cache/ttl'); const meta = require('../meta'); -const helpers = require('./helpers'); const user = require('../user'); +const helpers = require('./helpers'); const cache = cacheCreate({ - ttl: meta.config.uploadRateLimitCooldown * 1000, + ttl: meta.config.uploadRateLimitCooldown * 1000, }); exports.clearCache = function () { - cache.clear(); + cache.clear(); }; -exports.ratelimit = helpers.try(async (req, res, next) => { - const { uid } = req; - if (!meta.config.uploadRateLimitThreshold || (uid && await user.isAdminOrGlobalMod(uid))) { - return next(); - } +exports.ratelimit = helpers.try(async (request, res, next) => { + const {uid} = request; + if (!meta.config.uploadRateLimitThreshold || (uid && await user.isAdminOrGlobalMod(uid))) { + return next(); + } + + const count = (cache.get(`${request.ip}:uploaded_file_count`) || 0) + request.files.files.length; + if (count > meta.config.uploadRateLimitThreshold) { + return next(new Error(['[[error:upload-ratelimit-reached]]'])); + } - const count = (cache.get(`${req.ip}:uploaded_file_count`) || 0) + req.files.files.length; - if (count > meta.config.uploadRateLimitThreshold) { - return next(new Error(['[[error:upload-ratelimit-reached]]'])); - } - cache.set(`${req.ip}:uploaded_file_count`, count); - next(); + cache.set(`${request.ip}:uploaded_file_count`, count); + next(); }); diff --git a/src/middleware/user.js b/src/middleware/user.js index 3c2733a..b2fab40 100644 --- a/src/middleware/user.js +++ b/src/middleware/user.js @@ -1,245 +1,264 @@ 'use strict'; +const path = require('node:path'); +const util = require('node:util'); const winston = require('winston'); const passport = require('passport'); const nconf = require('nconf'); -const path = require('path'); -const util = require('util'); - const user = require('../user'); const privileges = require('../privileges'); const plugins = require('../plugins'); -const helpers = require('./helpers'); const auth = require('../routes/authentication'); const writeRouter = require('../routes/write'); +const helpers = require('./helpers'); const controllers = { - helpers: require('../controllers/helpers'), - authentication: require('../controllers/authentication'), + helpers: require('../controllers/helpers'), + authentication: require('../controllers/authentication'), }; -const passportAuthenticateAsync = function (req, res) { - return new Promise((resolve, reject) => { - passport.authenticate('core.api', (err, user) => { - if (err) { - reject(err); - } else { - resolve(user); - res.on('finish', writeRouter.cleanup.bind(null, req)); - } - })(req, res); - }); +const passportAuthenticateAsync = function (request, res) { + return new Promise((resolve, reject) => { + passport.authenticate('core.api', (error, user) => { + if (error) { + reject(error); + } else { + resolve(user); + res.on('finish', writeRouter.cleanup.bind(null, request)); + } + })(request, res); + }); }; module.exports = function (middleware) { - async function authenticate(req, res) { - async function finishLogin(req, user) { - const loginAsync = util.promisify(req.login).bind(req); - await loginAsync(user, { keepSessionInfo: true }); - await controllers.authentication.onSuccessfulLogin(req, user.uid); - req.uid = user.uid; - req.loggedIn = req.uid > 0; - return true; - } - - if (res.locals.isAPI && (req.loggedIn || !req.headers.hasOwnProperty('authorization'))) { - // If authenticated via cookie (express-session), protect routes with CSRF checking - await middleware.applyCSRFasync(req, res); - } - - if (req.loggedIn) { - return true; - } else if (req.headers.hasOwnProperty('authorization')) { - const user = await passportAuthenticateAsync(req, res); - if (!user) { return true; } - - if (user.hasOwnProperty('uid')) { - return await finishLogin(req, user); - } else if (user.hasOwnProperty('master') && user.master === true) { - // If the token received was a master token, a _uid must also be present for all calls - if (req.body.hasOwnProperty('_uid') || req.query.hasOwnProperty('_uid')) { - user.uid = req.body._uid || req.query._uid; - delete user.master; - return await finishLogin(req, user); - } - - throw new Error('[[error:api.master-token-no-uid]]'); - } else { - winston.warn('[api/authenticate] Unable to find user after verifying token'); - return true; - } - } - - await plugins.hooks.fire('response:middleware.authenticate', { - req: req, - res: res, - next: function () {}, // no-op for backwards compatibility - }); - - if (!res.headersSent) { - auth.setAuthVars(req); - } - return !res.headersSent; - } - - middleware.authenticateRequest = helpers.try(async (req, res, next) => { - const { skip } = await plugins.hooks.fire('filter:middleware.authenticate', { - skip: { - // get: [], - post: ['/api/v3/utilities/login'], - // etc... - }, - }); - - const mountedPath = path.join(req.baseUrl, req.path).replace(nconf.get('relative_path'), ''); - const method = req.method.toLowerCase(); - if (skip[method] && skip[method].includes(mountedPath)) { - return next(); - } - - if (!await authenticate(req, res)) { - return; - } - next(); - }); - - middleware.ensureSelfOrGlobalPrivilege = helpers.try(async (req, res, next) => { - await ensureSelfOrMethod(user.isAdminOrGlobalMod, req, res, next); - }); - - middleware.ensureSelfOrPrivileged = helpers.try(async (req, res, next) => { - await ensureSelfOrMethod(user.isPrivileged, req, res, next); - }); - - async function ensureSelfOrMethod(method, req, res, next) { - /* + async function authenticate(request, res) { + async function finishLogin(request_, user) { + const loginAsync = util.promisify(request_.login).bind(request_); + await loginAsync(user, {keepSessionInfo: true}); + await controllers.authentication.onSuccessfulLogin(request_, user.uid); + request_.uid = user.uid; + request_.loggedIn = request_.uid > 0; + return true; + } + + if (res.locals.isAPI && (request.loggedIn || !request.headers.hasOwnProperty('authorization'))) { + // If authenticated via cookie (express-session), protect routes with CSRF checking + await middleware.applyCSRFasync(request, res); + } + + if (request.loggedIn) { + return true; + } + + if (request.headers.hasOwnProperty('authorization')) { + const user = await passportAuthenticateAsync(request, res); + if (!user) { + return true; + } + + if (user.hasOwnProperty('uid')) { + return await finishLogin(request, user); + } + + if (user.hasOwnProperty('master') && user.master === true) { + // If the token received was a master token, a _uid must also be present for all calls + if (request.body.hasOwnProperty('_uid') || request.query.hasOwnProperty('_uid')) { + user.uid = request.body._uid || request.query._uid; + delete user.master; + return await finishLogin(request, user); + } + + throw new Error('[[error:api.master-token-no-uid]]'); + } else { + winston.warn('[api/authenticate] Unable to find user after verifying token'); + return true; + } + } + + await plugins.hooks.fire('response:middleware.authenticate', { + req: request, + res, + next() {}, // No-op for backwards compatibility + }); + + if (!res.headersSent) { + auth.setAuthVars(request); + } + + return !res.headersSent; + } + + middleware.authenticateRequest = helpers.try(async (request, res, next) => { + const {skip} = await plugins.hooks.fire('filter:middleware.authenticate', { + skip: { + // Get: [], + post: ['/api/v3/utilities/login'], + // Etc... + }, + }); + + const mountedPath = path.join(request.baseUrl, request.path).replace(nconf.get('relative_path'), ''); + const method = request.method.toLowerCase(); + if (skip[method] && skip[method].includes(mountedPath)) { + return next(); + } + + if (!await authenticate(request, res)) { + return; + } + + next(); + }); + + middleware.ensureSelfOrGlobalPrivilege = helpers.try(async (request, res, next) => { + await ensureSelfOrMethod(user.isAdminOrGlobalMod, request, res, next); + }); + + middleware.ensureSelfOrPrivileged = helpers.try(async (request, res, next) => { + await ensureSelfOrMethod(user.isPrivileged, request, res, next); + }); + + async function ensureSelfOrMethod(method, request, res, next) { + /* The "self" part of this middleware hinges on you having used middleware.exposeUid prior to invoking this middleware. */ - if (!req.loggedIn) { - return controllers.helpers.notAllowed(req, res); - } - if (req.uid === parseInt(res.locals.uid, 10)) { - return next(); - } - const allowed = await method(req.uid); - if (!allowed) { - return controllers.helpers.notAllowed(req, res); - } - - return next(); - } - - middleware.canViewUsers = helpers.try(async (req, res, next) => { - if (parseInt(res.locals.uid, 10) === req.uid) { - return next(); - } - const canView = await privileges.global.can('view:users', req.uid); - if (canView) { - return next(); - } - controllers.helpers.notAllowed(req, res); - }); - - middleware.canViewGroups = helpers.try(async (req, res, next) => { - const canView = await privileges.global.can('view:groups', req.uid); - if (canView) { - return next(); - } - controllers.helpers.notAllowed(req, res); - }); - - middleware.canChat = helpers.try(async (req, res, next) => { - const canChat = await privileges.global.can('chat', req.uid); - if (canChat) { - return next(); - } - controllers.helpers.notAllowed(req, res); - }); - - middleware.checkAccountPermissions = helpers.try(async (req, res, next) => { - // This middleware ensures that only the requested user and admins can pass - - // This check if left behind for legacy purposes. Older plugins may call this middleware without ensureLoggedIn - if (!req.loggedIn) { - return controllers.helpers.notAllowed(req, res); - } - - if (!['uid', 'userslug'].some(param => req.params.hasOwnProperty(param))) { - return controllers.helpers.notAllowed(req, res); - } - - const uid = req.params.uid || await user.getUidByUserslug(req.params.userslug); - let allowed = await privileges.users.canEdit(req.uid, uid); - if (allowed) { - return next(); - } - - if (/user\/.+\/info$/.test(req.path)) { - allowed = await privileges.global.can('view:users:info', req.uid); - } - if (allowed) { - return next(); - } - controllers.helpers.notAllowed(req, res); - }); - - middleware.redirectToAccountIfLoggedIn = helpers.try(async (req, res, next) => { - if (req.session.forceLogin || req.uid <= 0) { - return next(); - } - const userslug = await user.getUserField(req.uid, 'userslug'); - controllers.helpers.redirect(res, `/user/${userslug}`); - }); - - middleware.redirectUidToUserslug = helpers.try(async (req, res, next) => { - const uid = parseInt(req.params.uid, 10); - if (uid <= 0) { - return next(); - } - const userslug = await user.getUserField(uid, 'userslug'); - if (!userslug) { - return next(); - } - const path = req.url.replace(/^\/api/, '') - .replace(`/uid/${uid}`, () => `/user/${userslug}`); - controllers.helpers.redirect(res, path); - }); - - middleware.redirectMeToUserslug = helpers.try(async (req, res) => { - const userslug = await user.getUserField(req.uid, 'userslug'); - if (!userslug) { - return controllers.helpers.notAllowed(req, res); - } - const path = req.url.replace(/^(\/api)?\/me/, () => `/user/${userslug}`); - controllers.helpers.redirect(res, path); - }); - - middleware.requireUser = function (req, res, next) { - if (req.loggedIn) { - return next(); - } - - res.status(403).render('403', { title: '[[global:403.title]]' }); - }; - - middleware.registrationComplete = async function registrationComplete(req, res, next) { - // If the user's session contains registration data, redirect the user to complete registration - if (!req.session.hasOwnProperty('registration')) { - return setImmediate(next); - } - - const path = req.path.startsWith('/api/') ? req.path.replace('/api', '') : req.path; - const { allowed } = await plugins.hooks.fire('filter:middleware.registrationComplete', { - allowed: ['/register/complete'], - }); - if (!allowed.includes(path)) { - // Append user data if present - req.session.registration.uid = req.session.registration.uid || req.uid; - - controllers.helpers.redirect(res, '/register/complete'); - } else { - setImmediate(next); - } - }; + if (!request.loggedIn) { + return controllers.helpers.notAllowed(request, res); + } + + if (request.uid === Number.parseInt(res.locals.uid, 10)) { + return next(); + } + + const allowed = await method(request.uid); + if (!allowed) { + return controllers.helpers.notAllowed(request, res); + } + + return next(); + } + + middleware.canViewUsers = helpers.try(async (request, res, next) => { + if (Number.parseInt(res.locals.uid, 10) === request.uid) { + return next(); + } + + const canView = await privileges.global.can('view:users', request.uid); + if (canView) { + return next(); + } + + controllers.helpers.notAllowed(request, res); + }); + + middleware.canViewGroups = helpers.try(async (request, res, next) => { + const canView = await privileges.global.can('view:groups', request.uid); + if (canView) { + return next(); + } + + controllers.helpers.notAllowed(request, res); + }); + + middleware.canChat = helpers.try(async (request, res, next) => { + const canChat = await privileges.global.can('chat', request.uid); + if (canChat) { + return next(); + } + + controllers.helpers.notAllowed(request, res); + }); + + middleware.checkAccountPermissions = helpers.try(async (request, res, next) => { + // This middleware ensures that only the requested user and admins can pass + + // This check if left behind for legacy purposes. Older plugins may call this middleware without ensureLoggedIn + if (!request.loggedIn) { + return controllers.helpers.notAllowed(request, res); + } + + if (!['uid', 'userslug'].some(parameter => request.params.hasOwnProperty(parameter))) { + return controllers.helpers.notAllowed(request, res); + } + + const uid = request.params.uid || await user.getUidByUserslug(request.params.userslug); + let allowed = await privileges.users.canEdit(request.uid, uid); + if (allowed) { + return next(); + } + + if (/user\/.+\/info$/.test(request.path)) { + allowed = await privileges.global.can('view:users:info', request.uid); + } + + if (allowed) { + return next(); + } + + controllers.helpers.notAllowed(request, res); + }); + + middleware.redirectToAccountIfLoggedIn = helpers.try(async (request, res, next) => { + if (request.session.forceLogin || request.uid <= 0) { + return next(); + } + + const userslug = await user.getUserField(request.uid, 'userslug'); + controllers.helpers.redirect(res, `/user/${userslug}`); + }); + + middleware.redirectUidToUserslug = helpers.try(async (request, res, next) => { + const uid = Number.parseInt(request.params.uid, 10); + if (uid <= 0) { + return next(); + } + + const userslug = await user.getUserField(uid, 'userslug'); + if (!userslug) { + return next(); + } + + const path = request.url.replace(/^\/api/, '') + .replace(`/uid/${uid}`, () => `/user/${userslug}`); + controllers.helpers.redirect(res, path); + }); + + middleware.redirectMeToUserslug = helpers.try(async (request, res) => { + const userslug = await user.getUserField(request.uid, 'userslug'); + if (!userslug) { + return controllers.helpers.notAllowed(request, res); + } + + const path = request.url.replace(/^(\/api)?\/me/, () => `/user/${userslug}`); + controllers.helpers.redirect(res, path); + }); + + middleware.requireUser = function (request, res, next) { + if (request.loggedIn) { + return next(); + } + + res.status(403).render('403', {title: '[[global:403.title]]'}); + }; + + middleware.registrationComplete = async function registrationComplete(request, res, next) { + // If the user's session contains registration data, redirect the user to complete registration + if (!request.session.hasOwnProperty('registration')) { + return setImmediate(next); + } + + const path = request.path.startsWith('/api/') ? request.path.replace('/api', '') : request.path; + const {allowed} = await plugins.hooks.fire('filter:middleware.registrationComplete', { + allowed: ['/register/complete'], + }); + if (allowed.includes(path)) { + setImmediate(next); + } else { + // Append user data if present + request.session.registration.uid = request.session.registration.uid || request.uid; + + controllers.helpers.redirect(res, '/register/complete'); + } + }; }; diff --git a/src/navigation/admin.js b/src/navigation/admin.js index f72d850..062233d 100644 --- a/src/navigation/admin.js +++ b/src/navigation/admin.js @@ -2,7 +2,6 @@ const validator = require('validator'); const winston = require('winston'); - const plugins = require('../plugins'); const db = require('../database'); const pubsub = require('../pubsub'); @@ -11,35 +10,36 @@ const admin = module.exports; let cache = null; pubsub.on('admin:navigation:save', () => { - cache = null; + cache = null; }); admin.save = async function (data) { - const order = Object.keys(data); - const bulkSet = []; - data.forEach((item, index) => { - item.order = order[index]; - if (item.hasOwnProperty('groups')) { - item.groups = JSON.stringify(item.groups); - } - bulkSet.push([`navigation:enabled:${item.order}`, item]); - }); - - cache = null; - pubsub.publish('admin:navigation:save'); - const ids = await db.getSortedSetRange('navigation:enabled', 0, -1); - await db.deleteAll(ids.map(id => `navigation:enabled:${id}`)); - await db.setObjectBulk(bulkSet); - await db.delete('navigation:enabled'); - await db.sortedSetAdd('navigation:enabled', order, order); + const order = Object.keys(data); + const bulkSet = []; + for (const [index, item] of data.entries()) { + item.order = order[index]; + if (item.hasOwnProperty('groups')) { + item.groups = JSON.stringify(item.groups); + } + + bulkSet.push([`navigation:enabled:${item.order}`, item]); + } + + cache = null; + pubsub.publish('admin:navigation:save'); + const ids = await db.getSortedSetRange('navigation:enabled', 0, -1); + await db.deleteAll(ids.map(id => `navigation:enabled:${id}`)); + await db.setObjectBulk(bulkSet); + await db.delete('navigation:enabled'); + await db.sortedSetAdd('navigation:enabled', order, order); }; admin.getAdmin = async function () { - const [enabled, available] = await Promise.all([ - admin.get(), - getAvailable(), - ]); - return { enabled: enabled, available: available }; + const [enabled, available] = await Promise.all([ + admin.get(), + getAvailable(), + ]); + return {enabled, available}; }; const fieldsToEscape = ['iconClass', 'class', 'route', 'id', 'text', 'textClass', 'title']; @@ -48,57 +48,61 @@ admin.escapeFields = navItems => toggleEscape(navItems, true); admin.unescapeFields = navItems => toggleEscape(navItems, false); function toggleEscape(navItems, flag) { - navItems.forEach((item) => { - if (item) { - fieldsToEscape.forEach((field) => { - if (item.hasOwnProperty(field)) { - item[field] = validator[flag ? 'escape' : 'unescape'](String(item[field])); - } - }); - } - }); + for (const item of navItems) { + if (item) { + for (const field of fieldsToEscape) { + if (item.hasOwnProperty(field)) { + item[field] = validator[flag ? 'escape' : 'unescape'](String(item[field])); + } + } + } + } } admin.get = async function () { - if (cache) { - return cache.map(item => ({ ...item })); - } - const ids = await db.getSortedSetRange('navigation:enabled', 0, -1); - const data = await db.getObjects(ids.map(id => `navigation:enabled:${id}`)); - cache = data.map((item) => { - if (item.hasOwnProperty('groups')) { - try { - item.groups = JSON.parse(item.groups); - } catch (err) { - winston.error(err.stack); - item.groups = []; - } - } - item.groups = item.groups || []; - if (item.groups && !Array.isArray(item.groups)) { - item.groups = [item.groups]; - } - return item; - }); - admin.escapeFields(cache); - - return cache.map(item => ({ ...item })); + if (cache) { + return cache.map(item => ({...item})); + } + + const ids = await db.getSortedSetRange('navigation:enabled', 0, -1); + const data = await db.getObjects(ids.map(id => `navigation:enabled:${id}`)); + cache = data.map(item => { + if (item.hasOwnProperty('groups')) { + try { + item.groups = JSON.parse(item.groups); + } catch (error) { + winston.error(error.stack); + item.groups = []; + } + } + + item.groups = item.groups || []; + if (item.groups && !Array.isArray(item.groups)) { + item.groups = [item.groups]; + } + + return item; + }); + admin.escapeFields(cache); + + return cache.map(item => ({...item})); }; async function getAvailable() { - const core = require('../../install/data/navigation.json').map((item) => { - item.core = true; - item.id = item.id || ''; - return item; - }); - - const navItems = await plugins.hooks.fire('filter:navigation.available', core); - navItems.forEach((item) => { - if (item && !item.hasOwnProperty('enabled')) { - item.enabled = true; - } - }); - return navItems; + const core = require('../../install/data/navigation.json').map(item => { + item.core = true; + item.id = item.id || ''; + return item; + }); + + const navItems = await plugins.hooks.fire('filter:navigation.available', core); + for (const item of navItems) { + if (item && !item.hasOwnProperty('enabled')) { + item.enabled = true; + } + } + + return navItems; } require('../promisify')(admin); diff --git a/src/navigation/index.js b/src/navigation/index.js index 43fda13..cf1d137 100644 --- a/src/navigation/index.js +++ b/src/navigation/index.js @@ -2,33 +2,34 @@ const nconf = require('nconf'); const validator = require('validator'); -const admin = require('./admin'); const groups = require('../groups'); +const admin = require('./admin'); const navigation = module.exports; const relative_path = nconf.get('relative_path'); navigation.get = async function (uid) { - let data = await admin.get(); + let data = await admin.get(); + + data = data.filter(item => item && item.enabled).map(item => { + item.originalRoute = validator.unescape(item.route); - data = data.filter(item => item && item.enabled).map((item) => { - item.originalRoute = validator.unescape(item.route); + if (!item.route.startsWith('http')) { + item.route = relative_path + item.route; + } - if (!item.route.startsWith('http')) { - item.route = relative_path + item.route; - } + return item; + }); - return item; - }); + const pass = await Promise.all(data.map(async navItem => { + if (navItem.groups.length === 0) { + return true; + } - const pass = await Promise.all(data.map(async (navItem) => { - if (!navItem.groups.length) { - return true; - } - return await groups.isMemberOfAny(uid, navItem.groups); - })); - return data.filter((navItem, i) => pass[i]); + return await groups.isMemberOfAny(uid, navItem.groups); + })); + return data.filter((navItem, i) => pass[i]); }; require('../promisify')(navigation); diff --git a/src/notifications.js b/src/notifications.js index 2b2d4c9..451ff50 100644 --- a/src/notifications.js +++ b/src/notifications.js @@ -5,7 +5,6 @@ const winston = require('winston'); const cron = require('cron').CronJob; const nconf = require('nconf'); const _ = require('lodash'); - const db = require('./database'); const User = require('./user'); const posts = require('./posts'); @@ -19,429 +18,445 @@ const emailer = require('./emailer'); const Notifications = module.exports; Notifications.baseTypes = [ - 'notificationType_upvote', - 'notificationType_new-topic', - 'notificationType_new-reply', - 'notificationType_post-edit', - 'notificationType_follow', - 'notificationType_new-chat', - 'notificationType_new-group-chat', - 'notificationType_group-invite', - 'notificationType_group-leave', - 'notificationType_group-request-membership', + 'notificationType_upvote', + 'notificationType_new-topic', + 'notificationType_new-reply', + 'notificationType_post-edit', + 'notificationType_follow', + 'notificationType_new-chat', + 'notificationType_new-group-chat', + 'notificationType_group-invite', + 'notificationType_group-leave', + 'notificationType_group-request-membership', ]; Notifications.privilegedTypes = [ - 'notificationType_new-register', - 'notificationType_post-queue', - 'notificationType_new-post-flag', - 'notificationType_new-user-flag', + 'notificationType_new-register', + 'notificationType_post-queue', + 'notificationType_new-post-flag', + 'notificationType_new-user-flag', ]; -const notificationPruneCutoff = 2592000000; // one month +const notificationPruneCutoff = 2_592_000_000; // One month Notifications.getAllNotificationTypes = async function () { - const results = await plugins.hooks.fire('filter:user.notificationTypes', { - types: Notifications.baseTypes.slice(), - privilegedTypes: Notifications.privilegedTypes.slice(), - }); - return results.types.concat(results.privilegedTypes); + const results = await plugins.hooks.fire('filter:user.notificationTypes', { + types: Notifications.baseTypes.slice(), + privilegedTypes: Notifications.privilegedTypes.slice(), + }); + return results.types.concat(results.privilegedTypes); }; Notifications.startJobs = function () { - winston.verbose('[notifications.init] Registering jobs.'); - new cron('*/30 * * * *', Notifications.prune, null, true); + winston.verbose('[notifications.init] Registering jobs.'); + new cron('*/30 * * * *', Notifications.prune, null, true); }; Notifications.get = async function (nid) { - const notifications = await Notifications.getMultiple([nid]); - return Array.isArray(notifications) && notifications.length ? notifications[0] : null; + const notifications = await Notifications.getMultiple([nid]); + return Array.isArray(notifications) && notifications.length > 0 ? notifications[0] : null; }; Notifications.getMultiple = async function (nids) { - if (!Array.isArray(nids) || !nids.length) { - return []; - } - - const keys = nids.map(nid => `notifications:${nid}`); - const notifications = await db.getObjects(keys); - - const userKeys = notifications.map(n => n && n.from); - const usersData = await User.getUsersFields(userKeys, ['username', 'userslug', 'picture']); - - notifications.forEach((notification, index) => { - if (notification) { - if (notification.path && !notification.path.startsWith('http')) { - notification.path = nconf.get('relative_path') + notification.path; - } - notification.datetimeISO = utils.toISOString(notification.datetime); - - if (notification.bodyLong) { - notification.bodyLong = utils.stripHTMLTags(notification.bodyLong, ['img', 'p', 'a']); - } - - notification.user = usersData[index]; - if (notification.user) { - notification.image = notification.user.picture || null; - if (notification.user.username === '[[global:guest]]') { - notification.bodyShort = notification.bodyShort.replace(/([\s\S]*?),[\s\S]*?,([\s\S]*?)/, '$1, [[global:guest]], $2'); - } - } else if (notification.image === 'brand:logo' || !notification.image) { - notification.image = meta.config['brand:logo'] || `${nconf.get('relative_path')}/logo.png`; - } - } - }); - return notifications; + if (!Array.isArray(nids) || nids.length === 0) { + return []; + } + + const keys = nids.map(nid => `notifications:${nid}`); + const notifications = await db.getObjects(keys); + + const userKeys = notifications.map(n => n && n.from); + const usersData = await User.getUsersFields(userKeys, ['username', 'userslug', 'picture']); + + for (const [index, notification] of notifications.entries()) { + if (notification) { + if (notification.path && !notification.path.startsWith('http')) { + notification.path = nconf.get('relative_path') + notification.path; + } + + notification.datetimeISO = utils.toISOString(notification.datetime); + + notification.bodyLong &&= utils.stripHTMLTags(notification.bodyLong, ['img', 'p', 'a']); + + notification.user = usersData[index]; + if (notification.user) { + notification.image = notification.user.picture || null; + if (notification.user.username === '[[global:guest]]') { + notification.bodyShort = notification.bodyShort.replace(/([\s\S]*?),[\s\S]*?,([\s\S]*?)/, '$1, [[global:guest]], $2'); + } + } else if (notification.image === 'brand:logo' || !notification.image) { + notification.image = meta.config['brand:logo'] || `${nconf.get('relative_path')}/logo.png`; + } + } + } + + return notifications; }; Notifications.filterExists = async function (nids) { - const exists = await db.isSortedSetMembers('notifications', nids); - return nids.filter((nid, idx) => exists[idx]); + const exists = await db.isSortedSetMembers('notifications', nids); + return nids.filter((nid, index) => exists[index]); }; Notifications.findRelated = async function (mergeIds, set) { - mergeIds = mergeIds.filter(Boolean); - if (!mergeIds.length) { - return []; - } - // A related notification is one in a zset that has the same mergeId - const nids = await db.getSortedSetRevRange(set, 0, -1); - - const keys = nids.map(nid => `notifications:${nid}`); - const notificationData = await db.getObjectsFields(keys, ['mergeId']); - const notificationMergeIds = notificationData.map(notifObj => String(notifObj.mergeId)); - const mergeSet = new Set(mergeIds.map(id => String(id))); - return nids.filter((nid, idx) => mergeSet.has(notificationMergeIds[idx])); + mergeIds = mergeIds.filter(Boolean); + if (mergeIds.length === 0) { + return []; + } + + // A related notification is one in a zset that has the same mergeId + const nids = await db.getSortedSetRevRange(set, 0, -1); + + const keys = nids.map(nid => `notifications:${nid}`); + const notificationData = await db.getObjectsFields(keys, ['mergeId']); + const notificationMergeIds = notificationData.map(notificationObject => String(notificationObject.mergeId)); + const mergeSet = new Set(mergeIds.map(String)); + return nids.filter((nid, index) => mergeSet.has(notificationMergeIds[index])); }; Notifications.create = async function (data) { - if (!data.nid) { - throw new Error('[[error:no-notification-id]]'); - } - data.importance = data.importance || 5; - const oldNotif = await db.getObject(`notifications:${data.nid}`); - if ( - oldNotif && - parseInt(oldNotif.pid, 10) === parseInt(data.pid, 10) && - parseInt(oldNotif.importance, 10) > parseInt(data.importance, 10) - ) { - return null; - } - const now = Date.now(); - data.datetime = now; - const result = await plugins.hooks.fire('filter:notifications.create', { - data: data, - }); - if (!result.data) { - return null; - } - await Promise.all([ - db.sortedSetAdd('notifications', now, data.nid), - db.setObject(`notifications:${data.nid}`, data), - ]); - return data; + if (!data.nid) { + throw new Error('[[error:no-notification-id]]'); + } + + data.importance = data.importance || 5; + const oldNotification = await db.getObject(`notifications:${data.nid}`); + if ( + oldNotification + && Number.parseInt(oldNotification.pid, 10) === Number.parseInt(data.pid, 10) + && Number.parseInt(oldNotification.importance, 10) > Number.parseInt(data.importance, 10) + ) { + return null; + } + + const now = Date.now(); + data.datetime = now; + const result = await plugins.hooks.fire('filter:notifications.create', { + data, + }); + if (!result.data) { + return null; + } + + await Promise.all([ + db.sortedSetAdd('notifications', now, data.nid), + db.setObject(`notifications:${data.nid}`, data), + ]); + return data; }; Notifications.push = async function (notification, uids) { - if (!notification || !notification.nid) { - return; - } - uids = Array.isArray(uids) ? _.uniq(uids) : [uids]; - if (!uids.length) { - return; - } - - setTimeout(() => { - batch.processArray(uids, async (uids) => { - await pushToUids(uids, notification); - }, { interval: 1000, batch: 500 }, (err) => { - if (err) { - winston.error(err.stack); - } - }); - }, 1000); + if (!notification || !notification.nid) { + return; + } + + uids = Array.isArray(uids) ? _.uniq(uids) : [uids]; + if (uids.length === 0) { + return; + } + + setTimeout(() => { + batch.processArray(uids, async uids => { + await pushToUids(uids, notification); + }, {interval: 1000, batch: 500}, error => { + if (error) { + winston.error(error.stack); + } + }); + }, 1000); }; async function pushToUids(uids, notification) { - async function sendNotification(uids) { - if (!uids.length) { - return; - } - const cutoff = Date.now() - notificationPruneCutoff; - const unreadKeys = uids.map(uid => `uid:${uid}:notifications:unread`); - const readKeys = uids.map(uid => `uid:${uid}:notifications:read`); - await Promise.all([ - db.sortedSetsAdd(unreadKeys, notification.datetime, notification.nid), - db.sortedSetsRemove(readKeys, notification.nid), - ]); - await db.sortedSetsRemoveRangeByScore(unreadKeys.concat(readKeys), '-inf', cutoff); - const websockets = require('./socket.io'); - if (websockets.server) { - uids.forEach((uid) => { - websockets.in(`uid_${uid}`).emit('event:new_notification', notification); - }); - } - } - - async function sendEmail(uids) { - // Update CTA messaging (as not all notification types need custom text) - if (['new-reply', 'new-chat'].includes(notification.type)) { - notification['cta-type'] = notification.type; - } - let body = notification.bodyLong || ''; - if (meta.config.removeEmailNotificationImages) { - body = body.replace(/]*>/, ''); - } - body = posts.relativeToAbsolute(body, posts.urlRegex); - body = posts.relativeToAbsolute(body, posts.imgRegex); - let errorLogged = false; - await async.eachLimit(uids, 3, async (uid) => { - await emailer.send('notification', uid, { - path: notification.path, - notification_url: notification.path.startsWith('http') ? notification.path : nconf.get('url') + notification.path, - subject: utils.stripHTMLTags(notification.subject || '[[notifications:new_notification]]'), - intro: utils.stripHTMLTags(notification.bodyShort), - body: body, - notification: notification, - showUnsubscribe: true, - }).catch((err) => { - if (!errorLogged) { - winston.error(`[emailer.send] ${err.stack}`); - errorLogged = true; - } - }); - }); - } - - async function getUidsBySettings(uids) { - const uidsToNotify = []; - const uidsToEmail = []; - const usersSettings = await User.getMultipleUserSettings(uids); - usersSettings.forEach((userSettings) => { - const setting = userSettings[`notificationType_${notification.type}`] || 'notification'; - - if (setting === 'notification' || setting === 'notificationemail') { - uidsToNotify.push(userSettings.uid); - } - - if (setting === 'email' || setting === 'notificationemail') { - uidsToEmail.push(userSettings.uid); - } - }); - return { uidsToNotify: uidsToNotify, uidsToEmail: uidsToEmail }; - } - - // Remove uid from recipients list if they have blocked the user triggering the notification - uids = await User.blocks.filterUids(notification.from, uids); - const data = await plugins.hooks.fire('filter:notification.push', { notification: notification, uids: uids }); - if (!data || !data.notification || !data.uids || !data.uids.length) { - return; - } - - notification = data.notification; - let results = { uidsToNotify: data.uids, uidsToEmail: [] }; - if (notification.type) { - results = await getUidsBySettings(data.uids); - } - await Promise.all([ - sendNotification(results.uidsToNotify), - sendEmail(results.uidsToEmail), - ]); - plugins.hooks.fire('action:notification.pushed', { - notification: notification, - uids: results.uidsToNotify, - uidsNotified: results.uidsToNotify, - uidsEmailed: results.uidsToEmail, - }); + async function sendNotification(uids) { + if (uids.length === 0) { + return; + } + + const cutoff = Date.now() - notificationPruneCutoff; + const unreadKeys = uids.map(uid => `uid:${uid}:notifications:unread`); + const readKeys = uids.map(uid => `uid:${uid}:notifications:read`); + await Promise.all([ + db.sortedSetsAdd(unreadKeys, notification.datetime, notification.nid), + db.sortedSetsRemove(readKeys, notification.nid), + ]); + await db.sortedSetsRemoveRangeByScore(unreadKeys.concat(readKeys), '-inf', cutoff); + const websockets = require('./socket.io'); + if (websockets.server) { + for (const uid of uids) { + websockets.in(`uid_${uid}`).emit('event:new_notification', notification); + } + } + } + + async function sendEmail(uids) { + // Update CTA messaging (as not all notification types need custom text) + if (['new-reply', 'new-chat'].includes(notification.type)) { + notification['cta-type'] = notification.type; + } + + let body = notification.bodyLong || ''; + if (meta.config.removeEmailNotificationImages) { + body = body.replace(/]*>/, ''); + } + + body = posts.relativeToAbsolute(body, posts.urlRegex); + body = posts.relativeToAbsolute(body, posts.imgRegex); + let errorLogged = false; + await async.eachLimit(uids, 3, async uid => { + await emailer.send('notification', uid, { + path: notification.path, + notification_url: notification.path.startsWith('http') ? notification.path : nconf.get('url') + notification.path, + subject: utils.stripHTMLTags(notification.subject || '[[notifications:new_notification]]'), + intro: utils.stripHTMLTags(notification.bodyShort), + body, + notification, + showUnsubscribe: true, + }).catch(error => { + if (!errorLogged) { + winston.error(`[emailer.send] ${error.stack}`); + errorLogged = true; + } + }); + }); + } + + async function getUidsBySettings(uids) { + const uidsToNotify = []; + const uidsToEmail = []; + const usersSettings = await User.getMultipleUserSettings(uids); + for (const userSettings of usersSettings) { + const setting = userSettings[`notificationType_${notification.type}`] || 'notification'; + + if (setting === 'notification' || setting === 'notificationemail') { + uidsToNotify.push(userSettings.uid); + } + + if (setting === 'email' || setting === 'notificationemail') { + uidsToEmail.push(userSettings.uid); + } + } + + return {uidsToNotify, uidsToEmail}; + } + + // Remove uid from recipients list if they have blocked the user triggering the notification + uids = await User.blocks.filterUids(notification.from, uids); + const data = await plugins.hooks.fire('filter:notification.push', {notification, uids}); + if (!data || !data.notification || !data.uids || data.uids.length === 0) { + return; + } + + notification = data.notification; + let results = {uidsToNotify: data.uids, uidsToEmail: []}; + if (notification.type) { + results = await getUidsBySettings(data.uids); + } + + await Promise.all([ + sendNotification(results.uidsToNotify), + sendEmail(results.uidsToEmail), + ]); + plugins.hooks.fire('action:notification.pushed', { + notification, + uids: results.uidsToNotify, + uidsNotified: results.uidsToNotify, + uidsEmailed: results.uidsToEmail, + }); } Notifications.pushGroup = async function (notification, groupName) { - if (!notification) { - return; - } - const members = await groups.getMembers(groupName, 0, -1); - await Notifications.push(notification, members); + if (!notification) { + return; + } + + const members = await groups.getMembers(groupName, 0, -1); + await Notifications.push(notification, members); }; Notifications.pushGroups = async function (notification, groupNames) { - if (!notification) { - return; - } - let groupMembers = await groups.getMembersOfGroups(groupNames); - groupMembers = _.uniq(_.flatten(groupMembers)); - await Notifications.push(notification, groupMembers); + if (!notification) { + return; + } + + let groupMembers = await groups.getMembersOfGroups(groupNames); + groupMembers = _.uniq(groupMembers.flat()); + await Notifications.push(notification, groupMembers); }; Notifications.rescind = async function (nids) { - nids = Array.isArray(nids) ? nids : [nids]; - await Promise.all([ - db.sortedSetRemove('notifications', nids), - db.deleteAll(nids.map(nid => `notifications:${nid}`)), - ]); + nids = Array.isArray(nids) ? nids : [nids]; + await Promise.all([ + db.sortedSetRemove('notifications', nids), + db.deleteAll(nids.map(nid => `notifications:${nid}`)), + ]); }; Notifications.markRead = async function (nid, uid) { - if (parseInt(uid, 10) <= 0 || !nid) { - return; - } - await Notifications.markReadMultiple([nid], uid); + if (Number.parseInt(uid, 10) <= 0 || !nid) { + return; + } + + await Notifications.markReadMultiple([nid], uid); }; Notifications.markUnread = async function (nid, uid) { - if (!(parseInt(uid, 10) > 0) || !nid) { - return; - } - const notification = await db.getObject(`notifications:${nid}`); - if (!notification) { - throw new Error('[[error:no-notification]]'); - } - notification.datetime = notification.datetime || Date.now(); - - await Promise.all([ - db.sortedSetRemove(`uid:${uid}:notifications:read`, nid), - db.sortedSetAdd(`uid:${uid}:notifications:unread`, notification.datetime, nid), - ]); + if (!(Number.parseInt(uid, 10) > 0) || !nid) { + return; + } + + const notification = await db.getObject(`notifications:${nid}`); + if (!notification) { + throw new Error('[[error:no-notification]]'); + } + + notification.datetime = notification.datetime || Date.now(); + + await Promise.all([ + db.sortedSetRemove(`uid:${uid}:notifications:read`, nid), + db.sortedSetAdd(`uid:${uid}:notifications:unread`, notification.datetime, nid), + ]); }; Notifications.markReadMultiple = async function (nids, uid) { - nids = nids.filter(Boolean); - if (!Array.isArray(nids) || !nids.length || !(parseInt(uid, 10) > 0)) { - return; - } - - let notificationKeys = nids.map(nid => `notifications:${nid}`); - let mergeIds = await db.getObjectsFields(notificationKeys, ['mergeId']); - // Isolate mergeIds and find related notifications - mergeIds = _.uniq(mergeIds.map(set => set.mergeId)); - - const relatedNids = await Notifications.findRelated(mergeIds, `uid:${uid}:notifications:unread`); - notificationKeys = _.union(nids, relatedNids).map(nid => `notifications:${nid}`); - - let notificationData = await db.getObjectsFields(notificationKeys, ['nid', 'datetime']); - notificationData = notificationData.filter(n => n && n.nid); - - nids = notificationData.map(n => n.nid); - const datetimes = notificationData.map(n => (n && n.datetime) || Date.now()); - await Promise.all([ - db.sortedSetRemove(`uid:${uid}:notifications:unread`, nids), - db.sortedSetAdd(`uid:${uid}:notifications:read`, datetimes, nids), - ]); + nids = nids.filter(Boolean); + if (!Array.isArray(nids) || nids.length === 0 || !(Number.parseInt(uid, 10) > 0)) { + return; + } + + let notificationKeys = nids.map(nid => `notifications:${nid}`); + let mergeIds = await db.getObjectsFields(notificationKeys, ['mergeId']); + // Isolate mergeIds and find related notifications + mergeIds = _.uniq(mergeIds.map(set => set.mergeId)); + + const relatedNids = await Notifications.findRelated(mergeIds, `uid:${uid}:notifications:unread`); + notificationKeys = _.union(nids, relatedNids).map(nid => `notifications:${nid}`); + + let notificationData = await db.getObjectsFields(notificationKeys, ['nid', 'datetime']); + notificationData = notificationData.filter(n => n && n.nid); + + nids = notificationData.map(n => n.nid); + const datetimes = notificationData.map(n => (n && n.datetime) || Date.now()); + await Promise.all([ + db.sortedSetRemove(`uid:${uid}:notifications:unread`, nids), + db.sortedSetAdd(`uid:${uid}:notifications:read`, datetimes, nids), + ]); }; Notifications.markAllRead = async function (uid) { - const nids = await db.getSortedSetRevRange(`uid:${uid}:notifications:unread`, 0, 99); - await Notifications.markReadMultiple(nids, uid); + const nids = await db.getSortedSetRevRange(`uid:${uid}:notifications:unread`, 0, 99); + await Notifications.markReadMultiple(nids, uid); }; Notifications.prune = async function () { - const cutoffTime = Date.now() - notificationPruneCutoff; - const nids = await db.getSortedSetRangeByScore('notifications', 0, 500, '-inf', cutoffTime); - if (!nids.length) { - return; - } - try { - await Promise.all([ - db.sortedSetRemove('notifications', nids), - db.deleteAll(nids.map(nid => `notifications:${nid}`)), - ]); - - await batch.processSortedSet('users:joindate', async (uids) => { - const unread = uids.map(uid => `uid:${uid}:notifications:unread`); - const read = uids.map(uid => `uid:${uid}:notifications:read`); - await db.sortedSetsRemoveRangeByScore(unread.concat(read), '-inf', cutoffTime); - }, { batch: 500, interval: 100 }); - } catch (err) { - if (err) { - winston.error(`Encountered error pruning notifications\n${err.stack}`); - } - } + const cutoffTime = Date.now() - notificationPruneCutoff; + const nids = await db.getSortedSetRangeByScore('notifications', 0, 500, '-inf', cutoffTime); + if (nids.length === 0) { + return; + } + + try { + await Promise.all([ + db.sortedSetRemove('notifications', nids), + db.deleteAll(nids.map(nid => `notifications:${nid}`)), + ]); + + await batch.processSortedSet('users:joindate', async uids => { + const unread = uids.map(uid => `uid:${uid}:notifications:unread`); + const read = uids.map(uid => `uid:${uid}:notifications:read`); + await db.sortedSetsRemoveRangeByScore(unread.concat(read), '-inf', cutoffTime); + }, {batch: 500, interval: 100}); + } catch (error) { + if (error) { + winston.error(`Encountered error pruning notifications\n${error.stack}`); + } + } }; Notifications.merge = async function (notifications) { - // When passed a set of notification objects, merge any that can be merged - const mergeIds = [ - 'notifications:upvoted_your_post_in', - 'notifications:user_started_following_you', - 'notifications:user_posted_to', - 'notifications:user_flagged_post_in', - 'notifications:user_flagged_user', - 'new_register', - 'post-queue', - ]; - - notifications = mergeIds.reduce((notifications, mergeId) => { - const isolated = notifications.filter(n => n && n.hasOwnProperty('mergeId') && n.mergeId.split('|')[0] === mergeId); - if (isolated.length <= 1) { - return notifications; // Nothing to merge - } - - // Each isolated mergeId may have multiple differentiators, so process each separately - const differentiators = isolated.reduce((cur, next) => { - const differentiator = next.mergeId.split('|')[1] || 0; - if (!cur.includes(differentiator)) { - cur.push(differentiator); - } - - return cur; - }, []); - - differentiators.forEach((differentiator) => { - let set; - if (differentiator === 0 && differentiators.length === 1) { - set = isolated; - } else { - set = isolated.filter(n => n.mergeId === (`${mergeId}|${differentiator}`)); - } - - const modifyIndex = notifications.indexOf(set[0]); - if (modifyIndex === -1 || set.length === 1) { - return notifications; - } - - switch (mergeId) { - case 'notifications:upvoted_your_post_in': - case 'notifications:user_started_following_you': - case 'notifications:user_posted_to': - case 'notifications:user_flagged_post_in': - case 'notifications:user_flagged_user': { - const usernames = _.uniq(set.map(notifObj => notifObj && notifObj.user && notifObj.user.username)); - const numUsers = usernames.length; - - const title = utils.decodeHTMLEntities(notifications[modifyIndex].topicTitle || ''); - let titleEscaped = title.replace(/%/g, '%').replace(/,/g, ','); - titleEscaped = titleEscaped ? (`, ${titleEscaped}`) : ''; - - if (numUsers === 2) { - notifications[modifyIndex].bodyShort = `[[${mergeId}_dual, ${usernames.join(', ')}${titleEscaped}]]`; - } else if (numUsers > 2) { - notifications[modifyIndex].bodyShort = `[[${mergeId}_multiple, ${usernames[0]}, ${numUsers - 1}${titleEscaped}]]`; - } - - notifications[modifyIndex].path = set[set.length - 1].path; - } break; - - case 'new_register': - notifications[modifyIndex].bodyShort = `[[notifications:${mergeId}_multiple, ${set.length}]]`; - break; - } - - // Filter out duplicates - notifications = notifications.filter((notifObj, idx) => { - if (!notifObj || !notifObj.mergeId) { - return true; - } - - return !(notifObj.mergeId === (mergeId + (differentiator ? `|${differentiator}` : '')) && idx !== modifyIndex); - }); - }); - - return notifications; - }, notifications); - - const data = await plugins.hooks.fire('filter:notifications.merge', { - notifications: notifications, - }); - return data && data.notifications; + // When passed a set of notification objects, merge any that can be merged + const mergeIds = [ + 'notifications:upvoted_your_post_in', + 'notifications:user_started_following_you', + 'notifications:user_posted_to', + 'notifications:user_flagged_post_in', + 'notifications:user_flagged_user', + 'new_register', + 'post-queue', + ]; + + notifications = mergeIds.reduce((notifications, mergeId) => { + const isolated = notifications.filter(n => n && n.hasOwnProperty('mergeId') && n.mergeId.split('|')[0] === mergeId); + if (isolated.length <= 1) { + return notifications; // Nothing to merge + } + + // Each isolated mergeId may have multiple differentiators, so process each separately + const differentiators = isolated.reduce((current, next) => { + const differentiator = next.mergeId.split('|')[1] || 0; + if (!current.includes(differentiator)) { + current.push(differentiator); + } + + return current; + }, []); + + for (const differentiator of differentiators) { + let set; + set = differentiator === 0 && differentiators.length === 1 ? isolated : isolated.filter(n => n.mergeId === (`${mergeId}|${differentiator}`)); + + const modifyIndex = notifications.indexOf(set[0]); + if (modifyIndex === -1 || set.length === 1) { + notifications; continue; + } + + switch (mergeId) { + case 'notifications:upvoted_your_post_in': + case 'notifications:user_started_following_you': + case 'notifications:user_posted_to': + case 'notifications:user_flagged_post_in': + case 'notifications:user_flagged_user': { { + const usernames = _.uniq(set.map(notificationObject => notificationObject && notificationObject.user && notificationObject.user.username)); + const numberUsers = usernames.length; + + const title = utils.decodeHTMLEntities(notifications[modifyIndex].topicTitle || ''); + let titleEscaped = title.replaceAll('%', '%').replaceAll(',', ','); + titleEscaped = titleEscaped ? (`, ${titleEscaped}`) : ''; + + if (numberUsers === 2) { + notifications[modifyIndex].bodyShort = `[[${mergeId}_dual, ${usernames.join(', ')}${titleEscaped}]]`; + } else if (numberUsers > 2) { + notifications[modifyIndex].bodyShort = `[[${mergeId}_multiple, ${usernames[0]}, ${numberUsers - 1}${titleEscaped}]]`; + } + + notifications[modifyIndex].path = set.at(-1).path; + } + + break; + } + + case 'new_register': { + notifications[modifyIndex].bodyShort = `[[notifications:${mergeId}_multiple, ${set.length}]]`; + break; + } + } + + // Filter out duplicates + notifications = notifications.filter((notificationObject, index) => { + if (!notificationObject || !notificationObject.mergeId) { + return true; + } + + return !(notificationObject.mergeId === (mergeId + (differentiator ? `|${differentiator}` : '')) && index !== modifyIndex); + }); + } + + return notifications; + }, notifications); + + const data = await plugins.hooks.fire('filter:notifications.merge', { + notifications, + }); + return data && data.notifications; }; require('./promisify')(Notifications); diff --git a/src/pagination.js b/src/pagination.js index 037f922..6d78f44 100644 --- a/src/pagination.js +++ b/src/pagination.js @@ -1,81 +1,86 @@ 'use strict'; -const qs = require('querystring'); +const qs = require('node:querystring'); const _ = require('lodash'); const pagination = module.exports; -pagination.create = function (currentPage, pageCount, queryObj) { - if (pageCount <= 1) { - return { - prev: { page: 1, active: currentPage > 1 }, - next: { page: 1, active: currentPage < pageCount }, - first: { page: 1, active: currentPage === 1 }, - last: { page: 1, active: currentPage === pageCount }, - rel: [], - pages: [], - currentPage: 1, - pageCount: 1, - }; - } - pageCount = parseInt(pageCount, 10); - let pagesToShow = [1, 2, pageCount - 1, pageCount]; - - currentPage = parseInt(currentPage, 10) || 1; - const previous = Math.max(1, currentPage - 1); - const next = Math.min(pageCount, currentPage + 1); - - let startPage = Math.max(1, currentPage - 2); - if (startPage > pageCount - 5) { - startPage -= 2 - (pageCount - currentPage); - } - let i; - for (i = 0; i < 5; i += 1) { - pagesToShow.push(startPage + i); - } - - pagesToShow = _.uniq(pagesToShow).filter(page => page > 0 && page <= pageCount).sort((a, b) => a - b); - - queryObj = { ...(queryObj || {}) }; - - delete queryObj._; - - const pages = pagesToShow.map((page) => { - queryObj.page = page; - return { page: page, active: page === currentPage, qs: qs.stringify(queryObj) }; - }); - - for (i = pages.length - 1; i > 0; i -= 1) { - if (pages[i].page - 2 === pages[i - 1].page) { - pages.splice(i, 0, { page: pages[i].page - 1, active: false, qs: qs.stringify(queryObj) }); - } else if (pages[i].page - 1 !== pages[i - 1].page) { - pages.splice(i, 0, { separator: true }); - } - } - - const data = { rel: [], pages: pages, currentPage: currentPage, pageCount: pageCount }; - queryObj.page = previous; - data.prev = { page: previous, active: currentPage > 1, qs: qs.stringify(queryObj) }; - queryObj.page = next; - data.next = { page: next, active: currentPage < pageCount, qs: qs.stringify(queryObj) }; - - queryObj.page = 1; - data.first = { page: 1, active: currentPage === 1, qs: qs.stringify(queryObj) }; - queryObj.page = pageCount; - data.last = { page: pageCount, active: currentPage === pageCount, qs: qs.stringify(queryObj) }; - - if (currentPage < pageCount) { - data.rel.push({ - rel: 'next', - href: `?${qs.stringify({ ...queryObj, page: next })}`, - }); - } - - if (currentPage > 1) { - data.rel.push({ - rel: 'prev', - href: `?${qs.stringify({ ...queryObj, page: previous })}`, - }); - } - return data; +pagination.create = function (currentPage, pageCount, queryObject) { + if (pageCount <= 1) { + return { + prev: {page: 1, active: currentPage > 1}, + next: {page: 1, active: currentPage < pageCount}, + first: {page: 1, active: currentPage === 1}, + last: {page: 1, active: currentPage === pageCount}, + rel: [], + pages: [], + currentPage: 1, + pageCount: 1, + }; + } + + pageCount = Number.parseInt(pageCount, 10); + let pagesToShow = [1, 2, pageCount - 1, pageCount]; + + currentPage = Number.parseInt(currentPage, 10) || 1; + const previous = Math.max(1, currentPage - 1); + const next = Math.min(pageCount, currentPage + 1); + + let startPage = Math.max(1, currentPage - 2); + if (startPage > pageCount - 5) { + startPage -= 2 - (pageCount - currentPage); + } + + let i; + for (i = 0; i < 5; i += 1) { + pagesToShow.push(startPage + i); + } + + pagesToShow = _.uniq(pagesToShow).filter(page => page > 0 && page <= pageCount).sort((a, b) => a - b); + + queryObject = {...queryObject}; + + delete queryObject._; + + const pages = pagesToShow.map(page => { + queryObject.page = page; + return {page, active: page === currentPage, qs: qs.stringify(queryObject)}; + }); + + for (i = pages.length - 1; i > 0; i -= 1) { + if (pages[i].page - 2 === pages[i - 1].page) { + pages.splice(i, 0, {page: pages[i].page - 1, active: false, qs: qs.stringify(queryObject)}); + } else if (pages[i].page - 1 !== pages[i - 1].page) { + pages.splice(i, 0, {separator: true}); + } + } + + const data = { + rel: [], pages, currentPage, pageCount, + }; + queryObject.page = previous; + data.prev = {page: previous, active: currentPage > 1, qs: qs.stringify(queryObject)}; + queryObject.page = next; + data.next = {page: next, active: currentPage < pageCount, qs: qs.stringify(queryObject)}; + + queryObject.page = 1; + data.first = {page: 1, active: currentPage === 1, qs: qs.stringify(queryObject)}; + queryObject.page = pageCount; + data.last = {page: pageCount, active: currentPage === pageCount, qs: qs.stringify(queryObject)}; + + if (currentPage < pageCount) { + data.rel.push({ + rel: 'next', + href: `?${qs.stringify({...queryObject, page: next})}`, + }); + } + + if (currentPage > 1) { + data.rel.push({ + rel: 'prev', + href: `?${qs.stringify({...queryObject, page: previous})}`, + }); + } + + return data; }; diff --git a/src/password.js b/src/password.js index 9ad6924..5326ce7 100644 --- a/src/password.js +++ b/src/password.js @@ -1,81 +1,80 @@ 'use strict'; -const path = require('path'); -const crypto = require('crypto'); -const util = require('util'); - +const path = require('node:path'); +const crypto = require('node:crypto'); +const util = require('node:util'); const bcrypt = require('bcryptjs'); - const fork = require('./meta/debugFork'); function forkChild(message, callback) { - const child = fork(path.join(__dirname, 'password')); + const child = fork(path.join(__dirname, 'password')); - child.on('message', (msg) => { - callback(msg.err ? new Error(msg.err) : null, msg.result); - }); - child.on('error', (err) => { - console.error(err.stack); - callback(err); - }); + child.on('message', message_ => { + callback(message_.err ? new Error(message_.err) : null, message_.result); + }); + child.on('error', error => { + console.error(error.stack); + callback(error); + }); - child.send(message); + child.send(message); } const forkChildAsync = util.promisify(forkChild); exports.hash = async function (rounds, password) { - password = crypto.createHash('sha512').update(password).digest('hex'); - return await forkChildAsync({ type: 'hash', rounds: rounds, password: password }); + password = crypto.createHash('sha512').update(password).digest('hex'); + return await forkChildAsync({type: 'hash', rounds, password}); }; exports.compare = async function (password, hash, shaWrapped) { - const fakeHash = await getFakeHash(); + const fakeHash = await getFakeHash(); - if (shaWrapped) { - password = crypto.createHash('sha512').update(password).digest('hex'); - } + if (shaWrapped) { + password = crypto.createHash('sha512').update(password).digest('hex'); + } - return await forkChildAsync({ type: 'compare', password: password, hash: hash || fakeHash }); + return await forkChildAsync({type: 'compare', password, hash: hash || fakeHash}); }; let fakeHashCache; async function getFakeHash() { - if (fakeHashCache) { - return fakeHashCache; - } - fakeHashCache = await exports.hash(12, Math.random().toString()); - return fakeHashCache; + if (fakeHashCache) { + return fakeHashCache; + } + + fakeHashCache = await exports.hash(12, Math.random().toString()); + return fakeHashCache; } -// child process -process.on('message', (msg) => { - if (msg.type === 'hash') { - tryMethod(hashPassword, msg); - } else if (msg.type === 'compare') { - tryMethod(compare, msg); - } +// Child process +process.on('message', message => { + if (message.type === 'hash') { + tryMethod(hashPassword, message); + } else if (message.type === 'compare') { + tryMethod(compare, message); + } }); -async function tryMethod(method, msg) { - try { - const result = await method(msg); - process.send({ result: result }); - } catch (err) { - process.send({ err: err.message }); - } finally { - process.disconnect(); - } +async function tryMethod(method, message) { + try { + const result = await method(message); + process.send({result}); + } catch (error) { + process.send({err: error.message}); + } finally { + process.disconnect(); + } } -async function hashPassword(msg) { - const salt = await bcrypt.genSalt(parseInt(msg.rounds, 10)); - const hash = await bcrypt.hash(msg.password, salt); - return hash; +async function hashPassword(message) { + const salt = await bcrypt.genSalt(Number.parseInt(message.rounds, 10)); + const hash = await bcrypt.hash(message.password, salt); + return hash; } -async function compare(msg) { - return await bcrypt.compare(String(msg.password || ''), String(msg.hash || '')); +async function compare(message) { + return await bcrypt.compare(String(message.password || ''), String(message.hash || '')); } require('./promisify')(exports); diff --git a/src/plugins/data.js b/src/plugins/data.js index 8e1914d..c38e498 100644 --- a/src/plugins/data.js +++ b/src/plugins/data.js @@ -1,149 +1,151 @@ 'use strict'; -const fs = require('fs'); -const path = require('path'); +const fs = require('node:fs'); +const path = require('node:path'); const winston = require('winston'); const _ = require('lodash'); const nconf = require('nconf'); - const db = require('../database'); const file = require('../file'); -const { paths } = require('../constants'); +const {paths} = require('../constants'); const Data = module.exports; const basePath = path.join(__dirname, '../../'); -// to get this functionality use `plugins.getActive()` from `src/plugins/install.js` instead +// 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); + 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 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) { - winston.warn(`[plugins] "${plugins[i]}" is active but not installed.`); - } - }); - return pluginPaths.filter((p, i) => exists[i]); + 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) { + winston.warn(`[plugins] "${plugins[i]}" is active but not installed.`); + } + }); + return pluginPaths.filter((p, i) => exists[i]); }; Data.loadPluginInfo = async function (pluginPath) { - const [packageJson, pluginJson] = await Promise.all([ - fs.promises.readFile(path.join(pluginPath, 'package.json'), 'utf8'), - fs.promises.readFile(path.join(pluginPath, 'plugin.json'), 'utf8'), - ]); - - let pluginData; - let packageData; - try { - pluginData = JSON.parse(pluginJson); - packageData = JSON.parse(packageJson); - - pluginData.license = parseLicense(packageData); - - pluginData.id = packageData.name; - pluginData.name = packageData.name; - pluginData.description = packageData.description; - pluginData.version = packageData.version; - pluginData.repository = packageData.repository; - pluginData.nbbpm = packageData.nbbpm; - pluginData.path = pluginPath; - } catch (err) { - const pluginDir = path.basename(pluginPath); - - winston.error(`[plugins/${pluginDir}] Error in plugin.json or package.json!${err.stack}`); - throw new Error('[[error:parse-error]]'); - } - return pluginData; + const [packageJson, pluginJson] = await Promise.all([ + fs.promises.readFile(path.join(pluginPath, 'package.json'), 'utf8'), + fs.promises.readFile(path.join(pluginPath, 'plugin.json'), 'utf8'), + ]); + + let pluginData; + let packageData; + try { + pluginData = JSON.parse(pluginJson); + packageData = JSON.parse(packageJson); + + pluginData.license = parseLicense(packageData); + + pluginData.id = packageData.name; + pluginData.name = packageData.name; + pluginData.description = packageData.description; + pluginData.version = packageData.version; + pluginData.repository = packageData.repository; + pluginData.nbbpm = packageData.nbbpm; + pluginData.path = pluginPath; + } catch (error) { + const pluginDir = path.basename(pluginPath); + + winston.error(`[plugins/${pluginDir}] Error in plugin.json or package.json!${error.stack}`); + throw new Error('[[error:parse-error]]'); + } + + return pluginData; }; function parseLicense(packageData) { - try { - const licenseData = require(`spdx-license-list/licenses/${packageData.license}`); - return { - name: licenseData.name, - text: licenseData.licenseText, - }; - } catch (e) { - // No license matched - return null; - } + try { + const licenseData = require(`spdx-license-list/licenses/${packageData.license}`); + return { + name: licenseData.name, + text: licenseData.licenseText, + }; + } catch { + // No license matched + return null; + } } Data.getActive = async function () { - const pluginPaths = await Data.getPluginPaths(); - return await Promise.all(pluginPaths.map(p => Data.loadPluginInfo(p))); + const pluginPaths = await Data.getPluginPaths(); + return await Promise.all(pluginPaths.map(p => Data.loadPluginInfo(p))); }; - Data.getStaticDirectories = async function (pluginData) { - const validMappedPath = /^[\w\-_]+$/; - - if (!pluginData.staticDirs) { - return; - } - - const dirs = Object.keys(pluginData.staticDirs); - if (!dirs.length) { - return; - } - - const staticDirs = {}; - - async function processDir(route) { - if (!validMappedPath.test(route)) { - winston.warn(`[plugins/${pluginData.id}] Invalid mapped path specified: ${ - route}. Path must adhere to: ${validMappedPath.toString()}`); - return; - } - const dirPath = await resolveModulePath(pluginData.path, pluginData.staticDirs[route]); - if (!dirPath) { - winston.warn(`[plugins/${pluginData.id}] Invalid mapped path specified: ${ - route} => ${pluginData.staticDirs[route]}`); - return; - } - try { - const stats = await fs.promises.stat(dirPath); - if (!stats.isDirectory()) { - winston.warn(`[plugins/${pluginData.id}] Mapped path '${ - route} => ${dirPath}' is not a directory.`); - return; - } - - staticDirs[`${pluginData.id}/${route}`] = dirPath; - } catch (err) { - if (err.code === 'ENOENT') { - winston.warn(`[plugins/${pluginData.id}] Mapped path '${ - route} => ${dirPath}' not found.`); - return; - } - throw err; - } - } - - await Promise.all(dirs.map(route => processDir(route))); - winston.verbose(`[plugins] found ${Object.keys(staticDirs).length} static directories for ${pluginData.id}`); - return staticDirs; + const validMappedPath = /^[\w\-_]+$/; + + if (!pluginData.staticDirs) { + return; + } + + const directories = Object.keys(pluginData.staticDirs); + if (directories.length === 0) { + return; + } + + const staticDirectories = {}; + + async function processDir(route) { + if (!validMappedPath.test(route)) { + winston.warn(`[plugins/${pluginData.id}] Invalid mapped path specified: ${ + route}. Path must adhere to: ${validMappedPath.toString()}`); + return; + } + + const dirPath = await resolveModulePath(pluginData.path, pluginData.staticDirs[route]); + if (!dirPath) { + winston.warn(`[plugins/${pluginData.id}] Invalid mapped path specified: ${ + route} => ${pluginData.staticDirs[route]}`); + return; + } + + try { + const stats = await fs.promises.stat(dirPath); + if (!stats.isDirectory()) { + winston.warn(`[plugins/${pluginData.id}] Mapped path '${ + route} => ${dirPath}' is not a directory.`); + return; + } + + staticDirectories[`${pluginData.id}/${route}`] = dirPath; + } catch (error) { + if (error.code === 'ENOENT') { + winston.warn(`[plugins/${pluginData.id}] Mapped path '${ + route} => ${dirPath}' not found.`); + return; + } + + throw error; + } + } + + await Promise.all(directories.map(route => processDir(route))); + winston.verbose(`[plugins] found ${Object.keys(staticDirectories).length} static directories for ${pluginData.id}`); + return staticDirectories; }; - Data.getFiles = async function (pluginData, type) { - if (!Array.isArray(pluginData[type]) || !pluginData[type].length) { - return; - } + if (!Array.isArray(pluginData[type]) || pluginData[type].length === 0) { + return; + } - winston.verbose(`[plugins] Found ${pluginData[type].length} ${type} file(s) for plugin ${pluginData.id}`); + winston.verbose(`[plugins] Found ${pluginData[type].length} ${type} file(s) for plugin ${pluginData.id}`); - return pluginData[type].map(file => path.join(pluginData.id, file)); + return pluginData[type].map(file => path.join(pluginData.id, file)); }; /** @@ -151,115 +153,113 @@ Data.getFiles = async function (pluginData, type) { * This method resolves these differences if it can. */ async function resolveModulePath(basePath, modulePath) { - const isNodeModule = /node_modules/; - - const currentPath = path.join(basePath, modulePath); - const exists = await file.exists(currentPath); - if (exists) { - return currentPath; - } - if (!isNodeModule.test(modulePath)) { - winston.warn(`[plugins] File not found: ${currentPath} (Ignoring)`); - return; - } - - const dirPath = path.dirname(basePath); - if (dirPath === basePath) { - winston.warn(`[plugins] File not found: ${currentPath} (Ignoring)`); - return; - } - - return await resolveModulePath(dirPath, modulePath); + const isNodeModule = /node_modules/; + + const currentPath = path.join(basePath, modulePath); + const exists = await file.exists(currentPath); + if (exists) { + return currentPath; + } + + if (!isNodeModule.test(modulePath)) { + winston.warn(`[plugins] File not found: ${currentPath} (Ignoring)`); + return; + } + + const dirPath = path.dirname(basePath); + if (dirPath === basePath) { + winston.warn(`[plugins] File not found: ${currentPath} (Ignoring)`); + return; + } + + return await resolveModulePath(dirPath, modulePath); } - Data.getScripts = async function getScripts(pluginData, target) { - target = (target === 'client') ? 'scripts' : 'acpScripts'; - - const input = pluginData[target]; - if (!Array.isArray(input) || !input.length) { - return; - } - - const scripts = []; - - for (const filePath of input) { - /* eslint-disable no-await-in-loop */ - const modulePath = await resolveModulePath(pluginData.path, filePath); - if (modulePath) { - scripts.push(modulePath); - } - } - if (scripts.length) { - winston.verbose(`[plugins] Found ${scripts.length} js file(s) for plugin ${pluginData.id}`); - } - return scripts; -}; + target = (target === 'client') ? 'scripts' : 'acpScripts'; + const input = pluginData[target]; + if (!Array.isArray(input) || input.length === 0) { + return; + } + + const scripts = []; + + for (const filePath of input) { + /* eslint-disable no-await-in-loop */ + const modulePath = await resolveModulePath(pluginData.path, filePath); + if (modulePath) { + scripts.push(modulePath); + } + } + + if (scripts.length > 0) { + winston.verbose(`[plugins] Found ${scripts.length} js file(s) for plugin ${pluginData.id}`); + } + + return scripts; +}; Data.getModules = async function getModules(pluginData) { - if (!pluginData.modules || !pluginData.hasOwnProperty('modules')) { - return; - } - - let pluginModules = pluginData.modules; - - if (Array.isArray(pluginModules)) { - const strip = parseInt(pluginData.modulesStrip, 10) || 0; - - pluginModules = pluginModules.reduce((prev, modulePath) => { - let key; - if (strip) { - key = modulePath.replace(new RegExp(`.?(/[^/]+){${strip}}/`), ''); - } else { - key = path.basename(modulePath); - } - - prev[key] = modulePath; - return prev; - }, {}); - } - - const modules = {}; - async function processModule(key) { - const modulePath = await resolveModulePath(pluginData.path, pluginModules[key]); - if (modulePath) { - modules[key] = path.relative(basePath, modulePath); - } - } - - await Promise.all(Object.keys(pluginModules).map(key => processModule(key))); - - const len = Object.keys(modules).length; - winston.verbose(`[plugins] Found ${len} AMD-style module(s) for plugin ${pluginData.id}`); - return modules; + if (!pluginData.modules || !pluginData.hasOwnProperty('modules')) { + return; + } + + let pluginModules = pluginData.modules; + + if (Array.isArray(pluginModules)) { + const strip = Number.parseInt(pluginData.modulesStrip, 10) || 0; + + pluginModules = pluginModules.reduce((previous, modulePath) => { + let key; + key = strip ? modulePath.replace(new RegExp(`.?(/[^/]+){${strip}}/`), '') : path.basename(modulePath); + + previous[key] = modulePath; + return previous; + }, {}); + } + + const modules = {}; + async function processModule(key) { + const modulePath = await resolveModulePath(pluginData.path, pluginModules[key]); + if (modulePath) { + modules[key] = path.relative(basePath, modulePath); + } + } + + await Promise.all(Object.keys(pluginModules).map(key => processModule(key))); + + const length = Object.keys(modules).length; + winston.verbose(`[plugins] Found ${length} AMD-style module(s) for plugin ${pluginData.id}`); + return modules; }; Data.getLanguageData = async function getLanguageData(pluginData) { - if (typeof pluginData.languages !== 'string') { - return; - } - - const pathToFolder = path.join(paths.nodeModules, pluginData.id, pluginData.languages); - const filepaths = await file.walk(pathToFolder); - - const namespaces = []; - const languages = []; - - filepaths.forEach((p) => { - const rel = path.relative(pathToFolder, p).split(/[/\\]/); - const language = rel.shift().replace('_', '-').replace('@', '-x-'); - const namespace = rel.join('/').replace(/\.json$/, ''); - - if (!language || !namespace) { - return; - } - - languages.push(language); - namespaces.push(namespace); - }); - return { - languages: _.uniq(languages), - namespaces: _.uniq(namespaces), - }; + if (typeof pluginData.languages !== 'string') { + return; + } + + const pathToFolder = path.join(paths.nodeModules, pluginData.id, pluginData.languages); + const filepaths = await file.walk(pathToFolder); + + const namespaces = []; + const languages = []; + + for (const p of filepaths) { + const rel = path.relative(pathToFolder, p).split(/[/\\]/); + const language = rel.shift().replace('_', '-').replace('@', '-x-'); + const namespace = rel.join('/').replace(/\.json$/, ''); + + if (!language || !namespace) { + continue; + } + + languages.push(language); + namespaces.push(namespace); + } + + return { + languages: _.uniq(languages), + namespaces: _.uniq(namespaces), + }; }; diff --git a/src/plugins/hooks.js b/src/plugins/hooks.js index 23b3aa9..edd34ba 100644 --- a/src/plugins/hooks.js +++ b/src/plugins/hooks.js @@ -1,57 +1,57 @@ 'use strict'; -const util = require('util'); +const util = require('node:util'); const winston = require('winston'); -const plugins = require('.'); const utils = require('../utils'); +const plugins = require('.'); const Hooks = module.exports; Hooks._deprecated = new Map([ - ['filter:email.send', { - new: 'static:email.send', - since: 'v1.17.0', - until: 'v2.0.0', - }], - ['filter:router.page', { - new: 'response:router.page', - since: 'v1.15.3', - until: 'v2.1.0', - }], - ['filter:post.purge', { - new: 'filter:posts.purge', - since: 'v1.19.6', - until: 'v2.1.0', - }], - ['action:post.purge', { - new: 'action:posts.purge', - since: 'v1.19.6', - until: 'v2.1.0', - }], - ['filter:user.verify.code', { - new: 'filter:user.verify', - since: 'v2.2.0', - until: 'v3.0.0', - }], - ['filter:flags.getFilters', { - new: 'filter:flags.init', - since: 'v2.7.0', - until: 'v3.0.0', - }], + ['filter:email.send', { + new: 'static:email.send', + since: 'v1.17.0', + until: 'v2.0.0', + }], + ['filter:router.page', { + new: 'response:router.page', + since: 'v1.15.3', + until: 'v2.1.0', + }], + ['filter:post.purge', { + new: 'filter:posts.purge', + since: 'v1.19.6', + until: 'v2.1.0', + }], + ['action:post.purge', { + new: 'action:posts.purge', + since: 'v1.19.6', + until: 'v2.1.0', + }], + ['filter:user.verify.code', { + new: 'filter:user.verify', + since: 'v2.2.0', + until: 'v3.0.0', + }], + ['filter:flags.getFilters', { + new: 'filter:flags.init', + since: 'v2.7.0', + until: 'v3.0.0', + }], ]); Hooks.internals = { - _register: function (data) { - plugins.loadedHooks[data.hook] = plugins.loadedHooks[data.hook] || []; - plugins.loadedHooks[data.hook].push(data); - }, + _register(data) { + plugins.loadedHooks[data.hook] = plugins.loadedHooks[data.hook] || []; + plugins.loadedHooks[data.hook].push(data); + }, }; const hookTypeToMethod = { - filter: fireFilterHook, - action: fireActionHook, - static: fireStaticHook, - response: fireResponseHook, + filter: fireFilterHook, + action: fireActionHook, + static: fireStaticHook, + response: fireResponseHook, }; /* @@ -61,220 +61,231 @@ const hookTypeToMethod = { `data.priority`, the relative priority of the method when it is eventually called (default: 10) */ Hooks.register = function (id, data) { - if (!data.hook || !data.method) { - winston.warn(`[plugins/${id}] registerHook called with invalid data.hook/method`, data); - return; - } - - // `hasOwnProperty` needed for hooks with no alternative (set to null) - if (Hooks._deprecated.has(data.hook)) { - const deprecation = Hooks._deprecated.get(data.hook); - if (!deprecation.hasOwnProperty('affected')) { - deprecation.affected = new Set(); - } - deprecation.affected.add(id); - Hooks._deprecated.set(data.hook, deprecation); - } - - data.id = id; - if (!data.priority) { - data.priority = 10; - } - - if (Array.isArray(data.method) && data.method.every(method => typeof method === 'function' || typeof method === 'string')) { - // Go go gadget recursion! - data.method.forEach((method) => { - const singularData = { ...data, method: method }; - Hooks.register(id, singularData); - }); - } else if (typeof data.method === 'string' && data.method.length > 0) { - const method = data.method.split('.').reduce((memo, prop) => { - if (memo && memo[prop]) { - return memo[prop]; - } - // Couldn't find method by path, aborting - return null; - }, plugins.libraries[data.id]); - - // Write the actual method reference to the hookObj - data.method = method; - - Hooks.internals._register(data); - } else if (typeof data.method === 'function') { - Hooks.internals._register(data); - } else { - winston.warn(`[plugins/${id}] Hook method mismatch: ${data.hook} => ${data.method}`); - } + if (!data.hook || !data.method) { + winston.warn(`[plugins/${id}] registerHook called with invalid data.hook/method`, data); + return; + } + + // `hasOwnProperty` needed for hooks with no alternative (set to null) + if (Hooks._deprecated.has(data.hook)) { + const deprecation = Hooks._deprecated.get(data.hook); + if (!deprecation.hasOwnProperty('affected')) { + deprecation.affected = new Set(); + } + + deprecation.affected.add(id); + Hooks._deprecated.set(data.hook, deprecation); + } + + data.id = id; + data.priority ||= 10; + + if (Array.isArray(data.method) && data.method.every(method => typeof method === 'function' || typeof method === 'string')) { + // Go go gadget recursion! + for (const method of data.method) { + const singularData = {...data, method}; + Hooks.register(id, singularData); + } + } else if (typeof data.method === 'string' && data.method.length > 0) { + const method = data.method.split('.').reduce((memo, property) => { + if (memo && memo[property]) { + return memo[property]; + } + + // Couldn't find method by path, aborting + return null; + }, plugins.libraries[data.id]); + + // Write the actual method reference to the hookObj + data.method = method; + + Hooks.internals._register(data); + } else if (typeof data.method === 'function') { + Hooks.internals._register(data); + } else { + winston.warn(`[plugins/${id}] Hook method mismatch: ${data.hook} => ${data.method}`); + } }; Hooks.unregister = function (id, hook, method) { - const hooks = plugins.loadedHooks[hook] || []; - plugins.loadedHooks[hook] = hooks.filter(hookData => hookData && hookData.id !== id && hookData.method !== method); + const hooks = plugins.loadedHooks[hook] || []; + plugins.loadedHooks[hook] = hooks.filter(hookData => hookData && hookData.id !== id && hookData.method !== method); }; -Hooks.fire = async function (hook, params) { - const hookList = plugins.loadedHooks[hook]; - const hookType = hook.split(':')[0]; - if (global.env === 'development' && hook !== 'action:plugins.firehook' && hook !== 'filter:plugins.firehook') { - winston.verbose(`[plugins/fireHook] ${hook}`); - } - - if (!hookTypeToMethod[hookType]) { - winston.warn(`[plugins] Unknown hookType: ${hookType}, hook : ${hook}`); - return; - } - let deleteCaller = false; - if (params && typeof params === 'object' && !Array.isArray(params) && !params.hasOwnProperty('caller')) { - const als = require('../als'); - params.caller = als.getStore(); - deleteCaller = true; - } - const result = await hookTypeToMethod[hookType](hook, hookList, params); - - if (hook !== 'action:plugins.firehook' && hook !== 'filter:plugins.firehook') { - const payload = await Hooks.fire('filter:plugins.firehook', { hook: hook, params: result || params }); - Hooks.fire('action:plugins.firehook', payload); - } - if (result !== undefined) { - if (deleteCaller && result && result.hasOwnProperty('caller')) { - delete result.caller; - } - return result; - } +Hooks.fire = async function (hook, parameters) { + const hookList = plugins.loadedHooks[hook]; + const hookType = hook.split(':')[0]; + if (global.env === 'development' && hook !== 'action:plugins.firehook' && hook !== 'filter:plugins.firehook') { + winston.verbose(`[plugins/fireHook] ${hook}`); + } + + if (!hookTypeToMethod[hookType]) { + winston.warn(`[plugins] Unknown hookType: ${hookType}, hook : ${hook}`); + return; + } + + let deleteCaller = false; + if (parameters && typeof parameters === 'object' && !Array.isArray(parameters) && !parameters.hasOwnProperty('caller')) { + const als = require('../als'); + parameters.caller = als.getStore(); + deleteCaller = true; + } + + const result = await hookTypeToMethod[hookType](hook, hookList, parameters); + + if (hook !== 'action:plugins.firehook' && hook !== 'filter:plugins.firehook') { + const payload = await Hooks.fire('filter:plugins.firehook', {hook, params: result || parameters}); + Hooks.fire('action:plugins.firehook', payload); + } + + if (result !== undefined) { + if (deleteCaller && result && result.hasOwnProperty('caller')) { + delete result.caller; + } + + return result; + } }; Hooks.hasListeners = function (hook) { - return !!(plugins.loadedHooks[hook] && plugins.loadedHooks[hook].length > 0); + return Boolean(plugins.loadedHooks[hook] && plugins.loadedHooks[hook].length > 0); }; -async function fireFilterHook(hook, hookList, params) { - if (!Array.isArray(hookList) || !hookList.length) { - return params; - } - - async function fireMethod(hookObj, params) { - if (typeof hookObj.method !== 'function') { - if (global.env === 'development') { - winston.warn(`[plugins] Expected method for hook '${hook}' in plugin '${hookObj.id}' not found, skipping.`); - } - return params; - } - - if (hookObj.method.constructor && hookObj.method.constructor.name === 'AsyncFunction') { - return await hookObj.method(params); - } - return new Promise((resolve, reject) => { - let resolved = false; - function _resolve(result) { - if (resolved) { - winston.warn(`[plugins] ${hook} already resolved in plugin ${hookObj.id}`); - return; - } - resolved = true; - resolve(result); - } - const returned = hookObj.method(params, (err, result) => { - if (err) reject(err); else _resolve(result); - }); - - if (utils.isPromise(returned)) { - returned.then( - payload => _resolve(payload), - err => reject(err) - ); - return; - } - if (returned) { - _resolve(returned); - } - }); - } - - for (const hookObj of hookList) { - // eslint-disable-next-line - params = await fireMethod(hookObj, params); - } - return params; +async function fireFilterHook(hook, hookList, parameters) { + if (!Array.isArray(hookList) || hookList.length === 0) { + return parameters; + } + + async function fireMethod(hookObject, parameters_) { + if (typeof hookObject.method !== 'function') { + if (global.env === 'development') { + winston.warn(`[plugins] Expected method for hook '${hook}' in plugin '${hookObject.id}' not found, skipping.`); + } + + return parameters_; + } + + if (hookObject.method.constructor && hookObject.method.constructor.name === 'AsyncFunction') { + return await hookObject.method(parameters_); + } + + return new Promise((resolve, reject) => { + let resolved = false; + function _resolve(result) { + if (resolved) { + winston.warn(`[plugins] ${hook} already resolved in plugin ${hookObject.id}`); + return; + } + + resolved = true; + resolve(result); + } + + const returned = hookObject.method(parameters_, (error, result) => { + if (error) { + reject(error); + } else { + _resolve(result); + } + }); + + if (utils.isPromise(returned)) { + returned.then( + payload => _resolve(payload), + error => reject(error), + ); + return; + } + + if (returned) { + _resolve(returned); + } + }); + } + + for (const hookObject of hookList) { + // eslint-disable-next-line + parameters = await fireMethod(hookObject, parameters); + } + + return parameters; } -async function fireActionHook(hook, hookList, params) { - if (!Array.isArray(hookList) || !hookList.length) { - return; - } - for (const hookObj of hookList) { - if (typeof hookObj.method !== 'function') { - if (global.env === 'development') { - winston.warn(`[plugins] Expected method for hook '${hook}' in plugin '${hookObj.id}' not found, skipping.`); - } - } else { - // eslint-disable-next-line - await hookObj.method(params); - } - } +async function fireActionHook(hook, hookList, parameters) { + if (!Array.isArray(hookList) || hookList.length === 0) { + return; + } + + for (const hookObject of hookList) { + if (typeof hookObject.method === 'function') { + // eslint-disable-next-line + await hookObject.method(parameters); + } else if (global.env === 'development') { + winston.warn(`[plugins] Expected method for hook '${hook}' in plugin '${hookObject.id}' not found, skipping.`); + } + } } -async function fireStaticHook(hook, hookList, params) { - if (!Array.isArray(hookList) || !hookList.length) { - return; - } - // don't bubble errors from these hooks, so bad plugins don't stop startup - const noErrorHooks = ['static:app.load', 'static:assets.prepare', 'static:app.preload']; - - for (const hookObj of hookList) { - if (typeof hookObj.method !== 'function') { - if (global.env === 'development') { - winston.warn(`[plugins] Expected method for hook '${hook}' in plugin '${hookObj.id}' not found, skipping.`); - } - } else { - let hookFn = hookObj.method; - if (hookFn.constructor && hookFn.constructor.name !== 'AsyncFunction') { - hookFn = util.promisify(hookFn); - } - - try { - // eslint-disable-next-line - await timeout(hookFn(params), 5000, 'timeout'); - } catch (err) { - if (err && err.message === 'timeout') { - winston.warn(`[plugins] Callback timed out, hook '${hook}' in plugin '${hookObj.id}'`); - } else { - winston.error(`[plugins] Error executing '${hook}' in plugin '${hookObj.id}'\n${err.stack}`); - if (!noErrorHooks.includes(hook)) { - throw err; - } - } - } - } - } +async function fireStaticHook(hook, hookList, parameters) { + if (!Array.isArray(hookList) || hookList.length === 0) { + return; + } + + // Don't bubble errors from these hooks, so bad plugins don't stop startup + const noErrorHooks = new Set(['static:app.load', 'static:assets.prepare', 'static:app.preload']); + + for (const hookObject of hookList) { + if (typeof hookObject.method === 'function') { + let hookFunction = hookObject.method; + if (hookFunction.constructor && hookFunction.constructor.name !== 'AsyncFunction') { + hookFunction = util.promisify(hookFunction); + } + + try { + // eslint-disable-next-line + await timeout(hookFunction(parameters), 5000, 'timeout'); + } catch (error) { + if (error && error.message === 'timeout') { + winston.warn(`[plugins] Callback timed out, hook '${hook}' in plugin '${hookObject.id}'`); + } else { + winston.error(`[plugins] Error executing '${hook}' in plugin '${hookObject.id}'\n${error.stack}`); + if (!noErrorHooks.has(hook)) { + throw error; + } + } + } + } else if (global.env === 'development') { + winston.warn(`[plugins] Expected method for hook '${hook}' in plugin '${hookObject.id}' not found, skipping.`); + } + } } // https://advancedweb.hu/how-to-add-timeout-to-a-promise-in-javascript/ const timeout = (prom, time, error) => { - let timer; - return Promise.race([ - prom, - new Promise((resolve, reject) => { - timer = setTimeout(reject, time, new Error(error)); - }), - ]).finally(() => clearTimeout(timer)); + let timer; + return Promise.race([ + prom, + new Promise((resolve, reject) => { + timer = setTimeout(reject, time, new Error(error)); + }), + ]).finally(() => clearTimeout(timer)); }; -async function fireResponseHook(hook, hookList, params) { - if (!Array.isArray(hookList) || !hookList.length) { - return; - } - for (const hookObj of hookList) { - if (typeof hookObj.method !== 'function') { - if (global.env === 'development') { - winston.warn(`[plugins] Expected method for hook '${hook}' in plugin '${hookObj.id}' not found, skipping.`); - } - } else { - // Skip remaining hooks if headers have been sent - if (params.res.headersSent) { - return; - } - // eslint-disable-next-line - await hookObj.method(params); - } - } +async function fireResponseHook(hook, hookList, parameters) { + if (!Array.isArray(hookList) || hookList.length === 0) { + return; + } + + for (const hookObject of hookList) { + if (typeof hookObject.method === 'function') { + // Skip remaining hooks if headers have been sent + if (parameters.res.headersSent) { + return; + } + // eslint-disable-next-line + await hookObject.method(parameters); + } else if (global.env === 'development') { + winston.warn(`[plugins] Expected method for hook '${hook}' in plugin '${hookObject.id}' not found, skipping.`); + } + } } diff --git a/src/plugins/index.js b/src/plugins/index.js index d16e048..ce0e93e 100644 --- a/src/plugins/index.js +++ b/src/plugins/index.js @@ -1,18 +1,16 @@ 'use strict'; -const fs = require('fs'); -const path = require('path'); +const fs = require('node:fs'); +const path = require('node:path'); const winston = require('winston'); const semver = require('semver'); const nconf = require('nconf'); const chalk = require('chalk'); const request = require('request-promise-native'); - const user = require('../user'); const posts = require('../posts'); const meta = require('../meta'); - -const { pluginNamePattern, themeNamePattern, paths } = require('../constants'); +const {pluginNamePattern, themeNamePattern, paths} = require('../constants'); let app; let middleware; @@ -45,276 +43,285 @@ Plugins.loadedPlugins = []; Plugins.initialized = false; Plugins.requireLibrary = function (pluginData) { - let libraryPath; - // attempt to load a plugin directly with `require("nodebb-plugin-*")` - // Plugins should define their entry point in the standard `main` property of `package.json` - try { - libraryPath = pluginData.path; - Plugins.libraries[pluginData.id] = require(libraryPath); - } catch (e) { - // DEPRECATED: @1.15.0, remove in version >=1.17 - // for backwards compatibility - // if that fails, fall back to `pluginData.library` - if (pluginData.library) { - winston.warn(` [plugins/${pluginData.id}] The plugin.json field "library" is deprecated. Please use the package.json field "main" instead.`); - winston.verbose(`[plugins/${pluginData.id}] See https://github.com/NodeBB/NodeBB/issues/8686`); - - libraryPath = path.join(pluginData.path, pluginData.library); - Plugins.libraries[pluginData.id] = require(libraryPath); - } else { - throw e; - } - } - - Plugins.libraryPaths.push(libraryPath); + let libraryPath; + // Attempt to load a plugin directly with `require("nodebb-plugin-*")` + // Plugins should define their entry point in the standard `main` property of `package.json` + try { + libraryPath = pluginData.path; + Plugins.libraries[pluginData.id] = require(libraryPath); + } catch (error) { + // DEPRECATED: @1.15.0, remove in version >=1.17 + // for backwards compatibility + // if that fails, fall back to `pluginData.library` + if (pluginData.library) { + winston.warn(` [plugins/${pluginData.id}] The plugin.json field "library" is deprecated. Please use the package.json field "main" instead.`); + winston.verbose(`[plugins/${pluginData.id}] See https://github.com/NodeBB/NodeBB/issues/8686`); + + libraryPath = path.join(pluginData.path, pluginData.library); + Plugins.libraries[pluginData.id] = require(libraryPath); + } else { + throw error; + } + } + + Plugins.libraryPaths.push(libraryPath); }; Plugins.init = async function (nbbApp, nbbMiddleware) { - if (Plugins.initialized) { - return; - } + if (Plugins.initialized) { + return; + } - if (nbbApp) { - app = nbbApp; - middleware = nbbMiddleware; - } + if (nbbApp) { + app = nbbApp; + middleware = nbbMiddleware; + } - if (global.env === 'development') { - winston.verbose('[plugins] Initializing plugins system'); - } + if (global.env === 'development') { + winston.verbose('[plugins] Initializing plugins system'); + } - await Plugins.reload(); - if (global.env === 'development') { - winston.info('[plugins] Plugins OK'); - } + await Plugins.reload(); + if (global.env === 'development') { + winston.info('[plugins] Plugins OK'); + } - Plugins.initialized = true; + Plugins.initialized = true; }; Plugins.reload = async function () { - // Resetting all local plugin data - Plugins.libraries = {}; - Plugins.loadedHooks = {}; - Plugins.staticDirs = {}; - Plugins.versionWarning = []; - Plugins.cssFiles.length = 0; - Plugins.lessFiles.length = 0; - Plugins.acpLessFiles.length = 0; - Plugins.clientScripts.length = 0; - Plugins.acpScripts.length = 0; - Plugins.libraryPaths.length = 0; - Plugins.loadedPlugins.length = 0; - - await user.addInterstitials(); - - const paths = await Plugins.getPluginPaths(); - for (const path of paths) { - /* eslint-disable no-await-in-loop */ - await Plugins.loadPlugin(path); - } - - // If some plugins are incompatible, throw the warning here - if (Plugins.versionWarning.length && nconf.get('isPrimary')) { - console.log(''); - winston.warn('[plugins/load] The following plugins may not be compatible with your version of NodeBB. This may cause unintended behaviour or crashing. In the event of an unresponsive NodeBB caused by this plugin, run `./nodebb reset -p PLUGINNAME` to disable it.'); - for (let x = 0, numPlugins = Plugins.versionWarning.length; x < numPlugins; x += 1) { - console.log(`${chalk.yellow(' * ') + Plugins.versionWarning[x]}`); - } - console.log(''); - } - - // Core hooks - posts.registerHooks(); - meta.configs.registerHooks(); - - // Deprecation notices - Plugins.hooks._deprecated.forEach((deprecation, hook) => { - if (!deprecation.affected || !deprecation.affected.size) { - return; - } - - const replacement = deprecation.hasOwnProperty('new') ? `Please use ${chalk.yellow(deprecation.new)} instead.` : 'There is no alternative.'; - winston.warn(`[plugins/load] ${chalk.white.bgRed.bold('DEPRECATION')} The hook ${chalk.yellow(hook)} has been deprecated as of ${deprecation.since}, and slated for removal in ${deprecation.until}. ${replacement} The following plugins are still listening for this hook:`); - deprecation.affected.forEach(id => console.log(` ${chalk.yellow('*')} ${id}`)); - }); - - // Lower priority runs earlier - Object.keys(Plugins.loadedHooks).forEach((hook) => { - Plugins.loadedHooks[hook].sort((a, b) => a.priority - b.priority); - }); - - // Post-reload actions - await posts.configureSanitize(); + // Resetting all local plugin data + Plugins.libraries = {}; + Plugins.loadedHooks = {}; + Plugins.staticDirs = {}; + Plugins.versionWarning = []; + Plugins.cssFiles.length = 0; + Plugins.lessFiles.length = 0; + Plugins.acpLessFiles.length = 0; + Plugins.clientScripts.length = 0; + Plugins.acpScripts.length = 0; + Plugins.libraryPaths.length = 0; + Plugins.loadedPlugins.length = 0; + + await user.addInterstitials(); + + const paths = await Plugins.getPluginPaths(); + for (const path of paths) { + /* eslint-disable no-await-in-loop */ + await Plugins.loadPlugin(path); + } + + // If some plugins are incompatible, throw the warning here + if (Plugins.versionWarning.length > 0 && nconf.get('isPrimary')) { + console.log(''); + winston.warn('[plugins/load] The following plugins may not be compatible with your version of NodeBB. This may cause unintended behaviour or crashing. In the event of an unresponsive NodeBB caused by this plugin, run `./nodebb reset -p PLUGINNAME` to disable it.'); + for (let x = 0, numberPlugins = Plugins.versionWarning.length; x < numberPlugins; x += 1) { + console.log(`${chalk.yellow(' * ') + Plugins.versionWarning[x]}`); + } + + console.log(''); + } + + // Core hooks + posts.registerHooks(); + meta.configs.registerHooks(); + + // Deprecation notices + for (const [hook, deprecation] of Plugins.hooks._deprecated.entries()) { + if (!deprecation.affected || deprecation.affected.size === 0) { + continue; + } + + const replacement = deprecation.hasOwnProperty('new') ? `Please use ${chalk.yellow(deprecation.new)} instead.` : 'There is no alternative.'; + winston.warn(`[plugins/load] ${chalk.white.bgRed.bold('DEPRECATION')} The hook ${chalk.yellow(hook)} has been deprecated as of ${deprecation.since}, and slated for removal in ${deprecation.until}. ${replacement} The following plugins are still listening for this hook:`); + for (const id of deprecation.affected) { + console.log(` ${chalk.yellow('*')} ${id}`); + } + } + + // Lower priority runs earlier + for (const hook of Object.keys(Plugins.loadedHooks)) { + Plugins.loadedHooks[hook].sort((a, b) => a.priority - b.priority); + } + + // Post-reload actions + await posts.configureSanitize(); }; -Plugins.reloadRoutes = async function (params) { - const controllers = require('../controllers'); - await Plugins.hooks.fire('static:app.load', { app: app, router: params.router, middleware: middleware, controllers: controllers }); - winston.verbose('[plugins] All plugins reloaded and rerouted'); +Plugins.reloadRoutes = async function (parameters) { + const controllers = require('../controllers'); + await Plugins.hooks.fire('static:app.load', { + app, router: parameters.router, middleware, controllers, + }); + winston.verbose('[plugins] All plugins reloaded and rerouted'); }; Plugins.get = async function (id) { - const url = `${nconf.get('registry') || 'https://packages.nodebb.org'}/api/v1/plugins/${id}`; - const body = await request(url, { - json: true, - }); - - let normalised = await Plugins.normalise([body ? body.payload : {}]); - normalised = normalised.filter(plugin => plugin.id === id); - return normalised.length ? normalised[0] : undefined; + const url = `${nconf.get('registry') || 'https://packages.nodebb.org'}/api/v1/plugins/${id}`; + const body = await request(url, { + json: true, + }); + + let normalised = await Plugins.normalise([body ? body.payload : {}]); + normalised = normalised.filter(plugin => plugin.id === id); + return normalised.length > 0 ? normalised[0] : undefined; }; Plugins.list = async function (matching) { - if (matching === undefined) { - matching = true; - } - const { version } = require(paths.currentPackage); - const url = `${nconf.get('registry') || 'https://packages.nodebb.org'}/api/v1/plugins${matching !== false ? `?version=${version}` : ''}`; - try { - const body = await request(url, { - json: true, - }); - return await Plugins.normalise(body); - } catch (err) { - winston.error(`Error loading ${url}`, err); - return await Plugins.normalise([]); - } + if (matching === undefined) { + matching = true; + } + + const {version} = require(paths.currentPackage); + const url = `${nconf.get('registry') || 'https://packages.nodebb.org'}/api/v1/plugins${matching === false ? '' : `?version=${version}`}`; + try { + const body = await request(url, { + json: true, + }); + return await Plugins.normalise(body); + } catch (error) { + winston.error(`Error loading ${url}`, error); + return await Plugins.normalise([]); + } }; Plugins.listTrending = async () => { - const url = `${nconf.get('registry') || 'https://packages.nodebb.org'}/api/v1/analytics/top/week`; - return await request(url, { - json: true, - }); + const url = `${nconf.get('registry') || 'https://packages.nodebb.org'}/api/v1/analytics/top/week`; + return await request(url, { + json: true, + }); }; Plugins.normalise = async function (apiReturn) { - const pluginMap = {}; - const { dependencies } = require(paths.currentPackage); - apiReturn = Array.isArray(apiReturn) ? apiReturn : []; - apiReturn.forEach((packageData) => { - packageData.id = packageData.name; - packageData.installed = false; - packageData.active = false; - packageData.url = packageData.url || (packageData.repository ? packageData.repository.url : ''); - pluginMap[packageData.name] = packageData; - }); - - let installedPlugins = await Plugins.showInstalled(); - installedPlugins = installedPlugins.filter(plugin => plugin && !plugin.system); - - installedPlugins.forEach((plugin) => { - // If it errored out because a package.json or plugin.json couldn't be read, no need to do this stuff - if (plugin.error) { - pluginMap[plugin.id] = pluginMap[plugin.id] || {}; - pluginMap[plugin.id].installed = true; - pluginMap[plugin.id].error = true; - return; - } - - pluginMap[plugin.id] = pluginMap[plugin.id] || {}; - pluginMap[plugin.id].id = pluginMap[plugin.id].id || plugin.id; - pluginMap[plugin.id].name = plugin.name || pluginMap[plugin.id].name; - pluginMap[plugin.id].description = plugin.description; - pluginMap[plugin.id].url = pluginMap[plugin.id].url || plugin.url; - pluginMap[plugin.id].installed = true; - pluginMap[plugin.id].isTheme = themeNamePattern.test(plugin.id); - pluginMap[plugin.id].error = plugin.error || false; - pluginMap[plugin.id].active = plugin.active; - pluginMap[plugin.id].version = plugin.version; - pluginMap[plugin.id].settingsRoute = plugin.settingsRoute; - pluginMap[plugin.id].license = plugin.license; - - // If package.json defines a version to use, stick to that - if (dependencies.hasOwnProperty(plugin.id) && semver.valid(dependencies[plugin.id])) { - pluginMap[plugin.id].latest = dependencies[plugin.id]; - } else { - pluginMap[plugin.id].latest = pluginMap[plugin.id].latest || plugin.version; - } - pluginMap[plugin.id].outdated = semver.gt(pluginMap[plugin.id].latest, pluginMap[plugin.id].version); - }); - - const pluginArray = Object.values(pluginMap); - - pluginArray.sort((a, b) => { - if (a.name > b.name) { - return 1; - } else if (a.name < b.name) { - return -1; - } - return 0; - }); - - return pluginArray; + const pluginMap = {}; + const {dependencies} = require(paths.currentPackage); + apiReturn = Array.isArray(apiReturn) ? apiReturn : []; + for (const packageData of apiReturn) { + packageData.id = packageData.name; + packageData.installed = false; + packageData.active = false; + packageData.url = packageData.url || (packageData.repository ? packageData.repository.url : ''); + pluginMap[packageData.name] = packageData; + } + + let installedPlugins = await Plugins.showInstalled(); + installedPlugins = installedPlugins.filter(plugin => plugin && !plugin.system); + + for (const plugin of installedPlugins) { + // If it errored out because a package.json or plugin.json couldn't be read, no need to do this stuff + if (plugin.error) { + pluginMap[plugin.id] = pluginMap[plugin.id] || {}; + pluginMap[plugin.id].installed = true; + pluginMap[plugin.id].error = true; + continue; + } + + pluginMap[plugin.id] = pluginMap[plugin.id] || {}; + pluginMap[plugin.id].id = pluginMap[plugin.id].id || plugin.id; + pluginMap[plugin.id].name = plugin.name || pluginMap[plugin.id].name; + pluginMap[plugin.id].description = plugin.description; + pluginMap[plugin.id].url = pluginMap[plugin.id].url || plugin.url; + pluginMap[plugin.id].installed = true; + pluginMap[plugin.id].isTheme = themeNamePattern.test(plugin.id); + pluginMap[plugin.id].error = plugin.error || false; + pluginMap[plugin.id].active = plugin.active; + pluginMap[plugin.id].version = plugin.version; + pluginMap[plugin.id].settingsRoute = plugin.settingsRoute; + pluginMap[plugin.id].license = plugin.license; + + // If package.json defines a version to use, stick to that + pluginMap[plugin.id].latest = dependencies.hasOwnProperty(plugin.id) && semver.valid(dependencies[plugin.id]) ? dependencies[plugin.id] : pluginMap[plugin.id].latest || plugin.version; + + pluginMap[plugin.id].outdated = semver.gt(pluginMap[plugin.id].latest, pluginMap[plugin.id].version); + } + + const pluginArray = Object.values(pluginMap); + + pluginArray.sort((a, b) => { + if (a.name > b.name) { + return 1; + } + + if (a.name < b.name) { + return -1; + } + + return 0; + }); + + return pluginArray; }; Plugins.nodeModulesPath = paths.nodeModules; Plugins.showInstalled = async function () { - const dirs = await fs.promises.readdir(Plugins.nodeModulesPath); - - let pluginPaths = await findNodeBBModules(dirs); - pluginPaths = pluginPaths.map(dir => path.join(Plugins.nodeModulesPath, dir)); - - async function load(file) { - try { - const pluginData = await Plugins.loadPluginInfo(file); - const isActive = await Plugins.isActive(pluginData.name); - delete pluginData.hooks; - delete pluginData.library; - pluginData.active = isActive; - pluginData.installed = true; - pluginData.error = false; - return pluginData; - } catch (err) { - winston.error(err.stack); - } - } - const plugins = await Promise.all(pluginPaths.map(file => load(file))); - return plugins.filter(Boolean); + const directories = await fs.promises.readdir(Plugins.nodeModulesPath); + + let pluginPaths = await findNodeBBModules(directories); + pluginPaths = pluginPaths.map(dir => path.join(Plugins.nodeModulesPath, dir)); + + async function load(file) { + try { + const pluginData = await Plugins.loadPluginInfo(file); + const isActive = await Plugins.isActive(pluginData.name); + delete pluginData.hooks; + delete pluginData.library; + pluginData.active = isActive; + pluginData.installed = true; + pluginData.error = false; + return pluginData; + } catch (error) { + winston.error(error.stack); + } + } + + const plugins = await Promise.all(pluginPaths.map(file => load(file))); + return plugins.filter(Boolean); }; -async function findNodeBBModules(dirs) { - const pluginPaths = []; - await Promise.all(dirs.map(async (dirname) => { - const dirPath = path.join(Plugins.nodeModulesPath, dirname); - const isDir = await isDirectory(dirPath); - if (!isDir) { - return; - } - if (pluginNamePattern.test(dirname)) { - pluginPaths.push(dirname); - return; - } - - if (dirname[0] === '@') { - const subdirs = await fs.promises.readdir(dirPath); - await Promise.all(subdirs.map(async (subdir) => { - if (!pluginNamePattern.test(subdir)) { - return; - } - - const subdirPath = path.join(dirPath, subdir); - const isDir = await isDirectory(subdirPath); - if (isDir) { - pluginPaths.push(`${dirname}/${subdir}`); - } - })); - } - })); - return pluginPaths; +async function findNodeBBModules(directories) { + const pluginPaths = []; + await Promise.all(directories.map(async dirname => { + const dirPath = path.join(Plugins.nodeModulesPath, dirname); + const isDir = await isDirectory(dirPath); + if (!isDir) { + return; + } + + if (pluginNamePattern.test(dirname)) { + pluginPaths.push(dirname); + return; + } + + if (dirname[0] === '@') { + const subdirs = await fs.promises.readdir(dirPath); + await Promise.all(subdirs.map(async subdir => { + if (!pluginNamePattern.test(subdir)) { + return; + } + + const subdirPath = path.join(dirPath, subdir); + const isDir = await isDirectory(subdirPath); + if (isDir) { + pluginPaths.push(`${dirname}/${subdir}`); + } + })); + } + })); + return pluginPaths; } async function isDirectory(dirPath) { - try { - const stats = await fs.promises.stat(dirPath); - return stats.isDirectory(); - } catch (err) { - if (err.code !== 'ENOENT') { - throw err; - } - return false; - } + try { + const stats = await fs.promises.stat(dirPath); + return stats.isDirectory(); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + + return false; + } } require('../promisify')(Plugins); diff --git a/src/plugins/install.js b/src/plugins/install.js index 3bc83cf..af541f0 100644 --- a/src/plugins/install.js +++ b/src/plugins/install.js @@ -1,180 +1,183 @@ 'use strict'; const winston = require('winston'); -const path = require('path'); -const fs = require('fs').promises; +const path = require('node:path'); +const fs = require('node:fs').promises; +const os = require('node:os'); +const cproc = require('node:child_process'); +const util = require('node:util'); const nconf = require('nconf'); -const os = require('os'); -const cproc = require('child_process'); -const util = require('util'); const request = require('request-promise-native'); - const db = require('../database'); const meta = require('../meta'); const pubsub = require('../pubsub'); -const { paths } = require('../constants'); +const {paths} = require('../constants'); const pkgInstall = require('../cli/package-install'); const packageManager = pkgInstall.getPackageManager(); let packageManagerExecutable = packageManager; const packageManagerCommands = { - yarn: { - install: 'add', - uninstall: 'remove', - }, - npm: { - install: 'install', - uninstall: 'uninstall', - }, - cnpm: { - install: 'install', - uninstall: 'uninstall', - }, - pnpm: { - install: 'install', - uninstall: 'uninstall', - }, + yarn: { + install: 'add', + uninstall: 'remove', + }, + npm: { + install: 'install', + uninstall: 'uninstall', + }, + cnpm: { + install: 'install', + uninstall: 'uninstall', + }, + pnpm: { + install: 'install', + uninstall: 'uninstall', + }, }; if (process.platform === 'win32') { - packageManagerExecutable += '.cmd'; + packageManagerExecutable += '.cmd'; } module.exports = function (Plugins) { - if (nconf.get('isPrimary')) { - pubsub.on('plugins:toggleInstall', (data) => { - if (data.hostname !== os.hostname()) { - toggleInstall(data.id, data.version); - } - }); - - pubsub.on('plugins:upgrade', (data) => { - if (data.hostname !== os.hostname()) { - upgrade(data.id, data.version); - } - }); - } - - 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); - } else { - const count = await db.sortedSetCard('plugins:active'); - await db.sortedSetAdd('plugins:active', count, id); - } - meta.reloadRequired = true; - const hook = isActive ? 'deactivate' : 'activate'; - Plugins.hooks.fire(`action:plugin.${hook}`, { id: id }); - return { id: id, active: !isActive }; - }; - - Plugins.checkWhitelist = async function (id, version) { - const body = await request({ - method: 'GET', - url: `https://packages.nodebb.org/api/v1/plugins/${encodeURIComponent(id)}`, - json: true, - }); - - if (body && body.code === 'ok' && (version === 'latest' || body.payload.valid.includes(version))) { - return; - } - - throw new Error('[[error:plugin-not-whitelisted]]'); - }; - - Plugins.suggest = async function (pluginId, nbbVersion) { - const body = await request({ - method: 'GET', - url: `https://packages.nodebb.org/api/v1/suggest?package=${encodeURIComponent(pluginId)}&version=${encodeURIComponent(nbbVersion)}`, - json: true, - }); - return body; - }; - - Plugins.toggleInstall = async function (id, version) { - pubsub.publish('plugins:toggleInstall', { hostname: os.hostname(), id: id, version: version }); - return await toggleInstall(id, version); - }; - - const runPackageManagerCommandAsync = util.promisify(runPackageManagerCommand); - - async function toggleInstall(id, version) { - const [installed, active] = await Promise.all([ - Plugins.isInstalled(id), - Plugins.isActive(id), - ]); - const type = installed ? 'uninstall' : 'install'; - if (active) { - await Plugins.toggleActive(id); - } - await runPackageManagerCommandAsync(type, id, version || 'latest'); - const pluginData = await Plugins.get(id); - Plugins.hooks.fire(`action:plugin.${type}`, { id: id, version: version }); - return pluginData; - } - - function runPackageManagerCommand(command, pkgName, version, callback) { - cproc.execFile(packageManagerExecutable, [ - packageManagerCommands[packageManager][command], - pkgName + (command === 'install' ? `@${version}` : ''), - '--save', - ], (err, stdout) => { - if (err) { - return callback(err); - } - - winston.verbose(`[plugins/${command}] ${stdout}`); - callback(); - }); - } - - - Plugins.upgrade = async function (id, version) { - pubsub.publish('plugins:upgrade', { hostname: os.hostname(), id: id, version: version }); - return await upgrade(id, version); - }; - - async function upgrade(id, version) { - await runPackageManagerCommandAsync('install', id, version || 'latest'); - const isActive = await Plugins.isActive(id); - meta.reloadRequired = isActive; - return isActive; - } - - Plugins.isInstalled = async function (id) { - const pluginDir = path.join(paths.nodeModules, id); - try { - const stats = await fs.stat(pluginDir); - return stats.isDirectory(); - } catch (err) { - return false; - } - }; - - 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); - }; - - Plugins.autocomplete = async (fragment) => { - const pluginDir = paths.nodeModules; - const plugins = (await fs.readdir(pluginDir)).filter(filename => filename.startsWith(fragment)); - - // Autocomplete only if single match - return plugins.length === 1 ? plugins.pop() : fragment; - }; + if (nconf.get('isPrimary')) { + pubsub.on('plugins:toggleInstall', data => { + if (data.hostname !== os.hostname()) { + toggleInstall(data.id, data.version); + } + }); + + pubsub.on('plugins:upgrade', data => { + if (data.hostname !== os.hostname()) { + upgrade(data.id, data.version); + } + }); + } + + 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); + } else { + const count = await db.sortedSetCard('plugins:active'); + await db.sortedSetAdd('plugins:active', count, id); + } + + meta.reloadRequired = true; + const hook = isActive ? 'deactivate' : 'activate'; + Plugins.hooks.fire(`action:plugin.${hook}`, {id}); + return {id, active: !isActive}; + }; + + Plugins.checkWhitelist = async function (id, version) { + const body = await request({ + method: 'GET', + url: `https://packages.nodebb.org/api/v1/plugins/${encodeURIComponent(id)}`, + json: true, + }); + + if (body && body.code === 'ok' && (version === 'latest' || body.payload.valid.includes(version))) { + return; + } + + throw new Error('[[error:plugin-not-whitelisted]]'); + }; + + Plugins.suggest = async function (pluginId, nbbVersion) { + const body = await request({ + method: 'GET', + url: `https://packages.nodebb.org/api/v1/suggest?package=${encodeURIComponent(pluginId)}&version=${encodeURIComponent(nbbVersion)}`, + json: true, + }); + return body; + }; + + Plugins.toggleInstall = async function (id, version) { + pubsub.publish('plugins:toggleInstall', {hostname: os.hostname(), id, version}); + return await toggleInstall(id, version); + }; + + const runPackageManagerCommandAsync = util.promisify(runPackageManagerCommand); + + async function toggleInstall(id, version) { + const [installed, active] = await Promise.all([ + Plugins.isInstalled(id), + Plugins.isActive(id), + ]); + const type = installed ? 'uninstall' : 'install'; + if (active) { + await Plugins.toggleActive(id); + } + + await runPackageManagerCommandAsync(type, id, version || 'latest'); + const pluginData = await Plugins.get(id); + Plugins.hooks.fire(`action:plugin.${type}`, {id, version}); + return pluginData; + } + + function runPackageManagerCommand(command, packageName, version, callback) { + cproc.execFile(packageManagerExecutable, [ + packageManagerCommands[packageManager][command], + packageName + (command === 'install' ? `@${version}` : ''), + '--save', + ], (error, stdout) => { + if (error) { + return callback(error); + } + + winston.verbose(`[plugins/${command}] ${stdout}`); + callback(); + }); + } + + Plugins.upgrade = async function (id, version) { + pubsub.publish('plugins:upgrade', {hostname: os.hostname(), id, version}); + return await upgrade(id, version); + }; + + async function upgrade(id, version) { + await runPackageManagerCommandAsync('install', id, version || 'latest'); + const isActive = await Plugins.isActive(id); + meta.reloadRequired = isActive; + return isActive; + } + + Plugins.isInstalled = async function (id) { + const pluginDir = path.join(paths.nodeModules, id); + try { + const stats = await fs.stat(pluginDir); + return stats.isDirectory(); + } catch { + return false; + } + }; + + 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); + }; + + Plugins.autocomplete = async fragment => { + const pluginDir = paths.nodeModules; + const plugins = (await fs.readdir(pluginDir)).filter(filename => filename.startsWith(fragment)); + + // Autocomplete only if single match + return plugins.length === 1 ? plugins.pop() : fragment; + }; }; diff --git a/src/plugins/load.js b/src/plugins/load.js index afcff23..4f82221 100644 --- a/src/plugins/load.js +++ b/src/plugins/load.js @@ -5,167 +5,175 @@ const async = require('async'); const winston = require('winston'); const nconf = require('nconf'); const _ = require('lodash'); - const meta = require('../meta'); -const { themeNamePattern } = require('../constants'); +const {themeNamePattern} = require('../constants'); module.exports = function (Plugins) { - async function registerPluginAssets(pluginData, fields) { - function add(dest, arr) { - dest.push(...(arr || [])); - } - - const handlers = { - staticDirs: function (next) { - Plugins.data.getStaticDirectories(pluginData, next); - }, - cssFiles: function (next) { - Plugins.data.getFiles(pluginData, 'css', next); - }, - lessFiles: function (next) { - Plugins.data.getFiles(pluginData, 'less', next); - }, - acpLessFiles: function (next) { - Plugins.data.getFiles(pluginData, 'acpLess', next); - }, - clientScripts: function (next) { - Plugins.data.getScripts(pluginData, 'client', next); - }, - acpScripts: function (next) { - Plugins.data.getScripts(pluginData, 'acp', next); - }, - modules: function (next) { - Plugins.data.getModules(pluginData, next); - }, - languageData: function (next) { - Plugins.data.getLanguageData(pluginData, next); - }, - }; - - let methods = {}; - if (Array.isArray(fields)) { - fields.forEach((field) => { - methods[field] = handlers[field]; - }); - } else { - methods = handlers; - } - - const results = await async.parallel(methods); - - Object.assign(Plugins.staticDirs, results.staticDirs || {}); - add(Plugins.cssFiles, results.cssFiles); - add(Plugins.lessFiles, results.lessFiles); - add(Plugins.acpLessFiles, results.acpLessFiles); - add(Plugins.clientScripts, results.clientScripts); - add(Plugins.acpScripts, results.acpScripts); - Object.assign(meta.js.scripts.modules, results.modules || {}); - if (results.languageData) { - Plugins.languageData.languages = _.union(Plugins.languageData.languages, results.languageData.languages); - Plugins.languageData.namespaces = _.union(Plugins.languageData.namespaces, results.languageData.namespaces); - pluginData.languageData = results.languageData; - } - Plugins.pluginsData[pluginData.id] = pluginData; - } - - Plugins.prepareForBuild = async function (targets) { - const map = { - 'plugin static dirs': ['staticDirs'], - 'requirejs modules': ['modules'], - 'client js bundle': ['clientScripts'], - 'admin js bundle': ['acpScripts'], - 'client side styles': ['cssFiles', 'lessFiles'], - 'admin control panel styles': ['cssFiles', 'lessFiles', 'acpLessFiles'], - languages: ['languageData'], - }; - - const fields = _.uniq(_.flatMap(targets, target => map[target] || [])); - - // clear old data before build - fields.forEach((field) => { - switch (field) { - case 'clientScripts': - case 'acpScripts': - case 'cssFiles': - case 'lessFiles': - case 'acpLessFiles': - Plugins[field].length = 0; - break; - case 'languageData': - Plugins.languageData.languages = []; - Plugins.languageData.namespaces = []; - break; - // do nothing for modules and staticDirs - } - }); - - winston.verbose(`[plugins] loading the following fields from plugin data: ${fields.join(', ')}`); - const plugins = await Plugins.data.getActive(); - await Promise.all(plugins.map(p => registerPluginAssets(p, fields))); - }; - - Plugins.loadPlugin = async function (pluginPath) { - let pluginData; - try { - pluginData = await Plugins.data.loadPluginInfo(pluginPath); - } catch (err) { - if (err.message === '[[error:parse-error]]') { - return; - } - if (!themeNamePattern.test(pluginPath)) { - throw err; - } - return; - } - checkVersion(pluginData); - - try { - registerHooks(pluginData); - await registerPluginAssets(pluginData); - } catch (err) { - winston.error(err.stack); - winston.verbose(`[plugins] Could not load plugin : ${pluginData.id}`); - return; - } - - if (!pluginData.private) { - Plugins.loadedPlugins.push({ - id: pluginData.id, - version: pluginData.version, - }); - } - - winston.verbose(`[plugins] Loaded plugin: ${pluginData.id}`); - }; - - function checkVersion(pluginData) { - function add() { - if (!Plugins.versionWarning.includes(pluginData.id)) { - Plugins.versionWarning.push(pluginData.id); - } - } - - if (pluginData.nbbpm && pluginData.nbbpm.compatibility && semver.validRange(pluginData.nbbpm.compatibility)) { - if (!semver.satisfies(nconf.get('version'), pluginData.nbbpm.compatibility)) { - add(); - } - } else { - add(); - } - } - - function registerHooks(pluginData) { - try { - if (!Plugins.libraries[pluginData.id]) { - Plugins.requireLibrary(pluginData); - } - - if (Array.isArray(pluginData.hooks)) { - pluginData.hooks.forEach(hook => Plugins.hooks.register(pluginData.id, hook)); - } - } catch (err) { - winston.warn(`[plugins] Unable to load library for: ${pluginData.id}`); - throw err; - } - } + async function registerPluginAssets(pluginData, fields) { + function add(destination, array) { + destination.push(...(array || [])); + } + + const handlers = { + staticDirs(next) { + Plugins.data.getStaticDirectories(pluginData, next); + }, + cssFiles(next) { + Plugins.data.getFiles(pluginData, 'css', next); + }, + lessFiles(next) { + Plugins.data.getFiles(pluginData, 'less', next); + }, + acpLessFiles(next) { + Plugins.data.getFiles(pluginData, 'acpLess', next); + }, + clientScripts(next) { + Plugins.data.getScripts(pluginData, 'client', next); + }, + acpScripts(next) { + Plugins.data.getScripts(pluginData, 'acp', next); + }, + modules(next) { + Plugins.data.getModules(pluginData, next); + }, + languageData(next) { + Plugins.data.getLanguageData(pluginData, next); + }, + }; + + let methods = {}; + if (Array.isArray(fields)) { + for (const field of fields) { + methods[field] = handlers[field]; + } + } else { + methods = handlers; + } + + const results = await async.parallel(methods); + + Object.assign(Plugins.staticDirs, results.staticDirs || {}); + add(Plugins.cssFiles, results.cssFiles); + add(Plugins.lessFiles, results.lessFiles); + add(Plugins.acpLessFiles, results.acpLessFiles); + add(Plugins.clientScripts, results.clientScripts); + add(Plugins.acpScripts, results.acpScripts); + Object.assign(meta.js.scripts.modules, results.modules || {}); + if (results.languageData) { + Plugins.languageData.languages = _.union(Plugins.languageData.languages, results.languageData.languages); + Plugins.languageData.namespaces = _.union(Plugins.languageData.namespaces, results.languageData.namespaces); + pluginData.languageData = results.languageData; + } + + Plugins.pluginsData[pluginData.id] = pluginData; + } + + Plugins.prepareForBuild = async function (targets) { + const map = { + 'plugin static dirs': ['staticDirs'], + 'requirejs modules': ['modules'], + 'client js bundle': ['clientScripts'], + 'admin js bundle': ['acpScripts'], + 'client side styles': ['cssFiles', 'lessFiles'], + 'admin control panel styles': ['cssFiles', 'lessFiles', 'acpLessFiles'], + languages: ['languageData'], + }; + + const fields = _.uniq(_.flatMap(targets, target => map[target] || [])); + + // Clear old data before build + for (const field of fields) { + switch (field) { + case 'clientScripts': + case 'acpScripts': + case 'cssFiles': + case 'lessFiles': + case 'acpLessFiles': { + Plugins[field].length = 0; + break; + } + + case 'languageData': { + Plugins.languageData.languages = []; + Plugins.languageData.namespaces = []; + break; + } + // Do nothing for modules and staticDirs + } + } + + winston.verbose(`[plugins] loading the following fields from plugin data: ${fields.join(', ')}`); + const plugins = await Plugins.data.getActive(); + await Promise.all(plugins.map(p => registerPluginAssets(p, fields))); + }; + + Plugins.loadPlugin = async function (pluginPath) { + let pluginData; + try { + pluginData = await Plugins.data.loadPluginInfo(pluginPath); + } catch (error) { + if (error.message === '[[error:parse-error]]') { + return; + } + + if (!themeNamePattern.test(pluginPath)) { + throw error; + } + + return; + } + + checkVersion(pluginData); + + try { + registerHooks(pluginData); + await registerPluginAssets(pluginData); + } catch (error) { + winston.error(error.stack); + winston.verbose(`[plugins] Could not load plugin : ${pluginData.id}`); + return; + } + + if (!pluginData.private) { + Plugins.loadedPlugins.push({ + id: pluginData.id, + version: pluginData.version, + }); + } + + winston.verbose(`[plugins] Loaded plugin: ${pluginData.id}`); + }; + + function checkVersion(pluginData) { + function add() { + if (!Plugins.versionWarning.includes(pluginData.id)) { + Plugins.versionWarning.push(pluginData.id); + } + } + + if (pluginData.nbbpm && pluginData.nbbpm.compatibility && semver.validRange(pluginData.nbbpm.compatibility)) { + if (!semver.satisfies(nconf.get('version'), pluginData.nbbpm.compatibility)) { + add(); + } + } else { + add(); + } + } + + function registerHooks(pluginData) { + try { + if (!Plugins.libraries[pluginData.id]) { + Plugins.requireLibrary(pluginData); + } + + if (Array.isArray(pluginData.hooks)) { + for (const hook of pluginData.hooks) { + Plugins.hooks.register(pluginData.id, hook); + } + } + } catch (error) { + winston.warn(`[plugins] Unable to load library for: ${pluginData.id}`); + throw error; + } + } }; diff --git a/src/plugins/usage.js b/src/plugins/usage.js index 561ae7a..390a101 100644 --- a/src/plugins/usage.js +++ b/src/plugins/usage.js @@ -1,48 +1,47 @@ 'use strict'; +const crypto = require('node:crypto'); const nconf = require('nconf'); const request = require('request'); const winston = require('winston'); -const crypto = require('crypto'); const cronJob = require('cron').CronJob; - const pkg = require('../../package.json'); - const meta = require('../meta'); module.exports = function (Plugins) { - Plugins.startJobs = function () { - new cronJob('0 0 0 * * *', (() => { - Plugins.submitUsageData(); - }), null, true); - }; + Plugins.startJobs = function () { + new cronJob('0 0 0 * * *', (() => { + Plugins.submitUsageData(); + }), null, true); + }; + + Plugins.submitUsageData = function (callback) { + callback ||= function () {}; + if (!meta.config.submitPluginUsage || Plugins.loadedPlugins.length === 0 || global.env !== 'production') { + return callback(); + } - Plugins.submitUsageData = function (callback) { - callback = callback || function () {}; - if (!meta.config.submitPluginUsage || !Plugins.loadedPlugins.length || global.env !== 'production') { - return callback(); - } + const hash = crypto.createHash('sha256'); + hash.update(nconf.get('url')); + request.post(`${nconf.get('registry') || 'https://packages.nodebb.org'}/api/v1/plugin/usage`, { + form: { + id: hash.digest('hex'), + version: pkg.version, + plugins: Plugins.loadedPlugins, + }, + timeout: 5000, + }, (error, res, body) => { + if (error) { + winston.error(error.stack); + return callback(error); + } - const hash = crypto.createHash('sha256'); - hash.update(nconf.get('url')); - request.post(`${nconf.get('registry') || 'https://packages.nodebb.org'}/api/v1/plugin/usage`, { - form: { - id: hash.digest('hex'), - version: pkg.version, - plugins: Plugins.loadedPlugins, - }, - timeout: 5000, - }, (err, res, body) => { - if (err) { - winston.error(err.stack); - return callback(err); - } - if (res.statusCode !== 200) { - winston.error(`[plugins.submitUsageData] received ${res.statusCode} ${body}`); - callback(new Error(`[[error:nbbpm-${res.statusCode}]]`)); - } else { - callback(); - } - }); - }; + if (res.statusCode === 200) { + callback(); + } else { + winston.error(`[plugins.submitUsageData] received ${res.statusCode} ${body}`); + callback(new Error(`[[error:nbbpm-${res.statusCode}]]`)); + } + }); + }; }; diff --git a/src/posts/bookmarks.js b/src/posts/bookmarks.js index 9924664..aad0768 100644 --- a/src/posts/bookmarks.js +++ b/src/posts/bookmarks.js @@ -4,65 +4,63 @@ const db = require('../database'); const plugins = require('../plugins'); module.exports = function (Posts) { - Posts.bookmark = async function (pid, uid) { - return await toggleBookmark('bookmark', pid, uid); - }; + Posts.bookmark = async function (pid, uid) { + return await toggleBookmark('bookmark', pid, uid); + }; - Posts.unbookmark = async function (pid, uid) { - return await toggleBookmark('unbookmark', pid, uid); - }; + Posts.unbookmark = async function (pid, uid) { + return await toggleBookmark('unbookmark', pid, uid); + }; - async function toggleBookmark(type, pid, uid) { - if (parseInt(uid, 10) <= 0) { - throw new Error('[[error:not-logged-in]]'); - } + async function toggleBookmark(type, pid, uid) { + if (Number.parseInt(uid, 10) <= 0) { + throw new Error('[[error:not-logged-in]]'); + } - const isBookmarking = type === 'bookmark'; + const isBookmarking = type === 'bookmark'; - const [postData, hasBookmarked] = await Promise.all([ - Posts.getPostFields(pid, ['pid', 'uid']), - Posts.hasBookmarked(pid, uid), - ]); + const [postData, hasBookmarked] = await Promise.all([ + Posts.getPostFields(pid, ['pid', 'uid']), + Posts.hasBookmarked(pid, uid), + ]); - if (isBookmarking && hasBookmarked) { - throw new Error('[[error:already-bookmarked]]'); - } + if (isBookmarking && hasBookmarked) { + throw new Error('[[error:already-bookmarked]]'); + } - if (!isBookmarking && !hasBookmarked) { - throw new Error('[[error:already-unbookmarked]]'); - } + if (!isBookmarking && !hasBookmarked) { + throw new Error('[[error:already-unbookmarked]]'); + } - if (isBookmarking) { - await db.sortedSetAdd(`uid:${uid}:bookmarks`, Date.now(), pid); - } else { - await db.sortedSetRemove(`uid:${uid}:bookmarks`, pid); - } - await db[isBookmarking ? 'setAdd' : 'setRemove'](`pid:${pid}:users_bookmarked`, uid); - postData.bookmarks = await db.setCount(`pid:${pid}:users_bookmarked`); - await Posts.setPostField(pid, 'bookmarks', postData.bookmarks); + await (isBookmarking ? db.sortedSetAdd(`uid:${uid}:bookmarks`, Date.now(), pid) : db.sortedSetRemove(`uid:${uid}:bookmarks`, pid)); - plugins.hooks.fire(`action:post.${type}`, { - pid: pid, - uid: uid, - owner: postData.uid, - current: hasBookmarked ? 'bookmarked' : 'unbookmarked', - }); + await db[isBookmarking ? 'setAdd' : 'setRemove'](`pid:${pid}:users_bookmarked`, uid); + postData.bookmarks = await db.setCount(`pid:${pid}:users_bookmarked`); + await Posts.setPostField(pid, 'bookmarks', postData.bookmarks); - return { - post: postData, - isBookmarked: isBookmarking, - }; - } + plugins.hooks.fire(`action:post.${type}`, { + pid, + uid, + owner: postData.uid, + current: hasBookmarked ? 'bookmarked' : 'unbookmarked', + }); - Posts.hasBookmarked = async function (pid, uid) { - if (parseInt(uid, 10) <= 0) { - return Array.isArray(pid) ? pid.map(() => false) : false; - } + return { + post: postData, + isBookmarked: isBookmarking, + }; + } - if (Array.isArray(pid)) { - const sets = pid.map(pid => `pid:${pid}:users_bookmarked`); - return await db.isMemberOfSets(sets, uid); - } - return await db.isSetMember(`pid:${pid}:users_bookmarked`, uid); - }; + Posts.hasBookmarked = async function (pid, uid) { + if (Number.parseInt(uid, 10) <= 0) { + return Array.isArray(pid) ? pid.map(() => false) : false; + } + + if (Array.isArray(pid)) { + const sets = pid.map(pid => `pid:${pid}:users_bookmarked`); + return await db.isMemberOfSets(sets, uid); + } + + return await db.isSetMember(`pid:${pid}:users_bookmarked`, uid); + }; }; diff --git a/src/posts/cache.js b/src/posts/cache.js index 5daee08..432dce0 100644 --- a/src/posts/cache.js +++ b/src/posts/cache.js @@ -4,9 +4,11 @@ const cacheCreate = require('../cache/lru'); const meta = require('../meta'); module.exports = cacheCreate({ - name: 'post', - maxSize: meta.config.postCacheSize, - sizeCalculation: function (n) { return n.length || 1; }, - ttl: 0, - enabled: global.env === 'production', + name: 'post', + maxSize: meta.config.postCacheSize, + sizeCalculation(n) { + return n.length || 1; + }, + ttl: 0, + enabled: global.env === 'production', }); diff --git a/src/posts/category.js b/src/posts/category.js index 43334c8..548340d 100644 --- a/src/posts/category.js +++ b/src/posts/category.js @@ -1,41 +1,40 @@ 'use strict'; - const _ = require('lodash'); - const db = require('../database'); const topics = require('../topics'); module.exports = function (Posts) { - Posts.getCidByPid = async function (pid) { - const tid = await Posts.getPostField(pid, 'tid'); - return await topics.getTopicField(tid, 'cid'); - }; - - Posts.getCidsByPids = async function (pids) { - const postData = await Posts.getPostsFields(pids, ['tid']); - const tids = _.uniq(postData.map(post => post && post.tid).filter(Boolean)); - const topicData = await topics.getTopicsFields(tids, ['cid']); - const tidToTopic = _.zipObject(tids, topicData); - const cids = postData.map(post => tidToTopic[post.tid] && tidToTopic[post.tid].cid); - return cids; - }; - - Posts.filterPidsByCid = async function (pids, cid) { - if (!cid) { - return pids; - } - - if (!Array.isArray(cid) || cid.length === 1) { - return await filterPidsBySingleCid(pids, cid); - } - const pidsArr = await Promise.all(cid.map(c => Posts.filterPidsByCid(pids, c))); - return _.union(...pidsArr); - }; - - async function filterPidsBySingleCid(pids, cid) { - const isMembers = await db.isSortedSetMembers(`cid:${parseInt(cid, 10)}:pids`, pids); - return pids.filter((pid, index) => pid && isMembers[index]); - } + Posts.getCidByPid = async function (pid) { + const tid = await Posts.getPostField(pid, 'tid'); + return await topics.getTopicField(tid, 'cid'); + }; + + Posts.getCidsByPids = async function (pids) { + const postData = await Posts.getPostsFields(pids, ['tid']); + const tids = _.uniq(postData.map(post => post && post.tid).filter(Boolean)); + const topicData = await topics.getTopicsFields(tids, ['cid']); + const tidToTopic = _.zipObject(tids, topicData); + const cids = postData.map(post => tidToTopic[post.tid] && tidToTopic[post.tid].cid); + return cids; + }; + + Posts.filterPidsByCid = async function (pids, cid) { + if (!cid) { + return pids; + } + + if (!Array.isArray(cid) || cid.length === 1) { + return await filterPidsBySingleCid(pids, cid); + } + + const pidsArray = await Promise.all(cid.map(c => Posts.filterPidsByCid(pids, c))); + return _.union(...pidsArray); + }; + + async function filterPidsBySingleCid(pids, cid) { + const isMembers = await db.isSortedSetMembers(`cid:${Number.parseInt(cid, 10)}:pids`, pids); + return pids.filter((pid, index) => pid && isMembers[index]); + } }; diff --git a/src/posts/create.js b/src/posts/create.js index 094ae1c..1bdb635 100644 --- a/src/posts/create.js +++ b/src/posts/create.js @@ -1,7 +1,6 @@ 'use strict'; const _ = require('lodash'); - const meta = require('../meta'); const db = require('../database'); const plugins = require('../plugins'); @@ -12,72 +11,75 @@ const groups = require('../groups'); const utils = require('../utils'); module.exports = function (Posts) { - Posts.create = async function (data) { - // This is an internal method, consider using Topics.reply instead - const { uid } = data; - const { tid } = data; - const content = data.content.toString(); - const timestamp = data.timestamp || Date.now(); - const isMain = data.isMain || false; + Posts.create = async function (data) { + // This is an internal method, consider using Topics.reply instead + const {uid} = data; + const {tid} = data; + const content = data.content.toString(); + const timestamp = data.timestamp || Date.now(); + const isMain = data.isMain || false; + + if (!uid && Number.parseInt(uid, 10) !== 0) { + throw new Error('[[error:invalid-uid]]'); + } + + if (data.toPid && !utils.isNumber(data.toPid)) { + throw new Error('[[error:invalid-pid]]'); + } + + const pid = await db.incrObjectField('global', 'nextPid'); + let postData = { + pid, + uid, + tid, + content, + timestamp, + }; - if (!uid && parseInt(uid, 10) !== 0) { - throw new Error('[[error:invalid-uid]]'); - } + if (data.toPid) { + postData.toPid = data.toPid; + } - if (data.toPid && !utils.isNumber(data.toPid)) { - throw new Error('[[error:invalid-pid]]'); - } + if (data.ip && meta.config.trackIpPerPost) { + postData.ip = data.ip; + } - const pid = await db.incrObjectField('global', 'nextPid'); - let postData = { - pid: pid, - uid: uid, - tid: tid, - content: content, - timestamp: timestamp, - }; + if (data.handle && !Number.parseInt(uid, 10)) { + postData.handle = data.handle; + } - if (data.toPid) { - postData.toPid = data.toPid; - } - if (data.ip && meta.config.trackIpPerPost) { - postData.ip = data.ip; - } - if (data.handle && !parseInt(uid, 10)) { - postData.handle = data.handle; - } + let result = await plugins.hooks.fire('filter:post.create', {post: postData, data}); + postData = result.post; + await db.setObject(`post:${postData.pid}`, postData); - let result = await plugins.hooks.fire('filter:post.create', { post: postData, data: data }); - postData = result.post; - await db.setObject(`post:${postData.pid}`, postData); + const topicData = await topics.getTopicFields(tid, ['cid', 'pinned']); + postData.cid = topicData.cid; - const topicData = await topics.getTopicFields(tid, ['cid', 'pinned']); - postData.cid = topicData.cid; + await Promise.all([ + db.sortedSetAdd('posts:pid', timestamp, postData.pid), + db.incrObjectField('global', 'postCount'), + user.onNewPostMade(postData), + topics.onNewPostMade(postData), + categories.onNewPostMade(topicData.cid, topicData.pinned, postData), + groups.onNewPostMade(postData), + addReplyTo(postData, timestamp), + Posts.uploads.sync(postData.pid), + ]); - await Promise.all([ - db.sortedSetAdd('posts:pid', timestamp, postData.pid), - db.incrObjectField('global', 'postCount'), - user.onNewPostMade(postData), - topics.onNewPostMade(postData), - categories.onNewPostMade(topicData.cid, topicData.pinned, postData), - groups.onNewPostMade(postData), - addReplyTo(postData, timestamp), - Posts.uploads.sync(postData.pid), - ]); + result = await plugins.hooks.fire('filter:post.get', {post: postData, uid: data.uid}); + result.post.isMain = isMain; + plugins.hooks.fire('action:post.save', {post: _.clone(result.post)}); + return result.post; + }; - result = await plugins.hooks.fire('filter:post.get', { post: postData, uid: data.uid }); - result.post.isMain = isMain; - plugins.hooks.fire('action:post.save', { post: _.clone(result.post) }); - return result.post; - }; + async function addReplyTo(postData, timestamp) { + if (!postData.toPid) { + return; + } - async function addReplyTo(postData, timestamp) { - if (!postData.toPid) { - return; - } - await Promise.all([ - db.sortedSetAdd(`pid:${postData.toPid}:replies`, timestamp, postData.pid), - db.incrObjectField(`post:${postData.toPid}`, 'replies'), - ]); - } + await Promise.all([ + db.sortedSetAdd(`pid:${postData.toPid}:replies`, timestamp, postData.pid), + db.incrObjectField(`post:${postData.toPid}`, 'replies'), + ]); + } }; diff --git a/src/posts/data.js b/src/posts/data.js index 21503d2..7c221fc 100644 --- a/src/posts/data.js +++ b/src/posts/data.js @@ -5,67 +5,83 @@ const plugins = require('../plugins'); const utils = require('../utils'); const intFields = [ - 'uid', 'pid', 'tid', 'deleted', 'timestamp', - 'upvotes', 'downvotes', 'deleterUid', 'edited', - 'replies', 'bookmarks', 'pinned', 'resolved', + 'uid', + 'pid', + 'tid', + 'deleted', + 'timestamp', + 'upvotes', + 'downvotes', + 'deleterUid', + 'edited', + 'replies', + 'bookmarks', + 'pinned', + 'resolved', ]; module.exports = function (Posts) { - Posts.getPostsFields = async function (pids, fields) { - if (!Array.isArray(pids) || !pids.length) { - return []; - } - const keys = pids.map(pid => `post:${pid}`); - const postData = await db.getObjects(keys, fields); - const result = await plugins.hooks.fire('filter:post.getFields', { - pids: pids, - posts: postData, - fields: fields, - }); - result.posts.forEach(post => modifyPost(post, fields)); - return result.posts; - }; + Posts.getPostsFields = async function (pids, fields) { + if (!Array.isArray(pids) || pids.length === 0) { + return []; + } - Posts.getPostData = async function (pid) { - const posts = await Posts.getPostsFields([pid], []); - return posts && posts.length ? posts[0] : null; - }; + const keys = pids.map(pid => `post:${pid}`); + const postData = await db.getObjects(keys, fields); + const result = await plugins.hooks.fire('filter:post.getFields', { + pids, + posts: postData, + fields, + }); + for (const post of result.posts) { + modifyPost(post, fields); + } - Posts.getPostsData = async function (pids) { - return await Posts.getPostsFields(pids, []); - }; + return result.posts; + }; - Posts.getPostField = async function (pid, field) { - const post = await Posts.getPostFields(pid, [field]); - return post ? post[field] : null; - }; + Posts.getPostData = async function (pid) { + const posts = await Posts.getPostsFields([pid], []); + return posts && posts.length > 0 ? posts[0] : null; + }; - Posts.getPostFields = async function (pid, fields) { - const posts = await Posts.getPostsFields([pid], fields); - return posts ? posts[0] : null; - }; + Posts.getPostsData = async function (pids) { + return await Posts.getPostsFields(pids, []); + }; - Posts.setPostField = async function (pid, field, value) { - await Posts.setPostFields(pid, { [field]: value }); - }; + Posts.getPostField = async function (pid, field) { + const post = await Posts.getPostFields(pid, [field]); + return post ? post[field] : null; + }; - Posts.setPostFields = async function (pid, data) { - await db.setObject(`post:${pid}`, data); - plugins.hooks.fire('action:post.setFields', { data: { ...data, pid } }); - }; + Posts.getPostFields = async function (pid, fields) { + const posts = await Posts.getPostsFields([pid], fields); + return posts ? posts[0] : null; + }; + + Posts.setPostField = async function (pid, field, value) { + await Posts.setPostFields(pid, {[field]: value}); + }; + + Posts.setPostFields = async function (pid, data) { + await db.setObject(`post:${pid}`, data); + plugins.hooks.fire('action:post.setFields', {data: {...data, pid}}); + }; }; function modifyPost(post, fields) { - if (post) { - db.parseIntFields(post, intFields, fields); - if (post.hasOwnProperty('upvotes') && post.hasOwnProperty('downvotes')) { - post.votes = post.upvotes - post.downvotes; - } - if (post.hasOwnProperty('timestamp')) { - post.timestampISO = utils.toISOString(post.timestamp); - } - if (post.hasOwnProperty('edited')) { - post.editedISO = post.edited !== 0 ? utils.toISOString(post.edited) : ''; - } - } + if (post) { + db.parseIntFields(post, intFields, fields); + if (post.hasOwnProperty('upvotes') && post.hasOwnProperty('downvotes')) { + post.votes = post.upvotes - post.downvotes; + } + + if (post.hasOwnProperty('timestamp')) { + post.timestampISO = utils.toISOString(post.timestamp); + } + + if (post.hasOwnProperty('edited')) { + post.editedISO = post.edited === 0 ? '' : utils.toISOString(post.edited); + } + } } diff --git a/src/posts/delete.js b/src/posts/delete.js index 5def089..c0bd0cc 100644 --- a/src/posts/delete.js +++ b/src/posts/delete.js @@ -1,7 +1,6 @@ 'use strict'; const _ = require('lodash'); - const db = require('../database'); const topics = require('../topics'); const categories = require('../categories'); @@ -11,222 +10,225 @@ const plugins = require('../plugins'); const flags = require('../flags'); module.exports = function (Posts) { - Posts.delete = async function (pid, uid) { - return await deleteOrRestore('delete', pid, uid); - }; - - Posts.restore = async function (pid, uid) { - return await deleteOrRestore('restore', pid, uid); - }; - - async function deleteOrRestore(type, pid, uid) { - const isDeleting = type === 'delete'; - await plugins.hooks.fire(`filter:post.${type}`, { pid: pid, uid: uid }); - await Posts.setPostFields(pid, { - deleted: isDeleting ? 1 : 0, - deleterUid: isDeleting ? uid : 0, - }); - const postData = await Posts.getPostFields(pid, ['pid', 'tid', 'uid', 'content', 'timestamp']); - const topicData = await topics.getTopicFields(postData.tid, ['tid', 'cid', 'pinned']); - postData.cid = topicData.cid; - await Promise.all([ - topics.updateLastPostTimeFromLastPid(postData.tid), - topics.updateTeaser(postData.tid), - isDeleting ? - db.sortedSetRemove(`cid:${topicData.cid}:pids`, pid) : - db.sortedSetAdd(`cid:${topicData.cid}:pids`, postData.timestamp, pid), - ]); - await categories.updateRecentTidForCid(postData.cid); - plugins.hooks.fire(`action:post.${type}`, { post: _.clone(postData), uid: uid }); - if (type === 'delete') { - await flags.resolveFlag('post', pid, uid); - } - return postData; - } - - Posts.purge = async function (pids, uid) { - pids = Array.isArray(pids) ? pids : [pids]; - let postData = await Posts.getPostsData(pids); - pids = pids.filter((pid, index) => !!postData[index]); - postData = postData.filter(Boolean); - if (!postData.length) { - return; - } - const uniqTids = _.uniq(postData.map(p => p.tid)); - const topicData = await topics.getTopicsFields(uniqTids, ['tid', 'cid', 'pinned', 'postcount']); - const tidToTopic = _.zipObject(uniqTids, topicData); - - postData.forEach((p) => { - p.topic = tidToTopic[p.tid]; - p.cid = tidToTopic[p.tid] && tidToTopic[p.tid].cid; - }); - - // deprecated hook - await Promise.all(postData.map(p => plugins.hooks.fire('filter:post.purge', { post: p, pid: p.pid, uid: uid }))); - - // new hook - await plugins.hooks.fire('filter:posts.purge', { - posts: postData, - pids: postData.map(p => p.pid), - uid: uid, - }); - - await Promise.all([ - deleteFromTopicUserNotification(postData), - deleteFromCategoryRecentPosts(postData), - deleteFromUsersBookmarks(pids), - deleteFromUsersVotes(pids), - deleteFromReplies(postData), - deleteFromGroups(pids), - deleteDiffs(pids), - deleteFromUploads(pids), - db.sortedSetsRemove(['posts:pid', 'posts:votes', 'posts:flagged'], pids), - ]); - - await resolveFlags(postData, uid); - - // deprecated hook - Promise.all(postData.map(p => plugins.hooks.fire('action:post.purge', { post: p, uid: uid }))); - - // new hook - plugins.hooks.fire('action:posts.purge', { posts: postData, uid: uid }); - - await db.deleteAll(postData.map(p => `post:${p.pid}`)); - }; - - async function deleteFromTopicUserNotification(postData) { - const bulkRemove = []; - postData.forEach((p) => { - bulkRemove.push([`tid:${p.tid}:posts`, p.pid]); - bulkRemove.push([`tid:${p.tid}:posts:votes`, p.pid]); - bulkRemove.push([`uid:${p.uid}:posts`, p.pid]); - bulkRemove.push([`cid:${p.cid}:uid:${p.uid}:pids`, p.pid]); - bulkRemove.push([`cid:${p.cid}:uid:${p.uid}:pids:votes`, p.pid]); - }); - await db.sortedSetRemoveBulk(bulkRemove); - - const incrObjectBulk = [['global', { postCount: -postData.length }]]; - - const postsByCategory = _.groupBy(postData, p => parseInt(p.cid, 10)); - for (const [cid, posts] of Object.entries(postsByCategory)) { - incrObjectBulk.push([`category:${cid}`, { post_count: -posts.length }]); - } - - const postsByTopic = _.groupBy(postData, p => parseInt(p.tid, 10)); - const topicPostCountTasks = []; - const topicTasks = []; - const zsetIncrBulk = []; - for (const [tid, posts] of Object.entries(postsByTopic)) { - incrObjectBulk.push([`topic:${tid}`, { postcount: -posts.length }]); - if (posts.length && posts[0]) { - const topicData = posts[0].topic; - const newPostCount = topicData.postcount - posts.length; - topicPostCountTasks.push(['topics:posts', newPostCount, tid]); - if (!topicData.pinned) { - zsetIncrBulk.push([`cid:${topicData.cid}:tids:posts`, -posts.length, tid]); - } - } - topicTasks.push(topics.updateTeaser(tid)); - topicTasks.push(topics.updateLastPostTimeFromLastPid(tid)); - const postsByUid = _.groupBy(posts, p => parseInt(p.uid, 10)); - for (const [uid, uidPosts] of Object.entries(postsByUid)) { - zsetIncrBulk.push([`tid:${tid}:posters`, -uidPosts.length, uid]); - } - topicTasks.push(db.sortedSetIncrByBulk(zsetIncrBulk)); - } - - await Promise.all([ - db.incrObjectFieldByBulk(incrObjectBulk), - db.sortedSetAddBulk(topicPostCountTasks), - ...topicTasks, - user.updatePostCount(_.uniq(postData.map(p => p.uid))), - notifications.rescind(...postData.map(p => `new_post:tid:${p.tid}:pid:${p.pid}:uid:${p.uid}`)), - ]); - } - - async function deleteFromCategoryRecentPosts(postData) { - const uniqCids = _.uniq(postData.map(p => p.cid)); - const sets = uniqCids.map(cid => `cid:${cid}:pids`); - await db.sortedSetRemove(sets, postData.map(p => p.pid)); - await Promise.all(uniqCids.map(categories.updateRecentTidForCid)); - } - - async function deleteFromUsersBookmarks(pids) { - const arrayOfUids = await db.getSetsMembers(pids.map(pid => `pid:${pid}:users_bookmarked`)); - const bulkRemove = []; - pids.forEach((pid, index) => { - arrayOfUids[index].forEach((uid) => { - bulkRemove.push([`uid:${uid}:bookmarks`, pid]); - }); - }); - await db.sortedSetRemoveBulk(bulkRemove); - await db.deleteAll(pids.map(pid => `pid:${pid}:users_bookmarked`)); - } - - async function deleteFromUsersVotes(pids) { - const [upvoters, downvoters] = await Promise.all([ - db.getSetsMembers(pids.map(pid => `pid:${pid}:upvote`)), - db.getSetsMembers(pids.map(pid => `pid:${pid}:downvote`)), - ]); - const bulkRemove = []; - pids.forEach((pid, index) => { - upvoters[index].forEach((upvoterUid) => { - bulkRemove.push([`uid:${upvoterUid}:upvote`, pid]); - }); - downvoters[index].forEach((downvoterUid) => { - bulkRemove.push([`uid:${downvoterUid}:downvote`, pid]); - }); - }); - - await Promise.all([ - db.sortedSetRemoveBulk(bulkRemove), - db.deleteAll([ - ...pids.map(pid => `pid:${pid}:upvote`), - ...pids.map(pid => `pid:${pid}:downvote`), - ]), - ]); - } - - async function deleteFromReplies(postData) { - const arrayOfReplyPids = await db.getSortedSetsMembers(postData.map(p => `pid:${p.pid}:replies`)); - const allReplyPids = _.flatten(arrayOfReplyPids); - const promises = [ - db.deleteObjectFields( - allReplyPids.map(pid => `post:${pid}`), ['toPid'] - ), - db.deleteAll(postData.map(p => `pid:${p.pid}:replies`)), - ]; - - const postsWithParents = postData.filter(p => parseInt(p.toPid, 10)); - const bulkRemove = postsWithParents.map(p => [`pid:${p.toPid}:replies`, p.pid]); - promises.push(db.sortedSetRemoveBulk(bulkRemove)); - await Promise.all(promises); - - const parentPids = _.uniq(postsWithParents.map(p => p.toPid)); - const counts = await db.sortedSetsCard(parentPids.map(pid => `pid:${pid}:replies`)); - await db.setObjectBulk(parentPids.map((pid, index) => [`post:${pid}`, { replies: counts[index] }])); - } - - async function deleteFromGroups(pids) { - const groupNames = await db.getSortedSetMembers('groups:visible:createtime'); - const keys = groupNames.map(groupName => `group:${groupName}:member:pids`); - await db.sortedSetRemove(keys, pids); - } - - async function deleteDiffs(pids) { - const timestamps = await Promise.all(pids.map(pid => Posts.diffs.list(pid))); - await db.deleteAll([ - ...pids.map(pid => `post:${pid}:diffs`), - ..._.flattenDeep(pids.map((pid, index) => timestamps[index].map(t => `diff:${pid}.${t}`))), - ]); - } - - async function deleteFromUploads(pids) { - await Promise.all(pids.map(Posts.uploads.dissociateAll)); - } - - async function resolveFlags(postData, uid) { - const flaggedPosts = postData.filter(p => parseInt(p.flagId, 10)); - await Promise.all(flaggedPosts.map(p => flags.update(p.flagId, uid, { state: 'resolved' }))); - } + Posts.delete = async function (pid, uid) { + return await deleteOrRestore('delete', pid, uid); + }; + + Posts.restore = async function (pid, uid) { + return await deleteOrRestore('restore', pid, uid); + }; + + async function deleteOrRestore(type, pid, uid) { + const isDeleting = type === 'delete'; + await plugins.hooks.fire(`filter:post.${type}`, {pid, uid}); + await Posts.setPostFields(pid, { + deleted: isDeleting ? 1 : 0, + deleterUid: isDeleting ? uid : 0, + }); + const postData = await Posts.getPostFields(pid, ['pid', 'tid', 'uid', 'content', 'timestamp']); + const topicData = await topics.getTopicFields(postData.tid, ['tid', 'cid', 'pinned']); + postData.cid = topicData.cid; + await Promise.all([ + topics.updateLastPostTimeFromLastPid(postData.tid), + topics.updateTeaser(postData.tid), + isDeleting + ? db.sortedSetRemove(`cid:${topicData.cid}:pids`, pid) + : db.sortedSetAdd(`cid:${topicData.cid}:pids`, postData.timestamp, pid), + ]); + await categories.updateRecentTidForCid(postData.cid); + plugins.hooks.fire(`action:post.${type}`, {post: _.clone(postData), uid}); + if (type === 'delete') { + await flags.resolveFlag('post', pid, uid); + } + + return postData; + } + + Posts.purge = async function (pids, uid) { + pids = Array.isArray(pids) ? pids : [pids]; + let postData = await Posts.getPostsData(pids); + pids = pids.filter((pid, index) => Boolean(postData[index])); + postData = postData.filter(Boolean); + if (postData.length === 0) { + return; + } + + const uniqTids = _.uniq(postData.map(p => p.tid)); + const topicData = await topics.getTopicsFields(uniqTids, ['tid', 'cid', 'pinned', 'postcount']); + const tidToTopic = _.zipObject(uniqTids, topicData); + + for (const p of postData) { + p.topic = tidToTopic[p.tid]; + p.cid = tidToTopic[p.tid] && tidToTopic[p.tid].cid; + } + + // Deprecated hook + await Promise.all(postData.map(p => plugins.hooks.fire('filter:post.purge', {post: p, pid: p.pid, uid}))); + + // New hook + await plugins.hooks.fire('filter:posts.purge', { + posts: postData, + pids: postData.map(p => p.pid), + uid, + }); + + await Promise.all([ + deleteFromTopicUserNotification(postData), + deleteFromCategoryRecentPosts(postData), + deleteFromUsersBookmarks(pids), + deleteFromUsersVotes(pids), + deleteFromReplies(postData), + deleteFromGroups(pids), + deleteDiffs(pids), + deleteFromUploads(pids), + db.sortedSetsRemove(['posts:pid', 'posts:votes', 'posts:flagged'], pids), + ]); + + await resolveFlags(postData, uid); + + // Deprecated hook + Promise.all(postData.map(p => plugins.hooks.fire('action:post.purge', {post: p, uid}))); + + // New hook + plugins.hooks.fire('action:posts.purge', {posts: postData, uid}); + + await db.deleteAll(postData.map(p => `post:${p.pid}`)); + }; + + async function deleteFromTopicUserNotification(postData) { + const bulkRemove = []; + for (const p of postData) { + bulkRemove.push([`tid:${p.tid}:posts`, p.pid], [`tid:${p.tid}:posts:votes`, p.pid], [`uid:${p.uid}:posts`, p.pid], [`cid:${p.cid}:uid:${p.uid}:pids`, p.pid], [`cid:${p.cid}:uid:${p.uid}:pids:votes`, p.pid]); + } + + await db.sortedSetRemoveBulk(bulkRemove); + + const incrObjectBulk = [['global', {postCount: -postData.length}]]; + + const postsByCategory = _.groupBy(postData, p => Number.parseInt(p.cid, 10)); + for (const [cid, posts] of Object.entries(postsByCategory)) { + incrObjectBulk.push([`category:${cid}`, {post_count: -posts.length}]); + } + + const postsByTopic = _.groupBy(postData, p => Number.parseInt(p.tid, 10)); + const topicPostCountTasks = []; + const topicTasks = []; + const zsetIncrBulk = []; + for (const [tid, posts] of Object.entries(postsByTopic)) { + incrObjectBulk.push([`topic:${tid}`, {postcount: -posts.length}]); + if (posts.length > 0 && posts[0]) { + const topicData = posts[0].topic; + const newPostCount = topicData.postcount - posts.length; + topicPostCountTasks.push(['topics:posts', newPostCount, tid]); + if (!topicData.pinned) { + zsetIncrBulk.push([`cid:${topicData.cid}:tids:posts`, -posts.length, tid]); + } + } + + topicTasks.push(topics.updateTeaser(tid)); + topicTasks.push(topics.updateLastPostTimeFromLastPid(tid)); + const postsByUid = _.groupBy(posts, p => Number.parseInt(p.uid, 10)); + for (const [uid, uidPosts] of Object.entries(postsByUid)) { + zsetIncrBulk.push([`tid:${tid}:posters`, -uidPosts.length, uid]); + } + + topicTasks.push(db.sortedSetIncrByBulk(zsetIncrBulk)); + } + + await Promise.all([ + db.incrObjectFieldByBulk(incrObjectBulk), + db.sortedSetAddBulk(topicPostCountTasks), + ...topicTasks, + user.updatePostCount(_.uniq(postData.map(p => p.uid))), + notifications.rescind(...postData.map(p => `new_post:tid:${p.tid}:pid:${p.pid}:uid:${p.uid}`)), + ]); + } + + async function deleteFromCategoryRecentPosts(postData) { + const uniqCids = _.uniq(postData.map(p => p.cid)); + const sets = uniqCids.map(cid => `cid:${cid}:pids`); + await db.sortedSetRemove(sets, postData.map(p => p.pid)); + await Promise.all(uniqCids.map(categories.updateRecentTidForCid)); + } + + async function deleteFromUsersBookmarks(pids) { + const arrayOfUids = await db.getSetsMembers(pids.map(pid => `pid:${pid}:users_bookmarked`)); + const bulkRemove = []; + for (const [index, pid] of pids.entries()) { + for (const uid of arrayOfUids[index]) { + bulkRemove.push([`uid:${uid}:bookmarks`, pid]); + } + } + + await db.sortedSetRemoveBulk(bulkRemove); + await db.deleteAll(pids.map(pid => `pid:${pid}:users_bookmarked`)); + } + + async function deleteFromUsersVotes(pids) { + const [upvoters, downvoters] = await Promise.all([ + db.getSetsMembers(pids.map(pid => `pid:${pid}:upvote`)), + db.getSetsMembers(pids.map(pid => `pid:${pid}:downvote`)), + ]); + const bulkRemove = []; + for (const [index, pid] of pids.entries()) { + for (const upvoterUid of upvoters[index]) { + bulkRemove.push([`uid:${upvoterUid}:upvote`, pid]); + } + + for (const downvoterUid of downvoters[index]) { + bulkRemove.push([`uid:${downvoterUid}:downvote`, pid]); + } + } + + await Promise.all([ + db.sortedSetRemoveBulk(bulkRemove), + db.deleteAll([ + ...pids.map(pid => `pid:${pid}:upvote`), + ...pids.map(pid => `pid:${pid}:downvote`), + ]), + ]); + } + + async function deleteFromReplies(postData) { + const arrayOfReplyPids = await db.getSortedSetsMembers(postData.map(p => `pid:${p.pid}:replies`)); + const allReplyPids = arrayOfReplyPids.flat(); + const promises = [ + db.deleteObjectFields( + allReplyPids.map(pid => `post:${pid}`), ['toPid'], + ), + db.deleteAll(postData.map(p => `pid:${p.pid}:replies`)), + ]; + + const postsWithParents = postData.filter(p => Number.parseInt(p.toPid, 10)); + const bulkRemove = postsWithParents.map(p => [`pid:${p.toPid}:replies`, p.pid]); + promises.push(db.sortedSetRemoveBulk(bulkRemove)); + await Promise.all(promises); + + const parentPids = _.uniq(postsWithParents.map(p => p.toPid)); + const counts = await db.sortedSetsCard(parentPids.map(pid => `pid:${pid}:replies`)); + await db.setObjectBulk(parentPids.map((pid, index) => [`post:${pid}`, {replies: counts[index]}])); + } + + async function deleteFromGroups(pids) { + const groupNames = await db.getSortedSetMembers('groups:visible:createtime'); + const keys = groupNames.map(groupName => `group:${groupName}:member:pids`); + await db.sortedSetRemove(keys, pids); + } + + async function deleteDiffs(pids) { + const timestamps = await Promise.all(pids.map(pid => Posts.diffs.list(pid))); + await db.deleteAll([ + ...pids.map(pid => `post:${pid}:diffs`), + ..._.flattenDeep(pids.map((pid, index) => timestamps[index].map(t => `diff:${pid}.${t}`))), + ]); + } + + async function deleteFromUploads(pids) { + await Promise.all(pids.map(Posts.uploads.dissociateAll)); + } + + async function resolveFlags(postData, uid) { + const flaggedPosts = postData.filter(p => Number.parseInt(p.flagId, 10)); + await Promise.all(flaggedPosts.map(p => flags.update(p.flagId, uid, {state: 'resolved'}))); + } }; diff --git a/src/posts/diffs.js b/src/posts/diffs.js index ee3bbeb..ec76914 100644 --- a/src/posts/diffs.js +++ b/src/posts/diffs.js @@ -2,7 +2,6 @@ const validator = require('validator'); const diff = require('diff'); - const db = require('../database'); const meta = require('../meta'); const plugins = require('../plugins'); @@ -10,166 +9,170 @@ const translator = require('../translator'); const topics = require('../topics'); module.exports = function (Posts) { - const Diffs = {}; - Posts.diffs = Diffs; - Diffs.exists = async function (pid) { - if (meta.config.enablePostHistory !== 1) { - return false; - } - - const numDiffs = await db.listLength(`post:${pid}:diffs`); - return !!numDiffs; - }; - - Diffs.get = async function (pid, since) { - const timestamps = await Diffs.list(pid); - if (!since) { - since = 0; - } - - // Pass those made after `since`, and create keys - const keys = timestamps.filter(t => (parseInt(t, 10) || 0) > since) - .map(t => `diff:${pid}.${t}`); - return await db.getObjects(keys); - }; - - Diffs.list = async function (pid) { - return await db.getListRange(`post:${pid}:diffs`, 0, -1); - }; - - Diffs.save = async function (data) { - const { pid, uid, oldContent, newContent, edited, topic } = data; - const editTimestamp = edited || Date.now(); - const diffData = { - uid: uid, - pid: pid, - }; - if (oldContent !== newContent) { - diffData.patch = diff.createPatch('', newContent, oldContent); - } - if (topic.renamed) { - diffData.title = topic.oldTitle; - } - if (topic.tagsupdated && Array.isArray(topic.oldTags)) { - diffData.tags = topic.oldTags.map(tag => tag && tag.value).filter(Boolean).join(','); - } - await Promise.all([ - db.listPrepend(`post:${pid}:diffs`, editTimestamp), - db.setObject(`diff:${pid}.${editTimestamp}`, diffData), - ]); - }; - - Diffs.load = async function (pid, since, uid) { - since = getValidatedTimestamp(since); - const post = await postDiffLoad(pid, since, uid); - post.content = String(post.content || ''); - - const result = await plugins.hooks.fire('filter:parse.post', { postData: post }); - result.postData.content = translator.escape(result.postData.content); - return result.postData; - }; - - Diffs.restore = async function (pid, since, uid, req) { - since = getValidatedTimestamp(since); - const post = await postDiffLoad(pid, since, uid); - - return await Posts.edit({ - uid: uid, - pid: pid, - content: post.content, - req: req, - timestamp: since, - title: post.topic.title, - tags: post.topic.tags.map(tag => tag.value), - }); - }; - - Diffs.delete = async function (pid, timestamp, uid) { - getValidatedTimestamp(timestamp); - - const [post, diffs, timestamps] = await Promise.all([ - Posts.getPostSummaryByPids([pid], uid, { parse: false }), - Diffs.get(pid), - Diffs.list(pid), - ]); - - const timestampIndex = timestamps.indexOf(timestamp); - const lastTimestampIndex = timestamps.length - 1; - - if (timestamp === String(post[0].timestamp)) { - // Deleting oldest diff, so history rewrite is not needed - return Promise.all([ - db.delete(`diff:${pid}.${timestamps[lastTimestampIndex]}`), - db.listRemoveAll(`post:${pid}:diffs`, timestamps[lastTimestampIndex]), - ]); - } - if (timestampIndex === 0 || timestampIndex === -1) { - throw new Error('[[error:invalid-data]]'); - } - - const postContent = validator.unescape(post[0].content); - const versionContents = {}; - for (let i = 0, content = postContent; i < timestamps.length; ++i) { - versionContents[timestamps[i]] = applyPatch(content, diffs[i]); - content = versionContents[timestamps[i]]; - } - - /* eslint-disable no-await-in-loop */ - for (let i = lastTimestampIndex; i >= timestampIndex; --i) { - // Recreate older diffs with skipping the deleted diff - const newContentIndex = i === timestampIndex ? i - 2 : i - 1; - const timestampToUpdate = newContentIndex + 1; - const newContent = newContentIndex < 0 ? postContent : versionContents[timestamps[newContentIndex]]; - const patch = diff.createPatch('', newContent, versionContents[timestamps[i]]); - await db.setObject(`diff:${pid}.${timestamps[timestampToUpdate]}`, { patch }); - } - - return Promise.all([ - db.delete(`diff:${pid}.${timestamp}`), - db.listRemoveAll(`post:${pid}:diffs`, timestamp), - ]); - }; - - async function postDiffLoad(pid, since, uid) { - // Retrieves all diffs made since `since` and replays them to reconstruct what the post looked like at `since` - const [post, diffs] = await Promise.all([ - Posts.getPostSummaryByPids([pid], uid, { parse: false }), - Posts.diffs.get(pid, since), - ]); - - // Replace content with re-constructed content from that point in time - post[0].content = diffs.reduce(applyPatch, validator.unescape(post[0].content)); - - const titleDiffs = diffs.filter(d => d.hasOwnProperty('title') && d.title); - if (titleDiffs.length && post[0].topic) { - post[0].topic.title = validator.unescape(String(titleDiffs[titleDiffs.length - 1].title)); - } - const tagDiffs = diffs.filter(d => d.hasOwnProperty('tags') && d.tags); - if (tagDiffs.length && post[0].topic) { - const tags = tagDiffs[tagDiffs.length - 1].tags.split(',').map(tag => ({ value: tag })); - post[0].topic.tags = await topics.getTagData(tags); - } - - return post[0]; - } - - function getValidatedTimestamp(timestamp) { - timestamp = parseInt(timestamp, 10); - - if (isNaN(timestamp)) { - throw new Error('[[error:invalid-data]]'); - } - - return timestamp; - } - - function applyPatch(content, aDiff) { - if (aDiff && aDiff.patch) { - const result = diff.applyPatch(content, aDiff.patch, { - fuzzFactor: 1, - }); - return typeof result === 'string' ? result : content; - } - return content; - } + const Diffs = {}; + Posts.diffs = Diffs; + Diffs.exists = async function (pid) { + if (meta.config.enablePostHistory !== 1) { + return false; + } + + const numberDiffs = await db.listLength(`post:${pid}:diffs`); + return Boolean(numberDiffs); + }; + + Diffs.get = async function (pid, since) { + const timestamps = await Diffs.list(pid); + since ||= 0; + + // Pass those made after `since`, and create keys + const keys = timestamps.filter(t => (Number.parseInt(t, 10) || 0) > since) + .map(t => `diff:${pid}.${t}`); + return await db.getObjects(keys); + }; + + Diffs.list = async function (pid) { + return await db.getListRange(`post:${pid}:diffs`, 0, -1); + }; + + Diffs.save = async function (data) { + const {pid, uid, oldContent, newContent, edited, topic} = data; + const editTimestamp = edited || Date.now(); + const diffData = { + uid, + pid, + }; + if (oldContent !== newContent) { + diffData.patch = diff.createPatch('', newContent, oldContent); + } + + if (topic.renamed) { + diffData.title = topic.oldTitle; + } + + if (topic.tagsupdated && Array.isArray(topic.oldTags)) { + diffData.tags = topic.oldTags.map(tag => tag && tag.value).filter(Boolean).join(','); + } + + await Promise.all([ + db.listPrepend(`post:${pid}:diffs`, editTimestamp), + db.setObject(`diff:${pid}.${editTimestamp}`, diffData), + ]); + }; + + Diffs.load = async function (pid, since, uid) { + since = getValidatedTimestamp(since); + const post = await postDiffLoad(pid, since, uid); + post.content = String(post.content || ''); + + const result = await plugins.hooks.fire('filter:parse.post', {postData: post}); + result.postData.content = translator.escape(result.postData.content); + return result.postData; + }; + + Diffs.restore = async function (pid, since, uid, request) { + since = getValidatedTimestamp(since); + const post = await postDiffLoad(pid, since, uid); + + return await Posts.edit({ + uid, + pid, + content: post.content, + req: request, + timestamp: since, + title: post.topic.title, + tags: post.topic.tags.map(tag => tag.value), + }); + }; + + Diffs.delete = async function (pid, timestamp, uid) { + getValidatedTimestamp(timestamp); + + const [post, diffs, timestamps] = await Promise.all([ + Posts.getPostSummaryByPids([pid], uid, {parse: false}), + Diffs.get(pid), + Diffs.list(pid), + ]); + + const timestampIndex = timestamps.indexOf(timestamp); + const lastTimestampIndex = timestamps.length - 1; + + if (timestamp === String(post[0].timestamp)) { + // Deleting oldest diff, so history rewrite is not needed + return Promise.all([ + db.delete(`diff:${pid}.${timestamps[lastTimestampIndex]}`), + db.listRemoveAll(`post:${pid}:diffs`, timestamps[lastTimestampIndex]), + ]); + } + + if (timestampIndex === 0 || timestampIndex === -1) { + throw new Error('[[error:invalid-data]]'); + } + + const postContent = validator.unescape(post[0].content); + const versionContents = {}; + for (let i = 0, content = postContent; i < timestamps.length; ++i) { + versionContents[timestamps[i]] = applyPatch(content, diffs[i]); + content = versionContents[timestamps[i]]; + } + + /* eslint-disable no-await-in-loop */ + for (let i = lastTimestampIndex; i >= timestampIndex; --i) { + // Recreate older diffs with skipping the deleted diff + const newContentIndex = i === timestampIndex ? i - 2 : i - 1; + const timestampToUpdate = newContentIndex + 1; + const newContent = newContentIndex < 0 ? postContent : versionContents[timestamps[newContentIndex]]; + const patch = diff.createPatch('', newContent, versionContents[timestamps[i]]); + await db.setObject(`diff:${pid}.${timestamps[timestampToUpdate]}`, {patch}); + } + + return Promise.all([ + db.delete(`diff:${pid}.${timestamp}`), + db.listRemoveAll(`post:${pid}:diffs`, timestamp), + ]); + }; + + async function postDiffLoad(pid, since, uid) { + // Retrieves all diffs made since `since` and replays them to reconstruct what the post looked like at `since` + const [post, diffs] = await Promise.all([ + Posts.getPostSummaryByPids([pid], uid, {parse: false}), + Posts.diffs.get(pid, since), + ]); + + // Replace content with re-constructed content from that point in time + post[0].content = diffs.reduce(applyPatch, validator.unescape(post[0].content)); + + const titleDiffs = diffs.filter(d => d.hasOwnProperty('title') && d.title); + if (titleDiffs.length > 0 && post[0].topic) { + post[0].topic.title = validator.unescape(String(titleDiffs.at(-1).title)); + } + + const tagDiffs = diffs.filter(d => d.hasOwnProperty('tags') && d.tags); + if (tagDiffs.length > 0 && post[0].topic) { + const tags = tagDiffs.at(-1).tags.split(',').map(tag => ({value: tag})); + post[0].topic.tags = await topics.getTagData(tags); + } + + return post[0]; + } + + function getValidatedTimestamp(timestamp) { + timestamp = Number.parseInt(timestamp, 10); + + if (isNaN(timestamp)) { + throw new TypeError('[[error:invalid-data]]'); + } + + return timestamp; + } + + function applyPatch(content, aDiff) { + if (aDiff && aDiff.patch) { + const result = diff.applyPatch(content, aDiff.patch, { + fuzzFactor: 1, + }); + return typeof result === 'string' ? result : content; + } + + return content; + } }; diff --git a/src/posts/edit.js b/src/posts/edit.js index 6349148..a377bca 100644 --- a/src/posts/edit.js +++ b/src/posts/edit.js @@ -2,7 +2,6 @@ const validator = require('validator'); const _ = require('lodash'); - const db = require('../database'); const meta = require('../meta'); const topics = require('../topics'); @@ -15,203 +14,209 @@ const slugify = require('../slugify'); const translator = require('../translator'); module.exports = function (Posts) { - pubsub.on('post:edit', (pid) => { - require('./cache').del(pid); - }); - - Posts.edit = async function (data) { - const canEdit = await privileges.posts.canEdit(data.pid, data.uid); - if (!canEdit.flag) { - throw new Error(canEdit.message); - } - const postData = await Posts.getPostData(data.pid); - if (!postData) { - throw new Error('[[error:no-post]]'); - } - - const topicData = await topics.getTopicFields(postData.tid, [ - 'cid', 'mainPid', 'title', 'timestamp', 'scheduled', 'slug', 'tags', - ]); - - await scheduledTopicCheck(data, topicData); - - const oldContent = postData.content; // for diffing purposes - const editPostData = getEditPostData(data, topicData, postData); - - if (data.handle) { - editPostData.handle = data.handle; - } - - const result = await plugins.hooks.fire('filter:post.edit', { - req: data.req, - post: editPostData, - data: data, - uid: data.uid, - }); - - const [editor, topic] = await Promise.all([ - user.getUserFields(data.uid, ['username', 'userslug']), - editMainPost(data, postData, topicData), - ]); - - await Posts.setPostFields(data.pid, result.post); - const contentChanged = data.content !== oldContent || - topic.renamed || - topic.tagsupdated; - - if (meta.config.enablePostHistory === 1 && contentChanged) { - await Posts.diffs.save({ - pid: data.pid, - uid: data.uid, - oldContent: oldContent, - newContent: data.content, - edited: editPostData.edited, - topic, - }); - } - await Posts.uploads.sync(data.pid); - - // Normalize data prior to constructing returnPostData (match types with getPostSummaryByPids) - postData.deleted = !!postData.deleted; - - const returnPostData = { ...postData, ...result.post }; - returnPostData.cid = topic.cid; - returnPostData.topic = topic; - returnPostData.editedISO = utils.toISOString(editPostData.edited); - returnPostData.changed = contentChanged; - returnPostData.oldContent = oldContent; - returnPostData.newContent = data.content; - - await topics.notifyFollowers(returnPostData, data.uid, { - type: 'post-edit', - bodyShort: translator.compile('notifications:user_edited_post', editor.username, topic.title), - nid: `edit_post:${data.pid}:uid:${data.uid}`, - }); - await topics.syncBacklinks(returnPostData); - - plugins.hooks.fire('action:post.edit', { post: _.clone(returnPostData), data: data, uid: data.uid }); - - require('./cache').del(String(postData.pid)); - pubsub.publish('post:edit', String(postData.pid)); - - await Posts.parsePost(returnPostData); - - return { - topic: topic, - editor: editor, - post: returnPostData, - }; - }; - - async function editMainPost(data, postData, topicData) { - const { tid } = postData; - const title = data.title ? data.title.trim() : ''; - - const isMain = parseInt(data.pid, 10) === parseInt(topicData.mainPid, 10); - if (!isMain) { - return { - tid: tid, - cid: topicData.cid, - title: validator.escape(String(topicData.title)), - isMainPost: false, - renamed: false, - tagsupdated: false, - }; - } - - const newTopicData = { - tid: tid, - cid: topicData.cid, - uid: postData.uid, - mainPid: data.pid, - timestamp: rescheduling(data, topicData) ? data.timestamp : topicData.timestamp, - }; - if (title) { - newTopicData.title = title; - newTopicData.slug = `${tid}/${slugify(title) || 'topic'}`; - } - - const tagsupdated = Array.isArray(data.tags) && - !_.isEqual(data.tags, topicData.tags.map(tag => tag.value)); - - if (tagsupdated) { - const canTag = await privileges.categories.can('topics:tag', topicData.cid, data.uid); - if (!canTag) { - throw new Error('[[error:no-privileges]]'); - } - await topics.validateTags(data.tags, topicData.cid, data.uid, tid); - } - - const results = await plugins.hooks.fire('filter:topic.edit', { - req: data.req, - topic: newTopicData, - data: data, - }); - await db.setObject(`topic:${tid}`, results.topic); - if (tagsupdated) { - await topics.updateTopicTags(tid, data.tags); - } - const tags = await topics.getTopicTagsObjects(tid); - - if (rescheduling(data, topicData)) { - await topics.scheduled.reschedule(newTopicData); - } - - newTopicData.tags = data.tags; - newTopicData.oldTitle = topicData.title; - const renamed = title && translator.escape(validator.escape(String(title))) !== topicData.title; - plugins.hooks.fire('action:topic.edit', { topic: newTopicData, uid: data.uid }); - return { - tid: tid, - cid: newTopicData.cid, - uid: postData.uid, - title: validator.escape(String(title)), - oldTitle: topicData.title, - slug: newTopicData.slug || topicData.slug, - isMainPost: true, - renamed: renamed, - tagsupdated: tagsupdated, - tags: tags, - oldTags: topicData.tags, - rescheduled: rescheduling(data, topicData), - }; - } - - async function scheduledTopicCheck(data, topicData) { - if (!topicData.scheduled) { - return; - } - const canSchedule = await privileges.categories.can('topics:schedule', topicData.cid, data.uid); - if (!canSchedule) { - throw new Error('[[error:no-privileges]]'); - } - const isMain = parseInt(data.pid, 10) === parseInt(topicData.mainPid, 10); - if (isMain && (isNaN(data.timestamp) || data.timestamp < Date.now())) { - throw new Error('[[error:invalid-data]]'); - } - } - - function getEditPostData(data, topicData, postData) { - const editPostData = { - content: data.content, - editor: data.uid, - }; - - // For posts in scheduled topics, if edited before, use edit timestamp - editPostData.edited = topicData.scheduled ? (postData.edited || postData.timestamp) + 1 : Date.now(); - - // if rescheduling the main post - if (rescheduling(data, topicData)) { - // For main posts, use timestamp coming from user (otherwise, it is ignored) - editPostData.edited = data.timestamp; - editPostData.timestamp = data.timestamp; - } - - return editPostData; - } - - function rescheduling(data, topicData) { - const isMain = parseInt(data.pid, 10) === parseInt(topicData.mainPid, 10); - return isMain && topicData.scheduled && topicData.timestamp !== data.timestamp; - } + pubsub.on('post:edit', pid => { + require('./cache').del(pid); + }); + + Posts.edit = async function (data) { + const canEdit = await privileges.posts.canEdit(data.pid, data.uid); + if (!canEdit.flag) { + throw new Error(canEdit.message); + } + + const postData = await Posts.getPostData(data.pid); + if (!postData) { + throw new Error('[[error:no-post]]'); + } + + const topicData = await topics.getTopicFields(postData.tid, [ + 'cid', 'mainPid', 'title', 'timestamp', 'scheduled', 'slug', 'tags', + ]); + + await scheduledTopicCheck(data, topicData); + + const oldContent = postData.content; // For diffing purposes + const editPostData = getEditPostData(data, topicData, postData); + + if (data.handle) { + editPostData.handle = data.handle; + } + + const result = await plugins.hooks.fire('filter:post.edit', { + req: data.req, + post: editPostData, + data, + uid: data.uid, + }); + + const [editor, topic] = await Promise.all([ + user.getUserFields(data.uid, ['username', 'userslug']), + editMainPost(data, postData, topicData), + ]); + + await Posts.setPostFields(data.pid, result.post); + const contentChanged = data.content !== oldContent + || topic.renamed + || topic.tagsupdated; + + if (meta.config.enablePostHistory === 1 && contentChanged) { + await Posts.diffs.save({ + pid: data.pid, + uid: data.uid, + oldContent, + newContent: data.content, + edited: editPostData.edited, + topic, + }); + } + + await Posts.uploads.sync(data.pid); + + // Normalize data prior to constructing returnPostData (match types with getPostSummaryByPids) + postData.deleted = Boolean(postData.deleted); + + const returnPostData = {...postData, ...result.post}; + returnPostData.cid = topic.cid; + returnPostData.topic = topic; + returnPostData.editedISO = utils.toISOString(editPostData.edited); + returnPostData.changed = contentChanged; + returnPostData.oldContent = oldContent; + returnPostData.newContent = data.content; + + await topics.notifyFollowers(returnPostData, data.uid, { + type: 'post-edit', + bodyShort: translator.compile('notifications:user_edited_post', editor.username, topic.title), + nid: `edit_post:${data.pid}:uid:${data.uid}`, + }); + await topics.syncBacklinks(returnPostData); + + plugins.hooks.fire('action:post.edit', {post: _.clone(returnPostData), data, uid: data.uid}); + + require('./cache').del(String(postData.pid)); + pubsub.publish('post:edit', String(postData.pid)); + + await Posts.parsePost(returnPostData); + + return { + topic, + editor, + post: returnPostData, + }; + }; + + async function editMainPost(data, postData, topicData) { + const {tid} = postData; + const title = data.title ? data.title.trim() : ''; + + const isMain = Number.parseInt(data.pid, 10) === Number.parseInt(topicData.mainPid, 10); + if (!isMain) { + return { + tid, + cid: topicData.cid, + title: validator.escape(String(topicData.title)), + isMainPost: false, + renamed: false, + tagsupdated: false, + }; + } + + const newTopicData = { + tid, + cid: topicData.cid, + uid: postData.uid, + mainPid: data.pid, + timestamp: rescheduling(data, topicData) ? data.timestamp : topicData.timestamp, + }; + if (title) { + newTopicData.title = title; + newTopicData.slug = `${tid}/${slugify(title) || 'topic'}`; + } + + const tagsupdated = Array.isArray(data.tags) + && !_.isEqual(data.tags, topicData.tags.map(tag => tag.value)); + + if (tagsupdated) { + const canTag = await privileges.categories.can('topics:tag', topicData.cid, data.uid); + if (!canTag) { + throw new Error('[[error:no-privileges]]'); + } + + await topics.validateTags(data.tags, topicData.cid, data.uid, tid); + } + + const results = await plugins.hooks.fire('filter:topic.edit', { + req: data.req, + topic: newTopicData, + data, + }); + await db.setObject(`topic:${tid}`, results.topic); + if (tagsupdated) { + await topics.updateTopicTags(tid, data.tags); + } + + const tags = await topics.getTopicTagsObjects(tid); + + if (rescheduling(data, topicData)) { + await topics.scheduled.reschedule(newTopicData); + } + + newTopicData.tags = data.tags; + newTopicData.oldTitle = topicData.title; + const renamed = title && translator.escape(validator.escape(String(title))) !== topicData.title; + plugins.hooks.fire('action:topic.edit', {topic: newTopicData, uid: data.uid}); + return { + tid, + cid: newTopicData.cid, + uid: postData.uid, + title: validator.escape(String(title)), + oldTitle: topicData.title, + slug: newTopicData.slug || topicData.slug, + isMainPost: true, + renamed, + tagsupdated, + tags, + oldTags: topicData.tags, + rescheduled: rescheduling(data, topicData), + }; + } + + async function scheduledTopicCheck(data, topicData) { + if (!topicData.scheduled) { + return; + } + + const canSchedule = await privileges.categories.can('topics:schedule', topicData.cid, data.uid); + if (!canSchedule) { + throw new Error('[[error:no-privileges]]'); + } + + const isMain = Number.parseInt(data.pid, 10) === Number.parseInt(topicData.mainPid, 10); + if (isMain && (isNaN(data.timestamp) || data.timestamp < Date.now())) { + throw new Error('[[error:invalid-data]]'); + } + } + + function getEditPostData(data, topicData, postData) { + const editPostData = { + content: data.content, + editor: data.uid, + }; + + // For posts in scheduled topics, if edited before, use edit timestamp + editPostData.edited = topicData.scheduled ? (postData.edited || postData.timestamp) + 1 : Date.now(); + + // If rescheduling the main post + if (rescheduling(data, topicData)) { + // For main posts, use timestamp coming from user (otherwise, it is ignored) + editPostData.edited = data.timestamp; + editPostData.timestamp = data.timestamp; + } + + return editPostData; + } + + function rescheduling(data, topicData) { + const isMain = Number.parseInt(data.pid, 10) === Number.parseInt(topicData.mainPid, 10); + return isMain && topicData.scheduled && topicData.timestamp !== data.timestamp; + } }; diff --git a/src/posts/index.js b/src/posts/index.js index 17722b0..36b1035 100644 --- a/src/posts/index.js +++ b/src/posts/index.js @@ -1,7 +1,6 @@ 'use strict'; const _ = require('lodash'); - const db = require('../database'); const utils = require('../utils'); const user = require('../user'); @@ -30,77 +29,82 @@ require('./diffs')(Posts); require('./uploads')(Posts); Posts.exists = async function (pids) { - return await db.exists( - Array.isArray(pids) ? pids.map(pid => `post:${pid}`) : `post:${pids}` - ); + return await db.exists( + Array.isArray(pids) ? pids.map(pid => `post:${pid}`) : `post:${pids}`, + ); }; Posts.getPidsFromSet = async function (set, start, stop, reverse) { - if (isNaN(start) || isNaN(stop)) { - return []; - } - return await db[reverse ? 'getSortedSetRevRange' : 'getSortedSetRange'](set, start, stop); + if (isNaN(start) || isNaN(stop)) { + return []; + } + + return await db[reverse ? 'getSortedSetRevRange' : 'getSortedSetRange'](set, start, stop); }; Posts.getPostsByPids = async function (pids, uid) { - if (!Array.isArray(pids) || !pids.length) { - return []; - } - let posts = await Posts.getPostsData(pids); - posts = await Promise.all(posts.map(Posts.parsePost)); - const data = await plugins.hooks.fire('filter:post.getPosts', { posts: posts, uid: uid }); - if (!data || !Array.isArray(data.posts)) { - return []; - } - return data.posts.filter(Boolean); + if (!Array.isArray(pids) || pids.length === 0) { + return []; + } + + let posts = await Posts.getPostsData(pids); + posts = await Promise.all(posts.map(Posts.parsePost)); + const data = await plugins.hooks.fire('filter:post.getPosts', {posts, uid}); + if (!data || !Array.isArray(data.posts)) { + return []; + } + + return data.posts.filter(Boolean); }; Posts.getPostSummariesFromSet = async function (set, uid, start, stop) { - let pids = await db.getSortedSetRevRange(set, start, stop); - pids = await privileges.posts.filter('topics:read', pids, uid); - const posts = await Posts.getPostSummaryByPids(pids, uid, { stripTags: false }); - return { posts: posts, nextStart: stop + 1 }; + let pids = await db.getSortedSetRevRange(set, start, stop); + pids = await privileges.posts.filter('topics:read', pids, uid); + const posts = await Posts.getPostSummaryByPids(pids, uid, {stripTags: false}); + return {posts, nextStart: stop + 1}; }; Posts.getPidIndex = async function (pid, tid, topicPostSort) { - const set = topicPostSort === 'most_votes' ? `tid:${tid}:posts:votes` : `tid:${tid}:posts`; - const reverse = topicPostSort === 'newest_to_oldest' || topicPostSort === 'most_votes'; - const index = await db[reverse ? 'sortedSetRevRank' : 'sortedSetRank'](set, pid); - if (!utils.isNumber(index)) { - return 0; - } - return utils.isNumber(index) ? parseInt(index, 10) + 1 : 0; + const set = topicPostSort === 'most_votes' ? `tid:${tid}:posts:votes` : `tid:${tid}:posts`; + const reverse = topicPostSort === 'newest_to_oldest' || topicPostSort === 'most_votes'; + const index = await db[reverse ? 'sortedSetRevRank' : 'sortedSetRank'](set, pid); + if (!utils.isNumber(index)) { + return 0; + } + + return utils.isNumber(index) ? Number.parseInt(index, 10) + 1 : 0; }; Posts.getPostIndices = async function (posts, uid) { - if (!Array.isArray(posts) || !posts.length) { - return []; - } - const settings = await user.getSettings(uid); - - const byVotes = settings.topicPostSort === 'most_votes'; - let sets = posts.map(p => (byVotes ? `tid:${p.tid}:posts:votes` : `tid:${p.tid}:posts`)); - const reverse = settings.topicPostSort === 'newest_to_oldest' || settings.topicPostSort === 'most_votes'; - - const uniqueSets = _.uniq(sets); - let method = reverse ? 'sortedSetsRevRanks' : 'sortedSetsRanks'; - if (uniqueSets.length === 1) { - method = reverse ? 'sortedSetRevRanks' : 'sortedSetRanks'; - sets = uniqueSets[0]; - } - - const pids = posts.map(post => post.pid); - const indices = await db[method](sets, pids); - return indices.map(index => (utils.isNumber(index) ? parseInt(index, 10) + 1 : 0)); + if (!Array.isArray(posts) || posts.length === 0) { + return []; + } + + const settings = await user.getSettings(uid); + + const byVotes = settings.topicPostSort === 'most_votes'; + let sets = posts.map(p => (byVotes ? `tid:${p.tid}:posts:votes` : `tid:${p.tid}:posts`)); + const reverse = settings.topicPostSort === 'newest_to_oldest' || settings.topicPostSort === 'most_votes'; + + const uniqueSets = _.uniq(sets); + let method = reverse ? 'sortedSetsRevRanks' : 'sortedSetsRanks'; + if (uniqueSets.length === 1) { + method = reverse ? 'sortedSetRevRanks' : 'sortedSetRanks'; + sets = uniqueSets[0]; + } + + const pids = posts.map(post => post.pid); + const indices = await db[method](sets, pids); + return indices.map(index => (utils.isNumber(index) ? Number.parseInt(index, 10) + 1 : 0)); }; Posts.modifyPostByPrivilege = function (post, privileges) { - if (post && post.deleted && !(post.selfPost || privileges['posts:view_deleted'])) { - post.content = '[[topic:post_is_deleted]]'; - if (post.user) { - post.user.signature = ''; - } - } + if (post && post.deleted && !(post.selfPost || privileges['posts:view_deleted'])) { + post.content = '[[topic:post_is_deleted]]'; + if (post.user) { + post.user.signature = ''; + } + } }; require('../promisify')(Posts); diff --git a/src/posts/parse.js b/src/posts/parse.js index 771fd0d..e27249d 100644 --- a/src/posts/parse.js +++ b/src/posts/parse.js @@ -1,174 +1,196 @@ 'use strict'; +const url = require('node:url'); const nconf = require('nconf'); -const url = require('url'); const winston = require('winston'); const sanitize = require('sanitize-html'); const _ = require('lodash'); - const meta = require('../meta'); const plugins = require('../plugins'); const translator = require('../translator'); const utils = require('../utils'); let sanitizeConfig = { - allowedTags: sanitize.defaults.allowedTags.concat([ - // Some safe-to-use tags to add - 'sup', 'ins', 'del', 'img', 'button', - 'video', 'audio', 'iframe', 'embed', - // 'sup' still necessary until https://github.com/apostrophecms/sanitize-html/pull/422 merged - ]), - allowedAttributes: { - ...sanitize.defaults.allowedAttributes, - a: ['href', 'name', 'hreflang', 'media', 'rel', 'target', 'type'], - img: ['alt', 'height', 'ismap', 'src', 'usemap', 'width', 'srcset'], - iframe: ['height', 'name', 'src', 'width'], - video: ['autoplay', 'controls', 'height', 'loop', 'muted', 'poster', 'preload', 'src', 'width'], - audio: ['autoplay', 'controls', 'loop', 'muted', 'preload', 'src'], - embed: ['height', 'src', 'type', 'width'], - }, - globalAttributes: ['accesskey', 'class', 'contenteditable', 'dir', - 'draggable', 'dropzone', 'hidden', 'id', 'lang', 'spellcheck', 'style', - 'tabindex', 'title', 'translate', 'aria-expanded', 'data-*', - ], - allowedClasses: { - ...sanitize.defaults.allowedClasses, - }, + allowedTags: sanitize.defaults.allowedTags.concat([ + // Some safe-to-use tags to add + 'sup', + 'ins', + 'del', + 'img', + 'button', + 'video', + 'audio', + 'iframe', + 'embed', + // 'sup' still necessary until https://github.com/apostrophecms/sanitize-html/pull/422 merged + ]), + allowedAttributes: { + ...sanitize.defaults.allowedAttributes, + a: ['href', 'name', 'hreflang', 'media', 'rel', 'target', 'type'], + img: ['alt', 'height', 'ismap', 'src', 'usemap', 'width', 'srcset'], + iframe: ['height', 'name', 'src', 'width'], + video: ['autoplay', 'controls', 'height', 'loop', 'muted', 'poster', 'preload', 'src', 'width'], + audio: ['autoplay', 'controls', 'loop', 'muted', 'preload', 'src'], + embed: ['height', 'src', 'type', 'width'], + }, + globalAttributes: ['accesskey', + 'class', + 'contenteditable', + 'dir', + 'draggable', + 'dropzone', + 'hidden', + 'id', + 'lang', + 'spellcheck', + 'style', + 'tabindex', + 'title', + 'translate', + 'aria-expanded', + 'data-*'], + allowedClasses: { + ...sanitize.defaults.allowedClasses, + }, }; module.exports = function (Posts) { - Posts.urlRegex = { - regex: /href="([^"]+)"/g, - length: 6, - }; - - Posts.imgRegex = { - regex: /src="([^"]+)"/g, - length: 5, - }; - - Posts.parsePost = async function (postData) { - if (!postData) { - return postData; - } - postData.content = String(postData.content || ''); - const cache = require('./cache'); - const pid = String(postData.pid); - const cachedContent = cache.get(pid); - if (postData.pid && cachedContent !== undefined) { - postData.content = cachedContent; - return postData; - } - - const data = await plugins.hooks.fire('filter:parse.post', { postData: postData }); - data.postData.content = translator.escape(data.postData.content); - if (data.postData.pid) { - cache.set(pid, data.postData.content); - } - return data.postData; - }; - - Posts.parseSignature = async function (userData, uid) { - userData.signature = sanitizeSignature(userData.signature || ''); - return await plugins.hooks.fire('filter:parse.signature', { userData: userData, uid: uid }); - }; - - Posts.relativeToAbsolute = function (content, regex) { - // Turns relative links in content to absolute urls - if (!content) { - return content; - } - let parsed; - let current = regex.regex.exec(content); - let absolute; - while (current !== null) { - if (current[1]) { - try { - parsed = url.parse(current[1]); - if (!parsed.protocol) { - if (current[1].startsWith('/')) { - // Internal link - absolute = nconf.get('base_url') + current[1]; - } else { - // External link - absolute = `//${current[1]}`; - } - - content = content.slice(0, current.index + regex.length) + - absolute + - content.slice(current.index + regex.length + current[1].length); - } - } catch (err) { - winston.verbose(err.messsage); - } - } - current = regex.regex.exec(content); - } - - return content; - }; - - Posts.sanitize = function (content) { - return sanitize(content, { - allowedTags: sanitizeConfig.allowedTags, - allowedAttributes: sanitizeConfig.allowedAttributes, - allowedClasses: sanitizeConfig.allowedClasses, - }); - }; - - Posts.configureSanitize = async () => { - // Each allowed tags should have some common global attributes... - sanitizeConfig.allowedTags.forEach((tag) => { - sanitizeConfig.allowedAttributes[tag] = _.union( - sanitizeConfig.allowedAttributes[tag], - sanitizeConfig.globalAttributes - ); - }); - - // Some plugins might need to adjust or whitelist their own tags... - sanitizeConfig = await plugins.hooks.fire('filter:sanitize.config', sanitizeConfig); - }; - - Posts.registerHooks = () => { - plugins.hooks.register('core', { - hook: 'filter:parse.post', - method: async (data) => { - data.postData.content = Posts.sanitize(data.postData.content); - return data; - }, - }); - - plugins.hooks.register('core', { - hook: 'filter:parse.raw', - method: async content => Posts.sanitize(content), - }); - - plugins.hooks.register('core', { - hook: 'filter:parse.aboutme', - method: async content => Posts.sanitize(content), - }); - - plugins.hooks.register('core', { - hook: 'filter:parse.signature', - method: async (data) => { - data.userData.signature = Posts.sanitize(data.userData.signature); - return data; - }, - }); - }; - - function sanitizeSignature(signature) { - signature = translator.escape(signature); - const tagsToStrip = []; - - if (meta.config['signatures:disableLinks']) { - tagsToStrip.push('a'); - } - - if (meta.config['signatures:disableImages']) { - tagsToStrip.push('img'); - } - - return utils.stripHTMLTags(signature, tagsToStrip); - } + Posts.urlRegex = { + regex: /href="([^"]+)"/g, + length: 6, + }; + + Posts.imgRegex = { + regex: /src="([^"]+)"/g, + length: 5, + }; + + Posts.parsePost = async function (postData) { + if (!postData) { + return postData; + } + + postData.content = String(postData.content || ''); + const cache = require('./cache'); + const pid = String(postData.pid); + const cachedContent = cache.get(pid); + if (postData.pid && cachedContent !== undefined) { + postData.content = cachedContent; + return postData; + } + + const data = await plugins.hooks.fire('filter:parse.post', {postData}); + data.postData.content = translator.escape(data.postData.content); + if (data.postData.pid) { + cache.set(pid, data.postData.content); + } + + return data.postData; + }; + + Posts.parseSignature = async function (userData, uid) { + userData.signature = sanitizeSignature(userData.signature || ''); + return await plugins.hooks.fire('filter:parse.signature', {userData, uid}); + }; + + Posts.relativeToAbsolute = function (content, regex) { + // Turns relative links in content to absolute urls + if (!content) { + return content; + } + + let parsed; + let current = regex.regex.exec(content); + let absolute; + while (current !== null) { + if (current[1]) { + try { + parsed = url.parse(current[1]); + if (!parsed.protocol) { + if (current[1].startsWith('/')) { + // Internal link + absolute = nconf.get('base_url') + current[1]; + } else { + // External link + absolute = `//${current[1]}`; + } + + content = content.slice(0, current.index + regex.length) + + absolute + + content.slice(current.index + regex.length + current[1].length); + } + } catch (error) { + winston.verbose(error.messsage); + } + } + + current = regex.regex.exec(content); + } + + return content; + }; + + Posts.sanitize = function (content) { + return sanitize(content, { + allowedTags: sanitizeConfig.allowedTags, + allowedAttributes: sanitizeConfig.allowedAttributes, + allowedClasses: sanitizeConfig.allowedClasses, + }); + }; + + Posts.configureSanitize = async () => { + // Each allowed tags should have some common global attributes... + for (const tag of sanitizeConfig.allowedTags) { + sanitizeConfig.allowedAttributes[tag] = _.union( + sanitizeConfig.allowedAttributes[tag], + sanitizeConfig.globalAttributes, + ); + } + + // Some plugins might need to adjust or whitelist their own tags... + sanitizeConfig = await plugins.hooks.fire('filter:sanitize.config', sanitizeConfig); + }; + + Posts.registerHooks = () => { + plugins.hooks.register('core', { + hook: 'filter:parse.post', + async method(data) { + data.postData.content = Posts.sanitize(data.postData.content); + return data; + }, + }); + + plugins.hooks.register('core', { + hook: 'filter:parse.raw', + method: async content => Posts.sanitize(content), + }); + + plugins.hooks.register('core', { + hook: 'filter:parse.aboutme', + method: async content => Posts.sanitize(content), + }); + + plugins.hooks.register('core', { + hook: 'filter:parse.signature', + async method(data) { + data.userData.signature = Posts.sanitize(data.userData.signature); + return data; + }, + }); + }; + + function sanitizeSignature(signature) { + signature = translator.escape(signature); + const tagsToStrip = []; + + if (meta.config['signatures:disableLinks']) { + tagsToStrip.push('a'); + } + + if (meta.config['signatures:disableImages']) { + tagsToStrip.push('img'); + } + + return utils.stripHTMLTags(signature, tagsToStrip); + } }; diff --git a/src/posts/pin.js b/src/posts/pin.js index 1363ba8..9dc84d3 100644 --- a/src/posts/pin.js +++ b/src/posts/pin.js @@ -15,10 +15,10 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge }; const plugins = require("../plugins"); const topics = require("../topics"); -function postFunc(Posts) { +function postFunction(Posts) { function togglePin(type, pid, uid) { return __awaiter(this, void 0, void 0, function* () { - if (parseInt(uid, 10) <= 0) { + if (Number.parseInt(uid, 10) <= 0) { throw new Error('[[error:not-logged-in]]'); } const isPinning = type === 'pin'; @@ -34,9 +34,9 @@ function postFunc(Posts) { const toWrite = isPinning ? 1 : 0; yield Posts.setPostField(pid, 'pinned', toWrite); yield plugins.hooks.fire(`action:post.${type}`, { - pid: pid, + pid, tid: postData.tid, - uid: uid, + uid, owner: postData.uid, current: hasPinned ? 'pinned' : 'unpinned', }); @@ -48,7 +48,7 @@ function postFunc(Posts) { } Posts.hasPinned = function (pid, uid) { return __awaiter(this, void 0, void 0, function* () { - if (parseInt(uid, 10) <= 0) { + if (Number.parseInt(uid, 10) <= 0) { return Array.isArray(pid) ? pid.map(() => false) : false; } const postData = yield Posts.getPostFields(pid, ['pinned']); @@ -57,17 +57,17 @@ function postFunc(Posts) { }; Posts.pin = function (pid, uid) { return __awaiter(this, void 0, void 0, function* () { - return yield togglePin('pin', pid, uid); + return togglePin('pin', pid, uid); }); }; Posts.unpin = function (pid, uid) { return __awaiter(this, void 0, void 0, function* () { - return yield togglePin('unpin', pid, uid); + return togglePin('unpin', pid, uid); }); }; Posts.isTopicOP = function (pid, uid) { return __awaiter(this, void 0, void 0, function* () { - if (parseInt(uid, 10) <= 0 || parseInt(pid, 10) <= 0) { + if (Number.parseInt(uid, 10) <= 0 || Number.parseInt(pid, 10) <= 0) { return false; } // Get the post's topic @@ -78,4 +78,4 @@ function postFunc(Posts) { }); }; } -module.exports = postFunc; +module.exports = postFunction; diff --git a/src/posts/pin.ts b/src/posts/pin.ts index bda14c7..fa41549 100644 --- a/src/posts/pin.ts +++ b/src/posts/pin.ts @@ -8,99 +8,97 @@ import plugins = require('../plugins'); import topics = require('../topics'); type PostData = { - tid : string; - uid : string; - pinned : number; -} + tid: string; + uid: string; + pinned: number; +}; type TopicData = { - // This is the only field that's important to us for now - uid : string; -} + // This is the only field that's important to us for now + uid: string; +}; type Topic = { - getTopicFields : (tid : string, fields : string[]) => Promise; -} + getTopicFields: (tid: string, fields: string[]) => Promise; +}; type Post = { - pin : (pid : string, uid : string) => Promise; - unpin : (pid : string, uid : string) => Promise; - hasPinned : (pid : string, uid : string) => Promise; - getPostFields : (pid : string, fields : string[]) => Promise; - setPostField : (pid : string, field : string, value : number) => Promise; - isTopicOP : (pid : string, uid : string) => Promise; + pin: (pid: string, uid: string) => Promise; + unpin: (pid: string, uid: string) => Promise; + hasPinned: (pid: string, uid: string) => Promise; + getPostFields: (pid: string, fields: string[]) => Promise; + setPostField: (pid: string, field: string, value: number) => Promise; + isTopicOP: (pid: string, uid: string) => Promise; +}; + +function postFunction(Posts: Post) { + async function togglePin(type: string, pid: string, uid: string) { + if (Number.parseInt(uid, 10) <= 0) { + throw new Error('[[error:not-logged-in]]'); + } + + const isPinning = type === 'pin'; + + const postData = await Posts.getPostFields(pid, ['pid', 'uid', 'tid']); + + const hasPinned = await Posts.hasPinned(pid, uid); + + if (isPinning && hasPinned) { + throw new Error('Already pinned!'); + } + + if (!isPinning && !hasPinned) { + throw new Error('Already unpinned!'); + } + + // TODO: This line is sketchy. + const toWrite = isPinning ? 1 : 0; + await Posts.setPostField(pid, 'pinned', toWrite); + + await plugins.hooks.fire(`action:post.${type}`, { + pid, + tid: postData.tid, + uid, + owner: postData.uid, + current: hasPinned ? 'pinned' : 'unpinned', + }); + + return { + post: postData, + pinned: isPinning, + }; + } + + Posts.hasPinned = async function (pid, uid) { + if (Number.parseInt(uid, 10) <= 0) { + return Array.isArray(pid) ? pid.map(() => false) : false; + } + + const postData = await Posts.getPostFields(pid, ['pinned']); + return Boolean(postData.pinned); + }; + + Posts.pin = async function (pid: string, uid: string) { + return togglePin('pin', pid, uid); + }; + + Posts.unpin = async function (pid: string, uid: string) { + return togglePin('unpin', pid, uid); + }; + + Posts.isTopicOP = async function (pid, uid) { + if (Number.parseInt(uid, 10) <= 0 || Number.parseInt(pid, 10) <= 0) { + return false; + } + + // Get the post's topic + const postData = await Posts.getPostFields(pid, ['tid']); + + // Get the topic itself + const topicData = await (topics as Topic).getTopicFields(postData.tid, ['uid']); + + return uid === topicData.uid; + }; } -function postFunc(Posts : Post) { - async function togglePin(type : string, pid : string, uid : string) { - if (parseInt(uid, 10) <= 0) { - throw new Error('[[error:not-logged-in]]'); - } - - const isPinning = type === 'pin'; - - const postData = await Posts.getPostFields(pid, ['pid', 'uid', 'tid']); - - const hasPinned = await Posts.hasPinned(pid, uid); - - if (isPinning && hasPinned) { - throw new Error('Already pinned!'); - } - - if (!isPinning && !hasPinned) { - throw new Error('Already unpinned!'); - } - - // TODO: This line is sketchy. - const toWrite = isPinning ? 1 : 0; - await Posts.setPostField(pid, 'pinned', toWrite); - - await plugins.hooks.fire(`action:post.${type}`, { - pid: pid, - tid: postData.tid, - uid: uid, - owner: postData.uid, - current: hasPinned ? 'pinned' : 'unpinned', - }); - - return { - post: postData, - pinned: isPinning, - }; - } - - Posts.hasPinned = async function (pid, uid) { - if (parseInt(uid, 10) <= 0) { - return Array.isArray(pid) ? pid.map(() => false) : false; - } - - const postData = await Posts.getPostFields(pid, ['pinned']); - return Boolean(postData.pinned); - }; - - - Posts.pin = async function (pid : string, uid : string) { - return await togglePin('pin', pid, uid); - }; - - Posts.unpin = async function (pid : string, uid : string) { - return await togglePin('unpin', pid, uid); - }; - - Posts.isTopicOP = async function (pid, uid) { - if (parseInt(uid, 10) <= 0 || parseInt(pid, 10) <= 0) { - return false; - } - - // Get the post's topic - const postData = await Posts.getPostFields(pid, ['tid']); - - // Get the topic itself - const topicData = await (topics as Topic).getTopicFields(postData.tid, ['uid']); - - return uid === topicData.uid; - }; -} - - -export = postFunc; +export = postFunction; diff --git a/src/posts/queue.js b/src/posts/queue.js index 2aa9576..8b4577c 100644 --- a/src/posts/queue.js +++ b/src/posts/queue.js @@ -3,7 +3,6 @@ const _ = require('lodash'); const validator = require('validator'); const nconf = require('nconf'); - const db = require('../database'); const user = require('../user'); const meta = require('../meta'); @@ -18,350 +17,379 @@ const cache = require('../cache'); const socketHelpers = require('../socket.io/helpers'); module.exports = function (Posts) { - Posts.getQueuedPosts = async (filter = {}, options = {}) => { - options = { metadata: true, ...options }; // defaults - let postData = _.cloneDeep(cache.get('post-queue')); - if (!postData) { - const ids = await db.getSortedSetRange('post:queue', 0, -1); - const keys = ids.map(id => `post:queue:${id}`); - postData = await db.getObjects(keys); - postData.forEach((data) => { - if (data) { - data.data = JSON.parse(data.data); - data.data.timestampISO = utils.toISOString(data.data.timestamp); - } - }); - const uids = postData.map(data => data && data.uid); - const userData = await user.getUsersFields(uids, ['username', 'userslug', 'picture']); - postData.forEach((postData, index) => { - if (postData) { - postData.user = userData[index]; - postData.data.rawContent = validator.escape(String(postData.data.content)); - postData.data.title = validator.escape(String(postData.data.title || '')); - } - }); - cache.set('post-queue', _.cloneDeep(postData)); - } - if (filter.id) { - postData = postData.filter(p => p.id === filter.id); - } - if (options.metadata) { - await Promise.all(postData.map(p => addMetaData(p))); - } - - // Filter by tid if present - if (utils.isNumber(filter.tid)) { - const tid = parseInt(filter.tid, 10); - postData = postData.filter(item => item.data.tid && parseInt(item.data.tid, 10) === tid); - } else if (Array.isArray(filter.tid)) { - const tids = filter.tid.map(tid => parseInt(tid, 10)); - postData = postData.filter( - item => item.data.tid && tids.includes(parseInt(item.data.tid, 10)) - ); - } - - return postData; - }; - - async function addMetaData(postData) { - if (!postData) { - return; - } - postData.topic = { cid: 0 }; - if (postData.data.cid) { - postData.topic = { cid: parseInt(postData.data.cid, 10) }; - } else if (postData.data.tid) { - postData.topic = await topics.getTopicFields(postData.data.tid, ['title', 'cid']); - } - postData.category = await categories.getCategoryData(postData.topic.cid); - const result = await plugins.hooks.fire('filter:parse.post', { postData: postData.data }); - postData.data.content = result.postData.content; - } - - Posts.shouldQueue = async function (uid, data) { - const [userData, isMemberOfExempt, categoryQueueEnabled] = await Promise.all([ - user.getUserFields(uid, ['uid', 'reputation', 'postcount']), - groups.isMemberOfAny(uid, meta.config.groupsExemptFromPostQueue), - isCategoryQueueEnabled(data), - ]); - - const shouldQueue = meta.config.postQueue && categoryQueueEnabled && - !isMemberOfExempt && - (!userData.uid || userData.reputation < meta.config.postQueueReputationThreshold || - userData.postcount <= 0); - const result = await plugins.hooks.fire('filter:post.shouldQueue', { - shouldQueue: !!shouldQueue, - uid: uid, - data: data, - }); - return result.shouldQueue; - }; - - async function isCategoryQueueEnabled(data) { - const type = getType(data); - const cid = await getCid(type, data); - if (!cid) { - throw new Error('[[error:invalid-cid]]'); - } - return await categories.getCategoryField(cid, 'postQueue'); - } - - function getType(data) { - if (data.hasOwnProperty('tid')) { - return 'reply'; - } else if (data.hasOwnProperty('cid')) { - return 'topic'; - } - throw new Error('[[error:invalid-type]]'); - } - - async function removeQueueNotification(id) { - await notifications.rescind(`post-queue-${id}`); - const data = await getParsedObject(id); - if (!data) { - return; - } - const cid = await getCid(data.type, data); - const uids = await getNotificationUids(cid); - uids.forEach(uid => user.notifications.pushCount(uid)); - } - - async function getNotificationUids(cid) { - const results = await Promise.all([ - groups.getMembersOfGroups(['administrators', 'Global Moderators']), - categories.getModeratorUids([cid]), - ]); - return _.uniq(_.flattenDeep(results)); - } - - Posts.addToQueue = async function (data) { - const type = getType(data); - const now = Date.now(); - const id = `${type}-${now}`; - await canPost(type, data); - - let payload = { - id: id, - uid: data.uid, - type: type, - data: data, - }; - payload = await plugins.hooks.fire('filter:post-queue.save', payload); - payload.data = JSON.stringify(data); - - await db.sortedSetAdd('post:queue', now, id); - await db.setObject(`post:queue:${id}`, payload); - await user.setUserField(data.uid, 'lastqueuetime', now); - cache.del('post-queue'); - - const cid = await getCid(type, data); - const uids = await getNotificationUids(cid); - const bodyLong = await parseBodyLong(cid, type, data); - - const notifObj = await notifications.create({ - type: 'post-queue', - nid: `post-queue-${id}`, - mergeId: 'post-queue', - bodyShort: '[[notifications:post_awaiting_review]]', - bodyLong: bodyLong, - path: `/post-queue/${id}`, - }); - await notifications.push(notifObj, uids); - return { - id: id, - type: type, - queued: true, - message: '[[success:post-queued]]', - }; - }; - - async function parseBodyLong(cid, type, data) { - const url = nconf.get('url'); - const [content, category, userData] = await Promise.all([ - plugins.hooks.fire('filter:parse.raw', data.content), - categories.getCategoryFields(cid, ['name', 'slug']), - user.getUserFields(data.uid, ['uid', 'username']), - ]); - - category.url = `${url}/category/${category.slug}`; - if (userData.uid > 0) { - userData.url = `${url}/uid/${userData.uid}`; - } - - const topic = { cid: cid, title: data.title, tid: data.tid }; - if (type === 'reply') { - topic.title = await topics.getTopicField(data.tid, 'title'); - topic.url = `${url}/topic/${data.tid}`; - } - const { app } = require('../webserver'); - return await app.renderAsync('emails/partials/post-queue-body', { - content: content, - category: category, - user: userData, - topic: topic, - }); - } - - async function getCid(type, data) { - if (type === 'topic') { - return data.cid; - } else if (type === 'reply') { - return await topics.getTopicField(data.tid, 'cid'); - } - return null; - } - - async function canPost(type, data) { - const cid = await getCid(type, data); - const typeToPrivilege = { - topic: 'topics:create', - reply: 'topics:reply', - }; - - topics.checkContent(data.content); - if (type === 'topic') { - topics.checkTitle(data.title); - if (data.tags) { - await topics.validateTags(data.tags, cid, data.uid); - } - } - - const [canPost] = await Promise.all([ - privileges.categories.can(typeToPrivilege[type], cid, data.uid), - user.isReadyToQueue(data.uid, cid), - ]); - if (!canPost) { - throw new Error('[[error:no-privileges]]'); - } - } - - Posts.removeFromQueue = async function (id) { - const data = await getParsedObject(id); - if (!data) { - return null; - } - const result = await plugins.hooks.fire('filter:post-queue:removeFromQueue', { data: data }); - await removeFromQueue(id); - plugins.hooks.fire('action:post-queue:removeFromQueue', { data: result.data }); - return result.data; - }; - - async function removeFromQueue(id) { - await removeQueueNotification(id); - await db.sortedSetRemove('post:queue', id); - await db.delete(`post:queue:${id}`); - cache.del('post-queue'); - } - - Posts.submitFromQueue = async function (id) { - let data = await getParsedObject(id); - if (!data) { - return null; - } - const result = await plugins.hooks.fire('filter:post-queue:submitFromQueue', { data: data }); - data = result.data; - if (data.type === 'topic') { - const result = await createTopic(data.data); - data.pid = result.postData.pid; - } else if (data.type === 'reply') { - const result = await createReply(data.data); - data.pid = result.pid; - } - await removeFromQueue(id); - plugins.hooks.fire('action:post-queue:submitFromQueue', { data: data }); - return data; - }; - - Posts.getFromQueue = async function (id) { - return await getParsedObject(id); - }; - - async function getParsedObject(id) { - const data = await db.getObject(`post:queue:${id}`); - if (!data) { - return null; - } - data.data = JSON.parse(data.data); - data.data.fromQueue = true; - return data; - } - - async function createTopic(data) { - const result = await topics.post(data); - socketHelpers.notifyNew(data.uid, 'newTopic', { posts: [result.postData], topic: result.topicData }); - return result; - } - - async function createReply(data) { - const postData = await topics.reply(data); - const result = { - posts: [postData], - 'reputation:disabled': !!meta.config['reputation:disabled'], - 'downvote:disabled': !!meta.config['downvote:disabled'], - }; - socketHelpers.notifyNew(data.uid, 'newPost', result); - return postData; - } - - Posts.editQueuedContent = async function (uid, editData) { - const canEditQueue = await Posts.canEditQueue(uid, editData, 'edit'); - if (!canEditQueue) { - throw new Error('[[error:no-privileges]]'); - } - const data = await getParsedObject(editData.id); - if (!data) { - return; - } - if (editData.content !== undefined) { - data.data.content = editData.content; - } - if (editData.title !== undefined) { - data.data.title = editData.title; - } - if (editData.cid !== undefined) { - data.data.cid = editData.cid; - } - await db.setObjectField(`post:queue:${editData.id}`, 'data', JSON.stringify(data.data)); - cache.del('post-queue'); - }; - - Posts.canEditQueue = async function (uid, editData, action) { - const [isAdminOrGlobalMod, data] = await Promise.all([ - user.isAdminOrGlobalMod(uid), - getParsedObject(editData.id), - ]); - if (!data) { - return false; - } - const selfPost = parseInt(uid, 10) === parseInt(data.uid, 10); - if (isAdminOrGlobalMod || ((action === 'reject' || action === 'edit') && selfPost)) { - return true; - } - - let cid; - if (data.type === 'topic') { - cid = data.data.cid; - } else if (data.type === 'reply') { - cid = await topics.getTopicField(data.data.tid, 'cid'); - } - const isModerator = await user.isModerator(uid, cid); - let isModeratorOfTargetCid = true; - if (editData.cid) { - isModeratorOfTargetCid = await user.isModerator(uid, editData.cid); - } - return isModerator && isModeratorOfTargetCid; - }; - - Posts.updateQueuedPostsTopic = async function (newTid, tids) { - const postData = await Posts.getQueuedPosts({ tid: tids }, { metadata: false }); - if (postData.length) { - postData.forEach((post) => { - post.data.tid = newTid; - }); - await db.setObjectBulk( - postData.map(p => [`post:queue:${p.id}`, { data: JSON.stringify(p.data) }]), - ); - cache.del('post-queue'); - } - }; + Posts.getQueuedPosts = async (filter = {}, options = {}) => { + options = {metadata: true, ...options}; // Defaults + let postData = _.cloneDeep(cache.get('post-queue')); + if (!postData) { + const ids = await db.getSortedSetRange('post:queue', 0, -1); + const keys = ids.map(id => `post:queue:${id}`); + postData = await db.getObjects(keys); + for (const data of postData) { + if (data) { + data.data = JSON.parse(data.data); + data.data.timestampISO = utils.toISOString(data.data.timestamp); + } + } + + const uids = postData.map(data => data && data.uid); + const userData = await user.getUsersFields(uids, ['username', 'userslug', 'picture']); + postData.forEach((postData, index) => { + if (postData) { + postData.user = userData[index]; + postData.data.rawContent = validator.escape(String(postData.data.content)); + postData.data.title = validator.escape(String(postData.data.title || '')); + } + }); + cache.set('post-queue', _.cloneDeep(postData)); + } + + if (filter.id) { + postData = postData.filter(p => p.id === filter.id); + } + + if (options.metadata) { + await Promise.all(postData.map(p => addMetaData(p))); + } + + // Filter by tid if present + if (utils.isNumber(filter.tid)) { + const tid = Number.parseInt(filter.tid, 10); + postData = postData.filter(item => item.data.tid && Number.parseInt(item.data.tid, 10) === tid); + } else if (Array.isArray(filter.tid)) { + const tids = new Set(filter.tid.map(tid => Number.parseInt(tid, 10))); + postData = postData.filter( + item => item.data.tid && tids.has(Number.parseInt(item.data.tid, 10)), + ); + } + + return postData; + }; + + async function addMetaData(postData) { + if (!postData) { + return; + } + + postData.topic = {cid: 0}; + if (postData.data.cid) { + postData.topic = {cid: Number.parseInt(postData.data.cid, 10)}; + } else if (postData.data.tid) { + postData.topic = await topics.getTopicFields(postData.data.tid, ['title', 'cid']); + } + + postData.category = await categories.getCategoryData(postData.topic.cid); + const result = await plugins.hooks.fire('filter:parse.post', {postData: postData.data}); + postData.data.content = result.postData.content; + } + + Posts.shouldQueue = async function (uid, data) { + const [userData, isMemberOfExempt, categoryQueueEnabled] = await Promise.all([ + user.getUserFields(uid, ['uid', 'reputation', 'postcount']), + groups.isMemberOfAny(uid, meta.config.groupsExemptFromPostQueue), + isCategoryQueueEnabled(data), + ]); + + const shouldQueue = meta.config.postQueue && categoryQueueEnabled + && !isMemberOfExempt + && (!userData.uid || userData.reputation < meta.config.postQueueReputationThreshold + || userData.postcount <= 0); + const result = await plugins.hooks.fire('filter:post.shouldQueue', { + shouldQueue: Boolean(shouldQueue), + uid, + data, + }); + return result.shouldQueue; + }; + + async function isCategoryQueueEnabled(data) { + const type = getType(data); + const cid = await getCid(type, data); + if (!cid) { + throw new Error('[[error:invalid-cid]]'); + } + + return await categories.getCategoryField(cid, 'postQueue'); + } + + function getType(data) { + if (data.hasOwnProperty('tid')) { + return 'reply'; + } + + if (data.hasOwnProperty('cid')) { + return 'topic'; + } + + throw new Error('[[error:invalid-type]]'); + } + + async function removeQueueNotification(id) { + await notifications.rescind(`post-queue-${id}`); + const data = await getParsedObject(id); + if (!data) { + return; + } + + const cid = await getCid(data.type, data); + const uids = await getNotificationUids(cid); + for (const uid of uids) { + user.notifications.pushCount(uid); + } + } + + async function getNotificationUids(cid) { + const results = await Promise.all([ + groups.getMembersOfGroups(['administrators', 'Global Moderators']), + categories.getModeratorUids([cid]), + ]); + return _.uniq(_.flattenDeep(results)); + } + + Posts.addToQueue = async function (data) { + const type = getType(data); + const now = Date.now(); + const id = `${type}-${now}`; + await canPost(type, data); + + let payload = { + id, + uid: data.uid, + type, + data, + }; + payload = await plugins.hooks.fire('filter:post-queue.save', payload); + payload.data = JSON.stringify(data); + + await db.sortedSetAdd('post:queue', now, id); + await db.setObject(`post:queue:${id}`, payload); + await user.setUserField(data.uid, 'lastqueuetime', now); + cache.del('post-queue'); + + const cid = await getCid(type, data); + const uids = await getNotificationUids(cid); + const bodyLong = await parseBodyLong(cid, type, data); + + const notificationObject = await notifications.create({ + type: 'post-queue', + nid: `post-queue-${id}`, + mergeId: 'post-queue', + bodyShort: '[[notifications:post_awaiting_review]]', + bodyLong, + path: `/post-queue/${id}`, + }); + await notifications.push(notificationObject, uids); + return { + id, + type, + queued: true, + message: '[[success:post-queued]]', + }; + }; + + async function parseBodyLong(cid, type, data) { + const url = nconf.get('url'); + const [content, category, userData] = await Promise.all([ + plugins.hooks.fire('filter:parse.raw', data.content), + categories.getCategoryFields(cid, ['name', 'slug']), + user.getUserFields(data.uid, ['uid', 'username']), + ]); + + category.url = `${url}/category/${category.slug}`; + if (userData.uid > 0) { + userData.url = `${url}/uid/${userData.uid}`; + } + + const topic = {cid, title: data.title, tid: data.tid}; + if (type === 'reply') { + topic.title = await topics.getTopicField(data.tid, 'title'); + topic.url = `${url}/topic/${data.tid}`; + } + + const {app} = require('../webserver'); + return await app.renderAsync('emails/partials/post-queue-body', { + content, + category, + user: userData, + topic, + }); + } + + async function getCid(type, data) { + if (type === 'topic') { + return data.cid; + } + + if (type === 'reply') { + return await topics.getTopicField(data.tid, 'cid'); + } + + return null; + } + + async function canPost(type, data) { + const cid = await getCid(type, data); + const typeToPrivilege = { + topic: 'topics:create', + reply: 'topics:reply', + }; + + topics.checkContent(data.content); + if (type === 'topic') { + topics.checkTitle(data.title); + if (data.tags) { + await topics.validateTags(data.tags, cid, data.uid); + } + } + + const [canPost] = await Promise.all([ + privileges.categories.can(typeToPrivilege[type], cid, data.uid), + user.isReadyToQueue(data.uid, cid), + ]); + if (!canPost) { + throw new Error('[[error:no-privileges]]'); + } + } + + Posts.removeFromQueue = async function (id) { + const data = await getParsedObject(id); + if (!data) { + return null; + } + + const result = await plugins.hooks.fire('filter:post-queue:removeFromQueue', {data}); + await removeFromQueue(id); + plugins.hooks.fire('action:post-queue:removeFromQueue', {data: result.data}); + return result.data; + }; + + async function removeFromQueue(id) { + await removeQueueNotification(id); + await db.sortedSetRemove('post:queue', id); + await db.delete(`post:queue:${id}`); + cache.del('post-queue'); + } + + Posts.submitFromQueue = async function (id) { + let data = await getParsedObject(id); + if (!data) { + return null; + } + + const result = await plugins.hooks.fire('filter:post-queue:submitFromQueue', {data}); + data = result.data; + if (data.type === 'topic') { + const result = await createTopic(data.data); + data.pid = result.postData.pid; + } else if (data.type === 'reply') { + const result = await createReply(data.data); + data.pid = result.pid; + } + + await removeFromQueue(id); + plugins.hooks.fire('action:post-queue:submitFromQueue', {data}); + return data; + }; + + Posts.getFromQueue = async function (id) { + return await getParsedObject(id); + }; + + async function getParsedObject(id) { + const data = await db.getObject(`post:queue:${id}`); + if (!data) { + return null; + } + + data.data = JSON.parse(data.data); + data.data.fromQueue = true; + return data; + } + + async function createTopic(data) { + const result = await topics.post(data); + socketHelpers.notifyNew(data.uid, 'newTopic', {posts: [result.postData], topic: result.topicData}); + return result; + } + + async function createReply(data) { + const postData = await topics.reply(data); + const result = { + posts: [postData], + 'reputation:disabled': Boolean(meta.config['reputation:disabled']), + 'downvote:disabled': Boolean(meta.config['downvote:disabled']), + }; + socketHelpers.notifyNew(data.uid, 'newPost', result); + return postData; + } + + Posts.editQueuedContent = async function (uid, editData) { + const canEditQueue = await Posts.canEditQueue(uid, editData, 'edit'); + if (!canEditQueue) { + throw new Error('[[error:no-privileges]]'); + } + + const data = await getParsedObject(editData.id); + if (!data) { + return; + } + + if (editData.content !== undefined) { + data.data.content = editData.content; + } + + if (editData.title !== undefined) { + data.data.title = editData.title; + } + + if (editData.cid !== undefined) { + data.data.cid = editData.cid; + } + + await db.setObjectField(`post:queue:${editData.id}`, 'data', JSON.stringify(data.data)); + cache.del('post-queue'); + }; + + Posts.canEditQueue = async function (uid, editData, action) { + const [isAdminOrGlobalModule, data] = await Promise.all([ + user.isAdminOrGlobalMod(uid), + getParsedObject(editData.id), + ]); + if (!data) { + return false; + } + + const selfPost = Number.parseInt(uid, 10) === Number.parseInt(data.uid, 10); + if (isAdminOrGlobalModule || ((action === 'reject' || action === 'edit') && selfPost)) { + return true; + } + + let cid; + if (data.type === 'topic') { + cid = data.data.cid; + } else if (data.type === 'reply') { + cid = await topics.getTopicField(data.data.tid, 'cid'); + } + + const isModerator = await user.isModerator(uid, cid); + let isModeratorOfTargetCid = true; + if (editData.cid) { + isModeratorOfTargetCid = await user.isModerator(uid, editData.cid); + } + + return isModerator && isModeratorOfTargetCid; + }; + + Posts.updateQueuedPostsTopic = async function (newTid, tids) { + const postData = await Posts.getQueuedPosts({tid: tids}, {metadata: false}); + if (postData.length > 0) { + for (const post of postData) { + post.data.tid = newTid; + } + + await db.setObjectBulk( + postData.map(p => [`post:queue:${p.id}`, {data: JSON.stringify(p.data)}]), + ); + cache.del('post-queue'); + } + }; }; diff --git a/src/posts/recent.js b/src/posts/recent.js index d08102a..4fcd26d 100644 --- a/src/posts/recent.js +++ b/src/posts/recent.js @@ -1,33 +1,31 @@ 'use strict'; const _ = require('lodash'); - const db = require('../database'); const privileges = require('../privileges'); - module.exports = function (Posts) { - const terms = { - day: 86400000, - week: 604800000, - month: 2592000000, - }; + const terms = { + day: 86_400_000, + week: 604_800_000, + month: 2_592_000_000, + }; - Posts.getRecentPosts = async function (uid, start, stop, term) { - let min = 0; - if (terms[term]) { - min = Date.now() - terms[term]; - } + Posts.getRecentPosts = async function (uid, start, stop, term) { + let min = 0; + if (terms[term]) { + min = Date.now() - terms[term]; + } - const count = parseInt(stop, 10) === -1 ? stop : stop - start + 1; - let pids = await db.getSortedSetRevRangeByScore('posts:pid', start, count, '+inf', min); - pids = await privileges.posts.filter('topics:read', pids, uid); - return await Posts.getPostSummaryByPids(pids, uid, { stripTags: true }); - }; + const count = Number.parseInt(stop, 10) === -1 ? stop : stop - start + 1; + let pids = await db.getSortedSetRevRangeByScore('posts:pid', start, count, '+inf', min); + pids = await privileges.posts.filter('topics:read', pids, uid); + return await Posts.getPostSummaryByPids(pids, uid, {stripTags: true}); + }; - Posts.getRecentPosterUids = async function (start, stop) { - const pids = await db.getSortedSetRevRange('posts:pid', start, stop); - const postData = await Posts.getPostsFields(pids, ['uid']); - return _.uniq(postData.map(p => p && p.uid).filter(uid => parseInt(uid, 10))); - }; + Posts.getRecentPosterUids = async function (start, stop) { + const pids = await db.getSortedSetRevRange('posts:pid', start, stop); + const postData = await Posts.getPostsFields(pids, ['uid']); + return _.uniq(postData.map(p => p && p.uid).filter(uid => Number.parseInt(uid, 10))); + }; }; diff --git a/src/posts/resolves.js b/src/posts/resolves.js index 9bb86f9..98b13b0 100644 --- a/src/posts/resolves.js +++ b/src/posts/resolves.js @@ -1,38 +1,37 @@ 'use strict'; -// used the bookmarks.js file as a reference +// Used the bookmarks.js file as a reference // const db = require('../database'); const plugins = require('../plugins'); module.exports = function (Posts) { - Posts.resolve = async function (pid, uid) { - return await toggleResolve('resolve', pid, uid); - }; - - async function toggleResolve(type, pid, uid) { - if (parseInt(uid, 10) <= 0) { - throw new Error('[[error:not-logged-in]]'); - } - - const isResolving = true; - - const [postData] = await Promise.all([ - Posts.getPostFields(pid, ['pid', 'uid']), - ]); - - await Posts.setPostField(pid, 'resolved', 1); - - plugins.hooks.fire(`action:post.resolve`, { - pid: pid, - uid: uid, - owner: postData.uid, - current: 'resolved', - }); - - - return { - post: postData, - isResolved: isResolving, - }; - } + Posts.resolve = async function (pid, uid) { + return await toggleResolve('resolve', pid, uid); + }; + + async function toggleResolve(type, pid, uid) { + if (Number.parseInt(uid, 10) <= 0) { + throw new Error('[[error:not-logged-in]]'); + } + + const isResolving = true; + + const [postData] = await Promise.all([ + Posts.getPostFields(pid, ['pid', 'uid']), + ]); + + await Posts.setPostField(pid, 'resolved', 1); + + plugins.hooks.fire('action:post.resolve', { + pid, + uid, + owner: postData.uid, + current: 'resolved', + }); + + return { + post: postData, + isResolved: isResolving, + }; + } }; diff --git a/src/posts/summary.js b/src/posts/summary.js index 6587865..93462ae 100644 --- a/src/posts/summary.js +++ b/src/posts/summary.js @@ -3,7 +3,6 @@ const validator = require('validator'); const _ = require('lodash'); - const topics = require('../topics'); const user = require('../user'); const plugins = require('../plugins'); @@ -11,95 +10,116 @@ const categories = require('../categories'); const utils = require('../utils'); module.exports = function (Posts) { - Posts.getPostSummaryByPids = async function (pids, uid, options) { - if (!Array.isArray(pids) || !pids.length) { - return []; - } - - options.stripTags = options.hasOwnProperty('stripTags') ? options.stripTags : false; - options.parse = options.hasOwnProperty('parse') ? options.parse : true; - options.extraFields = options.hasOwnProperty('extraFields') ? options.extraFields : []; - - const fields = ['pid', 'tid', 'content', 'uid', 'timestamp', 'deleted', 'upvotes', 'downvotes', 'replies', 'handle'].concat(options.extraFields); - - let posts = await Posts.getPostsFields(pids, fields); - posts = posts.filter(Boolean); - posts = await user.blocks.filter(uid, posts); - - const uids = _.uniq(posts.map(p => p && p.uid)); - const tids = _.uniq(posts.map(p => p && p.tid)); - - const [users, topicsAndCategories] = await Promise.all([ - user.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture', 'status']), - getTopicAndCategories(tids), - ]); - - const uidToUser = toObject('uid', users); - const tidToTopic = toObject('tid', topicsAndCategories.topics); - const cidToCategory = toObject('cid', topicsAndCategories.categories); - - posts.forEach((post) => { - // If the post author isn't represented in the retrieved users' data, - // then it means they were deleted, assume guest. - if (!uidToUser.hasOwnProperty(post.uid)) { - post.uid = 0; - } - post.user = uidToUser[post.uid]; - Posts.overrideGuestHandle(post, post.handle); - post.handle = undefined; - post.topic = tidToTopic[post.tid]; - post.category = post.topic && cidToCategory[post.topic.cid]; - post.isMainPost = post.topic && post.pid === post.topic.mainPid; - post.deleted = post.deleted === 1; - post.timestampISO = utils.toISOString(post.timestamp); - }); - - posts = posts.filter(post => tidToTopic[post.tid]); - - posts = await parsePosts(posts, options); - const result = await plugins.hooks.fire('filter:post.getPostSummaryByPids', { posts: posts, uid: uid }); - return result.posts; - }; - - async function parsePosts(posts, options) { - return await Promise.all(posts.map(async (post) => { - if (!post.content || !options.parse) { - post.content = post.content ? validator.escape(String(post.content)) : post.content; - return post; - } - post = await Posts.parsePost(post); - if (options.stripTags) { - post.content = stripTags(post.content); - } - return post; - })); - } - - async function getTopicAndCategories(tids) { - const topicsData = await topics.getTopicsFields(tids, [ - 'uid', 'tid', 'title', 'cid', 'tags', 'slug', - 'deleted', 'scheduled', 'postcount', 'mainPid', 'teaserPid', - ]); - const cids = _.uniq(topicsData.map(topic => topic && topic.cid)); - const categoriesData = await categories.getCategoriesFields(cids, [ - 'cid', 'name', 'icon', 'slug', 'parentCid', - 'bgColor', 'color', 'backgroundImage', 'imageClass', - ]); - return { topics: topicsData, categories: categoriesData }; - } - - function toObject(key, data) { - const obj = {}; - for (let i = 0; i < data.length; i += 1) { - obj[data[i][key]] = data[i]; - } - return obj; - } - - function stripTags(content) { - if (content) { - return utils.stripHTMLTags(content, utils.stripTags); - } - return content; - } + Posts.getPostSummaryByPids = async function (pids, uid, options) { + if (!Array.isArray(pids) || pids.length === 0) { + return []; + } + + options.stripTags = options.hasOwnProperty('stripTags') ? options.stripTags : false; + options.parse = options.hasOwnProperty('parse') ? options.parse : true; + options.extraFields = options.hasOwnProperty('extraFields') ? options.extraFields : []; + + const fields = ['pid', 'tid', 'content', 'uid', 'timestamp', 'deleted', 'upvotes', 'downvotes', 'replies', 'handle'].concat(options.extraFields); + + let posts = await Posts.getPostsFields(pids, fields); + posts = posts.filter(Boolean); + posts = await user.blocks.filter(uid, posts); + + const uids = _.uniq(posts.map(p => p && p.uid)); + const tids = _.uniq(posts.map(p => p && p.tid)); + + const [users, topicsAndCategories] = await Promise.all([ + user.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture', 'status']), + getTopicAndCategories(tids), + ]); + + const uidToUser = toObject('uid', users); + const tidToTopic = toObject('tid', topicsAndCategories.topics); + const cidToCategory = toObject('cid', topicsAndCategories.categories); + + for (const post of posts) { + // If the post author isn't represented in the retrieved users' data, + // then it means they were deleted, assume guest. + if (!uidToUser.hasOwnProperty(post.uid)) { + post.uid = 0; + } + + post.user = uidToUser[post.uid]; + Posts.overrideGuestHandle(post, post.handle); + post.handle = undefined; + post.topic = tidToTopic[post.tid]; + post.category = post.topic && cidToCategory[post.topic.cid]; + post.isMainPost = post.topic && post.pid === post.topic.mainPid; + post.deleted = post.deleted === 1; + post.timestampISO = utils.toISOString(post.timestamp); + } + + posts = posts.filter(post => tidToTopic[post.tid]); + + posts = await parsePosts(posts, options); + const result = await plugins.hooks.fire('filter:post.getPostSummaryByPids', {posts, uid}); + return result.posts; + }; + + async function parsePosts(posts, options) { + return await Promise.all(posts.map(async post => { + if (!post.content || !options.parse) { + post.content = post.content ? validator.escape(String(post.content)) : post.content; + return post; + } + + post = await Posts.parsePost(post); + if (options.stripTags) { + post.content = stripTags(post.content); + } + + return post; + })); + } + + async function getTopicAndCategories(tids) { + const topicsData = await topics.getTopicsFields(tids, [ + 'uid', + 'tid', + 'title', + 'cid', + 'tags', + 'slug', + 'deleted', + 'scheduled', + 'postcount', + 'mainPid', + 'teaserPid', + ]); + const cids = _.uniq(topicsData.map(topic => topic && topic.cid)); + const categoriesData = await categories.getCategoriesFields(cids, [ + 'cid', + 'name', + 'icon', + 'slug', + 'parentCid', + 'bgColor', + 'color', + 'backgroundImage', + 'imageClass', + ]); + return {topics: topicsData, categories: categoriesData}; + } + + function toObject(key, data) { + const object = {}; + for (const datum of data) { + object[datum[key]] = datum; + } + + return object; + } + + function stripTags(content) { + if (content) { + return utils.stripHTMLTags(content, utils.stripTags); + } + + return content; + } }; diff --git a/src/posts/tools.js b/src/posts/tools.js index 60367d4..bf3f9c1 100644 --- a/src/posts/tools.js +++ b/src/posts/tools.js @@ -3,42 +3,44 @@ const privileges = require('../privileges'); module.exports = function (Posts) { - Posts.tools = {}; - - Posts.tools.delete = async function (uid, pid) { - return await togglePostDelete(uid, pid, true); - }; - - Posts.tools.restore = async function (uid, pid) { - return await togglePostDelete(uid, pid, false); - }; - - async function togglePostDelete(uid, pid, isDelete) { - const [postData, canDelete] = await Promise.all([ - Posts.getPostData(pid), - privileges.posts.canDelete(pid, uid), - ]); - if (!postData) { - throw new Error('[[error:no-post]]'); - } - - if (postData.deleted && isDelete) { - throw new Error('[[error:post-already-deleted]]'); - } else if (!postData.deleted && !isDelete) { - throw new Error('[[error:post-already-restored]]'); - } - - if (!canDelete.flag) { - throw new Error(canDelete.message); - } - let post; - if (isDelete) { - require('./cache').del(pid); - post = await Posts.delete(pid, uid); - } else { - post = await Posts.restore(pid, uid); - post = await Posts.parsePost(post); - } - return post; - } + Posts.tools = {}; + + Posts.tools.delete = async function (uid, pid) { + return await togglePostDelete(uid, pid, true); + }; + + Posts.tools.restore = async function (uid, pid) { + return await togglePostDelete(uid, pid, false); + }; + + async function togglePostDelete(uid, pid, isDelete) { + const [postData, canDelete] = await Promise.all([ + Posts.getPostData(pid), + privileges.posts.canDelete(pid, uid), + ]); + if (!postData) { + throw new Error('[[error:no-post]]'); + } + + if (postData.deleted && isDelete) { + throw new Error('[[error:post-already-deleted]]'); + } else if (!postData.deleted && !isDelete) { + throw new Error('[[error:post-already-restored]]'); + } + + if (!canDelete.flag) { + throw new Error(canDelete.message); + } + + let post; + if (isDelete) { + require('./cache').del(pid); + post = await Posts.delete(pid, uid); + } else { + post = await Posts.restore(pid, uid); + post = await Posts.parsePost(post); + } + + return post; + } }; diff --git a/src/posts/topics.js b/src/posts/topics.js index 64f5667..516ab62 100644 --- a/src/posts/topics.js +++ b/src/posts/topics.js @@ -6,49 +6,50 @@ const user = require('../user'); const utils = require('../utils'); module.exports = function (Posts) { - Posts.getPostsFromSet = async function (set, start, stop, uid, reverse) { - const pids = await Posts.getPidsFromSet(set, start, stop, reverse); - const posts = await Posts.getPostsByPids(pids, uid); - return await user.blocks.filter(uid, posts); - }; - - Posts.isMain = async function (pids) { - const isArray = Array.isArray(pids); - pids = isArray ? pids : [pids]; - const postData = await Posts.getPostsFields(pids, ['tid']); - const topicData = await topics.getTopicsFields(postData.map(t => t.tid), ['mainPid']); - const result = pids.map((pid, i) => parseInt(pid, 10) === parseInt(topicData[i].mainPid, 10)); - return isArray ? result : result[0]; - }; - - Posts.getTopicFields = async function (pid, fields) { - const tid = await Posts.getPostField(pid, 'tid'); - return await topics.getTopicFields(tid, fields); - }; - - Posts.generatePostPath = async function (pid, uid) { - const paths = await Posts.generatePostPaths([pid], uid); - return Array.isArray(paths) && paths.length ? paths[0] : null; - }; - - Posts.generatePostPaths = async function (pids, uid) { - const postData = await Posts.getPostsFields(pids, ['pid', 'tid']); - const tids = postData.map(post => post && post.tid); - const [indices, topicData] = await Promise.all([ - Posts.getPostIndices(postData, uid), - topics.getTopicsFields(tids, ['slug']), - ]); - - const paths = pids.map((pid, index) => { - const slug = topicData[index] ? topicData[index].slug : null; - const postIndex = utils.isNumber(indices[index]) ? parseInt(indices[index], 10) + 1 : null; - - if (slug && postIndex) { - return `/topic/${slug}/${postIndex}`; - } - return null; - }); - - return paths; - }; + Posts.getPostsFromSet = async function (set, start, stop, uid, reverse) { + const pids = await Posts.getPidsFromSet(set, start, stop, reverse); + const posts = await Posts.getPostsByPids(pids, uid); + return await user.blocks.filter(uid, posts); + }; + + Posts.isMain = async function (pids) { + const isArray = Array.isArray(pids); + pids = isArray ? pids : [pids]; + const postData = await Posts.getPostsFields(pids, ['tid']); + const topicData = await topics.getTopicsFields(postData.map(t => t.tid), ['mainPid']); + const result = pids.map((pid, i) => Number.parseInt(pid, 10) === Number.parseInt(topicData[i].mainPid, 10)); + return isArray ? result : result[0]; + }; + + Posts.getTopicFields = async function (pid, fields) { + const tid = await Posts.getPostField(pid, 'tid'); + return await topics.getTopicFields(tid, fields); + }; + + Posts.generatePostPath = async function (pid, uid) { + const paths = await Posts.generatePostPaths([pid], uid); + return Array.isArray(paths) && paths.length > 0 ? paths[0] : null; + }; + + Posts.generatePostPaths = async function (pids, uid) { + const postData = await Posts.getPostsFields(pids, ['pid', 'tid']); + const tids = postData.map(post => post && post.tid); + const [indices, topicData] = await Promise.all([ + Posts.getPostIndices(postData, uid), + topics.getTopicsFields(tids, ['slug']), + ]); + + const paths = pids.map((pid, index) => { + const slug = topicData[index] ? topicData[index].slug : null; + const postIndex = utils.isNumber(indices[index]) ? Number.parseInt(indices[index], 10) + 1 : null; + + if (slug && postIndex) { + return `/topic/${slug}/${postIndex}`; + } + + return null; + }); + + return paths; + }; }; diff --git a/src/posts/uploads.js b/src/posts/uploads.js index c0287bb..1a72c3c 100644 --- a/src/posts/uploads.js +++ b/src/posts/uploads.js @@ -1,15 +1,14 @@ 'use strict'; const nconf = require('nconf'); -const fs = require('fs').promises; -const crypto = require('crypto'); -const path = require('path'); +const fs = require('node:fs').promises; +const crypto = require('node:crypto'); +const path = require('node:path'); const winston = require('winston'); const mime = require('mime'); const validator = require('validator'); const cronJob = require('cron').CronJob; const chalk = require('chalk'); - const db = require('../database'); const image = require('../image'); const user = require('../user'); @@ -18,214 +17,214 @@ const file = require('../file'); const meta = require('../meta'); module.exports = function (Posts) { - Posts.uploads = {}; - - const md5 = filename => crypto.createHash('md5').update(filename).digest('hex'); - const pathPrefix = path.join(nconf.get('upload_path')); - const searchRegex = /\/assets\/uploads\/(files\/[^\s")]+\.?[\w]*)/g; - - const _getFullPath = relativePath => path.join(pathPrefix, relativePath); - const _filterValidPaths = async filePaths => (await Promise.all(filePaths.map(async (filePath) => { - const fullPath = _getFullPath(filePath); - return fullPath.startsWith(pathPrefix) && await file.exists(fullPath) ? filePath : false; - }))).filter(Boolean); - - const runJobs = nconf.get('runJobs'); - if (runJobs) { - new cronJob('0 2 * * 0', async () => { - const orphans = await Posts.uploads.cleanOrphans(); - if (orphans.length) { - winston.info(`[posts/uploads] Deleting ${orphans.length} orphaned uploads...`); - orphans.forEach((relPath) => { - process.stdout.write(`${chalk.red(' - ')} ${relPath}`); - }); - } - }, null, true); - } - - Posts.uploads.sync = async function (pid) { - // Scans a post's content and updates sorted set of uploads - - const [content, currentUploads, isMainPost] = await Promise.all([ - Posts.getPostField(pid, 'content'), - Posts.uploads.list(pid), - Posts.isMain(pid), - ]); - - // Extract upload file paths from post content - let match = searchRegex.exec(content); - const uploads = []; - while (match) { - uploads.push(match[1].replace('-resized', '')); - match = searchRegex.exec(content); - } - - // Main posts can contain topic thumbs, which are also tracked by pid - if (isMainPost) { - const tid = await Posts.getPostField(pid, 'tid'); - let thumbs = await topics.thumbs.get(tid); - const replacePath = path.posix.join(`${nconf.get('relative_path')}${nconf.get('upload_url')}/`); - thumbs = thumbs.map(thumb => thumb.url.replace(replacePath, '')).filter(path => !validator.isURL(path, { - require_protocol: true, - })); - uploads.push(...thumbs); - } - - // Create add/remove sets - const add = uploads.filter(path => !currentUploads.includes(path)); - const remove = currentUploads.filter(path => !uploads.includes(path)); - await Promise.all([ - Posts.uploads.associate(pid, add), - Posts.uploads.dissociate(pid, remove), - ]); - }; - - Posts.uploads.list = async function (pid) { - return await db.getSortedSetMembers(`post:${pid}:uploads`); - }; - - Posts.uploads.listWithSizes = async function (pid) { - const paths = await Posts.uploads.list(pid); - const sizes = await db.getObjects(paths.map(path => `upload:${md5(path)}`)) || []; - - return sizes.map((sizeObj, idx) => ({ - ...sizeObj, - name: paths[idx], - })); - }; - - Posts.uploads.getOrphans = async () => { - let files = await fs.readdir(_getFullPath('/files')); - files = files.filter(filename => filename !== '.gitignore'); - - // Exclude non-timestamped files (e.g. group covers; see gh#10783/gh#10705) - const tsPrefix = /^\d{13}-/; - files = files.filter(filename => tsPrefix.test(filename)); - - files = await Promise.all(files.map(async filename => (await Posts.uploads.isOrphan(`files/${filename}`) ? `files/${filename}` : null))); - files = files.filter(Boolean); - - return files; - }; - - Posts.uploads.cleanOrphans = async () => { - const now = Date.now(); - const expiration = now - (1000 * 60 * 60 * 24 * meta.config.orphanExpiryDays); - const days = meta.config.orphanExpiryDays; - if (!days) { - return []; - } - - let orphans = await Posts.uploads.getOrphans(); - - orphans = await Promise.all(orphans.map(async (relPath) => { - const { mtimeMs } = await fs.stat(_getFullPath(relPath)); - return mtimeMs < expiration ? relPath : null; - })); - orphans = orphans.filter(Boolean); - - // Note: no await. Deletion not guaranteed by method end. - orphans.forEach((relPath) => { - file.delete(_getFullPath(relPath)); - }); - - return orphans; - }; - - Posts.uploads.isOrphan = async function (filePath) { - const length = await db.sortedSetCard(`upload:${md5(filePath)}:pids`); - return length === 0; - }; - - Posts.uploads.getUsage = async function (filePaths) { - // Given an array of file names, determines which pids they are used in - if (!Array.isArray(filePaths)) { - filePaths = [filePaths]; - } - - const keys = filePaths.map(fileObj => `upload:${md5(fileObj.path.replace('-resized', ''))}:pids`); - return await Promise.all(keys.map(k => db.getSortedSetRange(k, 0, -1))); - }; - - Posts.uploads.associate = async function (pid, filePaths) { - // Adds an upload to a post's sorted set of uploads - filePaths = !Array.isArray(filePaths) ? [filePaths] : filePaths; - if (!filePaths.length) { - return; - } - - // Only process files that exist and are within uploads directory - filePaths = await _filterValidPaths(filePaths); - - const now = Date.now(); - const scores = filePaths.map(() => now); - const bulkAdd = filePaths.map(path => [`upload:${md5(path)}:pids`, now, pid]); - await Promise.all([ - db.sortedSetAdd(`post:${pid}:uploads`, scores, filePaths), - db.sortedSetAddBulk(bulkAdd), - Posts.uploads.saveSize(filePaths), - ]); - }; - - Posts.uploads.dissociate = async function (pid, filePaths) { - // Removes an upload from a post's sorted set of uploads - filePaths = !Array.isArray(filePaths) ? [filePaths] : filePaths; - if (!filePaths.length) { - return; - } - - const bulkRemove = filePaths.map(path => [`upload:${md5(path)}:pids`, pid]); - const promises = [ - db.sortedSetRemove(`post:${pid}:uploads`, filePaths), - db.sortedSetRemoveBulk(bulkRemove), - ]; - - await Promise.all(promises); - - if (!meta.config.preserveOrphanedUploads) { - const deletePaths = (await Promise.all( - filePaths.map(async filePath => (await Posts.uploads.isOrphan(filePath) ? filePath : false)) - )).filter(Boolean); - - const uploaderUids = (await db.getObjectsFields(deletePaths.map(path => `upload:${md5(path)}`, ['uid']))).map(o => (o ? o.uid || null : null)); - await Promise.all(uploaderUids.map((uid, idx) => ( - uid && isFinite(uid) ? user.deleteUpload(uid, uid, deletePaths[idx]) : null - )).filter(Boolean)); - await Posts.uploads.deleteFromDisk(deletePaths); - } - }; - - Posts.uploads.dissociateAll = async (pid) => { - const current = await Posts.uploads.list(pid); - await Posts.uploads.dissociate(pid, current); - }; - - Posts.uploads.deleteFromDisk = async (filePaths) => { - if (typeof filePaths === 'string') { - filePaths = [filePaths]; - } else if (!Array.isArray(filePaths)) { - throw new Error(`[[error:wrong-parameter-type, filePaths, ${typeof filePaths}, array]]`); - } - - filePaths = (await _filterValidPaths(filePaths)).map(_getFullPath); - await Promise.all(filePaths.map(file.delete)); - }; - - Posts.uploads.saveSize = async (filePaths) => { - filePaths = filePaths.filter((fileName) => { - const type = mime.getType(fileName); - return type && type.match(/image./); - }); - await Promise.all(filePaths.map(async (fileName) => { - try { - const size = await image.size(_getFullPath(fileName)); - await db.setObject(`upload:${md5(fileName)}`, { - width: size.width, - height: size.height, - }); - } catch (err) { - winston.error(`[posts/uploads] Error while saving post upload sizes (${fileName}): ${err.message}`); - } - })); - }; + Posts.uploads = {}; + + const md5 = filename => crypto.createHash('md5').update(filename).digest('hex'); + const pathPrefix = path.join(nconf.get('upload_path')); + const searchRegex = /\/assets\/uploads\/(files\/[^\s")]+\.?\w*)/g; + + const _getFullPath = relativePath => path.join(pathPrefix, relativePath); + const _filterValidPaths = async filePaths => (await Promise.all(filePaths.map(async filePath => { + const fullPath = _getFullPath(filePath); + return fullPath.startsWith(pathPrefix) && await file.exists(fullPath) ? filePath : false; + }))).filter(Boolean); + + const runJobs = nconf.get('runJobs'); + if (runJobs) { + new cronJob('0 2 * * 0', async () => { + const orphans = await Posts.uploads.cleanOrphans(); + if (orphans.length > 0) { + winston.info(`[posts/uploads] Deleting ${orphans.length} orphaned uploads...`); + for (const relPath of orphans) { + process.stdout.write(`${chalk.red(' - ')} ${relPath}`); + } + } + }, null, true); + } + + Posts.uploads.sync = async function (pid) { + // Scans a post's content and updates sorted set of uploads + + const [content, currentUploads, isMainPost] = await Promise.all([ + Posts.getPostField(pid, 'content'), + Posts.uploads.list(pid), + Posts.isMain(pid), + ]); + + // Extract upload file paths from post content + let match = searchRegex.exec(content); + const uploads = []; + while (match) { + uploads.push(match[1].replace('-resized', '')); + match = searchRegex.exec(content); + } + + // Main posts can contain topic thumbs, which are also tracked by pid + if (isMainPost) { + const tid = await Posts.getPostField(pid, 'tid'); + let thumbs = await topics.thumbs.get(tid); + const replacePath = path.posix.join(`${nconf.get('relative_path')}${nconf.get('upload_url')}/`); + thumbs = thumbs.map(thumb => thumb.url.replace(replacePath, '')).filter(path => !validator.isURL(path, { + require_protocol: true, + })); + uploads.push(...thumbs); + } + + // Create add/remove sets + const add = uploads.filter(path => !currentUploads.includes(path)); + const remove = currentUploads.filter(path => !uploads.includes(path)); + await Promise.all([ + Posts.uploads.associate(pid, add), + Posts.uploads.dissociate(pid, remove), + ]); + }; + + Posts.uploads.list = async function (pid) { + return await db.getSortedSetMembers(`post:${pid}:uploads`); + }; + + Posts.uploads.listWithSizes = async function (pid) { + const paths = await Posts.uploads.list(pid); + const sizes = await db.getObjects(paths.map(path => `upload:${md5(path)}`)) || []; + + return sizes.map((sizeObject, index) => ({ + ...sizeObject, + name: paths[index], + })); + }; + + Posts.uploads.getOrphans = async () => { + let files = await fs.readdir(_getFullPath('/files')); + files = files.filter(filename => filename !== '.gitignore'); + + // Exclude non-timestamped files (e.g. group covers; see gh#10783/gh#10705) + const tsPrefix = /^\d{13}-/; + files = files.filter(filename => tsPrefix.test(filename)); + + files = await Promise.all(files.map(async filename => (await Posts.uploads.isOrphan(`files/${filename}`) ? `files/${filename}` : null))); + files = files.filter(Boolean); + + return files; + }; + + Posts.uploads.cleanOrphans = async () => { + const now = Date.now(); + const expiration = now - (1000 * 60 * 60 * 24 * meta.config.orphanExpiryDays); + const days = meta.config.orphanExpiryDays; + if (!days) { + return []; + } + + let orphans = await Posts.uploads.getOrphans(); + + orphans = await Promise.all(orphans.map(async relPath => { + const {mtimeMs} = await fs.stat(_getFullPath(relPath)); + return mtimeMs < expiration ? relPath : null; + })); + orphans = orphans.filter(Boolean); + + // Note: no await. Deletion not guaranteed by method end. + for (const relPath of orphans) { + file.delete(_getFullPath(relPath)); + } + + return orphans; + }; + + Posts.uploads.isOrphan = async function (filePath) { + const length = await db.sortedSetCard(`upload:${md5(filePath)}:pids`); + return length === 0; + }; + + Posts.uploads.getUsage = async function (filePaths) { + // Given an array of file names, determines which pids they are used in + if (!Array.isArray(filePaths)) { + filePaths = [filePaths]; + } + + const keys = filePaths.map(fileObject => `upload:${md5(fileObject.path.replace('-resized', ''))}:pids`); + return await Promise.all(keys.map(k => db.getSortedSetRange(k, 0, -1))); + }; + + Posts.uploads.associate = async function (pid, filePaths) { + // Adds an upload to a post's sorted set of uploads + filePaths = Array.isArray(filePaths) ? filePaths : [filePaths]; + if (filePaths.length === 0) { + return; + } + + // Only process files that exist and are within uploads directory + filePaths = await _filterValidPaths(filePaths); + + const now = Date.now(); + const scores = filePaths.map(() => now); + const bulkAdd = filePaths.map(path => [`upload:${md5(path)}:pids`, now, pid]); + await Promise.all([ + db.sortedSetAdd(`post:${pid}:uploads`, scores, filePaths), + db.sortedSetAddBulk(bulkAdd), + Posts.uploads.saveSize(filePaths), + ]); + }; + + Posts.uploads.dissociate = async function (pid, filePaths) { + // Removes an upload from a post's sorted set of uploads + filePaths = Array.isArray(filePaths) ? filePaths : [filePaths]; + if (filePaths.length === 0) { + return; + } + + const bulkRemove = filePaths.map(path => [`upload:${md5(path)}:pids`, pid]); + const promises = [ + db.sortedSetRemove(`post:${pid}:uploads`, filePaths), + db.sortedSetRemoveBulk(bulkRemove), + ]; + + await Promise.all(promises); + + if (!meta.config.preserveOrphanedUploads) { + const deletePaths = (await Promise.all( + filePaths.map(async filePath => (await Posts.uploads.isOrphan(filePath) ? filePath : false)), + )).filter(Boolean); + + const uploaderUids = (await db.getObjectsFields(deletePaths.map(path => `upload:${md5(path)}`))).map(o => (o ? o.uid || null : null)); + await Promise.all(uploaderUids.map((uid, index) => ( + uid && isFinite(uid) ? user.deleteUpload(uid, uid, deletePaths[index]) : null + )).filter(Boolean)); + await Posts.uploads.deleteFromDisk(deletePaths); + } + }; + + Posts.uploads.dissociateAll = async pid => { + const current = await Posts.uploads.list(pid); + await Posts.uploads.dissociate(pid, current); + }; + + Posts.uploads.deleteFromDisk = async filePaths => { + if (typeof filePaths === 'string') { + filePaths = [filePaths]; + } else if (!Array.isArray(filePaths)) { + throw new TypeError(`[[error:wrong-parameter-type, filePaths, ${typeof filePaths}, array]]`); + } + + filePaths = (await _filterValidPaths(filePaths)).map(_getFullPath); + await Promise.all(filePaths.map(file.delete)); + }; + + Posts.uploads.saveSize = async filePaths => { + filePaths = filePaths.filter(fileName => { + const type = mime.getType(fileName); + return type && type.match(/image./); + }); + await Promise.all(filePaths.map(async fileName => { + try { + const size = await image.size(_getFullPath(fileName)); + await db.setObject(`upload:${md5(fileName)}`, { + width: size.width, + height: size.height, + }); + } catch (error) { + winston.error(`[posts/uploads] Error while saving post upload sizes (${fileName}): ${error.message}`); + } + })); + }; }; diff --git a/src/posts/user.js b/src/posts/user.js index 0675524..a691be7 100644 --- a/src/posts/user.js +++ b/src/posts/user.js @@ -3,7 +3,6 @@ const async = require('async'); const validator = require('validator'); const _ = require('lodash'); - const db = require('../database'); const user = require('../user'); const topics = require('../topics'); @@ -13,249 +12,264 @@ const plugins = require('../plugins'); const privileges = require('../privileges'); module.exports = function (Posts) { - Posts.getUserInfoForPosts = async function (uids, uid) { - const [userData, userSettings, signatureUids] = await Promise.all([ - getUserData(uids, uid), - user.getMultipleUserSettings(uids), - privileges.global.filterUids('signature', uids), - ]); - const uidsSignatureSet = new Set(signatureUids.map(uid => parseInt(uid, 10))); - const groupsMap = await getGroupsMap(userData); - - userData.forEach((userData, index) => { - userData.signature = validator.escape(String(userData.signature || '')); - userData.fullname = userSettings[index].showfullname ? validator.escape(String(userData.fullname || '')) : undefined; - userData.selectedGroups = []; - - if (meta.config.hideFullname) { - userData.fullname = undefined; - } - }); - - const result = await Promise.all(userData.map(async (userData) => { - const [isMemberOfGroups, signature, customProfileInfo] = await Promise.all([ - checkGroupMembership(userData.uid, userData.groupTitleArray), - parseSignature(userData, uid, uidsSignatureSet), - plugins.hooks.fire('filter:posts.custom_profile_info', { profile: [], uid: userData.uid }), - ]); - - if (isMemberOfGroups && userData.groupTitleArray) { - userData.groupTitleArray.forEach((userGroup, index) => { - if (isMemberOfGroups[index] && groupsMap[userGroup]) { - userData.selectedGroups.push(groupsMap[userGroup]); - } - }); - } - userData.signature = signature; - userData.custom_profile_info = customProfileInfo.profile; - - return await plugins.hooks.fire('filter:posts.modifyUserInfo', userData); - })); - const hookResult = await plugins.hooks.fire('filter:posts.getUserInfoForPosts', { users: result }); - return hookResult.users; - }; - - Posts.overrideGuestHandle = function (postData, handle) { - if (meta.config.allowGuestHandles && postData && postData.user && parseInt(postData.uid, 10) === 0 && handle) { - postData.user.username = validator.escape(String(handle)); - if (postData.user.hasOwnProperty('fullname')) { - postData.user.fullname = postData.user.username; - } - postData.user.displayname = postData.user.username; - } - }; - - async function checkGroupMembership(uid, groupTitleArray) { - if (!Array.isArray(groupTitleArray) || !groupTitleArray.length) { - return null; - } - return await groups.isMemberOfGroups(uid, groupTitleArray); - } - - async function parseSignature(userData, uid, signatureUids) { - if (!userData.signature || !signatureUids.has(userData.uid) || meta.config.disableSignatures) { - return ''; - } - const result = await Posts.parseSignature(userData, uid); - return result.userData.signature; - } - - async function getGroupsMap(userData) { - const groupTitles = _.uniq(_.flatten(userData.map(u => u && u.groupTitleArray))); - const groupsMap = {}; - const groupsData = await groups.getGroupsData(groupTitles); - groupsData.forEach((group) => { - if (group && group.userTitleEnabled && !group.hidden) { - groupsMap[group.name] = { - name: group.name, - slug: group.slug, - labelColor: group.labelColor, - textColor: group.textColor, - icon: group.icon, - userTitle: group.userTitle, - }; - } - }); - return groupsMap; - } - - async function getUserData(uids, uid) { - const fields = [ - 'uid', 'username', 'fullname', 'userslug', - 'reputation', 'postcount', 'topiccount', 'picture', - 'signature', 'banned', 'banned:expire', 'status', - 'lastonline', 'groupTitle', 'mutedUntil', - ]; - const result = await plugins.hooks.fire('filter:posts.addUserFields', { - fields: fields, - uid: uid, - uids: uids, - }); - return await user.getUsersFields(result.uids, _.uniq(result.fields)); - } - - Posts.isOwner = async function (pids, uid) { - uid = parseInt(uid, 10); - const isArray = Array.isArray(pids); - pids = isArray ? pids : [pids]; - if (uid <= 0) { - return isArray ? pids.map(() => false) : false; - } - const postData = await Posts.getPostsFields(pids, ['uid']); - const result = postData.map(post => post && post.uid === uid); - return isArray ? result : result[0]; - }; - - Posts.isModerator = async function (pids, uid) { - if (parseInt(uid, 10) <= 0) { - return pids.map(() => false); - } - const cids = await Posts.getCidsByPids(pids); - return await user.isModerator(uid, cids); - }; - - Posts.changeOwner = async function (pids, toUid) { - const exists = await user.exists(toUid); - if (!exists) { - throw new Error('[[error:no-user]]'); - } - let postData = await Posts.getPostsFields(pids, [ - 'pid', 'tid', 'uid', 'content', 'deleted', 'timestamp', 'upvotes', 'downvotes', - ]); - postData = postData.filter(p => p.pid && p.uid !== parseInt(toUid, 10)); - pids = postData.map(p => p.pid); - - const cids = await Posts.getCidsByPids(pids); - - const bulkRemove = []; - const bulkAdd = []; - let repChange = 0; - const postsByUser = {}; - postData.forEach((post, i) => { - post.cid = cids[i]; - repChange += post.votes; - bulkRemove.push([`uid:${post.uid}:posts`, post.pid]); - bulkRemove.push([`cid:${post.cid}:uid:${post.uid}:pids`, post.pid]); - bulkRemove.push([`cid:${post.cid}:uid:${post.uid}:pids:votes`, post.pid]); - - bulkAdd.push([`uid:${toUid}:posts`, post.timestamp, post.pid]); - bulkAdd.push([`cid:${post.cid}:uid:${toUid}:pids`, post.timestamp, post.pid]); - if (post.votes > 0 || post.votes < 0) { - bulkAdd.push([`cid:${post.cid}:uid:${toUid}:pids:votes`, post.votes, post.pid]); - } - postsByUser[post.uid] = postsByUser[post.uid] || []; - postsByUser[post.uid].push(post); - }); - - await Promise.all([ - db.setObjectField(pids.map(pid => `post:${pid}`), 'uid', toUid), - db.sortedSetRemoveBulk(bulkRemove), - db.sortedSetAddBulk(bulkAdd), - user.incrementUserReputationBy(toUid, repChange), - handleMainPidOwnerChange(postData, toUid), - updateTopicPosters(postData, toUid), - ]); - - await Promise.all([ - user.updatePostCount(toUid), - reduceCounters(postsByUser), - ]); - - plugins.hooks.fire('action:post.changeOwner', { - posts: _.cloneDeep(postData), - toUid: toUid, - }); - return postData; - }; - - async function reduceCounters(postsByUser) { - await async.eachOfSeries(postsByUser, async (posts, uid) => { - const repChange = posts.reduce((acc, val) => acc + val.votes, 0); - await Promise.all([ - user.updatePostCount(uid), - user.incrementUserReputationBy(uid, -repChange), - ]); - }); - } - - async function updateTopicPosters(postData, toUid) { - const postsByTopic = _.groupBy(postData, p => parseInt(p.tid, 10)); - await async.eachOf(postsByTopic, async (posts, tid) => { - const postsByUser = _.groupBy(posts, p => parseInt(p.uid, 10)); - await db.sortedSetIncrBy(`tid:${tid}:posters`, posts.length, toUid); - await async.eachOf(postsByUser, async (posts, uid) => { - await db.sortedSetIncrBy(`tid:${tid}:posters`, -posts.length, uid); - }); - }); - } - - async function handleMainPidOwnerChange(postData, toUid) { - const tids = _.uniq(postData.map(p => p.tid)); - const topicData = await topics.getTopicsFields(tids, [ - 'tid', 'cid', 'deleted', 'title', 'uid', 'mainPid', 'timestamp', - ]); - const tidToTopic = _.zipObject(tids, topicData); - - const mainPosts = postData.filter(p => p.pid === tidToTopic[p.tid].mainPid); - if (!mainPosts.length) { - return; - } - - const bulkAdd = []; - const bulkRemove = []; - const postsByUser = {}; - mainPosts.forEach((post) => { - bulkRemove.push([`cid:${post.cid}:uid:${post.uid}:tids`, post.tid]); - bulkRemove.push([`uid:${post.uid}:topics`, post.tid]); - - bulkAdd.push([`cid:${post.cid}:uid:${toUid}:tids`, tidToTopic[post.tid].timestamp, post.tid]); - bulkAdd.push([`uid:${toUid}:topics`, tidToTopic[post.tid].timestamp, post.tid]); - postsByUser[post.uid] = postsByUser[post.uid] || []; - postsByUser[post.uid].push(post); - }); - - await Promise.all([ - db.setObjectField(mainPosts.map(p => `topic:${p.tid}`), 'uid', toUid), - db.sortedSetRemoveBulk(bulkRemove), - db.sortedSetAddBulk(bulkAdd), - user.incrementUserFieldBy(toUid, 'topiccount', mainPosts.length), - reduceTopicCounts(postsByUser), - ]); - - const changedTopics = mainPosts.map(p => tidToTopic[p.tid]); - plugins.hooks.fire('action:topic.changeOwner', { - topics: _.cloneDeep(changedTopics), - toUid: toUid, - }); - } - - async function reduceTopicCounts(postsByUser) { - await async.eachSeries(Object.keys(postsByUser), async (uid) => { - const posts = postsByUser[uid]; - const exists = await user.exists(uid); - if (exists) { - await user.incrementUserFieldBy(uid, 'topiccount', -posts.length); - } - }); - } + Posts.getUserInfoForPosts = async function (uids, uid) { + const [userData, userSettings, signatureUids] = await Promise.all([ + getUserData(uids, uid), + user.getMultipleUserSettings(uids), + privileges.global.filterUids('signature', uids), + ]); + const uidsSignatureSet = new Set(signatureUids.map(uid => Number.parseInt(uid, 10))); + const groupsMap = await getGroupsMap(userData); + + userData.forEach((userData, index) => { + userData.signature = validator.escape(String(userData.signature || '')); + userData.fullname = userSettings[index].showfullname ? validator.escape(String(userData.fullname || '')) : undefined; + userData.selectedGroups = []; + + if (meta.config.hideFullname) { + userData.fullname = undefined; + } + }); + + const result = await Promise.all(userData.map(async userData => { + const [isMemberOfGroups, signature, customProfileInfo] = await Promise.all([ + checkGroupMembership(userData.uid, userData.groupTitleArray), + parseSignature(userData, uid, uidsSignatureSet), + plugins.hooks.fire('filter:posts.custom_profile_info', {profile: [], uid: userData.uid}), + ]); + + if (isMemberOfGroups && userData.groupTitleArray) { + for (const [index, userGroup] of userData.groupTitleArray.entries()) { + if (isMemberOfGroups[index] && groupsMap[userGroup]) { + userData.selectedGroups.push(groupsMap[userGroup]); + } + } + } + + userData.signature = signature; + userData.custom_profile_info = customProfileInfo.profile; + + return await plugins.hooks.fire('filter:posts.modifyUserInfo', userData); + })); + const hookResult = await plugins.hooks.fire('filter:posts.getUserInfoForPosts', {users: result}); + return hookResult.users; + }; + + Posts.overrideGuestHandle = function (postData, handle) { + if (meta.config.allowGuestHandles && postData && postData.user && Number.parseInt(postData.uid, 10) === 0 && handle) { + postData.user.username = validator.escape(String(handle)); + if (postData.user.hasOwnProperty('fullname')) { + postData.user.fullname = postData.user.username; + } + + postData.user.displayname = postData.user.username; + } + }; + + async function checkGroupMembership(uid, groupTitleArray) { + if (!Array.isArray(groupTitleArray) || groupTitleArray.length === 0) { + return null; + } + + return await groups.isMemberOfGroups(uid, groupTitleArray); + } + + async function parseSignature(userData, uid, signatureUids) { + if (!userData.signature || !signatureUids.has(userData.uid) || meta.config.disableSignatures) { + return ''; + } + + const result = await Posts.parseSignature(userData, uid); + return result.userData.signature; + } + + async function getGroupsMap(userData) { + const groupTitles = _.uniq(userData.flatMap(u => u && u.groupTitleArray)); + const groupsMap = {}; + const groupsData = await groups.getGroupsData(groupTitles); + for (const group of groupsData) { + if (group && group.userTitleEnabled && !group.hidden) { + groupsMap[group.name] = { + name: group.name, + slug: group.slug, + labelColor: group.labelColor, + textColor: group.textColor, + icon: group.icon, + userTitle: group.userTitle, + }; + } + } + + return groupsMap; + } + + async function getUserData(uids, uid) { + const fields = [ + 'uid', + 'username', + 'fullname', + 'userslug', + 'reputation', + 'postcount', + 'topiccount', + 'picture', + 'signature', + 'banned', + 'banned:expire', + 'status', + 'lastonline', + 'groupTitle', + 'mutedUntil', + ]; + const result = await plugins.hooks.fire('filter:posts.addUserFields', { + fields, + uid, + uids, + }); + return await user.getUsersFields(result.uids, _.uniq(result.fields)); + } + + Posts.isOwner = async function (pids, uid) { + uid = Number.parseInt(uid, 10); + const isArray = Array.isArray(pids); + pids = isArray ? pids : [pids]; + if (uid <= 0) { + return isArray ? pids.map(() => false) : false; + } + + const postData = await Posts.getPostsFields(pids, ['uid']); + const result = postData.map(post => post && post.uid === uid); + return isArray ? result : result[0]; + }; + + Posts.isModerator = async function (pids, uid) { + if (Number.parseInt(uid, 10) <= 0) { + return pids.map(() => false); + } + + const cids = await Posts.getCidsByPids(pids); + return await user.isModerator(uid, cids); + }; + + Posts.changeOwner = async function (pids, toUid) { + const exists = await user.exists(toUid); + if (!exists) { + throw new Error('[[error:no-user]]'); + } + + let postData = await Posts.getPostsFields(pids, [ + 'pid', 'tid', 'uid', 'content', 'deleted', 'timestamp', 'upvotes', 'downvotes', + ]); + postData = postData.filter(p => p.pid && p.uid !== Number.parseInt(toUid, 10)); + pids = postData.map(p => p.pid); + + const cids = await Posts.getCidsByPids(pids); + + const bulkRemove = []; + const bulkAdd = []; + let repChange = 0; + const postsByUser = {}; + for (const [i, post] of postData.entries()) { + post.cid = cids[i]; + repChange += post.votes; + bulkRemove.push([`uid:${post.uid}:posts`, post.pid], [`cid:${post.cid}:uid:${post.uid}:pids`, post.pid], [`cid:${post.cid}:uid:${post.uid}:pids:votes`, post.pid]); + + bulkAdd.push([`uid:${toUid}:posts`, post.timestamp, post.pid], [`cid:${post.cid}:uid:${toUid}:pids`, post.timestamp, post.pid]); + if (post.votes > 0 || post.votes < 0) { + bulkAdd.push([`cid:${post.cid}:uid:${toUid}:pids:votes`, post.votes, post.pid]); + } + + postsByUser[post.uid] = postsByUser[post.uid] || []; + postsByUser[post.uid].push(post); + } + + await Promise.all([ + db.setObjectField(pids.map(pid => `post:${pid}`), 'uid', toUid), + db.sortedSetRemoveBulk(bulkRemove), + db.sortedSetAddBulk(bulkAdd), + user.incrementUserReputationBy(toUid, repChange), + handleMainPidOwnerChange(postData, toUid), + updateTopicPosters(postData, toUid), + ]); + + await Promise.all([ + user.updatePostCount(toUid), + reduceCounters(postsByUser), + ]); + + plugins.hooks.fire('action:post.changeOwner', { + posts: _.cloneDeep(postData), + toUid, + }); + return postData; + }; + + async function reduceCounters(postsByUser) { + await async.eachOfSeries(postsByUser, async (posts, uid) => { + const repChange = posts.reduce((accumulator, value) => accumulator + value.votes, 0); + await Promise.all([ + user.updatePostCount(uid), + user.incrementUserReputationBy(uid, -repChange), + ]); + }); + } + + async function updateTopicPosters(postData, toUid) { + const postsByTopic = _.groupBy(postData, p => Number.parseInt(p.tid, 10)); + await async.eachOf(postsByTopic, async (posts, tid) => { + const postsByUser = _.groupBy(posts, p => Number.parseInt(p.uid, 10)); + await db.sortedSetIncrBy(`tid:${tid}:posters`, posts.length, toUid); + await async.eachOf(postsByUser, async (posts, uid) => { + await db.sortedSetIncrBy(`tid:${tid}:posters`, -posts.length, uid); + }); + }); + } + + async function handleMainPidOwnerChange(postData, toUid) { + const tids = _.uniq(postData.map(p => p.tid)); + const topicData = await topics.getTopicsFields(tids, [ + 'tid', 'cid', 'deleted', 'title', 'uid', 'mainPid', 'timestamp', + ]); + const tidToTopic = _.zipObject(tids, topicData); + + const mainPosts = postData.filter(p => p.pid === tidToTopic[p.tid].mainPid); + if (mainPosts.length === 0) { + return; + } + + const bulkAdd = []; + const bulkRemove = []; + const postsByUser = {}; + for (const post of mainPosts) { + bulkRemove.push([`cid:${post.cid}:uid:${post.uid}:tids`, post.tid], [`uid:${post.uid}:topics`, post.tid]); + + bulkAdd.push([`cid:${post.cid}:uid:${toUid}:tids`, tidToTopic[post.tid].timestamp, post.tid], [`uid:${toUid}:topics`, tidToTopic[post.tid].timestamp, post.tid]); + postsByUser[post.uid] = postsByUser[post.uid] || []; + postsByUser[post.uid].push(post); + } + + await Promise.all([ + db.setObjectField(mainPosts.map(p => `topic:${p.tid}`), 'uid', toUid), + db.sortedSetRemoveBulk(bulkRemove), + db.sortedSetAddBulk(bulkAdd), + user.incrementUserFieldBy(toUid, 'topiccount', mainPosts.length), + reduceTopicCounts(postsByUser), + ]); + + const changedTopics = mainPosts.map(p => tidToTopic[p.tid]); + plugins.hooks.fire('action:topic.changeOwner', { + topics: _.cloneDeep(changedTopics), + toUid, + }); + } + + async function reduceTopicCounts(postsByUser) { + await async.eachSeries(Object.keys(postsByUser), async uid => { + const posts = postsByUser[uid]; + const exists = await user.exists(uid); + if (exists) { + await user.incrementUserFieldBy(uid, 'topiccount', -posts.length); + } + }); + } }; diff --git a/src/posts/votes.js b/src/posts/votes.js index 2f812f3..730c772 100644 --- a/src/posts/votes.js +++ b/src/posts/votes.js @@ -10,284 +10,282 @@ const privileges = require('../privileges'); const translator = require('../translator'); module.exports = function (Posts) { - const votesInProgress = {}; - - Posts.upvote = async function (pid, uid) { - if (meta.config['reputation:disabled']) { - throw new Error('[[error:reputation-system-disabled]]'); - } - const canUpvote = await privileges.posts.can('posts:upvote', pid, uid); - if (!canUpvote) { - throw new Error('[[error:no-privileges]]'); - } - - if (voteInProgress(pid, uid)) { - throw new Error('[[error:already-voting-for-this-post]]'); - } - putVoteInProgress(pid, uid); - - try { - return await toggleVote('upvote', pid, uid); - } finally { - clearVoteProgress(pid, uid); - } - }; - - Posts.downvote = async function (pid, uid) { - if (meta.config['reputation:disabled']) { - throw new Error('[[error:reputation-system-disabled]]'); - } - - if (meta.config['downvote:disabled']) { - throw new Error('[[error:downvoting-disabled]]'); - } - const canDownvote = await privileges.posts.can('posts:downvote', pid, uid); - if (!canDownvote) { - throw new Error('[[error:no-privileges]]'); - } - - if (voteInProgress(pid, uid)) { - throw new Error('[[error:already-voting-for-this-post]]'); - } - - putVoteInProgress(pid, uid); - try { - return await toggleVote('downvote', pid, uid); - } finally { - clearVoteProgress(pid, uid); - } - }; - - Posts.unvote = async function (pid, uid) { - if (voteInProgress(pid, uid)) { - throw new Error('[[error:already-voting-for-this-post]]'); - } - - putVoteInProgress(pid, uid); - try { - const voteStatus = await Posts.hasVoted(pid, uid); - return await unvote(pid, uid, 'unvote', voteStatus); - } finally { - clearVoteProgress(pid, uid); - } - }; - - Posts.hasVoted = async function (pid, uid) { - if (parseInt(uid, 10) <= 0) { - return { upvoted: false, downvoted: false }; - } - const hasVoted = await db.isMemberOfSets([`pid:${pid}:upvote`, `pid:${pid}:downvote`], uid); - return { upvoted: hasVoted[0], downvoted: hasVoted[1] }; - }; - - Posts.getVoteStatusByPostIDs = async function (pids, uid) { - if (parseInt(uid, 10) <= 0) { - const data = pids.map(() => false); - return { upvotes: data, downvotes: data }; - } - const upvoteSets = pids.map(pid => `pid:${pid}:upvote`); - const downvoteSets = pids.map(pid => `pid:${pid}:downvote`); - const data = await db.isMemberOfSets(upvoteSets.concat(downvoteSets), uid); - return { - upvotes: data.slice(0, pids.length), - downvotes: data.slice(pids.length, pids.length * 2), - }; - }; - - Posts.getUpvotedUidsByPids = async function (pids) { - return await db.getSetsMembers(pids.map(pid => `pid:${pid}:upvote`)); - }; - - function voteInProgress(pid, uid) { - return Array.isArray(votesInProgress[uid]) && votesInProgress[uid].includes(parseInt(pid, 10)); - } - - function putVoteInProgress(pid, uid) { - votesInProgress[uid] = votesInProgress[uid] || []; - votesInProgress[uid].push(parseInt(pid, 10)); - } - - function clearVoteProgress(pid, uid) { - if (Array.isArray(votesInProgress[uid])) { - const index = votesInProgress[uid].indexOf(parseInt(pid, 10)); - if (index !== -1) { - votesInProgress[uid].splice(index, 1); - } - } - } - - async function toggleVote(type, pid, uid) { - const voteStatus = await Posts.hasVoted(pid, uid); - await unvote(pid, uid, type, voteStatus); - return await vote(type, false, pid, uid, voteStatus); - } - - async function unvote(pid, uid, type, voteStatus) { - const owner = await Posts.getPostField(pid, 'uid'); - if (parseInt(uid, 10) === parseInt(owner, 10)) { - throw new Error('[[error:self-vote]]'); - } - - if (type === 'downvote' || type === 'upvote') { - await checkVoteLimitation(pid, uid, type); - } - - if (!voteStatus || (!voteStatus.upvoted && !voteStatus.downvoted)) { - return; - } - - return await vote(voteStatus.upvoted ? 'downvote' : 'upvote', true, pid, uid, voteStatus); - } - - async function checkVoteLimitation(pid, uid, type) { - // type = 'upvote' or 'downvote' - const oneDay = 86400000; - const [reputation, targetUid, votedPidsToday] = await Promise.all([ - user.getUserField(uid, 'reputation'), - Posts.getPostField(pid, 'uid'), - db.getSortedSetRevRangeByScore( - `uid:${uid}:${type}`, 0, -1, '+inf', Date.now() - oneDay - ), - ]); - - if (reputation < meta.config[`min:rep:${type}`]) { - throw new Error(`[[error:not-enough-reputation-to-${type}, ${meta.config[`min:rep:${type}`]}]]`); - } - const votesToday = meta.config[`${type}sPerDay`]; - if (votesToday && votedPidsToday.length >= votesToday) { - throw new Error(`[[error:too-many-${type}s-today, ${votesToday}]]`); - } - const voterPerUserToday = meta.config[`${type}sPerUserPerDay`]; - if (voterPerUserToday) { - const postData = await Posts.getPostsFields(votedPidsToday, ['uid']); - const targetUpVotes = postData.filter(p => p.uid === targetUid).length; - if (targetUpVotes >= voterPerUserToday) { - throw new Error(`[[error:too-many-${type}s-today-user, ${voterPerUserToday}]]`); - } - } - } - - async function vote(type, unvote, pid, uid, voteStatus) { - uid = parseInt(uid, 10); - if (uid <= 0) { - throw new Error('[[error:not-logged-in]]'); - } - const now = Date.now(); - - if (type === 'upvote' && !unvote) { - await db.sortedSetAdd(`uid:${uid}:upvote`, now, pid); - } else { - await db.sortedSetRemove(`uid:${uid}:upvote`, pid); - } - - if (type === 'upvote' || unvote) { - await db.sortedSetRemove(`uid:${uid}:downvote`, pid); - } else { - await db.sortedSetAdd(`uid:${uid}:downvote`, now, pid); - } - - const postData = await Posts.getPostFields(pid, ['pid', 'uid', 'tid']); - const newReputation = await user.incrementUserReputationBy(postData.uid, type === 'upvote' ? 1 : -1); - - await adjustPostVotes(postData, uid, type, unvote); - - await fireVoteHook(postData, uid, type, unvote, voteStatus); - - return { - user: { - reputation: newReputation, - }, - fromuid: uid, - post: postData, - upvote: type === 'upvote' && !unvote, - downvote: type === 'downvote' && !unvote, - }; - } - - async function fireVoteHook(postData, uid, type, unvote, voteStatus) { - let hook = type; - let current = voteStatus.upvoted ? 'upvote' : 'downvote'; - if (unvote) { // e.g. unvoting, removing a upvote or downvote - hook = 'unvote'; - } else { // e.g. User *has not* voted, clicks upvote or downvote - current = 'unvote'; - } - // action:post.upvote - // action:post.downvote - // action:post.unvote - plugins.hooks.fire(`action:post.${hook}`, { - pid: postData.pid, - uid: uid, - owner: postData.uid, - current: current, - }); - } - - async function adjustPostVotes(postData, uid, type, unvote) { - const notType = (type === 'upvote' ? 'downvote' : 'upvote'); - if (unvote) { - await db.setRemove(`pid:${postData.pid}:${type}`, uid); - } else { - await db.setAdd(`pid:${postData.pid}:${type}`, uid); - } - await db.setRemove(`pid:${postData.pid}:${notType}`, uid); - - const [upvotes, downvotes] = await Promise.all([ - db.setCount(`pid:${postData.pid}:upvote`), - db.setCount(`pid:${postData.pid}:downvote`), - ]); - postData.upvotes = upvotes; - postData.downvotes = downvotes; - postData.votes = postData.upvotes - postData.downvotes; - await Posts.updatePostVoteCount(postData); - } - - Posts.updatePostVoteCount = async function (postData) { - if (!postData || !postData.pid || !postData.tid) { - return; - } - const threshold = meta.config['flags:autoFlagOnDownvoteThreshold']; - if (threshold && postData.votes <= (-threshold)) { - const adminUid = await user.getFirstAdminUid(); - const reportMsg = await translator.translate(`[[flags:auto-flagged, ${-postData.votes}]]`); - const flagObj = await flags.create('post', postData.pid, adminUid, reportMsg, null, true); - await flags.notify(flagObj, adminUid, true); - } - await Promise.all([ - updateTopicVoteCount(postData), - db.sortedSetAdd('posts:votes', postData.votes, postData.pid), - Posts.setPostFields(postData.pid, { - upvotes: postData.upvotes, - downvotes: postData.downvotes, - }), - ]); - plugins.hooks.fire('action:post.updatePostVoteCount', { post: postData }); - }; - - async function updateTopicVoteCount(postData) { - const topicData = await topics.getTopicFields(postData.tid, ['mainPid', 'cid', 'pinned']); - - if (postData.uid) { - if (postData.votes !== 0) { - await db.sortedSetAdd(`cid:${topicData.cid}:uid:${postData.uid}:pids:votes`, postData.votes, postData.pid); - } else { - await db.sortedSetRemove(`cid:${topicData.cid}:uid:${postData.uid}:pids:votes`, postData.pid); - } - } - - if (parseInt(topicData.mainPid, 10) !== parseInt(postData.pid, 10)) { - return await db.sortedSetAdd(`tid:${postData.tid}:posts:votes`, postData.votes, postData.pid); - } - const promises = [ - topics.setTopicFields(postData.tid, { - upvotes: postData.upvotes, - downvotes: postData.downvotes, - }), - db.sortedSetAdd('topics:votes', postData.votes, postData.tid), - ]; - if (!topicData.pinned) { - promises.push(db.sortedSetAdd(`cid:${topicData.cid}:tids:votes`, postData.votes, postData.tid)); - } - await Promise.all(promises); - } + const votesInProgress = {}; + + Posts.upvote = async function (pid, uid) { + if (meta.config['reputation:disabled']) { + throw new Error('[[error:reputation-system-disabled]]'); + } + + const canUpvote = await privileges.posts.can('posts:upvote', pid, uid); + if (!canUpvote) { + throw new Error('[[error:no-privileges]]'); + } + + if (voteInProgress(pid, uid)) { + throw new Error('[[error:already-voting-for-this-post]]'); + } + + putVoteInProgress(pid, uid); + + try { + return await toggleVote('upvote', pid, uid); + } finally { + clearVoteProgress(pid, uid); + } + }; + + Posts.downvote = async function (pid, uid) { + if (meta.config['reputation:disabled']) { + throw new Error('[[error:reputation-system-disabled]]'); + } + + if (meta.config['downvote:disabled']) { + throw new Error('[[error:downvoting-disabled]]'); + } + + const canDownvote = await privileges.posts.can('posts:downvote', pid, uid); + if (!canDownvote) { + throw new Error('[[error:no-privileges]]'); + } + + if (voteInProgress(pid, uid)) { + throw new Error('[[error:already-voting-for-this-post]]'); + } + + putVoteInProgress(pid, uid); + try { + return await toggleVote('downvote', pid, uid); + } finally { + clearVoteProgress(pid, uid); + } + }; + + Posts.unvote = async function (pid, uid) { + if (voteInProgress(pid, uid)) { + throw new Error('[[error:already-voting-for-this-post]]'); + } + + putVoteInProgress(pid, uid); + try { + const voteStatus = await Posts.hasVoted(pid, uid); + return await unvote(pid, uid, 'unvote', voteStatus); + } finally { + clearVoteProgress(pid, uid); + } + }; + + Posts.hasVoted = async function (pid, uid) { + if (Number.parseInt(uid, 10) <= 0) { + return {upvoted: false, downvoted: false}; + } + + const hasVoted = await db.isMemberOfSets([`pid:${pid}:upvote`, `pid:${pid}:downvote`], uid); + return {upvoted: hasVoted[0], downvoted: hasVoted[1]}; + }; + + Posts.getVoteStatusByPostIDs = async function (pids, uid) { + if (Number.parseInt(uid, 10) <= 0) { + const data = pids.map(() => false); + return {upvotes: data, downvotes: data}; + } + + const upvoteSets = pids.map(pid => `pid:${pid}:upvote`); + const downvoteSets = pids.map(pid => `pid:${pid}:downvote`); + const data = await db.isMemberOfSets(upvoteSets.concat(downvoteSets), uid); + return { + upvotes: data.slice(0, pids.length), + downvotes: data.slice(pids.length, pids.length * 2), + }; + }; + + Posts.getUpvotedUidsByPids = async function (pids) { + return await db.getSetsMembers(pids.map(pid => `pid:${pid}:upvote`)); + }; + + function voteInProgress(pid, uid) { + return Array.isArray(votesInProgress[uid]) && votesInProgress[uid].includes(Number.parseInt(pid, 10)); + } + + function putVoteInProgress(pid, uid) { + votesInProgress[uid] = votesInProgress[uid] || []; + votesInProgress[uid].push(Number.parseInt(pid, 10)); + } + + function clearVoteProgress(pid, uid) { + if (Array.isArray(votesInProgress[uid])) { + const index = votesInProgress[uid].indexOf(Number.parseInt(pid, 10)); + if (index !== -1) { + votesInProgress[uid].splice(index, 1); + } + } + } + + async function toggleVote(type, pid, uid) { + const voteStatus = await Posts.hasVoted(pid, uid); + await unvote(pid, uid, type, voteStatus); + return await vote(type, false, pid, uid, voteStatus); + } + + async function unvote(pid, uid, type, voteStatus) { + const owner = await Posts.getPostField(pid, 'uid'); + if (Number.parseInt(uid, 10) === Number.parseInt(owner, 10)) { + throw new Error('[[error:self-vote]]'); + } + + if (type === 'downvote' || type === 'upvote') { + await checkVoteLimitation(pid, uid, type); + } + + if (!voteStatus || (!voteStatus.upvoted && !voteStatus.downvoted)) { + return; + } + + return await vote(voteStatus.upvoted ? 'downvote' : 'upvote', true, pid, uid, voteStatus); + } + + async function checkVoteLimitation(pid, uid, type) { + // Type = 'upvote' or 'downvote' + const oneDay = 86_400_000; + const [reputation, targetUid, votedPidsToday] = await Promise.all([ + user.getUserField(uid, 'reputation'), + Posts.getPostField(pid, 'uid'), + db.getSortedSetRevRangeByScore( + `uid:${uid}:${type}`, 0, -1, '+inf', Date.now() - oneDay, + ), + ]); + + if (reputation < meta.config[`min:rep:${type}`]) { + throw new Error(`[[error:not-enough-reputation-to-${type}, ${meta.config[`min:rep:${type}`]}]]`); + } + + const votesToday = meta.config[`${type}sPerDay`]; + if (votesToday && votedPidsToday.length >= votesToday) { + throw new Error(`[[error:too-many-${type}s-today, ${votesToday}]]`); + } + + const voterPerUserToday = meta.config[`${type}sPerUserPerDay`]; + if (voterPerUserToday) { + const postData = await Posts.getPostsFields(votedPidsToday, ['uid']); + const targetUpVotes = postData.filter(p => p.uid === targetUid).length; + if (targetUpVotes >= voterPerUserToday) { + throw new Error(`[[error:too-many-${type}s-today-user, ${voterPerUserToday}]]`); + } + } + } + + async function vote(type, unvote, pid, uid, voteStatus) { + uid = Number.parseInt(uid, 10); + if (uid <= 0) { + throw new Error('[[error:not-logged-in]]'); + } + + const now = Date.now(); + + await (type === 'upvote' && !unvote ? db.sortedSetAdd(`uid:${uid}:upvote`, now, pid) : db.sortedSetRemove(`uid:${uid}:upvote`, pid)); + + await (type === 'upvote' || unvote ? db.sortedSetRemove(`uid:${uid}:downvote`, pid) : db.sortedSetAdd(`uid:${uid}:downvote`, now, pid)); + + const postData = await Posts.getPostFields(pid, ['pid', 'uid', 'tid']); + const newReputation = await user.incrementUserReputationBy(postData.uid, type === 'upvote' ? 1 : -1); + + await adjustPostVotes(postData, uid, type, unvote); + + await fireVoteHook(postData, uid, type, unvote, voteStatus); + + return { + user: { + reputation: newReputation, + }, + fromuid: uid, + post: postData, + upvote: type === 'upvote' && !unvote, + downvote: type === 'downvote' && !unvote, + }; + } + + async function fireVoteHook(postData, uid, type, unvote, voteStatus) { + let hook = type; + let current = voteStatus.upvoted ? 'upvote' : 'downvote'; + if (unvote) { // E.g. unvoting, removing a upvote or downvote + hook = 'unvote'; + } else { // E.g. User *has not* voted, clicks upvote or downvote + current = 'unvote'; + } + + // Action:post.upvote + // action:post.downvote + // action:post.unvote + plugins.hooks.fire(`action:post.${hook}`, { + pid: postData.pid, + uid, + owner: postData.uid, + current, + }); + } + + async function adjustPostVotes(postData, uid, type, unvote) { + const notType = (type === 'upvote' ? 'downvote' : 'upvote'); + await (unvote ? db.setRemove(`pid:${postData.pid}:${type}`, uid) : db.setAdd(`pid:${postData.pid}:${type}`, uid)); + + await db.setRemove(`pid:${postData.pid}:${notType}`, uid); + + const [upvotes, downvotes] = await Promise.all([ + db.setCount(`pid:${postData.pid}:upvote`), + db.setCount(`pid:${postData.pid}:downvote`), + ]); + postData.upvotes = upvotes; + postData.downvotes = downvotes; + postData.votes = postData.upvotes - postData.downvotes; + await Posts.updatePostVoteCount(postData); + } + + Posts.updatePostVoteCount = async function (postData) { + if (!postData || !postData.pid || !postData.tid) { + return; + } + + const threshold = meta.config['flags:autoFlagOnDownvoteThreshold']; + if (threshold && postData.votes <= (-threshold)) { + const adminUid = await user.getFirstAdminUid(); + const reportMessage = await translator.translate(`[[flags:auto-flagged, ${-postData.votes}]]`); + const flagObject = await flags.create('post', postData.pid, adminUid, reportMessage, null, true); + await flags.notify(flagObject, adminUid, true); + } + + await Promise.all([ + updateTopicVoteCount(postData), + db.sortedSetAdd('posts:votes', postData.votes, postData.pid), + Posts.setPostFields(postData.pid, { + upvotes: postData.upvotes, + downvotes: postData.downvotes, + }), + ]); + plugins.hooks.fire('action:post.updatePostVoteCount', {post: postData}); + }; + + async function updateTopicVoteCount(postData) { + const topicData = await topics.getTopicFields(postData.tid, ['mainPid', 'cid', 'pinned']); + + if (postData.uid) { + await (postData.votes === 0 ? db.sortedSetRemove(`cid:${topicData.cid}:uid:${postData.uid}:pids:votes`, postData.pid) : db.sortedSetAdd(`cid:${topicData.cid}:uid:${postData.uid}:pids:votes`, postData.votes, postData.pid)); + } + + if (Number.parseInt(topicData.mainPid, 10) !== Number.parseInt(postData.pid, 10)) { + return await db.sortedSetAdd(`tid:${postData.tid}:posts:votes`, postData.votes, postData.pid); + } + + const promises = [ + topics.setTopicFields(postData.tid, { + upvotes: postData.upvotes, + downvotes: postData.downvotes, + }), + db.sortedSetAdd('topics:votes', postData.votes, postData.tid), + ]; + if (!topicData.pinned) { + promises.push(db.sortedSetAdd(`cid:${topicData.cid}:tids:votes`, postData.votes, postData.tid)); + } + + await Promise.all(promises); + } }; diff --git a/src/prestart.js b/src/prestart.js index f39ec76..eef4c74 100644 --- a/src/prestart.js +++ b/src/prestart.js @@ -1,123 +1,123 @@ 'use strict'; +const url = require('node:url'); +const path = require('node:path'); const nconf = require('nconf'); -const url = require('url'); const winston = require('winston'); -const path = require('path'); const chalk = require('chalk'); - const pkg = require('../package.json'); -const { paths } = require('./constants'); +const {paths} = require('./constants'); function setupWinston() { - if (!winston.format) { - return; - } - - const formats = []; - if (nconf.get('log-colorize') !== 'false') { - formats.push(winston.format.colorize()); - } - - if (nconf.get('json-logging')) { - formats.push(winston.format.timestamp()); - formats.push(winston.format.json()); - } else { - const timestampFormat = winston.format((info) => { - const dateString = `${new Date().toISOString()} [${nconf.get('port')}/${global.process.pid}]`; - info.level = `${dateString} - ${info.level}`; - return info; - }); - formats.push(timestampFormat()); - formats.push(winston.format.splat()); - formats.push(winston.format.simple()); - } - - winston.configure({ - level: nconf.get('log-level') || (process.env.NODE_ENV === 'production' ? 'info' : 'verbose'), - format: winston.format.combine.apply(null, formats), - transports: [ - new winston.transports.Console({ - handleExceptions: true, - }), - ], - }); + if (!winston.format) { + return; + } + + const formats = []; + if (nconf.get('log-colorize') !== 'false') { + formats.push(winston.format.colorize()); + } + + if (nconf.get('json-logging')) { + formats.push(winston.format.timestamp()); + formats.push(winston.format.json()); + } else { + const timestampFormat = winston.format(info => { + const dateString = `${new Date().toISOString()} [${nconf.get('port')}/${global.process.pid}]`; + info.level = `${dateString} - ${info.level}`; + return info; + }); + formats.push(timestampFormat()); + formats.push(winston.format.splat()); + formats.push(winston.format.simple()); + } + + winston.configure({ + level: nconf.get('log-level') || (process.env.NODE_ENV === 'production' ? 'info' : 'verbose'), + format: winston.format.combine.apply(null, formats), + transports: [ + new winston.transports.Console({ + handleExceptions: true, + }), + ], + }); } function loadConfig(configFile) { - nconf.file({ - file: configFile, - }); - - nconf.defaults({ - base_dir: paths.baseDir, - themes_path: paths.themes, - upload_path: 'public/uploads', - views_dir: path.join(paths.baseDir, 'build/public/templates'), - version: pkg.version, - isCluster: false, - isPrimary: true, - jobsDisabled: false, - }); - - // Explicitly cast as Bool, loader.js passes in isCluster as string 'true'/'false' - const castAsBool = ['isCluster', 'isPrimary', 'jobsDisabled']; - nconf.stores.env.readOnly = false; - castAsBool.forEach((prop) => { - const value = nconf.get(prop); - if (value !== undefined) { - nconf.set(prop, ['1', 1, 'true', true].includes(value)); - } - }); - nconf.stores.env.readOnly = true; - nconf.set('runJobs', nconf.get('isPrimary') && !nconf.get('jobsDisabled')); - - // Ensure themes_path is a full filepath - nconf.set('themes_path', path.resolve(paths.baseDir, nconf.get('themes_path'))); - nconf.set('core_templates_path', path.join(paths.baseDir, 'src/views')); - nconf.set('base_templates_path', path.join(nconf.get('themes_path'), 'nodebb-theme-persona/templates')); - - nconf.set('upload_path', path.resolve(nconf.get('base_dir'), nconf.get('upload_path'))); - nconf.set('upload_url', '/assets/uploads'); - - - // nconf defaults, if not set in config - if (!nconf.get('sessionKey')) { - nconf.set('sessionKey', 'express.sid'); - } - - if (nconf.get('url')) { - nconf.set('url', nconf.get('url').replace(/\/$/, '')); - nconf.set('url_parsed', url.parse(nconf.get('url'))); - // Parse out the relative_url and other goodies from the configured URL - const urlObject = url.parse(nconf.get('url')); - const relativePath = urlObject.pathname !== '/' ? urlObject.pathname.replace(/\/+$/, '') : ''; - nconf.set('base_url', `${urlObject.protocol}//${urlObject.host}`); - nconf.set('secure', urlObject.protocol === 'https:'); - nconf.set('use_port', !!urlObject.port); - nconf.set('relative_path', relativePath); - if (!nconf.get('asset_base_url')) { - nconf.set('asset_base_url', `${relativePath}/assets`); - } - nconf.set('port', nconf.get('PORT') || nconf.get('port') || urlObject.port || (nconf.get('PORT_ENV_VAR') ? nconf.get(nconf.get('PORT_ENV_VAR')) : false) || 4567); - - // cookies don't provide isolation by port: http://stackoverflow.com/a/16328399/122353 - const domain = nconf.get('cookieDomain') || urlObject.hostname; - const origins = nconf.get('socket.io:origins') || `${urlObject.protocol}//${domain}:*`; - nconf.set('socket.io:origins', origins); - } + nconf.file({ + file: configFile, + }); + + nconf.defaults({ + base_dir: paths.baseDir, + themes_path: paths.themes, + upload_path: 'public/uploads', + views_dir: path.join(paths.baseDir, 'build/public/templates'), + version: pkg.version, + isCluster: false, + isPrimary: true, + jobsDisabled: false, + }); + + // Explicitly cast as Bool, loader.js passes in isCluster as string 'true'/'false' + const castAsBool = ['isCluster', 'isPrimary', 'jobsDisabled']; + nconf.stores.env.readOnly = false; + for (const property of castAsBool) { + const value = nconf.get(property); + if (value !== undefined) { + nconf.set(property, ['1', 1, 'true', true].includes(value)); + } + } + + nconf.stores.env.readOnly = true; + nconf.set('runJobs', nconf.get('isPrimary') && !nconf.get('jobsDisabled')); + + // Ensure themes_path is a full filepath + nconf.set('themes_path', path.resolve(paths.baseDir, nconf.get('themes_path'))); + nconf.set('core_templates_path', path.join(paths.baseDir, 'src/views')); + nconf.set('base_templates_path', path.join(nconf.get('themes_path'), 'nodebb-theme-persona/templates')); + + nconf.set('upload_path', path.resolve(nconf.get('base_dir'), nconf.get('upload_path'))); + nconf.set('upload_url', '/assets/uploads'); + + // Nconf defaults, if not set in config + if (!nconf.get('sessionKey')) { + nconf.set('sessionKey', 'express.sid'); + } + + if (nconf.get('url')) { + nconf.set('url', nconf.get('url').replace(/\/$/, '')); + nconf.set('url_parsed', url.parse(nconf.get('url'))); + // Parse out the relative_url and other goodies from the configured URL + const urlObject = url.parse(nconf.get('url')); + const relativePath = urlObject.pathname === '/' ? '' : urlObject.pathname.replace(/\/+$/, ''); + nconf.set('base_url', `${urlObject.protocol}//${urlObject.host}`); + nconf.set('secure', urlObject.protocol === 'https:'); + nconf.set('use_port', Boolean(urlObject.port)); + nconf.set('relative_path', relativePath); + if (!nconf.get('asset_base_url')) { + nconf.set('asset_base_url', `${relativePath}/assets`); + } + + nconf.set('port', nconf.get('PORT') || nconf.get('port') || urlObject.port || (nconf.get('PORT_ENV_VAR') ? nconf.get(nconf.get('PORT_ENV_VAR')) : false) || 4567); + + // Cookies don't provide isolation by port: http://stackoverflow.com/a/16328399/122353 + const domain = nconf.get('cookieDomain') || urlObject.hostname; + const origins = nconf.get('socket.io:origins') || `${urlObject.protocol}//${domain}:*`; + nconf.set('socket.io:origins', origins); + } } function versionCheck() { - const version = process.version.slice(1); - const range = pkg.engines.node; - const semver = require('semver'); - const compatible = semver.satisfies(version, range); - - if (!compatible) { - winston.warn('Your version of Node.js is too outdated for NodeBB. Please update your version of Node.js.'); - winston.warn(`Recommended ${chalk.green(range)}, ${chalk.yellow(version)} provided\n`); - } + const version = process.version.slice(1); + const range = pkg.engines.node; + const semver = require('semver'); + const compatible = semver.satisfies(version, range); + + if (!compatible) { + winston.warn('Your version of Node.js is too outdated for NodeBB. Please update your version of Node.js.'); + winston.warn(`Recommended ${chalk.green(range)}, ${chalk.yellow(version)} provided\n`); + } } exports.setupWinston = setupWinston; diff --git a/src/privileges/admin.js b/src/privileges/admin.js index 4b778da..98d3302 100644 --- a/src/privileges/admin.js +++ b/src/privileges/admin.js @@ -2,12 +2,11 @@ 'use strict'; const _ = require('lodash'); - const user = require('../user'); const groups = require('../groups'); -const helpers = require('./helpers'); const plugins = require('../plugins'); const utils = require('../utils'); +const helpers = require('./helpers'); const privsAdmin = module.exports; @@ -17,196 +16,197 @@ const privsAdmin = module.exports; * in to your listener. */ const _privilegeMap = new Map([ - ['admin:dashboard', { label: '[[admin/manage/privileges:admin-dashboard]]' }], - ['admin:categories', { label: '[[admin/manage/privileges:admin-categories]]' }], - ['admin:privileges', { label: '[[admin/manage/privileges:admin-privileges]]' }], - ['admin:admins-mods', { label: '[[admin/manage/privileges:admin-admins-mods]]' }], - ['admin:users', { label: '[[admin/manage/privileges:admin-users]]' }], - ['admin:groups', { label: '[[admin/manage/privileges:admin-groups]]' }], - ['admin:tags', { label: '[[admin/manage/privileges:admin-tags]]' }], - ['admin:settings', { label: '[[admin/manage/privileges:admin-settings]]' }], + ['admin:dashboard', {label: '[[admin/manage/privileges:admin-dashboard]]'}], + ['admin:categories', {label: '[[admin/manage/privileges:admin-categories]]'}], + ['admin:privileges', {label: '[[admin/manage/privileges:admin-privileges]]'}], + ['admin:admins-mods', {label: '[[admin/manage/privileges:admin-admins-mods]]'}], + ['admin:users', {label: '[[admin/manage/privileges:admin-users]]'}], + ['admin:groups', {label: '[[admin/manage/privileges:admin-groups]]'}], + ['admin:tags', {label: '[[admin/manage/privileges:admin-tags]]'}], + ['admin:settings', {label: '[[admin/manage/privileges:admin-settings]]'}], ]); privsAdmin.getUserPrivilegeList = async () => await plugins.hooks.fire('filter:privileges.admin.list', Array.from(_privilegeMap.keys())); privsAdmin.getGroupPrivilegeList = async () => await plugins.hooks.fire('filter:privileges.admin.groups.list', Array.from(_privilegeMap.keys()).map(privilege => `groups:${privilege}`)); privsAdmin.getPrivilegeList = async () => { - const [user, group] = await Promise.all([ - privsAdmin.getUserPrivilegeList(), - privsAdmin.getGroupPrivilegeList(), - ]); - return user.concat(group); + const [user, group] = await Promise.all([ + privsAdmin.getUserPrivilegeList(), + privsAdmin.getGroupPrivilegeList(), + ]); + return user.concat(group); }; privsAdmin.init = async () => { - await plugins.hooks.fire('static:privileges.admin.init', { - privileges: _privilegeMap, - }); + await plugins.hooks.fire('static:privileges.admin.init', { + privileges: _privilegeMap, + }); }; // Mapping for a page route (via direct match or regexp) to a privilege privsAdmin.routeMap = { - dashboard: 'admin:dashboard', - 'manage/categories': 'admin:categories', - 'manage/privileges': 'admin:privileges', - 'manage/admins-mods': 'admin:admins-mods', - 'manage/users': 'admin:users', - 'manage/groups': 'admin:groups', - 'manage/tags': 'admin:tags', - 'settings/tags': 'admin:tags', - 'extend/plugins': 'admin:settings', - 'extend/widgets': 'admin:settings', - 'extend/rewards': 'admin:settings', - // uploads - 'category/uploadpicture': 'admin:categories', - uploadfavicon: 'admin:settings', - uploadTouchIcon: 'admin:settings', - uploadMaskableIcon: 'admin:settings', - uploadlogo: 'admin:settings', - uploadOgImage: 'admin:settings', - uploadDefaultAvatar: 'admin:settings', + dashboard: 'admin:dashboard', + 'manage/categories': 'admin:categories', + 'manage/privileges': 'admin:privileges', + 'manage/admins-mods': 'admin:admins-mods', + 'manage/users': 'admin:users', + 'manage/groups': 'admin:groups', + 'manage/tags': 'admin:tags', + 'settings/tags': 'admin:tags', + 'extend/plugins': 'admin:settings', + 'extend/widgets': 'admin:settings', + 'extend/rewards': 'admin:settings', + // Uploads + 'category/uploadpicture': 'admin:categories', + uploadfavicon: 'admin:settings', + uploadTouchIcon: 'admin:settings', + uploadMaskableIcon: 'admin:settings', + uploadlogo: 'admin:settings', + uploadOgImage: 'admin:settings', + uploadDefaultAvatar: 'admin:settings', }; privsAdmin.routePrefixMap = { - 'manage/categories/': 'admin:categories', - 'manage/privileges/': 'admin:privileges', - 'manage/groups/': 'admin:groups', - 'settings/': 'admin:settings', - 'appearance/': 'admin:settings', - 'plugins/': 'admin:settings', + 'manage/categories/': 'admin:categories', + 'manage/privileges/': 'admin:privileges', + 'manage/groups/': 'admin:groups', + 'settings/': 'admin:settings', + 'appearance/': 'admin:settings', + 'plugins/': 'admin:settings', }; // Mapping for socket call methods to a privilege // In NodeBB v2, these socket calls will be removed in favour of xhr calls privsAdmin.socketMap = { - 'admin.rooms.getAll': 'admin:dashboard', - 'admin.analytics.get': 'admin:dashboard', - - 'admin.categories.copySettingsFrom': 'admin:categories', - 'admin.categories.copyPrivilegesToChildren': 'admin:privileges', - 'admin.categories.copyPrivilegesFrom': 'admin:privileges', - 'admin.categories.copyPrivilegesToAllCategories': 'admin:privileges', - - 'admin.user.makeAdmins': 'admin:admins-mods', - 'admin.user.removeAdmins': 'admin:admins-mods', - - 'admin.user.loadGroups': 'admin:users', - 'admin.groups.join': 'admin:users', - 'admin.groups.leave': 'admin:users', - 'admin.user.resetLockouts': 'admin:users', - 'admin.user.validateEmail': 'admin:users', - 'admin.user.sendValidationEmail': 'admin:users', - 'admin.user.sendPasswordResetEmail': 'admin:users', - 'admin.user.forcePasswordReset': 'admin:users', - 'admin.user.invite': 'admin:users', - - 'admin.tags.create': 'admin:tags', - 'admin.tags.rename': 'admin:tags', - 'admin.tags.deleteTags': 'admin:tags', - - 'admin.getSearchDict': 'admin:settings', - 'admin.config.setMultiple': 'admin:settings', - 'admin.config.remove': 'admin:settings', - 'admin.themes.getInstalled': 'admin:settings', - 'admin.themes.set': 'admin:settings', - 'admin.reloadAllSessions': 'admin:settings', - 'admin.settings.get': 'admin:settings', - 'admin.settings.set': 'admin:settings', + 'admin.rooms.getAll': 'admin:dashboard', + 'admin.analytics.get': 'admin:dashboard', + + 'admin.categories.copySettingsFrom': 'admin:categories', + 'admin.categories.copyPrivilegesToChildren': 'admin:privileges', + 'admin.categories.copyPrivilegesFrom': 'admin:privileges', + 'admin.categories.copyPrivilegesToAllCategories': 'admin:privileges', + + 'admin.user.makeAdmins': 'admin:admins-mods', + 'admin.user.removeAdmins': 'admin:admins-mods', + + 'admin.user.loadGroups': 'admin:users', + 'admin.groups.join': 'admin:users', + 'admin.groups.leave': 'admin:users', + 'admin.user.resetLockouts': 'admin:users', + 'admin.user.validateEmail': 'admin:users', + 'admin.user.sendValidationEmail': 'admin:users', + 'admin.user.sendPasswordResetEmail': 'admin:users', + 'admin.user.forcePasswordReset': 'admin:users', + 'admin.user.invite': 'admin:users', + + 'admin.tags.create': 'admin:tags', + 'admin.tags.rename': 'admin:tags', + 'admin.tags.deleteTags': 'admin:tags', + + 'admin.getSearchDict': 'admin:settings', + 'admin.config.setMultiple': 'admin:settings', + 'admin.config.remove': 'admin:settings', + 'admin.themes.getInstalled': 'admin:settings', + 'admin.themes.set': 'admin:settings', + 'admin.reloadAllSessions': 'admin:settings', + 'admin.settings.get': 'admin:settings', + 'admin.settings.set': 'admin:settings', }; -privsAdmin.resolve = (path) => { - if (privsAdmin.routeMap.hasOwnProperty(path)) { - return privsAdmin.routeMap[path]; - } - - const found = Object.entries(privsAdmin.routePrefixMap) - .filter(entry => path.startsWith(entry[0])) - .sort((entry1, entry2) => entry2[0].length - entry1[0].length); - if (!found.length) { - return undefined; - } - return found[0][1]; // [0] is path [1] is privilege +privsAdmin.resolve = path => { + if (privsAdmin.routeMap.hasOwnProperty(path)) { + return privsAdmin.routeMap[path]; + } + + const found = Object.entries(privsAdmin.routePrefixMap) + .filter(entry => path.startsWith(entry[0])) + .sort((entry1, entry2) => entry2[0].length - entry1[0].length); + if (found.length === 0) { + return undefined; + } + + return found[0][1]; // [0] is path [1] is privilege }; privsAdmin.list = async function (uid) { - const privilegeLabels = Array.from(_privilegeMap.values()).map(data => data.label); - const userPrivilegeList = await privsAdmin.getUserPrivilegeList(); - const groupPrivilegeList = await privsAdmin.getGroupPrivilegeList(); - - // Restrict privileges column to superadmins - if (!(await user.isAdministrator(uid))) { - const idx = Array.from(_privilegeMap.keys()).indexOf('admin:privileges'); - privilegeLabels.splice(idx, 1); - userPrivilegeList.splice(idx, 1); - groupPrivilegeList.splice(idx, 1); - } - - const labels = await utils.promiseParallel({ - users: plugins.hooks.fire('filter:privileges.admin.list_human', privilegeLabels.slice()), - groups: plugins.hooks.fire('filter:privileges.admin.groups.list_human', privilegeLabels.slice()), - }); - - const keys = { - users: userPrivilegeList, - groups: groupPrivilegeList, - }; - - const payload = await utils.promiseParallel({ - labels, - users: helpers.getUserPrivileges(0, keys.users), - groups: helpers.getGroupPrivileges(0, keys.groups), - }); - payload.keys = keys; - - return payload; + const privilegeLabels = Array.from(_privilegeMap.values()).map(data => data.label); + const userPrivilegeList = await privsAdmin.getUserPrivilegeList(); + const groupPrivilegeList = await privsAdmin.getGroupPrivilegeList(); + + // Restrict privileges column to superadmins + if (!(await user.isAdministrator(uid))) { + const index = Array.from(_privilegeMap.keys()).indexOf('admin:privileges'); + privilegeLabels.splice(index, 1); + userPrivilegeList.splice(index, 1); + groupPrivilegeList.splice(index, 1); + } + + const labels = await utils.promiseParallel({ + users: plugins.hooks.fire('filter:privileges.admin.list_human', privilegeLabels.slice()), + groups: plugins.hooks.fire('filter:privileges.admin.groups.list_human', privilegeLabels.slice()), + }); + + const keys = { + users: userPrivilegeList, + groups: groupPrivilegeList, + }; + + const payload = await utils.promiseParallel({ + labels, + users: helpers.getUserPrivileges(0, keys.users), + groups: helpers.getGroupPrivileges(0, keys.groups), + }); + payload.keys = keys; + + return payload; }; privsAdmin.get = async function (uid) { - const userPrivilegeList = await privsAdmin.getUserPrivilegeList(); - const [userPrivileges, isAdministrator] = await Promise.all([ - helpers.isAllowedTo(userPrivilegeList, uid, 0), - user.isAdministrator(uid), - ]); + const userPrivilegeList = await privsAdmin.getUserPrivilegeList(); + const [userPrivileges, isAdministrator] = await Promise.all([ + helpers.isAllowedTo(userPrivilegeList, uid, 0), + user.isAdministrator(uid), + ]); - const combined = userPrivileges.map(allowed => allowed || isAdministrator); - const privData = _.zipObject(userPrivilegeList, combined); + const combined = userPrivileges.map(allowed => allowed || isAdministrator); + const privData = _.zipObject(userPrivilegeList, combined); - privData.superadmin = isAdministrator; - return await plugins.hooks.fire('filter:privileges.admin.get', privData); + privData.superadmin = isAdministrator; + return await plugins.hooks.fire('filter:privileges.admin.get', privData); }; privsAdmin.can = async function (privilege, uid) { - const [isUserAllowedTo, isAdministrator] = await Promise.all([ - helpers.isAllowedTo(privilege, uid, [0]), - user.isAdministrator(uid), - ]); - return isAdministrator || isUserAllowedTo[0]; + const [isUserAllowedTo, isAdministrator] = await Promise.all([ + helpers.isAllowedTo(privilege, uid, [0]), + user.isAdministrator(uid), + ]); + return isAdministrator || isUserAllowedTo[0]; }; privsAdmin.canGroup = async function (privilege, groupName) { - return await groups.isMember(groupName, `cid:0:privileges:groups:${privilege}`); + return await groups.isMember(groupName, `cid:0:privileges:groups:${privilege}`); }; privsAdmin.give = async function (privileges, groupName) { - await helpers.giveOrRescind(groups.join, privileges, 0, groupName); - plugins.hooks.fire('action:privileges.admin.give', { - privileges: privileges, - groupNames: Array.isArray(groupName) ? groupName : [groupName], - }); + await helpers.giveOrRescind(groups.join, privileges, 0, groupName); + plugins.hooks.fire('action:privileges.admin.give', { + privileges, + groupNames: Array.isArray(groupName) ? groupName : [groupName], + }); }; privsAdmin.rescind = async function (privileges, groupName) { - await helpers.giveOrRescind(groups.leave, privileges, 0, groupName); - plugins.hooks.fire('action:privileges.admin.rescind', { - privileges: privileges, - groupNames: Array.isArray(groupName) ? groupName : [groupName], - }); + await helpers.giveOrRescind(groups.leave, privileges, 0, groupName); + plugins.hooks.fire('action:privileges.admin.rescind', { + privileges, + groupNames: Array.isArray(groupName) ? groupName : [groupName], + }); }; privsAdmin.userPrivileges = async function (uid) { - const userPrivilegeList = await privsAdmin.getUserPrivilegeList(); - return await helpers.userOrGroupPrivileges(0, uid, userPrivilegeList); + const userPrivilegeList = await privsAdmin.getUserPrivilegeList(); + return await helpers.userOrGroupPrivileges(0, uid, userPrivilegeList); }; privsAdmin.groupPrivileges = async function (groupName) { - const groupPrivilegeList = await privsAdmin.getGroupPrivilegeList(); - return await helpers.userOrGroupPrivileges(0, groupName, groupPrivilegeList); + const groupPrivilegeList = await privsAdmin.getGroupPrivilegeList(); + return await helpers.userOrGroupPrivileges(0, groupName, groupPrivilegeList); }; diff --git a/src/privileges/categories.js b/src/privileges/categories.js index 4f8b0e6..a5b9a90 100644 --- a/src/privileges/categories.js +++ b/src/privileges/categories.js @@ -2,13 +2,12 @@ 'use strict'; const _ = require('lodash'); - const categories = require('../categories'); const user = require('../user'); const groups = require('../groups'); -const helpers = require('./helpers'); const plugins = require('../plugins'); const utils = require('../utils'); +const helpers = require('./helpers'); const privsCategories = module.exports; @@ -18,203 +17,212 @@ const privsCategories = module.exports; * in to your listener. */ const _privilegeMap = new Map([ - ['find', { label: '[[admin/manage/privileges:find-category]]' }], - ['read', { label: '[[admin/manage/privileges:access-category]]' }], - ['topics:read', { label: '[[admin/manage/privileges:access-topics]]' }], - ['topics:create', { label: '[[admin/manage/privileges:create-topics]]' }], - ['topics:reply', { label: '[[admin/manage/privileges:reply-to-topics]]' }], - ['topics:schedule', { label: '[[admin/manage/privileges:schedule-topics]]' }], - ['topics:tag', { label: '[[admin/manage/privileges:tag-topics]]' }], - ['posts:edit', { label: '[[admin/manage/privileges:edit-posts]]' }], - ['posts:history', { label: '[[admin/manage/privileges:view-edit-history]]' }], - ['posts:delete', { label: '[[admin/manage/privileges:delete-posts]]' }], - ['posts:upvote', { label: '[[admin/manage/privileges:upvote-posts]]' }], - ['posts:downvote', { label: '[[admin/manage/privileges:downvote-posts]]' }], - ['topics:delete', { label: '[[admin/manage/privileges:delete-topics]]' }], - ['posts:view_deleted', { label: '[[admin/manage/privileges:view_deleted]]' }], - ['purge', { label: '[[admin/manage/privileges:purge]]' }], - ['moderate', { label: '[[admin/manage/privileges:moderate]]' }], + ['find', {label: '[[admin/manage/privileges:find-category]]'}], + ['read', {label: '[[admin/manage/privileges:access-category]]'}], + ['topics:read', {label: '[[admin/manage/privileges:access-topics]]'}], + ['topics:create', {label: '[[admin/manage/privileges:create-topics]]'}], + ['topics:reply', {label: '[[admin/manage/privileges:reply-to-topics]]'}], + ['topics:schedule', {label: '[[admin/manage/privileges:schedule-topics]]'}], + ['topics:tag', {label: '[[admin/manage/privileges:tag-topics]]'}], + ['posts:edit', {label: '[[admin/manage/privileges:edit-posts]]'}], + ['posts:history', {label: '[[admin/manage/privileges:view-edit-history]]'}], + ['posts:delete', {label: '[[admin/manage/privileges:delete-posts]]'}], + ['posts:upvote', {label: '[[admin/manage/privileges:upvote-posts]]'}], + ['posts:downvote', {label: '[[admin/manage/privileges:downvote-posts]]'}], + ['topics:delete', {label: '[[admin/manage/privileges:delete-topics]]'}], + ['posts:view_deleted', {label: '[[admin/manage/privileges:view_deleted]]'}], + ['purge', {label: '[[admin/manage/privileges:purge]]'}], + ['moderate', {label: '[[admin/manage/privileges:moderate]]'}], ]); privsCategories.getUserPrivilegeList = async () => await plugins.hooks.fire('filter:privileges.list', Array.from(_privilegeMap.keys())); privsCategories.getGroupPrivilegeList = async () => await plugins.hooks.fire('filter:privileges.groups.list', Array.from(_privilegeMap.keys()).map(privilege => `groups:${privilege}`)); privsCategories.getPrivilegeList = async () => { - const [user, group] = await Promise.all([ - privsCategories.getUserPrivilegeList(), - privsCategories.getGroupPrivilegeList(), - ]); - return user.concat(group); + const [user, group] = await Promise.all([ + privsCategories.getUserPrivilegeList(), + privsCategories.getGroupPrivilegeList(), + ]); + return user.concat(group); }; privsCategories.init = async () => { - privsCategories._coreSize = _privilegeMap.size; - await plugins.hooks.fire('static:privileges.categories.init', { - privileges: _privilegeMap, - }); + privsCategories._coreSize = _privilegeMap.size; + await plugins.hooks.fire('static:privileges.categories.init', { + privileges: _privilegeMap, + }); }; // Method used in admin/category controller to show all users/groups with privs in that given cid privsCategories.list = async function (cid) { - let labels = Array.from(_privilegeMap.values()).map(data => data.label); - labels = await utils.promiseParallel({ - users: plugins.hooks.fire('filter:privileges.list_human', labels.slice()), - groups: plugins.hooks.fire('filter:privileges.groups.list_human', labels.slice()), - }); - - const keys = await utils.promiseParallel({ - users: privsCategories.getUserPrivilegeList(), - groups: privsCategories.getGroupPrivilegeList(), - }); - - const payload = await utils.promiseParallel({ - labels, - users: helpers.getUserPrivileges(cid, keys.users), - groups: helpers.getGroupPrivileges(cid, keys.groups), - }); - payload.keys = keys; - - payload.columnCountUserOther = payload.labels.users.length - privsCategories._coreSize; - payload.columnCountGroupOther = payload.labels.groups.length - privsCategories._coreSize; - - return payload; + let labels = Array.from(_privilegeMap.values()).map(data => data.label); + labels = await utils.promiseParallel({ + users: plugins.hooks.fire('filter:privileges.list_human', labels.slice()), + groups: plugins.hooks.fire('filter:privileges.groups.list_human', labels.slice()), + }); + + const keys = await utils.promiseParallel({ + users: privsCategories.getUserPrivilegeList(), + groups: privsCategories.getGroupPrivilegeList(), + }); + + const payload = await utils.promiseParallel({ + labels, + users: helpers.getUserPrivileges(cid, keys.users), + groups: helpers.getGroupPrivileges(cid, keys.groups), + }); + payload.keys = keys; + + payload.columnCountUserOther = payload.labels.users.length - privsCategories._coreSize; + payload.columnCountGroupOther = payload.labels.groups.length - privsCategories._coreSize; + + return payload; }; privsCategories.get = async function (cid, uid) { - const privs = [ - 'topics:create', 'topics:read', 'topics:schedule', - 'topics:tag', 'read', 'posts:view_deleted', - ]; - - const [userPrivileges, isAdministrator, isModerator] = await Promise.all([ - helpers.isAllowedTo(privs, uid, cid), - user.isAdministrator(uid), - user.isModerator(uid, cid), - ]); - - const combined = userPrivileges.map(allowed => allowed || isAdministrator); - const privData = _.zipObject(privs, combined); - const isAdminOrMod = isAdministrator || isModerator; - - return await plugins.hooks.fire('filter:privileges.categories.get', { - ...privData, - cid: cid, - uid: uid, - editable: isAdminOrMod, - view_deleted: isAdminOrMod || privData['posts:view_deleted'], - isAdminOrMod: isAdminOrMod, - }); + const privs = [ + 'topics:create', + 'topics:read', + 'topics:schedule', + 'topics:tag', + 'read', + 'posts:view_deleted', + ]; + + const [userPrivileges, isAdministrator, isModerator] = await Promise.all([ + helpers.isAllowedTo(privs, uid, cid), + user.isAdministrator(uid), + user.isModerator(uid, cid), + ]); + + const combined = userPrivileges.map(allowed => allowed || isAdministrator); + const privData = _.zipObject(privs, combined); + const isAdminOrModule = isAdministrator || isModerator; + + return await plugins.hooks.fire('filter:privileges.categories.get', { + ...privData, + cid, + uid, + editable: isAdminOrModule, + view_deleted: isAdminOrModule || privData['posts:view_deleted'], + isAdminOrMod: isAdminOrModule, + }); }; privsCategories.isAdminOrMod = async function (cid, uid) { - if (parseInt(uid, 10) <= 0) { - return false; - } - const [isAdmin, isMod] = await Promise.all([ - user.isAdministrator(uid), - user.isModerator(uid, cid), - ]); - return isAdmin || isMod; + if (Number.parseInt(uid, 10) <= 0) { + return false; + } + + const [isAdmin, isModule] = await Promise.all([ + user.isAdministrator(uid), + user.isModerator(uid, cid), + ]); + return isAdmin || isModule; }; privsCategories.isUserAllowedTo = async function (privilege, cid, uid) { - if ((Array.isArray(privilege) && !privilege.length) || (Array.isArray(cid) && !cid.length)) { - return []; - } - if (!cid) { - return false; - } - const results = await helpers.isAllowedTo(privilege, uid, Array.isArray(cid) ? cid : [cid]); - - if (Array.isArray(results) && results.length) { - return Array.isArray(cid) ? results : results[0]; - } - return false; + if ((Array.isArray(privilege) && privilege.length === 0) || (Array.isArray(cid) && cid.length === 0)) { + return []; + } + + if (!cid) { + return false; + } + + const results = await helpers.isAllowedTo(privilege, uid, Array.isArray(cid) ? cid : [cid]); + + if (Array.isArray(results) && results.length > 0) { + return Array.isArray(cid) ? results : results[0]; + } + + return false; }; privsCategories.can = async function (privilege, cid, uid) { - if (!cid) { - return false; - } - const [disabled, isAdmin, isAllowed] = await Promise.all([ - categories.getCategoryField(cid, 'disabled'), - user.isAdministrator(uid), - privsCategories.isUserAllowedTo(privilege, cid, uid), - ]); - return !disabled && (isAllowed || isAdmin); + if (!cid) { + return false; + } + + const [disabled, isAdmin, isAllowed] = await Promise.all([ + categories.getCategoryField(cid, 'disabled'), + user.isAdministrator(uid), + privsCategories.isUserAllowedTo(privilege, cid, uid), + ]); + return !disabled && (isAllowed || isAdmin); }; privsCategories.filterCids = async function (privilege, cids, uid) { - if (!Array.isArray(cids) || !cids.length) { - return []; - } - - cids = _.uniq(cids); - const [categoryData, allowedTo, isAdmin] = await Promise.all([ - categories.getCategoriesFields(cids, ['disabled']), - helpers.isAllowedTo(privilege, uid, cids), - user.isAdministrator(uid), - ]); - return cids.filter( - (cid, index) => !!cid && !categoryData[index].disabled && (allowedTo[index] || isAdmin) - ); + if (!Array.isArray(cids) || cids.length === 0) { + return []; + } + + cids = _.uniq(cids); + const [categoryData, allowedTo, isAdmin] = await Promise.all([ + categories.getCategoriesFields(cids, ['disabled']), + helpers.isAllowedTo(privilege, uid, cids), + user.isAdministrator(uid), + ]); + return cids.filter( + (cid, index) => Boolean(cid) && !categoryData[index].disabled && (allowedTo[index] || isAdmin), + ); }; privsCategories.getBase = async function (privilege, cids, uid) { - return await utils.promiseParallel({ - categories: categories.getCategoriesFields(cids, ['disabled']), - allowedTo: helpers.isAllowedTo(privilege, uid, cids), - view_deleted: helpers.isAllowedTo('posts:view_deleted', uid, cids), - view_scheduled: helpers.isAllowedTo('topics:schedule', uid, cids), - isAdmin: user.isAdministrator(uid), - }); + return await utils.promiseParallel({ + categories: categories.getCategoriesFields(cids, ['disabled']), + allowedTo: helpers.isAllowedTo(privilege, uid, cids), + view_deleted: helpers.isAllowedTo('posts:view_deleted', uid, cids), + view_scheduled: helpers.isAllowedTo('topics:schedule', uid, cids), + isAdmin: user.isAdministrator(uid), + }); }; privsCategories.filterUids = async function (privilege, cid, uids) { - if (!uids.length) { - return []; - } + if (uids.length === 0) { + return []; + } - uids = _.uniq(uids); + uids = _.uniq(uids); - const [allowedTo, isAdmins] = await Promise.all([ - helpers.isUsersAllowedTo(privilege, uids, cid), - user.isAdministrator(uids), - ]); - return uids.filter((uid, index) => allowedTo[index] || isAdmins[index]); + const [allowedTo, isAdmins] = await Promise.all([ + helpers.isUsersAllowedTo(privilege, uids, cid), + user.isAdministrator(uids), + ]); + return uids.filter((uid, index) => allowedTo[index] || isAdmins[index]); }; privsCategories.give = async function (privileges, cid, members) { - await helpers.giveOrRescind(groups.join, privileges, cid, members); - plugins.hooks.fire('action:privileges.categories.give', { - privileges: privileges, - cids: Array.isArray(cid) ? cid : [cid], - members: Array.isArray(members) ? members : [members], - }); + await helpers.giveOrRescind(groups.join, privileges, cid, members); + plugins.hooks.fire('action:privileges.categories.give', { + privileges, + cids: Array.isArray(cid) ? cid : [cid], + members: Array.isArray(members) ? members : [members], + }); }; privsCategories.rescind = async function (privileges, cid, members) { - await helpers.giveOrRescind(groups.leave, privileges, cid, members); - plugins.hooks.fire('action:privileges.categories.rescind', { - privileges: privileges, - cids: Array.isArray(cid) ? cid : [cid], - members: Array.isArray(members) ? members : [members], - }); + await helpers.giveOrRescind(groups.leave, privileges, cid, members); + plugins.hooks.fire('action:privileges.categories.rescind', { + privileges, + cids: Array.isArray(cid) ? cid : [cid], + members: Array.isArray(members) ? members : [members], + }); }; privsCategories.canMoveAllTopics = async function (currentCid, targetCid, uid) { - const [isAdmin, isModerators] = await Promise.all([ - user.isAdministrator(uid), - user.isModerator(uid, [currentCid, targetCid]), - ]); - return isAdmin || !isModerators.includes(false); + const [isAdmin, isModerators] = await Promise.all([ + user.isAdministrator(uid), + user.isModerator(uid, [currentCid, targetCid]), + ]); + return isAdmin || !isModerators.includes(false); }; privsCategories.userPrivileges = async function (cid, uid) { - const userPrivilegeList = await privsCategories.getUserPrivilegeList(); - return await helpers.userOrGroupPrivileges(cid, uid, userPrivilegeList); + const userPrivilegeList = await privsCategories.getUserPrivilegeList(); + return await helpers.userOrGroupPrivileges(cid, uid, userPrivilegeList); }; privsCategories.groupPrivileges = async function (cid, groupName) { - const groupPrivilegeList = await privsCategories.getGroupPrivilegeList(); - return await helpers.userOrGroupPrivileges(cid, groupName, groupPrivilegeList); + const groupPrivilegeList = await privsCategories.getGroupPrivilegeList(); + return await helpers.userOrGroupPrivileges(cid, groupName, groupPrivilegeList); }; diff --git a/src/privileges/global.js b/src/privileges/global.js index 79567ea..f64a540 100644 --- a/src/privileges/global.js +++ b/src/privileges/global.js @@ -2,12 +2,11 @@ 'use strict'; const _ = require('lodash'); - const user = require('../user'); const groups = require('../groups'); -const helpers = require('./helpers'); const plugins = require('../plugins'); const utils = require('../utils'); +const helpers = require('./helpers'); const privsGlobal = module.exports; @@ -17,120 +16,120 @@ const privsGlobal = module.exports; * in to your listener. */ const _privilegeMap = new Map([ - ['chat', { label: '[[admin/manage/privileges:chat]]' }], - ['upload:post:image', { label: '[[admin/manage/privileges:upload-images]]' }], - ['upload:post:file', { label: '[[admin/manage/privileges:upload-files]]' }], - ['signature', { label: '[[admin/manage/privileges:signature]]' }], - ['invite', { label: '[[admin/manage/privileges:invite]]' }], - ['group:create', { label: '[[admin/manage/privileges:allow-group-creation]]' }], - ['search:content', { label: '[[admin/manage/privileges:search-content]]' }], - ['search:users', { label: '[[admin/manage/privileges:search-users]]' }], - ['search:tags', { label: '[[admin/manage/privileges:search-tags]]' }], - ['view:users', { label: '[[admin/manage/privileges:view-users]]' }], - ['view:tags', { label: '[[admin/manage/privileges:view-tags]]' }], - ['view:groups', { label: '[[admin/manage/privileges:view-groups]]' }], - ['local:login', { label: '[[admin/manage/privileges:allow-local-login]]' }], - ['ban', { label: '[[admin/manage/privileges:ban]]' }], - ['mute', { label: '[[admin/manage/privileges:mute]]' }], - ['view:users:info', { label: '[[admin/manage/privileges:view-users-info]]' }], + ['chat', {label: '[[admin/manage/privileges:chat]]'}], + ['upload:post:image', {label: '[[admin/manage/privileges:upload-images]]'}], + ['upload:post:file', {label: '[[admin/manage/privileges:upload-files]]'}], + ['signature', {label: '[[admin/manage/privileges:signature]]'}], + ['invite', {label: '[[admin/manage/privileges:invite]]'}], + ['group:create', {label: '[[admin/manage/privileges:allow-group-creation]]'}], + ['search:content', {label: '[[admin/manage/privileges:search-content]]'}], + ['search:users', {label: '[[admin/manage/privileges:search-users]]'}], + ['search:tags', {label: '[[admin/manage/privileges:search-tags]]'}], + ['view:users', {label: '[[admin/manage/privileges:view-users]]'}], + ['view:tags', {label: '[[admin/manage/privileges:view-tags]]'}], + ['view:groups', {label: '[[admin/manage/privileges:view-groups]]'}], + ['local:login', {label: '[[admin/manage/privileges:allow-local-login]]'}], + ['ban', {label: '[[admin/manage/privileges:ban]]'}], + ['mute', {label: '[[admin/manage/privileges:mute]]'}], + ['view:users:info', {label: '[[admin/manage/privileges:view-users-info]]'}], ]); privsGlobal.getUserPrivilegeList = async () => await plugins.hooks.fire('filter:privileges.global.list', Array.from(_privilegeMap.keys())); privsGlobal.getGroupPrivilegeList = async () => await plugins.hooks.fire('filter:privileges.global.groups.list', Array.from(_privilegeMap.keys()).map(privilege => `groups:${privilege}`)); privsGlobal.getPrivilegeList = async () => { - const [user, group] = await Promise.all([ - privsGlobal.getUserPrivilegeList(), - privsGlobal.getGroupPrivilegeList(), - ]); - return user.concat(group); + const [user, group] = await Promise.all([ + privsGlobal.getUserPrivilegeList(), + privsGlobal.getGroupPrivilegeList(), + ]); + return user.concat(group); }; privsGlobal.init = async () => { - privsGlobal._coreSize = _privilegeMap.size; - await plugins.hooks.fire('static:privileges.global.init', { - privileges: _privilegeMap, - }); + privsGlobal._coreSize = _privilegeMap.size; + await plugins.hooks.fire('static:privileges.global.init', { + privileges: _privilegeMap, + }); }; privsGlobal.list = async function () { - async function getLabels() { - const labels = Array.from(_privilegeMap.values()).map(data => data.label); - return await utils.promiseParallel({ - users: plugins.hooks.fire('filter:privileges.global.list_human', labels.slice()), - groups: plugins.hooks.fire('filter:privileges.global.groups.list_human', labels.slice()), - }); - } - - const keys = await utils.promiseParallel({ - users: privsGlobal.getUserPrivilegeList(), - groups: privsGlobal.getGroupPrivilegeList(), - }); - - const payload = await utils.promiseParallel({ - labels: getLabels(), - users: helpers.getUserPrivileges(0, keys.users), - groups: helpers.getGroupPrivileges(0, keys.groups), - }); - payload.keys = keys; - - payload.columnCountUserOther = keys.users.length - privsGlobal._coreSize; - payload.columnCountGroupOther = keys.groups.length - privsGlobal._coreSize; - - return payload; + async function getLabels() { + const labels = Array.from(_privilegeMap.values()).map(data => data.label); + return await utils.promiseParallel({ + users: plugins.hooks.fire('filter:privileges.global.list_human', labels.slice()), + groups: plugins.hooks.fire('filter:privileges.global.groups.list_human', labels.slice()), + }); + } + + const keys = await utils.promiseParallel({ + users: privsGlobal.getUserPrivilegeList(), + groups: privsGlobal.getGroupPrivilegeList(), + }); + + const payload = await utils.promiseParallel({ + labels: getLabels(), + users: helpers.getUserPrivileges(0, keys.users), + groups: helpers.getGroupPrivileges(0, keys.groups), + }); + payload.keys = keys; + + payload.columnCountUserOther = keys.users.length - privsGlobal._coreSize; + payload.columnCountGroupOther = keys.groups.length - privsGlobal._coreSize; + + return payload; }; privsGlobal.get = async function (uid) { - const userPrivilegeList = await privsGlobal.getUserPrivilegeList(); - const [userPrivileges, isAdministrator] = await Promise.all([ - helpers.isAllowedTo(userPrivilegeList, uid, 0), - user.isAdministrator(uid), - ]); + const userPrivilegeList = await privsGlobal.getUserPrivilegeList(); + const [userPrivileges, isAdministrator] = await Promise.all([ + helpers.isAllowedTo(userPrivilegeList, uid, 0), + user.isAdministrator(uid), + ]); - const combined = userPrivileges.map(allowed => allowed || isAdministrator); - const privData = _.zipObject(userPrivilegeList, combined); + const combined = userPrivileges.map(allowed => allowed || isAdministrator); + const privData = _.zipObject(userPrivilegeList, combined); - return await plugins.hooks.fire('filter:privileges.global.get', privData); + return await plugins.hooks.fire('filter:privileges.global.get', privData); }; privsGlobal.can = async function (privilege, uid) { - const [isAdministrator, isUserAllowedTo] = await Promise.all([ - user.isAdministrator(uid), - helpers.isAllowedTo(privilege, uid, [0]), - ]); - return isAdministrator || isUserAllowedTo[0]; + const [isAdministrator, isUserAllowedTo] = await Promise.all([ + user.isAdministrator(uid), + helpers.isAllowedTo(privilege, uid, [0]), + ]); + return isAdministrator || isUserAllowedTo[0]; }; privsGlobal.canGroup = async function (privilege, groupName) { - return await groups.isMember(groupName, `cid:0:privileges:groups:${privilege}`); + return await groups.isMember(groupName, `cid:0:privileges:groups:${privilege}`); }; privsGlobal.filterUids = async function (privilege, uids) { - const privCategories = require('./categories'); - return await privCategories.filterUids(privilege, 0, uids); + const privCategories = require('./categories'); + return await privCategories.filterUids(privilege, 0, uids); }; privsGlobal.give = async function (privileges, groupName) { - await helpers.giveOrRescind(groups.join, privileges, 0, groupName); - plugins.hooks.fire('action:privileges.global.give', { - privileges: privileges, - groupNames: Array.isArray(groupName) ? groupName : [groupName], - }); + await helpers.giveOrRescind(groups.join, privileges, 0, groupName); + plugins.hooks.fire('action:privileges.global.give', { + privileges, + groupNames: Array.isArray(groupName) ? groupName : [groupName], + }); }; privsGlobal.rescind = async function (privileges, groupName) { - await helpers.giveOrRescind(groups.leave, privileges, 0, groupName); - plugins.hooks.fire('action:privileges.global.rescind', { - privileges: privileges, - groupNames: Array.isArray(groupName) ? groupName : [groupName], - }); + await helpers.giveOrRescind(groups.leave, privileges, 0, groupName); + plugins.hooks.fire('action:privileges.global.rescind', { + privileges, + groupNames: Array.isArray(groupName) ? groupName : [groupName], + }); }; privsGlobal.userPrivileges = async function (uid) { - const userPrivilegeList = await privsGlobal.getUserPrivilegeList(); - return await helpers.userOrGroupPrivileges(0, uid, userPrivilegeList); + const userPrivilegeList = await privsGlobal.getUserPrivilegeList(); + return await helpers.userOrGroupPrivileges(0, uid, userPrivilegeList); }; privsGlobal.groupPrivileges = async function (groupName) { - const groupPrivilegeList = await privsGlobal.getGroupPrivilegeList(); - return await helpers.userOrGroupPrivileges(0, groupName, groupPrivilegeList); + const groupPrivilegeList = await privsGlobal.getGroupPrivilegeList(); + return await helpers.userOrGroupPrivileges(0, groupName, groupPrivilegeList); }; diff --git a/src/privileges/helpers.js b/src/privileges/helpers.js index b32deb7..dbedad6 100644 --- a/src/privileges/helpers.js +++ b/src/privileges/helpers.js @@ -3,7 +3,6 @@ const _ = require('lodash'); const validator = require('validator'); - const groups = require('../groups'); const user = require('../user'); const plugins = require('../plugins'); @@ -12,181 +11,190 @@ const translator = require('../translator'); const helpers = module.exports; const uidToSystemGroup = { - 0: 'guests', - '-1': 'spiders', + 0: 'guests', + '-1': 'spiders', }; helpers.isUsersAllowedTo = async function (privilege, uids, cid) { - const [hasUserPrivilege, hasGroupPrivilege] = await Promise.all([ - groups.isMembers(uids, `cid:${cid}:privileges:${privilege}`), - groups.isMembersOfGroupList(uids, `cid:${cid}:privileges:groups:${privilege}`), - ]); - const allowed = uids.map((uid, index) => hasUserPrivilege[index] || hasGroupPrivilege[index]); - const result = await plugins.hooks.fire('filter:privileges:isUsersAllowedTo', { allowed: allowed, privilege: privilege, uids: uids, cid: cid }); - return result.allowed; + const [hasUserPrivilege, hasGroupPrivilege] = await Promise.all([ + groups.isMembers(uids, `cid:${cid}:privileges:${privilege}`), + groups.isMembersOfGroupList(uids, `cid:${cid}:privileges:groups:${privilege}`), + ]); + const allowed = uids.map((uid, index) => hasUserPrivilege[index] || hasGroupPrivilege[index]); + const result = await plugins.hooks.fire('filter:privileges:isUsersAllowedTo', { + allowed, privilege, uids, cid, + }); + return result.allowed; }; helpers.isAllowedTo = async function (privilege, uidOrGroupName, cid) { - let allowed; - if (Array.isArray(privilege) && !Array.isArray(cid)) { - allowed = await isAllowedToPrivileges(privilege, uidOrGroupName, cid); - } else if (Array.isArray(cid) && !Array.isArray(privilege)) { - allowed = await isAllowedToCids(privilege, uidOrGroupName, cid); - } - if (allowed) { - ({ allowed } = await plugins.hooks.fire('filter:privileges:isAllowedTo', { allowed: allowed, privilege: privilege, uid: uidOrGroupName, cid: cid })); - return allowed; - } - throw new Error('[[error:invalid-data]]'); + let allowed; + if (Array.isArray(privilege) && !Array.isArray(cid)) { + allowed = await isAllowedToPrivileges(privilege, uidOrGroupName, cid); + } else if (Array.isArray(cid) && !Array.isArray(privilege)) { + allowed = await isAllowedToCids(privilege, uidOrGroupName, cid); + } + + if (allowed) { + ({allowed} = await plugins.hooks.fire('filter:privileges:isAllowedTo', { + allowed, privilege, uid: uidOrGroupName, cid, + })); + return allowed; + } + + throw new Error('[[error:invalid-data]]'); }; async function isAllowedToCids(privilege, uidOrGroupName, cids) { - if (!privilege) { - return cids.map(() => false); - } + if (!privilege) { + return cids.map(() => false); + } - const groupKeys = cids.map(cid => `cid:${cid}:privileges:groups:${privilege}`); + const groupKeys = cids.map(cid => `cid:${cid}:privileges:groups:${privilege}`); - // Group handling - if (isNaN(parseInt(uidOrGroupName, 10)) && (uidOrGroupName || '').length) { - return await checkIfAllowedGroup(uidOrGroupName, groupKeys); - } + // Group handling + if (isNaN(Number.parseInt(uidOrGroupName, 10)) && (uidOrGroupName || '').length > 0) { + return await checkIfAllowedGroup(uidOrGroupName, groupKeys); + } - // User handling - if (parseInt(uidOrGroupName, 10) <= 0) { - return await isSystemGroupAllowedToCids(privilege, uidOrGroupName, cids); - } + // User handling + if (Number.parseInt(uidOrGroupName, 10) <= 0) { + return await isSystemGroupAllowedToCids(privilege, uidOrGroupName, cids); + } - const userKeys = cids.map(cid => `cid:${cid}:privileges:${privilege}`); - return await checkIfAllowedUser(uidOrGroupName, userKeys, groupKeys); + const userKeys = cids.map(cid => `cid:${cid}:privileges:${privilege}`); + return await checkIfAllowedUser(uidOrGroupName, userKeys, groupKeys); } async function isAllowedToPrivileges(privileges, uidOrGroupName, cid) { - const groupKeys = privileges.map(privilege => `cid:${cid}:privileges:groups:${privilege}`); - // Group handling - if (isNaN(parseInt(uidOrGroupName, 10)) && (uidOrGroupName || '').length) { - return await checkIfAllowedGroup(uidOrGroupName, groupKeys); - } - - // User handling - if (parseInt(uidOrGroupName, 10) <= 0) { - return await isSystemGroupAllowedToPrivileges(privileges, uidOrGroupName, cid); - } - - const userKeys = privileges.map(privilege => `cid:${cid}:privileges:${privilege}`); - return await checkIfAllowedUser(uidOrGroupName, userKeys, groupKeys); + const groupKeys = privileges.map(privilege => `cid:${cid}:privileges:groups:${privilege}`); + // Group handling + if (isNaN(Number.parseInt(uidOrGroupName, 10)) && (uidOrGroupName || '').length > 0) { + return await checkIfAllowedGroup(uidOrGroupName, groupKeys); + } + + // User handling + if (Number.parseInt(uidOrGroupName, 10) <= 0) { + return await isSystemGroupAllowedToPrivileges(privileges, uidOrGroupName, cid); + } + + const userKeys = privileges.map(privilege => `cid:${cid}:privileges:${privilege}`); + return await checkIfAllowedUser(uidOrGroupName, userKeys, groupKeys); } async function checkIfAllowedUser(uid, userKeys, groupKeys) { - const [hasUserPrivilege, hasGroupPrivilege] = await Promise.all([ - groups.isMemberOfGroups(uid, userKeys), - groups.isMemberOfGroupsList(uid, groupKeys), - ]); - return userKeys.map((key, index) => hasUserPrivilege[index] || hasGroupPrivilege[index]); + const [hasUserPrivilege, hasGroupPrivilege] = await Promise.all([ + groups.isMemberOfGroups(uid, userKeys), + groups.isMemberOfGroupsList(uid, groupKeys), + ]); + return userKeys.map((key, index) => hasUserPrivilege[index] || hasGroupPrivilege[index]); } async function checkIfAllowedGroup(groupName, groupKeys) { - const sets = await Promise.all([ - groups.isMemberOfGroups(groupName, groupKeys), - groups.isMemberOfGroups('registered-users', groupKeys), - ]); - return groupKeys.map((key, index) => sets[0][index] || sets[1][index]); + const sets = await Promise.all([ + groups.isMemberOfGroups(groupName, groupKeys), + groups.isMemberOfGroups('registered-users', groupKeys), + ]); + return groupKeys.map((key, index) => sets[0][index] || sets[1][index]); } async function isSystemGroupAllowedToCids(privilege, uid, cids) { - const groupKeys = cids.map(cid => `cid:${cid}:privileges:groups:${privilege}`); - return await groups.isMemberOfGroups(uidToSystemGroup[uid], groupKeys); + const groupKeys = cids.map(cid => `cid:${cid}:privileges:groups:${privilege}`); + return await groups.isMemberOfGroups(uidToSystemGroup[uid], groupKeys); } async function isSystemGroupAllowedToPrivileges(privileges, uid, cid) { - const groupKeys = privileges.map(privilege => `cid:${cid}:privileges:groups:${privilege}`); - return await groups.isMemberOfGroups(uidToSystemGroup[uid], groupKeys); + const groupKeys = privileges.map(privilege => `cid:${cid}:privileges:groups:${privilege}`); + return await groups.isMemberOfGroups(uidToSystemGroup[uid], groupKeys); } helpers.getUserPrivileges = async function (cid, userPrivileges) { - let memberSets = await groups.getMembersOfGroups(userPrivileges.map(privilege => `cid:${cid}:privileges:${privilege}`)); - memberSets = memberSets.map(set => set.map(uid => parseInt(uid, 10))); + let memberSets = await groups.getMembersOfGroups(userPrivileges.map(privilege => `cid:${cid}:privileges:${privilege}`)); + memberSets = memberSets.map(set => set.map(uid => Number.parseInt(uid, 10))); - const members = _.uniq(_.flatten(memberSets)); - const memberData = await user.getUsersFields(members, ['picture', 'username', 'banned']); + const members = _.uniq(memberSets.flat()); + const memberData = await user.getUsersFields(members, ['picture', 'username', 'banned']); - memberData.forEach((member) => { - member.privileges = {}; - for (let x = 0, numPrivs = userPrivileges.length; x < numPrivs; x += 1) { - member.privileges[userPrivileges[x]] = memberSets[x].includes(parseInt(member.uid, 10)); - } - }); + for (const member of memberData) { + member.privileges = {}; + for (let x = 0, numberPrivs = userPrivileges.length; x < numberPrivs; x += 1) { + member.privileges[userPrivileges[x]] = memberSets[x].includes(Number.parseInt(member.uid, 10)); + } + } - return memberData; + return memberData; }; helpers.getGroupPrivileges = async function (cid, groupPrivileges) { - const [memberSets, allGroupNames] = await Promise.all([ - groups.getMembersOfGroups(groupPrivileges.map(privilege => `cid:${cid}:privileges:${privilege}`)), - groups.getGroups('groups:createtime', 0, -1), - ]); - - const uniqueGroups = _.uniq(_.flatten(memberSets)); - - let groupNames = allGroupNames.filter(groupName => !groupName.includes(':privileges:') && uniqueGroups.includes(groupName)); - - groupNames = groups.ephemeralGroups.concat(groupNames); - moveToFront(groupNames, groups.BANNED_USERS); - moveToFront(groupNames, 'Global Moderators'); - moveToFront(groupNames, 'unverified-users'); - moveToFront(groupNames, 'verified-users'); - moveToFront(groupNames, 'registered-users'); - - const adminIndex = groupNames.indexOf('administrators'); - if (adminIndex !== -1) { - groupNames.splice(adminIndex, 1); - } - const groupData = await groups.getGroupsFields(groupNames, ['private', 'system']); - const memberData = groupNames.map((member, index) => { - const memberPrivs = {}; - - for (let x = 0, numPrivs = groupPrivileges.length; x < numPrivs; x += 1) { - memberPrivs[groupPrivileges[x]] = memberSets[x].includes(member); - } - return { - name: validator.escape(member), - nameEscaped: translator.escape(validator.escape(member)), - privileges: memberPrivs, - isPrivate: groupData[index] && !!groupData[index].private, - isSystem: groupData[index] && !!groupData[index].system, - }; - }); - return memberData; + const [memberSets, allGroupNames] = await Promise.all([ + groups.getMembersOfGroups(groupPrivileges.map(privilege => `cid:${cid}:privileges:${privilege}`)), + groups.getGroups('groups:createtime', 0, -1), + ]); + + const uniqueGroups = _.uniq(memberSets.flat()); + + let groupNames = allGroupNames.filter(groupName => !groupName.includes(':privileges:') && uniqueGroups.includes(groupName)); + + groupNames = groups.ephemeralGroups.concat(groupNames); + moveToFront(groupNames, groups.BANNED_USERS); + moveToFront(groupNames, 'Global Moderators'); + moveToFront(groupNames, 'unverified-users'); + moveToFront(groupNames, 'verified-users'); + moveToFront(groupNames, 'registered-users'); + + const adminIndex = groupNames.indexOf('administrators'); + if (adminIndex !== -1) { + groupNames.splice(adminIndex, 1); + } + + const groupData = await groups.getGroupsFields(groupNames, ['private', 'system']); + const memberData = groupNames.map((member, index) => { + const memberPrivs = {}; + + for (let x = 0, numberPrivs = groupPrivileges.length; x < numberPrivs; x += 1) { + memberPrivs[groupPrivileges[x]] = memberSets[x].includes(member); + } + + return { + name: validator.escape(member), + nameEscaped: translator.escape(validator.escape(member)), + privileges: memberPrivs, + isPrivate: groupData[index] && Boolean(groupData[index].private), + isSystem: groupData[index] && Boolean(groupData[index].system), + }; + }); + return memberData; }; function moveToFront(groupNames, groupToMove) { - const index = groupNames.indexOf(groupToMove); - if (index !== -1) { - groupNames.splice(0, 0, groupNames.splice(index, 1)[0]); - } else { - groupNames.unshift(groupToMove); - } + const index = groupNames.indexOf(groupToMove); + if (index === -1) { + groupNames.unshift(groupToMove); + } else { + groupNames.splice(0, 0, groupNames.splice(index, 1)[0]); + } } helpers.giveOrRescind = async function (method, privileges, cids, members) { - members = Array.isArray(members) ? members : [members]; - cids = Array.isArray(cids) ? cids : [cids]; - for (const member of members) { - const groupKeys = []; - cids.forEach((cid) => { - privileges.forEach((privilege) => { - groupKeys.push(`cid:${cid}:privileges:${privilege}`); - }); - }); - /* eslint-disable no-await-in-loop */ - await method(groupKeys, member); - } + members = Array.isArray(members) ? members : [members]; + cids = Array.isArray(cids) ? cids : [cids]; + for (const member of members) { + const groupKeys = []; + for (const cid of cids) { + for (const privilege of privileges) { + groupKeys.push(`cid:${cid}:privileges:${privilege}`); + } + } + + /* eslint-disable no-await-in-loop */ + await method(groupKeys, member); + } }; helpers.userOrGroupPrivileges = async function (cid, uidOrGroup, privilegeList) { - const groupNames = privilegeList.map(privilege => `cid:${cid}:privileges:${privilege}`); - const isMembers = await groups.isMemberOfGroups(uidOrGroup, groupNames); - return _.zipObject(privilegeList, isMembers); + const groupNames = privilegeList.map(privilege => `cid:${cid}:privileges:${privilege}`); + const isMembers = await groups.isMemberOfGroups(uidOrGroup, groupNames); + return _.zipObject(privilegeList, isMembers); }; require('../promisify')(helpers); diff --git a/src/privileges/index.js b/src/privileges/index.js index 0fddedc..e399e25 100644 --- a/src/privileges/index.js +++ b/src/privileges/index.js @@ -9,9 +9,9 @@ privileges.posts = require('./posts'); privileges.users = require('./users'); privileges.init = async () => { - await privileges.global.init(); - await privileges.admin.init(); - await privileges.categories.init(); + await privileges.global.init(); + await privileges.admin.init(); + await privileges.categories.init(); }; require('../promisify')(privileges); diff --git a/src/privileges/posts.js b/src/privileges/posts.js index e46b749..b3b0298 100644 --- a/src/privileges/posts.js +++ b/src/privileges/posts.js @@ -2,233 +2,238 @@ 'use strict'; const _ = require('lodash'); - const meta = require('../meta'); const posts = require('../posts'); const topics = require('../topics'); const user = require('../user'); -const helpers = require('./helpers'); const plugins = require('../plugins'); const utils = require('../utils'); +const helpers = require('./helpers'); const privsCategories = require('./categories'); const privsTopics = require('./topics'); const privsPosts = module.exports; privsPosts.get = async function (pids, uid) { - if (!Array.isArray(pids) || !pids.length) { - return []; - } - const cids = await posts.getCidsByPids(pids); - const uniqueCids = _.uniq(cids); - - const results = await utils.promiseParallel({ - isAdmin: user.isAdministrator(uid), - isModerator: user.isModerator(uid, uniqueCids), - isOwner: posts.isOwner(pids, uid), - 'topics:read': helpers.isAllowedTo('topics:read', uid, uniqueCids), - read: helpers.isAllowedTo('read', uid, uniqueCids), - 'posts:edit': helpers.isAllowedTo('posts:edit', uid, uniqueCids), - 'posts:history': helpers.isAllowedTo('posts:history', uid, uniqueCids), - 'posts:view_deleted': helpers.isAllowedTo('posts:view_deleted', uid, uniqueCids), - }); - - const isModerator = _.zipObject(uniqueCids, results.isModerator); - const privData = {}; - privData['topics:read'] = _.zipObject(uniqueCids, results['topics:read']); - privData.read = _.zipObject(uniqueCids, results.read); - privData['posts:edit'] = _.zipObject(uniqueCids, results['posts:edit']); - privData['posts:history'] = _.zipObject(uniqueCids, results['posts:history']); - privData['posts:view_deleted'] = _.zipObject(uniqueCids, results['posts:view_deleted']); - - const privileges = cids.map((cid, i) => { - const isAdminOrMod = results.isAdmin || isModerator[cid]; - const editable = (privData['posts:edit'][cid] && (results.isOwner[i] || results.isModerator)) || results.isAdmin; - const viewDeletedPosts = results.isOwner[i] || privData['posts:view_deleted'][cid] || results.isAdmin; - const viewHistory = results.isOwner[i] || privData['posts:history'][cid] || results.isAdmin; - - return { - editable: editable, - move: isAdminOrMod, - isAdminOrMod: isAdminOrMod, - 'topics:read': privData['topics:read'][cid] || results.isAdmin, - read: privData.read[cid] || results.isAdmin, - 'posts:history': viewHistory, - 'posts:view_deleted': viewDeletedPosts, - }; - }); - - return privileges; + if (!Array.isArray(pids) || pids.length === 0) { + return []; + } + + const cids = await posts.getCidsByPids(pids); + const uniqueCids = _.uniq(cids); + + const results = await utils.promiseParallel({ + isAdmin: user.isAdministrator(uid), + isModerator: user.isModerator(uid, uniqueCids), + isOwner: posts.isOwner(pids, uid), + 'topics:read': helpers.isAllowedTo('topics:read', uid, uniqueCids), + read: helpers.isAllowedTo('read', uid, uniqueCids), + 'posts:edit': helpers.isAllowedTo('posts:edit', uid, uniqueCids), + 'posts:history': helpers.isAllowedTo('posts:history', uid, uniqueCids), + 'posts:view_deleted': helpers.isAllowedTo('posts:view_deleted', uid, uniqueCids), + }); + + const isModerator = _.zipObject(uniqueCids, results.isModerator); + const privData = {}; + privData['topics:read'] = _.zipObject(uniqueCids, results['topics:read']); + privData.read = _.zipObject(uniqueCids, results.read); + privData['posts:edit'] = _.zipObject(uniqueCids, results['posts:edit']); + privData['posts:history'] = _.zipObject(uniqueCids, results['posts:history']); + privData['posts:view_deleted'] = _.zipObject(uniqueCids, results['posts:view_deleted']); + + const privileges = cids.map((cid, i) => { + const isAdminOrModule = results.isAdmin || isModerator[cid]; + const editable = (privData['posts:edit'][cid] && (results.isOwner[i] || results.isModerator)) || results.isAdmin; + const viewDeletedPosts = results.isOwner[i] || privData['posts:view_deleted'][cid] || results.isAdmin; + const viewHistory = results.isOwner[i] || privData['posts:history'][cid] || results.isAdmin; + + return { + editable, + move: isAdminOrModule, + isAdminOrMod: isAdminOrModule, + 'topics:read': privData['topics:read'][cid] || results.isAdmin, + read: privData.read[cid] || results.isAdmin, + 'posts:history': viewHistory, + 'posts:view_deleted': viewDeletedPosts, + }; + }); + + return privileges; }; privsPosts.can = async function (privilege, pid, uid) { - const cid = await posts.getCidByPid(pid); - return await privsCategories.can(privilege, cid, uid); + const cid = await posts.getCidByPid(pid); + return await privsCategories.can(privilege, cid, uid); }; privsPosts.filter = async function (privilege, pids, uid) { - if (!Array.isArray(pids) || !pids.length) { - return []; - } - - pids = _.uniq(pids); - const postData = await posts.getPostsFields(pids, ['uid', 'tid', 'deleted']); - const tids = _.uniq(postData.map(post => post && post.tid).filter(Boolean)); - const topicData = await topics.getTopicsFields(tids, ['deleted', 'scheduled', 'cid']); - - const tidToTopic = _.zipObject(tids, topicData); - - let cids = postData.map((post, index) => { - if (post) { - post.pid = pids[index]; - post.topic = tidToTopic[post.tid]; - } - return tidToTopic[post.tid] && tidToTopic[post.tid].cid; - }).filter(cid => parseInt(cid, 10)); - - cids = _.uniq(cids); - - const results = await privsCategories.getBase(privilege, cids, uid); - const allowedCids = cids.filter((cid, index) => !results.categories[index].disabled && - (results.allowedTo[index] || results.isAdmin)); - - const cidsSet = new Set(allowedCids); - const canViewDeleted = _.zipObject(cids, results.view_deleted); - const canViewScheduled = _.zipObject(cids, results.view_scheduled); - - pids = postData.filter(post => ( - post.topic && - cidsSet.has(post.topic.cid) && - (privsTopics.canViewDeletedScheduled({ - deleted: post.topic.deleted || post.deleted, - scheduled: post.topic.scheduled, + if (!Array.isArray(pids) || pids.length === 0) { + return []; + } + + pids = _.uniq(pids); + const postData = await posts.getPostsFields(pids, ['uid', 'tid', 'deleted']); + const tids = _.uniq(postData.map(post => post && post.tid).filter(Boolean)); + const topicData = await topics.getTopicsFields(tids, ['deleted', 'scheduled', 'cid']); + + const tidToTopic = _.zipObject(tids, topicData); + + let cids = postData.map((post, index) => { + if (post) { + post.pid = pids[index]; + post.topic = tidToTopic[post.tid]; + } + + return tidToTopic[post.tid] && tidToTopic[post.tid].cid; + }).filter(cid => Number.parseInt(cid, 10)); + + cids = _.uniq(cids); + + const results = await privsCategories.getBase(privilege, cids, uid); + const allowedCids = cids.filter((cid, index) => !results.categories[index].disabled + && (results.allowedTo[index] || results.isAdmin)); + + const cidsSet = new Set(allowedCids); + const canViewDeleted = _.zipObject(cids, results.view_deleted); + const canViewScheduled = _.zipObject(cids, results.view_scheduled); + + pids = postData.filter(post => ( + post.topic + && cidsSet.has(post.topic.cid) + && (privsTopics.canViewDeletedScheduled({ + deleted: post.topic.deleted || post.deleted, + scheduled: post.topic.scheduled, }, {}, canViewDeleted[post.topic.cid], canViewScheduled[post.topic.cid]) || results.isAdmin) - )).map(post => post.pid); + )).map(post => post.pid); - const data = await plugins.hooks.fire('filter:privileges.posts.filter', { - privilege: privilege, - uid: uid, - pids: pids, - }); + const data = await plugins.hooks.fire('filter:privileges.posts.filter', { + privilege, + uid, + pids, + }); - return data ? data.pids : null; + return data ? data.pids : null; }; privsPosts.canEdit = async function (pid, uid) { - const results = await utils.promiseParallel({ - isAdmin: user.isAdministrator(uid), - isMod: posts.isModerator([pid], uid), - owner: posts.isOwner(pid, uid), - edit: privsPosts.can('posts:edit', pid, uid), - postData: posts.getPostFields(pid, ['tid', 'timestamp', 'deleted', 'deleterUid']), - userData: user.getUserFields(uid, ['reputation']), - }); - - results.isMod = results.isMod[0]; - if (results.isAdmin) { - return { flag: true }; - } - - if ( - !results.isMod && - meta.config.postEditDuration && - (Date.now() - results.postData.timestamp > meta.config.postEditDuration * 1000) - ) { - return { flag: false, message: `[[error:post-edit-duration-expired, ${meta.config.postEditDuration}]]` }; - } - if ( - !results.isMod && - meta.config.newbiePostEditDuration > 0 && - meta.config.newbiePostDelayThreshold > results.userData.reputation && - Date.now() - results.postData.timestamp > meta.config.newbiePostEditDuration * 1000 - ) { - return { flag: false, message: `[[error:post-edit-duration-expired, ${meta.config.newbiePostEditDuration}]]` }; - } - - const isLocked = await topics.isLocked(results.postData.tid); - if (!results.isMod && isLocked) { - return { flag: false, message: '[[error:topic-locked]]' }; - } - - if (!results.isMod && results.postData.deleted && parseInt(uid, 10) !== parseInt(results.postData.deleterUid, 10)) { - return { flag: false, message: '[[error:post-deleted]]' }; - } - - results.pid = parseInt(pid, 10); - results.uid = uid; - - const result = await plugins.hooks.fire('filter:privileges.posts.edit', results); - return { flag: result.edit && (result.owner || result.isMod), message: '[[error:no-privileges]]' }; + const results = await utils.promiseParallel({ + isAdmin: user.isAdministrator(uid), + isMod: posts.isModerator([pid], uid), + owner: posts.isOwner(pid, uid), + edit: privsPosts.can('posts:edit', pid, uid), + postData: posts.getPostFields(pid, ['tid', 'timestamp', 'deleted', 'deleterUid']), + userData: user.getUserFields(uid, ['reputation']), + }); + + results.isMod = results.isMod[0]; + if (results.isAdmin) { + return {flag: true}; + } + + if ( + !results.isMod + && meta.config.postEditDuration + && (Date.now() - results.postData.timestamp > meta.config.postEditDuration * 1000) + ) { + return {flag: false, message: `[[error:post-edit-duration-expired, ${meta.config.postEditDuration}]]`}; + } + + if ( + !results.isMod + && meta.config.newbiePostEditDuration > 0 + && meta.config.newbiePostDelayThreshold > results.userData.reputation + && Date.now() - results.postData.timestamp > meta.config.newbiePostEditDuration * 1000 + ) { + return {flag: false, message: `[[error:post-edit-duration-expired, ${meta.config.newbiePostEditDuration}]]`}; + } + + const isLocked = await topics.isLocked(results.postData.tid); + if (!results.isMod && isLocked) { + return {flag: false, message: '[[error:topic-locked]]'}; + } + + if (!results.isMod && results.postData.deleted && Number.parseInt(uid, 10) !== Number.parseInt(results.postData.deleterUid, 10)) { + return {flag: false, message: '[[error:post-deleted]]'}; + } + + results.pid = Number.parseInt(pid, 10); + results.uid = uid; + + const result = await plugins.hooks.fire('filter:privileges.posts.edit', results); + return {flag: result.edit && (result.owner || result.isMod), message: '[[error:no-privileges]]'}; }; privsPosts.canDelete = async function (pid, uid) { - const postData = await posts.getPostFields(pid, ['uid', 'tid', 'timestamp', 'deleterUid']); - const results = await utils.promiseParallel({ - isAdmin: user.isAdministrator(uid), - isMod: posts.isModerator([pid], uid), - isLocked: topics.isLocked(postData.tid), - isOwner: posts.isOwner(pid, uid), - 'posts:delete': privsPosts.can('posts:delete', pid, uid), - }); - results.isMod = results.isMod[0]; - if (results.isAdmin) { - return { flag: true }; - } - - if (!results.isMod && results.isLocked) { - return { flag: false, message: '[[error:topic-locked]]' }; - } - - const { postDeleteDuration } = meta.config; - if (!results.isMod && postDeleteDuration && (Date.now() - postData.timestamp > postDeleteDuration * 1000)) { - return { flag: false, message: `[[error:post-delete-duration-expired, ${meta.config.postDeleteDuration}]]` }; - } - const { deleterUid } = postData; - const flag = results['posts:delete'] && ((results.isOwner && (deleterUid === 0 || deleterUid === postData.uid)) || results.isMod); - return { flag: flag, message: '[[error:no-privileges]]' }; + const postData = await posts.getPostFields(pid, ['uid', 'tid', 'timestamp', 'deleterUid']); + const results = await utils.promiseParallel({ + isAdmin: user.isAdministrator(uid), + isMod: posts.isModerator([pid], uid), + isLocked: topics.isLocked(postData.tid), + isOwner: posts.isOwner(pid, uid), + 'posts:delete': privsPosts.can('posts:delete', pid, uid), + }); + results.isMod = results.isMod[0]; + if (results.isAdmin) { + return {flag: true}; + } + + if (!results.isMod && results.isLocked) { + return {flag: false, message: '[[error:topic-locked]]'}; + } + + const {postDeleteDuration} = meta.config; + if (!results.isMod && postDeleteDuration && (Date.now() - postData.timestamp > postDeleteDuration * 1000)) { + return {flag: false, message: `[[error:post-delete-duration-expired, ${meta.config.postDeleteDuration}]]`}; + } + + const {deleterUid} = postData; + const flag = results['posts:delete'] && ((results.isOwner && (deleterUid === 0 || deleterUid === postData.uid)) || results.isMod); + return {flag, message: '[[error:no-privileges]]'}; }; privsPosts.canFlag = async function (pid, uid) { - const targetUid = await posts.getPostField(pid, 'uid'); - const [userReputation, isAdminOrModerator, targetPrivileged, reporterPrivileged] = await Promise.all([ - user.getUserField(uid, 'reputation'), - isAdminOrMod(pid, uid), - user.isPrivileged(targetUid), - user.isPrivileged(uid), - ]); - const minimumReputation = meta.config['min:rep:flag']; - let canFlag = isAdminOrModerator || (userReputation >= minimumReputation); - - if (targetPrivileged && !reporterPrivileged) { - canFlag = false; - } - - return { flag: canFlag }; + const targetUid = await posts.getPostField(pid, 'uid'); + const [userReputation, isAdminOrModerator, targetPrivileged, reporterPrivileged] = await Promise.all([ + user.getUserField(uid, 'reputation'), + isAdminOrModule(pid, uid), + user.isPrivileged(targetUid), + user.isPrivileged(uid), + ]); + const minimumReputation = meta.config['min:rep:flag']; + let canFlag = isAdminOrModerator || (userReputation >= minimumReputation); + + if (targetPrivileged && !reporterPrivileged) { + canFlag = false; + } + + return {flag: canFlag}; }; privsPosts.canMove = async function (pid, uid) { - const isMain = await posts.isMain(pid); - if (isMain) { - throw new Error('[[error:cant-move-mainpost]]'); - } - return await isAdminOrMod(pid, uid); + const isMain = await posts.isMain(pid); + if (isMain) { + throw new Error('[[error:cant-move-mainpost]]'); + } + + return await isAdminOrModule(pid, uid); }; privsPosts.canPurge = async function (pid, uid) { - const cid = await posts.getCidByPid(pid); - const results = await utils.promiseParallel({ - purge: privsCategories.isUserAllowedTo('purge', cid, uid), - owner: posts.isOwner(pid, uid), - isAdmin: user.isAdministrator(uid), - isModerator: user.isModerator(uid, cid), - }); - return (results.purge && (results.owner || results.isModerator)) || results.isAdmin; + const cid = await posts.getCidByPid(pid); + const results = await utils.promiseParallel({ + purge: privsCategories.isUserAllowedTo('purge', cid, uid), + owner: posts.isOwner(pid, uid), + isAdmin: user.isAdministrator(uid), + isModerator: user.isModerator(uid, cid), + }); + return (results.purge && (results.owner || results.isModerator)) || results.isAdmin; }; -async function isAdminOrMod(pid, uid) { - if (parseInt(uid, 10) <= 0) { - return false; - } - const cid = await posts.getCidByPid(pid); - return await privsCategories.isAdminOrMod(cid, uid); +async function isAdminOrModule(pid, uid) { + if (Number.parseInt(uid, 10) <= 0) { + return false; + } + + const cid = await posts.getCidByPid(pid); + return await privsCategories.isAdminOrMod(cid, uid); } diff --git a/src/privileges/topics.js b/src/privileges/topics.js index 6523c81..9f0efb1 100644 --- a/src/privileges/topics.js +++ b/src/privileges/topics.js @@ -2,191 +2,202 @@ 'use strict'; const _ = require('lodash'); - const meta = require('../meta'); const topics = require('../topics'); const user = require('../user'); -const helpers = require('./helpers'); const categories = require('../categories'); const plugins = require('../plugins'); +const helpers = require('./helpers'); const privsCategories = require('./categories'); const privsTopics = module.exports; privsTopics.get = async function (tid, uid) { - uid = parseInt(uid, 10); - - const privs = [ - 'topics:reply', 'topics:read', 'topics:schedule', 'topics:tag', - 'topics:delete', 'posts:edit', 'posts:history', - 'posts:delete', 'posts:view_deleted', 'read', 'purge', - ]; - const topicData = await topics.getTopicFields(tid, ['cid', 'uid', 'locked', 'deleted', 'scheduled']); - const [userPrivileges, isAdministrator, isModerator, disabled] = await Promise.all([ - helpers.isAllowedTo(privs, uid, topicData.cid), - user.isAdministrator(uid), - user.isModerator(uid, topicData.cid), - categories.getCategoryField(topicData.cid, 'disabled'), - ]); - const privData = _.zipObject(privs, userPrivileges); - const isOwner = uid > 0 && uid === topicData.uid; - const isAdminOrMod = isAdministrator || isModerator; - const editable = isAdminOrMod; - const deletable = (privData['topics:delete'] && (isOwner || isModerator)) || isAdministrator; - const mayReply = privsTopics.canViewDeletedScheduled(topicData, {}, false, privData['topics:schedule']); - - return await plugins.hooks.fire('filter:privileges.topics.get', { - 'topics:reply': (privData['topics:reply'] && ((!topicData.locked && mayReply) || isModerator)) || isAdministrator, - 'topics:read': privData['topics:read'] || isAdministrator, - 'topics:schedule': privData['topics:schedule'] || isAdministrator, - 'topics:tag': privData['topics:tag'] || isAdministrator, - 'topics:delete': (privData['topics:delete'] && (isOwner || isModerator)) || isAdministrator, - 'posts:edit': (privData['posts:edit'] && (!topicData.locked || isModerator)) || isAdministrator, - 'posts:history': privData['posts:history'] || isAdministrator, - 'posts:delete': (privData['posts:delete'] && (!topicData.locked || isModerator)) || isAdministrator, - 'posts:view_deleted': privData['posts:view_deleted'] || isAdministrator, - read: privData.read || isAdministrator, - purge: (privData.purge && (isOwner || isModerator)) || isAdministrator, - - view_thread_tools: editable || deletable, - editable: editable, - deletable: deletable, - view_deleted: isAdminOrMod || isOwner || privData['posts:view_deleted'], - view_scheduled: privData['topics:schedule'] || isAdministrator, - isAdminOrMod: isAdminOrMod, - disabled: disabled, - tid: tid, - uid: uid, - }); + uid = Number.parseInt(uid, 10); + + const privs = [ + 'topics:reply', + 'topics:read', + 'topics:schedule', + 'topics:tag', + 'topics:delete', + 'posts:edit', + 'posts:history', + 'posts:delete', + 'posts:view_deleted', + 'read', + 'purge', + ]; + const topicData = await topics.getTopicFields(tid, ['cid', 'uid', 'locked', 'deleted', 'scheduled']); + const [userPrivileges, isAdministrator, isModerator, disabled] = await Promise.all([ + helpers.isAllowedTo(privs, uid, topicData.cid), + user.isAdministrator(uid), + user.isModerator(uid, topicData.cid), + categories.getCategoryField(topicData.cid, 'disabled'), + ]); + const privData = _.zipObject(privs, userPrivileges); + const isOwner = uid > 0 && uid === topicData.uid; + const isAdminOrModule = isAdministrator || isModerator; + const editable = isAdminOrModule; + const deletable = (privData['topics:delete'] && (isOwner || isModerator)) || isAdministrator; + const mayReply = privsTopics.canViewDeletedScheduled(topicData, {}, false, privData['topics:schedule']); + + return await plugins.hooks.fire('filter:privileges.topics.get', { + 'topics:reply': (privData['topics:reply'] && ((!topicData.locked && mayReply) || isModerator)) || isAdministrator, + 'topics:read': privData['topics:read'] || isAdministrator, + 'topics:schedule': privData['topics:schedule'] || isAdministrator, + 'topics:tag': privData['topics:tag'] || isAdministrator, + 'topics:delete': (privData['topics:delete'] && (isOwner || isModerator)) || isAdministrator, + 'posts:edit': (privData['posts:edit'] && (!topicData.locked || isModerator)) || isAdministrator, + 'posts:history': privData['posts:history'] || isAdministrator, + 'posts:delete': (privData['posts:delete'] && (!topicData.locked || isModerator)) || isAdministrator, + 'posts:view_deleted': privData['posts:view_deleted'] || isAdministrator, + read: privData.read || isAdministrator, + purge: (privData.purge && (isOwner || isModerator)) || isAdministrator, + + view_thread_tools: editable || deletable, + editable, + deletable, + view_deleted: isAdminOrModule || isOwner || privData['posts:view_deleted'], + view_scheduled: privData['topics:schedule'] || isAdministrator, + isAdminOrMod: isAdminOrModule, + disabled, + tid, + uid, + }); }; privsTopics.can = async function (privilege, tid, uid) { - const cid = await topics.getTopicField(tid, 'cid'); - return await privsCategories.can(privilege, cid, uid); + const cid = await topics.getTopicField(tid, 'cid'); + return await privsCategories.can(privilege, cid, uid); }; privsTopics.filterTids = async function (privilege, tids, uid) { - if (!Array.isArray(tids) || !tids.length) { - return []; - } - - const topicsData = await topics.getTopicsFields(tids, ['tid', 'cid', 'deleted', 'scheduled']); - const cids = _.uniq(topicsData.map(topic => topic.cid)); - const results = await privsCategories.getBase(privilege, cids, uid); - - const allowedCids = cids.filter((cid, index) => ( - !results.categories[index].disabled && - (results.allowedTo[index] || results.isAdmin) - )); - - const cidsSet = new Set(allowedCids); - const canViewDeleted = _.zipObject(cids, results.view_deleted); - const canViewScheduled = _.zipObject(cids, results.view_scheduled); - - tids = topicsData.filter(t => ( - cidsSet.has(t.cid) && - (results.isAdmin || privsTopics.canViewDeletedScheduled(t, {}, canViewDeleted[t.cid], canViewScheduled[t.cid])) - )).map(t => t.tid); - - const data = await plugins.hooks.fire('filter:privileges.topics.filter', { - privilege: privilege, - uid: uid, - tids: tids, - }); - return data ? data.tids : []; + if (!Array.isArray(tids) || tids.length === 0) { + return []; + } + + const topicsData = await topics.getTopicsFields(tids, ['tid', 'cid', 'deleted', 'scheduled']); + const cids = _.uniq(topicsData.map(topic => topic.cid)); + const results = await privsCategories.getBase(privilege, cids, uid); + + const allowedCids = cids.filter((cid, index) => ( + !results.categories[index].disabled + && (results.allowedTo[index] || results.isAdmin) + )); + + const cidsSet = new Set(allowedCids); + const canViewDeleted = _.zipObject(cids, results.view_deleted); + const canViewScheduled = _.zipObject(cids, results.view_scheduled); + + tids = topicsData.filter(t => ( + cidsSet.has(t.cid) + && (results.isAdmin || privsTopics.canViewDeletedScheduled(t, {}, canViewDeleted[t.cid], canViewScheduled[t.cid])) + )).map(t => t.tid); + + const data = await plugins.hooks.fire('filter:privileges.topics.filter', { + privilege, + uid, + tids, + }); + return data ? data.tids : []; }; privsTopics.filterUids = async function (privilege, tid, uids) { - if (!Array.isArray(uids) || !uids.length) { - return []; - } - - uids = _.uniq(uids); - const topicData = await topics.getTopicFields(tid, ['tid', 'cid', 'deleted', 'scheduled']); - const [disabled, allowedTo, isAdmins] = await Promise.all([ - categories.getCategoryField(topicData.cid, 'disabled'), - helpers.isUsersAllowedTo(privilege, uids, topicData.cid), - user.isAdministrator(uids), - ]); - - if (topicData.scheduled) { - const canViewScheduled = await helpers.isUsersAllowedTo('topics:schedule', uids, topicData.cid); - uids = uids.filter((uid, index) => canViewScheduled[index]); - } - - return uids.filter((uid, index) => !disabled && - ((allowedTo[index] && (topicData.scheduled || !topicData.deleted)) || isAdmins[index])); + if (!Array.isArray(uids) || uids.length === 0) { + return []; + } + + uids = _.uniq(uids); + const topicData = await topics.getTopicFields(tid, ['tid', 'cid', 'deleted', 'scheduled']); + const [disabled, allowedTo, isAdmins] = await Promise.all([ + categories.getCategoryField(topicData.cid, 'disabled'), + helpers.isUsersAllowedTo(privilege, uids, topicData.cid), + user.isAdministrator(uids), + ]); + + if (topicData.scheduled) { + const canViewScheduled = await helpers.isUsersAllowedTo('topics:schedule', uids, topicData.cid); + uids = uids.filter((uid, index) => canViewScheduled[index]); + } + + return uids.filter((uid, index) => !disabled + && ((allowedTo[index] && (topicData.scheduled || !topicData.deleted)) || isAdmins[index])); }; privsTopics.canPurge = async function (tid, uid) { - const cid = await topics.getTopicField(tid, 'cid'); - const [purge, owner, isAdmin, isModerator] = await Promise.all([ - privsCategories.isUserAllowedTo('purge', cid, uid), - topics.isOwner(tid, uid), - user.isAdministrator(uid), - user.isModerator(uid, cid), - ]); - return (purge && (owner || isModerator)) || isAdmin; + const cid = await topics.getTopicField(tid, 'cid'); + const [purge, owner, isAdmin, isModerator] = await Promise.all([ + privsCategories.isUserAllowedTo('purge', cid, uid), + topics.isOwner(tid, uid), + user.isAdministrator(uid), + user.isModerator(uid, cid), + ]); + return (purge && (owner || isModerator)) || isAdmin; }; privsTopics.canDelete = async function (tid, uid) { - const topicData = await topics.getTopicFields(tid, ['uid', 'cid', 'postcount', 'deleterUid']); - const [isModerator, isAdministrator, isOwner, allowedTo] = await Promise.all([ - user.isModerator(uid, topicData.cid), - user.isAdministrator(uid), - topics.isOwner(tid, uid), - helpers.isAllowedTo('topics:delete', uid, [topicData.cid]), - ]); - - if (isAdministrator) { - return true; - } - - const { preventTopicDeleteAfterReplies } = meta.config; - if (!isModerator && preventTopicDeleteAfterReplies && (topicData.postcount - 1) >= preventTopicDeleteAfterReplies) { - const langKey = preventTopicDeleteAfterReplies > 1 ? - `[[error:cant-delete-topic-has-replies, ${meta.config.preventTopicDeleteAfterReplies}]]` : - '[[error:cant-delete-topic-has-reply]]'; - throw new Error(langKey); - } - - const { deleterUid } = topicData; - return allowedTo[0] && ((isOwner && (deleterUid === 0 || deleterUid === topicData.uid)) || isModerator); + const topicData = await topics.getTopicFields(tid, ['uid', 'cid', 'postcount', 'deleterUid']); + const [isModerator, isAdministrator, isOwner, allowedTo] = await Promise.all([ + user.isModerator(uid, topicData.cid), + user.isAdministrator(uid), + topics.isOwner(tid, uid), + helpers.isAllowedTo('topics:delete', uid, [topicData.cid]), + ]); + + if (isAdministrator) { + return true; + } + + const {preventTopicDeleteAfterReplies} = meta.config; + if (!isModerator && preventTopicDeleteAfterReplies && (topicData.postcount - 1) >= preventTopicDeleteAfterReplies) { + const langKey = preventTopicDeleteAfterReplies > 1 + ? `[[error:cant-delete-topic-has-replies, ${meta.config.preventTopicDeleteAfterReplies}]]` + : '[[error:cant-delete-topic-has-reply]]'; + throw new Error(langKey); + } + + const {deleterUid} = topicData; + return allowedTo[0] && ((isOwner && (deleterUid === 0 || deleterUid === topicData.uid)) || isModerator); }; privsTopics.canEdit = async function (tid, uid) { - return await privsTopics.isOwnerOrAdminOrMod(tid, uid); + return await privsTopics.isOwnerOrAdminOrMod(tid, uid); }; privsTopics.isOwnerOrAdminOrMod = async function (tid, uid) { - const [isOwner, isAdminOrMod] = await Promise.all([ - topics.isOwner(tid, uid), - privsTopics.isAdminOrMod(tid, uid), - ]); - return isOwner || isAdminOrMod; + const [isOwner, isAdminOrModule] = await Promise.all([ + topics.isOwner(tid, uid), + privsTopics.isAdminOrMod(tid, uid), + ]); + return isOwner || isAdminOrModule; }; privsTopics.isAdminOrMod = async function (tid, uid) { - if (parseInt(uid, 10) <= 0) { - return false; - } - const cid = await topics.getTopicField(tid, 'cid'); - return await privsCategories.isAdminOrMod(cid, uid); + if (Number.parseInt(uid, 10) <= 0) { + return false; + } + + const cid = await topics.getTopicField(tid, 'cid'); + return await privsCategories.isAdminOrMod(cid, uid); }; privsTopics.canViewDeletedScheduled = function (topic, privileges = {}, viewDeleted = false, viewScheduled = false) { - if (!topic) { - return false; - } - const { deleted = false, scheduled = false } = topic; - const { view_deleted = viewDeleted, view_scheduled = viewScheduled } = privileges; - - // conceptually exclusive, scheduled topics deemed to be not deleted (they can only be purged) - if (scheduled) { - return view_scheduled; - } else if (deleted) { - return view_deleted; - } - - return true; + if (!topic) { + return false; + } + + const {deleted = false, scheduled = false} = topic; + const {view_deleted = viewDeleted, view_scheduled = viewScheduled} = privileges; + + // Conceptually exclusive, scheduled topics deemed to be not deleted (they can only be purged) + if (scheduled) { + return view_scheduled; + } + + if (deleted) { + return view_deleted; + } + + return true; }; diff --git a/src/privileges/users.js b/src/privileges/users.js index d986cc5..8a179a4 100644 --- a/src/privileges/users.js +++ b/src/privileges/users.js @@ -2,7 +2,6 @@ 'use strict'; const _ = require('lodash'); - const user = require('../user'); const meta = require('../meta'); const groups = require('../groups'); @@ -12,132 +11,137 @@ const helpers = require('./helpers'); const privsUsers = module.exports; privsUsers.isAdministrator = async function (uid) { - return await isGroupMember(uid, 'administrators'); + return await isGroupMember(uid, 'administrators'); }; privsUsers.isGlobalModerator = async function (uid) { - return await isGroupMember(uid, 'Global Moderators'); + return await isGroupMember(uid, 'Global Moderators'); }; async function isGroupMember(uid, groupName) { - return await groups[Array.isArray(uid) ? 'isMembers' : 'isMember'](uid, groupName); + return await groups[Array.isArray(uid) ? 'isMembers' : 'isMember'](uid, groupName); } privsUsers.isModerator = async function (uid, cid) { - if (Array.isArray(cid)) { - return await isModeratorOfCategories(cid, uid); - } else if (Array.isArray(uid)) { - return await isModeratorsOfCategory(cid, uid); - } - return await isModeratorOfCategory(cid, uid); + if (Array.isArray(cid)) { + return await isModeratorOfCategories(cid, uid); + } + + if (Array.isArray(uid)) { + return await isModeratorsOfCategory(cid, uid); + } + + return await isModeratorOfCategory(cid, uid); }; async function isModeratorOfCategories(cids, uid) { - if (parseInt(uid, 10) <= 0) { - return await filterIsModerator(cids, uid, cids.map(() => false)); - } - - const isGlobalModerator = await privsUsers.isGlobalModerator(uid); - if (isGlobalModerator) { - return await filterIsModerator(cids, uid, cids.map(() => true)); - } - const uniqueCids = _.uniq(cids); - const isAllowed = await helpers.isAllowedTo('moderate', uid, uniqueCids); - - const cidToIsAllowed = _.zipObject(uniqueCids, isAllowed); - const isModerator = cids.map(cid => cidToIsAllowed[cid]); - return await filterIsModerator(cids, uid, isModerator); + if (Number.parseInt(uid, 10) <= 0) { + return await filterIsModerator(cids, uid, cids.map(() => false)); + } + + const isGlobalModerator = await privsUsers.isGlobalModerator(uid); + if (isGlobalModerator) { + return await filterIsModerator(cids, uid, cids.map(() => true)); + } + + const uniqueCids = _.uniq(cids); + const isAllowed = await helpers.isAllowedTo('moderate', uid, uniqueCids); + + const cidToIsAllowed = _.zipObject(uniqueCids, isAllowed); + const isModerator = cids.map(cid => cidToIsAllowed[cid]); + return await filterIsModerator(cids, uid, isModerator); } async function isModeratorsOfCategory(cid, uids) { - const [check1, check2, check3] = await Promise.all([ - privsUsers.isGlobalModerator(uids), - groups.isMembers(uids, `cid:${cid}:privileges:moderate`), - groups.isMembersOfGroupList(uids, `cid:${cid}:privileges:groups:moderate`), - ]); - const isModerator = uids.map((uid, idx) => check1[idx] || check2[idx] || check3[idx]); - return await filterIsModerator(cid, uids, isModerator); + const [check1, check2, check3] = await Promise.all([ + privsUsers.isGlobalModerator(uids), + groups.isMembers(uids, `cid:${cid}:privileges:moderate`), + groups.isMembersOfGroupList(uids, `cid:${cid}:privileges:groups:moderate`), + ]); + const isModerator = uids.map((uid, index) => check1[index] || check2[index] || check3[index]); + return await filterIsModerator(cid, uids, isModerator); } async function isModeratorOfCategory(cid, uid) { - const result = await isModeratorOfCategories([cid], uid); - return result ? result[0] : false; + const result = await isModeratorOfCategories([cid], uid); + return result ? result[0] : false; } async function filterIsModerator(cid, uid, isModerator) { - const data = await plugins.hooks.fire('filter:user.isModerator', { uid: uid, cid: cid, isModerator: isModerator }); - if ((Array.isArray(uid) || Array.isArray(cid)) && !Array.isArray(data.isModerator)) { - throw new Error('filter:user.isModerator - i/o mismatch'); - } + const data = await plugins.hooks.fire('filter:user.isModerator', {uid, cid, isModerator}); + if ((Array.isArray(uid) || Array.isArray(cid)) && !Array.isArray(data.isModerator)) { + throw new TypeError('filter:user.isModerator - i/o mismatch'); + } - return data.isModerator; + return data.isModerator; } privsUsers.canEdit = async function (callerUid, uid) { - if (parseInt(callerUid, 10) === parseInt(uid, 10)) { - return true; - } - const [isAdmin, isGlobalMod, isTargetAdmin] = await Promise.all([ - privsUsers.isAdministrator(callerUid), - privsUsers.isGlobalModerator(callerUid), - privsUsers.isAdministrator(uid), - ]); - - const data = await plugins.hooks.fire('filter:user.canEdit', { - isAdmin: isAdmin, - isGlobalMod: isGlobalMod, - isTargetAdmin: isTargetAdmin, - canEdit: isAdmin || (isGlobalMod && !isTargetAdmin), - callerUid: callerUid, - uid: uid, - }); - return data.canEdit; + if (Number.parseInt(callerUid, 10) === Number.parseInt(uid, 10)) { + return true; + } + + const [isAdmin, isGlobalModule, isTargetAdmin] = await Promise.all([ + privsUsers.isAdministrator(callerUid), + privsUsers.isGlobalModerator(callerUid), + privsUsers.isAdministrator(uid), + ]); + + const data = await plugins.hooks.fire('filter:user.canEdit', { + isAdmin, + isGlobalMod: isGlobalModule, + isTargetAdmin, + canEdit: isAdmin || (isGlobalModule && !isTargetAdmin), + callerUid, + uid, + }); + return data.canEdit; }; privsUsers.canBanUser = async function (callerUid, uid) { - const privsGlobal = require('./global'); - const [canBan, isTargetAdmin] = await Promise.all([ - privsGlobal.can('ban', callerUid), - privsUsers.isAdministrator(uid), - ]); - - const data = await plugins.hooks.fire('filter:user.canBanUser', { - canBan: canBan && !isTargetAdmin, - callerUid: callerUid, - uid: uid, - }); - return data.canBan; + const privsGlobal = require('./global'); + const [canBan, isTargetAdmin] = await Promise.all([ + privsGlobal.can('ban', callerUid), + privsUsers.isAdministrator(uid), + ]); + + const data = await plugins.hooks.fire('filter:user.canBanUser', { + canBan: canBan && !isTargetAdmin, + callerUid, + uid, + }); + return data.canBan; }; privsUsers.canMuteUser = async function (callerUid, uid) { - const privsGlobal = require('./global'); - const [canMute, isTargetAdmin] = await Promise.all([ - privsGlobal.can('mute', callerUid), - privsUsers.isAdministrator(uid), - ]); - - const data = await plugins.hooks.fire('filter:user.canMuteUser', { - canMute: canMute && !isTargetAdmin, - callerUid: callerUid, - uid: uid, - }); - return data.canMute; + const privsGlobal = require('./global'); + const [canMute, isTargetAdmin] = await Promise.all([ + privsGlobal.can('mute', callerUid), + privsUsers.isAdministrator(uid), + ]); + + const data = await plugins.hooks.fire('filter:user.canMuteUser', { + canMute: canMute && !isTargetAdmin, + callerUid, + uid, + }); + return data.canMute; }; privsUsers.canFlag = async function (callerUid, uid) { - const [userReputation, targetPrivileged, reporterPrivileged] = await Promise.all([ - user.getUserField(callerUid, 'reputation'), - user.isPrivileged(uid), - user.isPrivileged(callerUid), - ]); - const minimumReputation = meta.config['min:rep:flag']; - let canFlag = reporterPrivileged || (userReputation >= minimumReputation); - - if (targetPrivileged && !reporterPrivileged) { - canFlag = false; - } - - return { flag: canFlag }; + const [userReputation, targetPrivileged, reporterPrivileged] = await Promise.all([ + user.getUserField(callerUid, 'reputation'), + user.isPrivileged(uid), + user.isPrivileged(callerUid), + ]); + const minimumReputation = meta.config['min:rep:flag']; + let canFlag = reporterPrivileged || (userReputation >= minimumReputation); + + if (targetPrivileged && !reporterPrivileged) { + canFlag = false; + } + + return {flag: canFlag}; }; privsUsers.hasBanPrivilege = async uid => await hasGlobalPrivilege('ban', uid); @@ -145,10 +149,10 @@ privsUsers.hasMutePrivilege = async uid => await hasGlobalPrivilege('mute', uid) privsUsers.hasInvitePrivilege = async uid => await hasGlobalPrivilege('invite', uid); async function hasGlobalPrivilege(privilege, uid) { - const privsGlobal = require('./global'); - const privilegeName = privilege.split('-').map(word => word.slice(0, 1).toUpperCase() + word.slice(1)).join(''); - let payload = { uid }; - payload[`can${privilegeName}`] = await privsGlobal.can(privilege, uid); - payload = await plugins.hooks.fire(`filter:user.has${privilegeName}Privilege`, payload); - return payload[`can${privilegeName}`]; + const privsGlobal = require('./global'); + const privilegeName = privilege.split('-').map(word => word.slice(0, 1).toUpperCase() + word.slice(1)).join(''); + let payload = {uid}; + payload[`can${privilegeName}`] = await privsGlobal.can(privilege, uid); + payload = await plugins.hooks.fire(`filter:user.has${privilegeName}Privilege`, payload); + return payload[`can${privilegeName}`]; } diff --git a/src/promisify.js b/src/promisify.js index 03c3b20..013ce9e 100644 --- a/src/promisify.js +++ b/src/promisify.js @@ -1,61 +1,64 @@ 'use strict'; -const util = require('util'); +const util = require('node:util'); module.exports = function (theModule, ignoreKeys) { - ignoreKeys = ignoreKeys || []; - function isCallbackedFunction(func) { - if (typeof func !== 'function') { - return false; - } - const str = func.toString().split('\n')[0]; - return str.includes('callback)'); - } - - function isAsyncFunction(fn) { - return fn && fn.constructor && fn.constructor.name === 'AsyncFunction'; - } - - function promisifyRecursive(module) { - if (!module) { - return; - } - - const keys = Object.keys(module); - keys.forEach((key) => { - if (ignoreKeys.includes(key)) { - return; - } - if (isAsyncFunction(module[key])) { - module[key] = wrapCallback(module[key], util.callbackify(module[key])); - } else if (isCallbackedFunction(module[key])) { - module[key] = wrapPromise(module[key], util.promisify(module[key])); - } else if (typeof module[key] === 'object') { - promisifyRecursive(module[key]); - } - }); - } - - function wrapCallback(origFn, callbackFn) { - return async function wrapperCallback(...args) { - if (args.length && typeof args[args.length - 1] === 'function') { - const cb = args.pop(); - args.push((err, res) => (res !== undefined ? cb(err, res) : cb(err))); - return callbackFn(...args); - } - return origFn(...args); - }; - } - - function wrapPromise(origFn, promiseFn) { - return function wrapperPromise(...args) { - if (args.length && typeof args[args.length - 1] === 'function') { - return origFn(...args); - } - - return promiseFn(...args); - }; - } - - promisifyRecursive(theModule); + ignoreKeys ||= []; + function isCallbackedFunction(function_) { + if (typeof function_ !== 'function') { + return false; + } + + const string_ = function_.toString().split('\n')[0]; + return string_.includes('callback)'); + } + + function isAsyncFunction(function_) { + return function_ && function_.constructor && function_.constructor.name === 'AsyncFunction'; + } + + function promisifyRecursive(module) { + if (!module) { + return; + } + + const keys = Object.keys(module); + for (const key of keys) { + if (ignoreKeys.includes(key)) { + continue; + } + + if (isAsyncFunction(module[key])) { + module[key] = wrapCallback(module[key], util.callbackify(module[key])); + } else if (isCallbackedFunction(module[key])) { + module[key] = wrapPromise(module[key], util.promisify(module[key])); + } else if (typeof module[key] === 'object') { + promisifyRecursive(module[key]); + } + } + } + + function wrapCallback(origFunction, callbackFunction) { + return async function wrapperCallback(...arguments_) { + if (arguments_.length > 0 && typeof arguments_.at(-1) === 'function') { + const callback = arguments_.pop(); + arguments_.push((error, res) => (res === undefined ? callback(error) : callback(error, res))); + return callbackFunction(...arguments_); + } + + return origFunction(...arguments_); + }; + } + + function wrapPromise(origFunction, promiseFunction) { + return function wrapperPromise(...arguments_) { + if (arguments_.length > 0 && typeof arguments_.at(-1) === 'function') { + return origFunction(...arguments_); + } + + return promiseFunction(...arguments_); + }; + } + + promisifyRecursive(theModule); }; diff --git a/src/pubsub.js b/src/pubsub.js index 1a14110..cede2df 100644 --- a/src/pubsub.js +++ b/src/pubsub.js @@ -1,6 +1,6 @@ 'use strict'; -const EventEmitter = require('events'); +const EventEmitter = require('node:events'); const nconf = require('nconf'); let real; @@ -8,64 +8,68 @@ let noCluster; let singleHost; function get() { - if (real) { - return real; - } + if (real) { + return real; + } - let pubsub; + let pubsub; - if (!nconf.get('isCluster')) { - if (noCluster) { - real = noCluster; - return real; - } - noCluster = new EventEmitter(); - noCluster.publish = noCluster.emit.bind(noCluster); - pubsub = noCluster; - } else if (nconf.get('singleHostCluster')) { - if (singleHost) { - real = singleHost; - return real; - } - singleHost = new EventEmitter(); - if (!process.send) { - singleHost.publish = singleHost.emit.bind(singleHost); - } else { - singleHost.publish = function (event, data) { - process.send({ - action: 'pubsub', - event: event, - data: data, - }); - }; - process.on('message', (message) => { - if (message && typeof message === 'object' && message.action === 'pubsub') { - singleHost.emit(message.event, message.data); - } - }); - } - pubsub = singleHost; - } else if (nconf.get('redis')) { - pubsub = require('./database/redis/pubsub'); - } else { - throw new Error('[[error:redis-required-for-pubsub]]'); - } + if (!nconf.get('isCluster')) { + if (noCluster) { + real = noCluster; + return real; + } - real = pubsub; - return pubsub; + noCluster = new EventEmitter(); + noCluster.publish = noCluster.emit.bind(noCluster); + pubsub = noCluster; + } else if (nconf.get('singleHostCluster')) { + if (singleHost) { + real = singleHost; + return real; + } + + singleHost = new EventEmitter(); + if (process.send) { + singleHost.publish = function (event, data) { + process.send({ + action: 'pubsub', + event, + data, + }); + }; + + process.on('message', message => { + if (message && typeof message === 'object' && message.action === 'pubsub') { + singleHost.emit(message.event, message.data); + } + }); + } else { + singleHost.publish = singleHost.emit.bind(singleHost); + } + + pubsub = singleHost; + } else if (nconf.get('redis')) { + pubsub = require('./database/redis/pubsub'); + } else { + throw new Error('[[error:redis-required-for-pubsub]]'); + } + + real = pubsub; + return pubsub; } module.exports = { - publish: function (event, data) { - get().publish(event, data); - }, - on: function (event, callback) { - get().on(event, callback); - }, - removeAllListeners: function (event) { - get().removeAllListeners(event); - }, - reset: function () { - real = null; - }, + publish(event, data) { + get().publish(event, data); + }, + on(event, callback) { + get().on(event, callback); + }, + removeAllListeners(event) { + get().removeAllListeners(event); + }, + reset() { + real = null; + }, }; diff --git a/src/rewards/admin.js b/src/rewards/admin.js index f46ad78..8c7b7ac 100644 --- a/src/rewards/admin.js +++ b/src/rewards/admin.js @@ -7,75 +7,78 @@ const utils = require('../utils'); const rewards = module.exports; rewards.save = async function (data) { - async function save(data) { - if (!Object.keys(data.rewards).length) { - return; - } - const rewardsData = data.rewards; - delete data.rewards; - if (!parseInt(data.id, 10)) { - data.id = await db.incrObjectField('global', 'rewards:id'); - } - await rewards.delete(data); - await db.setAdd('rewards:list', data.id); - await db.setObject(`rewards:id:${data.id}`, data); - await db.setObject(`rewards:id:${data.id}:rewards`, rewardsData); - } - - await Promise.all(data.map(data => save(data))); - await saveConditions(data); - return data; + async function save(data) { + if (Object.keys(data.rewards).length === 0) { + return; + } + + const rewardsData = data.rewards; + delete data.rewards; + if (!Number.parseInt(data.id, 10)) { + data.id = await db.incrObjectField('global', 'rewards:id'); + } + + await rewards.delete(data); + await db.setAdd('rewards:list', data.id); + await db.setObject(`rewards:id:${data.id}`, data); + await db.setObject(`rewards:id:${data.id}:rewards`, rewardsData); + } + + await Promise.all(data.map(data => save(data))); + await saveConditions(data); + return data; }; rewards.delete = async function (data) { - await Promise.all([ - db.setRemove('rewards:list', data.id), - db.delete(`rewards:id:${data.id}`), - db.delete(`rewards:id:${data.id}:rewards`), - ]); + await Promise.all([ + db.setRemove('rewards:list', data.id), + db.delete(`rewards:id:${data.id}`), + db.delete(`rewards:id:${data.id}:rewards`), + ]); }; rewards.get = async function () { - return await utils.promiseParallel({ - active: getActiveRewards(), - conditions: plugins.hooks.fire('filter:rewards.conditions', []), - conditionals: plugins.hooks.fire('filter:rewards.conditionals', []), - rewards: plugins.hooks.fire('filter:rewards.rewards', []), - }); + return await utils.promiseParallel({ + active: getActiveRewards(), + conditions: plugins.hooks.fire('filter:rewards.conditions', []), + conditionals: plugins.hooks.fire('filter:rewards.conditionals', []), + rewards: plugins.hooks.fire('filter:rewards.rewards', []), + }); }; async function saveConditions(data) { - const rewardsPerCondition = {}; - await db.delete('conditions:active'); - const conditions = []; + const rewardsPerCondition = {}; + await db.delete('conditions:active'); + const conditions = []; - data.forEach((reward) => { - conditions.push(reward.condition); - rewardsPerCondition[reward.condition] = rewardsPerCondition[reward.condition] || []; - rewardsPerCondition[reward.condition].push(reward.id); - }); + for (const reward of data) { + conditions.push(reward.condition); + rewardsPerCondition[reward.condition] = rewardsPerCondition[reward.condition] || []; + rewardsPerCondition[reward.condition].push(reward.id); + } - await db.setAdd('conditions:active', conditions); + await db.setAdd('conditions:active', conditions); - await Promise.all(Object.keys(rewardsPerCondition).map(c => db.setAdd(`condition:${c}:rewards`, rewardsPerCondition[c]))); + await Promise.all(Object.keys(rewardsPerCondition).map(c => db.setAdd(`condition:${c}:rewards`, rewardsPerCondition[c]))); } async function getActiveRewards() { - async function load(id) { - const [main, rewards] = await Promise.all([ - db.getObject(`rewards:id:${id}`), - db.getObject(`rewards:id:${id}:rewards`), - ]); - if (main) { - main.disabled = main.disabled === 'true'; - main.rewards = rewards; - } - return main; - } - - const rewardsList = await db.getSetMembers('rewards:list'); - const rewardData = await Promise.all(rewardsList.map(id => load(id))); - return rewardData.filter(Boolean); + async function load(id) { + const [main, rewards] = await Promise.all([ + db.getObject(`rewards:id:${id}`), + db.getObject(`rewards:id:${id}:rewards`), + ]); + if (main) { + main.disabled = main.disabled === 'true'; + main.rewards = rewards; + } + + return main; + } + + const rewardsList = await db.getSetMembers('rewards:list'); + const rewardData = await Promise.all(rewardsList.map(id => load(id))); + return rewardData.filter(Boolean); } require('../promisify')(rewards); diff --git a/src/rewards/index.js b/src/rewards/index.js index 396c94c..f7502a2 100644 --- a/src/rewards/index.js +++ b/src/rewards/index.js @@ -1,80 +1,82 @@ 'use strict'; -const util = require('util'); - +const util = require('node:util'); const db = require('../database'); const plugins = require('../plugins'); const rewards = module.exports; -rewards.checkConditionAndRewardUser = async function (params) { - const { uid, condition, method } = params; - const isActive = await isConditionActive(condition); - if (!isActive) { - return; - } - const ids = await getIDsByCondition(condition); - let rewardData = await getRewardDataByIDs(ids); - rewardData = await filterCompletedRewards(uid, rewardData); - rewardData = rewardData.filter(Boolean); - if (!rewardData || !rewardData.length) { - return; - } - const eligible = await Promise.all(rewardData.map(reward => checkCondition(reward, method))); - const eligibleRewards = rewardData.filter((reward, index) => eligible[index]); - await giveRewards(uid, eligibleRewards); +rewards.checkConditionAndRewardUser = async function (parameters) { + const {uid, condition, method} = parameters; + const isActive = await isConditionActive(condition); + if (!isActive) { + return; + } + + const ids = await getIDsByCondition(condition); + let rewardData = await getRewardDataByIDs(ids); + rewardData = await filterCompletedRewards(uid, rewardData); + rewardData = rewardData.filter(Boolean); + if (!rewardData || rewardData.length === 0) { + return; + } + + const eligible = await Promise.all(rewardData.map(reward => checkCondition(reward, method))); + const eligibleRewards = rewardData.filter((reward, index) => eligible[index]); + await giveRewards(uid, eligibleRewards); }; async function isConditionActive(condition) { - return await db.isSetMember('conditions:active', condition); + return await db.isSetMember('conditions:active', condition); } async function getIDsByCondition(condition) { - return await db.getSetMembers(`condition:${condition}:rewards`); + return await db.getSetMembers(`condition:${condition}:rewards`); } async function filterCompletedRewards(uid, rewards) { - const data = await db.getSortedSetRangeByScoreWithScores(`uid:${uid}:rewards`, 0, -1, 1, '+inf'); - const userRewards = {}; + const data = await db.getSortedSetRangeByScoreWithScores(`uid:${uid}:rewards`, 0, -1, 1, '+inf'); + const userRewards = {}; - data.forEach((obj) => { - userRewards[obj.value] = parseInt(obj.score, 10); - }); + for (const object of data) { + userRewards[object.value] = Number.parseInt(object.score, 10); + } - return rewards.filter((reward) => { - if (!reward) { - return false; - } + return rewards.filter(reward => { + if (!reward) { + return false; + } - const claimable = parseInt(reward.claimable, 10); - return claimable === 0 || (!userRewards[reward.id] || userRewards[reward.id] < reward.claimable); - }); + const claimable = Number.parseInt(reward.claimable, 10); + return claimable === 0 || (!userRewards[reward.id] || userRewards[reward.id] < reward.claimable); + }); } async function getRewardDataByIDs(ids) { - return await db.getObjects(ids.map(id => `rewards:id:${id}`)); + return await db.getObjects(ids.map(id => `rewards:id:${id}`)); } async function getRewardsByRewardData(rewards) { - return await db.getObjects(rewards.map(reward => `rewards:id:${reward.id}:rewards`)); + return await db.getObjects(rewards.map(reward => `rewards:id:${reward.id}:rewards`)); } async function checkCondition(reward, method) { - if (method.constructor && method.constructor.name !== 'AsyncFunction') { - method = util.promisify(method); - } - const value = await method(); - const bool = await plugins.hooks.fire(`filter:rewards.checkConditional:${reward.conditional}`, { left: value, right: reward.value }); - return bool; + if (method.constructor && method.constructor.name !== 'AsyncFunction') { + method = util.promisify(method); + } + + const value = await method(); + const bool = await plugins.hooks.fire(`filter:rewards.checkConditional:${reward.conditional}`, {left: value, right: reward.value}); + return bool; } async function giveRewards(uid, rewards) { - const rewardData = await getRewardsByRewardData(rewards); - for (let i = 0; i < rewards.length; i++) { - /* eslint-disable no-await-in-loop */ - await plugins.hooks.fire(`action:rewards.award:${rewards[i].rid}`, { uid: uid, reward: rewardData[i] }); - await db.sortedSetIncrBy(`uid:${uid}:rewards`, 1, rewards[i].id); - } + const rewardData = await getRewardsByRewardData(rewards); + for (const [i, reward] of rewards.entries()) { + /* eslint-disable no-await-in-loop */ + await plugins.hooks.fire(`action:rewards.award:${reward.rid}`, {uid, reward: rewardData[i]}); + await db.sortedSetIncrBy(`uid:${uid}:rewards`, 1, reward.id); + } } require('../promisify')(rewards); diff --git a/src/routes/admin.js b/src/routes/admin.js index 1bf3c11..f022979 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -3,83 +3,84 @@ const helpers = require('./helpers'); module.exports = function (app, name, middleware, controllers) { - const middlewares = [middleware.pluginHooks]; + const middlewares = [middleware.pluginHooks]; - helpers.setupAdminPageRoute(app, `/${name}`, middlewares, controllers.admin.routeIndex); + helpers.setupAdminPageRoute(app, `/${name}`, middlewares, controllers.admin.routeIndex); - helpers.setupAdminPageRoute(app, `/${name}/dashboard`, middlewares, controllers.admin.dashboard.get); - helpers.setupAdminPageRoute(app, `/${name}/dashboard/logins`, middlewares, controllers.admin.dashboard.getLogins); - helpers.setupAdminPageRoute(app, `/${name}/dashboard/users`, middlewares, controllers.admin.dashboard.getUsers); - helpers.setupAdminPageRoute(app, `/${name}/dashboard/topics`, middlewares, controllers.admin.dashboard.getTopics); - helpers.setupAdminPageRoute(app, `/${name}/dashboard/searches`, middlewares, controllers.admin.dashboard.getSearches); + helpers.setupAdminPageRoute(app, `/${name}/dashboard`, middlewares, controllers.admin.dashboard.get); + helpers.setupAdminPageRoute(app, `/${name}/dashboard/logins`, middlewares, controllers.admin.dashboard.getLogins); + helpers.setupAdminPageRoute(app, `/${name}/dashboard/users`, middlewares, controllers.admin.dashboard.getUsers); + helpers.setupAdminPageRoute(app, `/${name}/dashboard/topics`, middlewares, controllers.admin.dashboard.getTopics); + helpers.setupAdminPageRoute(app, `/${name}/dashboard/searches`, middlewares, controllers.admin.dashboard.getSearches); - helpers.setupAdminPageRoute(app, `/${name}/manage/categories`, middlewares, controllers.admin.categories.getAll); - helpers.setupAdminPageRoute(app, `/${name}/manage/categories/:category_id`, middlewares, controllers.admin.categories.get); - helpers.setupAdminPageRoute(app, `/${name}/manage/categories/:category_id/analytics`, middlewares, controllers.admin.categories.getAnalytics); + helpers.setupAdminPageRoute(app, `/${name}/manage/categories`, middlewares, controllers.admin.categories.getAll); + helpers.setupAdminPageRoute(app, `/${name}/manage/categories/:category_id`, middlewares, controllers.admin.categories.get); + helpers.setupAdminPageRoute(app, `/${name}/manage/categories/:category_id/analytics`, middlewares, controllers.admin.categories.getAnalytics); - helpers.setupAdminPageRoute(app, `/${name}/manage/privileges/:cid?`, middlewares, controllers.admin.privileges.get); - helpers.setupAdminPageRoute(app, `/${name}/manage/tags`, middlewares, controllers.admin.tags.get); + helpers.setupAdminPageRoute(app, `/${name}/manage/privileges/:cid?`, middlewares, controllers.admin.privileges.get); + helpers.setupAdminPageRoute(app, `/${name}/manage/tags`, middlewares, controllers.admin.tags.get); - helpers.setupAdminPageRoute(app, `/${name}/manage/users`, middlewares, controllers.admin.users.index); - helpers.setupAdminPageRoute(app, `/${name}/manage/registration`, middlewares, controllers.admin.users.registrationQueue); + helpers.setupAdminPageRoute(app, `/${name}/manage/users`, middlewares, controllers.admin.users.index); + helpers.setupAdminPageRoute(app, `/${name}/manage/registration`, middlewares, controllers.admin.users.registrationQueue); - helpers.setupAdminPageRoute(app, `/${name}/manage/admins-mods`, middlewares, controllers.admin.adminsMods.get); + helpers.setupAdminPageRoute(app, `/${name}/manage/admins-mods`, middlewares, controllers.admin.adminsMods.get); - helpers.setupAdminPageRoute(app, `/${name}/manage/groups`, middlewares, controllers.admin.groups.list); - helpers.setupAdminPageRoute(app, `/${name}/manage/groups/:name`, middlewares, controllers.admin.groups.get); + helpers.setupAdminPageRoute(app, `/${name}/manage/groups`, middlewares, controllers.admin.groups.list); + helpers.setupAdminPageRoute(app, `/${name}/manage/groups/:name`, middlewares, controllers.admin.groups.get); - helpers.setupAdminPageRoute(app, `/${name}/manage/uploads`, middlewares, controllers.admin.uploads.get); - helpers.setupAdminPageRoute(app, `/${name}/manage/digest`, middlewares, controllers.admin.digest.get); + helpers.setupAdminPageRoute(app, `/${name}/manage/uploads`, middlewares, controllers.admin.uploads.get); + helpers.setupAdminPageRoute(app, `/${name}/manage/digest`, middlewares, controllers.admin.digest.get); - helpers.setupAdminPageRoute(app, `/${name}/settings/email`, middlewares, controllers.admin.settings.email); - helpers.setupAdminPageRoute(app, `/${name}/settings/user`, middlewares, controllers.admin.settings.user); - helpers.setupAdminPageRoute(app, `/${name}/settings/post`, middlewares, controllers.admin.settings.post); - helpers.setupAdminPageRoute(app, `/${name}/settings/advanced`, middlewares, controllers.admin.settings.advanced); - helpers.setupAdminPageRoute(app, `/${name}/settings/languages`, middlewares, controllers.admin.settings.languages); - helpers.setupAdminPageRoute(app, `/${name}/settings/navigation`, middlewares, controllers.admin.settings.navigation); - helpers.setupAdminPageRoute(app, `/${name}/settings/homepage`, middlewares, controllers.admin.settings.homepage); - helpers.setupAdminPageRoute(app, `/${name}/settings/social`, middlewares, controllers.admin.settings.social); - helpers.setupAdminPageRoute(app, `/${name}/settings/:term?`, middlewares, controllers.admin.settings.get); + helpers.setupAdminPageRoute(app, `/${name}/settings/email`, middlewares, controllers.admin.settings.email); + helpers.setupAdminPageRoute(app, `/${name}/settings/user`, middlewares, controllers.admin.settings.user); + helpers.setupAdminPageRoute(app, `/${name}/settings/post`, middlewares, controllers.admin.settings.post); + helpers.setupAdminPageRoute(app, `/${name}/settings/advanced`, middlewares, controllers.admin.settings.advanced); + helpers.setupAdminPageRoute(app, `/${name}/settings/languages`, middlewares, controllers.admin.settings.languages); + helpers.setupAdminPageRoute(app, `/${name}/settings/navigation`, middlewares, controllers.admin.settings.navigation); + helpers.setupAdminPageRoute(app, `/${name}/settings/homepage`, middlewares, controllers.admin.settings.homepage); + helpers.setupAdminPageRoute(app, `/${name}/settings/social`, middlewares, controllers.admin.settings.social); + helpers.setupAdminPageRoute(app, `/${name}/settings/:term?`, middlewares, controllers.admin.settings.get); - helpers.setupAdminPageRoute(app, `/${name}/appearance/:term?`, middlewares, controllers.admin.appearance.get); + helpers.setupAdminPageRoute(app, `/${name}/appearance/:term?`, middlewares, controllers.admin.appearance.get); - helpers.setupAdminPageRoute(app, `/${name}/extend/plugins`, middlewares, controllers.admin.plugins.get); - helpers.setupAdminPageRoute(app, `/${name}/extend/widgets`, middlewares, controllers.admin.extend.widgets.get); - helpers.setupAdminPageRoute(app, `/${name}/extend/rewards`, middlewares, controllers.admin.extend.rewards.get); + helpers.setupAdminPageRoute(app, `/${name}/extend/plugins`, middlewares, controllers.admin.plugins.get); + helpers.setupAdminPageRoute(app, `/${name}/extend/widgets`, middlewares, controllers.admin.extend.widgets.get); + helpers.setupAdminPageRoute(app, `/${name}/extend/rewards`, middlewares, controllers.admin.extend.rewards.get); - helpers.setupAdminPageRoute(app, `/${name}/advanced/database`, middlewares, controllers.admin.database.get); - helpers.setupAdminPageRoute(app, `/${name}/advanced/events`, middlewares, controllers.admin.events.get); - helpers.setupAdminPageRoute(app, `/${name}/advanced/hooks`, middlewares, controllers.admin.hooks.get); - helpers.setupAdminPageRoute(app, `/${name}/advanced/logs`, middlewares, controllers.admin.logs.get); - helpers.setupAdminPageRoute(app, `/${name}/advanced/errors`, middlewares, controllers.admin.errors.get); - helpers.setupAdminPageRoute(app, `/${name}/advanced/errors/export`, middlewares, controllers.admin.errors.export); - helpers.setupAdminPageRoute(app, `/${name}/advanced/cache`, middlewares, controllers.admin.cache.get); + helpers.setupAdminPageRoute(app, `/${name}/advanced/database`, middlewares, controllers.admin.database.get); + helpers.setupAdminPageRoute(app, `/${name}/advanced/events`, middlewares, controllers.admin.events.get); + helpers.setupAdminPageRoute(app, `/${name}/advanced/hooks`, middlewares, controllers.admin.hooks.get); + helpers.setupAdminPageRoute(app, `/${name}/advanced/logs`, middlewares, controllers.admin.logs.get); + helpers.setupAdminPageRoute(app, `/${name}/advanced/errors`, middlewares, controllers.admin.errors.get); + helpers.setupAdminPageRoute(app, `/${name}/advanced/errors/export`, middlewares, controllers.admin.errors.export); + helpers.setupAdminPageRoute(app, `/${name}/advanced/cache`, middlewares, controllers.admin.cache.get); - helpers.setupAdminPageRoute(app, `/${name}/development/logger`, middlewares, controllers.admin.logger.get); - helpers.setupAdminPageRoute(app, `/${name}/development/info`, middlewares, controllers.admin.info.get); + helpers.setupAdminPageRoute(app, `/${name}/development/logger`, middlewares, controllers.admin.logger.get); + helpers.setupAdminPageRoute(app, `/${name}/development/info`, middlewares, controllers.admin.info.get); - apiRoutes(app, name, middleware, controllers); + apiRoutes(app, name, middleware, controllers); }; - function apiRoutes(router, name, middleware, controllers) { - router.get(`/api/${name}/users/csv`, middleware.ensureLoggedIn, helpers.tryRoute(controllers.admin.users.getCSV)); - router.get(`/api/${name}/groups/:groupname/csv`, middleware.ensureLoggedIn, helpers.tryRoute(controllers.admin.groups.getCSV)); - router.get(`/api/${name}/analytics`, middleware.ensureLoggedIn, helpers.tryRoute(controllers.admin.dashboard.getAnalytics)); - router.get(`/api/${name}/advanced/cache/dump`, middleware.ensureLoggedIn, helpers.tryRoute(controllers.admin.cache.dump)); - - const multipart = require('connect-multiparty'); - const multipartMiddleware = multipart(); - - const middlewares = [multipartMiddleware, middleware.validateFiles, - middleware.applyCSRF, middleware.ensureLoggedIn]; - - router.post(`/api/${name}/category/uploadpicture`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadCategoryPicture)); - router.post(`/api/${name}/uploadfavicon`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadFavicon)); - router.post(`/api/${name}/uploadTouchIcon`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadTouchIcon)); - router.post(`/api/${name}/uploadMaskableIcon`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadMaskableIcon)); - router.post(`/api/${name}/uploadlogo`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadLogo)); - router.post(`/api/${name}/uploadOgImage`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadOgImage)); - router.post(`/api/${name}/upload/file`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadFile)); - router.post(`/api/${name}/uploadDefaultAvatar`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadDefaultAvatar)); + router.get(`/api/${name}/users/csv`, middleware.ensureLoggedIn, helpers.tryRoute(controllers.admin.users.getCSV)); + router.get(`/api/${name}/groups/:groupname/csv`, middleware.ensureLoggedIn, helpers.tryRoute(controllers.admin.groups.getCSV)); + router.get(`/api/${name}/analytics`, middleware.ensureLoggedIn, helpers.tryRoute(controllers.admin.dashboard.getAnalytics)); + router.get(`/api/${name}/advanced/cache/dump`, middleware.ensureLoggedIn, helpers.tryRoute(controllers.admin.cache.dump)); + + const multipart = require('connect-multiparty'); + const multipartMiddleware = multipart(); + + const middlewares = [multipartMiddleware, + middleware.validateFiles, + middleware.applyCSRF, + middleware.ensureLoggedIn]; + + router.post(`/api/${name}/category/uploadpicture`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadCategoryPicture)); + router.post(`/api/${name}/uploadfavicon`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadFavicon)); + router.post(`/api/${name}/uploadTouchIcon`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadTouchIcon)); + router.post(`/api/${name}/uploadMaskableIcon`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadMaskableIcon)); + router.post(`/api/${name}/uploadlogo`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadLogo)); + router.post(`/api/${name}/uploadOgImage`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadOgImage)); + router.post(`/api/${name}/upload/file`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadFile)); + router.post(`/api/${name}/uploadDefaultAvatar`, middlewares, helpers.tryRoute(controllers.admin.uploads.uploadDefaultAvatar)); } diff --git a/src/routes/api.js b/src/routes/api.js index 0119bfe..dd0fe1c 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -2,55 +2,54 @@ const express = require('express'); const winston = require('winston'); - const uploadsController = require('../controllers/uploads'); const helpers = require('./helpers'); module.exports = function (app, middleware, controllers) { - const middlewares = [middleware.authenticateRequest]; - const router = express.Router(); - app.use('/api', router); - - router.get('/config', [...middlewares, middleware.applyCSRF], helpers.tryRoute(controllers.api.getConfig)); - - router.get('/self', [...middlewares], helpers.tryRoute(controllers.user.getCurrentUser)); - router.get('/user/uid/:uid', [...middlewares, middleware.canViewUsers], helpers.tryRoute(controllers.user.getUserByUID)); - router.get('/user/username/:username', [...middlewares, middleware.canViewUsers], helpers.tryRoute(controllers.user.getUserByUsername)); - router.get('/user/email/:email', [...middlewares, middleware.canViewUsers], helpers.tryRoute(controllers.user.getUserByEmail)); - - router.get('/user/:userslug/export/posts', [...middlewares, middleware.authenticateRequest, middleware.ensureLoggedIn, middleware.checkAccountPermissions, middleware.exposeUid], helpers.tryRoute(controllers.user.exportPosts)); - router.get('/user/:userslug/export/uploads', [...middlewares, middleware.authenticateRequest, middleware.ensureLoggedIn, middleware.checkAccountPermissions, middleware.exposeUid], helpers.tryRoute(controllers.user.exportUploads)); - router.get('/user/:userslug/export/profile', [...middlewares, middleware.authenticateRequest, middleware.ensureLoggedIn, middleware.checkAccountPermissions, middleware.exposeUid], helpers.tryRoute(controllers.user.exportProfile)); - - // Deprecated, remove in v1.20.0 - router.get('/user/uid/:userslug/export/:type', (req, res) => { - winston.warn(`[router] \`/api/user/uid/${req.params.userslug}/export/${req.params.type}\` is deprecated, call it \`/api/user/${req.params.userslug}/export/${req.params.type}\`instead.`); - res.redirect(`/api/user/${req.params.userslug}/export/${req.params.type}`); - }); - - router.get('/categories/:cid/moderators', [...middlewares], helpers.tryRoute(controllers.api.getModerators)); - router.get('/recent/posts/:term?', [...middlewares], helpers.tryRoute(controllers.posts.getRecentPosts)); - router.get('/unread/total', [...middlewares, middleware.ensureLoggedIn], helpers.tryRoute(controllers.unread.unreadTotal)); - router.get('/topic/teaser/:topic_id', [...middlewares], helpers.tryRoute(controllers.topics.teaser)); - router.get('/topic/pagination/:topic_id', [...middlewares], helpers.tryRoute(controllers.topics.pagination)); - - const multipart = require('connect-multiparty'); - const multipartMiddleware = multipart(); - const postMiddlewares = [ - middleware.maintenanceMode, - multipartMiddleware, - middleware.validateFiles, - middleware.uploads.ratelimit, - middleware.applyCSRF, - ]; - - router.post('/post/upload', postMiddlewares, helpers.tryRoute(uploadsController.uploadPost)); - router.post('/user/:userslug/uploadpicture', [ - ...middlewares, - ...postMiddlewares, - middleware.exposeUid, - middleware.ensureLoggedIn, - middleware.canViewUsers, - middleware.checkAccountPermissions, - ], helpers.tryRoute(controllers.accounts.edit.uploadPicture)); + const middlewares = [middleware.authenticateRequest]; + const router = express.Router(); + app.use('/api', router); + + router.get('/config', [...middlewares, middleware.applyCSRF], helpers.tryRoute(controllers.api.getConfig)); + + router.get('/self', [...middlewares], helpers.tryRoute(controllers.user.getCurrentUser)); + router.get('/user/uid/:uid', [...middlewares, middleware.canViewUsers], helpers.tryRoute(controllers.user.getUserByUID)); + router.get('/user/username/:username', [...middlewares, middleware.canViewUsers], helpers.tryRoute(controllers.user.getUserByUsername)); + router.get('/user/email/:email', [...middlewares, middleware.canViewUsers], helpers.tryRoute(controllers.user.getUserByEmail)); + + router.get('/user/:userslug/export/posts', [...middlewares, middleware.authenticateRequest, middleware.ensureLoggedIn, middleware.checkAccountPermissions, middleware.exposeUid], helpers.tryRoute(controllers.user.exportPosts)); + router.get('/user/:userslug/export/uploads', [...middlewares, middleware.authenticateRequest, middleware.ensureLoggedIn, middleware.checkAccountPermissions, middleware.exposeUid], helpers.tryRoute(controllers.user.exportUploads)); + router.get('/user/:userslug/export/profile', [...middlewares, middleware.authenticateRequest, middleware.ensureLoggedIn, middleware.checkAccountPermissions, middleware.exposeUid], helpers.tryRoute(controllers.user.exportProfile)); + + // Deprecated, remove in v1.20.0 + router.get('/user/uid/:userslug/export/:type', (request, res) => { + winston.warn(`[router] \`/api/user/uid/${request.params.userslug}/export/${request.params.type}\` is deprecated, call it \`/api/user/${request.params.userslug}/export/${request.params.type}\`instead.`); + res.redirect(`/api/user/${request.params.userslug}/export/${request.params.type}`); + }); + + router.get('/categories/:cid/moderators', [...middlewares], helpers.tryRoute(controllers.api.getModerators)); + router.get('/recent/posts/:term?', [...middlewares], helpers.tryRoute(controllers.posts.getRecentPosts)); + router.get('/unread/total', [...middlewares, middleware.ensureLoggedIn], helpers.tryRoute(controllers.unread.unreadTotal)); + router.get('/topic/teaser/:topic_id', [...middlewares], helpers.tryRoute(controllers.topics.teaser)); + router.get('/topic/pagination/:topic_id', [...middlewares], helpers.tryRoute(controllers.topics.pagination)); + + const multipart = require('connect-multiparty'); + const multipartMiddleware = multipart(); + const postMiddlewares = [ + middleware.maintenanceMode, + multipartMiddleware, + middleware.validateFiles, + middleware.uploads.ratelimit, + middleware.applyCSRF, + ]; + + router.post('/post/upload', postMiddlewares, helpers.tryRoute(uploadsController.uploadPost)); + router.post('/user/:userslug/uploadpicture', [ + ...middlewares, + ...postMiddlewares, + middleware.exposeUid, + middleware.ensureLoggedIn, + middleware.canViewUsers, + middleware.checkAccountPermissions, + ], helpers.tryRoute(controllers.accounts.edit.uploadPicture)); }; diff --git a/src/routes/authentication.js b/src/routes/authentication.js index 406d2e9..1aeba87 100644 --- a/src/routes/authentication.js +++ b/src/routes/authentication.js @@ -5,7 +5,6 @@ const passport = require('passport'); const passportLocal = require('passport-local').Strategy; const BearerStrategy = require('passport-http-bearer').Strategy; const winston = require('winston'); - const meta = require('../meta'); const controllers = require('../controllers'); const helpers = require('../controllers/helpers'); @@ -16,172 +15,173 @@ let loginStrategies = []; const Auth = module.exports; Auth.initialize = function (app, middleware) { - app.use(passport.initialize()); - app.use(passport.session()); - app.use((req, res, next) => { - Auth.setAuthVars(req, res); - next(); - }); - - Auth.app = app; - Auth.middleware = middleware; - - // Apply wrapper around passport.authenticate to pass in keepSessionInfo option - const _authenticate = passport.authenticate; - passport.authenticate = (strategy, options, callback) => { - if (!callback && typeof options === 'function') { - return _authenticate.call(passport, strategy, options); - } - - if (!options.hasOwnProperty('keepSessionInfo')) { - options.keepSessionInfo = true; - } - - return _authenticate.call(passport, strategy, options, callback); - }; + app.use(passport.initialize()); + app.use(passport.session()); + app.use((request, res, next) => { + Auth.setAuthVars(request, res); + next(); + }); + + Auth.app = app; + Auth.middleware = middleware; + + // Apply wrapper around passport.authenticate to pass in keepSessionInfo option + const _authenticate = passport.authenticate; + passport.authenticate = (strategy, options, callback) => { + if (!callback && typeof options === 'function') { + return _authenticate.call(passport, strategy, options); + } + + if (!options.hasOwnProperty('keepSessionInfo')) { + options.keepSessionInfo = true; + } + + return _authenticate.call(passport, strategy, options, callback); + }; }; -Auth.setAuthVars = function setAuthVars(req) { - const isSpider = req.isSpider(); - req.loggedIn = !isSpider && !!req.user; - if (req.user) { - req.uid = parseInt(req.user.uid, 10); - } else if (isSpider) { - req.uid = -1; - } else { - req.uid = 0; - } +Auth.setAuthVars = function setAuthVariables(request) { + const isSpider = request.isSpider(); + request.loggedIn = !isSpider && Boolean(request.user); + if (request.user) { + request.uid = Number.parseInt(request.user.uid, 10); + } else if (isSpider) { + request.uid = -1; + } else { + request.uid = 0; + } }; Auth.getLoginStrategies = function () { - return loginStrategies; + return loginStrategies; }; Auth.verifyToken = async function (token, done) { - const { tokens = [] } = await meta.settings.get('core.api'); - const tokenObj = tokens.find(t => t.token === token); - const uid = tokenObj ? tokenObj.uid : undefined; - - if (uid !== undefined) { - if (parseInt(uid, 10) > 0) { - done(null, { - uid: uid, - }); - } else { - done(null, { - master: true, - }); - } - } else { - done(false); - } + const {tokens = []} = await meta.settings.get('core.api'); + const tokenObject = tokens.find(t => t.token === token); + const uid = tokenObject ? tokenObject.uid : undefined; + + if (uid === undefined) { + done(false); + } else if (Number.parseInt(uid, 10) > 0) { + done(null, { + uid, + }); + } else { + done(null, { + master: true, + }); + } }; -Auth.reloadRoutes = async function (params) { - loginStrategies.length = 0; - const { router } = params; - - // Local Logins - if (plugins.hooks.hasListeners('action:auth.overrideLogin')) { - winston.warn('[authentication] Login override detected, skipping local login strategy.'); - plugins.hooks.fire('action:auth.overrideLogin'); - } else { - passport.use(new passportLocal({ passReqToCallback: true }, controllers.authentication.localLogin)); - } - - // HTTP bearer authentication - passport.use('core.api', new BearerStrategy({}, Auth.verifyToken)); - - // Additional logins via SSO plugins - try { - loginStrategies = await plugins.hooks.fire('filter:auth.init', loginStrategies); - } catch (err) { - winston.error(`[authentication] ${err.stack}`); - } - loginStrategies = loginStrategies || []; - loginStrategies.forEach((strategy) => { - if (strategy.url) { - router[strategy.urlMethod || 'get'](strategy.url, Auth.middleware.applyCSRF, async (req, res, next) => { - let opts = { - scope: strategy.scope, - prompt: strategy.prompt || undefined, - }; - - if (strategy.checkState !== false) { - req.session.ssoState = req.csrfToken && req.csrfToken(); - opts.state = req.session.ssoState; - } - - // Allow SSO plugins to override/append options (for use in passport prototype authorizationParams) - ({ opts } = await plugins.hooks.fire('filter:auth.options', { req, res, opts })); - passport.authenticate(strategy.name, opts)(req, res, next); - }); - } - - router[strategy.callbackMethod || 'get'](strategy.callbackURL, (req, res, next) => { - // Ensure the passed-back state value is identical to the saved ssoState (unless explicitly skipped) - if (strategy.checkState === false) { - return next(); - } - - next(req.query.state !== req.session.ssoState ? new Error('[[error:csrf-invalid]]') : null); - }, (req, res, next) => { - // Trigger registration interstitial checks - req.session.registration = req.session.registration || {}; - // save returnTo for later usage in /register/complete - // passport seems to remove `req.session.returnTo` after it redirects - req.session.registration.returnTo = req.session.returnTo; - - passport.authenticate(strategy.name, (err, user) => { - if (err) { - if (req.session && req.session.registration) { - delete req.session.registration; - } - return next(err); - } - - if (!user) { - if (req.session && req.session.registration) { - delete req.session.registration; - } - return helpers.redirect(res, strategy.failureUrl !== undefined ? strategy.failureUrl : '/login'); - } - - res.locals.user = user; - res.locals.strategy = strategy; - next(); - })(req, res, next); - }, Auth.middleware.validateAuth, (req, res, next) => { - async.waterfall([ - async.apply(req.login.bind(req), res.locals.user, { keepSessionInfo: true }), - async.apply(controllers.authentication.onSuccessfulLogin, req, req.uid), - ], (err) => { - if (err) { - return next(err); - } - - helpers.redirect(res, strategy.successUrl !== undefined ? strategy.successUrl : '/'); - }); - }); - }); - - const multipart = require('connect-multiparty'); - const multipartMiddleware = multipart(); - const middlewares = [multipartMiddleware, Auth.middleware.applyCSRF, Auth.middleware.applyBlacklist]; - - router.post('/register', middlewares, controllers.authentication.register); - router.post('/register/complete', middlewares, controllers.authentication.registerComplete); - router.post('/register/abort', Auth.middleware.applyCSRF, controllers.authentication.registerAbort); - router.post('/login', Auth.middleware.applyCSRF, Auth.middleware.applyBlacklist, controllers.authentication.login); - router.post('/logout', Auth.middleware.applyCSRF, controllers.authentication.logout); +Auth.reloadRoutes = async function (parameters) { + loginStrategies.length = 0; + const {router} = parameters; + + // Local Logins + if (plugins.hooks.hasListeners('action:auth.overrideLogin')) { + winston.warn('[authentication] Login override detected, skipping local login strategy.'); + plugins.hooks.fire('action:auth.overrideLogin'); + } else { + passport.use(new passportLocal({passReqToCallback: true}, controllers.authentication.localLogin)); + } + + // HTTP bearer authentication + passport.use('core.api', new BearerStrategy({}, Auth.verifyToken)); + + // Additional logins via SSO plugins + try { + loginStrategies = await plugins.hooks.fire('filter:auth.init', loginStrategies); + } catch (error) { + winston.error(`[authentication] ${error.stack}`); + } + + loginStrategies ||= []; + for (const strategy of loginStrategies) { + if (strategy.url) { + router[strategy.urlMethod || 'get'](strategy.url, Auth.middleware.applyCSRF, async (request, res, next) => { + let options = { + scope: strategy.scope, + prompt: strategy.prompt || undefined, + }; + + if (strategy.checkState !== false) { + request.session.ssoState = request.csrfToken && request.csrfToken(); + options.state = request.session.ssoState; + } + + // Allow SSO plugins to override/append options (for use in passport prototype authorizationParams) + ({opts: options} = await plugins.hooks.fire('filter:auth.options', {req: request, res, opts: options})); + passport.authenticate(strategy.name, options)(request, res, next); + }); + } + + router[strategy.callbackMethod || 'get'](strategy.callbackURL, (request, res, next) => { + // Ensure the passed-back state value is identical to the saved ssoState (unless explicitly skipped) + if (strategy.checkState === false) { + return next(); + } + + next(request.query.state === request.session.ssoState ? null : new Error('[[error:csrf-invalid]]')); + }, (request, res, next) => { + // Trigger registration interstitial checks + request.session.registration = request.session.registration || {}; + // Save returnTo for later usage in /register/complete + // passport seems to remove `req.session.returnTo` after it redirects + request.session.registration.returnTo = request.session.returnTo; + + passport.authenticate(strategy.name, (error, user) => { + if (error) { + if (request.session && request.session.registration) { + delete request.session.registration; + } + + return next(error); + } + + if (!user) { + if (request.session && request.session.registration) { + delete request.session.registration; + } + + return helpers.redirect(res, strategy.failureUrl === undefined ? '/login' : strategy.failureUrl); + } + + res.locals.user = user; + res.locals.strategy = strategy; + next(); + })(request, res, next); + }, Auth.middleware.validateAuth, (request, res, next) => { + async.waterfall([ + async.apply(request.login.bind(request), res.locals.user, {keepSessionInfo: true}), + async.apply(controllers.authentication.onSuccessfulLogin, request, request.uid), + ], error => { + if (error) { + return next(error); + } + + helpers.redirect(res, strategy.successUrl === undefined ? '/' : strategy.successUrl); + }); + }); + } + + const multipart = require('connect-multiparty'); + const multipartMiddleware = multipart(); + const middlewares = [multipartMiddleware, Auth.middleware.applyCSRF, Auth.middleware.applyBlacklist]; + + router.post('/register', middlewares, controllers.authentication.register); + router.post('/register/complete', middlewares, controllers.authentication.registerComplete); + router.post('/register/abort', Auth.middleware.applyCSRF, controllers.authentication.registerAbort); + router.post('/login', Auth.middleware.applyCSRF, Auth.middleware.applyBlacklist, controllers.authentication.login); + router.post('/logout', Auth.middleware.applyCSRF, controllers.authentication.logout); }; passport.serializeUser((user, done) => { - done(null, user.uid); + done(null, user.uid); }); passport.deserializeUser((uid, done) => { - done(null, { - uid: uid, - }); + done(null, { + uid, + }); }); diff --git a/src/routes/debug.js b/src/routes/debug.js index 110e8ed..fa41ff5 100644 --- a/src/routes/debug.js +++ b/src/routes/debug.js @@ -2,34 +2,33 @@ const express = require('express'); const nconf = require('nconf'); - -const fs = require('fs').promises; -const path = require('path'); +const fs = require('node:fs').promises; +const path = require('node:path'); module.exports = function (app) { - const router = express.Router(); - - router.get('/test', async (req, res) => { - res.redirect(404); - }); - - // Redoc - router.get('/spec/:type', async (req, res, next) => { - const types = ['read', 'write']; - const { type } = req.params; - if (!types.includes(type)) { - return next(); - } - - const handle = await fs.open(path.resolve(__dirname, '../../public/vendor/redoc/index.html'), 'r'); - let html = await handle.readFile({ - encoding: 'utf-8', - }); - await handle.close(); - - html = html.replace('apiUrl', `${nconf.get('relative_path')}/assets/openapi/${type}.yaml`); - res.status(200).type('text/html').send(html); - }); - - app.use(`${nconf.get('relative_path')}/debug`, router); + const router = express.Router(); + + router.get('/test', async (request, res) => { + res.redirect(404); + }); + + // Redoc + router.get('/spec/:type', async (request, res, next) => { + const types = ['read', 'write']; + const {type} = request.params; + if (!types.includes(type)) { + return next(); + } + + const handle = await fs.open(path.resolve(__dirname, '../../public/vendor/redoc/index.html'), 'r'); + let html = await handle.readFile({ + encoding: 'utf-8', + }); + await handle.close(); + + html = html.replace('apiUrl', `${nconf.get('relative_path')}/assets/openapi/${type}.yaml`); + res.status(200).type('text/html').send(html); + }); + + app.use(`${nconf.get('relative_path')}/debug`, router); }; diff --git a/src/routes/feeds.js b/src/routes/feeds.js index 31ec431..cf3f838 100644 --- a/src/routes/feeds.js +++ b/src/routes/feeds.js @@ -3,7 +3,6 @@ const rss = require('rss'); const nconf = require('nconf'); const validator = require('validator'); - const posts = require('../posts'); const topics = require('../topics'); const user = require('../user'); @@ -16,408 +15,421 @@ const utils = require('../utils'); const controllers404 = require('../controllers/404'); const terms = { - daily: 'day', - weekly: 'week', - monthly: 'month', - alltime: 'alltime', + daily: 'day', + weekly: 'week', + monthly: 'month', + alltime: 'alltime', }; module.exports = function (app, middleware) { - app.get('/topic/:topic_id.rss', middleware.maintenanceMode, generateForTopic); - app.get('/category/:category_id.rss', middleware.maintenanceMode, generateForCategory); - app.get('/topics.rss', middleware.maintenanceMode, generateForTopics); - app.get('/recent.rss', middleware.maintenanceMode, generateForRecent); - app.get('/top.rss', middleware.maintenanceMode, generateForTop); - app.get('/top/:term.rss', middleware.maintenanceMode, generateForTop); - app.get('/popular.rss', middleware.maintenanceMode, generateForPopular); - app.get('/popular/:term.rss', middleware.maintenanceMode, generateForPopular); - app.get('/recentposts.rss', middleware.maintenanceMode, generateForRecentPosts); - app.get('/category/:category_id/recentposts.rss', middleware.maintenanceMode, generateForCategoryRecentPosts); - app.get('/user/:userslug/topics.rss', middleware.maintenanceMode, generateForUserTopics); - app.get('/tags/:tag.rss', middleware.maintenanceMode, generateForTag); + app.get('/topic/:topic_id.rss', middleware.maintenanceMode, generateForTopic); + app.get('/category/:category_id.rss', middleware.maintenanceMode, generateForCategory); + app.get('/topics.rss', middleware.maintenanceMode, generateForTopics); + app.get('/recent.rss', middleware.maintenanceMode, generateForRecent); + app.get('/top.rss', middleware.maintenanceMode, generateForTop); + app.get('/top/:term.rss', middleware.maintenanceMode, generateForTop); + app.get('/popular.rss', middleware.maintenanceMode, generateForPopular); + app.get('/popular/:term.rss', middleware.maintenanceMode, generateForPopular); + app.get('/recentposts.rss', middleware.maintenanceMode, generateForRecentPosts); + app.get('/category/:category_id/recentposts.rss', middleware.maintenanceMode, generateForCategoryRecentPosts); + app.get('/user/:userslug/topics.rss', middleware.maintenanceMode, generateForUserTopics); + app.get('/tags/:tag.rss', middleware.maintenanceMode, generateForTag); }; -async function validateTokenIfRequiresLogin(requiresLogin, cid, req, res) { - const uid = parseInt(req.query.uid, 10) || 0; - const { token } = req.query; - - if (!requiresLogin) { - return true; - } - - if (uid <= 0 || !token) { - return helpers.notAllowed(req, res); - } - const userToken = await db.getObjectField(`user:${uid}`, 'rss_token'); - if (userToken !== token) { - await user.auth.logAttempt(uid, req.ip); - return helpers.notAllowed(req, res); - } - const userPrivileges = await privileges.categories.get(cid, uid); - if (!userPrivileges.read) { - return helpers.notAllowed(req, res); - } - return true; +async function validateTokenIfRequiresLogin(requiresLogin, cid, request, res) { + const uid = Number.parseInt(request.query.uid, 10) || 0; + const {token} = request.query; + + if (!requiresLogin) { + return true; + } + + if (uid <= 0 || !token) { + return helpers.notAllowed(request, res); + } + + const userToken = await db.getObjectField(`user:${uid}`, 'rss_token'); + if (userToken !== token) { + await user.auth.logAttempt(uid, request.ip); + return helpers.notAllowed(request, res); + } + + const userPrivileges = await privileges.categories.get(cid, uid); + if (!userPrivileges.read) { + return helpers.notAllowed(request, res); + } + + return true; } -async function generateForTopic(req, res, next) { - if (meta.config['feeds:disableRSS']) { - return next(); - } - - const tid = req.params.topic_id; - - const [userPrivileges, topic] = await Promise.all([ - privileges.topics.get(tid, req.uid), - topics.getTopicData(tid), - ]); - - if (!privileges.topics.canViewDeletedScheduled(topic, userPrivileges)) { - return next(); - } - - if (await validateTokenIfRequiresLogin(!userPrivileges['topics:read'], topic.cid, req, res)) { - const topicData = await topics.getTopicWithPosts(topic, `tid:${tid}:posts`, req.uid || req.query.uid || 0, 0, 24, true); - - topics.modifyPostsByPrivilege(topicData, userPrivileges); - - const feed = new rss({ - title: utils.stripHTMLTags(topicData.title, utils.tags), - description: topicData.posts.length ? topicData.posts[0].content : '', - feed_url: `${nconf.get('url')}/topic/${tid}.rss`, - site_url: `${nconf.get('url')}/topic/${topicData.slug}`, - image_url: topicData.posts.length ? topicData.posts[0].picture : '', - author: topicData.posts.length ? topicData.posts[0].username : '', - ttl: 60, - }); - - if (topicData.posts.length > 0) { - feed.pubDate = new Date(parseInt(topicData.posts[0].timestamp, 10)).toUTCString(); - } - const replies = topicData.posts.slice(1); - replies.forEach((postData) => { - if (!postData.deleted) { - const dateStamp = new Date( - parseInt(parseInt(postData.edited, 10) === 0 ? postData.timestamp : postData.edited, 10) - ).toUTCString(); - - feed.item({ - title: `Reply to ${utils.stripHTMLTags(topicData.title, utils.tags)} on ${dateStamp}`, - description: postData.content, - url: `${nconf.get('url')}/post/${postData.pid}`, - author: postData.user ? postData.user.username : '', - date: dateStamp, - }); - } - }); - - sendFeed(feed, res); - } +async function generateForTopic(request, res, next) { + if (meta.config['feeds:disableRSS']) { + return next(); + } + + const tid = request.params.topic_id; + + const [userPrivileges, topic] = await Promise.all([ + privileges.topics.get(tid, request.uid), + topics.getTopicData(tid), + ]); + + if (!privileges.topics.canViewDeletedScheduled(topic, userPrivileges)) { + return next(); + } + + if (await validateTokenIfRequiresLogin(!userPrivileges['topics:read'], topic.cid, request, res)) { + const topicData = await topics.getTopicWithPosts(topic, `tid:${tid}:posts`, request.uid || request.query.uid || 0, 0, 24, true); + + topics.modifyPostsByPrivilege(topicData, userPrivileges); + + const feed = new rss({ + title: utils.stripHTMLTags(topicData.title, utils.tags), + description: topicData.posts.length > 0 ? topicData.posts[0].content : '', + feed_url: `${nconf.get('url')}/topic/${tid}.rss`, + site_url: `${nconf.get('url')}/topic/${topicData.slug}`, + image_url: topicData.posts.length > 0 ? topicData.posts[0].picture : '', + author: topicData.posts.length > 0 ? topicData.posts[0].username : '', + ttl: 60, + }); + + if (topicData.posts.length > 0) { + feed.pubDate = new Date(Number.parseInt(topicData.posts[0].timestamp, 10)).toUTCString(); + } + + const replies = topicData.posts.slice(1); + for (const postData of replies) { + if (!postData.deleted) { + const dateStamp = new Date( + Number.parseInt(Number.parseInt(postData.edited, 10) === 0 ? postData.timestamp : postData.edited, 10), + ).toUTCString(); + + feed.item({ + title: `Reply to ${utils.stripHTMLTags(topicData.title, utils.tags)} on ${dateStamp}`, + description: postData.content, + url: `${nconf.get('url')}/post/${postData.pid}`, + author: postData.user ? postData.user.username : '', + date: dateStamp, + }); + } + } + + sendFeed(feed, res); + } } -async function generateForCategory(req, res, next) { - const cid = req.params.category_id; - if (meta.config['feeds:disableRSS'] || !parseInt(cid, 10)) { - return next(); - } - const uid = req.uid || req.query.uid || 0; - const [userPrivileges, category, tids] = await Promise.all([ - privileges.categories.get(cid, req.uid), - categories.getCategoryData(cid), - db.getSortedSetRevIntersect({ - sets: ['topics:tid', `cid:${cid}:tids:lastposttime`], - start: 0, - stop: 25, - weights: [1, 0], - }), - ]); - - if (!category || !category.name) { - return next(); - } - - if (await validateTokenIfRequiresLogin(!userPrivileges.read, cid, req, res)) { - let topicsData = await topics.getTopicsByTids(tids, uid); - topicsData = await user.blocks.filter(uid, topicsData); - const feed = await generateTopicsFeed({ - uid: uid, - title: category.name, - description: category.description, - feed_url: `/category/${cid}.rss`, - site_url: `/category/${category.cid}`, - }, topicsData, 'timestamp'); - - sendFeed(feed, res); - } +async function generateForCategory(request, res, next) { + const cid = request.params.category_id; + if (meta.config['feeds:disableRSS'] || !Number.parseInt(cid, 10)) { + return next(); + } + + const uid = request.uid || request.query.uid || 0; + const [userPrivileges, category, tids] = await Promise.all([ + privileges.categories.get(cid, request.uid), + categories.getCategoryData(cid), + db.getSortedSetRevIntersect({ + sets: ['topics:tid', `cid:${cid}:tids:lastposttime`], + start: 0, + stop: 25, + weights: [1, 0], + }), + ]); + + if (!category || !category.name) { + return next(); + } + + if (await validateTokenIfRequiresLogin(!userPrivileges.read, cid, request, res)) { + let topicsData = await topics.getTopicsByTids(tids, uid); + topicsData = await user.blocks.filter(uid, topicsData); + const feed = await generateTopicsFeed({ + uid, + title: category.name, + description: category.description, + feed_url: `/category/${cid}.rss`, + site_url: `/category/${category.cid}`, + }, topicsData, 'timestamp'); + + sendFeed(feed, res); + } } -async function generateForTopics(req, res, next) { - if (meta.config['feeds:disableRSS']) { - return next(); - } - let token = null; - if (req.query.token && req.query.uid) { - token = await db.getObjectField(`user:${req.query.uid}`, 'rss_token'); - } - - await sendTopicsFeed({ - uid: token && token === req.query.token ? req.query.uid : req.uid, - title: 'Most recently created topics', - description: 'A list of topics that have been created recently', - feed_url: '/topics.rss', - useMainPost: true, - }, 'topics:tid', res); +async function generateForTopics(request, res, next) { + if (meta.config['feeds:disableRSS']) { + return next(); + } + + let token = null; + if (request.query.token && request.query.uid) { + token = await db.getObjectField(`user:${request.query.uid}`, 'rss_token'); + } + + await sendTopicsFeed({ + uid: token && token === request.query.token ? request.query.uid : request.uid, + title: 'Most recently created topics', + description: 'A list of topics that have been created recently', + feed_url: '/topics.rss', + useMainPost: true, + }, 'topics:tid', res); } -async function generateForRecent(req, res, next) { - await generateSorted({ - title: 'Recently Active Topics', - description: 'A list of topics that have been active within the past 24 hours', - feed_url: '/recent.rss', - site_url: '/recent', - sort: 'recent', - timestampField: 'lastposttime', - term: 'alltime', - }, req, res, next); +async function generateForRecent(request, res, next) { + await generateSorted({ + title: 'Recently Active Topics', + description: 'A list of topics that have been active within the past 24 hours', + feed_url: '/recent.rss', + site_url: '/recent', + sort: 'recent', + timestampField: 'lastposttime', + term: 'alltime', + }, request, res, next); } -async function generateForTop(req, res, next) { - await generateSorted({ - title: 'Top Voted Topics', - description: 'A list of topics that have received the most votes', - feed_url: `/top/${req.params.term || 'daily'}.rss`, - site_url: `/top/${req.params.term || 'daily'}`, - sort: 'votes', - timestampField: 'timestamp', - term: 'day', - }, req, res, next); +async function generateForTop(request, res, next) { + await generateSorted({ + title: 'Top Voted Topics', + description: 'A list of topics that have received the most votes', + feed_url: `/top/${request.params.term || 'daily'}.rss`, + site_url: `/top/${request.params.term || 'daily'}`, + sort: 'votes', + timestampField: 'timestamp', + term: 'day', + }, request, res, next); } -async function generateForPopular(req, res, next) { - await generateSorted({ - title: 'Popular Topics', - description: 'A list of topics that are sorted by post count', - feed_url: `/popular/${req.params.term || 'daily'}.rss`, - site_url: `/popular/${req.params.term || 'daily'}`, - sort: 'posts', - timestampField: 'timestamp', - term: 'day', - }, req, res, next); +async function generateForPopular(request, res, next) { + await generateSorted({ + title: 'Popular Topics', + description: 'A list of topics that are sorted by post count', + feed_url: `/popular/${request.params.term || 'daily'}.rss`, + site_url: `/popular/${request.params.term || 'daily'}`, + sort: 'posts', + timestampField: 'timestamp', + term: 'day', + }, request, res, next); } -async function generateSorted(options, req, res, next) { - if (meta.config['feeds:disableRSS']) { - return next(); - } - - const term = terms[req.params.term] || options.term; - - let token = null; - if (req.query.token && req.query.uid) { - token = await db.getObjectField(`user:${req.query.uid}`, 'rss_token'); - } - - const uid = token && token === req.query.token ? req.query.uid : req.uid; - - const params = { - uid: uid, - start: 0, - stop: 19, - term: term, - sort: options.sort, - }; - - const { cid } = req.query; - if (cid) { - if (!await privileges.categories.can('topics:read', cid, uid)) { - return helpers.notAllowed(req, res); - } - params.cids = [cid]; - } - - const result = await topics.getSortedTopics(params); - const feed = await generateTopicsFeed({ - uid: uid, - title: options.title, - description: options.description, - feed_url: options.feed_url, - site_url: options.site_url, - }, result.topics, options.timestampField); - - sendFeed(feed, res); +async function generateSorted(options, request, res, next) { + if (meta.config['feeds:disableRSS']) { + return next(); + } + + const term = terms[request.params.term] || options.term; + + let token = null; + if (request.query.token && request.query.uid) { + token = await db.getObjectField(`user:${request.query.uid}`, 'rss_token'); + } + + const uid = token && token === request.query.token ? request.query.uid : request.uid; + + const parameters = { + uid, + start: 0, + stop: 19, + term, + sort: options.sort, + }; + + const {cid} = request.query; + if (cid) { + if (!await privileges.categories.can('topics:read', cid, uid)) { + return helpers.notAllowed(request, res); + } + + parameters.cids = [cid]; + } + + const result = await topics.getSortedTopics(parameters); + const feed = await generateTopicsFeed({ + uid, + title: options.title, + description: options.description, + feed_url: options.feed_url, + site_url: options.site_url, + }, result.topics, options.timestampField); + + sendFeed(feed, res); } async function sendTopicsFeed(options, set, res, timestampField) { - const start = options.hasOwnProperty('start') ? options.start : 0; - const stop = options.hasOwnProperty('stop') ? options.stop : 19; - const topicData = await topics.getTopicsFromSet(set, options.uid, start, stop); - const feed = await generateTopicsFeed(options, topicData.topics, timestampField); - sendFeed(feed, res); + const start = options.hasOwnProperty('start') ? options.start : 0; + const stop = options.hasOwnProperty('stop') ? options.stop : 19; + const topicData = await topics.getTopicsFromSet(set, options.uid, start, stop); + const feed = await generateTopicsFeed(options, topicData.topics, timestampField); + sendFeed(feed, res); } async function generateTopicsFeed(feedOptions, feedTopics, timestampField) { - feedOptions.ttl = 60; - feedOptions.feed_url = nconf.get('url') + feedOptions.feed_url; - feedOptions.site_url = nconf.get('url') + feedOptions.site_url; - - feedTopics = feedTopics.filter(Boolean); - - const feed = new rss(feedOptions); - - if (feedTopics.length > 0) { - feed.pubDate = new Date(feedTopics[0][timestampField]).toUTCString(); - } - - async function addFeedItem(topicData) { - const feedItem = { - title: utils.stripHTMLTags(topicData.title, utils.tags), - url: `${nconf.get('url')}/topic/${topicData.slug}`, - date: new Date(topicData[timestampField]).toUTCString(), - }; - - if (topicData.deleted) { - return; - } - - if (topicData.teaser && topicData.teaser.user && !feedOptions.useMainPost) { - feedItem.description = topicData.teaser.content; - feedItem.author = topicData.teaser.user.username; - feed.item(feedItem); - return; - } - - const mainPost = await topics.getMainPost(topicData.tid, feedOptions.uid); - if (!mainPost) { - feed.item(feedItem); - return; - } - feedItem.description = mainPost.content; - feedItem.author = mainPost.user && mainPost.user.username; - feed.item(feedItem); - } - - for (const topicData of feedTopics) { - /* eslint-disable no-await-in-loop */ - await addFeedItem(topicData); - } - return feed; + feedOptions.ttl = 60; + feedOptions.feed_url = nconf.get('url') + feedOptions.feed_url; + feedOptions.site_url = nconf.get('url') + feedOptions.site_url; + + feedTopics = feedTopics.filter(Boolean); + + const feed = new rss(feedOptions); + + if (feedTopics.length > 0) { + feed.pubDate = new Date(feedTopics[0][timestampField]).toUTCString(); + } + + async function addFeedItem(topicData) { + const feedItem = { + title: utils.stripHTMLTags(topicData.title, utils.tags), + url: `${nconf.get('url')}/topic/${topicData.slug}`, + date: new Date(topicData[timestampField]).toUTCString(), + }; + + if (topicData.deleted) { + return; + } + + if (topicData.teaser && topicData.teaser.user && !feedOptions.useMainPost) { + feedItem.description = topicData.teaser.content; + feedItem.author = topicData.teaser.user.username; + feed.item(feedItem); + return; + } + + const mainPost = await topics.getMainPost(topicData.tid, feedOptions.uid); + if (!mainPost) { + feed.item(feedItem); + return; + } + + feedItem.description = mainPost.content; + feedItem.author = mainPost.user && mainPost.user.username; + feed.item(feedItem); + } + + for (const topicData of feedTopics) { + /* eslint-disable no-await-in-loop */ + await addFeedItem(topicData); + } + + return feed; } -async function generateForRecentPosts(req, res, next) { - if (meta.config['feeds:disableRSS']) { - return next(); - } - const page = parseInt(req.query.page, 10) || 1; - const postsPerPage = 20; - const start = Math.max(0, (page - 1) * postsPerPage); - const stop = start + postsPerPage - 1; - const postData = await posts.getRecentPosts(req.uid, start, stop, 'month'); - const feed = generateForPostsFeed({ - title: 'Recent Posts', - description: 'A list of recent posts', - feed_url: '/recentposts.rss', - site_url: '/recentposts', - }, postData); - - sendFeed(feed, res); +async function generateForRecentPosts(request, res, next) { + if (meta.config['feeds:disableRSS']) { + return next(); + } + + const page = Number.parseInt(request.query.page, 10) || 1; + const postsPerPage = 20; + const start = Math.max(0, (page - 1) * postsPerPage); + const stop = start + postsPerPage - 1; + const postData = await posts.getRecentPosts(request.uid, start, stop, 'month'); + const feed = generateForPostsFeed({ + title: 'Recent Posts', + description: 'A list of recent posts', + feed_url: '/recentposts.rss', + site_url: '/recentposts', + }, postData); + + sendFeed(feed, res); } -async function generateForCategoryRecentPosts(req, res) { - if (meta.config['feeds:disableRSS']) { - return controllers404.handle404(req, res); - } - const cid = req.params.category_id; - const page = parseInt(req.query.page, 10) || 1; - const topicsPerPage = 20; - const start = Math.max(0, (page - 1) * topicsPerPage); - const stop = start + topicsPerPage - 1; - const [userPrivileges, category, postData] = await Promise.all([ - privileges.categories.get(cid, req.uid), - categories.getCategoryData(cid), - categories.getRecentReplies(cid, req.uid || req.query.uid || 0, start, stop), - ]); - - if (!category) { - return controllers404.handle404(req, res); - } - - if (await validateTokenIfRequiresLogin(!userPrivileges.read, cid, req, res)) { - const feed = generateForPostsFeed({ - title: `${category.name} Recent Posts`, - description: `A list of recent posts from ${category.name}`, - feed_url: `/category/${cid}/recentposts.rss`, - site_url: `/category/${cid}/recentposts`, - }, postData); - - sendFeed(feed, res); - } +async function generateForCategoryRecentPosts(request, res) { + if (meta.config['feeds:disableRSS']) { + return controllers404.handle404(request, res); + } + + const cid = request.params.category_id; + const page = Number.parseInt(request.query.page, 10) || 1; + const topicsPerPage = 20; + const start = Math.max(0, (page - 1) * topicsPerPage); + const stop = start + topicsPerPage - 1; + const [userPrivileges, category, postData] = await Promise.all([ + privileges.categories.get(cid, request.uid), + categories.getCategoryData(cid), + categories.getRecentReplies(cid, request.uid || request.query.uid || 0, start, stop), + ]); + + if (!category) { + return controllers404.handle404(request, res); + } + + if (await validateTokenIfRequiresLogin(!userPrivileges.read, cid, request, res)) { + const feed = generateForPostsFeed({ + title: `${category.name} Recent Posts`, + description: `A list of recent posts from ${category.name}`, + feed_url: `/category/${cid}/recentposts.rss`, + site_url: `/category/${cid}/recentposts`, + }, postData); + + sendFeed(feed, res); + } } function generateForPostsFeed(feedOptions, posts) { - feedOptions.ttl = 60; - feedOptions.feed_url = nconf.get('url') + feedOptions.feed_url; - feedOptions.site_url = nconf.get('url') + feedOptions.site_url; - - const feed = new rss(feedOptions); - - if (posts.length > 0) { - feed.pubDate = new Date(parseInt(posts[0].timestamp, 10)).toUTCString(); - } - - posts.forEach((postData) => { - feed.item({ - title: postData.topic ? postData.topic.title : '', - description: postData.content, - url: `${nconf.get('url')}/post/${postData.pid}`, - author: postData.user ? postData.user.username : '', - date: new Date(parseInt(postData.timestamp, 10)).toUTCString(), - }); - }); - - return feed; + feedOptions.ttl = 60; + feedOptions.feed_url = nconf.get('url') + feedOptions.feed_url; + feedOptions.site_url = nconf.get('url') + feedOptions.site_url; + + const feed = new rss(feedOptions); + + if (posts.length > 0) { + feed.pubDate = new Date(Number.parseInt(posts[0].timestamp, 10)).toUTCString(); + } + + for (const postData of posts) { + feed.item({ + title: postData.topic ? postData.topic.title : '', + description: postData.content, + url: `${nconf.get('url')}/post/${postData.pid}`, + author: postData.user ? postData.user.username : '', + date: new Date(Number.parseInt(postData.timestamp, 10)).toUTCString(), + }); + } + + return feed; } -async function generateForUserTopics(req, res, next) { - if (meta.config['feeds:disableRSS']) { - return next(); - } - - const { userslug } = req.params; - const uid = await user.getUidByUserslug(userslug); - if (!uid) { - return next(); - } - const userData = await user.getUserFields(uid, ['uid', 'username']); - await sendTopicsFeed({ - uid: req.uid, - title: `Topics by ${userData.username}`, - description: `A list of topics that are posted by ${userData.username}`, - feed_url: `/user/${userslug}/topics.rss`, - site_url: `/user/${userslug}/topics`, - }, `uid:${userData.uid}:topics`, res); +async function generateForUserTopics(request, res, next) { + if (meta.config['feeds:disableRSS']) { + return next(); + } + + const {userslug} = request.params; + const uid = await user.getUidByUserslug(userslug); + if (!uid) { + return next(); + } + + const userData = await user.getUserFields(uid, ['uid', 'username']); + await sendTopicsFeed({ + uid: request.uid, + title: `Topics by ${userData.username}`, + description: `A list of topics that are posted by ${userData.username}`, + feed_url: `/user/${userslug}/topics.rss`, + site_url: `/user/${userslug}/topics`, + }, `uid:${userData.uid}:topics`, res); } -async function generateForTag(req, res) { - if (meta.config['feeds:disableRSS']) { - return controllers404.handle404(req, res); - } - const tag = validator.escape(String(req.params.tag)); - const page = parseInt(req.query.page, 10) || 1; - const topicsPerPage = meta.config.topicsPerPage || 20; - const start = Math.max(0, (page - 1) * topicsPerPage); - const stop = start + topicsPerPage - 1; - await sendTopicsFeed({ - uid: req.uid, - title: `Topics tagged with ${tag}`, - description: `A list of topics that have been tagged with ${tag}`, - feed_url: `/tags/${tag}.rss`, - site_url: `/tags/${tag}`, - start: start, - stop: stop, - }, `tag:${tag}:topics`, res); +async function generateForTag(request, res) { + if (meta.config['feeds:disableRSS']) { + return controllers404.handle404(request, res); + } + + const tag = validator.escape(String(request.params.tag)); + const page = Number.parseInt(request.query.page, 10) || 1; + const topicsPerPage = meta.config.topicsPerPage || 20; + const start = Math.max(0, (page - 1) * topicsPerPage); + const stop = start + topicsPerPage - 1; + await sendTopicsFeed({ + uid: request.uid, + title: `Topics tagged with ${tag}`, + description: `A list of topics that have been tagged with ${tag}`, + feed_url: `/tags/${tag}.rss`, + site_url: `/tags/${tag}`, + start, + stop, + }, `tag:${tag}:topics`, res); } function sendFeed(feed, res) { - const xml = feed.xml(); - res.type('xml').set('Content-Length', Buffer.byteLength(xml)).send(xml); + const xml = feed.xml(); + res.type('xml').set('Content-Length', Buffer.byteLength(xml)).send(xml); } diff --git a/src/routes/helpers.js b/src/routes/helpers.js index cf1f295..fe26e9a 100644 --- a/src/routes/helpers.js +++ b/src/routes/helpers.js @@ -5,80 +5,82 @@ const winston = require('winston'); const middleware = require('../middleware'); const controllerHelpers = require('../controllers/helpers'); -// router, name, middleware(deprecated), middlewares(optional), controller -helpers.setupPageRoute = function (...args) { - const [router, name] = args; - let middlewares = args.length > 3 ? args[args.length - 2] : []; - const controller = args[args.length - 1]; +// Router, name, middleware(deprecated), middlewares(optional), controller +helpers.setupPageRoute = function (...arguments_) { + const [router, name] = arguments_; + let middlewares = arguments_.length > 3 ? arguments_.at(-2) : []; + const controller = arguments_.at(-1); - if (args.length === 5) { - winston.warn(`[helpers.setupPageRoute(${name})] passing \`middleware\` as the third param is deprecated, it can now be safely removed`); - } + if (arguments_.length === 5) { + winston.warn(`[helpers.setupPageRoute(${name})] passing \`middleware\` as the third param is deprecated, it can now be safely removed`); + } - middlewares = [ - middleware.authenticateRequest, - middleware.maintenanceMode, - middleware.registrationComplete, - middleware.pluginHooks, - ...middlewares, - middleware.pageView, - ]; + middlewares = [ + middleware.authenticateRequest, + middleware.maintenanceMode, + middleware.registrationComplete, + middleware.pluginHooks, + ...middlewares, + middleware.pageView, + ]; - router.get( - name, - middleware.busyCheck, - middlewares, - middleware.buildHeader, - helpers.tryRoute(controller) - ); - router.get(`/api${name}`, middlewares, helpers.tryRoute(controller)); + router.get( + name, + middleware.busyCheck, + middlewares, + middleware.buildHeader, + helpers.tryRoute(controller), + ); + router.get(`/api${name}`, middlewares, helpers.tryRoute(controller)); }; -// router, name, middleware(deprecated), middlewares(optional), controller -helpers.setupAdminPageRoute = function (...args) { - const [router, name] = args; - const middlewares = args.length > 3 ? args[args.length - 2] : []; - const controller = args[args.length - 1]; - if (args.length === 5) { - winston.warn(`[helpers.setupAdminPageRoute(${name})] passing \`middleware\` as the third param is deprecated, it can now be safely removed`); - } - router.get(name, middleware.admin.buildHeader, middlewares, helpers.tryRoute(controller)); - router.get(`/api${name}`, middlewares, helpers.tryRoute(controller)); +// Router, name, middleware(deprecated), middlewares(optional), controller +helpers.setupAdminPageRoute = function (...arguments_) { + const [router, name] = arguments_; + const middlewares = arguments_.length > 3 ? arguments_.at(-2) : []; + const controller = arguments_.at(-1); + if (arguments_.length === 5) { + winston.warn(`[helpers.setupAdminPageRoute(${name})] passing \`middleware\` as the third param is deprecated, it can now be safely removed`); + } + + router.get(name, middleware.admin.buildHeader, middlewares, helpers.tryRoute(controller)); + router.get(`/api${name}`, middlewares, helpers.tryRoute(controller)); }; -// router, verb, name, middlewares(optional), controller -helpers.setupApiRoute = function (...args) { - const [router, verb, name] = args; - let middlewares = args.length > 4 ? args[args.length - 2] : []; - const controller = args[args.length - 1]; +// Router, verb, name, middlewares(optional), controller +helpers.setupApiRoute = function (...arguments_) { + const [router, verb, name] = arguments_; + let middlewares = arguments_.length > 4 ? arguments_.at(-2) : []; + const controller = arguments_.at(-1); - middlewares = [ - middleware.authenticateRequest, - middleware.maintenanceMode, - middleware.registrationComplete, - middleware.pluginHooks, - ...middlewares, - ]; + middlewares = [ + middleware.authenticateRequest, + middleware.maintenanceMode, + middleware.registrationComplete, + middleware.pluginHooks, + ...middlewares, + ]; - router[verb](name, middlewares, helpers.tryRoute(controller, (err, res) => { - controllerHelpers.formatApiResponse(400, res, err); - })); + router[verb](name, middlewares, helpers.tryRoute(controller, (error, res) => { + controllerHelpers.formatApiResponse(400, res, error); + })); }; helpers.tryRoute = function (controller, handler) { - // `handler` is optional - if (controller && controller.constructor && controller.constructor.name === 'AsyncFunction') { - return async function (req, res, next) { - try { - await controller(req, res, next); - } catch (err) { - if (handler) { - return handler(err, res); - } + // `handler` is optional + if (controller && controller.constructor && controller.constructor.name === 'AsyncFunction') { + return async function (request, res, next) { + try { + await controller(request, res, next); + } catch (error) { + if (handler) { + return handler(error, res); + } + + next(error); + } + }; + } - next(err); - } - }; - } - return controller; + return controller; }; diff --git a/src/routes/index.js b/src/routes/index.js index 91e9f00..e5a67ed 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -1,231 +1,231 @@ 'use strict'; +const path = require('node:path'); const nconf = require('nconf'); const winston = require('winston'); -const path = require('path'); const express = require('express'); const chalk = require('chalk'); - const meta = require('../meta'); const controllers = require('../controllers'); const controllerHelpers = require('../controllers/helpers'); const plugins = require('../plugins'); - const authRoutes = require('./authentication'); const writeRoutes = require('./write'); const helpers = require('./helpers'); -const { setupPageRoute } = helpers; +const {setupPageRoute} = helpers; const _mounts = { - user: require('./user'), - meta: require('./meta'), - api: require('./api'), - admin: require('./admin'), - feed: require('./feeds'), + user: require('./user'), + meta: require('./meta'), + api: require('./api'), + admin: require('./admin'), + feed: require('./feeds'), }; _mounts.main = (app, middleware, controllers) => { - const loginRegisterMiddleware = [middleware.redirectToAccountIfLoggedIn]; - - setupPageRoute(app, '/login', loginRegisterMiddleware, controllers.login); - setupPageRoute(app, '/register', loginRegisterMiddleware, controllers.register); - setupPageRoute(app, '/register/complete', [], controllers.registerInterstitial); - setupPageRoute(app, '/compose', [], controllers.composer.get); - setupPageRoute(app, '/confirm/:code', [], controllers.confirmEmail); - setupPageRoute(app, '/outgoing', [], controllers.outgoing); - setupPageRoute(app, '/search', [], controllers.search.search); - setupPageRoute(app, '/reset/:code?', [middleware.delayLoading], controllers.reset); - setupPageRoute(app, '/tos', [], controllers.termsOfUse); - - setupPageRoute(app, '/email/unsubscribe/:token', [], controllers.accounts.settings.unsubscribe); - app.post('/email/unsubscribe/:token', controllers.accounts.settings.unsubscribePost); - - app.post('/compose', middleware.applyCSRF, controllers.composer.post); + const loginRegisterMiddleware = [middleware.redirectToAccountIfLoggedIn]; + + setupPageRoute(app, '/login', loginRegisterMiddleware, controllers.login); + setupPageRoute(app, '/register', loginRegisterMiddleware, controllers.register); + setupPageRoute(app, '/register/complete', [], controllers.registerInterstitial); + setupPageRoute(app, '/compose', [], controllers.composer.get); + setupPageRoute(app, '/confirm/:code', [], controllers.confirmEmail); + setupPageRoute(app, '/outgoing', [], controllers.outgoing); + setupPageRoute(app, '/search', [], controllers.search.search); + setupPageRoute(app, '/reset/:code?', [middleware.delayLoading], controllers.reset); + setupPageRoute(app, '/tos', [], controllers.termsOfUse); + + setupPageRoute(app, '/email/unsubscribe/:token', [], controllers.accounts.settings.unsubscribe); + app.post('/email/unsubscribe/:token', controllers.accounts.settings.unsubscribePost); + + app.post('/compose', middleware.applyCSRF, controllers.composer.post); }; _mounts.mod = (app, middleware, controllers) => { - setupPageRoute(app, '/flags', [], controllers.mods.flags.list); - setupPageRoute(app, '/flags/:flagId', [], controllers.mods.flags.detail); - setupPageRoute(app, '/post-queue/:id?', [], controllers.mods.postQueue); + setupPageRoute(app, '/flags', [], controllers.mods.flags.list); + setupPageRoute(app, '/flags/:flagId', [], controllers.mods.flags.detail); + setupPageRoute(app, '/post-queue/:id?', [], controllers.mods.postQueue); }; _mounts.globalMod = (app, middleware, controllers) => { - setupPageRoute(app, '/ip-blacklist', [], controllers.globalMods.ipBlacklist); - setupPageRoute(app, '/registration-queue', [], controllers.globalMods.registrationQueue); + setupPageRoute(app, '/ip-blacklist', [], controllers.globalMods.ipBlacklist); + setupPageRoute(app, '/registration-queue', [], controllers.globalMods.registrationQueue); }; _mounts.topic = (app, name, middleware, controllers) => { - setupPageRoute(app, `/${name}/:topic_id/:slug/:post_index?`, [], controllers.topics.get); - setupPageRoute(app, `/${name}/:topic_id/:slug?`, [], controllers.topics.get); + setupPageRoute(app, `/${name}/:topic_id/:slug/:post_index?`, [], controllers.topics.get); + setupPageRoute(app, `/${name}/:topic_id/:slug?`, [], controllers.topics.get); }; _mounts.post = (app, name, middleware, controllers) => { - const middlewares = [ - middleware.maintenanceMode, - middleware.authenticateRequest, - middleware.registrationComplete, - middleware.pluginHooks, - ]; - app.get(`/${name}/:pid`, middleware.busyCheck, middlewares, controllers.posts.redirectToPost); - app.get(`/api/${name}/:pid`, middlewares, controllers.posts.redirectToPost); + const middlewares = [ + middleware.maintenanceMode, + middleware.authenticateRequest, + middleware.registrationComplete, + middleware.pluginHooks, + ]; + app.get(`/${name}/:pid`, middleware.busyCheck, middlewares, controllers.posts.redirectToPost); + app.get(`/api/${name}/:pid`, middlewares, controllers.posts.redirectToPost); }; _mounts.tags = (app, name, middleware, controllers) => { - setupPageRoute(app, `/${name}/:tag`, [middleware.privateTagListing], controllers.tags.getTag); - setupPageRoute(app, `/${name}`, [middleware.privateTagListing], controllers.tags.getTags); + setupPageRoute(app, `/${name}/:tag`, [middleware.privateTagListing], controllers.tags.getTag); + setupPageRoute(app, `/${name}`, [middleware.privateTagListing], controllers.tags.getTags); }; _mounts.category = (app, name, middleware, controllers) => { - setupPageRoute(app, '/categories', [], controllers.categories.list); - setupPageRoute(app, '/popular', [], controllers.popular.get); - setupPageRoute(app, '/recent', [], controllers.recent.get); - setupPageRoute(app, '/top', [], controllers.top.get); - setupPageRoute(app, '/unread', [middleware.ensureLoggedIn], controllers.unread.get); - - setupPageRoute(app, `/${name}/:category_id/:slug/:topic_index`, [], controllers.category.get); - setupPageRoute(app, `/${name}/:category_id/:slug?`, [], controllers.category.get); + setupPageRoute(app, '/categories', [], controllers.categories.list); + setupPageRoute(app, '/popular', [], controllers.popular.get); + setupPageRoute(app, '/recent', [], controllers.recent.get); + setupPageRoute(app, '/top', [], controllers.top.get); + setupPageRoute(app, '/unread', [middleware.ensureLoggedIn], controllers.unread.get); + + setupPageRoute(app, `/${name}/:category_id/:slug/:topic_index`, [], controllers.category.get); + setupPageRoute(app, `/${name}/:category_id/:slug?`, [], controllers.category.get); }; _mounts.career = (app, name, middleware, controllers) => { - const middlewares = [middleware.ensureLoggedIn]; + const middlewares = [middleware.ensureLoggedIn]; - setupPageRoute(app, `/${name}`, middlewares, controllers.career.get); + setupPageRoute(app, `/${name}`, middlewares, controllers.career.get); }; _mounts.users = (app, name, middleware, controllers) => { - const middlewares = [middleware.canViewUsers]; + const middlewares = [middleware.canViewUsers]; - setupPageRoute(app, `/${name}`, middlewares, controllers.users.index); + setupPageRoute(app, `/${name}`, middlewares, controllers.users.index); }; _mounts.groups = (app, name, middleware, controllers) => { - const middlewares = [middleware.canViewGroups]; + const middlewares = [middleware.canViewGroups]; - setupPageRoute(app, `/${name}`, middlewares, controllers.groups.list); - setupPageRoute(app, `/${name}/:slug`, middlewares, controllers.groups.details); - setupPageRoute(app, `/${name}/:slug/members`, middlewares, controllers.groups.members); + setupPageRoute(app, `/${name}`, middlewares, controllers.groups.list); + setupPageRoute(app, `/${name}/:slug`, middlewares, controllers.groups.details); + setupPageRoute(app, `/${name}/:slug/members`, middlewares, controllers.groups.members); }; module.exports = async function (app, middleware) { - const router = express.Router(); - router.render = function (...args) { - app.render(...args); - }; - - // Allow plugins/themes to mount some routes elsewhere - const remountable = ['admin', 'category', 'topic', 'post', 'users', 'user', 'groups', 'tags', 'career']; - const { mounts } = await plugins.hooks.fire('filter:router.add', { - mounts: remountable.reduce((memo, mount) => { - memo[mount] = mount; - return memo; - }, {}), - }); - // Guard against plugins sending back missing/extra mounts - Object.keys(mounts).forEach((mount) => { - if (!remountable.includes(mount)) { - delete mounts[mount]; - } else if (typeof mount !== 'string') { - mounts[mount] = mount; - } - }); - remountable.forEach((mount) => { - if (!mounts.hasOwnProperty(mount)) { - mounts[mount] = mount; - } - }); - - router.all('(/+api|/+api/*?)', middleware.prepareAPI); - router.all(`(/+api/admin|/+api/admin/*?${mounts.admin !== 'admin' ? `|/+api/${mounts.admin}|/+api/${mounts.admin}/*?` : ''})`, middleware.authenticateRequest, middleware.ensureLoggedIn, middleware.admin.checkPrivileges); - router.all(`(/+admin|/+admin/*?${mounts.admin !== 'admin' ? `|/+${mounts.admin}|/+${mounts.admin}/*?` : ''})`, middleware.ensureLoggedIn, middleware.applyCSRF, middleware.admin.checkPrivileges); - - app.use(middleware.stripLeadingSlashes); - - // handle custom homepage routes - router.use('/', controllers.home.rewrite); - - // homepage handled by `action:homepage.get:[route]` - setupPageRoute(router, '/', [], controllers.home.pluginHook); - - await plugins.reloadRoutes({ router: router }); - await authRoutes.reloadRoutes({ router: router }); - await writeRoutes.reload({ router: router }); - addCoreRoutes(app, router, middleware, mounts); - - winston.info('[router] Routes added'); + const router = express.Router(); + router.render = function (...arguments_) { + app.render(...arguments_); + }; + + // Allow plugins/themes to mount some routes elsewhere + const remountable = ['admin', 'category', 'topic', 'post', 'users', 'user', 'groups', 'tags', 'career']; + const {mounts} = await plugins.hooks.fire('filter:router.add', { + mounts: remountable.reduce((memo, mount) => { + memo[mount] = mount; + return memo; + }, {}), + }); + // Guard against plugins sending back missing/extra mounts + for (const mount of Object.keys(mounts)) { + if (!remountable.includes(mount)) { + delete mounts[mount]; + } else if (typeof mount !== 'string') { + mounts[mount] = mount; + } + } + + for (const mount of remountable) { + if (!mounts.hasOwnProperty(mount)) { + mounts[mount] = mount; + } + } + + router.all('(/+api|/+api/*?)', middleware.prepareAPI); + router.all(`(/+api/admin|/+api/admin/*?${mounts.admin === 'admin' ? '' : `|/+api/${mounts.admin}|/+api/${mounts.admin}/*?`})`, middleware.authenticateRequest, middleware.ensureLoggedIn, middleware.admin.checkPrivileges); + router.all(`(/+admin|/+admin/*?${mounts.admin === 'admin' ? '' : `|/+${mounts.admin}|/+${mounts.admin}/*?`})`, middleware.ensureLoggedIn, middleware.applyCSRF, middleware.admin.checkPrivileges); + + app.use(middleware.stripLeadingSlashes); + + // Handle custom homepage routes + router.use('/', controllers.home.rewrite); + + // Homepage handled by `action:homepage.get:[route]` + setupPageRoute(router, '/', [], controllers.home.pluginHook); + + await plugins.reloadRoutes({router}); + await authRoutes.reloadRoutes({router}); + await writeRoutes.reload({router}); + addCoreRoutes(app, router, middleware, mounts); + + winston.info('[router] Routes added'); }; function addCoreRoutes(app, router, middleware, mounts) { - _mounts.meta(router, middleware, controllers); - _mounts.api(router, middleware, controllers); - _mounts.feed(router, middleware, controllers); - - _mounts.main(router, middleware, controllers); - _mounts.mod(router, middleware, controllers); - _mounts.globalMod(router, middleware, controllers); - - addRemountableRoutes(app, router, middleware, mounts); - - const relativePath = nconf.get('relative_path'); - app.use(relativePath || '/', router); - - if (process.env.NODE_ENV === 'development') { - require('./debug')(app, middleware, controllers); - } - - app.use(middleware.privateUploads); - - const statics = [ - { route: '/assets', path: path.join(__dirname, '../../build/public') }, - { route: '/assets', path: path.join(__dirname, '../../public') }, - ]; - const staticOptions = { - maxAge: app.enabled('cache') ? 5184000000 : 0, - }; - - if (path.resolve(__dirname, '../../public/uploads') !== nconf.get('upload_path')) { - statics.unshift({ route: '/assets/uploads', path: nconf.get('upload_path') }); - } - - statics.forEach((obj) => { - app.use(relativePath + obj.route, middleware.addUploadHeaders, express.static(obj.path, staticOptions)); - }); - app.use(`${relativePath}/uploads`, (req, res) => { - res.redirect(`${relativePath}/assets/uploads${req.path}?${meta.config['cache-buster']}`); - }); - app.use(`${relativePath}/plugins`, (req, res) => { - winston.warn(`${chalk.bold.red('[deprecation]')} The \`/plugins\` shorthand prefix is deprecated, prefix with \`/assets/plugins\` instead (path: ${req.path})`); - res.redirect(`${relativePath}/assets/plugins${req.path}${req._parsedUrl.search || ''}`); - }); - - // Skins - meta.css.supportedSkins.forEach((skin) => { - app.use(`${relativePath}/assets/client-${skin}.css`, middleware.buildSkinAsset); - }); - - app.use(controllers['404'].handle404); - app.use(controllers.errors.handleURIErrors); - app.use(controllers.errors.handleErrors); + _mounts.meta(router, middleware, controllers); + _mounts.api(router, middleware, controllers); + _mounts.feed(router, middleware, controllers); + + _mounts.main(router, middleware, controllers); + _mounts.mod(router, middleware, controllers); + _mounts.globalMod(router, middleware, controllers); + + addRemountableRoutes(app, router, middleware, mounts); + + const relativePath = nconf.get('relative_path'); + app.use(relativePath || '/', router); + + if (process.env.NODE_ENV === 'development') { + require('./debug')(app, middleware, controllers); + } + + app.use(middleware.privateUploads); + + const statics = [ + {route: '/assets', path: path.join(__dirname, '../../build/public')}, + {route: '/assets', path: path.join(__dirname, '../../public')}, + ]; + const staticOptions = { + maxAge: app.enabled('cache') ? 5_184_000_000 : 0, + }; + + if (path.resolve(__dirname, '../../public/uploads') !== nconf.get('upload_path')) { + statics.unshift({route: '/assets/uploads', path: nconf.get('upload_path')}); + } + + for (const object of statics) { + app.use(relativePath + object.route, middleware.addUploadHeaders, express.static(object.path, staticOptions)); + } + + app.use(`${relativePath}/uploads`, (request, res) => { + res.redirect(`${relativePath}/assets/uploads${request.path}?${meta.config['cache-buster']}`); + }); + app.use(`${relativePath}/plugins`, (request, res) => { + winston.warn(`${chalk.bold.red('[deprecation]')} The \`/plugins\` shorthand prefix is deprecated, prefix with \`/assets/plugins\` instead (path: ${request.path})`); + res.redirect(`${relativePath}/assets/plugins${request.path}${request._parsedUrl.search || ''}`); + }); + + // Skins + for (const skin of meta.css.supportedSkins) { + app.use(`${relativePath}/assets/client-${skin}.css`, middleware.buildSkinAsset); + } + + app.use(controllers['404'].handle404); + app.use(controllers.errors.handleURIErrors); + app.use(controllers.errors.handleErrors); } function addRemountableRoutes(app, router, middleware, mounts) { - Object.keys(mounts).map(async (mount) => { - const original = mount; - mount = mounts[original]; - - if (!mount) { // do not mount at all - winston.warn(`[router] Not mounting /${original}`); - return; - } - - if (mount !== original) { - // Set up redirect for fallback handling (some js/tpls may still refer to the traditional mount point) - winston.info(`[router] /${original} prefix re-mounted to /${mount}. Requests to /${original}/* will now redirect to /${mount}`); - router.use(new RegExp(`/(api/)?${original}`), (req, res) => { - controllerHelpers.redirect(res, `${nconf.get('relative_path')}/${mount}${req.path}`); - }); - } - - _mounts[original](router, mount, middleware, controllers); - }); + Object.keys(mounts).map(async mount => { + const original = mount; + mount = mounts[original]; + + if (!mount) { // Do not mount at all + winston.warn(`[router] Not mounting /${original}`); + return; + } + + if (mount !== original) { + // Set up redirect for fallback handling (some js/tpls may still refer to the traditional mount point) + winston.info(`[router] /${original} prefix re-mounted to /${mount}. Requests to /${original}/* will now redirect to /${mount}`); + router.use(new RegExp(`/(api/)?${original}`), (request, res) => { + controllerHelpers.redirect(res, `${nconf.get('relative_path')}/${mount}${request.path}`); + }); + } + + _mounts[original](router, mount, middleware, controllers); + }); } diff --git a/src/routes/meta.js b/src/routes/meta.js index 3eb28df..67379e1 100644 --- a/src/routes/meta.js +++ b/src/routes/meta.js @@ -1,18 +1,18 @@ 'use strict'; -const path = require('path'); +const path = require('node:path'); const nconf = require('nconf'); module.exports = function (app, middleware, controllers) { - app.get('/sitemap.xml', controllers.sitemap.render); - app.get('/sitemap/pages.xml', controllers.sitemap.getPages); - app.get('/sitemap/categories.xml', controllers.sitemap.getCategories); - app.get(/\/sitemap\/topics\.(\d+)\.xml/, controllers.sitemap.getTopicPage); - app.get('/robots.txt', controllers.robots); - app.get('/manifest.webmanifest', controllers.manifest); - app.get('/css/previews/:theme', controllers.admin.themes.get); - app.get('/osd.xml', controllers.osd.handle); - app.get('/service-worker.js', (req, res) => { - res.status(200).type('application/javascript').set('Service-Worker-Allowed', `${nconf.get('relative_path')}/`).sendFile(path.join(__dirname, '../../public/src/service-worker.js')); - }); + app.get('/sitemap.xml', controllers.sitemap.render); + app.get('/sitemap/pages.xml', controllers.sitemap.getPages); + app.get('/sitemap/categories.xml', controllers.sitemap.getCategories); + app.get(/\/sitemap\/topics\.(\d+)\.xml/, controllers.sitemap.getTopicPage); + app.get('/robots.txt', controllers.robots); + app.get('/manifest.webmanifest', controllers.manifest); + app.get('/css/previews/:theme', controllers.admin.themes.get); + app.get('/osd.xml', controllers.osd.handle); + app.get('/service-worker.js', (request, res) => { + res.status(200).type('application/javascript').set('Service-Worker-Allowed', `${nconf.get('relative_path')}/`).sendFile(path.join(__dirname, '../../public/src/service-worker.js')); + }); }; diff --git a/src/routes/user.js b/src/routes/user.js index 286910c..56bb4fc 100644 --- a/src/routes/user.js +++ b/src/routes/user.js @@ -2,52 +2,52 @@ const helpers = require('./helpers'); -const { setupPageRoute } = helpers; +const {setupPageRoute} = helpers; module.exports = function (app, name, middleware, controllers) { - const middlewares = [middleware.exposeUid, middleware.canViewUsers]; - const accountMiddlewares = [ - middleware.exposeUid, - middleware.ensureLoggedIn, - middleware.canViewUsers, - middleware.checkAccountPermissions, - ]; - - setupPageRoute(app, '/me', [], middleware.redirectMeToUserslug); - setupPageRoute(app, '/me/*', [], middleware.redirectMeToUserslug); - setupPageRoute(app, '/uid/:uid*', [], middleware.redirectUidToUserslug); - - setupPageRoute(app, `/${name}/:userslug`, middlewares, controllers.accounts.profile.get); - setupPageRoute(app, `/${name}/:userslug/following`, middlewares, controllers.accounts.follow.getFollowing); - setupPageRoute(app, `/${name}/:userslug/followers`, middlewares, controllers.accounts.follow.getFollowers); - - setupPageRoute(app, `/${name}/:userslug/posts`, middlewares, controllers.accounts.posts.getPosts); - setupPageRoute(app, `/${name}/:userslug/topics`, middlewares, controllers.accounts.posts.getTopics); - setupPageRoute(app, `/${name}/:userslug/best`, middlewares, controllers.accounts.posts.getBestPosts); - setupPageRoute(app, `/${name}/:userslug/controversial`, middlewares, controllers.accounts.posts.getControversialPosts); - setupPageRoute(app, `/${name}/:userslug/groups`, middlewares, controllers.accounts.groups.get); - - setupPageRoute(app, `/${name}/:userslug/categories`, accountMiddlewares, controllers.accounts.categories.get); - setupPageRoute(app, `/${name}/:userslug/bookmarks`, accountMiddlewares, controllers.accounts.posts.getBookmarks); - setupPageRoute(app, `/${name}/:userslug/watched`, accountMiddlewares, controllers.accounts.posts.getWatchedTopics); - setupPageRoute(app, `/${name}/:userslug/ignored`, accountMiddlewares, controllers.accounts.posts.getIgnoredTopics); - setupPageRoute(app, `/${name}/:userslug/upvoted`, accountMiddlewares, controllers.accounts.posts.getUpVotedPosts); - setupPageRoute(app, `/${name}/:userslug/downvoted`, accountMiddlewares, controllers.accounts.posts.getDownVotedPosts); - setupPageRoute(app, `/${name}/:userslug/edit`, accountMiddlewares, controllers.accounts.edit.get); - setupPageRoute(app, `/${name}/:userslug/edit/username`, accountMiddlewares, controllers.accounts.edit.username); - setupPageRoute(app, `/${name}/:userslug/edit/email`, accountMiddlewares, controllers.accounts.edit.email); - setupPageRoute(app, `/${name}/:userslug/edit/password`, accountMiddlewares, controllers.accounts.edit.password); - app.use('/.well-known/change-password', (req, res) => { - res.redirect('/me/edit/password'); - }); - setupPageRoute(app, `/${name}/:userslug/info`, accountMiddlewares, controllers.accounts.info.get); - setupPageRoute(app, `/${name}/:userslug/settings`, accountMiddlewares, controllers.accounts.settings.get); - setupPageRoute(app, `/${name}/:userslug/uploads`, accountMiddlewares, controllers.accounts.uploads.get); - setupPageRoute(app, `/${name}/:userslug/consent`, accountMiddlewares, controllers.accounts.consent.get); - setupPageRoute(app, `/${name}/:userslug/blocks`, accountMiddlewares, controllers.accounts.blocks.getBlocks); - setupPageRoute(app, `/${name}/:userslug/sessions`, accountMiddlewares, controllers.accounts.sessions.get); - - setupPageRoute(app, '/notifications', [middleware.ensureLoggedIn], controllers.accounts.notifications.get); - setupPageRoute(app, `/${name}/:userslug/chats/:roomid?`, middlewares, controllers.accounts.chats.get); - setupPageRoute(app, '/chats/:roomid?', [middleware.ensureLoggedIn], controllers.accounts.chats.redirectToChat); + const middlewares = [middleware.exposeUid, middleware.canViewUsers]; + const accountMiddlewares = [ + middleware.exposeUid, + middleware.ensureLoggedIn, + middleware.canViewUsers, + middleware.checkAccountPermissions, + ]; + + setupPageRoute(app, '/me', [], middleware.redirectMeToUserslug); + setupPageRoute(app, '/me/*', [], middleware.redirectMeToUserslug); + setupPageRoute(app, '/uid/:uid*', [], middleware.redirectUidToUserslug); + + setupPageRoute(app, `/${name}/:userslug`, middlewares, controllers.accounts.profile.get); + setupPageRoute(app, `/${name}/:userslug/following`, middlewares, controllers.accounts.follow.getFollowing); + setupPageRoute(app, `/${name}/:userslug/followers`, middlewares, controllers.accounts.follow.getFollowers); + + setupPageRoute(app, `/${name}/:userslug/posts`, middlewares, controllers.accounts.posts.getPosts); + setupPageRoute(app, `/${name}/:userslug/topics`, middlewares, controllers.accounts.posts.getTopics); + setupPageRoute(app, `/${name}/:userslug/best`, middlewares, controllers.accounts.posts.getBestPosts); + setupPageRoute(app, `/${name}/:userslug/controversial`, middlewares, controllers.accounts.posts.getControversialPosts); + setupPageRoute(app, `/${name}/:userslug/groups`, middlewares, controllers.accounts.groups.get); + + setupPageRoute(app, `/${name}/:userslug/categories`, accountMiddlewares, controllers.accounts.categories.get); + setupPageRoute(app, `/${name}/:userslug/bookmarks`, accountMiddlewares, controllers.accounts.posts.getBookmarks); + setupPageRoute(app, `/${name}/:userslug/watched`, accountMiddlewares, controllers.accounts.posts.getWatchedTopics); + setupPageRoute(app, `/${name}/:userslug/ignored`, accountMiddlewares, controllers.accounts.posts.getIgnoredTopics); + setupPageRoute(app, `/${name}/:userslug/upvoted`, accountMiddlewares, controllers.accounts.posts.getUpVotedPosts); + setupPageRoute(app, `/${name}/:userslug/downvoted`, accountMiddlewares, controllers.accounts.posts.getDownVotedPosts); + setupPageRoute(app, `/${name}/:userslug/edit`, accountMiddlewares, controllers.accounts.edit.get); + setupPageRoute(app, `/${name}/:userslug/edit/username`, accountMiddlewares, controllers.accounts.edit.username); + setupPageRoute(app, `/${name}/:userslug/edit/email`, accountMiddlewares, controllers.accounts.edit.email); + setupPageRoute(app, `/${name}/:userslug/edit/password`, accountMiddlewares, controllers.accounts.edit.password); + app.use('/.well-known/change-password', (request, res) => { + res.redirect('/me/edit/password'); + }); + setupPageRoute(app, `/${name}/:userslug/info`, accountMiddlewares, controllers.accounts.info.get); + setupPageRoute(app, `/${name}/:userslug/settings`, accountMiddlewares, controllers.accounts.settings.get); + setupPageRoute(app, `/${name}/:userslug/uploads`, accountMiddlewares, controllers.accounts.uploads.get); + setupPageRoute(app, `/${name}/:userslug/consent`, accountMiddlewares, controllers.accounts.consent.get); + setupPageRoute(app, `/${name}/:userslug/blocks`, accountMiddlewares, controllers.accounts.blocks.getBlocks); + setupPageRoute(app, `/${name}/:userslug/sessions`, accountMiddlewares, controllers.accounts.sessions.get); + + setupPageRoute(app, '/notifications', [middleware.ensureLoggedIn], controllers.accounts.notifications.get); + setupPageRoute(app, `/${name}/:userslug/chats/:roomid?`, middlewares, controllers.accounts.chats.get); + setupPageRoute(app, '/chats/:roomid?', [middleware.ensureLoggedIn], controllers.accounts.chats.redirectToChat); }; diff --git a/src/routes/write/admin.js b/src/routes/write/admin.js index 873e814..ebc2a74 100644 --- a/src/routes/write/admin.js +++ b/src/routes/write/admin.js @@ -5,15 +5,15 @@ const middleware = require('../../middleware'); const controllers = require('../../controllers'); const routeHelpers = require('../helpers'); -const { setupApiRoute } = routeHelpers; +const {setupApiRoute} = routeHelpers; module.exports = function () { - const middlewares = [middleware.ensureLoggedIn, middleware.admin.checkPrivileges]; + const middlewares = [middleware.ensureLoggedIn, middleware.admin.checkPrivileges]; - setupApiRoute(router, 'put', '/settings/:setting', [...middlewares, middleware.checkRequired.bind(null, ['value'])], controllers.write.admin.updateSetting); + setupApiRoute(router, 'put', '/settings/:setting', [...middlewares, middleware.checkRequired.bind(null, ['value'])], controllers.write.admin.updateSetting); - setupApiRoute(router, 'get', '/analytics', [...middlewares], controllers.write.admin.getAnalyticsKeys); - setupApiRoute(router, 'get', '/analytics/:set', [...middlewares], controllers.write.admin.getAnalyticsData); + setupApiRoute(router, 'get', '/analytics', [...middlewares], controllers.write.admin.getAnalyticsKeys); + setupApiRoute(router, 'get', '/analytics/:set', [...middlewares], controllers.write.admin.getAnalyticsData); - return router; + return router; }; diff --git a/src/routes/write/categories.js b/src/routes/write/categories.js index ce0ece3..0b81367 100644 --- a/src/routes/write/categories.js +++ b/src/routes/write/categories.js @@ -5,22 +5,22 @@ const middleware = require('../../middleware'); const controllers = require('../../controllers'); const routeHelpers = require('../helpers'); -const { setupApiRoute } = routeHelpers; +const {setupApiRoute} = routeHelpers; module.exports = function () { - const middlewares = [middleware.ensureLoggedIn]; + const middlewares = [middleware.ensureLoggedIn]; - setupApiRoute(router, 'post', '/', [...middlewares, middleware.checkRequired.bind(null, ['name'])], controllers.write.categories.create); - setupApiRoute(router, 'get', '/:cid', [], controllers.write.categories.get); - setupApiRoute(router, 'put', '/:cid', [...middlewares], controllers.write.categories.update); - setupApiRoute(router, 'delete', '/:cid', [...middlewares], controllers.write.categories.delete); + setupApiRoute(router, 'post', '/', [...middlewares, middleware.checkRequired.bind(null, ['name'])], controllers.write.categories.create); + setupApiRoute(router, 'get', '/:cid', [], controllers.write.categories.get); + setupApiRoute(router, 'put', '/:cid', [...middlewares], controllers.write.categories.update); + setupApiRoute(router, 'delete', '/:cid', [...middlewares], controllers.write.categories.delete); - setupApiRoute(router, 'get', '/:cid/privileges', [...middlewares], controllers.write.categories.getPrivileges); - setupApiRoute(router, 'put', '/:cid/privileges/:privilege', [...middlewares, middleware.checkRequired.bind(null, ['member'])], controllers.write.categories.setPrivilege); - setupApiRoute(router, 'delete', '/:cid/privileges/:privilege', [...middlewares, middleware.checkRequired.bind(null, ['member'])], controllers.write.categories.setPrivilege); + setupApiRoute(router, 'get', '/:cid/privileges', [...middlewares], controllers.write.categories.getPrivileges); + setupApiRoute(router, 'put', '/:cid/privileges/:privilege', [...middlewares, middleware.checkRequired.bind(null, ['member'])], controllers.write.categories.setPrivilege); + setupApiRoute(router, 'delete', '/:cid/privileges/:privilege', [...middlewares, middleware.checkRequired.bind(null, ['member'])], controllers.write.categories.setPrivilege); - setupApiRoute(router, 'put', '/:cid/moderator/:uid', [...middlewares], controllers.write.categories.setModerator); - setupApiRoute(router, 'delete', '/:cid/moderator/:uid', [...middlewares], controllers.write.categories.setModerator); + setupApiRoute(router, 'put', '/:cid/moderator/:uid', [...middlewares], controllers.write.categories.setModerator); + setupApiRoute(router, 'delete', '/:cid/moderator/:uid', [...middlewares], controllers.write.categories.setModerator); - return router; + return router; }; diff --git a/src/routes/write/chats.js b/src/routes/write/chats.js index 89ba95f..b5abc9c 100644 --- a/src/routes/write/chats.js +++ b/src/routes/write/chats.js @@ -5,30 +5,30 @@ const middleware = require('../../middleware'); const controllers = require('../../controllers'); const routeHelpers = require('../helpers'); -const { setupApiRoute } = routeHelpers; +const {setupApiRoute} = routeHelpers; module.exports = function () { - const middlewares = [middleware.ensureLoggedIn, middleware.canChat]; + const middlewares = [middleware.ensureLoggedIn, middleware.canChat]; - setupApiRoute(router, 'get', '/', [...middlewares], controllers.write.chats.list); - setupApiRoute(router, 'post', '/', [...middlewares, middleware.checkRequired.bind(null, ['uids'])], controllers.write.chats.create); + setupApiRoute(router, 'get', '/', [...middlewares], controllers.write.chats.list); + setupApiRoute(router, 'post', '/', [...middlewares, middleware.checkRequired.bind(null, ['uids'])], controllers.write.chats.create); - setupApiRoute(router, 'head', '/:roomId', [...middlewares, middleware.assert.room], controllers.write.chats.exists); - setupApiRoute(router, 'get', '/:roomId', [...middlewares, middleware.assert.room], controllers.write.chats.get); - setupApiRoute(router, 'post', '/:roomId', [...middlewares, middleware.assert.room, middleware.checkRequired.bind(null, ['message'])], controllers.write.chats.post); - setupApiRoute(router, 'put', '/:roomId', [...middlewares, middleware.assert.room, middleware.checkRequired.bind(null, ['name'])], controllers.write.chats.rename); - // no route for room deletion, noted here just in case... + setupApiRoute(router, 'head', '/:roomId', [...middlewares, middleware.assert.room], controllers.write.chats.exists); + setupApiRoute(router, 'get', '/:roomId', [...middlewares, middleware.assert.room], controllers.write.chats.get); + setupApiRoute(router, 'post', '/:roomId', [...middlewares, middleware.assert.room, middleware.checkRequired.bind(null, ['message'])], controllers.write.chats.post); + setupApiRoute(router, 'put', '/:roomId', [...middlewares, middleware.assert.room, middleware.checkRequired.bind(null, ['name'])], controllers.write.chats.rename); + // No route for room deletion, noted here just in case... - setupApiRoute(router, 'get', '/:roomId/users', [...middlewares, middleware.assert.room], controllers.write.chats.users); - setupApiRoute(router, 'post', '/:roomId/users', [...middlewares, middleware.assert.room, middleware.checkRequired.bind(null, ['uids'])], controllers.write.chats.invite); - setupApiRoute(router, 'delete', '/:roomId/users', [...middlewares, middleware.assert.room, middleware.checkRequired.bind(null, ['uids'])], controllers.write.chats.kick); - setupApiRoute(router, 'delete', '/:roomId/users/:uid', [...middlewares, middleware.assert.room, middleware.assert.user], controllers.write.chats.kickUser); + setupApiRoute(router, 'get', '/:roomId/users', [...middlewares, middleware.assert.room], controllers.write.chats.users); + setupApiRoute(router, 'post', '/:roomId/users', [...middlewares, middleware.assert.room, middleware.checkRequired.bind(null, ['uids'])], controllers.write.chats.invite); + setupApiRoute(router, 'delete', '/:roomId/users', [...middlewares, middleware.assert.room, middleware.checkRequired.bind(null, ['uids'])], controllers.write.chats.kick); + setupApiRoute(router, 'delete', '/:roomId/users/:uid', [...middlewares, middleware.assert.room, middleware.assert.user], controllers.write.chats.kickUser); - setupApiRoute(router, 'get', '/:roomId/messages', [...middlewares, middleware.assert.room], controllers.write.chats.messages.list); - setupApiRoute(router, 'get', '/:roomId/messages/:mid', [...middlewares, middleware.assert.room, middleware.assert.message], controllers.write.chats.messages.get); - setupApiRoute(router, 'put', '/:roomId/messages/:mid', [...middlewares, middleware.assert.room, middleware.assert.message], controllers.write.chats.messages.edit); - setupApiRoute(router, 'post', '/:roomId/messages/:mid', [...middlewares, middleware.assert.room, middleware.assert.message], controllers.write.chats.messages.restore); - setupApiRoute(router, 'delete', '/:roomId/messages/:mid', [...middlewares, middleware.assert.room, middleware.assert.message], controllers.write.chats.messages.delete); + setupApiRoute(router, 'get', '/:roomId/messages', [...middlewares, middleware.assert.room], controllers.write.chats.messages.list); + setupApiRoute(router, 'get', '/:roomId/messages/:mid', [...middlewares, middleware.assert.room, middleware.assert.message], controllers.write.chats.messages.get); + setupApiRoute(router, 'put', '/:roomId/messages/:mid', [...middlewares, middleware.assert.room, middleware.assert.message], controllers.write.chats.messages.edit); + setupApiRoute(router, 'post', '/:roomId/messages/:mid', [...middlewares, middleware.assert.room, middleware.assert.message], controllers.write.chats.messages.restore); + setupApiRoute(router, 'delete', '/:roomId/messages/:mid', [...middlewares, middleware.assert.room, middleware.assert.message], controllers.write.chats.messages.delete); - return router; + return router; }; diff --git a/src/routes/write/files.js b/src/routes/write/files.js index 6f8d787..f9c05bd 100644 --- a/src/routes/write/files.js +++ b/src/routes/write/files.js @@ -5,29 +5,29 @@ const middleware = require('../../middleware'); const controllers = require('../../controllers'); const routeHelpers = require('../helpers'); -const { setupApiRoute } = routeHelpers; +const {setupApiRoute} = routeHelpers; module.exports = function () { - const middlewares = [middleware.ensureLoggedIn, middleware.admin.checkPrivileges]; + const middlewares = [middleware.ensureLoggedIn, middleware.admin.checkPrivileges]; - // setupApiRoute(router, 'put', '/', [ - // ...middlewares, - // middleware.checkRequired.bind(null, ['path']), - // middleware.assert.folder - // ], controllers.write.files.upload); - setupApiRoute(router, 'delete', '/', [ - ...middlewares, - middleware.checkRequired.bind(null, ['path']), - middleware.assert.path, - ], controllers.write.files.delete); + // SetupApiRoute(router, 'put', '/', [ + // ...middlewares, + // middleware.checkRequired.bind(null, ['path']), + // middleware.assert.folder + // ], controllers.write.files.upload); + setupApiRoute(router, 'delete', '/', [ + ...middlewares, + middleware.checkRequired.bind(null, ['path']), + middleware.assert.path, + ], controllers.write.files.delete); - setupApiRoute(router, 'put', '/folder', [ - ...middlewares, - middleware.checkRequired.bind(null, ['path', 'folderName']), - middleware.assert.path, - // Should come after assert.path - middleware.assert.folderName, - ], controllers.write.files.createFolder); + setupApiRoute(router, 'put', '/folder', [ + ...middlewares, + middleware.checkRequired.bind(null, ['path', 'folderName']), + middleware.assert.path, + // Should come after assert.path + middleware.assert.folderName, + ], controllers.write.files.createFolder); - return router; + return router; }; diff --git a/src/routes/write/flags.js b/src/routes/write/flags.js index 76b8998..329e2bd 100644 --- a/src/routes/write/flags.js +++ b/src/routes/write/flags.js @@ -5,19 +5,19 @@ const middleware = require('../../middleware'); const controllers = require('../../controllers'); const routeHelpers = require('../helpers'); -const { setupApiRoute } = routeHelpers; +const {setupApiRoute} = routeHelpers; module.exports = function () { - const middlewares = [middleware.ensureLoggedIn]; + const middlewares = [middleware.ensureLoggedIn]; - setupApiRoute(router, 'post', '/', [...middlewares], controllers.write.flags.create); + setupApiRoute(router, 'post', '/', [...middlewares], controllers.write.flags.create); - setupApiRoute(router, 'get', '/:flagId', [...middlewares, middleware.assert.flag], controllers.write.flags.get); - setupApiRoute(router, 'put', '/:flagId', [...middlewares, middleware.assert.flag], controllers.write.flags.update); - setupApiRoute(router, 'delete', '/:flagId', [...middlewares, middleware.assert.flag], controllers.write.flags.delete); + setupApiRoute(router, 'get', '/:flagId', [...middlewares, middleware.assert.flag], controllers.write.flags.get); + setupApiRoute(router, 'put', '/:flagId', [...middlewares, middleware.assert.flag], controllers.write.flags.update); + setupApiRoute(router, 'delete', '/:flagId', [...middlewares, middleware.assert.flag], controllers.write.flags.delete); - setupApiRoute(router, 'post', '/:flagId/notes', [...middlewares, middleware.assert.flag], controllers.write.flags.appendNote); - setupApiRoute(router, 'delete', '/:flagId/notes/:datetime', [...middlewares, middleware.assert.flag], controllers.write.flags.deleteNote); + setupApiRoute(router, 'post', '/:flagId/notes', [...middlewares, middleware.assert.flag], controllers.write.flags.appendNote); + setupApiRoute(router, 'delete', '/:flagId/notes/:datetime', [...middlewares, middleware.assert.flag], controllers.write.flags.deleteNote); - return router; + return router; }; diff --git a/src/routes/write/groups.js b/src/routes/write/groups.js index 050f3fc..65f9cc7 100644 --- a/src/routes/write/groups.js +++ b/src/routes/write/groups.js @@ -5,19 +5,19 @@ const middleware = require('../../middleware'); const controllers = require('../../controllers'); const routeHelpers = require('../helpers'); -const { setupApiRoute } = routeHelpers; +const {setupApiRoute} = routeHelpers; module.exports = function () { - const middlewares = [middleware.ensureLoggedIn]; + const middlewares = [middleware.ensureLoggedIn]; - setupApiRoute(router, 'post', '/', [...middlewares, middleware.checkRequired.bind(null, ['name'])], controllers.write.groups.create); - setupApiRoute(router, 'head', '/:slug', [middleware.assert.group], controllers.write.groups.exists); - setupApiRoute(router, 'put', '/:slug', [...middlewares, middleware.assert.group], controllers.write.groups.update); - setupApiRoute(router, 'delete', '/:slug', [...middlewares, middleware.assert.group], controllers.write.groups.delete); - setupApiRoute(router, 'put', '/:slug/membership/:uid', [...middlewares, middleware.assert.group], controllers.write.groups.join); - setupApiRoute(router, 'delete', '/:slug/membership/:uid', [...middlewares, middleware.assert.group], controllers.write.groups.leave); - setupApiRoute(router, 'put', '/:slug/ownership/:uid', [...middlewares, middleware.assert.group], controllers.write.groups.grant); - setupApiRoute(router, 'delete', '/:slug/ownership/:uid', [...middlewares, middleware.assert.group], controllers.write.groups.rescind); + setupApiRoute(router, 'post', '/', [...middlewares, middleware.checkRequired.bind(null, ['name'])], controllers.write.groups.create); + setupApiRoute(router, 'head', '/:slug', [middleware.assert.group], controllers.write.groups.exists); + setupApiRoute(router, 'put', '/:slug', [...middlewares, middleware.assert.group], controllers.write.groups.update); + setupApiRoute(router, 'delete', '/:slug', [...middlewares, middleware.assert.group], controllers.write.groups.delete); + setupApiRoute(router, 'put', '/:slug/membership/:uid', [...middlewares, middleware.assert.group], controllers.write.groups.join); + setupApiRoute(router, 'delete', '/:slug/membership/:uid', [...middlewares, middleware.assert.group], controllers.write.groups.leave); + setupApiRoute(router, 'put', '/:slug/ownership/:uid', [...middlewares, middleware.assert.group], controllers.write.groups.grant); + setupApiRoute(router, 'delete', '/:slug/ownership/:uid', [...middlewares, middleware.assert.group], controllers.write.groups.rescind); - return router; + return router; }; diff --git a/src/routes/write/index.js b/src/routes/write/index.js index 153d25e..ab463e6 100644 --- a/src/routes/write/index.js +++ b/src/routes/write/index.js @@ -9,65 +9,65 @@ const helpers = require('../../controllers/helpers'); const Write = module.exports; -Write.reload = async (params) => { - const { router } = params; - let apiSettings = await meta.settings.get('core.api'); - plugins.hooks.register('core', { - hook: 'action:settings.set', - method: async (data) => { - if (data.plugin === 'core.api') { - apiSettings = await meta.settings.get('core.api'); - } - }, - }); +Write.reload = async parameters => { + const {router} = parameters; + let apiSettings = await meta.settings.get('core.api'); + plugins.hooks.register('core', { + hook: 'action:settings.set', + async method(data) { + if (data.plugin === 'core.api') { + apiSettings = await meta.settings.get('core.api'); + } + }, + }); - router.use('/api/v3', (req, res, next) => { - // Require https if configured so - if (apiSettings.requireHttps === 'on' && req.protocol !== 'https') { - res.set('Upgrade', 'TLS/1.0, HTTP/1.1'); - return helpers.formatApiResponse(426, res); - } + router.use('/api/v3', (request, res, next) => { + // Require https if configured so + if (apiSettings.requireHttps === 'on' && request.protocol !== 'https') { + res.set('Upgrade', 'TLS/1.0, HTTP/1.1'); + return helpers.formatApiResponse(426, res); + } - res.locals.isAPI = true; - next(); - }); + res.locals.isAPI = true; + next(); + }); - router.use('/api/v3/users', require('./users')()); - router.use('/api/v3/groups', require('./groups')()); - router.use('/api/v3/categories', require('./categories')()); - router.use('/api/v3/topics', require('./topics')()); - router.use('/api/v3/posts', require('./posts')()); - router.use('/api/v3/chats', require('./chats')()); - router.use('/api/v3/flags', require('./flags')()); - router.use('/api/v3/admin', require('./admin')()); - router.use('/api/v3/files', require('./files')()); - router.use('/api/v3/utilities', require('./utilities')()); + router.use('/api/v3/users', require('./users')()); + router.use('/api/v3/groups', require('./groups')()); + router.use('/api/v3/categories', require('./categories')()); + router.use('/api/v3/topics', require('./topics')()); + router.use('/api/v3/posts', require('./posts')()); + router.use('/api/v3/chats', require('./chats')()); + router.use('/api/v3/flags', require('./flags')()); + router.use('/api/v3/admin', require('./admin')()); + router.use('/api/v3/files', require('./files')()); + router.use('/api/v3/utilities', require('./utilities')()); - router.get('/api/v3/ping', writeControllers.utilities.ping.get); - router.post('/api/v3/ping', middleware.authenticateRequest, middleware.ensureLoggedIn, writeControllers.utilities.ping.post); + router.get('/api/v3/ping', writeControllers.utilities.ping.get); + router.post('/api/v3/ping', middleware.authenticateRequest, middleware.ensureLoggedIn, writeControllers.utilities.ping.post); - /** + /** * Plugins can add routes to the Write API by attaching a listener to the * below hook. The hooks added to the passed-in router will be mounted to * `/api/v3/plugins`. */ - const pluginRouter = require('express').Router(); - await plugins.hooks.fire('static:api.routes', { - router: pluginRouter, - middleware, - helpers, - }); - winston.info(`[api] Adding ${pluginRouter.stack.length} route(s) to \`api/v3/plugins\``); - router.use('/api/v3/plugins', pluginRouter); + const pluginRouter = require('express').Router(); + await plugins.hooks.fire('static:api.routes', { + router: pluginRouter, + middleware, + helpers, + }); + winston.info(`[api] Adding ${pluginRouter.stack.length} route(s) to \`api/v3/plugins\``); + router.use('/api/v3/plugins', pluginRouter); - // 404 handling - router.use('/api/v3', (req, res) => { - helpers.formatApiResponse(404, res); - }); + // 404 handling + router.use('/api/v3', (request, res) => { + helpers.formatApiResponse(404, res); + }); }; -Write.cleanup = (req) => { - if (req && req.session) { - req.session.destroy(); - } +Write.cleanup = request => { + if (request && request.session) { + request.session.destroy(); + } }; diff --git a/src/routes/write/posts.js b/src/routes/write/posts.js index f300171..dc9aa31 100644 --- a/src/routes/write/posts.js +++ b/src/routes/write/posts.js @@ -5,37 +5,37 @@ const middleware = require('../../middleware'); const controllers = require('../../controllers'); const routeHelpers = require('../helpers'); -const { setupApiRoute } = routeHelpers; +const {setupApiRoute} = routeHelpers; module.exports = function () { - const middlewares = [middleware.ensureLoggedIn]; + const middlewares = [middleware.ensureLoggedIn]; - setupApiRoute(router, 'get', '/:pid', [], controllers.write.posts.get); - // There is no POST route because you POST to a topic to create a new post. Intuitive, no? - setupApiRoute(router, 'put', '/:pid', [...middlewares, middleware.checkRequired.bind(null, ['content'])], controllers.write.posts.edit); - setupApiRoute(router, 'delete', '/:pid', [...middlewares, middleware.assert.post], controllers.write.posts.purge); + setupApiRoute(router, 'get', '/:pid', [], controllers.write.posts.get); + // There is no POST route because you POST to a topic to create a new post. Intuitive, no? + setupApiRoute(router, 'put', '/:pid', [...middlewares, middleware.checkRequired.bind(null, ['content'])], controllers.write.posts.edit); + setupApiRoute(router, 'delete', '/:pid', [...middlewares, middleware.assert.post], controllers.write.posts.purge); - setupApiRoute(router, 'put', '/:pid/state', [...middlewares, middleware.assert.post], controllers.write.posts.restore); - setupApiRoute(router, 'delete', '/:pid/state', [...middlewares, middleware.assert.post], controllers.write.posts.delete); + setupApiRoute(router, 'put', '/:pid/state', [...middlewares, middleware.assert.post], controllers.write.posts.restore); + setupApiRoute(router, 'delete', '/:pid/state', [...middlewares, middleware.assert.post], controllers.write.posts.delete); - setupApiRoute(router, 'put', '/:pid/move', [...middlewares, middleware.assert.post, middleware.checkRequired.bind(null, ['tid'])], controllers.write.posts.move); + setupApiRoute(router, 'put', '/:pid/move', [...middlewares, middleware.assert.post, middleware.checkRequired.bind(null, ['tid'])], controllers.write.posts.move); - setupApiRoute(router, 'put', '/:pid/vote', [...middlewares, middleware.checkRequired.bind(null, ['delta']), middleware.assert.post], controllers.write.posts.vote); - setupApiRoute(router, 'delete', '/:pid/vote', [...middlewares, middleware.assert.post], controllers.write.posts.unvote); + setupApiRoute(router, 'put', '/:pid/vote', [...middlewares, middleware.checkRequired.bind(null, ['delta']), middleware.assert.post], controllers.write.posts.vote); + setupApiRoute(router, 'delete', '/:pid/vote', [...middlewares, middleware.assert.post], controllers.write.posts.unvote); - setupApiRoute(router, 'put', '/:pid/bookmark', [...middlewares, middleware.assert.post], controllers.write.posts.bookmark); - setupApiRoute(router, 'delete', '/:pid/bookmark', [...middlewares, middleware.assert.post], controllers.write.posts.unbookmark); + setupApiRoute(router, 'put', '/:pid/bookmark', [...middlewares, middleware.assert.post], controllers.write.posts.bookmark); + setupApiRoute(router, 'delete', '/:pid/bookmark', [...middlewares, middleware.assert.post], controllers.write.posts.unbookmark); - // New pinning feature - setupApiRoute(router, 'put', '/:pid/pin', [...middlewares, middleware.assert.post], controllers.write.posts.pin); - setupApiRoute(router, 'delete', '/:pid/pin', [...middlewares, middleware.assert.post], controllers.write.posts.unpin); + // New pinning feature + setupApiRoute(router, 'put', '/:pid/pin', [...middlewares, middleware.assert.post], controllers.write.posts.pin); + setupApiRoute(router, 'delete', '/:pid/pin', [...middlewares, middleware.assert.post], controllers.write.posts.unpin); - setupApiRoute(router, 'put', '/:pid/resolve', [...middlewares, middleware.assert.post], controllers.write.posts.resolve); + setupApiRoute(router, 'put', '/:pid/resolve', [...middlewares, middleware.assert.post], controllers.write.posts.resolve); - setupApiRoute(router, 'get', '/:pid/diffs', [middleware.assert.post], controllers.write.posts.getDiffs); - setupApiRoute(router, 'get', '/:pid/diffs/:since', [middleware.assert.post], controllers.write.posts.loadDiff); - setupApiRoute(router, 'put', '/:pid/diffs/:since', [...middlewares, middleware.assert.post], controllers.write.posts.restoreDiff); - setupApiRoute(router, 'delete', '/:pid/diffs/:timestamp', [...middlewares, middleware.assert.post], controllers.write.posts.deleteDiff); + setupApiRoute(router, 'get', '/:pid/diffs', [middleware.assert.post], controllers.write.posts.getDiffs); + setupApiRoute(router, 'get', '/:pid/diffs/:since', [middleware.assert.post], controllers.write.posts.loadDiff); + setupApiRoute(router, 'put', '/:pid/diffs/:since', [...middlewares, middleware.assert.post], controllers.write.posts.restoreDiff); + setupApiRoute(router, 'delete', '/:pid/diffs/:timestamp', [...middlewares, middleware.assert.post], controllers.write.posts.deleteDiff); - return router; + return router; }; diff --git a/src/routes/write/topics.js b/src/routes/write/topics.js index 4f9674f..5176ea5 100644 --- a/src/routes/write/topics.js +++ b/src/routes/write/topics.js @@ -5,47 +5,47 @@ const middleware = require('../../middleware'); const controllers = require('../../controllers'); const routeHelpers = require('../helpers'); -const { setupApiRoute } = routeHelpers; +const {setupApiRoute} = routeHelpers; module.exports = function () { - const middlewares = [middleware.ensureLoggedIn]; + const middlewares = [middleware.ensureLoggedIn]; - const multipart = require('connect-multiparty'); - const multipartMiddleware = multipart(); + const multipart = require('connect-multiparty'); + const multipartMiddleware = multipart(); - setupApiRoute(router, 'post', '/', [middleware.checkRequired.bind(null, ['cid', 'title', 'content'])], controllers.write.topics.create); - setupApiRoute(router, 'get', '/:tid', [], controllers.write.topics.get); - setupApiRoute(router, 'post', '/:tid', [middleware.checkRequired.bind(null, ['content']), middleware.assert.topic], controllers.write.topics.reply); - setupApiRoute(router, 'delete', '/:tid', [...middlewares], controllers.write.topics.purge); + setupApiRoute(router, 'post', '/', [middleware.checkRequired.bind(null, ['cid', 'title', 'content'])], controllers.write.topics.create); + setupApiRoute(router, 'get', '/:tid', [], controllers.write.topics.get); + setupApiRoute(router, 'post', '/:tid', [middleware.checkRequired.bind(null, ['content']), middleware.assert.topic], controllers.write.topics.reply); + setupApiRoute(router, 'delete', '/:tid', [...middlewares], controllers.write.topics.purge); - setupApiRoute(router, 'put', '/:tid/state', [...middlewares], controllers.write.topics.restore); - setupApiRoute(router, 'delete', '/:tid/state', [...middlewares], controllers.write.topics.delete); + setupApiRoute(router, 'put', '/:tid/state', [...middlewares], controllers.write.topics.restore); + setupApiRoute(router, 'delete', '/:tid/state', [...middlewares], controllers.write.topics.delete); - setupApiRoute(router, 'put', '/:tid/pin', [...middlewares, middleware.assert.topic], controllers.write.topics.pin); - setupApiRoute(router, 'delete', '/:tid/pin', [...middlewares], controllers.write.topics.unpin); + setupApiRoute(router, 'put', '/:tid/pin', [...middlewares, middleware.assert.topic], controllers.write.topics.pin); + setupApiRoute(router, 'delete', '/:tid/pin', [...middlewares], controllers.write.topics.unpin); - setupApiRoute(router, 'put', '/:tid/lock', [...middlewares], controllers.write.topics.lock); - setupApiRoute(router, 'delete', '/:tid/lock', [...middlewares], controllers.write.topics.unlock); + setupApiRoute(router, 'put', '/:tid/lock', [...middlewares], controllers.write.topics.lock); + setupApiRoute(router, 'delete', '/:tid/lock', [...middlewares], controllers.write.topics.unlock); - setupApiRoute(router, 'put', '/:tid/private', [...middlewares], controllers.write.topics.private); - setupApiRoute(router, 'delete', '/:tid/private', [...middlewares], controllers.write.topics.public); + setupApiRoute(router, 'put', '/:tid/private', [...middlewares], controllers.write.topics.private); + setupApiRoute(router, 'delete', '/:tid/private', [...middlewares], controllers.write.topics.public); - setupApiRoute(router, 'put', '/:tid/follow', [...middlewares, middleware.assert.topic], controllers.write.topics.follow); - setupApiRoute(router, 'delete', '/:tid/follow', [...middlewares, middleware.assert.topic], controllers.write.topics.unfollow); - setupApiRoute(router, 'put', '/:tid/ignore', [...middlewares, middleware.assert.topic], controllers.write.topics.ignore); - setupApiRoute(router, 'delete', '/:tid/ignore', [...middlewares, middleware.assert.topic], controllers.write.topics.unfollow); // intentional, unignore == unfollow + setupApiRoute(router, 'put', '/:tid/follow', [...middlewares, middleware.assert.topic], controllers.write.topics.follow); + setupApiRoute(router, 'delete', '/:tid/follow', [...middlewares, middleware.assert.topic], controllers.write.topics.unfollow); + setupApiRoute(router, 'put', '/:tid/ignore', [...middlewares, middleware.assert.topic], controllers.write.topics.ignore); + setupApiRoute(router, 'delete', '/:tid/ignore', [...middlewares, middleware.assert.topic], controllers.write.topics.unfollow); // Intentional, unignore == unfollow - setupApiRoute(router, 'put', '/:tid/tags', [...middlewares, middleware.checkRequired.bind(null, ['tags']), middleware.assert.topic], controllers.write.topics.addTags); - setupApiRoute(router, 'delete', '/:tid/tags', [...middlewares, middleware.assert.topic], controllers.write.topics.deleteTags); + setupApiRoute(router, 'put', '/:tid/tags', [...middlewares, middleware.checkRequired.bind(null, ['tags']), middleware.assert.topic], controllers.write.topics.addTags); + setupApiRoute(router, 'delete', '/:tid/tags', [...middlewares, middleware.assert.topic], controllers.write.topics.deleteTags); - setupApiRoute(router, 'get', '/:tid/thumbs', [], controllers.write.topics.getThumbs); - setupApiRoute(router, 'post', '/:tid/thumbs', [multipartMiddleware, middleware.validateFiles, middleware.uploads.ratelimit, ...middlewares], controllers.write.topics.addThumb); - setupApiRoute(router, 'put', '/:tid/thumbs', [...middlewares, middleware.checkRequired.bind(null, ['tid'])], controllers.write.topics.migrateThumbs); - setupApiRoute(router, 'delete', '/:tid/thumbs', [...middlewares, middleware.checkRequired.bind(null, ['path'])], controllers.write.topics.deleteThumb); - setupApiRoute(router, 'put', '/:tid/thumbs/order', [...middlewares, middleware.checkRequired.bind(null, ['path', 'order'])], controllers.write.topics.reorderThumbs); + setupApiRoute(router, 'get', '/:tid/thumbs', [], controllers.write.topics.getThumbs); + setupApiRoute(router, 'post', '/:tid/thumbs', [multipartMiddleware, middleware.validateFiles, middleware.uploads.ratelimit, ...middlewares], controllers.write.topics.addThumb); + setupApiRoute(router, 'put', '/:tid/thumbs', [...middlewares, middleware.checkRequired.bind(null, ['tid'])], controllers.write.topics.migrateThumbs); + setupApiRoute(router, 'delete', '/:tid/thumbs', [...middlewares, middleware.checkRequired.bind(null, ['path'])], controllers.write.topics.deleteThumb); + setupApiRoute(router, 'put', '/:tid/thumbs/order', [...middlewares, middleware.checkRequired.bind(null, ['path', 'order'])], controllers.write.topics.reorderThumbs); - setupApiRoute(router, 'get', '/:tid/events', [middleware.assert.topic], controllers.write.topics.getEvents); - setupApiRoute(router, 'delete', '/:tid/events/:eventId', [middleware.assert.topic], controllers.write.topics.deleteEvent); + setupApiRoute(router, 'get', '/:tid/events', [middleware.assert.topic], controllers.write.topics.getEvents); + setupApiRoute(router, 'delete', '/:tid/events/:eventId', [middleware.assert.topic], controllers.write.topics.deleteEvent); - return router; + return router; }; diff --git a/src/routes/write/users.js b/src/routes/write/users.js index ad071df..e2cbb78 100644 --- a/src/routes/write/users.js +++ b/src/routes/write/users.js @@ -5,62 +5,62 @@ const middleware = require('../../middleware'); const controllers = require('../../controllers'); const routeHelpers = require('../helpers'); -const { setupApiRoute } = routeHelpers; +const {setupApiRoute} = routeHelpers; // eslint-disable-next-line no-unused-vars function guestRoutes() { - // like registration, login... + // Like registration, login... } function authenticatedRoutes() { - const middlewares = [middleware.ensureLoggedIn]; + const middlewares = [middleware.ensureLoggedIn]; - setupApiRoute(router, 'post', '/', [...middlewares, middleware.checkRequired.bind(null, ['username'])], controllers.write.users.create); - setupApiRoute(router, 'delete', '/', [...middlewares, middleware.checkRequired.bind(null, ['uids'])], controllers.write.users.deleteMany); + setupApiRoute(router, 'post', '/', [...middlewares, middleware.checkRequired.bind(null, ['username'])], controllers.write.users.create); + setupApiRoute(router, 'delete', '/', [...middlewares, middleware.checkRequired.bind(null, ['uids'])], controllers.write.users.deleteMany); - setupApiRoute(router, 'head', '/:uid', [middleware.assert.user], controllers.write.users.exists); - setupApiRoute(router, 'get', '/:uid', [...middlewares, middleware.assert.user], controllers.write.users.get); - setupApiRoute(router, 'put', '/:uid', [...middlewares, middleware.assert.user], controllers.write.users.update); - setupApiRoute(router, 'delete', '/:uid', [...middlewares, middleware.assert.user], controllers.write.users.delete); - setupApiRoute(router, 'put', '/:uid/picture', [...middlewares, middleware.assert.user], controllers.write.users.changePicture); - setupApiRoute(router, 'delete', '/:uid/content', [...middlewares, middleware.assert.user], controllers.write.users.deleteContent); - setupApiRoute(router, 'delete', '/:uid/account', [...middlewares, middleware.assert.user], controllers.write.users.deleteAccount); + setupApiRoute(router, 'head', '/:uid', [middleware.assert.user], controllers.write.users.exists); + setupApiRoute(router, 'get', '/:uid', [...middlewares, middleware.assert.user], controllers.write.users.get); + setupApiRoute(router, 'put', '/:uid', [...middlewares, middleware.assert.user], controllers.write.users.update); + setupApiRoute(router, 'delete', '/:uid', [...middlewares, middleware.assert.user], controllers.write.users.delete); + setupApiRoute(router, 'put', '/:uid/picture', [...middlewares, middleware.assert.user], controllers.write.users.changePicture); + setupApiRoute(router, 'delete', '/:uid/content', [...middlewares, middleware.assert.user], controllers.write.users.deleteContent); + setupApiRoute(router, 'delete', '/:uid/account', [...middlewares, middleware.assert.user], controllers.write.users.deleteAccount); - setupApiRoute(router, 'put', '/:uid/settings', [...middlewares, middleware.checkRequired.bind(null, ['settings'])], controllers.write.users.updateSettings); + setupApiRoute(router, 'put', '/:uid/settings', [...middlewares, middleware.checkRequired.bind(null, ['settings'])], controllers.write.users.updateSettings); - setupApiRoute(router, 'put', '/:uid/password', [...middlewares, middleware.checkRequired.bind(null, ['newPassword']), middleware.assert.user], controllers.write.users.changePassword); + setupApiRoute(router, 'put', '/:uid/password', [...middlewares, middleware.checkRequired.bind(null, ['newPassword']), middleware.assert.user], controllers.write.users.changePassword); - setupApiRoute(router, 'put', '/:uid/follow', [...middlewares, middleware.assert.user], controllers.write.users.follow); - setupApiRoute(router, 'delete', '/:uid/follow', [...middlewares, middleware.assert.user], controllers.write.users.unfollow); + setupApiRoute(router, 'put', '/:uid/follow', [...middlewares, middleware.assert.user], controllers.write.users.follow); + setupApiRoute(router, 'delete', '/:uid/follow', [...middlewares, middleware.assert.user], controllers.write.users.unfollow); - setupApiRoute(router, 'put', '/:uid/ban', [...middlewares, middleware.assert.user], controllers.write.users.ban); - setupApiRoute(router, 'delete', '/:uid/ban', [...middlewares, middleware.assert.user], controllers.write.users.unban); + setupApiRoute(router, 'put', '/:uid/ban', [...middlewares, middleware.assert.user], controllers.write.users.ban); + setupApiRoute(router, 'delete', '/:uid/ban', [...middlewares, middleware.assert.user], controllers.write.users.unban); - setupApiRoute(router, 'put', '/:uid/mute', [...middlewares, middleware.assert.user], controllers.write.users.mute); - setupApiRoute(router, 'delete', '/:uid/mute', [...middlewares, middleware.assert.user], controllers.write.users.unmute); + setupApiRoute(router, 'put', '/:uid/mute', [...middlewares, middleware.assert.user], controllers.write.users.mute); + setupApiRoute(router, 'delete', '/:uid/mute', [...middlewares, middleware.assert.user], controllers.write.users.unmute); - setupApiRoute(router, 'post', '/:uid/tokens', [...middlewares, middleware.assert.user], controllers.write.users.generateToken); - setupApiRoute(router, 'delete', '/:uid/tokens/:token', [...middlewares, middleware.assert.user], controllers.write.users.deleteToken); + setupApiRoute(router, 'post', '/:uid/tokens', [...middlewares, middleware.assert.user], controllers.write.users.generateToken); + setupApiRoute(router, 'delete', '/:uid/tokens/:token', [...middlewares, middleware.assert.user], controllers.write.users.deleteToken); - setupApiRoute(router, 'delete', '/:uid/sessions/:uuid', [...middlewares, middleware.assert.user], controllers.write.users.revokeSession); + setupApiRoute(router, 'delete', '/:uid/sessions/:uuid', [...middlewares, middleware.assert.user], controllers.write.users.revokeSession); - setupApiRoute(router, 'post', '/:uid/invites', middlewares, controllers.write.users.invite); - setupApiRoute(router, 'get', '/:uid/invites/groups', [...middlewares, middleware.assert.user], controllers.write.users.getInviteGroups); + setupApiRoute(router, 'post', '/:uid/invites', middlewares, controllers.write.users.invite); + setupApiRoute(router, 'get', '/:uid/invites/groups', [...middlewares, middleware.assert.user], controllers.write.users.getInviteGroups); - setupApiRoute(router, 'get', '/:uid/emails', [...middlewares, middleware.assert.user], controllers.write.users.listEmails); - setupApiRoute(router, 'get', '/:uid/emails/:email', [...middlewares, middleware.assert.user], controllers.write.users.getEmail); - setupApiRoute(router, 'post', '/:uid/emails/:email/confirm', [...middlewares, middleware.assert.user], controllers.write.users.confirmEmail); + setupApiRoute(router, 'get', '/:uid/emails', [...middlewares, middleware.assert.user], controllers.write.users.listEmails); + setupApiRoute(router, 'get', '/:uid/emails/:email', [...middlewares, middleware.assert.user], controllers.write.users.getEmail); + setupApiRoute(router, 'post', '/:uid/emails/:email/confirm', [...middlewares, middleware.assert.user], controllers.write.users.confirmEmail); - setupApiRoute(router, 'head', '/:uid/exports/:type', [...middlewares, middleware.assert.user, middleware.checkAccountPermissions], controllers.write.users.checkExportByType); - setupApiRoute(router, 'get', '/:uid/exports/:type', [...middlewares, middleware.assert.user, middleware.checkAccountPermissions], controllers.write.users.getExportByType); - setupApiRoute(router, 'post', '/:uid/exports/:type', [...middlewares, middleware.assert.user, middleware.checkAccountPermissions], controllers.write.users.generateExportsByType); + setupApiRoute(router, 'head', '/:uid/exports/:type', [...middlewares, middleware.assert.user, middleware.checkAccountPermissions], controllers.write.users.checkExportByType); + setupApiRoute(router, 'get', '/:uid/exports/:type', [...middlewares, middleware.assert.user, middleware.checkAccountPermissions], controllers.write.users.getExportByType); + setupApiRoute(router, 'post', '/:uid/exports/:type', [...middlewares, middleware.assert.user, middleware.checkAccountPermissions], controllers.write.users.generateExportsByType); - // Shorthand route to access user routes by userslug - router.all('/+bySlug/:userslug*?', [], controllers.write.users.redirectBySlug); + // Shorthand route to access user routes by userslug + router.all('/+bySlug/:userslug*?', [], controllers.write.users.redirectBySlug); } module.exports = function () { - authenticatedRoutes(); + authenticatedRoutes(); - return router; + return router; }; diff --git a/src/routes/write/utilities.js b/src/routes/write/utilities.js index 6e29c0c..c9fa1b7 100644 --- a/src/routes/write/utilities.js +++ b/src/routes/write/utilities.js @@ -5,13 +5,13 @@ const middleware = require('../../middleware'); const controllers = require('../../controllers'); const routeHelpers = require('../helpers'); -const { setupApiRoute } = routeHelpers; +const {setupApiRoute} = routeHelpers; module.exports = function () { - // The "ping" routes are mounted at root level, but for organizational purposes, - // the controllers are in `utilities.js` - const middlewares = middleware.checkRequired.bind(null, ['username', 'password']); - setupApiRoute(router, 'post', '/login', [middlewares], controllers.write.utilities.login); + // The "ping" routes are mounted at root level, but for organizational purposes, + // the controllers are in `utilities.js` + const middlewares = middleware.checkRequired.bind(null, ['username', 'password']); + setupApiRoute(router, 'post', '/login', [middlewares], controllers.write.utilities.login); - return router; + return router; }; diff --git a/src/search.js b/src/search.js index e09ead6..9e2aa47 100644 --- a/src/search.js +++ b/src/search.js @@ -1,7 +1,6 @@ 'use strict'; const _ = require('lodash'); - const db = require('./database'); const posts = require('./posts'); const topics = require('./topics'); @@ -14,311 +13,342 @@ const utils = require('./utils'); const search = module.exports; search.search = async function (data) { - const start = process.hrtime(); - data.sortBy = data.sortBy || 'relevance'; - - let result; - if (data.searchIn === 'posts' || data.searchIn === 'titles' || data.searchIn === 'titlesposts') { - result = await searchInContent(data); - } else if (data.searchIn === 'users') { - result = await user.search(data); - } else if (data.searchIn === 'categories') { - result = await categories.search(data); - } else if (data.searchIn === 'tags') { - result = await topics.searchAndLoadTags(data); - } else if (data.searchIn) { - result = await plugins.hooks.fire('filter:search.searchIn', { - data, - }); - } else { - throw new Error('[[error:unknown-search-filter]]'); - } - - result.time = (process.elapsedTimeSince(start) / 1000).toFixed(2); - return result; + const start = process.hrtime(); + data.sortBy = data.sortBy || 'relevance'; + + let result; + switch (data.searchIn) { + case 'posts': + case 'titles': + case 'titlesposts': { + result = await searchInContent(data); + + break; + } + + case 'users': { + result = await user.search(data); + + break; + } + + case 'categories': { + result = await categories.search(data); + + break; + } + + case 'tags': { + result = await topics.searchAndLoadTags(data); + + break; + } + + default: { if (data.searchIn) { + result = await plugins.hooks.fire('filter:search.searchIn', { + data, + }); + } else { + throw new Error('[[error:unknown-search-filter]]'); + } + } + } + + result.time = (process.elapsedTimeSince(start) / 1000).toFixed(2); + return result; }; async function searchInContent(data) { - data.uid = data.uid || 0; - - const [searchCids, searchUids] = await Promise.all([ - getSearchCids(data), - getSearchUids(data), - ]); - - async function doSearch(type, searchIn) { - if (searchIn.includes(data.searchIn)) { - const result = await plugins.hooks.fire('filter:search.query', { - index: type, - content: data.query, - matchWords: data.matchWords || 'all', - cid: searchCids, - uid: searchUids, - searchData: data, - ids: [], - }); - return Array.isArray(result) ? result : result.ids; - } - return []; - } - let pids = []; - let tids = []; - const inTopic = String(data.query || '').match(/^in:topic-([\d]+) /); - if (inTopic) { - const tid = inTopic[1]; - const cleanedTerm = data.query.replace(inTopic[0], ''); - pids = await topics.search(tid, cleanedTerm); - } else { - [pids, tids] = await Promise.all([ - doSearch('post', ['posts', 'titlesposts']), - doSearch('topic', ['titles', 'titlesposts']), - ]); - } - - const mainPids = await topics.getMainPids(tids); - - let allPids = mainPids.concat(pids).filter(Boolean); - - allPids = await privileges.posts.filter('topics:read', allPids, data.uid); - - allPids = await filterAndSort(allPids, data); - - const metadata = await plugins.hooks.fire('filter:search.inContent', { - pids: allPids, - data: data, - }); - - if (data.returnIds) { - const mainPidsSet = new Set(mainPids); - const mainPidToTid = _.zipObject(mainPids, tids); - const pidsSet = new Set(pids); - const returnPids = allPids.filter(pid => pidsSet.has(pid)); - const returnTids = allPids.filter(pid => mainPidsSet.has(pid)).map(pid => mainPidToTid[pid]); - return { pids: returnPids, tids: returnTids }; - } - - const itemsPerPage = Math.min(data.itemsPerPage || 10, 100); - const returnData = { - posts: [], - matchCount: metadata.pids.length, - pageCount: Math.max(1, Math.ceil(parseInt(metadata.pids.length, 10) / itemsPerPage)), - }; - - if (data.page) { - const start = Math.max(0, (data.page - 1)) * itemsPerPage; - metadata.pids = metadata.pids.slice(start, start + itemsPerPage); - } - - returnData.posts = await posts.getPostSummaryByPids(metadata.pids, data.uid, {}); - await plugins.hooks.fire('filter:search.contentGetResult', { result: returnData, data: data }); - delete metadata.pids; - delete metadata.data; - return Object.assign(returnData, metadata); + data.uid = data.uid || 0; + + const [searchCids, searchUids] = await Promise.all([ + getSearchCids(data), + getSearchUids(data), + ]); + + async function doSearch(type, searchIn) { + if (searchIn.includes(data.searchIn)) { + const result = await plugins.hooks.fire('filter:search.query', { + index: type, + content: data.query, + matchWords: data.matchWords || 'all', + cid: searchCids, + uid: searchUids, + searchData: data, + ids: [], + }); + return Array.isArray(result) ? result : result.ids; + } + + return []; + } + + let pids = []; + let tids = []; + const inTopic = String(data.query || '').match(/^in:topic-(\d+) /); + if (inTopic) { + const tid = inTopic[1]; + const cleanedTerm = data.query.replace(inTopic[0], ''); + pids = await topics.search(tid, cleanedTerm); + } else { + [pids, tids] = await Promise.all([ + doSearch('post', ['posts', 'titlesposts']), + doSearch('topic', ['titles', 'titlesposts']), + ]); + } + + const mainPids = await topics.getMainPids(tids); + + let allPids = mainPids.concat(pids).filter(Boolean); + + allPids = await privileges.posts.filter('topics:read', allPids, data.uid); + + allPids = await filterAndSort(allPids, data); + + const metadata = await plugins.hooks.fire('filter:search.inContent', { + pids: allPids, + data, + }); + + if (data.returnIds) { + const mainPidsSet = new Set(mainPids); + const mainPidToTid = _.zipObject(mainPids, tids); + const pidsSet = new Set(pids); + const returnPids = allPids.filter(pid => pidsSet.has(pid)); + const returnTids = allPids.filter(pid => mainPidsSet.has(pid)).map(pid => mainPidToTid[pid]); + return {pids: returnPids, tids: returnTids}; + } + + const itemsPerPage = Math.min(data.itemsPerPage || 10, 100); + const returnData = { + posts: [], + matchCount: metadata.pids.length, + pageCount: Math.max(1, Math.ceil(Number.parseInt(metadata.pids.length, 10) / itemsPerPage)), + }; + + if (data.page) { + const start = Math.max(0, (data.page - 1)) * itemsPerPage; + metadata.pids = metadata.pids.slice(start, start + itemsPerPage); + } + + returnData.posts = await posts.getPostSummaryByPids(metadata.pids, data.uid, {}); + await plugins.hooks.fire('filter:search.contentGetResult', {result: returnData, data}); + delete metadata.pids; + delete metadata.data; + return Object.assign(returnData, metadata); } async function filterAndSort(pids, data) { - if (data.sortBy === 'relevance' && !data.topicName && !data.replies && !data.timeRange && !data.hasTags && !plugins.hooks.hasListeners('filter:search.filterAndSort')) { - return pids; - } - let postsData = await getMatchedPosts(pids, data); - if (!postsData.length) { - return pids; - } - postsData = postsData.filter(Boolean); - postsData = filterByTopic(postsData, data.topicName); - postsData = filterByPostcount(postsData, data.replies, data.repliesFilter); - postsData = filterByTimerange(postsData, data.timeRange, data.timeFilter); - postsData = filterByTags(postsData, data.hasTags); - - sortPosts(postsData, data); - - const result = await plugins.hooks.fire('filter:search.filterAndSort', { pids: pids, posts: postsData, data: data }); - return result.posts.map(post => post && post.pid); + if (data.sortBy === 'relevance' && !data.topicName && !data.replies && !data.timeRange && !data.hasTags && !plugins.hooks.hasListeners('filter:search.filterAndSort')) { + return pids; + } + + let postsData = await getMatchedPosts(pids, data); + if (postsData.length === 0) { + return pids; + } + + postsData = postsData.filter(Boolean); + postsData = filterByTopic(postsData, data.topicName); + postsData = filterByPostcount(postsData, data.replies, data.repliesFilter); + postsData = filterByTimerange(postsData, data.timeRange, data.timeFilter); + postsData = filterByTags(postsData, data.hasTags); + + sortPosts(postsData, data); + + const result = await plugins.hooks.fire('filter:search.filterAndSort', {pids, posts: postsData, data}); + return result.posts.map(post => post && post.pid); } async function getMatchedPosts(pids, data) { - const postFields = ['pid', 'uid', 'tid', 'timestamp', 'deleted', 'upvotes', 'downvotes']; - - let postsData = await posts.getPostsFields(pids, postFields); - postsData = postsData.filter(post => post && !post.deleted); - const uids = _.uniq(postsData.map(post => post.uid)); - const tids = _.uniq(postsData.map(post => post.tid)); - - const [users, topics] = await Promise.all([ - getUsers(uids, data), - getTopics(tids, data), - ]); - - const tidToTopic = _.zipObject(tids, topics); - const uidToUser = _.zipObject(uids, users); - postsData.forEach((post) => { - if (topics && tidToTopic[post.tid]) { - post.topic = tidToTopic[post.tid]; - if (post.topic && post.topic.category) { - post.category = post.topic.category; - } - } - - if (uidToUser[post.uid]) { - post.user = uidToUser[post.uid]; - } - }); - - return postsData.filter(post => post && post.topic && !post.topic.deleted); + const postFields = ['pid', 'uid', 'tid', 'timestamp', 'deleted', 'upvotes', 'downvotes']; + + let postsData = await posts.getPostsFields(pids, postFields); + postsData = postsData.filter(post => post && !post.deleted); + const uids = _.uniq(postsData.map(post => post.uid)); + const tids = _.uniq(postsData.map(post => post.tid)); + + const [users, topics] = await Promise.all([ + getUsers(uids, data), + getTopics(tids, data), + ]); + + const tidToTopic = _.zipObject(tids, topics); + const uidToUser = _.zipObject(uids, users); + for (const post of postsData) { + if (topics && tidToTopic[post.tid]) { + post.topic = tidToTopic[post.tid]; + if (post.topic && post.topic.category) { + post.category = post.topic.category; + } + } + + if (uidToUser[post.uid]) { + post.user = uidToUser[post.uid]; + } + } + + return postsData.filter(post => post && post.topic && !post.topic.deleted); } async function getUsers(uids, data) { - if (data.sortBy.startsWith('user')) { - return user.getUsersFields(uids, ['username']); - } - return []; + if (data.sortBy.startsWith('user')) { + return user.getUsersFields(uids, ['username']); + } + + return []; } async function getTopics(tids, data) { - const topicsData = await topics.getTopicsData(tids); - const cids = _.uniq(topicsData.map(topic => topic && topic.cid)); - const categories = await getCategories(cids, data); - - const cidToCategory = _.zipObject(cids, categories); - topicsData.forEach((topic) => { - if (topic && categories && cidToCategory[topic.cid]) { - topic.category = cidToCategory[topic.cid]; - } - if (topic && topic.tags) { - topic.tags = topic.tags.map(tag => tag.value); - } - }); - - return topicsData; + const topicsData = await topics.getTopicsData(tids); + const cids = _.uniq(topicsData.map(topic => topic && topic.cid)); + const categories = await getCategories(cids, data); + + const cidToCategory = _.zipObject(cids, categories); + for (const topic of topicsData) { + if (topic && categories && cidToCategory[topic.cid]) { + topic.category = cidToCategory[topic.cid]; + } + + if (topic && topic.tags) { + topic.tags = topic.tags.map(tag => tag.value); + } + } + + return topicsData; } async function getCategories(cids, data) { - const categoryFields = []; + const categoryFields = []; + + if (data.sortBy.startsWith('category.')) { + categoryFields.push(data.sortBy.split('.')[1]); + } - if (data.sortBy.startsWith('category.')) { - categoryFields.push(data.sortBy.split('.')[1]); - } - if (!categoryFields.length) { - return null; - } + if (categoryFields.length === 0) { + return null; + } - return await db.getObjectsFields(cids.map(cid => `category:${cid}`), categoryFields); + return await db.getObjectsFields(cids.map(cid => `category:${cid}`), categoryFields); } function filterByPostcount(posts, postCount, repliesFilter) { - postCount = parseInt(postCount, 10); - if (postCount) { - if (repliesFilter === 'atleast') { - posts = posts.filter(post => post.topic && post.topic.postcount >= postCount); - } else { - posts = posts.filter(post => post.topic && post.topic.postcount <= postCount); - } - } - return posts; + postCount = Number.parseInt(postCount, 10); + if (postCount) { + posts = repliesFilter === 'atleast' ? posts.filter(post => post.topic && post.topic.postcount >= postCount) : posts.filter(post => post.topic && post.topic.postcount <= postCount); + } + + return posts; } function filterByTopic(posts, topicName) { - if (topicName) { - posts = posts.filter(post => post.topic && post.topic.title === topicName[0]); - } - return posts; + if (topicName) { + posts = posts.filter(post => post.topic && post.topic.title === topicName[0]); + } + + return posts; } function filterByTimerange(posts, timeRange, timeFilter) { - timeRange = parseInt(timeRange, 10) * 1000; - if (timeRange) { - const time = Date.now() - timeRange; - if (timeFilter === 'newer') { - posts = posts.filter(post => post.timestamp >= time); - } else { - posts = posts.filter(post => post.timestamp <= time); - } - } - return posts; + timeRange = Number.parseInt(timeRange, 10) * 1000; + if (timeRange) { + const time = Date.now() - timeRange; + posts = timeFilter === 'newer' ? posts.filter(post => post.timestamp >= time) : posts.filter(post => post.timestamp <= time); + } + + return posts; } function filterByTags(posts, hasTags) { - if (Array.isArray(hasTags) && hasTags.length) { - posts = posts.filter((post) => { - let hasAllTags = false; - if (post && post.topic && Array.isArray(post.topic.tags) && post.topic.tags.length) { - hasAllTags = hasTags.every(tag => post.topic.tags.includes(tag)); - } - return hasAllTags; - }); - } - return posts; + if (Array.isArray(hasTags) && hasTags.length > 0) { + posts = posts.filter(post => { + let hasAllTags = false; + if (post && post.topic && Array.isArray(post.topic.tags) && post.topic.tags.length > 0) { + hasAllTags = hasTags.every(tag => post.topic.tags.includes(tag)); + } + + return hasAllTags; + }); + } + + return posts; } function sortPosts(posts, data) { - if (!posts.length || data.sortBy === 'relevance') { - return; - } - - data.sortDirection = data.sortDirection || 'desc'; - const direction = data.sortDirection === 'desc' ? 1 : -1; - const fields = data.sortBy.split('.'); - if (fields.length === 1) { - return posts.sort((p1, p2) => direction * (p2[fields[0]] - p1[fields[0]])); - } - - const firstPost = posts[0]; - if (!fields || fields.length !== 2 || !firstPost[fields[0]] || !firstPost[fields[0]][fields[1]]) { - return; - } - - const isNumeric = utils.isNumber(firstPost[fields[0]][fields[1]]); - - if (isNumeric) { - posts.sort((p1, p2) => direction * (p2[fields[0]][fields[1]] - p1[fields[0]][fields[1]])); - } else { - posts.sort((p1, p2) => { - if (p1[fields[0]][fields[1]] > p2[fields[0]][fields[1]]) { - return direction; - } else if (p1[fields[0]][fields[1]] < p2[fields[0]][fields[1]]) { - return -direction; - } - return 0; - }); - } + if (posts.length === 0 || data.sortBy === 'relevance') { + return; + } + + data.sortDirection = data.sortDirection || 'desc'; + const direction = data.sortDirection === 'desc' ? 1 : -1; + const fields = data.sortBy.split('.'); + if (fields.length === 1) { + return posts.sort((p1, p2) => direction * (p2[fields[0]] - p1[fields[0]])); + } + + const firstPost = posts[0]; + if (!fields || fields.length !== 2 || !firstPost[fields[0]] || !firstPost[fields[0]][fields[1]]) { + return; + } + + const isNumeric = utils.isNumber(firstPost[fields[0]][fields[1]]); + + if (isNumeric) { + posts.sort((p1, p2) => direction * (p2[fields[0]][fields[1]] - p1[fields[0]][fields[1]])); + } else { + posts.sort((p1, p2) => { + if (p1[fields[0]][fields[1]] > p2[fields[0]][fields[1]]) { + return direction; + } + + if (p1[fields[0]][fields[1]] < p2[fields[0]][fields[1]]) { + return -direction; + } + + return 0; + }); + } } async function getSearchCids(data) { - if (!Array.isArray(data.categories) || !data.categories.length) { - return []; - } - - if (data.categories.includes('all')) { - return await categories.getCidsByPrivilege('categories:cid', data.uid, 'read'); - } - - const [watchedCids, childrenCids] = await Promise.all([ - getWatchedCids(data), - getChildrenCids(data), - ]); - return _.uniq(watchedCids.concat(childrenCids).concat(data.categories).filter(Boolean)); + if (!Array.isArray(data.categories) || data.categories.length === 0) { + return []; + } + + if (data.categories.includes('all')) { + return await categories.getCidsByPrivilege('categories:cid', data.uid, 'read'); + } + + const [watchedCids, childrenCids] = await Promise.all([ + getWatchedCids(data), + getChildrenCids(data), + ]); + return _.uniq(watchedCids.concat(childrenCids).concat(data.categories).filter(Boolean)); } async function getWatchedCids(data) { - if (!data.categories.includes('watched')) { - return []; - } - return await user.getWatchedCategories(data.uid); + if (!data.categories.includes('watched')) { + return []; + } + + return await user.getWatchedCategories(data.uid); } async function getChildrenCids(data) { - if (!data.searchChildren) { - return []; - } - const childrenCids = await Promise.all(data.categories.map(cid => categories.getChildrenCids(cid))); - return await privileges.categories.filterCids('find', _.uniq(_.flatten(childrenCids)), data.uid); + if (!data.searchChildren) { + return []; + } + + const childrenCids = await Promise.all(data.categories.map(cid => categories.getChildrenCids(cid))); + return await privileges.categories.filterCids('find', _.uniq(childrenCids.flat()), data.uid); } async function getSearchUids(data) { - if (!data.postedBy) { - return []; - } - return await user.getUidsByUsernames(Array.isArray(data.postedBy) ? data.postedBy : [data.postedBy]); + if (!data.postedBy) { + return []; + } + + return await user.getUidsByUsernames(Array.isArray(data.postedBy) ? data.postedBy : [data.postedBy]); } require('./promisify')(search); diff --git a/src/settings.js b/src/settings.js index 7ae73dc..ee07652 100644 --- a/src/settings.js +++ b/src/settings.js @@ -3,46 +3,47 @@ const meta = require('./meta'); const pubsub = require('./pubsub'); -function expandObjBy(obj1, obj2) { - let changed = false; - if (!obj1 || !obj2) { - return changed; - } - for (const [key, val2] of Object.entries(obj2)) { - const val1 = obj1[key]; - const xorIsArray = Array.isArray(val1) !== Array.isArray(val2); - if (xorIsArray || !obj1.hasOwnProperty(key) || typeof val2 !== typeof val1) { - obj1[key] = val2; - changed = true; - } else if (typeof val2 === 'object' && !Array.isArray(val2)) { - if (expandObjBy(val1, val2)) { - changed = true; - } - } - } - return changed; +function expandObjectBy(object1, object2) { + let changed = false; + if (!object1 || !object2) { + return changed; + } + + for (const [key, value2] of Object.entries(object2)) { + const value1 = object1[key]; + const xorIsArray = Array.isArray(value1) !== Array.isArray(value2); + if (xorIsArray || !object1.hasOwnProperty(key) || typeof value2 !== typeof value1) { + object1[key] = value2; + changed = true; + } else if (typeof value2 === 'object' && !Array.isArray(value2) && expandObjectBy(value1, value2)) { + changed = true; + } + } + + return changed; } -function trim(obj1, obj2) { - for (const [key, val1] of Object.entries(obj1)) { - if (!obj2.hasOwnProperty(key)) { - delete obj1[key]; - } else if (typeof val1 === 'object' && !Array.isArray(val1)) { - trim(val1, obj2[key]); - } - } +function trim(object1, object2) { + for (const [key, value1] of Object.entries(object1)) { + if (!object2.hasOwnProperty(key)) { + delete object1[key]; + } else if (typeof value1 === 'object' && !Array.isArray(value1)) { + trim(value1, object2[key]); + } + } } function mergeSettings(cfg, defCfg) { - if (typeof defCfg !== 'object') { - return; - } - if (typeof cfg._ !== 'object') { - cfg._ = defCfg; - } else { - expandObjBy(cfg._, defCfg); - trim(cfg._, defCfg); - } + if (typeof defCfg !== 'object') { + return; + } + + if (typeof cfg._ === 'object') { + expandObjectBy(cfg._, defCfg); + trim(cfg._, defCfg); + } else { + cfg._ = defCfg; + } } /** @@ -57,23 +58,24 @@ function mergeSettings(cfg, defCfg) { @param reset Whether to reset the settings. */ function Settings(hash, version, defCfg, callback, forceUpdate, reset) { - this.hash = hash; - this.version = version || this.version; - this.defCfg = defCfg; - const self = this; - - if (reset) { - this.reset(callback); - } else { - this.sync(function () { - this.checkStructure(callback, forceUpdate); - }); - } - pubsub.on(`action:settings.set.${hash}`, (data) => { - try { - self.cfg._ = JSON.parse(data._); - } catch (err) {} - }); + this.hash = hash; + this.version = version || this.version; + this.defCfg = defCfg; + const self = this; + + if (reset) { + this.reset(callback); + } else { + this.sync(function () { + this.checkStructure(callback, forceUpdate); + }); + } + + pubsub.on(`action:settings.set.${hash}`, data => { + try { + self.cfg._ = JSON.parse(data._); + } catch {} + }); } Settings.prototype.hash = ''; @@ -86,23 +88,22 @@ Settings.prototype.version = '0.0.0'; @param callback Gets called when done. */ Settings.prototype.sync = function (callback) { - const _this = this; - meta.settings.get(this.hash, (err, settings) => { - try { - if (settings._) { - settings._ = JSON.parse(settings._); - } - } catch (_error) {} - _this.cfg = settings; - if (typeof _this.cfg._ !== 'object') { - _this.cfg._ = _this.defCfg; - _this.persist(callback); - } else if (expandObjBy(_this.cfg._, _this.defCfg)) { - _this.persist(callback); - } else if (typeof callback === 'function') { - callback.apply(_this, err); - } - }); + const _this = this; + meta.settings.get(this.hash, (error, settings) => { + try { + settings._ &&= JSON.parse(settings._); + } catch {} + + _this.cfg = settings; + if (typeof _this.cfg._ !== 'object') { + _this.cfg._ = _this.defCfg; + _this.persist(callback); + } else if (expandObjectBy(_this.cfg._, _this.defCfg)) { + _this.persist(callback); + } else if (typeof callback === 'function') { + callback.apply(_this, error); + } + }); }; /** @@ -110,17 +111,18 @@ Settings.prototype.sync = function (callback) { @param callback Gets called when done. */ Settings.prototype.persist = function (callback) { - let conf = this.cfg._; - const _this = this; - if (typeof conf === 'object') { - conf = JSON.stringify(conf); - } - meta.settings.set(this.hash, this.createWrapper(this.cfg.v, conf), (...args) => { - if (typeof callback === 'function') { - callback.apply(_this, args || []); - } - }); - return this; + let config = this.cfg._; + const _this = this; + if (typeof config === 'object') { + config = JSON.stringify(config); + } + + meta.settings.set(this.hash, this.createWrapper(this.cfg.v, config), (...arguments_) => { + if (typeof callback === 'function') { + callback.apply(_this, arguments_ || []); + } + }); + return this; }; /** @@ -130,28 +132,31 @@ Settings.prototype.persist = function (callback) { @returns Object The setting to be used. */ Settings.prototype.get = function (key, def) { - let obj = this.cfg._; - const parts = (key || '').split('.'); - let part; - for (let i = 0; i < parts.length; i += 1) { - part = parts[i]; - if (part && obj != null) { - obj = obj[part]; - } - } - if (obj === undefined) { - if (def === undefined) { - def = this.defCfg; - for (let j = 0; j < parts.length; j += 1) { - part = parts[j]; - if (part && def != null) { - def = def[part]; - } - } - } - return def; - } - return obj; + let object = this.cfg._; + const parts = (key || '').split('.'); + let part; + for (const part_ of parts) { + part = part_; + if (part && object != null) { + object = object[part]; + } + } + + if (object === undefined) { + if (def === undefined) { + def = this.defCfg; + for (const part_ of parts) { + part = part_; + if (part && def != null) { + def = def[part]; + } + } + } + + return def; + } + + return object; }; /** @@ -159,7 +164,7 @@ Settings.prototype.get = function (key, def) { @returns Object The settings-wrapper. */ Settings.prototype.getWrapper = function () { - return this.cfg; + return this.cfg; }; /** @@ -167,10 +172,10 @@ Settings.prototype.getWrapper = function () { @returns Object The new settings-wrapper. */ Settings.prototype.createWrapper = function (version, settings) { - return { - v: version, - _: settings, - }; + return { + v: version, + _: settings, + }; }; /** @@ -178,7 +183,7 @@ Settings.prototype.createWrapper = function (version, settings) { @returns Object The new settings-wrapper. */ Settings.prototype.createDefaultWrapper = function () { - return this.createWrapper(this.version, this.defCfg); + return this.createWrapper(this.version, this.defCfg); }; /** @@ -186,28 +191,31 @@ Settings.prototype.createDefaultWrapper = function () { @param key The key of the setting to set. @param val The value to set. */ -Settings.prototype.set = function (key, val) { - let part; - let obj; - let parts; - this.cfg.v = this.version; - if (val == null || !key) { - this.cfg._ = val || key; - } else { - obj = this.cfg._; - parts = key.split('.'); - for (let i = 0, _len = parts.length - 1; i < _len; i += 1) { - part = parts[i]; - if (part) { - if (!obj.hasOwnProperty(part)) { - obj[part] = {}; - } - obj = obj[part]; - } - } - obj[parts[parts.length - 1]] = val; - } - return this; +Settings.prototype.set = function (key, value) { + let part; + let object; + let parts; + this.cfg.v = this.version; + if (value == null || !key) { + this.cfg._ = value || key; + } else { + object = this.cfg._; + parts = key.split('.'); + for (let i = 0, _length = parts.length - 1; i < _length; i += 1) { + part = parts[i]; + if (part) { + if (!object.hasOwnProperty(part)) { + object[part] = {}; + } + + object = object[part]; + } + } + + object[parts.at(-1)] = value; + } + + return this; }; /** @@ -215,8 +223,8 @@ Settings.prototype.set = function (key, val) { @param callback Gets called when done. */ Settings.prototype.reset = function (callback) { - this.set(this.defCfg).persist(callback); - return this; + this.set(this.defCfg).persist(callback); + return this; }; /** @@ -225,16 +233,17 @@ Settings.prototype.reset = function (callback) { @param force Whether to update and persist the settings even if the versions ara equal. */ Settings.prototype.checkStructure = function (callback, force) { - if (!force && this.cfg.v === this.version) { - if (typeof callback === 'function') { - callback(); - } - } else { - mergeSettings(this.cfg, this.defCfg); - this.cfg.v = this.version; - this.persist(callback); - } - return this; + if (!force && this.cfg.v === this.version) { + if (typeof callback === 'function') { + callback(); + } + } else { + mergeSettings(this.cfg, this.defCfg); + this.cfg.v = this.version; + this.persist(callback); + } + + return this; }; module.exports = Settings; diff --git a/src/sitemap.js b/src/sitemap.js index 49c6e2a..7a5e184 100644 --- a/src/sitemap.js +++ b/src/sitemap.js @@ -1,8 +1,7 @@ 'use strict'; -const { SitemapStream, streamToPromise } = require('sitemap'); +const {SitemapStream, streamToPromise} = require('sitemap'); const nconf = require('nconf'); - const db = require('./database'); const categories = require('./categories'); const topics = require('./topics'); @@ -13,168 +12,174 @@ const utils = require('./utils'); const sitemap = module.exports; sitemap.maps = { - topics: [], + topics: [], }; sitemap.render = async function () { - const topicsPerPage = meta.config.sitemapTopics; - const returnData = { - url: nconf.get('url'), - topics: [], - }; - const [topicCount, categories, pages] = await Promise.all([ - db.getObjectField('global', 'topicCount'), - getSitemapCategories(), - getSitemapPages(), - ]); - returnData.categories = categories.length > 0; - returnData.pages = pages.length > 0; - const numPages = Math.ceil(Math.max(0, topicCount / topicsPerPage)); - for (let x = 1; x <= numPages; x += 1) { - returnData.topics.push(x); - } - - return returnData; + const topicsPerPage = meta.config.sitemapTopics; + const returnData = { + url: nconf.get('url'), + topics: [], + }; + const [topicCount, categories, pages] = await Promise.all([ + db.getObjectField('global', 'topicCount'), + getSitemapCategories(), + getSitemapPages(), + ]); + returnData.categories = categories.length > 0; + returnData.pages = pages.length > 0; + const numberPages = Math.ceil(Math.max(0, topicCount / topicsPerPage)); + for (let x = 1; x <= numberPages; x += 1) { + returnData.topics.push(x); + } + + return returnData; }; async function getSitemapPages() { - const urls = [{ - url: '', - changefreq: 'weekly', - priority: 0.6, - }, { - url: `${nconf.get('relative_path')}/recent`, - changefreq: 'daily', - priority: 0.4, - }, { - url: `${nconf.get('relative_path')}/users`, - changefreq: 'daily', - priority: 0.4, - }, { - url: `${nconf.get('relative_path')}/groups`, - changefreq: 'daily', - priority: 0.4, - }]; - - const data = await plugins.hooks.fire('filter:sitemap.getPages', { urls: urls }); - return data.urls; + const urls = [{ + url: '', + changefreq: 'weekly', + priority: 0.6, + }, { + url: `${nconf.get('relative_path')}/recent`, + changefreq: 'daily', + priority: 0.4, + }, { + url: `${nconf.get('relative_path')}/users`, + changefreq: 'daily', + priority: 0.4, + }, { + url: `${nconf.get('relative_path')}/groups`, + changefreq: 'daily', + priority: 0.4, + }]; + + const data = await plugins.hooks.fire('filter:sitemap.getPages', {urls}); + return data.urls; } sitemap.getPages = async function () { - if (sitemap.maps.pages && Date.now() < sitemap.maps.pagesCacheExpireTimestamp) { - return sitemap.maps.pages; - } - - const urls = await getSitemapPages(); - if (!urls.length) { - sitemap.maps.pages = ''; - sitemap.maps.pagesCacheExpireTimestamp = Date.now() + (1000 * 60 * 60 * 24); - return sitemap.maps.pages; - } - - sitemap.maps.pages = await urlsToSitemap(urls); - sitemap.maps.pagesCacheExpireTimestamp = Date.now() + (1000 * 60 * 60 * 24); - return sitemap.maps.pages; + if (sitemap.maps.pages && Date.now() < sitemap.maps.pagesCacheExpireTimestamp) { + return sitemap.maps.pages; + } + + const urls = await getSitemapPages(); + if (urls.length === 0) { + sitemap.maps.pages = ''; + sitemap.maps.pagesCacheExpireTimestamp = Date.now() + (1000 * 60 * 60 * 24); + return sitemap.maps.pages; + } + + sitemap.maps.pages = await urlsToSitemap(urls); + sitemap.maps.pagesCacheExpireTimestamp = Date.now() + (1000 * 60 * 60 * 24); + return sitemap.maps.pages; }; async function getSitemapCategories() { - const cids = await categories.getCidsByPrivilege('categories:cid', 0, 'find'); - return await categories.getCategoriesFields(cids, ['slug']); + const cids = await categories.getCidsByPrivilege('categories:cid', 0, 'find'); + return await categories.getCategoriesFields(cids, ['slug']); } sitemap.getCategories = async function () { - if (sitemap.maps.categories && Date.now() < sitemap.maps.categoriesCacheExpireTimestamp) { - return sitemap.maps.categories; - } - - const categoryUrls = []; - const categoriesData = await getSitemapCategories(); - categoriesData.forEach((category) => { - if (category) { - categoryUrls.push({ - url: `${nconf.get('relative_path')}/category/${category.slug}`, - changefreq: 'weekly', - priority: 0.4, - }); - } - }); - - if (!categoryUrls.length) { - sitemap.maps.categories = ''; - sitemap.maps.categoriesCacheExpireTimestamp = Date.now() + (1000 * 60 * 60 * 24); - return sitemap.maps.categories; - } - - sitemap.maps.categories = await urlsToSitemap(categoryUrls); - sitemap.maps.categoriesCacheExpireTimestamp = Date.now() + (1000 * 60 * 60 * 24); - return sitemap.maps.categories; + if (sitemap.maps.categories && Date.now() < sitemap.maps.categoriesCacheExpireTimestamp) { + return sitemap.maps.categories; + } + + const categoryUrls = []; + const categoriesData = await getSitemapCategories(); + for (const category of categoriesData) { + if (category) { + categoryUrls.push({ + url: `${nconf.get('relative_path')}/category/${category.slug}`, + changefreq: 'weekly', + priority: 0.4, + }); + } + } + + if (categoryUrls.length === 0) { + sitemap.maps.categories = ''; + sitemap.maps.categoriesCacheExpireTimestamp = Date.now() + (1000 * 60 * 60 * 24); + return sitemap.maps.categories; + } + + sitemap.maps.categories = await urlsToSitemap(categoryUrls); + sitemap.maps.categoriesCacheExpireTimestamp = Date.now() + (1000 * 60 * 60 * 24); + return sitemap.maps.categories; }; sitemap.getTopicPage = async function (page) { - if (parseInt(page, 10) <= 0) { - return; - } - - const numTopics = meta.config.sitemapTopics; - const start = (parseInt(page, 10) - 1) * numTopics; - const stop = start + numTopics - 1; - - if (sitemap.maps.topics[page - 1] && Date.now() < sitemap.maps.topics[page - 1].cacheExpireTimestamp) { - return sitemap.maps.topics[page - 1].sm; - } - - const topicUrls = []; - let tids = await db.getSortedSetRange('topics:tid', start, stop); - tids = await privileges.topics.filterTids('topics:read', tids, 0); - const topicData = await topics.getTopicsFields(tids, ['tid', 'title', 'slug', 'lastposttime']); - - if (!topicData.length) { - sitemap.maps.topics[page - 1] = { - sm: '', - cacheExpireTimestamp: Date.now() + (1000 * 60 * 60 * 24), - }; - return sitemap.maps.topics[page - 1].sm; - } - - topicData.forEach((topic) => { - if (topic) { - topicUrls.push({ - url: `${nconf.get('relative_path')}/topic/${topic.slug}`, - lastmodISO: utils.toISOString(topic.lastposttime), - changefreq: 'daily', - priority: 0.6, - }); - } - }); - - sitemap.maps.topics[page - 1] = { - sm: await urlsToSitemap(topicUrls), - cacheExpireTimestamp: Date.now() + (1000 * 60 * 60 * 24), - }; - - return sitemap.maps.topics[page - 1].sm; + if (Number.parseInt(page, 10) <= 0) { + return; + } + + const numberTopics = meta.config.sitemapTopics; + const start = (Number.parseInt(page, 10) - 1) * numberTopics; + const stop = start + numberTopics - 1; + + if (sitemap.maps.topics[page - 1] && Date.now() < sitemap.maps.topics[page - 1].cacheExpireTimestamp) { + return sitemap.maps.topics[page - 1].sm; + } + + const topicUrls = []; + let tids = await db.getSortedSetRange('topics:tid', start, stop); + tids = await privileges.topics.filterTids('topics:read', tids, 0); + const topicData = await topics.getTopicsFields(tids, ['tid', 'title', 'slug', 'lastposttime']); + + if (topicData.length === 0) { + sitemap.maps.topics[page - 1] = { + sm: '', + cacheExpireTimestamp: Date.now() + (1000 * 60 * 60 * 24), + }; + return sitemap.maps.topics[page - 1].sm; + } + + for (const topic of topicData) { + if (topic) { + topicUrls.push({ + url: `${nconf.get('relative_path')}/topic/${topic.slug}`, + lastmodISO: utils.toISOString(topic.lastposttime), + changefreq: 'daily', + priority: 0.6, + }); + } + } + + sitemap.maps.topics[page - 1] = { + sm: await urlsToSitemap(topicUrls), + cacheExpireTimestamp: Date.now() + (1000 * 60 * 60 * 24), + }; + + return sitemap.maps.topics[page - 1].sm; }; async function urlsToSitemap(urls) { - if (!urls.length) { - return ''; - } - const smStream = new SitemapStream({ hostname: nconf.get('url') }); - urls.forEach(url => smStream.write(url)); - smStream.end(); - return (await streamToPromise(smStream)).toString(); + if (urls.length === 0) { + return ''; + } + + const smStream = new SitemapStream({hostname: nconf.get('url')}); + for (const url of urls) { + smStream.write(url); + } + + smStream.end(); + return (await streamToPromise(smStream)).toString(); } sitemap.clearCache = function () { - if (sitemap.maps.pages) { - sitemap.maps.pagesCacheExpireTimestamp = 0; - } - if (sitemap.maps.categories) { - sitemap.maps.categoriesCacheExpireTimestamp = 0; - } - sitemap.maps.topics.forEach((topicMap) => { - topicMap.cacheExpireTimestamp = 0; - }); + if (sitemap.maps.pages) { + sitemap.maps.pagesCacheExpireTimestamp = 0; + } + + if (sitemap.maps.categories) { + sitemap.maps.categoriesCacheExpireTimestamp = 0; + } + + for (const topicMap of sitemap.maps.topics) { + topicMap.cacheExpireTimestamp = 0; + } }; require('./promisify')(sitemap); diff --git a/src/social.js b/src/social.js index e1caaeb..8c4037d 100644 --- a/src/social.js +++ b/src/social.js @@ -40,11 +40,11 @@ function getPostSharing() { ]; networks = (yield plugins_1.default.hooks.fire('filter:social.posts', networks)); // The next line calls a function in a module that has not been updated to TS yet - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + // eslint-disable-next-line @typescript-eslint/no-unsafe-call const activated = yield database_1.default.getSetMembers('social:posts.activated'); - networks.forEach((network) => { + for (const network of networks) { network.activated = activated.includes(network.id); - }); + } postSharing = networks; return lodash_1.default.cloneDeep(networks); }); @@ -61,13 +61,13 @@ function setActivePostSharingNetworks(networkIDs) { return __awaiter(this, void 0, void 0, function* () { postSharing = null; // The next line calls a function in a module that has not been updated to TS yet - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + // eslint-disable-next-line @typescript-eslint/no-unsafe-call yield database_1.default.delete('social:posts.activated'); - if (!networkIDs.length) { + if (networkIDs.length === 0) { return; } // The next line calls a function in a module that has not been updated to TS yet - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + // eslint-disable-next-line @typescript-eslint/no-unsafe-call yield database_1.default.setAdd('social:posts.activated', networkIDs); }); } diff --git a/src/social.ts b/src/social.ts index f494add..c562576 100644 --- a/src/social.ts +++ b/src/social.ts @@ -4,62 +4,61 @@ import _ from 'lodash'; import plugins from './plugins'; import db from './database'; +import {type Network} from './types'; -import { Network } from './types'; - -let postSharing: Network[] | null = null; +let postSharing: Network[] | undefined = null; export async function getPostSharing(): Promise { - if (postSharing) { - return _.cloneDeep(postSharing); - } + if (postSharing) { + return _.cloneDeep(postSharing); + } - let networks: Network[] = [ - { - id: 'facebook', - name: 'Facebook', - class: 'fa-facebook', - activated: null, - }, - { - id: 'twitter', - name: 'Twitter', - class: 'fa-twitter', - activated: null, - }, - ]; + let networks: Network[] = [ + { + id: 'facebook', + name: 'Facebook', + class: 'fa-facebook', + activated: null, + }, + { + id: 'twitter', + name: 'Twitter', + class: 'fa-twitter', + activated: null, + }, + ]; - networks = await plugins.hooks.fire('filter:social.posts', networks) as Network[]; + networks = await plugins.hooks.fire('filter:social.posts', networks) as Network[]; - // The next line calls a function in a module that has not been updated to TS yet - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - const activated: string[] = await db.getSetMembers('social:posts.activated') as string[]; + // The next line calls a function in a module that has not been updated to TS yet + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const activated: string[] = await db.getSetMembers('social:posts.activated') as string[]; - networks.forEach((network) => { - network.activated = activated.includes(network.id); - }); + for (const network of networks) { + network.activated = activated.includes(network.id); + } - postSharing = networks; - return _.cloneDeep(networks); + postSharing = networks; + return _.cloneDeep(networks); } export async function getActivePostSharing(): Promise { - const networks: Network[] = await getPostSharing(); - return networks.filter(network => network && network.activated); + const networks: Network[] = await getPostSharing(); + return networks.filter(network => network && network.activated); } export async function setActivePostSharingNetworks(networkIDs: string[]): Promise { - postSharing = null; + postSharing = null; - // The next line calls a function in a module that has not been updated to TS yet - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - await db.delete('social:posts.activated'); + // The next line calls a function in a module that has not been updated to TS yet + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + await db.delete('social:posts.activated'); - if (!networkIDs.length) { - return; - } + if (networkIDs.length === 0) { + return; + } - // The next line calls a function in a module that has not been updated to TS yet - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - await db.setAdd('social:posts.activated', networkIDs); + // The next line calls a function in a module that has not been updated to TS yet + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + await db.setAdd('social:posts.activated', networkIDs); } diff --git a/src/social_original.js b/src/social_original.js index bb03862..903f823 100644 --- a/src/social_original.js +++ b/src/social_original.js @@ -15,44 +15,45 @@ const social = module.exports; social.postSharing = null; social.getPostSharing = async function () { - if (social.postSharing) { - return _.cloneDeep(social.postSharing); - } - - let networks = [ - { - id: 'facebook', - name: 'Facebook', - class: 'fa-facebook', - }, - { - id: 'twitter', - name: 'Twitter', - class: 'fa-twitter', - }, - ]; - networks = await plugins.hooks.fire('filter:social.posts', networks); - const activated = await db.getSetMembers('social:posts.activated'); - networks.forEach((network) => { - network.activated = activated.includes(network.id); - }); - - social.postSharing = networks; - return _.cloneDeep(networks); + if (social.postSharing) { + return _.cloneDeep(social.postSharing); + } + + let networks = [ + { + id: 'facebook', + name: 'Facebook', + class: 'fa-facebook', + }, + { + id: 'twitter', + name: 'Twitter', + class: 'fa-twitter', + }, + ]; + networks = await plugins.hooks.fire('filter:social.posts', networks); + const activated = await db.getSetMembers('social:posts.activated'); + for (const network of networks) { + network.activated = activated.includes(network.id); + } + + social.postSharing = networks; + return _.cloneDeep(networks); }; social.getActivePostSharing = async function () { - const networks = await social.getPostSharing(); - return networks.filter(network => network && network.activated); + const networks = await social.getPostSharing(); + return networks.filter(network => network && network.activated); }; social.setActivePostSharingNetworks = async function (networkIDs) { - social.postSharing = null; - await db.delete('social:posts.activated'); - if (!networkIDs.length) { - return; - } - await db.setAdd('social:posts.activated', networkIDs); + social.postSharing = null; + await db.delete('social:posts.activated'); + if (networkIDs.length === 0) { + return; + } + + await db.setAdd('social:posts.activated', networkIDs); }; require('./promisify')(social); diff --git a/src/socket.io/admin.js b/src/socket.io/admin.js index 38c2ff1..757fa09 100644 --- a/src/socket.io/admin.js +++ b/src/socket.io/admin.js @@ -1,7 +1,6 @@ 'use strict'; const winston = require('winston'); - const meta = require('../meta'); const user = require('../user'); const events = require('../events'); @@ -9,7 +8,7 @@ const db = require('../database'); const privileges = require('../privileges'); const websockets = require('./index'); const index = require('./index'); -const getAdminSearchDict = require('../admin/search').getDictionary; +const getAdminSearchDictionary = require('../admin/search').getDictionary; const SocketAdmin = module.exports; SocketAdmin.user = require('./admin/user'); @@ -33,89 +32,89 @@ SocketAdmin.digest = require('./admin/digest'); SocketAdmin.cache = require('./admin/cache'); SocketAdmin.before = async function (socket, method) { - const isAdmin = await user.isAdministrator(socket.uid); - if (isAdmin) { - return; - } - - // Check admin privileges mapping (if not in mapping, deny access) - const privilegeSet = privileges.admin.socketMap.hasOwnProperty(method) ? privileges.admin.socketMap[method].split(';') : []; - const hasPrivilege = (await Promise.all(privilegeSet.map( - async privilege => privileges.admin.can(privilege, socket.uid) - ))).some(Boolean); - if (privilegeSet.length && hasPrivilege) { - return; - } - - winston.warn(`[socket.io] Call to admin method ( ${method} ) blocked (accessed by uid ${socket.uid})`); - throw new Error('[[error:no-privileges]]'); + const isAdmin = await user.isAdministrator(socket.uid); + if (isAdmin) { + return; + } + + // Check admin privileges mapping (if not in mapping, deny access) + const privilegeSet = privileges.admin.socketMap.hasOwnProperty(method) ? privileges.admin.socketMap[method].split(';') : []; + const hasPrivilege = (await Promise.all(privilegeSet.map( + async privilege => privileges.admin.can(privilege, socket.uid), + ))).some(Boolean); + if (privilegeSet.length > 0 && hasPrivilege) { + return; + } + + winston.warn(`[socket.io] Call to admin method ( ${method} ) blocked (accessed by uid ${socket.uid})`); + throw new Error('[[error:no-privileges]]'); }; SocketAdmin.restart = async function (socket) { - await logRestart(socket); - meta.restart(); + await logRestart(socket); + meta.restart(); }; async function logRestart(socket) { - await events.log({ - type: 'restart', - uid: socket.uid, - ip: socket.ip, - }); - await db.setObject('lastrestart', { - uid: socket.uid, - ip: socket.ip, - timestamp: Date.now(), - }); + await events.log({ + type: 'restart', + uid: socket.uid, + ip: socket.ip, + }); + await db.setObject('lastrestart', { + uid: socket.uid, + ip: socket.ip, + timestamp: Date.now(), + }); } SocketAdmin.reload = async function (socket) { - await require('../meta/build').buildAll(); - await events.log({ - type: 'build', - uid: socket.uid, - ip: socket.ip, - }); - - await logRestart(socket); - meta.restart(); + await require('../meta/build').buildAll(); + await events.log({ + type: 'build', + uid: socket.uid, + ip: socket.ip, + }); + + await logRestart(socket); + meta.restart(); }; SocketAdmin.fireEvent = function (socket, data, callback) { - index.server.emit(data.name, data.payload || {}); - callback(); + index.server.emit(data.name, data.payload || {}); + callback(); }; SocketAdmin.deleteEvents = function (socket, eids, callback) { - events.deleteEvents(eids, callback); + events.deleteEvents(eids, callback); }; SocketAdmin.deleteAllEvents = function (socket, data, callback) { - events.deleteAll(callback); + events.deleteAll(callback); }; SocketAdmin.getSearchDict = async function (socket) { - const settings = await user.getSettings(socket.uid); - const lang = settings.userLang || meta.config.defaultLang || 'en-GB'; - return await getAdminSearchDict(lang); + const settings = await user.getSettings(socket.uid); + const lang = settings.userLang || meta.config.defaultLang || 'en-GB'; + return await getAdminSearchDictionary(lang); }; SocketAdmin.deleteAllSessions = function (socket, data, callback) { - user.auth.deleteAllSessions(callback); + user.auth.deleteAllSessions(callback); }; SocketAdmin.reloadAllSessions = function (socket, data, callback) { - websockets.in(`uid_${socket.uid}`).emit('event:livereload'); - callback(); + websockets.in(`uid_${socket.uid}`).emit('event:livereload'); + callback(); }; SocketAdmin.getServerTime = function (socket, data, callback) { - const now = new Date(); + const now = new Date(); - callback(null, { - timestamp: now.getTime(), - offset: now.getTimezoneOffset(), - }); + callback(null, { + timestamp: now.getTime(), + offset: now.getTimezoneOffset(), + }); }; require('../promisify')(SocketAdmin); diff --git a/src/socket.io/admin/analytics.js b/src/socket.io/admin/analytics.js index 5dbeb2e..99c9634 100644 --- a/src/socket.io/admin/analytics.js +++ b/src/socket.io/admin/analytics.js @@ -6,31 +6,26 @@ const utils = require('../../utils'); const Analytics = module.exports; Analytics.get = async function (socket, data) { - if (!data || !data.graph || !data.units) { - throw new Error('[[error:invalid-data]]'); - } + if (!data || !data.graph || !data.units) { + throw new Error('[[error:invalid-data]]'); + } - // Default returns views from past 24 hours, by hour - if (!data.amount) { - if (data.units === 'days') { - data.amount = 30; - } else { - data.amount = 24; - } - } - const getStats = data.units === 'days' ? analytics.getDailyStatsForSet : analytics.getHourlyStatsForSet; - if (data.graph === 'traffic') { - const result = await utils.promiseParallel({ - uniqueVisitors: getStats('analytics:uniquevisitors', data.until || Date.now(), data.amount), - pageviews: getStats('analytics:pageviews', data.until || Date.now(), data.amount), - pageviewsRegistered: getStats('analytics:pageviews:registered', data.until || Date.now(), data.amount), - pageviewsGuest: getStats('analytics:pageviews:guest', data.until || Date.now(), data.amount), - pageviewsBot: getStats('analytics:pageviews:bot', data.until || Date.now(), data.amount), - summary: analytics.getSummary(), - }); - result.pastDay = result.pageviews.reduce((a, b) => parseInt(a, 10) + parseInt(b, 10)); - const last = result.pageviews.length - 1; - result.pageviews[last] = parseInt(result.pageviews[last], 10) + analytics.getUnwrittenPageviews(); - return result; - } + // Default returns views from past 24 hours, by hour + data.amount ||= data.units === 'days' ? 30 : 24; + + const getStats = data.units === 'days' ? analytics.getDailyStatsForSet : analytics.getHourlyStatsForSet; + if (data.graph === 'traffic') { + const result = await utils.promiseParallel({ + uniqueVisitors: getStats('analytics:uniquevisitors', data.until || Date.now(), data.amount), + pageviews: getStats('analytics:pageviews', data.until || Date.now(), data.amount), + pageviewsRegistered: getStats('analytics:pageviews:registered', data.until || Date.now(), data.amount), + pageviewsGuest: getStats('analytics:pageviews:guest', data.until || Date.now(), data.amount), + pageviewsBot: getStats('analytics:pageviews:bot', data.until || Date.now(), data.amount), + summary: analytics.getSummary(), + }); + result.pastDay = result.pageviews.reduce((a, b) => Number.parseInt(a, 10) + Number.parseInt(b, 10)); + const last = result.pageviews.length - 1; + result.pageviews[last] = Number.parseInt(result.pageviews[last], 10) + analytics.getUnwrittenPageviews(); + return result; + } }; diff --git a/src/socket.io/admin/cache.js b/src/socket.io/admin/cache.js index 3ebb39b..041e1ca 100644 --- a/src/socket.io/admin/cache.js +++ b/src/socket.io/admin/cache.js @@ -6,29 +6,31 @@ const db = require('../../database'); const plugins = require('../../plugins'); SocketCache.clear = async function (socket, data) { - let caches = { - post: require('../../posts/cache'), - object: db.objectCache, - group: require('../../groups').cache, - local: require('../../cache'), - }; - caches = await plugins.hooks.fire('filter:admin.cache.get', caches); - if (!caches[data.name]) { - return; - } - caches[data.name].reset(); + let caches = { + post: require('../../posts/cache'), + object: db.objectCache, + group: require('../../groups').cache, + local: require('../../cache'), + }; + caches = await plugins.hooks.fire('filter:admin.cache.get', caches); + if (!caches[data.name]) { + return; + } + + caches[data.name].reset(); }; SocketCache.toggle = async function (socket, data) { - let caches = { - post: require('../../posts/cache'), - object: db.objectCache, - group: require('../../groups').cache, - local: require('../../cache'), - }; - caches = await plugins.hooks.fire('filter:admin.cache.get', caches); - if (!caches[data.name]) { - return; - } - caches[data.name].enabled = data.enabled; + let caches = { + post: require('../../posts/cache'), + object: db.objectCache, + group: require('../../groups').cache, + local: require('../../cache'), + }; + caches = await plugins.hooks.fire('filter:admin.cache.get', caches); + if (!caches[data.name]) { + return; + } + + caches[data.name].enabled = data.enabled; }; diff --git a/src/socket.io/admin/categories.js b/src/socket.io/admin/categories.js index 7f3db89..5057420 100644 --- a/src/socket.io/admin/categories.js +++ b/src/socket.io/admin/categories.js @@ -1,44 +1,43 @@ 'use strict'; - const categories = require('../../categories'); const Categories = module.exports; Categories.getNames = async function () { - return await categories.getAllCategoryFields(['cid', 'name']); + return await categories.getAllCategoryFields(['cid', 'name']); }; Categories.copyPrivilegesToChildren = async function (socket, data) { - const result = await categories.getChildren([data.cid], socket.uid); - const children = result[0]; - for (const child of children) { - // eslint-disable-next-line no-await-in-loop - await copyPrivilegesToChildrenRecursive(data.cid, child, data.group, data.filter); - } + const result = await categories.getChildren([data.cid], socket.uid); + const children = result[0]; + for (const child of children) { + // eslint-disable-next-line no-await-in-loop + await copyPrivilegesToChildrenRecursive(data.cid, child, data.group, data.filter); + } }; async function copyPrivilegesToChildrenRecursive(parentCid, category, group, filter) { - await categories.copyPrivilegesFrom(parentCid, category.cid, group, filter); - for (const child of category.children) { - // eslint-disable-next-line no-await-in-loop - await copyPrivilegesToChildrenRecursive(parentCid, child, group, filter); - } + await categories.copyPrivilegesFrom(parentCid, category.cid, group, filter); + for (const child of category.children) { + // eslint-disable-next-line no-await-in-loop + await copyPrivilegesToChildrenRecursive(parentCid, child, group, filter); + } } Categories.copySettingsFrom = async function (socket, data) { - return await categories.copySettingsFrom(data.fromCid, data.toCid, data.copyParent); + return await categories.copySettingsFrom(data.fromCid, data.toCid, data.copyParent); }; Categories.copyPrivilegesFrom = async function (socket, data) { - await categories.copyPrivilegesFrom(data.fromCid, data.toCid, data.group, data.filter); + await categories.copyPrivilegesFrom(data.fromCid, data.toCid, data.group, data.filter); }; Categories.copyPrivilegesToAllCategories = async function (socket, data) { - let cids = await categories.getAllCidsFromSet('categories:cid'); - cids = cids.filter(cid => parseInt(cid, 10) !== parseInt(data.cid, 10)); - for (const toCid of cids) { - // eslint-disable-next-line no-await-in-loop - await categories.copyPrivilegesFrom(data.cid, toCid, data.group, data.filter); - } + let cids = await categories.getAllCidsFromSet('categories:cid'); + cids = cids.filter(cid => Number.parseInt(cid, 10) !== Number.parseInt(data.cid, 10)); + for (const toCid of cids) { + // eslint-disable-next-line no-await-in-loop + await categories.copyPrivilegesFrom(data.cid, toCid, data.group, data.filter); + } }; diff --git a/src/socket.io/admin/config.js b/src/socket.io/admin/config.js index b4c2429..1f259ef 100644 --- a/src/socket.io/admin/config.js +++ b/src/socket.io/admin/config.js @@ -9,42 +9,45 @@ const index = require('../index'); const Config = module.exports; Config.set = async function (socket, data) { - if (!data) { - throw new Error('[[error:invalid-data]]'); - } - const _data = {}; - _data[data.key] = data.value; - await Config.setMultiple(socket, _data); + if (!data) { + throw new Error('[[error:invalid-data]]'); + } + + const _data = {}; + _data[data.key] = data.value; + await Config.setMultiple(socket, _data); }; Config.setMultiple = async function (socket, data) { - if (!data) { - throw new Error('[[error:invalid-data]]'); - } - - const changes = {}; - const newData = meta.configs.serialize(data); - const oldData = meta.configs.serialize(meta.config); - Object.keys(newData).forEach((key) => { - if (newData[key] !== oldData[key]) { - changes[key] = newData[key]; - changes[`${key}_old`] = meta.config[key]; - } - }); - await meta.configs.setMultiple(data); - for (const [key, value] of Object.entries(data)) { - const setting = { key, value }; - plugins.hooks.fire('action:config.set', setting); - logger.monitorConfig({ io: index.server }, setting); - } - if (Object.keys(changes).length) { - changes.type = 'config-change'; - changes.uid = socket.uid; - changes.ip = socket.ip; - await events.log(changes); - } + if (!data) { + throw new Error('[[error:invalid-data]]'); + } + + const changes = {}; + const newData = meta.configs.serialize(data); + const oldData = meta.configs.serialize(meta.config); + for (const key of Object.keys(newData)) { + if (newData[key] !== oldData[key]) { + changes[key] = newData[key]; + changes[`${key}_old`] = meta.config[key]; + } + } + + await meta.configs.setMultiple(data); + for (const [key, value] of Object.entries(data)) { + const setting = {key, value}; + plugins.hooks.fire('action:config.set', setting); + logger.monitorConfig({io: index.server}, setting); + } + + if (Object.keys(changes).length > 0) { + changes.type = 'config-change'; + changes.uid = socket.uid; + changes.ip = socket.ip; + await events.log(changes); + } }; Config.remove = async function (socket, key) { - await meta.configs.remove(key); + await meta.configs.remove(key); }; diff --git a/src/socket.io/admin/digest.js b/src/socket.io/admin/digest.js index cb664b7..a9986f1 100644 --- a/src/socket.io/admin/digest.js +++ b/src/socket.io/admin/digest.js @@ -6,19 +6,19 @@ const userDigest = require('../../user/digest'); const Digest = module.exports; Digest.resend = async (socket, data) => { - const { uid } = data; - const interval = data.action.startsWith('resend-') ? data.action.slice(7) : await userDigest.getUsersInterval(uid); + const {uid} = data; + const interval = data.action.startsWith('resend-') ? data.action.slice(7) : await userDigest.getUsersInterval(uid); - if (!interval && meta.config.dailyDigestFreq === 'off') { - throw new Error('[[error:digest-not-enabled]]'); - } + if (!interval && meta.config.dailyDigestFreq === 'off') { + throw new Error('[[error:digest-not-enabled]]'); + } - if (uid) { - await userDigest.execute({ - interval: interval || meta.config.dailyDigestFreq, - subscribers: [uid], - }); - } else { - await userDigest.execute({ interval: interval }); - } + if (uid) { + await userDigest.execute({ + interval: interval || meta.config.dailyDigestFreq, + subscribers: [uid], + }); + } else { + await userDigest.execute({interval}); + } }; diff --git a/src/socket.io/admin/email.js b/src/socket.io/admin/email.js index de22fed..3888283 100644 --- a/src/socket.io/admin/email.js +++ b/src/socket.io/admin/email.js @@ -10,59 +10,63 @@ const utils = require('../../utils'); const Email = module.exports; Email.test = async function (socket, data) { - const payload = { - ...(data.payload || {}), - subject: '[[email:test-email.subject]]', - }; + const payload = { + ...data.payload, + subject: '[[email:test-email.subject]]', + }; - switch (data.template) { - case 'digest': - await userDigest.execute({ - interval: 'month', - subscribers: [socket.uid], - }); - break; + switch (data.template) { + case 'digest': { + await userDigest.execute({ + interval: 'month', + subscribers: [socket.uid], + }); + break; + } - case 'banned': - Object.assign(payload, { - username: 'test-user', - until: utils.toISOString(Date.now()), - reason: 'Test Reason', - }); - await emailer.send(data.template, socket.uid, payload); - break; + case 'banned': { + Object.assign(payload, { + username: 'test-user', + until: utils.toISOString(Date.now()), + reason: 'Test Reason', + }); + await emailer.send(data.template, socket.uid, payload); + break; + } - case 'verify-email': - case 'welcome': - await userEmail.sendValidationEmail(socket.uid, { - force: 1, - template: data.template, - subject: data.template === 'welcome' ? `[[email:welcome-to, ${meta.config.title || meta.config.browserTitle || 'NodeBB'}]]` : undefined, - }); - break; + case 'verify-email': + case 'welcome': { + await userEmail.sendValidationEmail(socket.uid, { + force: 1, + template: data.template, + subject: data.template === 'welcome' ? `[[email:welcome-to, ${meta.config.title || meta.config.browserTitle || 'NodeBB'}]]` : undefined, + }); + break; + } - case 'notification': { - const notification = await notifications.create({ - type: 'test', - bodyShort: '[[email:notif.test.short]]', - bodyLong: '[[email:notif.test.long]]', - nid: `uid:${socket.uid}:test`, - path: '/', - from: socket.uid, - }); - await emailer.send('notification', socket.uid, { - path: notification.path, - subject: utils.stripHTMLTags(notification.subject || '[[notifications:new_notification]]'), - intro: utils.stripHTMLTags(notification.bodyShort), - body: notification.bodyLong || '', - notification, - showUnsubscribe: true, - }); - break; - } + case 'notification': { + const notification = await notifications.create({ + type: 'test', + bodyShort: '[[email:notif.test.short]]', + bodyLong: '[[email:notif.test.long]]', + nid: `uid:${socket.uid}:test`, + path: '/', + from: socket.uid, + }); + await emailer.send('notification', socket.uid, { + path: notification.path, + subject: utils.stripHTMLTags(notification.subject || '[[notifications:new_notification]]'), + intro: utils.stripHTMLTags(notification.bodyShort), + body: notification.bodyLong || '', + notification, + showUnsubscribe: true, + }); + break; + } - default: - await emailer.send(data.template, socket.uid, payload); - break; - } + default: { + await emailer.send(data.template, socket.uid, payload); + break; + } + } }; diff --git a/src/socket.io/admin/errors.js b/src/socket.io/admin/errors.js index 84f5867..9cd3bdd 100644 --- a/src/socket.io/admin/errors.js +++ b/src/socket.io/admin/errors.js @@ -5,5 +5,5 @@ const meta = require('../../meta'); const Errors = module.exports; Errors.clear = async function () { - await meta.errors.clear(); + await meta.errors.clear(); }; diff --git a/src/socket.io/admin/logs.js b/src/socket.io/admin/logs.js index 96f3a85..1062934 100644 --- a/src/socket.io/admin/logs.js +++ b/src/socket.io/admin/logs.js @@ -5,9 +5,9 @@ const meta = require('../../meta'); const Logs = module.exports; Logs.get = async function () { - return await meta.logs.get(); + return await meta.logs.get(); }; Logs.clear = async function () { - await meta.logs.clear(); + await meta.logs.clear(); }; diff --git a/src/socket.io/admin/navigation.js b/src/socket.io/admin/navigation.js index 8bc840b..a4dc1d1 100644 --- a/src/socket.io/admin/navigation.js +++ b/src/socket.io/admin/navigation.js @@ -5,5 +5,5 @@ const navigationAdmin = require('../../navigation/admin'); const SocketNavigation = module.exports; SocketNavigation.save = async function (socket, data) { - await navigationAdmin.save(data); + await navigationAdmin.save(data); }; diff --git a/src/socket.io/admin/plugins.js b/src/socket.io/admin/plugins.js index 53aae6b..64a847a 100644 --- a/src/socket.io/admin/plugins.js +++ b/src/socket.io/admin/plugins.js @@ -1,7 +1,6 @@ 'use strict'; const nconf = require('nconf'); - const plugins = require('../../plugins'); const events = require('../../events'); const db = require('../../database'); @@ -9,41 +8,42 @@ const db = require('../../database'); const Plugins = module.exports; Plugins.toggleActive = async function (socket, plugin_id) { - require('../../posts/cache').reset(); - const data = await plugins.toggleActive(plugin_id); - await events.log({ - type: `plugin-${data.active ? 'activate' : 'deactivate'}`, - text: plugin_id, - uid: socket.uid, - }); - return data; + require('../../posts/cache').reset(); + const data = await plugins.toggleActive(plugin_id); + await events.log({ + type: `plugin-${data.active ? 'activate' : 'deactivate'}`, + text: plugin_id, + uid: socket.uid, + }); + return data; }; Plugins.toggleInstall = async function (socket, data) { - require('../../posts/cache').reset(); - await plugins.checkWhitelist(data.id, data.version); - const pluginData = await plugins.toggleInstall(data.id, data.version); - await events.log({ - type: `plugin-${pluginData.installed ? 'install' : 'uninstall'}`, - text: data.id, - version: data.version, - uid: socket.uid, - }); - return pluginData; + require('../../posts/cache').reset(); + await plugins.checkWhitelist(data.id, data.version); + const pluginData = await plugins.toggleInstall(data.id, data.version); + await events.log({ + type: `plugin-${pluginData.installed ? 'install' : 'uninstall'}`, + text: data.id, + version: data.version, + uid: socket.uid, + }); + return pluginData; }; Plugins.getActive = async function () { - return await plugins.getActive(); + return await plugins.getActive(); }; 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))); + 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))); }; Plugins.upgrade = async function (socket, data) { - return await plugins.upgrade(data.id, data.version); + return await plugins.upgrade(data.id, data.version); }; diff --git a/src/socket.io/admin/rewards.js b/src/socket.io/admin/rewards.js index b66d4f7..278d5e6 100644 --- a/src/socket.io/admin/rewards.js +++ b/src/socket.io/admin/rewards.js @@ -5,9 +5,9 @@ const rewardsAdmin = require('../../rewards/admin'); const SocketRewards = module.exports; SocketRewards.save = async function (socket, data) { - return await rewardsAdmin.save(data); + return await rewardsAdmin.save(data); }; SocketRewards.delete = async function (socket, data) { - await rewardsAdmin.delete(data); + await rewardsAdmin.delete(data); }; diff --git a/src/socket.io/admin/rooms.js b/src/socket.io/admin/rooms.js index c4cb137..3830a33 100644 --- a/src/socket.io/admin/rooms.js +++ b/src/socket.io/admin/rooms.js @@ -1,8 +1,7 @@ 'use strict'; -const os = require('os'); +const os = require('node:os'); const nconf = require('nconf'); - const topics = require('../../topics'); const pubsub = require('../../pubsub'); const utils = require('../../utils'); @@ -16,145 +15,144 @@ SocketRooms.stats = stats; SocketRooms.totals = totals; pubsub.on('sync:stats:start', () => { - const stats = SocketRooms.getLocalStats(); - pubsub.publish('sync:stats:end', { - stats: stats, - id: `${os.hostname()}:${nconf.get('port')}`, - }); + const stats = SocketRooms.getLocalStats(); + pubsub.publish('sync:stats:end', { + stats, + id: `${os.hostname()}:${nconf.get('port')}`, + }); }); -pubsub.on('sync:stats:end', (data) => { - stats[data.id] = data.stats; +pubsub.on('sync:stats:end', data => { + stats[data.id] = data.stats; }); -pubsub.on('sync:stats:guests', (eventId) => { - const Sockets = require('../index'); - const guestCount = Sockets.getCountInRoom('online_guests'); - pubsub.publish(eventId, guestCount); +pubsub.on('sync:stats:guests', eventId => { + const Sockets = require('../index'); + const guestCount = Sockets.getCountInRoom('online_guests'); + pubsub.publish(eventId, guestCount); }); SocketRooms.getTotalGuestCount = function (callback) { - let count = 0; - const eventId = `sync:stats:guests:end:${utils.generateUUID()}`; - pubsub.on(eventId, (guestCount) => { - count += guestCount; - }); - - pubsub.publish('sync:stats:guests', eventId); - - setTimeout(() => { - pubsub.removeAllListeners(eventId); - callback(null, count); - }, 100); + let count = 0; + const eventId = `sync:stats:guests:end:${utils.generateUUID()}`; + pubsub.on(eventId, guestCount => { + count += guestCount; + }); + + pubsub.publish('sync:stats:guests', eventId); + + setTimeout(() => { + pubsub.removeAllListeners(eventId); + callback(null, count); + }, 100); }; - SocketRooms.getAll = async function () { - pubsub.publish('sync:stats:start'); - - totals.onlineGuestCount = 0; - totals.onlineRegisteredCount = 0; - totals.socketCount = 0; - totals.topics = {}; - totals.users = { - categories: 0, - recent: 0, - unread: 0, - topics: 0, - category: 0, - }; - - for (const instance of Object.values(stats)) { - totals.onlineGuestCount += instance.onlineGuestCount; - totals.onlineRegisteredCount += instance.onlineRegisteredCount; - totals.socketCount += instance.socketCount; - totals.users.categories += instance.users.categories; - totals.users.recent += instance.users.recent; - totals.users.unread += instance.users.unread; - totals.users.topics += instance.users.topics; - totals.users.category += instance.users.category; - - instance.topics.forEach((topic) => { - totals.topics[topic.tid] = totals.topics[topic.tid] || { count: 0, tid: topic.tid }; - totals.topics[topic.tid].count += topic.count; - }); - } - - let topTenTopics = []; - Object.keys(totals.topics).forEach((tid) => { - topTenTopics.push({ tid: tid, count: totals.topics[tid].count || 0 }); - }); - - topTenTopics = topTenTopics.sort((a, b) => b.count - a.count).slice(0, 10); - - const topTenTids = topTenTopics.map(topic => topic.tid); - - const titles = await topics.getTopicsFields(topTenTids, ['title']); - totals.topTenTopics = topTenTopics.map((topic, index) => { - topic.title = titles[index].title; - return topic; - }); - return totals; + pubsub.publish('sync:stats:start'); + + totals.onlineGuestCount = 0; + totals.onlineRegisteredCount = 0; + totals.socketCount = 0; + totals.topics = {}; + totals.users = { + categories: 0, + recent: 0, + unread: 0, + topics: 0, + category: 0, + }; + + for (const instance of Object.values(stats)) { + totals.onlineGuestCount += instance.onlineGuestCount; + totals.onlineRegisteredCount += instance.onlineRegisteredCount; + totals.socketCount += instance.socketCount; + totals.users.categories += instance.users.categories; + totals.users.recent += instance.users.recent; + totals.users.unread += instance.users.unread; + totals.users.topics += instance.users.topics; + totals.users.category += instance.users.category; + + for (const topic of instance.topics) { + totals.topics[topic.tid] = totals.topics[topic.tid] || {count: 0, tid: topic.tid}; + totals.topics[topic.tid].count += topic.count; + } + } + + let topTenTopics = []; + for (const tid of Object.keys(totals.topics)) { + topTenTopics.push({tid, count: totals.topics[tid].count || 0}); + } + + topTenTopics = topTenTopics.sort((a, b) => b.count - a.count).slice(0, 10); + + const topTenTids = topTenTopics.map(topic => topic.tid); + + const titles = await topics.getTopicsFields(topTenTids, ['title']); + totals.topTenTopics = topTenTopics.map((topic, index) => { + topic.title = titles[index].title; + return topic; + }); + return totals; }; SocketRooms.getOnlineUserCount = function (io) { - let count = 0; + let count = 0; - if (io) { - for (const [key] of io.sockets.adapter.rooms) { - if (key.startsWith('uid_')) { - count += 1; - } - } - } + if (io) { + for (const [key] of io.sockets.adapter.rooms) { + if (key.startsWith('uid_')) { + count += 1; + } + } + } - return count; + return count; }; SocketRooms.getLocalStats = function () { - const Sockets = require('../index'); - const io = Sockets.server; - - const socketData = { - onlineGuestCount: 0, - onlineRegisteredCount: 0, - socketCount: 0, - users: { - categories: 0, - recent: 0, - unread: 0, - topics: 0, - category: 0, - }, - topics: {}, - }; - - if (io && io.sockets) { - socketData.onlineGuestCount = Sockets.getCountInRoom('online_guests'); - socketData.onlineRegisteredCount = SocketRooms.getOnlineUserCount(io); - socketData.socketCount = io.sockets.sockets.size; - socketData.users.categories = Sockets.getCountInRoom('categories'); - socketData.users.recent = Sockets.getCountInRoom('recent_topics'); - socketData.users.unread = Sockets.getCountInRoom('unread_topics'); - - let topTenTopics = []; - let tid; - - for (const [room, clients] of io.sockets.adapter.rooms) { - tid = room.match(/^topic_(\d+)/); - if (tid) { - socketData.users.topics += clients.size; - topTenTopics.push({ tid: tid[1], count: clients.size }); - } else if (room.match(/^category/)) { - socketData.users.category += clients.size; - } - } - - topTenTopics = topTenTopics.sort((a, b) => b.count - a.count).slice(0, 10); - socketData.topics = topTenTopics; - } - - return socketData; + const Sockets = require('../index'); + const io = Sockets.server; + + const socketData = { + onlineGuestCount: 0, + onlineRegisteredCount: 0, + socketCount: 0, + users: { + categories: 0, + recent: 0, + unread: 0, + topics: 0, + category: 0, + }, + topics: {}, + }; + + if (io && io.sockets) { + socketData.onlineGuestCount = Sockets.getCountInRoom('online_guests'); + socketData.onlineRegisteredCount = SocketRooms.getOnlineUserCount(io); + socketData.socketCount = io.sockets.sockets.size; + socketData.users.categories = Sockets.getCountInRoom('categories'); + socketData.users.recent = Sockets.getCountInRoom('recent_topics'); + socketData.users.unread = Sockets.getCountInRoom('unread_topics'); + + let topTenTopics = []; + let tid; + + for (const [room, clients] of io.sockets.adapter.rooms) { + tid = room.match(/^topic_(\d+)/); + if (tid) { + socketData.users.topics += clients.size; + topTenTopics.push({tid: tid[1], count: clients.size}); + } else if (room.startsWith('category')) { + socketData.users.category += clients.size; + } + } + + topTenTopics = topTenTopics.sort((a, b) => b.count - a.count).slice(0, 10); + socketData.topics = topTenTopics; + } + + return socketData; }; require('../../promisify')(SocketRooms); diff --git a/src/socket.io/admin/settings.js b/src/socket.io/admin/settings.js index 031a08a..89208af 100644 --- a/src/socket.io/admin/settings.js +++ b/src/socket.io/admin/settings.js @@ -6,19 +6,19 @@ const events = require('../../events'); const Settings = module.exports; Settings.get = async function (socket, data) { - return await meta.settings.get(data.hash); + return await meta.settings.get(data.hash); }; Settings.set = async function (socket, data) { - await meta.settings.set(data.hash, data.values); - const eventData = data.values; - eventData.type = 'settings-change'; - eventData.uid = socket.uid; - eventData.ip = socket.ip; - eventData.hash = data.hash; - await events.log(eventData); + await meta.settings.set(data.hash, data.values); + const eventData = data.values; + eventData.type = 'settings-change'; + eventData.uid = socket.uid; + eventData.ip = socket.ip; + eventData.hash = data.hash; + await events.log(eventData); }; Settings.clearSitemapCache = async function () { - require('../../sitemap').clearCache(); + require('../../sitemap').clearCache(); }; diff --git a/src/socket.io/admin/social.js b/src/socket.io/admin/social.js index 2358ed3..378d736 100644 --- a/src/socket.io/admin/social.js +++ b/src/socket.io/admin/social.js @@ -5,5 +5,5 @@ const social = require('../../social'); const SocketSocial = module.exports; SocketSocial.savePostSharingNetworks = async function (socket, data) { - await social.setActivePostSharingNetworks(data); + await social.setActivePostSharingNetworks(data); }; diff --git a/src/socket.io/admin/tags.js b/src/socket.io/admin/tags.js index 64ba212..74dec88 100644 --- a/src/socket.io/admin/tags.js +++ b/src/socket.io/admin/tags.js @@ -5,25 +5,25 @@ const topics = require('../../topics'); const Tags = module.exports; Tags.create = async function (socket, data) { - if (!data) { - throw new Error('[[error:invalid-data]]'); - } + if (!data) { + throw new Error('[[error:invalid-data]]'); + } - await topics.createEmptyTag(data.tag); + await topics.createEmptyTag(data.tag); }; Tags.rename = async function (socket, data) { - if (!Array.isArray(data)) { - throw new Error('[[error:invalid-data]]'); - } + if (!Array.isArray(data)) { + throw new TypeError('[[error:invalid-data]]'); + } - await topics.renameTags(data); + await topics.renameTags(data); }; Tags.deleteTags = async function (socket, data) { - if (!data) { - throw new Error('[[error:invalid-data]]'); - } + if (!data) { + throw new Error('[[error:invalid-data]]'); + } - await topics.deleteTags(data.tags); + await topics.deleteTags(data.tags); }; diff --git a/src/socket.io/admin/themes.js b/src/socket.io/admin/themes.js index 27260e0..99dd39b 100644 --- a/src/socket.io/admin/themes.js +++ b/src/socket.io/admin/themes.js @@ -6,19 +6,20 @@ const widgets = require('../../widgets'); const Themes = module.exports; Themes.getInstalled = async function () { - return await meta.themes.get(); + return await meta.themes.get(); }; Themes.set = async function (socket, data) { - if (!data) { - throw new Error('[[error:invalid-data]]'); - } - if (data.type === 'local') { - await widgets.reset(); - } + if (!data) { + throw new Error('[[error:invalid-data]]'); + } - data.ip = socket.ip; - data.uid = socket.uid; + if (data.type === 'local') { + await widgets.reset(); + } - await meta.themes.set(data); + data.ip = socket.ip; + data.uid = socket.uid; + + await meta.themes.set(data); }; diff --git a/src/socket.io/admin/user.js b/src/socket.io/admin/user.js index e0e4983..1d4517c 100644 --- a/src/socket.io/admin/user.js +++ b/src/socket.io/admin/user.js @@ -2,7 +2,6 @@ const async = require('async'); const winston = require('winston'); - const db = require('../../database'); const groups = require('../../groups'); const user = require('../../user'); @@ -13,153 +12,163 @@ const sockets = require('..'); const User = module.exports; User.makeAdmins = async function (socket, uids) { - if (!Array.isArray(uids)) { - throw new Error('[[error:invalid-data]]'); - } - const isMembersOfBanned = await groups.isMembers(uids, groups.BANNED_USERS); - if (isMembersOfBanned.includes(true)) { - throw new Error('[[error:cant-make-banned-users-admin]]'); - } - for (const uid of uids) { - /* eslint-disable no-await-in-loop */ - await groups.join('administrators', uid); - await events.log({ - type: 'user-makeAdmin', - uid: socket.uid, - targetUid: uid, - ip: socket.ip, - }); - } + if (!Array.isArray(uids)) { + throw new TypeError('[[error:invalid-data]]'); + } + + const isMembersOfBanned = await groups.isMembers(uids, groups.BANNED_USERS); + if (isMembersOfBanned.includes(true)) { + throw new Error('[[error:cant-make-banned-users-admin]]'); + } + + for (const uid of uids) { + /* eslint-disable no-await-in-loop */ + await groups.join('administrators', uid); + await events.log({ + type: 'user-makeAdmin', + uid: socket.uid, + targetUid: uid, + ip: socket.ip, + }); + } }; User.removeAdmins = async function (socket, uids) { - if (!Array.isArray(uids)) { - throw new Error('[[error:invalid-data]]'); - } - for (const uid of uids) { - /* eslint-disable no-await-in-loop */ - const count = await groups.getMemberCount('administrators'); - if (count === 1) { - throw new Error('[[error:cant-remove-last-admin]]'); - } - await groups.leave('administrators', uid); - await events.log({ - type: 'user-removeAdmin', - uid: socket.uid, - targetUid: uid, - ip: socket.ip, - }); - } + if (!Array.isArray(uids)) { + throw new TypeError('[[error:invalid-data]]'); + } + + for (const uid of uids) { + /* eslint-disable no-await-in-loop */ + const count = await groups.getMemberCount('administrators'); + if (count === 1) { + throw new Error('[[error:cant-remove-last-admin]]'); + } + + await groups.leave('administrators', uid); + await events.log({ + type: 'user-removeAdmin', + uid: socket.uid, + targetUid: uid, + ip: socket.ip, + }); + } }; User.resetLockouts = async function (socket, uids) { - if (!Array.isArray(uids)) { - throw new Error('[[error:invalid-data]]'); - } - await Promise.all(uids.map(uid => user.auth.resetLockout(uid))); + if (!Array.isArray(uids)) { + throw new TypeError('[[error:invalid-data]]'); + } + + await Promise.all(uids.map(uid => user.auth.resetLockout(uid))); }; User.validateEmail = async function (socket, uids) { - if (!Array.isArray(uids)) { - throw new Error('[[error:invalid-data]]'); - } + if (!Array.isArray(uids)) { + throw new TypeError('[[error:invalid-data]]'); + } - for (const uid of uids) { - await user.email.confirmByUid(uid); - } + for (const uid of uids) { + await user.email.confirmByUid(uid); + } }; User.sendValidationEmail = async function (socket, uids) { - if (!Array.isArray(uids)) { - throw new Error('[[error:invalid-data]]'); - } - - const failed = []; - let errorLogged = false; - await async.eachLimit(uids, 50, async (uid) => { - await user.email.sendValidationEmail(uid, { force: true }).catch((err) => { - if (!errorLogged) { - winston.error(`[user.create] Validation email failed to send\n[emailer.send] ${err.stack}`); - errorLogged = true; - } - - failed.push(uid); - }); - }); - - if (failed.length) { - throw Error(`Email sending failed for the following uids, check server logs for more info: ${failed.join(',')}`); - } + if (!Array.isArray(uids)) { + throw new TypeError('[[error:invalid-data]]'); + } + + const failed = []; + let errorLogged = false; + await async.eachLimit(uids, 50, async uid => { + await user.email.sendValidationEmail(uid, {force: true}).catch(error => { + if (!errorLogged) { + winston.error(`[user.create] Validation email failed to send\n[emailer.send] ${error.stack}`); + errorLogged = true; + } + + failed.push(uid); + }); + }); + + if (failed.length > 0) { + throw new Error(`Email sending failed for the following uids, check server logs for more info: ${failed.join(',')}`); + } }; User.sendPasswordResetEmail = async function (socket, uids) { - if (!Array.isArray(uids)) { - throw new Error('[[error:invalid-data]]'); - } - - uids = uids.filter(uid => parseInt(uid, 10)); - - await Promise.all(uids.map(async (uid) => { - const userData = await user.getUserFields(uid, ['email', 'username']); - if (!userData.email) { - throw new Error(`[[error:user-doesnt-have-email, ${userData.username}]]`); - } - await user.reset.send(userData.email); - })); + if (!Array.isArray(uids)) { + throw new TypeError('[[error:invalid-data]]'); + } + + uids = uids.filter(uid => Number.parseInt(uid, 10)); + + await Promise.all(uids.map(async uid => { + const userData = await user.getUserFields(uid, ['email', 'username']); + if (!userData.email) { + throw new Error(`[[error:user-doesnt-have-email, ${userData.username}]]`); + } + + await user.reset.send(userData.email); + })); }; User.forcePasswordReset = async function (socket, uids) { - if (!Array.isArray(uids)) { - throw new Error('[[error:invalid-data]]'); - } + if (!Array.isArray(uids)) { + throw new TypeError('[[error:invalid-data]]'); + } - uids = uids.filter(uid => parseInt(uid, 10)); + uids = uids.filter(uid => Number.parseInt(uid, 10)); - await db.setObjectField(uids.map(uid => `user:${uid}`), 'passwordExpiry', Date.now()); - await user.auth.revokeAllSessions(uids); - uids.forEach(uid => sockets.in(`uid_${uid}`).emit('event:logout')); + await db.setObjectField(uids.map(uid => `user:${uid}`), 'passwordExpiry', Date.now()); + await user.auth.revokeAllSessions(uids); + for (const uid of uids) { + sockets.in(`uid_${uid}`).emit('event:logout'); + } }; User.restartJobs = async function () { - user.startJobs(); + user.startJobs(); }; User.loadGroups = async function (socket, uids) { - const [userData, groupData] = await Promise.all([ - user.getUsersData(uids), - groups.getUserGroupsFromSet('groups:createtime', uids), - ]); - userData.forEach((data, index) => { - data.groups = groupData[index].filter(group => !groups.isPrivilegeGroup(group.name)); - data.groups.forEach((group) => { - group.nameEscaped = translator.escape(group.displayName); - }); - }); - return { users: userData }; + const [userData, groupData] = await Promise.all([ + user.getUsersData(uids), + groups.getUserGroupsFromSet('groups:createtime', uids), + ]); + for (const [index, data] of userData.entries()) { + data.groups = groupData[index].filter(group => !groups.isPrivilegeGroup(group.name)); + for (const group of data.groups) { + group.nameEscaped = translator.escape(group.displayName); + } + } + + return {users: userData}; }; User.exportUsersCSV = async function (socket) { - await events.log({ - type: 'exportUsersCSV', - uid: socket.uid, - ip: socket.ip, - }); - setTimeout(async () => { - try { - await user.exportUsersCSV(); - if (socket.emit) { - socket.emit('event:export-users-csv'); - } - const notifications = require('../../notifications'); - const n = await notifications.create({ - bodyShort: '[[notifications:users-csv-exported]]', - path: '/api/admin/users/csv', - nid: 'users:csv:export', - from: socket.uid, - }); - await notifications.push(n, [socket.uid]); - } catch (err) { - winston.error(err.stack); - } - }, 0); + await events.log({ + type: 'exportUsersCSV', + uid: socket.uid, + ip: socket.ip, + }); + setTimeout(async () => { + try { + await user.exportUsersCSV(); + if (socket.emit) { + socket.emit('event:export-users-csv'); + } + + const notifications = require('../../notifications'); + const n = await notifications.create({ + bodyShort: '[[notifications:users-csv-exported]]', + path: '/api/admin/users/csv', + nid: 'users:csv:export', + from: socket.uid, + }); + await notifications.push(n, [socket.uid]); + } catch (error) { + winston.error(error.stack); + } + }, 0); }; diff --git a/src/socket.io/admin/widgets.js b/src/socket.io/admin/widgets.js index 9f67fba..feec741 100644 --- a/src/socket.io/admin/widgets.js +++ b/src/socket.io/admin/widgets.js @@ -5,8 +5,9 @@ const widgets = require('../../widgets'); const Widgets = module.exports; Widgets.set = async function (socket, data) { - if (!Array.isArray(data)) { - throw new Error('[[error:invalid-data]]'); - } - await widgets.setAreas(data); + if (!Array.isArray(data)) { + throw new TypeError('[[error:invalid-data]]'); + } + + await widgets.setAreas(data); }; diff --git a/src/socket.io/blacklist.js b/src/socket.io/blacklist.js index 9b28e62..b79c97b 100644 --- a/src/socket.io/blacklist.js +++ b/src/socket.io/blacklist.js @@ -5,32 +5,33 @@ const user = require('../user'); const meta = require('../meta'); const events = require('../events'); -const SocketBlacklist = module.exports; +const SocketExclude = module.exports; -SocketBlacklist.validate = async function (socket, data) { - return meta.blacklist.validate(data.rules); +SocketExclude.validate = async function (socket, data) { + return meta.blacklist.validate(data.rules); }; -SocketBlacklist.save = async function (socket, rules) { - await blacklist(socket, 'save', rules); +SocketExclude.save = async function (socket, rules) { + await exclude(socket, 'save', rules); }; -SocketBlacklist.addRule = async function (socket, rule) { - await blacklist(socket, 'addRule', rule); +SocketExclude.addRule = async function (socket, rule) { + await exclude(socket, 'addRule', rule); }; -async function blacklist(socket, method, rule) { - const isAdminOrGlobalMod = await user.isAdminOrGlobalMod(socket.uid); - if (!isAdminOrGlobalMod) { - throw new Error('[[error:no-privileges]]'); - } - await meta.blacklist[method](rule); - await events.log({ - type: `ip-blacklist-${method}`, - uid: socket.uid, - ip: socket.ip, - rule: rule, - }); +async function exclude(socket, method, rule) { + const isAdminOrGlobalModule = await user.isAdminOrGlobalMod(socket.uid); + if (!isAdminOrGlobalModule) { + throw new Error('[[error:no-privileges]]'); + } + + await meta.blacklist[method](rule); + await events.log({ + type: `ip-blacklist-${method}`, + uid: socket.uid, + ip: socket.ip, + rule, + }); } -require('../promisify')(SocketBlacklist); +require('../promisify')(SocketExclude); diff --git a/src/socket.io/categories.js b/src/socket.io/categories.js index ce71fc7..f9900fc 100644 --- a/src/socket.io/categories.js +++ b/src/socket.io/categories.js @@ -10,158 +10,164 @@ const SocketCategories = module.exports; require('./categories/search')(SocketCategories); SocketCategories.getRecentReplies = async function (socket, cid) { - return await categories.getRecentReplies(cid, socket.uid, 0, 4); + return await categories.getRecentReplies(cid, socket.uid, 0, 4); }; SocketCategories.get = async function (socket) { - async function getCategories() { - const cids = await categories.getCidsByPrivilege('categories:cid', socket.uid, 'find'); - return await categories.getCategoriesData(cids); - } - const [isAdmin, categoriesData] = await Promise.all([ - user.isAdministrator(socket.uid), - getCategories(), - ]); - return categoriesData.filter(category => category && (!category.disabled || isAdmin)); + async function getCategories() { + const cids = await categories.getCidsByPrivilege('categories:cid', socket.uid, 'find'); + return await categories.getCategoriesData(cids); + } + + const [isAdmin, categoriesData] = await Promise.all([ + user.isAdministrator(socket.uid), + getCategories(), + ]); + return categoriesData.filter(category => category && (!category.disabled || isAdmin)); }; SocketCategories.getWatchedCategories = async function (socket) { - const [categoriesData, ignoredCids] = await Promise.all([ - categories.getCategoriesByPrivilege('cid:0:children', socket.uid, 'find'), - user.getIgnoredCategories(socket.uid), - ]); - return categoriesData.filter(category => category && !ignoredCids.includes(String(category.cid))); + const [categoriesData, ignoredCids] = await Promise.all([ + categories.getCategoriesByPrivilege('cid:0:children', socket.uid, 'find'), + user.getIgnoredCategories(socket.uid), + ]); + return categoriesData.filter(category => category && !ignoredCids.includes(String(category.cid))); }; SocketCategories.loadMore = async function (socket, data) { - if (!data) { - throw new Error('[[error:invalid-data]]'); - } - data.query = data.query || {}; - const [userPrivileges, settings, targetUid] = await Promise.all([ - privileges.categories.get(data.cid, socket.uid), - user.getSettings(socket.uid), - user.getUidByUserslug(data.query.author), - ]); - - if (!userPrivileges.read) { - throw new Error('[[error:no-privileges]]'); - } - - const infScrollTopicsPerPage = 20; - const sort = data.sort || data.categoryTopicSort; - - let start = Math.max(0, parseInt(data.after, 10)); - - if (data.direction === -1) { - start -= infScrollTopicsPerPage; - } - - let stop = start + infScrollTopicsPerPage - 1; - - start = Math.max(0, start); - stop = Math.max(0, stop); - const result = await categories.getCategoryTopics({ - uid: socket.uid, - cid: data.cid, - start: start, - stop: stop, - sort: sort, - settings: settings, - query: data.query, - tag: data.query.tag, - targetUid: targetUid, - }); - categories.modifyTopicsByPrivilege(result.topics, userPrivileges); - - result.privileges = userPrivileges; - result.template = { - category: true, - name: 'category', - }; - return result; + if (!data) { + throw new Error('[[error:invalid-data]]'); + } + + data.query = data.query || {}; + const [userPrivileges, settings, targetUid] = await Promise.all([ + privileges.categories.get(data.cid, socket.uid), + user.getSettings(socket.uid), + user.getUidByUserslug(data.query.author), + ]); + + if (!userPrivileges.read) { + throw new Error('[[error:no-privileges]]'); + } + + const infScrollTopicsPerPage = 20; + const sort = data.sort || data.categoryTopicSort; + + let start = Math.max(0, Number.parseInt(data.after, 10)); + + if (data.direction === -1) { + start -= infScrollTopicsPerPage; + } + + let stop = start + infScrollTopicsPerPage - 1; + + start = Math.max(0, start); + stop = Math.max(0, stop); + const result = await categories.getCategoryTopics({ + uid: socket.uid, + cid: data.cid, + start, + stop, + sort, + settings, + query: data.query, + tag: data.query.tag, + targetUid, + }); + categories.modifyTopicsByPrivilege(result.topics, userPrivileges); + + result.privileges = userPrivileges; + result.template = { + category: true, + name: 'category', + }; + return result; }; SocketCategories.getTopicCount = async function (socket, cid) { - return await categories.getCategoryField(cid, 'topic_count'); + return await categories.getCategoryField(cid, 'topic_count'); }; SocketCategories.getCategoriesByPrivilege = async function (socket, privilege) { - return await categories.getCategoriesByPrivilege('categories:cid', socket.uid, privilege); + return await categories.getCategoriesByPrivilege('categories:cid', socket.uid, privilege); }; SocketCategories.getMoveCategories = async function (socket, data) { - return await SocketCategories.getSelectCategories(socket, data); + return await SocketCategories.getSelectCategories(socket, data); }; SocketCategories.getSelectCategories = async function (socket) { - const [isAdmin, categoriesData] = await Promise.all([ - user.isAdministrator(socket.uid), - categories.buildForSelect(socket.uid, 'find', ['disabled', 'link']), - ]); - return categoriesData.filter(category => category && (!category.disabled || isAdmin) && !category.link); + const [isAdmin, categoriesData] = await Promise.all([ + user.isAdministrator(socket.uid), + categories.buildForSelect(socket.uid, 'find', ['disabled', 'link']), + ]); + return categoriesData.filter(category => category && (!category.disabled || isAdmin) && !category.link); }; SocketCategories.setWatchState = async function (socket, data) { - if (!data || !data.cid || !data.state) { - throw new Error('[[error:invalid-data]]'); - } - return await ignoreOrWatch(async (uid, cids) => { - await user.setCategoryWatchState(uid, cids, categories.watchStates[data.state]); - }, socket, data); + if (!data || !data.cid || !data.state) { + throw new Error('[[error:invalid-data]]'); + } + + return await ignoreOrWatch(async (uid, cids) => { + await user.setCategoryWatchState(uid, cids, categories.watchStates[data.state]); + }, socket, data); }; SocketCategories.watch = async function (socket, data) { - return await ignoreOrWatch(user.watchCategory, socket, data); + return await ignoreOrWatch(user.watchCategory, socket, data); }; SocketCategories.ignore = async function (socket, data) { - return await ignoreOrWatch(user.ignoreCategory, socket, data); + return await ignoreOrWatch(user.ignoreCategory, socket, data); }; -async function ignoreOrWatch(fn, socket, data) { - let targetUid = socket.uid; - const cids = Array.isArray(data.cid) ? data.cid.map(cid => parseInt(cid, 10)) : [parseInt(data.cid, 10)]; - if (data.hasOwnProperty('uid')) { - targetUid = data.uid; - } - await user.isAdminOrGlobalModOrSelf(socket.uid, targetUid); - const allCids = await categories.getAllCidsFromSet('categories:cid'); - const categoryData = await categories.getCategoriesFields(allCids, ['cid', 'parentCid']); - - // filter to subcategories of cid - let cat; - do { - cat = categoryData.find(c => !cids.includes(c.cid) && cids.includes(c.parentCid)); - if (cat) { - cids.push(cat.cid); - } - } while (cat); - - await fn(targetUid, cids); - await topics.pushUnreadCount(targetUid); - return cids; +async function ignoreOrWatch(function_, socket, data) { + let targetUid = socket.uid; + const cids = Array.isArray(data.cid) ? data.cid.map(cid => Number.parseInt(cid, 10)) : [Number.parseInt(data.cid, 10)]; + if (data.hasOwnProperty('uid')) { + targetUid = data.uid; + } + + await user.isAdminOrGlobalModOrSelf(socket.uid, targetUid); + const allCids = await categories.getAllCidsFromSet('categories:cid'); + const categoryData = await categories.getCategoriesFields(allCids, ['cid', 'parentCid']); + + // Filter to subcategories of cid + let cat; + do { + cat = categoryData.find(c => !cids.includes(c.cid) && cids.includes(c.parentCid)); + if (cat) { + cids.push(cat.cid); + } + } while (cat); + + await function_(targetUid, cids); + await topics.pushUnreadCount(targetUid); + return cids; } SocketCategories.isModerator = async function (socket, cid) { - return await user.isModerator(socket.uid, cid); + return await user.isModerator(socket.uid, cid); }; SocketCategories.loadMoreSubCategories = async function (socket, data) { - if (!data || !data.cid || !(parseInt(data.start, 10) > 0)) { - throw new Error('[[error:invalid-data]]'); - } - const allowed = await privileges.categories.can('read', data.cid, socket.uid); - if (!allowed) { - throw new Error('[[error:no-privileges]]'); - } - const category = await categories.getCategoryData(data.cid); - await categories.getChildrenTree(category, socket.uid); - const allCategories = []; - categories.flattenCategories(allCategories, category.children); - await categories.getRecentTopicReplies(allCategories, socket.uid); - const start = parseInt(data.start, 10); - return category.children.slice(start, start + category.subCategoriesPerPage); + if (!data || !data.cid || !(Number.parseInt(data.start, 10) > 0)) { + throw new Error('[[error:invalid-data]]'); + } + + const allowed = await privileges.categories.can('read', data.cid, socket.uid); + if (!allowed) { + throw new Error('[[error:no-privileges]]'); + } + + const category = await categories.getCategoryData(data.cid); + await categories.getChildrenTree(category, socket.uid); + const allCategories = []; + categories.flattenCategories(allCategories, category.children); + await categories.getRecentTopicReplies(allCategories, socket.uid); + const start = Number.parseInt(data.start, 10); + return category.children.slice(start, start + category.subCategoriesPerPage); }; require('../promisify')(SocketCategories); diff --git a/src/socket.io/categories/search.js b/src/socket.io/categories/search.js index cf7a837..f43204c 100644 --- a/src/socket.io/categories/search.js +++ b/src/socket.io/categories/search.js @@ -1,7 +1,6 @@ 'use strict'; const _ = require('lodash'); - const meta = require('../../meta'); const categories = require('../../categories'); const privileges = require('../../privileges'); @@ -9,93 +8,94 @@ const controllersHelpers = require('../../controllers/helpers'); const plugins = require('../../plugins'); module.exports = function (SocketCategories) { - // used by categorySearch module - SocketCategories.categorySearch = async function (socket, data) { - let cids = []; - let matchedCids = []; - const privilege = data.privilege || 'topics:read'; - data.states = (data.states || ['watching', 'notwatching', 'ignoring']).map( - state => categories.watchStates[state] - ); + // Used by categorySearch module + SocketCategories.categorySearch = async function (socket, data) { + let cids = []; + let matchedCids = []; + const privilege = data.privilege || 'topics:read'; + data.states = (data.states || ['watching', 'notwatching', 'ignoring']).map( + state => categories.watchStates[state], + ); + + if (data.search) { + ({cids, matchedCids} = await findMatchedCids(socket.uid, data)); + } else { + cids = await loadCids(socket.uid, data.parentCid); + } - if (data.search) { - ({ cids, matchedCids } = await findMatchedCids(socket.uid, data)); - } else { - cids = await loadCids(socket.uid, data.parentCid); - } + const visibleCategories = await controllersHelpers.getVisibleCategories({ + cids, uid: socket.uid, states: data.states, privilege, showLinks: data.showLinks, parentCid: data.parentCid, + }); - const visibleCategories = await controllersHelpers.getVisibleCategories({ - cids, uid: socket.uid, states: data.states, privilege, showLinks: data.showLinks, parentCid: data.parentCid, - }); + if (Array.isArray(data.selectedCids)) { + data.selectedCids = data.selectedCids.map(cid => Number.parseInt(cid, 10)); + } - if (Array.isArray(data.selectedCids)) { - data.selectedCids = data.selectedCids.map(cid => parseInt(cid, 10)); - } + let categoriesData = categories.buildForSelectCategories(visibleCategories, ['disabledClass'], data.parentCid); + categoriesData = categoriesData.slice(0, 200); - let categoriesData = categories.buildForSelectCategories(visibleCategories, ['disabledClass'], data.parentCid); - categoriesData = categoriesData.slice(0, 200); + for (const category of categoriesData) { + category.selected = data.selectedCids ? data.selectedCids.includes(category.cid) : false; + if (matchedCids.includes(category.cid)) { + category.match = true; + } + } - categoriesData.forEach((category) => { - category.selected = data.selectedCids ? data.selectedCids.includes(category.cid) : false; - if (matchedCids.includes(category.cid)) { - category.match = true; - } - }); - const result = await plugins.hooks.fire('filter:categories.categorySearch', { - categories: categoriesData, - ...data, - uid: socket.uid, - }); - return result.categories; - }; + const result = await plugins.hooks.fire('filter:categories.categorySearch', { + categories: categoriesData, + ...data, + uid: socket.uid, + }); + return result.categories; + }; - async function findMatchedCids(uid, data) { - const result = await categories.search({ - uid: uid, - query: data.search, - qs: data.query, - paginate: false, - }); + async function findMatchedCids(uid, data) { + const result = await categories.search({ + uid, + query: data.search, + qs: data.query, + paginate: false, + }); - let matchedCids = result.categories.map(c => c.cid); - // no need to filter if all 3 states are used - const filterByWatchState = !Object.values(categories.watchStates) - .every(state => data.states.includes(state)); + let matchedCids = result.categories.map(c => c.cid); + // No need to filter if all 3 states are used + const filterByWatchState = !Object.values(categories.watchStates) + .every(state => data.states.includes(state)); - if (filterByWatchState) { - const states = await categories.getWatchState(matchedCids, uid); - matchedCids = matchedCids.filter((cid, index) => data.states.includes(states[index])); - } + if (filterByWatchState) { + const states = await categories.getWatchState(matchedCids, uid); + matchedCids = matchedCids.filter((cid, index) => data.states.includes(states[index])); + } - const rootCids = _.uniq(_.flatten(await Promise.all(matchedCids.map(categories.getParentCids)))); - const allChildCids = _.uniq(_.flatten(await Promise.all(matchedCids.map(categories.getChildrenCids)))); + const rootCids = _.uniq((await Promise.all(matchedCids.map(categories.getParentCids))).flat()); + const allChildCids = _.uniq((await Promise.all(matchedCids.map(categories.getChildrenCids))).flat()); - return { - cids: _.uniq(rootCids.concat(allChildCids).concat(matchedCids)), - matchedCids: matchedCids, - }; - } + return { + cids: _.uniq(rootCids.concat(allChildCids).concat(matchedCids)), + matchedCids, + }; + } - async function loadCids(uid, parentCid) { - let resultCids = []; - async function getCidsRecursive(cids) { - const categoryData = await categories.getCategoriesFields(cids, ['subCategoriesPerPage']); - const cidToData = _.zipObject(cids, categoryData); - await Promise.all(cids.map(async (cid) => { - const allChildCids = await categories.getAllCidsFromSet(`cid:${cid}:children`); - if (allChildCids.length) { - const childCids = await privileges.categories.filterCids('find', allChildCids, uid); - resultCids.push(...childCids.slice(0, cidToData[cid].subCategoriesPerPage)); - await getCidsRecursive(childCids); - } - })); - } + async function loadCids(uid, parentCid) { + let resultCids = []; + async function getCidsRecursive(cids) { + const categoryData = await categories.getCategoriesFields(cids, ['subCategoriesPerPage']); + const cidToData = _.zipObject(cids, categoryData); + await Promise.all(cids.map(async cid => { + const allChildCids = await categories.getAllCidsFromSet(`cid:${cid}:children`); + if (allChildCids.length > 0) { + const childCids = await privileges.categories.filterCids('find', allChildCids, uid); + resultCids.push(...childCids.slice(0, cidToData[cid].subCategoriesPerPage)); + await getCidsRecursive(childCids); + } + })); + } - const allRootCids = await categories.getAllCidsFromSet(`cid:${parentCid}:children`); - const rootCids = await privileges.categories.filterCids('find', allRootCids, uid); - const pageCids = rootCids.slice(0, meta.config.categoriesPerPage); - resultCids = pageCids; - await getCidsRecursive(pageCids); - return resultCids; - } + const allRootCids = await categories.getAllCidsFromSet(`cid:${parentCid}:children`); + const rootCids = await privileges.categories.filterCids('find', allRootCids, uid); + const pageCids = rootCids.slice(0, meta.config.categoriesPerPage); + resultCids = pageCids; + await getCidsRecursive(pageCids); + return resultCids; + } }; diff --git a/src/socket.io/groups.js b/src/socket.io/groups.js index 760a2ec..cca8378 100644 --- a/src/socket.io/groups.js +++ b/src/socket.io/groups.js @@ -9,283 +9,297 @@ const privileges = require('../privileges'); const SocketGroups = module.exports; SocketGroups.before = async (socket, method, data) => { - if (!data) { - throw new Error('[[error:invalid-data]]'); - } + if (!data) { + throw new Error('[[error:invalid-data]]'); + } }; SocketGroups.addMember = async (socket, data) => { - await isOwner(socket, data); - if (data.groupName === 'administrators' || groups.isPrivilegeGroup(data.groupName)) { - throw new Error('[[error:not-allowed]]'); - } - if (!data.uid) { - throw new Error('[[error:invalid-data]]'); - } - data.uid = !Array.isArray(data.uid) ? [data.uid] : data.uid; - if (data.uid.filter(uid => !(parseInt(uid, 10) > 0)).length) { - throw new Error('[[error:invalid-uid]]'); - } - for (const uid of data.uid) { - // eslint-disable-next-line no-await-in-loop - await groups.join(data.groupName, uid); - } - - logGroupEvent(socket, 'group-add-member', { - groupName: data.groupName, - targetUid: String(data.uid), - }); + await isOwner(socket, data); + if (data.groupName === 'administrators' || groups.isPrivilegeGroup(data.groupName)) { + throw new Error('[[error:not-allowed]]'); + } + + if (!data.uid) { + throw new Error('[[error:invalid-data]]'); + } + + data.uid = Array.isArray(data.uid) ? data.uid : [data.uid]; + if (data.uid.some(uid => !(Number.parseInt(uid, 10) > 0))) { + throw new Error('[[error:invalid-uid]]'); + } + + for (const uid of data.uid) { + // eslint-disable-next-line no-await-in-loop + await groups.join(data.groupName, uid); + } + + logGroupEvent(socket, 'group-add-member', { + groupName: data.groupName, + targetUid: String(data.uid), + }); }; async function isOwner(socket, data) { - if (typeof data.groupName !== 'string') { - throw new Error('[[error:invalid-group-name]]'); - } - const results = await utils.promiseParallel({ - hasAdminPrivilege: privileges.admin.can('admin:groups', socket.uid), - isGlobalModerator: user.isGlobalModerator(socket.uid), - isOwner: groups.ownership.isOwner(socket.uid, data.groupName), - group: groups.getGroupData(data.groupName), - }); - - const isOwner = results.isOwner || - results.hasAdminPrivilege || - (results.isGlobalModerator && !results.group.system); - if (!isOwner) { - throw new Error('[[error:no-privileges]]'); - } + if (typeof data.groupName !== 'string') { + throw new TypeError('[[error:invalid-group-name]]'); + } + + const results = await utils.promiseParallel({ + hasAdminPrivilege: privileges.admin.can('admin:groups', socket.uid), + isGlobalModerator: user.isGlobalModerator(socket.uid), + isOwner: groups.ownership.isOwner(socket.uid, data.groupName), + group: groups.getGroupData(data.groupName), + }); + + const isOwner = results.isOwner + || results.hasAdminPrivilege + || (results.isGlobalModerator && !results.group.system); + if (!isOwner) { + throw new Error('[[error:no-privileges]]'); + } } async function isInvited(socket, data) { - if (typeof data.groupName !== 'string') { - throw new Error('[[error:invalid-group-name]]'); - } - const invited = await groups.isInvited(socket.uid, data.groupName); - if (!invited) { - throw new Error('[[error:not-invited]]'); - } + if (typeof data.groupName !== 'string') { + throw new TypeError('[[error:invalid-group-name]]'); + } + + const invited = await groups.isInvited(socket.uid, data.groupName); + if (!invited) { + throw new Error('[[error:not-invited]]'); + } } SocketGroups.accept = async (socket, data) => { - await isOwner(socket, data); - await groups.acceptMembership(data.groupName, data.toUid); - logGroupEvent(socket, 'group-accept-membership', { - groupName: data.groupName, - targetUid: data.toUid, - }); + await isOwner(socket, data); + await groups.acceptMembership(data.groupName, data.toUid); + logGroupEvent(socket, 'group-accept-membership', { + groupName: data.groupName, + targetUid: data.toUid, + }); }; SocketGroups.reject = async (socket, data) => { - await isOwner(socket, data); - await groups.rejectMembership(data.groupName, data.toUid); - logGroupEvent(socket, 'group-reject-membership', { - groupName: data.groupName, - targetUid: data.toUid, - }); + await isOwner(socket, data); + await groups.rejectMembership(data.groupName, data.toUid); + logGroupEvent(socket, 'group-reject-membership', { + groupName: data.groupName, + targetUid: data.toUid, + }); }; SocketGroups.acceptAll = async (socket, data) => { - await isOwner(socket, data); - await acceptRejectAll(SocketGroups.accept, socket, data); + await isOwner(socket, data); + await acceptRejectAll(SocketGroups.accept, socket, data); }; SocketGroups.rejectAll = async (socket, data) => { - await isOwner(socket, data); - await acceptRejectAll(SocketGroups.reject, socket, data); + await isOwner(socket, data); + await acceptRejectAll(SocketGroups.reject, socket, data); }; async function acceptRejectAll(method, socket, data) { - if (typeof data.groupName !== 'string') { - throw new Error('[[error:invalid-group-name]]'); - } - const uids = await groups.getPending(data.groupName); - await Promise.all(uids.map(async (uid) => { - await method(socket, { groupName: data.groupName, toUid: uid }); - })); + if (typeof data.groupName !== 'string') { + throw new TypeError('[[error:invalid-group-name]]'); + } + + const uids = await groups.getPending(data.groupName); + await Promise.all(uids.map(async uid => { + await method(socket, {groupName: data.groupName, toUid: uid}); + })); } SocketGroups.issueInvite = async (socket, data) => { - await isOwner(socket, data); - await groups.invite(data.groupName, data.toUid); - logGroupEvent(socket, 'group-invite', { - groupName: data.groupName, - targetUid: data.toUid, - }); + await isOwner(socket, data); + await groups.invite(data.groupName, data.toUid); + logGroupEvent(socket, 'group-invite', { + groupName: data.groupName, + targetUid: data.toUid, + }); }; SocketGroups.issueMassInvite = async (socket, data) => { - await isOwner(socket, data); - if (!data || !data.usernames || !data.groupName) { - throw new Error('[[error:invalid-data]]'); - } - let usernames = String(data.usernames).split(','); - usernames = usernames.map(username => username && username.trim()); - - let uids = await user.getUidsByUsernames(usernames); - uids = uids.filter(uid => !!uid && parseInt(uid, 10)); - - await groups.invite(data.groupName, uids); - - for (const uid of uids) { - logGroupEvent(socket, 'group-invite', { - groupName: data.groupName, - targetUid: uid, - }); - } + await isOwner(socket, data); + if (!data || !data.usernames || !data.groupName) { + throw new Error('[[error:invalid-data]]'); + } + + let usernames = String(data.usernames).split(','); + usernames = usernames.map(username => username && username.trim()); + + let uids = await user.getUidsByUsernames(usernames); + uids = uids.filter(uid => Boolean(uid) && Number.parseInt(uid, 10)); + + await groups.invite(data.groupName, uids); + + for (const uid of uids) { + logGroupEvent(socket, 'group-invite', { + groupName: data.groupName, + targetUid: uid, + }); + } }; SocketGroups.rescindInvite = async (socket, data) => { - await isOwner(socket, data); - await groups.rejectMembership(data.groupName, data.toUid); + await isOwner(socket, data); + await groups.rejectMembership(data.groupName, data.toUid); }; SocketGroups.acceptInvite = async (socket, data) => { - await isInvited(socket, data); - await groups.acceptMembership(data.groupName, socket.uid); - logGroupEvent(socket, 'group-invite-accept', { - groupName: data.groupName, - }); + await isInvited(socket, data); + await groups.acceptMembership(data.groupName, socket.uid); + logGroupEvent(socket, 'group-invite-accept', { + groupName: data.groupName, + }); }; SocketGroups.rejectInvite = async (socket, data) => { - await isInvited(socket, data); - await groups.rejectMembership(data.groupName, socket.uid); - logGroupEvent(socket, 'group-invite-reject', { - groupName: data.groupName, - }); + await isInvited(socket, data); + await groups.rejectMembership(data.groupName, socket.uid); + logGroupEvent(socket, 'group-invite-reject', { + groupName: data.groupName, + }); }; SocketGroups.kick = async (socket, data) => { - await isOwner(socket, data); - if (socket.uid === parseInt(data.uid, 10)) { - throw new Error('[[error:cant-kick-self]]'); - } - - const isOwnerBit = await groups.ownership.isOwner(data.uid, data.groupName); - await groups.kick(data.uid, data.groupName, isOwnerBit); - logGroupEvent(socket, 'group-kick', { - groupName: data.groupName, - targetUid: data.uid, - }); + await isOwner(socket, data); + if (socket.uid === Number.parseInt(data.uid, 10)) { + throw new Error('[[error:cant-kick-self]]'); + } + + const isOwnerBit = await groups.ownership.isOwner(data.uid, data.groupName); + await groups.kick(data.uid, data.groupName, isOwnerBit); + logGroupEvent(socket, 'group-kick', { + groupName: data.groupName, + targetUid: data.uid, + }); }; SocketGroups.search = async (socket, data) => { - data.options = data.options || {}; - - if (!data.query) { - const groupsPerPage = 15; - const groupData = await groups.getGroupsBySort(data.options.sort, 0, groupsPerPage - 1); - return groupData; - } - data.options.filterHidden = data.options.filterHidden || !await user.isAdministrator(socket.uid); - return await groups.search(data.query, data.options); + data.options = data.options || {}; + + if (!data.query) { + const groupsPerPage = 15; + const groupData = await groups.getGroupsBySort(data.options.sort, 0, groupsPerPage - 1); + return groupData; + } + + data.options.filterHidden = data.options.filterHidden || !await user.isAdministrator(socket.uid); + return await groups.search(data.query, data.options); }; SocketGroups.loadMore = async (socket, data) => { - if (!data.sort || !utils.isNumber(data.after) || parseInt(data.after, 10) < 0) { - throw new Error('[[error:invalid-data]]'); - } - - const groupsPerPage = 10; - const start = parseInt(data.after, 10); - const stop = start + groupsPerPage - 1; - const groupData = await groups.getGroupsBySort(data.sort, start, stop); - return { groups: groupData, nextStart: stop + 1 }; + if (!data.sort || !utils.isNumber(data.after) || Number.parseInt(data.after, 10) < 0) { + throw new Error('[[error:invalid-data]]'); + } + + const groupsPerPage = 10; + const start = Number.parseInt(data.after, 10); + const stop = start + groupsPerPage - 1; + const groupData = await groups.getGroupsBySort(data.sort, start, stop); + return {groups: groupData, nextStart: stop + 1}; }; SocketGroups.searchMembers = async (socket, data) => { - if (!data.groupName) { - throw new Error('[[error:invalid-data]]'); - } - await canSearchMembers(socket.uid, data.groupName); - if (!await privileges.global.can('search:users', socket.uid)) { - throw new Error('[[error:no-privileges]]'); - } - return await groups.searchMembers({ - uid: socket.uid, - query: data.query, - groupName: data.groupName, - }); + if (!data.groupName) { + throw new Error('[[error:invalid-data]]'); + } + + await canSearchMembers(socket.uid, data.groupName); + if (!await privileges.global.can('search:users', socket.uid)) { + throw new Error('[[error:no-privileges]]'); + } + + return await groups.searchMembers({ + uid: socket.uid, + query: data.query, + groupName: data.groupName, + }); }; SocketGroups.loadMoreMembers = async (socket, data) => { - if (!data.groupName || !utils.isNumber(data.after) || parseInt(data.after, 10) < 0) { - throw new Error('[[error:invalid-data]]'); - } - await canSearchMembers(socket.uid, data.groupName); - data.after = parseInt(data.after, 10); - const users = await groups.getOwnersAndMembers(data.groupName, socket.uid, data.after, data.after + 9); - return { - users: users, - nextStart: data.after + 10, - }; + if (!data.groupName || !utils.isNumber(data.after) || Number.parseInt(data.after, 10) < 0) { + throw new Error('[[error:invalid-data]]'); + } + + await canSearchMembers(socket.uid, data.groupName); + data.after = Number.parseInt(data.after, 10); + const users = await groups.getOwnersAndMembers(data.groupName, socket.uid, data.after, data.after + 9); + return { + users, + nextStart: data.after + 10, + }; }; async function canSearchMembers(uid, groupName) { - const [isHidden, isMember, hasAdminPrivilege, isGlobalMod, viewGroups] = await Promise.all([ - groups.isHidden(groupName), - groups.isMember(uid, groupName), - privileges.admin.can('admin:groups', uid), - user.isGlobalModerator(uid), - privileges.global.can('view:groups', uid), - ]); - - if (!viewGroups || (isHidden && !isMember && !hasAdminPrivilege && !isGlobalMod)) { - throw new Error('[[error:no-privileges]]'); - } + const [isHidden, isMember, hasAdminPrivilege, isGlobalModule, viewGroups] = await Promise.all([ + groups.isHidden(groupName), + groups.isMember(uid, groupName), + privileges.admin.can('admin:groups', uid), + user.isGlobalModerator(uid), + privileges.global.can('view:groups', uid), + ]); + + if (!viewGroups || (isHidden && !isMember && !hasAdminPrivilege && !isGlobalModule)) { + throw new Error('[[error:no-privileges]]'); + } } SocketGroups.cover = {}; SocketGroups.cover.update = async (socket, data) => { - if (!socket.uid) { - throw new Error('[[error:no-privileges]]'); - } - if (data.file || (!data.imageData && !data.position)) { - throw new Error('[[error:invalid-data]]'); - } - await canModifyGroup(socket.uid, data.groupName); - return await groups.updateCover(socket.uid, { - groupName: data.groupName, - imageData: data.imageData, - position: data.position, - }); + if (!socket.uid) { + throw new Error('[[error:no-privileges]]'); + } + + if (data.file || (!data.imageData && !data.position)) { + throw new Error('[[error:invalid-data]]'); + } + + await canModifyGroup(socket.uid, data.groupName); + return await groups.updateCover(socket.uid, { + groupName: data.groupName, + imageData: data.imageData, + position: data.position, + }); }; SocketGroups.cover.remove = async (socket, data) => { - if (!socket.uid) { - throw new Error('[[error:no-privileges]]'); - } - - await canModifyGroup(socket.uid, data.groupName); - await groups.removeCover({ - groupName: data.groupName, - }); + if (!socket.uid) { + throw new Error('[[error:no-privileges]]'); + } + + await canModifyGroup(socket.uid, data.groupName); + await groups.removeCover({ + groupName: data.groupName, + }); }; async function canModifyGroup(uid, groupName) { - if (typeof groupName !== 'string') { - throw new Error('[[error:invalid-group-name]]'); - } - const results = await utils.promiseParallel({ - isOwner: groups.ownership.isOwner(uid, groupName), - system: groups.getGroupField(groupName, 'system'), - hasAdminPrivilege: privileges.admin.can('admin:groups', uid), - isGlobalMod: user.isGlobalModerator(uid), - }); - - if (!(results.isOwner || results.hasAdminPrivilege || (results.isGlobalMod && !results.system))) { - throw new Error('[[error:no-privileges]]'); - } + if (typeof groupName !== 'string') { + throw new TypeError('[[error:invalid-group-name]]'); + } + + const results = await utils.promiseParallel({ + isOwner: groups.ownership.isOwner(uid, groupName), + system: groups.getGroupField(groupName, 'system'), + hasAdminPrivilege: privileges.admin.can('admin:groups', uid), + isGlobalMod: user.isGlobalModerator(uid), + }); + + if (!(results.isOwner || results.hasAdminPrivilege || (results.isGlobalMod && !results.system))) { + throw new Error('[[error:no-privileges]]'); + } } function logGroupEvent(socket, event, additional) { - events.log({ - type: event, - uid: socket.uid, - ip: socket.ip, - ...additional, - }); + events.log({ + type: event, + uid: socket.uid, + ip: socket.ip, + ...additional, + }); } require('../promisify')(SocketGroups); diff --git a/src/socket.io/helpers.js b/src/socket.io/helpers.js index 2be0f0c..b60ec3d 100644 --- a/src/socket.io/helpers.js +++ b/src/socket.io/helpers.js @@ -1,9 +1,7 @@ 'use strict'; const _ = require('lodash'); - const db = require('../database'); -const websockets = require('./index'); const user = require('../user'); const posts = require('../posts'); const topics = require('../topics'); @@ -13,188 +11,192 @@ const notifications = require('../notifications'); const plugins = require('../plugins'); const utils = require('../utils'); const batch = require('../batch'); +const websockets = require('./index'); const SocketHelpers = module.exports; SocketHelpers.notifyNew = async function (uid, type, result) { - let uids = await user.getUidsFromSet('users:online', 0, -1); - uids = uids.filter(toUid => parseInt(toUid, 10) !== uid); - await batch.processArray(uids, async (uids) => { - await notifyUids(uid, uids, type, result); - }, { - interval: 1000, - }); + let uids = await user.getUidsFromSet('users:online', 0, -1); + uids = uids.filter(toUid => Number.parseInt(toUid, 10) !== uid); + await batch.processArray(uids, async uids => { + await notifyUids(uid, uids, type, result); + }, { + interval: 1000, + }); }; async function notifyUids(uid, uids, type, result) { - const post = result.posts[0]; - const { tid } = post.topic; - const { cid } = post.topic; - uids = await privileges.topics.filterUids('topics:read', tid, uids); - const watchStateUids = uids; - - const watchStates = await getWatchStates(watchStateUids, tid, cid); - - const categoryWatchStates = _.zipObject(watchStateUids, watchStates.categoryWatchStates); - const topicFollowState = _.zipObject(watchStateUids, watchStates.topicFollowed); - uids = filterTidCidIgnorers(watchStateUids, watchStates); - uids = await user.blocks.filterUids(uid, uids); - uids = await user.blocks.filterUids(post.topic.uid, uids); - const data = await plugins.hooks.fire('filter:sockets.sendNewPostToUids', { - uidsTo: uids, - uidFrom: uid, - type: type, - post: post, - }); - - post.ip = undefined; - - data.uidsTo.forEach((toUid) => { - post.categoryWatchState = categoryWatchStates[toUid]; - post.topic.isFollowing = topicFollowState[toUid]; - websockets.in(`uid_${toUid}`).emit('event:new_post', result); - if (result.topic && type === 'newTopic') { - websockets.in(`uid_${toUid}`).emit('event:new_topic', result.topic); - } - }); + const post = result.posts[0]; + const {tid} = post.topic; + const {cid} = post.topic; + uids = await privileges.topics.filterUids('topics:read', tid, uids); + const watchStateUids = uids; + + const watchStates = await getWatchStates(watchStateUids, tid, cid); + + const categoryWatchStates = _.zipObject(watchStateUids, watchStates.categoryWatchStates); + const topicFollowState = _.zipObject(watchStateUids, watchStates.topicFollowed); + uids = filterTidCidIgnorers(watchStateUids, watchStates); + uids = await user.blocks.filterUids(uid, uids); + uids = await user.blocks.filterUids(post.topic.uid, uids); + const data = await plugins.hooks.fire('filter:sockets.sendNewPostToUids', { + uidsTo: uids, + uidFrom: uid, + type, + post, + }); + + post.ip = undefined; + + for (const toUid of data.uidsTo) { + post.categoryWatchState = categoryWatchStates[toUid]; + post.topic.isFollowing = topicFollowState[toUid]; + websockets.in(`uid_${toUid}`).emit('event:new_post', result); + if (result.topic && type === 'newTopic') { + websockets.in(`uid_${toUid}`).emit('event:new_topic', result.topic); + } + } } async function getWatchStates(uids, tid, cid) { - return await utils.promiseParallel({ - topicFollowed: db.isSetMembers(`tid:${tid}:followers`, uids), - topicIgnored: db.isSetMembers(`tid:${tid}:ignorers`, uids), - categoryWatchStates: categories.getUidsWatchStates(cid, uids), - }); + return await utils.promiseParallel({ + topicFollowed: db.isSetMembers(`tid:${tid}:followers`, uids), + topicIgnored: db.isSetMembers(`tid:${tid}:ignorers`, uids), + categoryWatchStates: categories.getUidsWatchStates(cid, uids), + }); } function filterTidCidIgnorers(uids, watchStates) { - return uids.filter((uid, index) => watchStates.topicFollowed[index] || - (!watchStates.topicIgnored[index] && - watchStates.categoryWatchStates[index] !== categories.watchStates.ignoring)); + return uids.filter((uid, index) => watchStates.topicFollowed[index] + || (!watchStates.topicIgnored[index] + && watchStates.categoryWatchStates[index] !== categories.watchStates.ignoring)); } SocketHelpers.sendNotificationToPostOwner = async function (pid, fromuid, command, notification) { - if (!pid || !fromuid || !notification) { - return; - } - fromuid = parseInt(fromuid, 10); - const postData = await posts.getPostFields(pid, ['tid', 'uid', 'content']); - const [canRead, isIgnoring] = await Promise.all([ - privileges.posts.can('topics:read', pid, postData.uid), - topics.isIgnoring([postData.tid], postData.uid), - ]); - if (!canRead || isIgnoring[0] || !postData.uid || fromuid === postData.uid) { - return; - } - const [userData, topicTitle, postObj] = await Promise.all([ - user.getUserFields(fromuid, ['username']), - topics.getTopicField(postData.tid, 'title'), - posts.parsePost(postData), - ]); - - const { displayname } = userData; - - const title = utils.decodeHTMLEntities(topicTitle); - const titleEscaped = title.replace(/%/g, '%').replace(/,/g, ','); - - const notifObj = await notifications.create({ - type: command, - bodyShort: `[[${notification}, ${displayname}, ${titleEscaped}]]`, - bodyLong: postObj.content, - pid: pid, - tid: postData.tid, - path: `/post/${pid}`, - nid: `${command}:post:${pid}:uid:${fromuid}`, - from: fromuid, - mergeId: `${notification}|${pid}`, - topicTitle: topicTitle, - }); - - notifications.push(notifObj, [postData.uid]); + if (!pid || !fromuid || !notification) { + return; + } + + fromuid = Number.parseInt(fromuid, 10); + const postData = await posts.getPostFields(pid, ['tid', 'uid', 'content']); + const [canRead, isIgnoring] = await Promise.all([ + privileges.posts.can('topics:read', pid, postData.uid), + topics.isIgnoring([postData.tid], postData.uid), + ]); + if (!canRead || isIgnoring[0] || !postData.uid || fromuid === postData.uid) { + return; + } + + const [userData, topicTitle, postObject] = await Promise.all([ + user.getUserFields(fromuid, ['username']), + topics.getTopicField(postData.tid, 'title'), + posts.parsePost(postData), + ]); + + const {displayname} = userData; + + const title = utils.decodeHTMLEntities(topicTitle); + const titleEscaped = title.replaceAll('%', '%').replaceAll(',', ','); + + const notificationObject = await notifications.create({ + type: command, + bodyShort: `[[${notification}, ${displayname}, ${titleEscaped}]]`, + bodyLong: postObject.content, + pid, + tid: postData.tid, + path: `/post/${pid}`, + nid: `${command}:post:${pid}:uid:${fromuid}`, + from: fromuid, + mergeId: `${notification}|${pid}`, + topicTitle, + }); + + notifications.push(notificationObject, [postData.uid]); }; - SocketHelpers.sendNotificationToTopicOwner = async function (tid, fromuid, command, notification) { - if (!tid || !fromuid || !notification) { - return; - } + if (!tid || !fromuid || !notification) { + return; + } - fromuid = parseInt(fromuid, 10); + fromuid = Number.parseInt(fromuid, 10); - const [userData, topicData] = await Promise.all([ - user.getUserFields(fromuid, ['username']), - topics.getTopicFields(tid, ['uid', 'slug', 'title']), - ]); + const [userData, topicData] = await Promise.all([ + user.getUserFields(fromuid, ['username']), + topics.getTopicFields(tid, ['uid', 'slug', 'title']), + ]); - if (fromuid === topicData.uid) { - return; - } + if (fromuid === topicData.uid) { + return; + } - const { displayname } = userData; + const {displayname} = userData; - const ownerUid = topicData.uid; - const title = utils.decodeHTMLEntities(topicData.title); - const titleEscaped = title.replace(/%/g, '%').replace(/,/g, ','); + const ownerUid = topicData.uid; + const title = utils.decodeHTMLEntities(topicData.title); + const titleEscaped = title.replaceAll('%', '%').replaceAll(',', ','); - const notifObj = await notifications.create({ - bodyShort: `[[${notification}, ${displayname}, ${titleEscaped}]]`, - path: `/topic/${topicData.slug}`, - nid: `${command}:tid:${tid}:uid:${fromuid}`, - from: fromuid, - }); + const notificationObject = await notifications.create({ + bodyShort: `[[${notification}, ${displayname}, ${titleEscaped}]]`, + path: `/topic/${topicData.slug}`, + nid: `${command}:tid:${tid}:uid:${fromuid}`, + from: fromuid, + }); - if (ownerUid) { - notifications.push(notifObj, [ownerUid]); - } + if (ownerUid) { + notifications.push(notificationObject, [ownerUid]); + } }; SocketHelpers.upvote = async function (data, notification) { - if (!data || !data.post || !data.post.uid || !data.post.votes || !data.post.pid || !data.fromuid) { - return; - } - - const { votes } = data.post; - const touid = data.post.uid; - const { fromuid } = data; - const { pid } = data.post; - - const shouldNotify = { - all: function () { - return votes > 0; - }, - first: function () { - return votes === 1; - }, - everyTen: function () { - return votes > 0 && votes % 10 === 0; - }, - threshold: function () { - return [1, 5, 10, 25].includes(votes) || (votes >= 50 && votes % 50 === 0); - }, - logarithmic: function () { - return votes > 1 && Math.log10(votes) % 1 === 0; - }, - disabled: function () { - return false; - }, - }; - const settings = await user.getSettings(touid); - const should = shouldNotify[settings.upvoteNotifFreq] || shouldNotify.all; - - if (should()) { - SocketHelpers.sendNotificationToPostOwner(pid, fromuid, 'upvote', notification); - } + if (!data || !data.post || !data.post.uid || !data.post.votes || !data.post.pid || !data.fromuid) { + return; + } + + const {votes} = data.post; + const touid = data.post.uid; + const {fromuid} = data; + const {pid} = data.post; + + const shouldNotify = { + all() { + return votes > 0; + }, + first() { + return votes === 1; + }, + everyTen() { + return votes > 0 && votes % 10 === 0; + }, + threshold() { + return [1, 5, 10, 25].includes(votes) || (votes >= 50 && votes % 50 === 0); + }, + logarithmic() { + return votes > 1 && Math.log10(votes) % 1 === 0; + }, + disabled() { + return false; + }, + }; + const settings = await user.getSettings(touid); + const should = shouldNotify[settings.upvoteNotifFreq] || shouldNotify.all; + + if (should()) { + SocketHelpers.sendNotificationToPostOwner(pid, fromuid, 'upvote', notification); + } }; SocketHelpers.rescindUpvoteNotification = async function (pid, fromuid) { - await notifications.rescind(`upvote:post:${pid}:uid:${fromuid}`); - const uid = await posts.getPostField(pid, 'uid'); - const count = await user.notifications.getUnreadCount(uid); - websockets.in(`uid_${uid}`).emit('event:notifications.updateCount', count); + await notifications.rescind(`upvote:post:${pid}:uid:${fromuid}`); + const uid = await posts.getPostField(pid, 'uid'); + const count = await user.notifications.getUnreadCount(uid); + websockets.in(`uid_${uid}`).emit('event:notifications.updateCount', count); }; SocketHelpers.emitToUids = async function (event, data, uids) { - uids.forEach(toUid => websockets.in(`uid_${toUid}`).emit(event, data)); + for (const toUid of uids) { + websockets.in(`uid_${toUid}`).emit(event, data); + } }; require('../promisify')(SocketHelpers); diff --git a/src/socket.io/index.js b/src/socket.io/index.js index 7e2262f..9a75959 100644 --- a/src/socket.io/index.js +++ b/src/socket.io/index.js @@ -1,12 +1,11 @@ 'use strict'; -const os = require('os'); +const os = require('node:os'); +const util = require('node:util'); const nconf = require('nconf'); const winston = require('winston'); -const util = require('util'); const validator = require('validator'); const cookieParser = require('cookie-parser')(nconf.get('secret')); - const db = require('../database'); const user = require('../user'); const logger = require('../logger'); @@ -18,261 +17,277 @@ const Namespaces = Object.create(null); const Sockets = module.exports; Sockets.init = async function (server) { - requireModules(); - - const SocketIO = require('socket.io').Server; - const io = new SocketIO({ - path: `${nconf.get('relative_path')}/socket.io`, - }); - - if (nconf.get('isCluster')) { - if (nconf.get('redis')) { - const adapter = await require('../database/redis').socketAdapter(); - io.adapter(adapter); - } else { - winston.warn('clustering detected, you should setup redis!'); - } - } - - io.use(authorize); - - io.on('connection', onConnection); - - const opts = { - transports: nconf.get('socket.io:transports') || ['polling', 'websocket'], - cookie: false, - }; - /* + requireModules(); + + const SocketIO = require('socket.io').Server; + const io = new SocketIO({ + path: `${nconf.get('relative_path')}/socket.io`, + }); + + if (nconf.get('isCluster')) { + if (nconf.get('redis')) { + const adapter = await require('../database/redis').socketAdapter(); + io.adapter(adapter); + } else { + winston.warn('clustering detected, you should setup redis!'); + } + } + + io.use(authorize); + + io.on('connection', onConnection); + + const options = { + transports: nconf.get('socket.io:transports') || ['polling', 'websocket'], + cookie: false, + }; + /* * Restrict socket.io listener to cookie domain. If none is set, infer based on url. * Production only so you don't get accidentally locked out. * Can be overridden via config (socket.io:origins) */ - if (process.env.NODE_ENV !== 'development' || nconf.get('socket.io:cors')) { - const origins = nconf.get('socket.io:origins'); - opts.cors = nconf.get('socket.io:cors') || { - origin: origins, - methods: ['GET', 'POST'], - allowedHeaders: ['content-type'], - }; - winston.info(`[socket.io] Restricting access to origin: ${origins}`); - } - - io.listen(server, opts); - Sockets.server = io; + if (process.env.NODE_ENV !== 'development' || nconf.get('socket.io:cors')) { + const origins = nconf.get('socket.io:origins'); + options.cors = nconf.get('socket.io:cors') || { + origin: origins, + methods: ['GET', 'POST'], + allowedHeaders: ['content-type'], + }; + winston.info(`[socket.io] Restricting access to origin: ${origins}`); + } + + io.listen(server, options); + Sockets.server = io; }; function onConnection(socket) { - socket.ip = (socket.request.headers['x-forwarded-for'] || socket.request.connection.remoteAddress || '').split(',')[0]; - socket.request.ip = socket.ip; - logger.io_one(socket, socket.uid); - - onConnect(socket); - socket.onAny((event, ...args) => { - const payload = { data: [event].concat(args) }; - const als = require('../als'); - als.run({ uid: socket.uid }, onMessage, socket, payload); - }); - - socket.on('disconnect', () => { - onDisconnect(socket); - }); + socket.ip = (socket.request.headers['x-forwarded-for'] || socket.request.connection.remoteAddress || '').split(',')[0]; + socket.request.ip = socket.ip; + logger.io_one(socket, socket.uid); + + onConnect(socket); + socket.onAny((event, ...arguments_) => { + const payload = {data: [event].concat(arguments_)}; + const als = require('../als'); + als.run({uid: socket.uid}, onMessage, socket, payload); + }); + + socket.on('disconnect', () => { + onDisconnect(socket); + }); } function onDisconnect(socket) { - require('./uploads').clear(socket.id); - plugins.hooks.fire('action:sockets.disconnect', { socket: socket }); + require('./uploads').clear(socket.id); + plugins.hooks.fire('action:sockets.disconnect', {socket}); } async function onConnect(socket) { - try { - await validateSession(socket, '[[error:invalid-session]]'); - } catch (e) { - if (e.message === '[[error:invalid-session]]') { - socket.emit('event:invalid_session'); - } - - return; - } - - if (socket.uid) { - socket.join(`uid_${socket.uid}`); - socket.join('online_users'); - } else { - socket.join('online_guests'); - } - - socket.join(`sess_${socket.request.signedCookies[nconf.get('sessionKey')]}`); - socket.emit('checkSession', socket.uid); - socket.emit('setHostname', os.hostname()); - plugins.hooks.fire('action:sockets.connect', { socket: socket }); + try { + await validateSession(socket, '[[error:invalid-session]]'); + } catch (error) { + if (error.message === '[[error:invalid-session]]') { + socket.emit('event:invalid_session'); + } + + return; + } + + if (socket.uid) { + socket.join(`uid_${socket.uid}`); + socket.join('online_users'); + } else { + socket.join('online_guests'); + } + + socket.join(`sess_${socket.request.signedCookies[nconf.get('sessionKey')]}`); + socket.emit('checkSession', socket.uid); + socket.emit('setHostname', os.hostname()); + plugins.hooks.fire('action:sockets.connect', {socket}); } async function onMessage(socket, payload) { - if (!payload.data.length) { - return winston.warn('[socket.io] Empty payload'); - } - - const eventName = payload.data[0]; - const params = typeof payload.data[1] === 'function' ? {} : payload.data[1]; - const callback = typeof payload.data[payload.data.length - 1] === 'function' ? payload.data[payload.data.length - 1] : function () {}; - - if (!eventName) { - return winston.warn('[socket.io] Empty method name'); - } - - const parts = eventName.toString().split('.'); - const namespace = parts[0]; - const methodToCall = parts.reduce((prev, cur) => { - if (prev !== null && prev[cur] && (!prev.hasOwnProperty || prev.hasOwnProperty(cur))) { - return prev[cur]; - } - return null; - }, Namespaces); - - if (!methodToCall || typeof methodToCall !== 'function') { - if (process.env.NODE_ENV === 'development') { - winston.warn(`[socket.io] Unrecognized message: ${eventName}`); - } - const escapedName = validator.escape(String(eventName)); - return callback({ message: `[[error:invalid-event, ${escapedName}]]` }); - } - - socket.previousEvents = socket.previousEvents || []; - socket.previousEvents.push(eventName); - if (socket.previousEvents.length > 20) { - socket.previousEvents.shift(); - } - - if (!eventName.startsWith('admin.') && ratelimit.isFlooding(socket)) { - winston.warn(`[socket.io] Too many emits! Disconnecting uid : ${socket.uid}. Events : ${socket.previousEvents}`); - return socket.disconnect(); - } - - try { - await checkMaintenance(socket); - await validateSession(socket, '[[error:revalidate-failure]]'); - - if (Namespaces[namespace].before) { - await Namespaces[namespace].before(socket, eventName, params); - } - - if (methodToCall.constructor && methodToCall.constructor.name === 'AsyncFunction') { - const result = await methodToCall(socket, params); - callback(null, result); - } else { - methodToCall(socket, params, (err, result) => { - callback(err ? { message: err.message } : null, result); - }); - } - } catch (err) { - winston.error(`${eventName}\n${err.stack ? err.stack : err.message}`); - callback({ message: err.message }); - } + if (payload.data.length === 0) { + return winston.warn('[socket.io] Empty payload'); + } + + const eventName = payload.data[0]; + const parameters = typeof payload.data[1] === 'function' ? {} : payload.data[1]; + const callback = typeof payload.data.at(-1) === 'function' ? payload.data.at(-1) : function () {}; + + if (!eventName) { + return winston.warn('[socket.io] Empty method name'); + } + + const parts = eventName.toString().split('.'); + const namespace = parts[0]; + const methodToCall = parts.reduce((previous, current) => { + if (previous !== null && previous[current] && (!previous.hasOwnProperty || previous.hasOwnProperty(current))) { + return previous[current]; + } + + return null; + }, Namespaces); + + if (!methodToCall || typeof methodToCall !== 'function') { + if (process.env.NODE_ENV === 'development') { + winston.warn(`[socket.io] Unrecognized message: ${eventName}`); + } + + const escapedName = validator.escape(String(eventName)); + return callback({message: `[[error:invalid-event, ${escapedName}]]`}); + } + + socket.previousEvents = socket.previousEvents || []; + socket.previousEvents.push(eventName); + if (socket.previousEvents.length > 20) { + socket.previousEvents.shift(); + } + + if (!eventName.startsWith('admin.') && ratelimit.isFlooding(socket)) { + winston.warn(`[socket.io] Too many emits! Disconnecting uid : ${socket.uid}. Events : ${socket.previousEvents}`); + return socket.disconnect(); + } + + try { + await checkMaintenance(socket); + await validateSession(socket, '[[error:revalidate-failure]]'); + + if (Namespaces[namespace].before) { + await Namespaces[namespace].before(socket, eventName, parameters); + } + + if (methodToCall.constructor && methodToCall.constructor.name === 'AsyncFunction') { + const result = await methodToCall(socket, parameters); + callback(null, result); + } else { + methodToCall(socket, parameters, (error, result) => { + callback(error ? {message: error.message} : null, result); + }); + } + } catch (error) { + winston.error(`${eventName}\n${error.stack ? error.stack : error.message}`); + callback({message: error.message}); + } } function requireModules() { - const modules = [ - 'admin', 'categories', 'groups', 'meta', 'modules', - 'notifications', 'plugins', 'posts', 'topics', 'user', - 'blacklist', 'uploads', - ]; - - modules.forEach((module) => { - Namespaces[module] = require(`./${module}`); - }); + const modules = [ + 'admin', + 'categories', + 'groups', + 'meta', + 'modules', + 'notifications', + 'plugins', + 'posts', + 'topics', + 'user', + 'blacklist', + 'uploads', + ]; + + for (const module of modules) { + Namespaces[module] = require(`./${module}`); + } } async function checkMaintenance(socket) { - const meta = require('../meta'); - if (!meta.config.maintenanceMode) { - return; - } - const isAdmin = await user.isAdministrator(socket.uid); - if (isAdmin) { - return; - } - const validator = require('validator'); - throw new Error(`[[pages:maintenance.text, ${validator.escape(String(meta.config.title || 'NodeBB'))}]]`); + const meta = require('../meta'); + if (!meta.config.maintenanceMode) { + return; + } + + const isAdmin = await user.isAdministrator(socket.uid); + if (isAdmin) { + return; + } + + const validator = require('validator'); + throw new Error(`[[pages:maintenance.text, ${validator.escape(String(meta.config.title || 'NodeBB'))}]]`); } const getSessionAsync = util.promisify( - (sid, callback) => db.sessionStore.get(sid, (err, sessionObj) => callback(err, sessionObj || null)) + (sid, callback) => db.sessionStore.get(sid, (error, sessionObject) => callback(error, sessionObject || null)), ); -async function validateSession(socket, errorMsg) { - const req = socket.request; - const { sessionId } = await plugins.hooks.fire('filter:sockets.sessionId', { - sessionId: req.signedCookies ? req.signedCookies[nconf.get('sessionKey')] : null, - request: req, - }); +async function validateSession(socket, errorMessage) { + const request = socket.request; + const {sessionId} = await plugins.hooks.fire('filter:sockets.sessionId', { + sessionId: request.signedCookies ? request.signedCookies[nconf.get('sessionKey')] : null, + request, + }); - if (!sessionId) { - return; - } + if (!sessionId) { + return; + } - const sessionData = await getSessionAsync(sessionId); + const sessionData = await getSessionAsync(sessionId); - if (!sessionData) { - throw new Error(errorMsg); - } + if (!sessionData) { + throw new Error(errorMessage); + } - await plugins.hooks.fire('static:sockets.validateSession', { - req: req, - socket: socket, - session: sessionData, - }); + await plugins.hooks.fire('static:sockets.validateSession', { + req: request, + socket, + session: sessionData, + }); } -const cookieParserAsync = util.promisify((req, callback) => cookieParser(req, {}, err => callback(err))); +const cookieParserAsync = util.promisify((request, callback) => cookieParser(request, {}, error => callback(error))); async function authorize(socket, callback) { - const { request } = socket; + const {request} = socket; + + if (!request) { + return callback(new Error('[[error:not-authorized]]')); + } - if (!request) { - return callback(new Error('[[error:not-authorized]]')); - } + await cookieParserAsync(request); - await cookieParserAsync(request); + const {sessionId} = await plugins.hooks.fire('filter:sockets.sessionId', { + sessionId: request.signedCookies ? request.signedCookies[nconf.get('sessionKey')] : null, + request, + }); - const { sessionId } = await plugins.hooks.fire('filter:sockets.sessionId', { - sessionId: request.signedCookies ? request.signedCookies[nconf.get('sessionKey')] : null, - request: request, - }); + const sessionData = await getSessionAsync(sessionId); - const sessionData = await getSessionAsync(sessionId); + if (sessionData && sessionData.passport && sessionData.passport.user) { + request.session = sessionData; + socket.uid = Number.parseInt(sessionData.passport.user, 10); + } else { + socket.uid = 0; + } - if (sessionData && sessionData.passport && sessionData.passport.user) { - request.session = sessionData; - socket.uid = parseInt(sessionData.passport.user, 10); - } else { - socket.uid = 0; - } - request.uid = socket.uid; - callback(); + request.uid = socket.uid; + callback(); } Sockets.in = function (room) { - return Sockets.server && Sockets.server.in(room); + return Sockets.server && Sockets.server.in(room); }; Sockets.getUserSocketCount = function (uid) { - return Sockets.getCountInRoom(`uid_${uid}`); + return Sockets.getCountInRoom(`uid_${uid}`); }; Sockets.getCountInRoom = function (room) { - if (!Sockets.server) { - return 0; - } - const roomMap = Sockets.server.sockets.adapter.rooms.get(room); - return roomMap ? roomMap.size : 0; + if (!Sockets.server) { + return 0; + } + + const roomMap = Sockets.server.sockets.adapter.rooms.get(room); + return roomMap ? roomMap.size : 0; }; Sockets.warnDeprecated = (socket, replacement) => { - if (socket.previousEvents && socket.emit) { - socket.emit('event:deprecated_call', { - eventName: socket.previousEvents[socket.previousEvents.length - 1], - replacement: replacement, - }); - } - winston.warn(`[deprecated]\n ${new Error('-').stack.split('\n').slice(2, 5).join('\n')}\n use ${replacement}`); + if (socket.previousEvents && socket.emit) { + socket.emit('event:deprecated_call', { + eventName: socket.previousEvents.at(-1), + replacement, + }); + } + + winston.warn(`[deprecated]\n ${new Error('-').stack.split('\n').slice(2, 5).join('\n')}\n use ${replacement}`); }; diff --git a/src/socket.io/meta.js b/src/socket.io/meta.js index 3591394..bdb927a 100644 --- a/src/socket.io/meta.js +++ b/src/socket.io/meta.js @@ -1,63 +1,63 @@ 'use strict'; - const user = require('../user'); const topics = require('../topics'); const SocketMeta = { - rooms: {}, + rooms: {}, }; SocketMeta.reconnected = function (socket, data, callback) { - callback = callback || function () {}; - if (socket.uid) { - topics.pushUnreadCount(socket.uid); - user.notifications.pushCount(socket.uid); - } - callback(); + callback ||= function () {}; + if (socket.uid) { + topics.pushUnreadCount(socket.uid); + user.notifications.pushCount(socket.uid); + } + + callback(); }; /* Rooms */ SocketMeta.rooms.enter = function (socket, data, callback) { - if (!socket.uid) { - return callback(); - } + if (!socket.uid) { + return callback(); + } - if (!data) { - return callback(new Error('[[error:invalid-data]]')); - } + if (!data) { + return callback(new Error('[[error:invalid-data]]')); + } - if (data.enter) { - data.enter = data.enter.toString(); - } + data.enter &&= data.enter.toString(); - if (data.enter && data.enter.startsWith('uid_') && data.enter !== `uid_${socket.uid}`) { - return callback(new Error('[[error:not-allowed]]')); - } + if (data.enter && data.enter.startsWith('uid_') && data.enter !== `uid_${socket.uid}`) { + return callback(new Error('[[error:not-allowed]]')); + } - leaveCurrentRoom(socket); + leaveCurrentRoom(socket); - if (data.enter) { - socket.join(data.enter); - socket.currentRoom = data.enter; - } - callback(); + if (data.enter) { + socket.join(data.enter); + socket.currentRoom = data.enter; + } + + callback(); }; SocketMeta.rooms.leaveCurrent = function (socket, data, callback) { - if (!socket.uid || !socket.currentRoom) { - return callback(); - } - leaveCurrentRoom(socket); - callback(); + if (!socket.uid || !socket.currentRoom) { + return callback(); + } + + leaveCurrentRoom(socket); + callback(); }; function leaveCurrentRoom(socket) { - if (socket.currentRoom) { - socket.leave(socket.currentRoom); - socket.currentRoom = ''; - } + if (socket.currentRoom) { + socket.leave(socket.currentRoom); + socket.currentRoom = ''; + } } module.exports = SocketMeta; diff --git a/src/socket.io/modules.js b/src/socket.io/modules.js index 2665975..4345395 100644 --- a/src/socket.io/modules.js +++ b/src/socket.io/modules.js @@ -4,12 +4,11 @@ const db = require('../database'); const notifications = require('../notifications'); const Messaging = require('../messaging'); const utils = require('../utils'); -const server = require('./index'); const user = require('../user'); const privileges = require('../privileges'); - -const sockets = require('.'); const api = require('../api'); +const sockets = require('.'); +const server = require('./index'); const SocketModules = module.exports; @@ -19,236 +18,245 @@ SocketModules.settings = {}; /* Chat */ SocketModules.chats.getRaw = async function (socket, data) { - if (!data || !data.hasOwnProperty('mid')) { - throw new Error('[[error:invalid-data]]'); - } - const roomId = await Messaging.getMessageField(data.mid, 'roomId'); - const [isAdmin, hasMessage, inRoom] = await Promise.all([ - user.isAdministrator(socket.uid), - db.isSortedSetMember(`uid:${socket.uid}:chat:room:${roomId}:mids`, data.mid), - Messaging.isUserInRoom(socket.uid, roomId), - ]); - - if (!isAdmin && (!inRoom || !hasMessage)) { - throw new Error('[[error:not-allowed]]'); - } - - return await Messaging.getMessageField(data.mid, 'content'); + if (!data || !data.hasOwnProperty('mid')) { + throw new Error('[[error:invalid-data]]'); + } + + const roomId = await Messaging.getMessageField(data.mid, 'roomId'); + const [isAdmin, hasMessage, inRoom] = await Promise.all([ + user.isAdministrator(socket.uid), + db.isSortedSetMember(`uid:${socket.uid}:chat:room:${roomId}:mids`, data.mid), + Messaging.isUserInRoom(socket.uid, roomId), + ]); + + if (!isAdmin && (!inRoom || !hasMessage)) { + throw new Error('[[error:not-allowed]]'); + } + + return await Messaging.getMessageField(data.mid, 'content'); }; SocketModules.chats.isDnD = async function (socket, uid) { - const status = await db.getObjectField(`user:${uid}`, 'status'); - return status === 'dnd'; + const status = await db.getObjectField(`user:${uid}`, 'status'); + return status === 'dnd'; }; SocketModules.chats.newRoom = async function (socket, data) { - sockets.warnDeprecated(socket, 'POST /api/v3/chats'); + sockets.warnDeprecated(socket, 'POST /api/v3/chats'); - if (!data) { - throw new Error('[[error:invalid-data]]'); - } + if (!data) { + throw new Error('[[error:invalid-data]]'); + } - const roomObj = await api.chats.create(socket, { - uids: [data.touid], - }); - return roomObj.roomId; + const roomObject = await api.chats.create(socket, { + uids: [data.touid], + }); + return roomObject.roomId; }; SocketModules.chats.send = async function (socket, data) { - sockets.warnDeprecated(socket, 'POST /api/v3/chats/:roomId'); + sockets.warnDeprecated(socket, 'POST /api/v3/chats/:roomId'); - if (!data || !data.roomId || !socket.uid) { - throw new Error('[[error:invalid-data]]'); - } + if (!data || !data.roomId || !socket.uid) { + throw new Error('[[error:invalid-data]]'); + } - const canChat = await privileges.global.can('chat', socket.uid); - if (!canChat) { - throw new Error('[[error:no-privileges]]'); - } + const canChat = await privileges.global.can('chat', socket.uid); + if (!canChat) { + throw new Error('[[error:no-privileges]]'); + } - return api.chats.post(socket, data); + return api.chats.post(socket, data); }; SocketModules.chats.loadRoom = async function (socket, data) { - sockets.warnDeprecated(socket, 'GET /api/v3/chats/:roomId'); + sockets.warnDeprecated(socket, 'GET /api/v3/chats/:roomId'); - if (!data || !data.roomId) { - throw new Error('[[error:invalid-data]]'); - } + if (!data || !data.roomId) { + throw new Error('[[error:invalid-data]]'); + } - return await Messaging.loadRoom(socket.uid, data); + return await Messaging.loadRoom(socket.uid, data); }; SocketModules.chats.getUsersInRoom = async function (socket, data) { - sockets.warnDeprecated(socket, 'GET /api/v3/chats/:roomId/users'); + sockets.warnDeprecated(socket, 'GET /api/v3/chats/:roomId/users'); - if (!data || !data.roomId) { - throw new Error('[[error:invalid-data]]'); - } - const isUserInRoom = await Messaging.isUserInRoom(socket.uid, data.roomId); - if (!isUserInRoom) { - throw new Error('[[error:no-privileges]]'); - } + if (!data || !data.roomId) { + throw new Error('[[error:invalid-data]]'); + } - return api.chats.users(socket, data); + const isUserInRoom = await Messaging.isUserInRoom(socket.uid, data.roomId); + if (!isUserInRoom) { + throw new Error('[[error:no-privileges]]'); + } + + return api.chats.users(socket, data); }; SocketModules.chats.addUserToRoom = async function (socket, data) { - sockets.warnDeprecated(socket, 'POST /api/v3/chats/:roomId/users'); + sockets.warnDeprecated(socket, 'POST /api/v3/chats/:roomId/users'); - if (!data || !data.roomId || !data.username) { - throw new Error('[[error:invalid-data]]'); - } + if (!data || !data.roomId || !data.username) { + throw new Error('[[error:invalid-data]]'); + } - const canChat = await privileges.global.can('chat', socket.uid); - if (!canChat) { - throw new Error('[[error:no-privileges]]'); - } + const canChat = await privileges.global.can('chat', socket.uid); + if (!canChat) { + throw new Error('[[error:no-privileges]]'); + } - // Revised API now takes uids, not usernames - data.uids = [await user.getUidByUsername(data.username)]; - delete data.username; + // Revised API now takes uids, not usernames + data.uids = [await user.getUidByUsername(data.username)]; + delete data.username; - await api.chats.invite(socket, data); + await api.chats.invite(socket, data); }; SocketModules.chats.removeUserFromRoom = async function (socket, data) { - sockets.warnDeprecated(socket, 'DELETE /api/v3/chats/:roomId/users OR DELETE /api/v3/chats/:roomId/users/:uid'); + sockets.warnDeprecated(socket, 'DELETE /api/v3/chats/:roomId/users OR DELETE /api/v3/chats/:roomId/users/:uid'); - if (!data || !data.roomId) { - throw new Error('[[error:invalid-data]]'); - } + if (!data || !data.roomId) { + throw new Error('[[error:invalid-data]]'); + } - // Revised API can accept multiple uids now - data.uids = [data.uid]; - delete data.uid; + // Revised API can accept multiple uids now + data.uids = [data.uid]; + delete data.uid; - await api.chats.kick(socket, data); + await api.chats.kick(socket, data); }; SocketModules.chats.leave = async function (socket, roomid) { - sockets.warnDeprecated(socket, 'DELETE /api/v3/chats/:roomId/users OR DELETE /api/v3/chats/:roomId/users/:uid'); + sockets.warnDeprecated(socket, 'DELETE /api/v3/chats/:roomId/users OR DELETE /api/v3/chats/:roomId/users/:uid'); - if (!socket.uid || !roomid) { - throw new Error('[[error:invalid-data]]'); - } + if (!socket.uid || !roomid) { + throw new Error('[[error:invalid-data]]'); + } - await Messaging.leaveRoom([socket.uid], roomid); + await Messaging.leaveRoom([socket.uid], roomid); }; SocketModules.chats.edit = async function (socket, data) { - sockets.warnDeprecated(socket, 'PUT /api/v3/chats/:roomId/:mid'); + sockets.warnDeprecated(socket, 'PUT /api/v3/chats/:roomId/:mid'); + + if (!data || !data.roomId || !data.message) { + throw new Error('[[error:invalid-data]]'); + } - if (!data || !data.roomId || !data.message) { - throw new Error('[[error:invalid-data]]'); - } - await Messaging.canEdit(data.mid, socket.uid); - await Messaging.editMessage(socket.uid, data.mid, data.roomId, data.message); + await Messaging.canEdit(data.mid, socket.uid); + await Messaging.editMessage(socket.uid, data.mid, data.roomId, data.message); }; SocketModules.chats.delete = async function (socket, data) { - sockets.warnDeprecated(socket, 'DELETE /api/v3/chats/:roomId/:mid'); + sockets.warnDeprecated(socket, 'DELETE /api/v3/chats/:roomId/:mid'); - if (!data || !data.roomId || !data.messageId) { - throw new Error('[[error:invalid-data]]'); - } - await Messaging.canDelete(data.messageId, socket.uid); - await Messaging.deleteMessage(data.messageId, socket.uid); + if (!data || !data.roomId || !data.messageId) { + throw new Error('[[error:invalid-data]]'); + } + + await Messaging.canDelete(data.messageId, socket.uid); + await Messaging.deleteMessage(data.messageId, socket.uid); }; SocketModules.chats.restore = async function (socket, data) { - sockets.warnDeprecated(socket, 'POST /api/v3/chats/:roomId/:mid'); + sockets.warnDeprecated(socket, 'POST /api/v3/chats/:roomId/:mid'); + + if (!data || !data.roomId || !data.messageId) { + throw new Error('[[error:invalid-data]]'); + } - if (!data || !data.roomId || !data.messageId) { - throw new Error('[[error:invalid-data]]'); - } - await Messaging.canDelete(data.messageId, socket.uid); - await Messaging.restoreMessage(data.messageId, socket.uid); + await Messaging.canDelete(data.messageId, socket.uid); + await Messaging.restoreMessage(data.messageId, socket.uid); }; SocketModules.chats.canMessage = async function (socket, roomId) { - await Messaging.canMessageRoom(socket.uid, roomId); + await Messaging.canMessageRoom(socket.uid, roomId); }; SocketModules.chats.markRead = async function (socket, roomId) { - if (!socket.uid || !roomId) { - throw new Error('[[error:invalid-data]]'); - } - const [uidsInRoom] = await Promise.all([ - Messaging.getUidsInRoom(roomId, 0, -1), - Messaging.markRead(socket.uid, roomId), - ]); + if (!socket.uid || !roomId) { + throw new Error('[[error:invalid-data]]'); + } - Messaging.pushUnreadCount(socket.uid); - server.in(`uid_${socket.uid}`).emit('event:chats.markedAsRead', { roomId: roomId }); + const [uidsInRoom] = await Promise.all([ + Messaging.getUidsInRoom(roomId, 0, -1), + Messaging.markRead(socket.uid, roomId), + ]); - if (!uidsInRoom.includes(String(socket.uid))) { - return; - } + Messaging.pushUnreadCount(socket.uid); + server.in(`uid_${socket.uid}`).emit('event:chats.markedAsRead', {roomId}); - // Mark notification read - const nids = uidsInRoom.filter(uid => parseInt(uid, 10) !== socket.uid) - .map(uid => `chat_${uid}_${roomId}`); + if (!uidsInRoom.includes(String(socket.uid))) { + return; + } - await notifications.markReadMultiple(nids, socket.uid); - await user.notifications.pushCount(socket.uid); + // Mark notification read + const nids = uidsInRoom.filter(uid => Number.parseInt(uid, 10) !== socket.uid) + .map(uid => `chat_${uid}_${roomId}`); + + await notifications.markReadMultiple(nids, socket.uid); + await user.notifications.pushCount(socket.uid); }; SocketModules.chats.markAllRead = async function (socket) { - await Messaging.markAllRead(socket.uid); - Messaging.pushUnreadCount(socket.uid); + await Messaging.markAllRead(socket.uid); + Messaging.pushUnreadCount(socket.uid); }; SocketModules.chats.renameRoom = async function (socket, data) { - sockets.warnDeprecated(socket, 'PUT /api/v3/chats/:roomId'); + sockets.warnDeprecated(socket, 'PUT /api/v3/chats/:roomId'); - if (!data || !data.roomId || !data.newName) { - throw new Error('[[error:invalid-data]]'); - } + if (!data || !data.roomId || !data.newName) { + throw new Error('[[error:invalid-data]]'); + } - data.name = data.newName; - delete data.newName; - await api.chats.rename(socket, data); + data.name = data.newName; + delete data.newName; + await api.chats.rename(socket, data); }; SocketModules.chats.getRecentChats = async function (socket, data) { - if (!data || !utils.isNumber(data.after) || !utils.isNumber(data.uid)) { - throw new Error('[[error:invalid-data]]'); - } - const start = parseInt(data.after, 10); - const stop = start + 9; - return await Messaging.getRecentChats(socket.uid, data.uid, start, stop); + if (!data || !utils.isNumber(data.after) || !utils.isNumber(data.uid)) { + throw new Error('[[error:invalid-data]]'); + } + + const start = Number.parseInt(data.after, 10); + const stop = start + 9; + return await Messaging.getRecentChats(socket.uid, data.uid, start, stop); }; SocketModules.chats.hasPrivateChat = async function (socket, uid) { - if (socket.uid <= 0 || uid <= 0) { - throw new Error('[[error:invalid-data]]'); - } - return await Messaging.hasPrivateChat(socket.uid, uid); + if (socket.uid <= 0 || uid <= 0) { + throw new Error('[[error:invalid-data]]'); + } + + return await Messaging.hasPrivateChat(socket.uid, uid); }; SocketModules.chats.getMessages = async function (socket, data) { - sockets.warnDeprecated(socket, 'GET /api/v3/chats/:roomId/messages'); - - if (!socket.uid || !data || !data.uid || !data.roomId) { - throw new Error('[[error:invalid-data]]'); - } - - return await Messaging.getMessages({ - callerUid: socket.uid, - uid: data.uid, - roomId: data.roomId, - start: parseInt(data.start, 10) || 0, - count: 50, - }); + sockets.warnDeprecated(socket, 'GET /api/v3/chats/:roomId/messages'); + + if (!socket.uid || !data || !data.uid || !data.roomId) { + throw new Error('[[error:invalid-data]]'); + } + + return await Messaging.getMessages({ + callerUid: socket.uid, + uid: data.uid, + roomId: data.roomId, + start: Number.parseInt(data.start, 10) || 0, + count: 50, + }); }; SocketModules.chats.getIP = async function (socket, mid) { - const allowed = await privileges.global.can('view:users:info', socket.uid); - if (!allowed) { - throw new Error('[[error:no-privilege]]'); - } - return await Messaging.getMessageField(mid, 'ip'); + const allowed = await privileges.global.can('view:users:info', socket.uid); + if (!allowed) { + throw new Error('[[error:no-privilege]]'); + } + + return await Messaging.getMessageField(mid, 'ip'); }; require('../promisify')(SocketModules); diff --git a/src/socket.io/notifications.js b/src/socket.io/notifications.js index 1f9236a..8fbb1e6 100644 --- a/src/socket.io/notifications.js +++ b/src/socket.io/notifications.js @@ -6,37 +6,38 @@ const notifications = require('../notifications'); const SocketNotifs = module.exports; SocketNotifs.get = async function (socket, data) { - if (data && Array.isArray(data.nids) && socket.uid) { - return await user.notifications.getNotifications(data.nids, socket.uid); - } - return await user.notifications.get(socket.uid); + if (data && Array.isArray(data.nids) && socket.uid) { + return await user.notifications.getNotifications(data.nids, socket.uid); + } + + return await user.notifications.get(socket.uid); }; SocketNotifs.getCount = async function (socket) { - return await user.notifications.getUnreadCount(socket.uid); + return await user.notifications.getUnreadCount(socket.uid); }; SocketNotifs.deleteAll = async function (socket) { - if (!socket.uid) { - throw new Error('[[error:no-privileges]]'); - } + if (!socket.uid) { + throw new Error('[[error:no-privileges]]'); + } - await user.notifications.deleteAll(socket.uid); + await user.notifications.deleteAll(socket.uid); }; SocketNotifs.markRead = async function (socket, nid) { - await notifications.markRead(nid, socket.uid); - user.notifications.pushCount(socket.uid); + await notifications.markRead(nid, socket.uid); + user.notifications.pushCount(socket.uid); }; SocketNotifs.markUnread = async function (socket, nid) { - await notifications.markUnread(nid, socket.uid); - user.notifications.pushCount(socket.uid); + await notifications.markUnread(nid, socket.uid); + user.notifications.pushCount(socket.uid); }; SocketNotifs.markAllRead = async function (socket) { - await notifications.markAllRead(socket.uid); - user.notifications.pushCount(socket.uid); + await notifications.markAllRead(socket.uid); + user.notifications.pushCount(socket.uid); }; require('../promisify')(SocketNotifs); diff --git a/src/socket.io/posts.js b/src/socket.io/posts.js index 0b7cc14..837cd93 100644 --- a/src/socket.io/posts.js +++ b/src/socket.io/posts.js @@ -1,7 +1,6 @@ 'use strict'; const validator = require('validator'); - const db = require('../database'); const posts = require('../posts'); const privileges = require('../privileges'); @@ -19,166 +18,175 @@ require('./posts/votes')(SocketPosts); require('./posts/tools')(SocketPosts); SocketPosts.getRawPost = async function (socket, pid) { - const canRead = await privileges.posts.can('topics:read', pid, socket.uid); - if (!canRead) { - throw new Error('[[error:no-privileges]]'); - } - - const postData = await posts.getPostFields(pid, ['content', 'deleted']); - if (postData.deleted) { - throw new Error('[[error:no-post]]'); - } - postData.pid = pid; - const result = await plugins.hooks.fire('filter:post.getRawPost', { uid: socket.uid, postData: postData }); - return result.postData.content; + const canRead = await privileges.posts.can('topics:read', pid, socket.uid); + if (!canRead) { + throw new Error('[[error:no-privileges]]'); + } + + const postData = await posts.getPostFields(pid, ['content', 'deleted']); + if (postData.deleted) { + throw new Error('[[error:no-post]]'); + } + + postData.pid = pid; + const result = await plugins.hooks.fire('filter:post.getRawPost', {uid: socket.uid, postData}); + return result.postData.content; }; SocketPosts.getPostSummaryByIndex = async function (socket, data) { - if (data.index < 0) { - data.index = 0; - } - let pid; - if (data.index === 0) { - pid = await topics.getTopicField(data.tid, 'mainPid'); - } else { - pid = await db.getSortedSetRange(`tid:${data.tid}:posts`, data.index - 1, data.index - 1); - } - pid = Array.isArray(pid) ? pid[0] : pid; - if (!pid) { - return 0; - } - - const topicPrivileges = await privileges.topics.get(data.tid, socket.uid); - if (!topicPrivileges['topics:read']) { - throw new Error('[[error:no-privileges]]'); - } - - const postsData = await posts.getPostSummaryByPids([pid], socket.uid, { stripTags: false }); - posts.modifyPostByPrivilege(postsData[0], topicPrivileges); - return postsData[0]; + if (data.index < 0) { + data.index = 0; + } + + let pid; + pid = await (data.index === 0 ? topics.getTopicField(data.tid, 'mainPid') : db.getSortedSetRange(`tid:${data.tid}:posts`, data.index - 1, data.index - 1)); + + pid = Array.isArray(pid) ? pid[0] : pid; + if (!pid) { + return 0; + } + + const topicPrivileges = await privileges.topics.get(data.tid, socket.uid); + if (!topicPrivileges['topics:read']) { + throw new Error('[[error:no-privileges]]'); + } + + const postsData = await posts.getPostSummaryByPids([pid], socket.uid, {stripTags: false}); + posts.modifyPostByPrivilege(postsData[0], topicPrivileges); + return postsData[0]; }; SocketPosts.getPostSummaryByPid = async function (socket, data) { - if (!data || !data.pid) { - throw new Error('[[error:invalid-data]]'); - } - const { pid } = data; - const tid = await posts.getPostField(pid, 'tid'); - const topicPrivileges = await privileges.topics.get(tid, socket.uid); - if (!topicPrivileges['topics:read']) { - throw new Error('[[error:no-privileges]]'); - } - - const postsData = await posts.getPostSummaryByPids([pid], socket.uid, { stripTags: false }); - posts.modifyPostByPrivilege(postsData[0], topicPrivileges); - return postsData[0]; + if (!data || !data.pid) { + throw new Error('[[error:invalid-data]]'); + } + + const {pid} = data; + const tid = await posts.getPostField(pid, 'tid'); + const topicPrivileges = await privileges.topics.get(tid, socket.uid); + if (!topicPrivileges['topics:read']) { + throw new Error('[[error:no-privileges]]'); + } + + const postsData = await posts.getPostSummaryByPids([pid], socket.uid, {stripTags: false}); + posts.modifyPostByPrivilege(postsData[0], topicPrivileges); + return postsData[0]; }; SocketPosts.getCategory = async function (socket, pid) { - return await posts.getCidByPid(pid); + return await posts.getCidByPid(pid); }; SocketPosts.getPidIndex = async function (socket, data) { - if (!data) { - throw new Error('[[error:invalid-data]]'); - } - return await posts.getPidIndex(data.pid, data.tid, data.topicPostSort); + if (!data) { + throw new Error('[[error:invalid-data]]'); + } + + return await posts.getPidIndex(data.pid, data.tid, data.topicPostSort); }; SocketPosts.getReplies = async function (socket, pid) { - if (!utils.isNumber(pid)) { - throw new Error('[[error:invalid-data]]'); - } - const { topicPostSort } = await user.getSettings(socket.uid); - const pids = await posts.getPidsFromSet(`pid:${pid}:replies`, 0, -1, topicPostSort === 'newest_to_oldest'); - - let [postData, postPrivileges] = await Promise.all([ - posts.getPostsByPids(pids, socket.uid), - privileges.posts.get(pids, socket.uid), - ]); - postData = await topics.addPostData(postData, socket.uid); - postData.forEach((postData, index) => posts.modifyPostByPrivilege(postData, postPrivileges[index])); - postData = postData.filter((postData, index) => postData && postPrivileges[index].read); - postData = await user.blocks.filter(socket.uid, postData); - return postData; + if (!utils.isNumber(pid)) { + throw new TypeError('[[error:invalid-data]]'); + } + + const {topicPostSort} = await user.getSettings(socket.uid); + const pids = await posts.getPidsFromSet(`pid:${pid}:replies`, 0, -1, topicPostSort === 'newest_to_oldest'); + + let [postData, postPrivileges] = await Promise.all([ + posts.getPostsByPids(pids, socket.uid), + privileges.posts.get(pids, socket.uid), + ]); + postData = await topics.addPostData(postData, socket.uid); + postData.forEach((postData, index) => posts.modifyPostByPrivilege(postData, postPrivileges[index])); + postData = postData.filter((postData, index) => postData && postPrivileges[index].read); + postData = await user.blocks.filter(socket.uid, postData); + return postData; }; SocketPosts.accept = async function (socket, data) { - await canEditQueue(socket, data, 'accept'); - const result = await posts.submitFromQueue(data.id); - if (result && socket.uid !== parseInt(result.uid, 10)) { - await sendQueueNotification('post-queue-accepted', result.uid, `/post/${result.pid}`); - } - await logQueueEvent(socket, result, 'accept'); + await canEditQueue(socket, data, 'accept'); + const result = await posts.submitFromQueue(data.id); + if (result && socket.uid !== Number.parseInt(result.uid, 10)) { + await sendQueueNotification('post-queue-accepted', result.uid, `/post/${result.pid}`); + } + + await logQueueEvent(socket, result, 'accept'); }; SocketPosts.reject = async function (socket, data) { - await canEditQueue(socket, data, 'reject'); - const result = await posts.removeFromQueue(data.id); - if (result && socket.uid !== parseInt(result.uid, 10)) { - await sendQueueNotification('post-queue-rejected', result.uid, '/'); - } - await logQueueEvent(socket, result, 'reject'); + await canEditQueue(socket, data, 'reject'); + const result = await posts.removeFromQueue(data.id); + if (result && socket.uid !== Number.parseInt(result.uid, 10)) { + await sendQueueNotification('post-queue-rejected', result.uid, '/'); + } + + await logQueueEvent(socket, result, 'reject'); }; async function logQueueEvent(socket, result, type) { - const eventData = { - type: `post-queue-${result.type}-${type}`, - uid: socket.uid, - ip: socket.ip, - content: result.data.content, - targetUid: result.uid, - }; - if (result.type === 'topic') { - eventData.cid = result.data.cid; - eventData.title = result.data.title; - } else { - eventData.tid = result.data.tid; - } - if (result.pid) { - eventData.pid = result.pid; - } - await events.log(eventData); + const eventData = { + type: `post-queue-${result.type}-${type}`, + uid: socket.uid, + ip: socket.ip, + content: result.data.content, + targetUid: result.uid, + }; + if (result.type === 'topic') { + eventData.cid = result.data.cid; + eventData.title = result.data.title; + } else { + eventData.tid = result.data.tid; + } + + if (result.pid) { + eventData.pid = result.pid; + } + + await events.log(eventData); } SocketPosts.notify = async function (socket, data) { - await canEditQueue(socket, data, 'notify'); - const result = await posts.getFromQueue(data.id); - if (result) { - await sendQueueNotification('post-queue-notify', result.uid, `/post-queue/${data.id}`, validator.escape(String(data.message))); - } + await canEditQueue(socket, data, 'notify'); + const result = await posts.getFromQueue(data.id); + if (result) { + await sendQueueNotification('post-queue-notify', result.uid, `/post-queue/${data.id}`, validator.escape(String(data.message))); + } }; async function canEditQueue(socket, data, action) { - const canEditQueue = await posts.canEditQueue(socket.uid, data, action); - if (!canEditQueue) { - throw new Error('[[error:no-privileges]]'); - } + const canEditQueue = await posts.canEditQueue(socket.uid, data, action); + if (!canEditQueue) { + throw new Error('[[error:no-privileges]]'); + } } async function sendQueueNotification(type, targetUid, path, notificationText) { - const notifData = { - type: type, - nid: `${type}-${targetUid}-${path}`, - bodyShort: notificationText ? `[[notifications:${type}, ${notificationText}]]` : `[[notifications:${type}]]`, - path: path, - }; - if (parseInt(meta.config.postQueueNotificationUid, 10) > 0) { - notifData.from = meta.config.postQueueNotificationUid; - } - const notifObj = await notifications.create(notifData); - await notifications.push(notifObj, [targetUid]); + const notificationData = { + type, + nid: `${type}-${targetUid}-${path}`, + bodyShort: notificationText ? `[[notifications:${type}, ${notificationText}]]` : `[[notifications:${type}]]`, + path, + }; + if (Number.parseInt(meta.config.postQueueNotificationUid, 10) > 0) { + notificationData.from = meta.config.postQueueNotificationUid; + } + + const notificationObject = await notifications.create(notificationData); + await notifications.push(notificationObject, [targetUid]); } SocketPosts.editQueuedContent = async function (socket, data) { - if (!data || !data.id || (!data.content && !data.title && !data.cid)) { - throw new Error('[[error:invalid-data]]'); - } - await posts.editQueuedContent(socket.uid, data); - if (data.content) { - return await plugins.hooks.fire('filter:parse.post', { postData: data }); - } - return { postData: data }; + if (!data || !data.id || (!data.content && !data.title && !data.cid)) { + throw new Error('[[error:invalid-data]]'); + } + + await posts.editQueuedContent(socket.uid, data); + if (data.content) { + return await plugins.hooks.fire('filter:parse.post', {postData: data}); + } + + return {postData: data}; }; require('../promisify')(SocketPosts); diff --git a/src/socket.io/posts/tools.js b/src/socket.io/posts/tools.js index 71fe499..b0ef99d 100644 --- a/src/socket.io/posts/tools.js +++ b/src/socket.io/posts/tools.js @@ -1,7 +1,6 @@ 'use strict'; const nconf = require('nconf'); - const db = require('../../database'); const posts = require('../../posts'); const flags = require('../../flags'); @@ -13,86 +12,88 @@ const user = require('../../user'); const utils = require('../../utils'); module.exports = function (SocketPosts) { - SocketPosts.loadPostTools = async function (socket, data) { - if (!data || !data.pid || !data.cid) { - throw new Error('[[error:invalid-data]]'); - } + SocketPosts.loadPostTools = async function (socket, data) { + if (!data || !data.pid || !data.cid) { + throw new Error('[[error:invalid-data]]'); + } + + const results = await utils.promiseParallel({ + posts: posts.getPostFields(data.pid, ['deleted', 'bookmarks', 'uid', 'ip', 'flagId']), + isAdmin: user.isAdministrator(socket.uid), + isGlobalMod: user.isGlobalModerator(socket.uid), + isModerator: user.isModerator(socket.uid, data.cid), + canEdit: privileges.posts.canEdit(data.pid, socket.uid), + canDelete: privileges.posts.canDelete(data.pid, socket.uid), + canPurge: privileges.posts.canPurge(data.pid, socket.uid), + canFlag: privileges.posts.canFlag(data.pid, socket.uid), + flagged: flags.exists('post', data.pid, socket.uid), // Specifically, whether THIS calling user flagged + bookmarked: posts.hasBookmarked(data.pid, socket.uid), + pinned: posts.hasPinned(data.pid, socket.uid), + postSharing: social.getActivePostSharing(), + history: posts.diffs.exists(data.pid), + canViewInfo: privileges.global.can('view:users:info', socket.uid), + // Is the user also the topic owner? + isTopicOP: posts.isTopicOP(data.pid, socket.uid), + }); + + const postData = results.posts; + postData.absolute_url = `${nconf.get('url')}/post/${data.pid}`; + postData.bookmarked = results.bookmarked; + postData.selfPost = socket.uid && socket.uid === postData.uid; + postData.displayPin = results.isTopicOP || results.isAdmin || results.isGlobalMod || results.isModerator; + postData.display_edit_tools = results.canEdit.flag; + postData.display_delete_tools = results.canDelete.flag; + postData.display_purge_tools = results.canPurge; + postData.display_flag_tools = socket.uid && results.canFlag.flag; + postData.display_moderator_tools = postData.display_edit_tools || postData.display_delete_tools; + postData.display_move_tools = results.isAdmin || results.isModerator; + postData.display_change_owner_tools = results.isAdmin || results.isModerator; + postData.display_ip_ban = (results.isAdmin || results.isGlobalMod) && !postData.selfPost; + postData.display_history = results.history; + postData.flags = { + flagId: Number.parseInt(results.posts.flagId, 10) || null, + can: results.canFlag.flag, + exists: Boolean(results.posts.flagId), + flagged: results.flagged, + state: await db.getObjectField(`flag:${postData.flagId}`, 'state'), + }; - const results = await utils.promiseParallel({ - posts: posts.getPostFields(data.pid, ['deleted', 'bookmarks', 'uid', 'ip', 'flagId']), - isAdmin: user.isAdministrator(socket.uid), - isGlobalMod: user.isGlobalModerator(socket.uid), - isModerator: user.isModerator(socket.uid, data.cid), - canEdit: privileges.posts.canEdit(data.pid, socket.uid), - canDelete: privileges.posts.canDelete(data.pid, socket.uid), - canPurge: privileges.posts.canPurge(data.pid, socket.uid), - canFlag: privileges.posts.canFlag(data.pid, socket.uid), - flagged: flags.exists('post', data.pid, socket.uid), // specifically, whether THIS calling user flagged - bookmarked: posts.hasBookmarked(data.pid, socket.uid), - pinned: posts.hasPinned(data.pid, socket.uid), - postSharing: social.getActivePostSharing(), - history: posts.diffs.exists(data.pid), - canViewInfo: privileges.global.can('view:users:info', socket.uid), - // Is the user also the topic owner? - isTopicOP: posts.isTopicOP(data.pid, socket.uid), - }); + if (!results.isAdmin && !results.canViewInfo) { + postData.ip = undefined; + } - const postData = results.posts; - postData.absolute_url = `${nconf.get('url')}/post/${data.pid}`; - postData.bookmarked = results.bookmarked; - postData.selfPost = socket.uid && socket.uid === postData.uid; - postData.displayPin = results.isTopicOP || results.isAdmin || results.isGlobalMod || results.isModerator; - postData.display_edit_tools = results.canEdit.flag; - postData.display_delete_tools = results.canDelete.flag; - postData.display_purge_tools = results.canPurge; - postData.display_flag_tools = socket.uid && results.canFlag.flag; - postData.display_moderator_tools = postData.display_edit_tools || postData.display_delete_tools; - postData.display_move_tools = results.isAdmin || results.isModerator; - postData.display_change_owner_tools = results.isAdmin || results.isModerator; - postData.display_ip_ban = (results.isAdmin || results.isGlobalMod) && !postData.selfPost; - postData.display_history = results.history; - postData.flags = { - flagId: parseInt(results.posts.flagId, 10) || null, - can: results.canFlag.flag, - exists: !!results.posts.flagId, - flagged: results.flagged, - state: await db.getObjectField(`flag:${postData.flagId}`, 'state'), - }; + const {tools} = await plugins.hooks.fire('filter:post.tools', { + pid: data.pid, + post: postData, + uid: socket.uid, + tools: [], + }); + postData.tools = tools; - if (!results.isAdmin && !results.canViewInfo) { - postData.ip = undefined; - } - const { tools } = await plugins.hooks.fire('filter:post.tools', { - pid: data.pid, - post: postData, - uid: socket.uid, - tools: [], - }); - postData.tools = tools; + return results; + }; - return results; - }; + SocketPosts.changeOwner = async function (socket, data) { + if (!data || !Array.isArray(data.pids) || !data.toUid) { + throw new Error('[[error:invalid-data]]'); + } - SocketPosts.changeOwner = async function (socket, data) { - if (!data || !Array.isArray(data.pids) || !data.toUid) { - throw new Error('[[error:invalid-data]]'); - } - const isAdminOrGlobalMod = await user.isAdminOrGlobalMod(socket.uid); - if (!isAdminOrGlobalMod) { - throw new Error('[[error:no-privileges]]'); - } + const isAdminOrGlobalModule = await user.isAdminOrGlobalMod(socket.uid); + if (!isAdminOrGlobalModule) { + throw new Error('[[error:no-privileges]]'); + } - const postData = await posts.changeOwner(data.pids, data.toUid); - const logs = postData.map(({ pid, uid, cid }) => (events.log({ - type: 'post-change-owner', - uid: socket.uid, - ip: socket.ip, - targetUid: data.toUid, - pid: pid, - originalUid: uid, - cid: cid, - }))); + const postData = await posts.changeOwner(data.pids, data.toUid); + const logs = postData.map(({pid, uid, cid}) => (events.log({ + type: 'post-change-owner', + uid: socket.uid, + ip: socket.ip, + targetUid: data.toUid, + pid, + originalUid: uid, + cid, + }))); - await Promise.all(logs); - }; + await Promise.all(logs); + }; }; diff --git a/src/socket.io/posts/votes.js b/src/socket.io/posts/votes.js index 8309269..4e066c3 100644 --- a/src/socket.io/posts/votes.js +++ b/src/socket.io/posts/votes.js @@ -7,56 +7,60 @@ const privileges = require('../../privileges'); const meta = require('../../meta'); module.exports = function (SocketPosts) { - SocketPosts.getVoters = async function (socket, data) { - if (!data || !data.pid || !data.cid) { - throw new Error('[[error:invalid-data]]'); - } - const showDownvotes = !meta.config['downvote:disabled']; - const canSeeVotes = meta.config.votesArePublic || - await privileges.categories.isAdminOrMod(data.cid, socket.uid); - if (!canSeeVotes) { - throw new Error('[[error:no-privileges]]'); - } - const [upvoteUids, downvoteUids] = await Promise.all([ - db.getSetMembers(`pid:${data.pid}:upvote`), - showDownvotes ? db.getSetMembers(`pid:${data.pid}:downvote`) : [], - ]); - - const [upvoters, downvoters] = await Promise.all([ - user.getUsersFields(upvoteUids, ['username', 'userslug', 'picture']), - user.getUsersFields(downvoteUids, ['username', 'userslug', 'picture']), - ]); - - return { - upvoteCount: upvoters.length, - downvoteCount: downvoters.length, - showDownvotes: showDownvotes, - upvoters: upvoters, - downvoters: downvoters, - }; - }; - - SocketPosts.getUpvoters = async function (socket, pids) { - if (!Array.isArray(pids)) { - throw new Error('[[error:invalid-data]]'); - } - const data = await posts.getUpvotedUidsByPids(pids); - if (!data.length) { - return []; - } - - const result = await Promise.all(data.map(async (uids) => { - let otherCount = 0; - if (uids.length > 6) { - otherCount = uids.length - 5; - uids = uids.slice(0, 5); - } - const usernames = await user.getUsernamesByUids(uids); - return { - otherCount: otherCount, - usernames: usernames, - }; - })); - return result; - }; + SocketPosts.getVoters = async function (socket, data) { + if (!data || !data.pid || !data.cid) { + throw new Error('[[error:invalid-data]]'); + } + + const showDownvotes = !meta.config['downvote:disabled']; + const canSeeVotes = meta.config.votesArePublic + || await privileges.categories.isAdminOrMod(data.cid, socket.uid); + if (!canSeeVotes) { + throw new Error('[[error:no-privileges]]'); + } + + const [upvoteUids, downvoteUids] = await Promise.all([ + db.getSetMembers(`pid:${data.pid}:upvote`), + showDownvotes ? db.getSetMembers(`pid:${data.pid}:downvote`) : [], + ]); + + const [upvoters, downvoters] = await Promise.all([ + user.getUsersFields(upvoteUids, ['username', 'userslug', 'picture']), + user.getUsersFields(downvoteUids, ['username', 'userslug', 'picture']), + ]); + + return { + upvoteCount: upvoters.length, + downvoteCount: downvoters.length, + showDownvotes, + upvoters, + downvoters, + }; + }; + + SocketPosts.getUpvoters = async function (socket, pids) { + if (!Array.isArray(pids)) { + throw new TypeError('[[error:invalid-data]]'); + } + + const data = await posts.getUpvotedUidsByPids(pids); + if (data.length === 0) { + return []; + } + + const result = await Promise.all(data.map(async uids => { + let otherCount = 0; + if (uids.length > 6) { + otherCount = uids.length - 5; + uids = uids.slice(0, 5); + } + + const usernames = await user.getUsernamesByUids(uids); + return { + otherCount, + usernames, + }; + })); + return result; + }; }; diff --git a/src/socket.io/topics.js b/src/socket.io/topics.js index cbbd93f..a8750ca 100644 --- a/src/socket.io/topics.js +++ b/src/socket.io/topics.js @@ -1,7 +1,6 @@ 'use strict'; const _ = require('lodash'); - const db = require('../database'); const posts = require('../posts'); const topics = require('../topics'); @@ -21,108 +20,117 @@ require('./topics/tags')(SocketTopics); require('./topics/merge')(SocketTopics); SocketTopics.postcount = async function (socket, tid) { - const canRead = await privileges.topics.can('topics:read', tid, socket.uid); - if (!canRead) { - throw new Error('[[no-privileges]]'); - } - return await topics.getTopicField(tid, 'postcount'); + const canRead = await privileges.topics.can('topics:read', tid, socket.uid); + if (!canRead) { + throw new Error('[[no-privileges]]'); + } + + return await topics.getTopicField(tid, 'postcount'); }; SocketTopics.bookmark = async function (socket, data) { - if (!socket.uid || !data) { - throw new Error('[[error:invalid-data]]'); - } - const postcount = await topics.getTopicField(data.tid, 'postcount'); - if (data.index > meta.config.bookmarkThreshold && postcount > meta.config.bookmarkThreshold) { - await topics.setUserBookmark(data.tid, socket.uid, data.index); - } + if (!socket.uid || !data) { + throw new Error('[[error:invalid-data]]'); + } + + const postcount = await topics.getTopicField(data.tid, 'postcount'); + if (data.index > meta.config.bookmarkThreshold && postcount > meta.config.bookmarkThreshold) { + await topics.setUserBookmark(data.tid, socket.uid, data.index); + } }; SocketTopics.createTopicFromPosts = async function (socket, data) { - if (!socket.uid) { - throw new Error('[[error:not-logged-in]]'); - } - - if (!data || !data.title || !data.pids || !Array.isArray(data.pids)) { - throw new Error('[[error:invalid-data]]'); - } - - const result = await topics.createTopicFromPosts(socket.uid, data.title, data.pids, data.fromTid); - await events.log({ - type: `topic-fork`, - uid: socket.uid, - ip: socket.ip, - pids: String(data.pids), - fromTid: data.fromTid, - toTid: result.tid, - }); - return result; + if (!socket.uid) { + throw new Error('[[error:not-logged-in]]'); + } + + if (!data || !data.title || !data.pids || !Array.isArray(data.pids)) { + throw new Error('[[error:invalid-data]]'); + } + + const result = await topics.createTopicFromPosts(socket.uid, data.title, data.pids, data.fromTid); + await events.log({ + type: 'topic-fork', + uid: socket.uid, + ip: socket.ip, + pids: String(data.pids), + fromTid: data.fromTid, + toTid: result.tid, + }); + return result; }; SocketTopics.isFollowed = async function (socket, tid) { - const isFollowing = await topics.isFollowing([tid], socket.uid); - return isFollowing[0]; + const isFollowing = await topics.isFollowing([tid], socket.uid); + return isFollowing[0]; }; SocketTopics.isModerator = async function (socket, tid) { - const cid = await topics.getTopicField(tid, 'cid'); - return await user.isModerator(socket.uid, cid); + const cid = await topics.getTopicField(tid, 'cid'); + return await user.isModerator(socket.uid, cid); }; SocketTopics.getMyNextPostIndex = async function (socket, data) { - if (!data || !data.tid || !data.index || !data.sort) { - throw new Error('[[error:invalid-data]]'); - } - - async function getTopicPids(index) { - const topicSet = data.sort === 'most_votes' ? `tid:${data.tid}:posts:votes` : `tid:${data.tid}:posts`; - const reverse = data.sort === 'newest_to_oldest' || data.sort === 'most_votes'; - const cacheKey = `np:s:${topicSet}:r:${String(reverse)}:tid:${data.tid}:pids`; - const topicPids = cache.get(cacheKey); - if (topicPids) { - return topicPids.slice(index - 1); - } - const pids = await db[reverse ? 'getSortedSetRevRange' : 'getSortedSetRange'](topicSet, 0, -1); - cache.set(cacheKey, pids, 30000); - return pids.slice(index - 1); - } - - async function getUserPids() { - const cid = await topics.getTopicField(data.tid, 'cid'); - const cacheKey = `np:cid:${cid}:uid:${socket.uid}:pids`; - const userPids = cache.get(cacheKey); - if (userPids) { - return userPids; - } - const pids = await db.getSortedSetRange(`cid:${cid}:uid:${socket.uid}:pids`, 0, -1); - cache.set(cacheKey, pids, 30000); - return pids; - } - const postCountInTopic = await db.sortedSetScore(`tid:${data.tid}:posters`, socket.uid); - if (postCountInTopic <= 0) { - return 0; - } - const [topicPids, userPidsInCategory] = await Promise.all([ - getTopicPids(data.index), - getUserPids(), - ]); - const userPidsInTopic = _.intersection(topicPids, userPidsInCategory); - if (!userPidsInTopic.length) { - if (postCountInTopic > 0) { - // wrap around to beginning - const wrapIndex = await SocketTopics.getMyNextPostIndex(socket, { ...data, index: 1 }); - return wrapIndex; - } - return 0; - } - return await posts.getPidIndex(userPidsInTopic[0], data.tid, data.sort); + if (!data || !data.tid || !data.index || !data.sort) { + throw new Error('[[error:invalid-data]]'); + } + + async function getTopicPids(index) { + const topicSet = data.sort === 'most_votes' ? `tid:${data.tid}:posts:votes` : `tid:${data.tid}:posts`; + const reverse = data.sort === 'newest_to_oldest' || data.sort === 'most_votes'; + const cacheKey = `np:s:${topicSet}:r:${String(reverse)}:tid:${data.tid}:pids`; + const topicPids = cache.get(cacheKey); + if (topicPids) { + return topicPids.slice(index - 1); + } + + const pids = await db[reverse ? 'getSortedSetRevRange' : 'getSortedSetRange'](topicSet, 0, -1); + cache.set(cacheKey, pids, 30_000); + return pids.slice(index - 1); + } + + async function getUserPids() { + const cid = await topics.getTopicField(data.tid, 'cid'); + const cacheKey = `np:cid:${cid}:uid:${socket.uid}:pids`; + const userPids = cache.get(cacheKey); + if (userPids) { + return userPids; + } + + const pids = await db.getSortedSetRange(`cid:${cid}:uid:${socket.uid}:pids`, 0, -1); + cache.set(cacheKey, pids, 30_000); + return pids; + } + + const postCountInTopic = await db.sortedSetScore(`tid:${data.tid}:posters`, socket.uid); + if (postCountInTopic <= 0) { + return 0; + } + + const [topicPids, userPidsInCategory] = await Promise.all([ + getTopicPids(data.index), + getUserPids(), + ]); + const userPidsInTopic = _.intersection(topicPids, userPidsInCategory); + if (userPidsInTopic.length === 0) { + if (postCountInTopic > 0) { + // Wrap around to beginning + const wrapIndex = await SocketTopics.getMyNextPostIndex(socket, {...data, index: 1}); + return wrapIndex; + } + + return 0; + } + + return await posts.getPidIndex(userPidsInTopic[0], data.tid, data.sort); }; SocketTopics.getPostCountInTopic = async function (socket, tid) { - if (!socket.uid || !tid) { - return 0; - } - return await db.sortedSetScore(`tid:${tid}:posters`, socket.uid); + if (!socket.uid || !tid) { + return 0; + } + + return await db.sortedSetScore(`tid:${tid}:posters`, socket.uid); }; require('../promisify')(SocketTopics); diff --git a/src/socket.io/topics/infinitescroll.js b/src/socket.io/topics/infinitescroll.js index c825bbe..87758d5 100644 --- a/src/socket.io/topics/infinitescroll.js +++ b/src/socket.io/topics/infinitescroll.js @@ -7,49 +7,49 @@ const utils = require('../../utils'); const social = require('../../social'); module.exports = function (SocketTopics) { - SocketTopics.loadMore = async function (socket, data) { - if (!data || !data.tid || !utils.isNumber(data.after) || parseInt(data.after, 10) < 0) { - throw new Error('[[error:invalid-data]]'); - } - - const [userPrivileges, topicData] = await Promise.all([ - privileges.topics.get(data.tid, socket.uid), - topics.getTopicData(data.tid), - ]); - - if (!userPrivileges['topics:read'] || !privileges.topics.canViewDeletedScheduled(topicData, userPrivileges)) { - throw new Error('[[error:no-privileges]]'); - } - - const set = data.topicPostSort === 'most_votes' ? `tid:${data.tid}:posts:votes` : `tid:${data.tid}:posts`; - const reverse = data.topicPostSort === 'newest_to_oldest' || data.topicPostSort === 'most_votes'; - let start = Math.max(0, parseInt(data.after, 10)); - - const infScrollPostsPerPage = Math.max(0, Math.min( - meta.config.postsPerPage || 20, - parseInt(data.count, 10) || meta.config.postsPerPage || 20 - )); - - if (data.direction === -1) { - start -= infScrollPostsPerPage; - } - - let stop = start + infScrollPostsPerPage - 1; - - start = Math.max(0, start); - stop = Math.max(0, stop); - const [posts, postSharing] = await Promise.all([ - topics.getTopicPosts(topicData, set, start, stop, socket.uid, reverse), - social.getActivePostSharing(), - ]); - - topicData.posts = posts; - topicData.privileges = userPrivileges; - topicData.postSharing = postSharing; - topicData['reputation:disabled'] = meta.config['reputation:disabled'] === 1; - topicData['downvote:disabled'] = meta.config['downvote:disabled'] === 1; - - topics.modifyPostsByPrivilege(topicData, userPrivileges); - return topicData; - }; + SocketTopics.loadMore = async function (socket, data) { + if (!data || !data.tid || !utils.isNumber(data.after) || Number.parseInt(data.after, 10) < 0) { + throw new Error('[[error:invalid-data]]'); + } + + const [userPrivileges, topicData] = await Promise.all([ + privileges.topics.get(data.tid, socket.uid), + topics.getTopicData(data.tid), + ]); + + if (!userPrivileges['topics:read'] || !privileges.topics.canViewDeletedScheduled(topicData, userPrivileges)) { + throw new Error('[[error:no-privileges]]'); + } + + const set = data.topicPostSort === 'most_votes' ? `tid:${data.tid}:posts:votes` : `tid:${data.tid}:posts`; + const reverse = data.topicPostSort === 'newest_to_oldest' || data.topicPostSort === 'most_votes'; + let start = Math.max(0, Number.parseInt(data.after, 10)); + + const infScrollPostsPerPage = Math.max(0, Math.min( + meta.config.postsPerPage || 20, + Number.parseInt(data.count, 10) || meta.config.postsPerPage || 20, + )); + + if (data.direction === -1) { + start -= infScrollPostsPerPage; + } + + let stop = start + infScrollPostsPerPage - 1; + + start = Math.max(0, start); + stop = Math.max(0, stop); + const [posts, postSharing] = await Promise.all([ + topics.getTopicPosts(topicData, set, start, stop, socket.uid, reverse), + social.getActivePostSharing(), + ]); + + topicData.posts = posts; + topicData.privileges = userPrivileges; + topicData.postSharing = postSharing; + topicData['reputation:disabled'] = meta.config['reputation:disabled'] === 1; + topicData['downvote:disabled'] = meta.config['downvote:disabled'] === 1; + + topics.modifyPostsByPrivilege(topicData, userPrivileges); + return topicData; + }; }; diff --git a/src/socket.io/topics/merge.js b/src/socket.io/topics/merge.js index 7a143ed..f0bd407 100644 --- a/src/socket.io/topics/merge.js +++ b/src/socket.io/topics/merge.js @@ -5,25 +5,28 @@ const privileges = require('../../privileges'); const events = require('../../events'); module.exports = function (SocketTopics) { - SocketTopics.merge = async function (socket, data) { - if (!data || !Array.isArray(data.tids)) { - throw new Error('[[error:invalid-data]]'); - } - const allowed = await Promise.all(data.tids.map(tid => privileges.topics.isAdminOrMod(tid, socket.uid))); - if (allowed.includes(false)) { - throw new Error('[[error:no-privileges]]'); - } - if (data.options && data.options.mainTid && !data.tids.includes(data.options.mainTid)) { - throw new Error('[[error:invalid-data]]'); - } - const mergeIntoTid = await topics.merge(data.tids, socket.uid, data.options); - await events.log({ - type: `topic-merge`, - uid: socket.uid, - ip: socket.ip, - mergeIntoTid: mergeIntoTid, - tids: String(data.tids), - }); - return mergeIntoTid; - }; + SocketTopics.merge = async function (socket, data) { + if (!data || !Array.isArray(data.tids)) { + throw new Error('[[error:invalid-data]]'); + } + + const allowed = await Promise.all(data.tids.map(tid => privileges.topics.isAdminOrMod(tid, socket.uid))); + if (allowed.includes(false)) { + throw new Error('[[error:no-privileges]]'); + } + + if (data.options && data.options.mainTid && !data.tids.includes(data.options.mainTid)) { + throw new Error('[[error:invalid-data]]'); + } + + const mergeIntoTid = await topics.merge(data.tids, socket.uid, data.options); + await events.log({ + type: 'topic-merge', + uid: socket.uid, + ip: socket.ip, + mergeIntoTid, + tids: String(data.tids), + }); + return mergeIntoTid; + }; }; diff --git a/src/socket.io/topics/move.js b/src/socket.io/topics/move.js index acdde55..e4d6fff 100644 --- a/src/socket.io/topics/move.js +++ b/src/socket.io/topics/move.js @@ -9,65 +9,66 @@ const socketHelpers = require('../helpers'); const events = require('../../events'); module.exports = function (SocketTopics) { - SocketTopics.move = async function (socket, data) { - if (!data || !Array.isArray(data.tids) || !data.cid) { - throw new Error('[[error:invalid-data]]'); - } + SocketTopics.move = async function (socket, data) { + if (!data || !Array.isArray(data.tids) || !data.cid) { + throw new Error('[[error:invalid-data]]'); + } - const canMove = await privileges.categories.isAdminOrMod(data.cid, socket.uid); - if (!canMove) { - throw new Error('[[error:no-privileges]]'); - } + const canMove = await privileges.categories.isAdminOrMod(data.cid, socket.uid); + if (!canMove) { + throw new Error('[[error:no-privileges]]'); + } - const uids = await user.getUidsFromSet('users:online', 0, -1); + const uids = await user.getUidsFromSet('users:online', 0, -1); - await async.eachLimit(data.tids, 10, async (tid) => { - const canMove = await privileges.topics.isAdminOrMod(tid, socket.uid); - if (!canMove) { - throw new Error('[[error:no-privileges]]'); - } - const topicData = await topics.getTopicFields(tid, ['tid', 'cid', 'slug', 'deleted']); - data.uid = socket.uid; - await topics.tools.move(tid, data); + await async.eachLimit(data.tids, 10, async tid => { + const canMove = await privileges.topics.isAdminOrMod(tid, socket.uid); + if (!canMove) { + throw new Error('[[error:no-privileges]]'); + } - const notifyUids = await privileges.categories.filterUids('topics:read', topicData.cid, uids); - socketHelpers.emitToUids('event:topic_moved', topicData, notifyUids); - if (!topicData.deleted) { - socketHelpers.sendNotificationToTopicOwner(tid, socket.uid, 'move', 'notifications:moved_your_topic'); - } + const topicData = await topics.getTopicFields(tid, ['tid', 'cid', 'slug', 'deleted']); + data.uid = socket.uid; + await topics.tools.move(tid, data); - await events.log({ - type: `topic-move`, - uid: socket.uid, - ip: socket.ip, - tid: tid, - fromCid: topicData.cid, - toCid: data.cid, - }); - }); - }; + const notifyUids = await privileges.categories.filterUids('topics:read', topicData.cid, uids); + socketHelpers.emitToUids('event:topic_moved', topicData, notifyUids); + if (!topicData.deleted) { + socketHelpers.sendNotificationToTopicOwner(tid, socket.uid, 'move', 'notifications:moved_your_topic'); + } + await events.log({ + type: 'topic-move', + uid: socket.uid, + ip: socket.ip, + tid, + fromCid: topicData.cid, + toCid: data.cid, + }); + }); + }; - SocketTopics.moveAll = async function (socket, data) { - if (!data || !data.cid || !data.currentCid) { - throw new Error('[[error:invalid-data]]'); - } - const canMove = await privileges.categories.canMoveAllTopics(data.currentCid, data.cid, socket.uid); - if (!canMove) { - throw new Error('[[error:no-privileges]]'); - } + SocketTopics.moveAll = async function (socket, data) { + if (!data || !data.cid || !data.currentCid) { + throw new Error('[[error:invalid-data]]'); + } - const tids = await categories.getAllTopicIds(data.currentCid, 0, -1); - data.uid = socket.uid; - await async.eachLimit(tids, 50, async (tid) => { - await topics.tools.move(tid, data); - }); - await events.log({ - type: `topic-move-all`, - uid: socket.uid, - ip: socket.ip, - fromCid: data.currentCid, - toCid: data.cid, - }); - }; + const canMove = await privileges.categories.canMoveAllTopics(data.currentCid, data.cid, socket.uid); + if (!canMove) { + throw new Error('[[error:no-privileges]]'); + } + + const tids = await categories.getAllTopicIds(data.currentCid, 0, -1); + data.uid = socket.uid; + await async.eachLimit(tids, 50, async tid => { + await topics.tools.move(tid, data); + }); + await events.log({ + type: 'topic-move-all', + uid: socket.uid, + ip: socket.ip, + fromCid: data.currentCid, + toCid: data.cid, + }); + }; }; diff --git a/src/socket.io/topics/tags.js b/src/socket.io/topics/tags.js index daac701..6a0d8ef 100644 --- a/src/socket.io/topics/tags.js +++ b/src/socket.io/topics/tags.js @@ -8,78 +8,81 @@ const privileges = require('../../privileges'); const utils = require('../../utils'); module.exports = function (SocketTopics) { - SocketTopics.isTagAllowed = async function (socket, data) { - if (!data || !utils.isNumber(data.cid) || !data.tag) { - throw new Error('[[error:invalid-data]]'); - } + SocketTopics.isTagAllowed = async function (socket, data) { + if (!data || !utils.isNumber(data.cid) || !data.tag) { + throw new Error('[[error:invalid-data]]'); + } - const systemTags = (meta.config.systemTags || '').split(','); - const [tagWhitelist, isPrivileged] = await Promise.all([ - categories.getTagWhitelist([data.cid]), - user.isPrivileged(socket.uid), - ]); - return isPrivileged || - ( - !systemTags.includes(data.tag) && - (!tagWhitelist[0].length || tagWhitelist[0].includes(data.tag)) + const systemTags = (meta.config.systemTags || '').split(','); + const [tagInclude, isPrivileged] = await Promise.all([ + categories.getTagWhitelist([data.cid]), + user.isPrivileged(socket.uid), + ]); + return isPrivileged + || ( + !systemTags.includes(data.tag) + && (tagInclude[0].length === 0 || tagInclude[0].includes(data.tag)) ); - }; + }; - SocketTopics.canRemoveTag = async function (socket, data) { - if (!data || !data.tag) { - throw new Error('[[error:invalid-data]]'); - } + SocketTopics.canRemoveTag = async function (socket, data) { + if (!data || !data.tag) { + throw new Error('[[error:invalid-data]]'); + } - const systemTags = (meta.config.systemTags || '').split(','); - const isPrivileged = await user.isPrivileged(socket.uid); - return isPrivileged || !systemTags.includes(String(data.tag).trim()); - }; + const systemTags = (meta.config.systemTags || '').split(','); + const isPrivileged = await user.isPrivileged(socket.uid); + return isPrivileged || !systemTags.includes(String(data.tag).trim()); + }; - SocketTopics.autocompleteTags = async function (socket, data) { - if (data.cid) { - const canRead = await privileges.categories.can('topics:read', data.cid, socket.uid); - if (!canRead) { - throw new Error('[[error:no-privileges]]'); - } - } - data.cids = await categories.getCidsByPrivilege('categories:cid', socket.uid, 'topics:read'); - const result = await topics.autocompleteTags(data); - return result.map(tag => tag.value); - }; + SocketTopics.autocompleteTags = async function (socket, data) { + if (data.cid) { + const canRead = await privileges.categories.can('topics:read', data.cid, socket.uid); + if (!canRead) { + throw new Error('[[error:no-privileges]]'); + } + } - SocketTopics.searchTags = async function (socket, data) { - const result = await searchTags(socket.uid, topics.searchTags, data); - return result.map(tag => tag.value); - }; + data.cids = await categories.getCidsByPrivilege('categories:cid', socket.uid, 'topics:read'); + const result = await topics.autocompleteTags(data); + return result.map(tag => tag.value); + }; - SocketTopics.searchAndLoadTags = async function (socket, data) { - return await searchTags(socket.uid, topics.searchAndLoadTags, data); - }; + SocketTopics.searchTags = async function (socket, data) { + const result = await searchTags(socket.uid, topics.searchTags, data); + return result.map(tag => tag.value); + }; - async function searchTags(uid, method, data) { - const allowed = await privileges.global.can('search:tags', uid); - if (!allowed) { - throw new Error('[[error:no-privileges]]'); - } - if (data.cid) { - const canRead = await privileges.categories.can('topics:read', data.cid, uid); - if (!canRead) { - throw new Error('[[error:no-privileges]]'); - } - } - data.cids = await categories.getCidsByPrivilege('categories:cid', uid, 'topics:read'); - return await method(data); - } + SocketTopics.searchAndLoadTags = async function (socket, data) { + return await searchTags(socket.uid, topics.searchAndLoadTags, data); + }; - SocketTopics.loadMoreTags = async function (socket, data) { - if (!data || !utils.isNumber(data.after)) { - throw new Error('[[error:invalid-data]]'); - } + async function searchTags(uid, method, data) { + const allowed = await privileges.global.can('search:tags', uid); + if (!allowed) { + throw new Error('[[error:no-privileges]]'); + } - const start = parseInt(data.after, 10); - const stop = start + 99; - const cids = await categories.getCidsByPrivilege('categories:cid', socket.uid, 'topics:read'); - const tags = await topics.getCategoryTagsData(cids, start, stop); - return { tags: tags.filter(Boolean), nextStart: stop + 1 }; - }; + if (data.cid) { + const canRead = await privileges.categories.can('topics:read', data.cid, uid); + if (!canRead) { + throw new Error('[[error:no-privileges]]'); + } + } + + data.cids = await categories.getCidsByPrivilege('categories:cid', uid, 'topics:read'); + return await method(data); + } + + SocketTopics.loadMoreTags = async function (socket, data) { + if (!data || !utils.isNumber(data.after)) { + throw new Error('[[error:invalid-data]]'); + } + + const start = Number.parseInt(data.after, 10); + const stop = start + 99; + const cids = await categories.getCidsByPrivilege('categories:cid', socket.uid, 'topics:read'); + const tags = await topics.getCategoryTagsData(cids, start, stop); + return {tags: tags.filter(Boolean), nextStart: stop + 1}; + }; }; diff --git a/src/socket.io/topics/tools.js b/src/socket.io/topics/tools.js index 2a4064d..45fa9ee 100644 --- a/src/socket.io/topics/tools.js +++ b/src/socket.io/topics/tools.js @@ -5,36 +5,39 @@ const privileges = require('../../privileges'); const plugins = require('../../plugins'); module.exports = function (SocketTopics) { - SocketTopics.loadTopicTools = async function (socket, data) { - if (!socket.uid) { - throw new Error('[[error:no-privileges]]'); - } - if (!data) { - throw new Error('[[error:invalid-data]]'); - } - - const [topicData, userPrivileges] = await Promise.all([ - topics.getTopicData(data.tid), - privileges.topics.get(data.tid, socket.uid), - ]); - - if (!topicData) { - throw new Error('[[error:no-topic]]'); - } - if (!userPrivileges['topics:read']) { - throw new Error('[[error:no-privileges]]'); - } - topicData.privileges = userPrivileges; - const result = await plugins.hooks.fire('filter:topic.thread_tools', { topic: topicData, uid: socket.uid, tools: [] }); - result.topic.thread_tools = result.tools; - return result.topic; - }; - - SocketTopics.orderPinnedTopics = async function (socket, data) { - if (!data || !data.tid) { - throw new Error('[[error:invalid-data]]'); - } - - await topics.tools.orderPinnedTopics(socket.uid, data); - }; + SocketTopics.loadTopicTools = async function (socket, data) { + if (!socket.uid) { + throw new Error('[[error:no-privileges]]'); + } + + if (!data) { + throw new Error('[[error:invalid-data]]'); + } + + const [topicData, userPrivileges] = await Promise.all([ + topics.getTopicData(data.tid), + privileges.topics.get(data.tid, socket.uid), + ]); + + if (!topicData) { + throw new Error('[[error:no-topic]]'); + } + + if (!userPrivileges['topics:read']) { + throw new Error('[[error:no-privileges]]'); + } + + topicData.privileges = userPrivileges; + const result = await plugins.hooks.fire('filter:topic.thread_tools', {topic: topicData, uid: socket.uid, tools: []}); + result.topic.thread_tools = result.tools; + return result.topic; + }; + + SocketTopics.orderPinnedTopics = async function (socket, data) { + if (!data || !data.tid) { + throw new Error('[[error:invalid-data]]'); + } + + await topics.tools.orderPinnedTopics(socket.uid, data); + }; }; diff --git a/src/socket.io/topics/unread.js b/src/socket.io/topics/unread.js index 123a356..7c7bce3 100644 --- a/src/socket.io/topics/unread.js +++ b/src/socket.io/topics/unread.js @@ -5,70 +5,78 @@ const user = require('../../user'); const topics = require('../../topics'); module.exports = function (SocketTopics) { - SocketTopics.markAsRead = async function (socket, tids) { - if (!Array.isArray(tids) || socket.uid <= 0) { - throw new Error('[[error:invalid-data]]'); - } - const hasMarked = await topics.markAsRead(tids, socket.uid); - const promises = [topics.markTopicNotificationsRead(tids, socket.uid)]; - if (hasMarked) { - promises.push(topics.pushUnreadCount(socket.uid)); - } - await Promise.all(promises); - }; - - SocketTopics.markTopicNotificationsRead = async function (socket, tids) { - if (!Array.isArray(tids) || !socket.uid) { - throw new Error('[[error:invalid-data]]'); - } - await topics.markTopicNotificationsRead(tids, socket.uid); - }; - - SocketTopics.markAllRead = async function (socket) { - if (socket.uid <= 0) { - throw new Error('[[error:invalid-uid]]'); - } - await topics.markAllRead(socket.uid); - topics.pushUnreadCount(socket.uid); - }; - - SocketTopics.markCategoryTopicsRead = async function (socket, cid) { - const tids = await topics.getUnreadTids({ cid: cid, uid: socket.uid, filter: '' }); - await SocketTopics.markAsRead(socket, tids); - }; - - SocketTopics.markUnread = async function (socket, tid) { - if (!tid || socket.uid <= 0) { - throw new Error('[[error:invalid-data]]'); - } - await topics.markUnread(tid, socket.uid); - topics.pushUnreadCount(socket.uid); - }; - - SocketTopics.markAsUnreadForAll = async function (socket, tids) { - if (!Array.isArray(tids)) { - throw new Error('[[error:invalid-tid]]'); - } - - if (socket.uid <= 0) { - throw new Error('[[error:no-privileges]]'); - } - const isAdmin = await user.isAdministrator(socket.uid); - const now = Date.now(); - await Promise.all(tids.map(async (tid) => { - const topicData = await topics.getTopicFields(tid, ['tid', 'cid']); - if (!topicData.tid) { - throw new Error('[[error:no-topic]]'); - } - const isMod = await user.isModerator(socket.uid, topicData.cid); - if (!isAdmin && !isMod) { - throw new Error('[[error:no-privileges]]'); - } - await topics.markAsUnreadForAll(tid); - await topics.updateRecent(tid, now); - await db.sortedSetAdd(`cid:${topicData.cid}:tids:lastposttime`, now, tid); - await topics.setTopicField(tid, 'lastposttime', now); - })); - topics.pushUnreadCount(socket.uid); - }; + SocketTopics.markAsRead = async function (socket, tids) { + if (!Array.isArray(tids) || socket.uid <= 0) { + throw new Error('[[error:invalid-data]]'); + } + + const hasMarked = await topics.markAsRead(tids, socket.uid); + const promises = [topics.markTopicNotificationsRead(tids, socket.uid)]; + if (hasMarked) { + promises.push(topics.pushUnreadCount(socket.uid)); + } + + await Promise.all(promises); + }; + + SocketTopics.markTopicNotificationsRead = async function (socket, tids) { + if (!Array.isArray(tids) || !socket.uid) { + throw new Error('[[error:invalid-data]]'); + } + + await topics.markTopicNotificationsRead(tids, socket.uid); + }; + + SocketTopics.markAllRead = async function (socket) { + if (socket.uid <= 0) { + throw new Error('[[error:invalid-uid]]'); + } + + await topics.markAllRead(socket.uid); + topics.pushUnreadCount(socket.uid); + }; + + SocketTopics.markCategoryTopicsRead = async function (socket, cid) { + const tids = await topics.getUnreadTids({cid, uid: socket.uid, filter: ''}); + await SocketTopics.markAsRead(socket, tids); + }; + + SocketTopics.markUnread = async function (socket, tid) { + if (!tid || socket.uid <= 0) { + throw new Error('[[error:invalid-data]]'); + } + + await topics.markUnread(tid, socket.uid); + topics.pushUnreadCount(socket.uid); + }; + + SocketTopics.markAsUnreadForAll = async function (socket, tids) { + if (!Array.isArray(tids)) { + throw new TypeError('[[error:invalid-tid]]'); + } + + if (socket.uid <= 0) { + throw new Error('[[error:no-privileges]]'); + } + + const isAdmin = await user.isAdministrator(socket.uid); + const now = Date.now(); + await Promise.all(tids.map(async tid => { + const topicData = await topics.getTopicFields(tid, ['tid', 'cid']); + if (!topicData.tid) { + throw new Error('[[error:no-topic]]'); + } + + const isModule = await user.isModerator(socket.uid, topicData.cid); + if (!isAdmin && !isModule) { + throw new Error('[[error:no-privileges]]'); + } + + await topics.markAsUnreadForAll(tid); + await topics.updateRecent(tid, now); + await db.sortedSetAdd(`cid:${topicData.cid}:tids:lastposttime`, now, tid); + await topics.setTopicField(tid, 'lastposttime', now); + })); + topics.pushUnreadCount(socket.uid); + }; }; diff --git a/src/socket.io/uploads.js b/src/socket.io/uploads.js index c7f05b6..3c57000 100644 --- a/src/socket.io/uploads.js +++ b/src/socket.io/uploads.js @@ -1,53 +1,55 @@ 'use strict'; -const socketUser = require('./user'); -const socketGroup = require('./groups'); const image = require('../image'); const meta = require('../meta'); +const socketUser = require('./user'); +const socketGroup = require('./groups'); const inProgress = {}; const uploads = module.exports; uploads.upload = async function (socket, data) { - const methodToFunc = { - 'user.uploadCroppedPicture': socketUser.uploadCroppedPicture, - 'user.updateCover': socketUser.updateCover, - 'groups.cover.update': socketGroup.cover.update, - }; - if (!socket.uid || !data || !data.chunk || - !data.params || !data.params.method || !methodToFunc.hasOwnProperty(data.params.method)) { - throw new Error('[[error:invalid-data]]'); - } - - inProgress[socket.id] = inProgress[socket.id] || Object.create(null); - const socketUploads = inProgress[socket.id]; - const { method } = data.params; - - socketUploads[method] = socketUploads[method] || { imageData: '' }; - socketUploads[method].imageData += data.chunk; - - try { - const maxSize = data.params.method === 'user.uploadCroppedPicture' ? - meta.config.maximumProfileImageSize : meta.config.maximumCoverImageSize; - const size = image.sizeFromBase64(socketUploads[method].imageData); - - if (size > maxSize * 1024) { - throw new Error(`[[error:file-too-big, ${maxSize}]]`); - } - if (socketUploads[method].imageData.length < data.params.size) { - return; - } - data.params.imageData = socketUploads[method].imageData; - const result = await methodToFunc[data.params.method](socket, data.params); - delete socketUploads[method]; - return result; - } catch (err) { - delete inProgress[socket.id]; - throw err; - } + const methodToFunction = { + 'user.uploadCroppedPicture': socketUser.uploadCroppedPicture, + 'user.updateCover': socketUser.updateCover, + 'groups.cover.update': socketGroup.cover.update, + }; + if (!socket.uid || !data || !data.chunk + || !data.params || !data.params.method || !methodToFunction.hasOwnProperty(data.params.method)) { + throw new Error('[[error:invalid-data]]'); + } + + inProgress[socket.id] = inProgress[socket.id] || Object.create(null); + const socketUploads = inProgress[socket.id]; + const {method} = data.params; + + socketUploads[method] = socketUploads[method] || {imageData: ''}; + socketUploads[method].imageData += data.chunk; + + try { + const maxSize = data.params.method === 'user.uploadCroppedPicture' + ? meta.config.maximumProfileImageSize : meta.config.maximumCoverImageSize; + const size = image.sizeFromBase64(socketUploads[method].imageData); + + if (size > maxSize * 1024) { + throw new Error(`[[error:file-too-big, ${maxSize}]]`); + } + + if (socketUploads[method].imageData.length < data.params.size) { + return; + } + + data.params.imageData = socketUploads[method].imageData; + const result = await methodToFunction[data.params.method](socket, data.params); + delete socketUploads[method]; + return result; + } catch (error) { + delete inProgress[socket.id]; + throw error; + } }; uploads.clear = function (sid) { - delete inProgress[sid]; + delete inProgress[sid]; }; diff --git a/src/socket.io/user.js b/src/socket.io/user.js index a70bc5a..26e1dac 100644 --- a/src/socket.io/user.js +++ b/src/socket.io/user.js @@ -1,6 +1,6 @@ 'use strict'; -const util = require('util'); +const util = require('node:util'); const winston = require('winston'); const sleep = util.promisify(setTimeout); @@ -26,164 +26,172 @@ require('./user/picture')(SocketUser); require('./user/registration')(SocketUser); SocketUser.emailConfirm = async function (socket) { - sockets.warnDeprecated(socket, 'HTTP 302 /me/edit/email'); + sockets.warnDeprecated(socket, 'HTTP 302 /me/edit/email'); - if (!socket.uid) { - throw new Error('[[error:no-privileges]]'); - } + if (!socket.uid) { + throw new Error('[[error:no-privileges]]'); + } - return await user.email.sendValidationEmail(socket.uid); + return await user.email.sendValidationEmail(socket.uid); }; // Password Reset SocketUser.reset = {}; SocketUser.reset.send = async function (socket, email) { - if (!email) { - throw new Error('[[error:invalid-data]]'); - } - - if (meta.config['password:disableEdit']) { - throw new Error('[[error:no-privileges]]'); - } - async function logEvent(text) { - await events.log({ - type: 'password-reset', - text: text, - ip: socket.ip, - uid: socket.uid, - email: email, - }); - } - try { - await user.reset.send(email); - await logEvent('[[success:success]]'); - await sleep(2500 + ((Math.random() * 500) - 250)); - } catch (err) { - await logEvent(err.message); - await sleep(2500 + ((Math.random() * 500) - 250)); - const internalErrors = ['[[error:invalid-email]]', '[[error:reset-rate-limited]]']; - if (!internalErrors.includes(err.message)) { - throw err; - } - } + if (!email) { + throw new Error('[[error:invalid-data]]'); + } + + if (meta.config['password:disableEdit']) { + throw new Error('[[error:no-privileges]]'); + } + + async function logEvent(text) { + await events.log({ + type: 'password-reset', + text, + ip: socket.ip, + uid: socket.uid, + email, + }); + } + + try { + await user.reset.send(email); + await logEvent('[[success:success]]'); + await sleep(2500 + ((Math.random() * 500) - 250)); + } catch (error) { + await logEvent(error.message); + await sleep(2500 + ((Math.random() * 500) - 250)); + const internalErrors = ['[[error:invalid-email]]', '[[error:reset-rate-limited]]']; + if (!internalErrors.includes(error.message)) { + throw error; + } + } }; SocketUser.reset.commit = async function (socket, data) { - if (!data || !data.code || !data.password) { - throw new Error('[[error:invalid-data]]'); - } - const [uid] = await Promise.all([ - db.getObjectField('reset:uid', data.code), - user.reset.commit(data.code, data.password), - plugins.hooks.fire('action:password.reset', { uid: socket.uid }), - ]); - - await events.log({ - type: 'password-reset', - uid: uid, - ip: socket.ip, - }); - - const username = await user.getUserField(uid, 'username'); - const now = new Date(); - const parsedDate = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`; - emailer.send('reset_notify', uid, { - username: username, - date: parsedDate, - subject: '[[email:reset.notify.subject]]', - }).catch(err => winston.error(`[emailer.send] ${err.stack}`)); + if (!data || !data.code || !data.password) { + throw new Error('[[error:invalid-data]]'); + } + + const [uid] = await Promise.all([ + db.getObjectField('reset:uid', data.code), + user.reset.commit(data.code, data.password), + plugins.hooks.fire('action:password.reset', {uid: socket.uid}), + ]); + + await events.log({ + type: 'password-reset', + uid, + ip: socket.ip, + }); + + const username = await user.getUserField(uid, 'username'); + const now = new Date(); + const parsedDate = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`; + emailer.send('reset_notify', uid, { + username, + date: parsedDate, + subject: '[[email:reset.notify.subject]]', + }).catch(error => winston.error(`[emailer.send] ${error.stack}`)); }; SocketUser.isFollowing = async function (socket, data) { - if (!socket.uid || !data.uid) { - return false; - } + if (!socket.uid || !data.uid) { + return false; + } - return await user.isFollowing(socket.uid, data.uid); + return await user.isFollowing(socket.uid, data.uid); }; SocketUser.getUnreadCount = async function (socket) { - if (!socket.uid) { - return 0; - } - return await topics.getTotalUnread(socket.uid, ''); + if (!socket.uid) { + return 0; + } + + return await topics.getTotalUnread(socket.uid, ''); }; SocketUser.getUnreadChatCount = async function (socket) { - if (!socket.uid) { - return 0; - } - return await messaging.getUnreadCount(socket.uid); + if (!socket.uid) { + return 0; + } + + return await messaging.getUnreadCount(socket.uid); }; SocketUser.getUnreadCounts = async function (socket) { - if (!socket.uid) { - return {}; - } - const results = await utils.promiseParallel({ - unreadCounts: topics.getUnreadTids({ uid: socket.uid, count: true }), - unreadChatCount: messaging.getUnreadCount(socket.uid), - unreadNotificationCount: user.notifications.getUnreadCount(socket.uid), - }); - results.unreadTopicCount = results.unreadCounts['']; - results.unreadNewTopicCount = results.unreadCounts.new; - results.unreadWatchedTopicCount = results.unreadCounts.watched; - results.unreadUnrepliedTopicCount = results.unreadCounts.unreplied; - return results; + if (!socket.uid) { + return {}; + } + + const results = await utils.promiseParallel({ + unreadCounts: topics.getUnreadTids({uid: socket.uid, count: true}), + unreadChatCount: messaging.getUnreadCount(socket.uid), + unreadNotificationCount: user.notifications.getUnreadCount(socket.uid), + }); + results.unreadTopicCount = results.unreadCounts['']; + results.unreadNewTopicCount = results.unreadCounts.new; + results.unreadWatchedTopicCount = results.unreadCounts.watched; + results.unreadUnrepliedTopicCount = results.unreadCounts.unreplied; + return results; }; SocketUser.getUserByUID = async function (socket, uid) { - return await userController.getUserDataByField(socket.uid, 'uid', uid); + return await userController.getUserDataByField(socket.uid, 'uid', uid); }; SocketUser.getUserByUsername = async function (socket, username) { - return await userController.getUserDataByField(socket.uid, 'username', username); + return await userController.getUserDataByField(socket.uid, 'username', username); }; SocketUser.getUserByEmail = async function (socket, email) { - return await userController.getUserDataByField(socket.uid, 'email', email); + return await userController.getUserDataByField(socket.uid, 'email', email); }; SocketUser.setModerationNote = async function (socket, data) { - if (!socket.uid || !data || !data.uid || !data.note) { - throw new Error('[[error:invalid-data]]'); - } - const noteData = { - uid: socket.uid, - note: data.note, - timestamp: Date.now(), - }; - let canEdit = await privileges.users.canEdit(socket.uid, data.uid); - if (!canEdit) { - canEdit = await user.isModeratorOfAnyCategory(socket.uid); - } - if (!canEdit) { - throw new Error('[[error:no-privileges]]'); - } - - await user.appendModerationNote({ uid: data.uid, noteData }); + if (!socket.uid || !data || !data.uid || !data.note) { + throw new Error('[[error:invalid-data]]'); + } + + const noteData = { + uid: socket.uid, + note: data.note, + timestamp: Date.now(), + }; + let canEdit = await privileges.users.canEdit(socket.uid, data.uid); + canEdit ||= await user.isModeratorOfAnyCategory(socket.uid); + + if (!canEdit) { + throw new Error('[[error:no-privileges]]'); + } + + await user.appendModerationNote({uid: data.uid, noteData}); }; SocketUser.deleteUpload = async function (socket, data) { - if (!data || !data.name || !data.uid) { - throw new Error('[[error:invalid-data]]'); - } - await user.deleteUpload(socket.uid, data.uid, data.name); + if (!data || !data.name || !data.uid) { + throw new Error('[[error:invalid-data]]'); + } + + await user.deleteUpload(socket.uid, data.uid, data.name); }; SocketUser.gdpr = {}; SocketUser.gdpr.consent = async function (socket) { - await user.setUserField(socket.uid, 'gdpr_consent', 1); + await user.setUserField(socket.uid, 'gdpr_consent', 1); }; SocketUser.gdpr.check = async function (socket, data) { - const isAdmin = await user.isAdministrator(socket.uid); - if (!isAdmin) { - data.uid = socket.uid; - } - return await db.getObjectField(`user:${data.uid}`, 'gdpr_consent'); + const isAdmin = await user.isAdministrator(socket.uid); + if (!isAdmin) { + data.uid = socket.uid; + } + + return await db.getObjectField(`user:${data.uid}`, 'gdpr_consent'); }; require('../promisify')(SocketUser); diff --git a/src/socket.io/user/picture.js b/src/socket.io/user/picture.js index 5493772..990d043 100644 --- a/src/socket.io/user/picture.js +++ b/src/socket.io/user/picture.js @@ -4,41 +4,42 @@ const user = require('../../user'); const plugins = require('../../plugins'); module.exports = function (SocketUser) { - SocketUser.removeUploadedPicture = async function (socket, data) { - if (!socket.uid || !data || !data.uid) { - throw new Error('[[error:invalid-data]]'); - } - await user.isAdminOrSelf(socket.uid, data.uid); - // 'keepAllUserImages' is ignored, since there is explicit user intent - const userData = await user.removeProfileImage(data.uid); - plugins.hooks.fire('action:user.removeUploadedPicture', { - callerUid: socket.uid, - uid: data.uid, - user: userData, - }); - }; + SocketUser.removeUploadedPicture = async function (socket, data) { + if (!socket.uid || !data || !data.uid) { + throw new Error('[[error:invalid-data]]'); + } - SocketUser.getProfilePictures = async function (socket, data) { - if (!data || !data.uid) { - throw new Error('[[error:invalid-data]]'); - } + await user.isAdminOrSelf(socket.uid, data.uid); + // 'keepAllUserImages' is ignored, since there is explicit user intent + const userData = await user.removeProfileImage(data.uid); + plugins.hooks.fire('action:user.removeUploadedPicture', { + callerUid: socket.uid, + uid: data.uid, + user: userData, + }); + }; - const [list, uploaded] = await Promise.all([ - plugins.hooks.fire('filter:user.listPictures', { - uid: data.uid, - pictures: [], - }), - user.getUserField(data.uid, 'uploadedpicture'), - ]); + SocketUser.getProfilePictures = async function (socket, data) { + if (!data || !data.uid) { + throw new Error('[[error:invalid-data]]'); + } - if (uploaded) { - list.pictures.push({ - type: 'uploaded', - url: uploaded, - text: '[[user:uploaded_picture]]', - }); - } + const [list, uploaded] = await Promise.all([ + plugins.hooks.fire('filter:user.listPictures', { + uid: data.uid, + pictures: [], + }), + user.getUserField(data.uid, 'uploadedpicture'), + ]); - return list.pictures; - }; + if (uploaded) { + list.pictures.push({ + type: 'uploaded', + url: uploaded, + text: '[[user:uploaded_picture]]', + }); + } + + return list.pictures; + }; }; diff --git a/src/socket.io/user/profile.js b/src/socket.io/user/profile.js index 6f68073..d2960d0 100644 --- a/src/socket.io/user/profile.js +++ b/src/socket.io/user/profile.js @@ -3,77 +3,78 @@ const user = require('../../user'); const privileges = require('../../privileges'); const plugins = require('../../plugins'); - const sockets = require('..'); const api = require('../../api'); module.exports = function (SocketUser) { - SocketUser.updateCover = async function (socket, data) { - if (!socket.uid) { - throw new Error('[[error:no-privileges]]'); - } - await user.isAdminOrGlobalModOrSelf(socket.uid, data.uid); - await user.checkMinReputation(socket.uid, data.uid, 'min:rep:cover-picture'); - return await user.updateCoverPicture(data); - }; - - SocketUser.uploadCroppedPicture = async function (socket, data) { - if (!socket.uid || !(await privileges.users.canEdit(socket.uid, data.uid))) { - throw new Error('[[error:no-privileges]]'); - } - - await user.checkMinReputation(socket.uid, data.uid, 'min:rep:profile-picture'); - data.callerUid = socket.uid; - return await user.uploadCroppedPicture(data); - }; - - SocketUser.removeCover = async function (socket, data) { - if (!socket.uid) { - throw new Error('[[error:no-privileges]]'); - } - await user.isAdminOrGlobalModOrSelf(socket.uid, data.uid); - const userData = await user.getUserFields(data.uid, ['cover:url']); - // 'keepAllUserImages' is ignored, since there is explicit user intent - await user.removeCoverPicture(data); - plugins.hooks.fire('action:user.removeCoverPicture', { - callerUid: socket.uid, - uid: data.uid, - user: userData, - }); - }; - - SocketUser.toggleBlock = async function (socket, data) { - const isBlocked = await user.blocks.is(data.blockeeUid, data.blockerUid); - await user.blocks.can(socket.uid, data.blockerUid, data.blockeeUid, isBlocked ? 'unblock' : 'block'); - await user.blocks[isBlocked ? 'remove' : 'add'](data.blockeeUid, data.blockerUid); - return !isBlocked; - }; - - SocketUser.exportProfile = async function (socket, data) { - await doExport(socket, data, 'profile'); - }; - - SocketUser.exportPosts = async function (socket, data) { - await doExport(socket, data, 'posts'); - }; - - SocketUser.exportUploads = async function (socket, data) { - await doExport(socket, data, 'uploads'); - }; - - async function doExport(socket, data, type) { - sockets.warnDeprecated(socket, 'POST /api/v3/users/:uid/exports/:type'); - - if (!socket.uid) { - throw new Error('[[error:invalid-uid]]'); - } - - if (!data || parseInt(data.uid, 10) <= 0) { - throw new Error('[[error:invalid-data]]'); - } - - await user.isAdminOrSelf(socket.uid, data.uid); - - api.users.generateExport(socket, { type, ...data }); - } + SocketUser.updateCover = async function (socket, data) { + if (!socket.uid) { + throw new Error('[[error:no-privileges]]'); + } + + await user.isAdminOrGlobalModOrSelf(socket.uid, data.uid); + await user.checkMinReputation(socket.uid, data.uid, 'min:rep:cover-picture'); + return await user.updateCoverPicture(data); + }; + + SocketUser.uploadCroppedPicture = async function (socket, data) { + if (!socket.uid || !(await privileges.users.canEdit(socket.uid, data.uid))) { + throw new Error('[[error:no-privileges]]'); + } + + await user.checkMinReputation(socket.uid, data.uid, 'min:rep:profile-picture'); + data.callerUid = socket.uid; + return await user.uploadCroppedPicture(data); + }; + + SocketUser.removeCover = async function (socket, data) { + if (!socket.uid) { + throw new Error('[[error:no-privileges]]'); + } + + await user.isAdminOrGlobalModOrSelf(socket.uid, data.uid); + const userData = await user.getUserFields(data.uid, ['cover:url']); + // 'keepAllUserImages' is ignored, since there is explicit user intent + await user.removeCoverPicture(data); + plugins.hooks.fire('action:user.removeCoverPicture', { + callerUid: socket.uid, + uid: data.uid, + user: userData, + }); + }; + + SocketUser.toggleBlock = async function (socket, data) { + const isBlocked = await user.blocks.is(data.blockeeUid, data.blockerUid); + await user.blocks.can(socket.uid, data.blockerUid, data.blockeeUid, isBlocked ? 'unblock' : 'block'); + await user.blocks[isBlocked ? 'remove' : 'add'](data.blockeeUid, data.blockerUid); + return !isBlocked; + }; + + SocketUser.exportProfile = async function (socket, data) { + await doExport(socket, data, 'profile'); + }; + + SocketUser.exportPosts = async function (socket, data) { + await doExport(socket, data, 'posts'); + }; + + SocketUser.exportUploads = async function (socket, data) { + await doExport(socket, data, 'uploads'); + }; + + async function doExport(socket, data, type) { + sockets.warnDeprecated(socket, 'POST /api/v3/users/:uid/exports/:type'); + + if (!socket.uid) { + throw new Error('[[error:invalid-uid]]'); + } + + if (!data || Number.parseInt(data.uid, 10) <= 0) { + throw new Error('[[error:invalid-data]]'); + } + + await user.isAdminOrSelf(socket.uid, data.uid); + + api.users.generateExport(socket, {type, ...data}); + } }; diff --git a/src/socket.io/user/registration.js b/src/socket.io/user/registration.js index 0d173a3..d1f2637 100644 --- a/src/socket.io/user/registration.js +++ b/src/socket.io/user/registration.js @@ -4,40 +4,43 @@ const user = require('../../user'); const events = require('../../events'); module.exports = function (SocketUser) { - SocketUser.acceptRegistration = async function (socket, data) { - const isAdminOrGlobalMod = await user.isAdminOrGlobalMod(socket.uid); - if (!isAdminOrGlobalMod) { - throw new Error('[[error:no-privileges]]'); - } - const uid = await user.acceptRegistration(data.username); - await events.log({ - type: 'registration-approved', - uid: socket.uid, - ip: socket.ip, - targetUid: uid, - }); - return uid; - }; + SocketUser.acceptRegistration = async function (socket, data) { + const isAdminOrGlobalModule = await user.isAdminOrGlobalMod(socket.uid); + if (!isAdminOrGlobalModule) { + throw new Error('[[error:no-privileges]]'); + } - SocketUser.rejectRegistration = async function (socket, data) { - const isAdminOrGlobalMod = await user.isAdminOrGlobalMod(socket.uid); - if (!isAdminOrGlobalMod) { - throw new Error('[[error:no-privileges]]'); - } - await user.rejectRegistration(data.username); - await events.log({ - type: 'registration-rejected', - uid: socket.uid, - ip: socket.ip, - username: data.username, - }); - }; + const uid = await user.acceptRegistration(data.username); + await events.log({ + type: 'registration-approved', + uid: socket.uid, + ip: socket.ip, + targetUid: uid, + }); + return uid; + }; - SocketUser.deleteInvitation = async function (socket, data) { - const isAdminOrGlobalMod = await user.isAdminOrGlobalMod(socket.uid); - if (!isAdminOrGlobalMod) { - throw new Error('[[error:no-privileges]]'); - } - await user.deleteInvitation(data.invitedBy, data.email); - }; + SocketUser.rejectRegistration = async function (socket, data) { + const isAdminOrGlobalModule = await user.isAdminOrGlobalMod(socket.uid); + if (!isAdminOrGlobalModule) { + throw new Error('[[error:no-privileges]]'); + } + + await user.rejectRegistration(data.username); + await events.log({ + type: 'registration-rejected', + uid: socket.uid, + ip: socket.ip, + username: data.username, + }); + }; + + SocketUser.deleteInvitation = async function (socket, data) { + const isAdminOrGlobalModule = await user.isAdminOrGlobalMod(socket.uid); + if (!isAdminOrGlobalModule) { + throw new Error('[[error:no-privileges]]'); + } + + await user.deleteInvitation(data.invitedBy, data.email); + }; }; diff --git a/src/socket.io/user/status.js b/src/socket.io/user/status.js index b81f1de..d73fe11 100644 --- a/src/socket.io/user/status.js +++ b/src/socket.io/user/status.js @@ -4,37 +4,40 @@ const user = require('../../user'); const websockets = require('../index'); module.exports = function (SocketUser) { - SocketUser.checkStatus = async function (socket, uid) { - if (!socket.uid) { - throw new Error('[[error:invalid-uid]]'); - } - const userData = await user.getUserFields(uid, ['lastonline', 'status']); - return user.getStatus(userData); - }; - - SocketUser.setStatus = async function (socket, status) { - if (socket.uid <= 0) { - throw new Error('[[error:invalid-uid]]'); - } - - const allowedStatus = ['online', 'offline', 'dnd', 'away']; - if (!allowedStatus.includes(status)) { - throw new Error('[[error:invalid-user-status]]'); - } - - const userData = { status: status }; - if (status !== 'offline') { - userData.lastonline = Date.now(); - } - await user.setUserFields(socket.uid, userData); - if (status !== 'offline') { - await user.updateOnlineUsers(socket.uid); - } - const eventData = { - uid: socket.uid, - status: status, - }; - websockets.server.emit('event:user_status_change', eventData); - return eventData; - }; + SocketUser.checkStatus = async function (socket, uid) { + if (!socket.uid) { + throw new Error('[[error:invalid-uid]]'); + } + + const userData = await user.getUserFields(uid, ['lastonline', 'status']); + return user.getStatus(userData); + }; + + SocketUser.setStatus = async function (socket, status) { + if (socket.uid <= 0) { + throw new Error('[[error:invalid-uid]]'); + } + + const allowedStatus = ['online', 'offline', 'dnd', 'away']; + if (!allowedStatus.includes(status)) { + throw new Error('[[error:invalid-user-status]]'); + } + + const userData = {status}; + if (status !== 'offline') { + userData.lastonline = Date.now(); + } + + await user.setUserFields(socket.uid, userData); + if (status !== 'offline') { + await user.updateOnlineUsers(socket.uid); + } + + const eventData = { + uid: socket.uid, + status, + }; + websockets.server.emit('event:user_status_change', eventData); + return eventData; + }; }; diff --git a/src/start.js b/src/start.js index 7dd6af9..63d6961 100644 --- a/src/start.js +++ b/src/start.js @@ -6,140 +6,145 @@ const winston = require('winston'); const start = module.exports; start.start = async function () { - printStartupInfo(); - - addProcessHandlers(); - - try { - const db = require('./database'); - await db.init(); - await db.checkCompatibility(); - - const meta = require('./meta'); - await meta.configs.init(); - - if (nconf.get('runJobs')) { - await runUpgrades(); - } - - if (nconf.get('dep-check') === undefined || nconf.get('dep-check') !== false) { - await meta.dependencies.check(); - } else { - winston.warn('[init] Dependency checking skipped!'); - } - - await db.initSessionStore(); - - const webserver = require('./webserver'); - const sockets = require('./socket.io'); - await sockets.init(webserver.server); - - if (nconf.get('runJobs')) { - require('./notifications').startJobs(); - require('./user').startJobs(); - require('./plugins').startJobs(); - require('./topics').scheduled.startJobs(); - await db.delete('locks'); - } - - await webserver.listen(); - - if (process.send) { - process.send({ - action: 'listening', - }); - } - } catch (err) { - switch (err.message) { - case 'dependencies-out-of-date': - winston.error('One or more of NodeBB\'s dependent packages are out-of-date. Please run the following command to update them:'); - winston.error(' ./nodebb upgrade'); - break; - case 'dependencies-missing': - winston.error('One or more of NodeBB\'s dependent packages are missing. Please run the following command to update them:'); - winston.error(' ./nodebb upgrade'); - break; - default: - winston.error(err.stack); - break; - } - - // Either way, bad stuff happened. Abort start. - process.exit(); - } + printStartupInfo(); + + addProcessHandlers(); + + try { + const db = require('./database'); + await db.init(); + await db.checkCompatibility(); + + const meta = require('./meta'); + await meta.configs.init(); + + if (nconf.get('runJobs')) { + await runUpgrades(); + } + + if (nconf.get('dep-check') === undefined || nconf.get('dep-check') !== false) { + await meta.dependencies.check(); + } else { + winston.warn('[init] Dependency checking skipped!'); + } + + await db.initSessionStore(); + + const webserver = require('./webserver'); + const sockets = require('./socket.io'); + await sockets.init(webserver.server); + + if (nconf.get('runJobs')) { + require('./notifications').startJobs(); + require('./user').startJobs(); + require('./plugins').startJobs(); + require('./topics').scheduled.startJobs(); + await db.delete('locks'); + } + + await webserver.listen(); + + if (process.send) { + process.send({ + action: 'listening', + }); + } + } catch (error) { + switch (error.message) { + case 'dependencies-out-of-date': { + winston.error('One or more of NodeBB\'s dependent packages are out-of-date. Please run the following command to update them:'); + winston.error(' ./nodebb upgrade'); + break; + } + + case 'dependencies-missing': { + winston.error('One or more of NodeBB\'s dependent packages are missing. Please run the following command to update them:'); + winston.error(' ./nodebb upgrade'); + break; + } + + default: { + winston.error(error.stack); + break; + } + } + + // Either way, bad stuff happened. Abort start. + process.exit(); + } }; async function runUpgrades() { - const upgrade = require('./upgrade'); - try { - await upgrade.check(); - } catch (err) { - if (err && err.message === 'schema-out-of-date') { - await upgrade.run(); - } else { - throw err; - } - } + const upgrade = require('./upgrade'); + try { + await upgrade.check(); + } catch (error) { + if (error && error.message === 'schema-out-of-date') { + await upgrade.run(); + } else { + throw error; + } + } } function printStartupInfo() { - if (nconf.get('isPrimary')) { - winston.info('Initializing NodeBB v%s %s', nconf.get('version'), nconf.get('url')); + if (nconf.get('isPrimary')) { + winston.info('Initializing NodeBB v%s %s', nconf.get('version'), nconf.get('url')); - const host = nconf.get(`${nconf.get('database')}:host`); - const storeLocation = host ? `at ${host}${!host.includes('/') ? `:${nconf.get(`${nconf.get('database')}:port`)}` : ''}` : ''; + const host = nconf.get(`${nconf.get('database')}:host`); + const storeLocation = host ? `at ${host}${host.includes('/') ? '' : `:${nconf.get(`${nconf.get('database')}:port`)}`}` : ''; - winston.verbose('* using %s store %s', nconf.get('database'), storeLocation); - winston.verbose('* using themes stored in: %s', nconf.get('themes_path')); - } + winston.verbose('* using %s store %s', nconf.get('database'), storeLocation); + winston.verbose('* using themes stored in: %s', nconf.get('themes_path')); + } } function addProcessHandlers() { - process.on('SIGTERM', shutdown); - process.on('SIGINT', shutdown); - process.on('SIGHUP', restart); - process.on('uncaughtException', (err) => { - winston.error(err.stack); - - require('./meta').js.killMinifier(); - shutdown(1); - }); - process.on('message', (msg) => { - if (msg && msg.compiling === 'tpl') { - const benchpressjs = require('benchpressjs'); - benchpressjs.flush(); - } else if (msg && msg.compiling === 'lang') { - const translator = require('./translator'); - translator.flush(); - } - }); + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); + process.on('SIGHUP', restart); + process.on('uncaughtException', error => { + winston.error(error.stack); + + require('./meta').js.killMinifier(); + shutdown(1); + }); + process.on('message', message => { + if (message && message.compiling === 'tpl') { + const benchpressjs = require('benchpressjs'); + benchpressjs.flush(); + } else if (message && message.compiling === 'lang') { + const translator = require('./translator'); + translator.flush(); + } + }); } function restart() { - if (process.send) { - winston.info('[app] Restarting...'); - process.send({ - action: 'restart', - }); - } else { - winston.error('[app] Could not restart server. Shutting down.'); - shutdown(1); - } + if (process.send) { + winston.info('[app] Restarting...'); + process.send({ + action: 'restart', + }); + } else { + winston.error('[app] Could not restart server. Shutting down.'); + shutdown(1); + } } async function shutdown(code) { - winston.info('[app] Shutdown (SIGTERM/SIGINT) Initialised.'); - try { - await require('./webserver').destroy(); - winston.info('[app] Web server closed to connections.'); - await require('./analytics').writeData(); - winston.info('[app] Live analytics saved.'); - await require('./database').close(); - winston.info('[app] Database connection closed.'); - winston.info('[app] Shutdown complete.'); - process.exit(code || 0); - } catch (err) { - winston.error(err.stack); - return process.exit(code || 0); - } + winston.info('[app] Shutdown (SIGTERM/SIGINT) Initialised.'); + try { + await require('./webserver').destroy(); + winston.info('[app] Web server closed to connections.'); + await require('./analytics').writeData(); + winston.info('[app] Live analytics saved.'); + await require('./database').close(); + winston.info('[app] Database connection closed.'); + winston.info('[app] Shutdown complete.'); + process.exit(code || 0); + } catch (error) { + winston.error(error.stack); + return process.exit(code || 0); + } } diff --git a/src/topics/bookmarks.js b/src/topics/bookmarks.js index 9c94f21..c5aa1ab 100644 --- a/src/topics/bookmarks.js +++ b/src/topics/bookmarks.js @@ -2,65 +2,66 @@ 'use strict'; const async = require('async'); - const db = require('../database'); const user = require('../user'); module.exports = function (Topics) { - Topics.getUserBookmark = async function (tid, uid) { - if (parseInt(uid, 10) <= 0) { - return null; - } - return await db.sortedSetScore(`tid:${tid}:bookmarks`, uid); - }; - - Topics.getUserBookmarks = async function (tids, uid) { - if (parseInt(uid, 10) <= 0) { - return tids.map(() => null); - } - return await db.sortedSetsScore(tids.map(tid => `tid:${tid}:bookmarks`), uid); - }; - - Topics.setUserBookmark = async function (tid, uid, index) { - await db.sortedSetAdd(`tid:${tid}:bookmarks`, index, uid); - }; - - Topics.getTopicBookmarks = async function (tid) { - return await db.getSortedSetRangeWithScores(`tid:${tid}:bookmarks`, 0, -1); - }; - - Topics.updateTopicBookmarks = async function (tid, pids) { - const maxIndex = await Topics.getPostCount(tid); - const indices = await db.sortedSetRanks(`tid:${tid}:posts`, pids); - const postIndices = indices.map(i => (i === null ? 0 : i + 1)); - const minIndex = Math.min(...postIndices); - - const bookmarks = await Topics.getTopicBookmarks(tid); - - const uidData = bookmarks.map(b => ({ uid: b.value, bookmark: parseInt(b.score, 10) })) - .filter(data => data.bookmark >= minIndex); - - await async.eachLimit(uidData, 50, async (data) => { - let bookmark = Math.min(data.bookmark, maxIndex); - - postIndices.forEach((i) => { - if (i < data.bookmark) { - bookmark -= 1; - } - }); - - // make sure the bookmark is valid if we removed the last post - bookmark = Math.min(bookmark, maxIndex - pids.length); - if (bookmark === data.bookmark) { - return; - } - - const settings = await user.getSettings(data.uid); - if (settings.topicPostSort === 'most_votes') { - return; - } - - await Topics.setUserBookmark(tid, data.uid, bookmark); - }); - }; + Topics.getUserBookmark = async function (tid, uid) { + if (Number.parseInt(uid, 10) <= 0) { + return null; + } + + return await db.sortedSetScore(`tid:${tid}:bookmarks`, uid); + }; + + Topics.getUserBookmarks = async function (tids, uid) { + if (Number.parseInt(uid, 10) <= 0) { + return tids.map(() => null); + } + + return await db.sortedSetsScore(tids.map(tid => `tid:${tid}:bookmarks`), uid); + }; + + Topics.setUserBookmark = async function (tid, uid, index) { + await db.sortedSetAdd(`tid:${tid}:bookmarks`, index, uid); + }; + + Topics.getTopicBookmarks = async function (tid) { + return await db.getSortedSetRangeWithScores(`tid:${tid}:bookmarks`, 0, -1); + }; + + Topics.updateTopicBookmarks = async function (tid, pids) { + const maxIndex = await Topics.getPostCount(tid); + const indices = await db.sortedSetRanks(`tid:${tid}:posts`, pids); + const postIndices = indices.map(i => (i === null ? 0 : i + 1)); + const minIndex = Math.min(...postIndices); + + const bookmarks = await Topics.getTopicBookmarks(tid); + + const uidData = bookmarks.map(b => ({uid: b.value, bookmark: Number.parseInt(b.score, 10)})) + .filter(data => data.bookmark >= minIndex); + + await async.eachLimit(uidData, 50, async data => { + let bookmark = Math.min(data.bookmark, maxIndex); + + for (const i of postIndices) { + if (i < data.bookmark) { + bookmark -= 1; + } + } + + // Make sure the bookmark is valid if we removed the last post + bookmark = Math.min(bookmark, maxIndex - pids.length); + if (bookmark === data.bookmark) { + return; + } + + const settings = await user.getSettings(data.uid); + if (settings.topicPostSort === 'most_votes') { + return; + } + + await Topics.setUserBookmark(tid, data.uid, bookmark); + }); + }; }; diff --git a/src/topics/create.js b/src/topics/create.js index c366c21..c0e1be8 100644 --- a/src/topics/create.js +++ b/src/topics/create.js @@ -2,7 +2,6 @@ 'use strict'; const _ = require('lodash'); - const db = require('../database'); const utils = require('../utils'); const slugify = require('../slugify'); @@ -16,295 +15,298 @@ const categories = require('../categories'); const translator = require('../translator'); module.exports = function (Topics) { - Topics.create = async function (data) { - // This is an internal method, consider using Topics.post instead - const timestamp = data.timestamp || Date.now(); - - const tid = await db.incrObjectField('global', 'nextTid'); - - let topicData = { - tid: tid, - uid: data.uid, - cid: data.cid, - mainPid: 0, - title: data.title, - slug: `${tid}/${slugify(data.title) || 'topic'}`, - timestamp: timestamp, - lastposttime: 0, - postcount: 0, - viewcount: 0, - }; - - if (Array.isArray(data.tags) && data.tags.length) { - topicData.tags = data.tags.join(','); - } - - const result = await plugins.hooks.fire('filter:topic.create', { topic: topicData, data: data }); - topicData = result.topic; - await db.setObject(`topic:${topicData.tid}`, topicData); - - const timestampedSortedSetKeys = [ - 'topics:tid', - `cid:${topicData.cid}:tids`, - `cid:${topicData.cid}:uid:${topicData.uid}:tids`, - ]; - - const scheduled = timestamp > Date.now(); - if (scheduled) { - timestampedSortedSetKeys.push('topics:scheduled'); - } - - await Promise.all([ - db.sortedSetsAdd(timestampedSortedSetKeys, timestamp, topicData.tid), - db.sortedSetsAdd([ - 'topics:views', 'topics:posts', 'topics:votes', - `cid:${topicData.cid}:tids:votes`, - `cid:${topicData.cid}:tids:posts`, - `cid:${topicData.cid}:tids:views`, - ], 0, topicData.tid), - user.addTopicIdToUser(topicData.uid, topicData.tid, timestamp), - db.incrObjectField(`category:${topicData.cid}`, 'topic_count'), - db.incrObjectField('global', 'topicCount'), - Topics.createTags(data.tags, topicData.tid, timestamp), - scheduled ? Promise.resolve() : categories.updateRecentTid(topicData.cid, topicData.tid), - ]); - if (scheduled) { - await Topics.scheduled.pin(tid, topicData); - } - - plugins.hooks.fire('action:topic.save', { topic: _.clone(topicData), data: data }); - return topicData.tid; - }; - - Topics.post = async function (data) { - data = await plugins.hooks.fire('filter:topic.post', data); - const { uid } = data; - - data.title = String(data.title).trim(); - data.tags = data.tags || []; - if (data.content) { - data.content = utils.rtrim(data.content); - } - Topics.checkTitle(data.title); - await Topics.validateTags(data.tags, data.cid, uid); - data.tags = await Topics.filterTags(data.tags, data.cid); - if (!data.fromQueue) { - Topics.checkContent(data.content); - } - - const [categoryExists, canCreate, canTag] = await Promise.all([ - categories.exists(data.cid), - privileges.categories.can('topics:create', data.cid, uid), - privileges.categories.can('topics:tag', data.cid, uid), - ]); - - if (!categoryExists) { - throw new Error('[[error:no-category]]'); - } - - if (!canCreate || (!canTag && data.tags.length)) { - throw new Error('[[error:no-privileges]]'); - } - - await guestHandleValid(data); - if (!data.fromQueue) { - await user.isReadyToPost(uid, data.cid); - } - - const tid = await Topics.create(data); - - let postData = data; - postData.tid = tid; - postData.ip = data.req ? data.req.ip : null; - postData.isMain = true; - postData = await posts.create(postData); - postData = await onNewPost(postData, data); - - const [settings, topics] = await Promise.all([ - user.getSettings(uid), - Topics.getTopicsByTids([postData.tid], uid), - ]); - - if (!Array.isArray(topics) || !topics.length) { - throw new Error('[[error:no-topic]]'); - } - - if (uid > 0 && settings.followTopicsOnCreate) { - await Topics.follow(postData.tid, uid); - } - const topicData = topics[0]; - topicData.unreplied = true; - topicData.mainPost = postData; - topicData.index = 0; - postData.index = 0; - - if (topicData.scheduled) { - await Topics.delete(tid); - } - - analytics.increment(['topics', `topics:byCid:${topicData.cid}`]); - plugins.hooks.fire('action:topic.post', { topic: topicData, post: postData, data: data }); - - if (parseInt(uid, 10) && !topicData.scheduled) { - user.notifications.sendTopicNotificationToFollowers(uid, topicData, postData); - } - - return { - topicData: topicData, - postData: postData, - }; - }; - - Topics.reply = async function (data) { - data = await plugins.hooks.fire('filter:topic.reply', data); - const { tid } = data; - const { uid } = data; - - const topicData = await Topics.getTopicData(tid); - - await canReply(data, topicData); - - data.cid = topicData.cid; - - await guestHandleValid(data); - if (data.content) { - data.content = utils.rtrim(data.content); - } - if (!data.fromQueue) { - await user.isReadyToPost(uid, data.cid); - Topics.checkContent(data.content); - } - - // For replies to scheduled topics, don't have a timestamp older than topic's itself - if (topicData.scheduled) { - data.timestamp = topicData.lastposttime + 1; - } - - data.ip = data.req ? data.req.ip : null; - let postData = await posts.create(data); - postData = await onNewPost(postData, data); - - const settings = await user.getSettings(uid); - if (uid > 0 && settings.followTopicsOnReply) { - await Topics.follow(postData.tid, uid); - } - - if (parseInt(uid, 10)) { - user.setUserField(uid, 'lastonline', Date.now()); - } - - if (parseInt(uid, 10) || meta.config.allowGuestReplyNotifications) { - const { displayname } = postData.user; - - Topics.notifyFollowers(postData, uid, { - type: 'new-reply', - bodyShort: translator.compile('notifications:user_posted_to', displayname, postData.topic.title), - nid: `new_post:tid:${postData.topic.tid}:pid:${postData.pid}:uid:${uid}`, - mergeId: `notifications:user_posted_to|${postData.topic.tid}`, - }); - } - - analytics.increment(['posts', `posts:byCid:${data.cid}`]); - plugins.hooks.fire('action:topic.reply', { post: _.clone(postData), data: data }); - - return postData; - }; - - async function onNewPost(postData, data) { - const { tid } = postData; - const { uid } = postData; - await Topics.markAsUnreadForAll(tid); - await Topics.markAsRead([tid], uid); - const [ - userInfo, - topicInfo, - ] = await Promise.all([ - posts.getUserInfoForPosts([postData.uid], uid), - Topics.getTopicFields(tid, ['tid', 'uid', 'title', 'slug', 'cid', 'postcount', 'mainPid', 'scheduled']), - Topics.addParentPosts([postData]), - Topics.syncBacklinks(postData), - posts.parsePost(postData), - ]); - - postData.user = userInfo[0]; - postData.topic = topicInfo; - postData.index = topicInfo.postcount - 1; - - posts.overrideGuestHandle(postData, data.handle); - - postData.votes = 0; - postData.bookmarked = false; - postData.display_edit_tools = true; - postData.display_delete_tools = true; - postData.display_moderator_tools = true; - postData.display_move_tools = true; - postData.selfPost = false; - postData.timestampISO = utils.toISOString(postData.timestamp); - postData.topic.title = String(postData.topic.title); - - return postData; - } - - Topics.checkTitle = function (title) { - check(title, meta.config.minimumTitleLength, meta.config.maximumTitleLength, 'title-too-short', 'title-too-long'); - }; - - Topics.checkContent = function (content) { - check(content, meta.config.minimumPostLength, meta.config.maximumPostLength, 'content-too-short', 'content-too-long'); - }; - - function check(item, min, max, minError, maxError) { - // Trim and remove HTML (latter for composers that send in HTML, like redactor) - if (typeof item === 'string') { - item = utils.stripHTMLTags(item).trim(); - } - - if (item === null || item === undefined || item.length < parseInt(min, 10)) { - throw new Error(`[[error:${minError}, ${min}]]`); - } else if (item.length > parseInt(max, 10)) { - throw new Error(`[[error:${maxError}, ${max}]]`); - } - } - - async function guestHandleValid(data) { - if (meta.config.allowGuestHandles && parseInt(data.uid, 10) === 0 && data.handle) { - if (data.handle.length > meta.config.maximumUsernameLength) { - throw new Error('[[error:guest-handle-invalid]]'); - } - const exists = await user.existsBySlug(slugify(data.handle)); - if (exists) { - throw new Error('[[error:username-taken]]'); - } - } - } - - async function canReply(data, topicData) { - if (!topicData) { - throw new Error('[[error:no-topic]]'); - } - const { tid, uid } = data; - const { cid, deleted, locked, scheduled } = topicData; - - const [canReply, canSchedule, isAdminOrMod] = await Promise.all([ - privileges.topics.can('topics:reply', tid, uid), - privileges.topics.can('topics:schedule', tid, uid), - privileges.categories.isAdminOrMod(cid, uid), - ]); - - if (locked && !isAdminOrMod) { - throw new Error('[[error:topic-locked]]'); - } - - if (!scheduled && deleted && !isAdminOrMod) { - throw new Error('[[error:topic-deleted]]'); - } - - if (scheduled && !canSchedule) { - throw new Error('[[error:no-privileges]]'); - } - - if (!canReply) { - throw new Error('[[error:no-privileges]]'); - } - } + Topics.create = async function (data) { + // This is an internal method, consider using Topics.post instead + const timestamp = data.timestamp || Date.now(); + + const tid = await db.incrObjectField('global', 'nextTid'); + + let topicData = { + tid, + uid: data.uid, + cid: data.cid, + mainPid: 0, + title: data.title, + slug: `${tid}/${slugify(data.title) || 'topic'}`, + timestamp, + lastposttime: 0, + postcount: 0, + viewcount: 0, + }; + + if (Array.isArray(data.tags) && data.tags.length > 0) { + topicData.tags = data.tags.join(','); + } + + const result = await plugins.hooks.fire('filter:topic.create', {topic: topicData, data}); + topicData = result.topic; + await db.setObject(`topic:${topicData.tid}`, topicData); + + const timestampedSortedSetKeys = [ + 'topics:tid', + `cid:${topicData.cid}:tids`, + `cid:${topicData.cid}:uid:${topicData.uid}:tids`, + ]; + + const scheduled = timestamp > Date.now(); + if (scheduled) { + timestampedSortedSetKeys.push('topics:scheduled'); + } + + await Promise.all([ + db.sortedSetsAdd(timestampedSortedSetKeys, timestamp, topicData.tid), + db.sortedSetsAdd([ + 'topics:views', + 'topics:posts', + 'topics:votes', + `cid:${topicData.cid}:tids:votes`, + `cid:${topicData.cid}:tids:posts`, + `cid:${topicData.cid}:tids:views`, + ], 0, topicData.tid), + user.addTopicIdToUser(topicData.uid, topicData.tid, timestamp), + db.incrObjectField(`category:${topicData.cid}`, 'topic_count'), + db.incrObjectField('global', 'topicCount'), + Topics.createTags(data.tags, topicData.tid, timestamp), + scheduled ? Promise.resolve() : categories.updateRecentTid(topicData.cid, topicData.tid), + ]); + if (scheduled) { + await Topics.scheduled.pin(tid, topicData); + } + + plugins.hooks.fire('action:topic.save', {topic: _.clone(topicData), data}); + return topicData.tid; + }; + + Topics.post = async function (data) { + data = await plugins.hooks.fire('filter:topic.post', data); + const {uid} = data; + + data.title = String(data.title).trim(); + data.tags = data.tags || []; + data.content &&= utils.rtrim(data.content); + + Topics.checkTitle(data.title); + await Topics.validateTags(data.tags, data.cid, uid); + data.tags = await Topics.filterTags(data.tags, data.cid); + if (!data.fromQueue) { + Topics.checkContent(data.content); + } + + const [categoryExists, canCreate, canTag] = await Promise.all([ + categories.exists(data.cid), + privileges.categories.can('topics:create', data.cid, uid), + privileges.categories.can('topics:tag', data.cid, uid), + ]); + + if (!categoryExists) { + throw new Error('[[error:no-category]]'); + } + + if (!canCreate || (!canTag && data.tags.length > 0)) { + throw new Error('[[error:no-privileges]]'); + } + + await guestHandleValid(data); + if (!data.fromQueue) { + await user.isReadyToPost(uid, data.cid); + } + + const tid = await Topics.create(data); + + let postData = data; + postData.tid = tid; + postData.ip = data.req ? data.req.ip : null; + postData.isMain = true; + postData = await posts.create(postData); + postData = await onNewPost(postData, data); + + const [settings, topics] = await Promise.all([ + user.getSettings(uid), + Topics.getTopicsByTids([postData.tid], uid), + ]); + + if (!Array.isArray(topics) || topics.length === 0) { + throw new Error('[[error:no-topic]]'); + } + + if (uid > 0 && settings.followTopicsOnCreate) { + await Topics.follow(postData.tid, uid); + } + + const topicData = topics[0]; + topicData.unreplied = true; + topicData.mainPost = postData; + topicData.index = 0; + postData.index = 0; + + if (topicData.scheduled) { + await Topics.delete(tid); + } + + analytics.increment(['topics', `topics:byCid:${topicData.cid}`]); + plugins.hooks.fire('action:topic.post', {topic: topicData, post: postData, data}); + + if (Number.parseInt(uid, 10) && !topicData.scheduled) { + user.notifications.sendTopicNotificationToFollowers(uid, topicData, postData); + } + + return { + topicData, + postData, + }; + }; + + Topics.reply = async function (data) { + data = await plugins.hooks.fire('filter:topic.reply', data); + const {tid} = data; + const {uid} = data; + + const topicData = await Topics.getTopicData(tid); + + await canReply(data, topicData); + + data.cid = topicData.cid; + + await guestHandleValid(data); + data.content &&= utils.rtrim(data.content); + + if (!data.fromQueue) { + await user.isReadyToPost(uid, data.cid); + Topics.checkContent(data.content); + } + + // For replies to scheduled topics, don't have a timestamp older than topic's itself + if (topicData.scheduled) { + data.timestamp = topicData.lastposttime + 1; + } + + data.ip = data.req ? data.req.ip : null; + let postData = await posts.create(data); + postData = await onNewPost(postData, data); + + const settings = await user.getSettings(uid); + if (uid > 0 && settings.followTopicsOnReply) { + await Topics.follow(postData.tid, uid); + } + + if (Number.parseInt(uid, 10)) { + user.setUserField(uid, 'lastonline', Date.now()); + } + + if (Number.parseInt(uid, 10) || meta.config.allowGuestReplyNotifications) { + const {displayname} = postData.user; + + Topics.notifyFollowers(postData, uid, { + type: 'new-reply', + bodyShort: translator.compile('notifications:user_posted_to', displayname, postData.topic.title), + nid: `new_post:tid:${postData.topic.tid}:pid:${postData.pid}:uid:${uid}`, + mergeId: `notifications:user_posted_to|${postData.topic.tid}`, + }); + } + + analytics.increment(['posts', `posts:byCid:${data.cid}`]); + plugins.hooks.fire('action:topic.reply', {post: _.clone(postData), data}); + + return postData; + }; + + async function onNewPost(postData, data) { + const {tid} = postData; + const {uid} = postData; + await Topics.markAsUnreadForAll(tid); + await Topics.markAsRead([tid], uid); + const [ + userInfo, + topicInfo, + ] = await Promise.all([ + posts.getUserInfoForPosts([postData.uid], uid), + Topics.getTopicFields(tid, ['tid', 'uid', 'title', 'slug', 'cid', 'postcount', 'mainPid', 'scheduled']), + Topics.addParentPosts([postData]), + Topics.syncBacklinks(postData), + posts.parsePost(postData), + ]); + + postData.user = userInfo[0]; + postData.topic = topicInfo; + postData.index = topicInfo.postcount - 1; + + posts.overrideGuestHandle(postData, data.handle); + + postData.votes = 0; + postData.bookmarked = false; + postData.display_edit_tools = true; + postData.display_delete_tools = true; + postData.display_moderator_tools = true; + postData.display_move_tools = true; + postData.selfPost = false; + postData.timestampISO = utils.toISOString(postData.timestamp); + postData.topic.title = String(postData.topic.title); + + return postData; + } + + Topics.checkTitle = function (title) { + check(title, meta.config.minimumTitleLength, meta.config.maximumTitleLength, 'title-too-short', 'title-too-long'); + }; + + Topics.checkContent = function (content) { + check(content, meta.config.minimumPostLength, meta.config.maximumPostLength, 'content-too-short', 'content-too-long'); + }; + + function check(item, min, max, minError, maxError) { + // Trim and remove HTML (latter for composers that send in HTML, like redactor) + if (typeof item === 'string') { + item = utils.stripHTMLTags(item).trim(); + } + + if (item === null || item === undefined || item.length < Number.parseInt(min, 10)) { + throw new Error(`[[error:${minError}, ${min}]]`); + } else if (item.length > Number.parseInt(max, 10)) { + throw new Error(`[[error:${maxError}, ${max}]]`); + } + } + + async function guestHandleValid(data) { + if (meta.config.allowGuestHandles && Number.parseInt(data.uid, 10) === 0 && data.handle) { + if (data.handle.length > meta.config.maximumUsernameLength) { + throw new Error('[[error:guest-handle-invalid]]'); + } + + const exists = await user.existsBySlug(slugify(data.handle)); + if (exists) { + throw new Error('[[error:username-taken]]'); + } + } + } + + async function canReply(data, topicData) { + if (!topicData) { + throw new Error('[[error:no-topic]]'); + } + + const {tid, uid} = data; + const {cid, deleted, locked, scheduled} = topicData; + + const [canReply, canSchedule, isAdminOrModule] = await Promise.all([ + privileges.topics.can('topics:reply', tid, uid), + privileges.topics.can('topics:schedule', tid, uid), + privileges.categories.isAdminOrMod(cid, uid), + ]); + + if (locked && !isAdminOrModule) { + throw new Error('[[error:topic-locked]]'); + } + + if (!scheduled && deleted && !isAdminOrModule) { + throw new Error('[[error:topic-deleted]]'); + } + + if (scheduled && !canSchedule) { + throw new Error('[[error:no-privileges]]'); + } + + if (!canReply) { + throw new Error('[[error:no-privileges]]'); + } + } }; diff --git a/src/topics/data.js b/src/topics/data.js index 9101531..f34ff91 100644 --- a/src/topics/data.js +++ b/src/topics/data.js @@ -1,7 +1,6 @@ 'use strict'; const validator = require('validator'); - const db = require('../database'); const categories = require('../categories'); const utils = require('../utils'); @@ -9,134 +8,147 @@ const translator = require('../translator'); const plugins = require('../plugins'); const intFields = [ - 'tid', 'cid', 'uid', 'mainPid', 'postcount', - 'viewcount', 'postercount', 'deleted', 'locked', 'pinned', - 'pinExpiry', 'timestamp', 'upvotes', 'downvotes', 'lastposttime', - 'deleterUid', 'private', + 'tid', + 'cid', + 'uid', + 'mainPid', + 'postcount', + 'viewcount', + 'postercount', + 'deleted', + 'locked', + 'pinned', + 'pinExpiry', + 'timestamp', + 'upvotes', + 'downvotes', + 'lastposttime', + 'deleterUid', + 'private', ]; module.exports = function (Topics) { - Topics.getTopicsFields = async function (tids, fields) { - if (!Array.isArray(tids) || !tids.length) { - return []; - } - - // "scheduled" is derived from "timestamp" - if (fields.includes('scheduled') && !fields.includes('timestamp')) { - fields.push('timestamp'); - } - - const keys = tids.map(tid => `topic:${tid}`); - const topics = await db.getObjects(keys, fields); - const result = await plugins.hooks.fire('filter:topic.getFields', { - tids: tids, - topics: topics, - fields: fields, - keys: keys, - }); - result.topics.forEach(topic => modifyTopic(topic, fields)); - return result.topics; - }; - - Topics.getTopicField = async function (tid, field) { - const topic = await Topics.getTopicFields(tid, [field]); - return topic ? topic[field] : null; - }; - - Topics.getTopicFields = async function (tid, fields) { - const topics = await Topics.getTopicsFields([tid], fields); - return topics ? topics[0] : null; - }; - - Topics.getTopicData = async function (tid) { - const topics = await Topics.getTopicsFields([tid], []); - return topics && topics.length ? topics[0] : null; - }; - - Topics.getTopicsData = async function (tids) { - return await Topics.getTopicsFields(tids, []); - }; - - Topics.getCategoryData = async function (tid) { - const cid = await Topics.getTopicField(tid, 'cid'); - return await categories.getCategoryData(cid); - }; - - Topics.setTopicField = async function (tid, field, value) { - await db.setObjectField(`topic:${tid}`, field, value); - }; - - Topics.setTopicFields = async function (tid, data) { - await db.setObject(`topic:${tid}`, data); - }; - - Topics.deleteTopicField = async function (tid, field) { - await db.deleteObjectField(`topic:${tid}`, field); - }; - - Topics.deleteTopicFields = async function (tid, fields) { - await db.deleteObjectFields(`topic:${tid}`, fields); - }; + Topics.getTopicsFields = async function (tids, fields) { + if (!Array.isArray(tids) || tids.length === 0) { + return []; + } + + // "scheduled" is derived from "timestamp" + if (fields.includes('scheduled') && !fields.includes('timestamp')) { + fields.push('timestamp'); + } + + const keys = tids.map(tid => `topic:${tid}`); + const topics = await db.getObjects(keys, fields); + const result = await plugins.hooks.fire('filter:topic.getFields', { + tids, + topics, + fields, + keys, + }); + for (const topic of result.topics) { + modifyTopic(topic, fields); + } + + return result.topics; + }; + + Topics.getTopicField = async function (tid, field) { + const topic = await Topics.getTopicFields(tid, [field]); + return topic ? topic[field] : null; + }; + + Topics.getTopicFields = async function (tid, fields) { + const topics = await Topics.getTopicsFields([tid], fields); + return topics ? topics[0] : null; + }; + + Topics.getTopicData = async function (tid) { + const topics = await Topics.getTopicsFields([tid], []); + return topics && topics.length > 0 ? topics[0] : null; + }; + + Topics.getTopicsData = async function (tids) { + return await Topics.getTopicsFields(tids, []); + }; + + Topics.getCategoryData = async function (tid) { + const cid = await Topics.getTopicField(tid, 'cid'); + return await categories.getCategoryData(cid); + }; + + Topics.setTopicField = async function (tid, field, value) { + await db.setObjectField(`topic:${tid}`, field, value); + }; + + Topics.setTopicFields = async function (tid, data) { + await db.setObject(`topic:${tid}`, data); + }; + + Topics.deleteTopicField = async function (tid, field) { + await db.deleteObjectField(`topic:${tid}`, field); + }; + + Topics.deleteTopicFields = async function (tid, fields) { + await db.deleteObjectFields(`topic:${tid}`, fields); + }; }; function escapeTitle(topicData) { - if (topicData) { - if (topicData.title) { - topicData.title = translator.escape(validator.escape(topicData.title)); - } - if (topicData.titleRaw) { - topicData.titleRaw = translator.escape(topicData.titleRaw); - } - } + if (topicData) { + topicData.title &&= translator.escape(validator.escape(topicData.title)); + + topicData.titleRaw &&= translator.escape(topicData.titleRaw); + } } function modifyTopic(topic, fields) { - if (!topic) { - return; - } - - db.parseIntFields(topic, intFields, fields); - - if (topic.hasOwnProperty('title')) { - topic.titleRaw = topic.title; - topic.title = String(topic.title); - } - - escapeTitle(topic); - - if (topic.hasOwnProperty('timestamp')) { - topic.timestampISO = utils.toISOString(topic.timestamp); - if (!fields.length || fields.includes('scheduled')) { - topic.scheduled = topic.timestamp > Date.now(); - } - } - - if (topic.hasOwnProperty('lastposttime')) { - topic.lastposttimeISO = utils.toISOString(topic.lastposttime); - } - - if (topic.hasOwnProperty('pinExpiry')) { - topic.pinExpiryISO = utils.toISOString(topic.pinExpiry); - } - - if (topic.hasOwnProperty('upvotes') && topic.hasOwnProperty('downvotes')) { - topic.votes = topic.upvotes - topic.downvotes; - } - - if (fields.includes('teaserPid') || !fields.length) { - topic.teaserPid = topic.teaserPid || null; - } - - if (fields.includes('tags') || !fields.length) { - const tags = String(topic.tags || ''); - topic.tags = tags.split(',').filter(Boolean).map((tag) => { - const escaped = validator.escape(String(tag)); - return { - value: tag, - valueEscaped: escaped, - valueEncoded: encodeURIComponent(escaped), - class: escaped.replace(/\s/g, '-'), - }; - }); - } + if (!topic) { + return; + } + + db.parseIntFields(topic, intFields, fields); + + if (topic.hasOwnProperty('title')) { + topic.titleRaw = topic.title; + topic.title = String(topic.title); + } + + escapeTitle(topic); + + if (topic.hasOwnProperty('timestamp')) { + topic.timestampISO = utils.toISOString(topic.timestamp); + if (fields.length === 0 || fields.includes('scheduled')) { + topic.scheduled = topic.timestamp > Date.now(); + } + } + + if (topic.hasOwnProperty('lastposttime')) { + topic.lastposttimeISO = utils.toISOString(topic.lastposttime); + } + + if (topic.hasOwnProperty('pinExpiry')) { + topic.pinExpiryISO = utils.toISOString(topic.pinExpiry); + } + + if (topic.hasOwnProperty('upvotes') && topic.hasOwnProperty('downvotes')) { + topic.votes = topic.upvotes - topic.downvotes; + } + + if (fields.includes('teaserPid') || fields.length === 0) { + topic.teaserPid = topic.teaserPid || null; + } + + if (fields.includes('tags') || fields.length === 0) { + const tags = String(topic.tags || ''); + topic.tags = tags.split(',').filter(Boolean).map(tag => { + const escaped = validator.escape(String(tag)); + return { + value: tag, + valueEscaped: escaped, + valueEncoded: encodeURIComponent(escaped), + class: escaped.replaceAll(/\s/g, '-'), + }; + }); + } } diff --git a/src/topics/delete.js b/src/topics/delete.js index c8d776c..cac26ae 100644 --- a/src/topics/delete.js +++ b/src/topics/delete.js @@ -1,141 +1,140 @@ 'use strict'; const db = require('../database'); - const user = require('../user'); const posts = require('../posts'); const categories = require('../categories'); const plugins = require('../plugins'); const batch = require('../batch'); - module.exports = function (Topics) { - Topics.delete = async function (tid, uid) { - await removeTopicPidsFromCid(tid); - await Topics.setTopicFields(tid, { - deleted: 1, - deleterUid: uid, - deletedTimestamp: Date.now(), - }); - }; + Topics.delete = async function (tid, uid) { + await removeTopicPidsFromCid(tid); + await Topics.setTopicFields(tid, { + deleted: 1, + deleterUid: uid, + deletedTimestamp: Date.now(), + }); + }; + + async function removeTopicPidsFromCid(tid) { + const [cid, pids] = await Promise.all([ + Topics.getTopicField(tid, 'cid'), + Topics.getPids(tid), + ]); + await db.sortedSetRemove(`cid:${cid}:pids`, pids); + await categories.updateRecentTidForCid(cid); + } - async function removeTopicPidsFromCid(tid) { - const [cid, pids] = await Promise.all([ - Topics.getTopicField(tid, 'cid'), - Topics.getPids(tid), - ]); - await db.sortedSetRemove(`cid:${cid}:pids`, pids); - await categories.updateRecentTidForCid(cid); - } + async function addTopicPidsToCid(tid) { + const [cid, pids] = await Promise.all([ + Topics.getTopicField(tid, 'cid'), + Topics.getPids(tid), + ]); + let postData = await posts.getPostsFields(pids, ['pid', 'timestamp', 'deleted']); + postData = postData.filter(post => post && !post.deleted); + const pidsToAdd = postData.map(post => post.pid); + const scores = postData.map(post => post.timestamp); + await db.sortedSetAdd(`cid:${cid}:pids`, scores, pidsToAdd); + await categories.updateRecentTidForCid(cid); + } - async function addTopicPidsToCid(tid) { - const [cid, pids] = await Promise.all([ - Topics.getTopicField(tid, 'cid'), - Topics.getPids(tid), - ]); - let postData = await posts.getPostsFields(pids, ['pid', 'timestamp', 'deleted']); - postData = postData.filter(post => post && !post.deleted); - const pidsToAdd = postData.map(post => post.pid); - const scores = postData.map(post => post.timestamp); - await db.sortedSetAdd(`cid:${cid}:pids`, scores, pidsToAdd); - await categories.updateRecentTidForCid(cid); - } + Topics.restore = async function (tid) { + await Promise.all([ + Topics.deleteTopicFields(tid, [ + 'deleterUid', 'deletedTimestamp', + ]), + addTopicPidsToCid(tid), + ]); + await Topics.setTopicField(tid, 'deleted', 0); + }; - Topics.restore = async function (tid) { - await Promise.all([ - Topics.deleteTopicFields(tid, [ - 'deleterUid', 'deletedTimestamp', - ]), - addTopicPidsToCid(tid), - ]); - await Topics.setTopicField(tid, 'deleted', 0); - }; + Topics.purgePostsAndTopic = async function (tid, uid) { + const mainPid = await Topics.getTopicField(tid, 'mainPid'); + await batch.processSortedSet(`tid:${tid}:posts`, async pids => { + await posts.purge(pids, uid); + }, {alwaysStartAt: 0, batch: 500}); + await posts.purge(mainPid, uid); + await Topics.purge(tid, uid); + }; - Topics.purgePostsAndTopic = async function (tid, uid) { - const mainPid = await Topics.getTopicField(tid, 'mainPid'); - await batch.processSortedSet(`tid:${tid}:posts`, async (pids) => { - await posts.purge(pids, uid); - }, { alwaysStartAt: 0, batch: 500 }); - await posts.purge(mainPid, uid); - await Topics.purge(tid, uid); - }; + Topics.purge = async function (tid, uid) { + const [deletedTopic, tags] = await Promise.all([ + Topics.getTopicData(tid), + Topics.getTopicTags(tid), + ]); + if (!deletedTopic) { + return; + } - Topics.purge = async function (tid, uid) { - const [deletedTopic, tags] = await Promise.all([ - Topics.getTopicData(tid), - Topics.getTopicTags(tid), - ]); - if (!deletedTopic) { - return; - } - deletedTopic.tags = tags; - await deleteFromFollowersIgnorers(tid); + deletedTopic.tags = tags; + await deleteFromFollowersIgnorers(tid); - await Promise.all([ - db.deleteAll([ - `tid:${tid}:followers`, - `tid:${tid}:ignorers`, - `tid:${tid}:posts`, - `tid:${tid}:posts:votes`, - `tid:${tid}:bookmarks`, - `tid:${tid}:posters`, - ]), - db.sortedSetsRemove([ - 'topics:tid', - 'topics:recent', - 'topics:posts', - 'topics:views', - 'topics:votes', - 'topics:scheduled', - ], tid), - deleteTopicFromCategoryAndUser(tid), - Topics.deleteTopicTags(tid), - Topics.events.purge(tid), - Topics.thumbs.deleteAll(tid), - reduceCounters(tid), - ]); - plugins.hooks.fire('action:topic.purge', { topic: deletedTopic, uid: uid }); - await db.delete(`topic:${tid}`); - }; + await Promise.all([ + db.deleteAll([ + `tid:${tid}:followers`, + `tid:${tid}:ignorers`, + `tid:${tid}:posts`, + `tid:${tid}:posts:votes`, + `tid:${tid}:bookmarks`, + `tid:${tid}:posters`, + ]), + db.sortedSetsRemove([ + 'topics:tid', + 'topics:recent', + 'topics:posts', + 'topics:views', + 'topics:votes', + 'topics:scheduled', + ], tid), + deleteTopicFromCategoryAndUser(tid), + Topics.deleteTopicTags(tid), + Topics.events.purge(tid), + Topics.thumbs.deleteAll(tid), + reduceCounters(tid), + ]); + plugins.hooks.fire('action:topic.purge', {topic: deletedTopic, uid}); + await db.delete(`topic:${tid}`); + }; - async function deleteFromFollowersIgnorers(tid) { - const [followers, ignorers] = await Promise.all([ - db.getSetMembers(`tid:${tid}:followers`), - db.getSetMembers(`tid:${tid}:ignorers`), - ]); - const followerKeys = followers.map(uid => `uid:${uid}:followed_tids`); - const ignorerKeys = ignorers.map(uid => `uid:${uid}ignored_tids`); - await db.sortedSetsRemove(followerKeys.concat(ignorerKeys), tid); - } + async function deleteFromFollowersIgnorers(tid) { + const [followers, ignorers] = await Promise.all([ + db.getSetMembers(`tid:${tid}:followers`), + db.getSetMembers(`tid:${tid}:ignorers`), + ]); + const followerKeys = followers.map(uid => `uid:${uid}:followed_tids`); + const ignorerKeys = ignorers.map(uid => `uid:${uid}ignored_tids`); + await db.sortedSetsRemove(followerKeys.concat(ignorerKeys), tid); + } - async function deleteTopicFromCategoryAndUser(tid) { - const topicData = await Topics.getTopicFields(tid, ['cid', 'uid']); - await Promise.all([ - db.sortedSetsRemove([ - `cid:${topicData.cid}:tids`, - `cid:${topicData.cid}:tids:pinned`, - `cid:${topicData.cid}:tids:posts`, - `cid:${topicData.cid}:tids:lastposttime`, - `cid:${topicData.cid}:tids:votes`, - `cid:${topicData.cid}:tids:views`, - `cid:${topicData.cid}:recent_tids`, - `cid:${topicData.cid}:uid:${topicData.uid}:tids`, - `uid:${topicData.uid}:topics`, - ], tid), - user.decrementUserFieldBy(topicData.uid, 'topiccount', 1), - ]); - await categories.updateRecentTidForCid(topicData.cid); - } + async function deleteTopicFromCategoryAndUser(tid) { + const topicData = await Topics.getTopicFields(tid, ['cid', 'uid']); + await Promise.all([ + db.sortedSetsRemove([ + `cid:${topicData.cid}:tids`, + `cid:${topicData.cid}:tids:pinned`, + `cid:${topicData.cid}:tids:posts`, + `cid:${topicData.cid}:tids:lastposttime`, + `cid:${topicData.cid}:tids:votes`, + `cid:${topicData.cid}:tids:views`, + `cid:${topicData.cid}:recent_tids`, + `cid:${topicData.cid}:uid:${topicData.uid}:tids`, + `uid:${topicData.uid}:topics`, + ], tid), + user.decrementUserFieldBy(topicData.uid, 'topiccount', 1), + ]); + await categories.updateRecentTidForCid(topicData.cid); + } - async function reduceCounters(tid) { - const incr = -1; - await db.incrObjectFieldBy('global', 'topicCount', incr); - const topicData = await Topics.getTopicFields(tid, ['cid', 'postcount']); - const postCountChange = incr * topicData.postcount; - await Promise.all([ - db.incrObjectFieldBy('global', 'postCount', postCountChange), - db.incrObjectFieldBy(`category:${topicData.cid}`, 'post_count', postCountChange), - db.incrObjectFieldBy(`category:${topicData.cid}`, 'topic_count', incr), - ]); - } + async function reduceCounters(tid) { + const incr = -1; + await db.incrObjectFieldBy('global', 'topicCount', incr); + const topicData = await Topics.getTopicFields(tid, ['cid', 'postcount']); + const postCountChange = incr * topicData.postcount; + await Promise.all([ + db.incrObjectFieldBy('global', 'postCount', postCountChange), + db.incrObjectFieldBy(`category:${topicData.cid}`, 'post_count', postCountChange), + db.incrObjectFieldBy(`category:${topicData.cid}`, 'topic_count', incr), + ]); + } }; diff --git a/src/topics/events.js b/src/topics/events.js index 49a4b0c..fefb4d5 100644 --- a/src/topics/events.js +++ b/src/topics/events.js @@ -23,198 +23,202 @@ const Events = module.exports; * */ Events._types = { - pin: { - icon: 'fa-thumb-tack', - text: '[[topic:pinned-by]]', - }, - unpin: { - icon: 'fa-thumb-tack', - text: '[[topic:unpinned-by]]', - }, - lock: { - icon: 'fa-lock', - text: '[[topic:locked-by]]', - }, - unlock: { - icon: 'fa-unlock', - text: '[[topic:unlocked-by]]', - }, - delete: { - icon: 'fa-trash', - text: '[[topic:deleted-by]]', - }, - restore: { - icon: 'fa-trash-o', - text: '[[topic:restored-by]]', - }, - private: { - icon: 'fa-lock', - text: '[[topic:private-by]]', - }, - public: { - icon: 'fa-unlock', - text: '[[topic:public-by]]', - }, - move: { - icon: 'fa-arrow-circle-right', - // text: '[[topic:moved-from-by]]', - }, - 'post-queue': { - icon: 'fa-history', - text: '[[topic:queued-by]]', - href: '/post-queue', - }, - backlink: { - icon: 'fa-link', - text: '[[topic:backlink]]', - }, - fork: { - icon: 'fa-code-fork', - text: '[[topic:forked-by]]', - }, + pin: { + icon: 'fa-thumb-tack', + text: '[[topic:pinned-by]]', + }, + unpin: { + icon: 'fa-thumb-tack', + text: '[[topic:unpinned-by]]', + }, + lock: { + icon: 'fa-lock', + text: '[[topic:locked-by]]', + }, + unlock: { + icon: 'fa-unlock', + text: '[[topic:unlocked-by]]', + }, + delete: { + icon: 'fa-trash', + text: '[[topic:deleted-by]]', + }, + restore: { + icon: 'fa-trash-o', + text: '[[topic:restored-by]]', + }, + private: { + icon: 'fa-lock', + text: '[[topic:private-by]]', + }, + public: { + icon: 'fa-unlock', + text: '[[topic:public-by]]', + }, + move: { + icon: 'fa-arrow-circle-right', + // Text: '[[topic:moved-from-by]]', + }, + 'post-queue': { + icon: 'fa-history', + text: '[[topic:queued-by]]', + href: '/post-queue', + }, + backlink: { + icon: 'fa-link', + text: '[[topic:backlink]]', + }, + fork: { + icon: 'fa-code-fork', + text: '[[topic:forked-by]]', + }, }; Events.init = async () => { - // Allow plugins to define additional topic event types - const { types } = await plugins.hooks.fire('filter:topicEvents.init', { types: Events._types }); - Events._types = types; + // Allow plugins to define additional topic event types + const {types} = await plugins.hooks.fire('filter:topicEvents.init', {types: Events._types}); + Events._types = types; }; Events.get = async (tid, uid, reverse = false) => { - const topics = require('.'); - - if (!await topics.exists(tid)) { - throw new Error('[[error:no-topic]]'); - } - - let eventIds = await db.getSortedSetRangeWithScores(`topic:${tid}:events`, 0, -1); - const keys = eventIds.map(obj => `topicEvent:${obj.value}`); - const timestamps = eventIds.map(obj => obj.score); - eventIds = eventIds.map(obj => obj.value); - let events = await db.getObjects(keys); - events = await modifyEvent({ tid, uid, eventIds, timestamps, events }); - if (reverse) { - events.reverse(); - } - return events; + const topics = require('.'); + + if (!await topics.exists(tid)) { + throw new Error('[[error:no-topic]]'); + } + + let eventIds = await db.getSortedSetRangeWithScores(`topic:${tid}:events`, 0, -1); + const keys = eventIds.map(object => `topicEvent:${object.value}`); + const timestamps = eventIds.map(object => object.score); + eventIds = eventIds.map(object => object.value); + let events = await db.getObjects(keys); + events = await modifyEvent({ + tid, uid, eventIds, timestamps, events, + }); + if (reverse) { + events.reverse(); + } + + return events; }; async function getUserInfo(uids) { - uids = uids.filter((uid, idx) => !isNaN(parseInt(uid, 10)) && uids.indexOf(uid) === idx); - const userData = await user.getUsersFields(uids, ['picture', 'username', 'userslug']); - const userMap = userData.reduce((memo, cur) => memo.set(cur.uid, cur), new Map()); - userMap.set('system', { - system: true, - }); - - return userMap; + uids = uids.filter((uid, index) => !isNaN(Number.parseInt(uid, 10)) && uids.indexOf(uid) === index); + const userData = await user.getUsersFields(uids, ['picture', 'username', 'userslug']); + const userMap = userData.reduce((memo, current) => memo.set(current.uid, current), new Map()); + userMap.set('system', { + system: true, + }); + + return userMap; } async function getCategoryInfo(cids) { - const uniqCids = _.uniq(cids); - const catData = await categories.getCategoriesFields(uniqCids, ['name', 'slug', 'icon', 'color', 'bgColor']); - return _.zipObject(uniqCids, catData); + const uniqCids = _.uniq(cids); + const catData = await categories.getCategoriesFields(uniqCids, ['name', 'slug', 'icon', 'color', 'bgColor']); + return _.zipObject(uniqCids, catData); } -async function modifyEvent({ tid, uid, eventIds, timestamps, events }) { - // Add posts from post queue - const isPrivileged = await user.isPrivileged(uid); - if (isPrivileged) { - const queuedPosts = await posts.getQueuedPosts({ tid }, { metadata: false }); - events.push(...queuedPosts.map(item => ({ - type: 'post-queue', - timestamp: item.data.timestamp || Date.now(), - uid: item.data.uid, - }))); - queuedPosts.forEach((item) => { - timestamps.push(item.data.timestamp || Date.now()); - }); - } - - const [users, fromCategories] = await Promise.all([ - getUserInfo(events.map(event => event.uid).filter(Boolean)), - getCategoryInfo(events.map(event => event.fromCid).filter(Boolean)), - ]); - - // Remove backlink events if backlinks are disabled - if (meta.config.topicBacklinks !== 1) { - events = events.filter(event => event.type !== 'backlink'); - } else { - // remove backlinks that we dont have read permission - const backlinkPids = events.filter(e => e.type === 'backlink') - .map(e => e.href.split('/').pop()); - const pids = await privileges.posts.filter('topics:read', backlinkPids, uid); - events = events.filter( - e => e.type !== 'backlink' || pids.includes(e.href.split('/').pop()) - ); - } - - // Remove events whose types no longer exist (e.g. plugin uninstalled) - events = events.filter(event => Events._types.hasOwnProperty(event.type)); - - // Add user & metadata - events.forEach((event, idx) => { - event.id = parseInt(eventIds[idx], 10); - event.timestamp = timestamps[idx]; - event.timestampISO = new Date(timestamps[idx]).toISOString(); - if (event.hasOwnProperty('uid')) { - event.user = users.get(event.uid === 'system' ? 'system' : parseInt(event.uid, 10)); - } - if (event.hasOwnProperty('fromCid')) { - event.fromCategory = fromCategories[event.fromCid]; - event.text = translator.compile('topic:moved-from-by', event.fromCategory.name); - } - - Object.assign(event, Events._types[event.type]); - }); - - // Sort events - events.sort((a, b) => a.timestamp - b.timestamp); - - return events; +async function modifyEvent({tid, uid, eventIds, timestamps, events}) { + // Add posts from post queue + const isPrivileged = await user.isPrivileged(uid); + if (isPrivileged) { + const queuedPosts = await posts.getQueuedPosts({tid}, {metadata: false}); + events.push(...queuedPosts.map(item => ({ + type: 'post-queue', + timestamp: item.data.timestamp || Date.now(), + uid: item.data.uid, + }))); + for (const item of queuedPosts) { + timestamps.push(item.data.timestamp || Date.now()); + } + } + + const [users, fromCategories] = await Promise.all([ + getUserInfo(events.map(event => event.uid).filter(Boolean)), + getCategoryInfo(events.map(event => event.fromCid).filter(Boolean)), + ]); + + // Remove backlink events if backlinks are disabled + if (meta.config.topicBacklinks === 1) { + // Remove backlinks that we dont have read permission + const backlinkPids = events.filter(e => e.type === 'backlink') + .map(e => e.href.split('/').pop()); + const pids = await privileges.posts.filter('topics:read', backlinkPids, uid); + events = events.filter( + e => e.type !== 'backlink' || pids.includes(e.href.split('/').pop()), + ); + } else { + events = events.filter(event => event.type !== 'backlink'); + } + + // Remove events whose types no longer exist (e.g. plugin uninstalled) + events = events.filter(event => Events._types.hasOwnProperty(event.type)); + + // Add user & metadata + for (const [index, event] of events.entries()) { + event.id = Number.parseInt(eventIds[index], 10); + event.timestamp = timestamps[index]; + event.timestampISO = new Date(timestamps[index]).toISOString(); + if (event.hasOwnProperty('uid')) { + event.user = users.get(event.uid === 'system' ? 'system' : Number.parseInt(event.uid, 10)); + } + + if (event.hasOwnProperty('fromCid')) { + event.fromCategory = fromCategories[event.fromCid]; + event.text = translator.compile('topic:moved-from-by', event.fromCategory.name); + } + + Object.assign(event, Events._types[event.type]); + } + + // Sort events + events.sort((a, b) => a.timestamp - b.timestamp); + + return events; } Events.log = async (tid, payload) => { - const topics = require('.'); - const { type } = payload; - const timestamp = payload.timestamp || Date.now(); - - if (!Events._types.hasOwnProperty(type)) { - throw new Error(`[[error:topic-event-unrecognized, ${type}]]`); - } else if (!await topics.exists(tid)) { - throw new Error('[[error:no-topic]]'); - } - - const eventId = await db.incrObjectField('global', 'nextTopicEventId'); - - await Promise.all([ - db.setObject(`topicEvent:${eventId}`, payload), - db.sortedSetAdd(`topic:${tid}:events`, timestamp, eventId), - ]); - - let events = await modifyEvent({ - eventIds: [eventId], - timestamps: [timestamp], - events: [payload], - }); - - ({ events } = await plugins.hooks.fire('filter:topic.events.log', { events })); - return events; + const topics = require('.'); + const {type} = payload; + const timestamp = payload.timestamp || Date.now(); + + if (!Events._types.hasOwnProperty(type)) { + throw new Error(`[[error:topic-event-unrecognized, ${type}]]`); + } else if (!await topics.exists(tid)) { + throw new Error('[[error:no-topic]]'); + } + + const eventId = await db.incrObjectField('global', 'nextTopicEventId'); + + await Promise.all([ + db.setObject(`topicEvent:${eventId}`, payload), + db.sortedSetAdd(`topic:${tid}:events`, timestamp, eventId), + ]); + + let events = await modifyEvent({ + eventIds: [eventId], + timestamps: [timestamp], + events: [payload], + }); + + ({events} = await plugins.hooks.fire('filter:topic.events.log', {events})); + return events; }; Events.purge = async (tid, eventIds = []) => { - if (eventIds.length) { - const isTopicEvent = await db.isSortedSetMembers(`topic:${tid}:events`, eventIds); - eventIds = eventIds.filter((id, index) => isTopicEvent[index]); - await Promise.all([ - db.sortedSetRemove(`topic:${tid}:events`, eventIds), - db.deleteAll(eventIds.map(id => `topicEvent:${id}`)), - ]); - } else { - const keys = [`topic:${tid}:events`]; - const eventIds = await db.getSortedSetRange(keys[0], 0, -1); - keys.push(...eventIds.map(id => `topicEvent:${id}`)); - - await db.deleteAll(keys); - } + if (eventIds.length > 0) { + const isTopicEvent = await db.isSortedSetMembers(`topic:${tid}:events`, eventIds); + eventIds = eventIds.filter((id, index) => isTopicEvent[index]); + await Promise.all([ + db.sortedSetRemove(`topic:${tid}:events`, eventIds), + db.deleteAll(eventIds.map(id => `topicEvent:${id}`)), + ]); + } else { + const keys = [`topic:${tid}:events`]; + const eventIds = await db.getSortedSetRange(keys[0], 0, -1); + keys.push(...eventIds.map(id => `topicEvent:${id}`)); + + await db.deleteAll(keys); + } }; diff --git a/src/topics/follow.js b/src/topics/follow.js index fd4f34e..9926f2a 100644 --- a/src/topics/follow.js +++ b/src/topics/follow.js @@ -8,170 +8,177 @@ const plugins = require('../plugins'); const utils = require('../utils'); module.exports = function (Topics) { - Topics.toggleFollow = async function (tid, uid) { - const exists = await Topics.exists(tid); - if (!exists) { - throw new Error('[[error:no-topic]]'); - } - const isFollowing = await Topics.isFollowing([tid], uid); - if (isFollowing[0]) { - await Topics.unfollow(tid, uid); - } else { - await Topics.follow(tid, uid); - } - return !isFollowing[0]; - }; - - Topics.follow = async function (tid, uid) { - await setWatching(follow, unignore, 'action:topic.follow', tid, uid); - }; - - Topics.unfollow = async function (tid, uid) { - await setWatching(unfollow, unignore, 'action:topic.unfollow', tid, uid); - }; - - Topics.ignore = async function (tid, uid) { - await setWatching(ignore, unfollow, 'action:topic.ignore', tid, uid); - }; - - async function setWatching(method1, method2, hook, tid, uid) { - if (!(parseInt(uid, 10) > 0)) { - throw new Error('[[error:not-logged-in]]'); - } - const exists = await Topics.exists(tid); - if (!exists) { - throw new Error('[[error:no-topic]]'); - } - await method1(tid, uid); - await method2(tid, uid); - plugins.hooks.fire(hook, { uid: uid, tid: tid }); - } - - async function follow(tid, uid) { - await addToSets(`tid:${tid}:followers`, `uid:${uid}:followed_tids`, tid, uid); - } - - async function unfollow(tid, uid) { - await removeFromSets(`tid:${tid}:followers`, `uid:${uid}:followed_tids`, tid, uid); - } - - async function ignore(tid, uid) { - await addToSets(`tid:${tid}:ignorers`, `uid:${uid}:ignored_tids`, tid, uid); - } - - async function unignore(tid, uid) { - await removeFromSets(`tid:${tid}:ignorers`, `uid:${uid}:ignored_tids`, tid, uid); - } - - async function addToSets(set1, set2, tid, uid) { - await db.setAdd(set1, uid); - await db.sortedSetAdd(set2, Date.now(), tid); - } - - async function removeFromSets(set1, set2, tid, uid) { - await db.setRemove(set1, uid); - await db.sortedSetRemove(set2, tid); - } - - Topics.isFollowing = async function (tids, uid) { - return await isIgnoringOrFollowing('followers', tids, uid); - }; - - Topics.isIgnoring = async function (tids, uid) { - return await isIgnoringOrFollowing('ignorers', tids, uid); - }; - - Topics.getFollowData = async function (tids, uid) { - if (!Array.isArray(tids)) { - return; - } - if (parseInt(uid, 10) <= 0) { - return tids.map(() => ({ following: false, ignoring: false })); - } - const keys = []; - tids.forEach(tid => keys.push(`tid:${tid}:followers`, `tid:${tid}:ignorers`)); - - const data = await db.isMemberOfSets(keys, uid); - - const followData = []; - for (let i = 0; i < data.length; i += 2) { - followData.push({ - following: data[i], - ignoring: data[i + 1], - }); - } - return followData; - }; - - async function isIgnoringOrFollowing(set, tids, uid) { - if (!Array.isArray(tids)) { - return; - } - if (parseInt(uid, 10) <= 0) { - return tids.map(() => false); - } - const keys = tids.map(tid => `tid:${tid}:${set}`); - return await db.isMemberOfSets(keys, uid); - } - - Topics.getFollowers = async function (tid) { - return await db.getSetMembers(`tid:${tid}:followers`); - }; - - Topics.getIgnorers = async function (tid) { - return await db.getSetMembers(`tid:${tid}:ignorers`); - }; - - Topics.filterIgnoringUids = async function (tid, uids) { - const isIgnoring = await db.isSetMembers(`tid:${tid}:ignorers`, uids); - const readingUids = uids.filter((uid, index) => uid && !isIgnoring[index]); - return readingUids; - }; - - Topics.filterWatchedTids = async function (tids, uid) { - if (parseInt(uid, 10) <= 0) { - return []; - } - const scores = await db.sortedSetScores(`uid:${uid}:followed_tids`, tids); - return tids.filter((tid, index) => tid && !!scores[index]); - }; - - Topics.filterNotIgnoredTids = async function (tids, uid) { - if (parseInt(uid, 10) <= 0) { - return tids; - } - const scores = await db.sortedSetScores(`uid:${uid}:ignored_tids`, tids); - return tids.filter((tid, index) => tid && !scores[index]); - }; - - Topics.notifyFollowers = async function (postData, exceptUid, notifData) { - notifData = notifData || {}; - let followers = await Topics.getFollowers(postData.topic.tid); - const index = followers.indexOf(String(exceptUid)); - if (index !== -1) { - followers.splice(index, 1); - } - - followers = await privileges.topics.filterUids('topics:read', postData.topic.tid, followers); - if (!followers.length) { - return; - } - - let { title } = postData.topic; - if (title) { - title = utils.decodeHTMLEntities(title); - } - - const notification = await notifications.create({ - subject: title, - bodyLong: postData.content, - pid: postData.pid, - path: `/post/${postData.pid}`, - tid: postData.topic.tid, - from: exceptUid, - topicTitle: title, - ...notifData, - }); - notifications.push(notification, followers); - }; + Topics.toggleFollow = async function (tid, uid) { + const exists = await Topics.exists(tid); + if (!exists) { + throw new Error('[[error:no-topic]]'); + } + + const isFollowing = await Topics.isFollowing([tid], uid); + await (isFollowing[0] ? Topics.unfollow(tid, uid) : Topics.follow(tid, uid)); + + return !isFollowing[0]; + }; + + Topics.follow = async function (tid, uid) { + await setWatching(follow, unignore, 'action:topic.follow', tid, uid); + }; + + Topics.unfollow = async function (tid, uid) { + await setWatching(unfollow, unignore, 'action:topic.unfollow', tid, uid); + }; + + Topics.ignore = async function (tid, uid) { + await setWatching(ignore, unfollow, 'action:topic.ignore', tid, uid); + }; + + async function setWatching(method1, method2, hook, tid, uid) { + if (!(Number.parseInt(uid, 10) > 0)) { + throw new Error('[[error:not-logged-in]]'); + } + + const exists = await Topics.exists(tid); + if (!exists) { + throw new Error('[[error:no-topic]]'); + } + + await method1(tid, uid); + await method2(tid, uid); + plugins.hooks.fire(hook, {uid, tid}); + } + + async function follow(tid, uid) { + await addToSets(`tid:${tid}:followers`, `uid:${uid}:followed_tids`, tid, uid); + } + + async function unfollow(tid, uid) { + await removeFromSets(`tid:${tid}:followers`, `uid:${uid}:followed_tids`, tid, uid); + } + + async function ignore(tid, uid) { + await addToSets(`tid:${tid}:ignorers`, `uid:${uid}:ignored_tids`, tid, uid); + } + + async function unignore(tid, uid) { + await removeFromSets(`tid:${tid}:ignorers`, `uid:${uid}:ignored_tids`, tid, uid); + } + + async function addToSets(set1, set2, tid, uid) { + await db.setAdd(set1, uid); + await db.sortedSetAdd(set2, Date.now(), tid); + } + + async function removeFromSets(set1, set2, tid, uid) { + await db.setRemove(set1, uid); + await db.sortedSetRemove(set2, tid); + } + + Topics.isFollowing = async function (tids, uid) { + return await isIgnoringOrFollowing('followers', tids, uid); + }; + + Topics.isIgnoring = async function (tids, uid) { + return await isIgnoringOrFollowing('ignorers', tids, uid); + }; + + Topics.getFollowData = async function (tids, uid) { + if (!Array.isArray(tids)) { + return; + } + + if (Number.parseInt(uid, 10) <= 0) { + return tids.map(() => ({following: false, ignoring: false})); + } + + const keys = []; + for (const tid of tids) { + keys.push(`tid:${tid}:followers`, `tid:${tid}:ignorers`); + } + + const data = await db.isMemberOfSets(keys, uid); + + const followData = []; + for (let i = 0; i < data.length; i += 2) { + followData.push({ + following: data[i], + ignoring: data[i + 1], + }); + } + + return followData; + }; + + async function isIgnoringOrFollowing(set, tids, uid) { + if (!Array.isArray(tids)) { + return; + } + + if (Number.parseInt(uid, 10) <= 0) { + return tids.map(() => false); + } + + const keys = tids.map(tid => `tid:${tid}:${set}`); + return await db.isMemberOfSets(keys, uid); + } + + Topics.getFollowers = async function (tid) { + return await db.getSetMembers(`tid:${tid}:followers`); + }; + + Topics.getIgnorers = async function (tid) { + return await db.getSetMembers(`tid:${tid}:ignorers`); + }; + + Topics.filterIgnoringUids = async function (tid, uids) { + const isIgnoring = await db.isSetMembers(`tid:${tid}:ignorers`, uids); + const readingUids = uids.filter((uid, index) => uid && !isIgnoring[index]); + return readingUids; + }; + + Topics.filterWatchedTids = async function (tids, uid) { + if (Number.parseInt(uid, 10) <= 0) { + return []; + } + + const scores = await db.sortedSetScores(`uid:${uid}:followed_tids`, tids); + return tids.filter((tid, index) => tid && Boolean(scores[index])); + }; + + Topics.filterNotIgnoredTids = async function (tids, uid) { + if (Number.parseInt(uid, 10) <= 0) { + return tids; + } + + const scores = await db.sortedSetScores(`uid:${uid}:ignored_tids`, tids); + return tids.filter((tid, index) => tid && !scores[index]); + }; + + Topics.notifyFollowers = async function (postData, exceptUid, notificationData) { + notificationData ||= {}; + let followers = await Topics.getFollowers(postData.topic.tid); + const index = followers.indexOf(String(exceptUid)); + if (index !== -1) { + followers.splice(index, 1); + } + + followers = await privileges.topics.filterUids('topics:read', postData.topic.tid, followers); + if (followers.length === 0) { + return; + } + + let {title} = postData.topic; + title &&= utils.decodeHTMLEntities(title); + + const notification = await notifications.create({ + subject: title, + bodyLong: postData.content, + pid: postData.pid, + path: `/post/${postData.pid}`, + tid: postData.topic.tid, + from: exceptUid, + topicTitle: title, + ...notificationData, + }); + notifications.push(notification, followers); + }; }; diff --git a/src/topics/fork.js b/src/topics/fork.js index 49c5d9d..5844b80 100644 --- a/src/topics/fork.js +++ b/src/topics/fork.js @@ -9,151 +9,157 @@ const plugins = require('../plugins'); const meta = require('../meta'); module.exports = function (Topics) { - Topics.createTopicFromPosts = async function (uid, title, pids, fromTid) { - if (title) { - title = title.trim(); - } - - if (title.length < meta.config.minimumTitleLength) { - throw new Error(`[[error:title-too-short, ${meta.config.minimumTitleLength}]]`); - } else if (title.length > meta.config.maximumTitleLength) { - throw new Error(`[[error:title-too-long, ${meta.config.maximumTitleLength}]]`); - } - - if (!pids || !pids.length) { - throw new Error('[[error:invalid-pid]]'); - } - - pids.sort((a, b) => a - b); - - const mainPid = pids[0]; - const cid = await posts.getCidByPid(mainPid); - - const [postData, isAdminOrMod] = await Promise.all([ - posts.getPostData(mainPid), - privileges.categories.isAdminOrMod(cid, uid), - ]); - - if (!isAdminOrMod) { - throw new Error('[[error:no-privileges]]'); - } - - const scheduled = postData.timestamp > Date.now(); - const params = { - uid: postData.uid, - title: title, - cid: cid, - timestamp: scheduled && postData.timestamp, - }; - const result = await plugins.hooks.fire('filter:topic.fork', { - params: params, - tid: postData.tid, - }); - - const tid = await Topics.create(result.params); - await Topics.updateTopicBookmarks(fromTid, pids); - - for (const pid of pids) { - /* eslint-disable no-await-in-loop */ - const canEdit = await privileges.posts.canEdit(pid, uid); - if (!canEdit.flag) { - throw new Error(canEdit.message); - } - await Topics.movePostToTopic(uid, pid, tid, scheduled); - } - - await Topics.updateLastPostTime(tid, scheduled ? (postData.timestamp + 1) : Date.now()); - - await Promise.all([ - Topics.setTopicFields(tid, { - upvotes: postData.upvotes, - downvotes: postData.downvotes, - }), - db.sortedSetsAdd(['topics:votes', `cid:${cid}:tids:votes`], postData.votes, tid), - Topics.events.log(fromTid, { type: 'fork', uid, href: `/topic/${tid}`, timestamp: postData.timestamp }), - ]); - - plugins.hooks.fire('action:topic.fork', { tid: tid, fromTid: fromTid, uid: uid }); - - return await Topics.getTopicData(tid); - }; - - Topics.movePostToTopic = async function (callerUid, pid, tid, forceScheduled = false) { - tid = parseInt(tid, 10); - const topicData = await Topics.getTopicFields(tid, ['tid', 'scheduled']); - if (!topicData.tid) { - throw new Error('[[error:no-topic]]'); - } - if (!forceScheduled && topicData.scheduled) { - throw new Error('[[error:cant-move-posts-to-scheduled]]'); - } - const postData = await posts.getPostFields(pid, ['tid', 'uid', 'timestamp', 'upvotes', 'downvotes']); - if (!postData || !postData.tid) { - throw new Error('[[error:no-post]]'); - } - - const isSourceTopicScheduled = await Topics.getTopicField(postData.tid, 'scheduled'); - if (!forceScheduled && isSourceTopicScheduled) { - throw new Error('[[error:cant-move-from-scheduled-to-existing]]'); - } - - if (postData.tid === tid) { - throw new Error('[[error:cant-move-to-same-topic]]'); - } - - postData.pid = pid; - - await Topics.removePostFromTopic(postData.tid, postData); - await Promise.all([ - updateCategory(postData, tid), - posts.setPostField(pid, 'tid', tid), - Topics.addPostToTopic(tid, postData), - ]); - - await Promise.all([ - Topics.updateLastPostTimeFromLastPid(tid), - Topics.updateLastPostTimeFromLastPid(postData.tid), - ]); - plugins.hooks.fire('action:post.move', { uid: callerUid, post: postData, tid: tid }); - }; - - async function updateCategory(postData, toTid) { - const topicData = await Topics.getTopicsFields([postData.tid, toTid], ['cid', 'pinned']); - - if (!topicData[0].cid || !topicData[1].cid) { - return; - } - - if (!topicData[0].pinned) { - await db.sortedSetIncrBy(`cid:${topicData[0].cid}:tids:posts`, -1, postData.tid); - } - if (!topicData[1].pinned) { - await db.sortedSetIncrBy(`cid:${topicData[1].cid}:tids:posts`, 1, toTid); - } - if (topicData[0].cid === topicData[1].cid) { - await categories.updateRecentTidForCid(topicData[0].cid); - return; - } - const removeFrom = [ - `cid:${topicData[0].cid}:pids`, - `cid:${topicData[0].cid}:uid:${postData.uid}:pids`, - `cid:${topicData[0].cid}:uid:${postData.uid}:pids:votes`, - ]; - const tasks = [ - db.incrObjectFieldBy(`category:${topicData[0].cid}`, 'post_count', -1), - db.incrObjectFieldBy(`category:${topicData[1].cid}`, 'post_count', 1), - db.sortedSetRemove(removeFrom, postData.pid), - db.sortedSetAdd(`cid:${topicData[1].cid}:pids`, postData.timestamp, postData.pid), - db.sortedSetAdd(`cid:${topicData[1].cid}:uid:${postData.uid}:pids`, postData.timestamp, postData.pid), - ]; - if (postData.votes > 0 || postData.votes < 0) { - tasks.push(db.sortedSetAdd(`cid:${topicData[1].cid}:uid:${postData.uid}:pids:votes`, postData.votes, postData.pid)); - } - - await Promise.all(tasks); - await Promise.all([ - categories.updateRecentTidForCid(topicData[0].cid), - categories.updateRecentTidForCid(topicData[1].cid), - ]); - } + Topics.createTopicFromPosts = async function (uid, title, pids, fromTid) { + title &&= title.trim(); + + if (title.length < meta.config.minimumTitleLength) { + throw new Error(`[[error:title-too-short, ${meta.config.minimumTitleLength}]]`); + } else if (title.length > meta.config.maximumTitleLength) { + throw new Error(`[[error:title-too-long, ${meta.config.maximumTitleLength}]]`); + } + + if (!pids || pids.length === 0) { + throw new Error('[[error:invalid-pid]]'); + } + + pids.sort((a, b) => a - b); + + const mainPid = pids[0]; + const cid = await posts.getCidByPid(mainPid); + + const [postData, isAdminOrModule] = await Promise.all([ + posts.getPostData(mainPid), + privileges.categories.isAdminOrMod(cid, uid), + ]); + + if (!isAdminOrModule) { + throw new Error('[[error:no-privileges]]'); + } + + const scheduled = postData.timestamp > Date.now(); + const parameters = { + uid: postData.uid, + title, + cid, + timestamp: scheduled && postData.timestamp, + }; + const result = await plugins.hooks.fire('filter:topic.fork', { + params: parameters, + tid: postData.tid, + }); + + const tid = await Topics.create(result.params); + await Topics.updateTopicBookmarks(fromTid, pids); + + for (const pid of pids) { + /* eslint-disable no-await-in-loop */ + const canEdit = await privileges.posts.canEdit(pid, uid); + if (!canEdit.flag) { + throw new Error(canEdit.message); + } + + await Topics.movePostToTopic(uid, pid, tid, scheduled); + } + + await Topics.updateLastPostTime(tid, scheduled ? (postData.timestamp + 1) : Date.now()); + + await Promise.all([ + Topics.setTopicFields(tid, { + upvotes: postData.upvotes, + downvotes: postData.downvotes, + }), + db.sortedSetsAdd(['topics:votes', `cid:${cid}:tids:votes`], postData.votes, tid), + Topics.events.log(fromTid, { + type: 'fork', uid, href: `/topic/${tid}`, timestamp: postData.timestamp, + }), + ]); + + plugins.hooks.fire('action:topic.fork', {tid, fromTid, uid}); + + return await Topics.getTopicData(tid); + }; + + Topics.movePostToTopic = async function (callerUid, pid, tid, forceScheduled = false) { + tid = Number.parseInt(tid, 10); + const topicData = await Topics.getTopicFields(tid, ['tid', 'scheduled']); + if (!topicData.tid) { + throw new Error('[[error:no-topic]]'); + } + + if (!forceScheduled && topicData.scheduled) { + throw new Error('[[error:cant-move-posts-to-scheduled]]'); + } + + const postData = await posts.getPostFields(pid, ['tid', 'uid', 'timestamp', 'upvotes', 'downvotes']); + if (!postData || !postData.tid) { + throw new Error('[[error:no-post]]'); + } + + const isSourceTopicScheduled = await Topics.getTopicField(postData.tid, 'scheduled'); + if (!forceScheduled && isSourceTopicScheduled) { + throw new Error('[[error:cant-move-from-scheduled-to-existing]]'); + } + + if (postData.tid === tid) { + throw new Error('[[error:cant-move-to-same-topic]]'); + } + + postData.pid = pid; + + await Topics.removePostFromTopic(postData.tid, postData); + await Promise.all([ + updateCategory(postData, tid), + posts.setPostField(pid, 'tid', tid), + Topics.addPostToTopic(tid, postData), + ]); + + await Promise.all([ + Topics.updateLastPostTimeFromLastPid(tid), + Topics.updateLastPostTimeFromLastPid(postData.tid), + ]); + plugins.hooks.fire('action:post.move', {uid: callerUid, post: postData, tid}); + }; + + async function updateCategory(postData, toTid) { + const topicData = await Topics.getTopicsFields([postData.tid, toTid], ['cid', 'pinned']); + + if (!topicData[0].cid || !topicData[1].cid) { + return; + } + + if (!topicData[0].pinned) { + await db.sortedSetIncrBy(`cid:${topicData[0].cid}:tids:posts`, -1, postData.tid); + } + + if (!topicData[1].pinned) { + await db.sortedSetIncrBy(`cid:${topicData[1].cid}:tids:posts`, 1, toTid); + } + + if (topicData[0].cid === topicData[1].cid) { + await categories.updateRecentTidForCid(topicData[0].cid); + return; + } + + const removeFrom = [ + `cid:${topicData[0].cid}:pids`, + `cid:${topicData[0].cid}:uid:${postData.uid}:pids`, + `cid:${topicData[0].cid}:uid:${postData.uid}:pids:votes`, + ]; + const tasks = [ + db.incrObjectFieldBy(`category:${topicData[0].cid}`, 'post_count', -1), + db.incrObjectFieldBy(`category:${topicData[1].cid}`, 'post_count', 1), + db.sortedSetRemove(removeFrom, postData.pid), + db.sortedSetAdd(`cid:${topicData[1].cid}:pids`, postData.timestamp, postData.pid), + db.sortedSetAdd(`cid:${topicData[1].cid}:uid:${postData.uid}:pids`, postData.timestamp, postData.pid), + ]; + if (postData.votes > 0 || postData.votes < 0) { + tasks.push(db.sortedSetAdd(`cid:${topicData[1].cid}:uid:${postData.uid}:pids:votes`, postData.votes, postData.pid)); + } + + await Promise.all(tasks); + await Promise.all([ + categories.updateRecentTidForCid(topicData[0].cid), + categories.updateRecentTidForCid(topicData[1].cid), + ]); + } }; diff --git a/src/topics/index.js b/src/topics/index.js index bb4a6a1..e6796dc 100644 --- a/src/topics/index.js +++ b/src/topics/index.js @@ -2,7 +2,6 @@ const _ = require('lodash'); const validator = require('validator'); - const db = require('../database'); const posts = require('../posts'); const utils = require('../utils'); @@ -37,257 +36,268 @@ require('./merge')(Topics); Topics.events = require('./events'); Topics.exists = async function (tids) { - return await db.exists( - Array.isArray(tids) ? tids.map(tid => `topic:${tid}`) : `topic:${tids}` - ); + return await db.exists( + Array.isArray(tids) ? tids.map(tid => `topic:${tid}`) : `topic:${tids}`, + ); }; Topics.getTopicsFromSet = async function (set, uid, start, stop) { - const tids = await db.getSortedSetRevRange(set, start, stop); - const topics = await Topics.getTopics(tids, uid); - Topics.calculateTopicIndices(topics, start); - return { topics: topics, nextStart: stop + 1 }; + const tids = await db.getSortedSetRevRange(set, start, stop); + const topics = await Topics.getTopics(tids, uid); + Topics.calculateTopicIndices(topics, start); + return {topics, nextStart: stop + 1}; }; Topics.getTopics = async function (tids, options) { - let uid = options; - if (typeof options === 'object') { - uid = options.uid; - } + let uid = options; + if (typeof options === 'object') { + uid = options.uid; + } - tids = await privileges.topics.filterTids('topics:read', tids, uid); - return await Topics.getTopicsByTids(tids, options); + tids = await privileges.topics.filterTids('topics:read', tids, uid); + return await Topics.getTopicsByTids(tids, options); }; Topics.getTopicsByTids = async function (tids, options) { - if (!Array.isArray(tids) || !tids.length) { - return []; - } - let uid = options; - if (typeof options === 'object') { - uid = options.uid; - } - - async function loadTopics() { - const topics = await Topics.getTopicsData(tids); - const uids = _.uniq(topics.map(t => t && t.uid && t.uid.toString()).filter(v => utils.isNumber(v))); - const cids = _.uniq(topics.map(t => t && t.cid && t.cid.toString()).filter(v => utils.isNumber(v))); - const guestTopics = topics.filter(t => t && t.uid === 0); - - async function loadGuestHandles() { - const mainPids = guestTopics.map(t => t.mainPid); - const postData = await posts.getPostsFields(mainPids, ['handle']); - return postData.map(p => p.handle); - } - - async function loadShowfullnameSettings() { - if (meta.config.hideFullname) { - return uids.map(() => ({ showfullname: false })); - } - const data = await db.getObjectsFields(uids.map(uid => `user:${uid}:settings`), ['showfullname']); - data.forEach((settings) => { - settings.showfullname = parseInt(settings.showfullname, 10) === 1; - }); - return data; - } - - const [teasers, users, userSettings, categoriesData, guestHandles, thumbs] = await Promise.all([ - Topics.getTeasers(topics, options), - user.getUsersFields(uids, ['uid', 'username', 'fullname', 'userslug', 'reputation', 'postcount', 'picture', 'signature', 'banned', 'status']), - loadShowfullnameSettings(), - categories.getCategoriesFields(cids, ['cid', 'name', 'slug', 'icon', 'backgroundImage', 'imageClass', 'bgColor', 'color', 'disabled']), - loadGuestHandles(), - Topics.thumbs.load(topics), - ]); - - users.forEach((userObj, idx) => { - // Hide fullname if needed - if (!userSettings[idx].showfullname) { - userObj.fullname = undefined; - } - }); - - return { - topics, - teasers, - usersMap: _.zipObject(uids, users), - categoriesMap: _.zipObject(cids, categoriesData), - tidToGuestHandle: _.zipObject(guestTopics.map(t => t.tid), guestHandles), - thumbs, - }; - } - - const [result, hasRead, isIgnored, bookmarks, callerSettings] = await Promise.all([ - loadTopics(), - Topics.hasReadTopics(tids, uid), - Topics.isIgnoring(tids, uid), - Topics.getUserBookmarks(tids, uid), - user.getSettings(uid), - ]); - - const sortNewToOld = callerSettings.topicPostSort === 'newest_to_oldest'; - result.topics.forEach((topic, i) => { - if (topic) { - topic.thumbs = result.thumbs[i]; - topic.category = result.categoriesMap[topic.cid]; - topic.user = topic.uid ? result.usersMap[topic.uid] : { ...result.usersMap[topic.uid] }; - if (result.tidToGuestHandle[topic.tid]) { - topic.user.username = validator.escape(result.tidToGuestHandle[topic.tid]); - topic.user.displayname = topic.user.username; - } - topic.teaser = result.teasers[i] || null; - topic.isOwner = topic.uid === parseInt(uid, 10); - topic.ignored = isIgnored[i]; - topic.unread = parseInt(uid, 10) <= 0 || (!hasRead[i] && !isIgnored[i]); - topic.bookmark = sortNewToOld ? - Math.max(1, topic.postcount + 2 - bookmarks[i]) : - Math.min(topic.postcount, bookmarks[i] + 1); - topic.unreplied = !topic.teaser; - - topic.icons = []; - } - }); - - const filteredTopics = result.topics.filter(topic => topic && topic.category && !topic.category.disabled); - - const hookResult = await plugins.hooks.fire('filter:topics.get', { topics: filteredTopics, uid: uid }); - return hookResult.topics; + if (!Array.isArray(tids) || tids.length === 0) { + return []; + } + + let uid = options; + if (typeof options === 'object') { + uid = options.uid; + } + + async function loadTopics() { + const topics = await Topics.getTopicsData(tids); + const uids = _.uniq(topics.map(t => t && t.uid && t.uid.toString()).filter(v => utils.isNumber(v))); + const cids = _.uniq(topics.map(t => t && t.cid && t.cid.toString()).filter(v => utils.isNumber(v))); + const guestTopics = topics.filter(t => t && t.uid === 0); + + async function loadGuestHandles() { + const mainPids = guestTopics.map(t => t.mainPid); + const postData = await posts.getPostsFields(mainPids, ['handle']); + return postData.map(p => p.handle); + } + + async function loadShowfullnameSettings() { + if (meta.config.hideFullname) { + return uids.map(() => ({showfullname: false})); + } + + const data = await db.getObjectsFields(uids.map(uid => `user:${uid}:settings`), ['showfullname']); + for (const settings of data) { + settings.showfullname = Number.parseInt(settings.showfullname, 10) === 1; + } + + return data; + } + + const [teasers, users, userSettings, categoriesData, guestHandles, thumbs] = await Promise.all([ + Topics.getTeasers(topics, options), + user.getUsersFields(uids, ['uid', 'username', 'fullname', 'userslug', 'reputation', 'postcount', 'picture', 'signature', 'banned', 'status']), + loadShowfullnameSettings(), + categories.getCategoriesFields(cids, ['cid', 'name', 'slug', 'icon', 'backgroundImage', 'imageClass', 'bgColor', 'color', 'disabled']), + loadGuestHandles(), + Topics.thumbs.load(topics), + ]); + + for (const [index, userObject] of users.entries()) { + // Hide fullname if needed + if (!userSettings[index].showfullname) { + userObject.fullname = undefined; + } + } + + return { + topics, + teasers, + usersMap: _.zipObject(uids, users), + categoriesMap: _.zipObject(cids, categoriesData), + tidToGuestHandle: _.zipObject(guestTopics.map(t => t.tid), guestHandles), + thumbs, + }; + } + + const [result, hasRead, isIgnored, bookmarks, callerSettings] = await Promise.all([ + loadTopics(), + Topics.hasReadTopics(tids, uid), + Topics.isIgnoring(tids, uid), + Topics.getUserBookmarks(tids, uid), + user.getSettings(uid), + ]); + + const sortNewToOld = callerSettings.topicPostSort === 'newest_to_oldest'; + for (const [i, topic] of result.topics.entries()) { + if (topic) { + topic.thumbs = result.thumbs[i]; + topic.category = result.categoriesMap[topic.cid]; + topic.user = topic.uid ? result.usersMap[topic.uid] : {...result.usersMap[topic.uid]}; + if (result.tidToGuestHandle[topic.tid]) { + topic.user.username = validator.escape(result.tidToGuestHandle[topic.tid]); + topic.user.displayname = topic.user.username; + } + + topic.teaser = result.teasers[i] || null; + topic.isOwner = topic.uid === Number.parseInt(uid, 10); + topic.ignored = isIgnored[i]; + topic.unread = Number.parseInt(uid, 10) <= 0 || (!hasRead[i] && !isIgnored[i]); + topic.bookmark = sortNewToOld + ? Math.max(1, topic.postcount + 2 - bookmarks[i]) + : Math.min(topic.postcount, bookmarks[i] + 1); + topic.unreplied = !topic.teaser; + + topic.icons = []; + } + } + + const filteredTopics = result.topics.filter(topic => topic && topic.category && !topic.category.disabled); + + const hookResult = await plugins.hooks.fire('filter:topics.get', {topics: filteredTopics, uid}); + return hookResult.topics; }; Topics.getTopicWithPosts = async function (topicData, set, uid, start, stop, reverse) { - const [ - posts, - pinnedPosts, - category, - tagWhitelist, - threadTools, - followData, - bookmark, - postSharing, - deleter, - merger, - related, - thumbs, - events, - ] = await Promise.all([ - Topics.getTopicPosts(topicData, set, start, stop, uid, reverse), - Topics.getTopicPinnedPosts(topicData, uid), - categories.getCategoryData(topicData.cid), - categories.getTagWhitelist([topicData.cid]), - plugins.hooks.fire('filter:topic.thread_tools', { topic: topicData, uid: uid, tools: [] }), - Topics.getFollowData([topicData.tid], uid), - Topics.getUserBookmark(topicData.tid, uid), - social.getActivePostSharing(), - getDeleter(topicData), - getMerger(topicData), - Topics.getRelatedTopics(topicData, uid), - Topics.thumbs.load([topicData]), - Topics.events.get(topicData.tid, uid, reverse), - ]); - - topicData.thumbs = thumbs[0]; - topicData.posts = posts; - topicData.events = events; - topicData.posts.forEach((p) => { - p.events = events.filter( - event => event.timestamp >= p.eventStart && event.timestamp < p.eventEnd - ); - }); - - topicData.category = category; - topicData.tagWhitelist = tagWhitelist[0]; - topicData.minTags = category.minTags; - topicData.maxTags = category.maxTags; - topicData.thread_tools = threadTools.tools; - topicData.isFollowing = followData[0].following; - topicData.isNotFollowing = !followData[0].following && !followData[0].ignoring; - topicData.isIgnoring = followData[0].ignoring; - topicData.bookmark = bookmark; - topicData.postSharing = postSharing; - topicData.deleter = deleter; - if (deleter) { - topicData.deletedTimestampISO = utils.toISOString(topicData.deletedTimestamp); - } - topicData.merger = merger; - if (merger) { - topicData.mergedTimestampISO = utils.toISOString(topicData.mergedTimestamp); - } - topicData.related = related || []; - topicData.unreplied = topicData.postcount === 1; - topicData.icons = []; - - topicData.pinnedPosts = pinnedPosts; - - const result = await plugins.hooks.fire('filter:topic.get', { topic: topicData, uid: uid }); - return result.topic; + const [ + posts, + pinnedPosts, + category, + tagInclude, + threadTools, + followData, + bookmark, + postSharing, + deleter, + merger, + related, + thumbs, + events, + ] = await Promise.all([ + Topics.getTopicPosts(topicData, set, start, stop, uid, reverse), + Topics.getTopicPinnedPosts(topicData, uid), + categories.getCategoryData(topicData.cid), + categories.getTagWhitelist([topicData.cid]), + plugins.hooks.fire('filter:topic.thread_tools', {topic: topicData, uid, tools: []}), + Topics.getFollowData([topicData.tid], uid), + Topics.getUserBookmark(topicData.tid, uid), + social.getActivePostSharing(), + getDeleter(topicData), + getMerger(topicData), + Topics.getRelatedTopics(topicData, uid), + Topics.thumbs.load([topicData]), + Topics.events.get(topicData.tid, uid, reverse), + ]); + + topicData.thumbs = thumbs[0]; + topicData.posts = posts; + topicData.events = events; + for (const p of topicData.posts) { + p.events = events.filter( + event => event.timestamp >= p.eventStart && event.timestamp < p.eventEnd, + ); + } + + topicData.category = category; + topicData.tagWhitelist = tagInclude[0]; + topicData.minTags = category.minTags; + topicData.maxTags = category.maxTags; + topicData.thread_tools = threadTools.tools; + topicData.isFollowing = followData[0].following; + topicData.isNotFollowing = !followData[0].following && !followData[0].ignoring; + topicData.isIgnoring = followData[0].ignoring; + topicData.bookmark = bookmark; + topicData.postSharing = postSharing; + topicData.deleter = deleter; + if (deleter) { + topicData.deletedTimestampISO = utils.toISOString(topicData.deletedTimestamp); + } + + topicData.merger = merger; + if (merger) { + topicData.mergedTimestampISO = utils.toISOString(topicData.mergedTimestamp); + } + + topicData.related = related || []; + topicData.unreplied = topicData.postcount === 1; + topicData.icons = []; + + topicData.pinnedPosts = pinnedPosts; + + const result = await plugins.hooks.fire('filter:topic.get', {topic: topicData, uid}); + return result.topic; }; async function getDeleter(topicData) { - if (!parseInt(topicData.deleterUid, 10)) { - return null; - } - return await user.getUserFields(topicData.deleterUid, ['username', 'userslug', 'picture']); + if (!Number.parseInt(topicData.deleterUid, 10)) { + return null; + } + + return await user.getUserFields(topicData.deleterUid, ['username', 'userslug', 'picture']); } async function getMerger(topicData) { - if (!parseInt(topicData.mergerUid, 10)) { - return null; - } - const [ - merger, - mergedIntoTitle, - ] = await Promise.all([ - user.getUserFields(topicData.mergerUid, ['username', 'userslug', 'picture']), - Topics.getTopicField(topicData.mergeIntoTid, 'title'), - ]); - merger.mergedIntoTitle = mergedIntoTitle; - return merger; + if (!Number.parseInt(topicData.mergerUid, 10)) { + return null; + } + + const [ + merger, + mergedIntoTitle, + ] = await Promise.all([ + user.getUserFields(topicData.mergerUid, ['username', 'userslug', 'picture']), + Topics.getTopicField(topicData.mergeIntoTid, 'title'), + ]); + merger.mergedIntoTitle = mergedIntoTitle; + return merger; } Topics.getMainPost = async function (tid, uid) { - const mainPosts = await Topics.getMainPosts([tid], uid); - return Array.isArray(mainPosts) && mainPosts.length ? mainPosts[0] : null; + const mainPosts = await Topics.getMainPosts([tid], uid); + return Array.isArray(mainPosts) && mainPosts.length > 0 ? mainPosts[0] : null; }; Topics.getMainPids = async function (tids) { - if (!Array.isArray(tids) || !tids.length) { - return []; - } - const topicData = await Topics.getTopicsFields(tids, ['mainPid']); - return topicData.map(topic => topic && topic.mainPid); + if (!Array.isArray(tids) || tids.length === 0) { + return []; + } + + const topicData = await Topics.getTopicsFields(tids, ['mainPid']); + return topicData.map(topic => topic && topic.mainPid); }; Topics.getMainPosts = async function (tids, uid) { - const mainPids = await Topics.getMainPids(tids); - return await getMainPosts(mainPids, uid); + const mainPids = await Topics.getMainPids(tids); + return await getMainPosts(mainPids, uid); }; async function getMainPosts(mainPids, uid) { - let postData = await posts.getPostsByPids(mainPids, uid); - postData = await user.blocks.filter(uid, postData); - postData.forEach((post) => { - if (post) { - post.index = 0; - } - }); - return await Topics.addPostData(postData, uid); + let postData = await posts.getPostsByPids(mainPids, uid); + postData = await user.blocks.filter(uid, postData); + for (const post of postData) { + if (post) { + post.index = 0; + } + } + + return await Topics.addPostData(postData, uid); } Topics.isLocked = async function (tid) { - const locked = await Topics.getTopicField(tid, 'locked'); - return locked === 1; + const locked = await Topics.getTopicField(tid, 'locked'); + return locked === 1; }; Topics.search = async function (tid, term) { - if (!tid || !term) { - throw new Error('[[error:invalid-data]]'); - } - const result = await plugins.hooks.fire('filter:topic.search', { - tid: tid, - term: term, - ids: [], - }); - return Array.isArray(result) ? result : result.ids; + if (!tid || !term) { + throw new Error('[[error:invalid-data]]'); + } + + const result = await plugins.hooks.fire('filter:topic.search', { + tid, + term, + ids: [], + }); + return Array.isArray(result) ? result : result.ids; }; require('../promisify')(Topics); diff --git a/src/topics/merge.js b/src/topics/merge.js index d6e238e..0b59647 100644 --- a/src/topics/merge.js +++ b/src/topics/merge.js @@ -4,79 +4,79 @@ const plugins = require('../plugins'); const posts = require('../posts'); module.exports = function (Topics) { - Topics.merge = async function (tids, uid, options) { - options = options || {}; + Topics.merge = async function (tids, uid, options) { + options ||= {}; - const topicsData = await Topics.getTopicsFields(tids, ['scheduled']); - if (topicsData.some(t => t.scheduled)) { - throw new Error('[[error:cant-merge-scheduled]]'); - } + const topicsData = await Topics.getTopicsFields(tids, ['scheduled']); + if (topicsData.some(t => t.scheduled)) { + throw new Error('[[error:cant-merge-scheduled]]'); + } - const oldestTid = findOldestTopic(tids); - let mergeIntoTid = oldestTid; - if (options.mainTid) { - mergeIntoTid = options.mainTid; - } else if (options.newTopicTitle) { - mergeIntoTid = await createNewTopic(options.newTopicTitle, oldestTid); - } + const oldestTid = findOldestTopic(tids); + let mergeIntoTid = oldestTid; + if (options.mainTid) { + mergeIntoTid = options.mainTid; + } else if (options.newTopicTitle) { + mergeIntoTid = await createNewTopic(options.newTopicTitle, oldestTid); + } - const otherTids = tids.sort((a, b) => a - b) - .filter(tid => tid && parseInt(tid, 10) !== parseInt(mergeIntoTid, 10)); + const otherTids = tids.sort((a, b) => a - b) + .filter(tid => tid && Number.parseInt(tid, 10) !== Number.parseInt(mergeIntoTid, 10)); - for (const tid of otherTids) { - /* eslint-disable no-await-in-loop */ - const pids = await Topics.getPids(tid); - for (const pid of pids) { - await Topics.movePostToTopic(uid, pid, mergeIntoTid); - } + for (const tid of otherTids) { + /* eslint-disable no-await-in-loop */ + const pids = await Topics.getPids(tid); + for (const pid of pids) { + await Topics.movePostToTopic(uid, pid, mergeIntoTid); + } - await Topics.setTopicField(tid, 'mainPid', 0); - await Topics.delete(tid, uid); - await Topics.setTopicFields(tid, { - mergeIntoTid: mergeIntoTid, - mergerUid: uid, - mergedTimestamp: Date.now(), - }); - } + await Topics.setTopicField(tid, 'mainPid', 0); + await Topics.delete(tid, uid); + await Topics.setTopicFields(tid, { + mergeIntoTid, + mergerUid: uid, + mergedTimestamp: Date.now(), + }); + } - await Promise.all([ - posts.updateQueuedPostsTopic(mergeIntoTid, otherTids), - updateViewCount(mergeIntoTid, tids), - ]); + await Promise.all([ + posts.updateQueuedPostsTopic(mergeIntoTid, otherTids), + updateViewCount(mergeIntoTid, tids), + ]); - plugins.hooks.fire('action:topic.merge', { - uid: uid, - tids: tids, - mergeIntoTid: mergeIntoTid, - otherTids: otherTids, - }); - return mergeIntoTid; - }; + plugins.hooks.fire('action:topic.merge', { + uid, + tids, + mergeIntoTid, + otherTids, + }); + return mergeIntoTid; + }; - async function createNewTopic(title, oldestTid) { - const topicData = await Topics.getTopicFields(oldestTid, ['uid', 'cid']); - const params = { - uid: topicData.uid, - cid: topicData.cid, - title: title, - }; - const result = await plugins.hooks.fire('filter:topic.mergeCreateNewTopic', { - oldestTid: oldestTid, - params: params, - }); - const tid = await Topics.create(result.params); - return tid; - } + async function createNewTopic(title, oldestTid) { + const topicData = await Topics.getTopicFields(oldestTid, ['uid', 'cid']); + const parameters = { + uid: topicData.uid, + cid: topicData.cid, + title, + }; + const result = await plugins.hooks.fire('filter:topic.mergeCreateNewTopic', { + oldestTid, + params: parameters, + }); + const tid = await Topics.create(result.params); + return tid; + } - async function updateViewCount(mergeIntoTid, tids) { - const topicData = await Topics.getTopicsFields(tids, ['viewcount']); - const totalViewCount = topicData.reduce( - (count, topic) => count + parseInt(topic.viewcount, 10), 0 - ); - await Topics.setTopicField(mergeIntoTid, 'viewcount', totalViewCount); - } + async function updateViewCount(mergeIntoTid, tids) { + const topicData = await Topics.getTopicsFields(tids, ['viewcount']); + const totalViewCount = topicData.reduce( + (count, topic) => count + Number.parseInt(topic.viewcount, 10), 0, + ); + await Topics.setTopicField(mergeIntoTid, 'viewcount', totalViewCount); + } - function findOldestTopic(tids) { - return Math.min.apply(null, tids); - } + function findOldestTopic(tids) { + return Math.min.apply(null, tids); + } }; diff --git a/src/topics/posts.js b/src/topics/posts.js index 3b34a87..523720e 100644 --- a/src/topics/posts.js +++ b/src/topics/posts.js @@ -2,12 +2,10 @@ 'use strict'; // JS requirement -const assert = require('assert'); - +const assert = require('node:assert'); const _ = require('lodash'); const validator = require('validator'); const nconf = require('nconf'); - const db = require('../database'); const user = require('../user'); const posts = require('../posts'); @@ -18,13 +16,13 @@ const utils = require('../utils'); const backlinkRegex = new RegExp(`(?:${nconf.get('url').replace('/', '\\/')}|\b|\\s)\\/topic\\/(\\d+)(?:\\/\\w+)?`, 'g'); module.exports = function (Topics) { - Topics.onNewPostMade = async function (postData) { - await Topics.updateLastPostTime(postData.tid, postData.timestamp); - await Topics.addPostToTopic(postData.tid, postData); - }; + Topics.onNewPostMade = async function (postData) { + await Topics.updateLastPostTime(postData.tid, postData.timestamp); + await Topics.addPostToTopic(postData.tid, postData); + }; - Topics.getTopicPinnedPosts = async function (topicData, uid) { - /* + Topics.getTopicPinnedPosts = async function (topicData, uid) { + /* Parameters: - `topicData`: an object with information about the topic - `uid`: the user id @@ -32,431 +30,446 @@ module.exports = function (Topics) { Returns: a list of post objects, all of which are pinned */ - assert(topicData.hasOwnProperty('tid'), 'topicData has no tid field!'); - assert(typeof topicData.tid === typeof 1); - assert(topicData.hasOwnProperty('uid'), 'topicData has no uid field!'); - assert(typeof topicData.uid === typeof 1); - - // Let's just get *all* the posts belonging to this `tid` - const allPids = await db.getSortedSetMembers(`tid:${topicData.tid}:posts`); - - // Then filter by pinned - const postData = await posts.getPostsByPids(allPids, uid); - let pinnedPosts = postData.filter( - postObject => postObject.pinned - ); - - pinnedPosts = await Topics.addPostData(pinnedPosts, uid); - - function hasCorrectFields(postData) { - return ( - postData.hasOwnProperty('pid') && - (typeof postData.pid === typeof 1) && - postData.hasOwnProperty('tid') && - (typeof postData.tid === typeof 1) && - postData.hasOwnProperty('pinned') && - (typeof postData.pinned === typeof 1) - ); - } - - assert(pinnedPosts.every(hasCorrectFields)); - - return pinnedPosts; - }; - - Topics.getTopicPosts = async function (topicData, set, start, stop, uid, reverse) { - if (!topicData) { - return []; - } - - let repliesStart = start; - let repliesStop = stop; - if (stop > 0) { - repliesStop -= 1; - if (start > 0) { - repliesStart -= 1; - } - } - let pids = []; - if (start !== 0 || stop !== 0) { - pids = await posts.getPidsFromSet(set, repliesStart, repliesStop, reverse); - } - if (!pids.length && !topicData.mainPid) { - return []; - } - - if (topicData.mainPid && start === 0) { - pids.unshift(topicData.mainPid); - } - let postData = await posts.getPostsByPids(pids, uid); - if (!postData.length) { - return []; - } - let replies = postData; - if (topicData.mainPid && start === 0) { - postData[0].index = 0; - replies = postData.slice(1); - } - - Topics.calculatePostIndices(replies, repliesStart); - await addEventStartEnd(postData, set, reverse, topicData); - const allPosts = postData.slice(); - postData = await user.blocks.filter(uid, postData); - if (allPosts.length !== postData.length) { - const includedPids = new Set(postData.map(p => p.pid)); - allPosts.reverse().forEach((p, index) => { - if (!includedPids.has(p.pid) && allPosts[index + 1] && !reverse) { - allPosts[index + 1].eventEnd = p.eventEnd; - } - }); - } - - const result = await plugins.hooks.fire('filter:topic.getPosts', { - topic: topicData, - uid: uid, - posts: await Topics.addPostData(postData, uid), - }); - return result.posts; - }; - - async function addEventStartEnd(postData, set, reverse, topicData) { - if (!postData.length) { - return; - } - postData.forEach((p, index) => { - if (p && p.index === 0 && reverse) { - p.eventStart = topicData.lastposttime; - p.eventEnd = Date.now(); - } else if (p && postData[index + 1]) { - p.eventStart = reverse ? postData[index + 1].timestamp : p.timestamp; - p.eventEnd = reverse ? p.timestamp : postData[index + 1].timestamp; - } - }); - const lastPost = postData[postData.length - 1]; - if (lastPost) { - lastPost.eventStart = reverse ? topicData.timestamp : lastPost.timestamp; - lastPost.eventEnd = reverse ? lastPost.timestamp : Date.now(); - if (lastPost.index) { - const nextPost = await db[reverse ? 'getSortedSetRevRangeWithScores' : 'getSortedSetRangeWithScores'](set, lastPost.index, lastPost.index); - if (reverse) { - lastPost.eventStart = nextPost.length ? nextPost[0].score : lastPost.eventStart; - } else { - lastPost.eventEnd = nextPost.length ? nextPost[0].score : lastPost.eventEnd; - } - } - } - } - - Topics.addPostData = async function (postData, uid) { - if (!Array.isArray(postData) || !postData.length) { - return []; - } - const pids = postData.map(post => post && post.pid); - - async function getPostUserData(field, method) { - const uids = _.uniq(postData.filter(p => p && parseInt(p[field], 10) >= 0).map(p => p[field])); - const userData = await method(uids); - return _.zipObject(uids, userData); - } - const [ - bookmarks, - voteData, - userData, - editors, - replies, - ] = await Promise.all([ - posts.hasBookmarked(pids, uid), - posts.getVoteStatusByPostIDs(pids, uid), - getPostUserData('uid', async uids => await posts.getUserInfoForPosts(uids, uid)), - getPostUserData('editor', async uids => await user.getUsersFields(uids, ['uid', 'username', 'userslug'])), - getPostReplies(pids, uid), - Topics.addParentPosts(postData), - ]); - - postData.forEach((postObj, i) => { - if (postObj) { - postObj.user = postObj.uid ? userData[postObj.uid] : { ...userData[postObj.uid] }; - postObj.editor = postObj.editor ? editors[postObj.editor] : null; - postObj.bookmarked = bookmarks[i]; - postObj.upvoted = voteData.upvotes[i]; - postObj.downvoted = voteData.downvotes[i]; - postObj.votes = postObj.votes || 0; - postObj.replies = replies[i]; - postObj.selfPost = parseInt(uid, 10) > 0 && parseInt(uid, 10) === postObj.uid; - - // Username override for guests, if enabled - if (meta.config.allowGuestHandles && postObj.uid === 0 && postObj.handle) { - postObj.user.username = validator.escape(String(postObj.handle)); - postObj.user.displayname = postObj.user.username; - } - } - }); - - const result = await plugins.hooks.fire('filter:topics.addPostData', { - posts: postData, - uid: uid, - }); - return result.posts; - }; - - Topics.modifyPostsByPrivilege = function (topicData, topicPrivileges) { - const loggedIn = parseInt(topicPrivileges.uid, 10) > 0; - - function modifyPost(post) { - if (post) { - post.topicOwnerPost = parseInt(topicData.uid, 10) === parseInt(post.uid, 10); - post.display_edit_tools = topicPrivileges.isAdminOrMod || (post.selfPost && topicPrivileges['posts:edit']); - post.display_delete_tools = topicPrivileges.isAdminOrMod || (post.selfPost && topicPrivileges['posts:delete']); - post.display_moderator_tools = post.display_edit_tools || post.display_delete_tools; - post.display_move_tools = topicPrivileges.isAdminOrMod && post.index !== 0; - post.display_post_menu = topicPrivileges.isAdminOrMod || - (post.selfPost && - ((!topicData.locked && !post.deleted) || - (post.deleted && parseInt(post.deleterUid, 10) === parseInt(topicPrivileges.uid, 10)))) || - ((loggedIn || topicData.postSharing.length) && !post.deleted); - post.ip = topicPrivileges.isAdminOrMod ? post.ip : undefined; - - posts.modifyPostByPrivilege(post, topicPrivileges); - } - } - - topicData.posts.forEach((post) => { - modifyPost(post); - }); - - if (topicData.hasOwnProperty('pinnedPosts')) { - topicData.pinnedPosts.forEach((post) => { - modifyPost(post); - }); - } - }; - - Topics.addParentPosts = async function (postData) { - let parentPids = postData.map(postObj => (postObj && postObj.hasOwnProperty('toPid') ? parseInt(postObj.toPid, 10) : null)).filter(Boolean); - - if (!parentPids.length) { - return; - } - parentPids = _.uniq(parentPids); - const parentPosts = await posts.getPostsFields(parentPids, ['uid']); - const parentUids = _.uniq(parentPosts.map(postObj => postObj && postObj.uid)); - const userData = await user.getUsersFields(parentUids, ['username']); - - const usersMap = {}; - userData.forEach((user) => { - usersMap[user.uid] = user.username; - }); - const parents = {}; - parentPosts.forEach((post, i) => { - parents[parentPids[i]] = { username: usersMap[post.uid] }; - }); - - postData.forEach((post) => { - post.parent = parents[post.toPid]; - }); - }; - - Topics.calculatePostIndices = function (posts, start) { - posts.forEach((post, index) => { - if (post) { - post.index = start + index + 1; - } - }); - }; - - Topics.getLatestUndeletedPid = async function (tid) { - const pid = await Topics.getLatestUndeletedReply(tid); - if (pid) { - return pid; - } - const mainPid = await Topics.getTopicField(tid, 'mainPid'); - const mainPost = await posts.getPostFields(mainPid, ['pid', 'deleted']); - return mainPost.pid && !mainPost.deleted ? mainPost.pid : null; - }; - - Topics.getLatestUndeletedReply = async function (tid) { - let isDeleted = false; - let index = 0; - do { - /* eslint-disable no-await-in-loop */ - const pids = await db.getSortedSetRevRange(`tid:${tid}:posts`, index, index); - if (!pids.length) { - return null; - } - isDeleted = await posts.getPostField(pids[0], 'deleted'); - if (!isDeleted) { - return parseInt(pids[0], 10); - } - index += 1; - } while (isDeleted); - }; - - Topics.addPostToTopic = async function (tid, postData) { - const mainPid = await Topics.getTopicField(tid, 'mainPid'); - if (!parseInt(mainPid, 10)) { - await Topics.setTopicField(tid, 'mainPid', postData.pid); - } else { - const upvotes = parseInt(postData.upvotes, 10) || 0; - const downvotes = parseInt(postData.downvotes, 10) || 0; - const votes = upvotes - downvotes; - await db.sortedSetsAdd([ - `tid:${tid}:posts`, `tid:${tid}:posts:votes`, - ], [postData.timestamp, votes], postData.pid); - } - await Topics.increasePostCount(tid); - await db.sortedSetIncrBy(`tid:${tid}:posters`, 1, postData.uid); - const posterCount = await db.sortedSetCard(`tid:${tid}:posters`); - await Topics.setTopicField(tid, 'postercount', posterCount); - await Topics.updateTeaser(tid); - }; - - Topics.removePostFromTopic = async function (tid, postData) { - await db.sortedSetsRemove([ - `tid:${tid}:posts`, - `tid:${tid}:posts:votes`, - ], postData.pid); - await Topics.decreasePostCount(tid); - await db.sortedSetIncrBy(`tid:${tid}:posters`, -1, postData.uid); - await db.sortedSetsRemoveRangeByScore([`tid:${tid}:posters`], '-inf', 0); - const posterCount = await db.sortedSetCard(`tid:${tid}:posters`); - await Topics.setTopicField(tid, 'postercount', posterCount); - await Topics.updateTeaser(tid); - }; - - Topics.getPids = async function (tid) { - let [mainPid, pids] = await Promise.all([ - Topics.getTopicField(tid, 'mainPid'), - db.getSortedSetRange(`tid:${tid}:posts`, 0, -1), - ]); - if (parseInt(mainPid, 10)) { - pids = [mainPid].concat(pids); - } - return pids; - }; - - Topics.increasePostCount = async function (tid) { - incrementFieldAndUpdateSortedSet(tid, 'postcount', 1, 'topics:posts'); - }; - - Topics.decreasePostCount = async function (tid) { - incrementFieldAndUpdateSortedSet(tid, 'postcount', -1, 'topics:posts'); - }; - - Topics.increaseViewCount = async function (tid) { - const cid = await Topics.getTopicField(tid, 'cid'); - incrementFieldAndUpdateSortedSet(tid, 'viewcount', 1, ['topics:views', `cid:${cid}:tids:views`]); - }; - - async function incrementFieldAndUpdateSortedSet(tid, field, by, set) { - const value = await db.incrObjectFieldBy(`topic:${tid}`, field, by); - await db[Array.isArray(set) ? 'sortedSetsAdd' : 'sortedSetAdd'](set, value, tid); - } - - Topics.getTitleByPid = async function (pid) { - return await Topics.getTopicFieldByPid('title', pid); - }; - - Topics.getTopicFieldByPid = async function (field, pid) { - const tid = await posts.getPostField(pid, 'tid'); - return await Topics.getTopicField(tid, field); - }; - - Topics.getTopicDataByPid = async function (pid) { - const tid = await posts.getPostField(pid, 'tid'); - return await Topics.getTopicData(tid); - }; - - Topics.getPostCount = async function (tid) { - return await db.getObjectField(`topic:${tid}`, 'postcount'); - }; - - async function getPostReplies(pids, callerUid) { - const keys = pids.map(pid => `pid:${pid}:replies`); - const arrayOfReplyPids = await db.getSortedSetsMembers(keys); - - const uniquePids = _.uniq(_.flatten(arrayOfReplyPids)); - - let replyData = await posts.getPostsFields(uniquePids, ['pid', 'uid', 'timestamp']); - const result = await plugins.hooks.fire('filter:topics.getPostReplies', { - uid: callerUid, - replies: replyData, - }); - replyData = await user.blocks.filter(callerUid, result.replies); - - const uids = replyData.map(replyData => replyData && replyData.uid); - - const uniqueUids = _.uniq(uids); - - const userData = await user.getUsersWithFields(uniqueUids, ['uid', 'username', 'userslug', 'picture'], callerUid); - - const uidMap = _.zipObject(uniqueUids, userData); - const pidMap = _.zipObject(replyData.map(r => r.pid), replyData); - - const returnData = arrayOfReplyPids.map((replyPids) => { - replyPids = replyPids.filter(pid => pidMap[pid]); - const uidsUsed = {}; - const currentData = { - hasMore: false, - users: [], - text: replyPids.length > 1 ? `[[topic:replies_to_this_post, ${replyPids.length}]]` : '[[topic:one_reply_to_this_post]]', - count: replyPids.length, - timestampISO: replyPids.length ? utils.toISOString(pidMap[replyPids[0]].timestamp) : undefined, - }; - - replyPids.sort((a, b) => parseInt(a, 10) - parseInt(b, 10)); - - replyPids.forEach((replyPid) => { - const replyData = pidMap[replyPid]; - if (!uidsUsed[replyData.uid] && currentData.users.length < 6) { - currentData.users.push(uidMap[replyData.uid]); - uidsUsed[replyData.uid] = true; - } - }); - - if (currentData.users.length > 5) { - currentData.users.pop(); - currentData.hasMore = true; - } - - return currentData; - }); - - return returnData; - } - - Topics.syncBacklinks = async (postData) => { - if (!postData) { - throw new Error('[[error:invalid-data]]'); - } - - // Scan post content for topic links - const matches = [...postData.content.matchAll(backlinkRegex)]; - if (!matches) { - return 0; - } - - const { pid, uid, tid } = postData; - let add = _.uniq(matches.map(match => match[1]).map(tid => parseInt(tid, 10))); - - const now = Date.now(); - const topicsExist = await Topics.exists(add); - const current = (await db.getSortedSetMembers(`pid:${pid}:backlinks`)).map(tid => parseInt(tid, 10)); - const remove = current.filter(tid => !add.includes(tid)); - add = add.filter((_tid, idx) => topicsExist[idx] && !current.includes(_tid) && tid !== _tid); - - // Remove old backlinks - await db.sortedSetRemove(`pid:${pid}:backlinks`, remove); - - // Add new backlinks - await db.sortedSetAdd(`pid:${pid}:backlinks`, add.map(() => now), add); - await Promise.all(add.map(async (tid) => { - await Topics.events.log(tid, { - uid, - type: 'backlink', - href: `/post/${pid}`, - }); - })); - - return add.length + (current - remove); - }; + assert(topicData.hasOwnProperty('tid'), 'topicData has no tid field!'); + assert(typeof topicData.tid === typeof 1); + assert(topicData.hasOwnProperty('uid'), 'topicData has no uid field!'); + assert(typeof topicData.uid === typeof 1); + + // Let's just get *all* the posts belonging to this `tid` + const allPids = await db.getSortedSetMembers(`tid:${topicData.tid}:posts`); + + // Then filter by pinned + const postData = await posts.getPostsByPids(allPids, uid); + let pinnedPosts = postData.filter( + postObject => postObject.pinned, + ); + + pinnedPosts = await Topics.addPostData(pinnedPosts, uid); + + function hasCorrectFields(postData) { + return ( + postData.hasOwnProperty('pid') + && (typeof postData.pid === typeof 1) + && postData.hasOwnProperty('tid') + && (typeof postData.tid === typeof 1) + && postData.hasOwnProperty('pinned') + && (typeof postData.pinned === typeof 1) + ); + } + + assert(pinnedPosts.every(hasCorrectFields)); + + return pinnedPosts; + }; + + Topics.getTopicPosts = async function (topicData, set, start, stop, uid, reverse) { + if (!topicData) { + return []; + } + + let repliesStart = start; + let repliesStop = stop; + if (stop > 0) { + repliesStop -= 1; + if (start > 0) { + repliesStart -= 1; + } + } + + let pids = []; + if (start !== 0 || stop !== 0) { + pids = await posts.getPidsFromSet(set, repliesStart, repliesStop, reverse); + } + + if (pids.length === 0 && !topicData.mainPid) { + return []; + } + + if (topicData.mainPid && start === 0) { + pids.unshift(topicData.mainPid); + } + + let postData = await posts.getPostsByPids(pids, uid); + if (postData.length === 0) { + return []; + } + + let replies = postData; + if (topicData.mainPid && start === 0) { + postData[0].index = 0; + replies = postData.slice(1); + } + + Topics.calculatePostIndices(replies, repliesStart); + await addEventStartEnd(postData, set, reverse, topicData); + const allPosts = postData.slice(); + postData = await user.blocks.filter(uid, postData); + if (allPosts.length !== postData.length) { + const includedPids = new Set(postData.map(p => p.pid)); + for (const [index, p] of allPosts.reverse().entries()) { + if (!includedPids.has(p.pid) && allPosts[index + 1] && !reverse) { + allPosts[index + 1].eventEnd = p.eventEnd; + } + } + } + + const result = await plugins.hooks.fire('filter:topic.getPosts', { + topic: topicData, + uid, + posts: await Topics.addPostData(postData, uid), + }); + return result.posts; + }; + + async function addEventStartEnd(postData, set, reverse, topicData) { + if (postData.length === 0) { + return; + } + + for (const [index, p] of postData.entries()) { + if (p && p.index === 0 && reverse) { + p.eventStart = topicData.lastposttime; + p.eventEnd = Date.now(); + } else if (p && postData[index + 1]) { + p.eventStart = reverse ? postData[index + 1].timestamp : p.timestamp; + p.eventEnd = reverse ? p.timestamp : postData[index + 1].timestamp; + } + } + + const lastPost = postData.at(-1); + if (lastPost) { + lastPost.eventStart = reverse ? topicData.timestamp : lastPost.timestamp; + lastPost.eventEnd = reverse ? lastPost.timestamp : Date.now(); + if (lastPost.index) { + const nextPost = await db[reverse ? 'getSortedSetRevRangeWithScores' : 'getSortedSetRangeWithScores'](set, lastPost.index, lastPost.index); + if (reverse) { + lastPost.eventStart = nextPost.length > 0 ? nextPost[0].score : lastPost.eventStart; + } else { + lastPost.eventEnd = nextPost.length > 0 ? nextPost[0].score : lastPost.eventEnd; + } + } + } + } + + Topics.addPostData = async function (postData, uid) { + if (!Array.isArray(postData) || postData.length === 0) { + return []; + } + + const pids = postData.map(post => post && post.pid); + + async function getPostUserData(field, method) { + const uids = _.uniq(postData.filter(p => p && Number.parseInt(p[field], 10) >= 0).map(p => p[field])); + const userData = await method(uids); + return _.zipObject(uids, userData); + } + + const [ + bookmarks, + voteData, + userData, + editors, + replies, + ] = await Promise.all([ + posts.hasBookmarked(pids, uid), + posts.getVoteStatusByPostIDs(pids, uid), + getPostUserData('uid', async uids => await posts.getUserInfoForPosts(uids, uid)), + getPostUserData('editor', async uids => await user.getUsersFields(uids, ['uid', 'username', 'userslug'])), + getPostReplies(pids, uid), + Topics.addParentPosts(postData), + ]); + + for (const [i, postObject] of postData.entries()) { + if (postObject) { + postObject.user = postObject.uid ? userData[postObject.uid] : {...userData[postObject.uid]}; + postObject.editor = postObject.editor ? editors[postObject.editor] : null; + postObject.bookmarked = bookmarks[i]; + postObject.upvoted = voteData.upvotes[i]; + postObject.downvoted = voteData.downvotes[i]; + postObject.votes = postObject.votes || 0; + postObject.replies = replies[i]; + postObject.selfPost = Number.parseInt(uid, 10) > 0 && Number.parseInt(uid, 10) === postObject.uid; + + // Username override for guests, if enabled + if (meta.config.allowGuestHandles && postObject.uid === 0 && postObject.handle) { + postObject.user.username = validator.escape(String(postObject.handle)); + postObject.user.displayname = postObject.user.username; + } + } + } + + const result = await plugins.hooks.fire('filter:topics.addPostData', { + posts: postData, + uid, + }); + return result.posts; + }; + + Topics.modifyPostsByPrivilege = function (topicData, topicPrivileges) { + const loggedIn = Number.parseInt(topicPrivileges.uid, 10) > 0; + + function modifyPost(post) { + if (post) { + post.topicOwnerPost = Number.parseInt(topicData.uid, 10) === Number.parseInt(post.uid, 10); + post.display_edit_tools = topicPrivileges.isAdminOrMod || (post.selfPost && topicPrivileges['posts:edit']); + post.display_delete_tools = topicPrivileges.isAdminOrMod || (post.selfPost && topicPrivileges['posts:delete']); + post.display_moderator_tools = post.display_edit_tools || post.display_delete_tools; + post.display_move_tools = topicPrivileges.isAdminOrMod && post.index !== 0; + post.display_post_menu = topicPrivileges.isAdminOrMod + || (post.selfPost + && ((!topicData.locked && !post.deleted) + || (post.deleted && Number.parseInt(post.deleterUid, 10) === Number.parseInt(topicPrivileges.uid, 10)))) + || ((loggedIn || topicData.postSharing.length) && !post.deleted); + post.ip = topicPrivileges.isAdminOrMod ? post.ip : undefined; + + posts.modifyPostByPrivilege(post, topicPrivileges); + } + } + + for (const post of topicData.posts) { + modifyPost(post); + } + + if (topicData.hasOwnProperty('pinnedPosts')) { + for (const post of topicData.pinnedPosts) { + modifyPost(post); + } + } + }; + + Topics.addParentPosts = async function (postData) { + let parentPids = postData.map(postObject => (postObject && postObject.hasOwnProperty('toPid') ? Number.parseInt(postObject.toPid, 10) : null)).filter(Boolean); + + if (parentPids.length === 0) { + return; + } + + parentPids = _.uniq(parentPids); + const parentPosts = await posts.getPostsFields(parentPids, ['uid']); + const parentUids = _.uniq(parentPosts.map(postObject => postObject && postObject.uid)); + const userData = await user.getUsersFields(parentUids, ['username']); + + const usersMap = {}; + for (const user of userData) { + usersMap[user.uid] = user.username; + } + + const parents = {}; + for (const [i, post] of parentPosts.entries()) { + parents[parentPids[i]] = {username: usersMap[post.uid]}; + } + + for (const post of postData) { + post.parent = parents[post.toPid]; + } + }; + + Topics.calculatePostIndices = function (posts, start) { + for (const [index, post] of posts.entries()) { + if (post) { + post.index = start + index + 1; + } + } + }; + + Topics.getLatestUndeletedPid = async function (tid) { + const pid = await Topics.getLatestUndeletedReply(tid); + if (pid) { + return pid; + } + + const mainPid = await Topics.getTopicField(tid, 'mainPid'); + const mainPost = await posts.getPostFields(mainPid, ['pid', 'deleted']); + return mainPost.pid && !mainPost.deleted ? mainPost.pid : null; + }; + + Topics.getLatestUndeletedReply = async function (tid) { + let isDeleted = false; + let index = 0; + do { + /* eslint-disable no-await-in-loop */ + const pids = await db.getSortedSetRevRange(`tid:${tid}:posts`, index, index); + if (pids.length === 0) { + return null; + } + + isDeleted = await posts.getPostField(pids[0], 'deleted'); + if (!isDeleted) { + return Number.parseInt(pids[0], 10); + } + + index += 1; + } while (isDeleted); + }; + + Topics.addPostToTopic = async function (tid, postData) { + const mainPid = await Topics.getTopicField(tid, 'mainPid'); + if (Number.parseInt(mainPid, 10)) { + const upvotes = Number.parseInt(postData.upvotes, 10) || 0; + const downvotes = Number.parseInt(postData.downvotes, 10) || 0; + const votes = upvotes - downvotes; + await db.sortedSetsAdd([ + `tid:${tid}:posts`, `tid:${tid}:posts:votes`, + ], [postData.timestamp, votes], postData.pid); + } else { + await Topics.setTopicField(tid, 'mainPid', postData.pid); + } + + await Topics.increasePostCount(tid); + await db.sortedSetIncrBy(`tid:${tid}:posters`, 1, postData.uid); + const posterCount = await db.sortedSetCard(`tid:${tid}:posters`); + await Topics.setTopicField(tid, 'postercount', posterCount); + await Topics.updateTeaser(tid); + }; + + Topics.removePostFromTopic = async function (tid, postData) { + await db.sortedSetsRemove([ + `tid:${tid}:posts`, + `tid:${tid}:posts:votes`, + ], postData.pid); + await Topics.decreasePostCount(tid); + await db.sortedSetIncrBy(`tid:${tid}:posters`, -1, postData.uid); + await db.sortedSetsRemoveRangeByScore([`tid:${tid}:posters`], '-inf', 0); + const posterCount = await db.sortedSetCard(`tid:${tid}:posters`); + await Topics.setTopicField(tid, 'postercount', posterCount); + await Topics.updateTeaser(tid); + }; + + Topics.getPids = async function (tid) { + let [mainPid, pids] = await Promise.all([ + Topics.getTopicField(tid, 'mainPid'), + db.getSortedSetRange(`tid:${tid}:posts`, 0, -1), + ]); + if (Number.parseInt(mainPid, 10)) { + pids = [mainPid].concat(pids); + } + + return pids; + }; + + Topics.increasePostCount = async function (tid) { + incrementFieldAndUpdateSortedSet(tid, 'postcount', 1, 'topics:posts'); + }; + + Topics.decreasePostCount = async function (tid) { + incrementFieldAndUpdateSortedSet(tid, 'postcount', -1, 'topics:posts'); + }; + + Topics.increaseViewCount = async function (tid) { + const cid = await Topics.getTopicField(tid, 'cid'); + incrementFieldAndUpdateSortedSet(tid, 'viewcount', 1, ['topics:views', `cid:${cid}:tids:views`]); + }; + + async function incrementFieldAndUpdateSortedSet(tid, field, by, set) { + const value = await db.incrObjectFieldBy(`topic:${tid}`, field, by); + await db[Array.isArray(set) ? 'sortedSetsAdd' : 'sortedSetAdd'](set, value, tid); + } + + Topics.getTitleByPid = async function (pid) { + return await Topics.getTopicFieldByPid('title', pid); + }; + + Topics.getTopicFieldByPid = async function (field, pid) { + const tid = await posts.getPostField(pid, 'tid'); + return await Topics.getTopicField(tid, field); + }; + + Topics.getTopicDataByPid = async function (pid) { + const tid = await posts.getPostField(pid, 'tid'); + return await Topics.getTopicData(tid); + }; + + Topics.getPostCount = async function (tid) { + return await db.getObjectField(`topic:${tid}`, 'postcount'); + }; + + async function getPostReplies(pids, callerUid) { + const keys = pids.map(pid => `pid:${pid}:replies`); + const arrayOfReplyPids = await db.getSortedSetsMembers(keys); + + const uniquePids = _.uniq(arrayOfReplyPids.flat()); + + let replyData = await posts.getPostsFields(uniquePids, ['pid', 'uid', 'timestamp']); + const result = await plugins.hooks.fire('filter:topics.getPostReplies', { + uid: callerUid, + replies: replyData, + }); + replyData = await user.blocks.filter(callerUid, result.replies); + + const uids = replyData.map(replyData => replyData && replyData.uid); + + const uniqueUids = _.uniq(uids); + + const userData = await user.getUsersWithFields(uniqueUids, ['uid', 'username', 'userslug', 'picture'], callerUid); + + const uidMap = _.zipObject(uniqueUids, userData); + const pidMap = _.zipObject(replyData.map(r => r.pid), replyData); + + const returnData = arrayOfReplyPids.map(replyPids => { + replyPids = replyPids.filter(pid => pidMap[pid]); + const uidsUsed = {}; + const currentData = { + hasMore: false, + users: [], + text: replyPids.length > 1 ? `[[topic:replies_to_this_post, ${replyPids.length}]]` : '[[topic:one_reply_to_this_post]]', + count: replyPids.length, + timestampISO: replyPids.length > 0 ? utils.toISOString(pidMap[replyPids[0]].timestamp) : undefined, + }; + + replyPids.sort((a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10)); + + for (const replyPid of replyPids) { + const replyData = pidMap[replyPid]; + if (!uidsUsed[replyData.uid] && currentData.users.length < 6) { + currentData.users.push(uidMap[replyData.uid]); + uidsUsed[replyData.uid] = true; + } + } + + if (currentData.users.length > 5) { + currentData.users.pop(); + currentData.hasMore = true; + } + + return currentData; + }); + + return returnData; + } + + Topics.syncBacklinks = async postData => { + if (!postData) { + throw new Error('[[error:invalid-data]]'); + } + + // Scan post content for topic links + const matches = [...postData.content.matchAll(backlinkRegex)]; + if (!matches) { + return 0; + } + + const {pid, uid, tid} = postData; + let add = _.uniq(matches.map(match => match[1]).map(tid => Number.parseInt(tid, 10))); + + const now = Date.now(); + const topicsExist = await Topics.exists(add); + const current = (await db.getSortedSetMembers(`pid:${pid}:backlinks`)).map(tid => Number.parseInt(tid, 10)); + const remove = current.filter(tid => !add.includes(tid)); + add = add.filter((_tid, index) => topicsExist[index] && !current.includes(_tid) && tid !== _tid); + + // Remove old backlinks + await db.sortedSetRemove(`pid:${pid}:backlinks`, remove); + + // Add new backlinks + await db.sortedSetAdd(`pid:${pid}:backlinks`, add.map(() => now), add); + await Promise.all(add.map(async tid => { + await Topics.events.log(tid, { + uid, + type: 'backlink', + href: `/post/${pid}`, + }); + })); + + return add.length + (current - remove); + }; }; diff --git a/src/topics/private.js b/src/topics/private.js index 9310096..ddc0ac5 100644 --- a/src/topics/private.js +++ b/src/topics/private.js @@ -1,16 +1,16 @@ 'use strict'; module.exports = function (Topics) { - Topics.private = async function (tid, uid) { - await Topics.setTopicFields(tid, { - private: 1, - privater: uid, - privatedTimestamp: Date.now(), - }); - }; + Topics.private = async function (tid, uid) { + await Topics.setTopicFields(tid, { + private: 1, + privater: uid, + privatedTimestamp: Date.now(), + }); + }; - Topics.public = async function (tid) { - await Topics.deleteTopicFields(tid, ['privater', 'privatedTimestamp']); - await Topics.setTopicField(tid, 'private', 0); - }; + Topics.public = async function (tid) { + await Topics.deleteTopicFields(tid, ['privater', 'privatedTimestamp']); + await Topics.setTopicField(tid, 'private', 0); + }; }; diff --git a/src/topics/recent.js b/src/topics/recent.js index c972fa0..b681a54 100644 --- a/src/topics/recent.js +++ b/src/topics/recent.js @@ -6,74 +6,77 @@ const plugins = require('../plugins'); const posts = require('../posts'); module.exports = function (Topics) { - const terms = { - day: 86400000, - week: 604800000, - month: 2592000000, - year: 31104000000, - }; - - Topics.getRecentTopics = async function (cid, uid, start, stop, filter) { - return await Topics.getSortedTopics({ - cids: cid, - uid: uid, - start: start, - stop: stop, - filter: filter, - sort: 'recent', - }); - }; - - /* not an orphan method, used in widget-essentials */ - Topics.getLatestTopics = async function (options) { - // uid, start, stop, term - const tids = await Topics.getLatestTidsFromSet('topics:recent', options.start, options.stop, options.term); - const topics = await Topics.getTopics(tids, options); - return { topics: topics, nextStart: options.stop + 1 }; - }; - - Topics.getLatestTidsFromSet = async function (set, start, stop, term) { - let since = terms.day; - if (terms[term]) { - since = terms[term]; - } - - const count = parseInt(stop, 10) === -1 ? stop : stop - start + 1; - return await db.getSortedSetRevRangeByScore(set, start, count, '+inf', Date.now() - since); - }; - - Topics.updateLastPostTimeFromLastPid = async function (tid) { - const pid = await Topics.getLatestUndeletedPid(tid); - if (!pid) { - return; - } - const timestamp = await posts.getPostField(pid, 'timestamp'); - if (!timestamp) { - return; - } - await Topics.updateLastPostTime(tid, timestamp); - }; - - Topics.updateLastPostTime = async function (tid, lastposttime) { - await Topics.setTopicField(tid, 'lastposttime', lastposttime); - const topicData = await Topics.getTopicFields(tid, ['cid', 'deleted', 'pinned']); - - await db.sortedSetAdd(`cid:${topicData.cid}:tids:lastposttime`, lastposttime, tid); - - await Topics.updateRecent(tid, lastposttime); - - if (!topicData.pinned) { - await db.sortedSetAdd(`cid:${topicData.cid}:tids`, lastposttime, tid); - } - }; - - Topics.updateRecent = async function (tid, timestamp) { - let data = { tid: tid, timestamp: timestamp }; - if (plugins.hooks.hasListeners('filter:topics.updateRecent')) { - data = await plugins.hooks.fire('filter:topics.updateRecent', { tid: tid, timestamp: timestamp }); - } - if (data && data.tid && data.timestamp) { - await db.sortedSetAdd('topics:recent', data.timestamp, data.tid); - } - }; + const terms = { + day: 86_400_000, + week: 604_800_000, + month: 2_592_000_000, + year: 31_104_000_000, + }; + + Topics.getRecentTopics = async function (cid, uid, start, stop, filter) { + return await Topics.getSortedTopics({ + cids: cid, + uid, + start, + stop, + filter, + sort: 'recent', + }); + }; + + /* Not an orphan method, used in widget-essentials */ + Topics.getLatestTopics = async function (options) { + // Uid, start, stop, term + const tids = await Topics.getLatestTidsFromSet('topics:recent', options.start, options.stop, options.term); + const topics = await Topics.getTopics(tids, options); + return {topics, nextStart: options.stop + 1}; + }; + + Topics.getLatestTidsFromSet = async function (set, start, stop, term) { + let since = terms.day; + if (terms[term]) { + since = terms[term]; + } + + const count = Number.parseInt(stop, 10) === -1 ? stop : stop - start + 1; + return await db.getSortedSetRevRangeByScore(set, start, count, '+inf', Date.now() - since); + }; + + Topics.updateLastPostTimeFromLastPid = async function (tid) { + const pid = await Topics.getLatestUndeletedPid(tid); + if (!pid) { + return; + } + + const timestamp = await posts.getPostField(pid, 'timestamp'); + if (!timestamp) { + return; + } + + await Topics.updateLastPostTime(tid, timestamp); + }; + + Topics.updateLastPostTime = async function (tid, lastposttime) { + await Topics.setTopicField(tid, 'lastposttime', lastposttime); + const topicData = await Topics.getTopicFields(tid, ['cid', 'deleted', 'pinned']); + + await db.sortedSetAdd(`cid:${topicData.cid}:tids:lastposttime`, lastposttime, tid); + + await Topics.updateRecent(tid, lastposttime); + + if (!topicData.pinned) { + await db.sortedSetAdd(`cid:${topicData.cid}:tids`, lastposttime, tid); + } + }; + + Topics.updateRecent = async function (tid, timestamp) { + let data = {tid, timestamp}; + if (plugins.hooks.hasListeners('filter:topics.updateRecent')) { + data = await plugins.hooks.fire('filter:topics.updateRecent', {tid, timestamp}); + } + + if (data && data.tid && data.timestamp) { + await db.sortedSetAdd('topics:recent', data.timestamp, data.tid); + } + }; }; diff --git a/src/topics/scheduled.js b/src/topics/scheduled.js index f79bf32..56f69eb 100644 --- a/src/topics/scheduled.js +++ b/src/topics/scheduled.js @@ -2,128 +2,128 @@ const _ = require('lodash'); const winston = require('winston'); -const { CronJob } = require('cron'); - +const {CronJob} = require('cron'); const db = require('../database'); const posts = require('../posts'); const socketHelpers = require('../socket.io/helpers'); -const topics = require('./index'); const user = require('../user'); +const topics = require('./index'); const Scheduled = module.exports; Scheduled.startJobs = function () { - winston.verbose('[scheduled topics] Starting jobs.'); - new CronJob('*/1 * * * *', Scheduled.handleExpired, null, true); + winston.verbose('[scheduled topics] Starting jobs.'); + new CronJob('*/1 * * * *', Scheduled.handleExpired, null, true); }; Scheduled.handleExpired = async function () { - const now = Date.now(); - const tids = await db.getSortedSetRangeByScore('topics:scheduled', 0, -1, '-inf', now); - - if (!tids.length) { - return; - } - - let topicsData = await topics.getTopicsData(tids); - // Filter deleted - topicsData = topicsData.filter(topicData => Boolean(topicData)); - const uids = _.uniq(topicsData.map(topicData => topicData.uid)).filter(uid => uid); // Filter guests topics - - // Restore first to be not filtered for being deleted - // Restoring handles "updateRecentTid" - await Promise.all([].concat( - topicsData.map(topicData => topics.restore(topicData.tid)), - topicsData.map(topicData => topics.updateLastPostTimeFromLastPid(topicData.tid)) - )); - - await Promise.all([].concat( - sendNotifications(uids, topicsData), - updateUserLastposttimes(uids, topicsData), - ...topicsData.map(topicData => unpin(topicData.tid, topicData)), - db.sortedSetsRemoveRangeByScore([`topics:scheduled`], '-inf', now) - )); + const now = Date.now(); + const tids = await db.getSortedSetRangeByScore('topics:scheduled', 0, -1, '-inf', now); + + if (tids.length === 0) { + return; + } + + let topicsData = await topics.getTopicsData(tids); + // Filter deleted + topicsData = topicsData.filter(Boolean); + const uids = _.uniq(topicsData.map(topicData => topicData.uid)).filter(Boolean); // Filter guests topics + + // Restore first to be not filtered for being deleted + // Restoring handles "updateRecentTid" + await Promise.all([].concat( + topicsData.map(topicData => topics.restore(topicData.tid)), + topicsData.map(topicData => topics.updateLastPostTimeFromLastPid(topicData.tid)), + )); + + await Promise.all([].concat( + sendNotifications(uids, topicsData), + updateUserLastposttimes(uids, topicsData), + ...topicsData.map(topicData => unpin(topicData.tid, topicData)), + db.sortedSetsRemoveRangeByScore(['topics:scheduled'], '-inf', now), + )); }; -// topics/tools.js#pin/unpin would block non-admins/mods, thus the local versions +// Topics/tools.js#pin/unpin would block non-admins/mods, thus the local versions Scheduled.pin = async function (tid, topicData) { - return Promise.all([ - topics.setTopicField(tid, 'pinned', 1), - db.sortedSetAdd(`cid:${topicData.cid}:tids:pinned`, Date.now(), tid), - db.sortedSetsRemove([ - `cid:${topicData.cid}:tids`, - `cid:${topicData.cid}:tids:posts`, - `cid:${topicData.cid}:tids:votes`, - `cid:${topicData.cid}:tids:views`, - ], tid), - ]); + return Promise.all([ + topics.setTopicField(tid, 'pinned', 1), + db.sortedSetAdd(`cid:${topicData.cid}:tids:pinned`, Date.now(), tid), + db.sortedSetsRemove([ + `cid:${topicData.cid}:tids`, + `cid:${topicData.cid}:tids:posts`, + `cid:${topicData.cid}:tids:votes`, + `cid:${topicData.cid}:tids:views`, + ], tid), + ]); }; -Scheduled.reschedule = async function ({ cid, tid, timestamp, uid }) { - await Promise.all([ - db.sortedSetsAdd([ - 'topics:scheduled', - `uid:${uid}:topics`, - 'topics:tid', - `cid:${cid}:uid:${uid}:tids`, - ], timestamp, tid), - shiftPostTimes(tid, timestamp), - ]); - return topics.updateLastPostTimeFromLastPid(tid); +Scheduled.reschedule = async function ({cid, tid, timestamp, uid}) { + await Promise.all([ + db.sortedSetsAdd([ + 'topics:scheduled', + `uid:${uid}:topics`, + 'topics:tid', + `cid:${cid}:uid:${uid}:tids`, + ], timestamp, tid), + shiftPostTimes(tid, timestamp), + ]); + return topics.updateLastPostTimeFromLastPid(tid); }; function unpin(tid, topicData) { - return [ - topics.setTopicField(tid, 'pinned', 0), - topics.deleteTopicField(tid, 'pinExpiry'), - db.sortedSetRemove(`cid:${topicData.cid}:tids:pinned`, tid), - db.sortedSetAddBulk([ - [`cid:${topicData.cid}:tids`, topicData.lastposttime, tid], - [`cid:${topicData.cid}:tids:posts`, topicData.postcount, tid], - [`cid:${topicData.cid}:tids:votes`, parseInt(topicData.votes, 10) || 0, tid], - [`cid:${topicData.cid}:tids:views`, topicData.viewcount, tid], - ]), - ]; + return [ + topics.setTopicField(tid, 'pinned', 0), + topics.deleteTopicField(tid, 'pinExpiry'), + db.sortedSetRemove(`cid:${topicData.cid}:tids:pinned`, tid), + db.sortedSetAddBulk([ + [`cid:${topicData.cid}:tids`, topicData.lastposttime, tid], + [`cid:${topicData.cid}:tids:posts`, topicData.postcount, tid], + [`cid:${topicData.cid}:tids:votes`, Number.parseInt(topicData.votes, 10) || 0, tid], + [`cid:${topicData.cid}:tids:views`, topicData.viewcount, tid], + ]), + ]; } async function sendNotifications(uids, topicsData) { - const usernames = await Promise.all(uids.map(uid => user.getUserField(uid, 'username'))); - const uidToUsername = Object.fromEntries(uids.map((uid, idx) => [uid, usernames[idx]])); - - const postsData = await posts.getPostsData(topicsData.map(({ mainPid }) => mainPid)); - postsData.forEach((postData, idx) => { - postData.user = {}; - postData.user.username = uidToUsername[postData.uid]; - postData.topic = topicsData[idx]; - }); - - return Promise.all(topicsData.map( - (t, idx) => user.notifications.sendTopicNotificationToFollowers(t.uid, t, postsData[idx]) - ).concat( - topicsData.map( - (t, idx) => socketHelpers.notifyNew(t.uid, 'newTopic', { posts: [postsData[idx]], topic: t }) - ) - )); + const usernames = await Promise.all(uids.map(uid => user.getUserField(uid, 'username'))); + const uidToUsername = Object.fromEntries(uids.map((uid, index) => [uid, usernames[index]])); + + const postsData = await posts.getPostsData(topicsData.map(({mainPid}) => mainPid)); + for (const [index, postData] of postsData.entries()) { + postData.user = {}; + postData.user.username = uidToUsername[postData.uid]; + postData.topic = topicsData[index]; + } + + return Promise.all(topicsData.map( + (t, index) => user.notifications.sendTopicNotificationToFollowers(t.uid, t, postsData[index]), + ).concat( + topicsData.map( + (t, index) => socketHelpers.notifyNew(t.uid, 'newTopic', {posts: [postsData[index]], topic: t}), + ), + )); } async function updateUserLastposttimes(uids, topicsData) { - const lastposttimes = (await user.getUsersFields(uids, ['lastposttime'])).map(u => u.lastposttime); - - let tstampByUid = {}; - topicsData.forEach((tD) => { - tstampByUid[tD.uid] = tstampByUid[tD.uid] ? tstampByUid[tD.uid].concat(tD.lastposttime) : [tD.lastposttime]; - }); - tstampByUid = Object.fromEntries( - Object.entries(tstampByUid).map(uidTimestamp => [uidTimestamp[0], Math.max(...uidTimestamp[1])]) - ); - - const uidsToUpdate = uids.filter((uid, idx) => tstampByUid[uid] > lastposttimes[idx]); - return Promise.all(uidsToUpdate.map(uid => user.setUserField(uid, 'lastposttime', tstampByUid[uid]))); + const lastposttimes = (await user.getUsersFields(uids, ['lastposttime'])).map(u => u.lastposttime); + + let tstampByUid = {}; + for (const tD of topicsData) { + tstampByUid[tD.uid] = tstampByUid[tD.uid] ? tstampByUid[tD.uid].concat(tD.lastposttime) : [tD.lastposttime]; + } + + tstampByUid = Object.fromEntries( + Object.entries(tstampByUid).map(uidTimestamp => [uidTimestamp[0], Math.max(...uidTimestamp[1])]), + ); + + const uidsToUpdate = uids.filter((uid, index) => tstampByUid[uid] > lastposttimes[index]); + return Promise.all(uidsToUpdate.map(uid => user.setUserField(uid, 'lastposttime', tstampByUid[uid]))); } async function shiftPostTimes(tid, timestamp) { - const pids = (await posts.getPidsFromSet(`tid:${tid}:posts`, 0, -1, false)); - // Leaving other related score values intact, since they reflect post order correctly, - // and it seems that's good enough - return db.setObjectBulk(pids.map((pid, idx) => [`post:${pid}`, { timestamp: timestamp + idx + 1 }])); + const pids = (await posts.getPidsFromSet(`tid:${tid}:posts`, 0, -1, false)); + // Leaving other related score values intact, since they reflect post order correctly, + // and it seems that's good enough + return db.setObjectBulk(pids.map((pid, index) => [`post:${pid}`, {timestamp: timestamp + index + 1}])); } diff --git a/src/topics/sorted.js b/src/topics/sorted.js index ff94bcb..b8f608c 100644 --- a/src/topics/sorted.js +++ b/src/topics/sorted.js @@ -2,7 +2,6 @@ 'use strict'; const _ = require('lodash'); - const db = require('../database'); const privileges = require('../privileges'); const user = require('../user'); @@ -11,210 +10,220 @@ const meta = require('../meta'); const plugins = require('../plugins'); module.exports = function (Topics) { - Topics.getSortedTopics = async function (params) { - const data = { - nextStart: 0, - topicCount: 0, - topics: [], - }; - - params.term = params.term || 'alltime'; - params.sort = params.sort || 'recent'; - params.query = params.query || {}; - if (params.hasOwnProperty('cids') && params.cids && !Array.isArray(params.cids)) { - params.cids = [params.cids]; - } - params.tags = params.tags || []; - if (params.tags && !Array.isArray(params.tags)) { - params.tags = [params.tags]; - } - data.tids = await getTids(params); - data.tids = await sortTids(data.tids, params); - data.tids = await filterTids(data.tids.slice(0, meta.config.recentMaxTopics), params); - data.topicCount = data.tids.length; - data.topics = await getTopics(data.tids, params); - data.nextStart = params.stop + 1; - return data; - }; - - async function getTids(params) { - if (plugins.hooks.hasListeners('filter:topics.getSortedTids')) { - const result = await plugins.hooks.fire('filter:topics.getSortedTids', { params: params, tids: [] }); - return result.tids; - } - let tids = []; - if (params.term !== 'alltime') { - tids = await Topics.getLatestTidsFromSet('topics:tid', 0, -1, params.term); - if (params.filter === 'watched') { - tids = await Topics.filterWatchedTids(tids, params.uid); - } - } else if (params.filter === 'watched') { - tids = await db.getSortedSetRevRange(`uid:${params.uid}:followed_tids`, 0, -1); - } else if (params.cids) { - tids = await getCidTids(params); - } else if (params.tags.length) { - tids = await getTagTids(params); - } else if (params.sort === 'old') { - tids = await db.getSortedSetRange(`topics:recent`, 0, meta.config.recentMaxTopics - 1); - } else { - tids = await db.getSortedSetRevRange(`topics:${params.sort}`, 0, meta.config.recentMaxTopics - 1); - } - - return tids; - } - - async function getTagTids(params) { - const sets = [ - params.sort === 'old' ? - 'topics:recent' : - `topics:${params.sort}`, - ...params.tags.map(tag => `tag:${tag}:topics`), - ]; - const method = params.sort === 'old' ? - 'getSortedSetIntersect' : - 'getSortedSetRevIntersect'; - return await db[method]({ - sets: sets, - start: 0, - stop: meta.config.recentMaxTopics - 1, - weights: sets.map((s, index) => (index ? 0 : 1)), - }); - } - - async function getCidTids(params) { - if (params.tags.length) { - return _.intersection(...await Promise.all(params.tags.map(async (tag) => { - const sets = params.cids.map(cid => `cid:${cid}:tag:${tag}:topics`); - return await db.getSortedSetRevRange(sets, 0, -1); - }))); - } - - const sets = []; - const pinnedSets = []; - params.cids.forEach((cid) => { - if (params.sort === 'recent' || params.sort === 'old') { - sets.push(`cid:${cid}:tids`); - } else { - sets.push(`cid:${cid}:tids${params.sort ? `:${params.sort}` : ''}`); - } - pinnedSets.push(`cid:${cid}:tids:pinned`); - }); - let pinnedTids = await db.getSortedSetRevRange(pinnedSets, 0, -1); - pinnedTids = await Topics.tools.checkPinExpiry(pinnedTids); - const method = params.sort === 'old' ? - 'getSortedSetRange' : - 'getSortedSetRevRange'; - const tids = await db[method](sets, 0, meta.config.recentMaxTopics - 1); - return pinnedTids.concat(tids); - } - - async function sortTids(tids, params) { - if (params.term === 'alltime' && !params.cids && !params.tags.length && params.filter !== 'watched' && !params.floatPinned) { - return tids; - } - const topicData = await Topics.getTopicsFields(tids, ['tid', 'lastposttime', 'upvotes', 'downvotes', 'postcount', 'pinned']); - const sortMap = { - recent: sortRecent, - old: sortOld, - posts: sortPopular, - votes: sortVotes, - views: sortViews, - }; - const sortFn = sortMap[params.sort] || sortRecent; - - if (params.floatPinned) { - floatPinned(topicData, sortFn); - } else { - topicData.sort(sortFn); - } - - return topicData.map(topic => topic && topic.tid); - } - - function floatPinned(topicData, sortFn) { - topicData.sort((a, b) => (a.pinned !== b.pinned ? b.pinned - a.pinned : sortFn(a, b))); - } - - function sortRecent(a, b) { - return b.lastposttime - a.lastposttime; - } - - function sortOld(a, b) { - return a.lastposttime - b.lastposttime; - } - - function sortVotes(a, b) { - if (a.votes !== b.votes) { - return b.votes - a.votes; - } - return b.postcount - a.postcount; - } - - function sortPopular(a, b) { - if (a.postcount !== b.postcount) { - return b.postcount - a.postcount; - } - return b.viewcount - a.viewcount; - } - - function sortViews(a, b) { - return b.viewcount - a.viewcount; - } - - async function filterTids(tids, params) { - const { filter } = params; - const { uid } = params; - - if (filter === 'new') { - tids = await Topics.filterNewTids(tids, uid); - } else if (filter === 'unreplied') { - tids = await Topics.filterUnrepliedTids(tids); - } else { - tids = await Topics.filterNotIgnoredTids(tids, uid); - } - - tids = await privileges.topics.filterTids('topics:read', tids, uid); - let topicData = await Topics.getTopicsFields(tids, ['uid', 'tid', 'cid']); - const topicCids = _.uniq(topicData.map(topic => topic.cid)).filter(Boolean); - - async function getIgnoredCids() { - if (params.cids || filter === 'watched' || meta.config.disableRecentCategoryFilter) { - return []; - } - return await categories.isIgnored(topicCids, uid); - } - const [ignoredCids, filtered] = await Promise.all([ - getIgnoredCids(), - user.blocks.filter(uid, topicData), - ]); - - const isCidIgnored = _.zipObject(topicCids, ignoredCids); - topicData = filtered; - - const cids = params.cids && params.cids.map(String); - tids = topicData.filter(t => ( - t && - t.cid && - !isCidIgnored[t.cid] && - (!cids || cids.includes(String(t.cid))) - )).map(t => t.tid); - - const result = await plugins.hooks.fire('filter:topics.filterSortedTids', { tids: tids, params: params }); - return result.tids; - } - - async function getTopics(tids, params) { - tids = tids.slice(params.start, params.stop !== -1 ? params.stop + 1 : undefined); - const topicData = await Topics.getTopicsByTids(tids, params); - Topics.calculateTopicIndices(topicData, params.start); - return topicData; - } - - Topics.calculateTopicIndices = function (topicData, start) { - topicData.forEach((topic, index) => { - if (topic) { - topic.index = start + index; - } - }); - }; + Topics.getSortedTopics = async function (parameters) { + const data = { + nextStart: 0, + topicCount: 0, + topics: [], + }; + + parameters.term = parameters.term || 'alltime'; + parameters.sort = parameters.sort || 'recent'; + parameters.query = parameters.query || {}; + if (parameters.hasOwnProperty('cids') && parameters.cids && !Array.isArray(parameters.cids)) { + parameters.cids = [parameters.cids]; + } + + parameters.tags = parameters.tags || []; + if (parameters.tags && !Array.isArray(parameters.tags)) { + parameters.tags = [parameters.tags]; + } + + data.tids = await getTids(parameters); + data.tids = await sortTids(data.tids, parameters); + data.tids = await filterTids(data.tids.slice(0, meta.config.recentMaxTopics), parameters); + data.topicCount = data.tids.length; + data.topics = await getTopics(data.tids, parameters); + data.nextStart = parameters.stop + 1; + return data; + }; + + async function getTids(parameters) { + if (plugins.hooks.hasListeners('filter:topics.getSortedTids')) { + const result = await plugins.hooks.fire('filter:topics.getSortedTids', {params: parameters, tids: []}); + return result.tids; + } + + let tids = []; + if (parameters.term !== 'alltime') { + tids = await Topics.getLatestTidsFromSet('topics:tid', 0, -1, parameters.term); + if (parameters.filter === 'watched') { + tids = await Topics.filterWatchedTids(tids, parameters.uid); + } + } else if (parameters.filter === 'watched') { + tids = await db.getSortedSetRevRange(`uid:${parameters.uid}:followed_tids`, 0, -1); + } else if (parameters.cids) { + tids = await getCidTids(parameters); + } else if (parameters.tags.length > 0) { + tids = await getTagTids(parameters); + } else if (parameters.sort === 'old') { + tids = await db.getSortedSetRange('topics:recent', 0, meta.config.recentMaxTopics - 1); + } else { + tids = await db.getSortedSetRevRange(`topics:${parameters.sort}`, 0, meta.config.recentMaxTopics - 1); + } + + return tids; + } + + async function getTagTids(parameters) { + const sets = [ + parameters.sort === 'old' + ? 'topics:recent' + : `topics:${parameters.sort}`, + ...parameters.tags.map(tag => `tag:${tag}:topics`), + ]; + const method = parameters.sort === 'old' + ? 'getSortedSetIntersect' + : 'getSortedSetRevIntersect'; + return await db[method]({ + sets, + start: 0, + stop: meta.config.recentMaxTopics - 1, + weights: sets.map((s, index) => (index ? 0 : 1)), + }); + } + + async function getCidTids(parameters) { + if (parameters.tags.length > 0) { + return _.intersection(...await Promise.all(parameters.tags.map(async tag => { + const sets = parameters.cids.map(cid => `cid:${cid}:tag:${tag}:topics`); + return await db.getSortedSetRevRange(sets, 0, -1); + }))); + } + + const sets = []; + const pinnedSets = []; + for (const cid of parameters.cids) { + if (parameters.sort === 'recent' || parameters.sort === 'old') { + sets.push(`cid:${cid}:tids`); + } else { + sets.push(`cid:${cid}:tids${parameters.sort ? `:${parameters.sort}` : ''}`); + } + + pinnedSets.push(`cid:${cid}:tids:pinned`); + } + + let pinnedTids = await db.getSortedSetRevRange(pinnedSets, 0, -1); + pinnedTids = await Topics.tools.checkPinExpiry(pinnedTids); + const method = parameters.sort === 'old' + ? 'getSortedSetRange' + : 'getSortedSetRevRange'; + const tids = await db[method](sets, 0, meta.config.recentMaxTopics - 1); + return pinnedTids.concat(tids); + } + + async function sortTids(tids, parameters) { + if (parameters.term === 'alltime' && !parameters.cids && parameters.tags.length === 0 && parameters.filter !== 'watched' && !parameters.floatPinned) { + return tids; + } + + const topicData = await Topics.getTopicsFields(tids, ['tid', 'lastposttime', 'upvotes', 'downvotes', 'postcount', 'pinned']); + const sortMap = { + recent: sortRecent, + old: sortOld, + posts: sortPopular, + votes: sortVotes, + views: sortViews, + }; + const sortFunction = sortMap[parameters.sort] || sortRecent; + + if (parameters.floatPinned) { + floatPinned(topicData, sortFunction); + } else { + topicData.sort(sortFunction); + } + + return topicData.map(topic => topic && topic.tid); + } + + function floatPinned(topicData, sortFunction) { + topicData.sort((a, b) => (a.pinned === b.pinned ? sortFunction(a, b) : b.pinned - a.pinned)); + } + + function sortRecent(a, b) { + return b.lastposttime - a.lastposttime; + } + + function sortOld(a, b) { + return a.lastposttime - b.lastposttime; + } + + function sortVotes(a, b) { + if (a.votes !== b.votes) { + return b.votes - a.votes; + } + + return b.postcount - a.postcount; + } + + function sortPopular(a, b) { + if (a.postcount !== b.postcount) { + return b.postcount - a.postcount; + } + + return b.viewcount - a.viewcount; + } + + function sortViews(a, b) { + return b.viewcount - a.viewcount; + } + + async function filterTids(tids, parameters) { + const {filter} = parameters; + const {uid} = parameters; + + if (filter === 'new') { + tids = await Topics.filterNewTids(tids, uid); + } else if (filter === 'unreplied') { + tids = await Topics.filterUnrepliedTids(tids); + } else { + tids = await Topics.filterNotIgnoredTids(tids, uid); + } + + tids = await privileges.topics.filterTids('topics:read', tids, uid); + let topicData = await Topics.getTopicsFields(tids, ['uid', 'tid', 'cid']); + const topicCids = _.uniq(topicData.map(topic => topic.cid)).filter(Boolean); + + async function getIgnoredCids() { + if (parameters.cids || filter === 'watched' || meta.config.disableRecentCategoryFilter) { + return []; + } + + return await categories.isIgnored(topicCids, uid); + } + + const [ignoredCids, filtered] = await Promise.all([ + getIgnoredCids(), + user.blocks.filter(uid, topicData), + ]); + + const isCidIgnored = _.zipObject(topicCids, ignoredCids); + topicData = filtered; + + const cids = parameters.cids && parameters.cids.map(String); + tids = topicData.filter(t => ( + t + && t.cid + && !isCidIgnored[t.cid] + && (!cids || cids.includes(String(t.cid))) + )).map(t => t.tid); + + const result = await plugins.hooks.fire('filter:topics.filterSortedTids', {tids, params: parameters}); + return result.tids; + } + + async function getTopics(tids, parameters) { + tids = tids.slice(parameters.start, parameters.stop === -1 ? undefined : parameters.stop + 1); + const topicData = await Topics.getTopicsByTids(tids, parameters); + Topics.calculateTopicIndices(topicData, parameters.start); + return topicData; + } + + Topics.calculateTopicIndices = function (topicData, start) { + for (const [index, topic] of topicData.entries()) { + if (topic) { + topic.index = start + index; + } + } + }; }; diff --git a/src/topics/suggested.js b/src/topics/suggested.js index de19931..38c89e7 100644 --- a/src/topics/suggested.js +++ b/src/topics/suggested.js @@ -2,69 +2,69 @@ 'use strict'; const _ = require('lodash'); - const db = require('../database'); const user = require('../user'); const privileges = require('../privileges'); const search = require('../search'); module.exports = function (Topics) { - Topics.getSuggestedTopics = async function (tid, uid, start, stop, cutoff = 0) { - let tids; - tid = parseInt(tid, 10); - cutoff = cutoff === 0 ? cutoff : (cutoff * 2592000000); - const [tagTids, searchTids] = await Promise.all([ - getTidsWithSameTags(tid, cutoff), - getSearchTids(tid, uid, cutoff), - ]); + Topics.getSuggestedTopics = async function (tid, uid, start, stop, cutoff = 0) { + let tids; + tid = Number.parseInt(tid, 10); + cutoff = cutoff === 0 ? cutoff : (cutoff * 2_592_000_000); + const [tagTids, searchTids] = await Promise.all([ + getTidsWithSameTags(tid, cutoff), + getSearchTids(tid, uid, cutoff), + ]); + + tids = _.uniq(tagTids.concat(searchTids)); - tids = _.uniq(tagTids.concat(searchTids)); + let categoryTids = []; + if (stop !== -1 && tids.length < stop - start + 1) { + categoryTids = await getCategoryTids(tid, cutoff); + } - let categoryTids = []; - if (stop !== -1 && tids.length < stop - start + 1) { - categoryTids = await getCategoryTids(tid, cutoff); - } - tids = _.shuffle(_.uniq(tids.concat(categoryTids))); - tids = await privileges.topics.filterTids('topics:read', tids, uid); + tids = _.shuffle(_.uniq(tids.concat(categoryTids))); + tids = await privileges.topics.filterTids('topics:read', tids, uid); - let topicData = await Topics.getTopicsByTids(tids, uid); - topicData = topicData.filter(topic => topic && topic.tid !== tid); - topicData = await user.blocks.filter(uid, topicData); - topicData = topicData.slice(start, stop !== -1 ? stop + 1 : undefined) - .sort((t1, t2) => t2.timestamp - t1.timestamp); - return topicData; - }; + let topicData = await Topics.getTopicsByTids(tids, uid); + topicData = topicData.filter(topic => topic && topic.tid !== tid); + topicData = await user.blocks.filter(uid, topicData); + topicData = topicData.slice(start, stop === -1 ? undefined : stop + 1) + .sort((t1, t2) => t2.timestamp - t1.timestamp); + return topicData; + }; - async function getTidsWithSameTags(tid, cutoff) { - const tags = await Topics.getTopicTags(tid); - let tids = cutoff === 0 ? - await db.getSortedSetRevRange(tags.map(tag => `tag:${tag}:topics`), 0, -1) : - await db.getSortedSetRevRangeByScore(tags.map(tag => `tag:${tag}:topics`), 0, -1, '+inf', Date.now() - cutoff); - tids = tids.filter(_tid => _tid !== tid); // remove self - return _.shuffle(_.uniq(tids)).slice(0, 10).map(Number); - } + async function getTidsWithSameTags(tid, cutoff) { + const tags = await Topics.getTopicTags(tid); + let tids = cutoff === 0 + ? await db.getSortedSetRevRange(tags.map(tag => `tag:${tag}:topics`), 0, -1) + : await db.getSortedSetRevRangeByScore(tags.map(tag => `tag:${tag}:topics`), 0, -1, '+inf', Date.now() - cutoff); + tids = tids.filter(_tid => _tid !== tid); // Remove self + return _.shuffle(_.uniq(tids)).slice(0, 10).map(Number); + } - async function getSearchTids(tid, uid, cutoff) { - const topicData = await Topics.getTopicFields(tid, ['title', 'cid']); - const data = await search.search({ - query: topicData.title, - searchIn: 'titles', - matchWords: 'any', - categories: [topicData.cid], - uid: uid, - returnIds: true, - timeRange: cutoff !== 0 ? cutoff / 1000 : 0, - timeFilter: 'newer', - }); - data.tids = data.tids.filter(_tid => _tid !== tid); // remove self - return _.shuffle(data.tids).slice(0, 10).map(Number); - } + async function getSearchTids(tid, uid, cutoff) { + const topicData = await Topics.getTopicFields(tid, ['title', 'cid']); + const data = await search.search({ + query: topicData.title, + searchIn: 'titles', + matchWords: 'any', + categories: [topicData.cid], + uid, + returnIds: true, + timeRange: cutoff === 0 ? 0 : cutoff / 1000, + timeFilter: 'newer', + }); + data.tids = data.tids.filter(_tid => _tid !== tid); // Remove self + return _.shuffle(data.tids).slice(0, 10).map(Number); + } - async function getCategoryTids(tid, cutoff) { - const cid = await Topics.getTopicField(tid, 'cid'); - const tids = cutoff === 0 ? - await db.getSortedSetRevRange(`cid:${cid}:tids:lastposttime`, 0, 9) : - await db.getSortedSetRevRangeByScore(`cid:${cid}:tids:lastposttime`, 0, 9, '+inf', Date.now() - cutoff); - return _.shuffle(tids.map(Number).filter(_tid => _tid !== tid)); - } + async function getCategoryTids(tid, cutoff) { + const cid = await Topics.getTopicField(tid, 'cid'); + const tids = cutoff === 0 + ? await db.getSortedSetRevRange(`cid:${cid}:tids:lastposttime`, 0, 9) + : await db.getSortedSetRevRangeByScore(`cid:${cid}:tids:lastposttime`, 0, 9, '+inf', Date.now() - cutoff); + return _.shuffle(tids.map(Number).filter(_tid => _tid !== tid)); + } }; diff --git a/src/topics/tags.js b/src/topics/tags.js index d078082..1569db9 100644 --- a/src/topics/tags.js +++ b/src/topics/tags.js @@ -4,7 +4,6 @@ const async = require('async'); const validator = require('validator'); const _ = require('lodash'); - const db = require('../database'); const meta = require('../meta'); const user = require('../user'); @@ -15,514 +14,536 @@ const batch = require('../batch'); const cache = require('../cache'); module.exports = function (Topics) { - Topics.createTags = async function (tags, tid, timestamp) { - if (!Array.isArray(tags) || !tags.length) { - return; - } - - const cid = await Topics.getTopicField(tid, 'cid'); - const topicSets = tags.map(tag => `tag:${tag}:topics`).concat( - tags.map(tag => `cid:${cid}:tag:${tag}:topics`) - ); - await db.sortedSetsAdd(topicSets, timestamp, tid); - await Topics.updateCategoryTagsCount([cid], tags); - await Promise.all(tags.map(updateTagCount)); - }; - - Topics.filterTags = async function (tags, cid) { - const result = await plugins.hooks.fire('filter:tags.filter', { tags: tags, cid: cid }); - tags = _.uniq(result.tags) - .map(tag => utils.cleanUpTag(tag, meta.config.maximumTagLength)) - .filter(tag => tag && tag.length >= (meta.config.minimumTagLength || 3)); - - return await filterCategoryTags(tags, cid); - }; - - Topics.updateCategoryTagsCount = async function (cids, tags) { - await Promise.all(cids.map(async (cid) => { - const counts = await db.sortedSetsCard( - tags.map(tag => `cid:${cid}:tag:${tag}:topics`) - ); - const tagToCount = _.zipObject(tags, counts); - const set = `cid:${cid}:tags`; - - const bulkAdd = tags.filter(tag => tagToCount[tag] > 0) - .map(tag => [set, tagToCount[tag], tag]); - - const bulkRemove = tags.filter(tag => tagToCount[tag] <= 0) - .map(tag => [set, tag]); - - await Promise.all([ - db.sortedSetAddBulk(bulkAdd), - db.sortedSetRemoveBulk(bulkRemove), - ]); - })); - - await db.sortedSetsRemoveRangeByScore( - cids.map(cid => `cid:${cid}:tags`), '-inf', 0 - ); - }; - - Topics.validateTags = async function (tags, cid, uid, tid = null) { - if (!Array.isArray(tags)) { - throw new Error('[[error:invalid-data]]'); - } - tags = _.uniq(tags); - const [categoryData, isPrivileged, currentTags] = await Promise.all([ - categories.getCategoryFields(cid, ['minTags', 'maxTags']), - user.isPrivileged(uid), - tid ? Topics.getTopicTags(tid) : [], - ]); - if (tags.length < parseInt(categoryData.minTags, 10)) { - throw new Error(`[[error:not-enough-tags, ${categoryData.minTags}]]`); - } else if (tags.length > parseInt(categoryData.maxTags, 10)) { - throw new Error(`[[error:too-many-tags, ${categoryData.maxTags}]]`); - } - - const addedTags = tags.filter(tag => !currentTags.includes(tag)); - const removedTags = currentTags.filter(tag => !tags.includes(tag)); - const systemTags = (meta.config.systemTags || '').split(','); - - if (!isPrivileged && systemTags.length && - addedTags.length && addedTags.some(tag => systemTags.includes(tag))) { - throw new Error('[[error:cant-use-system-tag]]'); - } - - if (!isPrivileged && systemTags.length && - removedTags.length && removedTags.some(tag => systemTags.includes(tag))) { - throw new Error('[[error:cant-remove-system-tag]]'); - } - }; - - async function filterCategoryTags(tags, cid) { - const tagWhitelist = await categories.getTagWhitelist([cid]); - if (!Array.isArray(tagWhitelist[0]) || !tagWhitelist[0].length) { - return tags; - } - const whitelistSet = new Set(tagWhitelist[0]); - return tags.filter(tag => whitelistSet.has(tag)); - } - - Topics.createEmptyTag = async function (tag) { - if (!tag) { - throw new Error('[[error:invalid-tag]]'); - } - if (tag.length < (meta.config.minimumTagLength || 3)) { - throw new Error('[[error:tag-too-short]]'); - } - const isMember = await db.isSortedSetMember('tags:topic:count', tag); - if (!isMember) { - await db.sortedSetAdd('tags:topic:count', 0, tag); - cache.del('tags:topic:count'); - } - const allCids = await categories.getAllCidsFromSet('categories:cid'); - const isMembers = await db.isMemberOfSortedSets( - allCids.map(cid => `cid:${cid}:tags`), tag - ); - const bulkAdd = allCids.filter((cid, index) => !isMembers[index]) - .map(cid => ([`cid:${cid}:tags`, 0, tag])); - await db.sortedSetAddBulk(bulkAdd); - }; - - Topics.renameTags = async function (data) { - for (const tagData of data) { - // eslint-disable-next-line no-await-in-loop - await renameTag(tagData.value, tagData.newName); - } - }; - - async function renameTag(tag, newTagName) { - if (!newTagName || tag === newTagName) { - return; - } - newTagName = utils.cleanUpTag(newTagName, meta.config.maximumTagLength); - - await Topics.createEmptyTag(newTagName); - const allCids = {}; - - await batch.processSortedSet(`tag:${tag}:topics`, async (tids) => { - const topicData = await Topics.getTopicsFields(tids, ['tid', 'cid', 'tags']); - const cids = topicData.map(t => t.cid); - topicData.forEach((t) => { allCids[t.cid] = true; }); - const scores = await db.sortedSetScores(`tag:${tag}:topics`, tids); - // update tag::topics - await db.sortedSetAdd(`tag:${newTagName}:topics`, scores, tids); - await db.sortedSetRemove(`tag:${tag}:topics`, tids); - - // update cid::tag::topics - await db.sortedSetAddBulk(topicData.map( - (t, index) => [`cid:${t.cid}:tag:${newTagName}:topics`, scores[index], t.tid] - )); - await db.sortedSetRemove(cids.map(cid => `cid:${cid}:tag:${tag}:topics`), tids); - - // update 'tags' field in topic hash - topicData.forEach((topic) => { - topic.tags = topic.tags.map(tagItem => tagItem.value); - const index = topic.tags.indexOf(tag); - if (index !== -1) { - topic.tags.splice(index, 1, newTagName); - } - }); - await db.setObjectBulk( - topicData.map(t => [`topic:${t.tid}`, { tags: t.tags.join(',') }]), - ); - }, {}); - await Topics.deleteTag(tag); - await updateTagCount(newTagName); - await Topics.updateCategoryTagsCount(Object.keys(allCids), [newTagName]); - } - - async function updateTagCount(tag) { - const count = await Topics.getTagTopicCount(tag); - await db.sortedSetAdd('tags:topic:count', count || 0, tag); - cache.del('tags:topic:count'); - } - - Topics.getTagTids = async function (tag, start, stop) { - const tids = await db.getSortedSetRevRange(`tag:${tag}:topics`, start, stop); - const payload = await plugins.hooks.fire('filter:topics.getTagTids', { tag, start, stop, tids }); - return payload.tids; - }; - - Topics.getTagTidsByCids = async function (tag, cids, start, stop) { - const keys = cids.map(cid => `cid:${cid}:tag:${tag}:topics`); - const tids = await db.getSortedSetRevRange(keys, start, stop); - const payload = await plugins.hooks.fire('filter:topics.getTagTidsByCids', { tag, cids, start, stop, tids }); - return payload.tids; - }; - - Topics.getTagTopicCount = async function (tag, cids = []) { - let count = 0; - if (cids.length) { - count = await db.sortedSetsCardSum( - cids.map(cid => `cid:${cid}:tag:${tag}:topics`) - ); - } else { - count = await db.sortedSetCard(`tag:${tag}:topics`); - } - - const payload = await plugins.hooks.fire('filter:topics.getTagTopicCount', { tag, count, cids }); - return payload.count; - }; - - Topics.deleteTags = async function (tags) { - if (!Array.isArray(tags) || !tags.length) { - return; - } - await removeTagsFromTopics(tags); - const keys = tags.map(tag => `tag:${tag}:topics`); - await db.deleteAll(keys); - await db.sortedSetRemove('tags:topic:count', tags); - cache.del('tags:topic:count'); - const cids = await categories.getAllCidsFromSet('categories:cid'); - - await db.sortedSetRemove(cids.map(cid => `cid:${cid}:tags`), tags); - - const deleteKeys = []; - tags.forEach((tag) => { - deleteKeys.push(`tag:${tag}`); - cids.forEach((cid) => { - deleteKeys.push(`cid:${cid}:tag:${tag}:topics`); - }); - }); - await db.deleteAll(deleteKeys); - }; - - async function removeTagsFromTopics(tags) { - await async.eachLimit(tags, 50, async (tag) => { - const tids = await db.getSortedSetRange(`tag:${tag}:topics`, 0, -1); - if (!tids.length) { - return; - } - - await db.deleteObjectFields( - tids.map(tid => `topic:${tid}`), - ['tags'], - ); - }); - } - - Topics.deleteTag = async function (tag) { - await Topics.deleteTags([tag]); - }; - - Topics.getTags = async function (start, stop) { - return await getFromSet('tags:topic:count', start, stop); - }; - - Topics.getCategoryTags = async function (cids, start, stop) { - if (Array.isArray(cids)) { - return await db.getSortedSetRevUnion({ - sets: cids.map(cid => `cid:${cid}:tags`), - start, - stop, - }); - } - return await db.getSortedSetRevRange(`cid:${cids}:tags`, start, stop); - }; - - Topics.getCategoryTagsData = async function (cids, start, stop) { - return await getFromSet( - Array.isArray(cids) ? cids.map(cid => `cid:${cid}:tags`) : `cid:${cids}:tags`, - start, - stop - ); - }; - - async function getFromSet(set, start, stop) { - let tags; - if (Array.isArray(set)) { - tags = await db.getSortedSetRevUnion({ - sets: set, - start, - stop, - withScores: true, - }); - } else { - tags = await db.getSortedSetRevRangeWithScores(set, start, stop); - } - - const payload = await plugins.hooks.fire('filter:tags.getAll', { - tags: tags, - }); - return await Topics.getTagData(payload.tags); - } - - Topics.getTagData = async function (tags) { - if (!tags.length) { - return []; - } - tags.forEach((tag) => { - tag.valueEscaped = validator.escape(String(tag.value)); - tag.valueEncoded = encodeURIComponent(tag.valueEscaped); - tag.class = tag.valueEscaped.replace(/\s/g, '-'); - }); - return tags; - }; - - Topics.getTopicTags = async function (tid) { - const data = await Topics.getTopicsTags([tid]); - return data && data[0]; - }; - - Topics.getTopicsTags = async function (tids) { - const topicTagData = await Topics.getTopicsFields(tids, ['tags']); - return tids.map((tid, i) => topicTagData[i].tags.map(tagData => tagData.value)); - }; - - Topics.getTopicTagsObjects = async function (tid) { - const data = await Topics.getTopicsTagsObjects([tid]); - return Array.isArray(data) && data.length ? data[0] : []; - }; - - Topics.getTopicsTagsObjects = async function (tids) { - const topicTags = await Topics.getTopicsTags(tids); - const uniqueTopicTags = _.uniq(_.flatten(topicTags)); - - const tags = uniqueTopicTags.map(tag => ({ value: tag })); - const tagData = await Topics.getTagData(tags); - const tagDataMap = _.zipObject(uniqueTopicTags, tagData); - - topicTags.forEach((tags, index) => { - if (Array.isArray(tags)) { - topicTags[index] = tags.map(tag => tagDataMap[tag]); - } - }); - - return topicTags; - }; - - Topics.addTags = async function (tags, tids) { - const topicData = await Topics.getTopicsFields(tids, ['tid', 'cid', 'timestamp', 'tags']); - const bulkAdd = []; - const bulkSet = []; - topicData.forEach((t) => { - const topicTags = t.tags.map(tagItem => tagItem.value); - tags.forEach((tag) => { - bulkAdd.push([`tag:${tag}:topics`, t.timestamp, t.tid]); - bulkAdd.push([`cid:${t.cid}:tag:${tag}:topics`, t.timestamp, t.tid]); - if (!topicTags.includes(tag)) { - topicTags.push(tag); - } - }); - bulkSet.push([`topic:${t.tid}`, { tags: topicTags.join(',') }]); - }); - await Promise.all([ - db.sortedSetAddBulk(bulkAdd), - db.setObjectBulk(bulkSet), - ]); - - await Promise.all(tags.map(updateTagCount)); - await Topics.updateCategoryTagsCount(_.uniq(topicData.map(t => t.cid)), tags); - }; - - Topics.removeTags = async function (tags, tids) { - const topicData = await Topics.getTopicsFields(tids, ['tid', 'cid', 'tags']); - const bulkRemove = []; - const bulkSet = []; - - topicData.forEach((t) => { - const topicTags = t.tags.map(tagItem => tagItem.value); - tags.forEach((tag) => { - bulkRemove.push([`tag:${tag}:topics`, t.tid]); - bulkRemove.push([`cid:${t.cid}:tag:${tag}:topics`, t.tid]); - if (topicTags.includes(tag)) { - topicTags.splice(topicTags.indexOf(tag), 1); - } - }); - bulkSet.push([`topic:${t.tid}`, { tags: topicTags.join(',') }]); - }); - await Promise.all([ - db.sortedSetRemoveBulk(bulkRemove), - db.setObjectBulk(bulkSet), - ]); - - await Promise.all(tags.map(updateTagCount)); - await Topics.updateCategoryTagsCount(_.uniq(topicData.map(t => t.cid)), tags); - }; - - Topics.updateTopicTags = async function (tid, tags) { - await Topics.deleteTopicTags(tid); - const cid = await Topics.getTopicField(tid, 'cid'); - - tags = await Topics.filterTags(tags, cid); - await Topics.addTags(tags, [tid]); - }; - - Topics.deleteTopicTags = async function (tid) { - const topicData = await Topics.getTopicFields(tid, ['cid', 'tags']); - const { cid } = topicData; - const tags = topicData.tags.map(tagItem => tagItem.value); - await db.deleteObjectField(`topic:${tid}`, 'tags'); - - const sets = tags.map(tag => `tag:${tag}:topics`) - .concat(tags.map(tag => `cid:${cid}:tag:${tag}:topics`)); - await db.sortedSetsRemove(sets, tid); - - await Topics.updateCategoryTagsCount([cid], tags); - await Promise.all(tags.map(updateTagCount)); - }; - - Topics.searchTags = async function (data) { - if (!data || !data.query) { - return []; - } - let result; - if (plugins.hooks.hasListeners('filter:topics.searchTags')) { - result = await plugins.hooks.fire('filter:topics.searchTags', { data: data }); - } else { - result = await findMatches(data); - } - result = await plugins.hooks.fire('filter:tags.search', { data: data, matches: result.matches }); - return result.matches; - }; - - Topics.autocompleteTags = async function (data) { - if (!data || !data.query) { - return []; - } - let result; - if (plugins.hooks.hasListeners('filter:topics.autocompleteTags')) { - result = await plugins.hooks.fire('filter:topics.autocompleteTags', { data: data }); - } else { - result = await findMatches(data); - } - return result.matches; - }; - - async function getAllTags() { - const cached = cache.get('tags:topic:count'); - if (cached !== undefined) { - return cached; - } - const tags = await db.getSortedSetRevRangeWithScores('tags:topic:count', 0, -1); - cache.set('tags:topic:count', tags); - return tags; - } - - async function findMatches(data) { - let { query } = data; - let tagWhitelist = []; - if (parseInt(data.cid, 10)) { - tagWhitelist = await categories.getTagWhitelist([data.cid]); - } - let tags = []; - if (Array.isArray(tagWhitelist[0]) && tagWhitelist[0].length) { - const scores = await db.sortedSetScores(`cid:${data.cid}:tags`, tagWhitelist[0]); - tags = tagWhitelist[0].map((tag, index) => ({ value: tag, score: scores[index] })); - } else if (data.cids) { - tags = await db.getSortedSetRevUnion({ - sets: data.cids.map(cid => `cid:${cid}:tags`), - start: 0, - stop: -1, - withScores: true, - }); - } else { - tags = await getAllTags(); - } - - query = query.toLowerCase(); - - const matches = []; - for (let i = 0; i < tags.length; i += 1) { - if (tags[i].value && tags[i].value.toLowerCase().startsWith(query)) { - matches.push(tags[i]); - if (matches.length > 39) { - break; - } - } - } - - matches.sort((a, b) => { - if (a.value < b.value) { - return -1; - } else if (a.value > b.value) { - return 1; - } - return 0; - }); - return { matches: matches }; - } - - Topics.searchAndLoadTags = async function (data) { - const searchResult = { - tags: [], - matchCount: 0, - pageCount: 1, - }; - - if (!data || !data.query || !data.query.length) { - return searchResult; - } - const tags = await Topics.searchTags(data); - - const tagData = await Topics.getTagData(tags.map(tag => ({ value: tag.value }))); - - tagData.forEach((tag, index) => { - tag.score = tags[index].score; - }); - tagData.sort((a, b) => b.score - a.score); - searchResult.tags = tagData; - searchResult.matchCount = tagData.length; - searchResult.pageCount = 1; - return searchResult; - }; - - Topics.getRelatedTopics = async function (topicData, uid) { - if (plugins.hooks.hasListeners('filter:topic.getRelatedTopics')) { - const result = await plugins.hooks.fire('filter:topic.getRelatedTopics', { topic: topicData, uid: uid, topics: [] }); - return result.topics; - } - - let maximumTopics = meta.config.maximumRelatedTopics; - if (maximumTopics === 0 || !topicData.tags || !topicData.tags.length) { - return []; - } - - maximumTopics = maximumTopics || 5; - let tids = await Promise.all(topicData.tags.map(tag => Topics.getTagTids(tag.value, 0, 5))); - tids = _.shuffle(_.uniq(_.flatten(tids))).slice(0, maximumTopics); - const topics = await Topics.getTopics(tids, uid); - return topics.filter(t => t && !t.deleted && parseInt(t.uid, 10) !== parseInt(uid, 10)); - }; + Topics.createTags = async function (tags, tid, timestamp) { + if (!Array.isArray(tags) || tags.length === 0) { + return; + } + + const cid = await Topics.getTopicField(tid, 'cid'); + const topicSets = tags.map(tag => `tag:${tag}:topics`).concat( + tags.map(tag => `cid:${cid}:tag:${tag}:topics`), + ); + await db.sortedSetsAdd(topicSets, timestamp, tid); + await Topics.updateCategoryTagsCount([cid], tags); + await Promise.all(tags.map(updateTagCount)); + }; + + Topics.filterTags = async function (tags, cid) { + const result = await plugins.hooks.fire('filter:tags.filter', {tags, cid}); + tags = _.uniq(result.tags) + .map(tag => utils.cleanUpTag(tag, meta.config.maximumTagLength)) + .filter(tag => tag && tag.length >= (meta.config.minimumTagLength || 3)); + + return await filterCategoryTags(tags, cid); + }; + + Topics.updateCategoryTagsCount = async function (cids, tags) { + await Promise.all(cids.map(async cid => { + const counts = await db.sortedSetsCard( + tags.map(tag => `cid:${cid}:tag:${tag}:topics`), + ); + const tagToCount = _.zipObject(tags, counts); + const set = `cid:${cid}:tags`; + + const bulkAdd = tags.filter(tag => tagToCount[tag] > 0) + .map(tag => [set, tagToCount[tag], tag]); + + const bulkRemove = tags.filter(tag => tagToCount[tag] <= 0) + .map(tag => [set, tag]); + + await Promise.all([ + db.sortedSetAddBulk(bulkAdd), + db.sortedSetRemoveBulk(bulkRemove), + ]); + })); + + await db.sortedSetsRemoveRangeByScore( + cids.map(cid => `cid:${cid}:tags`), '-inf', 0, + ); + }; + + Topics.validateTags = async function (tags, cid, uid, tid = null) { + if (!Array.isArray(tags)) { + throw new TypeError('[[error:invalid-data]]'); + } + + tags = _.uniq(tags); + const [categoryData, isPrivileged, currentTags] = await Promise.all([ + categories.getCategoryFields(cid, ['minTags', 'maxTags']), + user.isPrivileged(uid), + tid ? Topics.getTopicTags(tid) : [], + ]); + if (tags.length < Number.parseInt(categoryData.minTags, 10)) { + throw new Error(`[[error:not-enough-tags, ${categoryData.minTags}]]`); + } else if (tags.length > Number.parseInt(categoryData.maxTags, 10)) { + throw new Error(`[[error:too-many-tags, ${categoryData.maxTags}]]`); + } + + const addedTags = tags.filter(tag => !currentTags.includes(tag)); + const removedTags = currentTags.filter(tag => !tags.includes(tag)); + const systemTags = (meta.config.systemTags || '').split(','); + + if (!isPrivileged && systemTags.length > 0 && addedTags.some(tag => systemTags.includes(tag))) { + throw new Error('[[error:cant-use-system-tag]]'); + } + + if (!isPrivileged && systemTags.length > 0 && removedTags.some(tag => systemTags.includes(tag))) { + throw new Error('[[error:cant-remove-system-tag]]'); + } + }; + + async function filterCategoryTags(tags, cid) { + const tagInclude = await categories.getTagWhitelist([cid]); + if (!Array.isArray(tagInclude[0]) || tagInclude[0].length === 0) { + return tags; + } + + const includeSet = new Set(tagInclude[0]); + return tags.filter(tag => includeSet.has(tag)); + } + + Topics.createEmptyTag = async function (tag) { + if (!tag) { + throw new Error('[[error:invalid-tag]]'); + } + + if (tag.length < (meta.config.minimumTagLength || 3)) { + throw new Error('[[error:tag-too-short]]'); + } + + const isMember = await db.isSortedSetMember('tags:topic:count', tag); + if (!isMember) { + await db.sortedSetAdd('tags:topic:count', 0, tag); + cache.del('tags:topic:count'); + } + + const allCids = await categories.getAllCidsFromSet('categories:cid'); + const isMembers = await db.isMemberOfSortedSets( + allCids.map(cid => `cid:${cid}:tags`), tag, + ); + const bulkAdd = allCids.filter((cid, index) => !isMembers[index]) + .map(cid => ([`cid:${cid}:tags`, 0, tag])); + await db.sortedSetAddBulk(bulkAdd); + }; + + Topics.renameTags = async function (data) { + for (const tagData of data) { + // eslint-disable-next-line no-await-in-loop + await renameTag(tagData.value, tagData.newName); + } + }; + + async function renameTag(tag, newTagName) { + if (!newTagName || tag === newTagName) { + return; + } + + newTagName = utils.cleanUpTag(newTagName, meta.config.maximumTagLength); + + await Topics.createEmptyTag(newTagName); + const allCids = {}; + + await batch.processSortedSet(`tag:${tag}:topics`, async tids => { + const topicData = await Topics.getTopicsFields(tids, ['tid', 'cid', 'tags']); + const cids = topicData.map(t => t.cid); + for (const t of topicData) { + allCids[t.cid] = true; + } + + const scores = await db.sortedSetScores(`tag:${tag}:topics`, tids); + // Update tag::topics + await db.sortedSetAdd(`tag:${newTagName}:topics`, scores, tids); + await db.sortedSetRemove(`tag:${tag}:topics`, tids); + + // Update cid::tag::topics + await db.sortedSetAddBulk(topicData.map( + (t, index) => [`cid:${t.cid}:tag:${newTagName}:topics`, scores[index], t.tid], + )); + await db.sortedSetRemove(cids.map(cid => `cid:${cid}:tag:${tag}:topics`), tids); + + // Update 'tags' field in topic hash + for (const topic of topicData) { + topic.tags = topic.tags.map(tagItem => tagItem.value); + const index = topic.tags.indexOf(tag); + if (index !== -1) { + topic.tags.splice(index, 1, newTagName); + } + } + + await db.setObjectBulk( + topicData.map(t => [`topic:${t.tid}`, {tags: t.tags.join(',')}]), + ); + }, {}); + await Topics.deleteTag(tag); + await updateTagCount(newTagName); + await Topics.updateCategoryTagsCount(Object.keys(allCids), [newTagName]); + } + + async function updateTagCount(tag) { + const count = await Topics.getTagTopicCount(tag); + await db.sortedSetAdd('tags:topic:count', count || 0, tag); + cache.del('tags:topic:count'); + } + + Topics.getTagTids = async function (tag, start, stop) { + const tids = await db.getSortedSetRevRange(`tag:${tag}:topics`, start, stop); + const payload = await plugins.hooks.fire('filter:topics.getTagTids', { + tag, start, stop, tids, + }); + return payload.tids; + }; + + Topics.getTagTidsByCids = async function (tag, cids, start, stop) { + const keys = cids.map(cid => `cid:${cid}:tag:${tag}:topics`); + const tids = await db.getSortedSetRevRange(keys, start, stop); + const payload = await plugins.hooks.fire('filter:topics.getTagTidsByCids', { + tag, cids, start, stop, tids, + }); + return payload.tids; + }; + + Topics.getTagTopicCount = async function (tag, cids = []) { + let count = 0; + if (cids.length > 0) { + count = await db.sortedSetsCardSum( + cids.map(cid => `cid:${cid}:tag:${tag}:topics`), + ); + } else { + count = await db.sortedSetCard(`tag:${tag}:topics`); + } + + const payload = await plugins.hooks.fire('filter:topics.getTagTopicCount', {tag, count, cids}); + return payload.count; + }; + + Topics.deleteTags = async function (tags) { + if (!Array.isArray(tags) || tags.length === 0) { + return; + } + + await removeTagsFromTopics(tags); + const keys = tags.map(tag => `tag:${tag}:topics`); + await db.deleteAll(keys); + await db.sortedSetRemove('tags:topic:count', tags); + cache.del('tags:topic:count'); + const cids = await categories.getAllCidsFromSet('categories:cid'); + + await db.sortedSetRemove(cids.map(cid => `cid:${cid}:tags`), tags); + + const deleteKeys = []; + for (const tag of tags) { + deleteKeys.push(`tag:${tag}`); + for (const cid of cids) { + deleteKeys.push(`cid:${cid}:tag:${tag}:topics`); + } + } + + await db.deleteAll(deleteKeys); + }; + + async function removeTagsFromTopics(tags) { + await async.eachLimit(tags, 50, async tag => { + const tids = await db.getSortedSetRange(`tag:${tag}:topics`, 0, -1); + if (tids.length === 0) { + return; + } + + await db.deleteObjectFields( + tids.map(tid => `topic:${tid}`), + ['tags'], + ); + }); + } + + Topics.deleteTag = async function (tag) { + await Topics.deleteTags([tag]); + }; + + Topics.getTags = async function (start, stop) { + return await getFromSet('tags:topic:count', start, stop); + }; + + Topics.getCategoryTags = async function (cids, start, stop) { + if (Array.isArray(cids)) { + return await db.getSortedSetRevUnion({ + sets: cids.map(cid => `cid:${cid}:tags`), + start, + stop, + }); + } + + return await db.getSortedSetRevRange(`cid:${cids}:tags`, start, stop); + }; + + Topics.getCategoryTagsData = async function (cids, start, stop) { + return await getFromSet( + Array.isArray(cids) ? cids.map(cid => `cid:${cid}:tags`) : `cid:${cids}:tags`, + start, + stop, + ); + }; + + async function getFromSet(set, start, stop) { + let tags; + if (Array.isArray(set)) { + tags = await db.getSortedSetRevUnion({ + sets: set, + start, + stop, + withScores: true, + }); + } else { + tags = await db.getSortedSetRevRangeWithScores(set, start, stop); + } + + const payload = await plugins.hooks.fire('filter:tags.getAll', { + tags, + }); + return await Topics.getTagData(payload.tags); + } + + Topics.getTagData = async function (tags) { + if (tags.length === 0) { + return []; + } + + for (const tag of tags) { + tag.valueEscaped = validator.escape(String(tag.value)); + tag.valueEncoded = encodeURIComponent(tag.valueEscaped); + tag.class = tag.valueEscaped.replaceAll(/\s/g, '-'); + } + + return tags; + }; + + Topics.getTopicTags = async function (tid) { + const data = await Topics.getTopicsTags([tid]); + return data && data[0]; + }; + + Topics.getTopicsTags = async function (tids) { + const topicTagData = await Topics.getTopicsFields(tids, ['tags']); + return tids.map((tid, i) => topicTagData[i].tags.map(tagData => tagData.value)); + }; + + Topics.getTopicTagsObjects = async function (tid) { + const data = await Topics.getTopicsTagsObjects([tid]); + return Array.isArray(data) && data.length > 0 ? data[0] : []; + }; + + Topics.getTopicsTagsObjects = async function (tids) { + const topicTags = await Topics.getTopicsTags(tids); + const uniqueTopicTags = _.uniq(topicTags.flat()); + + const tags = uniqueTopicTags.map(tag => ({value: tag})); + const tagData = await Topics.getTagData(tags); + const tagDataMap = _.zipObject(uniqueTopicTags, tagData); + + for (const [index, tags] of topicTags.entries()) { + if (Array.isArray(tags)) { + topicTags[index] = tags.map(tag => tagDataMap[tag]); + } + } + + return topicTags; + }; + + Topics.addTags = async function (tags, tids) { + const topicData = await Topics.getTopicsFields(tids, ['tid', 'cid', 'timestamp', 'tags']); + const bulkAdd = []; + const bulkSet = []; + for (const t of topicData) { + const topicTags = t.tags.map(tagItem => tagItem.value); + for (const tag of tags) { + bulkAdd.push([`tag:${tag}:topics`, t.timestamp, t.tid], [`cid:${t.cid}:tag:${tag}:topics`, t.timestamp, t.tid]); + if (!topicTags.includes(tag)) { + topicTags.push(tag); + } + } + + bulkSet.push([`topic:${t.tid}`, {tags: topicTags.join(',')}]); + } + + await Promise.all([ + db.sortedSetAddBulk(bulkAdd), + db.setObjectBulk(bulkSet), + ]); + + await Promise.all(tags.map(updateTagCount)); + await Topics.updateCategoryTagsCount(_.uniq(topicData.map(t => t.cid)), tags); + }; + + Topics.removeTags = async function (tags, tids) { + const topicData = await Topics.getTopicsFields(tids, ['tid', 'cid', 'tags']); + const bulkRemove = []; + const bulkSet = []; + + for (const t of topicData) { + const topicTags = t.tags.map(tagItem => tagItem.value); + for (const tag of tags) { + bulkRemove.push([`tag:${tag}:topics`, t.tid], [`cid:${t.cid}:tag:${tag}:topics`, t.tid]); + if (topicTags.includes(tag)) { + topicTags.splice(topicTags.indexOf(tag), 1); + } + } + + bulkSet.push([`topic:${t.tid}`, {tags: topicTags.join(',')}]); + } + + await Promise.all([ + db.sortedSetRemoveBulk(bulkRemove), + db.setObjectBulk(bulkSet), + ]); + + await Promise.all(tags.map(updateTagCount)); + await Topics.updateCategoryTagsCount(_.uniq(topicData.map(t => t.cid)), tags); + }; + + Topics.updateTopicTags = async function (tid, tags) { + await Topics.deleteTopicTags(tid); + const cid = await Topics.getTopicField(tid, 'cid'); + + tags = await Topics.filterTags(tags, cid); + await Topics.addTags(tags, [tid]); + }; + + Topics.deleteTopicTags = async function (tid) { + const topicData = await Topics.getTopicFields(tid, ['cid', 'tags']); + const {cid} = topicData; + const tags = topicData.tags.map(tagItem => tagItem.value); + await db.deleteObjectField(`topic:${tid}`, 'tags'); + + const sets = tags.map(tag => `tag:${tag}:topics`) + .concat(tags.map(tag => `cid:${cid}:tag:${tag}:topics`)); + await db.sortedSetsRemove(sets, tid); + + await Topics.updateCategoryTagsCount([cid], tags); + await Promise.all(tags.map(updateTagCount)); + }; + + Topics.searchTags = async function (data) { + if (!data || !data.query) { + return []; + } + + let result; + result = await (plugins.hooks.hasListeners('filter:topics.searchTags') ? plugins.hooks.fire('filter:topics.searchTags', {data}) : findMatches(data)); + + result = await plugins.hooks.fire('filter:tags.search', {data, matches: result.matches}); + return result.matches; + }; + + Topics.autocompleteTags = async function (data) { + if (!data || !data.query) { + return []; + } + + let result; + result = await (plugins.hooks.hasListeners('filter:topics.autocompleteTags') ? plugins.hooks.fire('filter:topics.autocompleteTags', {data}) : findMatches(data)); + + return result.matches; + }; + + async function getAllTags() { + const cached = cache.get('tags:topic:count'); + if (cached !== undefined) { + return cached; + } + + const tags = await db.getSortedSetRevRangeWithScores('tags:topic:count', 0, -1); + cache.set('tags:topic:count', tags); + return tags; + } + + async function findMatches(data) { + let {query} = data; + let tagInclude = []; + if (Number.parseInt(data.cid, 10)) { + tagInclude = await categories.getTagWhitelist([data.cid]); + } + + let tags = []; + if (Array.isArray(tagInclude[0]) && tagInclude[0].length > 0) { + const scores = await db.sortedSetScores(`cid:${data.cid}:tags`, tagInclude[0]); + tags = tagInclude[0].map((tag, index) => ({value: tag, score: scores[index]})); + } else if (data.cids) { + tags = await db.getSortedSetRevUnion({ + sets: data.cids.map(cid => `cid:${cid}:tags`), + start: 0, + stop: -1, + withScores: true, + }); + } else { + tags = await getAllTags(); + } + + query = query.toLowerCase(); + + const matches = []; + for (const tag of tags) { + if (tag.value && tag.value.toLowerCase().startsWith(query)) { + matches.push(tag); + if (matches.length > 39) { + break; + } + } + } + + matches.sort((a, b) => { + if (a.value < b.value) { + return -1; + } + + if (a.value > b.value) { + return 1; + } + + return 0; + }); + return {matches}; + } + + Topics.searchAndLoadTags = async function (data) { + const searchResult = { + tags: [], + matchCount: 0, + pageCount: 1, + }; + + if (!data || !data.query || data.query.length === 0) { + return searchResult; + } + + const tags = await Topics.searchTags(data); + + const tagData = await Topics.getTagData(tags.map(tag => ({value: tag.value}))); + + for (const [index, tag] of tagData.entries()) { + tag.score = tags[index].score; + } + + tagData.sort((a, b) => b.score - a.score); + searchResult.tags = tagData; + searchResult.matchCount = tagData.length; + searchResult.pageCount = 1; + return searchResult; + }; + + Topics.getRelatedTopics = async function (topicData, uid) { + if (plugins.hooks.hasListeners('filter:topic.getRelatedTopics')) { + const result = await plugins.hooks.fire('filter:topic.getRelatedTopics', {topic: topicData, uid, topics: []}); + return result.topics; + } + + let maximumTopics = meta.config.maximumRelatedTopics; + if (maximumTopics === 0 || !topicData.tags || topicData.tags.length === 0) { + return []; + } + + maximumTopics ||= 5; + let tids = await Promise.all(topicData.tags.map(tag => Topics.getTagTids(tag.value, 0, 5))); + tids = _.shuffle(_.uniq(tids.flat())).slice(0, maximumTopics); + const topics = await Topics.getTopics(tids, uid); + return topics.filter(t => t && !t.deleted && Number.parseInt(t.uid, 10) !== Number.parseInt(uid, 10)); + }; }; diff --git a/src/topics/teaser.js b/src/topics/teaser.js index 52edbb3..9073228 100644 --- a/src/topics/teaser.js +++ b/src/topics/teaser.js @@ -2,7 +2,6 @@ 'use strict'; const _ = require('lodash'); - const db = require('../database'); const meta = require('../meta'); const user = require('../user'); @@ -11,166 +10,169 @@ const plugins = require('../plugins'); const utils = require('../utils'); module.exports = function (Topics) { - Topics.getTeasers = async function (topics, options) { - if (!Array.isArray(topics) || !topics.length) { - return []; - } - let uid = options; - let { teaserPost } = meta.config; - if (typeof options === 'object') { - uid = options.uid; - teaserPost = options.teaserPost || meta.config.teaserPost; - } - - const counts = []; - const teaserPids = []; - const tidToPost = {}; - - topics.forEach((topic) => { - counts.push(topic && topic.postcount); - if (topic) { - if (topic.teaserPid === 'null') { - delete topic.teaserPid; - } - if (teaserPost === 'first') { - teaserPids.push(topic.mainPid); - } else if (teaserPost === 'last-post') { - teaserPids.push(topic.teaserPid || topic.mainPid); - } else { // last-reply and everything else uses teaserPid like `last` that was used before - teaserPids.push(topic.teaserPid); - } - } - }); - - const [allPostData, callerSettings] = await Promise.all([ - posts.getPostsFields(teaserPids, ['pid', 'uid', 'timestamp', 'tid', 'content']), - user.getSettings(uid), - ]); - let postData = allPostData.filter(post => post && post.pid); - postData = await handleBlocks(uid, postData); - postData = postData.filter(Boolean); - const uids = _.uniq(postData.map(post => post.uid)); - const sortNewToOld = callerSettings.topicPostSort === 'newest_to_oldest'; - const usersData = await user.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture']); - - const users = {}; - usersData.forEach((user) => { - users[user.uid] = user; - }); - postData.forEach((post) => { - // If the post author isn't represented in the retrieved users' data, - // then it means they were deleted, assume guest. - if (!users.hasOwnProperty(post.uid)) { - post.uid = 0; - } - - post.user = users[post.uid]; - post.timestampISO = utils.toISOString(post.timestamp); - tidToPost[post.tid] = post; - }); - await Promise.all(postData.map(p => posts.parsePost(p))); - - const { tags } = await plugins.hooks.fire('filter:teasers.configureStripTags', { tags: utils.stripTags.slice(0) }); - - const teasers = topics.map((topic, index) => { - if (!topic) { - return null; - } - - const topicPost = tidToPost[topic.tid]; - if (topicPost) { - topicPost.index = calcTeaserIndex(teaserPost, counts[index], sortNewToOld); - if (topicPost.content) { - topicPost.content = utils.stripHTMLTags(replaceImgWithAltText(topicPost.content), tags); - } - } - return topicPost; - }); - - const result = await plugins.hooks.fire('filter:teasers.get', { teasers: teasers, uid: uid }); - return result.teasers; - }; - - function calcTeaserIndex(teaserPost, postCountInTopic, sortNewToOld) { - if (teaserPost === 'first') { - return 1; - } - - if (sortNewToOld) { - return Math.min(2, postCountInTopic); - } - return postCountInTopic; - } - - function replaceImgWithAltText(str) { - return String(str).replace(/]*>/gi, '$1'); - } - - async function handleBlocks(uid, teasers) { - const blockedUids = await user.blocks.list(uid); - if (!blockedUids.length) { - return teasers; - } - - return await Promise.all(teasers.map(async (postData) => { - if (blockedUids.includes(parseInt(postData.uid, 10))) { - return await getPreviousNonBlockedPost(postData, blockedUids); - } - return postData; - })); - } - - async function getPreviousNonBlockedPost(postData, blockedUids) { - let isBlocked = false; - let prevPost = postData; - const postsPerIteration = 5; - let start = 0; - let stop = start + postsPerIteration - 1; - let checkedAllReplies = false; - - function checkBlocked(post) { - const isPostBlocked = blockedUids.includes(parseInt(post.uid, 10)); - prevPost = !isPostBlocked ? post : prevPost; - return isPostBlocked; - } - - do { - /* eslint-disable no-await-in-loop */ - let pids = await db.getSortedSetRevRange(`tid:${postData.tid}:posts`, start, stop); - if (!pids.length) { - checkedAllReplies = true; - const mainPid = await Topics.getTopicField(postData.tid, 'mainPid'); - pids = [mainPid]; - } - const prevPosts = await posts.getPostsFields(pids, ['pid', 'uid', 'timestamp', 'tid', 'content']); - isBlocked = prevPosts.every(checkBlocked); - start += postsPerIteration; - stop = start + postsPerIteration - 1; - } while (isBlocked && prevPost && prevPost.pid && !checkedAllReplies); - - return prevPost; - } - - Topics.getTeasersByTids = async function (tids, uid) { - if (!Array.isArray(tids) || !tids.length) { - return []; - } - const topics = await Topics.getTopicsFields(tids, ['tid', 'postcount', 'teaserPid', 'mainPid']); - return await Topics.getTeasers(topics, uid); - }; - - Topics.getTeaser = async function (tid, uid) { - const teasers = await Topics.getTeasersByTids([tid], uid); - return Array.isArray(teasers) && teasers.length ? teasers[0] : null; - }; - - Topics.updateTeaser = async function (tid) { - let pid = await Topics.getLatestUndeletedReply(tid); - pid = pid || null; - if (pid) { - await Topics.setTopicField(tid, 'teaserPid', pid); - } else { - await Topics.deleteTopicField(tid, 'teaserPid'); - } - }; + Topics.getTeasers = async function (topics, options) { + if (!Array.isArray(topics) || topics.length === 0) { + return []; + } + + let uid = options; + let {teaserPost} = meta.config; + if (typeof options === 'object') { + uid = options.uid; + teaserPost = options.teaserPost || meta.config.teaserPost; + } + + const counts = []; + const teaserPids = []; + const tidToPost = {}; + + for (const topic of topics) { + counts.push(topic && topic.postcount); + if (topic) { + if (topic.teaserPid === 'null') { + delete topic.teaserPid; + } + + if (teaserPost === 'first') { + teaserPids.push(topic.mainPid); + } else if (teaserPost === 'last-post') { + teaserPids.push(topic.teaserPid || topic.mainPid); + } else { // Last-reply and everything else uses teaserPid like `last` that was used before + teaserPids.push(topic.teaserPid); + } + } + } + + const [allPostData, callerSettings] = await Promise.all([ + posts.getPostsFields(teaserPids, ['pid', 'uid', 'timestamp', 'tid', 'content']), + user.getSettings(uid), + ]); + let postData = allPostData.filter(post => post && post.pid); + postData = await handleBlocks(uid, postData); + postData = postData.filter(Boolean); + const uids = _.uniq(postData.map(post => post.uid)); + const sortNewToOld = callerSettings.topicPostSort === 'newest_to_oldest'; + const usersData = await user.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture']); + + const users = {}; + for (const user of usersData) { + users[user.uid] = user; + } + + for (const post of postData) { + // If the post author isn't represented in the retrieved users' data, + // then it means they were deleted, assume guest. + if (!users.hasOwnProperty(post.uid)) { + post.uid = 0; + } + + post.user = users[post.uid]; + post.timestampISO = utils.toISOString(post.timestamp); + tidToPost[post.tid] = post; + } + + await Promise.all(postData.map(p => posts.parsePost(p))); + + const {tags} = await plugins.hooks.fire('filter:teasers.configureStripTags', {tags: utils.stripTags.slice(0)}); + + const teasers = topics.map((topic, index) => { + if (!topic) { + return null; + } + + const topicPost = tidToPost[topic.tid]; + if (topicPost) { + topicPost.index = calculateTeaserIndex(teaserPost, counts[index], sortNewToOld); + topicPost.content &&= utils.stripHTMLTags(replaceImgWithAltText(topicPost.content), tags); + } + + return topicPost; + }); + + const result = await plugins.hooks.fire('filter:teasers.get', {teasers, uid}); + return result.teasers; + }; + + function calculateTeaserIndex(teaserPost, postCountInTopic, sortNewToOld) { + if (teaserPost === 'first') { + return 1; + } + + if (sortNewToOld) { + return Math.min(2, postCountInTopic); + } + + return postCountInTopic; + } + + function replaceImgWithAltText(string_) { + return String(string_).replaceAll(/]*>/gi, '$1'); + } + + async function handleBlocks(uid, teasers) { + const blockedUids = await user.blocks.list(uid); + if (blockedUids.length === 0) { + return teasers; + } + + return await Promise.all(teasers.map(async postData => { + if (blockedUids.includes(Number.parseInt(postData.uid, 10))) { + return await getPreviousNonBlockedPost(postData, blockedUids); + } + + return postData; + })); + } + + async function getPreviousNonBlockedPost(postData, blockedUids) { + let isBlocked = false; + let previousPost = postData; + const postsPerIteration = 5; + let start = 0; + let stop = start + postsPerIteration - 1; + let checkedAllReplies = false; + + function checkBlocked(post) { + const isPostBlocked = blockedUids.includes(Number.parseInt(post.uid, 10)); + previousPost = isPostBlocked ? previousPost : post; + return isPostBlocked; + } + + do { + /* eslint-disable no-await-in-loop */ + let pids = await db.getSortedSetRevRange(`tid:${postData.tid}:posts`, start, stop); + if (pids.length === 0) { + checkedAllReplies = true; + const mainPid = await Topics.getTopicField(postData.tid, 'mainPid'); + pids = [mainPid]; + } + + const previousPosts = await posts.getPostsFields(pids, ['pid', 'uid', 'timestamp', 'tid', 'content']); + isBlocked = previousPosts.every(checkBlocked); + start += postsPerIteration; + stop = start + postsPerIteration - 1; + } while (isBlocked && previousPost && previousPost.pid && !checkedAllReplies); + + return previousPost; + } + + Topics.getTeasersByTids = async function (tids, uid) { + if (!Array.isArray(tids) || tids.length === 0) { + return []; + } + + const topics = await Topics.getTopicsFields(tids, ['tid', 'postcount', 'teaserPid', 'mainPid']); + return await Topics.getTeasers(topics, uid); + }; + + Topics.getTeaser = async function (tid, uid) { + const teasers = await Topics.getTeasersByTids([tid], uid); + return Array.isArray(teasers) && teasers.length > 0 ? teasers[0] : null; + }; + + Topics.updateTeaser = async function (tid) { + let pid = await Topics.getLatestUndeletedReply(tid); + pid ||= null; + await (pid ? Topics.setTopicField(tid, 'teaserPid', pid) : Topics.deleteTopicField(tid, 'teaserPid')); + }; }; diff --git a/src/topics/thumbs.js b/src/topics/thumbs.js index 6f40553..eecad25 100644 --- a/src/topics/thumbs.js +++ b/src/topics/thumbs.js @@ -1,11 +1,10 @@ 'use strict'; +const path = require('node:path'); const _ = require('lodash'); const nconf = require('nconf'); -const path = require('path'); const validator = require('validator'); - const db = require('../database'); const file = require('../file'); const plugins = require('../plugins'); @@ -16,147 +15,150 @@ const cache = require('../cache'); const Thumbs = module.exports; Thumbs.exists = async function (id, path) { - const isDraft = validator.isUUID(String(id)); - const set = `${isDraft ? 'draft' : 'topic'}:${id}:thumbs`; + const isDraft = validator.isUUID(String(id)); + const set = `${isDraft ? 'draft' : 'topic'}:${id}:thumbs`; - return db.isSortedSetMember(set, path); + return db.isSortedSetMember(set, path); }; Thumbs.load = async function (topicData) { - const topicsWithThumbs = topicData.filter(t => t && parseInt(t.numThumbs, 10) > 0); - const tidsWithThumbs = topicsWithThumbs.map(t => t.tid); - const thumbs = await Thumbs.get(tidsWithThumbs); - const tidToThumbs = _.zipObject(tidsWithThumbs, thumbs); - return topicData.map(t => (t && t.tid ? (tidToThumbs[t.tid] || []) : [])); + const topicsWithThumbs = topicData.filter(t => t && Number.parseInt(t.numThumbs, 10) > 0); + const tidsWithThumbs = topicsWithThumbs.map(t => t.tid); + const thumbs = await Thumbs.get(tidsWithThumbs); + const tidToThumbs = _.zipObject(tidsWithThumbs, thumbs); + return topicData.map(t => (t && t.tid ? (tidToThumbs[t.tid] || []) : [])); }; Thumbs.get = async function (tids) { - // Allow singular or plural usage - let singular = false; - if (!Array.isArray(tids)) { - tids = [tids]; - singular = true; - } - - if (!meta.config.allowTopicsThumbnail || !tids.length) { - return singular ? [] : tids.map(() => []); - } - - const hasTimestampPrefix = /^\d+-/; - const upload_url = nconf.get('relative_path') + nconf.get('upload_url'); - const sets = tids.map(tid => `${validator.isUUID(String(tid)) ? 'draft' : 'topic'}:${tid}:thumbs`); - const thumbs = await Promise.all(sets.map(getThumbs)); - let response = thumbs.map((thumbSet, idx) => thumbSet.map(thumb => ({ - id: tids[idx], - name: (() => { - const name = path.basename(thumb); - return hasTimestampPrefix.test(name) ? name.slice(14) : name; - })(), - url: thumb.startsWith('http') ? thumb : path.posix.join(upload_url, thumb), - }))); - - ({ thumbs: response } = await plugins.hooks.fire('filter:topics.getThumbs', { tids, thumbs: response })); - return singular ? response.pop() : response; + // Allow singular or plural usage + let singular = false; + if (!Array.isArray(tids)) { + tids = [tids]; + singular = true; + } + + if (!meta.config.allowTopicsThumbnail || tids.length === 0) { + return singular ? [] : tids.map(() => []); + } + + const hasTimestampPrefix = /^\d+-/; + const upload_url = nconf.get('relative_path') + nconf.get('upload_url'); + const sets = tids.map(tid => `${validator.isUUID(String(tid)) ? 'draft' : 'topic'}:${tid}:thumbs`); + const thumbs = await Promise.all(sets.map(getThumbs)); + let response = thumbs.map((thumbSet, index) => thumbSet.map(thumb => ({ + id: tids[index], + name: (() => { + const name = path.basename(thumb); + return hasTimestampPrefix.test(name) ? name.slice(14) : name; + })(), + url: thumb.startsWith('http') ? thumb : path.posix.join(upload_url, thumb), + }))); + + ({thumbs: response} = await plugins.hooks.fire('filter:topics.getThumbs', {tids, thumbs: response})); + return singular ? response.pop() : response; }; async function getThumbs(set) { - const cached = cache.get(set); - if (cached !== undefined) { - return cached.slice(); - } - const thumbs = await db.getSortedSetRange(set, 0, -1); - cache.set(set, thumbs); - return thumbs.slice(); + const cached = cache.get(set); + if (cached !== undefined) { + return cached.slice(); + } + + const thumbs = await db.getSortedSetRange(set, 0, -1); + cache.set(set, thumbs); + return thumbs.slice(); } -Thumbs.associate = async function ({ id, path, score }) { - // Associates a newly uploaded file as a thumb to the passed-in draft or topic - const isDraft = validator.isUUID(String(id)); - const isLocal = !path.startsWith('http'); - const set = `${isDraft ? 'draft' : 'topic'}:${id}:thumbs`; - const numThumbs = await db.sortedSetCard(set); - - // Normalize the path to allow for changes in upload_path (and so upload_url can be appended if needed) - if (isLocal) { - path = path.replace(nconf.get('upload_path'), ''); - } - const topics = require('.'); - await db.sortedSetAdd(set, isFinite(score) ? score : numThumbs, path); - if (!isDraft) { - const numThumbs = await db.sortedSetCard(set); - await topics.setTopicField(id, 'numThumbs', numThumbs); - } - cache.del(set); - - // Associate thumbnails with the main pid (only on local upload) - if (!isDraft && isLocal) { - const mainPid = (await topics.getMainPids([id]))[0]; - await posts.uploads.associate(mainPid, path.slice(1)); - } +Thumbs.associate = async function ({id, path, score}) { + // Associates a newly uploaded file as a thumb to the passed-in draft or topic + const isDraft = validator.isUUID(String(id)); + const isLocal = !path.startsWith('http'); + const set = `${isDraft ? 'draft' : 'topic'}:${id}:thumbs`; + const numberThumbs = await db.sortedSetCard(set); + + // Normalize the path to allow for changes in upload_path (and so upload_url can be appended if needed) + if (isLocal) { + path = path.replace(nconf.get('upload_path'), ''); + } + + const topics = require('.'); + await db.sortedSetAdd(set, isFinite(score) ? score : numberThumbs, path); + if (!isDraft) { + const numberThumbs = await db.sortedSetCard(set); + await topics.setTopicField(id, 'numThumbs', numberThumbs); + } + + cache.del(set); + + // Associate thumbnails with the main pid (only on local upload) + if (!isDraft && isLocal) { + const [mainPid] = await topics.getMainPids([id]); + await posts.uploads.associate(mainPid, path.slice(1)); + } }; Thumbs.migrate = async function (uuid, id) { - // Converts the draft thumb zset to the topic zset (combines thumbs if applicable) - const set = `draft:${uuid}:thumbs`; - const thumbs = await db.getSortedSetRangeWithScores(set, 0, -1); - await Promise.all(thumbs.map(async thumb => await Thumbs.associate({ - id, - path: thumb.value, - score: thumb.score, - }))); - await db.delete(set); - cache.del(set); + // Converts the draft thumb zset to the topic zset (combines thumbs if applicable) + const set = `draft:${uuid}:thumbs`; + const thumbs = await db.getSortedSetRangeWithScores(set, 0, -1); + await Promise.all(thumbs.map(async thumb => await Thumbs.associate({ + id, + path: thumb.value, + score: thumb.score, + }))); + await db.delete(set); + cache.del(set); }; Thumbs.delete = async function (id, relativePaths) { - const isDraft = validator.isUUID(String(id)); - const set = `${isDraft ? 'draft' : 'topic'}:${id}:thumbs`; - - if (typeof relativePaths === 'string') { - relativePaths = [relativePaths]; - } else if (!Array.isArray(relativePaths)) { - throw new Error('[[error:invalid-data]]'); - } - - const absolutePaths = relativePaths.map(relativePath => path.join(nconf.get('upload_path'), relativePath)); - const [associated, existsOnDisk] = await Promise.all([ - db.isSortedSetMembers(set, relativePaths), - Promise.all(absolutePaths.map(async absolutePath => file.exists(absolutePath))), - ]); - - const toRemove = []; - const toDelete = []; - relativePaths.forEach((relativePath, idx) => { - if (associated[idx]) { - toRemove.push(relativePath); - } - - if (existsOnDisk[idx]) { - toDelete.push(absolutePaths[idx]); - } - }); - - await db.sortedSetRemove(set, toRemove); - - if (isDraft && toDelete.length) { // drafts only; post upload dissociation handles disk deletion for topics - await Promise.all(toDelete.map(async absolutePath => file.delete(absolutePath))); - } - - if (toRemove.length && !isDraft) { - const topics = require('.'); - const mainPid = (await topics.getMainPids([id]))[0]; - - await Promise.all([ - db.incrObjectFieldBy(`topic:${id}`, 'numThumbs', -toRemove.length), - Promise.all(toRemove.map(async relativePath => posts.uploads.dissociate(mainPid, relativePath.slice(1)))), - ]); - } + const isDraft = validator.isUUID(String(id)); + const set = `${isDraft ? 'draft' : 'topic'}:${id}:thumbs`; + + if (typeof relativePaths === 'string') { + relativePaths = [relativePaths]; + } else if (!Array.isArray(relativePaths)) { + throw new TypeError('[[error:invalid-data]]'); + } + + const absolutePaths = relativePaths.map(relativePath => path.join(nconf.get('upload_path'), relativePath)); + const [associated, existsOnDisk] = await Promise.all([ + db.isSortedSetMembers(set, relativePaths), + Promise.all(absolutePaths.map(async absolutePath => file.exists(absolutePath))), + ]); + + const toRemove = []; + const toDelete = []; + for (const [index, relativePath] of relativePaths.entries()) { + if (associated[index]) { + toRemove.push(relativePath); + } + + if (existsOnDisk[index]) { + toDelete.push(absolutePaths[index]); + } + } + + await db.sortedSetRemove(set, toRemove); + + if (isDraft && toDelete.length > 0) { // Drafts only; post upload dissociation handles disk deletion for topics + await Promise.all(toDelete.map(async absolutePath => file.delete(absolutePath))); + } + + if (toRemove.length > 0 && !isDraft) { + const topics = require('.'); + const [mainPid] = await topics.getMainPids([id]); + + await Promise.all([ + db.incrObjectFieldBy(`topic:${id}`, 'numThumbs', -toRemove.length), + Promise.all(toRemove.map(async relativePath => posts.uploads.dissociate(mainPid, relativePath.slice(1)))), + ]); + } }; -Thumbs.deleteAll = async (id) => { - const isDraft = validator.isUUID(String(id)); - const set = `${isDraft ? 'draft' : 'topic'}:${id}:thumbs`; +Thumbs.deleteAll = async id => { + const isDraft = validator.isUUID(String(id)); + const set = `${isDraft ? 'draft' : 'topic'}:${id}:thumbs`; - const thumbs = await db.getSortedSetRange(set, 0, -1); - await Thumbs.delete(id, thumbs); + const thumbs = await db.getSortedSetRange(set, 0, -1); + await Thumbs.delete(id, thumbs); }; diff --git a/src/topics/tools.js b/src/topics/tools.js index 7f8f73c..ca3a652 100644 --- a/src/topics/tools.js +++ b/src/topics/tools.js @@ -1,344 +1,350 @@ 'use strict'; const _ = require('lodash'); - const db = require('../database'); -const topics = require('.'); const categories = require('../categories'); const user = require('../user'); const plugins = require('../plugins'); const privileges = require('../privileges'); const utils = require('../utils'); - +const topics = require('.'); module.exports = function (Topics) { - const topicTools = {}; - Topics.tools = topicTools; - - topicTools.delete = async function (tid, uid) { - return await toggleDelete(tid, uid, true); - }; - - topicTools.restore = async function (tid, uid) { - return await toggleDelete(tid, uid, false); - }; - - async function toggleDelete(tid, uid, isDelete) { - const topicData = await Topics.getTopicData(tid); - if (!topicData) { - throw new Error('[[error:no-topic]]'); - } - // Scheduled topics can only be purged - if (topicData.scheduled) { - throw new Error('[[error:invalid-data]]'); - } - const canDelete = await privileges.topics.canDelete(tid, uid); - - const hook = isDelete ? 'delete' : 'restore'; - const data = await plugins.hooks.fire(`filter:topic.${hook}`, { topicData: topicData, uid: uid, isDelete: isDelete, canDelete: canDelete, canRestore: canDelete }); - - if ((!data.canDelete && data.isDelete) || (!data.canRestore && !data.isDelete)) { - throw new Error('[[error:no-privileges]]'); - } - if (data.topicData.deleted && data.isDelete) { - throw new Error('[[error:topic-already-deleted]]'); - } else if (!data.topicData.deleted && !data.isDelete) { - throw new Error('[[error:topic-already-restored]]'); - } - if (data.isDelete) { - await Topics.delete(data.topicData.tid, data.uid); - } else { - await Topics.restore(data.topicData.tid); - } - const events = await Topics.events.log(tid, { type: isDelete ? 'delete' : 'restore', uid }); - - data.topicData.deleted = data.isDelete ? 1 : 0; - - if (data.isDelete) { - plugins.hooks.fire('action:topic.delete', { topic: data.topicData, uid: data.uid }); - } else { - plugins.hooks.fire('action:topic.restore', { topic: data.topicData, uid: data.uid }); - } - const userData = await user.getUserFields(data.uid, ['username', 'userslug']); - return { - tid: data.topicData.tid, - cid: data.topicData.cid, - isDelete: data.isDelete, - uid: data.uid, - user: userData, - events, - }; - } - - topicTools.private = async function (tid, uid) { - return await togglePrivate(tid, uid, true); - }; - - topicTools.public = async function (tid, uid) { - return await togglePrivate(tid, uid, false); - }; - - async function togglePrivate(tid, uid, isPrivate) { - const topicData = await Topics.getTopicData(tid); - if (!topicData) { - throw new Error('[[error:no-topic]]'); - } - - const canPrivate = await privileges.topics.canDelete(tid, uid); - - const hook = isPrivate ? 'private' : 'public'; - const data = await plugins.hooks.fire(`filter:topic.${hook}`, { topicData: topicData, uid: uid, isPrivate: isPrivate, canPrivate: canPrivate, canPublic: canPrivate }); - - if ((!data.canPrivate && data.isPrivate) || (!data.canPublic && !data.isPrivate)) { - throw new Error('[[error:no-privileges]]'); - } - - if (data.isPrivate) { - await Topics.private(data.topicData.tid, data.uid); - } else { - await Topics.public(data.topicData.tid); - } - const events = await Topics.events.log(tid, { type: isPrivate ? 'private' : 'public', uid }); - - data.topicData.private = data.isPrivate ? 1 : 0; - - if (data.isPrivate) { - plugins.hooks.fire('action:topic.private', { topic: data.topicData, uid: data.uid }); - } else { - plugins.hooks.fire('action:topic.public', { topic: data.topicData, uid: data.uid }); - } - const userData = await user.getUserFields(data.uid, ['username', 'userslug']); - - return { - tid: data.topicData.tid, - cid: data.topicData.cid, - isPrivate: data.isPrivate, - uid: data.uid, - user: userData, - events, - }; - } - - topicTools.purge = async function (tid, uid) { - const topicData = await Topics.getTopicData(tid); - if (!topicData) { - throw new Error('[[error:no-topic]]'); - } - const canPurge = await privileges.topics.canPurge(tid, uid); - if (!canPurge) { - throw new Error('[[error:no-privileges]]'); - } - - await Topics.purgePostsAndTopic(tid, uid); - return { tid: tid, cid: topicData.cid, uid: uid }; - }; - - topicTools.lock = async function (tid, uid) { - return await toggleLock(tid, uid, true); - }; - - topicTools.unlock = async function (tid, uid) { - return await toggleLock(tid, uid, false); - }; - - async function toggleLock(tid, uid, lock) { - const topicData = await Topics.getTopicFields(tid, ['tid', 'uid', 'cid']); - if (!topicData || !topicData.cid) { - throw new Error('[[error:no-topic]]'); - } - const isAdminOrMod = await privileges.categories.isAdminOrMod(topicData.cid, uid); - if (!isAdminOrMod) { - throw new Error('[[error:no-privileges]]'); - } - await Topics.setTopicField(tid, 'locked', lock ? 1 : 0); - topicData.events = await Topics.events.log(tid, { type: lock ? 'lock' : 'unlock', uid }); - topicData.isLocked = lock; // deprecate in v2.0 - topicData.locked = lock; - - plugins.hooks.fire('action:topic.lock', { topic: _.clone(topicData), uid: uid }); - return topicData; - } - - topicTools.pin = async function (tid, uid) { - return await togglePin(tid, uid, true); - }; - - topicTools.unpin = async function (tid, uid) { - return await togglePin(tid, uid, false); - }; - - topicTools.setPinExpiry = async (tid, expiry, uid) => { - if (isNaN(parseInt(expiry, 10)) || expiry <= Date.now()) { - throw new Error('[[error:invalid-data]]'); - } - - const topicData = await Topics.getTopicFields(tid, ['tid', 'uid', 'cid']); - const isAdminOrMod = await privileges.categories.isAdminOrMod(topicData.cid, uid); - if (!isAdminOrMod) { - throw new Error('[[error:no-privileges]]'); - } - - await Topics.setTopicField(tid, 'pinExpiry', expiry); - plugins.hooks.fire('action:topic.setPinExpiry', { topic: _.clone(topicData), uid: uid }); - }; - - topicTools.checkPinExpiry = async (tids) => { - const expiry = (await topics.getTopicsFields(tids, ['pinExpiry'])).map(obj => obj.pinExpiry); - const now = Date.now(); - - tids = await Promise.all(tids.map(async (tid, idx) => { - if (expiry[idx] && parseInt(expiry[idx], 10) <= now) { - await togglePin(tid, 'system', false); - return null; - } - - return tid; - })); - - return tids.filter(Boolean); - }; - - async function togglePin(tid, uid, pin) { - const topicData = await Topics.getTopicData(tid); - if (!topicData) { - throw new Error('[[error:no-topic]]'); - } - - if (topicData.scheduled) { - throw new Error('[[error:cant-pin-scheduled]]'); - } - - if (uid !== 'system' && !await privileges.topics.isAdminOrMod(tid, uid)) { - throw new Error('[[error:no-privileges]]'); - } - - const promises = [ - Topics.setTopicField(tid, 'pinned', pin ? 1 : 0), - Topics.events.log(tid, { type: pin ? 'pin' : 'unpin', uid }), - ]; - if (pin) { - promises.push(db.sortedSetAdd(`cid:${topicData.cid}:tids:pinned`, Date.now(), tid)); - promises.push(db.sortedSetsRemove([ - `cid:${topicData.cid}:tids`, - `cid:${topicData.cid}:tids:posts`, - `cid:${topicData.cid}:tids:votes`, - `cid:${topicData.cid}:tids:views`, - ], tid)); - } else { - promises.push(db.sortedSetRemove(`cid:${topicData.cid}:tids:pinned`, tid)); - promises.push(Topics.deleteTopicField(tid, 'pinExpiry')); - promises.push(db.sortedSetAddBulk([ - [`cid:${topicData.cid}:tids`, topicData.lastposttime, tid], - [`cid:${topicData.cid}:tids:posts`, topicData.postcount, tid], - [`cid:${topicData.cid}:tids:votes`, parseInt(topicData.votes, 10) || 0, tid], - [`cid:${topicData.cid}:tids:views`, topicData.viewcount, tid], - ])); - topicData.pinExpiry = undefined; - topicData.pinExpiryISO = undefined; - } - - const results = await Promise.all(promises); - - topicData.isPinned = pin; // deprecate in v2.0 - topicData.pinned = pin; - topicData.events = results[1]; - - plugins.hooks.fire('action:topic.pin', { topic: _.clone(topicData), uid }); - - return topicData; - } - - topicTools.orderPinnedTopics = async function (uid, data) { - const { tid, order } = data; - const cid = await Topics.getTopicField(tid, 'cid'); - - if (!cid || !tid || !utils.isNumber(order) || order < 0) { - throw new Error('[[error:invalid-data]]'); - } - - const isAdminOrMod = await privileges.categories.isAdminOrMod(cid, uid); - if (!isAdminOrMod) { - throw new Error('[[error:no-privileges]]'); - } - - const pinnedTids = await db.getSortedSetRange(`cid:${cid}:tids:pinned`, 0, -1); - const currentIndex = pinnedTids.indexOf(String(tid)); - if (currentIndex === -1) { - return; - } - const newOrder = pinnedTids.length - order - 1; - // moves tid to index order in the array - if (pinnedTids.length > 1) { - pinnedTids.splice(Math.max(0, newOrder), 0, pinnedTids.splice(currentIndex, 1)[0]); - } - - await db.sortedSetAdd( - `cid:${cid}:tids:pinned`, - pinnedTids.map((tid, index) => index), - pinnedTids - ); - }; - - topicTools.move = async function (tid, data) { - const cid = parseInt(data.cid, 10); - const topicData = await Topics.getTopicData(tid); - if (!topicData) { - throw new Error('[[error:no-topic]]'); - } - if (cid === topicData.cid) { - throw new Error('[[error:cant-move-topic-to-same-category]]'); - } - const tags = await Topics.getTopicTags(tid); - await db.sortedSetsRemove([ - `cid:${topicData.cid}:tids`, - `cid:${topicData.cid}:tids:pinned`, - `cid:${topicData.cid}:tids:posts`, - `cid:${topicData.cid}:tids:votes`, - `cid:${topicData.cid}:tids:views`, - `cid:${topicData.cid}:tids:lastposttime`, - `cid:${topicData.cid}:recent_tids`, - `cid:${topicData.cid}:uid:${topicData.uid}:tids`, - ...tags.map(tag => `cid:${topicData.cid}:tag:${tag}:topics`), - ], tid); - - topicData.postcount = topicData.postcount || 0; - const votes = topicData.upvotes - topicData.downvotes; - - const bulk = [ - [`cid:${cid}:tids:lastposttime`, topicData.lastposttime, tid], - [`cid:${cid}:uid:${topicData.uid}:tids`, topicData.timestamp, tid], - ...tags.map(tag => [`cid:${cid}:tag:${tag}:topics`, topicData.timestamp, tid]), - ]; - if (topicData.pinned) { - bulk.push([`cid:${cid}:tids:pinned`, Date.now(), tid]); - } else { - bulk.push([`cid:${cid}:tids`, topicData.lastposttime, tid]); - bulk.push([`cid:${cid}:tids:posts`, topicData.postcount, tid]); - bulk.push([`cid:${cid}:tids:votes`, votes, tid]); - bulk.push([`cid:${cid}:tids:views`, topicData.viewcount, tid]); - } - await db.sortedSetAddBulk(bulk); - - const oldCid = topicData.cid; - await categories.moveRecentReplies(tid, oldCid, cid); - - await Promise.all([ - categories.incrementCategoryFieldBy(oldCid, 'topic_count', -1), - categories.incrementCategoryFieldBy(cid, 'topic_count', 1), - categories.updateRecentTidForCid(cid), - categories.updateRecentTidForCid(oldCid), - Topics.setTopicFields(tid, { - cid: cid, - oldCid: oldCid, - }), - Topics.updateCategoryTagsCount([oldCid, cid], tags), - Topics.events.log(tid, { type: 'move', uid: data.uid, fromCid: oldCid }), - ]); - const hookData = _.clone(data); - hookData.fromCid = oldCid; - hookData.toCid = cid; - hookData.tid = tid; - - plugins.hooks.fire('action:topic.move', hookData); - }; + const topicTools = {}; + Topics.tools = topicTools; + + topicTools.delete = async function (tid, uid) { + return await toggleDelete(tid, uid, true); + }; + + topicTools.restore = async function (tid, uid) { + return await toggleDelete(tid, uid, false); + }; + + async function toggleDelete(tid, uid, isDelete) { + const topicData = await Topics.getTopicData(tid); + if (!topicData) { + throw new Error('[[error:no-topic]]'); + } + + // Scheduled topics can only be purged + if (topicData.scheduled) { + throw new Error('[[error:invalid-data]]'); + } + + const canDelete = await privileges.topics.canDelete(tid, uid); + + const hook = isDelete ? 'delete' : 'restore'; + const data = await plugins.hooks.fire(`filter:topic.${hook}`, { + topicData, uid, isDelete, canDelete, canRestore: canDelete, + }); + + if ((!data.canDelete && data.isDelete) || (!data.canRestore && !data.isDelete)) { + throw new Error('[[error:no-privileges]]'); + } + + if (data.topicData.deleted && data.isDelete) { + throw new Error('[[error:topic-already-deleted]]'); + } else if (!data.topicData.deleted && !data.isDelete) { + throw new Error('[[error:topic-already-restored]]'); + } + + await (data.isDelete ? Topics.delete(data.topicData.tid, data.uid) : Topics.restore(data.topicData.tid)); + + const events = await Topics.events.log(tid, {type: isDelete ? 'delete' : 'restore', uid}); + + data.topicData.deleted = data.isDelete ? 1 : 0; + + if (data.isDelete) { + plugins.hooks.fire('action:topic.delete', {topic: data.topicData, uid: data.uid}); + } else { + plugins.hooks.fire('action:topic.restore', {topic: data.topicData, uid: data.uid}); + } + + const userData = await user.getUserFields(data.uid, ['username', 'userslug']); + return { + tid: data.topicData.tid, + cid: data.topicData.cid, + isDelete: data.isDelete, + uid: data.uid, + user: userData, + events, + }; + } + + topicTools.private = async function (tid, uid) { + return await togglePrivate(tid, uid, true); + }; + + topicTools.public = async function (tid, uid) { + return await togglePrivate(tid, uid, false); + }; + + async function togglePrivate(tid, uid, isPrivate) { + const topicData = await Topics.getTopicData(tid); + if (!topicData) { + throw new Error('[[error:no-topic]]'); + } + + const canPrivate = await privileges.topics.canDelete(tid, uid); + + const hook = isPrivate ? 'private' : 'public'; + const data = await plugins.hooks.fire(`filter:topic.${hook}`, { + topicData, uid, isPrivate, canPrivate, canPublic: canPrivate, + }); + + if ((!data.canPrivate && data.isPrivate) || (!data.canPublic && !data.isPrivate)) { + throw new Error('[[error:no-privileges]]'); + } + + await (data.isPrivate ? Topics.private(data.topicData.tid, data.uid) : Topics.public(data.topicData.tid)); + + const events = await Topics.events.log(tid, {type: isPrivate ? 'private' : 'public', uid}); + + data.topicData.private = data.isPrivate ? 1 : 0; + + if (data.isPrivate) { + plugins.hooks.fire('action:topic.private', {topic: data.topicData, uid: data.uid}); + } else { + plugins.hooks.fire('action:topic.public', {topic: data.topicData, uid: data.uid}); + } + + const userData = await user.getUserFields(data.uid, ['username', 'userslug']); + + return { + tid: data.topicData.tid, + cid: data.topicData.cid, + isPrivate: data.isPrivate, + uid: data.uid, + user: userData, + events, + }; + } + + topicTools.purge = async function (tid, uid) { + const topicData = await Topics.getTopicData(tid); + if (!topicData) { + throw new Error('[[error:no-topic]]'); + } + + const canPurge = await privileges.topics.canPurge(tid, uid); + if (!canPurge) { + throw new Error('[[error:no-privileges]]'); + } + + await Topics.purgePostsAndTopic(tid, uid); + return {tid, cid: topicData.cid, uid}; + }; + + topicTools.lock = async function (tid, uid) { + return await toggleLock(tid, uid, true); + }; + + topicTools.unlock = async function (tid, uid) { + return await toggleLock(tid, uid, false); + }; + + async function toggleLock(tid, uid, lock) { + const topicData = await Topics.getTopicFields(tid, ['tid', 'uid', 'cid']); + if (!topicData || !topicData.cid) { + throw new Error('[[error:no-topic]]'); + } + + const isAdminOrModule = await privileges.categories.isAdminOrMod(topicData.cid, uid); + if (!isAdminOrModule) { + throw new Error('[[error:no-privileges]]'); + } + + await Topics.setTopicField(tid, 'locked', lock ? 1 : 0); + topicData.events = await Topics.events.log(tid, {type: lock ? 'lock' : 'unlock', uid}); + topicData.isLocked = lock; // Deprecate in v2.0 + topicData.locked = lock; + + plugins.hooks.fire('action:topic.lock', {topic: _.clone(topicData), uid}); + return topicData; + } + + topicTools.pin = async function (tid, uid) { + return await togglePin(tid, uid, true); + }; + + topicTools.unpin = async function (tid, uid) { + return await togglePin(tid, uid, false); + }; + + topicTools.setPinExpiry = async (tid, expiry, uid) => { + if (isNaN(Number.parseInt(expiry, 10)) || expiry <= Date.now()) { + throw new Error('[[error:invalid-data]]'); + } + + const topicData = await Topics.getTopicFields(tid, ['tid', 'uid', 'cid']); + const isAdminOrModule = await privileges.categories.isAdminOrMod(topicData.cid, uid); + if (!isAdminOrModule) { + throw new Error('[[error:no-privileges]]'); + } + + await Topics.setTopicField(tid, 'pinExpiry', expiry); + plugins.hooks.fire('action:topic.setPinExpiry', {topic: _.clone(topicData), uid}); + }; + + topicTools.checkPinExpiry = async tids => { + const expiry = (await topics.getTopicsFields(tids, ['pinExpiry'])).map(object => object.pinExpiry); + const now = Date.now(); + + tids = await Promise.all(tids.map(async (tid, index) => { + if (expiry[index] && Number.parseInt(expiry[index], 10) <= now) { + await togglePin(tid, 'system', false); + return null; + } + + return tid; + })); + + return tids.filter(Boolean); + }; + + async function togglePin(tid, uid, pin) { + const topicData = await Topics.getTopicData(tid); + if (!topicData) { + throw new Error('[[error:no-topic]]'); + } + + if (topicData.scheduled) { + throw new Error('[[error:cant-pin-scheduled]]'); + } + + if (uid !== 'system' && !await privileges.topics.isAdminOrMod(tid, uid)) { + throw new Error('[[error:no-privileges]]'); + } + + const promises = [ + Topics.setTopicField(tid, 'pinned', pin ? 1 : 0), + Topics.events.log(tid, {type: pin ? 'pin' : 'unpin', uid}), + ]; + if (pin) { + promises.push(db.sortedSetAdd(`cid:${topicData.cid}:tids:pinned`, Date.now(), tid)); + promises.push(db.sortedSetsRemove([ + `cid:${topicData.cid}:tids`, + `cid:${topicData.cid}:tids:posts`, + `cid:${topicData.cid}:tids:votes`, + `cid:${topicData.cid}:tids:views`, + ], tid)); + } else { + promises.push(db.sortedSetRemove(`cid:${topicData.cid}:tids:pinned`, tid)); + promises.push(Topics.deleteTopicField(tid, 'pinExpiry')); + promises.push(db.sortedSetAddBulk([ + [`cid:${topicData.cid}:tids`, topicData.lastposttime, tid], + [`cid:${topicData.cid}:tids:posts`, topicData.postcount, tid], + [`cid:${topicData.cid}:tids:votes`, Number.parseInt(topicData.votes, 10) || 0, tid], + [`cid:${topicData.cid}:tids:views`, topicData.viewcount, tid], + ])); + topicData.pinExpiry = undefined; + topicData.pinExpiryISO = undefined; + } + + const results = await Promise.all(promises); + + topicData.isPinned = pin; // Deprecate in v2.0 + topicData.pinned = pin; + topicData.events = results[1]; + + plugins.hooks.fire('action:topic.pin', {topic: _.clone(topicData), uid}); + + return topicData; + } + + topicTools.orderPinnedTopics = async function (uid, data) { + const {tid, order} = data; + const cid = await Topics.getTopicField(tid, 'cid'); + + if (!cid || !tid || !utils.isNumber(order) || order < 0) { + throw new Error('[[error:invalid-data]]'); + } + + const isAdminOrModule = await privileges.categories.isAdminOrMod(cid, uid); + if (!isAdminOrModule) { + throw new Error('[[error:no-privileges]]'); + } + + const pinnedTids = await db.getSortedSetRange(`cid:${cid}:tids:pinned`, 0, -1); + const currentIndex = pinnedTids.indexOf(String(tid)); + if (currentIndex === -1) { + return; + } + + const newOrder = pinnedTids.length - order - 1; + // Moves tid to index order in the array + if (pinnedTids.length > 1) { + pinnedTids.splice(Math.max(0, newOrder), 0, pinnedTids.splice(currentIndex, 1)[0]); + } + + await db.sortedSetAdd( + `cid:${cid}:tids:pinned`, + pinnedTids.map((tid, index) => index), + pinnedTids, + ); + }; + + topicTools.move = async function (tid, data) { + const cid = Number.parseInt(data.cid, 10); + const topicData = await Topics.getTopicData(tid); + if (!topicData) { + throw new Error('[[error:no-topic]]'); + } + + if (cid === topicData.cid) { + throw new Error('[[error:cant-move-topic-to-same-category]]'); + } + + const tags = await Topics.getTopicTags(tid); + await db.sortedSetsRemove([ + `cid:${topicData.cid}:tids`, + `cid:${topicData.cid}:tids:pinned`, + `cid:${topicData.cid}:tids:posts`, + `cid:${topicData.cid}:tids:votes`, + `cid:${topicData.cid}:tids:views`, + `cid:${topicData.cid}:tids:lastposttime`, + `cid:${topicData.cid}:recent_tids`, + `cid:${topicData.cid}:uid:${topicData.uid}:tids`, + ...tags.map(tag => `cid:${topicData.cid}:tag:${tag}:topics`), + ], tid); + + topicData.postcount = topicData.postcount || 0; + const votes = topicData.upvotes - topicData.downvotes; + + const bulk = [ + [`cid:${cid}:tids:lastposttime`, topicData.lastposttime, tid], + [`cid:${cid}:uid:${topicData.uid}:tids`, topicData.timestamp, tid], + ...tags.map(tag => [`cid:${cid}:tag:${tag}:topics`, topicData.timestamp, tid]), + ]; + if (topicData.pinned) { + bulk.push([`cid:${cid}:tids:pinned`, Date.now(), tid]); + } else { + bulk.push([`cid:${cid}:tids`, topicData.lastposttime, tid], [`cid:${cid}:tids:posts`, topicData.postcount, tid], [`cid:${cid}:tids:votes`, votes, tid], [`cid:${cid}:tids:views`, topicData.viewcount, tid]); + } + + await db.sortedSetAddBulk(bulk); + + const oldCid = topicData.cid; + await categories.moveRecentReplies(tid, oldCid, cid); + + await Promise.all([ + categories.incrementCategoryFieldBy(oldCid, 'topic_count', -1), + categories.incrementCategoryFieldBy(cid, 'topic_count', 1), + categories.updateRecentTidForCid(cid), + categories.updateRecentTidForCid(oldCid), + Topics.setTopicFields(tid, { + cid, + oldCid, + }), + Topics.updateCategoryTagsCount([oldCid, cid], tags), + Topics.events.log(tid, {type: 'move', uid: data.uid, fromCid: oldCid}), + ]); + const hookData = _.clone(data); + hookData.fromCid = oldCid; + hookData.toCid = cid; + hookData.tid = tid; + + plugins.hooks.fire('action:topic.move', hookData); + }; }; diff --git a/src/topics/unread.js b/src/topics/unread.js index f66611e..54a9b32 100644 --- a/src/topics/unread.js +++ b/src/topics/unread.js @@ -3,7 +3,6 @@ const async = require('async'); const _ = require('lodash'); - const db = require('../database'); const user = require('../user'); const posts = require('../posts'); @@ -15,375 +14,397 @@ const utils = require('../utils'); const plugins = require('../plugins'); module.exports = function (Topics) { - Topics.getTotalUnread = async function (uid, filter) { - filter = filter || ''; - const counts = await Topics.getUnreadTids({ cid: 0, uid: uid, count: true }); - return counts && counts[filter]; - }; - - Topics.getUnreadTopics = async function (params) { - const unreadTopics = { - showSelect: true, - nextStart: 0, - topics: [], - }; - let tids = await Topics.getUnreadTids(params); - unreadTopics.topicCount = tids.length; - - if (!tids.length) { - return unreadTopics; - } - - tids = tids.slice(params.start, params.stop !== -1 ? params.stop + 1 : undefined); - - const topicData = await Topics.getTopicsByTids(tids, params.uid); - if (!topicData.length) { - return unreadTopics; - } - Topics.calculateTopicIndices(topicData, params.start); - unreadTopics.topics = topicData; - unreadTopics.nextStart = params.stop + 1; - return unreadTopics; - }; - - Topics.unreadCutoff = async function (uid) { - const cutoff = Date.now() - (meta.config.unreadCutoff * 86400000); - const data = await plugins.hooks.fire('filter:topics.unreadCutoff', { uid: uid, cutoff: cutoff }); - return parseInt(data.cutoff, 10); - }; - - Topics.getUnreadTids = async function (params) { - const results = await Topics.getUnreadData(params); - return params.count ? results.counts : results.tids; - }; - - Topics.getUnreadData = async function (params) { - const uid = parseInt(params.uid, 10); - - params.filter = params.filter || ''; - - if (params.cid && !Array.isArray(params.cid)) { - params.cid = [params.cid]; - } - - const data = await getTids(params); - if (uid <= 0 || !data.tids || !data.tids.length) { - return data; - } - - const result = await plugins.hooks.fire('filter:topics.getUnreadTids', { - uid: uid, - tids: data.tids, - counts: data.counts, - tidsByFilter: data.tidsByFilter, - cid: params.cid, - filter: params.filter, - query: params.query || {}, - }); - return result; - }; - - async function getTids(params) { - const counts = { '': 0, new: 0, watched: 0, unreplied: 0 }; - const tidsByFilter = { '': [], new: [], watched: [], unreplied: [] }; - - if (params.uid <= 0) { - return { counts: counts, tids: [], tidsByFilter: tidsByFilter }; - } - - params.cutoff = await Topics.unreadCutoff(params.uid); - - const [followedTids, ignoredTids, categoryTids, userScores, tids_unread] = await Promise.all([ - getFollowedTids(params), - user.getIgnoredTids(params.uid, 0, -1), - getCategoryTids(params), - db.getSortedSetRevRangeByScoreWithScores(`uid:${params.uid}:tids_read`, 0, -1, '+inf', params.cutoff), - db.getSortedSetRevRangeWithScores(`uid:${params.uid}:tids_unread`, 0, -1), - ]); - - const userReadTimes = _.mapValues(_.keyBy(userScores, 'value'), 'score'); - const isTopicsFollowed = {}; - followedTids.forEach((t) => { - isTopicsFollowed[t.value] = true; - }); - const unreadFollowed = await db.isSortedSetMembers( - `uid:${params.uid}:followed_tids`, tids_unread.map(t => t.value) - ); - - tids_unread.forEach((t, i) => { - isTopicsFollowed[t.value] = unreadFollowed[i]; - }); - - const unreadTopics = _.unionWith(categoryTids, followedTids, (a, b) => a.value === b.value) - .filter(t => !ignoredTids.includes(t.value) && - (!userReadTimes[t.value] || t.score > userReadTimes[t.value])) - .concat(tids_unread.filter(t => !ignoredTids.includes(t.value))) - .sort((a, b) => b.score - a.score); - - let tids = _.uniq(unreadTopics.map(topic => topic.value)).slice(0, 200); - - if (!tids.length) { - return { counts: counts, tids: tids, tidsByFilter: tidsByFilter }; - } - - const blockedUids = await user.blocks.list(params.uid); - - tids = await filterTidsThatHaveBlockedPosts({ - uid: params.uid, - tids: tids, - blockedUids: blockedUids, - recentTids: categoryTids, - }); - - tids = await privileges.topics.filterTids('topics:read', tids, params.uid); - const topicData = (await Topics.getTopicsFields(tids, ['tid', 'cid', 'uid', 'postcount', 'deleted', 'scheduled'])) - .filter(t => t.scheduled || !t.deleted); - const topicCids = _.uniq(topicData.map(topic => topic.cid)).filter(Boolean); - - const categoryWatchState = await categories.getWatchState(topicCids, params.uid); - const userCidState = _.zipObject(topicCids, categoryWatchState); - - const filterCids = params.cid && params.cid.map(cid => parseInt(cid, 10)); - - topicData.forEach((topic) => { - if (topic && topic.cid && (!filterCids || filterCids.includes(topic.cid)) && - !blockedUids.includes(topic.uid)) { - if (isTopicsFollowed[topic.tid] || userCidState[topic.cid] === categories.watchStates.watching) { - tidsByFilter[''].push(topic.tid); - } - - if (isTopicsFollowed[topic.tid]) { - tidsByFilter.watched.push(topic.tid); - } - - if (topic.postcount <= 1) { - tidsByFilter.unreplied.push(topic.tid); - } - - if (!userReadTimes[topic.tid]) { - tidsByFilter.new.push(topic.tid); - } - } - }); - - counts[''] = tidsByFilter[''].length; - counts.watched = tidsByFilter.watched.length; - counts.unreplied = tidsByFilter.unreplied.length; - counts.new = tidsByFilter.new.length; - - return { - counts: counts, - tids: tidsByFilter[params.filter], - tidsByFilter: tidsByFilter, - }; - } - - async function getCategoryTids(params) { - if (plugins.hooks.hasListeners('filter:topics.unread.getCategoryTids')) { - const result = await plugins.hooks.fire('filter:topics.unread.getCategoryTids', { params: params, tids: [] }); - return result.tids; - } - if (params.filter === 'watched') { - return []; - } - const cids = params.cid || await user.getWatchedCategories(params.uid); - const keys = cids.map(cid => `cid:${cid}:tids:lastposttime`); - return await db.getSortedSetRevRangeByScoreWithScores(keys, 0, -1, '+inf', params.cutoff); - } - - async function getFollowedTids(params) { - let tids = await db.getSortedSetMembers(`uid:${params.uid}:followed_tids`); - const filterCids = params.cid && params.cid.map(cid => parseInt(cid, 10)); - if (filterCids) { - const topicData = await Topics.getTopicsFields(tids, ['tid', 'cid']); - tids = topicData.filter(t => filterCids.includes(t.cid)).map(t => t.tid); - } - const scores = await db.sortedSetScores('topics:recent', tids); - const data = tids.map((tid, index) => ({ value: String(tid), score: scores[index] })); - return data.filter(item => item.score > params.cutoff); - } - - async function filterTidsThatHaveBlockedPosts(params) { - if (!params.blockedUids.length) { - return params.tids; - } - const topicScores = _.mapValues(_.keyBy(params.recentTids, 'value'), 'score'); - - const results = await db.sortedSetScores(`uid:${params.uid}:tids_read`, params.tids); - - const userScores = _.zipObject(params.tids, results); - - return await async.filter(params.tids, async tid => await doesTidHaveUnblockedUnreadPosts(tid, { - blockedUids: params.blockedUids, - topicTimestamp: topicScores[tid], - userLastReadTimestamp: userScores[tid], - })); - } - - async function doesTidHaveUnblockedUnreadPosts(tid, params) { - const { userLastReadTimestamp } = params; - if (!userLastReadTimestamp) { - return true; - } - let start = 0; - const count = 3; - let done = false; - let hasUnblockedUnread = params.topicTimestamp > userLastReadTimestamp; - if (!params.blockedUids.length) { - return hasUnblockedUnread; - } - while (!done) { - /* eslint-disable no-await-in-loop */ - const pidsSinceLastVisit = await db.getSortedSetRangeByScore(`tid:${tid}:posts`, start, count, userLastReadTimestamp, '+inf'); - if (!pidsSinceLastVisit.length) { - return hasUnblockedUnread; - } - let postData = await posts.getPostsFields(pidsSinceLastVisit, ['pid', 'uid']); - postData = postData.filter(post => !params.blockedUids.includes(parseInt(post.uid, 10))); - - done = postData.length > 0; - hasUnblockedUnread = postData.length > 0; - start += count; - } - return hasUnblockedUnread; - } - - Topics.pushUnreadCount = async function (uid) { - if (!uid || parseInt(uid, 10) <= 0) { - return; - } - const results = await Topics.getUnreadTids({ uid: uid, count: true }); - require('../socket.io').in(`uid_${uid}`).emit('event:unread.updateCount', { - unreadTopicCount: results[''], - unreadNewTopicCount: results.new, - unreadWatchedTopicCount: results.watched, - unreadUnrepliedTopicCount: results.unreplied, - }); - }; - - Topics.markAsUnreadForAll = async function (tid) { - await Topics.markCategoryUnreadForAll(tid); - }; - - Topics.markAsRead = async function (tids, uid) { - if (!Array.isArray(tids) || !tids.length) { - return false; - } - - tids = _.uniq(tids).filter(tid => tid && utils.isNumber(tid)); - - if (!tids.length) { - return false; - } - const [topicScores, userScores] = await Promise.all([ - Topics.getTopicsFields(tids, ['tid', 'lastposttime', 'scheduled']), - db.sortedSetScores(`uid:${uid}:tids_read`, tids), - ]); - - const topics = topicScores.filter((t, i) => t.lastposttime && - (!userScores[i] || userScores[i] < t.lastposttime)); - tids = topics.map(t => t.tid); - - if (!tids.length) { - return false; - } - - const now = Date.now(); - const scores = topics.map(topic => (topic.scheduled ? topic.lastposttime : now)); - const [topicData] = await Promise.all([ - Topics.getTopicsFields(tids, ['cid']), - db.sortedSetAdd(`uid:${uid}:tids_read`, scores, tids), - db.sortedSetRemove(`uid:${uid}:tids_unread`, tids), - ]); - - const cids = _.uniq(topicData.map(t => t && t.cid).filter(Boolean)); - await categories.markAsRead(cids, uid); - - plugins.hooks.fire('action:topics.markAsRead', { uid: uid, tids: tids }); - return true; - }; - - Topics.markAllRead = async function (uid) { - const cutoff = await Topics.unreadCutoff(uid); - const tids = await db.getSortedSetRevRangeByScore('topics:recent', 0, -1, '+inf', cutoff); - Topics.markTopicNotificationsRead(tids, uid); - await Topics.markAsRead(tids, uid); - await db.delete(`uid:${uid}:tids_unread`); - }; - - Topics.markTopicNotificationsRead = async function (tids, uid) { - if (!Array.isArray(tids) || !tids.length) { - return; - } - const nids = await user.notifications.getUnreadByField(uid, 'tid', tids); - await notifications.markReadMultiple(nids, uid); - user.notifications.pushCount(uid); - }; - - Topics.markCategoryUnreadForAll = async function (tid) { - const cid = await Topics.getTopicField(tid, 'cid'); - await categories.markAsUnreadForAll(cid); - }; - - Topics.hasReadTopics = async function (tids, uid) { - if (!(parseInt(uid, 10) > 0)) { - return tids.map(() => false); - } - const [topicScores, userScores, tids_unread, blockedUids] = await Promise.all([ - db.sortedSetScores('topics:recent', tids), - db.sortedSetScores(`uid:${uid}:tids_read`, tids), - db.sortedSetScores(`uid:${uid}:tids_unread`, tids), - user.blocks.list(uid), - ]); - - const cutoff = await Topics.unreadCutoff(uid); - const result = tids.map((tid, index) => { - const read = !tids_unread[index] && - (topicScores[index] < cutoff || - !!(userScores[index] && userScores[index] >= topicScores[index])); - return { tid: tid, read: read, index: index }; - }); - - return await async.map(result, async (data) => { - if (data.read) { - return true; - } - const hasUnblockedUnread = await doesTidHaveUnblockedUnreadPosts(data.tid, { - topicTimestamp: topicScores[data.index], - userLastReadTimestamp: userScores[data.index], - blockedUids: blockedUids, - }); - if (!hasUnblockedUnread) { - data.read = true; - } - return data.read; - }); - }; - - Topics.hasReadTopic = async function (tid, uid) { - const hasRead = await Topics.hasReadTopics([tid], uid); - return Array.isArray(hasRead) && hasRead.length ? hasRead[0] : false; - }; - - Topics.markUnread = async function (tid, uid) { - const exists = await Topics.exists(tid); - if (!exists) { - throw new Error('[[error:no-topic]]'); - } - await db.sortedSetRemove(`uid:${uid}:tids_read`, tid); - await db.sortedSetAdd(`uid:${uid}:tids_unread`, Date.now(), tid); - }; - - Topics.filterNewTids = async function (tids, uid) { - if (parseInt(uid, 10) <= 0) { - return []; - } - const scores = await db.sortedSetScores(`uid:${uid}:tids_read`, tids); - return tids.filter((tid, index) => tid && !scores[index]); - }; - - Topics.filterUnrepliedTids = async function (tids) { - const scores = await db.sortedSetScores('topics:posts', tids); - return tids.filter((tid, index) => tid && scores[index] !== null && scores[index] <= 1); - }; + Topics.getTotalUnread = async function (uid, filter) { + filter ||= ''; + const counts = await Topics.getUnreadTids({cid: 0, uid, count: true}); + return counts && counts[filter]; + }; + + Topics.getUnreadTopics = async function (parameters) { + const unreadTopics = { + showSelect: true, + nextStart: 0, + topics: [], + }; + let tids = await Topics.getUnreadTids(parameters); + unreadTopics.topicCount = tids.length; + + if (tids.length === 0) { + return unreadTopics; + } + + tids = tids.slice(parameters.start, parameters.stop === -1 ? undefined : parameters.stop + 1); + + const topicData = await Topics.getTopicsByTids(tids, parameters.uid); + if (topicData.length === 0) { + return unreadTopics; + } + + Topics.calculateTopicIndices(topicData, parameters.start); + unreadTopics.topics = topicData; + unreadTopics.nextStart = parameters.stop + 1; + return unreadTopics; + }; + + Topics.unreadCutoff = async function (uid) { + const cutoff = Date.now() - (meta.config.unreadCutoff * 86_400_000); + const data = await plugins.hooks.fire('filter:topics.unreadCutoff', {uid, cutoff}); + return Number.parseInt(data.cutoff, 10); + }; + + Topics.getUnreadTids = async function (parameters) { + const results = await Topics.getUnreadData(parameters); + return parameters.count ? results.counts : results.tids; + }; + + Topics.getUnreadData = async function (parameters) { + const uid = Number.parseInt(parameters.uid, 10); + + parameters.filter = parameters.filter || ''; + + if (parameters.cid && !Array.isArray(parameters.cid)) { + parameters.cid = [parameters.cid]; + } + + const data = await getTids(parameters); + if (uid <= 0 || !data.tids || data.tids.length === 0) { + return data; + } + + const result = await plugins.hooks.fire('filter:topics.getUnreadTids', { + uid, + tids: data.tids, + counts: data.counts, + tidsByFilter: data.tidsByFilter, + cid: parameters.cid, + filter: parameters.filter, + query: parameters.query || {}, + }); + return result; + }; + + async function getTids(parameters) { + const counts = { + '': 0, new: 0, watched: 0, unreplied: 0, + }; + const tidsByFilter = { + '': [], new: [], watched: [], unreplied: [], + }; + + if (parameters.uid <= 0) { + return {counts, tids: [], tidsByFilter}; + } + + parameters.cutoff = await Topics.unreadCutoff(parameters.uid); + + const [followedTids, ignoredTids, categoryTids, userScores, tids_unread] = await Promise.all([ + getFollowedTids(parameters), + user.getIgnoredTids(parameters.uid, 0, -1), + getCategoryTids(parameters), + db.getSortedSetRevRangeByScoreWithScores(`uid:${parameters.uid}:tids_read`, 0, -1, '+inf', parameters.cutoff), + db.getSortedSetRevRangeWithScores(`uid:${parameters.uid}:tids_unread`, 0, -1), + ]); + + const userReadTimes = _.mapValues(_.keyBy(userScores, 'value'), 'score'); + const isTopicsFollowed = {}; + for (const t of followedTids) { + isTopicsFollowed[t.value] = true; + } + + const unreadFollowed = await db.isSortedSetMembers( + `uid:${parameters.uid}:followed_tids`, tids_unread.map(t => t.value), + ); + + for (const [i, t] of tids_unread.entries()) { + isTopicsFollowed[t.value] = unreadFollowed[i]; + } + + const unreadTopics = _.unionWith(categoryTids, followedTids, (a, b) => a.value === b.value) + .filter(t => !ignoredTids.includes(t.value) + && (!userReadTimes[t.value] || t.score > userReadTimes[t.value])) + .concat(tids_unread.filter(t => !ignoredTids.includes(t.value))) + .sort((a, b) => b.score - a.score); + + let tids = _.uniq(unreadTopics.map(topic => topic.value)).slice(0, 200); + + if (tids.length === 0) { + return {counts, tids, tidsByFilter}; + } + + const blockedUids = await user.blocks.list(parameters.uid); + + tids = await filterTidsThatHaveBlockedPosts({ + uid: parameters.uid, + tids, + blockedUids, + recentTids: categoryTids, + }); + + tids = await privileges.topics.filterTids('topics:read', tids, parameters.uid); + const topicData = (await Topics.getTopicsFields(tids, ['tid', 'cid', 'uid', 'postcount', 'deleted', 'scheduled'])) + .filter(t => t.scheduled || !t.deleted); + const topicCids = _.uniq(topicData.map(topic => topic.cid)).filter(Boolean); + + const categoryWatchState = await categories.getWatchState(topicCids, parameters.uid); + const userCidState = _.zipObject(topicCids, categoryWatchState); + + const filterCids = parameters.cid && parameters.cid.map(cid => Number.parseInt(cid, 10)); + + for (const topic of topicData) { + if (topic && topic.cid && (!filterCids || filterCids.includes(topic.cid)) + && !blockedUids.includes(topic.uid)) { + if (isTopicsFollowed[topic.tid] || userCidState[topic.cid] === categories.watchStates.watching) { + tidsByFilter[''].push(topic.tid); + } + + if (isTopicsFollowed[topic.tid]) { + tidsByFilter.watched.push(topic.tid); + } + + if (topic.postcount <= 1) { + tidsByFilter.unreplied.push(topic.tid); + } + + if (!userReadTimes[topic.tid]) { + tidsByFilter.new.push(topic.tid); + } + } + } + + counts[''] = tidsByFilter[''].length; + counts.watched = tidsByFilter.watched.length; + counts.unreplied = tidsByFilter.unreplied.length; + counts.new = tidsByFilter.new.length; + + return { + counts, + tids: tidsByFilter[parameters.filter], + tidsByFilter, + }; + } + + async function getCategoryTids(parameters) { + if (plugins.hooks.hasListeners('filter:topics.unread.getCategoryTids')) { + const result = await plugins.hooks.fire('filter:topics.unread.getCategoryTids', {params: parameters, tids: []}); + return result.tids; + } + + if (parameters.filter === 'watched') { + return []; + } + + const cids = parameters.cid || await user.getWatchedCategories(parameters.uid); + const keys = cids.map(cid => `cid:${cid}:tids:lastposttime`); + return await db.getSortedSetRevRangeByScoreWithScores(keys, 0, -1, '+inf', parameters.cutoff); + } + + async function getFollowedTids(parameters) { + let tids = await db.getSortedSetMembers(`uid:${parameters.uid}:followed_tids`); + const filterCids = parameters.cid && parameters.cid.map(cid => Number.parseInt(cid, 10)); + if (filterCids) { + const topicData = await Topics.getTopicsFields(tids, ['tid', 'cid']); + tids = topicData.filter(t => filterCids.includes(t.cid)).map(t => t.tid); + } + + const scores = await db.sortedSetScores('topics:recent', tids); + const data = tids.map((tid, index) => ({value: String(tid), score: scores[index]})); + return data.filter(item => item.score > parameters.cutoff); + } + + async function filterTidsThatHaveBlockedPosts(parameters) { + if (parameters.blockedUids.length === 0) { + return parameters.tids; + } + + const topicScores = _.mapValues(_.keyBy(parameters.recentTids, 'value'), 'score'); + + const results = await db.sortedSetScores(`uid:${parameters.uid}:tids_read`, parameters.tids); + + const userScores = _.zipObject(parameters.tids, results); + + return await async.filter(parameters.tids, async tid => await doesTidHaveUnblockedUnreadPosts(tid, { + blockedUids: parameters.blockedUids, + topicTimestamp: topicScores[tid], + userLastReadTimestamp: userScores[tid], + })); + } + + async function doesTidHaveUnblockedUnreadPosts(tid, parameters) { + const {userLastReadTimestamp} = parameters; + if (!userLastReadTimestamp) { + return true; + } + + let start = 0; + const count = 3; + let done = false; + let hasUnblockedUnread = parameters.topicTimestamp > userLastReadTimestamp; + if (parameters.blockedUids.length === 0) { + return hasUnblockedUnread; + } + + while (!done) { + /* eslint-disable no-await-in-loop */ + const pidsSinceLastVisit = await db.getSortedSetRangeByScore(`tid:${tid}:posts`, start, count, userLastReadTimestamp, '+inf'); + if (pidsSinceLastVisit.length === 0) { + return hasUnblockedUnread; + } + + let postData = await posts.getPostsFields(pidsSinceLastVisit, ['pid', 'uid']); + postData = postData.filter(post => !parameters.blockedUids.includes(Number.parseInt(post.uid, 10))); + + done = postData.length > 0; + hasUnblockedUnread = postData.length > 0; + start += count; + } + + return hasUnblockedUnread; + } + + Topics.pushUnreadCount = async function (uid) { + if (!uid || Number.parseInt(uid, 10) <= 0) { + return; + } + + const results = await Topics.getUnreadTids({uid, count: true}); + require('../socket.io').in(`uid_${uid}`).emit('event:unread.updateCount', { + unreadTopicCount: results[''], + unreadNewTopicCount: results.new, + unreadWatchedTopicCount: results.watched, + unreadUnrepliedTopicCount: results.unreplied, + }); + }; + + Topics.markAsUnreadForAll = async function (tid) { + await Topics.markCategoryUnreadForAll(tid); + }; + + Topics.markAsRead = async function (tids, uid) { + if (!Array.isArray(tids) || tids.length === 0) { + return false; + } + + tids = _.uniq(tids).filter(tid => tid && utils.isNumber(tid)); + + if (tids.length === 0) { + return false; + } + + const [topicScores, userScores] = await Promise.all([ + Topics.getTopicsFields(tids, ['tid', 'lastposttime', 'scheduled']), + db.sortedSetScores(`uid:${uid}:tids_read`, tids), + ]); + + const topics = topicScores.filter((t, i) => t.lastposttime + && (!userScores[i] || userScores[i] < t.lastposttime)); + tids = topics.map(t => t.tid); + + if (tids.length === 0) { + return false; + } + + const now = Date.now(); + const scores = topics.map(topic => (topic.scheduled ? topic.lastposttime : now)); + const [topicData] = await Promise.all([ + Topics.getTopicsFields(tids, ['cid']), + db.sortedSetAdd(`uid:${uid}:tids_read`, scores, tids), + db.sortedSetRemove(`uid:${uid}:tids_unread`, tids), + ]); + + const cids = _.uniq(topicData.map(t => t && t.cid).filter(Boolean)); + await categories.markAsRead(cids, uid); + + plugins.hooks.fire('action:topics.markAsRead', {uid, tids}); + return true; + }; + + Topics.markAllRead = async function (uid) { + const cutoff = await Topics.unreadCutoff(uid); + const tids = await db.getSortedSetRevRangeByScore('topics:recent', 0, -1, '+inf', cutoff); + Topics.markTopicNotificationsRead(tids, uid); + await Topics.markAsRead(tids, uid); + await db.delete(`uid:${uid}:tids_unread`); + }; + + Topics.markTopicNotificationsRead = async function (tids, uid) { + if (!Array.isArray(tids) || tids.length === 0) { + return; + } + + const nids = await user.notifications.getUnreadByField(uid, 'tid', tids); + await notifications.markReadMultiple(nids, uid); + user.notifications.pushCount(uid); + }; + + Topics.markCategoryUnreadForAll = async function (tid) { + const cid = await Topics.getTopicField(tid, 'cid'); + await categories.markAsUnreadForAll(cid); + }; + + Topics.hasReadTopics = async function (tids, uid) { + if (!(Number.parseInt(uid, 10) > 0)) { + return tids.map(() => false); + } + + const [topicScores, userScores, tids_unread, blockedUids] = await Promise.all([ + db.sortedSetScores('topics:recent', tids), + db.sortedSetScores(`uid:${uid}:tids_read`, tids), + db.sortedSetScores(`uid:${uid}:tids_unread`, tids), + user.blocks.list(uid), + ]); + + const cutoff = await Topics.unreadCutoff(uid); + const result = tids.map((tid, index) => { + const read = !tids_unread[index] + && (topicScores[index] < cutoff + || Boolean(userScores[index] && userScores[index] >= topicScores[index])); + return {tid, read, index}; + }); + + return await async.map(result, async data => { + if (data.read) { + return true; + } + + const hasUnblockedUnread = await doesTidHaveUnblockedUnreadPosts(data.tid, { + topicTimestamp: topicScores[data.index], + userLastReadTimestamp: userScores[data.index], + blockedUids, + }); + if (!hasUnblockedUnread) { + data.read = true; + } + + return data.read; + }); + }; + + Topics.hasReadTopic = async function (tid, uid) { + const hasRead = await Topics.hasReadTopics([tid], uid); + return Array.isArray(hasRead) && hasRead.length > 0 ? hasRead[0] : false; + }; + + Topics.markUnread = async function (tid, uid) { + const exists = await Topics.exists(tid); + if (!exists) { + throw new Error('[[error:no-topic]]'); + } + + await db.sortedSetRemove(`uid:${uid}:tids_read`, tid); + await db.sortedSetAdd(`uid:${uid}:tids_unread`, Date.now(), tid); + }; + + Topics.filterNewTids = async function (tids, uid) { + if (Number.parseInt(uid, 10) <= 0) { + return []; + } + + const scores = await db.sortedSetScores(`uid:${uid}:tids_read`, tids); + return tids.filter((tid, index) => tid && !scores[index]); + }; + + Topics.filterUnrepliedTids = async function (tids) { + const scores = await db.sortedSetScores('topics:posts', tids); + return tids.filter((tid, index) => tid && scores[index] !== null && scores[index] <= 1); + }; }; diff --git a/src/topics/user.js b/src/topics/user.js index 3fec6ef..9cb0692 100644 --- a/src/topics/user.js +++ b/src/topics/user.js @@ -3,16 +3,17 @@ const db = require('../database'); module.exports = function (Topics) { - Topics.isOwner = async function (tid, uid) { - uid = parseInt(uid, 10); - if (uid <= 0) { - return false; - } - const author = await Topics.getTopicField(tid, 'uid'); - return author === uid; - }; + Topics.isOwner = async function (tid, uid) { + uid = Number.parseInt(uid, 10); + if (uid <= 0) { + return false; + } - Topics.getUids = async function (tid) { - return await db.getSortedSetRevRangeByScore(`tid:${tid}:posters`, 0, -1, '+inf', 1); - }; + const author = await Topics.getTopicField(tid, 'uid'); + return author === uid; + }; + + Topics.getUids = async function (tid) { + return await db.getSortedSetRevRangeByScore(`tid:${tid}:posters`, 0, -1, '+inf', 1); + }; }; diff --git a/src/translator.js b/src/translator.js index 1efff6b..deb6231 100644 --- a/src/translator.js +++ b/src/translator.js @@ -2,11 +2,11 @@ const winston = require('winston'); -function warn(msg) { - winston.warn(msg); +function warn(message) { + winston.warn(message); } module.exports = require('../public/src/modules/translator.common')(require('./utils'), (lang, namespace) => { - const languages = require('./languages'); - return languages.get(lang, namespace); + const languages = require('./languages'); + return languages.get(lang, namespace); }, warn); diff --git a/src/types/admin.ts b/src/types/admin.ts index 761c75e..a6d1d73 100644 --- a/src/types/admin.ts +++ b/src/types/admin.ts @@ -1,25 +1,25 @@ export type Stats = { - stats: Stat[]; + stats: Stat[]; }; export type Stat = { - yesterday: number; - today: number; - lastweek: number; - thisweek: number; - lastmonth: number; - thismonth: number; - alltime: number; - dayIncrease: string; - dayTextClass: string; - weekIncrease: string; - weekTextClass: string; - monthIncrease: string; - monthTextClass: string; - name: string; + yesterday: number; + today: number; + lastweek: number; + thisweek: number; + lastmonth: number; + thismonth: number; + alltime: number; + dayIncrease: string; + dayTextClass: string; + weekIncrease: string; + weekTextClass: string; + monthIncrease: string; + monthTextClass: string; + name: string; } & StatOptionalProperties; export type StatOptionalProperties = { - name: string; - href: string; + name: string; + href: string; }; diff --git a/src/types/breadcrumbs.ts b/src/types/breadcrumbs.ts index 336440e..ad708b8 100644 --- a/src/types/breadcrumbs.ts +++ b/src/types/breadcrumbs.ts @@ -1,9 +1,9 @@ export type Breadcrumbs = { - breadcrumbs: Breadcrumb[]; + breadcrumbs: Breadcrumb[]; }; export type Breadcrumb = { - text: string; - url: string; - cid: number; + text: string; + url: string; + cid: number; }; diff --git a/src/types/category.ts b/src/types/category.ts index 3683bf2..df00eb5 100644 --- a/src/types/category.ts +++ b/src/types/category.ts @@ -1,31 +1,31 @@ export type CategoryObject = { - cid: number; - name: string; - description: string; - descriptionParsed: string; - icon: string; - bgColor: string; - color: string; - slug: string; - parentCid: number; - topic_count: number; - post_count: number; - disabled: number; - order: number; - link: string; - numRecentReplies: number; - class: string; - imageClass: string; - isSection: number; - minTags: number; - maxTags: number; - postQueue: number; - totalPostCount: number; - totalTopicCount: number; - subCategoriesPerPage: number; + cid: number; + name: string; + description: string; + descriptionParsed: string; + icon: string; + bgColor: string; + color: string; + slug: string; + parentCid: number; + topic_count: number; + post_count: number; + disabled: number; + order: number; + link: string; + numRecentReplies: number; + class: string; + imageClass: string; + isSection: number; + minTags: number; + maxTags: number; + postQueue: number; + totalPostCount: number; + totalTopicCount: number; + subCategoriesPerPage: number; }; export type CategoryOptionalProperties = { - cid: number; - backgroundImage: string; + cid: number; + backgroundImage: string; }; diff --git a/src/types/chat.ts b/src/types/chat.ts index 319313f..957a22f 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -1,41 +1,41 @@ -import { UserObjectSlim } from './user'; +import {type UserObjectSlim} from './user'; export type MessageObject = { - content: string; - timestamp: number; - fromuid: number; - roomId: number; - deleted: boolean; - system: boolean; - edited: number; - timestampISO: string; - editedISO: string; - messageId: number; - fromUser: UserObjectSlim; - self: number; - newSet: boolean; - cleanedContent: string; + content: string; + timestamp: number; + fromuid: number; + roomId: number; + deleted: boolean; + system: boolean; + edited: number; + timestampISO: string; + editedISO: string; + messageId: number; + fromUser: UserObjectSlim; + self: number; + newSet: boolean; + cleanedContent: string; }; export type RoomObject = { - owner: number; - roomId: number; - roomName: string; - groupChat: boolean; + owner: number; + roomId: number; + roomName: string; + groupChat: boolean; }; export type RoomUserList = { - users: UserObjectSlim[]; + users: UserObjectSlim[]; }; export type RoomObjectFull = { - isOwner: boolean; - users: UserObjectSlim[]; - canReply: boolean; - groupChat: boolean; - usernames: string; - maximumUsersInChatRoom: number; - maximumChatMessageLength: number; - showUserInput: boolean; - isAdminOrGlobalMod: boolean; + isOwner: boolean; + users: UserObjectSlim[]; + canReply: boolean; + groupChat: boolean; + usernames: string; + maximumUsersInChatRoom: number; + maximumChatMessageLength: number; + showUserInput: boolean; + isAdminOrGlobalMod: boolean; } & RoomObject & MessageObject; diff --git a/src/types/commonProps.ts b/src/types/commonProps.ts index 99bb673..9abd72d 100644 --- a/src/types/commonProps.ts +++ b/src/types/commonProps.ts @@ -1,33 +1,33 @@ -import { TagObject } from './tag'; +import {type TagObject} from './tag'; export type CommonProps = { - loggedIn: boolean; - relative_path: string; - template: Template; - url: string; - bodyClass: string; - _header: Header; - widgets: Widget[]; + loggedIn: boolean; + relative_path: string; + template: Template; + url: string; + bodyClass: string; + _header: Header; + widgets: Widget[]; }; -export interface Template { - name: string; -} +export type Template = { + name: string; +}; -export interface Header { - tags: TagObject[]; - link: Link[]; -} +export type Header = { + tags: TagObject[]; + link: Link[]; +}; -export interface Link { - rel: string; - type: string; - href: string; - title: string; - sizes: string; - as: string; -} +export type Link = { + rel: string; + type: string; + href: string; + title: string; + sizes: string; + as: string; +}; -export interface Widget { - html: string; -} +export type Widget = { + html: string; +}; diff --git a/src/types/error.ts b/src/types/error.ts index ba1e709..a326963 100644 --- a/src/types/error.ts +++ b/src/types/error.ts @@ -1,5 +1,5 @@ -import { StatusObject } from './status'; +import {type StatusObject} from './status'; export type ErrorObject = { - status: StatusObject; + status: StatusObject; }; diff --git a/src/types/flag.ts b/src/types/flag.ts index ef18330..1a855aa 100644 --- a/src/types/flag.ts +++ b/src/types/flag.ts @@ -1,55 +1,54 @@ -import { UserObjectSlim } from './user'; +import {type UserObjectSlim} from './user'; export type FlagHistoryObject = { - history: History[]; + history: History[]; }; -interface History { - uid: number; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fields: any; - meta: Meta[]; - datetime: number; - datetimeISO: string; - user: UserObjectSlim; -} - -interface Meta { - key: string; - value: string; - labelClass: string; -} +type History = { + uid: number; -export type FlagNotesObject = { - notes: Note[]; + fields: any; + meta: Meta[]; + datetime: number; + datetimeISO: string; + user: UserObjectSlim; }; +type Meta = { + key: string; + value: string; + labelClass: string; +}; -export interface Note { - uid: number; - content: string; - datetime: number; - datetimeISO: string; - user: UserObjectSlim; -} +export type FlagNotesObject = { + notes: Note[]; +}; + +export type Note = { + uid: number; + content: string; + datetime: number; + datetimeISO: string; + user: UserObjectSlim; +}; export type FlagObject = { - state: string; - flagId: number; - type: string; - targetId: number; - targetUid: number; - datetime: number; - datetimeISO: string; - target_readable: string; - target: object; - assignee: number; - reports: Reports; + state: string; + flagId: number; + type: string; + targetId: number; + targetUid: number; + datetime: number; + datetimeISO: string; + target_readable: string; + target: Record; + assignee: number; + reports: Reports; } & FlagHistoryObject & FlagNotesObject; -export interface Reports { - value: string; - timestamp: number; - timestampISO: string; - reporter: UserObjectSlim; -} +export type Reports = { + value: string; + timestamp: number; + timestampISO: string; + reporter: UserObjectSlim; +}; diff --git a/src/types/group.ts b/src/types/group.ts index c930d0f..49dfce0 100644 --- a/src/types/group.ts +++ b/src/types/group.ts @@ -1,44 +1,44 @@ -import { UserObjectSlim } from './user'; +import {type UserObjectSlim} from './user'; export type GroupDataObject = { - name: string; - slug: string; - createtime: number; - userTitle: number; - userTitleEscaped: number; - userTitleEnabled: number; - description: string; - memberCount: number; - hidden: number; - system: number; - private: number; - disableJoinRequests: number; - disableLeave: number; - 'cover:url': string; - 'cover:thumb:url': string; - 'cover:position': string; - nameEncoded: string; - displayName: string; - labelColor: string; - textColor: string; - icon: string; - createtimeISO: string; - memberPostCids: string; - memberPostCidsArray: number[]; + name: string; + slug: string; + createtime: number; + userTitle: number; + userTitleEscaped: number; + userTitleEnabled: number; + description: string; + memberCount: number; + hidden: number; + system: number; + private: number; + disableJoinRequests: number; + disableLeave: number; + 'cover:url': string; + 'cover:thumb:url': string; + 'cover:position': string; + nameEncoded: string; + displayName: string; + labelColor: string; + textColor: string; + icon: string; + createtimeISO: string; + memberPostCids: string; + memberPostCidsArray: number[]; }; export type GroupFullObject = GroupDataObject & GroupFullObjectProperties; export type GroupFullObjectProperties = { - descriptionParsed: string; - members: UserObjectSlim[]; - membersNextStart: number; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - pending: any[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - invited: any[]; - isMember: boolean; - isPending: boolean; - isInvited: boolean; - isOwner: boolean; + descriptionParsed: string; + members: UserObjectSlim[]; + membersNextStart: number; + + pending: any[]; + + invited: any[]; + isMember: boolean; + isPending: boolean; + isInvited: boolean; + isOwner: boolean; }; diff --git a/src/types/pagination.ts b/src/types/pagination.ts index 06141af..744454d 100644 --- a/src/types/pagination.ts +++ b/src/types/pagination.ts @@ -1,30 +1,30 @@ export type PaginationObject = { - pagination: Pagination; + pagination: Pagination; }; -export interface Pagination { - prev: ActivePage; - next: ActivePage; - first: ActivePage; - last: ActivePage; - rel: Relation[]; - pages: Page[]; - currentPage: number; - pageCount: number; -} +export type Pagination = { + prev: ActivePage; + next: ActivePage; + first: ActivePage; + last: ActivePage; + rel: Relation[]; + pages: Page[]; + currentPage: number; + pageCount: number; +}; -interface ActivePage { - page: number; - active: boolean; -} +type ActivePage = { + page: number; + active: boolean; +}; -interface Relation { - rel: string; - href: string; -} +type Relation = { + rel: string; + href: string; +}; -interface Page { - page: number; - active: boolean; - qs: string; -} +type Page = { + page: number; + active: boolean; + qs: string; +}; diff --git a/src/types/post.ts b/src/types/post.ts index 6453336..784004e 100644 --- a/src/types/post.ts +++ b/src/types/post.ts @@ -1,22 +1,22 @@ -import { CategoryObject } from './category'; -import { TopicObject } from './topic'; -import { UserObjectSlim } from './user'; +import {type CategoryObject} from './category'; +import {type TopicObject} from './topic'; +import {type UserObjectSlim} from './user'; export type PostObject = { - pid: number; - tid: number; - content: string; - uid: number; - timestamp: number; - deleted: boolean; - upvotes: number; - downvotes: number; - votes: number; - timestampISO: string; - user: UserObjectSlim; - topic: TopicObject; - category: CategoryObject; - isMainPost: boolean; - replies: number; - resolved: boolean; + pid: number; + tid: number; + content: string; + uid: number; + timestamp: number; + deleted: boolean; + upvotes: number; + downvotes: number; + votes: number; + timestampISO: string; + user: UserObjectSlim; + topic: TopicObject; + category: CategoryObject; + isMainPost: boolean; + replies: number; + resolved: boolean; }; diff --git a/src/types/settings.ts b/src/types/settings.ts index 79b0018..be755b6 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -1,41 +1,41 @@ export type SettingsObject = { - showemail: boolean; - usePagination: boolean; - topicsPerPage: number; - postsPerPage: number; - topicPostSort: string; - openOutgoingLinksInNewTab: boolean; - dailyDigestFreq: string; - showfullname: boolean; - followTopicsOnCreate: boolean; - followTopicsOnReply: boolean; - restrictChat: boolean; - topicSearchEnabled: boolean; - updateUrlWithPostIndex: boolean; - categoryTopicSort: string; - userLang: string; - bootswatchSkin: string; - homePageRoute: string; - scrollToMyPost: boolean; - 'notificationType_new-chat': string; - 'notificationType_new-group-chat': string; - 'notificationType_new-reply': string; - 'notificationType_post-edit': string; - sendChatNotifications: boolean; - sendPostNotifications: boolean; - notificationType_upvote: string; - 'notificationType_new-topic': string; - notificationType_follow: string; - 'notificationType_group-invite': string; - 'notificationType_group-leave': string; - upvoteNotifFreq: string; - notificationType_mention: string; - acpLang: string; - 'notificationType_new-register': string; - 'notificationType_post-queue': string; - 'notificationType_new-post-flag': string; - 'notificationType_new-user-flag': string; - categoryWatchState: string; - 'notificationType_group-request-membership': string; - uid: number; + showemail: boolean; + usePagination: boolean; + topicsPerPage: number; + postsPerPage: number; + topicPostSort: string; + openOutgoingLinksInNewTab: boolean; + dailyDigestFreq: string; + showfullname: boolean; + followTopicsOnCreate: boolean; + followTopicsOnReply: boolean; + restrictChat: boolean; + topicSearchEnabled: boolean; + updateUrlWithPostIndex: boolean; + categoryTopicSort: string; + userLang: string; + bootswatchSkin: string; + homePageRoute: string; + scrollToMyPost: boolean; + 'notificationType_new-chat': string; + 'notificationType_new-group-chat': string; + 'notificationType_new-reply': string; + 'notificationType_post-edit': string; + sendChatNotifications: boolean; + sendPostNotifications: boolean; + notificationType_upvote: string; + 'notificationType_new-topic': string; + notificationType_follow: string; + 'notificationType_group-invite': string; + 'notificationType_group-leave': string; + upvoteNotifFreq: string; + notificationType_mention: string; + acpLang: string; + 'notificationType_new-register': string; + 'notificationType_post-queue': string; + 'notificationType_new-post-flag': string; + 'notificationType_new-user-flag': string; + categoryWatchState: string; + 'notificationType_group-request-membership': string; + uid: number; }; diff --git a/src/types/social.ts b/src/types/social.ts index 9c9847f..94a0243 100644 --- a/src/types/social.ts +++ b/src/types/social.ts @@ -1,6 +1,6 @@ export type Network = { - id: string; - name: string; - class: string; - activated: boolean | null; + id: string; + name: string; + class: string; + activated: boolean | undefined; }; diff --git a/src/types/status.ts b/src/types/status.ts index 9432fa2..bee612d 100644 --- a/src/types/status.ts +++ b/src/types/status.ts @@ -1,4 +1,4 @@ export type StatusObject = { - code: string; - message: string; + code: string; + message: string; }; diff --git a/src/types/tag.ts b/src/types/tag.ts index 46a8fd0..05b34ce 100644 --- a/src/types/tag.ts +++ b/src/types/tag.ts @@ -1,7 +1,7 @@ export type TagObject = { - value: string; - score: number; - valueEscaped: string; - color: string; - bgColor: string; + value: string; + score: number; + valueEscaped: string; + color: string; + bgColor: string; }; diff --git a/src/types/topic.ts b/src/types/topic.ts index 0d2c08f..766f7d2 100644 --- a/src/types/topic.ts +++ b/src/types/topic.ts @@ -1,81 +1,81 @@ -import { CategoryObject } from './category'; -import { TagObject } from './tag'; -import { UserObjectSlim } from './user'; +import {type CategoryObject} from './category'; +import {type TagObject} from './tag'; +import {type UserObjectSlim} from './user'; export type TopicObject = TopicObjectSlim & TopicObjectCoreProperties & TopicObjectOptionalProperties; export type TopicObjectCoreProperties = { - lastposttime: number; - category: CategoryObject; - user: UserObjectSlim; - teaser: Teaser; - tags: TagObject[]; - isOwner: boolean; - ignored: boolean; - unread: boolean; - bookmark: number; - unreplied: boolean; - icons: string[]; + lastposttime: number; + category: CategoryObject; + user: UserObjectSlim; + teaser: Teaser; + tags: TagObject[]; + isOwner: boolean; + ignored: boolean; + unread: boolean; + bookmark: number; + unreplied: boolean; + icons: string[]; }; export type TopicObjectOptionalProperties = { - tid: number; - thumb: string; - pinExpiry: number; - pinExpiryISO: string; - index: number; + tid: number; + thumb: string; + pinExpiry: number; + pinExpiryISO: string; + index: number; }; -interface Teaser { - pid: number; - uid: number; - timestamp: number; - tid: number; - content: string; - timestampISO: string; - user: UserObjectSlim; - index: number; -} +type Teaser = { + pid: number; + uid: number; + timestamp: number; + tid: number; + content: string; + timestampISO: string; + user: UserObjectSlim; + index: number; +}; export type TopicObjectSlim = TopicSlimProperties & TopicSlimOptionalProperties; export type TopicSlimProperties = { - tid: number; - uid: number; - cid: number; - title: string; - slug: string; - mainPid: number; - postcount: string; - viewcount: string; - postercount: string; - scheduled: string; - deleted: string; - deleterUid: string; - titleRaw: string; - locked: string; - pinned: number; - timestamp: string; - timestampISO: number; - lastposttime: string; - lastposttimeISO: number; - pinExpiry: number; - pinExpiryISO: number; - upvotes: string; - downvotes: string; - votes: string; - teaserPid: number | string; - thumbs: Thumb[]; + tid: number; + uid: number; + cid: number; + title: string; + slug: string; + mainPid: number; + postcount: string; + viewcount: string; + postercount: string; + scheduled: string; + deleted: string; + deleterUid: string; + titleRaw: string; + locked: string; + pinned: number; + timestamp: string; + timestampISO: number; + lastposttime: string; + lastposttimeISO: number; + pinExpiry: number; + pinExpiryISO: number; + upvotes: string; + downvotes: string; + votes: string; + teaserPid: number | string; + thumbs: Thumb[]; }; export type Thumb = { - id: number; - name: string; - url: string; + id: number; + name: string; + url: string; }; export type TopicSlimOptionalProperties = { - tid: number; - numThumbs: number; + tid: number; + numThumbs: number; }; diff --git a/src/types/user.ts b/src/types/user.ts index a657277..eecd6cf 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -1,133 +1,133 @@ -import { GroupFullObject } from './group'; -import { StatusObject } from './status'; +import {type GroupFullObject} from './group'; +import {type StatusObject} from './status'; export type UserObjectSlim = { - uid: number; - username: string; - displayname: string; - userslug: string; - picture: string; - status: StatusObject; - postcount: number; - reputation: number; - 'email:confirmed': number; - lastonline: number; - flags: number; - banned: number; - 'banned:expire': number; - joindate: number; - accounttype: string; - 'icon:text': string; - 'icon:bgColor': string; - joindateISO: string; - lastonlineISO: string; - banned_until: number; - banned_until_readable: string; + uid: number; + username: string; + displayname: string; + userslug: string; + picture: string; + status: StatusObject; + postcount: number; + reputation: number; + 'email:confirmed': number; + lastonline: number; + flags: number; + banned: number; + 'banned:expire': number; + joindate: number; + accounttype: string; + 'icon:text': string; + 'icon:bgColor': string; + joindateISO: string; + lastonlineISO: string; + banned_until: number; + banned_until_readable: string; }; export type UserObjectACP = UserObjectSlim & { - administrator: boolean; - ip: string; - ips: string[]; + administrator: boolean; + ip: string; + ips: string[]; }; export type UserObject = UserObjectSlim & { - email: string; - fullname: string; - location: string; - birthday: string; - website: string; - aboutme: string; - signature: string; - uploadedpicture: string; - profileviews: number; - topiccount: number; - lastposttime: number; - followerCount: number; - followingCount: number; - 'cover:url': string; - 'cover:position': string; - groupTitle: string; - groupTitleArray: string[]; + email: string; + fullname: string; + location: string; + birthday: string; + website: string; + aboutme: string; + signature: string; + uploadedpicture: string; + profileviews: number; + topiccount: number; + lastposttime: number; + followerCount: number; + followingCount: number; + 'cover:url': string; + 'cover:position': string; + groupTitle: string; + groupTitleArray: string[]; }; export type UserObjectFull = UserObject & { - aboutmeParsed: string; - age: number; - emailClass: string; - ips: string[]; - moderationNote: string; - counts: Counts; - isBlocked: boolean; - blocksCount: number; - yourid: number; - theirid: number; - isTargetAdmin: boolean; - isAdmin: boolean; - isGlobalModerator: boolean; - isModerator: boolean; - isAdminOrGlobalModerator: boolean; - isAdminOrGlobalModeratorOrModerator: boolean; - isSelfOrAdminOrGlobalModerator: boolean; - canEdit: boolean; - canBan: boolean; - canFlag: boolean; - canChangePassword: boolean; - isSelf: boolean; - isFollowing: boolean; - hasPrivateChat: number; - showHidden: boolean; - groups: GroupFullObject[]; - disableSignatures: boolean; - 'reputation:disabled': boolean; - 'downvote:disabled': boolean; - profile_links: ProfileLink[]; - sso: SSO[]; - websiteLink: string; - websiteName: string; - 'username:disableEdit': number; - 'email:disableEdit': number; + aboutmeParsed: string; + age: number; + emailClass: string; + ips: string[]; + moderationNote: string; + counts: Counts; + isBlocked: boolean; + blocksCount: number; + yourid: number; + theirid: number; + isTargetAdmin: boolean; + isAdmin: boolean; + isGlobalModerator: boolean; + isModerator: boolean; + isAdminOrGlobalModerator: boolean; + isAdminOrGlobalModeratorOrModerator: boolean; + isSelfOrAdminOrGlobalModerator: boolean; + canEdit: boolean; + canBan: boolean; + canFlag: boolean; + canChangePassword: boolean; + isSelf: boolean; + isFollowing: boolean; + hasPrivateChat: number; + showHidden: boolean; + groups: GroupFullObject[]; + disableSignatures: boolean; + 'reputation:disabled': boolean; + 'downvote:disabled': boolean; + profile_links: ProfileLink[]; + sso: SSO[]; + websiteLink: string; + websiteName: string; + 'username:disableEdit': number; + 'email:disableEdit': number; }; export type Counts = { - best: number; - blocks: number; - bookmarks: number; - categoriesWatched: number; - downvoted: number; - followers: number; - following: number; - groups: number; - ignored: number; - posts: number; - topics: number; - uploaded: number; - upvoted: number; - watched: number; + best: number; + blocks: number; + bookmarks: number; + categoriesWatched: number; + downvoted: number; + followers: number; + following: number; + groups: number; + ignored: number; + posts: number; + topics: number; + uploaded: number; + upvoted: number; + watched: number; }; export type ProfileLink = { - id: string; - route: string; - name: string; - visibility: Visibility; - public: boolean; - icon: string; + id: string; + route: string; + name: string; + visibility: Visibility; + public: boolean; + icon: string; }; export type Visibility = { - self: boolean; - other: boolean; - moderator: boolean; - globalMod: boolean; - admin: boolean; - canViewInfo: boolean; + self: boolean; + other: boolean; + moderator: boolean; + globalMod: boolean; + admin: boolean; + canViewInfo: boolean; }; export type SSO = { - associated: boolean; - url: string; - name: string; - icon: string; - deathUrl: string; + associated: boolean; + url: string; + name: string; + icon: string; + deathUrl: string; }; diff --git a/src/upgrade.js b/src/upgrade.js index d9e4e56..c5a6e45 100644 --- a/src/upgrade.js +++ b/src/upgrade.js @@ -1,17 +1,16 @@ 'use strict'; -const path = require('path'); -const util = require('util'); +const path = require('node:path'); +const util = require('node:util'); +const readline = require('node:readline'); const semver = require('semver'); -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'); +const {paths} = require('./constants'); /* * Need to write an upgrade script for NodeBB? Cool. * @@ -24,181 +23,186 @@ const { paths } = require('./constants'); const Upgrade = module.exports; Upgrade.getAll = async function () { - let files = await file.walk(path.join(__dirname, './upgrades')); - - // Sort the upgrade scripts based on version - files = files.filter(file => path.basename(file) !== 'TEMPLATE').sort((a, b) => { - const versionA = path.dirname(a).split(path.sep).pop(); - const versionB = path.dirname(b).split(path.sep).pop(); - const semverCompare = semver.compare(versionA, versionB); - if (semverCompare) { - return semverCompare; - } - const timestampA = require(a).timestamp; - const timestampB = require(b).timestamp; - return timestampA - timestampB; - }); - - await Upgrade.appendPluginScripts(files); - - // check duplicates and error - const seen = {}; - const dupes = []; - files.forEach((file) => { - if (seen[file]) { - dupes.push(file); - } else { - seen[file] = true; - } - }); - if (dupes.length) { - winston.error(`Found duplicate upgrade scripts\n${dupes}`); - throw new Error('[[error:duplicate-upgrade-scripts]]'); - } - - return files; + let files = await file.walk(path.join(__dirname, './upgrades')); + + // Sort the upgrade scripts based on version + files = files.filter(file => path.basename(file) !== 'TEMPLATE').sort((a, b) => { + const versionA = path.dirname(a).split(path.sep).pop(); + const versionB = path.dirname(b).split(path.sep).pop(); + const semverCompare = semver.compare(versionA, versionB); + if (semverCompare) { + return semverCompare; + } + + const timestampA = require(a).timestamp; + const timestampB = require(b).timestamp; + return timestampA - timestampB; + }); + + await Upgrade.appendPluginScripts(files); + + // Check duplicates and error + const seen = {}; + const dupes = []; + for (const file of files) { + if (seen[file]) { + dupes.push(file); + } else { + seen[file] = true; + } + } + + if (dupes.length > 0) { + winston.error(`Found duplicate upgrade scripts\n${dupes}`); + throw new Error('[[error:duplicate-upgrade-scripts]]'); + } + + return files; }; Upgrade.appendPluginScripts = async function (files) { - // Find all active plugins - const activePlugins = await plugins.getActive(); - activePlugins.forEach((plugin) => { - const configPath = path.join(paths.nodeModules, plugin, 'plugin.json'); - try { - const pluginConfig = require(configPath); - if (pluginConfig.hasOwnProperty('upgrades') && Array.isArray(pluginConfig.upgrades)) { - pluginConfig.upgrades.forEach((script) => { - files.push(path.join(path.dirname(configPath), script)); - }); - } - } catch (e) { - if (e.code !== 'MODULE_NOT_FOUND') { - winston.error(e.stack); - } - } - }); - return files; + // Find all active plugins + const activePlugins = await plugins.getActive(); + for (const plugin of activePlugins) { + const configPath = path.join(paths.nodeModules, plugin, 'plugin.json'); + try { + const pluginConfig = require(configPath); + if (pluginConfig.hasOwnProperty('upgrades') && Array.isArray(pluginConfig.upgrades)) { + for (const script of pluginConfig.upgrades) { + files.push(path.join(path.dirname(configPath), script)); + } + } + } catch (error) { + if (error.code !== 'MODULE_NOT_FOUND') { + winston.error(error.stack); + } + } + } + + return files; }; Upgrade.check = async function () { - // Throw 'schema-out-of-date' if not all upgrade scripts have run - const files = await Upgrade.getAll(); - const executed = await db.getSortedSetRange('schemaLog', 0, -1); - const remainder = files.filter(name => !executed.includes(path.basename(name, '.js'))); - if (remainder.length > 0) { - throw new Error('schema-out-of-date'); - } + // Throw 'schema-out-of-date' if not all upgrade scripts have run + const files = await Upgrade.getAll(); + const executed = await db.getSortedSetRange('schemaLog', 0, -1); + const remainder = files.filter(name => !executed.includes(path.basename(name, '.js'))); + if (remainder.length > 0) { + throw new Error('schema-out-of-date'); + } }; Upgrade.run = async function () { - console.log('\nParsing upgrade scripts... '); - - const [completed, available] = await Promise.all([ - db.getSortedSetRange('schemaLog', 0, -1), - Upgrade.getAll(), - ]); - - let skipped = 0; - const queue = available.filter((cur) => { - const upgradeRan = completed.includes(path.basename(cur, '.js')); - if (upgradeRan) { - skipped += 1; - } - return !upgradeRan; - }); - - await Upgrade.process(queue, skipped); + console.log('\nParsing upgrade scripts... '); + + const [completed, available] = await Promise.all([ + db.getSortedSetRange('schemaLog', 0, -1), + Upgrade.getAll(), + ]); + + let skipped = 0; + const queue = available.filter(current => { + const upgradeRan = completed.includes(path.basename(current, '.js')); + if (upgradeRan) { + skipped += 1; + } + + return !upgradeRan; + }); + + await Upgrade.process(queue, skipped); }; Upgrade.runParticular = async function (names) { - console.log('\nParsing upgrade scripts... '); - const files = await file.walk(path.join(__dirname, './upgrades')); - await Upgrade.appendPluginScripts(files); - const upgrades = files.filter(file => names.includes(path.basename(file, '.js'))); - await Upgrade.process(upgrades, 0); + console.log('\nParsing upgrade scripts... '); + const files = await file.walk(path.join(__dirname, './upgrades')); + await Upgrade.appendPluginScripts(files); + const upgrades = files.filter(file => names.includes(path.basename(file, '.js'))); + await Upgrade.process(upgrades, 0); }; Upgrade.process = async function (files, skipCount) { - console.log(`${chalk.green('OK')} | ${chalk.cyan(`${files.length} script(s) found`)}${skipCount > 0 ? chalk.cyan(`, ${skipCount} skipped`) : ''}`); - const [schemaDate, schemaLogCount] = await Promise.all([ - db.get('schemaDate'), - db.sortedSetCard('schemaLog'), - ]); - - for (const file of files) { - /* eslint-disable no-await-in-loop */ - const scriptExport = require(file); - const date = new Date(scriptExport.timestamp); - const version = path.dirname(file).split('/').pop(); - const progress = { - current: 0, - counter: 0, - total: 0, - incr: Upgrade.incrementProgress, - script: scriptExport, - date: date, - }; - - process.stdout.write(`${chalk.white(' → ') + chalk.gray(`[${[date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate()].join('/')}] `) + scriptExport.name}...`); - - // For backwards compatibility, cross-reference with schemaDate (if found). If a script's date is older, skip it - if ((!schemaDate && !schemaLogCount) || (scriptExport.timestamp <= schemaDate && semver.lt(version, '1.5.0'))) { - process.stdout.write(chalk.grey(' skipped\n')); - - await db.sortedSetAdd('schemaLog', Date.now(), path.basename(file, '.js')); - // eslint-disable-next-line no-continue - continue; - } - - // Promisify method if necessary - if (scriptExport.method.constructor && scriptExport.method.constructor.name !== 'AsyncFunction') { - scriptExport.method = util.promisify(scriptExport.method); - } - - // Do the upgrade... - const upgradeStart = Date.now(); - try { - await scriptExport.method.bind({ - progress: progress, - })(); - } catch (err) { - console.error('Error occurred'); - throw err; - } - const upgradeDuration = ((Date.now() - upgradeStart) / 1000).toFixed(2); - process.stdout.write(chalk.green(` OK (${upgradeDuration} seconds)\n`)); - - // Record success in schemaLog - await db.sortedSetAdd('schemaLog', Date.now(), path.basename(file, '.js')); - } - - console.log(chalk.green('Schema update complete!\n')); + console.log(`${chalk.green('OK')} | ${chalk.cyan(`${files.length} script(s) found`)}${skipCount > 0 ? chalk.cyan(`, ${skipCount} skipped`) : ''}`); + const [schemaDate, schemaLogCount] = await Promise.all([ + db.get('schemaDate'), + db.sortedSetCard('schemaLog'), + ]); + + for (const file of files) { + /* eslint-disable no-await-in-loop */ + const scriptExport = require(file); + const date = new Date(scriptExport.timestamp); + const version = path.dirname(file).split('/').pop(); + const progress = { + current: 0, + counter: 0, + total: 0, + incr: Upgrade.incrementProgress, + script: scriptExport, + date, + }; + + process.stdout.write(`${chalk.white(' → ') + chalk.gray(`[${[date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate()].join('/')}] `) + scriptExport.name}...`); + + // For backwards compatibility, cross-reference with schemaDate (if found). If a script's date is older, skip it + if ((!schemaDate && !schemaLogCount) || (scriptExport.timestamp <= schemaDate && semver.lt(version, '1.5.0'))) { + process.stdout.write(chalk.grey(' skipped\n')); + + await db.sortedSetAdd('schemaLog', Date.now(), path.basename(file, '.js')); + + continue; + } + + // Promisify method if necessary + if (scriptExport.method.constructor && scriptExport.method.constructor.name !== 'AsyncFunction') { + scriptExport.method = util.promisify(scriptExport.method); + } + + // Do the upgrade... + const upgradeStart = Date.now(); + try { + await scriptExport.method.bind({ + progress, + })(); + } catch (error) { + console.error('Error occurred'); + throw error; + } + + const upgradeDuration = ((Date.now() - upgradeStart) / 1000).toFixed(2); + process.stdout.write(chalk.green(` OK (${upgradeDuration} seconds)\n`)); + + // Record success in schemaLog + await db.sortedSetAdd('schemaLog', Date.now(), path.basename(file, '.js')); + } + + console.log(chalk.green('Schema update complete!\n')); }; Upgrade.incrementProgress = function (value) { - // Newline on first invocation - if (this.current === 0) { - process.stdout.write('\n'); - } - - this.current += value || 1; - this.counter += value || 1; - const step = (this.total ? Math.floor(this.total / 100) : 100); - - if (this.counter > step || this.current >= this.total) { - this.counter -= step; - let percentage = 0; - let filled = 0; - let unfilled = 15; - if (this.total) { - percentage = `${Math.floor((this.current / this.total) * 100)}%`; - filled = Math.floor((this.current / this.total) * 15); - unfilled = Math.max(0, 15 - filled); - } - - readline.cursorTo(process.stdout, 0); - process.stdout.write(` [${filled ? new Array(filled).join('#') : ''}${new Array(unfilled).join(' ')}] (${this.current}/${this.total || '??'}) ${percentage} `); - } + // Newline on first invocation + if (this.current === 0) { + process.stdout.write('\n'); + } + + this.current += value || 1; + this.counter += value || 1; + const step = (this.total ? Math.floor(this.total / 100) : 100); + + if (this.counter > step || this.current >= this.total) { + this.counter -= step; + let percentage = 0; + let filled = 0; + let unfilled = 15; + if (this.total) { + percentage = `${Math.floor((this.current / this.total) * 100)}%`; + filled = Math.floor((this.current / this.total) * 15); + unfilled = Math.max(0, 15 - filled); + } + + readline.cursorTo(process.stdout, 0); + process.stdout.write(` [${filled ? new Array(filled).join('#') : ''}${new Array(unfilled).join(' ')}] (${this.current}/${this.total || '??'}) ${percentage} `); + } }; require('./promisify')(Upgrade); diff --git a/src/upgrades/1.0.0/chat_room_hashes.js b/src/upgrades/1.0.0/chat_room_hashes.js index 37e035e..1717fe5 100644 --- a/src/upgrades/1.0.0/chat_room_hashes.js +++ b/src/upgrades/1.0.0/chat_room_hashes.js @@ -3,37 +3,39 @@ const async = require('async'); const db = require('../../database'); - module.exports = { - name: 'Chat room hashes', - timestamp: Date.UTC(2015, 11, 23), - method: function (callback) { - db.getObjectField('global', 'nextChatRoomId', (err, nextChatRoomId) => { - if (err) { - return callback(err); - } - let currentChatRoomId = 1; - async.whilst((next) => { - next(null, currentChatRoomId <= nextChatRoomId); - }, (next) => { - db.getSortedSetRange(`chat:room:${currentChatRoomId}:uids`, 0, 0, (err, uids) => { - if (err) { - return next(err); - } - if (!Array.isArray(uids) || !uids.length || !uids[0]) { - currentChatRoomId += 1; - return next(); - } + name: 'Chat room hashes', + timestamp: Date.UTC(2015, 11, 23), + method(callback) { + db.getObjectField('global', 'nextChatRoomId', (error, nextChatRoomId) => { + if (error) { + return callback(error); + } + + let currentChatRoomId = 1; + async.whilst(next => { + next(null, currentChatRoomId <= nextChatRoomId); + }, next => { + db.getSortedSetRange(`chat:room:${currentChatRoomId}:uids`, 0, 0, (error, uids) => { + if (error) { + return next(error); + } + + if (!Array.isArray(uids) || uids.length === 0 || !uids[0]) { + currentChatRoomId += 1; + return next(); + } + + db.setObject(`chat:room:${currentChatRoomId}`, {owner: uids[0], roomId: currentChatRoomId}, error_ => { + if (error_) { + return next(error_); + } - db.setObject(`chat:room:${currentChatRoomId}`, { owner: uids[0], roomId: currentChatRoomId }, (err) => { - if (err) { - return next(err); - } - currentChatRoomId += 1; - next(); - }); - }); - }, callback); - }); - }, + currentChatRoomId += 1; + next(); + }); + }); + }, callback); + }); + }, }; diff --git a/src/upgrades/1.0.0/chat_upgrade.js b/src/upgrades/1.0.0/chat_upgrade.js index 73b82ae..8602664 100644 --- a/src/upgrades/1.0.0/chat_upgrade.js +++ b/src/upgrades/1.0.0/chat_upgrade.js @@ -1,83 +1,84 @@ 'use strict'; - const async = require('async'); const winston = require('winston'); const db = require('../../database'); module.exports = { - name: 'Upgrading chats', - timestamp: Date.UTC(2015, 11, 15), - method: function (callback) { - db.getObjectFields('global', ['nextMid', 'nextChatRoomId'], (err, globalData) => { - if (err) { - return callback(err); - } + name: 'Upgrading chats', + timestamp: Date.UTC(2015, 11, 15), + method(callback) { + db.getObjectFields('global', ['nextMid', 'nextChatRoomId'], (error, globalData) => { + if (error) { + return callback(error); + } + + const rooms = {}; + let roomId = globalData.nextChatRoomId || 1; + let currentMid = 1; + + async.whilst(next => { + next(null, currentMid <= globalData.nextMid); + }, next => { + db.getObject(`message:${currentMid}`, (error, message) => { + if (error || !message) { + winston.verbose('skipping chat message ', currentMid); + currentMid += 1; + return next(error); + } - const rooms = {}; - let roomId = globalData.nextChatRoomId || 1; - let currentMid = 1; + const pairID = [Number.parseInt(message.fromuid, 10), Number.parseInt(message.touid, 10)].sort().join(':'); + const messageTime = Number.parseInt(message.timestamp, 10); - async.whilst((next) => { - next(null, currentMid <= globalData.nextMid); - }, (next) => { - db.getObject(`message:${currentMid}`, (err, message) => { - if (err || !message) { - winston.verbose('skipping chat message ', currentMid); - currentMid += 1; - return next(err); - } + function addMessageToUids(roomId, callback) { + async.parallel([ + function (next) { + db.sortedSetAdd(`uid:${message.fromuid}:chat:room:${roomId}:mids`, messageTime, currentMid, next); + }, + function (next) { + db.sortedSetAdd(`uid:${message.touid}:chat:room:${roomId}:mids`, messageTime, currentMid, next); + }, + ], callback); + } - const pairID = [parseInt(message.fromuid, 10), parseInt(message.touid, 10)].sort().join(':'); - const msgTime = parseInt(message.timestamp, 10); + if (rooms[pairID]) { + winston.verbose(`adding message ${currentMid} to existing roomID ${roomId}`); + addMessageToUids(rooms[pairID], error_ => { + if (error_) { + return next(error_); + } - function addMessageToUids(roomId, callback) { - async.parallel([ - function (next) { - db.sortedSetAdd(`uid:${message.fromuid}:chat:room:${roomId}:mids`, msgTime, currentMid, next); - }, - function (next) { - db.sortedSetAdd(`uid:${message.touid}:chat:room:${roomId}:mids`, msgTime, currentMid, next); - }, - ], callback); - } + currentMid += 1; + next(); + }); + } else { + winston.verbose(`adding message ${currentMid} to new roomID ${roomId}`); + async.parallel([ + function (next) { + db.sortedSetAdd(`uid:${message.fromuid}:chat:rooms`, messageTime, roomId, next); + }, + function (next) { + db.sortedSetAdd(`uid:${message.touid}:chat:rooms`, messageTime, roomId, next); + }, + function (next) { + db.sortedSetAdd(`chat:room:${roomId}:uids`, [messageTime, messageTime + 1], [message.fromuid, message.touid], next); + }, + function (next) { + addMessageToUids(roomId, next); + }, + ], error_ => { + if (error_) { + return next(error_); + } - if (rooms[pairID]) { - winston.verbose(`adding message ${currentMid} to existing roomID ${roomId}`); - addMessageToUids(rooms[pairID], (err) => { - if (err) { - return next(err); - } - currentMid += 1; - next(); - }); - } else { - winston.verbose(`adding message ${currentMid} to new roomID ${roomId}`); - async.parallel([ - function (next) { - db.sortedSetAdd(`uid:${message.fromuid}:chat:rooms`, msgTime, roomId, next); - }, - function (next) { - db.sortedSetAdd(`uid:${message.touid}:chat:rooms`, msgTime, roomId, next); - }, - function (next) { - db.sortedSetAdd(`chat:room:${roomId}:uids`, [msgTime, msgTime + 1], [message.fromuid, message.touid], next); - }, - function (next) { - addMessageToUids(roomId, next); - }, - ], (err) => { - if (err) { - return next(err); - } - rooms[pairID] = roomId; - roomId += 1; - currentMid += 1; - db.setObjectField('global', 'nextChatRoomId', roomId, next); - }); - } - }); - }, callback); - }); - }, + rooms[pairID] = roomId; + roomId += 1; + currentMid += 1; + db.setObjectField('global', 'nextChatRoomId', roomId, next); + }); + } + }); + }, callback); + }); + }, }; diff --git a/src/upgrades/1.0.0/global_moderators.js b/src/upgrades/1.0.0/global_moderators.js index 46e6799..f4da003 100644 --- a/src/upgrades/1.0.0/global_moderators.js +++ b/src/upgrades/1.0.0/global_moderators.js @@ -1,22 +1,23 @@ 'use strict'; module.exports = { - name: 'Creating Global moderators group', - timestamp: Date.UTC(2016, 0, 23), - method: async function () { - const groups = require('../../groups'); - const exists = await groups.exists('Global Moderators'); - if (exists) { - return; - } - await groups.create({ - name: 'Global Moderators', - userTitle: 'Global Moderator', - description: 'Forum wide moderators', - hidden: 0, - private: 1, - disableJoinRequests: 1, - }); - await groups.show('Global Moderators'); - }, + name: 'Creating Global moderators group', + timestamp: Date.UTC(2016, 0, 23), + async method() { + const groups = require('../../groups'); + const exists = await groups.exists('Global Moderators'); + if (exists) { + return; + } + + await groups.create({ + name: 'Global Moderators', + userTitle: 'Global Moderator', + description: 'Forum wide moderators', + hidden: 0, + private: 1, + disableJoinRequests: 1, + }); + await groups.show('Global Moderators'); + }, }; diff --git a/src/upgrades/1.0.0/social_post_sharing.js b/src/upgrades/1.0.0/social_post_sharing.js index 240af1d..8caf416 100644 --- a/src/upgrades/1.0.0/social_post_sharing.js +++ b/src/upgrades/1.0.0/social_post_sharing.js @@ -3,19 +3,18 @@ const async = require('async'); const db = require('../../database'); - module.exports = { - name: 'Social: Post Sharing', - timestamp: Date.UTC(2016, 1, 25), - method: function (callback) { - const social = require('../../social'); - async.parallel([ - async function () { - await social.setActivePostSharingNetworks(['facebook', 'google', 'twitter']); - }, - async function () { - await db.deleteObjectField('config', 'disableSocialButtons'); - }, - ], callback); - }, + name: 'Social: Post Sharing', + timestamp: Date.UTC(2016, 1, 25), + method(callback) { + const social = require('../../social'); + async.parallel([ + async function () { + await social.setActivePostSharingNetworks(['facebook', 'google', 'twitter']); + }, + async function () { + await db.deleteObjectField('config', 'disableSocialButtons'); + }, + ], callback); + }, }; diff --git a/src/upgrades/1.0.0/theme_to_active_plugins.js b/src/upgrades/1.0.0/theme_to_active_plugins.js index 9af759b..6080ff6 100644 --- a/src/upgrades/1.0.0/theme_to_active_plugins.js +++ b/src/upgrades/1.0.0/theme_to_active_plugins.js @@ -2,12 +2,11 @@ const db = require('../../database'); - module.exports = { - name: 'Adding theme to active plugins sorted set', - timestamp: Date.UTC(2015, 11, 23), - method: async function () { - const themeId = await db.getObjectField('config', 'theme:id'); - await db.sortedSetAdd('plugins:active', 0, themeId); - }, + name: 'Adding theme to active plugins sorted set', + timestamp: Date.UTC(2015, 11, 23), + async method() { + const themeId = await db.getObjectField('config', 'theme:id'); + await db.sortedSetAdd('plugins:active', 0, themeId); + }, }; diff --git a/src/upgrades/1.0.0/user_best_posts.js b/src/upgrades/1.0.0/user_best_posts.js index abfd20d..937d49b 100644 --- a/src/upgrades/1.0.0/user_best_posts.js +++ b/src/upgrades/1.0.0/user_best_posts.js @@ -1,33 +1,34 @@ 'use strict'; - const async = require('async'); const winston = require('winston'); const db = require('../../database'); module.exports = { - name: 'Creating user best post sorted sets', - timestamp: Date.UTC(2016, 0, 14), - method: function (callback) { - const batch = require('../../batch'); - const { progress } = this; + name: 'Creating user best post sorted sets', + timestamp: Date.UTC(2016, 0, 14), + method(callback) { + const batch = require('../../batch'); + const {progress} = this; + + batch.processSortedSet('posts:pid', (ids, next) => { + async.eachSeries(ids, (id, next) => { + db.getObjectFields(`post:${id}`, ['pid', 'uid', 'votes'], (error, postData) => { + if (error) { + return next(error); + } + + if (!postData || !Number.parseInt(postData.votes, 10) || !Number.parseInt(postData.uid, 10)) { + return next(); + } - batch.processSortedSet('posts:pid', (ids, next) => { - async.eachSeries(ids, (id, next) => { - db.getObjectFields(`post:${id}`, ['pid', 'uid', 'votes'], (err, postData) => { - if (err) { - return next(err); - } - if (!postData || !parseInt(postData.votes, 10) || !parseInt(postData.uid, 10)) { - return next(); - } - winston.verbose(`processing pid: ${postData.pid} uid: ${postData.uid} votes: ${postData.votes}`); - db.sortedSetAdd(`uid:${postData.uid}:posts:votes`, postData.votes, postData.pid, next); - progress.incr(); - }); - }, next); - }, { - progress: progress, - }, callback); - }, + winston.verbose(`processing pid: ${postData.pid} uid: ${postData.uid} votes: ${postData.votes}`); + db.sortedSetAdd(`uid:${postData.uid}:posts:votes`, postData.votes, postData.pid, next); + progress.incr(); + }); + }, next); + }, { + progress, + }, callback); + }, }; diff --git a/src/upgrades/1.0.0/users_notvalidated.js b/src/upgrades/1.0.0/users_notvalidated.js index 22b05aa..ccc110d 100644 --- a/src/upgrades/1.0.0/users_notvalidated.js +++ b/src/upgrades/1.0.0/users_notvalidated.js @@ -1,29 +1,30 @@ 'use strict'; - const async = require('async'); const winston = require('winston'); const db = require('../../database'); module.exports = { - name: 'Creating users:notvalidated', - timestamp: Date.UTC(2016, 0, 20), - method: function (callback) { - const batch = require('../../batch'); - const now = Date.now(); - batch.processSortedSet('users:joindate', (ids, next) => { - async.eachSeries(ids, (id, next) => { - db.getObjectFields(`user:${id}`, ['uid', 'email:confirmed'], (err, userData) => { - if (err) { - return next(err); - } - if (!userData || !parseInt(userData.uid, 10) || parseInt(userData['email:confirmed'], 10) === 1) { - return next(); - } - winston.verbose(`processing uid: ${userData.uid} email:confirmed: ${userData['email:confirmed']}`); - db.sortedSetAdd('users:notvalidated', now, userData.uid, next); - }); - }, next); - }, callback); - }, + name: 'Creating users:notvalidated', + timestamp: Date.UTC(2016, 0, 20), + method(callback) { + const batch = require('../../batch'); + const now = Date.now(); + batch.processSortedSet('users:joindate', (ids, next) => { + async.eachSeries(ids, (id, next) => { + db.getObjectFields(`user:${id}`, ['uid', 'email:confirmed'], (error, userData) => { + if (error) { + return next(error); + } + + if (!userData || !Number.parseInt(userData.uid, 10) || Number.parseInt(userData['email:confirmed'], 10) === 1) { + return next(); + } + + winston.verbose(`processing uid: ${userData.uid} email:confirmed: ${userData['email:confirmed']}`); + db.sortedSetAdd('users:notvalidated', now, userData.uid, next); + }); + }, next); + }, callback); + }, }; diff --git a/src/upgrades/1.1.0/assign_topic_read_privilege.js b/src/upgrades/1.1.0/assign_topic_read_privilege.js index a9dd452..ac9196d 100644 --- a/src/upgrades/1.1.0/assign_topic_read_privilege.js +++ b/src/upgrades/1.1.0/assign_topic_read_privilege.js @@ -6,30 +6,31 @@ const winston = require('winston'); const db = require('../../database'); module.exports = { - name: 'Giving topics:read privs to any group/user that was previously allowed to Find & Access Category', - timestamp: Date.UTC(2016, 4, 28), - method: async function () { - const groupsAPI = require('../../groups'); - const privilegesAPI = require('../../privileges'); + name: 'Giving topics:read privs to any group/user that was previously allowed to Find & Access Category', + timestamp: Date.UTC(2016, 4, 28), + async method() { + const groupsAPI = require('../../groups'); + const privilegesAPI = require('../../privileges'); - const cids = await db.getSortedSetRange('categories:cid', 0, -1); - for (const cid of cids) { - const { groups, users } = await privilegesAPI.categories.list(cid); + const cids = await db.getSortedSetRange('categories:cid', 0, -1); + for (const cid of cids) { + const {groups, users} = await privilegesAPI.categories.list(cid); - for (const group of groups) { - if (group.privileges['groups:read']) { - await groupsAPI.join(`cid:${cid}:privileges:groups:topics:read`, group.name); - winston.verbose(`cid:${cid}:privileges:groups:topics:read granted to gid: ${group.name}`); - } - } + for (const group of groups) { + if (group.privileges['groups:read']) { + await groupsAPI.join(`cid:${cid}:privileges:groups:topics:read`, group.name); + winston.verbose(`cid:${cid}:privileges:groups:topics:read granted to gid: ${group.name}`); + } + } - for (const user of users) { - if (user.privileges.read) { - await groupsAPI.join(`cid:${cid}:privileges:topics:read`, user.uid); - winston.verbose(`cid:${cid}:privileges:topics:read granted to uid: ${user.uid}`); - } - } - winston.verbose(`-- cid ${cid} upgraded`); - } - }, + for (const user of users) { + if (user.privileges.read) { + await groupsAPI.join(`cid:${cid}:privileges:topics:read`, user.uid); + winston.verbose(`cid:${cid}:privileges:topics:read granted to uid: ${user.uid}`); + } + } + + winston.verbose(`-- cid ${cid} upgraded`); + } + }, }; diff --git a/src/upgrades/1.1.0/dismiss_flags_from_deleted_topics.js b/src/upgrades/1.1.0/dismiss_flags_from_deleted_topics.js index d860959..894d4f2 100644 --- a/src/upgrades/1.1.0/dismiss_flags_from_deleted_topics.js +++ b/src/upgrades/1.1.0/dismiss_flags_from_deleted_topics.js @@ -1,56 +1,57 @@ 'use strict'; - const winston = require('winston'); const db = require('../../database'); module.exports = { - name: 'Dismiss flags from deleted topics', - timestamp: Date.UTC(2016, 3, 29), - method: async function () { - const posts = require('../../posts'); - const topics = require('../../topics'); - - const pids = await db.getSortedSetRange('posts:flagged', 0, -1); - const postData = await posts.getPostsFields(pids, ['tid']); - const tids = postData.map(t => t.tid); - const topicData = await topics.getTopicsFields(tids, ['deleted']); - const toDismiss = topicData.map((t, idx) => (parseInt(t.deleted, 10) === 1 ? pids[idx] : null)).filter(Boolean); - - winston.verbose(`[2016/04/29] ${toDismiss.length} dismissable flags found`); - await Promise.all(toDismiss.map(dismissFlag)); - }, + name: 'Dismiss flags from deleted topics', + timestamp: Date.UTC(2016, 3, 29), + async method() { + const posts = require('../../posts'); + const topics = require('../../topics'); + + const pids = await db.getSortedSetRange('posts:flagged', 0, -1); + const postData = await posts.getPostsFields(pids, ['tid']); + const tids = postData.map(t => t.tid); + const topicData = await topics.getTopicsFields(tids, ['deleted']); + const toDismiss = topicData.map((t, index) => (Number.parseInt(t.deleted, 10) === 1 ? pids[index] : null)).filter(Boolean); + + winston.verbose(`[2016/04/29] ${toDismiss.length} dismissable flags found`); + await Promise.all(toDismiss.map(dismissFlag)); + }, }; -// copied from core since this function was removed +// Copied from core since this function was removed // https://github.com/NodeBB/NodeBB/blob/v1.x.x/src/posts/flags.js async function dismissFlag(pid) { - const postData = await db.getObjectFields(`post:${pid}`, ['pid', 'uid', 'flags']); - if (!postData.pid) { - return; - } - if (parseInt(postData.uid, 10) && parseInt(postData.flags, 10) > 0) { - await Promise.all([ - db.sortedSetIncrBy('users:flags', -postData.flags, postData.uid), - db.incrObjectFieldBy(`user:${postData.uid}`, 'flags', -postData.flags), - ]); - } - const uids = await db.getSortedSetRange(`pid:${pid}:flag:uids`, 0, -1); - const nids = uids.map(uid => `post_flag:${pid}:uid:${uid}`); - - await Promise.all([ - db.deleteAll(nids.map(nid => `notifications:${nid}`)), - db.sortedSetRemove('notifications', nids), - db.delete(`pid:${pid}:flag:uids`), - db.sortedSetsRemove([ - 'posts:flagged', - 'posts:flags:count', - `uid:${postData.uid}:flag:pids`, - ], pid), - db.deleteObjectField(`post:${pid}`, 'flags'), - db.delete(`pid:${pid}:flag:uid:reason`), - db.deleteObjectFields(`post:${pid}`, ['flag:state', 'flag:assignee', 'flag:notes', 'flag:history']), - ]); - - await db.sortedSetsRemoveRangeByScore(['users:flags'], '-inf', 0); + const postData = await db.getObjectFields(`post:${pid}`, ['pid', 'uid', 'flags']); + if (!postData.pid) { + return; + } + + if (Number.parseInt(postData.uid, 10) && Number.parseInt(postData.flags, 10) > 0) { + await Promise.all([ + db.sortedSetIncrBy('users:flags', -postData.flags, postData.uid), + db.incrObjectFieldBy(`user:${postData.uid}`, 'flags', -postData.flags), + ]); + } + + const uids = await db.getSortedSetRange(`pid:${pid}:flag:uids`, 0, -1); + const nids = uids.map(uid => `post_flag:${pid}:uid:${uid}`); + + await Promise.all([ + db.deleteAll(nids.map(nid => `notifications:${nid}`)), + db.sortedSetRemove('notifications', nids), + db.delete(`pid:${pid}:flag:uids`), + db.sortedSetsRemove([ + 'posts:flagged', + 'posts:flags:count', + `uid:${postData.uid}:flag:pids`, + ], pid), + db.deleteObjectField(`post:${pid}`, 'flags'), + db.delete(`pid:${pid}:flag:uid:reason`), + db.deleteObjectFields(`post:${pid}`, ['flag:state', 'flag:assignee', 'flag:notes', 'flag:history']), + ]); + + await db.sortedSetsRemoveRangeByScore(['users:flags'], '-inf', 0); } diff --git a/src/upgrades/1.1.0/group_title_update.js b/src/upgrades/1.1.0/group_title_update.js index fd308ce..94b3ac3 100644 --- a/src/upgrades/1.1.0/group_title_update.js +++ b/src/upgrades/1.1.0/group_title_update.js @@ -1,30 +1,30 @@ 'use strict'; - const async = require('async'); const winston = require('winston'); const db = require('../../database'); module.exports = { - name: 'Group title from settings to user profile', - timestamp: Date.UTC(2016, 3, 14), - method: function (callback) { - const user = require('../../user'); - const batch = require('../../batch'); - let count = 0; - batch.processSortedSet('users:joindate', (uids, next) => { - winston.verbose(`upgraded ${count} users`); - user.getMultipleUserSettings(uids, (err, settings) => { - if (err) { - return next(err); - } - count += uids.length; - settings = settings.filter(setting => setting && setting.groupTitle); + name: 'Group title from settings to user profile', + timestamp: Date.UTC(2016, 3, 14), + method(callback) { + const user = require('../../user'); + const batch = require('../../batch'); + let count = 0; + batch.processSortedSet('users:joindate', (uids, next) => { + winston.verbose(`upgraded ${count} users`); + user.getMultipleUserSettings(uids, (error, settings) => { + if (error) { + return next(error); + } + + count += uids.length; + settings = settings.filter(setting => setting && setting.groupTitle); - async.each(settings, (setting, next) => { - db.setObjectField(`user:${setting.uid}`, 'groupTitle', setting.groupTitle, next); - }, next); - }); - }, {}, callback); - }, + async.each(settings, (setting, next) => { + db.setObjectField(`user:${setting.uid}`, 'groupTitle', setting.groupTitle, next); + }, next); + }); + }, {}, callback); + }, }; diff --git a/src/upgrades/1.1.0/separate_upvote_downvote.js b/src/upgrades/1.1.0/separate_upvote_downvote.js index ac44c32..c3efaa4 100644 --- a/src/upgrades/1.1.0/separate_upvote_downvote.js +++ b/src/upgrades/1.1.0/separate_upvote_downvote.js @@ -1,54 +1,55 @@ 'use strict'; - const async = require('async'); const winston = require('winston'); const db = require('../../database'); module.exports = { - name: 'Store upvotes/downvotes separately', - timestamp: Date.UTC(2016, 5, 13), - method: function (callback) { - const batch = require('../../batch'); - const posts = require('../../posts'); - let count = 0; - const { progress } = this; - - batch.processSortedSet('posts:pid', (pids, next) => { - winston.verbose(`upgraded ${count} posts`); - count += pids.length; - async.each(pids, (pid, next) => { - async.parallel({ - upvotes: function (next) { - db.setCount(`pid:${pid}:upvote`, next); - }, - downvotes: function (next) { - db.setCount(`pid:${pid}:downvote`, next); - }, - }, (err, results) => { - if (err) { - return next(err); - } - const data = {}; - - if (parseInt(results.upvotes, 10) > 0) { - data.upvotes = results.upvotes; - } - if (parseInt(results.downvotes, 10) > 0) { - data.downvotes = results.downvotes; - } - - if (Object.keys(data).length) { - posts.setPostFields(pid, data, next); - } else { - next(); - } - - progress.incr(); - }, next); - }, next); - }, { - progress: progress, - }, callback); - }, + name: 'Store upvotes/downvotes separately', + timestamp: Date.UTC(2016, 5, 13), + method(callback) { + const batch = require('../../batch'); + const posts = require('../../posts'); + let count = 0; + const {progress} = this; + + batch.processSortedSet('posts:pid', (pids, next) => { + winston.verbose(`upgraded ${count} posts`); + count += pids.length; + async.each(pids, (pid, next) => { + async.parallel({ + upvotes(next) { + db.setCount(`pid:${pid}:upvote`, next); + }, + downvotes(next) { + db.setCount(`pid:${pid}:downvote`, next); + }, + }, (error, results) => { + if (error) { + return next(error); + } + + const data = {}; + + if (Number.parseInt(results.upvotes, 10) > 0) { + data.upvotes = results.upvotes; + } + + if (Number.parseInt(results.downvotes, 10) > 0) { + data.downvotes = results.downvotes; + } + + if (Object.keys(data).length > 0) { + posts.setPostFields(pid, data, next); + } else { + next(); + } + + progress.incr(); + }, next); + }, next); + }, { + progress, + }, callback); + }, }; diff --git a/src/upgrades/1.1.0/user_post_count_per_tid.js b/src/upgrades/1.1.0/user_post_count_per_tid.js index 11957df..81babcc 100644 --- a/src/upgrades/1.1.0/user_post_count_per_tid.js +++ b/src/upgrades/1.1.0/user_post_count_per_tid.js @@ -1,48 +1,50 @@ 'use strict'; - const async = require('async'); const winston = require('winston'); const db = require('../../database'); module.exports = { - name: 'Users post count per tid', - timestamp: Date.UTC(2016, 3, 19), - method: function (callback) { - const batch = require('../../batch'); - const topics = require('../../topics'); - let count = 0; - batch.processSortedSet('topics:tid', (tids, next) => { - winston.verbose(`upgraded ${count} topics`); - count += tids.length; - async.each(tids, (tid, next) => { - db.delete(`tid:${tid}:posters`, (err) => { - if (err) { - return next(err); - } - topics.getPids(tid, (err, pids) => { - if (err) { - return next(err); - } + name: 'Users post count per tid', + timestamp: Date.UTC(2016, 3, 19), + method(callback) { + const batch = require('../../batch'); + const topics = require('../../topics'); + let count = 0; + batch.processSortedSet('topics:tid', (tids, next) => { + winston.verbose(`upgraded ${count} topics`); + count += tids.length; + async.each(tids, (tid, next) => { + db.delete(`tid:${tid}:posters`, error => { + if (error) { + return next(error); + } + + topics.getPids(tid, (error, pids) => { + if (error) { + return next(error); + } + + if (pids.length === 0) { + return next(); + } + + async.eachSeries(pids, (pid, next) => { + db.getObjectField(`post:${pid}`, 'uid', (error, uid) => { + if (error) { + return next(error); + } - if (!pids.length) { - return next(); - } + if (!Number.parseInt(uid, 10)) { + return next(); + } - async.eachSeries(pids, (pid, next) => { - db.getObjectField(`post:${pid}`, 'uid', (err, uid) => { - if (err) { - return next(err); - } - if (!parseInt(uid, 10)) { - return next(); - } - db.sortedSetIncrBy(`tid:${tid}:posters`, 1, uid, next); - }); - }, next); - }); - }); - }, next); - }, {}, callback); - }, + db.sortedSetIncrBy(`tid:${tid}:posters`, 1, uid, next); + }); + }, next); + }); + }); + }, next); + }, {}, callback); + }, }; diff --git a/src/upgrades/1.1.1/remove_negative_best_posts.js b/src/upgrades/1.1.1/remove_negative_best_posts.js index 4d848e2..c9f65bd 100644 --- a/src/upgrades/1.1.1/remove_negative_best_posts.js +++ b/src/upgrades/1.1.1/remove_negative_best_posts.js @@ -1,20 +1,19 @@ 'use strict'; - const async = require('async'); const winston = require('winston'); const db = require('../../database'); module.exports = { - name: 'Removing best posts with negative scores', - timestamp: Date.UTC(2016, 7, 5), - method: function (callback) { - const batch = require('../../batch'); - batch.processSortedSet('users:joindate', (ids, next) => { - async.each(ids, (id, next) => { - winston.verbose(`processing uid ${id}`); - db.sortedSetsRemoveRangeByScore([`uid:${id}:posts:votes`], '-inf', 0, next); - }, next); - }, {}, callback); - }, + name: 'Removing best posts with negative scores', + timestamp: Date.UTC(2016, 7, 5), + method(callback) { + const batch = require('../../batch'); + batch.processSortedSet('users:joindate', (ids, next) => { + async.each(ids, (id, next) => { + winston.verbose(`processing uid ${id}`); + db.sortedSetsRemoveRangeByScore([`uid:${id}:posts:votes`], '-inf', 0, next); + }, next); + }, {}, callback); + }, }; diff --git a/src/upgrades/1.1.1/upload_privileges.js b/src/upgrades/1.1.1/upload_privileges.js index 73d2aed..5c9277c 100644 --- a/src/upgrades/1.1.1/upload_privileges.js +++ b/src/upgrades/1.1.1/upload_privileges.js @@ -3,36 +3,37 @@ const async = require('async'); const db = require('../../database'); - module.exports = { - name: 'Giving upload privileges', - timestamp: Date.UTC(2016, 6, 12), - method: function (callback) { - const privilegesAPI = require('../../privileges'); - const meta = require('../../meta'); + name: 'Giving upload privileges', + timestamp: Date.UTC(2016, 6, 12), + method(callback) { + const privilegesAPI = require('../../privileges'); + const meta = require('../../meta'); + + db.getSortedSetRange('categories:cid', 0, -1, (error, cids) => { + if (error) { + return callback(error); + } + + async.eachSeries(cids, (cid, next) => { + privilegesAPI.categories.list(cid, (error, data) => { + if (error) { + return next(error); + } - db.getSortedSetRange('categories:cid', 0, -1, (err, cids) => { - if (err) { - return callback(err); - } + async.eachSeries(data.groups, (group, next) => { + if (group.name === 'guests' && Number.parseInt(meta.config.allowGuestUploads, 10) !== 1) { + return next(); + } - async.eachSeries(cids, (cid, next) => { - privilegesAPI.categories.list(cid, (err, data) => { - if (err) { - return next(err); - } - async.eachSeries(data.groups, (group, next) => { - if (group.name === 'guests' && parseInt(meta.config.allowGuestUploads, 10) !== 1) { - return next(); - } - if (group.privileges['groups:read']) { - privilegesAPI.categories.give(['upload:post:image'], cid, group.name, next); - } else { - next(); - } - }, next); - }); - }, callback); - }); - }, + if (group.privileges['groups:read']) { + privilegesAPI.categories.give(['upload:post:image'], cid, group.name, next); + } else { + next(); + } + }, next); + }); + }, callback); + }); + }, }; diff --git a/src/upgrades/1.10.0/hash_recent_ip_addresses.js b/src/upgrades/1.10.0/hash_recent_ip_addresses.js index a97c4f1..d2fdbea 100644 --- a/src/upgrades/1.10.0/hash_recent_ip_addresses.js +++ b/src/upgrades/1.10.0/hash_recent_ip_addresses.js @@ -1,41 +1,40 @@ 'use strict'; - +const crypto = require('node:crypto'); const async = require('async'); -const crypto = require('crypto'); const nconf = require('nconf'); const batch = require('../../batch'); const db = require('../../database'); module.exports = { - name: 'Hash all IP addresses stored in Recent IPs zset', - timestamp: Date.UTC(2018, 5, 22), - method: function (callback) { - const { progress } = this; - const hashed = /[a-f0-9]{32}/; - let hash; + name: 'Hash all IP addresses stored in Recent IPs zset', + timestamp: Date.UTC(2018, 5, 22), + method(callback) { + const {progress} = this; + const hashed = /[a-f\d]{32}/; + let hash; - batch.processSortedSet('ip:recent', (ips, next) => { - async.each(ips, (set, next) => { - // Short circuit if already processed - if (hashed.test(set.value)) { - progress.incr(); - return setImmediate(next); - } + batch.processSortedSet('ip:recent', (ips, next) => { + async.each(ips, (set, next) => { + // Short circuit if already processed + if (hashed.test(set.value)) { + progress.incr(); + return setImmediate(next); + } - hash = crypto.createHash('sha1').update(set.value + nconf.get('secret')).digest('hex'); + hash = crypto.createHash('sha1').update(set.value + nconf.get('secret')).digest('hex'); - async.series([ - async.apply(db.sortedSetAdd, 'ip:recent', set.score, hash), - async.apply(db.sortedSetRemove, 'ip:recent', set.value), - ], (err) => { - progress.incr(); - next(err); - }); - }, next); - }, { - withScores: 1, - progress: this.progress, - }, callback); - }, + async.series([ + async.apply(db.sortedSetAdd, 'ip:recent', set.score, hash), + async.apply(db.sortedSetRemove, 'ip:recent', set.value), + ], error => { + progress.incr(); + next(error); + }); + }, next); + }, { + withScores: 1, + progress: this.progress, + }, callback); + }, }; diff --git a/src/upgrades/1.10.0/post_history_privilege.js b/src/upgrades/1.10.0/post_history_privilege.js index c556e65..451c090 100644 --- a/src/upgrades/1.10.0/post_history_privilege.js +++ b/src/upgrades/1.10.0/post_history_privilege.js @@ -1,22 +1,21 @@ 'use strict'; - const async = require('async'); - const privileges = require('../../privileges'); const db = require('../../database'); module.exports = { - name: 'Give post history viewing privilege to registered-users on all categories', - timestamp: Date.UTC(2018, 5, 7), - method: function (callback) { - db.getSortedSetRange('categories:cid', 0, -1, (err, cids) => { - if (err) { - return callback(err); - } - async.eachSeries(cids, (cid, next) => { - privileges.categories.give(['groups:posts:history'], cid, 'registered-users', next); - }, callback); - }); - }, + name: 'Give post history viewing privilege to registered-users on all categories', + timestamp: Date.UTC(2018, 5, 7), + method(callback) { + db.getSortedSetRange('categories:cid', 0, -1, (error, cids) => { + if (error) { + return callback(error); + } + + async.eachSeries(cids, (cid, next) => { + privileges.categories.give(['groups:posts:history'], cid, 'registered-users', next); + }, callback); + }); + }, }; diff --git a/src/upgrades/1.10.0/search_privileges.js b/src/upgrades/1.10.0/search_privileges.js index ed9dbd2..ff87e89 100644 --- a/src/upgrades/1.10.0/search_privileges.js +++ b/src/upgrades/1.10.0/search_privileges.js @@ -1,23 +1,25 @@ 'use strict'; module.exports = { - name: 'Give global search privileges', - timestamp: Date.UTC(2018, 4, 28), - method: async function () { - const meta = require('../../meta'); - const privileges = require('../../privileges'); - const allowGuestSearching = parseInt(meta.config.allowGuestSearching, 10) === 1; - const allowGuestUserSearching = parseInt(meta.config.allowGuestUserSearching, 10) === 1; + name: 'Give global search privileges', + timestamp: Date.UTC(2018, 4, 28), + async method() { + const meta = require('../../meta'); + const privileges = require('../../privileges'); + const allowGuestSearching = Number.parseInt(meta.config.allowGuestSearching, 10) === 1; + const allowGuestUserSearching = Number.parseInt(meta.config.allowGuestUserSearching, 10) === 1; - await privileges.global.give(['groups:search:content', 'groups:search:users', 'groups:search:tags'], 'registered-users'); - const guestPrivs = []; - if (allowGuestSearching) { - guestPrivs.push('groups:search:content'); - } - if (allowGuestUserSearching) { - guestPrivs.push('groups:search:users'); - } - guestPrivs.push('groups:search:tags'); - await privileges.global.give(guestPrivs, 'guests'); - }, + await privileges.global.give(['groups:search:content', 'groups:search:users', 'groups:search:tags'], 'registered-users'); + const guestPrivs = []; + if (allowGuestSearching) { + guestPrivs.push('groups:search:content'); + } + + if (allowGuestUserSearching) { + guestPrivs.push('groups:search:users'); + } + + guestPrivs.push('groups:search:tags'); + await privileges.global.give(guestPrivs, 'guests'); + }, }; diff --git a/src/upgrades/1.10.0/view_deleted_privilege.js b/src/upgrades/1.10.0/view_deleted_privilege.js index d099e0f..7df551b 100644 --- a/src/upgrades/1.10.0/view_deleted_privilege.js +++ b/src/upgrades/1.10.0/view_deleted_privilege.js @@ -6,17 +6,18 @@ const groups = require('../../groups'); const db = require('../../database'); module.exports = { - name: 'Give deleted post viewing privilege to moderators on all categories', - timestamp: Date.UTC(2018, 5, 8), - method: async function () { - const { progress } = this; - const cids = await db.getSortedSetRange('categories:cid', 0, -1); - for (const cid of cids) { - const uids = await db.getSortedSetRange(`group:cid:${cid}:privileges:moderate:members`, 0, -1); - for (const uid of uids) { - await groups.join(`cid:${cid}:privileges:posts:view_deleted`, uid); - } - progress.incr(); - } - }, + name: 'Give deleted post viewing privilege to moderators on all categories', + timestamp: Date.UTC(2018, 5, 8), + async method() { + const {progress} = this; + const cids = await db.getSortedSetRange('categories:cid', 0, -1); + for (const cid of cids) { + const uids = await db.getSortedSetRange(`group:cid:${cid}:privileges:moderate:members`, 0, -1); + for (const uid of uids) { + await groups.join(`cid:${cid}:privileges:posts:view_deleted`, uid); + } + + progress.incr(); + } + }, }; diff --git a/src/upgrades/1.10.2/event_filters.js b/src/upgrades/1.10.2/event_filters.js index 6f8877f..5836fb6 100644 --- a/src/upgrades/1.10.2/event_filters.js +++ b/src/upgrades/1.10.2/event_filters.js @@ -3,35 +3,36 @@ 'use strict'; const db = require('../../database'); - const batch = require('../../batch'); module.exports = { - name: 'add filters to events', - timestamp: Date.UTC(2018, 9, 4), - method: async function () { - const { progress } = this; + name: 'add filters to events', + timestamp: Date.UTC(2018, 9, 4), + async method() { + const {progress} = this; + + await batch.processSortedSet('events:time', async eids => { + for (const eid of eids) { + progress.incr(); + + const eventData = await db.getObject(`event:${eid}`); + if (!eventData) { + await db.sortedSetRemove('events:time', eid); + return; + } - await batch.processSortedSet('events:time', async (eids) => { - for (const eid of eids) { - progress.incr(); + // Privilege events we're missing type field + if (!eventData.type && eventData.privilege) { + eventData.type = 'privilege-change'; + await db.setObjectField(`event:${eid}`, 'type', 'privilege-change'); + await db.sortedSetAdd(`events:time:${eventData.type}`, eventData.timestamp, eid); + return; + } - const eventData = await db.getObject(`event:${eid}`); - if (!eventData) { - await db.sortedSetRemove('events:time', eid); - return; - } - // privilege events we're missing type field - if (!eventData.type && eventData.privilege) { - eventData.type = 'privilege-change'; - await db.setObjectField(`event:${eid}`, 'type', 'privilege-change'); - await db.sortedSetAdd(`events:time:${eventData.type}`, eventData.timestamp, eid); - return; - } - await db.sortedSetAdd(`events:time:${eventData.type || ''}`, eventData.timestamp, eid); - } - }, { - progress: this.progress, - }); - }, + await db.sortedSetAdd(`events:time:${eventData.type || ''}`, eventData.timestamp, eid); + } + }, { + progress: this.progress, + }); + }, }; diff --git a/src/upgrades/1.10.2/fix_category_post_zsets.js b/src/upgrades/1.10.2/fix_category_post_zsets.js index 82003c8..68721fa 100644 --- a/src/upgrades/1.10.2/fix_category_post_zsets.js +++ b/src/upgrades/1.10.2/fix_category_post_zsets.js @@ -6,27 +6,27 @@ const topics = require('../../topics'); const batch = require('../../batch'); module.exports = { - name: 'Fix category post zsets', - timestamp: Date.UTC(2018, 9, 10), - method: async function () { - const { progress } = this; + name: 'Fix category post zsets', + timestamp: Date.UTC(2018, 9, 10), + async method() { + const {progress} = this; - const cids = await db.getSortedSetRange('categories:cid', 0, -1); - const keys = cids.map(cid => `cid:${cid}:pids`); + const cids = await db.getSortedSetRange('categories:cid', 0, -1); + const keys = cids.map(cid => `cid:${cid}:pids`); - await batch.processSortedSet('posts:pid', async (postData) => { - const pids = postData.map(p => p.value); - const topicData = await posts.getPostsFields(pids, ['tid']); - const categoryData = await topics.getTopicsFields(topicData.map(t => t.tid), ['cid']); + await batch.processSortedSet('posts:pid', async postData => { + const pids = postData.map(p => p.value); + const topicData = await posts.getPostsFields(pids, ['tid']); + const categoryData = await topics.getTopicsFields(topicData.map(t => t.tid), ['cid']); - await db.sortedSetRemove(keys, pids); - const bulkAdd = postData.map((p, i) => ([`cid:${categoryData[i].cid}:pids`, p.score, p.value])); - await db.sortedSetAddBulk(bulkAdd); - progress.incr(postData.length); - }, { - batch: 500, - progress: progress, - withScores: true, - }); - }, + await db.sortedSetRemove(keys, pids); + const bulkAdd = postData.map((p, i) => ([`cid:${categoryData[i].cid}:pids`, p.score, p.value])); + await db.sortedSetAddBulk(bulkAdd); + progress.incr(postData.length); + }, { + batch: 500, + progress, + withScores: true, + }); + }, }; diff --git a/src/upgrades/1.10.2/fix_category_topic_zsets.js b/src/upgrades/1.10.2/fix_category_topic_zsets.js index b4becca..df9b8a1 100644 --- a/src/upgrades/1.10.2/fix_category_topic_zsets.js +++ b/src/upgrades/1.10.2/fix_category_topic_zsets.js @@ -3,28 +3,28 @@ 'use strict'; const db = require('../../database'); - const batch = require('../../batch'); module.exports = { - name: 'Fix category topic zsets', - timestamp: Date.UTC(2018, 9, 11), - method: async function () { - const { progress } = this; + name: 'Fix category topic zsets', + timestamp: Date.UTC(2018, 9, 11), + async method() { + const {progress} = this; + + const topics = require('../../topics'); + await batch.processSortedSet('topics:tid', async tids => { + for (const tid of tids) { + progress.incr(); + const topicData = await db.getObjectFields(`topic:${tid}`, ['cid', 'pinned', 'postcount']); + if (Number.parseInt(topicData.pinned, 10) !== 1) { + topicData.postcount = Number.parseInt(topicData.postcount, 10) || 0; + await db.sortedSetAdd(`cid:${topicData.cid}:tids:posts`, topicData.postcount, tid); + } - const topics = require('../../topics'); - await batch.processSortedSet('topics:tid', async (tids) => { - for (const tid of tids) { - progress.incr(); - const topicData = await db.getObjectFields(`topic:${tid}`, ['cid', 'pinned', 'postcount']); - if (parseInt(topicData.pinned, 10) !== 1) { - topicData.postcount = parseInt(topicData.postcount, 10) || 0; - await db.sortedSetAdd(`cid:${topicData.cid}:tids:posts`, topicData.postcount, tid); - } - await topics.updateLastPostTimeFromLastPid(tid); - } - }, { - progress: progress, - }); - }, + await topics.updateLastPostTimeFromLastPid(tid); + } + }, { + progress, + }); + }, }; diff --git a/src/upgrades/1.10.2/local_login_privileges.js b/src/upgrades/1.10.2/local_login_privileges.js index daedd2d..8e273ad 100644 --- a/src/upgrades/1.10.2/local_login_privileges.js +++ b/src/upgrades/1.10.2/local_login_privileges.js @@ -1,17 +1,17 @@ 'use strict'; module.exports = { - name: 'Give global local login privileges', - timestamp: Date.UTC(2018, 8, 28), - method: function (callback) { - const meta = require('../../meta'); - const privileges = require('../../privileges'); - const allowLocalLogin = parseInt(meta.config.allowLocalLogin, 10) !== 0; + name: 'Give global local login privileges', + timestamp: Date.UTC(2018, 8, 28), + method(callback) { + const meta = require('../../meta'); + const privileges = require('../../privileges'); + const allowLocalLogin = Number.parseInt(meta.config.allowLocalLogin, 10) !== 0; - if (allowLocalLogin) { - privileges.global.give(['groups:local:login'], 'registered-users', callback); - } else { - callback(); - } - }, + if (allowLocalLogin) { + privileges.global.give(['groups:local:login'], 'registered-users', callback); + } else { + callback(); + } + }, }; diff --git a/src/upgrades/1.10.2/postgres_sessions.js b/src/upgrades/1.10.2/postgres_sessions.js index cbf8a80..4d4ea4c 100644 --- a/src/upgrades/1.10.2/postgres_sessions.js +++ b/src/upgrades/1.10.2/postgres_sessions.js @@ -4,14 +4,14 @@ const nconf = require('nconf'); const db = require('../../database'); module.exports = { - name: 'Optimize PostgreSQL sessions', - timestamp: Date.UTC(2018, 9, 1), - method: function (callback) { - if (nconf.get('database') !== 'postgres' || nconf.get('redis')) { - return callback(); - } - - db.pool.query(` + name: 'Optimize PostgreSQL sessions', + timestamp: Date.UTC(2018, 9, 1), + method(callback) { + if (nconf.get('database') !== 'postgres' || nconf.get('redis')) { + return callback(); + } + + db.pool.query(` BEGIN TRANSACTION; CREATE TABLE IF NOT EXISTS "session" ( @@ -34,8 +34,8 @@ ALTER TABLE "session" CLUSTER "session"; ANALYZE "session"; -COMMIT;`, (err) => { - callback(err); - }); - }, +COMMIT;`, error => { + callback(error); + }); + }, }; diff --git a/src/upgrades/1.10.2/upgrade_bans_to_hashes.js b/src/upgrades/1.10.2/upgrade_bans_to_hashes.js index 250b859..4ad39aa 100644 --- a/src/upgrades/1.10.2/upgrade_bans_to_hashes.js +++ b/src/upgrades/1.10.2/upgrade_bans_to_hashes.js @@ -6,54 +6,55 @@ const db = require('../../database'); const batch = require('../../batch'); module.exports = { - name: 'Upgrade bans to hashes', - timestamp: Date.UTC(2018, 8, 24), - method: async function () { - const { progress } = this; + name: 'Upgrade bans to hashes', + timestamp: Date.UTC(2018, 8, 24), + async method() { + const {progress} = this; - await batch.processSortedSet('users:joindate', async (uids) => { - for (const uid of uids) { - progress.incr(); - const [bans, reasons, userData] = await Promise.all([ - db.getSortedSetRevRangeWithScores(`uid:${uid}:bans`, 0, -1), - db.getSortedSetRevRangeWithScores(`banned:${uid}:reasons`, 0, -1), - db.getObjectFields(`user:${uid}`, ['banned', 'banned:expire', 'joindate', 'lastposttime', 'lastonline']), - ]); + await batch.processSortedSet('users:joindate', async uids => { + for (const uid of uids) { + progress.incr(); + const [bans, reasons, userData] = await Promise.all([ + db.getSortedSetRevRangeWithScores(`uid:${uid}:bans`, 0, -1), + db.getSortedSetRevRangeWithScores(`banned:${uid}:reasons`, 0, -1), + db.getObjectFields(`user:${uid}`, ['banned', 'banned:expire', 'joindate', 'lastposttime', 'lastonline']), + ]); - // has no history, but is banned, create plain object with just uid and timestmap - if (!bans.length && parseInt(userData.banned, 10)) { - const banTimestamp = ( - userData.lastonline || - userData.lastposttime || - userData.joindate || - Date.now() - ); - const banKey = `uid:${uid}:ban:${banTimestamp}`; - await addBan(uid, banKey, { uid: uid, timestamp: banTimestamp }); - } else if (bans.length) { - // process ban history - for (const ban of bans) { - const reasonData = reasons.find(reasonData => reasonData.score === ban.score); - const banKey = `uid:${uid}:ban:${ban.score}`; - const data = { - uid: uid, - timestamp: ban.score, - expire: parseInt(ban.value, 10), - }; - if (reasonData) { - data.reason = reasonData.value; - } - await addBan(uid, banKey, data); - } - } - } - }, { - progress: this.progress, - }); - }, + // Has no history, but is banned, create plain object with just uid and timestmap + if (bans.length === 0 && Number.parseInt(userData.banned, 10)) { + const banTimestamp = ( + userData.lastonline + || userData.lastposttime + || userData.joindate + || Date.now() + ); + const banKey = `uid:${uid}:ban:${banTimestamp}`; + await addBan(uid, banKey, {uid, timestamp: banTimestamp}); + } else if (bans.length > 0) { + // Process ban history + for (const ban of bans) { + const reasonData = reasons.find(reasonData => reasonData.score === ban.score); + const banKey = `uid:${uid}:ban:${ban.score}`; + const data = { + uid, + timestamp: ban.score, + expire: Number.parseInt(ban.value, 10), + }; + if (reasonData) { + data.reason = reasonData.value; + } + + await addBan(uid, banKey, data); + } + } + } + }, { + progress: this.progress, + }); + }, }; async function addBan(uid, key, data) { - await db.setObject(key, data); - await db.sortedSetAdd(`uid:${uid}:bans:timestamp`, data.timestamp, key); + await db.setObject(key, data); + await db.sortedSetAdd(`uid:${uid}:bans:timestamp`, data.timestamp, key); } diff --git a/src/upgrades/1.10.2/username_email_history.js b/src/upgrades/1.10.2/username_email_history.js index c8cc2f7..a54c64b 100644 --- a/src/upgrades/1.10.2/username_email_history.js +++ b/src/upgrades/1.10.2/username_email_history.js @@ -1,37 +1,36 @@ 'use strict'; const db = require('../../database'); - const batch = require('../../batch'); const user = require('../../user'); module.exports = { - name: 'Record first entry in username/email history', - timestamp: Date.UTC(2018, 7, 28), - method: async function () { - const { progress } = this; + name: 'Record first entry in username/email history', + timestamp: Date.UTC(2018, 7, 28), + async method() { + const {progress} = this; - await batch.processSortedSet('users:joindate', async (uids) => { - async function updateHistory(uid, set, fieldName) { - const count = await db.sortedSetCard(set); - if (count <= 0) { - // User has not changed their username/email before, record original username - const userData = await user.getUserFields(uid, [fieldName, 'joindate']); - if (userData && userData.joindate && userData[fieldName]) { - await db.sortedSetAdd(set, userData.joindate, [userData[fieldName], userData.joindate].join(':')); - } - } - } + await batch.processSortedSet('users:joindate', async uids => { + async function updateHistory(uid, set, fieldName) { + const count = await db.sortedSetCard(set); + if (count <= 0) { + // User has not changed their username/email before, record original username + const userData = await user.getUserFields(uid, [fieldName, 'joindate']); + if (userData && userData.joindate && userData[fieldName]) { + await db.sortedSetAdd(set, userData.joindate, [userData[fieldName], userData.joindate].join(':')); + } + } + } - await Promise.all(uids.map(async (uid) => { - await Promise.all([ - updateHistory(uid, `user:${uid}:usernames`, 'username'), - updateHistory(uid, `user:${uid}:emails`, 'email'), - ]); - progress.incr(); - })); - }, { - progress: this.progress, - }); - }, + await Promise.all(uids.map(async uid => { + await Promise.all([ + updateHistory(uid, `user:${uid}:usernames`, 'username'), + updateHistory(uid, `user:${uid}:emails`, 'email'), + ]); + progress.incr(); + })); + }, { + progress: this.progress, + }); + }, }; diff --git a/src/upgrades/1.11.0/navigation_visibility_groups.js b/src/upgrades/1.11.0/navigation_visibility_groups.js index 556f601..d975fd3 100644 --- a/src/upgrades/1.11.0/navigation_visibility_groups.js +++ b/src/upgrades/1.11.0/navigation_visibility_groups.js @@ -1,58 +1,61 @@ 'use strict'; module.exports = { - name: 'Navigation item visibility groups', - timestamp: Date.UTC(2018, 10, 10), - method: async function () { - const data = await navigationAdminGet(); - data.forEach((navItem) => { - if (navItem && navItem.properties) { - navItem.groups = []; - if (navItem.properties.adminOnly) { - navItem.groups.push('administrators'); - } else if (navItem.properties.globalMod) { - navItem.groups.push('Global Moderators'); - } + name: 'Navigation item visibility groups', + timestamp: Date.UTC(2018, 10, 10), + async method() { + const data = await navigationAdminGet(); + for (const navItem of data) { + if (navItem && navItem.properties) { + navItem.groups = []; + if (navItem.properties.adminOnly) { + navItem.groups.push('administrators'); + } else if (navItem.properties.globalMod) { + navItem.groups.push('Global Moderators'); + } - if (navItem.properties.loggedIn) { - navItem.groups.push('registered-users'); - } else if (navItem.properties.guestOnly) { - navItem.groups.push('guests'); - } - } - }); - await navigationAdminSave(data); - }, + if (navItem.properties.loggedIn) { + navItem.groups.push('registered-users'); + } else if (navItem.properties.guestOnly) { + navItem.groups.push('guests'); + } + } + } + + await navigationAdminSave(data); + }, }; -// use navigation.get/save as it was in 1.11.0 so upgrade script doesn't crash on latest nbb +// Use navigation.get/save as it was in 1.11.0 so upgrade script doesn't crash on latest nbb // see https://github.com/NodeBB/NodeBB/pull/11013 async function navigationAdminGet() { - const db = require('../../database'); - const data = await db.getSortedSetRange('navigation:enabled', 0, -1); - return data.filter(Boolean).map((item) => { - item = JSON.parse(item); - item.groups = item.groups || []; - if (item.groups && !Array.isArray(item.groups)) { - item.groups = [item.groups]; - } - return item; - }); + const db = require('../../database'); + const data = await db.getSortedSetRange('navigation:enabled', 0, -1); + return data.filter(Boolean).map(item => { + item = JSON.parse(item); + item.groups = item.groups || []; + if (item.groups && !Array.isArray(item.groups)) { + item.groups = [item.groups]; + } + + return item; + }); } async function navigationAdminSave(data) { - const db = require('../../database'); - const translator = require('../../translator'); - const order = Object.keys(data); - const items = data.map((item, index) => { - Object.keys(item).forEach((key) => { - if (item.hasOwnProperty(key) && typeof item[key] === 'string' && (key === 'title' || key === 'text')) { - item[key] = translator.escape(item[key]); - } - }); - item.order = order[index]; - return JSON.stringify(item); - }); + const db = require('../../database'); + const translator = require('../../translator'); + const order = Object.keys(data); + const items = data.map((item, index) => { + for (const key of Object.keys(item)) { + if (item.hasOwnProperty(key) && typeof item[key] === 'string' && (key === 'title' || key === 'text')) { + item[key] = translator.escape(item[key]); + } + } + + item.order = order[index]; + return JSON.stringify(item); + }); - await db.delete('navigation:enabled'); - await db.sortedSetAdd('navigation:enabled', order, items); + await db.delete('navigation:enabled'); + await db.sortedSetAdd('navigation:enabled', order, items); } diff --git a/src/upgrades/1.11.0/resize_image_width.js b/src/upgrades/1.11.0/resize_image_width.js index 251d437..1483005 100644 --- a/src/upgrades/1.11.0/resize_image_width.js +++ b/src/upgrades/1.11.0/resize_image_width.js @@ -3,12 +3,12 @@ const db = require('../../database'); module.exports = { - name: 'Rename maximumImageWidth to resizeImageWidth', - timestamp: Date.UTC(2018, 9, 24), - method: async function () { - const meta = require('../../meta'); - const value = await meta.configs.get('maximumImageWidth'); - await meta.configs.set('resizeImageWidth', value); - await db.deleteObjectField('config', 'maximumImageWidth'); - }, + name: 'Rename maximumImageWidth to resizeImageWidth', + timestamp: Date.UTC(2018, 9, 24), + async method() { + const meta = require('../../meta'); + const value = await meta.configs.get('maximumImageWidth'); + await meta.configs.set('resizeImageWidth', value); + await db.deleteObjectField('config', 'maximumImageWidth'); + }, }; diff --git a/src/upgrades/1.11.0/widget_visibility_groups.js b/src/upgrades/1.11.0/widget_visibility_groups.js index bbe4a6c..fda49e9 100644 --- a/src/upgrades/1.11.0/widget_visibility_groups.js +++ b/src/upgrades/1.11.0/widget_visibility_groups.js @@ -1,38 +1,40 @@ 'use strict'; module.exports = { - name: 'Widget visibility groups', - timestamp: Date.UTC(2018, 10, 10), - method: async function () { - const widgetAdmin = require('../../widgets/admin'); - const widgets = require('../../widgets'); - const areas = await widgetAdmin.getAreas(); - for (const area of areas) { - if (area.data.length) { - // area.data is actually an array of widgets - area.widgets = area.data; - area.widgets.forEach((widget) => { - if (widget && widget.data) { - const groupsToShow = ['administrators', 'Global Moderators']; - if (widget.data['hide-guests'] !== 'on') { - groupsToShow.push('guests'); - } - if (widget.data['hide-registered'] !== 'on') { - groupsToShow.push('registered-users'); - } + name: 'Widget visibility groups', + timestamp: Date.UTC(2018, 10, 10), + async method() { + const widgetAdmin = require('../../widgets/admin'); + const widgets = require('../../widgets'); + const areas = await widgetAdmin.getAreas(); + for (const area of areas) { + if (area.data.length > 0) { + // Area.data is actually an array of widgets + area.widgets = area.data; + for (const widget of area.widgets) { + if (widget && widget.data) { + const groupsToShow = ['administrators', 'Global Moderators']; + if (widget.data['hide-guests'] !== 'on') { + groupsToShow.push('guests'); + } - widget.data.groups = groupsToShow; + if (widget.data['hide-registered'] !== 'on') { + groupsToShow.push('registered-users'); + } - // if we are showing to all 4 groups, set to empty array - // empty groups is shown to everyone - if (groupsToShow.length === 4) { - widget.data.groups.length = 0; - } - } - }); - // eslint-disable-next-line no-await-in-loop - await widgets.setArea(area); - } - } - }, + widget.data.groups = groupsToShow; + + // If we are showing to all 4 groups, set to empty array + // empty groups is shown to everyone + if (groupsToShow.length === 4) { + widget.data.groups.length = 0; + } + } + } + + // eslint-disable-next-line no-await-in-loop + await widgets.setArea(area); + } + } + }, }; diff --git a/src/upgrades/1.11.1/remove_ignored_cids_per_user.js b/src/upgrades/1.11.1/remove_ignored_cids_per_user.js index 6c89975..34406fc 100644 --- a/src/upgrades/1.11.1/remove_ignored_cids_per_user.js +++ b/src/upgrades/1.11.1/remove_ignored_cids_per_user.js @@ -1,22 +1,21 @@ 'use strict'; const db = require('../../database'); - const batch = require('../../batch'); module.exports = { - name: 'Remove uid::ignored:cids', - timestamp: Date.UTC(2018, 11, 11), - method: function (callback) { - const { progress } = this; + name: 'Remove uid::ignored:cids', + timestamp: Date.UTC(2018, 11, 11), + method(callback) { + const {progress} = this; - batch.processSortedSet('users:joindate', (uids, next) => { - progress.incr(uids.length); - const keys = uids.map(uid => `uid:${uid}:ignored:cids`); - db.deleteAll(keys, next); - }, { - progress: this.progress, - batch: 500, - }, callback); - }, + batch.processSortedSet('users:joindate', (uids, next) => { + progress.incr(uids.length); + const keys = uids.map(uid => `uid:${uid}:ignored:cids`); + db.deleteAll(keys, next); + }, { + progress: this.progress, + batch: 500, + }, callback); + }, }; diff --git a/src/upgrades/1.12.0/category_watch_state.js b/src/upgrades/1.12.0/category_watch_state.js index 76d36b6..5a48b00 100644 --- a/src/upgrades/1.12.0/category_watch_state.js +++ b/src/upgrades/1.12.0/category_watch_state.js @@ -7,29 +7,29 @@ const batch = require('../../batch'); const categories = require('../../categories'); module.exports = { - name: 'Update category watch data', - timestamp: Date.UTC(2018, 11, 13), - method: async function () { - const { progress } = this; + name: 'Update category watch data', + timestamp: Date.UTC(2018, 11, 13), + async method() { + const {progress} = this; - const cids = await db.getSortedSetRange('categories:cid', 0, -1); - const keys = cids.map(cid => `cid:${cid}:ignorers`); + const cids = await db.getSortedSetRange('categories:cid', 0, -1); + const keys = cids.map(cid => `cid:${cid}:ignorers`); - await batch.processSortedSet('users:joindate', async (uids) => { - progress.incr(uids.length); - for (const cid of cids) { - const isMembers = await db.isSortedSetMembers(`cid:${cid}:ignorers`, uids); - uids = uids.filter((uid, index) => isMembers[index]); - if (uids.length) { - const states = uids.map(() => categories.watchStates.ignoring); - await db.sortedSetAdd(`cid:${cid}:uid:watch:state`, states, uids); - } - } - }, { - progress: progress, - batch: 500, - }); + await batch.processSortedSet('users:joindate', async uids => { + progress.incr(uids.length); + for (const cid of cids) { + const isMembers = await db.isSortedSetMembers(`cid:${cid}:ignorers`, uids); + uids = uids.filter((uid, index) => isMembers[index]); + if (uids.length > 0) { + const states = uids.map(() => categories.watchStates.ignoring); + await db.sortedSetAdd(`cid:${cid}:uid:watch:state`, states, uids); + } + } + }, { + progress, + batch: 500, + }); - await db.deleteAll(keys); - }, + await db.deleteAll(keys); + }, }; diff --git a/src/upgrades/1.12.0/global_view_privileges.js b/src/upgrades/1.12.0/global_view_privileges.js index 1dbdf4b..896969b 100644 --- a/src/upgrades/1.12.0/global_view_privileges.js +++ b/src/upgrades/1.12.0/global_view_privileges.js @@ -4,25 +4,25 @@ const async = require('async'); const privileges = require('../../privileges'); module.exports = { - name: 'Global view privileges', - timestamp: Date.UTC(2019, 0, 5), - method: function (callback) { - const meta = require('../../meta'); + name: 'Global view privileges', + timestamp: Date.UTC(2019, 0, 5), + method(callback) { + const meta = require('../../meta'); - const tasks = [ - async.apply(privileges.global.give, ['groups:view:users', 'groups:view:tags', 'groups:view:groups'], 'registered-users'), - ]; + const tasks = [ + async.apply(privileges.global.give, ['groups:view:users', 'groups:view:tags', 'groups:view:groups'], 'registered-users'), + ]; - if (parseInt(meta.config.privateUserInfo, 10) !== 1) { - tasks.push(async.apply(privileges.global.give, ['groups:view:users', 'groups:view:groups'], 'guests')); - tasks.push(async.apply(privileges.global.give, ['groups:view:users', 'groups:view:groups'], 'spiders')); - } + if (Number.parseInt(meta.config.privateUserInfo, 10) !== 1) { + tasks.push(async.apply(privileges.global.give, ['groups:view:users', 'groups:view:groups'], 'guests')); + tasks.push(async.apply(privileges.global.give, ['groups:view:users', 'groups:view:groups'], 'spiders')); + } - if (parseInt(meta.config.privateTagListing, 10) !== 1) { - tasks.push(async.apply(privileges.global.give, ['groups:view:tags'], 'guests')); - tasks.push(async.apply(privileges.global.give, ['groups:view:tags'], 'spiders')); - } + if (Number.parseInt(meta.config.privateTagListing, 10) !== 1) { + tasks.push(async.apply(privileges.global.give, ['groups:view:tags'], 'guests')); + tasks.push(async.apply(privileges.global.give, ['groups:view:tags'], 'spiders')); + } - async.series(tasks, callback); - }, + async.series(tasks, callback); + }, }; diff --git a/src/upgrades/1.12.0/group_create_privilege.js b/src/upgrades/1.12.0/group_create_privilege.js index 5525772..1875028 100644 --- a/src/upgrades/1.12.0/group_create_privilege.js +++ b/src/upgrades/1.12.0/group_create_privilege.js @@ -3,14 +3,14 @@ const privileges = require('../../privileges'); module.exports = { - name: 'Group create global privilege', - timestamp: Date.UTC(2019, 0, 4), - method: function (callback) { - const meta = require('../../meta'); - if (parseInt(meta.config.allowGroupCreation, 10) === 1) { - privileges.global.give(['groups:group:create'], 'registered-users', callback); - } else { - setImmediate(callback); - } - }, + name: 'Group create global privilege', + timestamp: Date.UTC(2019, 0, 4), + method(callback) { + const meta = require('../../meta'); + if (Number.parseInt(meta.config.allowGroupCreation, 10) === 1) { + privileges.global.give(['groups:group:create'], 'registered-users', callback); + } else { + setImmediate(callback); + } + }, }; diff --git a/src/upgrades/1.12.1/clear_username_email_history.js b/src/upgrades/1.12.1/clear_username_email_history.js index 08f9897..c1cd5f3 100644 --- a/src/upgrades/1.12.1/clear_username_email_history.js +++ b/src/upgrades/1.12.1/clear_username_email_history.js @@ -5,41 +5,45 @@ const db = require('../../database'); const user = require('../../user'); module.exports = { - name: 'Delete username email history for deleted users', - timestamp: Date.UTC(2019, 2, 25), - method: function (callback) { - const { progress } = this; - let currentUid = 1; - db.getObjectField('global', 'nextUid', (err, nextUid) => { - if (err) { - return callback(err); - } - progress.total = nextUid; - async.whilst((next) => { - next(null, currentUid < nextUid); - }, - (next) => { - progress.incr(); - user.exists(currentUid, (err, exists) => { - if (err) { - return next(err); - } - if (exists) { - currentUid += 1; - return next(); - } - db.deleteAll([`user:${currentUid}:usernames`, `user:${currentUid}:emails`], (err) => { - if (err) { - return next(err); - } - currentUid += 1; - next(); - }); - }); - }, - (err) => { - callback(err); - }); - }); - }, + name: 'Delete username email history for deleted users', + timestamp: Date.UTC(2019, 2, 25), + method(callback) { + const {progress} = this; + let currentUid = 1; + db.getObjectField('global', 'nextUid', (error, nextUid) => { + if (error) { + return callback(error); + } + + progress.total = nextUid; + async.whilst(next => { + next(null, currentUid < nextUid); + }, + next => { + progress.incr(); + user.exists(currentUid, (error, exists) => { + if (error) { + return next(error); + } + + if (exists) { + currentUid += 1; + return next(); + } + + db.deleteAll([`user:${currentUid}:usernames`, `user:${currentUid}:emails`], error_ => { + if (error_) { + return next(error_); + } + + currentUid += 1; + next(); + }); + }); + }, + error => { + callback(error); + }); + }); + }, }; diff --git a/src/upgrades/1.12.1/moderation_notes_refactor.js b/src/upgrades/1.12.1/moderation_notes_refactor.js index 9068b8f..f560e9f 100644 --- a/src/upgrades/1.12.1/moderation_notes_refactor.js +++ b/src/upgrades/1.12.1/moderation_notes_refactor.js @@ -6,30 +6,30 @@ const db = require('../../database'); const batch = require('../../batch'); module.exports = { - name: 'Update moderation notes to hashes', - timestamp: Date.UTC(2019, 3, 5), - method: async function () { - const { progress } = this; + name: 'Update moderation notes to hashes', + timestamp: Date.UTC(2019, 3, 5), + async method() { + const {progress} = this; - await batch.processSortedSet('users:joindate', async (uids) => { - await Promise.all(uids.map(async (uid) => { - progress.incr(); + await batch.processSortedSet('users:joindate', async uids => { + await Promise.all(uids.map(async uid => { + progress.incr(); - const notes = await db.getSortedSetRevRange(`uid:${uid}:moderation:notes`, 0, -1); - for (const note of notes) { - const noteData = JSON.parse(note); - noteData.timestamp = noteData.timestamp || Date.now(); - await db.sortedSetRemove(`uid:${uid}:moderation:notes`, note); - await db.setObject(`uid:${uid}:moderation:note:${noteData.timestamp}`, { - uid: noteData.uid, - timestamp: noteData.timestamp, - note: noteData.note, - }); - await db.sortedSetAdd(`uid:${uid}:moderation:notes`, noteData.timestamp, noteData.timestamp); - } - })); - }, { - progress: this.progress, - }); - }, + const notes = await db.getSortedSetRevRange(`uid:${uid}:moderation:notes`, 0, -1); + for (const note of notes) { + const noteData = JSON.parse(note); + noteData.timestamp = noteData.timestamp || Date.now(); + await db.sortedSetRemove(`uid:${uid}:moderation:notes`, note); + await db.setObject(`uid:${uid}:moderation:note:${noteData.timestamp}`, { + uid: noteData.uid, + timestamp: noteData.timestamp, + note: noteData.note, + }); + await db.sortedSetAdd(`uid:${uid}:moderation:notes`, noteData.timestamp, noteData.timestamp); + } + })); + }, { + progress: this.progress, + }); + }, }; diff --git a/src/upgrades/1.12.1/post_upload_sizes.js b/src/upgrades/1.12.1/post_upload_sizes.js index 103e470..f17b703 100644 --- a/src/upgrades/1.12.1/post_upload_sizes.js +++ b/src/upgrades/1.12.1/post_upload_sizes.js @@ -5,19 +5,19 @@ const posts = require('../../posts'); const db = require('../../database'); module.exports = { - name: 'Calculate image sizes of all uploaded images', - timestamp: Date.UTC(2019, 2, 16), - method: async function () { - const { progress } = this; + name: 'Calculate image sizes of all uploaded images', + timestamp: Date.UTC(2019, 2, 16), + async method() { + const {progress} = this; - await batch.processSortedSet('posts:pid', async (pids) => { - const keys = pids.map(p => `post:${p}:uploads`); - const uploads = await db.getSortedSetRange(keys, 0, -1); - await posts.uploads.saveSize(uploads); - progress.incr(pids.length); - }, { - batch: 100, - progress: progress, - }); - }, + await batch.processSortedSet('posts:pid', async pids => { + const keys = pids.map(p => `post:${p}:uploads`); + const uploads = await db.getSortedSetRange(keys, 0, -1); + await posts.uploads.saveSize(uploads); + progress.incr(pids.length); + }, { + batch: 100, + progress, + }); + }, }; diff --git a/src/upgrades/1.12.3/disable_plugin_metrics.js b/src/upgrades/1.12.3/disable_plugin_metrics.js index 96df2bf..d11dd78 100644 --- a/src/upgrades/1.12.3/disable_plugin_metrics.js +++ b/src/upgrades/1.12.3/disable_plugin_metrics.js @@ -3,9 +3,9 @@ const db = require('../../database'); module.exports = { - name: 'Disable plugin metrics for existing installs', - timestamp: Date.UTC(2019, 4, 21), - method: async function (callback) { - db.setObjectField('config', 'submitPluginUsage', 0, callback); - }, + name: 'Disable plugin metrics for existing installs', + timestamp: Date.UTC(2019, 4, 21), + async method(callback) { + db.setObjectField('config', 'submitPluginUsage', 0, callback); + }, }; diff --git a/src/upgrades/1.12.3/give_mod_info_privilege.js b/src/upgrades/1.12.3/give_mod_info_privilege.js index 37d7a89..3f67ae9 100644 --- a/src/upgrades/1.12.3/give_mod_info_privilege.js +++ b/src/upgrades/1.12.3/give_mod_info_privilege.js @@ -7,21 +7,22 @@ const privileges = require('../../privileges'); const groups = require('../../groups'); module.exports = { - name: 'give mod info privilege', - timestamp: Date.UTC(2019, 9, 8), - method: async function () { - const cids = await db.getSortedSetRevRange('categories:cid', 0, -1); - for (const cid of cids) { - await givePrivsToModerators(cid, ''); - await givePrivsToModerators(cid, 'groups:'); - } - await privileges.global.give(['groups:view:users:info'], 'Global Moderators'); + name: 'give mod info privilege', + timestamp: Date.UTC(2019, 9, 8), + async method() { + const cids = await db.getSortedSetRevRange('categories:cid', 0, -1); + for (const cid of cids) { + await givePrivsToModerators(cid, ''); + await givePrivsToModerators(cid, 'groups:'); + } - async function givePrivsToModerators(cid, groupPrefix) { - const members = await db.getSortedSetRevRange(`group:cid:${cid}:privileges:${groupPrefix}moderate:members`, 0, -1); - for (const member of members) { - await groups.join(['cid:0:privileges:view:users:info'], member); - } - } - }, + await privileges.global.give(['groups:view:users:info'], 'Global Moderators'); + + async function givePrivsToModerators(cid, groupPrefix) { + const members = await db.getSortedSetRevRange(`group:cid:${cid}:privileges:${groupPrefix}moderate:members`, 0, -1); + for (const member of members) { + await groups.join(['cid:0:privileges:view:users:info'], member); + } + } + }, }; diff --git a/src/upgrades/1.12.3/give_mod_privileges.js b/src/upgrades/1.12.3/give_mod_privileges.js index 0ef9270..a8aac43 100644 --- a/src/upgrades/1.12.3/give_mod_privileges.js +++ b/src/upgrades/1.12.3/give_mod_privileges.js @@ -7,57 +7,58 @@ const groups = require('../../groups'); const db = require('../../database'); module.exports = { - name: 'Give mods explicit privileges', - timestamp: Date.UTC(2019, 4, 28), - method: async function () { - const defaultPrivileges = [ - 'find', - 'read', - 'topics:read', - 'topics:create', - 'topics:reply', - 'topics:tag', - 'posts:edit', - 'posts:history', - 'posts:delete', - 'posts:upvote', - 'posts:downvote', - 'topics:delete', - ]; - const modPrivileges = defaultPrivileges.concat([ - 'posts:view_deleted', - 'purge', - ]); + name: 'Give mods explicit privileges', + timestamp: Date.UTC(2019, 4, 28), + async method() { + const defaultPrivileges = [ + 'find', + 'read', + 'topics:read', + 'topics:create', + 'topics:reply', + 'topics:tag', + 'posts:edit', + 'posts:history', + 'posts:delete', + 'posts:upvote', + 'posts:downvote', + 'topics:delete', + ]; + const modulePrivileges = defaultPrivileges.concat([ + 'posts:view_deleted', + 'purge', + ]); - const globalModPrivs = [ - 'groups:chat', - 'groups:upload:post:image', - 'groups:upload:post:file', - 'groups:signature', - 'groups:ban', - 'groups:search:content', - 'groups:search:users', - 'groups:search:tags', - 'groups:view:users', - 'groups:view:tags', - 'groups:view:groups', - 'groups:local:login', - ]; + const globalModulePrivs = [ + 'groups:chat', + 'groups:upload:post:image', + 'groups:upload:post:file', + 'groups:signature', + 'groups:ban', + 'groups:search:content', + 'groups:search:users', + 'groups:search:tags', + 'groups:view:users', + 'groups:view:tags', + 'groups:view:groups', + 'groups:local:login', + ]; - const cids = await db.getSortedSetRevRange('categories:cid', 0, -1); - for (const cid of cids) { - await givePrivsToModerators(cid, ''); - await givePrivsToModerators(cid, 'groups:'); - await privileges.categories.give(modPrivileges.map(p => `groups:${p}`), cid, ['Global Moderators']); - } - await privileges.global.give(globalModPrivs, 'Global Moderators'); + const cids = await db.getSortedSetRevRange('categories:cid', 0, -1); + for (const cid of cids) { + await givePrivsToModerators(cid, ''); + await givePrivsToModerators(cid, 'groups:'); + await privileges.categories.give(modulePrivileges.map(p => `groups:${p}`), cid, ['Global Moderators']); + } - async function givePrivsToModerators(cid, groupPrefix) { - const privGroups = modPrivileges.map(priv => `cid:${cid}:privileges:${groupPrefix}${priv}`); - const members = await db.getSortedSetRevRange(`group:cid:${cid}:privileges:${groupPrefix}moderate:members`, 0, -1); - for (const member of members) { - await groups.join(privGroups, member); - } - } - }, + await privileges.global.give(globalModulePrivs, 'Global Moderators'); + + async function givePrivsToModerators(cid, groupPrefix) { + const privGroups = modulePrivileges.map(priv => `cid:${cid}:privileges:${groupPrefix}${priv}`); + const members = await db.getSortedSetRevRange(`group:cid:${cid}:privileges:${groupPrefix}moderate:members`, 0, -1); + for (const member of members) { + await groups.join(privGroups, member); + } + } + }, }; diff --git a/src/upgrades/1.12.3/update_registration_type.js b/src/upgrades/1.12.3/update_registration_type.js index 9b80546..ec339f0 100644 --- a/src/upgrades/1.12.3/update_registration_type.js +++ b/src/upgrades/1.12.3/update_registration_type.js @@ -3,18 +3,18 @@ const db = require('../../database'); module.exports = { - name: 'Update registration type', - timestamp: Date.UTC(2019, 5, 4), - method: function (callback) { - const meta = require('../../meta'); - const registrationType = meta.config.registrationType || 'normal'; - if (registrationType === 'admin-approval' || registrationType === 'admin-approval-ip') { - db.setObject('config', { - registrationType: 'normal', - registrationApprovalType: registrationType, - }, callback); - } else { - setImmediate(callback); - } - }, + name: 'Update registration type', + timestamp: Date.UTC(2019, 5, 4), + method(callback) { + const meta = require('../../meta'); + const registrationType = meta.config.registrationType || 'normal'; + if (registrationType === 'admin-approval' || registrationType === 'admin-approval-ip') { + db.setObject('config', { + registrationType: 'normal', + registrationApprovalType: registrationType, + }, callback); + } else { + setImmediate(callback); + } + }, }; diff --git a/src/upgrades/1.12.3/user_pid_sets.js b/src/upgrades/1.12.3/user_pid_sets.js index 6cab55a..38f63c6 100644 --- a/src/upgrades/1.12.3/user_pid_sets.js +++ b/src/upgrades/1.12.3/user_pid_sets.js @@ -1,35 +1,35 @@ 'use strict'; - const db = require('../../database'); const batch = require('../../batch'); const posts = require('../../posts'); const topics = require('../../topics'); module.exports = { - name: 'Create zsets for user posts per category', - timestamp: Date.UTC(2019, 5, 23), - method: async function () { - const { progress } = this; + name: 'Create zsets for user posts per category', + timestamp: Date.UTC(2019, 5, 23), + async method() { + const {progress} = this; + + await batch.processSortedSet('posts:pid', async pids => { + progress.incr(pids.length); + const postData = await posts.getPostsFields(pids, ['pid', 'uid', 'tid', 'upvotes', 'downvotes', 'timestamp']); + const tids = postData.map(p => p.tid); + const topicData = await topics.getTopicsFields(tids, ['cid']); + const bulk = []; + for (const [index, p] of postData.entries()) { + if (p && p.uid && p.pid && p.tid && p.timestamp) { + bulk.push([`cid:${topicData[index].cid}:uid:${p.uid}:pids`, p.timestamp, p.pid]); + if (p.votes > 0) { + bulk.push([`cid:${topicData[index].cid}:uid:${p.uid}:pids:votes`, p.votes, p.pid]); + } + } + } - await batch.processSortedSet('posts:pid', async (pids) => { - progress.incr(pids.length); - const postData = await posts.getPostsFields(pids, ['pid', 'uid', 'tid', 'upvotes', 'downvotes', 'timestamp']); - const tids = postData.map(p => p.tid); - const topicData = await topics.getTopicsFields(tids, ['cid']); - const bulk = []; - postData.forEach((p, index) => { - if (p && p.uid && p.pid && p.tid && p.timestamp) { - bulk.push([`cid:${topicData[index].cid}:uid:${p.uid}:pids`, p.timestamp, p.pid]); - if (p.votes > 0) { - bulk.push([`cid:${topicData[index].cid}:uid:${p.uid}:pids:votes`, p.votes, p.pid]); - } - } - }); - await db.sortedSetAddBulk(bulk); - }, { - progress: progress, - }); - }, + await db.sortedSetAddBulk(bulk); + }, { + progress, + }); + }, }; diff --git a/src/upgrades/1.13.0/clean_flag_byCid.js b/src/upgrades/1.13.0/clean_flag_byCid.js index c4bb66d..da47be8 100644 --- a/src/upgrades/1.13.0/clean_flag_byCid.js +++ b/src/upgrades/1.13.0/clean_flag_byCid.js @@ -4,24 +4,24 @@ const db = require('../../database'); const batch = require('../../batch'); module.exports = { - name: 'Clean flag byCid zsets', - timestamp: Date.UTC(2019, 8, 24), - method: async function () { - const { progress } = this; + name: 'Clean flag byCid zsets', + timestamp: Date.UTC(2019, 8, 24), + async method() { + const {progress} = this; - await batch.processSortedSet('flags:datetime', async (flagIds) => { - progress.incr(flagIds.length); - const flagData = await db.getObjects(flagIds.map(id => `flag:${id}`)); - const bulkRemove = []; - for (const flagObj of flagData) { - if (flagObj && flagObj.type === 'user' && flagObj.targetId && flagObj.flagId) { - bulkRemove.push([`flags:byCid:${flagObj.targetId}`, flagObj.flagId]); - } - } + await batch.processSortedSet('flags:datetime', async flagIds => { + progress.incr(flagIds.length); + const flagData = await db.getObjects(flagIds.map(id => `flag:${id}`)); + const bulkRemove = []; + for (const flagObject of flagData) { + if (flagObject && flagObject.type === 'user' && flagObject.targetId && flagObject.flagId) { + bulkRemove.push([`flags:byCid:${flagObject.targetId}`, flagObject.flagId]); + } + } - await db.sortedSetRemoveBulk(bulkRemove); - }, { - progress: progress, - }); - }, + await db.sortedSetRemoveBulk(bulkRemove); + }, { + progress, + }); + }, }; diff --git a/src/upgrades/1.13.0/clean_post_topic_hash.js b/src/upgrades/1.13.0/clean_post_topic_hash.js index 61c67a3..371290e 100644 --- a/src/upgrades/1.13.0/clean_post_topic_hash.js +++ b/src/upgrades/1.13.0/clean_post_topic_hash.js @@ -4,92 +4,102 @@ const db = require('../../database'); const batch = require('../../batch'); module.exports = { - name: 'Clean up post hash data', - timestamp: Date.UTC(2019, 9, 7), - method: async function () { - const { progress } = this; - await cleanPost(progress); - await cleanTopic(progress); - }, + name: 'Clean up post hash data', + timestamp: Date.UTC(2019, 9, 7), + async method() { + const {progress} = this; + await cleanPost(progress); + await cleanTopic(progress); + }, }; async function cleanPost(progress) { - await batch.processSortedSet('posts:pid', async (pids) => { - progress.incr(pids.length); + await batch.processSortedSet('posts:pid', async pids => { + progress.incr(pids.length); - const postData = await db.getObjects(pids.map(pid => `post:${pid}`)); - await Promise.all(postData.map(async (post) => { - if (!post) { - return; - } - const fieldsToDelete = []; - if (post.hasOwnProperty('editor') && post.editor === '') { - fieldsToDelete.push('editor'); - } - if (post.hasOwnProperty('deleted') && parseInt(post.deleted, 10) === 0) { - fieldsToDelete.push('deleted'); - } - if (post.hasOwnProperty('edited') && parseInt(post.edited, 10) === 0) { - fieldsToDelete.push('edited'); - } + const postData = await db.getObjects(pids.map(pid => `post:${pid}`)); + await Promise.all(postData.map(async post => { + if (!post) { + return; + } - // cleanup legacy fields, these are not used anymore - const legacyFields = [ - 'show_banned', 'fav_star_class', 'relativeEditTime', - 'post_rep', 'relativeTime', 'fav_button_class', - 'edited-class', - ]; - legacyFields.forEach((field) => { - if (post.hasOwnProperty(field)) { - fieldsToDelete.push(field); - } - }); + const fieldsToDelete = []; + if (post.hasOwnProperty('editor') && post.editor === '') { + fieldsToDelete.push('editor'); + } - if (fieldsToDelete.length) { - await db.deleteObjectFields(`post:${post.pid}`, fieldsToDelete); - } - })); - }, { - batch: 500, - progress: progress, - }); + if (post.hasOwnProperty('deleted') && Number.parseInt(post.deleted, 10) === 0) { + fieldsToDelete.push('deleted'); + } + + if (post.hasOwnProperty('edited') && Number.parseInt(post.edited, 10) === 0) { + fieldsToDelete.push('edited'); + } + + // Cleanup legacy fields, these are not used anymore + const legacyFields = [ + 'show_banned', + 'fav_star_class', + 'relativeEditTime', + 'post_rep', + 'relativeTime', + 'fav_button_class', + 'edited-class', + ]; + for (const field of legacyFields) { + if (post.hasOwnProperty(field)) { + fieldsToDelete.push(field); + } + } + + if (fieldsToDelete.length > 0) { + await db.deleteObjectFields(`post:${post.pid}`, fieldsToDelete); + } + })); + }, { + batch: 500, + progress, + }); } async function cleanTopic(progress) { - await batch.processSortedSet('topics:tid', async (tids) => { - progress.incr(tids.length); - const topicData = await db.getObjects(tids.map(tid => `topic:${tid}`)); - await Promise.all(topicData.map(async (topic) => { - if (!topic) { - return; - } - const fieldsToDelete = []; - if (topic.hasOwnProperty('deleted') && parseInt(topic.deleted, 10) === 0) { - fieldsToDelete.push('deleted'); - } - if (topic.hasOwnProperty('pinned') && parseInt(topic.pinned, 10) === 0) { - fieldsToDelete.push('pinned'); - } - if (topic.hasOwnProperty('locked') && parseInt(topic.locked, 10) === 0) { - fieldsToDelete.push('locked'); - } + await batch.processSortedSet('topics:tid', async tids => { + progress.incr(tids.length); + const topicData = await db.getObjects(tids.map(tid => `topic:${tid}`)); + await Promise.all(topicData.map(async topic => { + if (!topic) { + return; + } + + const fieldsToDelete = []; + if (topic.hasOwnProperty('deleted') && Number.parseInt(topic.deleted, 10) === 0) { + fieldsToDelete.push('deleted'); + } + + if (topic.hasOwnProperty('pinned') && Number.parseInt(topic.pinned, 10) === 0) { + fieldsToDelete.push('pinned'); + } + + if (topic.hasOwnProperty('locked') && Number.parseInt(topic.locked, 10) === 0) { + fieldsToDelete.push('locked'); + } - // cleanup legacy fields, these are not used anymore - const legacyFields = [ - 'category_name', 'category_slug', - ]; - legacyFields.forEach((field) => { - if (topic.hasOwnProperty(field)) { - fieldsToDelete.push(field); - } - }); + // Cleanup legacy fields, these are not used anymore + const legacyFields = [ + 'category_name', 'category_slug', + ]; + for (const field of legacyFields) { + if (topic.hasOwnProperty(field)) { + fieldsToDelete.push(field); + } + } - if (fieldsToDelete.length) { - await db.deleteObjectFields(`topic:${topic.tid}`, fieldsToDelete); - } - })); - }, { - batch: 500, - progress: progress, - }); + if (fieldsToDelete.length > 0) { + await db.deleteObjectFields(`topic:${topic.tid}`, fieldsToDelete); + } + })); + }, { + batch: 500, + progress, + }); } diff --git a/src/upgrades/1.13.0/cleanup_old_notifications.js b/src/upgrades/1.13.0/cleanup_old_notifications.js index 6521846..b1ff708 100644 --- a/src/upgrades/1.13.0/cleanup_old_notifications.js +++ b/src/upgrades/1.13.0/cleanup_old_notifications.js @@ -5,47 +5,52 @@ const batch = require('../../batch'); const user = require('../../user'); module.exports = { - name: 'Clean up old notifications and hash data', - timestamp: Date.UTC(2019, 9, 7), - method: async function () { - const { progress } = this; - const week = 604800000; - const cutoffTime = Date.now() - week; - await batch.processSortedSet('users:joindate', async (uids) => { - progress.incr(uids.length); - await Promise.all([ - db.sortedSetsRemoveRangeByScore(uids.map(uid => `uid:${uid}:notifications:unread`), '-inf', cutoffTime), - db.sortedSetsRemoveRangeByScore(uids.map(uid => `uid:${uid}:notifications:read`), '-inf', cutoffTime), - ]); - const userData = await user.getUsersData(uids); - await Promise.all(userData.map(async (user) => { - if (!user) { - return; - } - const fields = []; - ['picture', 'fullname', 'location', 'birthday', 'website', 'signature', 'uploadedpicture'].forEach((field) => { - if (user[field] === '') { - fields.push(field); - } - }); - ['profileviews', 'reputation', 'postcount', 'topiccount', 'lastposttime', 'banned', 'followerCount', 'followingCount'].forEach((field) => { - if (user[field] === 0) { - fields.push(field); - } - }); - if (user['icon:text']) { - fields.push('icon:text'); - } - if (user['icon:bgColor']) { - fields.push('icon:bgColor'); - } - if (fields.length) { - await db.deleteObjectFields(`user:${user.uid}`, fields); - } - })); - }, { - batch: 500, - progress: progress, - }); - }, + name: 'Clean up old notifications and hash data', + timestamp: Date.UTC(2019, 9, 7), + async method() { + const {progress} = this; + const week = 604_800_000; + const cutoffTime = Date.now() - week; + await batch.processSortedSet('users:joindate', async uids => { + progress.incr(uids.length); + await Promise.all([ + db.sortedSetsRemoveRangeByScore(uids.map(uid => `uid:${uid}:notifications:unread`), '-inf', cutoffTime), + db.sortedSetsRemoveRangeByScore(uids.map(uid => `uid:${uid}:notifications:read`), '-inf', cutoffTime), + ]); + const userData = await user.getUsersData(uids); + await Promise.all(userData.map(async user => { + if (!user) { + return; + } + + const fields = []; + for (const field of ['picture', 'fullname', 'location', 'birthday', 'website', 'signature', 'uploadedpicture']) { + if (user[field] === '') { + fields.push(field); + } + } + + for (const field of ['profileviews', 'reputation', 'postcount', 'topiccount', 'lastposttime', 'banned', 'followerCount', 'followingCount']) { + if (user[field] === 0) { + fields.push(field); + } + } + + if (user['icon:text']) { + fields.push('icon:text'); + } + + if (user['icon:bgColor']) { + fields.push('icon:bgColor'); + } + + if (fields.length > 0) { + await db.deleteObjectFields(`user:${user.uid}`, fields); + } + })); + }, { + batch: 500, + progress, + }); + }, }; diff --git a/src/upgrades/1.13.3/fix_users_sorted_sets.js b/src/upgrades/1.13.3/fix_users_sorted_sets.js index 9d2cc22..ae1c816 100644 --- a/src/upgrades/1.13.3/fix_users_sorted_sets.js +++ b/src/upgrades/1.13.3/fix_users_sorted_sets.js @@ -4,59 +4,61 @@ const db = require('../../database'); const batch = require('../../batch'); module.exports = { - name: 'Fix user sorted sets', - timestamp: Date.UTC(2020, 4, 2), - method: async function () { - const { progress } = this; - const nextUid = await db.getObjectField('global', 'nextUid'); - const allUids = []; - for (let i = 1; i <= nextUid; i++) { - allUids.push(i); - } - - progress.total = nextUid; - let totalUserCount = 0; - - await db.delete('user:null'); - await db.sortedSetsRemove([ - 'users:joindate', - 'users:reputation', - 'users:postcount', - 'users:flags', - ], 'null'); - - await batch.processArray(allUids, async (uids) => { - progress.incr(uids.length); - const userData = await db.getObjects(uids.map(id => `user:${id}`)); - - await Promise.all(userData.map(async (userData, index) => { - if (!userData || !userData.uid) { - await db.sortedSetsRemove([ - 'users:joindate', - 'users:reputation', - 'users:postcount', - 'users:flags', - ], uids[index]); - if (userData && !userData.uid) { - await db.delete(`user:${uids[index]}`); - } - return; - } - totalUserCount += 1; - await db.sortedSetAddBulk([ - ['users:joindate', userData.joindate || Date.now(), uids[index]], - ['users:reputation', userData.reputation || 0, uids[index]], - ['users:postcount', userData.postcount || 0, uids[index]], - ]); - if (userData.hasOwnProperty('flags') && parseInt(userData.flags, 10) > 0) { - await db.sortedSetAdd('users:flags', userData.flags, uids[index]); - } - })); - }, { - progress: progress, - batch: 500, - }); - - await db.setObjectField('global', 'userCount', totalUserCount); - }, + name: 'Fix user sorted sets', + timestamp: Date.UTC(2020, 4, 2), + async method() { + const {progress} = this; + const nextUid = await db.getObjectField('global', 'nextUid'); + const allUids = []; + for (let i = 1; i <= nextUid; i++) { + allUids.push(i); + } + + progress.total = nextUid; + let totalUserCount = 0; + + await db.delete('user:null'); + await db.sortedSetsRemove([ + 'users:joindate', + 'users:reputation', + 'users:postcount', + 'users:flags', + ], 'null'); + + await batch.processArray(allUids, async uids => { + progress.incr(uids.length); + const userData = await db.getObjects(uids.map(id => `user:${id}`)); + + await Promise.all(userData.map(async (userData, index) => { + if (!userData || !userData.uid) { + await db.sortedSetsRemove([ + 'users:joindate', + 'users:reputation', + 'users:postcount', + 'users:flags', + ], uids[index]); + if (userData && !userData.uid) { + await db.delete(`user:${uids[index]}`); + } + + return; + } + + totalUserCount += 1; + await db.sortedSetAddBulk([ + ['users:joindate', userData.joindate || Date.now(), uids[index]], + ['users:reputation', userData.reputation || 0, uids[index]], + ['users:postcount', userData.postcount || 0, uids[index]], + ]); + if (userData.hasOwnProperty('flags') && Number.parseInt(userData.flags, 10) > 0) { + await db.sortedSetAdd('users:flags', userData.flags, uids[index]); + } + })); + }, { + progress, + batch: 500, + }); + + await db.setObjectField('global', 'userCount', totalUserCount); + }, }; diff --git a/src/upgrades/1.13.4/remove_allowFileUploads_priv.js b/src/upgrades/1.13.4/remove_allowFileUploads_priv.js index e4f1c16..eafd012 100644 --- a/src/upgrades/1.13.4/remove_allowFileUploads_priv.js +++ b/src/upgrades/1.13.4/remove_allowFileUploads_priv.js @@ -4,19 +4,19 @@ const db = require('../../database'); const privileges = require('../../privileges'); module.exports = { - name: 'Removing file upload privilege if file uploads were disabled (`allowFileUploads`)', - timestamp: Date.UTC(2020, 4, 21), - method: async () => { - const allowFileUploads = parseInt(await db.getObjectField('config', 'allowFileUploads'), 10); - if (allowFileUploads === 1) { - await db.deleteObjectField('config', 'allowFileUploads'); - return; - } + name: 'Removing file upload privilege if file uploads were disabled (`allowFileUploads`)', + timestamp: Date.UTC(2020, 4, 21), + async method() { + const allowFileUploads = Number.parseInt(await db.getObjectField('config', 'allowFileUploads'), 10); + if (allowFileUploads === 1) { + await db.deleteObjectField('config', 'allowFileUploads'); + return; + } - // Remove `upload:post:file` privilege for all groups - await privileges.categories.rescind(['groups:upload:post:file'], 0, ['guests', 'registered-users', 'Global Moderators']); + // Remove `upload:post:file` privilege for all groups + await privileges.categories.rescind(['groups:upload:post:file'], 0, ['guests', 'registered-users', 'Global Moderators']); - // Clean up the old option from the config hash - await db.deleteObjectField('config', 'allowFileUploads'); - }, + // Clean up the old option from the config hash + await db.deleteObjectField('config', 'allowFileUploads'); + }, }; diff --git a/src/upgrades/1.14.0/fix_category_image_field.js b/src/upgrades/1.14.0/fix_category_image_field.js index 879c123..dd1f4df 100644 --- a/src/upgrades/1.14.0/fix_category_image_field.js +++ b/src/upgrades/1.14.0/fix_category_image_field.js @@ -3,21 +3,22 @@ const db = require('../../database'); module.exports = { - name: 'Remove duplicate image field for categories', - timestamp: Date.UTC(2020, 5, 9), - method: async () => { - const batch = require('../../batch'); - await batch.processSortedSet('categories:cid', async (cids) => { - let categoryData = await db.getObjects(cids.map(c => `category:${c}`)); - categoryData = categoryData.filter(c => c && (c.image || c.backgroundImage)); - if (categoryData.length) { - await Promise.all(categoryData.map(async (data) => { - if (data.image && !data.backgroundImage) { - await db.setObjectField(`category:${data.cid}`, 'backgroundImage', data.image); - } - await db.deleteObjectField(`category:${data.cid}`, 'image', data.image); - })); - } - }, { batch: 500 }); - }, + name: 'Remove duplicate image field for categories', + timestamp: Date.UTC(2020, 5, 9), + async method() { + const batch = require('../../batch'); + await batch.processSortedSet('categories:cid', async cids => { + let categoryData = await db.getObjects(cids.map(c => `category:${c}`)); + categoryData = categoryData.filter(c => c && (c.image || c.backgroundImage)); + if (categoryData.length > 0) { + await Promise.all(categoryData.map(async data => { + if (data.image && !data.backgroundImage) { + await db.setObjectField(`category:${data.cid}`, 'backgroundImage', data.image); + } + + await db.deleteObjectField(`category:${data.cid}`, 'image', data.image); + })); + } + }, {batch: 500}); + }, }; diff --git a/src/upgrades/1.14.0/unescape_navigation_titles.js b/src/upgrades/1.14.0/unescape_navigation_titles.js index a26fc87..7f76c16 100644 --- a/src/upgrades/1.14.0/unescape_navigation_titles.js +++ b/src/upgrades/1.14.0/unescape_navigation_titles.js @@ -3,30 +3,34 @@ const db = require('../../database'); module.exports = { - name: 'Unescape navigation titles', - timestamp: Date.UTC(2020, 5, 26), - method: async function () { - const data = await db.getSortedSetRangeWithScores('navigation:enabled', 0, -1); - const translator = require('../../translator'); - const order = []; - const items = []; - data.forEach((item) => { - const navItem = JSON.parse(item.value); - if (navItem.hasOwnProperty('title')) { - navItem.title = translator.unescape(navItem.title); - navItem.title = navItem.title.replace(/\/g, ''); - } - if (navItem.hasOwnProperty('text')) { - navItem.text = translator.unescape(navItem.text); - navItem.text = navItem.text.replace(/\/g, ''); - } - if (navItem.hasOwnProperty('route')) { - navItem.route = navItem.route.replace('/', '/'); - } - order.push(item.score); - items.push(JSON.stringify(navItem)); - }); - await db.delete('navigation:enabled'); - await db.sortedSetAdd('navigation:enabled', order, items); - }, + name: 'Unescape navigation titles', + timestamp: Date.UTC(2020, 5, 26), + async method() { + const data = await db.getSortedSetRangeWithScores('navigation:enabled', 0, -1); + const translator = require('../../translator'); + const order = []; + const items = []; + for (const item of data) { + const navItem = JSON.parse(item.value); + if (navItem.hasOwnProperty('title')) { + navItem.title = translator.unescape(navItem.title); + navItem.title = navItem.title.replaceAll('\', ''); + } + + if (navItem.hasOwnProperty('text')) { + navItem.text = translator.unescape(navItem.text); + navItem.text = navItem.text.replaceAll('\', ''); + } + + if (navItem.hasOwnProperty('route')) { + navItem.route = navItem.route.replace('/', '/'); + } + + order.push(item.score); + items.push(JSON.stringify(navItem)); + } + + await db.delete('navigation:enabled'); + await db.sortedSetAdd('navigation:enabled', order, items); + }, }; diff --git a/src/upgrades/1.14.1/readd_deleted_recent_topics.js b/src/upgrades/1.14.1/readd_deleted_recent_topics.js index 91c2c3f..32a188e 100644 --- a/src/upgrades/1.14.1/readd_deleted_recent_topics.js +++ b/src/upgrades/1.14.1/readd_deleted_recent_topics.js @@ -1,56 +1,56 @@ 'use strict'; const db = require('../../database'); - const batch = require('../../batch'); module.exports = { - name: 'Re-add deleted topics to topics:recent', - timestamp: Date.UTC(2018, 9, 11), - method: async function () { - const { progress } = this; - - await batch.processSortedSet('topics:tid', async (tids) => { - progress.incr(tids.length); - const topicData = await db.getObjectsFields( - tids.map(tid => `topic:${tid}`), - ['tid', 'lastposttime', 'viewcount', 'postcount', 'upvotes', 'downvotes'] - ); - if (!topicData.tid) { - return; - } - topicData.forEach((t) => { - if (t.hasOwnProperty('upvotes') && t.hasOwnProperty('downvotes')) { - t.votes = parseInt(t.upvotes, 10) - parseInt(t.downvotes, 10); - } - }); - - await db.sortedSetAdd( - 'topics:recent', - topicData.map(t => t.lastposttime || 0), - topicData.map(t => t.tid) - ); - - await db.sortedSetAdd( - 'topics:views', - topicData.map(t => t.viewcount || 0), - topicData.map(t => t.tid) - ); - - await db.sortedSetAdd( - 'topics:posts', - topicData.map(t => t.postcount || 0), - topicData.map(t => t.tid) - ); - - await db.sortedSetAdd( - 'topics:votes', - topicData.map(t => t.votes || 0), - topicData.map(t => t.tid) - ); - }, { - progress: progress, - batchSize: 500, - }); - }, + name: 'Re-add deleted topics to topics:recent', + timestamp: Date.UTC(2018, 9, 11), + async method() { + const {progress} = this; + + await batch.processSortedSet('topics:tid', async tids => { + progress.incr(tids.length); + const topicData = await db.getObjectsFields( + tids.map(tid => `topic:${tid}`), + ['tid', 'lastposttime', 'viewcount', 'postcount', 'upvotes', 'downvotes'], + ); + if (!topicData.tid) { + return; + } + + for (const t of topicData) { + if (t.hasOwnProperty('upvotes') && t.hasOwnProperty('downvotes')) { + t.votes = Number.parseInt(t.upvotes, 10) - Number.parseInt(t.downvotes, 10); + } + } + + await db.sortedSetAdd( + 'topics:recent', + topicData.map(t => t.lastposttime || 0), + topicData.map(t => t.tid), + ); + + await db.sortedSetAdd( + 'topics:views', + topicData.map(t => t.viewcount || 0), + topicData.map(t => t.tid), + ); + + await db.sortedSetAdd( + 'topics:posts', + topicData.map(t => t.postcount || 0), + topicData.map(t => t.tid), + ); + + await db.sortedSetAdd( + 'topics:votes', + topicData.map(t => t.votes || 0), + topicData.map(t => t.tid), + ); + }, { + progress, + batchSize: 500, + }); + }, }; diff --git a/src/upgrades/1.15.0/add_target_uid_to_flags.js b/src/upgrades/1.15.0/add_target_uid_to_flags.js index a7789ba..ddfc954 100644 --- a/src/upgrades/1.15.0/add_target_uid_to_flags.js +++ b/src/upgrades/1.15.0/add_target_uid_to_flags.js @@ -5,33 +5,33 @@ const batch = require('../../batch'); const posts = require('../../posts'); module.exports = { - name: 'Add target uid to flag objects', - timestamp: Date.UTC(2020, 7, 22), - method: async function () { - const { progress } = this; + name: 'Add target uid to flag objects', + timestamp: Date.UTC(2020, 7, 22), + async method() { + const {progress} = this; - await batch.processSortedSet('flags:datetime', async (flagIds) => { - progress.incr(flagIds.length); - const flagData = await db.getObjects(flagIds.map(id => `flag:${id}`)); - for (const flagObj of flagData) { - /* eslint-disable no-await-in-loop */ - if (flagObj) { - const { targetId } = flagObj; - if (targetId) { - if (flagObj.type === 'post') { - const targetUid = await posts.getPostField(targetId, 'uid'); - if (targetUid) { - await db.setObjectField(`flag:${flagObj.flagId}`, 'targetUid', targetUid); - } - } else if (flagObj.type === 'user') { - await db.setObjectField(`flag:${flagObj.flagId}`, 'targetUid', targetId); - } - } - } - } - }, { - progress: progress, - batch: 500, - }); - }, + await batch.processSortedSet('flags:datetime', async flagIds => { + progress.incr(flagIds.length); + const flagData = await db.getObjects(flagIds.map(id => `flag:${id}`)); + for (const flagObject of flagData) { + /* eslint-disable no-await-in-loop */ + if (flagObject) { + const {targetId} = flagObject; + if (targetId) { + if (flagObject.type === 'post') { + const targetUid = await posts.getPostField(targetId, 'uid'); + if (targetUid) { + await db.setObjectField(`flag:${flagObject.flagId}`, 'targetUid', targetUid); + } + } else if (flagObject.type === 'user') { + await db.setObjectField(`flag:${flagObject.flagId}`, 'targetUid', targetId); + } + } + } + } + }, { + progress, + batch: 500, + }); + }, }; diff --git a/src/upgrades/1.15.0/consolidate_flags.js b/src/upgrades/1.15.0/consolidate_flags.js index adf7cfa..eea2033 100644 --- a/src/upgrades/1.15.0/consolidate_flags.js +++ b/src/upgrades/1.15.0/consolidate_flags.js @@ -6,41 +6,43 @@ const posts = require('../../posts'); const user = require('../../user'); module.exports = { - name: 'Consolidate multiple flags reports, going forward', - timestamp: Date.UTC(2020, 6, 16), - method: async function () { - const { progress } = this; - - let flags = await db.getSortedSetRange('flags:datetime', 0, -1); - flags = flags.map(flagId => `flag:${flagId}`); - flags = await db.getObjectsFields(flags, ['flagId', 'type', 'targetId', 'uid', 'description', 'datetime']); - progress.total = flags.length; - - await batch.processArray(flags, async (subset) => { - progress.incr(subset.length); - - await Promise.all(subset.map(async (flagObj) => { - const methods = []; - switch (flagObj.type) { - case 'post': - methods.push(posts.setPostField.bind(posts, flagObj.targetId, 'flagId', flagObj.flagId)); - break; - - case 'user': - methods.push(user.setUserField.bind(user, flagObj.targetId, 'flagId', flagObj.flagId)); - break; - } - - methods.push( - db.sortedSetAdd.bind(db, `flag:${flagObj.flagId}:reports`, flagObj.datetime, String(flagObj.description).slice(0, 250)), - db.sortedSetAdd.bind(db, `flag:${flagObj.flagId}:reporters`, flagObj.datetime, flagObj.uid) - ); - - await Promise.all(methods.map(async method => method())); - })); - }, { - progress: progress, - batch: 500, - }); - }, + name: 'Consolidate multiple flags reports, going forward', + timestamp: Date.UTC(2020, 6, 16), + async method() { + const {progress} = this; + + let flags = await db.getSortedSetRange('flags:datetime', 0, -1); + flags = flags.map(flagId => `flag:${flagId}`); + flags = await db.getObjectsFields(flags, ['flagId', 'type', 'targetId', 'uid', 'description', 'datetime']); + progress.total = flags.length; + + await batch.processArray(flags, async subset => { + progress.incr(subset.length); + + await Promise.all(subset.map(async flagObject => { + const methods = []; + switch (flagObject.type) { + case 'post': { + methods.push(posts.setPostField.bind(posts, flagObject.targetId, 'flagId', flagObject.flagId)); + break; + } + + case 'user': { + methods.push(user.setUserField.bind(user, flagObject.targetId, 'flagId', flagObject.flagId)); + break; + } + } + + methods.push( + db.sortedSetAdd.bind(db, `flag:${flagObject.flagId}:reports`, flagObject.datetime, String(flagObject.description).slice(0, 250)), + db.sortedSetAdd.bind(db, `flag:${flagObject.flagId}:reporters`, flagObject.datetime, flagObject.uid), + ); + + await Promise.all(methods.map(async method => method())); + })); + }, { + progress, + batch: 500, + }); + }, }; diff --git a/src/upgrades/1.15.0/disable_sounds_plugin.js b/src/upgrades/1.15.0/disable_sounds_plugin.js index fde7963..c3a3476 100644 --- a/src/upgrades/1.15.0/disable_sounds_plugin.js +++ b/src/upgrades/1.15.0/disable_sounds_plugin.js @@ -3,9 +3,9 @@ const db = require('../../database'); module.exports = { - name: 'Disable nodebb-plugin-soundpack-default', - timestamp: Date.UTC(2020, 8, 6), - method: async function () { - await db.sortedSetRemove('plugins:active', 'nodebb-plugin-soundpack-default'); - }, + name: 'Disable nodebb-plugin-soundpack-default', + timestamp: Date.UTC(2020, 8, 6), + async method() { + await db.sortedSetRemove('plugins:active', 'nodebb-plugin-soundpack-default'); + }, }; diff --git a/src/upgrades/1.15.0/fix_category_colors.js b/src/upgrades/1.15.0/fix_category_colors.js index f8cfef4..fdac2e9 100644 --- a/src/upgrades/1.15.0/fix_category_colors.js +++ b/src/upgrades/1.15.0/fix_category_colors.js @@ -3,19 +3,19 @@ const db = require('../../database'); module.exports = { - name: 'Fix category colors that are 3 digit hex colors', - timestamp: Date.UTC(2020, 9, 11), - method: async () => { - const batch = require('../../batch'); - await batch.processSortedSet('categories:cid', async (cids) => { - let categoryData = await db.getObjects(cids.map(c => `category:${c}`)); - categoryData = categoryData.filter(c => c && (c.color === '#fff' || c.color === '#333' || String(c.color).length !== 7)); - if (categoryData.length) { - await Promise.all(categoryData.map(async (data) => { - const color = `#${new Array(6).fill((data.color && data.color[1]) || 'f').join('')}`; - await db.setObjectField(`category:${data.cid}`, 'color', color); - })); - } - }, { batch: 500 }); - }, + name: 'Fix category colors that are 3 digit hex colors', + timestamp: Date.UTC(2020, 9, 11), + async method() { + const batch = require('../../batch'); + await batch.processSortedSet('categories:cid', async cids => { + let categoryData = await db.getObjects(cids.map(c => `category:${c}`)); + categoryData = categoryData.filter(c => c && (c.color === '#fff' || c.color === '#333' || String(c.color).length !== 7)); + if (categoryData.length > 0) { + await Promise.all(categoryData.map(async data => { + const color = `#${Array.from({length: 6}).fill((data.color && data.color[1]) || 'f').join('')}`; + await db.setObjectField(`category:${data.cid}`, 'color', color); + })); + } + }, {batch: 500}); + }, }; diff --git a/src/upgrades/1.15.0/fullname_search_set.js b/src/upgrades/1.15.0/fullname_search_set.js index a398d46..2b31356 100644 --- a/src/upgrades/1.15.0/fullname_search_set.js +++ b/src/upgrades/1.15.0/fullname_search_set.js @@ -1,26 +1,25 @@ 'use strict'; const db = require('../../database'); - const batch = require('../../batch'); const user = require('../../user'); module.exports = { - name: 'Create fullname search set', - timestamp: Date.UTC(2020, 8, 11), - method: async function () { - const { progress } = this; + name: 'Create fullname search set', + timestamp: Date.UTC(2020, 8, 11), + async method() { + const {progress} = this; - await batch.processSortedSet('users:joindate', async (uids) => { - progress.incr(uids.length); - const userData = await user.getUsersFields(uids, ['uid', 'fullname']); - const bulkAdd = userData - .filter(u => u.uid && u.fullname) - .map(u => ['fullname:sorted', 0, `${String(u.fullname).slice(0, 255).toLowerCase()}:${u.uid}`]); - await db.sortedSetAddBulk(bulkAdd); - }, { - batch: 500, - progress: this.progress, - }); - }, + await batch.processSortedSet('users:joindate', async uids => { + progress.incr(uids.length); + const userData = await user.getUsersFields(uids, ['uid', 'fullname']); + const bulkAdd = userData + .filter(u => u.uid && u.fullname) + .map(u => ['fullname:sorted', 0, `${String(u.fullname).slice(0, 255).toLowerCase()}:${u.uid}`]); + await db.sortedSetAddBulk(bulkAdd); + }, { + batch: 500, + progress: this.progress, + }); + }, }; diff --git a/src/upgrades/1.15.0/remove_allow_from_uri.js b/src/upgrades/1.15.0/remove_allow_from_uri.js index 24a9dd4..13df71b 100644 --- a/src/upgrades/1.15.0/remove_allow_from_uri.js +++ b/src/upgrades/1.15.0/remove_allow_from_uri.js @@ -3,13 +3,14 @@ const db = require('../../database'); module.exports = { - name: 'Remove allow from uri setting', - timestamp: Date.UTC(2020, 8, 6), - method: async function () { - const meta = require('../../meta'); - if (meta.config['allow-from-uri']) { - await db.setObjectField('config', 'csp-frame-ancestors', meta.config['allow-from-uri']); - } - await db.deleteObjectField('config', 'allow-from-uri'); - }, + name: 'Remove allow from uri setting', + timestamp: Date.UTC(2020, 8, 6), + async method() { + const meta = require('../../meta'); + if (meta.config['allow-from-uri']) { + await db.setObjectField('config', 'csp-frame-ancestors', meta.config['allow-from-uri']); + } + + await db.deleteObjectField('config', 'allow-from-uri'); + }, }; diff --git a/src/upgrades/1.15.0/remove_flag_reporters_zset.js b/src/upgrades/1.15.0/remove_flag_reporters_zset.js index b64a84b..8391dd3 100644 --- a/src/upgrades/1.15.0/remove_flag_reporters_zset.js +++ b/src/upgrades/1.15.0/remove_flag_reporters_zset.js @@ -4,30 +4,30 @@ const db = require('../../database'); const batch = require('../../batch'); module.exports = { - name: 'Remove flag reporters sorted set', - timestamp: Date.UTC(2020, 6, 31), - method: async function () { - const { progress } = this; - progress.total = await db.sortedSetCard('flags:datetime'); + name: 'Remove flag reporters sorted set', + timestamp: Date.UTC(2020, 6, 31), + async method() { + const {progress} = this; + progress.total = await db.sortedSetCard('flags:datetime'); - await batch.processSortedSet('flags:datetime', async (flagIds) => { - await Promise.all(flagIds.map(async (flagId) => { - const [reports, reporterUids] = await Promise.all([ - db.getSortedSetRevRangeWithScores(`flag:${flagId}:reports`, 0, -1), - db.getSortedSetRevRange(`flag:${flagId}:reporters`, 0, -1), - ]); + await batch.processSortedSet('flags:datetime', async flagIds => { + await Promise.all(flagIds.map(async flagId => { + const [reports, reporterUids] = await Promise.all([ + db.getSortedSetRevRangeWithScores(`flag:${flagId}:reports`, 0, -1), + db.getSortedSetRevRange(`flag:${flagId}:reporters`, 0, -1), + ]); - const values = reports.reduce((memo, cur, idx) => { - memo.push([`flag:${flagId}:reports`, cur.score, [(reporterUids[idx] || 0), cur.value].join(';')]); - return memo; - }, []); + const values = reports.reduce((memo, current, index) => { + memo.push([`flag:${flagId}:reports`, current.score, [(reporterUids[index] || 0), current.value].join(';')]); + return memo; + }, []); - await db.delete(`flag:${flagId}:reports`); - await db.sortedSetAddBulk(values); - })); - }, { - batch: 500, - progress: progress, - }); - }, + await db.delete(`flag:${flagId}:reports`); + await db.sortedSetAddBulk(values); + })); + }, { + batch: 500, + progress, + }); + }, }; diff --git a/src/upgrades/1.15.0/topic_poster_count.js b/src/upgrades/1.15.0/topic_poster_count.js index 55834cf..fc078aa 100644 --- a/src/upgrades/1.15.0/topic_poster_count.js +++ b/src/upgrades/1.15.0/topic_poster_count.js @@ -1,30 +1,30 @@ 'use strict'; const db = require('../../database'); - const batch = require('../../batch'); module.exports = { - name: 'Store poster count in topic hash', - timestamp: Date.UTC(2020, 9, 24), - method: async function () { - const { progress } = this; + name: 'Store poster count in topic hash', + timestamp: Date.UTC(2020, 9, 24), + async method() { + const {progress} = this; + + await batch.processSortedSet('topics:tid', async tids => { + progress.incr(tids.length); + const keys = tids.map(tid => `tid:${tid}:posters`); + await db.sortedSetsRemoveRangeByScore(keys, '-inf', 0); + const counts = await db.sortedSetsCard(keys); + const bulkSet = []; + for (const [i, tid] of tids.entries()) { + if (counts[i] > 0) { + bulkSet.push([`topic:${tid}`, {postercount: counts[i]}]); + } + } - await batch.processSortedSet('topics:tid', async (tids) => { - progress.incr(tids.length); - const keys = tids.map(tid => `tid:${tid}:posters`); - await db.sortedSetsRemoveRangeByScore(keys, '-inf', 0); - const counts = await db.sortedSetsCard(keys); - const bulkSet = []; - for (let i = 0; i < tids.length; i++) { - if (counts[i] > 0) { - bulkSet.push([`topic:${tids[i]}`, { postercount: counts[i] }]); - } - } - await db.setObjectBulk(bulkSet); - }, { - progress: progress, - batchSize: 500, - }); - }, + await db.setObjectBulk(bulkSet); + }, { + progress, + batchSize: 500, + }); + }, }; diff --git a/src/upgrades/1.15.0/track_flags_by_target.js b/src/upgrades/1.15.0/track_flags_by_target.js index 952dae5..f3100f9 100644 --- a/src/upgrades/1.15.0/track_flags_by_target.js +++ b/src/upgrades/1.15.0/track_flags_by_target.js @@ -3,13 +3,13 @@ const db = require('../../database'); module.exports = { - name: 'New sorted set for tracking flags by target', - timestamp: Date.UTC(2020, 6, 15), - method: async () => { - const flags = await db.getSortedSetRange('flags:hash', 0, -1); - await Promise.all(flags.map(async (flag) => { - flag = flag.split(':').slice(0, 2); - await db.sortedSetIncrBy('flags:byTarget', 1, flag.join(':')); - })); - }, + name: 'New sorted set for tracking flags by target', + timestamp: Date.UTC(2020, 6, 15), + async method() { + const flags = await db.getSortedSetRange('flags:hash', 0, -1); + await Promise.all(flags.map(async flag => { + flag = flag.split(':').slice(0, 2); + await db.sortedSetIncrBy('flags:byTarget', 1, flag.join(':')); + })); + }, }; diff --git a/src/upgrades/1.15.0/verified_users_group.js b/src/upgrades/1.15.0/verified_users_group.js index b5eb6e8..fe5d182 100644 --- a/src/upgrades/1.15.0/verified_users_group.js +++ b/src/upgrades/1.15.0/verified_users_group.js @@ -1,7 +1,6 @@ 'use strict'; const db = require('../../database'); - const batch = require('../../batch'); const user = require('../../user'); const groups = require('../../groups'); @@ -10,101 +9,104 @@ const privileges = require('../../privileges'); const now = Date.now(); module.exports = { - name: 'Create verified/unverified user groups', - timestamp: Date.UTC(2020, 9, 13), - method: async function () { - const { progress } = this; - - const maxGroupLength = meta.config.maximumGroupNameLength; - meta.config.maximumGroupNameLength = 30; - const timestamp = await db.getObjectField('group:administrators', 'timestamp'); - const verifiedExists = await groups.exists('verified-users'); - if (!verifiedExists) { - await groups.create({ - name: 'verified-users', - hidden: 1, - private: 1, - system: 1, - disableLeave: 1, - disableJoinRequests: 1, - timestamp: timestamp + 1, - }); - } - const unverifiedExists = await groups.exists('unverified-users'); - if (!unverifiedExists) { - await groups.create({ - name: 'unverified-users', - hidden: 1, - private: 1, - system: 1, - disableLeave: 1, - disableJoinRequests: 1, - timestamp: timestamp + 1, - }); - } - // restore setting - meta.config.maximumGroupNameLength = maxGroupLength; - await batch.processSortedSet('users:joindate', async (uids) => { - progress.incr(uids.length); - const userData = await user.getUsersFields(uids, ['uid', 'email:confirmed']); - - const verified = userData.filter(u => parseInt(u['email:confirmed'], 10) === 1); - const unverified = userData.filter(u => parseInt(u['email:confirmed'], 10) !== 1); - - await db.sortedSetAdd( - 'group:verified-users:members', - verified.map(() => now), - verified.map(u => u.uid) - ); - - await db.sortedSetAdd( - 'group:unverified-users:members', - unverified.map(() => now), - unverified.map(u => u.uid) - ); - }, { - batch: 500, - progress: this.progress, - }); - - await db.delete('users:notvalidated'); - await updatePrivilges(); - - const verifiedCount = await db.sortedSetCard('group:verified-users:members'); - const unverifiedCount = await db.sortedSetCard('group:unverified-users:members'); - await db.setObjectField('group:verified-users', 'memberCount', verifiedCount); - await db.setObjectField('group:unverified-users', 'memberCount', unverifiedCount); - }, + name: 'Create verified/unverified user groups', + timestamp: Date.UTC(2020, 9, 13), + async method() { + const {progress} = this; + + const maxGroupLength = meta.config.maximumGroupNameLength; + meta.config.maximumGroupNameLength = 30; + const timestamp = await db.getObjectField('group:administrators', 'timestamp'); + const verifiedExists = await groups.exists('verified-users'); + if (!verifiedExists) { + await groups.create({ + name: 'verified-users', + hidden: 1, + private: 1, + system: 1, + disableLeave: 1, + disableJoinRequests: 1, + timestamp: timestamp + 1, + }); + } + + const unverifiedExists = await groups.exists('unverified-users'); + if (!unverifiedExists) { + await groups.create({ + name: 'unverified-users', + hidden: 1, + private: 1, + system: 1, + disableLeave: 1, + disableJoinRequests: 1, + timestamp: timestamp + 1, + }); + } + + // Restore setting + meta.config.maximumGroupNameLength = maxGroupLength; + await batch.processSortedSet('users:joindate', async uids => { + progress.incr(uids.length); + const userData = await user.getUsersFields(uids, ['uid', 'email:confirmed']); + + const verified = userData.filter(u => Number.parseInt(u['email:confirmed'], 10) === 1); + const unverified = userData.filter(u => Number.parseInt(u['email:confirmed'], 10) !== 1); + + await db.sortedSetAdd( + 'group:verified-users:members', + verified.map(() => now), + verified.map(u => u.uid), + ); + + await db.sortedSetAdd( + 'group:unverified-users:members', + unverified.map(() => now), + unverified.map(u => u.uid), + ); + }, { + batch: 500, + progress: this.progress, + }); + + await db.delete('users:notvalidated'); + await updatePrivilges(); + + const verifiedCount = await db.sortedSetCard('group:verified-users:members'); + const unverifiedCount = await db.sortedSetCard('group:unverified-users:members'); + await db.setObjectField('group:verified-users', 'memberCount', verifiedCount); + await db.setObjectField('group:unverified-users', 'memberCount', unverifiedCount); + }, }; async function updatePrivilges() { - // if email confirmation is required - // give chat, posting privs to "verified-users" group - // remove chat, posting privs from "registered-users" group - - // This config property has been removed from v1.18.0+, but is still present in old datasets - if (meta.config.requireEmailConfirmation) { - const cids = await db.getSortedSetRevRange('categories:cid', 0, -1); - const canChat = await privileges.global.canGroup('chat', 'registered-users'); - if (canChat) { - await privileges.global.give(['groups:chat'], 'verified-users'); - await privileges.global.rescind(['groups:chat'], 'registered-users'); - } - for (const cid of cids) { - /* eslint-disable no-await-in-loop */ - const data = await privileges.categories.list(cid); - - const registeredUsersPrivs = data.groups.find(d => d.name === 'registered-users').privileges; - - if (registeredUsersPrivs['groups:topics:create']) { - await privileges.categories.give(['groups:topics:create'], cid, 'verified-users'); - await privileges.categories.rescind(['groups:topics:create'], cid, 'registered-users'); - } - - if (registeredUsersPrivs['groups:topics:reply']) { - await privileges.categories.give(['groups:topics:reply'], cid, 'verified-users'); - await privileges.categories.rescind(['groups:topics:reply'], cid, 'registered-users'); - } - } - } + // If email confirmation is required + // give chat, posting privs to "verified-users" group + // remove chat, posting privs from "registered-users" group + + // This config property has been removed from v1.18.0+, but is still present in old datasets + if (meta.config.requireEmailConfirmation) { + const cids = await db.getSortedSetRevRange('categories:cid', 0, -1); + const canChat = await privileges.global.canGroup('chat', 'registered-users'); + if (canChat) { + await privileges.global.give(['groups:chat'], 'verified-users'); + await privileges.global.rescind(['groups:chat'], 'registered-users'); + } + + for (const cid of cids) { + /* eslint-disable no-await-in-loop */ + const data = await privileges.categories.list(cid); + + const registeredUsersPrivs = data.groups.find(d => d.name === 'registered-users').privileges; + + if (registeredUsersPrivs['groups:topics:create']) { + await privileges.categories.give(['groups:topics:create'], cid, 'verified-users'); + await privileges.categories.rescind(['groups:topics:create'], cid, 'registered-users'); + } + + if (registeredUsersPrivs['groups:topics:reply']) { + await privileges.categories.give(['groups:topics:reply'], cid, 'verified-users'); + await privileges.categories.rescind(['groups:topics:reply'], cid, 'registered-users'); + } + } + } } diff --git a/src/upgrades/1.15.4/clear_purged_replies.js b/src/upgrades/1.15.4/clear_purged_replies.js index c039494..9ce46fd 100644 --- a/src/upgrades/1.15.4/clear_purged_replies.js +++ b/src/upgrades/1.15.4/clear_purged_replies.js @@ -2,32 +2,32 @@ const _ = require('lodash'); const db = require('../../database'); - const batch = require('../../batch'); module.exports = { - name: 'Clear purged replies and toPid', - timestamp: Date.UTC(2020, 10, 26), - method: async function () { - const { progress } = this; + name: 'Clear purged replies and toPid', + timestamp: Date.UTC(2020, 10, 26), + async method() { + const {progress} = this; + + await batch.processSortedSet('posts:pid', async pids => { + progress.incr(pids.length); + let postData = await db.getObjects(pids.map(pid => `post:${pid}`)); + postData = postData.filter(p => p && Number.parseInt(p.toPid, 10)); + if (postData.length === 0) { + return; + } - await batch.processSortedSet('posts:pid', async (pids) => { - progress.incr(pids.length); - let postData = await db.getObjects(pids.map(pid => `post:${pid}`)); - postData = postData.filter(p => p && parseInt(p.toPid, 10)); - if (!postData.length) { - return; - } - const toPids = postData.map(p => p.toPid); - const exists = await db.exists(toPids.map(pid => `post:${pid}`)); - const pidsToDelete = postData.filter((p, index) => !exists[index]).map(p => p.pid); - await db.deleteObjectFields(pidsToDelete.map(pid => `post:${pid}`), ['toPid']); + const toPids = postData.map(p => p.toPid); + const exists = await db.exists(toPids.map(pid => `post:${pid}`)); + const pidsToDelete = postData.filter((p, index) => !exists[index]).map(p => p.pid); + await db.deleteObjectFields(pidsToDelete.map(pid => `post:${pid}`), ['toPid']); - const repliesToDelete = _.uniq(toPids.filter((pid, index) => !exists[index])); - await db.deleteAll(repliesToDelete.map(pid => `pid:${pid}:replies`)); - }, { - progress: progress, - batchSize: 500, - }); - }, + const repliesToDelete = _.uniq(toPids.filter((pid, index) => !exists[index])); + await db.deleteAll(repliesToDelete.map(pid => `pid:${pid}:replies`)); + }, { + progress, + batchSize: 500, + }); + }, }; diff --git a/src/upgrades/1.16.0/category_tags.js b/src/upgrades/1.16.0/category_tags.js index 52de5ff..954dc34 100644 --- a/src/upgrades/1.16.0/category_tags.js +++ b/src/upgrades/1.16.0/category_tags.js @@ -6,41 +6,41 @@ const batch = require('../../batch'); const topics = require('../../topics'); module.exports = { - name: 'Create category tags sorted sets', - timestamp: Date.UTC(2020, 10, 23), - method: async function () { - const { progress } = this; + name: 'Create category tags sorted sets', + timestamp: Date.UTC(2020, 10, 23), + async method() { + const {progress} = this; - async function getTopicsTags(tids) { - return await db.getSetsMembers( - tids.map(tid => `topic:${tid}:tags`), - ); - } + async function getTopicsTags(tids) { + return await db.getSetsMembers( + tids.map(tid => `topic:${tid}:tags`), + ); + } - await batch.processSortedSet('topics:tid', async (tids) => { - const [topicData, tags] = await Promise.all([ - topics.getTopicsFields(tids, ['tid', 'cid', 'timestamp']), - getTopicsTags(tids), - ]); - const topicsWithTags = topicData.map((t, i) => { - t.tags = tags[i]; - return t; - }).filter(t => t && t.tags.length); + await batch.processSortedSet('topics:tid', async tids => { + const [topicData, tags] = await Promise.all([ + topics.getTopicsFields(tids, ['tid', 'cid', 'timestamp']), + getTopicsTags(tids), + ]); + const topicsWithTags = topicData.map((t, i) => { + t.tags = tags[i]; + return t; + }).filter(t => t && t.tags.length); - await async.eachSeries(topicsWithTags, async (topicObj) => { - const { cid, tags } = topicObj; - await db.sortedSetsAdd( - tags.map(tag => `cid:${cid}:tag:${tag}:topics`), - topicObj.timestamp, - topicObj.tid - ); - const counts = await db.sortedSetsCard(tags.map(tag => `cid:${cid}:tag:${tag}:topics`)); - await db.sortedSetAdd(`cid:${cid}:tags`, counts, tags); - }); - progress.incr(tids.length); - }, { - batch: 500, - progress: progress, - }); - }, + await async.eachSeries(topicsWithTags, async topicObject => { + const {cid, tags} = topicObject; + await db.sortedSetsAdd( + tags.map(tag => `cid:${cid}:tag:${tag}:topics`), + topicObject.timestamp, + topicObject.tid, + ); + const counts = await db.sortedSetsCard(tags.map(tag => `cid:${cid}:tag:${tag}:topics`)); + await db.sortedSetAdd(`cid:${cid}:tags`, counts, tags); + }); + progress.incr(tids.length); + }, { + batch: 500, + progress, + }); + }, }; diff --git a/src/upgrades/1.16.0/migrate_thumbs.js b/src/upgrades/1.16.0/migrate_thumbs.js index 5125f34..c78622c 100644 --- a/src/upgrades/1.16.0/migrate_thumbs.js +++ b/src/upgrades/1.16.0/migrate_thumbs.js @@ -1,42 +1,42 @@ 'use strict'; const nconf = require('nconf'); - const db = require('../../database'); const meta = require('../../meta'); const topics = require('../../topics'); const batch = require('../../batch'); module.exports = { - name: 'Migrate existing topic thumbnails to new format', - timestamp: Date.UTC(2020, 11, 11), - method: async function () { - const { progress } = this; - const current = await meta.configs.get('topicThumbSize'); + name: 'Migrate existing topic thumbnails to new format', + timestamp: Date.UTC(2020, 11, 11), + async method() { + const {progress} = this; + const current = await meta.configs.get('topicThumbSize'); + + if (Number.parseInt(current, 10) === 120) { + await meta.configs.set('topicThumbSize', 512); + } - if (parseInt(current, 10) === 120) { - await meta.configs.set('topicThumbSize', 512); - } + await batch.processSortedSet('topics:tid', async tids => { + const keys = tids.map(tid => `topic:${tid}`); + const topicThumbs = (await db.getObjectsFields(keys, ['thumb'])) + .map(object => (object.thumb ? object.thumb.replace(nconf.get('upload_url'), '') : null)); - await batch.processSortedSet('topics:tid', async (tids) => { - const keys = tids.map(tid => `topic:${tid}`); - const topicThumbs = (await db.getObjectsFields(keys, ['thumb'])) - .map(obj => (obj.thumb ? obj.thumb.replace(nconf.get('upload_url'), '') : null)); + await Promise.all(tids.map(async (tid, index) => { + const path = topicThumbs[index]; + if (path) { + if (path.length < 255 && !path.startsWith('data:')) { + await topics.thumbs.associate({id: tid, path}); + } - await Promise.all(tids.map(async (tid, idx) => { - const path = topicThumbs[idx]; - if (path) { - if (path.length < 255 && !path.startsWith('data:')) { - await topics.thumbs.associate({ id: tid, path }); - } - await db.deleteObjectField(keys[idx], 'thumb'); - } + await db.deleteObjectField(keys[index], 'thumb'); + } - progress.incr(); - })); - }, { - batch: 500, - progress: progress, - }); - }, + progress.incr(); + })); + }, { + batch: 500, + progress, + }); + }, }; diff --git a/src/upgrades/1.17.0/banned_users_group.js b/src/upgrades/1.17.0/banned_users_group.js index a5b931c..9e218fd 100644 --- a/src/upgrades/1.17.0/banned_users_group.js +++ b/src/upgrades/1.17.0/banned_users_group.js @@ -7,57 +7,56 @@ const groups = require('../../groups'); const now = Date.now(); module.exports = { - name: 'Move banned users to banned-users group', - timestamp: Date.UTC(2020, 11, 13), - method: async function () { - const { progress } = this; - const timestamp = await db.getObjectField('group:administrators', 'timestamp'); - const bannedExists = await groups.exists('banned-users'); - if (!bannedExists) { - await groups.create({ - name: 'banned-users', - hidden: 1, - private: 1, - system: 1, - disableLeave: 1, - disableJoinRequests: 1, - timestamp: timestamp + 1, - }); - } - - await batch.processSortedSet('users:banned', async (uids) => { - progress.incr(uids.length); - - await db.sortedSetAdd( - 'group:banned-users:members', - uids.map(() => now), - uids - ); - - await db.sortedSetRemove( - [ - 'group:registered-users:members', - 'group:verified-users:members', - 'group:unverified-users:members', - 'group:Global Moderators:members', - ], - uids - ); - }, { - batch: 500, - progress: this.progress, - }); - - - const bannedCount = await db.sortedSetCard('group:banned-users:members'); - const registeredCount = await db.sortedSetCard('group:registered-users:members'); - const verifiedCount = await db.sortedSetCard('group:verified-users:members'); - const unverifiedCount = await db.sortedSetCard('group:unverified-users:members'); - const globalModCount = await db.sortedSetCard('group:Global Moderators:members'); - await db.setObjectField('group:banned-users', 'memberCount', bannedCount); - await db.setObjectField('group:registered-users', 'memberCount', registeredCount); - await db.setObjectField('group:verified-users', 'memberCount', verifiedCount); - await db.setObjectField('group:unverified-users', 'memberCount', unverifiedCount); - await db.setObjectField('group:Global Moderators', 'memberCount', globalModCount); - }, + name: 'Move banned users to banned-users group', + timestamp: Date.UTC(2020, 11, 13), + async method() { + const {progress} = this; + const timestamp = await db.getObjectField('group:administrators', 'timestamp'); + const bannedExists = await groups.exists('banned-users'); + if (!bannedExists) { + await groups.create({ + name: 'banned-users', + hidden: 1, + private: 1, + system: 1, + disableLeave: 1, + disableJoinRequests: 1, + timestamp: timestamp + 1, + }); + } + + await batch.processSortedSet('users:banned', async uids => { + progress.incr(uids.length); + + await db.sortedSetAdd( + 'group:banned-users:members', + uids.map(() => now), + uids, + ); + + await db.sortedSetRemove( + [ + 'group:registered-users:members', + 'group:verified-users:members', + 'group:unverified-users:members', + 'group:Global Moderators:members', + ], + uids, + ); + }, { + batch: 500, + progress: this.progress, + }); + + const bannedCount = await db.sortedSetCard('group:banned-users:members'); + const registeredCount = await db.sortedSetCard('group:registered-users:members'); + const verifiedCount = await db.sortedSetCard('group:verified-users:members'); + const unverifiedCount = await db.sortedSetCard('group:unverified-users:members'); + const globalModuleCount = await db.sortedSetCard('group:Global Moderators:members'); + await db.setObjectField('group:banned-users', 'memberCount', bannedCount); + await db.setObjectField('group:registered-users', 'memberCount', registeredCount); + await db.setObjectField('group:verified-users', 'memberCount', verifiedCount); + await db.setObjectField('group:unverified-users', 'memberCount', unverifiedCount); + await db.setObjectField('group:Global Moderators', 'memberCount', globalModuleCount); + }, }; diff --git a/src/upgrades/1.17.0/category_name_zset.js b/src/upgrades/1.17.0/category_name_zset.js index c5398db..14b9669 100644 --- a/src/upgrades/1.17.0/category_name_zset.js +++ b/src/upgrades/1.17.0/category_name_zset.js @@ -4,25 +4,25 @@ const db = require('../../database'); const batch = require('../../batch'); module.exports = { - name: 'Create category name sorted set', - timestamp: Date.UTC(2021, 0, 27), - method: async function () { - const { progress } = this; + name: 'Create category name sorted set', + timestamp: Date.UTC(2021, 0, 27), + async method() { + const {progress} = this; - await batch.processSortedSet('categories:cid', async (cids) => { - const keys = cids.map(cid => `category:${cid}`); - let categoryData = await db.getObjectsFields(keys, ['cid', 'name']); - categoryData = categoryData.filter(c => c.cid && c.name); - const bulkAdd = categoryData.map(cat => [ - 'categories:name', - 0, - `${String(cat.name).slice(0, 200).toLowerCase()}:${cat.cid}`, - ]); - await db.sortedSetAddBulk(bulkAdd); - progress.incr(cids.length); - }, { - batch: 500, - progress: progress, - }); - }, + await batch.processSortedSet('categories:cid', async cids => { + const keys = cids.map(cid => `category:${cid}`); + let categoryData = await db.getObjectsFields(keys, ['cid', 'name']); + categoryData = categoryData.filter(c => c.cid && c.name); + const bulkAdd = categoryData.map(cat => [ + 'categories:name', + 0, + `${String(cat.name).slice(0, 200).toLowerCase()}:${cat.cid}`, + ]); + await db.sortedSetAddBulk(bulkAdd); + progress.incr(cids.length); + }, { + batch: 500, + progress, + }); + }, }; diff --git a/src/upgrades/1.17.0/default_favicon.js b/src/upgrades/1.17.0/default_favicon.js index 057d99f..d3c5e74 100644 --- a/src/upgrades/1.17.0/default_favicon.js +++ b/src/upgrades/1.17.0/default_favicon.js @@ -1,20 +1,20 @@ 'use strict'; +const path = require('node:path'); +const fs = require('node:fs'); const nconf = require('nconf'); -const path = require('path'); -const fs = require('fs'); const file = require('../../file'); module.exports = { - name: 'Store default favicon if it does not exist', - timestamp: Date.UTC(2021, 2, 9), - method: async function () { - const pathToIco = path.join(nconf.get('upload_path'), 'system', 'favicon.ico'); - const defaultIco = path.join(nconf.get('base_dir'), 'public', 'favicon.ico'); - const targetExists = await file.exists(pathToIco); - const defaultExists = await file.exists(defaultIco); - if (defaultExists && !targetExists) { - await fs.promises.copyFile(defaultIco, pathToIco); - } - }, + name: 'Store default favicon if it does not exist', + timestamp: Date.UTC(2021, 2, 9), + async method() { + const pathToIco = path.join(nconf.get('upload_path'), 'system', 'favicon.ico'); + const defaultIco = path.join(nconf.get('base_dir'), 'public', 'favicon.ico'); + const targetExists = await file.exists(pathToIco); + const defaultExists = await file.exists(defaultIco); + if (defaultExists && !targetExists) { + await fs.promises.copyFile(defaultIco, pathToIco); + } + }, }; diff --git a/src/upgrades/1.17.0/schedule_privilege_for_existing_categories.js b/src/upgrades/1.17.0/schedule_privilege_for_existing_categories.js index 0d1a3ef..be62929 100644 --- a/src/upgrades/1.17.0/schedule_privilege_for_existing_categories.js +++ b/src/upgrades/1.17.0/schedule_privilege_for_existing_categories.js @@ -4,15 +4,15 @@ const db = require('../../database'); const privileges = require('../../privileges'); module.exports = { - name: 'Add "schedule" to default privileges of admins and gmods for existing categories', - timestamp: Date.UTC(2021, 2, 11), - method: async () => { - const privilegeToGive = ['groups:topics:schedule']; + name: 'Add "schedule" to default privileges of admins and gmods for existing categories', + timestamp: Date.UTC(2021, 2, 11), + async method() { + const privilegeToGive = ['groups:topics:schedule']; - const cids = await db.getSortedSetRevRange('categories:cid', 0, -1); - for (const cid of cids) { - /* eslint-disable no-await-in-loop */ - await privileges.categories.give(privilegeToGive, cid, ['administrators', 'Global Moderators']); - } - }, + const cids = await db.getSortedSetRevRange('categories:cid', 0, -1); + for (const cid of cids) { + /* eslint-disable no-await-in-loop */ + await privileges.categories.give(privilegeToGive, cid, ['administrators', 'Global Moderators']); + } + }, }; diff --git a/src/upgrades/1.17.0/subcategories_per_page.js b/src/upgrades/1.17.0/subcategories_per_page.js index 5fb4acf..238f579 100644 --- a/src/upgrades/1.17.0/subcategories_per_page.js +++ b/src/upgrades/1.17.0/subcategories_per_page.js @@ -4,20 +4,20 @@ const db = require('../../database'); const batch = require('../../batch'); module.exports = { - name: 'Create subCategoriesPerPage property for categories', - timestamp: Date.UTC(2021, 0, 31), - method: async function () { - const { progress } = this; + name: 'Create subCategoriesPerPage property for categories', + timestamp: Date.UTC(2021, 0, 31), + async method() { + const {progress} = this; - await batch.processSortedSet('categories:cid', async (cids) => { - const keys = cids.map(cid => `category:${cid}`); - await db.setObject(keys, { - subCategoriesPerPage: 10, - }); - progress.incr(cids.length); - }, { - batch: 500, - progress: progress, - }); - }, + await batch.processSortedSet('categories:cid', async cids => { + const keys = cids.map(cid => `category:${cid}`); + await db.setObject(keys, { + subCategoriesPerPage: 10, + }); + progress.incr(cids.length); + }, { + batch: 500, + progress, + }); + }, }; diff --git a/src/upgrades/1.17.0/topic_thumb_count.js b/src/upgrades/1.17.0/topic_thumb_count.js index b3366e6..af65819 100644 --- a/src/upgrades/1.17.0/topic_thumb_count.js +++ b/src/upgrades/1.17.0/topic_thumb_count.js @@ -5,24 +5,24 @@ const db = require('../../database'); const batch = require('../../batch'); module.exports = { - name: 'Store number of thumbs a topic has in the topic object', - timestamp: Date.UTC(2021, 1, 7), - method: async function () { - const { progress } = this; + name: 'Store number of thumbs a topic has in the topic object', + timestamp: Date.UTC(2021, 1, 7), + async method() { + const {progress} = this; - await batch.processSortedSet('topics:tid', async (tids) => { - const keys = tids.map(tid => `topic:${tid}:thumbs`); - const counts = await db.sortedSetsCard(keys); - const tidToCount = _.zipObject(tids, counts); - const tidsWithThumbs = tids.filter((t, i) => counts[i] > 0); - await db.setObjectBulk( - tidsWithThumbs.map(tid => [`topic:${tid}`, { numThumbs: tidToCount[tid] }]), - ); + await batch.processSortedSet('topics:tid', async tids => { + const keys = tids.map(tid => `topic:${tid}:thumbs`); + const counts = await db.sortedSetsCard(keys); + const tidToCount = _.zipObject(tids, counts); + const tidsWithThumbs = tids.filter((t, i) => counts[i] > 0); + await db.setObjectBulk( + tidsWithThumbs.map(tid => [`topic:${tid}`, {numThumbs: tidToCount[tid]}]), + ); - progress.incr(tids.length); - }, { - batch: 500, - progress: progress, - }); - }, + progress.incr(tids.length); + }, { + batch: 500, + progress, + }); + }, }; diff --git a/src/upgrades/1.18.0/enable_include_unverified_emails.js b/src/upgrades/1.18.0/enable_include_unverified_emails.js index 060e886..f93ba95 100644 --- a/src/upgrades/1.18.0/enable_include_unverified_emails.js +++ b/src/upgrades/1.18.0/enable_include_unverified_emails.js @@ -3,10 +3,10 @@ const meta = require('../../meta'); module.exports = { - name: 'Enable setting to include unverified emails for all mailings', - // remember, month is zero-indexed (so January is 0, December is 11) - timestamp: Date.UTC(2021, 5, 18), - method: async () => { - await meta.configs.set('includeUnverifiedEmails', 1); - }, + name: 'Enable setting to include unverified emails for all mailings', + // Remember, month is zero-indexed (so January is 0, December is 11) + timestamp: Date.UTC(2021, 5, 18), + async method() { + await meta.configs.set('includeUnverifiedEmails', 1); + }, }; diff --git a/src/upgrades/1.18.0/topic_tags_refactor.js b/src/upgrades/1.18.0/topic_tags_refactor.js index b1425f5..c675636 100644 --- a/src/upgrades/1.18.0/topic_tags_refactor.js +++ b/src/upgrades/1.18.0/topic_tags_refactor.js @@ -4,34 +4,34 @@ const db = require('../../database'); const batch = require('../../batch'); module.exports = { - name: 'Store tags in topic hash', - timestamp: Date.UTC(2021, 8, 9), - method: async function () { - const { progress } = this; + name: 'Store tags in topic hash', + timestamp: Date.UTC(2021, 8, 9), + async method() { + const {progress} = this; - async function getTopicsTags(tids) { - return await db.getSetsMembers( - tids.map(tid => `topic:${tid}:tags`), - ); - } + async function getTopicsTags(tids) { + return await db.getSetsMembers( + tids.map(tid => `topic:${tid}:tags`), + ); + } - await batch.processSortedSet('topics:tid', async (tids) => { - const tags = await getTopicsTags(tids); + await batch.processSortedSet('topics:tid', async tids => { + const tags = await getTopicsTags(tids); - const topicsWithTags = tids.map((tid, i) => { - const topic = { tid: tid }; - topic.tags = tags[i]; - return topic; - }).filter(t => t && t.tags.length); + const topicsWithTags = tids.map((tid, i) => { + const topic = {tid}; + topic.tags = tags[i]; + return topic; + }).filter(t => t && t.tags.length); - await db.setObjectBulk( - topicsWithTags.map(t => [`topic:${t.tid}`, { tags: t.tags.join(',') }]), - ); - await db.deleteAll(tids.map(tid => `topic:${tid}:tags`)); - progress.incr(tids.length); - }, { - batch: 500, - progress: progress, - }); - }, + await db.setObjectBulk( + topicsWithTags.map(t => [`topic:${t.tid}`, {tags: t.tags.join(',')}]), + ); + await db.deleteAll(tids.map(tid => `topic:${tid}:tags`)); + progress.incr(tids.length); + }, { + batch: 500, + progress, + }); + }, }; diff --git a/src/upgrades/1.18.4/category_topics_views.js b/src/upgrades/1.18.4/category_topics_views.js index f5601a7..2c25c9a 100644 --- a/src/upgrades/1.18.4/category_topics_views.js +++ b/src/upgrades/1.18.4/category_topics_views.js @@ -5,19 +5,19 @@ const batch = require('../../batch'); const topics = require('../../topics'); module.exports = { - name: 'Category topics sorted sets by views', - timestamp: Date.UTC(2021, 8, 28), - method: async function () { - const { progress } = this; + name: 'Category topics sorted sets by views', + timestamp: Date.UTC(2021, 8, 28), + async method() { + const {progress} = this; - await batch.processSortedSet('topics:tid', async (tids) => { - let topicData = await topics.getTopicsData(tids); - topicData = topicData.filter(t => t && t.cid); - await db.sortedSetAddBulk(topicData.map(t => ([`cid:${t.cid}:tids:views`, t.viewcount || 0, t.tid]))); - progress.incr(tids.length); - }, { - batch: 500, - progress: progress, - }); - }, + await batch.processSortedSet('topics:tid', async tids => { + let topicData = await topics.getTopicsData(tids); + topicData = topicData.filter(t => t && t.cid); + await db.sortedSetAddBulk(topicData.map(t => ([`cid:${t.cid}:tids:views`, t.viewcount || 0, t.tid]))); + progress.incr(tids.length); + }, { + batch: 500, + progress, + }); + }, }; diff --git a/src/upgrades/1.19.0/navigation-enabled-hashes.js b/src/upgrades/1.19.0/navigation-enabled-hashes.js index 3700986..4d0af9e 100644 --- a/src/upgrades/1.19.0/navigation-enabled-hashes.js +++ b/src/upgrades/1.19.0/navigation-enabled-hashes.js @@ -3,29 +3,33 @@ const db = require('../../database'); module.exports = { - name: 'Upgrade navigation items to hashes', - timestamp: Date.UTC(2021, 11, 13), - method: async function () { - const data = await db.getSortedSetRangeWithScores('navigation:enabled', 0, -1); - const order = []; - const bulkSet = []; + name: 'Upgrade navigation items to hashes', + timestamp: Date.UTC(2021, 11, 13), + async method() { + const data = await db.getSortedSetRangeWithScores('navigation:enabled', 0, -1); + const order = []; + const bulkSet = []; - data.forEach((item) => { - const navItem = JSON.parse(item.value); - if (navItem.hasOwnProperty('properties') && navItem.properties) { - if (navItem.properties.hasOwnProperty('targetBlank')) { - navItem.targetBlank = navItem.properties.targetBlank; - } - delete navItem.properties; - } - if (navItem.hasOwnProperty('groups') && (Array.isArray(navItem.groups) || typeof navItem.groups === 'string')) { - navItem.groups = JSON.stringify(navItem.groups); - } - bulkSet.push([`navigation:enabled:${item.score}`, navItem]); - order.push(item.score); - }); - await db.setObjectBulk(bulkSet); - await db.delete('navigation:enabled'); - await db.sortedSetAdd('navigation:enabled', order, order); - }, + for (const item of data) { + const navItem = JSON.parse(item.value); + if (navItem.hasOwnProperty('properties') && navItem.properties) { + if (navItem.properties.hasOwnProperty('targetBlank')) { + navItem.targetBlank = navItem.properties.targetBlank; + } + + delete navItem.properties; + } + + if (navItem.hasOwnProperty('groups') && (Array.isArray(navItem.groups) || typeof navItem.groups === 'string')) { + navItem.groups = JSON.stringify(navItem.groups); + } + + bulkSet.push([`navigation:enabled:${item.score}`, navItem]); + order.push(item.score); + } + + await db.setObjectBulk(bulkSet); + await db.delete('navigation:enabled'); + await db.sortedSetAdd('navigation:enabled', order, order); + }, }; diff --git a/src/upgrades/1.19.0/reenable-username-login.js b/src/upgrades/1.19.0/reenable-username-login.js index a3bed38..5929006 100644 --- a/src/upgrades/1.19.0/reenable-username-login.js +++ b/src/upgrades/1.19.0/reenable-username-login.js @@ -3,13 +3,13 @@ const meta = require('../../meta'); module.exports = { - name: 'Re-enable username login', - timestamp: Date.UTC(2021, 10, 23), - method: async () => { - const setting = await meta.config.allowLoginWith; + name: 'Re-enable username login', + timestamp: Date.UTC(2021, 10, 23), + async method() { + const setting = await meta.config.allowLoginWith; - if (setting === 'email') { - await meta.configs.set('allowLoginWith', 'username-email'); - } - }, + if (setting === 'email') { + await meta.configs.set('allowLoginWith', 'username-email'); + } + }, }; diff --git a/src/upgrades/1.19.2/remove_leftover_thumbs_after_topic_purge.js b/src/upgrades/1.19.2/remove_leftover_thumbs_after_topic_purge.js index da7de18..0dc276a 100644 --- a/src/upgrades/1.19.2/remove_leftover_thumbs_after_topic_purge.js +++ b/src/upgrades/1.19.2/remove_leftover_thumbs_after_topic_purge.js @@ -1,51 +1,50 @@ 'use strict'; -const path = require('path'); -const fs = require('fs').promises; +const path = require('node:path'); +const fs = require('node:fs').promises; const nconf = require('nconf'); - const db = require('../../database'); const batch = require('../../batch'); const file = require('../../file'); module.exports = { - name: 'Clean up leftover topic thumb sorted sets and files for since-purged topics', - timestamp: Date.UTC(2022, 1, 7), - method: async function () { - const { progress } = this; - const nextTid = await db.getObjectField('global', 'nextTid'); - const tids = []; - for (let x = 1; x < nextTid; x++) { - tids.push(x); - } - - const purgedTids = (await db.isSortedSetMembers('topics:tid', tids)) - .map((exists, idx) => (exists ? false : tids[idx])) - .filter(Boolean); - - const affectedTids = (await db.exists(purgedTids.map(tid => `topic:${tid}:thumbs`))) - .map((exists, idx) => (exists ? purgedTids[idx] : false)) - .filter(Boolean); - - progress.total = affectedTids.length; - - await batch.processArray(affectedTids, async (tids) => { - await Promise.all(tids.map(async (tid) => { - const relativePaths = await db.getSortedSetMembers(`topic:${tid}:thumbs`); - const absolutePaths = relativePaths.map(relativePath => path.join(nconf.get('upload_path'), relativePath)); - - await Promise.all(absolutePaths.map(async (absolutePath) => { - const exists = await file.exists(absolutePath); - if (exists) { - await fs.unlink(absolutePath); - } - })); - await db.delete(`topic:${tid}:thumbs`); - progress.incr(); - })); - }, { - progress, - batch: 100, - }); - }, + name: 'Clean up leftover topic thumb sorted sets and files for since-purged topics', + timestamp: Date.UTC(2022, 1, 7), + async method() { + const {progress} = this; + const nextTid = await db.getObjectField('global', 'nextTid'); + const tids = []; + for (let x = 1; x < nextTid; x++) { + tids.push(x); + } + + const purgedTids = (await db.isSortedSetMembers('topics:tid', tids)) + .map((exists, index) => (exists ? false : tids[index])) + .filter(Boolean); + + const affectedTids = (await db.exists(purgedTids.map(tid => `topic:${tid}:thumbs`))) + .map((exists, index) => (exists ? purgedTids[index] : false)) + .filter(Boolean); + + progress.total = affectedTids.length; + + await batch.processArray(affectedTids, async tids => { + await Promise.all(tids.map(async tid => { + const relativePaths = await db.getSortedSetMembers(`topic:${tid}:thumbs`); + const absolutePaths = relativePaths.map(relativePath => path.join(nconf.get('upload_path'), relativePath)); + + await Promise.all(absolutePaths.map(async absolutePath => { + const exists = await file.exists(absolutePath); + if (exists) { + await fs.unlink(absolutePath); + } + })); + await db.delete(`topic:${tid}:thumbs`); + progress.incr(); + })); + }, { + progress, + batch: 100, + }); + }, }; diff --git a/src/upgrades/1.19.2/store_downvoted_posts_in_zset.js b/src/upgrades/1.19.2/store_downvoted_posts_in_zset.js index 31830dc..baaf5a8 100644 --- a/src/upgrades/1.19.2/store_downvoted_posts_in_zset.js +++ b/src/upgrades/1.19.2/store_downvoted_posts_in_zset.js @@ -3,29 +3,30 @@ const db = require('../../database'); module.exports = { - name: 'Store downvoted posts in user votes sorted set', - timestamp: Date.UTC(2022, 1, 4), - method: async function () { - const batch = require('../../batch'); - const posts = require('../../posts'); - const { progress } = this; + name: 'Store downvoted posts in user votes sorted set', + timestamp: Date.UTC(2022, 1, 4), + async method() { + const batch = require('../../batch'); + const posts = require('../../posts'); + const {progress} = this; - await batch.processSortedSet('posts:pid', async (pids) => { - const postData = await posts.getPostsFields(pids, ['pid', 'uid', 'upvotes', 'downvotes']); - const cids = await posts.getCidsByPids(pids); + await batch.processSortedSet('posts:pid', async pids => { + const postData = await posts.getPostsFields(pids, ['pid', 'uid', 'upvotes', 'downvotes']); + const cids = await posts.getCidsByPids(pids); - const bulkAdd = []; - postData.forEach((post, index) => { - if (post.votes > 0 || post.votes < 0) { - const cid = cids[index]; - bulkAdd.push([`cid:${cid}:uid:${post.uid}:pids:votes`, post.votes, post.pid]); - } - }); - await db.sortedSetAddBulk(bulkAdd); - progress.incr(postData.length); - }, { - progress, - batch: 500, - }); - }, + const bulkAdd = []; + for (const [index, post] of postData.entries()) { + if (post.votes > 0 || post.votes < 0) { + const cid = cids[index]; + bulkAdd.push([`cid:${cid}:uid:${post.uid}:pids:votes`, post.votes, post.pid]); + } + } + + await db.sortedSetAddBulk(bulkAdd); + progress.incr(postData.length); + }, { + progress, + batch: 500, + }); + }, }; diff --git a/src/upgrades/1.19.3/fix_user_uploads_zset.js b/src/upgrades/1.19.3/fix_user_uploads_zset.js index 109df3a..67ac48a 100644 --- a/src/upgrades/1.19.3/fix_user_uploads_zset.js +++ b/src/upgrades/1.19.3/fix_user_uploads_zset.js @@ -1,43 +1,42 @@ 'use strict'; -const crypto = require('crypto'); - +const crypto = require('node:crypto'); const db = require('../../database'); const batch = require('../../batch'); const md5 = filename => crypto.createHash('md5').update(filename).digest('hex'); module.exports = { - name: 'Fix paths in user uploads sorted sets', - timestamp: Date.UTC(2022, 1, 10), - method: async function () { - const { progress } = this; + name: 'Fix paths in user uploads sorted sets', + timestamp: Date.UTC(2022, 1, 10), + async method() { + const {progress} = this; - await batch.processSortedSet('users:joindate', async (uids) => { - progress.incr(uids.length); + await batch.processSortedSet('users:joindate', async uids => { + progress.incr(uids.length); - await Promise.all(uids.map(async (uid) => { - const key = `uid:${uid}:uploads`; - // Rename the paths within - let uploads = await db.getSortedSetRangeWithScores(key, 0, -1); - if (uploads.length) { - // Don't process those that have already the right format - uploads = uploads.filter(upload => upload.value.startsWith('/files/')); + await Promise.all(uids.map(async uid => { + const key = `uid:${uid}:uploads`; + // Rename the paths within + let uploads = await db.getSortedSetRangeWithScores(key, 0, -1); + if (uploads.length > 0) { + // Don't process those that have already the right format + uploads = uploads.filter(upload => upload.value.startsWith('/files/')); - await db.sortedSetRemove(key, uploads.map(upload => upload.value)); - await db.sortedSetAdd( - key, - uploads.map(upload => upload.score), - uploads.map(upload => upload.value.slice(1)) - ); - // Add uid to the upload's hash object - uploads = await db.getSortedSetMembers(key); - await db.setObjectBulk(uploads.map(relativePath => [`upload:${md5(relativePath)}`, { uid: uid }])); - } - })); - }, { - batch: 500, - progress: progress, - }); - }, + await db.sortedSetRemove(key, uploads.map(upload => upload.value)); + await db.sortedSetAdd( + key, + uploads.map(upload => upload.score), + uploads.map(upload => upload.value.slice(1)), + ); + // Add uid to the upload's hash object + uploads = await db.getSortedSetMembers(key); + await db.setObjectBulk(uploads.map(relativePath => [`upload:${md5(relativePath)}`, {uid}])); + } + })); + }, { + batch: 500, + progress, + }); + }, }; diff --git a/src/upgrades/1.19.3/rename_post_upload_hashes.js b/src/upgrades/1.19.3/rename_post_upload_hashes.js index 6287c43..aeb259b 100644 --- a/src/upgrades/1.19.3/rename_post_upload_hashes.js +++ b/src/upgrades/1.19.3/rename_post_upload_hashes.js @@ -2,62 +2,62 @@ 'use strict'; -const crypto = require('crypto'); - +const crypto = require('node:crypto'); const db = require('../../database'); const batch = require('../../batch'); const md5 = filename => crypto.createHash('md5').update(filename).digest('hex'); module.exports = { - name: 'Rename object and sorted sets used in post uploads', - timestamp: Date.UTC(2022, 1, 10), - method: async function () { - const { progress } = this; - - await batch.processSortedSet('posts:pid', async (pids) => { - let keys = pids.map(pid => `post:${pid}:uploads`); - const exists = await db.exists(keys); - keys = keys.filter((key, idx) => exists[idx]); - - progress.incr(pids.length); - - for (const key of keys) { - // Rename the paths within - let uploads = await db.getSortedSetRangeWithScores(key, 0, -1); - - // Don't process those that have already the right format - uploads = uploads.filter(upload => upload && upload.value && !upload.value.startsWith('files/')); - - // Rename the zset members - await db.sortedSetRemove(key, uploads.map(upload => upload.value)); - await db.sortedSetAdd( - key, - uploads.map(upload => upload.score), - uploads.map(upload => `files/${upload.value}`) - ); - - // Rename the object and pids zsets - const hashes = uploads.map(upload => md5(upload.value)); - const newHashes = uploads.map(upload => md5(`files/${upload.value}`)); - - // cant use db.rename since `fix_user_uploads_zset.js` upgrade script already creates - // `upload:md5(upload.value) hash, trying to rename to existing key results in dupe error - const oldData = await db.getObjects(hashes.map(hash => `upload:${hash}`)); - const bulkSet = []; - oldData.forEach((data, idx) => { - if (data) { - bulkSet.push([`upload:${newHashes[idx]}`, data]); - } - }); - await db.setObjectBulk(bulkSet); - await db.deleteAll(hashes.map(hash => `upload:${hash}`)); - - await Promise.all(hashes.map((hash, idx) => db.rename(`upload:${hash}:pids`, `upload:${newHashes[idx]}:pids`))); - } - }, { - batch: 100, - progress: progress, - }); - }, + name: 'Rename object and sorted sets used in post uploads', + timestamp: Date.UTC(2022, 1, 10), + async method() { + const {progress} = this; + + await batch.processSortedSet('posts:pid', async pids => { + let keys = pids.map(pid => `post:${pid}:uploads`); + const exists = await db.exists(keys); + keys = keys.filter((key, index) => exists[index]); + + progress.incr(pids.length); + + for (const key of keys) { + // Rename the paths within + let uploads = await db.getSortedSetRangeWithScores(key, 0, -1); + + // Don't process those that have already the right format + uploads = uploads.filter(upload => upload && upload.value && !upload.value.startsWith('files/')); + + // Rename the zset members + await db.sortedSetRemove(key, uploads.map(upload => upload.value)); + await db.sortedSetAdd( + key, + uploads.map(upload => upload.score), + uploads.map(upload => `files/${upload.value}`), + ); + + // Rename the object and pids zsets + const hashes = uploads.map(upload => md5(upload.value)); + const newHashes = uploads.map(upload => md5(`files/${upload.value}`)); + + // Cant use db.rename since `fix_user_uploads_zset.js` upgrade script already creates + // `upload:md5(upload.value) hash, trying to rename to existing key results in dupe error + const oldData = await db.getObjects(hashes.map(hash => `upload:${hash}`)); + const bulkSet = []; + for (const [index, data] of oldData.entries()) { + if (data) { + bulkSet.push([`upload:${newHashes[index]}`, data]); + } + } + + await db.setObjectBulk(bulkSet); + await db.deleteAll(hashes.map(hash => `upload:${hash}`)); + + await Promise.all(hashes.map((hash, index) => db.rename(`upload:${hash}:pids`, `upload:${newHashes[index]}:pids`))); + } + }, { + batch: 100, + progress, + }); + }, }; diff --git a/src/upgrades/1.2.0/category_recent_tids.js b/src/upgrades/1.2.0/category_recent_tids.js index 4a75746..ce75c61 100644 --- a/src/upgrades/1.2.0/category_recent_tids.js +++ b/src/upgrades/1.2.0/category_recent_tids.js @@ -3,29 +3,30 @@ const async = require('async'); const db = require('../../database'); - module.exports = { - name: 'Category recent tids', - timestamp: Date.UTC(2016, 8, 22), - method: function (callback) { - db.getSortedSetRange('categories:cid', 0, -1, (err, cids) => { - if (err) { - return callback(err); - } + name: 'Category recent tids', + timestamp: Date.UTC(2016, 8, 22), + method(callback) { + db.getSortedSetRange('categories:cid', 0, -1, (error, cids) => { + if (error) { + return callback(error); + } + + async.eachSeries(cids, (cid, next) => { + db.getSortedSetRevRange(`cid:${cid}:pids`, 0, 0, (error, pid) => { + if (error || !pid) { + return next(error); + } + + db.getObjectFields(`post:${pid}`, ['tid', 'timestamp'], (error, postData) => { + if (error || !postData || !postData.tid) { + return next(error); + } - async.eachSeries(cids, (cid, next) => { - db.getSortedSetRevRange(`cid:${cid}:pids`, 0, 0, (err, pid) => { - if (err || !pid) { - return next(err); - } - db.getObjectFields(`post:${pid}`, ['tid', 'timestamp'], (err, postData) => { - if (err || !postData || !postData.tid) { - return next(err); - } - db.sortedSetAdd(`cid:${cid}:recent_tids`, postData.timestamp, postData.tid, next); - }); - }); - }, callback); - }); - }, + db.sortedSetAdd(`cid:${cid}:recent_tids`, postData.timestamp, postData.tid, next); + }); + }); + }, callback); + }); + }, }; diff --git a/src/upgrades/1.2.0/edit_delete_deletetopic_privileges.js b/src/upgrades/1.2.0/edit_delete_deletetopic_privileges.js index 11a0705..4f8fe14 100644 --- a/src/upgrades/1.2.0/edit_delete_deletetopic_privileges.js +++ b/src/upgrades/1.2.0/edit_delete_deletetopic_privileges.js @@ -6,47 +6,49 @@ const winston = require('winston'); const db = require('../../database'); module.exports = { - name: 'Granting edit/delete/delete topic on existing categories', - timestamp: Date.UTC(2016, 7, 7), - method: async function () { - const groupsAPI = require('../../groups'); - const privilegesAPI = require('../../privileges'); - - const cids = await db.getSortedSetRange('categories:cid', 0, -1); - - for (const cid of cids) { - const data = await privilegesAPI.categories.list(cid); - const { groups, users } = data; - - for (const group of groups) { - if (group.privileges['groups:topics:reply']) { - await Promise.all([ - groupsAPI.join(`cid:${cid}:privileges:groups:posts:edit`, group.name), - groupsAPI.join(`cid:${cid}:privileges:groups:posts:delete`, group.name), - ]); - winston.verbose(`cid:${cid}:privileges:groups:posts:edit, cid:${cid}:privileges:groups:posts:delete granted to gid: ${group.name}`); - } - - if (group.privileges['groups:topics:create']) { - await groupsAPI.join(`cid:${cid}:privileges:groups:topics:delete`, group.name); - winston.verbose(`cid:${cid}:privileges:groups:topics:delete granted to gid: ${group.name}`); - } - } - - for (const user of users) { - if (user.privileges['topics:reply']) { - await Promise.all([ - groupsAPI.join(`cid:${cid}:privileges:posts:edit`, user.uid), - groupsAPI.join(`cid:${cid}:privileges:posts:delete`, user.uid), - ]); - winston.verbose(`cid:${cid}:privileges:posts:edit, cid:${cid}:privileges:posts:delete granted to uid: ${user.uid}`); - } - if (user.privileges['topics:create']) { - await groupsAPI.join(`cid:${cid}:privileges:topics:delete`, user.uid); - winston.verbose(`cid:${cid}:privileges:topics:delete granted to uid: ${user.uid}`); - } - } - winston.verbose(`-- cid ${cid} upgraded`); - } - }, + name: 'Granting edit/delete/delete topic on existing categories', + timestamp: Date.UTC(2016, 7, 7), + async method() { + const groupsAPI = require('../../groups'); + const privilegesAPI = require('../../privileges'); + + const cids = await db.getSortedSetRange('categories:cid', 0, -1); + + for (const cid of cids) { + const data = await privilegesAPI.categories.list(cid); + const {groups, users} = data; + + for (const group of groups) { + if (group.privileges['groups:topics:reply']) { + await Promise.all([ + groupsAPI.join(`cid:${cid}:privileges:groups:posts:edit`, group.name), + groupsAPI.join(`cid:${cid}:privileges:groups:posts:delete`, group.name), + ]); + winston.verbose(`cid:${cid}:privileges:groups:posts:edit, cid:${cid}:privileges:groups:posts:delete granted to gid: ${group.name}`); + } + + if (group.privileges['groups:topics:create']) { + await groupsAPI.join(`cid:${cid}:privileges:groups:topics:delete`, group.name); + winston.verbose(`cid:${cid}:privileges:groups:topics:delete granted to gid: ${group.name}`); + } + } + + for (const user of users) { + if (user.privileges['topics:reply']) { + await Promise.all([ + groupsAPI.join(`cid:${cid}:privileges:posts:edit`, user.uid), + groupsAPI.join(`cid:${cid}:privileges:posts:delete`, user.uid), + ]); + winston.verbose(`cid:${cid}:privileges:posts:edit, cid:${cid}:privileges:posts:delete granted to uid: ${user.uid}`); + } + + if (user.privileges['topics:create']) { + await groupsAPI.join(`cid:${cid}:privileges:topics:delete`, user.uid); + winston.verbose(`cid:${cid}:privileges:topics:delete granted to uid: ${user.uid}`); + } + } + + winston.verbose(`-- cid ${cid} upgraded`); + } + }, }; diff --git a/src/upgrades/1.3.0/favourites_to_bookmarks.js b/src/upgrades/1.3.0/favourites_to_bookmarks.js index 79adb59..e10dcb3 100644 --- a/src/upgrades/1.3.0/favourites_to_bookmarks.js +++ b/src/upgrades/1.3.0/favourites_to_bookmarks.js @@ -3,37 +3,38 @@ const db = require('../../database'); module.exports = { - name: 'Favourites to Bookmarks', - timestamp: Date.UTC(2016, 9, 8), - method: async function () { - const { progress } = this; - const batch = require('../../batch'); + name: 'Favourites to Bookmarks', + timestamp: Date.UTC(2016, 9, 8), + async method() { + const {progress} = this; + const batch = require('../../batch'); - async function upgradePosts() { - await batch.processSortedSet('posts:pid', async (ids) => { - await Promise.all(ids.map(async (id) => { - progress.incr(); - await db.rename(`pid:${id}:users_favourited`, `pid:${id}:users_bookmarked`); - const reputation = await db.getObjectField(`post:${id}`, 'reputation'); - if (parseInt(reputation, 10)) { - await db.setObjectField(`post:${id}`, 'bookmarks', reputation); - } - await db.deleteObjectField(`post:${id}`, 'reputation'); - })); - }, { - progress: progress, - }); - } + async function upgradePosts() { + await batch.processSortedSet('posts:pid', async ids => { + await Promise.all(ids.map(async id => { + progress.incr(); + await db.rename(`pid:${id}:users_favourited`, `pid:${id}:users_bookmarked`); + const reputation = await db.getObjectField(`post:${id}`, 'reputation'); + if (Number.parseInt(reputation, 10)) { + await db.setObjectField(`post:${id}`, 'bookmarks', reputation); + } - async function upgradeUsers() { - await batch.processSortedSet('users:joindate', async (ids) => { - await Promise.all(ids.map(async (id) => { - await db.rename(`uid:${id}:favourites`, `uid:${id}:bookmarks`); - })); - }, {}); - } + await db.deleteObjectField(`post:${id}`, 'reputation'); + })); + }, { + progress, + }); + } - await upgradePosts(); - await upgradeUsers(); - }, + async function upgradeUsers() { + await batch.processSortedSet('users:joindate', async ids => { + await Promise.all(ids.map(async id => { + await db.rename(`uid:${id}:favourites`, `uid:${id}:bookmarks`); + })); + }, {}); + } + + await upgradePosts(); + await upgradeUsers(); + }, }; diff --git a/src/upgrades/1.3.0/sorted_sets_for_post_replies.js b/src/upgrades/1.3.0/sorted_sets_for_post_replies.js index 5fa0e41..9a7d402 100644 --- a/src/upgrades/1.3.0/sorted_sets_for_post_replies.js +++ b/src/upgrades/1.3.0/sorted_sets_for_post_replies.js @@ -1,39 +1,39 @@ 'use strict'; - const async = require('async'); const winston = require('winston'); const db = require('../../database'); module.exports = { - name: 'Sorted sets for post replies', - timestamp: Date.UTC(2016, 9, 14), - method: function (callback) { - const posts = require('../../posts'); - const batch = require('../../batch'); - const { progress } = this; + name: 'Sorted sets for post replies', + timestamp: Date.UTC(2016, 9, 14), + method(callback) { + const posts = require('../../posts'); + const batch = require('../../batch'); + const {progress} = this; + + batch.processSortedSet('posts:pid', (ids, next) => { + posts.getPostsFields(ids, ['pid', 'toPid', 'timestamp'], (error, data) => { + if (error) { + return next(error); + } - batch.processSortedSet('posts:pid', (ids, next) => { - posts.getPostsFields(ids, ['pid', 'toPid', 'timestamp'], (err, data) => { - if (err) { - return next(err); - } + progress.incr(); - progress.incr(); + async.eachSeries(data, (postData, next) => { + if (!Number.parseInt(postData.toPid, 10)) { + return next(null); + } - async.eachSeries(data, (postData, next) => { - if (!parseInt(postData.toPid, 10)) { - return next(null); - } - winston.verbose(`processing pid: ${postData.pid} toPid: ${postData.toPid}`); - async.parallel([ - async.apply(db.sortedSetAdd, `pid:${postData.toPid}:replies`, postData.timestamp, postData.pid), - async.apply(db.incrObjectField, `post:${postData.toPid}`, 'replies'), - ], next); - }, next); - }); - }, { - progress: progress, - }, callback); - }, + winston.verbose(`processing pid: ${postData.pid} toPid: ${postData.toPid}`); + async.parallel([ + async.apply(db.sortedSetAdd, `pid:${postData.toPid}:replies`, postData.timestamp, postData.pid), + async.apply(db.incrObjectField, `post:${postData.toPid}`, 'replies'), + ], next); + }, next); + }); + }, { + progress, + }, callback); + }, }; diff --git a/src/upgrades/1.4.0/global_and_user_language_keys.js b/src/upgrades/1.4.0/global_and_user_language_keys.js index 10a8bc0..82c5602 100644 --- a/src/upgrades/1.4.0/global_and_user_language_keys.js +++ b/src/upgrades/1.4.0/global_and_user_language_keys.js @@ -3,35 +3,35 @@ const db = require('../../database'); module.exports = { - name: 'Update global and user language keys', - timestamp: Date.UTC(2016, 10, 22), - method: async function () { - const { progress } = this; - const user = require('../../user'); - const meta = require('../../meta'); - const batch = require('../../batch'); + name: 'Update global and user language keys', + timestamp: Date.UTC(2016, 10, 22), + async method() { + const {progress} = this; + const user = require('../../user'); + const meta = require('../../meta'); + const batch = require('../../batch'); - const defaultLang = await meta.configs.get('defaultLang'); - if (defaultLang) { - const newLanguage = defaultLang.replace('_', '-').replace('@', '-x-'); - if (newLanguage !== defaultLang) { - await meta.configs.set('defaultLang', newLanguage); - } - } + const defaultLang = await meta.configs.get('defaultLang'); + if (defaultLang) { + const newLanguage = defaultLang.replace('_', '-').replace('@', '-x-'); + if (newLanguage !== defaultLang) { + await meta.configs.set('defaultLang', newLanguage); + } + } - await batch.processSortedSet('users:joindate', async (ids) => { - await Promise.all(ids.map(async (uid) => { - progress.incr(); - const language = await db.getObjectField(`user:${uid}:settings`, 'userLang'); - if (language) { - const newLanguage = language.replace('_', '-').replace('@', '-x-'); - if (newLanguage !== language) { - await user.setSetting(uid, 'userLang', newLanguage); - } - } - })); - }, { - progress: progress, - }); - }, + await batch.processSortedSet('users:joindate', async ids => { + await Promise.all(ids.map(async uid => { + progress.incr(); + const language = await db.getObjectField(`user:${uid}:settings`, 'userLang'); + if (language) { + const newLanguage = language.replace('_', '-').replace('@', '-x-'); + if (newLanguage !== language) { + await user.setSetting(uid, 'userLang', newLanguage); + } + } + })); + }, { + progress, + }); + }, }; diff --git a/src/upgrades/1.4.0/sorted_set_for_pinned_topics.js b/src/upgrades/1.4.0/sorted_set_for_pinned_topics.js index e8e96a7..caac4ab 100644 --- a/src/upgrades/1.4.0/sorted_set_for_pinned_topics.js +++ b/src/upgrades/1.4.0/sorted_set_for_pinned_topics.js @@ -1,34 +1,33 @@ 'use strict'; - const async = require('async'); const winston = require('winston'); const db = require('../../database'); module.exports = { - name: 'Sorted set for pinned topics', - timestamp: Date.UTC(2016, 10, 25), - method: function (callback) { - const topics = require('../../topics'); - const batch = require('../../batch'); - batch.processSortedSet('topics:tid', (ids, next) => { - topics.getTopicsFields(ids, ['tid', 'cid', 'pinned', 'lastposttime'], (err, data) => { - if (err) { - return next(err); - } + name: 'Sorted set for pinned topics', + timestamp: Date.UTC(2016, 10, 25), + method(callback) { + const topics = require('../../topics'); + const batch = require('../../batch'); + batch.processSortedSet('topics:tid', (ids, next) => { + topics.getTopicsFields(ids, ['tid', 'cid', 'pinned', 'lastposttime'], (error, data) => { + if (error) { + return next(error); + } - data = data.filter(topicData => parseInt(topicData.pinned, 10) === 1); + data = data.filter(topicData => Number.parseInt(topicData.pinned, 10) === 1); - async.eachSeries(data, (topicData, next) => { - winston.verbose(`processing tid: ${topicData.tid}`); + async.eachSeries(data, (topicData, next) => { + winston.verbose(`processing tid: ${topicData.tid}`); - async.parallel([ - async.apply(db.sortedSetAdd, `cid:${topicData.cid}:tids:pinned`, Date.now(), topicData.tid), - async.apply(db.sortedSetRemove, `cid:${topicData.cid}:tids`, topicData.tid), - async.apply(db.sortedSetRemove, `cid:${topicData.cid}:tids:posts`, topicData.tid), - ], next); - }, next); - }); - }, callback); - }, + async.parallel([ + async.apply(db.sortedSetAdd, `cid:${topicData.cid}:tids:pinned`, Date.now(), topicData.tid), + async.apply(db.sortedSetRemove, `cid:${topicData.cid}:tids`, topicData.tid), + async.apply(db.sortedSetRemove, `cid:${topicData.cid}:tids:posts`, topicData.tid), + ], next); + }, next); + }); + }, callback); + }, }; diff --git a/src/upgrades/1.4.4/config_urls_update.js b/src/upgrades/1.4.4/config_urls_update.js index 14f31ca..7d94379 100644 --- a/src/upgrades/1.4.4/config_urls_update.js +++ b/src/upgrades/1.4.4/config_urls_update.js @@ -1,34 +1,33 @@ 'use strict'; - const db = require('../../database'); module.exports = { - name: 'Upgrading config urls to use assets route', - timestamp: Date.UTC(2017, 1, 28), - method: async function () { - const config = await db.getObject('config'); - if (config) { - const keys = [ - 'brand:favicon', - 'brand:touchicon', - 'og:image', - 'brand:logo:url', - 'defaultAvatar', - 'profile:defaultCovers', - ]; + name: 'Upgrading config urls to use assets route', + timestamp: Date.UTC(2017, 1, 28), + async method() { + const config = await db.getObject('config'); + if (config) { + const keys = [ + 'brand:favicon', + 'brand:touchicon', + 'og:image', + 'brand:logo:url', + 'defaultAvatar', + 'profile:defaultCovers', + ]; - keys.forEach((key) => { - const oldValue = config[key]; + for (const key of keys) { + const oldValue = config[key]; - if (!oldValue || typeof oldValue !== 'string') { - return; - } + if (!oldValue || typeof oldValue !== 'string') { + continue; + } - config[key] = oldValue.replace(/(?:\/assets)?\/(images|uploads)\//g, '/assets/$1/'); - }); + config[key] = oldValue.replaceAll(/(?:\/assets)?\/(images|uploads)\//g, '/assets/$1/'); + } - await db.setObject('config', config); - } - }, + await db.setObject('config', config); + } + }, }; diff --git a/src/upgrades/1.4.4/sound_settings.js b/src/upgrades/1.4.4/sound_settings.js index d0a4034..6e90715 100644 --- a/src/upgrades/1.4.4/sound_settings.js +++ b/src/upgrades/1.4.4/sound_settings.js @@ -3,63 +3,63 @@ const async = require('async'); const db = require('../../database'); - module.exports = { - name: 'Update global and user sound settings', - timestamp: Date.UTC(2017, 1, 25), - method: function (callback) { - const meta = require('../../meta'); - const batch = require('../../batch'); + name: 'Update global and user sound settings', + timestamp: Date.UTC(2017, 1, 25), + method(callback) { + const meta = require('../../meta'); + const batch = require('../../batch'); + + const map = { + 'notification.mp3': 'Default | Deedle-dum', + 'waterdrop-high.mp3': 'Default | Water drop (high)', + 'waterdrop-low.mp3': 'Default | Water drop (low)', + }; - const map = { - 'notification.mp3': 'Default | Deedle-dum', - 'waterdrop-high.mp3': 'Default | Water drop (high)', - 'waterdrop-low.mp3': 'Default | Water drop (low)', - }; + async.parallel([ + function (callback_) { + const keys = ['chat-incoming', 'chat-outgoing', 'notification']; - async.parallel([ - function (cb) { - const keys = ['chat-incoming', 'chat-outgoing', 'notification']; + db.getObject('settings:sounds', (error, settings) => { + if (error || !settings) { + return callback_(error); + } - db.getObject('settings:sounds', (err, settings) => { - if (err || !settings) { - return cb(err); - } + for (const key of keys) { + if (settings[key] && !settings[key].includes(' | ')) { + settings[key] = map[settings[key]] || ''; + } + } - keys.forEach((key) => { - if (settings[key] && !settings[key].includes(' | ')) { - settings[key] = map[settings[key]] || ''; - } - }); + meta.configs.setMultiple(settings, callback_); + }); + }, + function (callback_) { + const keys = ['notificationSound', 'incomingChatSound', 'outgoingChatSound']; - meta.configs.setMultiple(settings, cb); - }); - }, - function (cb) { - const keys = ['notificationSound', 'incomingChatSound', 'outgoingChatSound']; + batch.processSortedSet('users:joindate', (ids, next) => { + async.each(ids, (uid, next) => { + db.getObject(`user:${uid}:settings`, (error, settings) => { + if (error || !settings) { + return next(error); + } - batch.processSortedSet('users:joindate', (ids, next) => { - async.each(ids, (uid, next) => { - db.getObject(`user:${uid}:settings`, (err, settings) => { - if (err || !settings) { - return next(err); - } - const newSettings = {}; - keys.forEach((key) => { - if (settings[key] && !settings[key].includes(' | ')) { - newSettings[key] = map[settings[key]] || ''; - } - }); + const newSettings = {}; + for (const key of keys) { + if (settings[key] && !settings[key].includes(' | ')) { + newSettings[key] = map[settings[key]] || ''; + } + } - if (Object.keys(newSettings).length) { - db.setObject(`user:${uid}:settings`, newSettings, next); - } else { - setImmediate(next); - } - }); - }, next); - }, cb); - }, - ], callback); - }, + if (Object.keys(newSettings).length > 0) { + db.setObject(`user:${uid}:settings`, newSettings, next); + } else { + setImmediate(next); + } + }); + }, next); + }, callback_); + }, + ], callback); + }, }; diff --git a/src/upgrades/1.4.6/delete_sessions.js b/src/upgrades/1.4.6/delete_sessions.js index b5f6a6d..5bb236d 100644 --- a/src/upgrades/1.4.6/delete_sessions.js +++ b/src/upgrades/1.4.6/delete_sessions.js @@ -5,37 +5,38 @@ const db = require('../../database'); const batch = require('../../batch'); module.exports = { - name: 'Delete accidentally long-lived sessions', - timestamp: Date.UTC(2017, 3, 16), - method: async function () { - let configJSON; - try { - configJSON = require('../../../config.json') || { [process.env.database]: true }; - } catch (err) { - configJSON = { [process.env.database]: true }; - } + name: 'Delete accidentally long-lived sessions', + timestamp: Date.UTC(2017, 3, 16), + async method() { + let configJSON; + try { + configJSON = require('../../../config.json') || {[process.env.database]: true}; + } catch { + configJSON = {[process.env.database]: true}; + } - const isRedisSessionStore = configJSON.hasOwnProperty('redis'); - const { progress } = this; + const isRedisSessionStore = configJSON.hasOwnProperty('redis'); + const {progress} = this; - if (isRedisSessionStore) { - const connection = require('../../database/redis/connection'); - const client = await connection.connect(nconf.get('redis')); - const sessionKeys = await client.keys('sess:*'); - progress.total = sessionKeys.length; + if (isRedisSessionStore) { + const connection = require('../../database/redis/connection'); + const client = await connection.connect(nconf.get('redis')); + const sessionKeys = await client.keys('sess:*'); + progress.total = sessionKeys.length; - await batch.processArray(sessionKeys, async (keys) => { - const multi = client.multi(); - keys.forEach((key) => { - progress.incr(); - multi.del(key); - }); - await multi.exec(); - }, { - batch: 1000, - }); - } else if (db.client && db.client.collection) { - await db.client.collection('sessions').deleteMany({}, {}); - } - }, + await batch.processArray(sessionKeys, async keys => { + const multi = client.multi(); + for (const key of keys) { + progress.incr(); + multi.del(key); + } + + await multi.exec(); + }, { + batch: 1000, + }); + } else if (db.client && db.client.collection) { + await db.client.collection('sessions').deleteMany({}, {}); + } + }, }; diff --git a/src/upgrades/1.5.0/allowed_file_extensions.js b/src/upgrades/1.5.0/allowed_file_extensions.js index 74ff750..d4345eb 100644 --- a/src/upgrades/1.5.0/allowed_file_extensions.js +++ b/src/upgrades/1.5.0/allowed_file_extensions.js @@ -3,14 +3,15 @@ const db = require('../../database'); module.exports = { - name: 'Set default allowed file extensions', - timestamp: Date.UTC(2017, 3, 14), - method: function (callback) { - db.getObjectField('config', 'allowedFileExtensions', (err, value) => { - if (err || value) { - return callback(err); - } - db.setObjectField('config', 'allowedFileExtensions', 'png,jpg,bmp', callback); - }); - }, + name: 'Set default allowed file extensions', + timestamp: Date.UTC(2017, 3, 14), + method(callback) { + db.getObjectField('config', 'allowedFileExtensions', (error, value) => { + if (error || value) { + return callback(error); + } + + db.setObjectField('config', 'allowedFileExtensions', 'png,jpg,bmp', callback); + }); + }, }; diff --git a/src/upgrades/1.5.0/flags_refactor.js b/src/upgrades/1.5.0/flags_refactor.js index 54a01ea..4c72847 100644 --- a/src/upgrades/1.5.0/flags_refactor.js +++ b/src/upgrades/1.5.0/flags_refactor.js @@ -3,55 +3,56 @@ const db = require('../../database'); module.exports = { - name: 'Migrating flags to new schema', - timestamp: Date.UTC(2016, 11, 7), - method: async function () { - const batch = require('../../batch'); - const posts = require('../../posts'); - const flags = require('../../flags'); - const { progress } = this; + name: 'Migrating flags to new schema', + timestamp: Date.UTC(2016, 11, 7), + async method() { + const batch = require('../../batch'); + const posts = require('../../posts'); + const flags = require('../../flags'); + const {progress} = this; - await batch.processSortedSet('posts:pid', async (ids) => { - let postData = await posts.getPostsByPids(ids, 1); - postData = postData.filter(post => post.hasOwnProperty('flags')); - await Promise.all(postData.map(async (post) => { - progress.incr(); + await batch.processSortedSet('posts:pid', async ids => { + let postData = await posts.getPostsByPids(ids, 1); + postData = postData.filter(post => post.hasOwnProperty('flags')); + await Promise.all(postData.map(async post => { + progress.incr(); - const [uids, reasons] = await Promise.all([ - db.getSortedSetRangeWithScores(`pid:${post.pid}:flag:uids`, 0, -1), - db.getSortedSetRange(`pid:${post.pid}:flag:uid:reason`, 0, -1), - ]); + const [uids, reasons] = await Promise.all([ + db.getSortedSetRangeWithScores(`pid:${post.pid}:flag:uids`, 0, -1), + db.getSortedSetRange(`pid:${post.pid}:flag:uid:reason`, 0, -1), + ]); - // Adding in another check here in case a post was improperly dismissed - // (flag count > 1 but no flags in db) - if (uids.length && reasons.length) { - // Just take the first entry - const datetime = uids[0].score; - const reason = reasons[0].split(':')[1]; + // Adding in another check here in case a post was improperly dismissed + // (flag count > 1 but no flags in db) + if (uids.length > 0 && reasons.length > 0) { + // Just take the first entry + const datetime = uids[0].score; + const reason = reasons[0].split(':')[1]; - try { - const flagObj = await flags.create('post', post.pid, uids[0].value, reason, datetime); - if (post['flag:state'] || post['flag:assignee']) { - await flags.update(flagObj.flagId, 1, { - state: post['flag:state'], - assignee: post['flag:assignee'], - datetime: datetime, - }); - } - if (post.hasOwnProperty('flag:notes') && post['flag:notes'].length) { - let history = JSON.parse(post['flag:history']); - history = history.filter(event => event.type === 'notes')[0]; - await flags.appendNote(flagObj.flagId, history.uid, post['flag:notes'], history.timestamp); - } - } catch (err) { - if (err.message !== '[[error:post-already-flagged]]') { - throw err; - } - } - } - })); - }, { - progress: this.progress, - }); - }, + try { + const flagObject = await flags.create('post', post.pid, uids[0].value, reason, datetime); + if (post['flag:state'] || post['flag:assignee']) { + await flags.update(flagObject.flagId, 1, { + state: post['flag:state'], + assignee: post['flag:assignee'], + datetime, + }); + } + + if (post.hasOwnProperty('flag:notes') && post['flag:notes'].length > 0) { + let history = JSON.parse(post['flag:history']); + history = history.find(event => event.type === 'notes'); + await flags.appendNote(flagObject.flagId, history.uid, post['flag:notes'], history.timestamp); + } + } catch (error) { + if (error.message !== '[[error:post-already-flagged]]') { + throw error; + } + } + } + })); + }, { + progress: this.progress, + }); + }, }; diff --git a/src/upgrades/1.5.0/moderation_history_refactor.js b/src/upgrades/1.5.0/moderation_history_refactor.js index 7ae0853..2c57571 100644 --- a/src/upgrades/1.5.0/moderation_history_refactor.js +++ b/src/upgrades/1.5.0/moderation_history_refactor.js @@ -4,32 +4,32 @@ const async = require('async'); const db = require('../../database'); const batch = require('../../batch'); - module.exports = { - name: 'Update moderation notes to zset', - timestamp: Date.UTC(2017, 2, 22), - method: function (callback) { - const { progress } = this; + name: 'Update moderation notes to zset', + timestamp: Date.UTC(2017, 2, 22), + method(callback) { + const {progress} = this; + + batch.processSortedSet('users:joindate', (ids, next) => { + async.each(ids, (uid, next) => { + db.getObjectField(`user:${uid}`, 'moderationNote', (error, moderationNote) => { + if (error || !moderationNote) { + progress.incr(); + return next(error); + } - batch.processSortedSet('users:joindate', (ids, next) => { - async.each(ids, (uid, next) => { - db.getObjectField(`user:${uid}`, 'moderationNote', (err, moderationNote) => { - if (err || !moderationNote) { - progress.incr(); - return next(err); - } - const note = { - uid: 1, - note: moderationNote, - timestamp: Date.now(), - }; + const note = { + uid: 1, + note: moderationNote, + timestamp: Date.now(), + }; - progress.incr(); - db.sortedSetAdd(`uid:${uid}:moderation:notes`, note.timestamp, JSON.stringify(note), next); - }); - }, next); - }, { - progress: this.progress, - }, callback); - }, + progress.incr(); + db.sortedSetAdd(`uid:${uid}:moderation:notes`, note.timestamp, JSON.stringify(note), next); + }); + }, next); + }, { + progress: this.progress, + }, callback); + }, }; diff --git a/src/upgrades/1.5.0/post_votes_zset.js b/src/upgrades/1.5.0/post_votes_zset.js index 51a901f..4ce4e6c 100644 --- a/src/upgrades/1.5.0/post_votes_zset.js +++ b/src/upgrades/1.5.0/post_votes_zset.js @@ -3,27 +3,26 @@ const async = require('async'); const db = require('../../database'); - module.exports = { - name: 'New sorted set posts:votes', - timestamp: Date.UTC(2017, 1, 27), - method: function (callback) { - const { progress } = this; + name: 'New sorted set posts:votes', + timestamp: Date.UTC(2017, 1, 27), + method(callback) { + const {progress} = this; - require('../../batch').processSortedSet('posts:pid', (pids, next) => { - async.each(pids, (pid, next) => { - db.getObjectFields(`post:${pid}`, ['upvotes', 'downvotes'], (err, postData) => { - if (err || !postData) { - return next(err); - } + require('../../batch').processSortedSet('posts:pid', (pids, next) => { + async.each(pids, (pid, next) => { + db.getObjectFields(`post:${pid}`, ['upvotes', 'downvotes'], (error, postData) => { + if (error || !postData) { + return next(error); + } - progress.incr(); - const votes = parseInt(postData.upvotes || 0, 10) - parseInt(postData.downvotes || 0, 10); - db.sortedSetAdd('posts:votes', votes, pid, next); - }); - }, next); - }, { - progress: this.progress, - }, callback); - }, + progress.incr(); + const votes = Number.parseInt(postData.upvotes || 0, 10) - Number.parseInt(postData.downvotes || 0, 10); + db.sortedSetAdd('posts:votes', votes, pid, next); + }); + }, next); + }, { + progress: this.progress, + }, callback); + }, }; diff --git a/src/upgrades/1.5.0/remove_relative_uploaded_profile_cover.js b/src/upgrades/1.5.0/remove_relative_uploaded_profile_cover.js index a457355..6769170 100644 --- a/src/upgrades/1.5.0/remove_relative_uploaded_profile_cover.js +++ b/src/upgrades/1.5.0/remove_relative_uploaded_profile_cover.js @@ -4,23 +4,23 @@ const db = require('../../database'); const batch = require('../../batch'); module.exports = { - name: 'Remove relative_path from uploaded profile cover urls', - timestamp: Date.UTC(2017, 3, 26), - method: async function () { - const { progress } = this; + name: 'Remove relative_path from uploaded profile cover urls', + timestamp: Date.UTC(2017, 3, 26), + async method() { + const {progress} = this; - await batch.processSortedSet('users:joindate', async (ids) => { - await Promise.all(ids.map(async (uid) => { - const url = await db.getObjectField(`user:${uid}`, 'cover:url'); - progress.incr(); + await batch.processSortedSet('users:joindate', async ids => { + await Promise.all(ids.map(async uid => { + const url = await db.getObjectField(`user:${uid}`, 'cover:url'); + progress.incr(); - if (url) { - const newUrl = url.replace(/^.*?\/uploads\//, '/assets/uploads/'); - await db.setObjectField(`user:${uid}`, 'cover:url', newUrl); - } - })); - }, { - progress: this.progress, - }); - }, + if (url) { + const newUrl = url.replace(/^.*?\/uploads\//, '/assets/uploads/'); + await db.setObjectField(`user:${uid}`, 'cover:url', newUrl); + } + })); + }, { + progress: this.progress, + }); + }, }; diff --git a/src/upgrades/1.5.1/rename_mods_group.js b/src/upgrades/1.5.1/rename_mods_group.js index f694d91..863c65d 100644 --- a/src/upgrades/1.5.1/rename_mods_group.js +++ b/src/upgrades/1.5.1/rename_mods_group.js @@ -2,32 +2,31 @@ const async = require('async'); const winston = require('winston'); - const batch = require('../../batch'); const groups = require('../../groups'); - module.exports = { - name: 'rename user mod privileges group', - timestamp: Date.UTC(2017, 4, 26), - method: function (callback) { - const { progress } = this; - batch.processSortedSet('categories:cid', (cids, next) => { - async.eachSeries(cids, (cid, next) => { - const groupName = `cid:${cid}:privileges:mods`; - const newName = `cid:${cid}:privileges:moderate`; - groups.exists(groupName, (err, exists) => { - if (err || !exists) { - progress.incr(); - return next(err); - } - winston.verbose(`renaming ${groupName} to ${newName}`); - progress.incr(); - groups.renameGroup(groupName, newName, next); - }); - }, next); - }, { - progress: progress, - }, callback); - }, + name: 'rename user mod privileges group', + timestamp: Date.UTC(2017, 4, 26), + method(callback) { + const {progress} = this; + batch.processSortedSet('categories:cid', (cids, next) => { + async.eachSeries(cids, (cid, next) => { + const groupName = `cid:${cid}:privileges:mods`; + const newName = `cid:${cid}:privileges:moderate`; + groups.exists(groupName, (error, exists) => { + if (error || !exists) { + progress.incr(); + return next(error); + } + + winston.verbose(`renaming ${groupName} to ${newName}`); + progress.incr(); + groups.renameGroup(groupName, newName, next); + }); + }, next); + }, { + progress, + }, callback); + }, }; diff --git a/src/upgrades/1.5.2/rss_token_wipe.js b/src/upgrades/1.5.2/rss_token_wipe.js index bee35e0..8b9a338 100644 --- a/src/upgrades/1.5.2/rss_token_wipe.js +++ b/src/upgrades/1.5.2/rss_token_wipe.js @@ -5,18 +5,18 @@ const batch = require('../../batch'); const db = require('../../database'); module.exports = { - name: 'Wipe all existing RSS tokens', - timestamp: Date.UTC(2017, 6, 5), - method: function (callback) { - const { progress } = this; + name: 'Wipe all existing RSS tokens', + timestamp: Date.UTC(2017, 6, 5), + method(callback) { + const {progress} = this; - batch.processSortedSet('users:joindate', (uids, next) => { - async.eachLimit(uids, 500, (uid, next) => { - progress.incr(); - db.deleteObjectField(`user:${uid}`, 'rss_token', next); - }, next); - }, { - progress: progress, - }, callback); - }, + batch.processSortedSet('users:joindate', (uids, next) => { + async.eachLimit(uids, 500, (uid, next) => { + progress.incr(); + db.deleteObjectField(`user:${uid}`, 'rss_token', next); + }, next); + }, { + progress, + }, callback); + }, }; diff --git a/src/upgrades/1.5.2/tags_privilege.js b/src/upgrades/1.5.2/tags_privilege.js index fd9f5bb..328587e 100644 --- a/src/upgrades/1.5.2/tags_privilege.js +++ b/src/upgrades/1.5.2/tags_privilege.js @@ -1,22 +1,21 @@ 'use strict'; const async = require('async'); - const batch = require('../../batch'); module.exports = { - name: 'Give tag privilege to registered-users on all categories', - timestamp: Date.UTC(2017, 5, 16), - method: function (callback) { - const { progress } = this; - const privileges = require('../../privileges'); - batch.processSortedSet('categories:cid', (cids, next) => { - async.eachSeries(cids, (cid, next) => { - progress.incr(); - privileges.categories.give(['groups:topics:tag'], cid, 'registered-users', next); - }, next); - }, { - progress: progress, - }, callback); - }, + name: 'Give tag privilege to registered-users on all categories', + timestamp: Date.UTC(2017, 5, 16), + method(callback) { + const {progress} = this; + const privileges = require('../../privileges'); + batch.processSortedSet('categories:cid', (cids, next) => { + async.eachSeries(cids, (cid, next) => { + progress.incr(); + privileges.categories.give(['groups:topics:tag'], cid, 'registered-users', next); + }, next); + }, { + progress, + }, callback); + }, }; diff --git a/src/upgrades/1.6.0/clear-stale-digest-template.js b/src/upgrades/1.6.0/clear-stale-digest-template.js index 8677f7f..4f6ecee 100644 --- a/src/upgrades/1.6.0/clear-stale-digest-template.js +++ b/src/upgrades/1.6.0/clear-stale-digest-template.js @@ -1,21 +1,21 @@ 'use strict'; -const crypto = require('crypto'); +const crypto = require('node:crypto'); const meta = require('../../meta'); module.exports = { - name: 'Clearing stale digest templates that were accidentally saved as custom', - timestamp: Date.UTC(2017, 8, 6), - method: async function () { - const matches = [ - '112e541b40023d6530dd44df4b0d9c5d', // digest @ 75917e25b3b5ad7bed8ed0c36433fb35c9ab33eb - '110b8805f70395b0282fd10555059e9f', // digest @ 9b02bb8f51f0e47c6e335578f776ffc17bc03537 - '9538e7249edb369b2a25b03f2bd3282b', // digest @ 3314ab4b83138c7ae579ac1f1f463098b8c2d414 - ]; - const fieldset = await meta.configs.getFields(['email:custom:digest']); - const hash = fieldset['email:custom:digest'] ? crypto.createHash('md5').update(fieldset['email:custom:digest']).digest('hex') : null; - if (matches.includes(hash)) { - await meta.configs.remove('email:custom:digest'); - } - }, + name: 'Clearing stale digest templates that were accidentally saved as custom', + timestamp: Date.UTC(2017, 8, 6), + async method() { + const matches = [ + '112e541b40023d6530dd44df4b0d9c5d', // Digest @ 75917e25b3b5ad7bed8ed0c36433fb35c9ab33eb + '110b8805f70395b0282fd10555059e9f', // Digest @ 9b02bb8f51f0e47c6e335578f776ffc17bc03537 + '9538e7249edb369b2a25b03f2bd3282b', // Digest @ 3314ab4b83138c7ae579ac1f1f463098b8c2d414 + ]; + const fieldset = await meta.configs.getFields(['email:custom:digest']); + const hash = fieldset['email:custom:digest'] ? crypto.createHash('md5').update(fieldset['email:custom:digest']).digest('hex') : null; + if (matches.includes(hash)) { + await meta.configs.remove('email:custom:digest'); + } + }, }; diff --git a/src/upgrades/1.6.0/generate-email-logo.js b/src/upgrades/1.6.0/generate-email-logo.js index 61c2b02..ff75ffa 100644 --- a/src/upgrades/1.6.0/generate-email-logo.js +++ b/src/upgrades/1.6.0/generate-email-logo.js @@ -1,53 +1,52 @@ 'use strict'; - +const path = require('node:path'); +const fs = require('node:fs'); const async = require('async'); -const path = require('path'); const nconf = require('nconf'); -const fs = require('fs'); const meta = require('../../meta'); const image = require('../../image'); module.exports = { - name: 'Generate email logo for use in email header', - timestamp: Date.UTC(2017, 6, 17), - method: function (callback) { - let skip = false; - - async.series([ - function (next) { - // Resize existing logo (if present) to email header size - const uploadPath = path.join(nconf.get('upload_path'), 'system', 'site-logo-x50.png'); - const sourcePath = meta.config['brand:logo'] ? path.join(nconf.get('upload_path'), 'system', path.basename(meta.config['brand:logo'])) : null; - - if (!sourcePath) { - skip = true; - return setImmediate(next); - } - - fs.access(sourcePath, (err) => { - if (err || path.extname(sourcePath) === '.svg') { - skip = true; - return setImmediate(next); - } - - image.resizeImage({ - path: sourcePath, - target: uploadPath, - height: 50, - }, next); - }); - }, - function (next) { - if (skip) { - return setImmediate(next); - } - - meta.configs.setMultiple({ - 'brand:logo': path.join('/assets/uploads/system', path.basename(meta.config['brand:logo'])), - 'brand:emailLogo': '/assets/uploads/system/site-logo-x50.png', - }, next); - }, - ], callback); - }, + name: 'Generate email logo for use in email header', + timestamp: Date.UTC(2017, 6, 17), + method(callback) { + let skip = false; + + async.series([ + function (next) { + // Resize existing logo (if present) to email header size + const uploadPath = path.join(nconf.get('upload_path'), 'system', 'site-logo-x50.png'); + const sourcePath = meta.config['brand:logo'] ? path.join(nconf.get('upload_path'), 'system', path.basename(meta.config['brand:logo'])) : null; + + if (!sourcePath) { + skip = true; + return setImmediate(next); + } + + fs.access(sourcePath, error => { + if (error || path.extname(sourcePath) === '.svg') { + skip = true; + return setImmediate(next); + } + + image.resizeImage({ + path: sourcePath, + target: uploadPath, + height: 50, + }, next); + }); + }, + function (next) { + if (skip) { + return setImmediate(next); + } + + meta.configs.setMultiple({ + 'brand:logo': path.join('/assets/uploads/system', path.basename(meta.config['brand:logo'])), + 'brand:emailLogo': '/assets/uploads/system/site-logo-x50.png', + }, next); + }, + ], callback); + }, }; diff --git a/src/upgrades/1.6.0/ipblacklist-fix.js b/src/upgrades/1.6.0/ipblacklist-fix.js index f6b75d4..6df1670 100644 --- a/src/upgrades/1.6.0/ipblacklist-fix.js +++ b/src/upgrades/1.6.0/ipblacklist-fix.js @@ -3,11 +3,11 @@ const db = require('../../database'); module.exports = { - name: 'Changing ip blacklist storage to object', - timestamp: Date.UTC(2017, 8, 7), - method: async function () { - const rules = await db.get('ip-blacklist-rules'); - await db.delete('ip-blacklist-rules'); - await db.setObject('ip-blacklist-rules', { rules: rules }); - }, + name: 'Changing ip blacklist storage to object', + timestamp: Date.UTC(2017, 8, 7), + async method() { + const rules = await db.get('ip-blacklist-rules'); + await db.delete('ip-blacklist-rules'); + await db.setObject('ip-blacklist-rules', {rules}); + }, }; diff --git a/src/upgrades/1.6.0/robots-config-change.js b/src/upgrades/1.6.0/robots-config-change.js index b56e180..2eb798d 100644 --- a/src/upgrades/1.6.0/robots-config-change.js +++ b/src/upgrades/1.6.0/robots-config-change.js @@ -3,19 +3,20 @@ const db = require('../../database'); module.exports = { - name: 'Fix incorrect robots.txt schema', - timestamp: Date.UTC(2017, 6, 10), - method: async function () { - const config = await db.getObject('config'); - if (config) { - // fix mongo nested data - if (config.robots && config.robots.txt) { - await db.setObjectField('config', 'robots:txt', config.robots.txt); - } else if (typeof config['robots.txt'] === 'string' && config['robots.txt']) { - await db.setObjectField('config', 'robots:txt', config['robots.txt']); - } - await db.deleteObjectField('config', 'robots'); - await db.deleteObjectField('config', 'robots.txt'); - } - }, + name: 'Fix incorrect robots.txt schema', + timestamp: Date.UTC(2017, 6, 10), + async method() { + const config = await db.getObject('config'); + if (config) { + // Fix mongo nested data + if (config.robots && config.robots.txt) { + await db.setObjectField('config', 'robots:txt', config.robots.txt); + } else if (typeof config['robots.txt'] === 'string' && config['robots.txt']) { + await db.setObjectField('config', 'robots:txt', config['robots.txt']); + } + + await db.deleteObjectField('config', 'robots'); + await db.deleteObjectField('config', 'robots.txt'); + } + }, }; diff --git a/src/upgrades/1.6.2/topics_lastposttime_zset.js b/src/upgrades/1.6.2/topics_lastposttime_zset.js index 459e97a..1f44c9b 100644 --- a/src/upgrades/1.6.2/topics_lastposttime_zset.js +++ b/src/upgrades/1.6.2/topics_lastposttime_zset.js @@ -1,29 +1,29 @@ 'use strict'; const async = require('async'); - const db = require('../../database'); module.exports = { - name: 'New sorted set cid::tids:lastposttime', - timestamp: Date.UTC(2017, 9, 30), - method: function (callback) { - const { progress } = this; + name: 'New sorted set cid::tids:lastposttime', + timestamp: Date.UTC(2017, 9, 30), + method(callback) { + const {progress} = this; + + require('../../batch').processSortedSet('topics:tid', (tids, next) => { + async.eachSeries(tids, (tid, next) => { + db.getObjectFields(`topic:${tid}`, ['cid', 'timestamp', 'lastposttime'], (error, topicData) => { + if (error || !topicData) { + return next(error); + } - require('../../batch').processSortedSet('topics:tid', (tids, next) => { - async.eachSeries(tids, (tid, next) => { - db.getObjectFields(`topic:${tid}`, ['cid', 'timestamp', 'lastposttime'], (err, topicData) => { - if (err || !topicData) { - return next(err); - } - progress.incr(); + progress.incr(); - const timestamp = topicData.lastposttime || topicData.timestamp || Date.now(); - db.sortedSetAdd(`cid:${topicData.cid}:tids:lastposttime`, timestamp, tid, next); - }, next); - }, next); - }, { - progress: this.progress, - }, callback); - }, + const timestamp = topicData.lastposttime || topicData.timestamp || Date.now(); + db.sortedSetAdd(`cid:${topicData.cid}:tids:lastposttime`, timestamp, tid, next); + }, next); + }, next); + }, { + progress: this.progress, + }, callback); + }, }; diff --git a/src/upgrades/1.7.0/generate-custom-html.js b/src/upgrades/1.7.0/generate-custom-html.js index cb453a2..bbb1ef9 100644 --- a/src/upgrades/1.7.0/generate-custom-html.js +++ b/src/upgrades/1.7.0/generate-custom-html.js @@ -4,40 +4,40 @@ const db = require('../../database'); const meta = require('../../meta'); module.exports = { - name: 'Generate customHTML block from old customJS setting', - timestamp: Date.UTC(2017, 9, 12), - method: function (callback) { - db.getObjectField('config', 'customJS', (err, newHTML) => { - if (err) { - return callback(err); - } - - let newJS = []; - - // Forgive me for parsing HTML with regex... - const scriptMatch = /^([\s\S]+?)<\/script>/m; - let match = scriptMatch.exec(newHTML); - - while (match) { - if (match[1]) { - // Append to newJS array - newJS.push(match[1].trim()); - - // Remove the match from the existing value - newHTML = ((match.index > 0 ? newHTML.slice(0, match.index) : '') + newHTML.slice(match.index + match[0].length)).trim(); - } - - match = scriptMatch.exec(newHTML); - } - - // Combine newJS array - newJS = newJS.join('\n\n'); - - // Write both values to config - meta.configs.setMultiple({ - customHTML: newHTML, - customJS: newJS, - }, callback); - }); - }, + name: 'Generate customHTML block from old customJS setting', + timestamp: Date.UTC(2017, 9, 12), + method(callback) { + db.getObjectField('config', 'customJS', (error, newHTML) => { + if (error) { + return callback(error); + } + + let newJS = []; + + // Forgive me for parsing HTML with regex... + const scriptMatch = /^([\s\S]+?)<\/script>/m; + let match = scriptMatch.exec(newHTML); + + while (match) { + if (match[1]) { + // Append to newJS array + newJS.push(match[1].trim()); + + // Remove the match from the existing value + newHTML = ((match.index > 0 ? newHTML.slice(0, match.index) : '') + newHTML.slice(match.index + match[0].length)).trim(); + } + + match = scriptMatch.exec(newHTML); + } + + // Combine newJS array + newJS = newJS.join('\n\n'); + + // Write both values to config + meta.configs.setMultiple({ + customHTML: newHTML, + customJS: newJS, + }, callback); + }); + }, }; diff --git a/src/upgrades/1.7.1/notification-settings.js b/src/upgrades/1.7.1/notification-settings.js index 144945c..c862f9d 100644 --- a/src/upgrades/1.7.1/notification-settings.js +++ b/src/upgrades/1.7.1/notification-settings.js @@ -4,28 +4,30 @@ const batch = require('../../batch'); const db = require('../../database'); module.exports = { - name: 'Convert old notification digest settings', - timestamp: Date.UTC(2017, 10, 15), - method: async function () { - const { progress } = this; + name: 'Convert old notification digest settings', + timestamp: Date.UTC(2017, 10, 15), + async method() { + const {progress} = this; - await batch.processSortedSet('users:joindate', async (uids) => { - await Promise.all(uids.map(async (uid) => { - progress.incr(); - const userSettings = await db.getObjectFields(`user:${uid}:settings`, ['sendChatNotifications', 'sendPostNotifications']); - if (userSettings) { - if (parseInt(userSettings.sendChatNotifications, 10) === 1) { - await db.setObjectField(`user:${uid}:settings`, 'notificationType_new-chat', 'notificationemail'); - } - if (parseInt(userSettings.sendPostNotifications, 10) === 1) { - await db.setObjectField(`user:${uid}:settings`, 'notificationType_new-reply', 'notificationemail'); - } - } - await db.deleteObjectFields(`user:${uid}:settings`, ['sendChatNotifications', 'sendPostNotifications']); - })); - }, { - progress: progress, - batch: 500, - }); - }, + await batch.processSortedSet('users:joindate', async uids => { + await Promise.all(uids.map(async uid => { + progress.incr(); + const userSettings = await db.getObjectFields(`user:${uid}:settings`, ['sendChatNotifications', 'sendPostNotifications']); + if (userSettings) { + if (Number.parseInt(userSettings.sendChatNotifications, 10) === 1) { + await db.setObjectField(`user:${uid}:settings`, 'notificationType_new-chat', 'notificationemail'); + } + + if (Number.parseInt(userSettings.sendPostNotifications, 10) === 1) { + await db.setObjectField(`user:${uid}:settings`, 'notificationType_new-reply', 'notificationemail'); + } + } + + await db.deleteObjectFields(`user:${uid}:settings`, ['sendChatNotifications', 'sendPostNotifications']); + })); + }, { + progress, + batch: 500, + }); + }, }; diff --git a/src/upgrades/1.7.3/key_value_schema_change.js b/src/upgrades/1.7.3/key_value_schema_change.js index 692b379..4fe7d3b 100644 --- a/src/upgrades/1.7.3/key_value_schema_change.js +++ b/src/upgrades/1.7.3/key_value_schema_change.js @@ -5,41 +5,43 @@ const db = require('../../database'); module.exports = { - name: 'Change the schema of simple keys so they don\'t use value field (mongodb only)', - timestamp: Date.UTC(2017, 11, 18), - method: async function () { - let configJSON; - try { - configJSON = require('../../../config.json') || { [process.env.database]: true, database: process.env.database }; - } catch (err) { - configJSON = { [process.env.database]: true, database: process.env.database }; - } - const isMongo = configJSON.hasOwnProperty('mongo') && configJSON.database === 'mongo'; - const { progress } = this; - if (!isMongo) { - return; - } - const { client } = db; - const query = { - _key: { $exists: true }, - value: { $exists: true }, - score: { $exists: false }, - }; - progress.total = await client.collection('objects').countDocuments(query); - const cursor = await client.collection('objects').find(query).batchSize(1000); + name: 'Change the schema of simple keys so they don\'t use value field (mongodb only)', + timestamp: Date.UTC(2017, 11, 18), + async method() { + let configJSON; + try { + configJSON = require('../../../config.json') || {[process.env.database]: true, database: process.env.database}; + } catch { + configJSON = {[process.env.database]: true, database: process.env.database}; + } - let done = false; - while (!done) { - const item = await cursor.next(); - progress.incr(); - if (item === null) { - done = true; - } else { - delete item.expireAt; - if (Object.keys(item).length === 3 && item.hasOwnProperty('_key') && item.hasOwnProperty('value')) { - await client.collection('objects').updateOne({ _key: item._key }, { $rename: { value: 'data' } }); - } - } - } - }, + const isMongo = configJSON.hasOwnProperty('mongo') && configJSON.database === 'mongo'; + const {progress} = this; + if (!isMongo) { + return; + } + + const {client} = db; + const query = { + _key: {$exists: true}, + value: {$exists: true}, + score: {$exists: false}, + }; + progress.total = await client.collection('objects').countDocuments(query); + const cursor = await client.collection('objects').find(query).batchSize(1000); + + let done = false; + while (!done) { + const item = await cursor.next(); + progress.incr(); + if (item === null) { + done = true; + } else { + delete item.expireAt; + if (Object.keys(item).length === 3 && item.hasOwnProperty('_key') && item.hasOwnProperty('value')) { + await client.collection('objects').updateOne({_key: item._key}, {$rename: {value: 'data'}}); + } + } + } + }, }; diff --git a/src/upgrades/1.7.3/topic_votes.js b/src/upgrades/1.7.3/topic_votes.js index 968a9b7..cf0ea50 100644 --- a/src/upgrades/1.7.3/topic_votes.js +++ b/src/upgrades/1.7.3/topic_votes.js @@ -1,42 +1,41 @@ 'use strict'; - const batch = require('../../batch'); const db = require('../../database'); module.exports = { - name: 'Add votes to topics', - timestamp: Date.UTC(2017, 11, 8), - method: async function () { - const { progress } = this; + name: 'Add votes to topics', + timestamp: Date.UTC(2017, 11, 8), + async method() { + const {progress} = this; - batch.processSortedSet('topics:tid', async (tids) => { - await Promise.all(tids.map(async (tid) => { - progress.incr(); - const topicData = await db.getObjectFields(`topic:${tid}`, ['mainPid', 'cid', 'pinned']); - if (topicData.mainPid && topicData.cid) { - const postData = await db.getObject(`post:${topicData.mainPid}`); - if (postData) { - const upvotes = parseInt(postData.upvotes, 10) || 0; - const downvotes = parseInt(postData.downvotes, 10) || 0; - const data = { - upvotes: upvotes, - downvotes: downvotes, - }; - const votes = upvotes - downvotes; - await Promise.all([ - db.setObject(`topic:${tid}`, data), - db.sortedSetAdd('topics:votes', votes, tid), - ]); - if (parseInt(topicData.pinned, 10) !== 1) { - await db.sortedSetAdd(`cid:${topicData.cid}:tids:votes`, votes, tid); - } - } - } - })); - }, { - progress: progress, - batch: 500, - }); - }, + batch.processSortedSet('topics:tid', async tids => { + await Promise.all(tids.map(async tid => { + progress.incr(); + const topicData = await db.getObjectFields(`topic:${tid}`, ['mainPid', 'cid', 'pinned']); + if (topicData.mainPid && topicData.cid) { + const postData = await db.getObject(`post:${topicData.mainPid}`); + if (postData) { + const upvotes = Number.parseInt(postData.upvotes, 10) || 0; + const downvotes = Number.parseInt(postData.downvotes, 10) || 0; + const data = { + upvotes, + downvotes, + }; + const votes = upvotes - downvotes; + await Promise.all([ + db.setObject(`topic:${tid}`, data), + db.sortedSetAdd('topics:votes', votes, tid), + ]); + if (Number.parseInt(topicData.pinned, 10) !== 1) { + await db.sortedSetAdd(`cid:${topicData.cid}:tids:votes`, votes, tid); + } + } + } + })); + }, { + progress, + batch: 500, + }); + }, }; diff --git a/src/upgrades/1.7.4/chat_privilege.js b/src/upgrades/1.7.4/chat_privilege.js index 5ce78c1..76df936 100644 --- a/src/upgrades/1.7.4/chat_privilege.js +++ b/src/upgrades/1.7.4/chat_privilege.js @@ -1,12 +1,11 @@ 'use strict'; - const groups = require('../../groups'); module.exports = { - name: 'Give chat privilege to registered-users', - timestamp: Date.UTC(2017, 11, 18), - method: function (callback) { - groups.join('cid:0:privileges:groups:chat', 'registered-users', callback); - }, + name: 'Give chat privilege to registered-users', + timestamp: Date.UTC(2017, 11, 18), + method(callback) { + groups.join('cid:0:privileges:groups:chat', 'registered-users', callback); + }, }; diff --git a/src/upgrades/1.7.4/fix_moved_topics_byvotes.js b/src/upgrades/1.7.4/fix_moved_topics_byvotes.js index cfc37ba..08ee685 100644 --- a/src/upgrades/1.7.4/fix_moved_topics_byvotes.js +++ b/src/upgrades/1.7.4/fix_moved_topics_byvotes.js @@ -4,28 +4,28 @@ const batch = require('../../batch'); const db = require('../../database'); module.exports = { - name: 'Fix sort by votes for moved topics', - timestamp: Date.UTC(2018, 0, 8), - method: async function () { - const { progress } = this; + name: 'Fix sort by votes for moved topics', + timestamp: Date.UTC(2018, 0, 8), + async method() { + const {progress} = this; - await batch.processSortedSet('topics:tid', async (tids) => { - await Promise.all(tids.map(async (tid) => { - progress.incr(); - const topicData = await db.getObjectFields(`topic:${tid}`, ['cid', 'oldCid', 'upvotes', 'downvotes', 'pinned']); - if (topicData.cid && topicData.oldCid) { - const upvotes = parseInt(topicData.upvotes, 10) || 0; - const downvotes = parseInt(topicData.downvotes, 10) || 0; - const votes = upvotes - downvotes; - await db.sortedSetRemove(`cid:${topicData.oldCid}:tids:votes`, tid); - if (parseInt(topicData.pinned, 10) !== 1) { - await db.sortedSetAdd(`cid:${topicData.cid}:tids:votes`, votes, tid); - } - } - })); - }, { - progress: progress, - batch: 500, - }); - }, + await batch.processSortedSet('topics:tid', async tids => { + await Promise.all(tids.map(async tid => { + progress.incr(); + const topicData = await db.getObjectFields(`topic:${tid}`, ['cid', 'oldCid', 'upvotes', 'downvotes', 'pinned']); + if (topicData.cid && topicData.oldCid) { + const upvotes = Number.parseInt(topicData.upvotes, 10) || 0; + const downvotes = Number.parseInt(topicData.downvotes, 10) || 0; + const votes = upvotes - downvotes; + await db.sortedSetRemove(`cid:${topicData.oldCid}:tids:votes`, tid); + if (Number.parseInt(topicData.pinned, 10) !== 1) { + await db.sortedSetAdd(`cid:${topicData.cid}:tids:votes`, votes, tid); + } + } + })); + }, { + progress, + batch: 500, + }); + }, }; diff --git a/src/upgrades/1.7.4/fix_user_topics_per_category.js b/src/upgrades/1.7.4/fix_user_topics_per_category.js index a5bda08..96194d7 100644 --- a/src/upgrades/1.7.4/fix_user_topics_per_category.js +++ b/src/upgrades/1.7.4/fix_user_topics_per_category.js @@ -4,26 +4,26 @@ const batch = require('../../batch'); const db = require('../../database'); module.exports = { - name: 'Fix topics in categories per user if they were moved', - timestamp: Date.UTC(2018, 0, 22), - method: async function () { - const { progress } = this; + name: 'Fix topics in categories per user if they were moved', + timestamp: Date.UTC(2018, 0, 22), + async method() { + const {progress} = this; - await batch.processSortedSet('topics:tid', async (tids) => { - await Promise.all(tids.map(async (tid) => { - progress.incr(); - const topicData = await db.getObjectFields(`topic:${tid}`, ['cid', 'tid', 'uid', 'oldCid', 'timestamp']); - if (topicData.cid && topicData.oldCid) { - const isMember = await db.isSortedSetMember(`cid:${topicData.oldCid}:uid:${topicData.uid}`, topicData.tid); - if (isMember) { - await db.sortedSetRemove(`cid:${topicData.oldCid}:uid:${topicData.uid}:tids`, tid); - await db.sortedSetAdd(`cid:${topicData.cid}:uid:${topicData.uid}:tids`, topicData.timestamp, tid); - } - } - })); - }, { - progress: progress, - batch: 500, - }); - }, + await batch.processSortedSet('topics:tid', async tids => { + await Promise.all(tids.map(async tid => { + progress.incr(); + const topicData = await db.getObjectFields(`topic:${tid}`, ['cid', 'tid', 'uid', 'oldCid', 'timestamp']); + if (topicData.cid && topicData.oldCid) { + const isMember = await db.isSortedSetMember(`cid:${topicData.oldCid}:uid:${topicData.uid}`, topicData.tid); + if (isMember) { + await db.sortedSetRemove(`cid:${topicData.oldCid}:uid:${topicData.uid}:tids`, tid); + await db.sortedSetAdd(`cid:${topicData.cid}:uid:${topicData.uid}:tids`, topicData.timestamp, tid); + } + } + })); + }, { + progress, + batch: 500, + }); + }, }; diff --git a/src/upgrades/1.7.4/global_upload_privilege.js b/src/upgrades/1.7.4/global_upload_privilege.js index 69495c2..b1e2908 100644 --- a/src/upgrades/1.7.4/global_upload_privilege.js +++ b/src/upgrades/1.7.4/global_upload_privilege.js @@ -1,45 +1,47 @@ 'use strict'; - const async = require('async'); const groups = require('../../groups'); const privileges = require('../../privileges'); const db = require('../../database'); module.exports = { - name: 'Give upload privilege to registered-users globally if it is given on a category', - timestamp: Date.UTC(2018, 0, 3), - method: function (callback) { - db.getSortedSetRange('categories:cid', 0, -1, (err, cids) => { - if (err) { - return callback(err); - } - async.eachSeries(cids, (cid, next) => { - getGroupPrivileges(cid, (err, groupPrivileges) => { - if (err) { - return next(err); - } - - const privs = []; - if (groupPrivileges['groups:upload:post:image']) { - privs.push('groups:upload:post:image'); - } - if (groupPrivileges['groups:upload:post:file']) { - privs.push('groups:upload:post:file'); - } - privileges.global.give(privs, 'registered-users', next); - }); - }, callback); - }); - }, + name: 'Give upload privilege to registered-users globally if it is given on a category', + timestamp: Date.UTC(2018, 0, 3), + method(callback) { + db.getSortedSetRange('categories:cid', 0, -1, (error, cids) => { + if (error) { + return callback(error); + } + + async.eachSeries(cids, (cid, next) => { + getGroupPrivileges(cid, (error, groupPrivileges) => { + if (error) { + return next(error); + } + + const privs = []; + if (groupPrivileges['groups:upload:post:image']) { + privs.push('groups:upload:post:image'); + } + + if (groupPrivileges['groups:upload:post:file']) { + privs.push('groups:upload:post:file'); + } + + privileges.global.give(privs, 'registered-users', next); + }); + }, callback); + }); + }, }; function getGroupPrivileges(cid, callback) { - const tasks = {}; + const tasks = {}; - ['groups:upload:post:image', 'groups:upload:post:file'].forEach((privilege) => { - tasks[privilege] = async.apply(groups.isMember, 'registered-users', `cid:${cid}:privileges:${privilege}`); - }); + for (const privilege of ['groups:upload:post:image', 'groups:upload:post:file']) { + tasks[privilege] = async.apply(groups.isMember, 'registered-users', `cid:${cid}:privileges:${privilege}`); + } - async.parallel(tasks, callback); + async.parallel(tasks, callback); } diff --git a/src/upgrades/1.7.4/rename_min_reputation_settings.js b/src/upgrades/1.7.4/rename_min_reputation_settings.js index 2caec13..861fcee 100644 --- a/src/upgrades/1.7.4/rename_min_reputation_settings.js +++ b/src/upgrades/1.7.4/rename_min_reputation_settings.js @@ -3,23 +3,24 @@ const db = require('../../database'); module.exports = { - name: 'Rename privileges:downvote and privileges:flag to min:rep:downvote, min:rep:flag respectively', - timestamp: Date.UTC(2018, 0, 12), - method: function (callback) { - db.getObjectFields('config', ['privileges:downvote', 'privileges:flag'], (err, config) => { - if (err) { - return callback(err); - } + name: 'Rename privileges:downvote and privileges:flag to min:rep:downvote, min:rep:flag respectively', + timestamp: Date.UTC(2018, 0, 12), + method(callback) { + db.getObjectFields('config', ['privileges:downvote', 'privileges:flag'], (error, config) => { + if (error) { + return callback(error); + } - db.setObject('config', { - 'min:rep:downvote': parseInt(config['privileges:downvote'], 10) || 0, - 'min:rep:flag': parseInt(config['privileges:downvote'], 10) || 0, - }, (err) => { - if (err) { - return callback(err); - } - db.deleteObjectFields('config', ['privileges:downvote', 'privileges:flag'], callback); - }); - }); - }, + db.setObject('config', { + 'min:rep:downvote': Number.parseInt(config['privileges:downvote'], 10) || 0, + 'min:rep:flag': Number.parseInt(config['privileges:downvote'], 10) || 0, + }, error_ => { + if (error_) { + return callback(error_); + } + + db.deleteObjectFields('config', ['privileges:downvote', 'privileges:flag'], callback); + }); + }); + }, }; diff --git a/src/upgrades/1.7.4/vote_privilege.js b/src/upgrades/1.7.4/vote_privilege.js index 3eb4be4..7696aaa 100644 --- a/src/upgrades/1.7.4/vote_privilege.js +++ b/src/upgrades/1.7.4/vote_privilege.js @@ -1,22 +1,21 @@ 'use strict'; - const async = require('async'); - const privileges = require('../../privileges'); const db = require('../../database'); module.exports = { - name: 'Give vote privilege to registered-users on all categories', - timestamp: Date.UTC(2018, 0, 9), - method: function (callback) { - db.getSortedSetRange('categories:cid', 0, -1, (err, cids) => { - if (err) { - return callback(err); - } - async.eachSeries(cids, (cid, next) => { - privileges.categories.give(['groups:posts:upvote', 'groups:posts:downvote'], cid, 'registered-users', next); - }, callback); - }); - }, + name: 'Give vote privilege to registered-users on all categories', + timestamp: Date.UTC(2018, 0, 9), + method(callback) { + db.getSortedSetRange('categories:cid', 0, -1, (error, cids) => { + if (error) { + return callback(error); + } + + async.eachSeries(cids, (cid, next) => { + privileges.categories.give(['groups:posts:upvote', 'groups:posts:downvote'], cid, 'registered-users', next); + }, callback); + }); + }, }; diff --git a/src/upgrades/1.7.6/flatten_navigation_data.js b/src/upgrades/1.7.6/flatten_navigation_data.js index 623933a..2744106 100644 --- a/src/upgrades/1.7.6/flatten_navigation_data.js +++ b/src/upgrades/1.7.6/flatten_navigation_data.js @@ -3,22 +3,24 @@ const db = require('../../database'); module.exports = { - name: 'Flatten navigation data', - timestamp: Date.UTC(2018, 1, 17), - method: async function () { - const data = await db.getSortedSetRangeWithScores('navigation:enabled', 0, -1); - const order = []; - const items = []; - data.forEach((item) => { - let navItem = JSON.parse(item.value); - const keys = Object.keys(navItem); - if (keys.length && parseInt(keys[0], 10) >= 0) { - navItem = navItem[keys[0]]; - } - order.push(item.score); - items.push(JSON.stringify(navItem)); - }); - await db.delete('navigation:enabled'); - await db.sortedSetAdd('navigation:enabled', order, items); - }, + name: 'Flatten navigation data', + timestamp: Date.UTC(2018, 1, 17), + async method() { + const data = await db.getSortedSetRangeWithScores('navigation:enabled', 0, -1); + const order = []; + const items = []; + for (const item of data) { + let navItem = JSON.parse(item.value); + const keys = Object.keys(navItem); + if (keys.length > 0 && Number.parseInt(keys[0], 10) >= 0) { + navItem = navItem[keys[0]]; + } + + order.push(item.score); + items.push(JSON.stringify(navItem)); + } + + await db.delete('navigation:enabled'); + await db.sortedSetAdd('navigation:enabled', order, items); + }, }; diff --git a/src/upgrades/1.7.6/notification_types.js b/src/upgrades/1.7.6/notification_types.js index d82726d..eccbaf8 100644 --- a/src/upgrades/1.7.6/notification_types.js +++ b/src/upgrades/1.7.6/notification_types.js @@ -3,19 +3,19 @@ const db = require('../../database'); module.exports = { - name: 'Add default settings for notification delivery types', - timestamp: Date.UTC(2018, 1, 14), - method: async function () { - const config = await db.getObject('config'); - const postNotifications = parseInt(config.sendPostNotifications, 10) === 1 ? 'notification' : 'none'; - const chatNotifications = parseInt(config.sendChatNotifications, 10) === 1 ? 'notification' : 'none'; - await db.setObject('config', { - notificationType_upvote: config.notificationType_upvote || 'notification', - 'notificationType_new-topic': config['notificationType_new-topic'] || 'notification', - 'notificationType_new-reply': config['notificationType_new-reply'] || postNotifications, - notificationType_follow: config.notificationType_follow || 'notification', - 'notificationType_new-chat': config['notificationType_new-chat'] || chatNotifications, - 'notificationType_group-invite': config['notificationType_group-invite'] || 'notification', - }); - }, + name: 'Add default settings for notification delivery types', + timestamp: Date.UTC(2018, 1, 14), + async method() { + const config = await db.getObject('config'); + const postNotifications = Number.parseInt(config.sendPostNotifications, 10) === 1 ? 'notification' : 'none'; + const chatNotifications = Number.parseInt(config.sendChatNotifications, 10) === 1 ? 'notification' : 'none'; + await db.setObject('config', { + notificationType_upvote: config.notificationType_upvote || 'notification', + 'notificationType_new-topic': config['notificationType_new-topic'] || 'notification', + 'notificationType_new-reply': config['notificationType_new-reply'] || postNotifications, + notificationType_follow: config.notificationType_follow || 'notification', + 'notificationType_new-chat': config['notificationType_new-chat'] || chatNotifications, + 'notificationType_group-invite': config['notificationType_group-invite'] || 'notification', + }); + }, }; diff --git a/src/upgrades/1.7.6/update_min_pass_strength.js b/src/upgrades/1.7.6/update_min_pass_strength.js index 9888544..22a38a9 100644 --- a/src/upgrades/1.7.6/update_min_pass_strength.js +++ b/src/upgrades/1.7.6/update_min_pass_strength.js @@ -3,12 +3,12 @@ const db = require('../../database'); module.exports = { - name: 'Revising minimum password strength to 1 (from 0)', - timestamp: Date.UTC(2018, 1, 21), - method: async function () { - const strength = await db.getObjectField('config', 'minimumPasswordStrength'); - if (!strength) { - await db.setObjectField('config', 'minimumPasswordStrength', 1); - } - }, + name: 'Revising minimum password strength to 1 (from 0)', + timestamp: Date.UTC(2018, 1, 21), + async method() { + const strength = await db.getObjectField('config', 'minimumPasswordStrength'); + if (!strength) { + await db.setObjectField('config', 'minimumPasswordStrength', 1); + } + }, }; diff --git a/src/upgrades/1.8.0/give_signature_privileges.js b/src/upgrades/1.8.0/give_signature_privileges.js index 9302171..2850a51 100644 --- a/src/upgrades/1.8.0/give_signature_privileges.js +++ b/src/upgrades/1.8.0/give_signature_privileges.js @@ -3,9 +3,9 @@ const privileges = require('../../privileges'); module.exports = { - name: 'Give registered users signature privilege', - timestamp: Date.UTC(2018, 1, 28), - method: function (callback) { - privileges.global.give(['groups:signature'], 'registered-users', callback); - }, + name: 'Give registered users signature privilege', + timestamp: Date.UTC(2018, 1, 28), + method(callback) { + privileges.global.give(['groups:signature'], 'registered-users', callback); + }, }; diff --git a/src/upgrades/1.8.0/give_spiders_privileges.js b/src/upgrades/1.8.0/give_spiders_privileges.js index 4169469..e7c45d5 100644 --- a/src/upgrades/1.8.0/give_spiders_privileges.js +++ b/src/upgrades/1.8.0/give_spiders_privileges.js @@ -1,49 +1,51 @@ 'use strict'; - const async = require('async'); const groups = require('../../groups'); const privileges = require('../../privileges'); const db = require('../../database'); module.exports = { - name: 'Give category access privileges to spiders system group', - timestamp: Date.UTC(2018, 0, 31), - method: function (callback) { - db.getSortedSetRange('categories:cid', 0, -1, (err, cids) => { - if (err) { - return callback(err); - } - async.eachSeries(cids, (cid, next) => { - getGroupPrivileges(cid, (err, groupPrivileges) => { - if (err) { - return next(err); - } - - const privs = []; - if (groupPrivileges['groups:find']) { - privs.push('groups:find'); - } - if (groupPrivileges['groups:read']) { - privs.push('groups:read'); - } - if (groupPrivileges['groups:topics:read']) { - privs.push('groups:topics:read'); - } - - privileges.categories.give(privs, cid, 'spiders', next); - }); - }, callback); - }); - }, + name: 'Give category access privileges to spiders system group', + timestamp: Date.UTC(2018, 0, 31), + method(callback) { + db.getSortedSetRange('categories:cid', 0, -1, (error, cids) => { + if (error) { + return callback(error); + } + + async.eachSeries(cids, (cid, next) => { + getGroupPrivileges(cid, (error, groupPrivileges) => { + if (error) { + return next(error); + } + + const privs = []; + if (groupPrivileges['groups:find']) { + privs.push('groups:find'); + } + + if (groupPrivileges['groups:read']) { + privs.push('groups:read'); + } + + if (groupPrivileges['groups:topics:read']) { + privs.push('groups:topics:read'); + } + + privileges.categories.give(privs, cid, 'spiders', next); + }); + }, callback); + }); + }, }; function getGroupPrivileges(cid, callback) { - const tasks = {}; + const tasks = {}; - ['groups:find', 'groups:read', 'groups:topics:read'].forEach((privilege) => { - tasks[privilege] = async.apply(groups.isMember, 'guests', `cid:${cid}:privileges:${privilege}`); - }); + for (const privilege of ['groups:find', 'groups:read', 'groups:topics:read']) { + tasks[privilege] = async.apply(groups.isMember, 'guests', `cid:${cid}:privileges:${privilege}`); + } - async.parallel(tasks, callback); + async.parallel(tasks, callback); } diff --git a/src/upgrades/1.8.1/diffs_zset_to_listhash.js b/src/upgrades/1.8.1/diffs_zset_to_listhash.js index a432b7c..c5f89b0 100644 --- a/src/upgrades/1.8.1/diffs_zset_to_listhash.js +++ b/src/upgrades/1.8.1/diffs_zset_to_listhash.js @@ -4,54 +4,53 @@ const async = require('async'); const db = require('../../database'); const batch = require('../../batch'); - module.exports = { - name: 'Reformatting post diffs to be stored in lists and hash instead of single zset', - timestamp: Date.UTC(2018, 2, 15), - method: function (callback) { - const { progress } = this; - - batch.processSortedSet('posts:pid', (pids, next) => { - async.each(pids, (pid, next) => { - db.getSortedSetRangeWithScores(`post:${pid}:diffs`, 0, -1, (err, diffs) => { - if (err) { - return next(err); - } - - if (!diffs || !diffs.length) { - progress.incr(); - return next(); - } - - // For each diff, push to list - async.each(diffs, (diff, next) => { - async.series([ - async.apply(db.delete.bind(db), `post:${pid}:diffs`), - async.apply(db.listPrepend.bind(db), `post:${pid}:diffs`, diff.score), - async.apply(db.setObject.bind(db), `diff:${pid}.${diff.score}`, { - pid: pid, - patch: diff.value, - }), - ], next); - }, (err) => { - if (err) { - return next(err); - } - - progress.incr(); - return next(); - }); - }); - }, (err) => { - if (err) { - // Probably type error, ok to incr and continue - progress.incr(); - } - - return next(); - }); - }, { - progress: progress, - }, callback); - }, + name: 'Reformatting post diffs to be stored in lists and hash instead of single zset', + timestamp: Date.UTC(2018, 2, 15), + method(callback) { + const {progress} = this; + + batch.processSortedSet('posts:pid', (pids, next) => { + async.each(pids, (pid, next) => { + db.getSortedSetRangeWithScores(`post:${pid}:diffs`, 0, -1, (error, diffs) => { + if (error) { + return next(error); + } + + if (!diffs || diffs.length === 0) { + progress.incr(); + return next(); + } + + // For each diff, push to list + async.each(diffs, (diff, next) => { + async.series([ + async.apply(db.delete.bind(db), `post:${pid}:diffs`), + async.apply(db.listPrepend.bind(db), `post:${pid}:diffs`, diff.score), + async.apply(db.setObject.bind(db), `diff:${pid}.${diff.score}`, { + pid, + patch: diff.value, + }), + ], next); + }, error_ => { + if (error_) { + return next(error_); + } + + progress.incr(); + return next(); + }); + }); + }, error => { + if (error) { + // Probably type error, ok to incr and continue + progress.incr(); + } + + return next(); + }); + }, { + progress, + }, callback); + }, }; diff --git a/src/upgrades/1.9.0/refresh_post_upload_associations.js b/src/upgrades/1.9.0/refresh_post_upload_associations.js index 0713fc8..5bc2916 100644 --- a/src/upgrades/1.9.0/refresh_post_upload_associations.js +++ b/src/upgrades/1.9.0/refresh_post_upload_associations.js @@ -4,18 +4,18 @@ const async = require('async'); const posts = require('../../posts'); module.exports = { - name: 'Refresh post-upload associations', - timestamp: Date.UTC(2018, 3, 16), - method: function (callback) { - const { progress } = this; + name: 'Refresh post-upload associations', + timestamp: Date.UTC(2018, 3, 16), + method(callback) { + const {progress} = this; - require('../../batch').processSortedSet('posts:pid', (pids, next) => { - async.each(pids, (pid, next) => { - posts.uploads.sync(pid, next); - progress.incr(); - }, next); - }, { - progress: this.progress, - }, callback); - }, + require('../../batch').processSortedSet('posts:pid', (pids, next) => { + async.each(pids, (pid, next) => { + posts.uploads.sync(pid, next); + progress.incr(); + }, next); + }, { + progress: this.progress, + }, callback); + }, }; diff --git a/src/user/admin.js b/src/user/admin.js index 21222c3..d5c020a 100644 --- a/src/user/admin.js +++ b/src/user/admin.js @@ -1,89 +1,90 @@ 'use strict'; -const fs = require('fs'); -const path = require('path'); +const fs = require('node:fs'); +const path = require('node:path'); const winston = require('winston'); const validator = require('validator'); - -const { baseDir } = require('../constants').paths; +const {baseDir} = require('../constants').paths; const db = require('../database'); const plugins = require('../plugins'); const batch = require('../batch'); module.exports = function (User) { - User.logIP = async function (uid, ip) { - if (!(parseInt(uid, 10) > 0)) { - return; - } - const now = Date.now(); - const bulk = [ - [`uid:${uid}:ip`, now, ip || 'Unknown'], - ]; - if (ip) { - bulk.push([`ip:${ip}:uid`, now, uid]); - } - await db.sortedSetAddBulk(bulk); - }; + User.logIP = async function (uid, ip) { + if (!(Number.parseInt(uid, 10) > 0)) { + return; + } + + const now = Date.now(); + const bulk = [ + [`uid:${uid}:ip`, now, ip || 'Unknown'], + ]; + if (ip) { + bulk.push([`ip:${ip}:uid`, now, uid]); + } + + await db.sortedSetAddBulk(bulk); + }; - User.getIPs = async function (uid, stop) { - const ips = await db.getSortedSetRevRange(`uid:${uid}:ip`, 0, stop); - return ips.map(ip => validator.escape(String(ip))); - }; + User.getIPs = async function (uid, stop) { + const ips = await db.getSortedSetRevRange(`uid:${uid}:ip`, 0, stop); + return ips.map(ip => validator.escape(String(ip))); + }; - User.getUsersCSV = async function () { - winston.verbose('[user/getUsersCSV] Compiling User CSV data'); + User.getUsersCSV = async function () { + winston.verbose('[user/getUsersCSV] Compiling User CSV data'); - const data = await plugins.hooks.fire('filter:user.csvFields', { fields: ['uid', 'email', 'username'] }); - let csvContent = `${data.fields.join(',')}\n`; - await batch.processSortedSet('users:joindate', async (uids) => { - const usersData = await User.getUsersFields(uids, data.fields); - csvContent += usersData.reduce((memo, user) => { - memo += `${data.fields.map(field => user[field]).join(',')}\n`; - return memo; - }, ''); - }, {}); + const data = await plugins.hooks.fire('filter:user.csvFields', {fields: ['uid', 'email', 'username']}); + let csvContent = `${data.fields.join(',')}\n`; + await batch.processSortedSet('users:joindate', async uids => { + const usersData = await User.getUsersFields(uids, data.fields); + csvContent += usersData.reduce((memo, user) => { + memo += `${data.fields.map(field => user[field]).join(',')}\n`; + return memo; + }, ''); + }, {}); - return csvContent; - }; + return csvContent; + }; - User.exportUsersCSV = async function () { - winston.verbose('[user/exportUsersCSV] Exporting User CSV data'); + User.exportUsersCSV = async function () { + winston.verbose('[user/exportUsersCSV] Exporting User CSV data'); - const { fields, showIps } = await plugins.hooks.fire('filter:user.csvFields', { - fields: ['email', 'username', 'uid'], - showIps: true, - }); - const fd = await fs.promises.open( - path.join(baseDir, 'build/export', 'users.csv'), - 'w' - ); - fs.promises.appendFile(fd, `${fields.join(',')}${showIps ? ',ip' : ''}\n`); - await batch.processSortedSet('users:joindate', async (uids) => { - const usersData = await User.getUsersFields(uids, fields.slice()); - let userIPs = ''; - let ips = []; + const {fields, showIps} = await plugins.hooks.fire('filter:user.csvFields', { + fields: ['email', 'username', 'uid'], + showIps: true, + }); + const fd = await fs.promises.open( + path.join(baseDir, 'build/export', 'users.csv'), + 'w', + ); + fs.promises.appendFile(fd, `${fields.join(',')}${showIps ? ',ip' : ''}\n`); + await batch.processSortedSet('users:joindate', async uids => { + const usersData = await User.getUsersFields(uids, fields.slice()); + let userIPs = ''; + let ips = []; - if (showIps) { - ips = await db.getSortedSetsMembers(uids.map(uid => `uid:${uid}:ip`)); - } + if (showIps) { + ips = await db.getSortedSetsMembers(uids.map(uid => `uid:${uid}:ip`)); + } - let line = ''; - usersData.forEach((user, index) => { - line += `${fields.map(field => user[field]).join(',')}`; - if (showIps) { - userIPs = ips[index] ? ips[index].join(',') : ''; - line += `,"${userIPs}"\n`; - } else { - line += '\n'; - } - }); + let line = ''; + for (const [index, user] of usersData.entries()) { + line += `${fields.map(field => user[field]).join(',')}`; + if (showIps) { + userIPs = ips[index] ? ips[index].join(',') : ''; + line += `,"${userIPs}"\n`; + } else { + line += '\n'; + } + } - await fs.promises.appendFile(fd, line); - }, { - batch: 5000, - interval: 250, - }); - await fd.close(); - }; + await fs.promises.appendFile(fd, line); + }, { + batch: 5000, + interval: 250, + }); + await fd.close(); + }; }; diff --git a/src/user/approval.js b/src/user/approval.js index 5a5d7d0..51f8667 100644 --- a/src/user/approval.js +++ b/src/user/approval.js @@ -3,7 +3,6 @@ const validator = require('validator'); const winston = require('winston'); const cronJob = require('cron').CronJob; - const db = require('../database'); const meta = require('../meta'); const emailer = require('../emailer'); @@ -14,126 +13,131 @@ const slugify = require('../slugify'); const plugins = require('../plugins'); module.exports = function (User) { - new cronJob('0 * * * *', (() => { - User.autoApprove(); - }), null, true); - - User.addToApprovalQueue = async function (userData) { - userData.username = userData.username.trim(); - userData.userslug = slugify(userData.username); - await canQueue(userData); - const hashedPassword = await User.hashPassword(userData.password); - const data = { - username: userData.username, - email: userData.email, - ip: userData.ip, - hashedPassword: hashedPassword, - }; - const results = await plugins.hooks.fire('filter:user.addToApprovalQueue', { data: data, userData: userData }); - await db.setObject(`registration:queue:name:${userData.username}`, results.data); - await db.sortedSetAdd('registration:queue', Date.now(), userData.username); - await sendNotificationToAdmins(userData.username); - }; - - async function canQueue(userData) { - await User.isDataValid(userData); - const usernames = await db.getSortedSetRange('registration:queue', 0, -1); - if (usernames.includes(userData.username)) { - throw new Error('[[error:username-taken]]'); - } - const keys = usernames.filter(Boolean).map(username => `registration:queue:name:${username}`); - const data = await db.getObjectsFields(keys, ['email']); - const emails = data.map(data => data && data.email).filter(Boolean); - if (userData.email && emails.includes(userData.email)) { - throw new Error('[[error:email-taken]]'); - } - } - - async function sendNotificationToAdmins(username) { - const notifObj = await notifications.create({ - type: 'new-register', - bodyShort: `[[notifications:new_register, ${username}]]`, - nid: `new_register:${username}`, - path: '/admin/manage/registration', - mergeId: 'new_register', - }); - await notifications.pushGroup(notifObj, 'administrators'); - } - - User.acceptRegistration = async function (username) { - const userData = await db.getObject(`registration:queue:name:${username}`); - if (!userData) { - throw new Error('[[error:invalid-data]]'); - } - const creation_time = await db.sortedSetScore('registration:queue', username); - const uid = await User.create(userData); - await User.setUserFields(uid, { - password: userData.hashedPassword, - 'password:shaWrapped': 1, - }); - await removeFromQueue(username); - await markNotificationRead(username); - await plugins.hooks.fire('filter:register.complete', { uid: uid }); - await emailer.send('registration_accepted', uid, { - username: username, - subject: `[[email:welcome-to, ${meta.config.title || meta.config.browserTitle || 'NodeBB'}]]`, - template: 'registration_accepted', - uid: uid, - }).catch(err => winston.error(`[emailer.send] ${err.stack}`)); - const total = await db.incrObjectFieldBy('registration:queue:approval:times', 'totalTime', Math.floor((Date.now() - creation_time) / 60000)); - const counter = await db.incrObjectField('registration:queue:approval:times', 'counter'); - await db.setObjectField('registration:queue:approval:times', 'average', total / counter); - return uid; - }; - - async function markNotificationRead(username) { - const nid = `new_register:${username}`; - const uids = await groups.getMembers('administrators', 0, -1); - const promises = uids.map(uid => notifications.markRead(nid, uid)); - await Promise.all(promises); - } - - User.rejectRegistration = async function (username) { - await removeFromQueue(username); - await markNotificationRead(username); - }; - - async function removeFromQueue(username) { - await Promise.all([ - db.sortedSetRemove('registration:queue', username), - db.delete(`registration:queue:name:${username}`), - ]); - } - - User.shouldQueueUser = async function (ip) { - const { registrationApprovalType } = meta.config; - if (registrationApprovalType === 'admin-approval') { - return true; - } else if (registrationApprovalType === 'admin-approval-ip') { - const count = await db.sortedSetCard(`ip:${ip}:uid`); - return !!count; - } - return false; - }; - - User.getRegistrationQueue = async function (start, stop) { - const data = await db.getSortedSetRevRangeWithScores('registration:queue', start, stop); - const keys = data.filter(Boolean).map(user => `registration:queue:name:${user.value}`); - let users = await db.getObjects(keys); - users = users.filter(Boolean).map((user, index) => { - user.timestampISO = utils.toISOString(data[index].score); - user.email = validator.escape(String(user.email)); - user.usernameEscaped = validator.escape(String(user.username)); - delete user.hashedPassword; - return user; - }); - await Promise.all(users.map(async (user) => { - // temporary: see http://www.stopforumspam.com/forum/viewtopic.php?id=6392 - // need to keep this for getIPMatchedUsers - user.ip = user.ip.replace('::ffff:', ''); - await getIPMatchedUsers(user); - user.customActions = [].concat(user.customActions); - /* + new cronJob('0 * * * *', (() => { + User.autoApprove(); + }), null, true); + + User.addToApprovalQueue = async function (userData) { + userData.username = userData.username.trim(); + userData.userslug = slugify(userData.username); + await canQueue(userData); + const hashedPassword = await User.hashPassword(userData.password); + const data = { + username: userData.username, + email: userData.email, + ip: userData.ip, + hashedPassword, + }; + const results = await plugins.hooks.fire('filter:user.addToApprovalQueue', {data, userData}); + await db.setObject(`registration:queue:name:${userData.username}`, results.data); + await db.sortedSetAdd('registration:queue', Date.now(), userData.username); + await sendNotificationToAdmins(userData.username); + }; + + async function canQueue(userData) { + await User.isDataValid(userData); + const usernames = await db.getSortedSetRange('registration:queue', 0, -1); + if (usernames.includes(userData.username)) { + throw new Error('[[error:username-taken]]'); + } + + const keys = usernames.filter(Boolean).map(username => `registration:queue:name:${username}`); + const data = await db.getObjectsFields(keys, ['email']); + const emails = data.map(data => data && data.email).filter(Boolean); + if (userData.email && emails.includes(userData.email)) { + throw new Error('[[error:email-taken]]'); + } + } + + async function sendNotificationToAdmins(username) { + const notificationObject = await notifications.create({ + type: 'new-register', + bodyShort: `[[notifications:new_register, ${username}]]`, + nid: `new_register:${username}`, + path: '/admin/manage/registration', + mergeId: 'new_register', + }); + await notifications.pushGroup(notificationObject, 'administrators'); + } + + User.acceptRegistration = async function (username) { + const userData = await db.getObject(`registration:queue:name:${username}`); + if (!userData) { + throw new Error('[[error:invalid-data]]'); + } + + const creation_time = await db.sortedSetScore('registration:queue', username); + const uid = await User.create(userData); + await User.setUserFields(uid, { + password: userData.hashedPassword, + 'password:shaWrapped': 1, + }); + await removeFromQueue(username); + await markNotificationRead(username); + await plugins.hooks.fire('filter:register.complete', {uid}); + await emailer.send('registration_accepted', uid, { + username, + subject: `[[email:welcome-to, ${meta.config.title || meta.config.browserTitle || 'NodeBB'}]]`, + template: 'registration_accepted', + uid, + }).catch(error => winston.error(`[emailer.send] ${error.stack}`)); + const total = await db.incrObjectFieldBy('registration:queue:approval:times', 'totalTime', Math.floor((Date.now() - creation_time) / 60_000)); + const counter = await db.incrObjectField('registration:queue:approval:times', 'counter'); + await db.setObjectField('registration:queue:approval:times', 'average', total / counter); + return uid; + }; + + async function markNotificationRead(username) { + const nid = `new_register:${username}`; + const uids = await groups.getMembers('administrators', 0, -1); + const promises = uids.map(uid => notifications.markRead(nid, uid)); + await Promise.all(promises); + } + + User.rejectRegistration = async function (username) { + await removeFromQueue(username); + await markNotificationRead(username); + }; + + async function removeFromQueue(username) { + await Promise.all([ + db.sortedSetRemove('registration:queue', username), + db.delete(`registration:queue:name:${username}`), + ]); + } + + User.shouldQueueUser = async function (ip) { + const {registrationApprovalType} = meta.config; + if (registrationApprovalType === 'admin-approval') { + return true; + } + + if (registrationApprovalType === 'admin-approval-ip') { + const count = await db.sortedSetCard(`ip:${ip}:uid`); + return Boolean(count); + } + + return false; + }; + + User.getRegistrationQueue = async function (start, stop) { + const data = await db.getSortedSetRevRangeWithScores('registration:queue', start, stop); + const keys = data.filter(Boolean).map(user => `registration:queue:name:${user.value}`); + let users = await db.getObjects(keys); + users = users.filter(Boolean).map((user, index) => { + user.timestampISO = utils.toISOString(data[index].score); + user.email = validator.escape(String(user.email)); + user.usernameEscaped = validator.escape(String(user.username)); + delete user.hashedPassword; + return user; + }); + await Promise.all(users.map(async user => { + // Temporary: see http://www.stopforumspam.com/forum/viewtopic.php?id=6392 + // need to keep this for getIPMatchedUsers + user.ip = user.ip.replace('::ffff:', ''); + await getIPMatchedUsers(user); + user.customActions = [user.customActions].flat(); + /* // then spam prevention plugins, using the "filter:user.getRegistrationQueue" hook can be like: user.customActions.push({ title: '[[spam-be-gone:report-user]]', @@ -142,26 +146,27 @@ module.exports = function (User) { icon: 'fa-flag' }); */ - })); - - const results = await plugins.hooks.fire('filter:user.getRegistrationQueue', { users: users }); - return results.users; - }; - - async function getIPMatchedUsers(user) { - const uids = await User.getUidsFromSet(`ip:${user.ip}:uid`, 0, -1); - user.ipMatch = await User.getUsersFields(uids, ['uid', 'username', 'picture']); - } - - User.autoApprove = async function () { - if (meta.config.autoApproveTime <= 0) { - return; - } - const users = await db.getSortedSetRevRangeWithScores('registration:queue', 0, -1); - const now = Date.now(); - for (const user of users.filter(user => now - user.score >= meta.config.autoApproveTime * 3600000)) { - // eslint-disable-next-line no-await-in-loop - await User.acceptRegistration(user.value); - } - }; + })); + + const results = await plugins.hooks.fire('filter:user.getRegistrationQueue', {users}); + return results.users; + }; + + async function getIPMatchedUsers(user) { + const uids = await User.getUidsFromSet(`ip:${user.ip}:uid`, 0, -1); + user.ipMatch = await User.getUsersFields(uids, ['uid', 'username', 'picture']); + } + + User.autoApprove = async function () { + if (meta.config.autoApproveTime <= 0) { + return; + } + + const users = await db.getSortedSetRevRangeWithScores('registration:queue', 0, -1); + const now = Date.now(); + for (const user of users.filter(user => now - user.score >= meta.config.autoApproveTime * 3_600_000)) { + // eslint-disable-next-line no-await-in-loop + await User.acceptRegistration(user.value); + } + }; }; diff --git a/src/user/auth.js b/src/user/auth.js index cdd500d..8d8e10e 100644 --- a/src/user/auth.js +++ b/src/user/auth.js @@ -1,8 +1,8 @@ 'use strict'; +const util = require('node:util'); const winston = require('winston'); const validator = require('validator'); -const util = require('util'); const _ = require('lodash'); const db = require('../database'); const meta = require('../meta'); @@ -11,153 +11,163 @@ const batch = require('../batch'); const utils = require('../utils'); module.exports = function (User) { - User.auth = {}; - - User.auth.logAttempt = async function (uid, ip) { - if (!(parseInt(uid, 10) > 0)) { - return; - } - const exists = await db.exists(`lockout:${uid}`); - if (exists) { - throw new Error('[[error:account-locked]]'); - } - const attempts = await db.increment(`loginAttempts:${uid}`); - if (attempts <= meta.config.loginAttempts) { - return await db.pexpire(`loginAttempts:${uid}`, 1000 * 60 * 60); - } - // Lock out the account - await db.set(`lockout:${uid}`, ''); - const duration = 1000 * 60 * meta.config.lockoutDuration; - - await db.delete(`loginAttempts:${uid}`); - await db.pexpire(`lockout:${uid}`, duration); - await events.log({ - type: 'account-locked', - uid: uid, - ip: ip, - }); - throw new Error('[[error:account-locked]]'); - }; - - User.auth.getFeedToken = async function (uid) { - if (!(parseInt(uid, 10) > 0)) { - return; - } - const _token = await db.getObjectField(`user:${uid}`, 'rss_token'); - const token = _token || utils.generateUUID(); - if (!_token) { - await User.setUserField(uid, 'rss_token', token); - } - return token; - }; - - User.auth.clearLoginAttempts = async function (uid) { - await db.delete(`loginAttempts:${uid}`); - }; - - User.auth.resetLockout = async function (uid) { - await db.deleteAll([ - `loginAttempts:${uid}`, - `lockout:${uid}`, - ]); - }; - - const getSessionFromStore = util.promisify( - (sid, callback) => db.sessionStore.get(sid, (err, sessObj) => callback(err, sessObj || null)) - ); - const sessionStoreDestroy = util.promisify( - (sid, callback) => db.sessionStore.destroy(sid, err => callback(err)) - ); - - User.auth.getSessions = async function (uid, curSessionId) { - await cleanExpiredSessions(uid); - const sids = await db.getSortedSetRevRange(`uid:${uid}:sessions`, 0, 19); - let sessions = await Promise.all(sids.map(sid => getSessionFromStore(sid))); - sessions = sessions.map((sessObj, idx) => { - if (sessObj && sessObj.meta) { - sessObj.meta.current = curSessionId === sids[idx]; - sessObj.meta.datetimeISO = new Date(sessObj.meta.datetime).toISOString(); - sessObj.meta.ip = validator.escape(String(sessObj.meta.ip)); - } - return sessObj && sessObj.meta; - }).filter(Boolean); - return sessions; - }; - - async function cleanExpiredSessions(uid) { - const uuidMapping = await db.getObject(`uid:${uid}:sessionUUID:sessionId`); - if (!uuidMapping) { - return; - } - const expiredUUIDs = []; - const expiredSids = []; - await Promise.all(Object.keys(uuidMapping).map(async (uuid) => { - const sid = uuidMapping[uuid]; - const sessionObj = await getSessionFromStore(sid); - const expired = !sessionObj || !sessionObj.hasOwnProperty('passport') || - !sessionObj.passport.hasOwnProperty('user') || - parseInt(sessionObj.passport.user, 10) !== parseInt(uid, 10); - if (expired) { - expiredUUIDs.push(uuid); - expiredSids.push(sid); - } - })); - await db.deleteObjectFields(`uid:${uid}:sessionUUID:sessionId`, expiredUUIDs); - await db.sortedSetRemove(`uid:${uid}:sessions`, expiredSids); - } - - User.auth.addSession = async function (uid, sessionId) { - if (!(parseInt(uid, 10) > 0)) { - return; - } - await cleanExpiredSessions(uid); - await db.sortedSetAdd(`uid:${uid}:sessions`, Date.now(), sessionId); - await revokeSessionsAboveThreshold(uid, meta.config.maxUserSessions); - }; - - async function revokeSessionsAboveThreshold(uid, maxUserSessions) { - const activeSessions = await db.getSortedSetRange(`uid:${uid}:sessions`, 0, -1); - if (activeSessions.length > maxUserSessions) { - const sessionsToRevoke = activeSessions.slice(0, activeSessions.length - maxUserSessions); - await Promise.all(sessionsToRevoke.map(sessionId => User.auth.revokeSession(sessionId, uid))); - } - } - - User.auth.revokeSession = async function (sessionId, uid) { - winston.verbose(`[user.auth] Revoking session ${sessionId} for user ${uid}`); - const sessionObj = await getSessionFromStore(sessionId); - if (sessionObj && sessionObj.meta && sessionObj.meta.uuid) { - await db.deleteObjectField(`uid:${uid}:sessionUUID:sessionId`, sessionObj.meta.uuid); - } - await Promise.all([ - db.sortedSetRemove(`uid:${uid}:sessions`, sessionId), - sessionStoreDestroy(sessionId), - ]); - }; - - User.auth.revokeAllSessions = async function (uids, except) { - uids = Array.isArray(uids) ? uids : [uids]; - const sids = await db.getSortedSetsMembers(uids.map(uid => `uid:${uid}:sessions`)); - const promises = []; - uids.forEach((uid, index) => { - const ids = sids[index].filter(id => id !== except); - if (ids.length) { - promises.push(ids.map(s => User.auth.revokeSession(s, uid))); - } - }); - await Promise.all(promises); - }; - - User.auth.deleteAllSessions = async function () { - await batch.processSortedSet('users:joindate', async (uids) => { - const sessionKeys = uids.map(uid => `uid:${uid}:sessions`); - const sessionUUIDKeys = uids.map(uid => `uid:${uid}:sessionUUID:sessionId`); - const sids = _.flatten(await db.getSortedSetRange(sessionKeys, 0, -1)); - - await Promise.all([ - db.deleteAll(sessionKeys.concat(sessionUUIDKeys)), - ...sids.map(sid => sessionStoreDestroy(sid)), - ]); - }, { batch: 1000 }); - }; + User.auth = {}; + + User.auth.logAttempt = async function (uid, ip) { + if (!(Number.parseInt(uid, 10) > 0)) { + return; + } + + const exists = await db.exists(`lockout:${uid}`); + if (exists) { + throw new Error('[[error:account-locked]]'); + } + + const attempts = await db.increment(`loginAttempts:${uid}`); + if (attempts <= meta.config.loginAttempts) { + return await db.pexpire(`loginAttempts:${uid}`, 1000 * 60 * 60); + } + + // Lock out the account + await db.set(`lockout:${uid}`, ''); + const duration = 1000 * 60 * meta.config.lockoutDuration; + + await db.delete(`loginAttempts:${uid}`); + await db.pexpire(`lockout:${uid}`, duration); + await events.log({ + type: 'account-locked', + uid, + ip, + }); + throw new Error('[[error:account-locked]]'); + }; + + User.auth.getFeedToken = async function (uid) { + if (!(Number.parseInt(uid, 10) > 0)) { + return; + } + + const _token = await db.getObjectField(`user:${uid}`, 'rss_token'); + const token = _token || utils.generateUUID(); + if (!_token) { + await User.setUserField(uid, 'rss_token', token); + } + + return token; + }; + + User.auth.clearLoginAttempts = async function (uid) { + await db.delete(`loginAttempts:${uid}`); + }; + + User.auth.resetLockout = async function (uid) { + await db.deleteAll([ + `loginAttempts:${uid}`, + `lockout:${uid}`, + ]); + }; + + const getSessionFromStore = util.promisify( + (sid, callback) => db.sessionStore.get(sid, (error, sessObject) => callback(error, sessObject || null)), + ); + const sessionStoreDestroy = util.promisify( + (sid, callback) => db.sessionStore.destroy(sid, error => callback(error)), + ); + + User.auth.getSessions = async function (uid, currentSessionId) { + await cleanExpiredSessions(uid); + const sids = await db.getSortedSetRevRange(`uid:${uid}:sessions`, 0, 19); + let sessions = await Promise.all(sids.map(sid => getSessionFromStore(sid))); + sessions = sessions.map((sessObject, index) => { + if (sessObject && sessObject.meta) { + sessObject.meta.current = currentSessionId === sids[index]; + sessObject.meta.datetimeISO = new Date(sessObject.meta.datetime).toISOString(); + sessObject.meta.ip = validator.escape(String(sessObject.meta.ip)); + } + + return sessObject && sessObject.meta; + }).filter(Boolean); + return sessions; + }; + + async function cleanExpiredSessions(uid) { + const uuidMapping = await db.getObject(`uid:${uid}:sessionUUID:sessionId`); + if (!uuidMapping) { + return; + } + + const expiredUUIDs = []; + const expiredSids = []; + await Promise.all(Object.keys(uuidMapping).map(async uuid => { + const sid = uuidMapping[uuid]; + const sessionObject = await getSessionFromStore(sid); + const expired = !sessionObject || !sessionObject.hasOwnProperty('passport') + || !sessionObject.passport.hasOwnProperty('user') + || Number.parseInt(sessionObject.passport.user, 10) !== Number.parseInt(uid, 10); + if (expired) { + expiredUUIDs.push(uuid); + expiredSids.push(sid); + } + })); + await db.deleteObjectFields(`uid:${uid}:sessionUUID:sessionId`, expiredUUIDs); + await db.sortedSetRemove(`uid:${uid}:sessions`, expiredSids); + } + + User.auth.addSession = async function (uid, sessionId) { + if (!(Number.parseInt(uid, 10) > 0)) { + return; + } + + await cleanExpiredSessions(uid); + await db.sortedSetAdd(`uid:${uid}:sessions`, Date.now(), sessionId); + await revokeSessionsAboveThreshold(uid, meta.config.maxUserSessions); + }; + + async function revokeSessionsAboveThreshold(uid, maxUserSessions) { + const activeSessions = await db.getSortedSetRange(`uid:${uid}:sessions`, 0, -1); + if (activeSessions.length > maxUserSessions) { + const sessionsToRevoke = activeSessions.slice(0, activeSessions.length - maxUserSessions); + await Promise.all(sessionsToRevoke.map(sessionId => User.auth.revokeSession(sessionId, uid))); + } + } + + User.auth.revokeSession = async function (sessionId, uid) { + winston.verbose(`[user.auth] Revoking session ${sessionId} for user ${uid}`); + const sessionObject = await getSessionFromStore(sessionId); + if (sessionObject && sessionObject.meta && sessionObject.meta.uuid) { + await db.deleteObjectField(`uid:${uid}:sessionUUID:sessionId`, sessionObject.meta.uuid); + } + + await Promise.all([ + db.sortedSetRemove(`uid:${uid}:sessions`, sessionId), + sessionStoreDestroy(sessionId), + ]); + }; + + User.auth.revokeAllSessions = async function (uids, except) { + uids = Array.isArray(uids) ? uids : [uids]; + const sids = await db.getSortedSetsMembers(uids.map(uid => `uid:${uid}:sessions`)); + const promises = []; + for (const [index, uid] of uids.entries()) { + const ids = sids[index].filter(id => id !== except); + if (ids.length > 0) { + promises.push(ids.map(s => User.auth.revokeSession(s, uid))); + } + } + + await Promise.all(promises); + }; + + User.auth.deleteAllSessions = async function () { + await batch.processSortedSet('users:joindate', async uids => { + const sessionKeys = uids.map(uid => `uid:${uid}:sessions`); + const sessionUUIDKeys = uids.map(uid => `uid:${uid}:sessionUUID:sessionId`); + const sids = (await db.getSortedSetRange(sessionKeys, 0, -1)).flat(); + + await Promise.all([ + db.deleteAll(sessionKeys.concat(sessionUUIDKeys)), + ...sids.map(sid => sessionStoreDestroy(sid)), + ]); + }, {batch: 1000}); + }; }; diff --git a/src/user/bans.js b/src/user/bans.js index 49fb61a..7cb2d0f 100644 --- a/src/user/bans.js +++ b/src/user/bans.js @@ -1,7 +1,6 @@ 'use strict'; const winston = require('winston'); - const meta = require('../meta'); const emailer = require('../emailer'); const db = require('../database'); @@ -9,135 +8,134 @@ const groups = require('../groups'); const privileges = require('../privileges'); module.exports = function (User) { - User.bans = {}; - - User.bans.ban = async function (uid, until, reason) { - // "until" (optional) is unix timestamp in milliseconds - // "reason" (optional) is a string - until = until || 0; - reason = reason || ''; - - const now = Date.now(); - - until = parseInt(until, 10); - if (isNaN(until)) { - throw new Error('[[error:ban-expiry-missing]]'); - } - - const banKey = `uid:${uid}:ban:${now}`; - const banData = { - uid: uid, - timestamp: now, - expire: until > now ? until : 0, - }; - if (reason) { - banData.reason = reason; - } - - // Leaving all other system groups to have privileges constrained to the "banned-users" group - const systemGroups = groups.systemGroups.filter(group => group !== groups.BANNED_USERS); - await groups.leave(systemGroups, uid); - await groups.join(groups.BANNED_USERS, uid); - await db.sortedSetAdd('users:banned', now, uid); - await db.sortedSetAdd(`uid:${uid}:bans:timestamp`, now, banKey); - await db.setObject(banKey, banData); - await User.setUserField(uid, 'banned:expire', banData.expire); - if (until > now) { - await db.sortedSetAdd('users:banned:expire', until, uid); - } else { - await db.sortedSetRemove('users:banned:expire', uid); - } - - // Email notification of ban - const username = await User.getUserField(uid, 'username'); - const siteTitle = meta.config.title || 'NodeBB'; - - const data = { - subject: `[[email:banned.subject, ${siteTitle}]]`, - username: username, - until: until ? (new Date(until)).toUTCString().replace(/,/g, '\\,') : false, - reason: reason, - }; - await emailer.send('banned', uid, data).catch(err => winston.error(`[emailer.send] ${err.stack}`)); - - return banData; - }; - - User.bans.unban = async function (uids) { - uids = Array.isArray(uids) ? uids : [uids]; - const userData = await User.getUsersFields(uids, ['email:confirmed']); - - await db.setObject(uids.map(uid => `user:${uid}`), { 'banned:expire': 0 }); - - /* eslint-disable no-await-in-loop */ - for (const user of userData) { - const systemGroupsToJoin = [ - 'registered-users', - (parseInt(user['email:confirmed'], 10) === 1 ? 'verified-users' : 'unverified-users'), - ]; - await groups.leave(groups.BANNED_USERS, user.uid); - // An unbanned user would lost its previous "Global Moderator" status - await groups.join(systemGroupsToJoin, user.uid); - } - - await db.sortedSetRemove(['users:banned', 'users:banned:expire'], uids); - }; - - User.bans.isBanned = async function (uids) { - const isArray = Array.isArray(uids); - uids = isArray ? uids : [uids]; - const result = await User.bans.unbanIfExpired(uids); - return isArray ? result.map(r => r.banned) : result[0].banned; - }; - - User.bans.canLoginIfBanned = async function (uid) { - let canLogin = true; - - const { banned } = (await User.bans.unbanIfExpired([uid]))[0]; - // Group privilege overshadows individual one - if (banned) { - canLogin = await privileges.global.canGroup('local:login', groups.BANNED_USERS); - } - if (banned && !canLogin) { - // Checking a single privilege of user - canLogin = await groups.isMember(uid, 'cid:0:privileges:local:login'); - } - - return canLogin; - }; - - User.bans.unbanIfExpired = async function (uids) { - // loading user data will unban if it has expired -barisu - const userData = await User.getUsersFields(uids, ['banned:expire']); - return User.bans.calcExpiredFromUserData(userData); - }; - - User.bans.calcExpiredFromUserData = async function (userData) { - const isArray = Array.isArray(userData); - userData = isArray ? userData : [userData]; - const banned = await groups.isMembers(userData.map(u => u.uid), groups.BANNED_USERS); - userData = userData.map((userData, index) => ({ - banned: banned[index], - 'banned:expire': userData && userData['banned:expire'], - banExpired: userData && userData['banned:expire'] <= Date.now() && userData['banned:expire'] !== 0, - })); - return isArray ? userData : userData[0]; - }; - - User.bans.filterBanned = async function (uids) { - const isBanned = await User.bans.isBanned(uids); - return uids.filter((uid, index) => !isBanned[index]); - }; - - User.bans.getReason = async function (uid) { - if (parseInt(uid, 10) <= 0) { - return ''; - } - const keys = await db.getSortedSetRevRange(`uid:${uid}:bans:timestamp`, 0, 0); - if (!keys.length) { - return ''; - } - const banObj = await db.getObject(keys[0]); - return banObj && banObj.reason ? banObj.reason : ''; - }; + User.bans = {}; + + User.bans.ban = async function (uid, until, reason) { + // "until" (optional) is unix timestamp in milliseconds + // "reason" (optional) is a string + until ||= 0; + reason ||= ''; + + const now = Date.now(); + + until = Number.parseInt(until, 10); + if (isNaN(until)) { + throw new TypeError('[[error:ban-expiry-missing]]'); + } + + const banKey = `uid:${uid}:ban:${now}`; + const banData = { + uid, + timestamp: now, + expire: until > now ? until : 0, + }; + if (reason) { + banData.reason = reason; + } + + // Leaving all other system groups to have privileges constrained to the "banned-users" group + const systemGroups = groups.systemGroups.filter(group => group !== groups.BANNED_USERS); + await groups.leave(systemGroups, uid); + await groups.join(groups.BANNED_USERS, uid); + await db.sortedSetAdd('users:banned', now, uid); + await db.sortedSetAdd(`uid:${uid}:bans:timestamp`, now, banKey); + await db.setObject(banKey, banData); + await User.setUserField(uid, 'banned:expire', banData.expire); + await (until > now ? db.sortedSetAdd('users:banned:expire', until, uid) : db.sortedSetRemove('users:banned:expire', uid)); + + // Email notification of ban + const username = await User.getUserField(uid, 'username'); + const siteTitle = meta.config.title || 'NodeBB'; + + const data = { + subject: `[[email:banned.subject, ${siteTitle}]]`, + username, + until: until ? (new Date(until)).toUTCString().replaceAll(',', '\\,') : false, + reason, + }; + await emailer.send('banned', uid, data).catch(error => winston.error(`[emailer.send] ${error.stack}`)); + + return banData; + }; + + User.bans.unban = async function (uids) { + uids = Array.isArray(uids) ? uids : [uids]; + const userData = await User.getUsersFields(uids, ['email:confirmed']); + + await db.setObject(uids.map(uid => `user:${uid}`), {'banned:expire': 0}); + + /* eslint-disable no-await-in-loop */ + for (const user of userData) { + const systemGroupsToJoin = [ + 'registered-users', + (Number.parseInt(user['email:confirmed'], 10) === 1 ? 'verified-users' : 'unverified-users'), + ]; + await groups.leave(groups.BANNED_USERS, user.uid); + // An unbanned user would lost its previous "Global Moderator" status + await groups.join(systemGroupsToJoin, user.uid); + } + + await db.sortedSetRemove(['users:banned', 'users:banned:expire'], uids); + }; + + User.bans.isBanned = async function (uids) { + const isArray = Array.isArray(uids); + uids = isArray ? uids : [uids]; + const result = await User.bans.unbanIfExpired(uids); + return isArray ? result.map(r => r.banned) : result[0].banned; + }; + + User.bans.canLoginIfBanned = async function (uid) { + let canLogin = true; + + const {banned} = (await User.bans.unbanIfExpired([uid]))[0]; + // Group privilege overshadows individual one + if (banned) { + canLogin = await privileges.global.canGroup('local:login', groups.BANNED_USERS); + } + + if (banned && !canLogin) { + // Checking a single privilege of user + canLogin = await groups.isMember(uid, 'cid:0:privileges:local:login'); + } + + return canLogin; + }; + + User.bans.unbanIfExpired = async function (uids) { + // Loading user data will unban if it has expired -barisu + const userData = await User.getUsersFields(uids, ['banned:expire']); + return User.bans.calcExpiredFromUserData(userData); + }; + + User.bans.calcExpiredFromUserData = async function (userData) { + const isArray = Array.isArray(userData); + userData = isArray ? userData : [userData]; + const banned = await groups.isMembers(userData.map(u => u.uid), groups.BANNED_USERS); + userData = userData.map((userData, index) => ({ + banned: banned[index], + 'banned:expire': userData && userData['banned:expire'], + banExpired: userData && userData['banned:expire'] <= Date.now() && userData['banned:expire'] !== 0, + })); + return isArray ? userData : userData[0]; + }; + + User.bans.filterBanned = async function (uids) { + const isBanned = await User.bans.isBanned(uids); + return uids.filter((uid, index) => !isBanned[index]); + }; + + User.bans.getReason = async function (uid) { + if (Number.parseInt(uid, 10) <= 0) { + return ''; + } + + const keys = await db.getSortedSetRevRange(`uid:${uid}:bans:timestamp`, 0, 0); + if (keys.length === 0) { + return ''; + } + + const banObject = await db.getObject(keys[0]); + return banObject && banObject.reason ? banObject.reason : ''; + }; }; diff --git a/src/user/blocks.js b/src/user/blocks.js index 3d36f6d..4549cff 100644 --- a/src/user/blocks.js +++ b/src/user/blocks.js @@ -5,109 +5,113 @@ const plugins = require('../plugins'); const cacheCreate = require('../cache/lru'); module.exports = function (User) { - User.blocks = { - _cache: cacheCreate({ - name: 'user:blocks', - max: 100, - ttl: 0, - }), - }; - - User.blocks.is = async function (targetUid, uids) { - const isArray = Array.isArray(uids); - uids = isArray ? uids : [uids]; - const blocks = await User.blocks.list(uids); - const isBlocked = uids.map((uid, index) => blocks[index] && blocks[index].includes(parseInt(targetUid, 10))); - return isArray ? isBlocked : isBlocked[0]; - }; - - User.blocks.can = async function (callerUid, blockerUid, blockeeUid, type) { - // Guests can't block - if (blockerUid === 0 || blockeeUid === 0) { - throw new Error('[[error:cannot-block-guest]]'); - } else if (blockerUid === blockeeUid) { - throw new Error('[[error:cannot-block-self]]'); - } - - // Administrators and global moderators cannot be blocked - // Only admins/mods can block users as another user - const [isCallerAdminOrMod, isBlockeeAdminOrMod] = await Promise.all([ - User.isAdminOrGlobalMod(callerUid), - User.isAdminOrGlobalMod(blockeeUid), - ]); - if (isBlockeeAdminOrMod && type === 'block') { - throw new Error('[[error:cannot-block-privileged]]'); - } - if (parseInt(callerUid, 10) !== parseInt(blockerUid, 10) && !isCallerAdminOrMod) { - throw new Error('[[error:no-privileges]]'); - } - }; - - User.blocks.list = async function (uids) { - const isArray = Array.isArray(uids); - uids = (isArray ? uids : [uids]).map(uid => parseInt(uid, 10)); - const cachedData = {}; - const unCachedUids = User.blocks._cache.getUnCachedKeys(uids, cachedData); - if (unCachedUids.length) { - const unCachedData = await db.getSortedSetsMembers(unCachedUids.map(uid => `uid:${uid}:blocked_uids`)); - unCachedUids.forEach((uid, index) => { - cachedData[uid] = (unCachedData[index] || []).map(uid => parseInt(uid, 10)); - User.blocks._cache.set(uid, cachedData[uid]); - }); - } - const result = uids.map(uid => cachedData[uid] || []); - return isArray ? result.slice() : result[0]; - }; - - User.blocks.add = async function (targetUid, uid) { - await User.blocks.applyChecks('block', targetUid, uid); - await db.sortedSetAdd(`uid:${uid}:blocked_uids`, Date.now(), targetUid); - await User.incrementUserFieldBy(uid, 'blocksCount', 1); - User.blocks._cache.del(parseInt(uid, 10)); - plugins.hooks.fire('action:user.blocks.add', { uid: uid, targetUid: targetUid }); - }; - - User.blocks.remove = async function (targetUid, uid) { - await User.blocks.applyChecks('unblock', targetUid, uid); - await db.sortedSetRemove(`uid:${uid}:blocked_uids`, targetUid); - await User.decrementUserFieldBy(uid, 'blocksCount', 1); - User.blocks._cache.del(parseInt(uid, 10)); - plugins.hooks.fire('action:user.blocks.remove', { uid: uid, targetUid: targetUid }); - }; - - User.blocks.applyChecks = async function (type, targetUid, uid) { - await User.blocks.can(uid, uid, targetUid); - const isBlock = type === 'block'; - const is = await User.blocks.is(targetUid, uid); - if (is === isBlock) { - throw new Error(`[[error:already-${isBlock ? 'blocked' : 'unblocked'}]]`); - } - }; - - User.blocks.filterUids = async function (targetUid, uids) { - const isBlocked = await User.blocks.is(targetUid, uids); - return uids.filter((uid, index) => !isBlocked[index]); - }; - - User.blocks.filter = async function (uid, property, set) { - // Given whatever is passed in, iterates through it, and removes entries made by blocked uids - // property is optional - if (Array.isArray(property) && typeof set === 'undefined') { - set = property; - property = 'uid'; - } - - if (!Array.isArray(set) || !set.length) { - return set; - } - - const isPlain = typeof set[0] !== 'object'; - const blocked_uids = await User.blocks.list(uid); - const blockedSet = new Set(blocked_uids); - - set = set.filter(item => !blockedSet.has(parseInt(isPlain ? item : (item && item[property]), 10))); - const data = await plugins.hooks.fire('filter:user.blocks.filter', { set: set, property: property, uid: uid, blockedSet: blockedSet }); - - return data.set; - }; + User.blocks = { + _cache: cacheCreate({ + name: 'user:blocks', + max: 100, + ttl: 0, + }), + }; + + User.blocks.is = async function (targetUid, uids) { + const isArray = Array.isArray(uids); + uids = isArray ? uids : [uids]; + const blocks = await User.blocks.list(uids); + const isBlocked = uids.map((uid, index) => blocks[index] && blocks[index].includes(Number.parseInt(targetUid, 10))); + return isArray ? isBlocked : isBlocked[0]; + }; + + User.blocks.can = async function (callerUid, blockerUid, blockeeUid, type) { + // Guests can't block + if (blockerUid === 0 || blockeeUid === 0) { + throw new Error('[[error:cannot-block-guest]]'); + } else if (blockerUid === blockeeUid) { + throw new Error('[[error:cannot-block-self]]'); + } + + // Administrators and global moderators cannot be blocked + // Only admins/mods can block users as another user + const [isCallerAdminOrModule, isBlockeeAdminOrModule] = await Promise.all([ + User.isAdminOrGlobalMod(callerUid), + User.isAdminOrGlobalMod(blockeeUid), + ]); + if (isBlockeeAdminOrModule && type === 'block') { + throw new Error('[[error:cannot-block-privileged]]'); + } + + if (Number.parseInt(callerUid, 10) !== Number.parseInt(blockerUid, 10) && !isCallerAdminOrModule) { + throw new Error('[[error:no-privileges]]'); + } + }; + + User.blocks.list = async function (uids) { + const isArray = Array.isArray(uids); + uids = (isArray ? uids : [uids]).map(uid => Number.parseInt(uid, 10)); + const cachedData = {}; + const unCachedUids = User.blocks._cache.getUnCachedKeys(uids, cachedData); + if (unCachedUids.length > 0) { + const unCachedData = await db.getSortedSetsMembers(unCachedUids.map(uid => `uid:${uid}:blocked_uids`)); + for (const [index, uid] of unCachedUids.entries()) { + cachedData[uid] = (unCachedData[index] || []).map(uid => Number.parseInt(uid, 10)); + User.blocks._cache.set(uid, cachedData[uid]); + } + } + + const result = uids.map(uid => cachedData[uid] || []); + return isArray ? result.slice() : result[0]; + }; + + User.blocks.add = async function (targetUid, uid) { + await User.blocks.applyChecks('block', targetUid, uid); + await db.sortedSetAdd(`uid:${uid}:blocked_uids`, Date.now(), targetUid); + await User.incrementUserFieldBy(uid, 'blocksCount', 1); + User.blocks._cache.del(Number.parseInt(uid, 10)); + plugins.hooks.fire('action:user.blocks.add', {uid, targetUid}); + }; + + User.blocks.remove = async function (targetUid, uid) { + await User.blocks.applyChecks('unblock', targetUid, uid); + await db.sortedSetRemove(`uid:${uid}:blocked_uids`, targetUid); + await User.decrementUserFieldBy(uid, 'blocksCount', 1); + User.blocks._cache.del(Number.parseInt(uid, 10)); + plugins.hooks.fire('action:user.blocks.remove', {uid, targetUid}); + }; + + User.blocks.applyChecks = async function (type, targetUid, uid) { + await User.blocks.can(uid, uid, targetUid); + const isBlock = type === 'block'; + const is = await User.blocks.is(targetUid, uid); + if (is === isBlock) { + throw new Error(`[[error:already-${isBlock ? 'blocked' : 'unblocked'}]]`); + } + }; + + User.blocks.filterUids = async function (targetUid, uids) { + const isBlocked = await User.blocks.is(targetUid, uids); + return uids.filter((uid, index) => !isBlocked[index]); + }; + + User.blocks.filter = async function (uid, property, set) { + // Given whatever is passed in, iterates through it, and removes entries made by blocked uids + // property is optional + if (Array.isArray(property) && set === undefined) { + set = property; + property = 'uid'; + } + + if (!Array.isArray(set) || set.length === 0) { + return set; + } + + const isPlain = typeof set[0] !== 'object'; + const blocked_uids = await User.blocks.list(uid); + const blockedSet = new Set(blocked_uids); + + set = set.filter(item => !blockedSet.has(Number.parseInt(isPlain ? item : (item && item[property]), 10))); + const data = await plugins.hooks.fire('filter:user.blocks.filter', { + set, property, uid, blockedSet, + }); + + return data.set; + }; }; diff --git a/src/user/categories.js b/src/user/categories.js index 42087d5..8ddbf7c 100644 --- a/src/user/categories.js +++ b/src/user/categories.js @@ -1,76 +1,81 @@ 'use strict'; const _ = require('lodash'); - const db = require('../database'); const categories = require('../categories'); const plugins = require('../plugins'); module.exports = function (User) { - User.setCategoryWatchState = async function (uid, cids, state) { - if (!(parseInt(uid, 10) > 0)) { - return; - } - const isStateValid = Object.values(categories.watchStates).includes(parseInt(state, 10)); - if (!isStateValid) { - throw new Error('[[error:invalid-watch-state]]'); - } - cids = Array.isArray(cids) ? cids : [cids]; - const exists = await categories.exists(cids); - if (exists.includes(false)) { - throw new Error('[[error:no-category]]'); - } - await db.sortedSetsAdd(cids.map(cid => `cid:${cid}:uid:watch:state`), state, uid); - }; + User.setCategoryWatchState = async function (uid, cids, state) { + if (!(Number.parseInt(uid, 10) > 0)) { + return; + } + + const isStateValid = Object.values(categories.watchStates).includes(Number.parseInt(state, 10)); + if (!isStateValid) { + throw new Error('[[error:invalid-watch-state]]'); + } + + cids = Array.isArray(cids) ? cids : [cids]; + const exists = await categories.exists(cids); + if (exists.includes(false)) { + throw new Error('[[error:no-category]]'); + } + + await db.sortedSetsAdd(cids.map(cid => `cid:${cid}:uid:watch:state`), state, uid); + }; + + User.getCategoryWatchState = async function (uid) { + if (!(Number.parseInt(uid, 10) > 0)) { + return {}; + } + + const cids = await categories.getAllCidsFromSet('categories:cid'); + const states = await categories.getWatchState(cids, uid); + return _.zipObject(cids, states); + }; + + User.getIgnoredCategories = async function (uid) { + if (!(Number.parseInt(uid, 10) > 0)) { + return []; + } - User.getCategoryWatchState = async function (uid) { - if (!(parseInt(uid, 10) > 0)) { - return {}; - } + const cids = await User.getCategoriesByStates(uid, [categories.watchStates.ignoring]); + const result = await plugins.hooks.fire('filter:user.getIgnoredCategories', { + uid, + cids, + }); + return result.cids; + }; - const cids = await categories.getAllCidsFromSet('categories:cid'); - const states = await categories.getWatchState(cids, uid); - return _.zipObject(cids, states); - }; + User.getWatchedCategories = async function (uid) { + if (!(Number.parseInt(uid, 10) > 0)) { + return []; + } - User.getIgnoredCategories = async function (uid) { - if (!(parseInt(uid, 10) > 0)) { - return []; - } - const cids = await User.getCategoriesByStates(uid, [categories.watchStates.ignoring]); - const result = await plugins.hooks.fire('filter:user.getIgnoredCategories', { - uid: uid, - cids: cids, - }); - return result.cids; - }; + const cids = await User.getCategoriesByStates(uid, [categories.watchStates.watching]); + const result = await plugins.hooks.fire('filter:user.getWatchedCategories', { + uid, + cids, + }); + return result.cids; + }; - User.getWatchedCategories = async function (uid) { - if (!(parseInt(uid, 10) > 0)) { - return []; - } - const cids = await User.getCategoriesByStates(uid, [categories.watchStates.watching]); - const result = await plugins.hooks.fire('filter:user.getWatchedCategories', { - uid: uid, - cids: cids, - }); - return result.cids; - }; + User.getCategoriesByStates = async function (uid, states) { + if (!(Number.parseInt(uid, 10) > 0)) { + return await categories.getAllCidsFromSet('categories:cid'); + } - User.getCategoriesByStates = async function (uid, states) { - if (!(parseInt(uid, 10) > 0)) { - return await categories.getAllCidsFromSet('categories:cid'); - } - const cids = await categories.getAllCidsFromSet('categories:cid'); - const userState = await categories.getWatchState(cids, uid); - return cids.filter((cid, index) => states.includes(userState[index])); - }; + const cids = await categories.getAllCidsFromSet('categories:cid'); + const userState = await categories.getWatchState(cids, uid); + return cids.filter((cid, index) => states.includes(userState[index])); + }; - User.ignoreCategory = async function (uid, cid) { - await User.setCategoryWatchState(uid, cid, categories.watchStates.ignoring); - }; + User.ignoreCategory = async function (uid, cid) { + await User.setCategoryWatchState(uid, cid, categories.watchStates.ignoring); + }; - User.watchCategory = async function (uid, cid) { - await User.setCategoryWatchState(uid, cid, categories.watchStates.watching); - }; + User.watchCategory = async function (uid, cid) { + await User.setCategoryWatchState(uid, cid, categories.watchStates.watching); + }; }; diff --git a/src/user/create.js b/src/user/create.js index 7d59ab6..e2e3eb7 100644 --- a/src/user/create.js +++ b/src/user/create.js @@ -2,7 +2,6 @@ const zxcvbn = require('zxcvbn'); const winston = require('winston'); - const db = require('../database'); const utils = require('../utils'); const slugify = require('../slugify'); @@ -12,188 +11,195 @@ const meta = require('../meta'); const analytics = require('../analytics'); module.exports = function (User) { - User.create = async function (data) { - data.username = data.username.trim(); - data.userslug = slugify(data.username); - if (data.email !== undefined) { - data.email = String(data.email).trim(); - } - if (data['account-type'] !== undefined) { - data.accounttype = data['account-type'].trim(); - } - - await User.isDataValid(data); - - await lock(data.username, '[[error:username-taken]]'); - if (data.email && data.email !== data.username) { - await lock(data.email, '[[error:email-taken]]'); - } - - try { - return await create(data); - } finally { - await db.deleteObjectFields('locks', [data.username, data.email]); - } - }; - - async function lock(value, error) { - const count = await db.incrObjectField('locks', value); - if (count > 1) { - throw new Error(error); - } - } - - async function create(data) { - const timestamp = data.timestamp || Date.now(); - - let userData = { - username: data.username, - userslug: data.userslug, - accounttype: data.accounttype || 'student', - email: data.email || '', - joindate: timestamp, - lastonline: timestamp, - status: 'online', - }; - ['picture', 'fullname', 'location', 'birthday'].forEach((field) => { - if (data[field]) { - userData[field] = data[field]; - } - }); - if (data.gdpr_consent === true) { - userData.gdpr_consent = 1; - } - if (data.acceptTos === true) { - userData.acceptTos = 1; - } - - const renamedUsername = await User.uniqueUsername(userData); - const userNameChanged = !!renamedUsername; - if (userNameChanged) { - userData.username = renamedUsername; - userData.userslug = slugify(renamedUsername); - } - - const results = await plugins.hooks.fire('filter:user.create', { user: userData, data: data }); - userData = results.user; - - const uid = await db.incrObjectField('global', 'nextUid'); - const isFirstUser = uid === 1; - userData.uid = uid; - - await db.setObject(`user:${uid}`, userData); - - const bulkAdd = [ - ['username:uid', userData.uid, userData.username], - [`user:${userData.uid}:usernames`, timestamp, `${userData.username}:${timestamp}`], - ['username:sorted', 0, `${userData.username.toLowerCase()}:${userData.uid}`], - ['userslug:uid', userData.uid, userData.userslug], - ['users:joindate', timestamp, userData.uid], - ['users:online', timestamp, userData.uid], - ['users:postcount', 0, userData.uid], - ['users:reputation', 0, userData.uid], - ]; - - if (userData.fullname) { - bulkAdd.push(['fullname:sorted', 0, `${userData.fullname.toLowerCase()}:${userData.uid}`]); - } - - await Promise.all([ - db.incrObjectField('global', 'userCount'), - analytics.increment('registrations'), - db.sortedSetAddBulk(bulkAdd), - groups.join(['registered-users', 'unverified-users'], userData.uid), - User.notifications.sendWelcomeNotification(userData.uid), - storePassword(userData.uid, data.password), - User.updateDigestSetting(userData.uid, meta.config.dailyDigestFreq), - ]); - - if (userData.email && isFirstUser) { - await User.email.confirmByUid(userData.uid); - } - - if (userData.email && userData.uid > 1) { - await User.email.sendValidationEmail(userData.uid, { - email: userData.email, - template: 'welcome', - subject: `[[email:welcome-to, ${meta.config.title || meta.config.browserTitle || 'NodeBB'}]]`, - }).catch(err => winston.error(`[user.create] Validation email failed to send\n[emailer.send] ${err.stack}`)); - } - if (userNameChanged) { - await User.notifications.sendNameChangeNotification(userData.uid, userData.username); - } - plugins.hooks.fire('action:user.create', { user: userData, data: data }); - return userData.uid; - } - - async function storePassword(uid, password) { - if (!password) { - return; - } - const hash = await User.hashPassword(password); - await Promise.all([ - User.setUserFields(uid, { - password: hash, - 'password:shaWrapped': 1, - }), - User.reset.updateExpiry(uid), - ]); - } - - User.isDataValid = async function (userData) { - if (userData.email && !utils.isEmailValid(userData.email)) { - throw new Error('[[error:invalid-email]]'); - } - - if (!utils.isUserNameValid(userData.username) || !userData.userslug) { - throw new Error(`[[error:invalid-username, ${userData.username}]]`); - } - - if (userData.password) { - User.isPasswordValid(userData.password); - } - - if (userData.email) { - const available = await User.email.available(userData.email); - if (!available) { - throw new Error('[[error:email-taken]]'); - } - } - }; - - User.isPasswordValid = function (password, minStrength) { - minStrength = (minStrength || minStrength === 0) ? minStrength : meta.config.minimumPasswordStrength; - - // Sanity checks: Checks if defined and is string - if (!password || !utils.isPasswordValid(password)) { - throw new Error('[[error:invalid-password]]'); - } - - if (password.length < meta.config.minimumPasswordLength) { - throw new Error('[[reset_password:password_too_short]]'); - } - - if (password.length > 512) { - throw new Error('[[error:password-too-long]]'); - } - - const strength = zxcvbn(password); - if (strength.score < minStrength) { - throw new Error('[[user:weak_password]]'); - } - }; - - User.uniqueUsername = async function (userData) { - let numTries = 0; - let { username } = userData; - while (true) { - /* eslint-disable no-await-in-loop */ - const exists = await meta.userOrGroupExists(username); - if (!exists) { - return numTries ? username : null; - } - username = `${userData.username} ${numTries.toString(32)}`; - numTries += 1; - } - }; + User.create = async function (data) { + data.username = data.username.trim(); + data.userslug = slugify(data.username); + if (data.email !== undefined) { + data.email = String(data.email).trim(); + } + + if (data['account-type'] !== undefined) { + data.accounttype = data['account-type'].trim(); + } + + await User.isDataValid(data); + + await lock(data.username, '[[error:username-taken]]'); + if (data.email && data.email !== data.username) { + await lock(data.email, '[[error:email-taken]]'); + } + + try { + return await create(data); + } finally { + await db.deleteObjectFields('locks', [data.username, data.email]); + } + }; + + async function lock(value, error) { + const count = await db.incrObjectField('locks', value); + if (count > 1) { + throw new Error(error); + } + } + + async function create(data) { + const timestamp = data.timestamp || Date.now(); + + let userData = { + username: data.username, + userslug: data.userslug, + accounttype: data.accounttype || 'student', + email: data.email || '', + joindate: timestamp, + lastonline: timestamp, + status: 'online', + }; + for (const field of ['picture', 'fullname', 'location', 'birthday']) { + if (data[field]) { + userData[field] = data[field]; + } + } + + if (data.gdpr_consent === true) { + userData.gdpr_consent = 1; + } + + if (data.acceptTos === true) { + userData.acceptTos = 1; + } + + const renamedUsername = await User.uniqueUsername(userData); + const userNameChanged = Boolean(renamedUsername); + if (userNameChanged) { + userData.username = renamedUsername; + userData.userslug = slugify(renamedUsername); + } + + const results = await plugins.hooks.fire('filter:user.create', {user: userData, data}); + userData = results.user; + + const uid = await db.incrObjectField('global', 'nextUid'); + const isFirstUser = uid === 1; + userData.uid = uid; + + await db.setObject(`user:${uid}`, userData); + + const bulkAdd = [ + ['username:uid', userData.uid, userData.username], + [`user:${userData.uid}:usernames`, timestamp, `${userData.username}:${timestamp}`], + ['username:sorted', 0, `${userData.username.toLowerCase()}:${userData.uid}`], + ['userslug:uid', userData.uid, userData.userslug], + ['users:joindate', timestamp, userData.uid], + ['users:online', timestamp, userData.uid], + ['users:postcount', 0, userData.uid], + ['users:reputation', 0, userData.uid], + ]; + + if (userData.fullname) { + bulkAdd.push(['fullname:sorted', 0, `${userData.fullname.toLowerCase()}:${userData.uid}`]); + } + + await Promise.all([ + db.incrObjectField('global', 'userCount'), + analytics.increment('registrations'), + db.sortedSetAddBulk(bulkAdd), + groups.join(['registered-users', 'unverified-users'], userData.uid), + User.notifications.sendWelcomeNotification(userData.uid), + storePassword(userData.uid, data.password), + User.updateDigestSetting(userData.uid, meta.config.dailyDigestFreq), + ]); + + if (userData.email && isFirstUser) { + await User.email.confirmByUid(userData.uid); + } + + if (userData.email && userData.uid > 1) { + await User.email.sendValidationEmail(userData.uid, { + email: userData.email, + template: 'welcome', + subject: `[[email:welcome-to, ${meta.config.title || meta.config.browserTitle || 'NodeBB'}]]`, + }).catch(error => winston.error(`[user.create] Validation email failed to send\n[emailer.send] ${error.stack}`)); + } + + if (userNameChanged) { + await User.notifications.sendNameChangeNotification(userData.uid, userData.username); + } + + plugins.hooks.fire('action:user.create', {user: userData, data}); + return userData.uid; + } + + async function storePassword(uid, password) { + if (!password) { + return; + } + + const hash = await User.hashPassword(password); + await Promise.all([ + User.setUserFields(uid, { + password: hash, + 'password:shaWrapped': 1, + }), + User.reset.updateExpiry(uid), + ]); + } + + User.isDataValid = async function (userData) { + if (userData.email && !utils.isEmailValid(userData.email)) { + throw new Error('[[error:invalid-email]]'); + } + + if (!utils.isUserNameValid(userData.username) || !userData.userslug) { + throw new Error(`[[error:invalid-username, ${userData.username}]]`); + } + + if (userData.password) { + User.isPasswordValid(userData.password); + } + + if (userData.email) { + const available = await User.email.available(userData.email); + if (!available) { + throw new Error('[[error:email-taken]]'); + } + } + }; + + User.isPasswordValid = function (password, minStrength) { + minStrength = (minStrength || minStrength === 0) ? minStrength : meta.config.minimumPasswordStrength; + + // Sanity checks: Checks if defined and is string + if (!password || !utils.isPasswordValid(password)) { + throw new Error('[[error:invalid-password]]'); + } + + if (password.length < meta.config.minimumPasswordLength) { + throw new Error('[[reset_password:password_too_short]]'); + } + + if (password.length > 512) { + throw new Error('[[error:password-too-long]]'); + } + + const strength = zxcvbn(password); + if (strength.score < minStrength) { + throw new Error('[[user:weak_password]]'); + } + }; + + User.uniqueUsername = async function (userData) { + let numberTries = 0; + let {username} = userData; + while (true) { + /* eslint-disable no-await-in-loop */ + const exists = await meta.userOrGroupExists(username); + if (!exists) { + return numberTries ? username : null; + } + + username = `${userData.username} ${numberTries.toString(32)}`; + numberTries += 1; + } + }; }; diff --git a/src/user/data.js b/src/user/data.js index 77c255a..4ba0813 100644 --- a/src/user/data.js +++ b/src/user/data.js @@ -3,7 +3,6 @@ const validator = require('validator'); const nconf = require('nconf'); const _ = require('lodash'); - const db = require('../database'); const meta = require('../meta'); const plugins = require('../plugins'); @@ -12,345 +11,405 @@ const utils = require('../utils'); const relative_path = nconf.get('relative_path'); const intFields = [ - 'uid', 'postcount', 'topiccount', 'reputation', 'profileviews', - 'banned', 'banned:expire', 'email:confirmed', 'joindate', 'lastonline', - 'lastqueuetime', 'lastposttime', 'followingCount', 'followerCount', - 'blocksCount', 'passwordExpiry', 'mutedUntil', + 'uid', + 'postcount', + 'topiccount', + 'reputation', + 'profileviews', + 'banned', + 'banned:expire', + 'email:confirmed', + 'joindate', + 'lastonline', + 'lastqueuetime', + 'lastposttime', + 'followingCount', + 'followerCount', + 'blocksCount', + 'passwordExpiry', + 'mutedUntil', ]; module.exports = function (User) { - const fieldWhitelist = [ - 'uid', 'username', 'userslug', 'email', 'email:confirmed', 'joindate', 'accounttype', - 'lastonline', 'picture', 'icon:bgColor', 'fullname', 'location', 'birthday', 'website', - 'aboutme', 'signature', 'uploadedpicture', 'profileviews', 'reputation', - 'postcount', 'topiccount', 'lastposttime', 'banned', 'banned:expire', - 'status', 'flags', 'followerCount', 'followingCount', 'cover:url', - 'cover:position', 'groupTitle', 'mutedUntil', 'mutedReason', - ]; - - User.guestData = { - uid: 0, - username: '[[global:guest]]', - displayname: '[[global:guest]]', - userslug: '', - fullname: '[[global:guest]]', - email: '', - 'icon:text': '?', - 'icon:bgColor': '#aaa', - groupTitle: '', - groupTitleArray: [], - status: 'offline', - reputation: 0, - 'email:confirmed': 0, - }; - - User.getUsersFields = async function (uids, fields) { - if (!Array.isArray(uids) || !uids.length) { - return []; - } - - uids = uids.map(uid => (isNaN(uid) ? 0 : parseInt(uid, 10))); - - const fieldsToRemove = []; - fields = fields.slice(); - ensureRequiredFields(fields, fieldsToRemove); - - const uniqueUids = _.uniq(uids).filter(uid => uid > 0); - - const results = await plugins.hooks.fire('filter:user.whitelistFields', { - uids: uids, - whitelist: fieldWhitelist.slice(), - }); - if (!fields.length) { - fields = results.whitelist; - } else { - // Never allow password retrieval via this method - fields = fields.filter(value => value !== 'password'); - } - - const users = await db.getObjectsFields(uniqueUids.map(uid => `user:${uid}`), fields); - const result = await plugins.hooks.fire('filter:user.getFields', { - uids: uniqueUids, - users: users, - fields: fields, - }); - result.users.forEach((user, index) => { - if (uniqueUids[index] > 0 && !user.uid) { - user.oldUid = uniqueUids[index]; - } - }); - await modifyUserData(result.users, fields, fieldsToRemove); - return uidsToUsers(uids, uniqueUids, result.users); - }; - - function ensureRequiredFields(fields, fieldsToRemove) { - function addField(field) { - if (!fields.includes(field)) { - fields.push(field); - fieldsToRemove.push(field); - } - } - - if (fields.length && !fields.includes('uid')) { - fields.push('uid'); - } - - if (fields.includes('picture')) { - addField('uploadedpicture'); - } - - if (fields.includes('status')) { - addField('lastonline'); - } - - if (fields.includes('banned') && !fields.includes('banned:expire')) { - addField('banned:expire'); - } - - if (fields.includes('username') && !fields.includes('fullname')) { - addField('fullname'); - } - } - - function uidsToUsers(uids, uniqueUids, usersData) { - const uidToUser = _.zipObject(uniqueUids, usersData); - const users = uids.map((uid) => { - const user = uidToUser[uid] || { ...User.guestData }; - if (!parseInt(user.uid, 10)) { - user.username = (user.hasOwnProperty('oldUid') && parseInt(user.oldUid, 10)) ? '[[global:former_user]]' : '[[global:guest]]'; - user.displayname = user.username; - } - - return user; - }); - return users; - } - - User.getUserField = async function (uid, field) { - const user = await User.getUserFields(uid, [field]); - return user ? user[field] : null; - }; - - User.getUserFields = async function (uid, fields) { - const users = await User.getUsersFields([uid], fields); - return users ? users[0] : null; - }; - - User.getUserData = async function (uid) { - const users = await User.getUsersData([uid]); - return users ? users[0] : null; - }; - - User.getUsersData = async function (uids) { - return await User.getUsersFields(uids, []); - }; - - User.hidePrivateData = async function (users, callerUID) { - let single = false; - if (!Array.isArray(users)) { - users = [users]; - single = true; - } - - const [userSettings, isAdmin, isGlobalModerator] = await Promise.all([ - User.getMultipleUserSettings(users.map(user => user.uid)), - User.isAdministrator(callerUID), - User.isGlobalModerator(callerUID), - ]); - - users = await Promise.all(users.map(async (userData, idx) => { - const _userData = { ...userData }; - - const isSelf = parseInt(callerUID, 10) === parseInt(_userData.uid, 10); - const privilegedOrSelf = isAdmin || isGlobalModerator || isSelf; - - if (!privilegedOrSelf && (!userSettings[idx].showemail || meta.config.hideEmail)) { - _userData.email = ''; - } - if (!privilegedOrSelf && (!userSettings[idx].showfullname || meta.config.hideFullname)) { - _userData.fullname = ''; - } - return _userData; - })); - - return single ? users.pop() : users; - }; - - async function modifyUserData(users, requestedFields, fieldsToRemove) { - let uidToSettings = {}; - if (meta.config.showFullnameAsDisplayName) { - const uids = users.map(user => user.uid); - uidToSettings = _.zipObject(uids, await db.getObjectsFields( - uids.map(uid => `user:${uid}:settings`), - ['showfullname'] - )); - } - - await Promise.all(users.map(async (user) => { - if (!user) { - return; - } - - db.parseIntFields(user, intFields, requestedFields); - - if (user.hasOwnProperty('username')) { - parseDisplayName(user, uidToSettings); - user.username = validator.escape(user.username ? user.username.toString() : ''); - } - - if (user.hasOwnProperty('email')) { - user.email = validator.escape(user.email ? user.email.toString() : ''); - } - - if (!parseInt(user.uid, 10)) { - for (const [key, value] of Object.entries(User.guestData)) { - user[key] = value; - } - user.picture = User.getDefaultAvatar(); - } - - if (user.hasOwnProperty('groupTitle')) { - parseGroupTitle(user); - } - - if (user.picture && user.picture === user.uploadedpicture) { - user.uploadedpicture = user.picture.startsWith('http') ? user.picture : relative_path + user.picture; - user.picture = user.uploadedpicture; - } else if (user.uploadedpicture) { - user.uploadedpicture = user.uploadedpicture.startsWith('http') ? user.uploadedpicture : relative_path + user.uploadedpicture; - } - if (meta.config.defaultAvatar && !user.picture) { - user.picture = User.getDefaultAvatar(); - } - - if (user.hasOwnProperty('status') && user.hasOwnProperty('lastonline')) { - user.status = User.getStatus(user); - } - - for (let i = 0; i < fieldsToRemove.length; i += 1) { - user[fieldsToRemove[i]] = undefined; - } - - // User Icons - if (requestedFields.includes('picture') && user.username && parseInt(user.uid, 10) && !meta.config.defaultAvatar) { - const iconBackgrounds = await User.getIconBackgrounds(user.uid); - let bgColor = await User.getUserField(user.uid, 'icon:bgColor'); - if (!iconBackgrounds.includes(bgColor)) { - bgColor = Array.prototype.reduce.call(user.username, (cur, next) => cur + next.charCodeAt(), 0); - bgColor = iconBackgrounds[bgColor % iconBackgrounds.length]; - } - user['icon:text'] = (user.username[0] || '').toUpperCase(); - user['icon:bgColor'] = bgColor; - } - - if (user.hasOwnProperty('joindate')) { - user.joindateISO = utils.toISOString(user.joindate); - } - - if (user.hasOwnProperty('lastonline')) { - user.lastonlineISO = utils.toISOString(user.lastonline) || user.joindateISO; - } - - if (user.hasOwnProperty('banned') || user.hasOwnProperty('banned:expire')) { - const result = await User.bans.calcExpiredFromUserData(user); - user.banned = result.banned; - const unban = result.banned && result.banExpired; - user.banned_until = unban ? 0 : user['banned:expire']; - user.banned_until_readable = user.banned_until && !unban ? utils.toISOString(user.banned_until) : 'Not Banned'; - if (unban) { - await User.bans.unban(user.uid); - user.banned = false; - } - } - - if (user.hasOwnProperty('mutedUntil')) { - user.muted = user.mutedUntil > Date.now(); - } - })); - - return await plugins.hooks.fire('filter:users.get', users); - } - - function parseDisplayName(user, uidToSettings) { - let showfullname = parseInt(meta.config.showfullname, 10) === 1; - if (uidToSettings[user.uid]) { - if (parseInt(uidToSettings[user.uid].showfullname, 10) === 0) { - showfullname = false; - } else if (parseInt(uidToSettings[user.uid].showfullname, 10) === 1) { - showfullname = true; - } - } - - user.displayname = validator.escape(String( - meta.config.showFullnameAsDisplayName && showfullname && user.fullname ? - user.fullname : - user.username - )); - } - - function parseGroupTitle(user) { - try { - user.groupTitleArray = JSON.parse(user.groupTitle); - } catch (err) { - if (user.groupTitle) { - user.groupTitleArray = [user.groupTitle]; - } else { - user.groupTitle = ''; - user.groupTitleArray = []; - } - } - if (!Array.isArray(user.groupTitleArray)) { - if (user.groupTitleArray) { - user.groupTitleArray = [user.groupTitleArray]; - } else { - user.groupTitleArray = []; - } - } - if (!meta.config.allowMultipleBadges && user.groupTitleArray.length) { - user.groupTitleArray = [user.groupTitleArray[0]]; - } - } - - User.getIconBackgrounds = async (uid = 0) => { - let iconBackgrounds = [ - '#f44336', '#e91e63', '#9c27b0', '#673ab7', '#3f51b5', '#2196f3', - '#009688', '#1b5e20', '#33691e', '#827717', '#e65100', '#ff5722', - '#795548', '#607d8b', - ]; - - ({ iconBackgrounds } = await plugins.hooks.fire('filter:user.iconBackgrounds', { uid, iconBackgrounds })); - return iconBackgrounds; - }; - - User.getDefaultAvatar = function () { - if (!meta.config.defaultAvatar) { - return ''; - } - return meta.config.defaultAvatar.startsWith('http') ? meta.config.defaultAvatar : relative_path + meta.config.defaultAvatar; - }; - - User.setUserField = async function (uid, field, value) { - await User.setUserFields(uid, { [field]: value }); - }; - - User.setUserFields = async function (uid, data) { - await db.setObject(`user:${uid}`, data); - for (const [field, value] of Object.entries(data)) { - plugins.hooks.fire('action:user.set', { uid, field, value, type: 'set' }); - } - }; - - User.incrementUserFieldBy = async function (uid, field, value) { - return await incrDecrUserFieldBy(uid, field, value, 'increment'); - }; - - User.decrementUserFieldBy = async function (uid, field, value) { - return await incrDecrUserFieldBy(uid, field, -value, 'decrement'); - }; - - async function incrDecrUserFieldBy(uid, field, value, type) { - const newValue = await db.incrObjectFieldBy(`user:${uid}`, field, value); - plugins.hooks.fire('action:user.set', { uid: uid, field: field, value: newValue, type: type }); - return newValue; - } + const fieldInclude = [ + 'uid', + 'username', + 'userslug', + 'email', + 'email:confirmed', + 'joindate', + 'accounttype', + 'lastonline', + 'picture', + 'icon:bgColor', + 'fullname', + 'location', + 'birthday', + 'website', + 'aboutme', + 'signature', + 'uploadedpicture', + 'profileviews', + 'reputation', + 'postcount', + 'topiccount', + 'lastposttime', + 'banned', + 'banned:expire', + 'status', + 'flags', + 'followerCount', + 'followingCount', + 'cover:url', + 'cover:position', + 'groupTitle', + 'mutedUntil', + 'mutedReason', + ]; + + User.guestData = { + uid: 0, + username: '[[global:guest]]', + displayname: '[[global:guest]]', + userslug: '', + fullname: '[[global:guest]]', + email: '', + 'icon:text': '?', + 'icon:bgColor': '#aaa', + groupTitle: '', + groupTitleArray: [], + status: 'offline', + reputation: 0, + 'email:confirmed': 0, + }; + + User.getUsersFields = async function (uids, fields) { + if (!Array.isArray(uids) || uids.length === 0) { + return []; + } + + uids = uids.map(uid => (isNaN(uid) ? 0 : Number.parseInt(uid, 10))); + + const fieldsToRemove = []; + fields = fields.slice(); + ensureRequiredFields(fields, fieldsToRemove); + + const uniqueUids = _.uniq(uids).filter(uid => uid > 0); + + const results = await plugins.hooks.fire('filter:user.whitelistFields', { + uids, + whitelist: fieldInclude.slice(), + }); + if (fields.length === 0) { + fields = results.whitelist; + } else { + // Never allow password retrieval via this method + fields = fields.filter(value => value !== 'password'); + } + + const users = await db.getObjectsFields(uniqueUids.map(uid => `user:${uid}`), fields); + const result = await plugins.hooks.fire('filter:user.getFields', { + uids: uniqueUids, + users, + fields, + }); + for (const [index, user] of result.users.entries()) { + if (uniqueUids[index] > 0 && !user.uid) { + user.oldUid = uniqueUids[index]; + } + } + + await modifyUserData(result.users, fields, fieldsToRemove); + return uidsToUsers(uids, uniqueUids, result.users); + }; + + function ensureRequiredFields(fields, fieldsToRemove) { + function addField(field) { + if (!fields.includes(field)) { + fields.push(field); + fieldsToRemove.push(field); + } + } + + if (fields.length > 0 && !fields.includes('uid')) { + fields.push('uid'); + } + + if (fields.includes('picture')) { + addField('uploadedpicture'); + } + + if (fields.includes('status')) { + addField('lastonline'); + } + + if (fields.includes('banned') && !fields.includes('banned:expire')) { + addField('banned:expire'); + } + + if (fields.includes('username') && !fields.includes('fullname')) { + addField('fullname'); + } + } + + function uidsToUsers(uids, uniqueUids, usersData) { + const uidToUser = _.zipObject(uniqueUids, usersData); + const users = uids.map(uid => { + const user = uidToUser[uid] || {...User.guestData}; + if (!Number.parseInt(user.uid, 10)) { + user.username = (user.hasOwnProperty('oldUid') && Number.parseInt(user.oldUid, 10)) ? '[[global:former_user]]' : '[[global:guest]]'; + user.displayname = user.username; + } + + return user; + }); + return users; + } + + User.getUserField = async function (uid, field) { + const user = await User.getUserFields(uid, [field]); + return user ? user[field] : null; + }; + + User.getUserFields = async function (uid, fields) { + const users = await User.getUsersFields([uid], fields); + return users ? users[0] : null; + }; + + User.getUserData = async function (uid) { + const users = await User.getUsersData([uid]); + return users ? users[0] : null; + }; + + User.getUsersData = async function (uids) { + return await User.getUsersFields(uids, []); + }; + + User.hidePrivateData = async function (users, callerUID) { + let single = false; + if (!Array.isArray(users)) { + users = [users]; + single = true; + } + + const [userSettings, isAdmin, isGlobalModerator] = await Promise.all([ + User.getMultipleUserSettings(users.map(user => user.uid)), + User.isAdministrator(callerUID), + User.isGlobalModerator(callerUID), + ]); + + users = await Promise.all(users.map(async (userData, index) => { + const _userData = {...userData}; + + const isSelf = Number.parseInt(callerUID, 10) === Number.parseInt(_userData.uid, 10); + const privilegedOrSelf = isAdmin || isGlobalModerator || isSelf; + + if (!privilegedOrSelf && (!userSettings[index].showemail || meta.config.hideEmail)) { + _userData.email = ''; + } + + if (!privilegedOrSelf && (!userSettings[index].showfullname || meta.config.hideFullname)) { + _userData.fullname = ''; + } + + return _userData; + })); + + return single ? users.pop() : users; + }; + + async function modifyUserData(users, requestedFields, fieldsToRemove) { + let uidToSettings = {}; + if (meta.config.showFullnameAsDisplayName) { + const uids = users.map(user => user.uid); + uidToSettings = _.zipObject(uids, await db.getObjectsFields( + uids.map(uid => `user:${uid}:settings`), + ['showfullname'], + )); + } + + await Promise.all(users.map(async user => { + if (!user) { + return; + } + + db.parseIntFields(user, intFields, requestedFields); + + if (user.hasOwnProperty('username')) { + parseDisplayName(user, uidToSettings); + user.username = validator.escape(user.username ? user.username.toString() : ''); + } + + if (user.hasOwnProperty('email')) { + user.email = validator.escape(user.email ? user.email.toString() : ''); + } + + if (!Number.parseInt(user.uid, 10)) { + for (const [key, value] of Object.entries(User.guestData)) { + user[key] = value; + } + + user.picture = User.getDefaultAvatar(); + } + + if (user.hasOwnProperty('groupTitle')) { + parseGroupTitle(user); + } + + if (user.picture && user.picture === user.uploadedpicture) { + user.uploadedpicture = user.picture.startsWith('http') ? user.picture : relative_path + user.picture; + user.picture = user.uploadedpicture; + } else { + user.uploadedpicture &&= user.uploadedpicture.startsWith('http') ? user.uploadedpicture : relative_path + user.uploadedpicture; + } + + if (meta.config.defaultAvatar && !user.picture) { + user.picture = User.getDefaultAvatar(); + } + + if (user.hasOwnProperty('status') && user.hasOwnProperty('lastonline')) { + user.status = User.getStatus(user); + } + + for (const element of fieldsToRemove) { + user[element] = undefined; + } + + // User Icons + if (requestedFields.includes('picture') && user.username && Number.parseInt(user.uid, 10) && !meta.config.defaultAvatar) { + const iconBackgrounds = await User.getIconBackgrounds(user.uid); + let bgColor = await User.getUserField(user.uid, 'icon:bgColor'); + if (!iconBackgrounds.includes(bgColor)) { + bgColor = Array.prototype.reduce.call(user.username, (current, next) => current + next.charCodeAt(), 0); + bgColor = iconBackgrounds[bgColor % iconBackgrounds.length]; + } + + user['icon:text'] = (user.username[0] || '').toUpperCase(); + user['icon:bgColor'] = bgColor; + } + + if (user.hasOwnProperty('joindate')) { + user.joindateISO = utils.toISOString(user.joindate); + } + + if (user.hasOwnProperty('lastonline')) { + user.lastonlineISO = utils.toISOString(user.lastonline) || user.joindateISO; + } + + if (user.hasOwnProperty('banned') || user.hasOwnProperty('banned:expire')) { + const result = await User.bans.calcExpiredFromUserData(user); + user.banned = result.banned; + const unban = result.banned && result.banExpired; + user.banned_until = unban ? 0 : user['banned:expire']; + user.banned_until_readable = user.banned_until && !unban ? utils.toISOString(user.banned_until) : 'Not Banned'; + if (unban) { + await User.bans.unban(user.uid); + user.banned = false; + } + } + + if (user.hasOwnProperty('mutedUntil')) { + user.muted = user.mutedUntil > Date.now(); + } + })); + + return await plugins.hooks.fire('filter:users.get', users); + } + + function parseDisplayName(user, uidToSettings) { + let showfullname = Number.parseInt(meta.config.showfullname, 10) === 1; + if (uidToSettings[user.uid]) { + if (Number.parseInt(uidToSettings[user.uid].showfullname, 10) === 0) { + showfullname = false; + } else if (Number.parseInt(uidToSettings[user.uid].showfullname, 10) === 1) { + showfullname = true; + } + } + + user.displayname = validator.escape(String( + meta.config.showFullnameAsDisplayName && showfullname && user.fullname + ? user.fullname + : user.username, + )); + } + + function parseGroupTitle(user) { + try { + user.groupTitleArray = JSON.parse(user.groupTitle); + } catch { + if (user.groupTitle) { + user.groupTitleArray = [user.groupTitle]; + } else { + user.groupTitle = ''; + user.groupTitleArray = []; + } + } + + if (!Array.isArray(user.groupTitleArray)) { + user.groupTitleArray = user.groupTitleArray ? [user.groupTitleArray] : []; + } + + if (!meta.config.allowMultipleBadges && user.groupTitleArray.length > 0) { + user.groupTitleArray = [user.groupTitleArray[0]]; + } + } + + User.getIconBackgrounds = async (uid = 0) => { + let iconBackgrounds = [ + '#f44336', + '#e91e63', + '#9c27b0', + '#673ab7', + '#3f51b5', + '#2196f3', + '#009688', + '#1b5e20', + '#33691e', + '#827717', + '#e65100', + '#ff5722', + '#795548', + '#607d8b', + ]; + + ({iconBackgrounds} = await plugins.hooks.fire('filter:user.iconBackgrounds', {uid, iconBackgrounds})); + return iconBackgrounds; + }; + + User.getDefaultAvatar = function () { + if (!meta.config.defaultAvatar) { + return ''; + } + + return meta.config.defaultAvatar.startsWith('http') ? meta.config.defaultAvatar : relative_path + meta.config.defaultAvatar; + }; + + User.setUserField = async function (uid, field, value) { + await User.setUserFields(uid, {[field]: value}); + }; + + User.setUserFields = async function (uid, data) { + await db.setObject(`user:${uid}`, data); + for (const [field, value] of Object.entries(data)) { + plugins.hooks.fire('action:user.set', { + uid, field, value, type: 'set', + }); + } + }; + + User.incrementUserFieldBy = async function (uid, field, value) { + return await incrDecrUserFieldBy(uid, field, value, 'increment'); + }; + + User.decrementUserFieldBy = async function (uid, field, value) { + return await incrDecrUserFieldBy(uid, field, -value, 'decrement'); + }; + + async function incrDecrUserFieldBy(uid, field, value, type) { + const newValue = await db.incrObjectFieldBy(`user:${uid}`, field, value); + plugins.hooks.fire('action:user.set', { + uid, field, value: newValue, type, + }); + return newValue; + } }; diff --git a/src/user/delete.js b/src/user/delete.js index 12c2546..c689a41 100644 --- a/src/user/delete.js +++ b/src/user/delete.js @@ -1,10 +1,10 @@ 'use strict'; +const path = require('node:path'); +const util = require('node:util'); const async = require('async'); const _ = require('lodash'); -const path = require('path'); const nconf = require('nconf'); -const util = require('util'); const rimrafAsync = util.promisify(require('rimraf')); const db = require('../database'); @@ -17,201 +17,209 @@ const plugins = require('../plugins'); const batch = require('../batch'); module.exports = function (User) { - const deletesInProgress = {}; - - User.delete = async (callerUid, uid) => { - await User.deleteContent(callerUid, uid); - return await User.deleteAccount(uid); - }; - - User.deleteContent = async function (callerUid, uid) { - if (parseInt(uid, 10) <= 0) { - throw new Error('[[error:invalid-uid]]'); - } - if (deletesInProgress[uid]) { - throw new Error('[[error:already-deleting]]'); - } - deletesInProgress[uid] = 'user.delete'; - await deletePosts(callerUid, uid); - await deleteTopics(callerUid, uid); - await deleteUploads(callerUid, uid); - await deleteQueued(uid); - delete deletesInProgress[uid]; - }; - - async function deletePosts(callerUid, uid) { - await batch.processSortedSet(`uid:${uid}:posts`, async (pids) => { - await posts.purge(pids, callerUid); - }, { alwaysStartAt: 0, batch: 500 }); - } - - async function deleteTopics(callerUid, uid) { - await batch.processSortedSet(`uid:${uid}:topics`, async (ids) => { - await async.eachSeries(ids, async (tid) => { - await topics.purge(tid, callerUid); - }); - }, { alwaysStartAt: 0 }); - } - - async function deleteUploads(callerUid, uid) { - const uploads = await db.getSortedSetMembers(`uid:${uid}:uploads`); - await User.deleteUpload(callerUid, uid, uploads); - } - - async function deleteQueued(uid) { - let deleteIds = []; - await batch.processSortedSet('post:queue', async (ids) => { - const data = await db.getObjects(ids.map(id => `post:queue:${id}`)); - const userQueuedIds = data.filter(d => parseInt(d.uid, 10) === parseInt(uid, 10)).map(d => d.id); - deleteIds = deleteIds.concat(userQueuedIds); - }, { batch: 500 }); - await async.eachSeries(deleteIds, posts.removeFromQueue); - } - - async function removeFromSortedSets(uid) { - await db.sortedSetsRemove([ - 'users:joindate', - 'users:postcount', - 'users:reputation', - 'users:banned', - 'users:banned:expire', - 'users:flags', - 'users:online', - 'digest:day:uids', - 'digest:week:uids', - 'digest:biweek:uids', - 'digest:month:uids', - ], uid); - } - - User.deleteAccount = async function (uid) { - if (deletesInProgress[uid] === 'user.deleteAccount') { - throw new Error('[[error:already-deleting]]'); - } - deletesInProgress[uid] = 'user.deleteAccount'; - - await removeFromSortedSets(uid); - const userData = await db.getObject(`user:${uid}`); - - if (!userData || !userData.username) { - delete deletesInProgress[uid]; - throw new Error('[[error:no-user]]'); - } - - await plugins.hooks.fire('static:user.delete', { uid: uid, userData: userData }); - await deleteVotes(uid); - await deleteChats(uid); - await User.auth.revokeAllSessions(uid); - - const keys = [ - `uid:${uid}:notifications:read`, - `uid:${uid}:notifications:unread`, - `uid:${uid}:bookmarks`, - `uid:${uid}:tids_read`, - `uid:${uid}:tids_unread`, - `uid:${uid}:followed_tids`, - `uid:${uid}:ignored_tids`, - `uid:${uid}:blocked_uids`, - `user:${uid}:settings`, - `user:${uid}:usernames`, - `user:${uid}:emails`, - `uid:${uid}:topics`, `uid:${uid}:posts`, - `uid:${uid}:chats`, `uid:${uid}:chats:unread`, - `uid:${uid}:chat:rooms`, `uid:${uid}:chat:rooms:unread`, - `uid:${uid}:upvote`, `uid:${uid}:downvote`, - `uid:${uid}:flag:pids`, - `uid:${uid}:sessions`, `uid:${uid}:sessionUUID:sessionId`, - `invitation:uid:${uid}`, - ]; - - const bulkRemove = [ - ['username:uid', userData.username], - ['username:sorted', `${userData.username.toLowerCase()}:${uid}`], - ['userslug:uid', userData.userslug], - ['fullname:uid', userData.fullname], - ]; - if (userData.email) { - bulkRemove.push(['email:uid', userData.email.toLowerCase()]); - bulkRemove.push(['email:sorted', `${userData.email.toLowerCase()}:${uid}`]); - } - - if (userData.fullname) { - bulkRemove.push(['fullname:sorted', `${userData.fullname.toLowerCase()}:${uid}`]); - } - - await Promise.all([ - db.sortedSetRemoveBulk(bulkRemove), - db.decrObjectField('global', 'userCount'), - db.deleteAll(keys), - db.setRemove('invitation:uids', uid), - deleteUserIps(uid), - deleteUserFromFollowers(uid), - deleteImages(uid), - groups.leaveAllGroups(uid), - flags.resolveFlag('user', uid, uid), - User.reset.cleanByUid(uid), - ]); - await db.deleteAll([`followers:${uid}`, `following:${uid}`, `user:${uid}`]); - delete deletesInProgress[uid]; - return userData; - }; - - async function deleteVotes(uid) { - const [upvotedPids, downvotedPids] = await Promise.all([ - db.getSortedSetRange(`uid:${uid}:upvote`, 0, -1), - db.getSortedSetRange(`uid:${uid}:downvote`, 0, -1), - ]); - const pids = _.uniq(upvotedPids.concat(downvotedPids).filter(Boolean)); - await async.eachSeries(pids, async (pid) => { - await posts.unvote(pid, uid); - }); - } - - async function deleteChats(uid) { - const roomIds = await db.getSortedSetRange(`uid:${uid}:chat:rooms`, 0, -1); - const userKeys = roomIds.map(roomId => `uid:${uid}:chat:room:${roomId}:mids`); - - await Promise.all([ - messaging.leaveRooms(uid, roomIds), - db.deleteAll(userKeys), - ]); - } - - async function deleteUserIps(uid) { - const ips = await db.getSortedSetRange(`uid:${uid}:ip`, 0, -1); - await db.sortedSetsRemove(ips.map(ip => `ip:${ip}:uid`), uid); - await db.delete(`uid:${uid}:ip`); - } - - async function deleteUserFromFollowers(uid) { - const [followers, following] = await Promise.all([ - db.getSortedSetRange(`followers:${uid}`, 0, -1), - db.getSortedSetRange(`following:${uid}`, 0, -1), - ]); - - async function updateCount(uids, name, fieldName) { - await async.each(uids, async (uid) => { - let count = await db.sortedSetCard(name + uid); - count = parseInt(count, 10) || 0; - await db.setObjectField(`user:${uid}`, fieldName, count); - }); - } - - const followingSets = followers.map(uid => `following:${uid}`); - const followerSets = following.map(uid => `followers:${uid}`); - - await Promise.all([ - db.sortedSetsRemove(followerSets.concat(followingSets), uid), - updateCount(following, 'followers:', 'followerCount'), - updateCount(followers, 'following:', 'followingCount'), - ]); - } - - async function deleteImages(uid) { - const folder = path.join(nconf.get('upload_path'), 'profile'); - await Promise.all([ - rimrafAsync(path.join(folder, `${uid}-profilecover*`)), - rimrafAsync(path.join(folder, `${uid}-profileavatar*`)), - ]); - } + const deletesInProgress = {}; + + User.delete = async (callerUid, uid) => { + await User.deleteContent(callerUid, uid); + return await User.deleteAccount(uid); + }; + + User.deleteContent = async function (callerUid, uid) { + if (Number.parseInt(uid, 10) <= 0) { + throw new Error('[[error:invalid-uid]]'); + } + + if (deletesInProgress[uid]) { + throw new Error('[[error:already-deleting]]'); + } + + deletesInProgress[uid] = 'user.delete'; + await deletePosts(callerUid, uid); + await deleteTopics(callerUid, uid); + await deleteUploads(callerUid, uid); + await deleteQueued(uid); + delete deletesInProgress[uid]; + }; + + async function deletePosts(callerUid, uid) { + await batch.processSortedSet(`uid:${uid}:posts`, async pids => { + await posts.purge(pids, callerUid); + }, {alwaysStartAt: 0, batch: 500}); + } + + async function deleteTopics(callerUid, uid) { + await batch.processSortedSet(`uid:${uid}:topics`, async ids => { + await async.eachSeries(ids, async tid => { + await topics.purge(tid, callerUid); + }); + }, {alwaysStartAt: 0}); + } + + async function deleteUploads(callerUid, uid) { + const uploads = await db.getSortedSetMembers(`uid:${uid}:uploads`); + await User.deleteUpload(callerUid, uid, uploads); + } + + async function deleteQueued(uid) { + let deleteIds = []; + await batch.processSortedSet('post:queue', async ids => { + const data = await db.getObjects(ids.map(id => `post:queue:${id}`)); + const userQueuedIds = data.filter(d => Number.parseInt(d.uid, 10) === Number.parseInt(uid, 10)).map(d => d.id); + deleteIds = deleteIds.concat(userQueuedIds); + }, {batch: 500}); + await async.eachSeries(deleteIds, posts.removeFromQueue); + } + + async function removeFromSortedSets(uid) { + await db.sortedSetsRemove([ + 'users:joindate', + 'users:postcount', + 'users:reputation', + 'users:banned', + 'users:banned:expire', + 'users:flags', + 'users:online', + 'digest:day:uids', + 'digest:week:uids', + 'digest:biweek:uids', + 'digest:month:uids', + ], uid); + } + + User.deleteAccount = async function (uid) { + if (deletesInProgress[uid] === 'user.deleteAccount') { + throw new Error('[[error:already-deleting]]'); + } + + deletesInProgress[uid] = 'user.deleteAccount'; + + await removeFromSortedSets(uid); + const userData = await db.getObject(`user:${uid}`); + + if (!userData || !userData.username) { + delete deletesInProgress[uid]; + throw new Error('[[error:no-user]]'); + } + + await plugins.hooks.fire('static:user.delete', {uid, userData}); + await deleteVotes(uid); + await deleteChats(uid); + await User.auth.revokeAllSessions(uid); + + const keys = [ + `uid:${uid}:notifications:read`, + `uid:${uid}:notifications:unread`, + `uid:${uid}:bookmarks`, + `uid:${uid}:tids_read`, + `uid:${uid}:tids_unread`, + `uid:${uid}:followed_tids`, + `uid:${uid}:ignored_tids`, + `uid:${uid}:blocked_uids`, + `user:${uid}:settings`, + `user:${uid}:usernames`, + `user:${uid}:emails`, + `uid:${uid}:topics`, + `uid:${uid}:posts`, + `uid:${uid}:chats`, + `uid:${uid}:chats:unread`, + `uid:${uid}:chat:rooms`, + `uid:${uid}:chat:rooms:unread`, + `uid:${uid}:upvote`, + `uid:${uid}:downvote`, + `uid:${uid}:flag:pids`, + `uid:${uid}:sessions`, + `uid:${uid}:sessionUUID:sessionId`, + `invitation:uid:${uid}`, + ]; + + const bulkRemove = [ + ['username:uid', userData.username], + ['username:sorted', `${userData.username.toLowerCase()}:${uid}`], + ['userslug:uid', userData.userslug], + ['fullname:uid', userData.fullname], + ]; + if (userData.email) { + bulkRemove.push(['email:uid', userData.email.toLowerCase()]); + bulkRemove.push(['email:sorted', `${userData.email.toLowerCase()}:${uid}`]); + } + + if (userData.fullname) { + bulkRemove.push(['fullname:sorted', `${userData.fullname.toLowerCase()}:${uid}`]); + } + + await Promise.all([ + db.sortedSetRemoveBulk(bulkRemove), + db.decrObjectField('global', 'userCount'), + db.deleteAll(keys), + db.setRemove('invitation:uids', uid), + deleteUserIps(uid), + deleteUserFromFollowers(uid), + deleteImages(uid), + groups.leaveAllGroups(uid), + flags.resolveFlag('user', uid, uid), + User.reset.cleanByUid(uid), + ]); + await db.deleteAll([`followers:${uid}`, `following:${uid}`, `user:${uid}`]); + delete deletesInProgress[uid]; + return userData; + }; + + async function deleteVotes(uid) { + const [upvotedPids, downvotedPids] = await Promise.all([ + db.getSortedSetRange(`uid:${uid}:upvote`, 0, -1), + db.getSortedSetRange(`uid:${uid}:downvote`, 0, -1), + ]); + const pids = _.uniq(upvotedPids.concat(downvotedPids).filter(Boolean)); + await async.eachSeries(pids, async pid => { + await posts.unvote(pid, uid); + }); + } + + async function deleteChats(uid) { + const roomIds = await db.getSortedSetRange(`uid:${uid}:chat:rooms`, 0, -1); + const userKeys = roomIds.map(roomId => `uid:${uid}:chat:room:${roomId}:mids`); + + await Promise.all([ + messaging.leaveRooms(uid, roomIds), + db.deleteAll(userKeys), + ]); + } + + async function deleteUserIps(uid) { + const ips = await db.getSortedSetRange(`uid:${uid}:ip`, 0, -1); + await db.sortedSetsRemove(ips.map(ip => `ip:${ip}:uid`), uid); + await db.delete(`uid:${uid}:ip`); + } + + async function deleteUserFromFollowers(uid) { + const [followers, following] = await Promise.all([ + db.getSortedSetRange(`followers:${uid}`, 0, -1), + db.getSortedSetRange(`following:${uid}`, 0, -1), + ]); + + async function updateCount(uids, name, fieldName) { + await async.each(uids, async uid => { + let count = await db.sortedSetCard(name + uid); + count = Number.parseInt(count, 10) || 0; + await db.setObjectField(`user:${uid}`, fieldName, count); + }); + } + + const followingSets = followers.map(uid => `following:${uid}`); + const followerSets = following.map(uid => `followers:${uid}`); + + await Promise.all([ + db.sortedSetsRemove(followerSets.concat(followingSets), uid), + updateCount(following, 'followers:', 'followerCount'), + updateCount(followers, 'following:', 'followingCount'), + ]); + } + + async function deleteImages(uid) { + const folder = path.join(nconf.get('upload_path'), 'profile'); + await Promise.all([ + rimrafAsync(path.join(folder, `${uid}-profilecover*`)), + rimrafAsync(path.join(folder, `${uid}-profileavatar*`)), + ]); + } }; diff --git a/src/user/digest.js b/src/user/digest.js index f81237b..4312f7d 100644 --- a/src/user/digest.js +++ b/src/user/digest.js @@ -2,211 +2,217 @@ const winston = require('winston'); const nconf = require('nconf'); - const db = require('../database'); const batch = require('../batch'); const meta = require('../meta'); -const user = require('./index'); const topics = require('../topics'); const plugins = require('../plugins'); const emailer = require('../emailer'); const utils = require('../utils'); +const user = require('./index'); const Digest = module.exports; const baseUrl = nconf.get('base_url'); Digest.execute = async function (payload) { - const digestsDisabled = meta.config.disableEmailSubscriptions === 1; - if (digestsDisabled) { - winston.info(`[user/jobs] Did not send digests (${payload.interval}) because subscription system is disabled.`); - return; - } - let { subscribers } = payload; - if (!subscribers) { - subscribers = await Digest.getSubscribers(payload.interval); - } - if (!subscribers.length) { - return; - } - try { - winston.info(`[user/jobs] Digest (${payload.interval}) scheduling completed (${subscribers.length} subscribers). Sending emails; this may take some time...`); - await Digest.send({ - interval: payload.interval, - subscribers: subscribers, - }); - winston.info(`[user/jobs] Digest (${payload.interval}) complete.`); - } catch (err) { - winston.error(`[user/jobs] Could not send digests (${payload.interval})\n${err.stack}`); - throw err; - } + const digestsDisabled = meta.config.disableEmailSubscriptions === 1; + if (digestsDisabled) { + winston.info(`[user/jobs] Did not send digests (${payload.interval}) because subscription system is disabled.`); + return; + } + + let {subscribers} = payload; + subscribers ||= await Digest.getSubscribers(payload.interval); + + if (subscribers.length === 0) { + return; + } + + try { + winston.info(`[user/jobs] Digest (${payload.interval}) scheduling completed (${subscribers.length} subscribers). Sending emails; this may take some time...`); + await Digest.send({ + interval: payload.interval, + subscribers, + }); + winston.info(`[user/jobs] Digest (${payload.interval}) complete.`); + } catch (error) { + winston.error(`[user/jobs] Could not send digests (${payload.interval})\n${error.stack}`); + throw error; + } }; -Digest.getUsersInterval = async (uids) => { - // Checks whether user specifies digest setting, or false for system default setting - let single = false; - if (!Array.isArray(uids) && !isNaN(parseInt(uids, 10))) { - uids = [uids]; - single = true; - } - - const settings = await db.getObjects(uids.map(uid => `user:${uid}:settings`)); - const interval = uids.map((uid, index) => (settings[index] && settings[index].dailyDigestFreq) || false); - return single ? interval[0] : interval; +Digest.getUsersInterval = async uids => { + // Checks whether user specifies digest setting, or false for system default setting + let single = false; + if (!Array.isArray(uids) && !isNaN(Number.parseInt(uids, 10))) { + uids = [uids]; + single = true; + } + + const settings = await db.getObjects(uids.map(uid => `user:${uid}:settings`)); + const interval = uids.map((uid, index) => (settings[index] && settings[index].dailyDigestFreq) || false); + return single ? interval[0] : interval; }; Digest.getSubscribers = async function (interval) { - let subscribers = []; - - await batch.processSortedSet('users:joindate', async (uids) => { - const settings = await user.getMultipleUserSettings(uids); - let subUids = []; - settings.forEach((hash) => { - if (hash.dailyDigestFreq === interval) { - subUids.push(hash.uid); - } - }); - subUids = await user.bans.filterBanned(subUids); - subscribers = subscribers.concat(subUids); - }, { - interval: 1000, - batch: 500, - }); - - const results = await plugins.hooks.fire('filter:digest.subscribers', { - interval: interval, - subscribers: subscribers, - }); - return results.subscribers; + let subscribers = []; + + await batch.processSortedSet('users:joindate', async uids => { + const settings = await user.getMultipleUserSettings(uids); + let subUids = []; + for (const hash of settings) { + if (hash.dailyDigestFreq === interval) { + subUids.push(hash.uid); + } + } + + subUids = await user.bans.filterBanned(subUids); + subscribers = subscribers.concat(subUids); + }, { + interval: 1000, + batch: 500, + }); + + const results = await plugins.hooks.fire('filter:digest.subscribers', { + interval, + subscribers, + }); + return results.subscribers; }; Digest.send = async function (data) { - let emailsSent = 0; - if (!data || !data.subscribers || !data.subscribers.length) { - return emailsSent; - } - let errorLogged = false; - await batch.processArray(data.subscribers, async (uids) => { - let userData = await user.getUsersFields(uids, ['uid', 'email', 'email:confirmed', 'username', 'userslug', 'lastonline']); - userData = userData.filter(u => u && u.email && (meta.config.includeUnverifiedEmails || u['email:confirmed'])); - if (!userData.length) { - return; - } - await Promise.all(userData.map(async (userObj) => { - const [notifications, topics] = await Promise.all([ - user.notifications.getUnreadInterval(userObj.uid, data.interval), - getTermTopics(data.interval, userObj.uid), - ]); - const unreadNotifs = notifications.filter(Boolean); - // If there are no notifications and no new topics, don't bother sending a digest - if (!unreadNotifs.length && !topics.top.length && !topics.popular.length && !topics.recent.length) { - return; - } - - unreadNotifs.forEach((n) => { - if (n.image && !n.image.startsWith('http')) { - n.image = baseUrl + n.image; - } - if (n.path) { - n.notification_url = n.path.startsWith('http') ? n.path : baseUrl + n.path; - } - }); - - emailsSent += 1; - const now = new Date(); - await emailer.send('digest', userObj.uid, { - subject: `[[email:digest.subject, ${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}]]`, - username: userObj.username, - userslug: userObj.userslug, - notifications: unreadNotifs, - recent: topics.recent, - topTopics: topics.top, - popularTopics: topics.popular, - interval: data.interval, - showUnsubscribe: true, - }).catch((err) => { - if (!errorLogged) { - winston.error(`[user/jobs] Could not send digest email\n[emailer.send] ${err.stack}`); - errorLogged = true; - } - }); - })); - if (data.interval !== 'alltime') { - const now = Date.now(); - await db.sortedSetAdd('digest:delivery', userData.map(() => now), userData.map(u => u.uid)); - } - }, { - interval: 1000, - batch: 100, - }); - winston.info(`[user/jobs] Digest (${data.interval}) sending completed. ${emailsSent} emails sent.`); + let emailsSent = 0; + if (!data || !data.subscribers || data.subscribers.length === 0) { + return emailsSent; + } + + let errorLogged = false; + await batch.processArray(data.subscribers, async uids => { + let userData = await user.getUsersFields(uids, ['uid', 'email', 'email:confirmed', 'username', 'userslug', 'lastonline']); + userData = userData.filter(u => u && u.email && (meta.config.includeUnverifiedEmails || u['email:confirmed'])); + if (userData.length === 0) { + return; + } + + await Promise.all(userData.map(async userObject => { + const [notifications, topics] = await Promise.all([ + user.notifications.getUnreadInterval(userObject.uid, data.interval), + getTermTopics(data.interval, userObject.uid), + ]); + const unreadNotifs = notifications.filter(Boolean); + // If there are no notifications and no new topics, don't bother sending a digest + if (unreadNotifs.length === 0 && topics.top.length === 0 && topics.popular.length === 0 && topics.recent.length === 0) { + return; + } + + for (const n of unreadNotifs) { + if (n.image && !n.image.startsWith('http')) { + n.image = baseUrl + n.image; + } + + if (n.path) { + n.notification_url = n.path.startsWith('http') ? n.path : baseUrl + n.path; + } + } + + emailsSent += 1; + const now = new Date(); + await emailer.send('digest', userObject.uid, { + subject: `[[email:digest.subject, ${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}]]`, + username: userObject.username, + userslug: userObject.userslug, + notifications: unreadNotifs, + recent: topics.recent, + topTopics: topics.top, + popularTopics: topics.popular, + interval: data.interval, + showUnsubscribe: true, + }).catch(error => { + if (!errorLogged) { + winston.error(`[user/jobs] Could not send digest email\n[emailer.send] ${error.stack}`); + errorLogged = true; + } + }); + })); + if (data.interval !== 'alltime') { + const now = Date.now(); + await db.sortedSetAdd('digest:delivery', userData.map(() => now), userData.map(u => u.uid)); + } + }, { + interval: 1000, + batch: 100, + }); + winston.info(`[user/jobs] Digest (${data.interval}) sending completed. ${emailsSent} emails sent.`); }; Digest.getDeliveryTimes = async (start, stop) => { - const count = await db.sortedSetCard('users:joindate'); - const uids = await user.getUidsFromSet('users:joindate', start, stop); - if (!uids.length) { - return []; - } - - const [scores, settings] = await Promise.all([ - // Grab the last time a digest was successfully delivered to these uids - db.sortedSetScores('digest:delivery', uids), - // Get users' digest settings - Digest.getUsersInterval(uids), - ]); - - // Populate user data - let userData = await user.getUsersFields(uids, ['username', 'picture']); - userData = userData.map((user, idx) => { - user.lastDelivery = scores[idx] ? new Date(scores[idx]).toISOString() : '[[admin/manage/digest:null]]'; - user.setting = settings[idx]; - return user; - }); - - return { - users: userData, - count: count, - }; + const count = await db.sortedSetCard('users:joindate'); + const uids = await user.getUidsFromSet('users:joindate', start, stop); + if (uids.length === 0) { + return []; + } + + const [scores, settings] = await Promise.all([ + // Grab the last time a digest was successfully delivered to these uids + db.sortedSetScores('digest:delivery', uids), + // Get users' digest settings + Digest.getUsersInterval(uids), + ]); + + // Populate user data + let userData = await user.getUsersFields(uids, ['username', 'picture']); + userData = userData.map((user, index) => { + user.lastDelivery = scores[index] ? new Date(scores[index]).toISOString() : '[[admin/manage/digest:null]]'; + user.setting = settings[index]; + return user; + }); + + return { + users: userData, + count, + }; }; async function getTermTopics(term, uid) { - const data = await topics.getSortedTopics({ - uid: uid, - start: 0, - stop: 199, - term: term, - sort: 'votes', - teaserPost: 'first', - }); - data.topics = data.topics.filter(topic => topic && !topic.deleted); - - const top = data.topics.filter(t => t.votes > 0).slice(0, 10); - const topTids = top.map(t => t.tid); - - const popular = data.topics - .filter(t => t.postcount > 1 && !topTids.includes(t.tid)) - .sort((a, b) => b.postcount - a.postcount) - .slice(0, 10); - const popularTids = popular.map(t => t.tid); - - const recent = data.topics - .filter(t => !topTids.includes(t.tid) && !popularTids.includes(t.tid)) - .sort((a, b) => b.lastposttime - a.lastposttime) - .slice(0, 10); - - [...top, ...popular, ...recent].forEach((topicObj) => { - if (topicObj) { - if (topicObj.teaser && topicObj.teaser.content && topicObj.teaser.content.length > 255) { - topicObj.teaser.content = `${topicObj.teaser.content.slice(0, 255)}...`; - } - // Fix relative paths in topic data - const user = topicObj.hasOwnProperty('teaser') && topicObj.teaser && topicObj.teaser.user ? - topicObj.teaser.user : topicObj.user; - if (user && user.picture && utils.isRelativeUrl(user.picture)) { - user.picture = baseUrl + user.picture; - } - } - }); - return { top, popular, recent }; + const data = await topics.getSortedTopics({ + uid, + start: 0, + stop: 199, + term, + sort: 'votes', + teaserPost: 'first', + }); + data.topics = data.topics.filter(topic => topic && !topic.deleted); + + const top = data.topics.filter(t => t.votes > 0).slice(0, 10); + const topTids = new Set(top.map(t => t.tid)); + + const popular = data.topics + .filter(t => t.postcount > 1 && !topTids.has(t.tid)) + .sort((a, b) => b.postcount - a.postcount) + .slice(0, 10); + const popularTids = new Set(popular.map(t => t.tid)); + + const recent = data.topics + .filter(t => !topTids.has(t.tid) && !popularTids.has(t.tid)) + .sort((a, b) => b.lastposttime - a.lastposttime) + .slice(0, 10); + + for (const topicObject of [...top, ...popular, ...recent]) { + if (topicObject) { + if (topicObject.teaser && topicObject.teaser.content && topicObject.teaser.content.length > 255) { + topicObject.teaser.content = `${topicObject.teaser.content.slice(0, 255)}...`; + } + + // Fix relative paths in topic data + const user = topicObject.hasOwnProperty('teaser') && topicObject.teaser && topicObject.teaser.user + ? topicObject.teaser.user : topicObject.user; + if (user && user.picture && utils.isRelativeUrl(user.picture)) { + user.picture = baseUrl + user.picture; + } + } + } + + return {top, popular, recent}; } diff --git a/src/user/email.js b/src/user/email.js index 6cc363a..d9c5769 100644 --- a/src/user/email.js +++ b/src/user/email.js @@ -3,8 +3,6 @@ const nconf = require('nconf'); const winston = require('winston'); - -const user = require('./index'); const utils = require('../utils'); const plugins = require('../plugins'); const db = require('../database'); @@ -12,201 +10,201 @@ const meta = require('../meta'); const emailer = require('../emailer'); const groups = require('../groups'); const events = require('../events'); +const user = require('./index'); const UserEmail = module.exports; UserEmail.exists = async function (email) { - const uid = await user.getUidByEmail(email.toLowerCase()); - return !!uid; + const uid = await user.getUidByEmail(email.toLowerCase()); + return Boolean(uid); }; UserEmail.available = async function (email) { - const exists = await db.isSortedSetMember('email:uid', email.toLowerCase()); - return !exists; + const exists = await db.isSortedSetMember('email:uid', email.toLowerCase()); + return !exists; }; UserEmail.remove = async function (uid, sessionId) { - const email = await user.getUserField(uid, 'email'); - if (!email) { - return; - } - - await Promise.all([ - user.setUserFields(uid, { - email: '', - 'email:confirmed': 0, - }), - db.sortedSetRemove('email:uid', email.toLowerCase()), - db.sortedSetRemove('email:sorted', `${email.toLowerCase()}:${uid}`), - user.email.expireValidation(uid), - user.auth.revokeAllSessions(uid, sessionId), - events.log({ type: 'email-change', email, newEmail: '' }), - ]); + const email = await user.getUserField(uid, 'email'); + if (!email) { + return; + } + + await Promise.all([ + user.setUserFields(uid, { + email: '', + 'email:confirmed': 0, + }), + db.sortedSetRemove('email:uid', email.toLowerCase()), + db.sortedSetRemove('email:sorted', `${email.toLowerCase()}:${uid}`), + user.email.expireValidation(uid), + user.auth.revokeAllSessions(uid, sessionId), + events.log({type: 'email-change', email, newEmail: ''}), + ]); }; UserEmail.isValidationPending = async (uid, email) => { - const code = await db.get(`confirm:byUid:${uid}`); + const code = await db.get(`confirm:byUid:${uid}`); - if (email) { - const confirmObj = await db.getObject(`confirm:${code}`); - return !!(confirmObj && email === confirmObj.email); - } + if (email) { + const confirmObject = await db.getObject(`confirm:${code}`); + return Boolean(confirmObject && email === confirmObject.email); + } - return !!code; + return Boolean(code); }; -UserEmail.getValidationExpiry = async (uid) => { - const pending = await UserEmail.isValidationPending(uid); - return pending ? db.pttl(`confirm:byUid:${uid}`) : null; +UserEmail.getValidationExpiry = async uid => { + const pending = await UserEmail.isValidationPending(uid); + return pending ? db.pttl(`confirm:byUid:${uid}`) : null; }; -UserEmail.expireValidation = async (uid) => { - const code = await db.get(`confirm:byUid:${uid}`); - await db.deleteAll([ - `confirm:byUid:${uid}`, - `confirm:${code}`, - ]); +UserEmail.expireValidation = async uid => { + const code = await db.get(`confirm:byUid:${uid}`); + await db.deleteAll([ + `confirm:byUid:${uid}`, + `confirm:${code}`, + ]); }; UserEmail.canSendValidation = async (uid, email) => { - const pending = UserEmail.isValidationPending(uid, email); - if (!pending) { - return true; - } + const pending = UserEmail.isValidationPending(uid, email); + if (!pending) { + return true; + } - const ttl = await UserEmail.getValidationExpiry(uid); - const max = meta.config.emailConfirmExpiry * 60 * 60 * 1000; - const interval = meta.config.emailConfirmInterval * 60 * 1000; + const ttl = await UserEmail.getValidationExpiry(uid); + const max = meta.config.emailConfirmExpiry * 60 * 60 * 1000; + const interval = meta.config.emailConfirmInterval * 60 * 1000; - return ttl + interval < max; + return ttl + interval < max; }; UserEmail.sendValidationEmail = async function (uid, options) { - /* + /* * Options: * - email, overrides email retrieval * - force, sends email even if it is too soon to send another * - template, changes the template used for email sending */ - if (meta.config.sendValidationEmail !== 1) { - winston.verbose(`[user/email] Validation email for uid ${uid} not sent due to config settings`); - return; - } - - options = options || {}; - - // Fallback behaviour (email passed in as second argument) - if (typeof options === 'string') { - options = { - email: options, - }; - } - - const confirm_code = utils.generateUUID(); - const confirm_link = `${nconf.get('url')}/confirm/${confirm_code}`; - - const { emailConfirmInterval, emailConfirmExpiry } = meta.config; - - // If no email passed in (default), retrieve email from uid - if (!options.email || !options.email.length) { - options.email = await user.getUserField(uid, 'email'); - } - if (!options.email) { - return; - } - - if (!options.force && !await UserEmail.canSendValidation(uid, options.email)) { - throw new Error(`[[error:confirm-email-already-sent, ${emailConfirmInterval}]]`); - } - - const username = await user.getUserField(uid, 'username'); - const data = await plugins.hooks.fire('filter:user.verify', { - uid, - username, - confirm_link, - confirm_code: await plugins.hooks.fire('filter:user.verify.code', confirm_code), - email: options.email, - - subject: options.subject || '[[email:email.verify-your-email.subject]]', - template: options.template || 'verify-email', - }); - - await UserEmail.expireValidation(uid); - await db.set(`confirm:byUid:${uid}`, confirm_code); - await db.pexpire(`confirm:byUid:${uid}`, emailConfirmExpiry * 24 * 60 * 60 * 1000); - - await db.setObject(`confirm:${confirm_code}`, { - email: options.email.toLowerCase(), - uid: uid, - }); - await db.pexpire(`confirm:${confirm_code}`, emailConfirmExpiry * 24 * 60 * 60 * 1000); - - winston.verbose(`[user/email] Validation email for uid ${uid} sent to ${options.email}`); - events.log({ - type: 'email-confirmation-sent', - uid, - confirm_code, - ...options, - }); - - if (plugins.hooks.hasListeners('action:user.verify')) { - plugins.hooks.fire('action:user.verify', { uid: uid, data: data }); - } else { - await emailer.send(data.template, uid, data); - } - return confirm_code; + if (meta.config.sendValidationEmail !== 1) { + winston.verbose(`[user/email] Validation email for uid ${uid} not sent due to config settings`); + return; + } + + options ||= {}; + + // Fallback behaviour (email passed in as second argument) + if (typeof options === 'string') { + options = { + email: options, + }; + } + + const confirm_code = utils.generateUUID(); + const confirm_link = `${nconf.get('url')}/confirm/${confirm_code}`; + + const {emailConfirmInterval, emailConfirmExpiry} = meta.config; + + // If no email passed in (default), retrieve email from uid + if (!options.email || options.email.length === 0) { + options.email = await user.getUserField(uid, 'email'); + } + + if (!options.email) { + return; + } + + if (!options.force && !await UserEmail.canSendValidation(uid, options.email)) { + throw new Error(`[[error:confirm-email-already-sent, ${emailConfirmInterval}]]`); + } + + const username = await user.getUserField(uid, 'username'); + const data = await plugins.hooks.fire('filter:user.verify', { + uid, + username, + confirm_link, + confirm_code: await plugins.hooks.fire('filter:user.verify.code', confirm_code), + email: options.email, + + subject: options.subject || '[[email:email.verify-your-email.subject]]', + template: options.template || 'verify-email', + }); + + await UserEmail.expireValidation(uid); + await db.set(`confirm:byUid:${uid}`, confirm_code); + await db.pexpire(`confirm:byUid:${uid}`, emailConfirmExpiry * 24 * 60 * 60 * 1000); + + await db.setObject(`confirm:${confirm_code}`, { + email: options.email.toLowerCase(), + uid, + }); + await db.pexpire(`confirm:${confirm_code}`, emailConfirmExpiry * 24 * 60 * 60 * 1000); + + winston.verbose(`[user/email] Validation email for uid ${uid} sent to ${options.email}`); + events.log({ + type: 'email-confirmation-sent', + uid, + confirm_code, + ...options, + }); + + if (plugins.hooks.hasListeners('action:user.verify')) { + plugins.hooks.fire('action:user.verify', {uid, data}); + } else { + await emailer.send(data.template, uid, data); + } + + return confirm_code; }; -// confirm email by code sent by confirmation email +// Confirm email by code sent by confirmation email UserEmail.confirmByCode = async function (code, sessionId) { - const confirmObj = await db.getObject(`confirm:${code}`); - if (!confirmObj || !confirmObj.uid || !confirmObj.email) { - throw new Error('[[error:invalid-data]]'); - } - - // If another uid has the same email, remove it - const oldUid = await db.sortedSetScore('email:uid', confirmObj.email.toLowerCase()); - if (oldUid) { - await UserEmail.remove(oldUid, sessionId); - } - - const oldEmail = await user.getUserField(confirmObj.uid, 'email'); - if (oldEmail && confirmObj.email !== oldEmail) { - await UserEmail.remove(confirmObj.uid, sessionId); - } else { - await user.auth.revokeAllSessions(confirmObj.uid, sessionId); - } - - await user.setUserField(confirmObj.uid, 'email', confirmObj.email); - await Promise.all([ - UserEmail.confirmByUid(confirmObj.uid), - db.delete(`confirm:${code}`), - events.log({ type: 'email-change', oldEmail, newEmail: confirmObj.email }), - ]); + const confirmObject = await db.getObject(`confirm:${code}`); + if (!confirmObject || !confirmObject.uid || !confirmObject.email) { + throw new Error('[[error:invalid-data]]'); + } + + // If another uid has the same email, remove it + const oldUid = await db.sortedSetScore('email:uid', confirmObject.email.toLowerCase()); + if (oldUid) { + await UserEmail.remove(oldUid, sessionId); + } + + const oldEmail = await user.getUserField(confirmObject.uid, 'email'); + await (oldEmail && confirmObject.email !== oldEmail ? UserEmail.remove(confirmObject.uid, sessionId) : user.auth.revokeAllSessions(confirmObject.uid, sessionId)); + + await user.setUserField(confirmObject.uid, 'email', confirmObject.email); + await Promise.all([ + UserEmail.confirmByUid(confirmObject.uid), + db.delete(`confirm:${code}`), + events.log({type: 'email-change', oldEmail, newEmail: confirmObject.email}), + ]); }; -// confirm uid's email via ACP +// Confirm uid's email via ACP UserEmail.confirmByUid = async function (uid) { - if (!(parseInt(uid, 10) > 0)) { - throw new Error('[[error:invalid-uid]]'); - } - const currentEmail = await user.getUserField(uid, 'email'); - if (!currentEmail) { - throw new Error('[[error:invalid-email]]'); - } - - await Promise.all([ - db.sortedSetAddBulk([ - ['email:uid', uid, currentEmail.toLowerCase()], - ['email:sorted', 0, `${currentEmail.toLowerCase()}:${uid}`], - [`user:${uid}:emails`, Date.now(), `${currentEmail}:${Date.now()}`], - ]), - user.setUserField(uid, 'email:confirmed', 1), - groups.join('verified-users', uid), - groups.leave('unverified-users', uid), - user.email.expireValidation(uid), - user.reset.cleanByUid(uid), - ]); - await plugins.hooks.fire('action:user.email.confirmed', { uid: uid, email: currentEmail }); + if (!(Number.parseInt(uid, 10) > 0)) { + throw new Error('[[error:invalid-uid]]'); + } + + const currentEmail = await user.getUserField(uid, 'email'); + if (!currentEmail) { + throw new Error('[[error:invalid-email]]'); + } + + await Promise.all([ + db.sortedSetAddBulk([ + ['email:uid', uid, currentEmail.toLowerCase()], + ['email:sorted', 0, `${currentEmail.toLowerCase()}:${uid}`], + [`user:${uid}:emails`, Date.now(), `${currentEmail}:${Date.now()}`], + ]), + user.setUserField(uid, 'email:confirmed', 1), + groups.join('verified-users', uid), + groups.leave('unverified-users', uid), + user.email.expireValidation(uid), + user.reset.cleanByUid(uid), + ]); + await plugins.hooks.fire('action:user.email.confirmed', {uid, email: currentEmail}); }; diff --git a/src/user/follow.js b/src/user/follow.js index d9ea470..84d7fbd 100644 --- a/src/user/follow.js +++ b/src/user/follow.js @@ -5,86 +5,92 @@ const plugins = require('../plugins'); const db = require('../database'); module.exports = function (User) { - User.follow = async function (uid, followuid) { - await toggleFollow('follow', uid, followuid); - }; - - User.unfollow = async function (uid, unfollowuid) { - await toggleFollow('unfollow', uid, unfollowuid); - }; - - async function toggleFollow(type, uid, theiruid) { - if (parseInt(uid, 10) <= 0 || parseInt(theiruid, 10) <= 0) { - throw new Error('[[error:invalid-uid]]'); - } - - if (parseInt(uid, 10) === parseInt(theiruid, 10)) { - throw new Error('[[error:you-cant-follow-yourself]]'); - } - const exists = await User.exists(theiruid); - if (!exists) { - throw new Error('[[error:no-user]]'); - } - const isFollowing = await User.isFollowing(uid, theiruid); - if (type === 'follow') { - if (isFollowing) { - throw new Error('[[error:already-following]]'); - } - const now = Date.now(); - await Promise.all([ - db.sortedSetAddBulk([ - [`following:${uid}`, now, theiruid], - [`followers:${theiruid}`, now, uid], - ]), - ]); - } else { - if (!isFollowing) { - throw new Error('[[error:not-following]]'); - } - await Promise.all([ - db.sortedSetRemoveBulk([ - [`following:${uid}`, theiruid], - [`followers:${theiruid}`, uid], - ]), - ]); - } - - const [followingCount, followerCount] = await Promise.all([ - db.sortedSetCard(`following:${uid}`), - db.sortedSetCard(`followers:${theiruid}`), - ]); - await Promise.all([ - User.setUserField(uid, 'followingCount', followingCount), - User.setUserField(theiruid, 'followerCount', followerCount), - ]); - } - - User.getFollowing = async function (uid, start, stop) { - return await getFollow(uid, 'following', start, stop); - }; - - User.getFollowers = async function (uid, start, stop) { - return await getFollow(uid, 'followers', start, stop); - }; - - async function getFollow(uid, type, start, stop) { - if (parseInt(uid, 10) <= 0) { - return []; - } - const uids = await db.getSortedSetRevRange(`${type}:${uid}`, start, stop); - const data = await plugins.hooks.fire(`filter:user.${type}`, { - uids: uids, - uid: uid, - start: start, - stop: stop, - }); - return await User.getUsers(data.uids, uid); - } - - User.isFollowing = async function (uid, theirid) { - if (parseInt(uid, 10) <= 0 || parseInt(theirid, 10) <= 0) { - return false; - } - return await db.isSortedSetMember(`following:${uid}`, theirid); - }; + User.follow = async function (uid, followuid) { + await toggleFollow('follow', uid, followuid); + }; + + User.unfollow = async function (uid, unfollowuid) { + await toggleFollow('unfollow', uid, unfollowuid); + }; + + async function toggleFollow(type, uid, theiruid) { + if (Number.parseInt(uid, 10) <= 0 || Number.parseInt(theiruid, 10) <= 0) { + throw new Error('[[error:invalid-uid]]'); + } + + if (Number.parseInt(uid, 10) === Number.parseInt(theiruid, 10)) { + throw new Error('[[error:you-cant-follow-yourself]]'); + } + + const exists = await User.exists(theiruid); + if (!exists) { + throw new Error('[[error:no-user]]'); + } + + const isFollowing = await User.isFollowing(uid, theiruid); + if (type === 'follow') { + if (isFollowing) { + throw new Error('[[error:already-following]]'); + } + + const now = Date.now(); + await Promise.all([ + db.sortedSetAddBulk([ + [`following:${uid}`, now, theiruid], + [`followers:${theiruid}`, now, uid], + ]), + ]); + } else { + if (!isFollowing) { + throw new Error('[[error:not-following]]'); + } + + await Promise.all([ + db.sortedSetRemoveBulk([ + [`following:${uid}`, theiruid], + [`followers:${theiruid}`, uid], + ]), + ]); + } + + const [followingCount, followerCount] = await Promise.all([ + db.sortedSetCard(`following:${uid}`), + db.sortedSetCard(`followers:${theiruid}`), + ]); + await Promise.all([ + User.setUserField(uid, 'followingCount', followingCount), + User.setUserField(theiruid, 'followerCount', followerCount), + ]); + } + + User.getFollowing = async function (uid, start, stop) { + return await getFollow(uid, 'following', start, stop); + }; + + User.getFollowers = async function (uid, start, stop) { + return await getFollow(uid, 'followers', start, stop); + }; + + async function getFollow(uid, type, start, stop) { + if (Number.parseInt(uid, 10) <= 0) { + return []; + } + + const uids = await db.getSortedSetRevRange(`${type}:${uid}`, start, stop); + const data = await plugins.hooks.fire(`filter:user.${type}`, { + uids, + uid, + start, + stop, + }); + return await User.getUsers(data.uids, uid); + } + + User.isFollowing = async function (uid, theirid) { + if (Number.parseInt(uid, 10) <= 0 || Number.parseInt(theirid, 10) <= 0) { + return false; + } + + return await db.isSortedSetMember(`following:${uid}`, theirid); + }; }; diff --git a/src/user/index.js b/src/user/index.js index 3d1594a..626acc6 100644 --- a/src/user/index.js +++ b/src/user/index.js @@ -1,7 +1,6 @@ 'use strict'; const _ = require('lodash'); - const groups = require('../groups'); const plugins = require('../plugins'); const db = require('../database'); @@ -42,207 +41,225 @@ require('./blocks')(User); require('./uploads')(User); User.exists = async function (uids) { - return await ( - Array.isArray(uids) ? - db.isSortedSetMembers('users:joindate', uids) : - db.isSortedSetMember('users:joindate', uids) - ); + return await ( + Array.isArray(uids) + ? db.isSortedSetMembers('users:joindate', uids) + : db.isSortedSetMember('users:joindate', uids) + ); }; User.existsBySlug = async function (userslug) { - const exists = await User.getUidByUserslug(userslug); - return !!exists; + const exists = await User.getUidByUserslug(userslug); + return Boolean(exists); }; User.getUidsFromSet = async function (set, start, stop) { - if (set === 'users:online') { - const count = parseInt(stop, 10) === -1 ? stop : stop - start + 1; - const now = Date.now(); - return await db.getSortedSetRevRangeByScore(set, start, count, '+inf', now - (meta.config.onlineCutoff * 60000)); - } - return await db.getSortedSetRevRange(set, start, stop); + if (set === 'users:online') { + const count = Number.parseInt(stop, 10) === -1 ? stop : stop - start + 1; + const now = Date.now(); + return await db.getSortedSetRevRangeByScore(set, start, count, '+inf', now - (meta.config.onlineCutoff * 60_000)); + } + + return await db.getSortedSetRevRange(set, start, stop); }; User.getUsersFromSet = async function (set, uid, start, stop) { - const uids = await User.getUidsFromSet(set, start, stop); - return await User.getUsers(uids, uid); + const uids = await User.getUidsFromSet(set, start, stop); + return await User.getUsers(uids, uid); }; User.getUsersWithFields = async function (uids, fields, uid) { - let results = await plugins.hooks.fire('filter:users.addFields', { fields: fields }); - results.fields = _.uniq(results.fields); - const userData = await User.getUsersFields(uids, results.fields); - results = await plugins.hooks.fire('filter:userlist.get', { users: userData, uid: uid }); - return results.users; + let results = await plugins.hooks.fire('filter:users.addFields', {fields}); + results.fields = _.uniq(results.fields); + const userData = await User.getUsersFields(uids, results.fields); + results = await plugins.hooks.fire('filter:userlist.get', {users: userData, uid}); + return results.users; }; User.getUsers = async function (uids, uid) { - const userData = await User.getUsersWithFields(uids, [ - 'uid', 'username', 'userslug', 'accounttype', 'picture', 'status', - 'postcount', 'reputation', 'email:confirmed', 'lastonline', - 'flags', 'banned', 'banned:expire', 'joindate', - ], uid); - - return User.hidePrivateData(userData, uid); + const userData = await User.getUsersWithFields(uids, [ + 'uid', + 'username', + 'userslug', + 'accounttype', + 'picture', + 'status', + 'postcount', + 'reputation', + 'email:confirmed', + 'lastonline', + 'flags', + 'banned', + 'banned:expire', + 'joindate', + ], uid); + + return User.hidePrivateData(userData, uid); }; User.getStatus = function (userData) { - if (userData.uid <= 0) { - return 'offline'; - } - const isOnline = (Date.now() - userData.lastonline) < (meta.config.onlineCutoff * 60000); - return isOnline ? (userData.status || 'online') : 'offline'; + if (userData.uid <= 0) { + return 'offline'; + } + + const isOnline = (Date.now() - userData.lastonline) < (meta.config.onlineCutoff * 60_000); + return isOnline ? (userData.status || 'online') : 'offline'; }; User.getUidByUsername = async function (username) { - if (!username) { - return 0; - } - return await db.sortedSetScore('username:uid', username); + if (!username) { + return 0; + } + + return await db.sortedSetScore('username:uid', username); }; User.getUidsByUsernames = async function (usernames) { - return await db.sortedSetScores('username:uid', usernames); + return await db.sortedSetScores('username:uid', usernames); }; User.getUidByUserslug = async function (userslug) { - if (!userslug) { - return 0; - } - return await db.sortedSetScore('userslug:uid', userslug); + if (!userslug) { + return 0; + } + + return await db.sortedSetScore('userslug:uid', userslug); }; User.getUsernamesByUids = async function (uids) { - const users = await User.getUsersFields(uids, ['username']); - return users.map(user => user.username); + const users = await User.getUsersFields(uids, ['username']); + return users.map(user => user.username); }; User.getUsernameByUserslug = async function (slug) { - const uid = await User.getUidByUserslug(slug); - return await User.getUserField(uid, 'username'); + const uid = await User.getUidByUserslug(slug); + return await User.getUserField(uid, 'username'); }; User.getUidByEmail = async function (email) { - return await db.sortedSetScore('email:uid', email.toLowerCase()); + return await db.sortedSetScore('email:uid', email.toLowerCase()); }; User.getUidsByEmails = async function (emails) { - emails = emails.map(email => email && email.toLowerCase()); - return await db.sortedSetScores('email:uid', emails); + emails = emails.map(email => email && email.toLowerCase()); + return await db.sortedSetScores('email:uid', emails); }; User.getUsernameByEmail = async function (email) { - const uid = await db.sortedSetScore('email:uid', String(email).toLowerCase()); - return await User.getUserField(uid, 'username'); + const uid = await db.sortedSetScore('email:uid', String(email).toLowerCase()); + return await User.getUserField(uid, 'username'); }; User.isModerator = async function (uid, cid) { - return await privileges.users.isModerator(uid, cid); + return await privileges.users.isModerator(uid, cid); }; User.isModeratorOfAnyCategory = async function (uid) { - const cids = await User.getModeratedCids(uid); - return Array.isArray(cids) ? !!cids.length : false; + const cids = await User.getModeratedCids(uid); + return Array.isArray(cids) ? cids.length > 0 : false; }; User.isAdministrator = async function (uid) { - return await privileges.users.isAdministrator(uid); + return await privileges.users.isAdministrator(uid); }; User.isGlobalModerator = async function (uid) { - return await privileges.users.isGlobalModerator(uid); + return await privileges.users.isGlobalModerator(uid); }; User.getPrivileges = async function (uid) { - return await utils.promiseParallel({ - isAdmin: User.isAdministrator(uid), - isGlobalModerator: User.isGlobalModerator(uid), - isModeratorOfAnyCategory: User.isModeratorOfAnyCategory(uid), - }); + return await utils.promiseParallel({ + isAdmin: User.isAdministrator(uid), + isGlobalModerator: User.isGlobalModerator(uid), + isModeratorOfAnyCategory: User.isModeratorOfAnyCategory(uid), + }); }; User.isPrivileged = async function (uid) { - if (!(parseInt(uid, 10) > 0)) { - return false; - } - const results = await User.getPrivileges(uid); - return results ? (results.isAdmin || results.isGlobalModerator || results.isModeratorOfAnyCategory) : false; + if (!(Number.parseInt(uid, 10) > 0)) { + return false; + } + + const results = await User.getPrivileges(uid); + return results ? (results.isAdmin || results.isGlobalModerator || results.isModeratorOfAnyCategory) : false; }; User.isAdminOrGlobalMod = async function (uid) { - const [isAdmin, isGlobalMod] = await Promise.all([ - User.isAdministrator(uid), - User.isGlobalModerator(uid), - ]); - return isAdmin || isGlobalMod; + const [isAdmin, isGlobalModule] = await Promise.all([ + User.isAdministrator(uid), + User.isGlobalModerator(uid), + ]); + return isAdmin || isGlobalModule; }; User.isAdminOrSelf = async function (callerUid, uid) { - await isSelfOrMethod(callerUid, uid, User.isAdministrator); + await isSelfOrMethod(callerUid, uid, User.isAdministrator); }; User.isAdminOrGlobalModOrSelf = async function (callerUid, uid) { - await isSelfOrMethod(callerUid, uid, User.isAdminOrGlobalMod); + await isSelfOrMethod(callerUid, uid, User.isAdminOrGlobalMod); }; User.isPrivilegedOrSelf = async function (callerUid, uid) { - await isSelfOrMethod(callerUid, uid, User.isPrivileged); + await isSelfOrMethod(callerUid, uid, User.isPrivileged); }; async function isSelfOrMethod(callerUid, uid, method) { - if (parseInt(callerUid, 10) === parseInt(uid, 10)) { - return; - } - const isPass = await method(callerUid); - if (!isPass) { - throw new Error('[[error:no-privileges]]'); - } + if (Number.parseInt(callerUid, 10) === Number.parseInt(uid, 10)) { + return; + } + + const isPass = await method(callerUid); + if (!isPass) { + throw new Error('[[error:no-privileges]]'); + } } User.getAdminsandGlobalMods = async function () { - const results = await groups.getMembersOfGroups(['administrators', 'Global Moderators']); - return await User.getUsersData(_.union(...results)); + const results = await groups.getMembersOfGroups(['administrators', 'Global Moderators']); + return await User.getUsersData(_.union(...results)); }; User.getAdminsandGlobalModsandModerators = async function () { - const results = await Promise.all([ - groups.getMembers('administrators', 0, -1), - groups.getMembers('Global Moderators', 0, -1), - User.getModeratorUids(), - ]); - return await User.getUsersData(_.union(...results)); + const results = await Promise.all([ + groups.getMembers('administrators', 0, -1), + groups.getMembers('Global Moderators', 0, -1), + User.getModeratorUids(), + ]); + return await User.getUsersData(_.union(...results)); }; User.getFirstAdminUid = async function () { - return (await db.getSortedSetRange('group:administrators:members', 0, 0))[0]; + return (await db.getSortedSetRange('group:administrators:members', 0, 0))[0]; }; User.getModeratorUids = async function () { - const cids = await categories.getAllCidsFromSet('categories:cid'); - const uids = await categories.getModeratorUids(cids); - return _.union(...uids); + const cids = await categories.getAllCidsFromSet('categories:cid'); + const uids = await categories.getModeratorUids(cids); + return _.union(...uids); }; User.getModeratedCids = async function (uid) { - if (parseInt(uid, 10) <= 0) { - return []; - } - const cids = await categories.getAllCidsFromSet('categories:cid'); - const isMods = await User.isModerator(uid, cids); - return cids.filter((cid, index) => cid && isMods[index]); + if (Number.parseInt(uid, 10) <= 0) { + return []; + } + + const cids = await categories.getAllCidsFromSet('categories:cid'); + const isMods = await User.isModerator(uid, cids); + return cids.filter((cid, index) => cid && isMods[index]); }; User.addInterstitials = function (callback) { - plugins.hooks.register('core', { - hook: 'filter:register.interstitial', - method: [ - User.interstitials.email, // Email address (for password reset + digest) - User.interstitials.gdpr, // GDPR information collection/processing consent + email consent - User.interstitials.tou, // Forum Terms of Use - ], - }); - - callback(); + plugins.hooks.register('core', { + hook: 'filter:register.interstitial', + method: [ + User.interstitials.email, // Email address (for password reset + digest) + User.interstitials.gdpr, // GDPR information collection/processing consent + email consent + User.interstitials.tou, // Forum Terms of Use + ], + }); + + callback(); }; require('../promisify')(User); diff --git a/src/user/info.js b/src/user/info.js index 45e28c4..c1d6843 100644 --- a/src/user/info.js +++ b/src/user/info.js @@ -2,143 +2,146 @@ const _ = require('lodash'); const validator = require('validator'); - const db = require('../database'); const posts = require('../posts'); const topics = require('../topics'); const utils = require('../utils'); module.exports = function (User) { - User.getLatestBanInfo = async function (uid) { - // Simply retrieves the last record of the user's ban, even if they've been unbanned since then. - const record = await db.getSortedSetRevRange(`uid:${uid}:bans:timestamp`, 0, 0); - if (!record.length) { - throw new Error('no-ban-info'); - } - const banInfo = await db.getObject(record[0]); - const expire = parseInt(banInfo.expire, 10); - const expire_readable = utils.toISOString(expire); - return { - uid: uid, - timestamp: banInfo.timestamp, - banned_until: expire, - expiry: expire, /* backward compatible alias */ - banned_until_readable: expire_readable, - expiry_readable: expire_readable, /* backward compatible alias */ - reason: validator.escape(String(banInfo.reason || '')), - }; - }; - - User.getModerationHistory = async function (uid) { - let [flags, bans, mutes] = await Promise.all([ - db.getSortedSetRevRangeWithScores(`flags:byTargetUid:${uid}`, 0, 19), - db.getSortedSetRevRange(`uid:${uid}:bans:timestamp`, 0, 19), - db.getSortedSetRevRange(`uid:${uid}:mutes:timestamp`, 0, 19), - ]); - - // Get pids from flag objects - const keys = flags.map(flagObj => `flag:${flagObj.value}`); - const payload = await db.getObjectsFields(keys, ['type', 'targetId']); - - // Only pass on flag ids from posts - flags = payload.reduce((memo, cur, idx) => { - if (cur.type === 'post') { - memo.push({ - value: parseInt(cur.targetId, 10), - score: flags[idx].score, - }); - } - - return memo; - }, []); - - [flags, bans, mutes] = await Promise.all([ - getFlagMetadata(flags), - formatBanMuteData(bans, '[[user:info.banned-no-reason]]'), - formatBanMuteData(mutes, '[[user:info.muted-no-reason]]'), - ]); - - return { - flags: flags, - bans: bans, - mutes: mutes, - }; - }; - - User.getHistory = async function (set) { - const data = await db.getSortedSetRevRangeWithScores(set, 0, -1); - return data.map((set) => { - set.timestamp = set.score; - set.timestampISO = utils.toISOString(set.score); - set.value = validator.escape(String(set.value.split(':')[0])); - delete set.score; - return set; - }); - }; - - async function getFlagMetadata(flags) { - const pids = flags.map(flagObj => parseInt(flagObj.value, 10)); - const postData = await posts.getPostsFields(pids, ['tid']); - const tids = postData.map(post => post.tid); - - const topicData = await topics.getTopicsFields(tids, ['title']); - flags = flags.map((flagObj, idx) => { - flagObj.pid = flagObj.value; - flagObj.timestamp = flagObj.score; - flagObj.timestampISO = new Date(flagObj.score).toISOString(); - flagObj.timestampReadable = new Date(flagObj.score).toString(); - - delete flagObj.value; - delete flagObj.score; - if (!tids[idx]) { - flagObj.targetPurged = true; - } - return _.extend(flagObj, topicData[idx]); - }); - return flags; - } - - async function formatBanMuteData(keys, noReasonLangKey) { - const data = await db.getObjects(keys); - const uids = data.map(d => d.fromUid); - const usersData = await User.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture']); - return data.map((banObj, index) => { - banObj.user = usersData[index]; - banObj.until = parseInt(banObj.expire, 10); - banObj.untilReadable = new Date(banObj.until).toString(); - banObj.timestampReadable = new Date(parseInt(banObj.timestamp, 10)).toString(); - banObj.timestampISO = utils.toISOString(banObj.timestamp); - banObj.reason = validator.escape(String(banObj.reason || '')) || noReasonLangKey; - return banObj; - }); - } - - User.getModerationNotes = async function (uid, start, stop) { - const noteIds = await db.getSortedSetRevRange(`uid:${uid}:moderation:notes`, start, stop); - const keys = noteIds.map(id => `uid:${uid}:moderation:note:${id}`); - const notes = await db.getObjects(keys); - const uids = []; - - const noteData = notes.map((note) => { - if (note) { - uids.push(note.uid); - note.timestampISO = utils.toISOString(note.timestamp); - note.note = validator.escape(String(note.note)); - } - return note; - }); - - const userData = await User.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture']); - noteData.forEach((note, index) => { - if (note) { - note.user = userData[index]; - } - }); - return noteData; - }; - - User.appendModerationNote = async ({ uid, noteData }) => { - await db.sortedSetAdd(`uid:${uid}:moderation:notes`, noteData.timestamp, noteData.timestamp); - await db.setObject(`uid:${uid}:moderation:note:${noteData.timestamp}`, noteData); - }; + User.getLatestBanInfo = async function (uid) { + // Simply retrieves the last record of the user's ban, even if they've been unbanned since then. + const record = await db.getSortedSetRevRange(`uid:${uid}:bans:timestamp`, 0, 0); + if (record.length === 0) { + throw new Error('no-ban-info'); + } + + const banInfo = await db.getObject(record[0]); + const expire = Number.parseInt(banInfo.expire, 10); + const expire_readable = utils.toISOString(expire); + return { + uid, + timestamp: banInfo.timestamp, + banned_until: expire, + expiry: expire, /* Backward compatible alias */ + banned_until_readable: expire_readable, + expiry_readable: expire_readable, /* Backward compatible alias */ + reason: validator.escape(String(banInfo.reason || '')), + }; + }; + + User.getModerationHistory = async function (uid) { + let [flags, bans, mutes] = await Promise.all([ + db.getSortedSetRevRangeWithScores(`flags:byTargetUid:${uid}`, 0, 19), + db.getSortedSetRevRange(`uid:${uid}:bans:timestamp`, 0, 19), + db.getSortedSetRevRange(`uid:${uid}:mutes:timestamp`, 0, 19), + ]); + + // Get pids from flag objects + const keys = flags.map(flagObject => `flag:${flagObject.value}`); + const payload = await db.getObjectsFields(keys, ['type', 'targetId']); + + // Only pass on flag ids from posts + flags = payload.reduce((memo, current, index) => { + if (current.type === 'post') { + memo.push({ + value: Number.parseInt(current.targetId, 10), + score: flags[index].score, + }); + } + + return memo; + }, []); + + [flags, bans, mutes] = await Promise.all([ + getFlagMetadata(flags), + formatBanMuteData(bans, '[[user:info.banned-no-reason]]'), + formatBanMuteData(mutes, '[[user:info.muted-no-reason]]'), + ]); + + return { + flags, + bans, + mutes, + }; + }; + + User.getHistory = async function (set) { + const data = await db.getSortedSetRevRangeWithScores(set, 0, -1); + return data.map(set => { + set.timestamp = set.score; + set.timestampISO = utils.toISOString(set.score); + set.value = validator.escape(String(set.value.split(':')[0])); + delete set.score; + return set; + }); + }; + + async function getFlagMetadata(flags) { + const pids = flags.map(flagObject => Number.parseInt(flagObject.value, 10)); + const postData = await posts.getPostsFields(pids, ['tid']); + const tids = postData.map(post => post.tid); + + const topicData = await topics.getTopicsFields(tids, ['title']); + flags = flags.map((flagObject, index) => { + flagObject.pid = flagObject.value; + flagObject.timestamp = flagObject.score; + flagObject.timestampISO = new Date(flagObject.score).toISOString(); + flagObject.timestampReadable = new Date(flagObject.score).toString(); + + delete flagObject.value; + delete flagObject.score; + if (!tids[index]) { + flagObject.targetPurged = true; + } + + return _.extend(flagObject, topicData[index]); + }); + return flags; + } + + async function formatBanMuteData(keys, noReasonLangKey) { + const data = await db.getObjects(keys); + const uids = data.map(d => d.fromUid); + const usersData = await User.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture']); + return data.map((banObject, index) => { + banObject.user = usersData[index]; + banObject.until = Number.parseInt(banObject.expire, 10); + banObject.untilReadable = new Date(banObject.until).toString(); + banObject.timestampReadable = new Date(Number.parseInt(banObject.timestamp, 10)).toString(); + banObject.timestampISO = utils.toISOString(banObject.timestamp); + banObject.reason = validator.escape(String(banObject.reason || '')) || noReasonLangKey; + return banObject; + }); + } + + User.getModerationNotes = async function (uid, start, stop) { + const noteIds = await db.getSortedSetRevRange(`uid:${uid}:moderation:notes`, start, stop); + const keys = noteIds.map(id => `uid:${uid}:moderation:note:${id}`); + const notes = await db.getObjects(keys); + const uids = []; + + const noteData = notes.map(note => { + if (note) { + uids.push(note.uid); + note.timestampISO = utils.toISOString(note.timestamp); + note.note = validator.escape(String(note.note)); + } + + return note; + }); + + const userData = await User.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture']); + for (const [index, note] of noteData.entries()) { + if (note) { + note.user = userData[index]; + } + } + + return noteData; + }; + + User.appendModerationNote = async ({uid, noteData}) => { + await db.sortedSetAdd(`uid:${uid}:moderation:notes`, noteData.timestamp, noteData.timestamp); + await db.setObject(`uid:${uid}:moderation:note:${noteData.timestamp}`, noteData); + }; }; diff --git a/src/user/interstitials.js b/src/user/interstitials.js index b7903d0..e95ffd9 100644 --- a/src/user/interstitials.js +++ b/src/user/interstitials.js @@ -1,198 +1,200 @@ 'use strict'; +const util = require('node:util'); const winston = require('winston'); -const util = require('util'); - -const user = require('.'); const db = require('../database'); const meta = require('../meta'); const privileges = require('../privileges'); const plugins = require('../plugins'); const utils = require('../utils'); +const user = require('.'); const sleep = util.promisify(setTimeout); const Interstitials = module.exports; -Interstitials.email = async (data) => { - if (!data.userData) { - throw new Error('[[error:invalid-data]]'); - } - if (!data.userData.updateEmail) { - return data; - } - - const [isAdminOrGlobalMod, hasPassword] = await Promise.all([ - user.isAdminOrGlobalMod(data.req.uid), - user.hasPassword(data.userData.uid), - ]); - - let email; - if (data.userData.uid) { - email = await user.getUserField(data.userData.uid, 'email'); - } - - data.interstitials.push({ - template: 'partials/email_update', - data: { - email, - requireEmailAddress: meta.config.requireEmailAddress, - issuePasswordChallenge: !!data.userData.uid && hasPassword, - }, - callback: async (userData, formData) => { - // Validate and send email confirmation - if (userData.uid) { - const [isPasswordCorrect, canEdit, { email: current, 'email:confirmed': confirmed }, { allowed, error }] = await Promise.all([ - user.isPasswordCorrect(userData.uid, formData.password, data.req.ip), - privileges.users.canEdit(data.req.uid, userData.uid), - user.getUserFields(userData.uid, ['email', 'email:confirmed']), - plugins.hooks.fire('filter:user.saveEmail', { - uid: userData.uid, - email: formData.email, - registration: false, - allowed: true, // change this value to disallow - error: '[[error:invalid-email]]', - }), - ]); - - if (!isAdminOrGlobalMod && !isPasswordCorrect) { - await sleep(2000); - } - - if (formData.email && formData.email.length) { - if (!allowed || !utils.isEmailValid(formData.email)) { - throw new Error(error); - } - - // Handle errors when setting to same email (unconfirmed accts only) - if (formData.email === current) { - if (confirmed) { - throw new Error('[[error:email-nochange]]'); - } else if (await user.email.canSendValidation(userData.uid, current)) { - throw new Error(`[[error:confirm-email-already-sent, ${meta.config.emailConfirmInterval}]]`); - } - } - - // Admins editing will auto-confirm, unless editing their own email - if (isAdminOrGlobalMod && userData.uid !== data.req.uid) { - await user.setUserField(userData.uid, 'email', formData.email); - await user.email.confirmByUid(userData.uid); - } else if (canEdit) { - if (hasPassword && !isPasswordCorrect) { - throw new Error('[[error:invalid-password]]'); - } - - await user.email.sendValidationEmail(userData.uid, { - email: formData.email, - force: true, - }).catch((err) => { - winston.error(`[user.interstitials.email] Validation email failed to send\n[emailer.send] ${err.stack}`); - }); - data.req.session.emailChanged = 1; - } else { - // User attempting to edit another user's email -- not allowed - throw new Error('[[error:no-privileges]]'); - } - } else { - if (meta.config.requireEmailAddress) { - throw new Error('[[error:invalid-email]]'); - } - - if (current.length && (!hasPassword || (hasPassword && isPasswordCorrect) || isAdminOrGlobalMod)) { - // User explicitly clearing their email - await user.email.remove(userData.uid, data.req.session.id); - } - } - } else { - const { allowed, error } = await plugins.hooks.fire('filter:user.saveEmail', { - uid: null, - email: formData.email, - registration: true, - allowed: true, // change this value to disallow - error: '[[error:invalid-email]]', - }); - - if (!allowed || (meta.config.requireEmailAddress && !(formData.email && formData.email.length))) { - throw new Error(error); - } - - // New registrants have the confirm email sent from user.create() - userData.email = formData.email; - } - - delete userData.updateEmail; - }, - }); - - return data; +Interstitials.email = async data => { + if (!data.userData) { + throw new Error('[[error:invalid-data]]'); + } + + if (!data.userData.updateEmail) { + return data; + } + + const [isAdminOrGlobalModule, hasPassword] = await Promise.all([ + user.isAdminOrGlobalMod(data.req.uid), + user.hasPassword(data.userData.uid), + ]); + + let email; + if (data.userData.uid) { + email = await user.getUserField(data.userData.uid, 'email'); + } + + data.interstitials.push({ + template: 'partials/email_update', + data: { + email, + requireEmailAddress: meta.config.requireEmailAddress, + issuePasswordChallenge: Boolean(data.userData.uid) && hasPassword, + }, + async callback(userData, formData) { + // Validate and send email confirmation + if (userData.uid) { + const [isPasswordCorrect, canEdit, {email: current, 'email:confirmed': confirmed}, {allowed, error}] = await Promise.all([ + user.isPasswordCorrect(userData.uid, formData.password, data.req.ip), + privileges.users.canEdit(data.req.uid, userData.uid), + user.getUserFields(userData.uid, ['email', 'email:confirmed']), + plugins.hooks.fire('filter:user.saveEmail', { + uid: userData.uid, + email: formData.email, + registration: false, + allowed: true, // Change this value to disallow + error: '[[error:invalid-email]]', + }), + ]); + + if (!isAdminOrGlobalModule && !isPasswordCorrect) { + await sleep(2000); + } + + if (formData.email && formData.email.length > 0) { + if (!allowed || !utils.isEmailValid(formData.email)) { + throw new Error(error); + } + + // Handle errors when setting to same email (unconfirmed accts only) + if (formData.email === current) { + if (confirmed) { + throw new Error('[[error:email-nochange]]'); + } else if (await user.email.canSendValidation(userData.uid, current)) { + throw new Error(`[[error:confirm-email-already-sent, ${meta.config.emailConfirmInterval}]]`); + } + } + + // Admins editing will auto-confirm, unless editing their own email + if (isAdminOrGlobalModule && userData.uid !== data.req.uid) { + await user.setUserField(userData.uid, 'email', formData.email); + await user.email.confirmByUid(userData.uid); + } else if (canEdit) { + if (hasPassword && !isPasswordCorrect) { + throw new Error('[[error:invalid-password]]'); + } + + await user.email.sendValidationEmail(userData.uid, { + email: formData.email, + force: true, + }).catch(error_ => { + winston.error(`[user.interstitials.email] Validation email failed to send\n[emailer.send] ${error_.stack}`); + }); + data.req.session.emailChanged = 1; + } else { + // User attempting to edit another user's email -- not allowed + throw new Error('[[error:no-privileges]]'); + } + } else { + if (meta.config.requireEmailAddress) { + throw new Error('[[error:invalid-email]]'); + } + + if (current.length > 0 && (!hasPassword || (hasPassword && isPasswordCorrect) || isAdminOrGlobalModule)) { + // User explicitly clearing their email + await user.email.remove(userData.uid, data.req.session.id); + } + } + } else { + const {allowed, error} = await plugins.hooks.fire('filter:user.saveEmail', { + uid: null, + email: formData.email, + registration: true, + allowed: true, // Change this value to disallow + error: '[[error:invalid-email]]', + }); + + if (!allowed || (meta.config.requireEmailAddress && !(formData.email && formData.email.length > 0))) { + throw new Error(error); + } + + // New registrants have the confirm email sent from user.create() + userData.email = formData.email; + } + + delete userData.updateEmail; + }, + }); + + return data; }; Interstitials.gdpr = async function (data) { - if (!meta.config.gdpr_enabled || (data.userData && data.userData.gdpr_consent)) { - return data; - } - if (!data.userData) { - throw new Error('[[error:invalid-data]]'); - } - - if (data.userData.uid) { - const consented = await db.getObjectField(`user:${data.userData.uid}`, 'gdpr_consent'); - if (parseInt(consented, 10)) { - return data; - } - } - - data.interstitials.push({ - template: 'partials/gdpr_consent', - data: { - digestFrequency: meta.config.dailyDigestFreq, - digestEnabled: meta.config.dailyDigestFreq !== 'off', - }, - callback: function (userData, formData, next) { - if (formData.gdpr_agree_data === 'on' && formData.gdpr_agree_email === 'on') { - userData.gdpr_consent = true; - } - - next(userData.gdpr_consent ? null : new Error('[[register:gdpr_consent_denied]]')); - }, - }); - return data; + if (!meta.config.gdpr_enabled || (data.userData && data.userData.gdpr_consent)) { + return data; + } + + if (!data.userData) { + throw new Error('[[error:invalid-data]]'); + } + + if (data.userData.uid) { + const consented = await db.getObjectField(`user:${data.userData.uid}`, 'gdpr_consent'); + if (Number.parseInt(consented, 10)) { + return data; + } + } + + data.interstitials.push({ + template: 'partials/gdpr_consent', + data: { + digestFrequency: meta.config.dailyDigestFreq, + digestEnabled: meta.config.dailyDigestFreq !== 'off', + }, + callback(userData, formData, next) { + if (formData.gdpr_agree_data === 'on' && formData.gdpr_agree_email === 'on') { + userData.gdpr_consent = true; + } + + next(userData.gdpr_consent ? null : new Error('[[register:gdpr_consent_denied]]')); + }, + }); + return data; }; Interstitials.tou = async function (data) { - if (!data.userData) { - throw new Error('[[error:invalid-data]]'); - } - if (!meta.config.termsOfUse || data.userData.acceptTos) { - // no ToS or ToS accepted, nothing to do - return data; - } - - if (data.userData.uid) { - const accepted = await db.getObjectField(`user:${data.userData.uid}`, 'acceptTos'); - if (parseInt(accepted, 10)) { - return data; - } - } - - const termsOfUse = await plugins.hooks.fire('filter:parse.post', { - postData: { - content: meta.config.termsOfUse || '', - }, - }); - - data.interstitials.push({ - template: 'partials/acceptTos', - data: { - termsOfUse: termsOfUse.postData.content, - }, - callback: function (userData, formData, next) { - if (formData['agree-terms'] === 'on') { - userData.acceptTos = true; - } - - next(userData.acceptTos ? null : new Error('[[register:terms_of_use_error]]')); - }, - }); - return data; + if (!data.userData) { + throw new Error('[[error:invalid-data]]'); + } + + if (!meta.config.termsOfUse || data.userData.acceptTos) { + // No ToS or ToS accepted, nothing to do + return data; + } + + if (data.userData.uid) { + const accepted = await db.getObjectField(`user:${data.userData.uid}`, 'acceptTos'); + if (Number.parseInt(accepted, 10)) { + return data; + } + } + + const termsOfUse = await plugins.hooks.fire('filter:parse.post', { + postData: { + content: meta.config.termsOfUse || '', + }, + }); + + data.interstitials.push({ + template: 'partials/acceptTos', + data: { + termsOfUse: termsOfUse.postData.content, + }, + callback(userData, formData, next) { + if (formData['agree-terms'] === 'on') { + userData.acceptTos = true; + } + + next(userData.acceptTos ? null : new Error('[[register:terms_of_use_error]]')); + }, + }); + return data; }; diff --git a/src/user/invite.js b/src/user/invite.js index c69daf9..c107ea3 100644 --- a/src/user/invite.js +++ b/src/user/invite.js @@ -4,7 +4,6 @@ const async = require('async'); const nconf = require('nconf'); const validator = require('validator'); - const db = require('../database'); const meta = require('../meta'); const emailer = require('../emailer'); @@ -14,174 +13,176 @@ const utils = require('../utils'); const plugins = require('../plugins'); module.exports = function (User) { - User.getInvites = async function (uid) { - const emails = await db.getSetMembers(`invitation:uid:${uid}`); - return emails.map(email => validator.escape(String(email))); - }; - - User.getInvitesNumber = async function (uid) { - return await db.setCount(`invitation:uid:${uid}`); - }; - - User.getInvitingUsers = async function () { - return await db.getSetMembers('invitation:uids'); - }; - - User.getAllInvites = async function () { - const uids = await User.getInvitingUsers(); - const invitations = await async.map(uids, User.getInvites); - return invitations.map((invites, index) => ({ - uid: uids[index], - invitations: invites, - })); - }; - - User.sendInvitationEmail = async function (uid, email, groupsToJoin) { - if (!uid) { - throw new Error('[[error:invalid-uid]]'); - } - - const email_exists = await User.getUidByEmail(email); - if (email_exists) { - // Silently drop the invitation if the invited email already exists locally - return true; - } - - const invitation_exists = await db.exists(`invitation:uid:${uid}:invited:${email}`); - if (invitation_exists) { - throw new Error('[[error:email-invited]]'); - } - - const data = await prepareInvitation(uid, email, groupsToJoin); - await emailer.sendToEmail('invitation', email, meta.config.defaultLang, data); - plugins.hooks.fire('action:user.invite', { uid, email, groupsToJoin }); - }; - - User.verifyInvitation = async function (query) { - if (!query.token) { - if (meta.config.registrationType.startsWith('admin-')) { - throw new Error('[[register:invite.error-admin-only]]'); - } else { - throw new Error('[[register:invite.error-invite-only]]'); - } - } - const token = await db.getObjectField(`invitation:token:${query.token}`, 'token'); - if (!token || token !== query.token) { - throw new Error('[[register:invite.error-invalid-data]]'); - } - }; - - User.confirmIfInviteEmailIsUsed = async function (token, enteredEmail, uid) { - if (!enteredEmail) { - return; - } - const email = await db.getObjectField(`invitation:token:${token}`, 'email'); - // "Confirm" user's email if registration completed with invited address - if (email && email === enteredEmail) { - await User.email.confirmByUid(uid); - } - }; - - User.joinGroupsFromInvitation = async function (uid, token) { - let groupsToJoin = await db.getObjectField(`invitation:token:${token}`, 'groupsToJoin'); - - try { - groupsToJoin = JSON.parse(groupsToJoin); - } catch (e) { - return; - } - - if (!groupsToJoin || groupsToJoin.length < 1) { - return; - } - - await groups.join(groupsToJoin, uid); - }; - - User.deleteInvitation = async function (invitedBy, email) { - const invitedByUid = await User.getUidByUsername(invitedBy); - if (!invitedByUid) { - throw new Error('[[error:invalid-username]]'); - } - const token = await db.get(`invitation:uid:${invitedByUid}:invited:${email}`); - await Promise.all([ - deleteFromReferenceList(invitedByUid, email), - db.setRemove(`invitation:invited:${email}`, token), - db.delete(`invitation:token:${token}`), - ]); - }; - - User.deleteInvitationKey = async function (registrationEmail, token) { - if (registrationEmail) { - const uids = await User.getInvitingUsers(); - await Promise.all(uids.map(uid => deleteFromReferenceList(uid, registrationEmail))); - // Delete all invites to an email address if it has joined - const tokens = await db.getSetMembers(`invitation:invited:${registrationEmail}`); - const keysToDelete = [`invitation:invited:${registrationEmail}`].concat(tokens.map(token => `invitation:token:${token}`)); - await db.deleteAll(keysToDelete); - } - if (token) { - const invite = await db.getObject(`invitation:token:${token}`); - if (!invite) { - return; - } - await deleteFromReferenceList(invite.inviter, invite.email); - await db.deleteAll([ - `invitation:invited:${invite.email}`, - `invitation:token:${token}`, - ]); - } - }; - - async function deleteFromReferenceList(uid, email) { - await Promise.all([ - db.setRemove(`invitation:uid:${uid}`, email), - db.delete(`invitation:uid:${uid}:invited:${email}`), - ]); - const count = await db.setCount(`invitation:uid:${uid}`); - if (count === 0) { - await db.setRemove('invitation:uids', uid); - } - } - - async function prepareInvitation(uid, email, groupsToJoin) { - const inviterExists = await User.exists(uid); - if (!inviterExists) { - throw new Error('[[error:invalid-uid]]'); - } - - const token = utils.generateUUID(); - const registerLink = `${nconf.get('url')}/register?token=${token}`; - - const expireDays = meta.config.inviteExpiration; - const expireIn = expireDays * 86400000; - - await db.setAdd(`invitation:uid:${uid}`, email); - await db.setAdd('invitation:uids', uid); - // Referencing from uid and email to token - await db.set(`invitation:uid:${uid}:invited:${email}`, token); - // Keeping references for all invites to this email address - await db.setAdd(`invitation:invited:${email}`, token); - await db.setObject(`invitation:token:${token}`, { - email, - token, - groupsToJoin: JSON.stringify(groupsToJoin), - inviter: uid, - }); - await db.pexpireAt(`invitation:token:${token}`, Date.now() + expireIn); - - const username = await User.getUserField(uid, 'username'); - const title = meta.config.title || meta.config.browserTitle || 'NodeBB'; - const subject = await translator.translate(`[[email:invite, ${title}]]`, meta.config.defaultLang); - - return { - ...emailer._defaultPayload, // Append default data to this email payload - site_title: title, - registerLink: registerLink, - subject: subject, - username: username, - template: 'invitation', - expireDays: expireDays, - }; - } + User.getInvites = async function (uid) { + const emails = await db.getSetMembers(`invitation:uid:${uid}`); + return emails.map(email => validator.escape(String(email))); + }; + + User.getInvitesNumber = async function (uid) { + return await db.setCount(`invitation:uid:${uid}`); + }; + + User.getInvitingUsers = async function () { + return await db.getSetMembers('invitation:uids'); + }; + + User.getAllInvites = async function () { + const uids = await User.getInvitingUsers(); + const invitations = await async.map(uids, User.getInvites); + return invitations.map((invites, index) => ({ + uid: uids[index], + invitations: invites, + })); + }; + + User.sendInvitationEmail = async function (uid, email, groupsToJoin) { + if (!uid) { + throw new Error('[[error:invalid-uid]]'); + } + + const email_exists = await User.getUidByEmail(email); + if (email_exists) { + // Silently drop the invitation if the invited email already exists locally + return true; + } + + const invitation_exists = await db.exists(`invitation:uid:${uid}:invited:${email}`); + if (invitation_exists) { + throw new Error('[[error:email-invited]]'); + } + + const data = await prepareInvitation(uid, email, groupsToJoin); + await emailer.sendToEmail('invitation', email, meta.config.defaultLang, data); + plugins.hooks.fire('action:user.invite', {uid, email, groupsToJoin}); + }; + + User.verifyInvitation = async function (query) { + if (!query.token) { + const error = meta.config.registrationType.startsWith('admin-') ? new Error('[[register:invite.error-admin-only]]') : new Error('[[register:invite.error-invite-only]]'); + throw error; + } + + const token = await db.getObjectField(`invitation:token:${query.token}`, 'token'); + if (!token || token !== query.token) { + throw new Error('[[register:invite.error-invalid-data]]'); + } + }; + + User.confirmIfInviteEmailIsUsed = async function (token, enteredEmail, uid) { + if (!enteredEmail) { + return; + } + + const email = await db.getObjectField(`invitation:token:${token}`, 'email'); + // "Confirm" user's email if registration completed with invited address + if (email && email === enteredEmail) { + await User.email.confirmByUid(uid); + } + }; + + User.joinGroupsFromInvitation = async function (uid, token) { + let groupsToJoin = await db.getObjectField(`invitation:token:${token}`, 'groupsToJoin'); + + try { + groupsToJoin = JSON.parse(groupsToJoin); + } catch { + return; + } + + if (!groupsToJoin || groupsToJoin.length === 0) { + return; + } + + await groups.join(groupsToJoin, uid); + }; + + User.deleteInvitation = async function (invitedBy, email) { + const invitedByUid = await User.getUidByUsername(invitedBy); + if (!invitedByUid) { + throw new Error('[[error:invalid-username]]'); + } + + const token = await db.get(`invitation:uid:${invitedByUid}:invited:${email}`); + await Promise.all([ + deleteFromReferenceList(invitedByUid, email), + db.setRemove(`invitation:invited:${email}`, token), + db.delete(`invitation:token:${token}`), + ]); + }; + + User.deleteInvitationKey = async function (registrationEmail, token) { + if (registrationEmail) { + const uids = await User.getInvitingUsers(); + await Promise.all(uids.map(uid => deleteFromReferenceList(uid, registrationEmail))); + // Delete all invites to an email address if it has joined + const tokens = await db.getSetMembers(`invitation:invited:${registrationEmail}`); + const keysToDelete = [`invitation:invited:${registrationEmail}`].concat(tokens.map(token => `invitation:token:${token}`)); + await db.deleteAll(keysToDelete); + } + + if (token) { + const invite = await db.getObject(`invitation:token:${token}`); + if (!invite) { + return; + } + + await deleteFromReferenceList(invite.inviter, invite.email); + await db.deleteAll([ + `invitation:invited:${invite.email}`, + `invitation:token:${token}`, + ]); + } + }; + + async function deleteFromReferenceList(uid, email) { + await Promise.all([ + db.setRemove(`invitation:uid:${uid}`, email), + db.delete(`invitation:uid:${uid}:invited:${email}`), + ]); + const count = await db.setCount(`invitation:uid:${uid}`); + if (count === 0) { + await db.setRemove('invitation:uids', uid); + } + } + + async function prepareInvitation(uid, email, groupsToJoin) { + const inviterExists = await User.exists(uid); + if (!inviterExists) { + throw new Error('[[error:invalid-uid]]'); + } + + const token = utils.generateUUID(); + const registerLink = `${nconf.get('url')}/register?token=${token}`; + + const expireDays = meta.config.inviteExpiration; + const expireIn = expireDays * 86_400_000; + + await db.setAdd(`invitation:uid:${uid}`, email); + await db.setAdd('invitation:uids', uid); + // Referencing from uid and email to token + await db.set(`invitation:uid:${uid}:invited:${email}`, token); + // Keeping references for all invites to this email address + await db.setAdd(`invitation:invited:${email}`, token); + await db.setObject(`invitation:token:${token}`, { + email, + token, + groupsToJoin: JSON.stringify(groupsToJoin), + inviter: uid, + }); + await db.pexpireAt(`invitation:token:${token}`, Date.now() + expireIn); + + const username = await User.getUserField(uid, 'username'); + const title = meta.config.title || meta.config.browserTitle || 'NodeBB'; + const subject = await translator.translate(`[[email:invite, ${title}]]`, meta.config.defaultLang); + + return { + ...emailer._defaultPayload, // Append default data to this email payload + site_title: title, + registerLink, + subject, + username, + template: 'invitation', + expireDays, + }; + } }; diff --git a/src/user/jobs.js b/src/user/jobs.js index 2b244e4..5167877 100644 --- a/src/user/jobs.js +++ b/src/user/jobs.js @@ -8,59 +8,61 @@ const meta = require('../meta'); const jobs = {}; module.exports = function (User) { - User.startJobs = function () { - winston.verbose('[user/jobs] (Re-)starting jobs...'); + User.startJobs = function () { + winston.verbose('[user/jobs] (Re-)starting jobs...'); - let { digestHour } = meta.config; + let {digestHour} = meta.config; - // Fix digest hour if invalid - if (isNaN(digestHour)) { - digestHour = 17; - } else if (digestHour > 23 || digestHour < 0) { - digestHour = 0; - } + // Fix digest hour if invalid + if (isNaN(digestHour)) { + digestHour = 17; + } else if (digestHour > 23 || digestHour < 0) { + digestHour = 0; + } - User.stopJobs(); + User.stopJobs(); - startDigestJob('digest.daily', `0 ${digestHour} * * *`, 'day'); - startDigestJob('digest.weekly', `0 ${digestHour} * * 0`, 'week'); - startDigestJob('digest.monthly', `0 ${digestHour} 1 * *`, 'month'); + startDigestJob('digest.daily', `0 ${digestHour} * * *`, 'day'); + startDigestJob('digest.weekly', `0 ${digestHour} * * 0`, 'week'); + startDigestJob('digest.monthly', `0 ${digestHour} 1 * *`, 'month'); - jobs['reset.clean'] = new cronJob('0 0 * * *', User.reset.clean, null, true); - winston.verbose('[user/jobs] Starting job (reset.clean)'); + jobs['reset.clean'] = new cronJob('0 0 * * *', User.reset.clean, null, true); + winston.verbose('[user/jobs] Starting job (reset.clean)'); - winston.verbose(`[user/jobs] jobs started`); - }; + winston.verbose('[user/jobs] jobs started'); + }; - function startDigestJob(name, cronString, term) { - jobs[name] = new cronJob(cronString, (async () => { - winston.verbose(`[user/jobs] Digest job (${name}) started.`); - try { - if (name === 'digest.weekly') { - const counter = await db.increment('biweeklydigestcounter'); - if (counter % 2) { - await User.digest.execute({ interval: 'biweek' }); - } - } - await User.digest.execute({ interval: term }); - } catch (err) { - winston.error(err.stack); - } - }), null, true); - winston.verbose(`[user/jobs] Starting job (${name})`); - } + function startDigestJob(name, cronString, term) { + jobs[name] = new cronJob(cronString, (async () => { + winston.verbose(`[user/jobs] Digest job (${name}) started.`); + try { + if (name === 'digest.weekly') { + const counter = await db.increment('biweeklydigestcounter'); + if (counter % 2) { + await User.digest.execute({interval: 'biweek'}); + } + } - User.stopJobs = function () { - let terminated = 0; - // Terminate any active cron jobs - for (const jobId of Object.keys(jobs)) { - winston.verbose(`[user/jobs] Terminating job (${jobId})`); - jobs[jobId].stop(); - delete jobs[jobId]; - terminated += 1; - } - if (terminated > 0) { - winston.verbose(`[user/jobs] ${terminated} jobs terminated`); - } - }; + await User.digest.execute({interval: term}); + } catch (error) { + winston.error(error.stack); + } + }), null, true); + winston.verbose(`[user/jobs] Starting job (${name})`); + } + + User.stopJobs = function () { + let terminated = 0; + // Terminate any active cron jobs + for (const jobId of Object.keys(jobs)) { + winston.verbose(`[user/jobs] Terminating job (${jobId})`); + jobs[jobId].stop(); + delete jobs[jobId]; + terminated += 1; + } + + if (terminated > 0) { + winston.verbose(`[user/jobs] ${terminated} jobs terminated`); + } + }; }; diff --git a/src/user/jobs/export-posts.js b/src/user/jobs/export-posts.js index c07a089..525cb4e 100644 --- a/src/user/jobs/export-posts.js +++ b/src/user/jobs/export-posts.js @@ -3,11 +3,11 @@ const nconf = require('nconf'); nconf.argv().env({ - separator: '__', + separator: '__', }); -const fs = require('fs'); -const path = require('path'); +const fs = require('node:fs'); +const path = require('node:path'); const json2csvAsync = require('json2csv').parseAsync; process.env.NODE_ENV = process.env.NODE_ENV || 'production'; @@ -22,35 +22,35 @@ prestart.setupWinston(); const db = require('../../database'); const batch = require('../../batch'); -process.on('message', async (msg) => { - if (msg && msg.uid) { - await db.init(); - - const targetUid = msg.uid; - const filePath = path.join(__dirname, '../../../build/export', `${targetUid}_posts.csv`); - - const posts = require('../../posts'); - - let payload = []; - await batch.processSortedSet(`uid:${targetUid}:posts`, async (pids) => { - let postData = await posts.getPostsData(pids); - // Remove empty post references and convert newlines in content - postData = postData.filter(Boolean).map((post) => { - post.content = `"${String(post.content || '').replace(/\n/g, '\\n').replace(/"/g, '\\"')}"`; - return post; - }); - payload = payload.concat(postData); - }, { - batch: 500, - interval: 1000, - }); - - const fields = payload.length ? Object.keys(payload[0]) : []; - const opts = { fields }; - const csv = await json2csvAsync(payload, opts); - await fs.promises.writeFile(filePath, csv); - - await db.close(); - process.exit(0); - } +process.on('message', async message => { + if (message && message.uid) { + await db.init(); + + const targetUid = message.uid; + const filePath = path.join(__dirname, '../../../build/export', `${targetUid}_posts.csv`); + + const posts = require('../../posts'); + + let payload = []; + await batch.processSortedSet(`uid:${targetUid}:posts`, async pids => { + let postData = await posts.getPostsData(pids); + // Remove empty post references and convert newlines in content + postData = postData.filter(Boolean).map(post => { + post.content = `"${String(post.content || '').replaceAll('\n', '\\n').replaceAll('"', '\\"')}"`; + return post; + }); + payload = payload.concat(postData); + }, { + batch: 500, + interval: 1000, + }); + + const fields = payload.length > 0 ? Object.keys(payload[0]) : []; + const options = {fields}; + const csv = await json2csvAsync(payload, options); + await fs.promises.writeFile(filePath, csv); + + await db.close(); + process.exit(0); + } }); diff --git a/src/user/jobs/export-profile.js b/src/user/jobs/export-profile.js index eb36b0e..6cf459a 100644 --- a/src/user/jobs/export-profile.js +++ b/src/user/jobs/export-profile.js @@ -3,11 +3,11 @@ const nconf = require('nconf'); nconf.argv().env({ - separator: '__', + separator: '__', }); -const fs = require('fs'); -const path = require('path'); +const fs = require('node:fs'); +const path = require('node:path'); const _ = require('lodash'); process.env.NODE_ENV = process.env.NODE_ENV || 'production'; @@ -22,103 +22,118 @@ prestart.setupWinston(); const db = require('../../database'); const batch = require('../../batch'); -process.on('message', async (msg) => { - if (msg && msg.uid) { - await db.init(); - await db.initSessionStore(); - - const targetUid = msg.uid; - - const profileFile = `${targetUid}_profile.json`; - const profilePath = path.join(__dirname, '../../../build/export', profileFile); - - const user = require('../index'); - const [ - userData, - userSettings, - ips, - sessions, - usernames, - emails, - bookmarks, - watchedTopics, - upvoted, - downvoted, - following, - ] = await Promise.all([ - db.getObject(`user:${targetUid}`), - db.getObject(`user:${targetUid}:settings`), - user.getIPs(targetUid, 9), - user.auth.getSessions(targetUid), - user.getHistory(`user:${targetUid}:usernames`), - user.getHistory(`user:${targetUid}:emails`), - getSetData(`uid:${targetUid}:bookmarks`, 'post:', targetUid), - getSetData(`uid:${targetUid}:followed_tids`, 'topic:', targetUid), - getSetData(`uid:${targetUid}:upvote`, 'post:', targetUid), - getSetData(`uid:${targetUid}:downvote`, 'post:', targetUid), - getSetData(`following:${targetUid}`, 'user:', targetUid), - ]); - delete userData.password; - - let chatData = []; - await batch.processSortedSet(`uid:${targetUid}:chat:rooms`, async (roomIds) => { - const result = await Promise.all(roomIds.map(roomId => getRoomMessages(targetUid, roomId))); - chatData = chatData.concat(_.flatten(result)); - }, { batch: 100, interval: 1000 }); - - await fs.promises.writeFile(profilePath, JSON.stringify({ - user: userData, - settings: userSettings, - ips: ips, - sessions: sessions, - usernames: usernames, - emails: emails, - messages: chatData, - bookmarks: bookmarks, - watchedTopics: watchedTopics, - upvoted: upvoted, - downvoted: downvoted, - following: following, - }, null, 4)); - - await db.close(); - process.exit(0); - } +process.on('message', async message => { + if (message && message.uid) { + await db.init(); + await db.initSessionStore(); + + const targetUid = message.uid; + + const profileFile = `${targetUid}_profile.json`; + const profilePath = path.join(__dirname, '../../../build/export', profileFile); + + const user = require('../index'); + const [ + userData, + userSettings, + ips, + sessions, + usernames, + emails, + bookmarks, + watchedTopics, + upvoted, + downvoted, + following, + ] = await Promise.all([ + db.getObject(`user:${targetUid}`), + db.getObject(`user:${targetUid}:settings`), + user.getIPs(targetUid, 9), + user.auth.getSessions(targetUid), + user.getHistory(`user:${targetUid}:usernames`), + user.getHistory(`user:${targetUid}:emails`), + getSetData(`uid:${targetUid}:bookmarks`, 'post:', targetUid), + getSetData(`uid:${targetUid}:followed_tids`, 'topic:', targetUid), + getSetData(`uid:${targetUid}:upvote`, 'post:', targetUid), + getSetData(`uid:${targetUid}:downvote`, 'post:', targetUid), + getSetData(`following:${targetUid}`, 'user:', targetUid), + ]); + delete userData.password; + + let chatData = []; + await batch.processSortedSet(`uid:${targetUid}:chat:rooms`, async roomIds => { + const result = await Promise.all(roomIds.map(roomId => getRoomMessages(targetUid, roomId))); + chatData = chatData.concat(result.flat()); + }, {batch: 100, interval: 1000}); + + await fs.promises.writeFile(profilePath, JSON.stringify({ + user: userData, + settings: userSettings, + ips, + sessions, + usernames, + emails, + messages: chatData, + bookmarks, + watchedTopics, + upvoted, + downvoted, + following, + }, null, 4)); + + await db.close(); + process.exit(0); + } }); async function getRoomMessages(uid, roomId) { - const batch = require('../../batch'); - let data = []; - await batch.processSortedSet(`uid:${uid}:chat:room:${roomId}:mids`, async (mids) => { - const messageData = await db.getObjects(mids.map(mid => `message:${mid}`)); - data = data.concat( - messageData - .filter(m => m && m.fromuid === uid && !m.system) - .map(m => ({ content: m.content, timestamp: m.timestamp })) - ); - }, { batch: 500, interval: 1000 }); - return data; + const batch = require('../../batch'); + let data = []; + await batch.processSortedSet(`uid:${uid}:chat:room:${roomId}:mids`, async mids => { + const messageData = await db.getObjects(mids.map(mid => `message:${mid}`)); + data = data.concat( + messageData + .filter(m => m && m.fromuid === uid && !m.system) + .map(m => ({content: m.content, timestamp: m.timestamp})), + ); + }, {batch: 500, interval: 1000}); + return data; } async function getSetData(set, keyPrefix, uid) { - const privileges = require('../../privileges'); - const batch = require('../../batch'); - let data = []; - await batch.processSortedSet(set, async (ids) => { - if (keyPrefix === 'post:') { - ids = await privileges.posts.filter('topics:read', ids, uid); - } else if (keyPrefix === 'topic:') { - ids = await privileges.topics.filterTids('topics:read', ids, uid); - } - let objData = await db.getObjects(ids.map(id => keyPrefix + id)); - if (keyPrefix === 'post:') { - objData = objData.map(o => _.pick(o, ['pid', 'content', 'timestamp'])); - } else if (keyPrefix === 'topic:') { - objData = objData.map(o => _.pick(o, ['tid', 'title', 'timestamp'])); - } else if (keyPrefix === 'user:') { - objData = objData.map(o => _.pick(o, ['uid', 'username'])); - } - data = data.concat(objData); - }, { batch: 500, interval: 1000 }); - return data; + const privileges = require('../../privileges'); + const batch = require('../../batch'); + let data = []; + await batch.processSortedSet(set, async ids => { + if (keyPrefix === 'post:') { + ids = await privileges.posts.filter('topics:read', ids, uid); + } else if (keyPrefix === 'topic:') { + ids = await privileges.topics.filterTids('topics:read', ids, uid); + } + + let objectData = await db.getObjects(ids.map(id => keyPrefix + id)); + switch (keyPrefix) { + case 'post:': { + objectData = objectData.map(o => _.pick(o, ['pid', 'content', 'timestamp'])); + + break; + } + + case 'topic:': { + objectData = objectData.map(o => _.pick(o, ['tid', 'title', 'timestamp'])); + + break; + } + + case 'user:': { + objectData = objectData.map(o => _.pick(o, ['uid', 'username'])); + + break; + } + // No default + } + + data = data.concat(objectData); + }, {batch: 500, interval: 1000}); + return data; } diff --git a/src/user/jobs/export-uploads.js b/src/user/jobs/export-uploads.js index 11569e6..238e573 100644 --- a/src/user/jobs/export-uploads.js +++ b/src/user/jobs/export-uploads.js @@ -3,11 +3,11 @@ const nconf = require('nconf'); nconf.argv().env({ - separator: '__', + separator: '__', }); -const fs = require('fs'); -const path = require('path'); +const fs = require('node:fs'); +const path = require('node:path'); const archiver = require('archiver'); const winston = require('winston'); @@ -22,66 +22,71 @@ prestart.setupWinston(); const db = require('../../database'); -process.on('message', async (msg) => { - if (msg && msg.uid) { - await db.init(); - - const targetUid = msg.uid; - - const archivePath = path.join(__dirname, '../../../build/export', `${targetUid}_uploads.zip`); - const rootDirectory = path.join(__dirname, '../../../public/uploads/'); - - const user = require('../index'); - - const archive = archiver('zip', { - zlib: { level: 9 }, // Sets the compression level. - }); - - archive.on('warning', (err) => { - switch (err.code) { - case 'ENOENT': - winston.warn(`[user/export/uploads] File not found: ${err.path}`); - break; - - default: - winston.warn(`[user/export/uploads] Unexpected warning: ${err.message}`); - break; - } - }); - - archive.on('error', (err) => { - const trimPath = function (path) { - return path.replace(rootDirectory, ''); - }; - switch (err.code) { - case 'EACCES': - winston.error(`[user/export/uploads] File inaccessible: ${trimPath(err.path)}`); - break; - - default: - winston.error(`[user/export/uploads] Unable to construct archive: ${err.message}`); - break; - } - }); - - const output = fs.createWriteStream(archivePath); - output.on('close', async () => { - await db.close(); - process.exit(0); - }); - - archive.pipe(output); - winston.verbose(`[user/export/uploads] Collating uploads for uid ${targetUid}`); - await user.collateUploads(targetUid, archive); - - const uploadedPicture = await user.getUserField(targetUid, 'uploadedpicture'); - if (uploadedPicture) { - const filePath = uploadedPicture.replace(nconf.get('upload_url'), ''); - archive.file(path.join(nconf.get('upload_path'), filePath), { - name: path.basename(filePath), - }); - } - - archive.finalize(); - } +process.on('message', async message => { + if (message && message.uid) { + await db.init(); + + const targetUid = message.uid; + + const archivePath = path.join(__dirname, '../../../build/export', `${targetUid}_uploads.zip`); + const rootDirectory = path.join(__dirname, '../../../public/uploads/'); + + const user = require('../index'); + + const archive = archiver('zip', { + zlib: {level: 9}, // Sets the compression level. + }); + + archive.on('warning', error => { + switch (error.code) { + case 'ENOENT': { + winston.warn(`[user/export/uploads] File not found: ${error.path}`); + break; + } + + default: { + winston.warn(`[user/export/uploads] Unexpected warning: ${error.message}`); + break; + } + } + }); + + archive.on('error', error => { + const trimPath = function (path) { + return path.replace(rootDirectory, ''); + }; + + switch (error.code) { + case 'EACCES': { + winston.error(`[user/export/uploads] File inaccessible: ${trimPath(error.path)}`); + break; + } + + default: { + winston.error(`[user/export/uploads] Unable to construct archive: ${error.message}`); + break; + } + } + }); + + const output = fs.createWriteStream(archivePath); + output.on('close', async () => { + await db.close(); + process.exit(0); + }); + + archive.pipe(output); + winston.verbose(`[user/export/uploads] Collating uploads for uid ${targetUid}`); + await user.collateUploads(targetUid, archive); + + const uploadedPicture = await user.getUserField(targetUid, 'uploadedpicture'); + if (uploadedPicture) { + const filePath = uploadedPicture.replace(nconf.get('upload_url'), ''); + archive.file(path.join(nconf.get('upload_path'), filePath), { + name: path.basename(filePath), + }); + } + + archive.finalize(); + } }); diff --git a/src/user/notifications.js b/src/user/notifications.js index de19e56..4dc5fe8 100644 --- a/src/user/notifications.js +++ b/src/user/notifications.js @@ -3,7 +3,6 @@ const winston = require('winston'); const _ = require('lodash'); - const db = require('../database'); const meta = require('../meta'); const notifications = require('../notifications'); @@ -14,220 +13,228 @@ const utils = require('../utils'); const UserNotifications = module.exports; UserNotifications.get = async function (uid) { - if (parseInt(uid, 10) <= 0) { - return { read: [], unread: [] }; - } - - let unread = await getNotificationsFromSet(`uid:${uid}:notifications:unread`, uid, 0, 49); - unread = unread.filter(Boolean); - let read = []; - if (unread.length < 50) { - read = await getNotificationsFromSet(`uid:${uid}:notifications:read`, uid, 0, 49 - unread.length); - } - - return await plugins.hooks.fire('filter:user.notifications.get', { - uid, - read: read.filter(Boolean), - unread: unread, - }); + if (Number.parseInt(uid, 10) <= 0) { + return {read: [], unread: []}; + } + + let unread = await getNotificationsFromSet(`uid:${uid}:notifications:unread`, uid, 0, 49); + unread = unread.filter(Boolean); + let read = []; + if (unread.length < 50) { + read = await getNotificationsFromSet(`uid:${uid}:notifications:read`, uid, 0, 49 - unread.length); + } + + return await plugins.hooks.fire('filter:user.notifications.get', { + uid, + read: read.filter(Boolean), + unread, + }); }; async function filterNotifications(nids, filter) { - if (!filter) { - return nids; - } - const keys = nids.map(nid => `notifications:${nid}`); - const notifications = await db.getObjectsFields(keys, ['nid', 'type']); - return notifications.filter(n => n && n.nid && n.type === filter).map(n => n.nid); + if (!filter) { + return nids; + } + + const keys = nids.map(nid => `notifications:${nid}`); + const notifications = await db.getObjectsFields(keys, ['nid', 'type']); + return notifications.filter(n => n && n.nid && n.type === filter).map(n => n.nid); } UserNotifications.getAll = async function (uid, filter) { - let nids = await db.getSortedSetRevRange([ - `uid:${uid}:notifications:unread`, - `uid:${uid}:notifications:read`, - ], 0, -1); - nids = _.uniq(nids); - const exists = await db.isSortedSetMembers('notifications', nids); - const deleteNids = []; - - nids = nids.filter((nid, index) => { - if (!nid || !exists[index]) { - deleteNids.push(nid); - } - return nid && exists[index]; - }); - - await deleteUserNids(deleteNids, uid); - return await filterNotifications(nids, filter); + let nids = await db.getSortedSetRevRange([ + `uid:${uid}:notifications:unread`, + `uid:${uid}:notifications:read`, + ], 0, -1); + nids = _.uniq(nids); + const exists = await db.isSortedSetMembers('notifications', nids); + const deleteNids = []; + + nids = nids.filter((nid, index) => { + if (!nid || !exists[index]) { + deleteNids.push(nid); + } + + return nid && exists[index]; + }); + + await deleteUserNids(deleteNids, uid); + return await filterNotifications(nids, filter); }; async function deleteUserNids(nids, uid) { - await db.sortedSetRemove([ - `uid:${uid}:notifications:read`, - `uid:${uid}:notifications:unread`, - ], nids); + await db.sortedSetRemove([ + `uid:${uid}:notifications:read`, + `uid:${uid}:notifications:unread`, + ], nids); } async function getNotificationsFromSet(set, uid, start, stop) { - const nids = await db.getSortedSetRevRange(set, start, stop); - return await UserNotifications.getNotifications(nids, uid); + const nids = await db.getSortedSetRevRange(set, start, stop); + return await UserNotifications.getNotifications(nids, uid); } UserNotifications.getNotifications = async function (nids, uid) { - if (!Array.isArray(nids) || !nids.length) { - return []; - } - - const [notifObjs, hasRead] = await Promise.all([ - notifications.getMultiple(nids), - db.isSortedSetMembers(`uid:${uid}:notifications:read`, nids), - ]); - - const deletedNids = []; - let notificationData = notifObjs.filter((notification, index) => { - if (!notification || !notification.nid) { - deletedNids.push(nids[index]); - } - if (notification) { - notification.read = hasRead[index]; - notification.readClass = !notification.read ? 'unread' : ''; - } - - return notification; - }); - - await deleteUserNids(deletedNids, uid); - notificationData = await notifications.merge(notificationData); - const result = await plugins.hooks.fire('filter:user.notifications.getNotifications', { - uid: uid, - notifications: notificationData, - }); - return result && result.notifications; + if (!Array.isArray(nids) || nids.length === 0) { + return []; + } + + const [notificationObjs, hasRead] = await Promise.all([ + notifications.getMultiple(nids), + db.isSortedSetMembers(`uid:${uid}:notifications:read`, nids), + ]); + + const deletedNids = []; + let notificationData = notificationObjs.filter((notification, index) => { + if (!notification || !notification.nid) { + deletedNids.push(nids[index]); + } + + if (notification) { + notification.read = hasRead[index]; + notification.readClass = notification.read ? '' : 'unread'; + } + + return notification; + }); + + await deleteUserNids(deletedNids, uid); + notificationData = await notifications.merge(notificationData); + const result = await plugins.hooks.fire('filter:user.notifications.getNotifications', { + uid, + notifications: notificationData, + }); + return result && result.notifications; }; UserNotifications.getUnreadInterval = async function (uid, interval) { - const dayInMs = 1000 * 60 * 60 * 24; - const times = { - day: dayInMs, - week: 7 * dayInMs, - month: 30 * dayInMs, - }; - if (!times[interval]) { - return []; - } - const min = Date.now() - times[interval]; - const nids = await db.getSortedSetRevRangeByScore(`uid:${uid}:notifications:unread`, 0, 20, '+inf', min); - return await UserNotifications.getNotifications(nids, uid); + const dayInMs = 1000 * 60 * 60 * 24; + const times = { + day: dayInMs, + week: 7 * dayInMs, + month: 30 * dayInMs, + }; + if (!times[interval]) { + return []; + } + + const min = Date.now() - times[interval]; + const nids = await db.getSortedSetRevRangeByScore(`uid:${uid}:notifications:unread`, 0, 20, '+inf', min); + return await UserNotifications.getNotifications(nids, uid); }; UserNotifications.getDailyUnread = async function (uid) { - return await UserNotifications.getUnreadInterval(uid, 'day'); + return await UserNotifications.getUnreadInterval(uid, 'day'); }; UserNotifications.getUnreadCount = async function (uid) { - if (parseInt(uid, 10) <= 0) { - return 0; - } - let nids = await db.getSortedSetRevRange(`uid:${uid}:notifications:unread`, 0, 99); - nids = await notifications.filterExists(nids); - const keys = nids.map(nid => `notifications:${nid}`); - const notifData = await db.getObjectsFields(keys, ['mergeId']); - const mergeIds = notifData.map(n => n.mergeId); - - // Collapse any notifications with identical mergeIds - let count = mergeIds.reduce((count, mergeId, idx, arr) => { - // A missing (null) mergeId means that notification is counted separately. - if (mergeId === null || idx === arr.indexOf(mergeId)) { - count += 1; - } - - return count; - }, 0); - - ({ count } = await plugins.hooks.fire('filter:user.notifications.getCount', { uid, count })); - return count; + if (Number.parseInt(uid, 10) <= 0) { + return 0; + } + + let nids = await db.getSortedSetRevRange(`uid:${uid}:notifications:unread`, 0, 99); + nids = await notifications.filterExists(nids); + const keys = nids.map(nid => `notifications:${nid}`); + const notificationData = await db.getObjectsFields(keys, ['mergeId']); + const mergeIds = notificationData.map(n => n.mergeId); + + // Collapse any notifications with identical mergeIds + let count = mergeIds.reduce((count, mergeId, index, array) => { + // A missing (null) mergeId means that notification is counted separately. + if (mergeId === null || index === array.indexOf(mergeId)) { + count += 1; + } + + return count; + }, 0); + + ({count} = await plugins.hooks.fire('filter:user.notifications.getCount', {uid, count})); + return count; }; UserNotifications.getUnreadByField = async function (uid, field, values) { - const nids = await db.getSortedSetRevRange(`uid:${uid}:notifications:unread`, 0, 99); - if (!nids.length) { - return []; - } - const keys = nids.map(nid => `notifications:${nid}`); - const notifData = await db.getObjectsFields(keys, ['nid', field]); - const valuesSet = new Set(values.map(value => String(value))); - return notifData.filter(n => n && n[field] && valuesSet.has(String(n[field]))).map(n => n.nid); + const nids = await db.getSortedSetRevRange(`uid:${uid}:notifications:unread`, 0, 99); + if (nids.length === 0) { + return []; + } + + const keys = nids.map(nid => `notifications:${nid}`); + const notificationData = await db.getObjectsFields(keys, ['nid', field]); + const valuesSet = new Set(values.map(String)); + return notificationData.filter(n => n && n[field] && valuesSet.has(String(n[field]))).map(n => n.nid); }; UserNotifications.deleteAll = async function (uid) { - if (parseInt(uid, 10) <= 0) { - return; - } - await db.deleteAll([ - `uid:${uid}:notifications:unread`, - `uid:${uid}:notifications:read`, - ]); + if (Number.parseInt(uid, 10) <= 0) { + return; + } + + await db.deleteAll([ + `uid:${uid}:notifications:unread`, + `uid:${uid}:notifications:read`, + ]); }; UserNotifications.sendTopicNotificationToFollowers = async function (uid, topicData, postData) { - try { - let followers = await db.getSortedSetRange(`followers:${uid}`, 0, -1); - followers = await privileges.categories.filterUids('read', topicData.cid, followers); - if (!followers.length) { - return; - } - let { title } = topicData; - if (title) { - title = utils.decodeHTMLEntities(title); - title = title.replace(/,/g, '\\,'); - } - - const notifObj = await notifications.create({ - type: 'new-topic', - bodyShort: `[[notifications:user_posted_topic, ${postData.user.displayname}, ${title}]]`, - bodyLong: postData.content, - pid: postData.pid, - path: `/post/${postData.pid}`, - nid: `tid:${postData.tid}:uid:${uid}`, - tid: postData.tid, - from: uid, - }); - - await notifications.push(notifObj, followers); - } catch (err) { - winston.error(err.stack); - } + try { + let followers = await db.getSortedSetRange(`followers:${uid}`, 0, -1); + followers = await privileges.categories.filterUids('read', topicData.cid, followers); + if (followers.length === 0) { + return; + } + + let {title} = topicData; + if (title) { + title = utils.decodeHTMLEntities(title); + title = title.replaceAll(',', '\\,'); + } + + const notificationObject = await notifications.create({ + type: 'new-topic', + bodyShort: `[[notifications:user_posted_topic, ${postData.user.displayname}, ${title}]]`, + bodyLong: postData.content, + pid: postData.pid, + path: `/post/${postData.pid}`, + nid: `tid:${postData.tid}:uid:${uid}`, + tid: postData.tid, + from: uid, + }); + + await notifications.push(notificationObject, followers); + } catch (error) { + winston.error(error.stack); + } }; UserNotifications.sendWelcomeNotification = async function (uid) { - if (!meta.config.welcomeNotification) { - return; - } - - const path = meta.config.welcomeLink ? meta.config.welcomeLink : '#'; - const notifObj = await notifications.create({ - bodyShort: meta.config.welcomeNotification, - path: path, - nid: `welcome_${uid}`, - from: meta.config.welcomeUid ? meta.config.welcomeUid : null, - }); - - await notifications.push(notifObj, [uid]); + if (!meta.config.welcomeNotification) { + return; + } + + const path = meta.config.welcomeLink ? meta.config.welcomeLink : '#'; + const notificationObject = await notifications.create({ + bodyShort: meta.config.welcomeNotification, + path, + nid: `welcome_${uid}`, + from: meta.config.welcomeUid ? meta.config.welcomeUid : null, + }); + + await notifications.push(notificationObject, [uid]); }; UserNotifications.sendNameChangeNotification = async function (uid, username) { - const notifObj = await notifications.create({ - bodyShort: `[[user:username_taken_workaround, ${username}]]`, - image: 'brand:logo', - nid: `username_taken:${uid}`, - datetime: Date.now(), - }); - - await notifications.push(notifObj, uid); + const notificationObject = await notifications.create({ + bodyShort: `[[user:username_taken_workaround, ${username}]]`, + image: 'brand:logo', + nid: `username_taken:${uid}`, + datetime: Date.now(), + }); + + await notifications.push(notificationObject, uid); }; UserNotifications.pushCount = async function (uid) { - const websockets = require('../socket.io'); - const count = await UserNotifications.getUnreadCount(uid); - websockets.in(`uid_${uid}`).emit('event:notifications.updateCount', count); + const websockets = require('../socket.io'); + const count = await UserNotifications.getUnreadCount(uid); + websockets.in(`uid_${uid}`).emit('event:notifications.updateCount', count); }; diff --git a/src/user/online.js b/src/user/online.js index a57f25f..238d58f 100644 --- a/src/user/online.js +++ b/src/user/online.js @@ -6,38 +6,42 @@ const plugins = require('../plugins'); const meta = require('../meta'); module.exports = function (User) { - User.updateLastOnlineTime = async function (uid) { - if (!(parseInt(uid, 10) > 0)) { - return; - } - const userData = await db.getObjectFields(`user:${uid}`, ['status', 'lastonline']); - const now = Date.now(); - if (userData.status === 'offline' || now - parseInt(userData.lastonline, 10) < 300000) { - return; - } - await User.setUserField(uid, 'lastonline', now); - }; - - User.updateOnlineUsers = async function (uid) { - if (!(parseInt(uid, 10) > 0)) { - return; - } - const now = Date.now(); - const userOnlineTime = await db.sortedSetScore('users:online', uid); - if (now - parseInt(userOnlineTime, 10) < 300000) { - return; - } - await db.sortedSetAdd('users:online', now, uid); - topics.pushUnreadCount(uid); - plugins.hooks.fire('action:user.online', { uid: uid, timestamp: now }); - }; - - User.isOnline = async function (uid) { - const now = Date.now(); - const isArray = Array.isArray(uid); - uid = isArray ? uid : [uid]; - const lastonline = await db.sortedSetScores('users:online', uid); - const isOnline = uid.map((uid, index) => (now - lastonline[index]) < (meta.config.onlineCutoff * 60000)); - return isArray ? isOnline : isOnline[0]; - }; + User.updateLastOnlineTime = async function (uid) { + if (!(Number.parseInt(uid, 10) > 0)) { + return; + } + + const userData = await db.getObjectFields(`user:${uid}`, ['status', 'lastonline']); + const now = Date.now(); + if (userData.status === 'offline' || now - Number.parseInt(userData.lastonline, 10) < 300_000) { + return; + } + + await User.setUserField(uid, 'lastonline', now); + }; + + User.updateOnlineUsers = async function (uid) { + if (!(Number.parseInt(uid, 10) > 0)) { + return; + } + + const now = Date.now(); + const userOnlineTime = await db.sortedSetScore('users:online', uid); + if (now - Number.parseInt(userOnlineTime, 10) < 300_000) { + return; + } + + await db.sortedSetAdd('users:online', now, uid); + topics.pushUnreadCount(uid); + plugins.hooks.fire('action:user.online', {uid, timestamp: now}); + }; + + User.isOnline = async function (uid) { + const now = Date.now(); + const isArray = Array.isArray(uid); + uid = isArray ? uid : [uid]; + const lastonline = await db.sortedSetScores('users:online', uid); + const isOnline = uid.map((uid, index) => (now - lastonline[index]) < (meta.config.onlineCutoff * 60_000)); + return isArray ? isOnline : isOnline[0]; + }; }; diff --git a/src/user/password.js b/src/user/password.js index 0285f4c..c8794a6 100644 --- a/src/user/password.js +++ b/src/user/password.js @@ -1,47 +1,46 @@ 'use strict'; - const nconf = require('nconf'); - const db = require('../database'); const Password = require('../password'); module.exports = function (User) { - User.hashPassword = async function (password) { - if (!password) { - return password; - } - - return await Password.hash(nconf.get('bcrypt_rounds') || 12, password); - }; - - User.isPasswordCorrect = async function (uid, password, ip) { - password = password || ''; - let { - password: hashedPassword, - 'password:shaWrapped': shaWrapped, - } = await db.getObjectFields(`user:${uid}`, ['password', 'password:shaWrapped']); - if (!hashedPassword) { - // Non-existant user, submit fake hash for comparison - hashedPassword = ''; - } - - try { - User.isPasswordValid(password, 0); - } catch (e) { - return false; - } - - await User.auth.logAttempt(uid, ip); - const ok = await Password.compare(password, hashedPassword, !!parseInt(shaWrapped, 10)); - if (ok) { - await User.auth.clearLoginAttempts(uid); - } - return ok; - }; - - User.hasPassword = async function (uid) { - const hashedPassword = await db.getObjectField(`user:${uid}`, 'password'); - return !!hashedPassword; - }; + User.hashPassword = async function (password) { + if (!password) { + return password; + } + + return await Password.hash(nconf.get('bcrypt_rounds') || 12, password); + }; + + User.isPasswordCorrect = async function (uid, password, ip) { + password ||= ''; + let { + password: hashedPassword, + 'password:shaWrapped': shaWrapped, + } = await db.getObjectFields(`user:${uid}`, ['password', 'password:shaWrapped']); + if (!hashedPassword) { + // Non-existant user, submit fake hash for comparison + hashedPassword = ''; + } + + try { + User.isPasswordValid(password, 0); + } catch { + return false; + } + + await User.auth.logAttempt(uid, ip); + const ok = await Password.compare(password, hashedPassword, Boolean(Number.parseInt(shaWrapped, 10))); + if (ok) { + await User.auth.clearLoginAttempts(uid); + } + + return ok; + }; + + User.hasPassword = async function (uid) { + const hashedPassword = await db.getObjectField(`user:${uid}`, 'password'); + return Boolean(hashedPassword); + }; }; diff --git a/src/user/picture.js b/src/user/picture.js index 234f5c3..2e5cc24 100644 --- a/src/user/picture.js +++ b/src/user/picture.js @@ -1,233 +1,237 @@ 'use strict'; +const path = require('node:path'); const winston = require('winston'); const mime = require('mime'); -const path = require('path'); const nconf = require('nconf'); - const db = require('../database'); const file = require('../file'); const image = require('../image'); const meta = require('../meta'); module.exports = function (User) { - User.getAllowedProfileImageExtensions = function () { - const exts = User.getAllowedImageTypes().map(type => mime.getExtension(type)); - if (exts.includes('jpeg')) { - exts.push('jpg'); - } - return exts; - }; - - User.getAllowedImageTypes = function () { - return ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']; - }; - - User.updateCoverPosition = async function (uid, position) { - // Reject anything that isn't two percentages - if (!/^[\d.]+%\s[\d.]+%$/.test(position)) { - winston.warn(`[user/updateCoverPosition] Invalid position received: ${position}`); - throw new Error('[[error:invalid-data]]'); - } - - await User.setUserField(uid, 'cover:position', position); - }; - - User.updateCoverPicture = async function (data) { - const picture = { - name: 'profileCover', - uid: data.uid, - }; - - try { - if (!data.imageData && data.position) { - return await User.updateCoverPosition(data.uid, data.position); - } - - validateUpload(data, meta.config.maximumCoverImageSize, ['image/png', 'image/jpeg', 'image/bmp']); - - picture.path = await image.writeImageDataToTempFile(data.imageData); - - const extension = file.typeToExtension(image.mimeFromBase64(data.imageData)); - const filename = `${data.uid}-profilecover-${Date.now()}${extension}`; - const uploadData = await image.uploadImage(filename, 'profile', picture); - - await deleteCurrentPicture(data.uid, 'cover:url'); - await User.setUserField(data.uid, 'cover:url', uploadData.url); - - if (data.position) { - await User.updateCoverPosition(data.uid, data.position); - } - - return { - url: uploadData.url, - }; - } finally { - await file.delete(picture.path); - } - }; - - // uploads a image file as profile picture - User.uploadCroppedPictureFile = async function (data) { - const userPhoto = data.file; - if (!meta.config.allowProfileImageUploads) { - throw new Error('[[error:profile-image-uploads-disabled]]'); - } - - if (userPhoto.size > meta.config.maximumProfileImageSize * 1024) { - throw new Error(`[[error:file-too-big, ${meta.config.maximumProfileImageSize}]]`); - } - - if (!userPhoto.type || !User.getAllowedImageTypes().includes(userPhoto.type)) { - throw new Error('[[error:invalid-image]]'); - } - - const extension = file.typeToExtension(userPhoto.type); - if (!extension) { - throw new Error('[[error:invalid-image-extension]]'); - } - - const newPath = await convertToPNG(userPhoto.path); - - await image.resizeImage({ - path: newPath, - width: meta.config.profileImageDimension, - height: meta.config.profileImageDimension, - }); - - const filename = generateProfileImageFilename(data.uid, extension); - const uploadedImage = await image.uploadImage(filename, 'profile', { - uid: data.uid, - path: newPath, - name: 'profileAvatar', - }); - - await deleteCurrentPicture(data.uid, 'uploadedpicture'); - await User.updateProfile(data.callerUid, { - uid: data.uid, - uploadedpicture: uploadedImage.url, - picture: uploadedImage.url, - }, ['uploadedpicture', 'picture']); - return uploadedImage; - }; - - // uploads image data in base64 as profile picture - User.uploadCroppedPicture = async function (data) { - const picture = { - name: 'profileAvatar', - uid: data.uid, - }; - - try { - if (!meta.config.allowProfileImageUploads) { - throw new Error('[[error:profile-image-uploads-disabled]]'); - } - - validateUpload(data, meta.config.maximumProfileImageSize, User.getAllowedImageTypes()); - - const extension = file.typeToExtension(image.mimeFromBase64(data.imageData)); - if (!extension) { - throw new Error('[[error:invalid-image-extension]]'); - } - - picture.path = await image.writeImageDataToTempFile(data.imageData); - picture.path = await convertToPNG(picture.path); - - await image.resizeImage({ - path: picture.path, - width: meta.config.profileImageDimension, - height: meta.config.profileImageDimension, - }); - - const filename = generateProfileImageFilename(data.uid, extension); - const uploadedImage = await image.uploadImage(filename, 'profile', picture); - - await deleteCurrentPicture(data.uid, 'uploadedpicture'); - await User.updateProfile(data.callerUid, { - uid: data.uid, - uploadedpicture: uploadedImage.url, - picture: uploadedImage.url, - }, ['uploadedpicture', 'picture']); - return uploadedImage; - } finally { - await file.delete(picture.path); - } - }; - - async function deleteCurrentPicture(uid, field) { - if (meta.config['profile:keepAllUserImages']) { - return; - } - await deletePicture(uid, field); - } - - async function deletePicture(uid, field) { - const uploadPath = await getPicturePath(uid, field); - if (uploadPath) { - await file.delete(uploadPath); - } - } - - function validateUpload(data, maxSize, allowedTypes) { - if (!data.imageData) { - throw new Error('[[error:invalid-data]]'); - } - const size = image.sizeFromBase64(data.imageData); - if (size > maxSize * 1024) { - throw new Error(`[[error:file-too-big, ${maxSize}]]`); - } - - const type = image.mimeFromBase64(data.imageData); - if (!type || !allowedTypes.includes(type)) { - throw new Error('[[error:invalid-image]]'); - } - } - - async function convertToPNG(path) { - const convertToPNG = meta.config['profile:convertProfileImageToPNG'] === 1; - if (!convertToPNG) { - return path; - } - const newPath = await image.normalise(path); - await file.delete(path); - return newPath; - } - - function generateProfileImageFilename(uid, extension) { - const convertToPNG = meta.config['profile:convertProfileImageToPNG'] === 1; - return `${uid}-profileavatar-${Date.now()}${convertToPNG ? '.png' : extension}`; - } - - User.removeCoverPicture = async function (data) { - await deletePicture(data.uid, 'cover:url'); - await db.deleteObjectFields(`user:${data.uid}`, ['cover:url', 'cover:position']); - }; - - User.removeProfileImage = async function (uid) { - const userData = await User.getUserFields(uid, ['uploadedpicture', 'picture']); - await deletePicture(uid, 'uploadedpicture'); - await User.setUserFields(uid, { - uploadedpicture: '', - // if current picture is uploaded picture, reset to user icon - picture: userData.uploadedpicture === userData.picture ? '' : userData.picture, - }); - return userData; - }; - - User.getLocalCoverPath = async function (uid) { - return getPicturePath(uid, 'cover:url'); - }; - - User.getLocalAvatarPath = async function (uid) { - return getPicturePath(uid, 'uploadedpicture'); - }; - - async function getPicturePath(uid, field) { - const value = await User.getUserField(uid, field); - if (!value || !value.startsWith(`${nconf.get('relative_path')}/assets/uploads/profile/`)) { - return false; - } - const filename = value.split('/').pop(); - return path.join(nconf.get('upload_path'), 'profile', filename); - } + User.getAllowedProfileImageExtensions = function () { + const extensions = User.getAllowedImageTypes().map(type => mime.getExtension(type)); + if (extensions.includes('jpeg')) { + extensions.push('jpg'); + } + + return extensions; + }; + + User.getAllowedImageTypes = function () { + return ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']; + }; + + User.updateCoverPosition = async function (uid, position) { + // Reject anything that isn't two percentages + if (!/^[\d.]+%\s[\d.]+%$/.test(position)) { + winston.warn(`[user/updateCoverPosition] Invalid position received: ${position}`); + throw new Error('[[error:invalid-data]]'); + } + + await User.setUserField(uid, 'cover:position', position); + }; + + User.updateCoverPicture = async function (data) { + const picture = { + name: 'profileCover', + uid: data.uid, + }; + + try { + if (!data.imageData && data.position) { + return await User.updateCoverPosition(data.uid, data.position); + } + + validateUpload(data, meta.config.maximumCoverImageSize, ['image/png', 'image/jpeg', 'image/bmp']); + + picture.path = await image.writeImageDataToTempFile(data.imageData); + + const extension = file.typeToExtension(image.mimeFromBase64(data.imageData)); + const filename = `${data.uid}-profilecover-${Date.now()}${extension}`; + const uploadData = await image.uploadImage(filename, 'profile', picture); + + await deleteCurrentPicture(data.uid, 'cover:url'); + await User.setUserField(data.uid, 'cover:url', uploadData.url); + + if (data.position) { + await User.updateCoverPosition(data.uid, data.position); + } + + return { + url: uploadData.url, + }; + } finally { + await file.delete(picture.path); + } + }; + + // Uploads a image file as profile picture + User.uploadCroppedPictureFile = async function (data) { + const userPhoto = data.file; + if (!meta.config.allowProfileImageUploads) { + throw new Error('[[error:profile-image-uploads-disabled]]'); + } + + if (userPhoto.size > meta.config.maximumProfileImageSize * 1024) { + throw new Error(`[[error:file-too-big, ${meta.config.maximumProfileImageSize}]]`); + } + + if (!userPhoto.type || !User.getAllowedImageTypes().includes(userPhoto.type)) { + throw new Error('[[error:invalid-image]]'); + } + + const extension = file.typeToExtension(userPhoto.type); + if (!extension) { + throw new Error('[[error:invalid-image-extension]]'); + } + + const newPath = await convertToPNG(userPhoto.path); + + await image.resizeImage({ + path: newPath, + width: meta.config.profileImageDimension, + height: meta.config.profileImageDimension, + }); + + const filename = generateProfileImageFilename(data.uid, extension); + const uploadedImage = await image.uploadImage(filename, 'profile', { + uid: data.uid, + path: newPath, + name: 'profileAvatar', + }); + + await deleteCurrentPicture(data.uid, 'uploadedpicture'); + await User.updateProfile(data.callerUid, { + uid: data.uid, + uploadedpicture: uploadedImage.url, + picture: uploadedImage.url, + }, ['uploadedpicture', 'picture']); + return uploadedImage; + }; + + // Uploads image data in base64 as profile picture + User.uploadCroppedPicture = async function (data) { + const picture = { + name: 'profileAvatar', + uid: data.uid, + }; + + try { + if (!meta.config.allowProfileImageUploads) { + throw new Error('[[error:profile-image-uploads-disabled]]'); + } + + validateUpload(data, meta.config.maximumProfileImageSize, User.getAllowedImageTypes()); + + const extension = file.typeToExtension(image.mimeFromBase64(data.imageData)); + if (!extension) { + throw new Error('[[error:invalid-image-extension]]'); + } + + picture.path = await image.writeImageDataToTempFile(data.imageData); + picture.path = await convertToPNG(picture.path); + + await image.resizeImage({ + path: picture.path, + width: meta.config.profileImageDimension, + height: meta.config.profileImageDimension, + }); + + const filename = generateProfileImageFilename(data.uid, extension); + const uploadedImage = await image.uploadImage(filename, 'profile', picture); + + await deleteCurrentPicture(data.uid, 'uploadedpicture'); + await User.updateProfile(data.callerUid, { + uid: data.uid, + uploadedpicture: uploadedImage.url, + picture: uploadedImage.url, + }, ['uploadedpicture', 'picture']); + return uploadedImage; + } finally { + await file.delete(picture.path); + } + }; + + async function deleteCurrentPicture(uid, field) { + if (meta.config['profile:keepAllUserImages']) { + return; + } + + await deletePicture(uid, field); + } + + async function deletePicture(uid, field) { + const uploadPath = await getPicturePath(uid, field); + if (uploadPath) { + await file.delete(uploadPath); + } + } + + function validateUpload(data, maxSize, allowedTypes) { + if (!data.imageData) { + throw new Error('[[error:invalid-data]]'); + } + + const size = image.sizeFromBase64(data.imageData); + if (size > maxSize * 1024) { + throw new Error(`[[error:file-too-big, ${maxSize}]]`); + } + + const type = image.mimeFromBase64(data.imageData); + if (!type || !allowedTypes.includes(type)) { + throw new Error('[[error:invalid-image]]'); + } + } + + async function convertToPNG(path) { + const convertToPNG = meta.config['profile:convertProfileImageToPNG'] === 1; + if (!convertToPNG) { + return path; + } + + const newPath = await image.normalise(path); + await file.delete(path); + return newPath; + } + + function generateProfileImageFilename(uid, extension) { + const convertToPNG = meta.config['profile:convertProfileImageToPNG'] === 1; + return `${uid}-profileavatar-${Date.now()}${convertToPNG ? '.png' : extension}`; + } + + User.removeCoverPicture = async function (data) { + await deletePicture(data.uid, 'cover:url'); + await db.deleteObjectFields(`user:${data.uid}`, ['cover:url', 'cover:position']); + }; + + User.removeProfileImage = async function (uid) { + const userData = await User.getUserFields(uid, ['uploadedpicture', 'picture']); + await deletePicture(uid, 'uploadedpicture'); + await User.setUserFields(uid, { + uploadedpicture: '', + // If current picture is uploaded picture, reset to user icon + picture: userData.uploadedpicture === userData.picture ? '' : userData.picture, + }); + return userData; + }; + + User.getLocalCoverPath = async function (uid) { + return getPicturePath(uid, 'cover:url'); + }; + + User.getLocalAvatarPath = async function (uid) { + return getPicturePath(uid, 'uploadedpicture'); + }; + + async function getPicturePath(uid, field) { + const value = await User.getUserField(uid, field); + if (!value || !value.startsWith(`${nconf.get('relative_path')}/assets/uploads/profile/`)) { + return false; + } + + const filename = value.split('/').pop(); + return path.join(nconf.get('upload_path'), 'profile', filename); + } }; diff --git a/src/user/posts.js b/src/user/posts.js index 47a6eb3..8b097b9 100644 --- a/src/user/posts.js +++ b/src/user/posts.js @@ -5,118 +5,121 @@ const meta = require('../meta'); const privileges = require('../privileges'); module.exports = function (User) { - User.isReadyToPost = async function (uid, cid) { - await isReady(uid, cid, 'lastposttime'); - }; - - User.isReadyToQueue = async function (uid, cid) { - await isReady(uid, cid, 'lastqueuetime'); - }; - - async function isReady(uid, cid, field) { - if (parseInt(uid, 10) === 0) { - return; - } - const [userData, isAdminOrMod] = await Promise.all([ - User.getUserFields(uid, ['uid', 'mutedUntil', 'joindate', 'email', 'reputation'].concat([field])), - privileges.categories.isAdminOrMod(cid, uid), - ]); - - if (!userData.uid) { - throw new Error('[[error:no-user]]'); - } - - if (isAdminOrMod) { - return; - } - - const now = Date.now(); - if (userData.mutedUntil > now) { - let muteLeft = ((userData.mutedUntil - now) / (1000 * 60)); - if (muteLeft > 60) { - muteLeft = (muteLeft / 60).toFixed(0); - throw new Error(`[[error:user-muted-for-hours, ${muteLeft}]]`); - } else { - throw new Error(`[[error:user-muted-for-minutes, ${muteLeft.toFixed(0)}]]`); - } - } - - if (now - userData.joindate < meta.config.initialPostDelay * 1000) { - throw new Error(`[[error:user-too-new, ${meta.config.initialPostDelay}]]`); - } - - const lasttime = userData[field] || 0; - - if ( - meta.config.newbiePostDelay > 0 && - meta.config.newbiePostDelayThreshold > userData.reputation && - now - lasttime < meta.config.newbiePostDelay * 1000 - ) { - throw new Error(`[[error:too-many-posts-newbie, ${meta.config.newbiePostDelay}, ${meta.config.newbiePostDelayThreshold}]]`); - } else if (now - lasttime < meta.config.postDelay * 1000) { - throw new Error(`[[error:too-many-posts, ${meta.config.postDelay}]]`); - } - } - - User.onNewPostMade = async function (postData) { - // For scheduled posts, use "action" time. It'll be updated in related cron job when post is published - const lastposttime = postData.timestamp > Date.now() ? Date.now() : postData.timestamp; - - await Promise.all([ - User.addPostIdToUser(postData), - User.setUserField(postData.uid, 'lastposttime', lastposttime), - User.updateLastOnlineTime(postData.uid), - ]); - }; - - User.addPostIdToUser = async function (postData) { - await db.sortedSetsAdd([ - `uid:${postData.uid}:posts`, - `cid:${postData.cid}:uid:${postData.uid}:pids`, - ], postData.timestamp, postData.pid); - await User.updatePostCount(postData.uid); - }; - - User.updatePostCount = async (uids) => { - uids = Array.isArray(uids) ? uids : [uids]; - const exists = await User.exists(uids); - uids = uids.filter((uid, index) => exists[index]); - if (uids.length) { - const counts = await db.sortedSetsCard(uids.map(uid => `uid:${uid}:posts`)); - await Promise.all([ - db.setObjectBulk(uids.map((uid, index) => ([`user:${uid}`, { postcount: counts[index] }]))), - db.sortedSetAdd('users:postcount', counts, uids), - ]); - } - }; - - User.incrementUserPostCountBy = async function (uid, value) { - return await incrementUserFieldAndSetBy(uid, 'postcount', 'users:postcount', value); - }; - - User.incrementUserReputationBy = async function (uid, value) { - return await incrementUserFieldAndSetBy(uid, 'reputation', 'users:reputation', value); - }; - - User.incrementUserFlagsBy = async function (uid, value) { - return await incrementUserFieldAndSetBy(uid, 'flags', 'users:flags', value); - }; - - async function incrementUserFieldAndSetBy(uid, field, set, value) { - value = parseInt(value, 10); - if (!value || !field || !(parseInt(uid, 10) > 0)) { - return; - } - const exists = await User.exists(uid); - if (!exists) { - return; - } - const newValue = await User.incrementUserFieldBy(uid, field, value); - await db.sortedSetAdd(set, newValue, uid); - return newValue; - } - - User.getPostIds = async function (uid, start, stop) { - return await db.getSortedSetRevRange(`uid:${uid}:posts`, start, stop); - }; + User.isReadyToPost = async function (uid, cid) { + await isReady(uid, cid, 'lastposttime'); + }; + + User.isReadyToQueue = async function (uid, cid) { + await isReady(uid, cid, 'lastqueuetime'); + }; + + async function isReady(uid, cid, field) { + if (Number.parseInt(uid, 10) === 0) { + return; + } + + const [userData, isAdminOrModule] = await Promise.all([ + User.getUserFields(uid, ['uid', 'mutedUntil', 'joindate', 'email', 'reputation'].concat([field])), + privileges.categories.isAdminOrMod(cid, uid), + ]); + + if (!userData.uid) { + throw new Error('[[error:no-user]]'); + } + + if (isAdminOrModule) { + return; + } + + const now = Date.now(); + if (userData.mutedUntil > now) { + let muteLeft = ((userData.mutedUntil - now) / (1000 * 60)); + if (muteLeft > 60) { + muteLeft = (muteLeft / 60).toFixed(0); + throw new Error(`[[error:user-muted-for-hours, ${muteLeft}]]`); + } else { + throw new Error(`[[error:user-muted-for-minutes, ${muteLeft.toFixed(0)}]]`); + } + } + + if (now - userData.joindate < meta.config.initialPostDelay * 1000) { + throw new Error(`[[error:user-too-new, ${meta.config.initialPostDelay}]]`); + } + + const lasttime = userData[field] || 0; + + if ( + meta.config.newbiePostDelay > 0 + && meta.config.newbiePostDelayThreshold > userData.reputation + && now - lasttime < meta.config.newbiePostDelay * 1000 + ) { + throw new Error(`[[error:too-many-posts-newbie, ${meta.config.newbiePostDelay}, ${meta.config.newbiePostDelayThreshold}]]`); + } else if (now - lasttime < meta.config.postDelay * 1000) { + throw new Error(`[[error:too-many-posts, ${meta.config.postDelay}]]`); + } + } + + User.onNewPostMade = async function (postData) { + // For scheduled posts, use "action" time. It'll be updated in related cron job when post is published + const lastposttime = postData.timestamp > Date.now() ? Date.now() : postData.timestamp; + + await Promise.all([ + User.addPostIdToUser(postData), + User.setUserField(postData.uid, 'lastposttime', lastposttime), + User.updateLastOnlineTime(postData.uid), + ]); + }; + + User.addPostIdToUser = async function (postData) { + await db.sortedSetsAdd([ + `uid:${postData.uid}:posts`, + `cid:${postData.cid}:uid:${postData.uid}:pids`, + ], postData.timestamp, postData.pid); + await User.updatePostCount(postData.uid); + }; + + User.updatePostCount = async uids => { + uids = Array.isArray(uids) ? uids : [uids]; + const exists = await User.exists(uids); + uids = uids.filter((uid, index) => exists[index]); + if (uids.length > 0) { + const counts = await db.sortedSetsCard(uids.map(uid => `uid:${uid}:posts`)); + await Promise.all([ + db.setObjectBulk(uids.map((uid, index) => ([`user:${uid}`, {postcount: counts[index]}]))), + db.sortedSetAdd('users:postcount', counts, uids), + ]); + } + }; + + User.incrementUserPostCountBy = async function (uid, value) { + return await incrementUserFieldAndSetBy(uid, 'postcount', 'users:postcount', value); + }; + + User.incrementUserReputationBy = async function (uid, value) { + return await incrementUserFieldAndSetBy(uid, 'reputation', 'users:reputation', value); + }; + + User.incrementUserFlagsBy = async function (uid, value) { + return await incrementUserFieldAndSetBy(uid, 'flags', 'users:flags', value); + }; + + async function incrementUserFieldAndSetBy(uid, field, set, value) { + value = Number.parseInt(value, 10); + if (!value || !field || !(Number.parseInt(uid, 10) > 0)) { + return; + } + + const exists = await User.exists(uid); + if (!exists) { + return; + } + + const newValue = await User.incrementUserFieldBy(uid, field, value); + await db.sortedSetAdd(set, newValue, uid); + return newValue; + } + + User.getPostIds = async function (uid, start, stop) { + return await db.getSortedSetRevRange(`uid:${uid}:posts`, start, stop); + }; }; diff --git a/src/user/profile.js b/src/user/profile.js index 5274a6d..f871f04 100644 --- a/src/user/profile.js +++ b/src/user/profile.js @@ -4,7 +4,6 @@ const _ = require('lodash'); const validator = require('validator'); const winston = require('winston'); - const utils = require('../utils'); const slugify = require('../slugify'); const meta = require('../meta'); @@ -13,323 +12,361 @@ const groups = require('../groups'); const plugins = require('../plugins'); module.exports = function (User) { - User.updateProfile = async function (uid, data, extraFields) { - let fields = [ - 'username', 'email', 'fullname', 'website', 'location', - 'groupTitle', 'birthday', 'signature', 'aboutme', - ]; - if (Array.isArray(extraFields)) { - fields = _.uniq(fields.concat(extraFields)); - } - if (!data.uid) { - throw new Error('[[error:invalid-update-uid]]'); - } - const updateUid = data.uid; - - const result = await plugins.hooks.fire('filter:user.updateProfile', { - uid: uid, - data: data, - fields: fields, - }); - fields = result.fields; - data = result.data; - - await validateData(uid, data); - - const oldData = await User.getUserFields(updateUid, fields); - const updateData = {}; - await Promise.all(fields.map(async (field) => { - if (!(data[field] !== undefined && typeof data[field] === 'string')) { - return; - } - - data[field] = data[field].trim(); - - if (field === 'email') { - return await updateEmail(updateUid, data.email); - } else if (field === 'username') { - return await updateUsername(updateUid, data.username); - } else if (field === 'fullname') { - return await updateFullname(updateUid, data.fullname); - } - updateData[field] = data[field]; - })); - - if (Object.keys(updateData).length) { - await User.setUserFields(updateUid, updateData); - } - - plugins.hooks.fire('action:user.updateProfile', { - uid: uid, - data: data, - fields: fields, - oldData: oldData, - }); - - return await User.getUserFields(updateUid, [ - 'email', 'username', 'userslug', - 'picture', 'icon:text', 'icon:bgColor', - ]); - }; - - async function validateData(callerUid, data) { - await isEmailValid(data); - await isUsernameAvailable(data, data.uid); - await isWebsiteValid(callerUid, data); - await isAboutMeValid(callerUid, data); - await isSignatureValid(callerUid, data); - isFullnameValid(data); - isLocationValid(data); - isBirthdayValid(data); - isGroupTitleValid(data); - } - - async function isEmailValid(data) { - if (!data.email) { - return; - } - - data.email = data.email.trim(); - if (!utils.isEmailValid(data.email)) { - throw new Error('[[error:invalid-email]]'); - } - } - - async function isUsernameAvailable(data, uid) { - if (!data.username) { - return; - } - data.username = data.username.trim(); - - let userData; - if (uid) { - userData = await User.getUserFields(uid, ['username', 'userslug']); - if (userData.username === data.username) { - return; - } - } - - if (data.username.length < meta.config.minimumUsernameLength) { - throw new Error('[[error:username-too-short]]'); - } - - if (data.username.length > meta.config.maximumUsernameLength) { - throw new Error('[[error:username-too-long]]'); - } - - const userslug = slugify(data.username); - if (!utils.isUserNameValid(data.username) || !userslug) { - throw new Error('[[error:invalid-username]]'); - } - - if (uid && userslug === userData.userslug) { - return; - } - const exists = await User.existsBySlug(userslug); - if (exists) { - throw new Error('[[error:username-taken]]'); - } - - const { error } = await plugins.hooks.fire('filter:username.check', { - username: data.username, - error: undefined, - }); - if (error) { - throw error; - } - } - User.checkUsername = async username => isUsernameAvailable({ username }); - - async function isWebsiteValid(callerUid, data) { - if (!data.website) { - return; - } - if (data.website.length > 255) { - throw new Error('[[error:invalid-website]]'); - } - await User.checkMinReputation(callerUid, data.uid, 'min:rep:website'); - } - - async function isAboutMeValid(callerUid, data) { - if (!data.aboutme) { - return; - } - if (data.aboutme !== undefined && data.aboutme.length > meta.config.maximumAboutMeLength) { - throw new Error(`[[error:about-me-too-long, ${meta.config.maximumAboutMeLength}]]`); - } - - await User.checkMinReputation(callerUid, data.uid, 'min:rep:aboutme'); - } - - async function isSignatureValid(callerUid, data) { - if (!data.signature) { - return; - } - const signature = data.signature.replace(/\r\n/g, '\n'); - if (signature.length > meta.config.maximumSignatureLength) { - throw new Error(`[[error:signature-too-long, ${meta.config.maximumSignatureLength}]]`); - } - await User.checkMinReputation(callerUid, data.uid, 'min:rep:signature'); - } - - function isFullnameValid(data) { - if (data.fullname && (validator.isURL(data.fullname) || data.fullname.length > 255)) { - throw new Error('[[error:invalid-fullname]]'); - } - } - - function isLocationValid(data) { - if (data.location && (validator.isURL(data.location) || data.location.length > 255)) { - throw new Error('[[error:invalid-location]]'); - } - } - - function isBirthdayValid(data) { - if (!data.birthday) { - return; - } - - const result = new Date(data.birthday); - if (result && result.toString() === 'Invalid Date') { - throw new Error('[[error:invalid-birthday]]'); - } - } - - function isGroupTitleValid(data) { - function checkTitle(title) { - if (title === 'registered-users' || groups.isPrivilegeGroup(title)) { - throw new Error('[[error:invalid-group-title]]'); - } - } - if (!data.groupTitle) { - return; - } - let groupTitles = []; - if (validator.isJSON(data.groupTitle)) { - groupTitles = JSON.parse(data.groupTitle); - if (!Array.isArray(groupTitles)) { - throw new Error('[[error:invalid-group-title]]'); - } - groupTitles.forEach(title => checkTitle(title)); - } else { - groupTitles = [data.groupTitle]; - checkTitle(data.groupTitle); - } - if (!meta.config.allowMultipleBadges && groupTitles.length > 1) { - data.groupTitle = JSON.stringify(groupTitles[0]); - } - } - - User.checkMinReputation = async function (callerUid, uid, setting) { - const isSelf = parseInt(callerUid, 10) === parseInt(uid, 10); - if (!isSelf || meta.config['reputation:disabled']) { - return; - } - const reputation = await User.getUserField(uid, 'reputation'); - if (reputation < meta.config[setting]) { - throw new Error(`[[error:not-enough-reputation-${setting.replace(/:/g, '-')}, ${meta.config[setting]}]]`); - } - }; - - async function updateEmail(uid, newEmail) { - let oldEmail = await User.getUserField(uid, 'email'); - oldEmail = oldEmail || ''; - if (oldEmail === newEmail) { - return; - } - - // 👉 Looking for email change logic? src/user/email.js (UserEmail.confirmByUid) - if (newEmail) { - await User.email.sendValidationEmail(uid, { - email: newEmail, - force: 1, - }).catch(err => winston.error(`[user.create] Validation email failed to send\n[emailer.send] ${err.stack}`)); - } - } - - async function updateUsername(uid, newUsername) { - if (!newUsername) { - return; - } - const userData = await User.getUserFields(uid, ['username', 'userslug']); - if (userData.username === newUsername) { - return; - } - const newUserslug = slugify(newUsername); - const now = Date.now(); - await Promise.all([ - updateUidMapping('username', uid, newUsername, userData.username), - updateUidMapping('userslug', uid, newUserslug, userData.userslug), - db.sortedSetAdd(`user:${uid}:usernames`, now, `${newUsername}:${now}`), - ]); - await db.sortedSetRemove('username:sorted', `${userData.username.toLowerCase()}:${uid}`); - await db.sortedSetAdd('username:sorted', 0, `${newUsername.toLowerCase()}:${uid}`); - } - - async function updateUidMapping(field, uid, value, oldValue) { - if (value === oldValue) { - return; - } - await db.sortedSetRemove(`${field}:uid`, oldValue); - await User.setUserField(uid, field, value); - if (value) { - await db.sortedSetAdd(`${field}:uid`, uid, value); - } - } - - async function updateFullname(uid, newFullname) { - const fullname = await User.getUserField(uid, 'fullname'); - await updateUidMapping('fullname', uid, newFullname, fullname); - if (newFullname !== fullname) { - if (fullname) { - await db.sortedSetRemove('fullname:sorted', `${fullname.toLowerCase()}:${uid}`); - } - if (newFullname) { - await db.sortedSetAdd('fullname:sorted', 0, `${newFullname.toLowerCase()}:${uid}`); - } - } - } - - User.changePassword = async function (uid, data) { - if (uid <= 0 || !data || !data.uid) { - throw new Error('[[error:invalid-uid]]'); - } - User.isPasswordValid(data.newPassword); - const [isAdmin, hasPassword] = await Promise.all([ - User.isAdministrator(uid), - User.hasPassword(uid), - ]); - - if (meta.config['password:disableEdit'] && !isAdmin) { - throw new Error('[[error:no-privileges]]'); - } - - const isSelf = parseInt(uid, 10) === parseInt(data.uid, 10); - - if (!isAdmin && !isSelf) { - throw new Error('[[user:change_password_error_privileges]]'); - } - - if (isSelf && hasPassword) { - const correct = await User.isPasswordCorrect(data.uid, data.currentPassword, data.ip); - if (!correct) { - throw new Error('[[user:change_password_error_wrong_current]]'); - } - } - - const hashedPassword = await User.hashPassword(data.newPassword); - await Promise.all([ - User.setUserFields(data.uid, { - password: hashedPassword, - 'password:shaWrapped': 1, - rss_token: utils.generateUUID(), - }), - User.reset.cleanByUid(data.uid), - User.reset.updateExpiry(data.uid), - User.auth.revokeAllSessions(data.uid), - User.email.expireValidation(data.uid), - ]); - - plugins.hooks.fire('action:password.change', { uid: uid, targetUid: data.uid }); - }; + User.updateProfile = async function (uid, data, extraFields) { + let fields = [ + 'username', + 'email', + 'fullname', + 'website', + 'location', + 'groupTitle', + 'birthday', + 'signature', + 'aboutme', + ]; + if (Array.isArray(extraFields)) { + fields = _.uniq(fields.concat(extraFields)); + } + + if (!data.uid) { + throw new Error('[[error:invalid-update-uid]]'); + } + + const updateUid = data.uid; + + const result = await plugins.hooks.fire('filter:user.updateProfile', { + uid, + data, + fields, + }); + fields = result.fields; + data = result.data; + + await validateData(uid, data); + + const oldData = await User.getUserFields(updateUid, fields); + const updateData = {}; + await Promise.all(fields.map(async field => { + if (!(data[field] !== undefined && typeof data[field] === 'string')) { + return; + } + + data[field] = data[field].trim(); + + if (field === 'email') { + return await updateEmail(updateUid, data.email); + } + + if (field === 'username') { + return await updateUsername(updateUid, data.username); + } + + if (field === 'fullname') { + return await updateFullname(updateUid, data.fullname); + } + + updateData[field] = data[field]; + })); + + if (Object.keys(updateData).length > 0) { + await User.setUserFields(updateUid, updateData); + } + + plugins.hooks.fire('action:user.updateProfile', { + uid, + data, + fields, + oldData, + }); + + return await User.getUserFields(updateUid, [ + 'email', + 'username', + 'userslug', + 'picture', + 'icon:text', + 'icon:bgColor', + ]); + }; + + async function validateData(callerUid, data) { + await isEmailValid(data); + await isUsernameAvailable(data, data.uid); + await isWebsiteValid(callerUid, data); + await isAboutMeValid(callerUid, data); + await isSignatureValid(callerUid, data); + isFullnameValid(data); + isLocationValid(data); + isBirthdayValid(data); + isGroupTitleValid(data); + } + + async function isEmailValid(data) { + if (!data.email) { + return; + } + + data.email = data.email.trim(); + if (!utils.isEmailValid(data.email)) { + throw new Error('[[error:invalid-email]]'); + } + } + + async function isUsernameAvailable(data, uid) { + if (!data.username) { + return; + } + + data.username = data.username.trim(); + + let userData; + if (uid) { + userData = await User.getUserFields(uid, ['username', 'userslug']); + if (userData.username === data.username) { + return; + } + } + + if (data.username.length < meta.config.minimumUsernameLength) { + throw new Error('[[error:username-too-short]]'); + } + + if (data.username.length > meta.config.maximumUsernameLength) { + throw new Error('[[error:username-too-long]]'); + } + + const userslug = slugify(data.username); + if (!utils.isUserNameValid(data.username) || !userslug) { + throw new Error('[[error:invalid-username]]'); + } + + if (uid && userslug === userData.userslug) { + return; + } + + const exists = await User.existsBySlug(userslug); + if (exists) { + throw new Error('[[error:username-taken]]'); + } + + const {error} = await plugins.hooks.fire('filter:username.check', { + username: data.username, + error: undefined, + }); + if (error) { + throw error; + } + } + + User.checkUsername = async username => isUsernameAvailable({username}); + + async function isWebsiteValid(callerUid, data) { + if (!data.website) { + return; + } + + if (data.website.length > 255) { + throw new Error('[[error:invalid-website]]'); + } + + await User.checkMinReputation(callerUid, data.uid, 'min:rep:website'); + } + + async function isAboutMeValid(callerUid, data) { + if (!data.aboutme) { + return; + } + + if (data.aboutme !== undefined && data.aboutme.length > meta.config.maximumAboutMeLength) { + throw new Error(`[[error:about-me-too-long, ${meta.config.maximumAboutMeLength}]]`); + } + + await User.checkMinReputation(callerUid, data.uid, 'min:rep:aboutme'); + } + + async function isSignatureValid(callerUid, data) { + if (!data.signature) { + return; + } + + const signature = data.signature.replaceAll('\r\n', '\n'); + if (signature.length > meta.config.maximumSignatureLength) { + throw new Error(`[[error:signature-too-long, ${meta.config.maximumSignatureLength}]]`); + } + + await User.checkMinReputation(callerUid, data.uid, 'min:rep:signature'); + } + + function isFullnameValid(data) { + if (data.fullname && (validator.isURL(data.fullname) || data.fullname.length > 255)) { + throw new Error('[[error:invalid-fullname]]'); + } + } + + function isLocationValid(data) { + if (data.location && (validator.isURL(data.location) || data.location.length > 255)) { + throw new Error('[[error:invalid-location]]'); + } + } + + function isBirthdayValid(data) { + if (!data.birthday) { + return; + } + + const result = new Date(data.birthday); + if (result && result.toString() === 'Invalid Date') { + throw new Error('[[error:invalid-birthday]]'); + } + } + + function isGroupTitleValid(data) { + function checkTitle(title) { + if (title === 'registered-users' || groups.isPrivilegeGroup(title)) { + throw new Error('[[error:invalid-group-title]]'); + } + } + + if (!data.groupTitle) { + return; + } + + let groupTitles = []; + if (validator.isJSON(data.groupTitle)) { + groupTitles = JSON.parse(data.groupTitle); + if (!Array.isArray(groupTitles)) { + throw new TypeError('[[error:invalid-group-title]]'); + } + + for (const title of groupTitles) { + checkTitle(title); + } + } else { + groupTitles = [data.groupTitle]; + checkTitle(data.groupTitle); + } + + if (!meta.config.allowMultipleBadges && groupTitles.length > 1) { + data.groupTitle = JSON.stringify(groupTitles[0]); + } + } + + User.checkMinReputation = async function (callerUid, uid, setting) { + const isSelf = Number.parseInt(callerUid, 10) === Number.parseInt(uid, 10); + if (!isSelf || meta.config['reputation:disabled']) { + return; + } + + const reputation = await User.getUserField(uid, 'reputation'); + if (reputation < meta.config[setting]) { + throw new Error(`[[error:not-enough-reputation-${setting.replaceAll(':', '-')}, ${meta.config[setting]}]]`); + } + }; + + async function updateEmail(uid, newEmail) { + let oldEmail = await User.getUserField(uid, 'email'); + oldEmail ||= ''; + if (oldEmail === newEmail) { + return; + } + + // 👉 Looking for email change logic? src/user/email.js (UserEmail.confirmByUid) + if (newEmail) { + await User.email.sendValidationEmail(uid, { + email: newEmail, + force: 1, + }).catch(error => winston.error(`[user.create] Validation email failed to send\n[emailer.send] ${error.stack}`)); + } + } + + async function updateUsername(uid, newUsername) { + if (!newUsername) { + return; + } + + const userData = await User.getUserFields(uid, ['username', 'userslug']); + if (userData.username === newUsername) { + return; + } + + const newUserslug = slugify(newUsername); + const now = Date.now(); + await Promise.all([ + updateUidMapping('username', uid, newUsername, userData.username), + updateUidMapping('userslug', uid, newUserslug, userData.userslug), + db.sortedSetAdd(`user:${uid}:usernames`, now, `${newUsername}:${now}`), + ]); + await db.sortedSetRemove('username:sorted', `${userData.username.toLowerCase()}:${uid}`); + await db.sortedSetAdd('username:sorted', 0, `${newUsername.toLowerCase()}:${uid}`); + } + + async function updateUidMapping(field, uid, value, oldValue) { + if (value === oldValue) { + return; + } + + await db.sortedSetRemove(`${field}:uid`, oldValue); + await User.setUserField(uid, field, value); + if (value) { + await db.sortedSetAdd(`${field}:uid`, uid, value); + } + } + + async function updateFullname(uid, newFullname) { + const fullname = await User.getUserField(uid, 'fullname'); + await updateUidMapping('fullname', uid, newFullname, fullname); + if (newFullname !== fullname) { + if (fullname) { + await db.sortedSetRemove('fullname:sorted', `${fullname.toLowerCase()}:${uid}`); + } + + if (newFullname) { + await db.sortedSetAdd('fullname:sorted', 0, `${newFullname.toLowerCase()}:${uid}`); + } + } + } + + User.changePassword = async function (uid, data) { + if (uid <= 0 || !data || !data.uid) { + throw new Error('[[error:invalid-uid]]'); + } + + User.isPasswordValid(data.newPassword); + const [isAdmin, hasPassword] = await Promise.all([ + User.isAdministrator(uid), + User.hasPassword(uid), + ]); + + if (meta.config['password:disableEdit'] && !isAdmin) { + throw new Error('[[error:no-privileges]]'); + } + + const isSelf = Number.parseInt(uid, 10) === Number.parseInt(data.uid, 10); + + if (!isAdmin && !isSelf) { + throw new Error('[[user:change_password_error_privileges]]'); + } + + if (isSelf && hasPassword) { + const correct = await User.isPasswordCorrect(data.uid, data.currentPassword, data.ip); + if (!correct) { + throw new Error('[[user:change_password_error_wrong_current]]'); + } + } + + const hashedPassword = await User.hashPassword(data.newPassword); + await Promise.all([ + User.setUserFields(data.uid, { + password: hashedPassword, + 'password:shaWrapped': 1, + rss_token: utils.generateUUID(), + }), + User.reset.cleanByUid(data.uid), + User.reset.updateExpiry(data.uid), + User.auth.revokeAllSessions(data.uid), + User.email.expireValidation(data.uid), + ]); + + plugins.hooks.fire('action:password.change', {uid, targetUid: data.uid}); + }; }; diff --git a/src/user/reset.js b/src/user/reset.js index 7940b46..eefa0a7 100644 --- a/src/user/reset.js +++ b/src/user/reset.js @@ -2,164 +2,167 @@ const nconf = require('nconf'); const winston = require('winston'); - -const user = require('./index'); const groups = require('../groups'); const utils = require('../utils'); const batch = require('../batch'); - const db = require('../database'); const meta = require('../meta'); const emailer = require('../emailer'); const Password = require('../password'); +const user = require('./index'); const UserReset = module.exports; -const twoHours = 7200000; +const twoHours = 7_200_000; UserReset.validate = async function (code) { - const uid = await db.getObjectField('reset:uid', code); - if (!uid) { - return false; - } - const issueDate = await db.sortedSetScore('reset:issueDate', code); - return parseInt(issueDate, 10) > Date.now() - twoHours; + const uid = await db.getObjectField('reset:uid', code); + if (!uid) { + return false; + } + + const issueDate = await db.sortedSetScore('reset:issueDate', code); + return Number.parseInt(issueDate, 10) > Date.now() - twoHours; }; UserReset.generate = async function (uid) { - const code = utils.generateUUID(); + const code = utils.generateUUID(); - // Invalidate past tokens (must be done prior) - await UserReset.cleanByUid(uid); + // Invalidate past tokens (must be done prior) + await UserReset.cleanByUid(uid); - await Promise.all([ - db.setObjectField('reset:uid', code, uid), - db.sortedSetAdd('reset:issueDate', Date.now(), code), - ]); - return code; + await Promise.all([ + db.setObjectField('reset:uid', code, uid), + db.sortedSetAdd('reset:issueDate', Date.now(), code), + ]); + return code; }; async function canGenerate(uid) { - const score = await db.sortedSetScore('reset:issueDate:uid', uid); - if (score > Date.now() - (1000 * 60)) { - throw new Error('[[error:reset-rate-limited]]'); - } + const score = await db.sortedSetScore('reset:issueDate:uid', uid); + if (score > Date.now() - (1000 * 60)) { + throw new Error('[[error:reset-rate-limited]]'); + } } UserReset.send = async function (email) { - const uid = await user.getUidByEmail(email); - if (!uid) { - throw new Error('[[error:invalid-email]]'); - } - await canGenerate(uid); - await db.sortedSetAdd('reset:issueDate:uid', Date.now(), uid); - const code = await UserReset.generate(uid); - await emailer.send('reset', uid, { - reset_link: `${nconf.get('url')}/reset/${code}`, - subject: '[[email:password-reset-requested]]', - template: 'reset', - uid: uid, - }).catch(err => winston.error(`[emailer.send] ${err.stack}`)); - - return code; + const uid = await user.getUidByEmail(email); + if (!uid) { + throw new Error('[[error:invalid-email]]'); + } + + await canGenerate(uid); + await db.sortedSetAdd('reset:issueDate:uid', Date.now(), uid); + const code = await UserReset.generate(uid); + await emailer.send('reset', uid, { + reset_link: `${nconf.get('url')}/reset/${code}`, + subject: '[[email:password-reset-requested]]', + template: 'reset', + uid, + }).catch(error => winston.error(`[emailer.send] ${error.stack}`)); + + return code; }; UserReset.commit = async function (code, password) { - user.isPasswordValid(password); - const validated = await UserReset.validate(code); - if (!validated) { - throw new Error('[[error:reset-code-not-valid]]'); - } - const uid = await db.getObjectField('reset:uid', code); - if (!uid) { - throw new Error('[[error:reset-code-not-valid]]'); - } - const userData = await db.getObjectFields( - `user:${uid}`, - ['password', 'passwordExpiry', 'password:shaWrapped'] - ); - const ok = await Password.compare(password, userData.password, !!parseInt(userData['password:shaWrapped'], 10)); - if (ok) { - throw new Error('[[error:reset-same-password]]'); - } - const hash = await user.hashPassword(password); - const data = { - password: hash, - 'password:shaWrapped': 1, - }; - - // don't verify email if password reset is due to expiry - const isPasswordExpired = userData.passwordExpiry && userData.passwordExpiry < Date.now(); - if (!isPasswordExpired) { - data['email:confirmed'] = 1; - await groups.join('verified-users', uid); - await groups.leave('unverified-users', uid); - } - - await Promise.all([ - user.setUserFields(uid, data), - db.deleteObjectField('reset:uid', code), - db.sortedSetRemoveBulk([ - ['reset:issueDate', code], - ['reset:issueDate:uid', uid], - ]), - user.reset.updateExpiry(uid), - user.auth.resetLockout(uid), - user.auth.revokeAllSessions(uid), - user.email.expireValidation(uid), - ]); + user.isPasswordValid(password); + const validated = await UserReset.validate(code); + if (!validated) { + throw new Error('[[error:reset-code-not-valid]]'); + } + + const uid = await db.getObjectField('reset:uid', code); + if (!uid) { + throw new Error('[[error:reset-code-not-valid]]'); + } + + const userData = await db.getObjectFields( + `user:${uid}`, + ['password', 'passwordExpiry', 'password:shaWrapped'], + ); + const ok = await Password.compare(password, userData.password, Boolean(Number.parseInt(userData['password:shaWrapped'], 10))); + if (ok) { + throw new Error('[[error:reset-same-password]]'); + } + + const hash = await user.hashPassword(password); + const data = { + password: hash, + 'password:shaWrapped': 1, + }; + + // Don't verify email if password reset is due to expiry + const isPasswordExpired = userData.passwordExpiry && userData.passwordExpiry < Date.now(); + if (!isPasswordExpired) { + data['email:confirmed'] = 1; + await groups.join('verified-users', uid); + await groups.leave('unverified-users', uid); + } + + await Promise.all([ + user.setUserFields(uid, data), + db.deleteObjectField('reset:uid', code), + db.sortedSetRemoveBulk([ + ['reset:issueDate', code], + ['reset:issueDate:uid', uid], + ]), + user.reset.updateExpiry(uid), + user.auth.resetLockout(uid), + user.auth.revokeAllSessions(uid), + user.email.expireValidation(uid), + ]); }; UserReset.updateExpiry = async function (uid) { - const expireDays = meta.config.passwordExpiryDays; - if (expireDays > 0) { - const oneDay = 1000 * 60 * 60 * 24; - const expiry = Date.now() + (oneDay * expireDays); - await user.setUserField(uid, 'passwordExpiry', expiry); - } else { - await db.deleteObjectField(`user:${uid}`, 'passwordExpiry'); - } + const expireDays = meta.config.passwordExpiryDays; + if (expireDays > 0) { + const oneDay = 1000 * 60 * 60 * 24; + const expiry = Date.now() + (oneDay * expireDays); + await user.setUserField(uid, 'passwordExpiry', expiry); + } else { + await db.deleteObjectField(`user:${uid}`, 'passwordExpiry'); + } }; UserReset.clean = async function () { - const [tokens, uids] = await Promise.all([ - db.getSortedSetRangeByScore('reset:issueDate', 0, -1, '-inf', Date.now() - twoHours), - db.getSortedSetRangeByScore('reset:issueDate:uid', 0, -1, '-inf', Date.now() - twoHours), - ]); - if (!tokens.length && !uids.length) { - return; - } - - winston.verbose(`[UserReset.clean] Removing ${tokens.length} reset tokens from database`); - await cleanTokensAndUids(tokens, uids); + const [tokens, uids] = await Promise.all([ + db.getSortedSetRangeByScore('reset:issueDate', 0, -1, '-inf', Date.now() - twoHours), + db.getSortedSetRangeByScore('reset:issueDate:uid', 0, -1, '-inf', Date.now() - twoHours), + ]); + if (tokens.length === 0 && uids.length === 0) { + return; + } + + winston.verbose(`[UserReset.clean] Removing ${tokens.length} reset tokens from database`); + await cleanTokensAndUids(tokens, uids); }; UserReset.cleanByUid = async function (uid) { - const tokensToClean = []; - uid = parseInt(uid, 10); - - await batch.processSortedSet('reset:issueDate', async (tokens) => { - const results = await db.getObjectFields('reset:uid', tokens); - for (const [code, result] of Object.entries(results)) { - if (parseInt(result, 10) === uid) { - tokensToClean.push(code); - } - } - }, { batch: 500 }); - - if (!tokensToClean.length) { - winston.verbose(`[UserReset.cleanByUid] No tokens found for uid (${uid}).`); - return; - } - - winston.verbose(`[UserReset.cleanByUid] Found ${tokensToClean.length} token(s), removing...`); - await cleanTokensAndUids(tokensToClean, uid); + const tokensToClean = []; + uid = Number.parseInt(uid, 10); + + await batch.processSortedSet('reset:issueDate', async tokens => { + const results = await db.getObjectFields('reset:uid', tokens); + for (const [code, result] of Object.entries(results)) { + if (Number.parseInt(result, 10) === uid) { + tokensToClean.push(code); + } + } + }, {batch: 500}); + + if (tokensToClean.length === 0) { + winston.verbose(`[UserReset.cleanByUid] No tokens found for uid (${uid}).`); + return; + } + + winston.verbose(`[UserReset.cleanByUid] Found ${tokensToClean.length} token(s), removing...`); + await cleanTokensAndUids(tokensToClean, uid); }; async function cleanTokensAndUids(tokens, uids) { - await Promise.all([ - db.deleteObjectFields('reset:uid', tokens), - db.sortedSetRemove('reset:issueDate', tokens), - db.sortedSetRemove('reset:issueDate:uid', uids), - ]); + await Promise.all([ + db.deleteObjectFields('reset:uid', tokens), + db.sortedSetRemove('reset:issueDate', tokens), + db.sortedSetRemove('reset:issueDate:uid', uids), + ]); } diff --git a/src/user/search.js b/src/user/search.js index cbacef3..9f05a01 100644 --- a/src/user/search.js +++ b/src/user/search.js @@ -2,7 +2,6 @@ 'use strict'; const _ = require('lodash'); - const meta = require('../meta'); const plugins = require('../plugins'); const db = require('../database'); @@ -10,150 +9,154 @@ const groups = require('../groups'); const utils = require('../utils'); module.exports = function (User) { - const filterFnMap = { - online: user => user.status !== 'offline' && (Date.now() - user.lastonline < 300000), - flagged: user => parseInt(user.flags, 10) > 0, - verified: user => !!user['email:confirmed'], - unverified: user => !user['email:confirmed'], - }; - - const filterFieldMap = { - online: ['status', 'lastonline'], - flagged: ['flags'], - verified: ['email:confirmed'], - unverified: ['email:confirmed'], - }; - - - User.search = async function (data) { - const query = data.query || ''; - const searchBy = data.searchBy || 'username'; - const page = data.page || 1; - const uid = data.uid || 0; - const paginate = data.hasOwnProperty('paginate') ? data.paginate : true; - - const startTime = process.hrtime(); - - let uids = []; - if (searchBy === 'ip') { - uids = await searchByIP(query); - } else if (searchBy === 'uid') { - uids = [query]; - } else { - const searchMethod = data.findUids || findUids; - uids = await searchMethod(query, searchBy, data.hardCap); - } - - uids = await filterAndSortUids(uids, data); - const result = await plugins.hooks.fire('filter:users.search', { uids: uids, uid: uid }); - uids = result.uids; - - const searchResult = { - matchCount: uids.length, - }; - - if (paginate) { - const resultsPerPage = data.resultsPerPage || meta.config.userSearchResultsPerPage; - const start = Math.max(0, page - 1) * resultsPerPage; - const stop = start + resultsPerPage; - searchResult.pageCount = Math.ceil(uids.length / resultsPerPage); - uids = uids.slice(start, stop); - } - - const userData = await User.getUsers(uids, uid); - searchResult.timing = (process.elapsedTimeSince(startTime) / 1000).toFixed(2); - searchResult.users = userData.filter(user => user && user.uid > 0); - return searchResult; - }; - - async function findUids(query, searchBy, hardCap) { - if (!query) { - return []; - } - query = String(query).toLowerCase(); - const min = query; - const max = query.substr(0, query.length - 1) + String.fromCharCode(query.charCodeAt(query.length - 1) + 1); - - const resultsPerPage = meta.config.userSearchResultsPerPage; - hardCap = hardCap || resultsPerPage * 10; - - const data = await db.getSortedSetRangeByLex(`${searchBy}:sorted`, min, max, 0, hardCap); - const uids = data.map(data => data.split(':').pop()); - return uids; - } - - async function filterAndSortUids(uids, data) { - uids = uids.filter(uid => parseInt(uid, 10)); - let filters = data.filters || []; - filters = Array.isArray(filters) ? filters : [data.filters]; - const fields = []; - - if (data.sortBy) { - fields.push(data.sortBy); - } - - filters.forEach((filter) => { - if (filterFieldMap[filter]) { - fields.push(...filterFieldMap[filter]); - } - }); - - if (data.groupName) { - const isMembers = await groups.isMembers(uids, data.groupName); - uids = uids.filter((uid, index) => isMembers[index]); - } - - if (!fields.length) { - return uids; - } - - if (filters.includes('banned') || filters.includes('notbanned')) { - const isMembersOfBanned = await groups.isMembers(uids, groups.BANNED_USERS); - const checkBanned = filters.includes('banned'); - uids = uids.filter((uid, index) => (checkBanned ? isMembersOfBanned[index] : !isMembersOfBanned[index])); - } - - fields.push('uid'); - let userData = await User.getUsersFields(uids, fields); - - filters.forEach((filter) => { - if (filterFnMap[filter]) { - userData = userData.filter(filterFnMap[filter]); - } - }); - - if (data.sortBy) { - sortUsers(userData, data.sortBy, data.sortDirection); - } - - return userData.map(user => user.uid); - } - - function sortUsers(userData, sortBy, sortDirection) { - if (!userData || !userData.length) { - return; - } - sortDirection = sortDirection || 'desc'; - const direction = sortDirection === 'desc' ? 1 : -1; - - const isNumeric = utils.isNumber(userData[0][sortBy]); - if (isNumeric) { - userData.sort((u1, u2) => direction * (u2[sortBy] - u1[sortBy])); - } else { - userData.sort((u1, u2) => { - if (u1[sortBy] < u2[sortBy]) { - return direction * -1; - } else if (u1[sortBy] > u2[sortBy]) { - return direction * 1; - } - return 0; - }); - } - } - - async function searchByIP(ip) { - const ipKeys = await db.scan({ match: `ip:${ip}*` }); - const uids = await db.getSortedSetRevRange(ipKeys, 0, -1); - return _.uniq(uids); - } + const filterFunctionMap = { + online: user => user.status !== 'offline' && (Date.now() - user.lastonline < 300_000), + flagged: user => Number.parseInt(user.flags, 10) > 0, + verified: user => Boolean(user['email:confirmed']), + unverified: user => !user['email:confirmed'], + }; + + const filterFieldMap = { + online: ['status', 'lastonline'], + flagged: ['flags'], + verified: ['email:confirmed'], + unverified: ['email:confirmed'], + }; + + User.search = async function (data) { + const query = data.query || ''; + const searchBy = data.searchBy || 'username'; + const page = data.page || 1; + const uid = data.uid || 0; + const paginate = data.hasOwnProperty('paginate') ? data.paginate : true; + + const startTime = process.hrtime(); + + let uids = []; + if (searchBy === 'ip') { + uids = await searchByIP(query); + } else if (searchBy === 'uid') { + uids = [query]; + } else { + const searchMethod = data.findUids || findUids; + uids = await searchMethod(query, searchBy, data.hardCap); + } + + uids = await filterAndSortUids(uids, data); + const result = await plugins.hooks.fire('filter:users.search', {uids, uid}); + uids = result.uids; + + const searchResult = { + matchCount: uids.length, + }; + + if (paginate) { + const resultsPerPage = data.resultsPerPage || meta.config.userSearchResultsPerPage; + const start = Math.max(0, page - 1) * resultsPerPage; + const stop = start + resultsPerPage; + searchResult.pageCount = Math.ceil(uids.length / resultsPerPage); + uids = uids.slice(start, stop); + } + + const userData = await User.getUsers(uids, uid); + searchResult.timing = (process.elapsedTimeSince(startTime) / 1000).toFixed(2); + searchResult.users = userData.filter(user => user && user.uid > 0); + return searchResult; + }; + + async function findUids(query, searchBy, hardCap) { + if (!query) { + return []; + } + + query = String(query).toLowerCase(); + const min = query; + const max = query.slice(0, Math.max(0, query.length - 1)) + String.fromCharCode(query.charCodeAt(query.length - 1) + 1); + + const resultsPerPage = meta.config.userSearchResultsPerPage; + hardCap ||= resultsPerPage * 10; + + const data = await db.getSortedSetRangeByLex(`${searchBy}:sorted`, min, max, 0, hardCap); + const uids = data.map(data => data.split(':').pop()); + return uids; + } + + async function filterAndSortUids(uids, data) { + uids = uids.filter(uid => Number.parseInt(uid, 10)); + let filters = data.filters || []; + filters = Array.isArray(filters) ? filters : [data.filters]; + const fields = []; + + if (data.sortBy) { + fields.push(data.sortBy); + } + + for (const filter of filters) { + if (filterFieldMap[filter]) { + fields.push(...filterFieldMap[filter]); + } + } + + if (data.groupName) { + const isMembers = await groups.isMembers(uids, data.groupName); + uids = uids.filter((uid, index) => isMembers[index]); + } + + if (fields.length === 0) { + return uids; + } + + if (filters.includes('banned') || filters.includes('notbanned')) { + const isMembersOfBanned = await groups.isMembers(uids, groups.BANNED_USERS); + const checkBanned = filters.includes('banned'); + uids = uids.filter((uid, index) => (checkBanned ? isMembersOfBanned[index] : !isMembersOfBanned[index])); + } + + fields.push('uid'); + let userData = await User.getUsersFields(uids, fields); + + for (const filter of filters) { + if (filterFunctionMap[filter]) { + userData = userData.filter(filterFunctionMap[filter]); + } + } + + if (data.sortBy) { + sortUsers(userData, data.sortBy, data.sortDirection); + } + + return userData.map(user => user.uid); + } + + function sortUsers(userData, sortBy, sortDirection) { + if (!userData || userData.length === 0) { + return; + } + + sortDirection ||= 'desc'; + const direction = sortDirection === 'desc' ? 1 : -1; + + const isNumeric = utils.isNumber(userData[0][sortBy]); + if (isNumeric) { + userData.sort((u1, u2) => direction * (u2[sortBy] - u1[sortBy])); + } else { + userData.sort((u1, u2) => { + if (u1[sortBy] < u2[sortBy]) { + return direction * -1; + } + + if (u1[sortBy] > u2[sortBy]) { + return Number(direction); + } + + return 0; + }); + } + } + + async function searchByIP(ip) { + const ipKeys = await db.scan({match: `ip:${ip}*`}); + const uids = await db.getSortedSetRevRange(ipKeys, 0, -1); + return _.uniq(uids); + } }; diff --git a/src/user/settings.js b/src/user/settings.js index a11892c..b774817 100644 --- a/src/user/settings.js +++ b/src/user/settings.js @@ -2,7 +2,6 @@ 'use strict'; const validator = require('validator'); - const meta = require('../meta'); const db = require('../database'); const plugins = require('../plugins'); @@ -10,162 +9,169 @@ const notifications = require('../notifications'); const languages = require('../languages'); module.exports = function (User) { - User.getSettings = async function (uid) { - if (parseInt(uid, 10) <= 0) { - return await onSettingsLoaded(0, {}); - } - let settings = await db.getObject(`user:${uid}:settings`); - settings = settings || {}; - settings.uid = uid; - return await onSettingsLoaded(uid, settings); - }; - - User.getMultipleUserSettings = async function (uids) { - if (!Array.isArray(uids) || !uids.length) { - return []; - } - - const keys = uids.map(uid => `user:${uid}:settings`); - let settings = await db.getObjects(keys); - settings = settings.map((userSettings, index) => { - userSettings = userSettings || {}; - userSettings.uid = uids[index]; - return userSettings; - }); - return await Promise.all(settings.map(s => onSettingsLoaded(s.uid, s))); - }; - - async function onSettingsLoaded(uid, settings) { - const data = await plugins.hooks.fire('filter:user.getSettings', { uid: uid, settings: settings }); - settings = data.settings; - - const defaultTopicsPerPage = meta.config.topicsPerPage; - const defaultPostsPerPage = meta.config.postsPerPage; - - settings.showemail = parseInt(getSetting(settings, 'showemail', 0), 10) === 1; - settings.showfullname = parseInt(getSetting(settings, 'showfullname', 0), 10) === 1; - settings.openOutgoingLinksInNewTab = parseInt(getSetting(settings, 'openOutgoingLinksInNewTab', 0), 10) === 1; - settings.dailyDigestFreq = getSetting(settings, 'dailyDigestFreq', 'off'); - settings.usePagination = parseInt(getSetting(settings, 'usePagination', 0), 10) === 1; - settings.topicsPerPage = Math.min( - meta.config.maxTopicsPerPage, - settings.topicsPerPage ? parseInt(settings.topicsPerPage, 10) : defaultTopicsPerPage, - defaultTopicsPerPage - ); - settings.postsPerPage = Math.min( - meta.config.maxPostsPerPage, - settings.postsPerPage ? parseInt(settings.postsPerPage, 10) : defaultPostsPerPage, - defaultPostsPerPage - ); - settings.userLang = settings.userLang || meta.config.defaultLang || 'en-GB'; - settings.acpLang = settings.acpLang || settings.userLang; - settings.topicPostSort = getSetting(settings, 'topicPostSort', 'oldest_to_newest'); - settings.categoryTopicSort = getSetting(settings, 'categoryTopicSort', 'newest_to_oldest'); - settings.followTopicsOnCreate = parseInt(getSetting(settings, 'followTopicsOnCreate', 1), 10) === 1; - settings.followTopicsOnReply = parseInt(getSetting(settings, 'followTopicsOnReply', 0), 10) === 1; - settings.upvoteNotifFreq = getSetting(settings, 'upvoteNotifFreq', 'all'); - settings.restrictChat = parseInt(getSetting(settings, 'restrictChat', 0), 10) === 1; - settings.topicSearchEnabled = parseInt(getSetting(settings, 'topicSearchEnabled', 0), 10) === 1; - settings.updateUrlWithPostIndex = parseInt(getSetting(settings, 'updateUrlWithPostIndex', 1), 10) === 1; - settings.bootswatchSkin = validator.escape(String(settings.bootswatchSkin || '')); - settings.homePageRoute = validator.escape(String(settings.homePageRoute || '')).replace(///g, '/'); - settings.scrollToMyPost = parseInt(getSetting(settings, 'scrollToMyPost', 1), 10) === 1; - settings.categoryWatchState = getSetting(settings, 'categoryWatchState', 'notwatching'); - - const notificationTypes = await notifications.getAllNotificationTypes(); - notificationTypes.forEach((notificationType) => { - settings[notificationType] = getSetting(settings, notificationType, 'notification'); - }); - - return settings; - } - - function getSetting(settings, key, defaultValue) { - if (settings[key] || settings[key] === 0) { - return settings[key]; - } else if (meta.config[key] || meta.config[key] === 0) { - return meta.config[key]; - } - return defaultValue; - } - - User.saveSettings = async function (uid, data) { - const maxPostsPerPage = meta.config.maxPostsPerPage || 20; - if ( - !data.postsPerPage || - parseInt(data.postsPerPage, 10) <= 1 || - parseInt(data.postsPerPage, 10) > maxPostsPerPage - ) { - throw new Error(`[[error:invalid-pagination-value, 2, ${maxPostsPerPage}]]`); - } - - const maxTopicsPerPage = meta.config.maxTopicsPerPage || 20; - if ( - !data.topicsPerPage || - parseInt(data.topicsPerPage, 10) <= 1 || - parseInt(data.topicsPerPage, 10) > maxTopicsPerPage - ) { - throw new Error(`[[error:invalid-pagination-value, 2, ${maxTopicsPerPage}]]`); - } - - const languageCodes = await languages.listCodes(); - if (data.userLang && !languageCodes.includes(data.userLang)) { - throw new Error('[[error:invalid-language]]'); - } - if (data.acpLang && !languageCodes.includes(data.acpLang)) { - throw new Error('[[error:invalid-language]]'); - } - data.userLang = data.userLang || meta.config.defaultLang; - - plugins.hooks.fire('action:user.saveSettings', { uid: uid, settings: data }); - - const settings = { - showemail: data.showemail, - showfullname: data.showfullname, - openOutgoingLinksInNewTab: data.openOutgoingLinksInNewTab, - dailyDigestFreq: data.dailyDigestFreq || 'off', - usePagination: data.usePagination, - topicsPerPage: Math.min(data.topicsPerPage, parseInt(maxTopicsPerPage, 10) || 20), - postsPerPage: Math.min(data.postsPerPage, parseInt(maxPostsPerPage, 10) || 20), - userLang: data.userLang || meta.config.defaultLang, - acpLang: data.acpLang || meta.config.defaultLang, - followTopicsOnCreate: data.followTopicsOnCreate, - followTopicsOnReply: data.followTopicsOnReply, - restrictChat: data.restrictChat, - topicSearchEnabled: data.topicSearchEnabled, - updateUrlWithPostIndex: data.updateUrlWithPostIndex, - homePageRoute: ((data.homePageRoute === 'custom' ? data.homePageCustom : data.homePageRoute) || '').replace(/^\//, ''), - scrollToMyPost: data.scrollToMyPost, - upvoteNotifFreq: data.upvoteNotifFreq, - bootswatchSkin: data.bootswatchSkin, - categoryWatchState: data.categoryWatchState, - categoryTopicSort: data.categoryTopicSort, - topicPostSort: data.topicPostSort, - }; - const notificationTypes = await notifications.getAllNotificationTypes(); - notificationTypes.forEach((notificationType) => { - if (data[notificationType]) { - settings[notificationType] = data[notificationType]; - } - }); - const result = await plugins.hooks.fire('filter:user.saveSettings', { uid: uid, settings: settings, data: data }); - await db.setObject(`user:${uid}:settings`, result.settings); - await User.updateDigestSetting(uid, data.dailyDigestFreq); - return await User.getSettings(uid); - }; - - User.updateDigestSetting = async function (uid, dailyDigestFreq) { - await db.sortedSetsRemove(['digest:day:uids', 'digest:week:uids', 'digest:month:uids'], uid); - if (['day', 'week', 'biweek', 'month'].includes(dailyDigestFreq)) { - await db.sortedSetAdd(`digest:${dailyDigestFreq}:uids`, Date.now(), uid); - } - }; - - User.setSetting = async function (uid, key, value) { - if (parseInt(uid, 10) <= 0) { - return; - } - - await db.setObjectField(`user:${uid}:settings`, key, value); - }; + User.getSettings = async function (uid) { + if (Number.parseInt(uid, 10) <= 0) { + return await onSettingsLoaded(0, {}); + } + + let settings = await db.getObject(`user:${uid}:settings`); + settings ||= {}; + settings.uid = uid; + return await onSettingsLoaded(uid, settings); + }; + + User.getMultipleUserSettings = async function (uids) { + if (!Array.isArray(uids) || uids.length === 0) { + return []; + } + + const keys = uids.map(uid => `user:${uid}:settings`); + let settings = await db.getObjects(keys); + settings = settings.map((userSettings, index) => { + userSettings ||= {}; + userSettings.uid = uids[index]; + return userSettings; + }); + return await Promise.all(settings.map(s => onSettingsLoaded(s.uid, s))); + }; + + async function onSettingsLoaded(uid, settings) { + const data = await plugins.hooks.fire('filter:user.getSettings', {uid, settings}); + settings = data.settings; + + const defaultTopicsPerPage = meta.config.topicsPerPage; + const defaultPostsPerPage = meta.config.postsPerPage; + + settings.showemail = Number.parseInt(getSetting(settings, 'showemail', 0), 10) === 1; + settings.showfullname = Number.parseInt(getSetting(settings, 'showfullname', 0), 10) === 1; + settings.openOutgoingLinksInNewTab = Number.parseInt(getSetting(settings, 'openOutgoingLinksInNewTab', 0), 10) === 1; + settings.dailyDigestFreq = getSetting(settings, 'dailyDigestFreq', 'off'); + settings.usePagination = Number.parseInt(getSetting(settings, 'usePagination', 0), 10) === 1; + settings.topicsPerPage = Math.min( + meta.config.maxTopicsPerPage, + settings.topicsPerPage ? Number.parseInt(settings.topicsPerPage, 10) : defaultTopicsPerPage, + defaultTopicsPerPage, + ); + settings.postsPerPage = Math.min( + meta.config.maxPostsPerPage, + settings.postsPerPage ? Number.parseInt(settings.postsPerPage, 10) : defaultPostsPerPage, + defaultPostsPerPage, + ); + settings.userLang = settings.userLang || meta.config.defaultLang || 'en-GB'; + settings.acpLang = settings.acpLang || settings.userLang; + settings.topicPostSort = getSetting(settings, 'topicPostSort', 'oldest_to_newest'); + settings.categoryTopicSort = getSetting(settings, 'categoryTopicSort', 'newest_to_oldest'); + settings.followTopicsOnCreate = Number.parseInt(getSetting(settings, 'followTopicsOnCreate', 1), 10) === 1; + settings.followTopicsOnReply = Number.parseInt(getSetting(settings, 'followTopicsOnReply', 0), 10) === 1; + settings.upvoteNotifFreq = getSetting(settings, 'upvoteNotifFreq', 'all'); + settings.restrictChat = Number.parseInt(getSetting(settings, 'restrictChat', 0), 10) === 1; + settings.topicSearchEnabled = Number.parseInt(getSetting(settings, 'topicSearchEnabled', 0), 10) === 1; + settings.updateUrlWithPostIndex = Number.parseInt(getSetting(settings, 'updateUrlWithPostIndex', 1), 10) === 1; + settings.bootswatchSkin = validator.escape(String(settings.bootswatchSkin || '')); + settings.homePageRoute = validator.escape(String(settings.homePageRoute || '')).replaceAll('/', '/'); + settings.scrollToMyPost = Number.parseInt(getSetting(settings, 'scrollToMyPost', 1), 10) === 1; + settings.categoryWatchState = getSetting(settings, 'categoryWatchState', 'notwatching'); + + const notificationTypes = await notifications.getAllNotificationTypes(); + for (const notificationType of notificationTypes) { + settings[notificationType] = getSetting(settings, notificationType, 'notification'); + } + + return settings; + } + + function getSetting(settings, key, defaultValue) { + if (settings[key] || settings[key] === 0) { + return settings[key]; + } + + if (meta.config[key] || meta.config[key] === 0) { + return meta.config[key]; + } + + return defaultValue; + } + + User.saveSettings = async function (uid, data) { + const maxPostsPerPage = meta.config.maxPostsPerPage || 20; + if ( + !data.postsPerPage + || Number.parseInt(data.postsPerPage, 10) <= 1 + || Number.parseInt(data.postsPerPage, 10) > maxPostsPerPage + ) { + throw new Error(`[[error:invalid-pagination-value, 2, ${maxPostsPerPage}]]`); + } + + const maxTopicsPerPage = meta.config.maxTopicsPerPage || 20; + if ( + !data.topicsPerPage + || Number.parseInt(data.topicsPerPage, 10) <= 1 + || Number.parseInt(data.topicsPerPage, 10) > maxTopicsPerPage + ) { + throw new Error(`[[error:invalid-pagination-value, 2, ${maxTopicsPerPage}]]`); + } + + const languageCodes = await languages.listCodes(); + if (data.userLang && !languageCodes.includes(data.userLang)) { + throw new Error('[[error:invalid-language]]'); + } + + if (data.acpLang && !languageCodes.includes(data.acpLang)) { + throw new Error('[[error:invalid-language]]'); + } + + data.userLang = data.userLang || meta.config.defaultLang; + + plugins.hooks.fire('action:user.saveSettings', {uid, settings: data}); + + const settings = { + showemail: data.showemail, + showfullname: data.showfullname, + openOutgoingLinksInNewTab: data.openOutgoingLinksInNewTab, + dailyDigestFreq: data.dailyDigestFreq || 'off', + usePagination: data.usePagination, + topicsPerPage: Math.min(data.topicsPerPage, Number.parseInt(maxTopicsPerPage, 10) || 20), + postsPerPage: Math.min(data.postsPerPage, Number.parseInt(maxPostsPerPage, 10) || 20), + userLang: data.userLang || meta.config.defaultLang, + acpLang: data.acpLang || meta.config.defaultLang, + followTopicsOnCreate: data.followTopicsOnCreate, + followTopicsOnReply: data.followTopicsOnReply, + restrictChat: data.restrictChat, + topicSearchEnabled: data.topicSearchEnabled, + updateUrlWithPostIndex: data.updateUrlWithPostIndex, + homePageRoute: ((data.homePageRoute === 'custom' ? data.homePageCustom : data.homePageRoute) || '').replace(/^\//, ''), + scrollToMyPost: data.scrollToMyPost, + upvoteNotifFreq: data.upvoteNotifFreq, + bootswatchSkin: data.bootswatchSkin, + categoryWatchState: data.categoryWatchState, + categoryTopicSort: data.categoryTopicSort, + topicPostSort: data.topicPostSort, + }; + const notificationTypes = await notifications.getAllNotificationTypes(); + for (const notificationType of notificationTypes) { + if (data[notificationType]) { + settings[notificationType] = data[notificationType]; + } + } + + const result = await plugins.hooks.fire('filter:user.saveSettings', {uid, settings, data}); + await db.setObject(`user:${uid}:settings`, result.settings); + await User.updateDigestSetting(uid, data.dailyDigestFreq); + return await User.getSettings(uid); + }; + + User.updateDigestSetting = async function (uid, dailyDigestFreq) { + await db.sortedSetsRemove(['digest:day:uids', 'digest:week:uids', 'digest:month:uids'], uid); + if (['day', 'week', 'biweek', 'month'].includes(dailyDigestFreq)) { + await db.sortedSetAdd(`digest:${dailyDigestFreq}:uids`, Date.now(), uid); + } + }; + + User.setSetting = async function (uid, key, value) { + if (Number.parseInt(uid, 10) <= 0) { + return; + } + + await db.setObjectField(`user:${uid}:settings`, key, value); + }; }; diff --git a/src/user/topics.js b/src/user/topics.js index 7080cac..79d329c 100644 --- a/src/user/topics.js +++ b/src/user/topics.js @@ -3,14 +3,14 @@ const db = require('../database'); module.exports = function (User) { - User.getIgnoredTids = async function (uid, start, stop) { - return await db.getSortedSetRevRange(`uid:${uid}:ignored_tids`, start, stop); - }; + User.getIgnoredTids = async function (uid, start, stop) { + return await db.getSortedSetRevRange(`uid:${uid}:ignored_tids`, start, stop); + }; - User.addTopicIdToUser = async function (uid, tid, timestamp) { - await Promise.all([ - db.sortedSetAdd(`uid:${uid}:topics`, timestamp, tid), - User.incrementUserFieldBy(uid, 'topiccount', 1), - ]); - }; + User.addTopicIdToUser = async function (uid, tid, timestamp) { + await Promise.all([ + db.sortedSetAdd(`uid:${uid}:topics`, timestamp, tid), + User.incrementUserFieldBy(uid, 'topiccount', 1), + ]); + }; }; diff --git a/src/user/uploads.js b/src/user/uploads.js index eb190c0..34305f1 100644 --- a/src/user/uploads.js +++ b/src/user/uploads.js @@ -1,10 +1,9 @@ 'use strict'; -const path = require('path'); +const path = require('node:path'); +const crypto = require('node:crypto'); const nconf = require('nconf'); const winston = require('winston'); -const crypto = require('crypto'); - const db = require('../database'); const posts = require('../posts'); const file = require('../file'); @@ -12,79 +11,79 @@ const batch = require('../batch'); const md5 = filename => crypto.createHash('md5').update(filename).digest('hex'); const _getFullPath = relativePath => path.resolve(nconf.get('upload_path'), relativePath); -const _validatePath = async (relativePaths) => { - if (typeof relativePaths === 'string') { - relativePaths = [relativePaths]; - } else if (!Array.isArray(relativePaths)) { - throw new Error(`[[error:wrong-parameter-type, relativePaths, ${typeof relativePaths}, array]]`); - } +const _validatePath = async relativePaths => { + if (typeof relativePaths === 'string') { + relativePaths = [relativePaths]; + } else if (!Array.isArray(relativePaths)) { + throw new TypeError(`[[error:wrong-parameter-type, relativePaths, ${typeof relativePaths}, array]]`); + } - const fullPaths = relativePaths.map(path => _getFullPath(path)); - const exists = await Promise.all(fullPaths.map(async fullPath => file.exists(fullPath))); + const fullPaths = relativePaths.map(path => _getFullPath(path)); + const exists = await Promise.all(fullPaths.map(async fullPath => file.exists(fullPath))); - if (!fullPaths.every(fullPath => fullPath.startsWith(nconf.get('upload_path'))) || !exists.every(Boolean)) { - throw new Error('[[error:invalid-path]]'); - } + if (!fullPaths.every(fullPath => fullPath.startsWith(nconf.get('upload_path'))) || !exists.every(Boolean)) { + throw new Error('[[error:invalid-path]]'); + } }; module.exports = function (User) { - User.associateUpload = async (uid, relativePath) => { - await _validatePath(relativePath); - await Promise.all([ - db.sortedSetAdd(`uid:${uid}:uploads`, Date.now(), relativePath), - db.setObjectField(`upload:${md5(relativePath)}`, 'uid', uid), - ]); - }; + User.associateUpload = async (uid, relativePath) => { + await _validatePath(relativePath); + await Promise.all([ + db.sortedSetAdd(`uid:${uid}:uploads`, Date.now(), relativePath), + db.setObjectField(`upload:${md5(relativePath)}`, 'uid', uid), + ]); + }; - User.deleteUpload = async function (callerUid, uid, uploadNames) { - if (typeof uploadNames === 'string') { - uploadNames = [uploadNames]; - } else if (!Array.isArray(uploadNames)) { - throw new Error(`[[error:wrong-parameter-type, uploadNames, ${typeof uploadNames}, array]]`); - } + User.deleteUpload = async function (callerUid, uid, uploadNames) { + if (typeof uploadNames === 'string') { + uploadNames = [uploadNames]; + } else if (!Array.isArray(uploadNames)) { + throw new TypeError(`[[error:wrong-parameter-type, uploadNames, ${typeof uploadNames}, array]]`); + } - await _validatePath(uploadNames); + await _validatePath(uploadNames); - const [isUsersUpload, isAdminOrGlobalMod] = await Promise.all([ - db.isSortedSetMembers(`uid:${callerUid}:uploads`, uploadNames), - User.isAdminOrGlobalMod(callerUid), - ]); - if (!isAdminOrGlobalMod && !isUsersUpload.every(Boolean)) { - throw new Error('[[error:no-privileges]]'); - } + const [isUsersUpload, isAdminOrGlobalModule] = await Promise.all([ + db.isSortedSetMembers(`uid:${callerUid}:uploads`, uploadNames), + User.isAdminOrGlobalMod(callerUid), + ]); + if (!isAdminOrGlobalModule && !isUsersUpload.every(Boolean)) { + throw new Error('[[error:no-privileges]]'); + } - await batch.processArray(uploadNames, async (uploadNames) => { - const fullPaths = uploadNames.map(path => _getFullPath(path)); + await batch.processArray(uploadNames, async uploadNames => { + const fullPaths = uploadNames.map(path => _getFullPath(path)); - await Promise.all(fullPaths.map(async (fullPath, idx) => { - winston.verbose(`[user/deleteUpload] Deleting ${uploadNames[idx]}`); - await Promise.all([ - file.delete(fullPath), - file.delete(file.appendToFileName(fullPath, '-resized')), - ]); - await Promise.all([ - db.sortedSetRemove(`uid:${uid}:uploads`, uploadNames[idx]), - db.delete(`upload:${md5(uploadNames[idx])}`), - ]); - })); + await Promise.all(fullPaths.map(async (fullPath, index) => { + winston.verbose(`[user/deleteUpload] Deleting ${uploadNames[index]}`); + await Promise.all([ + file.delete(fullPath), + file.delete(file.appendToFileName(fullPath, '-resized')), + ]); + await Promise.all([ + db.sortedSetRemove(`uid:${uid}:uploads`, uploadNames[index]), + db.delete(`upload:${md5(uploadNames[index])}`), + ]); + })); - // Dissociate the upload from pids, if any - const pids = await db.getSortedSetsMembers(uploadNames.map(relativePath => `upload:${md5(relativePath)}:pids`)); - await Promise.all(pids.map(async (pids, idx) => Promise.all( - pids.map(async pid => posts.uploads.dissociate(pid, uploadNames[idx])) - ))); - }, { batch: 50 }); - }; + // Dissociate the upload from pids, if any + const pids = await db.getSortedSetsMembers(uploadNames.map(relativePath => `upload:${md5(relativePath)}:pids`)); + await Promise.all(pids.map(async (pids, index) => Promise.all( + pids.map(async pid => posts.uploads.dissociate(pid, uploadNames[index])), + ))); + }, {batch: 50}); + }; - User.collateUploads = async function (uid, archive) { - await batch.processSortedSet(`uid:${uid}:uploads`, (files, next) => { - files.forEach((file) => { - archive.file(_getFullPath(file), { - name: path.basename(file), - }); - }); + User.collateUploads = async function (uid, archive) { + await batch.processSortedSet(`uid:${uid}:uploads`, (files, next) => { + for (const file of files) { + archive.file(_getFullPath(file), { + name: path.basename(file), + }); + } - setImmediate(next); - }, { batch: 100 }); - }; + setImmediate(next); + }, {batch: 100}); + }; }; diff --git a/src/utils.js b/src/utils.js index 0e87bb1..80dba9b 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,32 +1,33 @@ 'use strict'; -const crypto = require('crypto'); +const crypto = require('node:crypto'); process.profile = function (operation, start) { - console.log('%s took %d milliseconds', operation, process.elapsedTimeSince(start)); + console.log('%s took %d milliseconds', operation, process.elapsedTimeSince(start)); }; process.elapsedTimeSince = function (start) { - const diff = process.hrtime(start); - return (diff[0] * 1e3) + (diff[1] / 1e6); + const diff = process.hrtime(start); + return (diff[0] * 1e3) + (diff[1] / 1e6); }; -const utils = { ...require('../public/src/utils.common') }; + +const utils = {...require('../public/src/utils.common')}; utils.getLanguage = function () { - const meta = require('./meta'); - return meta.config && meta.config.defaultLang ? meta.config.defaultLang : 'en-GB'; + const meta = require('./meta'); + return meta.config && meta.config.defaultLang ? meta.config.defaultLang : 'en-GB'; }; utils.generateUUID = function () { - // from https://github.com/tracker1/node-uuid4/blob/master/index.js - let rnd = crypto.randomBytes(16); - /* eslint-disable no-bitwise */ - rnd[6] = (rnd[6] & 0x0f) | 0x40; - rnd[8] = (rnd[8] & 0x3f) | 0x80; - /* eslint-enable no-bitwise */ - rnd = rnd.toString('hex').match(/(.{8})(.{4})(.{4})(.{4})(.{12})/); - rnd.shift(); - return rnd.join('-'); + // From https://github.com/tracker1/node-uuid4/blob/master/index.js + let rnd = crypto.randomBytes(16); + /* eslint-disable no-bitwise */ + rnd[6] = (rnd[6] & 0x0F) | 0x40; + rnd[8] = (rnd[8] & 0x3F) | 0x80; + /* eslint-enable no-bitwise */ + rnd = rnd.toString('hex').match(/(.{8})(.{4})(.{4})(.{4})(.{12})/); + rnd.shift(); + return rnd.join('-'); }; module.exports = utils; diff --git a/src/webserver.js b/src/webserver.js index 2c79bf0..49a68aa 100644 --- a/src/webserver.js +++ b/src/webserver.js @@ -1,10 +1,10 @@ 'use strict'; -const fs = require('fs'); -const util = require('util'); -const path = require('path'); -const os = require('os'); +const fs = require('node:fs'); +const util = require('node:util'); +const path = require('node:path'); +const os = require('node:os'); const nconf = require('nconf'); const express = require('express'); const chalk = require('chalk'); @@ -21,7 +21,6 @@ const useragent = require('express-useragent'); const favicon = require('serve-favicon'); const detector = require('spider-detector'); const helmet = require('helmet'); - const Benchpress = require('benchpressjs'); const db = require('./database'); const analytics = require('./analytics'); @@ -35,294 +34,305 @@ const topicEvents = require('./topics/events'); const privileges = require('./privileges'); const routes = require('./routes'); const auth = require('./routes/authentication'); - const helpers = require('./helpers'); if (nconf.get('ssl')) { - server = require('https').createServer({ - key: fs.readFileSync(nconf.get('ssl').key), - cert: fs.readFileSync(nconf.get('ssl').cert), - }, app); + server = require('node:https').createServer({ + key: fs.readFileSync(nconf.get('ssl').key), + cert: fs.readFileSync(nconf.get('ssl').cert), + }, app); } else { - server = require('http').createServer(app); + server = require('node:http').createServer(app); } module.exports.server = server; module.exports.app = app; -server.on('error', (err) => { - if (err.code === 'EADDRINUSE') { - winston.error(`NodeBB address in use, exiting...\n${err.stack}`); - } else { - winston.error(err.stack); - } +server.on('error', error => { + if (error.code === 'EADDRINUSE') { + winston.error(`NodeBB address in use, exiting...\n${error.stack}`); + } else { + winston.error(error.stack); + } - throw err; + throw error; }); -// see https://github.com/isaacs/server-destroy/blob/master/index.js +// See https://github.com/isaacs/server-destroy/blob/master/index.js const connections = {}; -server.on('connection', (conn) => { - const key = `${conn.remoteAddress}:${conn.remotePort}`; - connections[key] = conn; - conn.on('close', () => { - delete connections[key]; - }); +server.on('connection', conn => { + const key = `${conn.remoteAddress}:${conn.remotePort}`; + connections[key] = conn; + conn.on('close', () => { + delete connections[key]; + }); }); exports.destroy = function (callback) { - server.close(callback); - for (const connection of Object.values(connections)) { - connection.destroy(); - } + server.close(callback); + for (const connection of Object.values(connections)) { + connection.destroy(); + } }; exports.listen = async function () { - emailer.registerApp(app); - setupExpressApp(app); - helpers.register(); - logger.init(app); - await initializeNodeBB(); - winston.info('🎉 NodeBB Ready'); + emailer.registerApp(app); + setupExpressApp(app); + helpers.register(); + logger.init(app); + await initializeNodeBB(); + winston.info('🎉 NodeBB Ready'); - require('./socket.io').server.emit('event:nodebb.ready', { - 'cache-buster': meta.config['cache-buster'], - hostname: os.hostname(), - }); + require('./socket.io').server.emit('event:nodebb.ready', { + 'cache-buster': meta.config['cache-buster'], + hostname: os.hostname(), + }); - plugins.hooks.fire('action:nodebb.ready'); + plugins.hooks.fire('action:nodebb.ready'); - await listen(); + await listen(); }; async function initializeNodeBB() { - const middleware = require('./middleware'); - await meta.themes.setupPaths(); - await plugins.init(app, middleware); - await plugins.hooks.fire('static:assets.prepare', {}); - await plugins.hooks.fire('static:app.preload', { - app: app, - middleware: middleware, - }); - await routes(app, middleware); - await privileges.init(); - await meta.blacklist.load(); - await flags.init(); - await analytics.init(); - await topicEvents.init(); + const middleware = require('./middleware'); + await meta.themes.setupPaths(); + await plugins.init(app, middleware); + await plugins.hooks.fire('static:assets.prepare', {}); + await plugins.hooks.fire('static:app.preload', { + app, + middleware, + }); + await routes(app, middleware); + await privileges.init(); + await meta.blacklist.load(); + await flags.init(); + await analytics.init(); + await topicEvents.init(); } function setupExpressApp(app) { - const middleware = require('./middleware'); - const pingController = require('./controllers/ping'); - - const relativePath = nconf.get('relative_path'); - const viewsDir = nconf.get('views_dir'); - - app.engine('tpl', (filepath, data, next) => { - filepath = filepath.replace(/\.tpl$/, '.js'); - - Benchpress.__express(filepath, data, next); - }); - app.set('view engine', 'tpl'); - app.set('views', viewsDir); - app.set('json spaces', global.env === 'development' ? 4 : 0); - app.use(flash()); - - app.enable('view cache'); - - if (global.env !== 'development') { - app.enable('cache'); - app.enable('minification'); - } - - if (meta.config.useCompression) { - const compression = require('compression'); - app.use(compression()); - } - if (relativePath) { - app.use((req, res, next) => { - if (!req.path.startsWith(relativePath)) { - return require('./controllers/helpers').redirect(res, req.path); - } - next(); - }); - } - - app.get(`${relativePath}/ping`, pingController.ping); - app.get(`${relativePath}/sping`, pingController.ping); - - setupFavicon(app); - - app.use(`${relativePath}/apple-touch-icon`, middleware.routeTouchIcon); - - configureBodyParser(app); - - app.use(cookieParser(nconf.get('secret'))); - app.use(useragent.express()); - app.use(detector.middleware()); - app.use(session({ - store: db.sessionStore, - secret: nconf.get('secret'), - key: nconf.get('sessionKey'), - cookie: setupCookie(), - resave: nconf.get('sessionResave') || false, - saveUninitialized: nconf.get('sessionSaveUninitialized') || false, - })); - - setupHelmet(app); - - app.use(middleware.addHeaders); - app.use(middleware.processRender); - auth.initialize(app, middleware); - const als = require('./als'); - app.use((req, res, next) => { - als.run({ uid: req.uid }, next); - }); - app.use(middleware.autoLocale); // must be added after auth middlewares are added - - const toobusy = require('toobusy-js'); - toobusy.maxLag(meta.config.eventLoopLagThreshold); - toobusy.interval(meta.config.eventLoopInterval); + const middleware = require('./middleware'); + const pingController = require('./controllers/ping'); + + const relativePath = nconf.get('relative_path'); + const viewsDir = nconf.get('views_dir'); + + app.engine('tpl', (filepath, data, next) => { + filepath = filepath.replace(/\.tpl$/, '.js'); + + Benchpress.__express(filepath, data, next); + }); + app.set('view engine', 'tpl'); + app.set('views', viewsDir); + app.set('json spaces', global.env === 'development' ? 4 : 0); + app.use(flash()); + + app.enable('view cache'); + + if (global.env !== 'development') { + app.enable('cache'); + app.enable('minification'); + } + + if (meta.config.useCompression) { + const compression = require('compression'); + app.use(compression()); + } + + if (relativePath) { + app.use((request, res, next) => { + if (!request.path.startsWith(relativePath)) { + return require('./controllers/helpers').redirect(res, request.path); + } + + next(); + }); + } + + app.get(`${relativePath}/ping`, pingController.ping); + app.get(`${relativePath}/sping`, pingController.ping); + + setupFavicon(app); + + app.use(`${relativePath}/apple-touch-icon`, middleware.routeTouchIcon); + + configureBodyParser(app); + + app.use(cookieParser(nconf.get('secret'))); + app.use(useragent.express()); + app.use(detector.middleware()); + app.use(session({ + store: db.sessionStore, + secret: nconf.get('secret'), + key: nconf.get('sessionKey'), + cookie: setupCookie(), + resave: nconf.get('sessionResave') || false, + saveUninitialized: nconf.get('sessionSaveUninitialized') || false, + })); + + setupHelmet(app); + + app.use(middleware.addHeaders); + app.use(middleware.processRender); + auth.initialize(app, middleware); + const als = require('./als'); + app.use((request, res, next) => { + als.run({uid: request.uid}, next); + }); + app.use(middleware.autoLocale); // Must be added after auth middlewares are added + + const toobusy = require('toobusy-js'); + toobusy.maxLag(meta.config.eventLoopLagThreshold); + toobusy.interval(meta.config.eventLoopInterval); } function setupHelmet(app) { - const options = { - contentSecurityPolicy: false, // defaults are too restrive and break plugins that load external assets... 🔜 - crossOriginOpenerPolicy: { policy: meta.config['cross-origin-opener-policy'] }, - crossOriginResourcePolicy: { policy: meta.config['cross-origin-resource-policy'] }, - referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, - }; - - if (!meta.config['cross-origin-embedder-policy']) { - options.crossOriginEmbedderPolicy = false; - } - if (meta.config['hsts-enabled']) { - options.hsts = { - maxAge: meta.config['hsts-maxage'], - includeSubDomains: !!meta.config['hsts-subdomains'], - preload: !!meta.config['hsts-preload'], - }; - } - - app.use(helmet(options)); + const options = { + contentSecurityPolicy: false, // Defaults are too restrive and break plugins that load external assets... 🔜 + crossOriginOpenerPolicy: {policy: meta.config['cross-origin-opener-policy']}, + crossOriginResourcePolicy: {policy: meta.config['cross-origin-resource-policy']}, + referrerPolicy: {policy: 'strict-origin-when-cross-origin'}, + }; + + if (!meta.config['cross-origin-embedder-policy']) { + options.crossOriginEmbedderPolicy = false; + } + + if (meta.config['hsts-enabled']) { + options.hsts = { + maxAge: meta.config['hsts-maxage'], + includeSubDomains: Boolean(meta.config['hsts-subdomains']), + preload: Boolean(meta.config['hsts-preload']), + }; + } + + app.use(helmet(options)); } - function setupFavicon(app) { - let faviconPath = meta.config['brand:favicon'] || 'favicon.ico'; - faviconPath = path.join(nconf.get('base_dir'), 'public', faviconPath.replace(/assets\/uploads/, 'uploads')); - if (file.existsSync(faviconPath)) { - app.use(nconf.get('relative_path'), favicon(faviconPath)); - } + let faviconPath = meta.config['brand:favicon'] || 'favicon.ico'; + faviconPath = path.join(nconf.get('base_dir'), 'public', faviconPath.replace(/assets\/uploads/, 'uploads')); + if (file.existsSync(faviconPath)) { + app.use(nconf.get('relative_path'), favicon(faviconPath)); + } } function configureBodyParser(app) { - const urlencodedOpts = nconf.get('bodyParser:urlencoded') || {}; - if (!urlencodedOpts.hasOwnProperty('extended')) { - urlencodedOpts.extended = true; - } - app.use(bodyParser.urlencoded(urlencodedOpts)); - - const jsonOpts = nconf.get('bodyParser:json') || {}; - app.use(bodyParser.json(jsonOpts)); + const urlencodedOptions = nconf.get('bodyParser:urlencoded') || {}; + if (!urlencodedOptions.hasOwnProperty('extended')) { + urlencodedOptions.extended = true; + } + + app.use(bodyParser.urlencoded(urlencodedOptions)); + + const jsonOptions = nconf.get('bodyParser:json') || {}; + app.use(bodyParser.json(jsonOptions)); } function setupCookie() { - const cookie = meta.configs.cookie.get(); - const ttl = meta.getSessionTTLSeconds() * 1000; - cookie.maxAge = ttl; + const cookie = meta.configs.cookie.get(); + const ttl = meta.getSessionTTLSeconds() * 1000; + cookie.maxAge = ttl; - return cookie; + return cookie; } async function listen() { - let port = nconf.get('port'); - const isSocket = isNaN(port) && !Array.isArray(port); - const socketPath = isSocket ? nconf.get('port') : ''; - - if (Array.isArray(port)) { - if (!port.length) { - winston.error('[startup] empty ports array in config.json'); - process.exit(); - } - - winston.warn('[startup] If you want to start nodebb on multiple ports please use loader.js'); - winston.warn(`[startup] Defaulting to first port in array, ${port[0]}`); - port = port[0]; - if (!port) { - winston.error('[startup] Invalid port, exiting'); - process.exit(); - } - } - port = parseInt(port, 10); - if ((port !== 80 && port !== 443) || nconf.get('trust_proxy') === true) { - winston.info('🤝 Enabling \'trust proxy\''); - app.enable('trust proxy'); - } - - if ((port === 80 || port === 443) && process.env.NODE_ENV !== 'development') { - winston.info('Using ports 80 and 443 is not recommend; use a proxy instead. See README.md'); - } - - const bind_address = ((nconf.get('bind_address') === '0.0.0.0' || !nconf.get('bind_address')) ? '0.0.0.0' : nconf.get('bind_address')); - const args = isSocket ? [socketPath] : [port, bind_address]; - let oldUmask; - - if (isSocket) { - oldUmask = process.umask('0000'); - try { - await exports.testSocket(socketPath); - } catch (err) { - winston.error(`[startup] NodeBB was unable to secure domain socket access (${socketPath})\n${err.stack}`); - throw err; - } - } - - return new Promise((resolve, reject) => { - server.listen(...args.concat([function (err) { - const onText = `${isSocket ? socketPath : `${bind_address}:${port}`}`; - if (err) { - winston.error(`[startup] NodeBB was unable to listen on: ${chalk.yellow(onText)}`); - reject(err); - } - - winston.info(`📡 NodeBB is now listening on: ${chalk.yellow(onText)}`); - winston.info(`🔗 Canonical URL: ${chalk.yellow(nconf.get('url'))}`); - if (oldUmask) { - process.umask(oldUmask); - } - resolve(); - }])); - }); + let port = nconf.get('port'); + const isSocket = isNaN(port) && !Array.isArray(port); + const socketPath = isSocket ? nconf.get('port') : ''; + + if (Array.isArray(port)) { + if (port.length === 0) { + winston.error('[startup] empty ports array in config.json'); + process.exit(); + } + + winston.warn('[startup] If you want to start nodebb on multiple ports please use loader.js'); + winston.warn(`[startup] Defaulting to first port in array, ${port[0]}`); + port = port[0]; + if (!port) { + winston.error('[startup] Invalid port, exiting'); + process.exit(); + } + } + + port = Number.parseInt(port, 10); + if ((port !== 80 && port !== 443) || nconf.get('trust_proxy') === true) { + winston.info('🤝 Enabling \'trust proxy\''); + app.enable('trust proxy'); + } + + if ((port === 80 || port === 443) && process.env.NODE_ENV !== 'development') { + winston.info('Using ports 80 and 443 is not recommend; use a proxy instead. See README.md'); + } + + const bind_address = ((nconf.get('bind_address') === '0.0.0.0' || !nconf.get('bind_address')) ? '0.0.0.0' : nconf.get('bind_address')); + const arguments_ = isSocket ? [socketPath] : [port, bind_address]; + let oldUmask; + + if (isSocket) { + oldUmask = process.umask('0000'); + try { + await exports.testSocket(socketPath); + } catch (error) { + winston.error(`[startup] NodeBB was unable to secure domain socket access (${socketPath})\n${error.stack}`); + throw error; + } + } + + return new Promise((resolve, reject) => { + server.listen(...arguments_.concat([function (error) { + const onText = `${isSocket ? socketPath : `${bind_address}:${port}`}`; + if (error) { + winston.error(`[startup] NodeBB was unable to listen on: ${chalk.yellow(onText)}`); + reject(error); + } + + winston.info(`📡 NodeBB is now listening on: ${chalk.yellow(onText)}`); + winston.info(`🔗 Canonical URL: ${chalk.yellow(nconf.get('url'))}`); + if (oldUmask) { + process.umask(oldUmask); + } + + resolve(); + }])); + }); } exports.testSocket = async function (socketPath) { - if (typeof socketPath !== 'string') { - throw new Error(`invalid socket path : ${socketPath}`); - } - const net = require('net'); - const file = require('./file'); - const exists = await file.exists(socketPath); - if (!exists) { - return; - } - return new Promise((resolve, reject) => { - const testSocket = new net.Socket(); - testSocket.on('error', (err) => { - if (err.code !== 'ECONNREFUSED') { - return reject(err); - } - // The socket was stale, kick it out of the way - fs.unlink(socketPath, (err) => { - if (err) reject(err); else resolve(); - }); - }); - testSocket.connect({ path: socketPath }, () => { - // Something's listening here, abort - reject(new Error('port-in-use')); - }); - }); + if (typeof socketPath !== 'string') { + throw new TypeError(`invalid socket path : ${socketPath}`); + } + + const net = require('node:net'); + const file = require('./file'); + const exists = await file.exists(socketPath); + if (!exists) { + return; + } + + return new Promise((resolve, reject) => { + const testSocket = new net.Socket(); + testSocket.on('error', error => { + if (error.code !== 'ECONNREFUSED') { + return reject(error); + } + + // The socket was stale, kick it out of the way + fs.unlink(socketPath, error => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + testSocket.connect({path: socketPath}, () => { + // Something's listening here, abort + reject(new Error('port-in-use')); + }); + }); }; require('./promisify')(exports); diff --git a/src/widgets/admin.js b/src/widgets/admin.js index c3b79d3..0921bfd 100644 --- a/src/widgets/admin.js +++ b/src/widgets/admin.js @@ -8,77 +8,80 @@ const index = require('./index'); const admin = module.exports; admin.get = async function () { - const [areas, availableWidgets] = await Promise.all([ - admin.getAreas(), - getAvailableWidgets(), - ]); - - return { - templates: buildTemplatesFromAreas(areas), - areas: areas, - availableWidgets: availableWidgets, - }; + const [areas, availableWidgets] = await Promise.all([ + admin.getAreas(), + getAvailableWidgets(), + ]); + + return { + templates: buildTemplatesFromAreas(areas), + areas, + availableWidgets, + }; }; admin.getAreas = async function () { - const defaultAreas = [ - { name: 'Global Sidebar', template: 'global', location: 'sidebar' }, - { name: 'Global Header', template: 'global', location: 'header' }, - { name: 'Global Footer', template: 'global', location: 'footer' }, - - { name: 'Group Page (Left)', template: 'groups/details.tpl', location: 'left' }, - { name: 'Group Page (Right)', template: 'groups/details.tpl', location: 'right' }, - ]; - - const areas = await plugins.hooks.fire('filter:widgets.getAreas', defaultAreas); - - areas.push({ name: 'Draft Zone', template: 'global', location: 'drafts' }); - const areaData = await Promise.all(areas.map(area => index.getArea(area.template, area.location))); - areas.forEach((area, i) => { - area.data = areaData[i]; - }); - return areas; + const defaultAreas = [ + {name: 'Global Sidebar', template: 'global', location: 'sidebar'}, + {name: 'Global Header', template: 'global', location: 'header'}, + {name: 'Global Footer', template: 'global', location: 'footer'}, + + {name: 'Group Page (Left)', template: 'groups/details.tpl', location: 'left'}, + {name: 'Group Page (Right)', template: 'groups/details.tpl', location: 'right'}, + ]; + + const areas = await plugins.hooks.fire('filter:widgets.getAreas', defaultAreas); + + areas.push({name: 'Draft Zone', template: 'global', location: 'drafts'}); + const areaData = await Promise.all(areas.map(area => index.getArea(area.template, area.location))); + for (const [i, area] of areas.entries()) { + area.data = areaData[i]; + } + + return areas; }; async function getAvailableWidgets() { - const [availableWidgets, adminTemplate] = await Promise.all([ - plugins.hooks.fire('filter:widgets.getWidgets', []), - renderAdminTemplate(), - ]); - availableWidgets.forEach((w) => { - w.content += adminTemplate; - }); - return availableWidgets; + const [availableWidgets, adminTemplate] = await Promise.all([ + plugins.hooks.fire('filter:widgets.getWidgets', []), + renderAdminTemplate(), + ]); + for (const w of availableWidgets) { + w.content += adminTemplate; + } + + return availableWidgets; } async function renderAdminTemplate() { - const groupsData = await groups.getNonPrivilegeGroups('groups:createtime', 0, -1); - groupsData.sort((a, b) => b.system - a.system); - return await webserver.app.renderAsync('admin/partials/widget-settings', { groups: groupsData }); + const groupsData = await groups.getNonPrivilegeGroups('groups:createtime', 0, -1); + groupsData.sort((a, b) => b.system - a.system); + return await webserver.app.renderAsync('admin/partials/widget-settings', {groups: groupsData}); } function buildTemplatesFromAreas(areas) { - const templates = []; - const list = {}; - let index = 0; - - areas.forEach((area) => { - if (typeof list[area.template] === 'undefined') { - list[area.template] = index; - templates.push({ - template: area.template, - areas: [], - }); - - index += 1; - } - - templates[list[area.template]].areas.push({ - name: area.name, - location: area.location, - }); - }); - return templates; + const templates = []; + const list = {}; + let index = 0; + + for (const area of areas) { + if (list[area.template] === undefined) { + list[area.template] = index; + templates.push({ + template: area.template, + areas: [], + }); + + index += 1; + } + + templates[list[area.template]].areas.push({ + name: area.name, + location: area.location, + }); + } + + return templates; } require('../promisify')(admin); diff --git a/src/widgets/index.js b/src/widgets/index.js index 992018f..dd0ca6f 100644 --- a/src/widgets/index.js +++ b/src/widgets/index.js @@ -3,7 +3,6 @@ const winston = require('winston'); const _ = require('lodash'); const Benchpress = require('benchpressjs'); - const plugins = require('../plugins'); const groups = require('../groups'); const translator = require('../translator'); @@ -14,218 +13,222 @@ const meta = require('../meta'); const widgets = module.exports; widgets.render = async function (uid, options) { - if (!options.template) { - throw new Error('[[error:invalid-data]]'); - } - const data = await widgets.getWidgetDataForTemplates(['global', options.template]); - delete data.global.drafts; + if (!options.template) { + throw new Error('[[error:invalid-data]]'); + } + + const data = await widgets.getWidgetDataForTemplates(['global', options.template]); + delete data.global.drafts; - const locations = _.uniq(Object.keys(data.global).concat(Object.keys(data[options.template]))); + const locations = _.uniq(Object.keys(data.global).concat(Object.keys(data[options.template]))); - const widgetData = await Promise.all(locations.map(location => renderLocation(location, data, uid, options))); + const widgetData = await Promise.all(locations.map(location => renderLocation(location, data, uid, options))); - const returnData = {}; - locations.forEach((location, i) => { - if (Array.isArray(widgetData[i]) && widgetData[i].length) { - returnData[location] = widgetData[i].filter(Boolean); - } - }); + const returnData = {}; + for (const [i, location] of locations.entries()) { + if (Array.isArray(widgetData[i]) && widgetData[i].length > 0) { + returnData[location] = widgetData[i].filter(Boolean); + } + } - return returnData; + return returnData; }; async function renderLocation(location, data, uid, options) { - const widgetsAtLocation = (data[options.template][location] || []).concat(data.global[location] || []); + const widgetsAtLocation = (data[options.template][location] || []).concat(data.global[location] || []); - if (!widgetsAtLocation.length) { - return []; - } + if (widgetsAtLocation.length === 0) { + return []; + } - const renderedWidgets = await Promise.all(widgetsAtLocation.map(widget => renderWidget(widget, uid, options))); - return renderedWidgets; + const renderedWidgets = await Promise.all(widgetsAtLocation.map(widget => renderWidget(widget, uid, options))); + return renderedWidgets; } async function renderWidget(widget, uid, options) { - if (!widget || !widget.data || (!!widget.data['hide-mobile'] && options.req.useragent.isMobile)) { - return; - } - - const isVisible = await widgets.checkVisibility(widget.data, uid); - if (!isVisible) { - return; - } - - let config = options.res.locals.config || {}; - if (options.res.locals.isAPI) { - config = await apiController.loadConfig(options.req); - } - - const userLang = config.userLang || meta.config.defaultLang || 'en-GB'; - const templateData = _.assign({ }, options.templateData, { config: config }); - const data = await plugins.hooks.fire(`filter:widget.render:${widget.widget}`, { - uid: uid, - area: options, - templateData: templateData, - data: widget.data, - req: options.req, - res: options.res, - }); - - if (!data) { - return; - } - - let { html } = data; - - if (widget.data.container && widget.data.container.match('{body}')) { - html = await Benchpress.compileRender(widget.data.container, { - title: widget.data.title, - body: html, - template: data.templateData && data.templateData.template, - }); - } - - if (html) { - html = await translator.translate(html, userLang); - } - - return { html }; + if (!widget || !widget.data || (Boolean(widget.data['hide-mobile']) && options.req.useragent.isMobile)) { + return; + } + + const isVisible = await widgets.checkVisibility(widget.data, uid); + if (!isVisible) { + return; + } + + let config = options.res.locals.config || {}; + if (options.res.locals.isAPI) { + config = await apiController.loadConfig(options.req); + } + + const userLang = config.userLang || meta.config.defaultLang || 'en-GB'; + const templateData = _.assign({}, options.templateData, {config}); + const data = await plugins.hooks.fire(`filter:widget.render:${widget.widget}`, { + uid, + area: options, + templateData, + data: widget.data, + req: options.req, + res: options.res, + }); + + if (!data) { + return; + } + + let {html} = data; + + if (widget.data.container && widget.data.container.match('{body}')) { + html = await Benchpress.compileRender(widget.data.container, { + title: widget.data.title, + body: html, + template: data.templateData && data.templateData.template, + }); + } + + html &&= await translator.translate(html, userLang); + + return {html}; } widgets.checkVisibility = async function (data, uid) { - let isVisible = true; - let isHidden = false; - if (data.groups.length) { - isVisible = await groups.isMemberOfAny(uid, data.groups); - } - if (data.groupsHideFrom.length) { - isHidden = await groups.isMemberOfAny(uid, data.groupsHideFrom); - } - return isVisible && !isHidden; + let isVisible = true; + let isHidden = false; + if (data.groups.length > 0) { + isVisible = await groups.isMemberOfAny(uid, data.groups); + } + + if (data.groupsHideFrom.length > 0) { + isHidden = await groups.isMemberOfAny(uid, data.groupsHideFrom); + } + + return isVisible && !isHidden; }; widgets.getWidgetDataForTemplates = async function (templates) { - const keys = templates.map(tpl => `widgets:${tpl}`); - const data = await db.getObjects(keys); - - const returnData = {}; - - templates.forEach((template, index) => { - returnData[template] = returnData[template] || {}; - - const templateWidgetData = data[index] || {}; - const locations = Object.keys(templateWidgetData); - - locations.forEach((location) => { - if (templateWidgetData && templateWidgetData[location]) { - try { - returnData[template][location] = parseWidgetData(templateWidgetData[location]); - } catch (err) { - winston.error(`can not parse widget data. template: ${template} location: ${location}`); - returnData[template][location] = []; - } - } else { - returnData[template][location] = []; - } - }); - }); - - return returnData; + const keys = templates.map(tpl => `widgets:${tpl}`); + const data = await db.getObjects(keys); + + const returnData = {}; + + for (const [index, template] of templates.entries()) { + returnData[template] = returnData[template] || {}; + + const templateWidgetData = data[index] || {}; + const locations = Object.keys(templateWidgetData); + + for (const location of locations) { + if (templateWidgetData && templateWidgetData[location]) { + try { + returnData[template][location] = parseWidgetData(templateWidgetData[location]); + } catch { + winston.error(`can not parse widget data. template: ${template} location: ${location}`); + returnData[template][location] = []; + } + } else { + returnData[template][location] = []; + } + } + } + + return returnData; }; widgets.getArea = async function (template, location) { - const result = await db.getObjectField(`widgets:${template}`, location); - if (!result) { - return []; - } - return parseWidgetData(result); + const result = await db.getObjectField(`widgets:${template}`, location); + if (!result) { + return []; + } + + return parseWidgetData(result); }; function parseWidgetData(data) { - const widgets = JSON.parse(data); - widgets.forEach((widget) => { - if (widget) { - widget.data.groups = widget.data.groups || []; - if (widget.data.groups && !Array.isArray(widget.data.groups)) { - widget.data.groups = [widget.data.groups]; - } - - widget.data.groupsHideFrom = widget.data.groupsHideFrom || []; - if (widget.data.groupsHideFrom && !Array.isArray(widget.data.groupsHideFrom)) { - widget.data.groupsHideFrom = [widget.data.groupsHideFrom]; - } - } - }); - return widgets; + const widgets = JSON.parse(data); + for (const widget of widgets) { + if (widget) { + widget.data.groups = widget.data.groups || []; + if (widget.data.groups && !Array.isArray(widget.data.groups)) { + widget.data.groups = [widget.data.groups]; + } + + widget.data.groupsHideFrom = widget.data.groupsHideFrom || []; + if (widget.data.groupsHideFrom && !Array.isArray(widget.data.groupsHideFrom)) { + widget.data.groupsHideFrom = [widget.data.groupsHideFrom]; + } + } + } + + return widgets; } widgets.setArea = async function (area) { - if (!area.location || !area.template) { - throw new Error('Missing location and template data'); - } + if (!area.location || !area.template) { + throw new Error('Missing location and template data'); + } - await db.setObjectField(`widgets:${area.template}`, area.location, JSON.stringify(area.widgets)); + await db.setObjectField(`widgets:${area.template}`, area.location, JSON.stringify(area.widgets)); }; widgets.setAreas = async function (areas) { - const templates = {}; - areas.forEach((area) => { - if (!area.location || !area.template) { - throw new Error('Missing location and template data'); - } - templates[area.template] = templates[area.template] || {}; - templates[area.template][area.location] = JSON.stringify(area.widgets); - }); - - await db.setObjectBulk( - Object.keys(templates).map(tpl => [`widgets:${tpl}`, templates[tpl]]) - ); + const templates = {}; + for (const area of areas) { + if (!area.location || !area.template) { + throw new Error('Missing location and template data'); + } + + templates[area.template] = templates[area.template] || {}; + templates[area.template][area.location] = JSON.stringify(area.widgets); + } + + await db.setObjectBulk( + Object.keys(templates).map(tpl => [`widgets:${tpl}`, templates[tpl]]), + ); }; widgets.reset = async function () { - const defaultAreas = [ - { name: 'Draft Zone', template: 'global', location: 'header' }, - { name: 'Draft Zone', template: 'global', location: 'footer' }, - { name: 'Draft Zone', template: 'global', location: 'sidebar' }, - ]; - - const [areas, drafts] = await Promise.all([ - plugins.hooks.fire('filter:widgets.getAreas', defaultAreas), - widgets.getArea('global', 'drafts'), - ]); - - let saveDrafts = drafts || []; - for (const area of areas) { - /* eslint-disable no-await-in-loop */ - const areaData = await widgets.getArea(area.template, area.location); - saveDrafts = saveDrafts.concat(areaData); - area.widgets = []; - await widgets.setArea(area); - } - - await widgets.setArea({ - template: 'global', - location: 'drafts', - widgets: saveDrafts, - }); + const defaultAreas = [ + {name: 'Draft Zone', template: 'global', location: 'header'}, + {name: 'Draft Zone', template: 'global', location: 'footer'}, + {name: 'Draft Zone', template: 'global', location: 'sidebar'}, + ]; + + const [areas, drafts] = await Promise.all([ + plugins.hooks.fire('filter:widgets.getAreas', defaultAreas), + widgets.getArea('global', 'drafts'), + ]); + + let saveDrafts = drafts || []; + for (const area of areas) { + /* eslint-disable no-await-in-loop */ + const areaData = await widgets.getArea(area.template, area.location); + saveDrafts = saveDrafts.concat(areaData); + area.widgets = []; + await widgets.setArea(area); + } + + await widgets.setArea({ + template: 'global', + location: 'drafts', + widgets: saveDrafts, + }); }; widgets.resetTemplate = async function (template) { - const area = await db.getObject(`widgets:${template}.tpl`); - if (area) { - const toBeDrafted = _.flatMap(Object.values(area), value => JSON.parse(value)); - await db.delete(`widgets:${template}.tpl`); - let draftWidgets = await db.getObjectField('widgets:global', 'drafts'); - draftWidgets = JSON.parse(draftWidgets).concat(toBeDrafted); - await db.setObjectField('widgets:global', 'drafts', JSON.stringify(draftWidgets)); - } + const area = await db.getObject(`widgets:${template}.tpl`); + if (area) { + const toBeDrafted = _.flatMap(Object.values(area), value => JSON.parse(value)); + await db.delete(`widgets:${template}.tpl`); + let draftWidgets = await db.getObjectField('widgets:global', 'drafts'); + draftWidgets = JSON.parse(draftWidgets).concat(toBeDrafted); + await db.setObjectField('widgets:global', 'drafts', JSON.stringify(draftWidgets)); + } }; widgets.resetTemplates = async function (templates) { - for (const template of templates) { - /* eslint-disable no-await-in-loop */ - await widgets.resetTemplate(template); - } + for (const template of templates) { + /* eslint-disable no-await-in-loop */ + await widgets.resetTemplate(template); + } }; require('../promisify')(widgets); diff --git a/test/api.js b/test/api.js index b37bddc..3036eca 100644 --- a/test/api.js +++ b/test/api.js @@ -1,19 +1,17 @@ 'use strict'; -const _ = require('lodash'); -const assert = require('assert'); -const path = require('path'); -const fs = require('fs'); -const SwaggerParser = require('@apidevtools/swagger-parser'); +const assert = require('node:assert'); +const path = require('node:path'); +const fs = require('node:fs'); +const util = require('node:util'); const request = require('request-promise-native'); const nconf = require('nconf'); const jwt = require('jsonwebtoken'); -const util = require('util'); +const SwaggerParser = require('@apidevtools/swagger-parser'); +const _ = require('lodash'); const wait = util.promisify(setTimeout); -const db = require('./mocks/databasemock'); -const helpers = require('./helpers'); const meta = require('../src/meta'); const user = require('../src/user'); const groups = require('../src/groups'); @@ -24,569 +22,591 @@ const plugins = require('../src/plugins'); const flags = require('../src/flags'); const messaging = require('../src/messaging'); const utils = require('../src/utils'); +const helpers = require('./helpers'); +const db = require('./mocks/databasemock'); describe('API', async () => { - let readApi = false; - let writeApi = false; - const readApiPath = path.resolve(__dirname, '../public/openapi/read.yaml'); - const writeApiPath = path.resolve(__dirname, '../public/openapi/write.yaml'); - let jar; - let csrfToken; - let setup = false; - const unauthenticatedRoutes = ['/api/login', '/api/register']; // Everything else will be called with the admin user - - const mocks = { - head: {}, - get: { - '/api/email/unsubscribe/{token}': [ - { - in: 'path', - name: 'token', - example: (() => jwt.sign({ - template: 'digest', - uid: 1, - }, nconf.get('secret')))(), - }, - ], - }, - post: {}, - put: {}, - delete: { - '/users/{uid}/tokens/{token}': [ - { - in: 'path', - name: 'uid', - example: 1, - }, - { - in: 'path', - name: 'token', - example: utils.generateUUID(), - }, - ], - '/users/{uid}/sessions/{uuid}': [ - { - in: 'path', - name: 'uid', - example: 1, - }, - { - in: 'path', - name: 'uuid', - example: '', // to be defined below... - }, - ], - '/posts/{pid}/diffs/{timestamp}': [ - { - in: 'path', - name: 'pid', - example: '', // to be defined below... - }, - { - in: 'path', - name: 'timestamp', - example: '', // to be defined below... - }, - ], - }, - }; - - async function dummySearchHook(data) { - return [1]; - } - async function dummyEmailerHook(data) { - // pretend to handle sending emails - } - - after(async () => { - plugins.hooks.unregister('core', 'filter:search.query', dummySearchHook); - plugins.hooks.unregister('emailer-test', 'filter:email.send'); - }); - - async function setupData() { - if (setup) { - return; - } - - // Create sample users - const adminUid = await user.create({ username: 'admin', password: '123456', email: 'test@example.org' }); - const unprivUid = await user.create({ username: 'unpriv', password: '123456', email: 'unpriv@example.org' }); - await user.setUserField(adminUid, 'email', 'test@example.org'); - await user.setUserField(unprivUid, 'email', 'unpriv@example.org'); - await user.email.confirmByUid(adminUid); - await user.email.confirmByUid(unprivUid); - - for (let x = 0; x < 4; x++) { - // eslint-disable-next-line no-await-in-loop - await user.create({ username: 'deleteme', password: '123456' }); // for testing of DELETE /users (uids 5, 6) and DELETE /user/:uid/account (uid 7) - } - await groups.join('administrators', adminUid); - - // Create sample group - await groups.create({ - name: 'Test Group', - }); - - await meta.settings.set('core.api', { - tokens: [{ - token: mocks.delete['/users/{uid}/tokens/{token}'][1].example, - uid: 1, - description: 'for testing of token deletion route', - timestamp: Date.now(), - }], - }); - meta.config.allowTopicsThumbnail = 1; - meta.config.termsOfUse = 'I, for one, welcome our new test-driven overlords'; - meta.config.chatMessageDelay = 0; - - // Create a category - const testCategory = await categories.create({ name: 'test' }); - - // Post a new topic - await topics.post({ - uid: adminUid, - cid: testCategory.cid, - title: 'Test Topic', - content: 'Test topic content', - }); - const unprivTopic = await topics.post({ - uid: unprivUid, - cid: testCategory.cid, - title: 'Test Topic 2', - content: 'Test topic 2 content', - }); - await topics.post({ - uid: unprivUid, - cid: testCategory.cid, - title: 'Test Topic 3', - content: 'Test topic 3 content', - }); - - // Create a post diff - await posts.edit({ - uid: adminUid, - pid: unprivTopic.postData.pid, - content: 'Test topic 2 edited content', - req: {}, - }); - mocks.delete['/posts/{pid}/diffs/{timestamp}'][0].example = unprivTopic.postData.pid; - mocks.delete['/posts/{pid}/diffs/{timestamp}'][1].example = (await posts.diffs.list(unprivTopic.postData.pid))[0]; - - // Create a sample flag - const { flagId } = await flags.create('post', 1, unprivUid, 'sample reasons', Date.now()); // deleted in DELETE /api/v3/flags/1 - await flags.appendNote(flagId, 1, 'test note', 1626446956652); - await flags.create('post', 2, unprivUid, 'sample reasons', Date.now()); // for testing flag notes (since flag 1 deleted) - - // Create a new chat room - await messaging.newRoom(1, [2]); - - // Create an empty file to test DELETE /files and thumb deletion - fs.closeSync(fs.openSync(path.resolve(nconf.get('upload_path'), 'files/test.txt'), 'w')); - fs.closeSync(fs.openSync(path.resolve(nconf.get('upload_path'), 'files/test.png'), 'w')); - - // Associate thumb with topic to test thumb reordering - await topics.thumbs.associate({ - id: 2, - path: 'files/test.png', - }); - - const socketUser = require('../src/socket.io/user'); - const socketAdmin = require('../src/socket.io/admin'); - // export data for admin user - await socketUser.exportProfile({ uid: adminUid }, { uid: adminUid }); - await wait(2000); - await socketUser.exportPosts({ uid: adminUid }, { uid: adminUid }); - await wait(2000); - await socketUser.exportUploads({ uid: adminUid }, { uid: adminUid }); - await wait(2000); - await socketAdmin.user.exportUsersCSV({ uid: adminUid }, {}); - // wait for export child process to complete - await wait(5000); - - // Attach a search hook so /api/search is enabled - plugins.hooks.register('core', { - hook: 'filter:search.query', - method: dummySearchHook, - }); - // Attach an emailer hook so related requests do not error - plugins.hooks.register('emailer-test', { - hook: 'filter:email.send', - method: dummyEmailerHook, - }); - - // All tests run as admin user - ({ jar } = await helpers.loginUser('admin', '123456')); - - // Retrieve CSRF token using cookie, to test Write API - const config = await request({ - url: `${nconf.get('url')}/api/config`, - json: true, - jar: jar, - }); - csrfToken = config.csrf_token; - - setup = true; - } - - it('should pass OpenAPI v3 validation', async () => { - try { - await SwaggerParser.validate(readApiPath); - await SwaggerParser.validate(writeApiPath); - } catch (e) { - assert.ifError(e); - } - }); - - readApi = await SwaggerParser.dereference(readApiPath); - writeApi = await SwaggerParser.dereference(writeApiPath); - - it('should grab all mounted routes and ensure a schema exists', async () => { - const webserver = require('../src/webserver'); - const buildPaths = function (stack, prefix) { - const paths = stack.map((dispatch) => { - if (dispatch.route && dispatch.route.path && typeof dispatch.route.path === 'string') { - if (!prefix && !dispatch.route.path.startsWith('/api/')) { - return null; - } - - if (prefix === nconf.get('relative_path')) { - prefix = ''; - } - - return { - method: Object.keys(dispatch.route.methods)[0], - path: (prefix || '') + dispatch.route.path, - }; - } else if (dispatch.name === 'router') { - const prefix = dispatch.regexp.toString().replace('/^', '').replace('\\/?(?=\\/|$)/i', '').replace(/\\\//g, '/'); - return buildPaths(dispatch.handle.stack, prefix); - } - - // Drop any that aren't actual routes (middlewares, error handlers, etc.) - return null; - }); - - return _.flatten(paths); - }; - - let paths = buildPaths(webserver.app._router.stack).filter(Boolean).map((pathObj) => { - pathObj.path = pathObj.path.replace(/\/:([^\\/]+)/g, '/{$1}'); - return pathObj; - }); - const exclusionPrefixes = [ - '/api/admin/plugins', '/api/compose', '/debug', - '/api/user/{userslug}/theme', // from persona - ]; - paths = paths.filter(path => path.method !== '_all' && !exclusionPrefixes.some(prefix => path.path.startsWith(prefix))); - - - // For each express path, query for existence in read and write api schemas - paths.forEach((pathObj) => { - describe(`${pathObj.method.toUpperCase()} ${pathObj.path}`, () => { - it('should be defined in schema docs', () => { - let schema = readApi; - if (pathObj.path.startsWith('/api/v3')) { - schema = writeApi; - pathObj.path = pathObj.path.replace('/api/v3', ''); - } - - // Don't check non-GET routes in Read API - if (schema === readApi && pathObj.method !== 'get') { - return; - } - - const normalizedPath = pathObj.path.replace(/\/:([^\\/]+)/g, '/{$1}').replace(/\?/g, ''); - assert(schema.paths.hasOwnProperty(normalizedPath), `${pathObj.path} is not defined in schema docs`); - assert(schema.paths[normalizedPath].hasOwnProperty(pathObj.method), `${pathObj.path} was found in schema docs, but ${pathObj.method.toUpperCase()} method is not defined`); - }); - }); - }); - }); - - // generateTests(readApi, Object.keys(readApi.paths)); - generateTests(writeApi, Object.keys(writeApi.paths), writeApi.servers[0].url); - - function generateTests(api, paths, prefix) { - // Iterate through all documented paths, make a call to it, - // and compare the result body with what is defined in the spec - const pathLib = path; // for calling path module from inside this forEach - paths.forEach((path) => { - const context = api.paths[path]; - let schema; - let response; - let url; - let method; - const headers = {}; - const qs = {}; - - Object.keys(context).forEach((_method) => { - // Only test GET routes in the Read API - if (api.info.title === 'NodeBB Read API' && _method !== 'get') { - return; - } - - it(`${_method.toUpperCase()} ${path}: should have each path parameter defined in its context`, () => { - method = _method; - if (!context[method].parameters) { - return; - } - - const pathParams = (path.match(/{[\w\-_*]+}?/g) || []).map(match => match.slice(1, -1)); - const schemaParams = context[method].parameters.map(param => (param.in === 'path' ? param.name : null)).filter(Boolean); - assert(pathParams.every(param => schemaParams.includes(param)), `${method.toUpperCase()} ${path} has path parameters specified but not defined`); - }); - - it(`${_method.toUpperCase()} ${path}: should have examples when parameters are present`, () => { - let { parameters } = context[method]; - let testPath = path; - - if (parameters) { - // Use mock data if provided - parameters = mocks[method][path] || parameters; - - parameters.forEach((param) => { - assert(param.example !== null && param.example !== undefined, `${method.toUpperCase()} ${path} has parameters without examples`); - - switch (param.in) { - case 'path': - testPath = testPath.replace(`{${param.name}}`, param.example); - break; - case 'header': - headers[param.name] = param.example; - break; - case 'query': - qs[param.name] = param.example; - break; - } - }); - } - - url = nconf.get('url') + (prefix || '') + testPath; - }); - - it(`${_method.toUpperCase()} ${path}: should contain a valid request body (if present) with application/json or multipart/form-data type if POST/PUT/DELETE`, () => { - if (['post', 'put', 'delete'].includes(method) && context[method].hasOwnProperty('requestBody')) { - const failMessage = `${method.toUpperCase()} ${path} has a malformed request body`; - assert(context[method].requestBody, failMessage); - assert(context[method].requestBody.content, failMessage); - - if (context[method].requestBody.content.hasOwnProperty('application/json')) { - assert(context[method].requestBody.content['application/json'], failMessage); - assert(context[method].requestBody.content['application/json'].schema, failMessage); - assert(context[method].requestBody.content['application/json'].schema.properties, failMessage); - } else if (context[method].requestBody.content.hasOwnProperty('multipart/form-data')) { - assert(context[method].requestBody.content['multipart/form-data'], failMessage); - assert(context[method].requestBody.content['multipart/form-data'].schema, failMessage); - assert(context[method].requestBody.content['multipart/form-data'].schema.properties, failMessage); - } - } - }); - - it(`${_method.toUpperCase()} ${path}: should not error out when called`, async () => { - await setupData(); - - if (csrfToken) { - headers['x-csrf-token'] = csrfToken; - } - - let body = {}; - let type = 'json'; - if (context[method].hasOwnProperty('requestBody') && context[method].requestBody.content['application/json']) { - body = buildBody(context[method].requestBody.content['application/json'].schema.properties); - } else if (context[method].hasOwnProperty('requestBody') && context[method].requestBody.content['multipart/form-data']) { - type = 'form'; - } - - try { - if (type === 'json') { - response = await request(url, { - method: method, - jar: !unauthenticatedRoutes.includes(path) ? jar : undefined, - json: true, - followRedirect: false, // all responses are significant (e.g. 302) - simple: false, // don't throw on non-200 (e.g. 302) - resolveWithFullResponse: true, // send full request back (to check statusCode) - headers: headers, - qs: qs, - body: body, - }); - } else if (type === 'form') { - response = await new Promise((resolve, reject) => { - helpers.uploadFile(url, pathLib.join(__dirname, './files/test.png'), {}, jar, csrfToken, (err, res) => { - if (err) { - return reject(err); - } - resolve(res); - }); - }); - } - } catch (e) { - assert(!e, `${method.toUpperCase()} ${path} errored with: ${e.message}`); - } - }); - - it(`${_method.toUpperCase()} ${path}: response status code should match one of the schema defined responses`, () => { - // HACK: allow HTTP 418 I am a teapot, for now 👇 - assert(context[method].responses.hasOwnProperty('418') || Object.keys(context[method].responses).includes(String(response.statusCode)), `${method.toUpperCase()} ${path} sent back unexpected HTTP status code: ${response.statusCode} ${JSON.stringify(response.body)}`); - }); - - // Recursively iterate through schema properties, comparing type - it(`${_method.toUpperCase()} ${path}: response body should match schema definition`, async () => { - const http302 = context[method].responses['302']; - if (http302 && response.statusCode === 302) { - // Compare headers instead - const expectedHeaders = Object.keys(http302.headers).reduce((memo, name) => { - const value = http302.headers[name].schema.example; - memo[name] = value.startsWith(nconf.get('relative_path')) ? value : nconf.get('relative_path') + value; - return memo; - }, {}); - - for (const header of Object.keys(expectedHeaders)) { - assert(response.headers[header.toLowerCase()]); - assert.strictEqual(response.headers[header.toLowerCase()], expectedHeaders[header]); - } - return; - } - - const http200 = context[method].responses['200']; - if (!http200) { - return; - } - - assert.strictEqual(response.statusCode, 200, `HTTP 200 expected (path: ${method} ${path}`); - - const hasJSON = http200.content && http200.content['application/json']; - if (hasJSON) { - schema = context[method].responses['200'].content['application/json'].schema; - compare(schema, response.body, method.toUpperCase(), path, 'root'); - } - - // TODO someday: text/csv, binary file type checking? - }); - - it(`${_method.toUpperCase()} ${path}: should successfully re-login if needed`, async () => { - const reloginPaths = ['PUT /users/{uid}/password', 'DELETE /users/{uid}/sessions/{uuid}']; - if (reloginPaths.includes(`${method.toUpperCase()} ${path}`)) { - ({ jar } = await helpers.loginUser('admin', '123456')); - const sessionUUIDs = await db.getObject('uid:1:sessionUUID:sessionId'); - mocks.delete['/users/{uid}/sessions/{uuid}'][1].example = Object.keys(sessionUUIDs).pop(); - - // Retrieve CSRF token using cookie, to test Write API - const config = await request({ - url: `${nconf.get('url')}/api/config`, - json: true, - jar: jar, - }); - csrfToken = config.csrf_token; - } - }); - - it(`${_method.toUpperCase()} ${path}: should back out of a registration interstitial if needed`, async () => { - const affectedPaths = ['GET /api/user/{userslug}/edit/email']; - if (affectedPaths.includes(`${method.toUpperCase()} ${path}`)) { - await request({ - uri: `${nconf.get('url')}/register/abort?_csrf=${csrfToken}`, - method: 'POST', - jar, - simple: false, - }); - } - }); - }); - }); - } - - function buildBody(schema) { - return Object.keys(schema).reduce((memo, cur) => { - memo[cur] = schema[cur].example; - return memo; - }, {}); - } - - function compare(schema, response, method, path, context) { - let required = []; - const additionalProperties = schema.hasOwnProperty('additionalProperties'); - - function flattenAllOf(obj) { - return obj.reduce((memo, obj) => { - if (obj.allOf) { - obj = { properties: flattenAllOf(obj.allOf) }; - } else { - try { - required = required.concat(obj.required ? obj.required : Object.keys(obj.properties)); - } catch (e) { - assert.fail(`Syntax error re: allOf, perhaps you allOf'd an array? (path: ${method} ${path}, context: ${context})`); - } - } - - return { ...memo, ...obj.properties }; - }, {}); - } - - if (schema.allOf) { - schema = flattenAllOf(schema.allOf); - } else if (schema.properties) { - required = schema.required || Object.keys(schema.properties); - schema = schema.properties; - } else { - // If schema contains no properties, check passes - return; - } - - // Compare the schema to the response - required.forEach((prop) => { - if (schema.hasOwnProperty(prop)) { - assert(response.hasOwnProperty(prop), `"${prop}" is a required property (path: ${method} ${path}, context: ${context})`); - - // Don't proceed with type-check if the value could possibly be unset (nullable: true, in spec) - if (response[prop] === null && schema[prop].nullable === true) { - return; - } - - // Therefore, if the value is actually null, that's a problem (nullable is probably missing) - assert(response[prop] !== null, `"${prop}" was null, but schema does not specify it to be a nullable property (path: ${method} ${path}, context: ${context})`); - - switch (schema[prop].type) { - case 'string': - assert.strictEqual(typeof response[prop], 'string', `"${prop}" was expected to be a string, but was ${typeof response[prop]} instead (path: ${method} ${path}, context: ${context})`); - break; - case 'boolean': - assert.strictEqual(typeof response[prop], 'boolean', `"${prop}" was expected to be a boolean, but was ${typeof response[prop]} instead (path: ${method} ${path}, context: ${context})`); - break; - case 'object': - assert.strictEqual(typeof response[prop], 'object', `"${prop}" was expected to be an object, but was ${typeof response[prop]} instead (path: ${method} ${path}, context: ${context})`); - compare(schema[prop], response[prop], method, path, context ? [context, prop].join('.') : prop); - break; - case 'array': - assert.strictEqual(Array.isArray(response[prop]), true, `"${prop}" was expected to be an array, but was ${typeof response[prop]} instead (path: ${method} ${path}, context: ${context})`); - - if (schema[prop].items) { - // Ensure the array items have a schema defined - assert(schema[prop].items.type || schema[prop].items.allOf, `"${prop}" is defined to be an array, but its items have no schema defined (path: ${method} ${path}, context: ${context})`); - - // Compare types - if (schema[prop].items.type === 'object' || Array.isArray(schema[prop].items.allOf)) { - response[prop].forEach((res) => { - compare(schema[prop].items, res, method, path, context ? [context, prop].join('.') : prop); - }); - } else if (response[prop].length) { // for now - response[prop].forEach((item) => { - assert.strictEqual(typeof item, schema[prop].items.type, `"${prop}" should have ${schema[prop].items.type} items, but found ${typeof items} instead (path: ${method} ${path}, context: ${context})`); - }); - } - } - break; - } - } - }); - - // Compare the response to the schema - Object.keys(response).forEach((prop) => { - if (additionalProperties) { // All bets are off - return; - } - - assert(schema[prop], `"${prop}" was found in response, but is not defined in schema (path: ${method} ${path}, context: ${context})`); - }); - } + let readApi = false; + let writeApi = false; + const readApiPath = path.resolve(__dirname, '../public/openapi/read.yaml'); + const writeApiPath = path.resolve(__dirname, '../public/openapi/write.yaml'); + let jar; + let csrfToken; + let setup = false; + const unauthenticatedRoutes = new Set(['/api/login', '/api/register']); // Everything else will be called with the admin user + + const mocks = { + head: {}, + get: { + '/api/email/unsubscribe/{token}': [ + { + in: 'path', + name: 'token', + example: (() => jwt.sign({ + template: 'digest', + uid: 1, + }, nconf.get('secret')))(), + }, + ], + }, + post: {}, + put: {}, + delete: { + '/users/{uid}/tokens/{token}': [ + { + in: 'path', + name: 'uid', + example: 1, + }, + { + in: 'path', + name: 'token', + example: utils.generateUUID(), + }, + ], + '/users/{uid}/sessions/{uuid}': [ + { + in: 'path', + name: 'uid', + example: 1, + }, + { + in: 'path', + name: 'uuid', + example: '', // To be defined below... + }, + ], + '/posts/{pid}/diffs/{timestamp}': [ + { + in: 'path', + name: 'pid', + example: '', // To be defined below... + }, + { + in: 'path', + name: 'timestamp', + example: '', // To be defined below... + }, + ], + }, + }; + + async function dummySearchHook(data) { + return [1]; + } + + async function dummyEmailerHook(data) { + // Pretend to handle sending emails + } + + after(async () => { + plugins.hooks.unregister('core', 'filter:search.query', dummySearchHook); + plugins.hooks.unregister('emailer-test', 'filter:email.send'); + }); + + async function setupData() { + if (setup) { + return; + } + + // Create sample users + const adminUid = await user.create({username: 'admin', password: '123456', email: 'test@example.org'}); + const unprivUid = await user.create({username: 'unpriv', password: '123456', email: 'unpriv@example.org'}); + await user.setUserField(adminUid, 'email', 'test@example.org'); + await user.setUserField(unprivUid, 'email', 'unpriv@example.org'); + await user.email.confirmByUid(adminUid); + await user.email.confirmByUid(unprivUid); + + for (let x = 0; x < 4; x++) { + // eslint-disable-next-line no-await-in-loop + await user.create({username: 'deleteme', password: '123456'}); // For testing of DELETE /users (uids 5, 6) and DELETE /user/:uid/account (uid 7) + } + + await groups.join('administrators', adminUid); + + // Create sample group + await groups.create({ + name: 'Test Group', + }); + + await meta.settings.set('core.api', { + tokens: [{ + token: mocks.delete['/users/{uid}/tokens/{token}'][1].example, + uid: 1, + description: 'for testing of token deletion route', + timestamp: Date.now(), + }], + }); + meta.config.allowTopicsThumbnail = 1; + meta.config.termsOfUse = 'I, for one, welcome our new test-driven overlords'; + meta.config.chatMessageDelay = 0; + + // Create a category + const testCategory = await categories.create({name: 'test'}); + + // Post a new topic + await topics.post({ + uid: adminUid, + cid: testCategory.cid, + title: 'Test Topic', + content: 'Test topic content', + }); + const unprivTopic = await topics.post({ + uid: unprivUid, + cid: testCategory.cid, + title: 'Test Topic 2', + content: 'Test topic 2 content', + }); + await topics.post({ + uid: unprivUid, + cid: testCategory.cid, + title: 'Test Topic 3', + content: 'Test topic 3 content', + }); + + // Create a post diff + await posts.edit({ + uid: adminUid, + pid: unprivTopic.postData.pid, + content: 'Test topic 2 edited content', + req: {}, + }); + mocks.delete['/posts/{pid}/diffs/{timestamp}'][0].example = unprivTopic.postData.pid; + mocks.delete['/posts/{pid}/diffs/{timestamp}'][1].example = (await posts.diffs.list(unprivTopic.postData.pid))[0]; + + // Create a sample flag + const {flagId} = await flags.create('post', 1, unprivUid, 'sample reasons', Date.now()); // Deleted in DELETE /api/v3/flags/1 + await flags.appendNote(flagId, 1, 'test note', 1_626_446_956_652); + await flags.create('post', 2, unprivUid, 'sample reasons', Date.now()); // For testing flag notes (since flag 1 deleted) + + // Create a new chat room + await messaging.newRoom(1, [2]); + + // Create an empty file to test DELETE /files and thumb deletion + fs.closeSync(fs.openSync(path.resolve(nconf.get('upload_path'), 'files/test.txt'), 'w')); + fs.closeSync(fs.openSync(path.resolve(nconf.get('upload_path'), 'files/test.png'), 'w')); + + // Associate thumb with topic to test thumb reordering + await topics.thumbs.associate({ + id: 2, + path: 'files/test.png', + }); + + const socketUser = require('../src/socket.io/user'); + const socketAdmin = require('../src/socket.io/admin'); + // Export data for admin user + await socketUser.exportProfile({uid: adminUid}, {uid: adminUid}); + await wait(2000); + await socketUser.exportPosts({uid: adminUid}, {uid: adminUid}); + await wait(2000); + await socketUser.exportUploads({uid: adminUid}, {uid: adminUid}); + await wait(2000); + await socketAdmin.user.exportUsersCSV({uid: adminUid}, {}); + // Wait for export child process to complete + await wait(5000); + + // Attach a search hook so /api/search is enabled + plugins.hooks.register('core', { + hook: 'filter:search.query', + method: dummySearchHook, + }); + // Attach an emailer hook so related requests do not error + plugins.hooks.register('emailer-test', { + hook: 'filter:email.send', + method: dummyEmailerHook, + }); + + // All tests run as admin user + ({jar} = await helpers.loginUser('admin', '123456')); + + // Retrieve CSRF token using cookie, to test Write API + const config = await request({ + url: `${nconf.get('url')}/api/config`, + json: true, + jar, + }); + csrfToken = config.csrf_token; + + setup = true; + } + + it('should pass OpenAPI v3 validation', async () => { + try { + await SwaggerParser.validate(readApiPath); + await SwaggerParser.validate(writeApiPath); + } catch (error) { + assert.ifError(error); + } + }); + + readApi = await SwaggerParser.dereference(readApiPath); + writeApi = await SwaggerParser.dereference(writeApiPath); + + it('should grab all mounted routes and ensure a schema exists', async () => { + const webserver = require('../src/webserver'); + const buildPaths = function (stack, prefix) { + const paths = stack.map(dispatch => { + if (dispatch.route && dispatch.route.path && typeof dispatch.route.path === 'string') { + if (!prefix && !dispatch.route.path.startsWith('/api/')) { + return null; + } + + if (prefix === nconf.get('relative_path')) { + prefix = ''; + } + + return { + method: Object.keys(dispatch.route.methods)[0], + path: (prefix || '') + dispatch.route.path, + }; + } + + if (dispatch.name === 'router') { + const prefix = dispatch.regexp.toString().replace('/^', '').replace('\\/?(?=\\/|$)/i', '').replaceAll('\\/', '/'); + return buildPaths(dispatch.handle.stack, prefix); + } + + // Drop any that aren't actual routes (middlewares, error handlers, etc.) + return null; + }); + + return paths.flat(); + }; + + let paths = buildPaths(webserver.app._router.stack).filter(Boolean).map(pathObject => { + pathObject.path = pathObject.path.replaceAll(/\/:([^\\/]+)/g, '/{$1}'); + return pathObject; + }); + const exclusionPrefixes = [ + '/api/admin/plugins', + '/api/compose', + '/debug', + '/api/user/{userslug}/theme', // From persona + ]; + paths = paths.filter(path => path.method !== '_all' && !exclusionPrefixes.some(prefix => path.path.startsWith(prefix))); + + // For each express path, query for existence in read and write api schemas + for (const pathObject of paths) { + describe(`${pathObject.method.toUpperCase()} ${pathObject.path}`, () => { + it('should be defined in schema docs', () => { + let schema = readApi; + if (pathObject.path.startsWith('/api/v3')) { + schema = writeApi; + pathObject.path = pathObject.path.replace('/api/v3', ''); + } + + // Don't check non-GET routes in Read API + if (schema === readApi && pathObject.method !== 'get') { + return; + } + + const normalizedPath = pathObject.path.replaceAll(/\/:([^\\/]+)/g, '/{$1}').replaceAll('?', ''); + assert(schema.paths.hasOwnProperty(normalizedPath), `${pathObject.path} is not defined in schema docs`); + assert(schema.paths[normalizedPath].hasOwnProperty(pathObject.method), `${pathObject.path} was found in schema docs, but ${pathObject.method.toUpperCase()} method is not defined`); + }); + }); + } + }); + + // GenerateTests(readApi, Object.keys(readApi.paths)); + generateTests(writeApi, Object.keys(writeApi.paths), writeApi.servers[0].url); + + function generateTests(api, paths, prefix) { + // Iterate through all documented paths, make a call to it, + // and compare the result body with what is defined in the spec + const pathLibrary = path; // For calling path module from inside this forEach + for (const path of paths) { + const context = api.paths[path]; + let schema; + let response; + let url; + let method; + const headers = {}; + const qs = {}; + + for (const _method of Object.keys(context)) { + // Only test GET routes in the Read API + if (api.info.title === 'NodeBB Read API' && _method !== 'get') { + continue; + } + + it(`${_method.toUpperCase()} ${path}: should have each path parameter defined in its context`, () => { + method = _method; + if (!context[method].parameters) { + return; + } + + const pathParameters = (path.match(/{[\w\-_*]+}?/g) || []).map(match => match.slice(1, -1)); + const schemaParameters = new Set(context[method].parameters.map(parameter => (parameter.in === 'path' ? parameter.name : null)).filter(Boolean)); + assert(pathParameters.every(parameter => schemaParameters.has(parameter)), `${method.toUpperCase()} ${path} has path parameters specified but not defined`); + }); + + it(`${_method.toUpperCase()} ${path}: should have examples when parameters are present`, () => { + let {parameters} = context[method]; + let testPath = path; + + if (parameters) { + // Use mock data if provided + parameters = mocks[method][path] || parameters; + + for (const parameter of parameters) { + assert(parameter.example !== null && parameter.example !== undefined, `${method.toUpperCase()} ${path} has parameters without examples`); + + switch (parameter.in) { + case 'path': { + testPath = testPath.replace(`{${parameter.name}}`, parameter.example); + break; + } + + case 'header': { + headers[parameter.name] = parameter.example; + break; + } + + case 'query': { + qs[parameter.name] = parameter.example; + break; + } + } + } + } + + url = nconf.get('url') + (prefix || '') + testPath; + }); + + it(`${_method.toUpperCase()} ${path}: should contain a valid request body (if present) with application/json or multipart/form-data type if POST/PUT/DELETE`, () => { + if (['post', 'put', 'delete'].includes(method) && context[method].hasOwnProperty('requestBody')) { + const failMessage = `${method.toUpperCase()} ${path} has a malformed request body`; + assert(context[method].requestBody, failMessage); + assert(context[method].requestBody.content, failMessage); + + if (context[method].requestBody.content.hasOwnProperty('application/json')) { + assert(context[method].requestBody.content['application/json'], failMessage); + assert(context[method].requestBody.content['application/json'].schema, failMessage); + assert(context[method].requestBody.content['application/json'].schema.properties, failMessage); + } else if (context[method].requestBody.content.hasOwnProperty('multipart/form-data')) { + assert(context[method].requestBody.content['multipart/form-data'], failMessage); + assert(context[method].requestBody.content['multipart/form-data'].schema, failMessage); + assert(context[method].requestBody.content['multipart/form-data'].schema.properties, failMessage); + } + } + }); + + it(`${_method.toUpperCase()} ${path}: should not error out when called`, async () => { + await setupData(); + + if (csrfToken) { + headers['x-csrf-token'] = csrfToken; + } + + let body = {}; + let type = 'json'; + if (context[method].hasOwnProperty('requestBody') && context[method].requestBody.content['application/json']) { + body = buildBody(context[method].requestBody.content['application/json'].schema.properties); + } else if (context[method].hasOwnProperty('requestBody') && context[method].requestBody.content['multipart/form-data']) { + type = 'form'; + } + + try { + if (type === 'json') { + response = await request(url, { + method, + jar: unauthenticatedRoutes.has(path) ? undefined : jar, + json: true, + followRedirect: false, // All responses are significant (e.g. 302) + simple: false, // Don't throw on non-200 (e.g. 302) + resolveWithFullResponse: true, // Send full request back (to check statusCode) + headers, + qs, + body, + }); + } else if (type === 'form') { + response = await new Promise((resolve, reject) => { + helpers.uploadFile(url, pathLibrary.join(__dirname, './files/test.png'), {}, jar, csrfToken, (error, res) => { + if (error) { + return reject(error); + } + + resolve(res); + }); + }); + } + } catch (error) { + assert(!error, `${method.toUpperCase()} ${path} errored with: ${error.message}`); + } + }); + + it(`${_method.toUpperCase()} ${path}: response status code should match one of the schema defined responses`, () => { + // HACK: allow HTTP 418 I am a teapot, for now 👇 + assert(context[method].responses.hasOwnProperty('418') || Object.keys(context[method].responses).includes(String(response.statusCode)), `${method.toUpperCase()} ${path} sent back unexpected HTTP status code: ${response.statusCode} ${JSON.stringify(response.body)}`); + }); + + // Recursively iterate through schema properties, comparing type + it(`${_method.toUpperCase()} ${path}: response body should match schema definition`, async () => { + const http302 = context[method].responses['302']; + if (http302 && response.statusCode === 302) { + // Compare headers instead + const expectedHeaders = Object.keys(http302.headers).reduce((memo, name) => { + const value = http302.headers[name].schema.example; + memo[name] = value.startsWith(nconf.get('relative_path')) ? value : nconf.get('relative_path') + value; + return memo; + }, {}); + + for (const header of Object.keys(expectedHeaders)) { + assert(response.headers[header.toLowerCase()]); + assert.strictEqual(response.headers[header.toLowerCase()], expectedHeaders[header]); + } + + return; + } + + const http200 = context[method].responses['200']; + if (!http200) { + return; + } + + assert.strictEqual(response.statusCode, 200, `HTTP 200 expected (path: ${method} ${path}`); + + const hasJSON = http200.content && http200.content['application/json']; + if (hasJSON) { + schema = context[method].responses['200'].content['application/json'].schema; + compare(schema, response.body, method.toUpperCase(), path, 'root'); + } + + // TODO someday: text/csv, binary file type checking? + }); + + it(`${_method.toUpperCase()} ${path}: should successfully re-login if needed`, async () => { + const reloginPaths = ['PUT /users/{uid}/password', 'DELETE /users/{uid}/sessions/{uuid}']; + if (reloginPaths.includes(`${method.toUpperCase()} ${path}`)) { + ({jar} = await helpers.loginUser('admin', '123456')); + const sessionUUIDs = await db.getObject('uid:1:sessionUUID:sessionId'); + mocks.delete['/users/{uid}/sessions/{uuid}'][1].example = Object.keys(sessionUUIDs).pop(); + + // Retrieve CSRF token using cookie, to test Write API + const config = await request({ + url: `${nconf.get('url')}/api/config`, + json: true, + jar, + }); + csrfToken = config.csrf_token; + } + }); + + it(`${_method.toUpperCase()} ${path}: should back out of a registration interstitial if needed`, async () => { + const affectedPaths = ['GET /api/user/{userslug}/edit/email']; + if (affectedPaths.includes(`${method.toUpperCase()} ${path}`)) { + await request({ + uri: `${nconf.get('url')}/register/abort?_csrf=${csrfToken}`, + method: 'POST', + jar, + simple: false, + }); + } + }); + } + } + } + + function buildBody(schema) { + return Object.keys(schema).reduce((memo, current) => { + memo[current] = schema[current].example; + return memo; + }, {}); + } + + function compare(schema, response, method, path, context) { + let required = []; + const additionalProperties = schema.hasOwnProperty('additionalProperties'); + + function flattenAllOf(object) { + return object.reduce((memo, object) => { + if (object.allOf) { + object = {properties: flattenAllOf(object.allOf)}; + } else { + try { + required = required.concat(object.required ? object.required : Object.keys(object.properties)); + } catch { + assert.fail(`Syntax error re: allOf, perhaps you allOf'd an array? (path: ${method} ${path}, context: ${context})`); + } + } + + return {...memo, ...object.properties}; + }, {}); + } + + if (schema.allOf) { + schema = flattenAllOf(schema.allOf); + } else if (schema.properties) { + required = schema.required || Object.keys(schema.properties); + schema = schema.properties; + } else { + // If schema contains no properties, check passes + return; + } + + // Compare the schema to the response + for (const property of required) { + if (schema.hasOwnProperty(property)) { + assert(response.hasOwnProperty(property), `"${property}" is a required property (path: ${method} ${path}, context: ${context})`); + + // Don't proceed with type-check if the value could possibly be unset (nullable: true, in spec) + if (response[property] === null && schema[property].nullable === true) { + continue; + } + + // Therefore, if the value is actually null, that's a problem (nullable is probably missing) + assert(response[property] !== null, `"${property}" was null, but schema does not specify it to be a nullable property (path: ${method} ${path}, context: ${context})`); + + switch (schema[property].type) { + case 'string': { + assert.strictEqual(typeof response[property], 'string', `"${property}" was expected to be a string, but was ${typeof response[property]} instead (path: ${method} ${path}, context: ${context})`); + break; + } + + case 'boolean': { + assert.strictEqual(typeof response[property], 'boolean', `"${property}" was expected to be a boolean, but was ${typeof response[property]} instead (path: ${method} ${path}, context: ${context})`); + break; + } + + case 'object': { + assert.strictEqual(typeof response[property], 'object', `"${property}" was expected to be an object, but was ${typeof response[property]} instead (path: ${method} ${path}, context: ${context})`); + compare(schema[property], response[property], method, path, context ? [context, property].join('.') : property); + break; + } + + case 'array': { + assert.strictEqual(Array.isArray(response[property]), true, `"${property}" was expected to be an array, but was ${typeof response[property]} instead (path: ${method} ${path}, context: ${context})`); + + if (schema[property].items) { + // Ensure the array items have a schema defined + assert(schema[property].items.type || schema[property].items.allOf, `"${property}" is defined to be an array, but its items have no schema defined (path: ${method} ${path}, context: ${context})`); + + // Compare types + if (schema[property].items.type === 'object' || Array.isArray(schema[property].items.allOf)) { + for (const res of response[property]) { + compare(schema[property].items, res, method, path, context ? [context, property].join('.') : property); + } + } else if (response[property].length > 0) { // For now + for (const item of response[property]) { + assert.strictEqual(typeof item, schema[property].items.type, `"${property}" should have ${schema[property].items.type} items, but found ${typeof items} instead (path: ${method} ${path}, context: ${context})`); + } + } + } + + break; + } + } + } + } + + // Compare the response to the schema + for (const property of Object.keys(response)) { + if (additionalProperties) { // All bets are off + continue; + } + + assert(schema[property], `"${property}" was found in response, but is not defined in schema (path: ${method} ${path}, context: ${context})`); + } + } }); diff --git a/test/authentication.js b/test/authentication.js index 9bffbfb..67c2049 100644 --- a/test/authentication.js +++ b/test/authentication.js @@ -1,639 +1,637 @@ 'use strict'; - -const assert = require('assert'); -const url = require('url'); -const async = require('async'); +const assert = require('node:assert'); +const url = require('node:url'); +const util = require('node:util'); const nconf = require('nconf'); const request = require('request'); -const util = require('util'); - -const db = require('./mocks/databasemock'); +const async = require('async'); const user = require('../src/user'); const utils = require('../src/utils'); const meta = require('../src/meta'); const privileges = require('../src/privileges'); +const db = require('./mocks/databasemock'); const helpers = require('./helpers'); describe('authentication', () => { - const jar = request.jar(); - let regularUid; - before((done) => { - user.create({ username: 'regular', password: 'regularpwd', email: 'regular@nodebb.org' }, (err, uid) => { - assert.ifError(err); - regularUid = uid; - assert.strictEqual(uid, 1); - done(); - }); - }); - - it('should allow login with email for uid 1', async () => { - const oldValue = meta.config.allowLoginWith; - meta.config.allowLoginWith = 'username-email'; - const { res } = await helpers.loginUser('regular@nodebb.org', 'regularpwd'); - assert.strictEqual(res.statusCode, 200); - meta.config.allowLoginWith = oldValue; - }); - - it('second user should fail to login with email since email is not confirmed', async () => { - const oldValue = meta.config.allowLoginWith; - meta.config.allowLoginWith = 'username-email'; - const uid = await user.create({ username: '2nduser', password: '2ndpassword', email: '2nduser@nodebb.org' }); - const { res, body } = await helpers.loginUser('2nduser@nodebb.org', '2ndpassword'); - assert.strictEqual(res.statusCode, 403); - assert.strictEqual(body, '[[error:invalid-login-credentials]]'); - meta.config.allowLoginWith = oldValue; - }); - - it('should fail to create user if username is too short', (done) => { - helpers.registerUser({ - username: 'a', - password: '123456', - }, (err, jar, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 400); - assert.equal(body, '[[error:username-too-short]]'); - done(); - }); - }); - - it('should fail to create user if userslug is too short', (done) => { - helpers.registerUser({ - username: '----a-----', - password: '123456', - }, (err, jar, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 400); - assert.equal(body, '[[error:username-too-short]]'); - done(); - }); - }); - - it('should fail to create user if userslug is too short', (done) => { - helpers.registerUser({ - username: ' a', - password: '123456', - }, (err, jar, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 400); - assert.equal(body, '[[error:username-too-short]]'); - done(); - }); - }); - - it('should fail to create user if userslug is too short', (done) => { - helpers.registerUser({ - username: 'a ', - password: '123456', - }, (err, jar, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 400); - assert.equal(body, '[[error:username-too-short]]'); - done(); - }); - }); - - it('should register and login a user', (done) => { - request({ - url: `${nconf.get('url')}/api/config`, - json: true, - jar: jar, - }, (err, response, body) => { - assert.ifError(err); - - request.post(`${nconf.get('url')}/register`, { - form: { - email: 'admin@nodebb.org', - username: 'admin', - password: 'adminpwd', - 'password-confirm': 'adminpwd', - 'account-type': 'instructor', - userLang: 'it', - gdpr_consent: true, - }, - json: true, - jar: jar, - headers: { - 'x-csrf-token': body.csrf_token, - }, - }, async (err, response, body) => { - const validationPending = await user.email.isValidationPending(body.uid, 'admin@nodebb.org'); - assert.strictEqual(validationPending, true); - assert.ifError(err); - assert(body); - assert(body.hasOwnProperty('uid') && body.uid > 0); - const newUid = body.uid; - request({ - url: `${nconf.get('url')}/api/self`, - json: true, - jar: jar, - }, (err, response, body) => { - assert.ifError(err); - assert(body); - assert.equal(body.username, 'admin'); - assert.equal(body.uid, newUid); - user.getSettings(body.uid, (err, settings) => { - assert.ifError(err); - assert.equal(settings.userLang, 'it'); - done(); - }); - }); - }); - }); - }); - - it('should logout a user', (done) => { - helpers.logoutUser(jar, (err) => { - assert.ifError(err); - request({ - url: `${nconf.get('url')}/api/me`, - json: true, - jar: jar, - }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 401); - assert.strictEqual(body.status.code, 'not-authorised'); - done(); - }); - }); - }); - - it('should login a user', (done) => { - helpers.loginUser('regular', 'regularpwd', (err, data) => { - assert.ifError(err); - assert(data.body); - request({ - url: `${nconf.get('url')}/api/self`, - json: true, - jar: data.jar, - }, (err, response, body) => { - assert.ifError(err); - assert(body); - assert.equal(body.username, 'regular'); - assert.equal(body.email, 'regular@nodebb.org'); - db.getObject(`uid:${regularUid}:sessionUUID:sessionId`, (err, sessions) => { - assert.ifError(err); - assert(sessions); - assert(Object.keys(sessions).length > 0); - done(); - }); - }); - }); - }); - - it('should regenerate the session identifier on successful login', async () => { - const matchRegexp = /express\.sid=s%3A(.+?);/; - const { hostname, path } = url.parse(nconf.get('url')); - - const sid = String(jar._jar.store.idx[hostname][path]['express.sid']).match(matchRegexp)[1]; - await helpers.logoutUser(jar); - const newJar = (await helpers.loginUser('regular', 'regularpwd')).jar; - const newSid = String(newJar._jar.store.idx[hostname][path]['express.sid']).match(matchRegexp)[1]; - - assert.notStrictEqual(newSid, sid); - }); - - it('should revoke all sessions', (done) => { - const socketAdmin = require('../src/socket.io/admin'); - db.sortedSetCard(`uid:${regularUid}:sessions`, (err, count) => { - assert.ifError(err); - assert(count); - socketAdmin.deleteAllSessions({ uid: 1 }, {}, (err) => { - assert.ifError(err); - db.sortedSetCard(`uid:${regularUid}:sessions`, (err, count) => { - assert.ifError(err); - assert(!count); - done(); - }); - }); - }); - }); - - it('should fail to login if ip address is invalid', (done) => { - const jar = request.jar(); - request({ - url: `${nconf.get('url')}/api/config`, - json: true, - jar: jar, - }, (err, response, body) => { - if (err) { - return done(err); - } - - request.post(`${nconf.get('url')}/login`, { - form: { - username: 'regular', - password: 'regularpwd', - }, - json: true, - jar: jar, - headers: { - 'x-csrf-token': body.csrf_token, - 'x-forwarded-for': '', - }, - }, (err, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 500); - done(); - }); - }); - }); - - it('should fail to login if user does not exist', (done) => { - helpers.loginUser('doesnotexist', 'nopassword', (err, data) => { - assert.ifError(err); - assert.equal(data.res.statusCode, 403); - assert.equal(data.body, '[[error:invalid-login-credentials]]'); - done(); - }); - }); - - it('should fail to login if username is empty', (done) => { - helpers.loginUser('', 'some password', (err, data) => { - assert.ifError(err); - assert.equal(data.res.statusCode, 403); - assert.equal(data.body, '[[error:invalid-username-or-password]]'); - done(); - }); - }); - - it('should fail to login if password is empty', (done) => { - helpers.loginUser('someuser', '', (err, data) => { - assert.ifError(err); - assert.equal(data.res.statusCode, 403); - assert.equal(data.body, '[[error:invalid-username-or-password]]'); - done(); - }); - }); - - it('should fail to login if username and password are empty', (done) => { - helpers.loginUser('', '', (err, data) => { - assert.ifError(err); - assert.equal(data.res.statusCode, 403); - assert.equal(data.body, '[[error:invalid-username-or-password]]'); - done(); - }); - }); - - it('should fail to login if user does not have password field in db', (done) => { - user.create({ username: 'hasnopassword', email: 'no@pass.org' }, (err, uid) => { - assert.ifError(err); - helpers.loginUser('hasnopassword', 'doesntmatter', (err, data) => { - assert.ifError(err); - assert.equal(data.res.statusCode, 403); - assert.equal(data.body, '[[error:invalid-login-credentials]]'); - done(); - }); - }); - }); - - it('should fail to login if password is longer than 4096', (done) => { - let longPassword; - for (let i = 0; i < 5000; i++) { - longPassword += 'a'; - } - helpers.loginUser('someuser', longPassword, (err, data) => { - assert.ifError(err); - assert.equal(data.res.statusCode, 403); - assert.equal(data.body, '[[error:password-too-long]]'); - done(); - }); - }); - - it('should fail to login if local login is disabled', (done) => { - privileges.global.rescind(['groups:local:login'], 'registered-users', (err) => { - assert.ifError(err); - helpers.loginUser('regular', 'regularpwd', (err, data) => { - assert.ifError(err); - assert.equal(data.res.statusCode, 403); - assert.equal(data.body, '[[error:local-login-disabled]]'); - privileges.global.give(['groups:local:login'], 'registered-users', done); - }); - }); - }); - - it('should fail to register if registraton is disabled', (done) => { - meta.config.registrationType = 'disabled'; - helpers.registerUser({ - username: 'someuser', - password: 'somepassword', - 'account-type': 'student', - }, (err, jar, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 403); - assert.equal(body, 'Forbidden'); - done(); - }); - }); - - it('should return error if invitation is not valid', (done) => { - meta.config.registrationType = 'invite-only'; - helpers.registerUser({ - username: 'someuser', - password: 'somepassword', - 'account-type': 'student', - }, (err, jar, response, body) => { - meta.config.registrationType = 'normal'; - assert.ifError(err); - assert.equal(response.statusCode, 400); - assert.equal(body, '[[register:invite.error-invite-only]]'); - done(); - }); - }); - - it('should fail to register if username is falsy or too short', (done) => { - helpers.registerUser({ - username: '', - password: 'somepassword', - 'account-type': 'student', - }, (err, jar, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 400); - assert.equal(body, '[[error:username-too-short]]'); - helpers.registerUser({ - username: 'a', - password: 'somepassword', - 'account-type': 'student', - }, (err, jar, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 400); - assert.equal(body, '[[error:username-too-short]]'); - done(); - }); - }); - }); - - it('should fail to register if username is too long', (done) => { - helpers.registerUser({ - username: 'thisisareallylongusername', - password: '123456', - 'account-type': 'student', - }, (err, jar, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 400); - assert.equal(body, '[[error:username-too-long]]'); - done(); - }); - }); - - it('should fail to register if account type is invalid', (done) => { - helpers.registerUser({ - username: 'someuser', - password: '123456', - 'account-type': 'invalidtype', - }, (err, jar, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 400); - assert.equal(body, 'Invalid account type'); - done(); - }); - }); - - it('should queue user if ip is used before', (done) => { - meta.config.registrationApprovalType = 'admin-approval-ip'; - helpers.registerUser({ - email: 'another@user.com', - username: 'anotheruser', - password: 'anotherpwd', - 'account-type': 'student', - gdpr_consent: 1, - }, (err, jar, response, body) => { - meta.config.registrationApprovalType = 'normal'; - assert.ifError(err); - assert.equal(response.statusCode, 200); - assert.equal(body.message, '[[register:registration-added-to-queue]]'); - done(); - }); - }); - - - it('should be able to login with email', async () => { - const email = 'ginger@nodebb.org'; - const uid = await user.create({ username: 'ginger', password: '123456', email }); - await user.setUserField(uid, 'email', email); - await user.email.confirmByUid(uid); - const { res } = await helpers.loginUser('ginger@nodebb.org', '123456'); - assert.equal(res.statusCode, 200); - }); - - it('should fail to login if login type is username and an email is sent', (done) => { - meta.config.allowLoginWith = 'username'; - helpers.loginUser('ginger@nodebb.org', '123456', (err, data) => { - meta.config.allowLoginWith = 'username-email'; - assert.ifError(err); - assert.equal(data.res.statusCode, 400); - assert.equal(data.body, '[[error:wrong-login-type-username]]'); - done(); - }); - }); - - it('should send 200 if not logged in', (done) => { - const jar = request.jar(); - request({ - url: `${nconf.get('url')}/api/config`, - json: true, - jar: jar, - }, (err, response, body) => { - assert.ifError(err); - - request.post(`${nconf.get('url')}/logout`, { - form: {}, - json: true, - jar: jar, - headers: { - 'x-csrf-token': body.csrf_token, - }, - }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(body, 'not-logged-in'); - done(); - }); - }); - }); - - describe('banned user authentication', () => { - const bannedUser = { - username: 'banme', - pw: '123456', - uid: null, - }; - - before(async () => { - bannedUser.uid = await user.create({ username: 'banme', password: '123456', email: 'ban@me.com' }); - }); - - it('should prevent banned user from logging in', (done) => { - user.bans.ban(bannedUser.uid, 0, 'spammer', (err) => { - assert.ifError(err); - helpers.loginUser(bannedUser.username, bannedUser.pw, (err, data) => { - assert.ifError(err); - assert.equal(data.res.statusCode, 403); - delete data.body.timestamp; - assert.deepStrictEqual(data.body, { - banned_until: 0, - banned_until_readable: '', - expiry: 0, - expiry_readable: '', - reason: 'spammer', - uid: bannedUser.uid, - }); - user.bans.unban(bannedUser.uid, (err) => { - assert.ifError(err); - const expiry = Date.now() + 10000; - user.bans.ban(bannedUser.uid, expiry, '', (err) => { - assert.ifError(err); - helpers.loginUser(bannedUser.username, bannedUser.pw, (err, data) => { - assert.ifError(err); - assert.equal(data.res.statusCode, 403); - assert(data.body.banned_until); - assert(data.body.reason, '[[user:info.banned-no-reason]]'); - done(); - }); - }); - }); - }); - }); - }); - - it('should allow banned user to log in if the "banned-users" group has "local-login" privilege', async () => { - await privileges.global.give(['groups:local:login'], 'banned-users'); - const { res } = await helpers.loginUser(bannedUser.username, bannedUser.pw); - assert.strictEqual(res.statusCode, 200); - }); - - it('should allow banned user to log in if the user herself has "local-login" privilege', async () => { - await privileges.global.rescind(['groups:local:login'], 'banned-users'); - await privileges.categories.give(['local:login'], 0, bannedUser.uid); - const { res } = await helpers.loginUser(bannedUser.username, bannedUser.pw); - assert.strictEqual(res.statusCode, 200); - }); - }); - - it('should lockout account on 3 failed login attempts', (done) => { - meta.config.loginAttempts = 3; - let uid; - async.waterfall([ - function (next) { - user.create({ username: 'lockme', password: '123456' }, next); - }, - function (_uid, next) { - uid = _uid; - helpers.loginUser('lockme', 'abcdef', next); - }, - function (data, next) { - helpers.loginUser('lockme', 'abcdef', next); - }, - function (data, next) { - helpers.loginUser('lockme', 'abcdef', next); - }, - function (data, next) { - helpers.loginUser('lockme', 'abcdef', next); - }, - function (data, next) { - meta.config.loginAttempts = 5; - assert.equal(data.res.statusCode, 403); - assert.equal(data.body, '[[error:account-locked]]'); - helpers.loginUser('lockme', 'abcdef', next); - }, - function (data, next) { - assert.equal(data.res.statusCode, 403); - assert.equal(data.body, '[[error:account-locked]]'); - db.exists(`lockout:${uid}`, next); - }, - function (locked, next) { - assert(locked); - next(); - }, - ], done); - }); - - it('should clear all reset tokens upon successful login', async () => { - const code = await user.reset.generate(regularUid); - await helpers.loginUser('regular', 'regularpwd'); - const valid = await user.reset.validate(code); - assert.strictEqual(valid, false); - }); - - describe('api tokens', () => { - let newUid; - let userToken; - let masterToken; - before(async () => { - newUid = await user.create({ username: 'apiUserTarget' }); - const settings = await meta.settings.get('core.api'); - settings.tokens = settings.tokens || []; - userToken = { - token: utils.generateUUID(), - uid: newUid, - description: `api token for uid ${newUid}`, - timestamp: Date.now(), - }; - settings.tokens.push(userToken); - masterToken = { - token: utils.generateUUID(), - uid: 0, - description: 'api master token', - timestamp: Date.now(), - }; - settings.tokens.push(masterToken); - - await meta.settings.set('core.api', settings); - }); - - it('should fail with invalid token', async () => { - const { res, body } = await helpers.request('get', `/api/self`, { - form: { - _uid: newUid, - }, - json: true, - jar: jar, - headers: { - Authorization: `Bearer sdfhaskfdja-jahfdaksdf`, - }, - }); - assert.strictEqual(res.statusCode, 401); - assert.strictEqual(body, 'not-authorized'); - }); - - it('should use a token tied to an uid', async () => { - const { res, body } = await helpers.request('get', `/api/self`, { - json: true, - headers: { - Authorization: `Bearer ${userToken.token}`, - }, - }); - - assert.strictEqual(res.statusCode, 200); - assert.strictEqual(body.username, 'apiUserTarget'); - }); - - it('should fail if _uid is not passed in with master token', async () => { - const { res, body } = await helpers.request('get', `/api/self`, { - form: {}, - json: true, - headers: { - Authorization: `Bearer ${masterToken.token}`, - }, - }); - - assert.strictEqual(res.statusCode, 500); - assert.strictEqual(body.error, '[[error:api.master-token-no-uid]]'); - }); - - it('should use master api token and _uid', async () => { - const { res, body } = await helpers.request('get', `/api/self`, { - form: { - _uid: newUid, - }, - json: true, - headers: { - Authorization: `Bearer ${masterToken.token}`, - }, - }); - - assert.strictEqual(res.statusCode, 200); - assert.strictEqual(body.username, 'apiUserTarget'); - }); - }); + const jar = request.jar(); + let regularUid; + before(done => { + user.create({username: 'regular', password: 'regularpwd', email: 'regular@nodebb.org'}, (error, uid) => { + assert.ifError(error); + regularUid = uid; + assert.strictEqual(uid, 1); + done(); + }); + }); + + it('should allow login with email for uid 1', async () => { + const oldValue = meta.config.allowLoginWith; + meta.config.allowLoginWith = 'username-email'; + const {res} = await helpers.loginUser('regular@nodebb.org', 'regularpwd'); + assert.strictEqual(res.statusCode, 200); + meta.config.allowLoginWith = oldValue; + }); + + it('second user should fail to login with email since email is not confirmed', async () => { + const oldValue = meta.config.allowLoginWith; + meta.config.allowLoginWith = 'username-email'; + const uid = await user.create({username: '2nduser', password: '2ndpassword', email: '2nduser@nodebb.org'}); + const {res, body} = await helpers.loginUser('2nduser@nodebb.org', '2ndpassword'); + assert.strictEqual(res.statusCode, 403); + assert.strictEqual(body, '[[error:invalid-login-credentials]]'); + meta.config.allowLoginWith = oldValue; + }); + + it('should fail to create user if username is too short', done => { + helpers.registerUser({ + username: 'a', + password: '123456', + }, (error, jar, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 400); + assert.equal(body, '[[error:username-too-short]]'); + done(); + }); + }); + + it('should fail to create user if userslug is too short', done => { + helpers.registerUser({ + username: '----a-----', + password: '123456', + }, (error, jar, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 400); + assert.equal(body, '[[error:username-too-short]]'); + done(); + }); + }); + + it('should fail to create user if userslug is too short', done => { + helpers.registerUser({ + username: ' a', + password: '123456', + }, (error, jar, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 400); + assert.equal(body, '[[error:username-too-short]]'); + done(); + }); + }); + + it('should fail to create user if userslug is too short', done => { + helpers.registerUser({ + username: 'a ', + password: '123456', + }, (error, jar, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 400); + assert.equal(body, '[[error:username-too-short]]'); + done(); + }); + }); + + it('should register and login a user', done => { + request({ + url: `${nconf.get('url')}/api/config`, + json: true, + jar, + }, (error, response, body) => { + assert.ifError(error); + + request.post(`${nconf.get('url')}/register`, { + form: { + email: 'admin@nodebb.org', + username: 'admin', + password: 'adminpwd', + 'password-confirm': 'adminpwd', + 'account-type': 'instructor', + userLang: 'it', + gdpr_consent: true, + }, + json: true, + jar, + headers: { + 'x-csrf-token': body.csrf_token, + }, + }, async (error, response, body) => { + const validationPending = await user.email.isValidationPending(body.uid, 'admin@nodebb.org'); + assert.strictEqual(validationPending, true); + assert.ifError(error); + assert(body); + assert(body.hasOwnProperty('uid') && body.uid > 0); + const newUid = body.uid; + request({ + url: `${nconf.get('url')}/api/self`, + json: true, + jar, + }, (error, response, body) => { + assert.ifError(error); + assert(body); + assert.equal(body.username, 'admin'); + assert.equal(body.uid, newUid); + user.getSettings(body.uid, (error, settings) => { + assert.ifError(error); + assert.equal(settings.userLang, 'it'); + done(); + }); + }); + }); + }); + }); + + it('should logout a user', done => { + helpers.logoutUser(jar, error => { + assert.ifError(error); + request({ + url: `${nconf.get('url')}/api/me`, + json: true, + jar, + }, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 401); + assert.strictEqual(body.status.code, 'not-authorised'); + done(); + }); + }); + }); + + it('should login a user', done => { + helpers.loginUser('regular', 'regularpwd', (error, data) => { + assert.ifError(error); + assert(data.body); + request({ + url: `${nconf.get('url')}/api/self`, + json: true, + jar: data.jar, + }, (error, response, body) => { + assert.ifError(error); + assert(body); + assert.equal(body.username, 'regular'); + assert.equal(body.email, 'regular@nodebb.org'); + db.getObject(`uid:${regularUid}:sessionUUID:sessionId`, (error, sessions) => { + assert.ifError(error); + assert(sessions); + assert(Object.keys(sessions).length > 0); + done(); + }); + }); + }); + }); + + it('should regenerate the session identifier on successful login', async () => { + const matchRegexp = /express\.sid=s%3A(.+?);/; + const {hostname, path} = url.parse(nconf.get('url')); + + const sid = String(jar._jar.store.idx[hostname][path]['express.sid']).match(matchRegexp)[1]; + await helpers.logoutUser(jar); + const newJar = (await helpers.loginUser('regular', 'regularpwd')).jar; + const newSid = String(newJar._jar.store.idx[hostname][path]['express.sid']).match(matchRegexp)[1]; + + assert.notStrictEqual(newSid, sid); + }); + + it('should revoke all sessions', done => { + const socketAdmin = require('../src/socket.io/admin'); + db.sortedSetCard(`uid:${regularUid}:sessions`, (error, count) => { + assert.ifError(error); + assert(count); + socketAdmin.deleteAllSessions({uid: 1}, {}, error_ => { + assert.ifError(error_); + db.sortedSetCard(`uid:${regularUid}:sessions`, (error, count) => { + assert.ifError(error); + assert(!count); + done(); + }); + }); + }); + }); + + it('should fail to login if ip address is invalid', done => { + const jar = request.jar(); + request({ + url: `${nconf.get('url')}/api/config`, + json: true, + jar, + }, (error, response, body) => { + if (error) { + return done(error); + } + + request.post(`${nconf.get('url')}/login`, { + form: { + username: 'regular', + password: 'regularpwd', + }, + json: true, + jar, + headers: { + 'x-csrf-token': body.csrf_token, + 'x-forwarded-for': '', + }, + }, (error, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 500); + done(); + }); + }); + }); + + it('should fail to login if user does not exist', done => { + helpers.loginUser('doesnotexist', 'nopassword', (error, data) => { + assert.ifError(error); + assert.equal(data.res.statusCode, 403); + assert.equal(data.body, '[[error:invalid-login-credentials]]'); + done(); + }); + }); + + it('should fail to login if username is empty', done => { + helpers.loginUser('', 'some password', (error, data) => { + assert.ifError(error); + assert.equal(data.res.statusCode, 403); + assert.equal(data.body, '[[error:invalid-username-or-password]]'); + done(); + }); + }); + + it('should fail to login if password is empty', done => { + helpers.loginUser('someuser', '', (error, data) => { + assert.ifError(error); + assert.equal(data.res.statusCode, 403); + assert.equal(data.body, '[[error:invalid-username-or-password]]'); + done(); + }); + }); + + it('should fail to login if username and password are empty', done => { + helpers.loginUser('', '', (error, data) => { + assert.ifError(error); + assert.equal(data.res.statusCode, 403); + assert.equal(data.body, '[[error:invalid-username-or-password]]'); + done(); + }); + }); + + it('should fail to login if user does not have password field in db', done => { + user.create({username: 'hasnopassword', email: 'no@pass.org'}, (error, uid) => { + assert.ifError(error); + helpers.loginUser('hasnopassword', 'doesntmatter', (error, data) => { + assert.ifError(error); + assert.equal(data.res.statusCode, 403); + assert.equal(data.body, '[[error:invalid-login-credentials]]'); + done(); + }); + }); + }); + + it('should fail to login if password is longer than 4096', done => { + let longPassword; + for (let i = 0; i < 5000; i++) { + longPassword += 'a'; + } + + helpers.loginUser('someuser', longPassword, (error, data) => { + assert.ifError(error); + assert.equal(data.res.statusCode, 403); + assert.equal(data.body, '[[error:password-too-long]]'); + done(); + }); + }); + + it('should fail to login if local login is disabled', done => { + privileges.global.rescind(['groups:local:login'], 'registered-users', error => { + assert.ifError(error); + helpers.loginUser('regular', 'regularpwd', (error, data) => { + assert.ifError(error); + assert.equal(data.res.statusCode, 403); + assert.equal(data.body, '[[error:local-login-disabled]]'); + privileges.global.give(['groups:local:login'], 'registered-users', done); + }); + }); + }); + + it('should fail to register if registraton is disabled', done => { + meta.config.registrationType = 'disabled'; + helpers.registerUser({ + username: 'someuser', + password: 'somepassword', + 'account-type': 'student', + }, (error, jar, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 403); + assert.equal(body, 'Forbidden'); + done(); + }); + }); + + it('should return error if invitation is not valid', done => { + meta.config.registrationType = 'invite-only'; + helpers.registerUser({ + username: 'someuser', + password: 'somepassword', + 'account-type': 'student', + }, (error, jar, response, body) => { + meta.config.registrationType = 'normal'; + assert.ifError(error); + assert.equal(response.statusCode, 400); + assert.equal(body, '[[register:invite.error-invite-only]]'); + done(); + }); + }); + + it('should fail to register if username is falsy or too short', done => { + helpers.registerUser({ + username: '', + password: 'somepassword', + 'account-type': 'student', + }, (error, jar, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 400); + assert.equal(body, '[[error:username-too-short]]'); + helpers.registerUser({ + username: 'a', + password: 'somepassword', + 'account-type': 'student', + }, (error, jar, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 400); + assert.equal(body, '[[error:username-too-short]]'); + done(); + }); + }); + }); + + it('should fail to register if username is too long', done => { + helpers.registerUser({ + username: 'thisisareallylongusername', + password: '123456', + 'account-type': 'student', + }, (error, jar, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 400); + assert.equal(body, '[[error:username-too-long]]'); + done(); + }); + }); + + it('should fail to register if account type is invalid', done => { + helpers.registerUser({ + username: 'someuser', + password: '123456', + 'account-type': 'invalidtype', + }, (error, jar, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 400); + assert.equal(body, 'Invalid account type'); + done(); + }); + }); + + it('should queue user if ip is used before', done => { + meta.config.registrationApprovalType = 'admin-approval-ip'; + helpers.registerUser({ + email: 'another@user.com', + username: 'anotheruser', + password: 'anotherpwd', + 'account-type': 'student', + gdpr_consent: 1, + }, (error, jar, response, body) => { + meta.config.registrationApprovalType = 'normal'; + assert.ifError(error); + assert.equal(response.statusCode, 200); + assert.equal(body.message, '[[register:registration-added-to-queue]]'); + done(); + }); + }); + + it('should be able to login with email', async () => { + const email = 'ginger@nodebb.org'; + const uid = await user.create({username: 'ginger', password: '123456', email}); + await user.setUserField(uid, 'email', email); + await user.email.confirmByUid(uid); + const {res} = await helpers.loginUser('ginger@nodebb.org', '123456'); + assert.equal(res.statusCode, 200); + }); + + it('should fail to login if login type is username and an email is sent', done => { + meta.config.allowLoginWith = 'username'; + helpers.loginUser('ginger@nodebb.org', '123456', (error, data) => { + meta.config.allowLoginWith = 'username-email'; + assert.ifError(error); + assert.equal(data.res.statusCode, 400); + assert.equal(data.body, '[[error:wrong-login-type-username]]'); + done(); + }); + }); + + it('should send 200 if not logged in', done => { + const jar = request.jar(); + request({ + url: `${nconf.get('url')}/api/config`, + json: true, + jar, + }, (error, response, body) => { + assert.ifError(error); + + request.post(`${nconf.get('url')}/logout`, { + form: {}, + json: true, + jar, + headers: { + 'x-csrf-token': body.csrf_token, + }, + }, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(body, 'not-logged-in'); + done(); + }); + }); + }); + + describe('banned user authentication', () => { + const bannedUser = { + username: 'banme', + pw: '123456', + uid: null, + }; + + before(async () => { + bannedUser.uid = await user.create({username: 'banme', password: '123456', email: 'ban@me.com'}); + }); + + it('should prevent banned user from logging in', done => { + user.bans.ban(bannedUser.uid, 0, 'spammer', error => { + assert.ifError(error); + helpers.loginUser(bannedUser.username, bannedUser.pw, (error, data) => { + assert.ifError(error); + assert.equal(data.res.statusCode, 403); + delete data.body.timestamp; + assert.deepStrictEqual(data.body, { + banned_until: 0, + banned_until_readable: '', + expiry: 0, + expiry_readable: '', + reason: 'spammer', + uid: bannedUser.uid, + }); + user.bans.unban(bannedUser.uid, error_ => { + assert.ifError(error_); + const expiry = Date.now() + 10_000; + user.bans.ban(bannedUser.uid, expiry, '', error_ => { + assert.ifError(error_); + helpers.loginUser(bannedUser.username, bannedUser.pw, (error, data) => { + assert.ifError(error); + assert.equal(data.res.statusCode, 403); + assert(data.body.banned_until); + assert(data.body.reason, '[[user:info.banned-no-reason]]'); + done(); + }); + }); + }); + }); + }); + }); + + it('should allow banned user to log in if the "banned-users" group has "local-login" privilege', async () => { + await privileges.global.give(['groups:local:login'], 'banned-users'); + const {res} = await helpers.loginUser(bannedUser.username, bannedUser.pw); + assert.strictEqual(res.statusCode, 200); + }); + + it('should allow banned user to log in if the user herself has "local-login" privilege', async () => { + await privileges.global.rescind(['groups:local:login'], 'banned-users'); + await privileges.categories.give(['local:login'], 0, bannedUser.uid); + const {res} = await helpers.loginUser(bannedUser.username, bannedUser.pw); + assert.strictEqual(res.statusCode, 200); + }); + }); + + it('should lockout account on 3 failed login attempts', done => { + meta.config.loginAttempts = 3; + let uid; + async.waterfall([ + function (next) { + user.create({username: 'lockme', password: '123456'}, next); + }, + function (_uid, next) { + uid = _uid; + helpers.loginUser('lockme', 'abcdef', next); + }, + function (data, next) { + helpers.loginUser('lockme', 'abcdef', next); + }, + function (data, next) { + helpers.loginUser('lockme', 'abcdef', next); + }, + function (data, next) { + helpers.loginUser('lockme', 'abcdef', next); + }, + function (data, next) { + meta.config.loginAttempts = 5; + assert.equal(data.res.statusCode, 403); + assert.equal(data.body, '[[error:account-locked]]'); + helpers.loginUser('lockme', 'abcdef', next); + }, + function (data, next) { + assert.equal(data.res.statusCode, 403); + assert.equal(data.body, '[[error:account-locked]]'); + db.exists(`lockout:${uid}`, next); + }, + function (locked, next) { + assert(locked); + next(); + }, + ], done); + }); + + it('should clear all reset tokens upon successful login', async () => { + const code = await user.reset.generate(regularUid); + await helpers.loginUser('regular', 'regularpwd'); + const valid = await user.reset.validate(code); + assert.strictEqual(valid, false); + }); + + describe('api tokens', () => { + let newUid; + let userToken; + let mainToken; + before(async () => { + newUid = await user.create({username: 'apiUserTarget'}); + const settings = await meta.settings.get('core.api'); + settings.tokens = settings.tokens || []; + userToken = { + token: utils.generateUUID(), + uid: newUid, + description: `api token for uid ${newUid}`, + timestamp: Date.now(), + }; + settings.tokens.push(userToken); + mainToken = { + token: utils.generateUUID(), + uid: 0, + description: 'api master token', + timestamp: Date.now(), + }; + settings.tokens.push(mainToken); + + await meta.settings.set('core.api', settings); + }); + + it('should fail with invalid token', async () => { + const {res, body} = await helpers.request('get', '/api/self', { + form: { + _uid: newUid, + }, + json: true, + jar, + headers: { + Authorization: 'Bearer sdfhaskfdja-jahfdaksdf', + }, + }); + assert.strictEqual(res.statusCode, 401); + assert.strictEqual(body, 'not-authorized'); + }); + + it('should use a token tied to an uid', async () => { + const {res, body} = await helpers.request('get', '/api/self', { + json: true, + headers: { + Authorization: `Bearer ${userToken.token}`, + }, + }); + + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(body.username, 'apiUserTarget'); + }); + + it('should fail if _uid is not passed in with master token', async () => { + const {res, body} = await helpers.request('get', '/api/self', { + form: {}, + json: true, + headers: { + Authorization: `Bearer ${mainToken.token}`, + }, + }); + + assert.strictEqual(res.statusCode, 500); + assert.strictEqual(body.error, '[[error:api.master-token-no-uid]]'); + }); + + it('should use master api token and _uid', async () => { + const {res, body} = await helpers.request('get', '/api/self', { + form: { + _uid: newUid, + }, + json: true, + headers: { + Authorization: `Bearer ${mainToken.token}`, + }, + }); + + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(body.username, 'apiUserTarget'); + }); + }); }); diff --git a/test/batch.js b/test/batch.js index a8df880..ef4a6c1 100644 --- a/test/batch.js +++ b/test/batch.js @@ -1,115 +1,115 @@ 'use strict'; +const assert = require('node:assert'); const async = require('async'); -const assert = require('assert'); - -const db = require('./mocks/databasemock'); - const batch = require('../src/batch'); +const db = require('./mocks/databasemock'); describe('batch', () => { - const scores = []; - const values = []; - before((done) => { - for (let i = 0; i < 100; i++) { - scores.push(i); - values.push(`val${i}`); - } - db.sortedSetAdd('processMe', scores, values, done); - }); - - it('should process sorted set with callbacks', (done) => { - let total = 0; - batch.processSortedSet('processMe', (items, next) => { - items.forEach((item) => { - total += item.score; - }); - - setImmediate(next); - }, { - withScores: true, - interval: 50, - batch: 10, - }, (err) => { - assert.ifError(err); - assert.strictEqual(total, 4950); - done(); - }); - }); - - it('should process sorted set with callbacks', (done) => { - let total = 0; - batch.processSortedSet('processMe', (values, next) => { - values.forEach((val) => { - total += val.length; - }); - - setImmediate(next); - }, (err) => { - assert.ifError(err); - assert.strictEqual(total, 490); - done(); - }); - }); - - it('should process sorted set with async/await', async () => { - let total = 0; - await batch.processSortedSet('processMe', (values, next) => { - values.forEach((val) => { - total += val.length; - }); - - setImmediate(next); - }, {}); - - assert.strictEqual(total, 490); - }); - - it('should process sorted set with async/await', async () => { - let total = 0; - await batch.processSortedSet('processMe', async (values) => { - values.forEach((val) => { - total += val.length; - }); - await db.getObject('doesnotexist'); - }, {}); - - assert.strictEqual(total, 490); - }); - - it('should process array with callbacks', (done) => { - let total = 0; - batch.processArray(scores, (nums, next) => { - nums.forEach((n) => { - total += n; - }); - - setImmediate(next); - }, { - withScores: true, - interval: 50, - batch: 10, - }, (err) => { - assert.ifError(err); - assert.strictEqual(total, 4950); - done(); - }); - }); - - it('should process array with async/await', async () => { - let total = 0; - await batch.processArray(scores, (nums, next) => { - nums.forEach((n) => { - total += n; - }); - - setImmediate(next); - }, { - withScores: true, - interval: 50, - batch: 10, - }); - - assert.strictEqual(total, 4950); - }); + const scores = []; + const values = []; + before(done => { + for (let i = 0; i < 100; i++) { + scores.push(i); + values.push(`val${i}`); + } + + db.sortedSetAdd('processMe', scores, values, done); + }); + + it('should process sorted set with callbacks', done => { + let total = 0; + batch.processSortedSet('processMe', (items, next) => { + for (const item of items) { + total += item.score; + } + + setImmediate(next); + }, { + withScores: true, + interval: 50, + batch: 10, + }, error => { + assert.ifError(error); + assert.strictEqual(total, 4950); + done(); + }); + }); + + it('should process sorted set with callbacks', done => { + let total = 0; + batch.processSortedSet('processMe', (values, next) => { + for (const value of values) { + total += value.length; + } + + setImmediate(next); + }, error => { + assert.ifError(error); + assert.strictEqual(total, 490); + done(); + }); + }); + + it('should process sorted set with async/await', async () => { + let total = 0; + await batch.processSortedSet('processMe', (values, next) => { + for (const value of values) { + total += value.length; + } + + setImmediate(next); + }, {}); + + assert.strictEqual(total, 490); + }); + + it('should process sorted set with async/await', async () => { + let total = 0; + await batch.processSortedSet('processMe', async values => { + for (const value of values) { + total += value.length; + } + + await db.getObject('doesnotexist'); + }, {}); + + assert.strictEqual(total, 490); + }); + + it('should process array with callbacks', done => { + let total = 0; + batch.processArray(scores, (nums, next) => { + for (const n of nums) { + total += n; + } + + setImmediate(next); + }, { + withScores: true, + interval: 50, + batch: 10, + }, error => { + assert.ifError(error); + assert.strictEqual(total, 4950); + done(); + }); + }); + + it('should process array with async/await', async () => { + let total = 0; + await batch.processArray(scores, (nums, next) => { + for (const n of nums) { + total += n; + } + + setImmediate(next); + }, { + withScores: true, + interval: 50, + batch: 10, + }); + + assert.strictEqual(total, 4950); + }); }); diff --git a/test/blacklist.js b/test/blacklist.js index 3ebab08..e005f81 100644 --- a/test/blacklist.js +++ b/test/blacklist.js @@ -1,68 +1,67 @@ 'use strict'; +const assert = require('node:assert'); const async = require('async'); -const assert = require('assert'); - -const db = require('./mocks/databasemock'); const groups = require('../src/groups'); const user = require('../src/user'); const blacklist = require('../src/meta/blacklist'); +const db = require('./mocks/databasemock'); describe('blacklist', () => { - let adminUid; + let adminUid; - before((done) => { - user.create({ username: 'admin' }, (err, uid) => { - assert.ifError(err); - adminUid = uid; - groups.join('administrators', adminUid, done); - }); - }); + before(done => { + user.create({username: 'admin'}, (error, uid) => { + assert.ifError(error); + adminUid = uid; + groups.join('administrators', adminUid, done); + }); + }); - const socketBlacklist = require('../src/socket.io/blacklist'); - const rules = '1.1.1.1\n2.2.2.2\n::ffff:0:2.2.2.2\n127.0.0.1\n192.168.100.0/22'; + const socketBlacklist = require('../src/socket.io/blacklist'); + const rules = '1.1.1.1\n2.2.2.2\n::ffff:0:2.2.2.2\n127.0.0.1\n192.168.100.0/22'; - it('should validate blacklist', (done) => { - socketBlacklist.validate({ uid: adminUid }, { - rules: rules, - }, (err, data) => { - assert.ifError(err); - done(); - }); - }); + it('should validate blacklist', done => { + socketBlacklist.validate({uid: adminUid}, { + rules, + }, (error, data) => { + assert.ifError(error); + done(); + }); + }); - it('should error if not admin', (done) => { - socketBlacklist.save({ uid: 0 }, rules, (err) => { - assert.equal(err.message, '[[error:no-privileges]]'); - done(); - }); - }); + it('should error if not admin', done => { + socketBlacklist.save({uid: 0}, rules, error => { + assert.equal(error.message, '[[error:no-privileges]]'); + done(); + }); + }); - it('should save blacklist', (done) => { - socketBlacklist.save({ uid: adminUid }, rules, (err) => { - assert.ifError(err); - done(); - }); - }); + it('should save blacklist', done => { + socketBlacklist.save({uid: adminUid}, rules, error => { + assert.ifError(error); + done(); + }); + }); - it('should pass ip test against blacklist', (done) => { - blacklist.test('3.3.3.3', (err) => { - assert.ifError(err); - done(); - }); - }); + it('should pass ip test against blacklist', done => { + blacklist.test('3.3.3.3', error => { + assert.ifError(error); + done(); + }); + }); - it('should fail ip test against blacklist', (done) => { - blacklist.test('1.1.1.1', (err) => { - assert.equal(err.message, '[[error:blacklisted-ip]]'); - done(); - }); - }); + it('should fail ip test against blacklist', done => { + blacklist.test('1.1.1.1', error => { + assert.equal(error.message, '[[error:blacklisted-ip]]'); + done(); + }); + }); - it('should pass ip test and not crash with ipv6 address', (done) => { - blacklist.test('2001:db8:85a3:0:0:8a2e:370:7334', (err) => { - assert.ifError(err); - done(); - }); - }); + it('should pass ip test and not crash with ipv6 address', done => { + blacklist.test('2001:db8:85a3:0:0:8a2e:370:7334', error => { + assert.ifError(error); + done(); + }); + }); }); diff --git a/test/build.js b/test/build.js index 5a411bf..6d8ec84 100644 --- a/test/build.js +++ b/test/build.js @@ -1,202 +1,201 @@ 'use strict'; -const path = require('path'); -const fs = require('fs'); -const assert = require('assert'); +const path = require('node:path'); +const fs = require('node:fs'); +const assert = require('node:assert'); const mkdirp = require('mkdirp'); const rimraf = require('rimraf'); const async = require('async'); - -const db = require('./mocks/databasemock'); const file = require('../src/file'); +const db = require('./mocks/databasemock'); describe('minifier', () => { - const testPath = path.join(__dirname, '../test/build'); - before(async () => { - await mkdirp(testPath); - }); - - after(async () => { - const files = await file.walk(testPath); - await Promise.all(files.map(async path => fs.promises.rm(path))); - await fs.promises.rmdir(testPath); - }); - - const minifier = require('../src/meta/minifier'); - const scripts = [ - path.resolve(__dirname, './files/1.js'), - path.resolve(__dirname, './files/2.js'), - ].map(script => ({ - srcPath: script, - destPath: path.resolve(__dirname, '../test/build', path.basename(script)), - filename: path.basename(script), - })); - - it('.js.bundle() should concat scripts', (done) => { - const destPath = path.resolve(__dirname, '../test/build/concatenated.js'); - - minifier.js.bundle({ - files: scripts, - destPath: destPath, - filename: 'concatenated.js', - }, false, false, (err) => { - assert.ifError(err); - - assert(file.existsSync(destPath)); - - assert.strictEqual( - fs.readFileSync(destPath).toString().replace(/\r\n/g, '\n'), - '(function (window, document) {' + - '\n window.doStuff = function () {' + - '\n document.body.innerHTML = \'Stuff has been done\';' + - '\n };' + - '\n})(window, document);' + - '\n' + - '\n;function foo(name, age) {' + - '\n return \'The person known as "\' + name + \'" is \' + age + \' years old\';' + - '\n}' + - '\n' - ); - done(); - }); - }); - it('.js.bundle() should minify scripts', (done) => { - const destPath = path.resolve(__dirname, '../test/build/minified.js'); - - minifier.js.bundle({ - files: scripts, - destPath: destPath, - filename: 'minified.js', - }, true, false, (err) => { - assert.ifError(err); - - assert(file.existsSync(destPath)); - - assert.strictEqual( - fs.readFileSync(destPath).toString(), - '(function(n,o){n.doStuff=function(){o.body.innerHTML="Stuff has been done"}})(window,document);function foo(n,o){return\'The person known as "\'+n+\'" is \'+o+" years old"}' + - '\n//# sourceMappingURL=minified.js.map' - ); - done(); - }); - }); - - it('.js.minifyBatch() should minify each script', (done) => { - minifier.js.minifyBatch(scripts, false, (err) => { - assert.ifError(err); - - assert(file.existsSync(scripts[0].destPath)); - assert(file.existsSync(scripts[1].destPath)); - - fs.readFile(scripts[0].destPath, (err, buffer) => { - assert.ifError(err); - assert.strictEqual( - buffer.toString(), - '(function(n,o){n.doStuff=function(){o.body.innerHTML="Stuff has been done"}})(window,document);' + - '\n//# sourceMappingURL=1.js.map' - ); - done(); - }); - }); - }); - - const styles = [ - '@import (inline) "./1.css";', - '@import "./2.less";', - ].join('\n'); - const paths = [ - path.resolve(__dirname, './files'), - ]; - it('.css.bundle() should concat styles', (done) => { - minifier.css.bundle(styles, paths, false, false, (err, bundle) => { - assert.ifError(err); - assert.strictEqual(bundle.code, '.help { margin: 10px; } .yellow { background: yellow; }\n.help {\n display: block;\n}\n.help .blue {\n background: blue;\n}\n'); - done(); - }); - }); - - it('.css.bundle() should minify styles', (done) => { - minifier.css.bundle(styles, paths, true, false, (err, bundle) => { - assert.ifError(err); - assert.strictEqual(bundle.code, '.help{margin:10px}.yellow{background:#ff0}.help{display:block}.help .blue{background:#00f}'); - done(); - }); - }); + const testPath = path.join(__dirname, '../test/build'); + before(async () => { + await mkdirp(testPath); + }); + + after(async () => { + const files = await file.walk(testPath); + await Promise.all(files.map(async path => fs.promises.rm(path))); + await fs.promises.rmdir(testPath); + }); + + const minifier = require('../src/meta/minifier'); + const scripts = [ + path.resolve(__dirname, './files/1.js'), + path.resolve(__dirname, './files/2.js'), + ].map(script => ({ + srcPath: script, + destPath: path.resolve(__dirname, '../test/build', path.basename(script)), + filename: path.basename(script), + })); + + it('.js.bundle() should concat scripts', done => { + const destinationPath = path.resolve(__dirname, '../test/build/concatenated.js'); + + minifier.js.bundle({ + files: scripts, + destPath: destinationPath, + filename: 'concatenated.js', + }, false, false, error => { + assert.ifError(error); + + assert(file.existsSync(destinationPath)); + + assert.strictEqual( + fs.readFileSync(destinationPath).toString().replaceAll('\r\n', '\n'), + '(function (window, document) {' + + '\n window.doStuff = function () {' + + '\n document.body.innerHTML = \'Stuff has been done\';' + + '\n };' + + '\n})(window, document);' + + '\n' + + '\n;function foo(name, age) {' + + '\n return \'The person known as "\' + name + \'" is \' + age + \' years old\';' + + '\n}' + + '\n', + ); + done(); + }); + }); + it('.js.bundle() should minify scripts', done => { + const destinationPath = path.resolve(__dirname, '../test/build/minified.js'); + + minifier.js.bundle({ + files: scripts, + destPath: destinationPath, + filename: 'minified.js', + }, true, false, error => { + assert.ifError(error); + + assert(file.existsSync(destinationPath)); + + assert.strictEqual( + fs.readFileSync(destinationPath).toString(), + '(function(n,o){n.doStuff=function(){o.body.innerHTML="Stuff has been done"}})(window,document);function foo(n,o){return\'The person known as "\'+n+\'" is \'+o+" years old"}' + + '\n//# sourceMappingURL=minified.js.map', + ); + done(); + }); + }); + + it('.js.minifyBatch() should minify each script', done => { + minifier.js.minifyBatch(scripts, false, error => { + assert.ifError(error); + + assert(file.existsSync(scripts[0].destPath)); + assert(file.existsSync(scripts[1].destPath)); + + fs.readFile(scripts[0].destPath, (error, buffer) => { + assert.ifError(error); + assert.strictEqual( + buffer.toString(), + '(function(n,o){n.doStuff=function(){o.body.innerHTML="Stuff has been done"}})(window,document);' + + '\n//# sourceMappingURL=1.js.map', + ); + done(); + }); + }); + }); + + const styles = [ + '@import (inline) "./1.css";', + '@import "./2.less";', + ].join('\n'); + const paths = [ + path.resolve(__dirname, './files'), + ]; + it('.css.bundle() should concat styles', done => { + minifier.css.bundle(styles, paths, false, false, (error, bundle) => { + assert.ifError(error); + assert.strictEqual(bundle.code, '.help { margin: 10px; } .yellow { background: yellow; }\n.help {\n display: block;\n}\n.help .blue {\n background: blue;\n}\n'); + done(); + }); + }); + + it('.css.bundle() should minify styles', done => { + minifier.css.bundle(styles, paths, true, false, (error, bundle) => { + assert.ifError(error); + assert.strictEqual(bundle.code, '.help{margin:10px}.yellow{background:#ff0}.help{display:block}.help .blue{background:#00f}'); + done(); + }); + }); }); describe('Build', () => { - const build = require('../src/meta/build'); - - before((done) => { - async.parallel([ - async.apply(rimraf, path.join(__dirname, '../build/public')), - async.apply(db.sortedSetAdd, 'plugins:active', Date.now(), 'nodebb-plugin-markdown'), - ], done); - }); - - it('should build plugin static dirs', (done) => { - build.build(['plugin static dirs'], (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should build requirejs modules', (done) => { - build.build(['requirejs modules'], (err) => { - assert.ifError(err); - const filename = path.join(__dirname, '../build/public/src/modules/alerts.js'); - assert(file.existsSync(filename)); - done(); - }); - }); - - it('should build client js bundle', (done) => { - build.build(['client js bundle'], (err) => { - assert.ifError(err); - const filename = path.join(__dirname, '../build/public/scripts-client.js'); - assert(file.existsSync(filename)); - assert(fs.readFileSync(filename).length > 1000); - done(); - }); - }); - - it('should build admin js bundle', (done) => { - build.build(['admin js bundle'], (err) => { - assert.ifError(err); - const filename = path.join(__dirname, '../build/public/scripts-admin.js'); - assert(file.existsSync(filename)); - assert(fs.readFileSync(filename).length > 1000); - done(); - }); - }); - - it('should build client side styles', (done) => { - build.build(['client side styles'], (err) => { - assert.ifError(err); - const filename = path.join(__dirname, '../build/public/client.css'); - assert(file.existsSync(filename)); - assert(fs.readFileSync(filename).toString().startsWith('/*! normalize.css')); - done(); - }); - }); - - it('should build admin control panel styles', (done) => { - build.build(['admin control panel styles'], (err) => { - assert.ifError(err); - const filename = path.join(__dirname, '../build/public/admin.css'); - assert(file.existsSync(filename)); - const adminCSS = fs.readFileSync(filename).toString(); - if (global.env === 'production') { - assert(adminCSS.startsWith('@charset "UTF-8";') || adminCSS.startsWith('@import url')); - } else { - assert(adminCSS.startsWith('.recent-replies')); - } - done(); - }); - }); - - - /* disabled, doesn't work on gh actions in prod mode + const build = require('../src/meta/build'); + + before(done => { + async.parallel([ + async.apply(rimraf, path.join(__dirname, '../build/public')), + async.apply(db.sortedSetAdd, 'plugins:active', Date.now(), 'nodebb-plugin-markdown'), + ], done); + }); + + it('should build plugin static dirs', done => { + build.build(['plugin static dirs'], error => { + assert.ifError(error); + done(); + }); + }); + + it('should build requirejs modules', done => { + build.build(['requirejs modules'], error => { + assert.ifError(error); + const filename = path.join(__dirname, '../build/public/src/modules/alerts.js'); + assert(file.existsSync(filename)); + done(); + }); + }); + + it('should build client js bundle', done => { + build.build(['client js bundle'], error => { + assert.ifError(error); + const filename = path.join(__dirname, '../build/public/scripts-client.js'); + assert(file.existsSync(filename)); + assert(fs.readFileSync(filename).length > 1000); + done(); + }); + }); + + it('should build admin js bundle', done => { + build.build(['admin js bundle'], error => { + assert.ifError(error); + const filename = path.join(__dirname, '../build/public/scripts-admin.js'); + assert(file.existsSync(filename)); + assert(fs.readFileSync(filename).length > 1000); + done(); + }); + }); + + it('should build client side styles', done => { + build.build(['client side styles'], error => { + assert.ifError(error); + const filename = path.join(__dirname, '../build/public/client.css'); + assert(file.existsSync(filename)); + assert(fs.readFileSync(filename).toString().startsWith('/*! normalize.css')); + done(); + }); + }); + + it('should build admin control panel styles', done => { + build.build(['admin control panel styles'], error => { + assert.ifError(error); + const filename = path.join(__dirname, '../build/public/admin.css'); + assert(file.existsSync(filename)); + const adminCSS = fs.readFileSync(filename).toString(); + if (global.env === 'production') { + assert(adminCSS.startsWith('@charset "UTF-8";') || adminCSS.startsWith('@import url')); + } else { + assert(adminCSS.startsWith('.recent-replies')); + } + + done(); + }); + }); + + /* Disabled, doesn't work on gh actions in prod mode it('should build bundle files', function (done) { this.timeout(0); build.buildAll(async (err) => { @@ -214,32 +213,32 @@ describe('Build', () => { }); */ - it('should build templates', function (done) { - this.timeout(0); - build.build(['templates'], (err) => { - assert.ifError(err); - const filename = path.join(__dirname, '../build/public/templates/admin/header.tpl'); - assert(file.existsSync(filename)); - assert(fs.readFileSync(filename).toString().startsWith('')); - done(); - }); - }); - - it('should build languages', (done) => { - build.build(['languages'], (err) => { - assert.ifError(err); - - const globalFile = path.join(__dirname, '../build/public/language/en-GB/global.json'); - assert(file.existsSync(globalFile), 'global.json exists'); - const global = fs.readFileSync(globalFile).toString(); - assert.strictEqual(JSON.parse(global).home, 'Home', 'global.json contains correct translations'); - - const mdFile = path.join(__dirname, '../build/public/language/en-GB/markdown.json'); - assert(file.existsSync(mdFile), 'markdown.json exists'); - const md = fs.readFileSync(mdFile).toString(); - assert.strictEqual(JSON.parse(md).bold, 'bolded text', 'markdown.json contains correct translations'); - - done(); - }); - }); + it('should build templates', function (done) { + this.timeout(0); + build.build(['templates'], error => { + assert.ifError(error); + const filename = path.join(__dirname, '../build/public/templates/admin/header.tpl'); + assert(file.existsSync(filename)); + assert(fs.readFileSync(filename).toString().startsWith('')); + done(); + }); + }); + + it('should build languages', done => { + build.build(['languages'], error => { + assert.ifError(error); + + const globalFile = path.join(__dirname, '../build/public/language/en-GB/global.json'); + assert(file.existsSync(globalFile), 'global.json exists'); + const global = fs.readFileSync(globalFile).toString(); + assert.strictEqual(JSON.parse(global).home, 'Home', 'global.json contains correct translations'); + + const mdFile = path.join(__dirname, '../build/public/language/en-GB/markdown.json'); + assert(file.existsSync(mdFile), 'markdown.json exists'); + const md = fs.readFileSync(mdFile).toString(); + assert.strictEqual(JSON.parse(md).bold, 'bolded text', 'markdown.json contains correct translations'); + + done(); + }); + }); }); diff --git a/test/categories.js b/test/categories.js index 939b478..e3c1b5d 100644 --- a/test/categories.js +++ b/test/categories.js @@ -1,945 +1,956 @@ 'use strict'; - +const assert = require('node:assert'); const async = require('async'); -const assert = require('assert'); const nconf = require('nconf'); const request = require('request'); - -const db = require('./mocks/databasemock'); const Categories = require('../src/categories'); const Topics = require('../src/topics'); const User = require('../src/user'); const groups = require('../src/groups'); const privileges = require('../src/privileges'); +const db = require('./mocks/databasemock'); describe('Categories', () => { - let categoryObj; - let posterUid; - let adminUid; - - before((done) => { - async.series({ - posterUid: function (next) { - User.create({ username: 'poster' }, next); - }, - adminUid: function (next) { - User.create({ username: 'admin' }, next); - }, - }, (err, results) => { - assert.ifError(err); - posterUid = results.posterUid; - adminUid = results.adminUid; - groups.join('administrators', adminUid, done); - }); - }); - - - it('should create a new category', (done) => { - Categories.create({ - name: 'Test Category & NodeBB', - description: 'Test category created by testing script', - icon: 'fa-check', - blockclass: 'category-blue', - order: '5', - }, (err, category) => { - assert.ifError(err); - - categoryObj = category; - done(); - }); - }); - - it('should retrieve a newly created category by its ID', (done) => { - Categories.getCategoryById({ - cid: categoryObj.cid, - start: 0, - stop: -1, - uid: 0, - }, (err, categoryData) => { - assert.ifError(err); - - assert(categoryData); - assert.equal('Test Category & NodeBB', categoryData.name); - assert.equal(categoryObj.description, categoryData.description); - assert.strictEqual(categoryObj.disabled, 0); - done(); - }); - }); - - it('should return null if category does not exist', (done) => { - Categories.getCategoryById({ - cid: 123123123, - start: 0, - stop: -1, - }, (err, categoryData) => { - assert.ifError(err); - assert.strictEqual(categoryData, null); - done(); - }); - }); - - it('should get all categories', (done) => { - Categories.getAllCategories(1, (err, data) => { - assert.ifError(err); - assert(Array.isArray(data)); - assert.equal(data[0].cid, categoryObj.cid); - done(); - }); - }); - - it('should load a category route', (done) => { - request(`${nconf.get('url')}/api/category/${categoryObj.cid}/test-category`, { json: true }, (err, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 200); - assert.equal(body.name, 'Test Category & NodeBB'); - assert(body); - done(); - }); - }); - - describe('Categories.getRecentTopicReplies', () => { - it('should not throw', (done) => { - Categories.getCategoryById({ - cid: categoryObj.cid, - set: `cid:${categoryObj.cid}:tids`, - reverse: true, - start: 0, - stop: -1, - uid: 0, - }, (err, categoryData) => { - assert.ifError(err); - Categories.getRecentTopicReplies(categoryData, 0, {}, (err) => { - assert.ifError(err); - done(); - }); - }); - }); - }); - - describe('.getCategoryTopics', () => { - it('should return a list of topics', (done) => { - Categories.getCategoryTopics({ - cid: categoryObj.cid, - start: 0, - stop: 10, - uid: 0, - sort: 'oldest_to_newest', - }, (err, result) => { - assert.equal(err, null); - - assert(Array.isArray(result.topics)); - assert(result.topics.every(topic => topic instanceof Object)); - - done(); - }); - }); - - it('should return a list of topics by a specific user', (done) => { - Categories.getCategoryTopics({ - cid: categoryObj.cid, - start: 0, - stop: 10, - uid: 0, - targetUid: 1, - sort: 'oldest_to_newest', - }, (err, result) => { - assert.equal(err, null); - assert(Array.isArray(result.topics)); - assert(result.topics.every(topic => topic instanceof Object && topic.uid === '1')); - - done(); - }); - }); - }); - - describe('Categories.moveRecentReplies', () => { - let moveCid; - let moveTid; - before((done) => { - async.parallel({ - category: function (next) { - Categories.create({ - name: 'Test Category 2', - description: 'Test category created by testing script', - }, next); - }, - topic: function (next) { - Topics.post({ - uid: posterUid, - cid: categoryObj.cid, - title: 'Test Topic Title', - content: 'The content of test topic', - }, next); - }, - }, (err, results) => { - if (err) { - return done(err); - } - moveCid = results.category.cid; - moveTid = results.topic.topicData.tid; - Topics.reply({ uid: posterUid, content: 'test post', tid: moveTid }, (err) => { - done(err); - }); - }); - }); - - it('should move posts from one category to another', (done) => { - Categories.moveRecentReplies(moveTid, categoryObj.cid, moveCid, (err) => { - assert.ifError(err); - db.getSortedSetRange(`cid:${categoryObj.cid}:pids`, 0, -1, (err, pids) => { - assert.ifError(err); - assert.equal(pids.length, 0); - db.getSortedSetRange(`cid:${moveCid}:pids`, 0, -1, (err, pids) => { - assert.ifError(err); - assert.equal(pids.length, 2); - done(); - }); - }); - }); - }); - }); - - describe('api/socket methods', () => { - const socketCategories = require('../src/socket.io/categories'); - const apiCategories = require('../src/api/categories'); - before(async () => { - await Topics.post({ - uid: posterUid, - cid: categoryObj.cid, - title: 'Test Topic Title', - content: 'The content of test topic', - tags: ['nodebb'], - }); - const data = await Topics.post({ - uid: posterUid, - cid: categoryObj.cid, - title: 'will delete', - content: 'The content of deleted topic', - }); - const newData = await Topics.post({ - uid: posterUid, - cid: categoryObj.cid, - title: 'will private', - content: 'The content of private topic', - }); - await Topics.delete(data.topicData.tid, adminUid); - await Topics.private(newData.topicData.tid, adminUid); - }); - - it('should get recent replies in category', (done) => { - socketCategories.getRecentReplies({ uid: posterUid }, categoryObj.cid, (err, data) => { - assert.ifError(err); - assert(Array.isArray(data)); - done(); - }); - }); - - it('should get categories', (done) => { - socketCategories.get({ uid: posterUid }, {}, (err, data) => { - assert.ifError(err); - assert(Array.isArray(data)); - done(); - }); - }); - - it('should get watched categories', (done) => { - socketCategories.getWatchedCategories({ uid: posterUid }, {}, (err, data) => { - assert.ifError(err); - assert(Array.isArray(data)); - done(); - }); - }); - - it('should load more topics', (done) => { - socketCategories.loadMore({ uid: posterUid }, { - cid: categoryObj.cid, - after: 0, - query: { - author: 'poster', - tag: 'nodebb', - }, - }, (err, data) => { - assert.ifError(err); - assert(Array.isArray(data.topics)); - assert.equal(data.topics[0].user.username, 'poster'); - assert.equal(data.topics[0].tags[0].value, 'nodebb'); - assert.equal(data.topics[0].category.cid, categoryObj.cid); - done(); - }); - }); - - it('should not show deleted topic titles', async () => { - const data = await socketCategories.loadMore({ uid: 0 }, { - cid: categoryObj.cid, - after: 0, - }); - - assert.deepStrictEqual( - data.topics.map(t => t.title), - ['[[topic:topic_is_private]]', '[[topic:topic_is_deleted]]', 'Test Topic Title', 'Test Topic Title'], - ); - }); - - it('should not show privated topic titles', async () => { - const data = await socketCategories.loadMore({ uid: 0 }, { - cid: categoryObj.cid, - after: 0, - }); - - assert.deepStrictEqual( - data.topics.map(t => t.title), - ['[[topic:topic_is_private]]', '[[topic:topic_is_deleted]]', 'Test Topic Title', 'Test Topic Title'], - ); - }); - - it('should show privated topic titles', async () => { - const data = await socketCategories.loadMore({ uid: posterUid }, { - cid: categoryObj.cid, - after: 0, - }); - - assert.deepStrictEqual( - data.topics.map(t => t.title), - ['will private', 'will delete', 'Test Topic Title', 'Test Topic Title'], - ); - }); - - it('should load topic count', (done) => { - socketCategories.getTopicCount({ uid: posterUid }, categoryObj.cid, (err, topicCount) => { - assert.ifError(err); - assert.strictEqual(topicCount, 4); - done(); - }); - }); - - it('should load category by privilege', (done) => { - socketCategories.getCategoriesByPrivilege({ uid: posterUid }, 'find', (err, data) => { - assert.ifError(err); - assert(Array.isArray(data)); - done(); - }); - }); - - it('should get move categories', (done) => { - socketCategories.getMoveCategories({ uid: posterUid }, {}, (err, data) => { - assert.ifError(err); - assert(Array.isArray(data)); - done(); - }); - }); - - it('should ignore category', (done) => { - socketCategories.ignore({ uid: posterUid }, { cid: categoryObj.cid }, (err) => { - assert.ifError(err); - Categories.isIgnored([categoryObj.cid], posterUid, (err, isIgnored) => { - assert.ifError(err); - assert.equal(isIgnored[0], true); - Categories.getIgnorers(categoryObj.cid, 0, -1, (err, ignorers) => { - assert.ifError(err); - assert.deepEqual(ignorers, [posterUid]); - done(); - }); - }); - }); - }); - - it('should watch category', (done) => { - socketCategories.watch({ uid: posterUid }, { cid: categoryObj.cid }, (err) => { - assert.ifError(err); - Categories.isIgnored([categoryObj.cid], posterUid, (err, isIgnored) => { - assert.ifError(err); - assert.equal(isIgnored[0], false); - done(); - }); - }); - }); - - it('should error if watch state does not exist', (done) => { - socketCategories.setWatchState({ uid: posterUid }, { cid: categoryObj.cid, state: 'invalid-state' }, (err) => { - assert.equal(err.message, '[[error:invalid-watch-state]]'); - done(); - }); - }); - - it('should check if user is moderator', (done) => { - socketCategories.isModerator({ uid: posterUid }, {}, (err, isModerator) => { - assert.ifError(err); - assert(!isModerator); - done(); - }); - }); - - it('should get category data', async () => { - const data = await apiCategories.get({ uid: posterUid }, { cid: categoryObj.cid }); - assert.equal(categoryObj.cid, data.cid); - }); - }); - - describe('admin api/socket methods', () => { - const socketCategories = require('../src/socket.io/admin/categories'); - const apiCategories = require('../src/api/categories'); - let cid; - before(async () => { - const category = await apiCategories.create({ uid: adminUid }, { - name: 'update name', - description: 'update description', - parentCid: categoryObj.cid, - icon: 'fa-check', - order: '5', - }); - cid = category.cid; - }); - - it('should return error with invalid data', async () => { - let err; - try { - await apiCategories.update({ uid: adminUid }, null); - } catch (_err) { - err = _err; - } - assert.strictEqual(err.message, '[[error:invalid-data]]'); - }); - - it('should error if you try to set parent as self', async () => { - const updateData = {}; - updateData[cid] = { - parentCid: cid, - }; - let err; - try { - await apiCategories.update({ uid: adminUid }, updateData); - } catch (_err) { - err = _err; - } - assert.strictEqual(err.message, '[[error:cant-set-self-as-parent]]'); - }); - - it('should error if you try to set child as parent', async () => { - const parentCategory = await Categories.create({ name: 'parent 1', description: 'poor parent' }); - const parentCid = parentCategory.cid; - const childCategory = await Categories.create({ name: 'child1', description: 'wanna be parent', parentCid: parentCid }); - const child1Cid = childCategory.cid; - const updateData = {}; - updateData[parentCid] = { - parentCid: child1Cid, - }; - let err; - try { - await apiCategories.update({ uid: adminUid }, updateData); - } catch (_err) { - err = _err; - } - assert.strictEqual(err.message, '[[error:cant-set-child-as-parent]]'); - }); - - it('should update category data', async () => { - const updateData = {}; - updateData[cid] = { - name: 'new name', - description: 'new description', - parentCid: 0, - order: 3, - icon: 'fa-hammer', - }; - await apiCategories.update({ uid: adminUid }, updateData); - - const data = await Categories.getCategoryData(cid); - assert.equal(data.name, updateData[cid].name); - assert.equal(data.description, updateData[cid].description); - assert.equal(data.parentCid, updateData[cid].parentCid); - assert.equal(data.order, updateData[cid].order); - assert.equal(data.icon, updateData[cid].icon); - }); - - it('should properly order categories', async () => { - const p1 = await Categories.create({ name: 'p1', description: 'd', parentCid: 0, order: 1 }); - const c1 = await Categories.create({ name: 'c1', description: 'd1', parentCid: p1.cid, order: 1 }); - const c2 = await Categories.create({ name: 'c2', description: 'd2', parentCid: p1.cid, order: 2 }); - const c3 = await Categories.create({ name: 'c3', description: 'd3', parentCid: p1.cid, order: 3 }); - // move c1 to second place - await apiCategories.update({ uid: adminUid }, { [c1.cid]: { order: 2 } }); - let cids = await db.getSortedSetRange(`cid:${p1.cid}:children`, 0, -1); - assert.deepStrictEqual(cids.map(Number), [c2.cid, c1.cid, c3.cid]); - - // move c3 to front - await apiCategories.update({ uid: adminUid }, { [c3.cid]: { order: 1 } }); - cids = await db.getSortedSetRange(`cid:${p1.cid}:children`, 0, -1); - assert.deepStrictEqual(cids.map(Number), [c3.cid, c2.cid, c1.cid]); - }); - - it('should not remove category from parent if parent is set again to same category', async () => { - const parentCat = await Categories.create({ name: 'parent', description: 'poor parent' }); - const updateData = {}; - updateData[cid] = { - parentCid: parentCat.cid, - }; - await Categories.update(updateData); - let data = await Categories.getCategoryData(cid); - assert.equal(data.parentCid, updateData[cid].parentCid); - let childrenCids = await db.getSortedSetRange(`cid:${parentCat.cid}:children`, 0, -1); - assert(childrenCids.includes(String(cid))); - - // update again to same parent - await Categories.update(updateData); - data = await Categories.getCategoryData(cid); - assert.equal(data.parentCid, updateData[cid].parentCid); - childrenCids = await db.getSortedSetRange(`cid:${parentCat.cid}:children`, 0, -1); - assert(childrenCids.includes(String(cid))); - }); - - it('should purge category', async () => { - const category = await Categories.create({ - name: 'purge me', - description: 'update description', - }); - await Topics.post({ - uid: posterUid, - cid: category.cid, - title: 'Test Topic Title', - content: 'The content of test topic', - }); - await apiCategories.delete({ uid: adminUid }, { cid: category.cid }); - const data = await Categories.getCategoryById(category.cid); - assert.strictEqual(data, null); - }); - - it('should get all category names', (done) => { - socketCategories.getNames({ uid: adminUid }, {}, (err, data) => { - assert.ifError(err); - assert(Array.isArray(data)); - done(); - }); - }); - - it('should give privilege', async () => { - await apiCategories.setPrivilege({ uid: adminUid }, { cid: categoryObj.cid, privilege: ['groups:topics:delete'], set: true, member: 'registered-users' }); - const canDeleteTopics = await privileges.categories.can('topics:delete', categoryObj.cid, posterUid); - assert(canDeleteTopics); - }); - - it('should remove privilege', async () => { - await apiCategories.setPrivilege({ uid: adminUid }, { cid: categoryObj.cid, privilege: 'groups:topics:delete', set: false, member: 'registered-users' }); - const canDeleteTopics = await privileges.categories.can('topics:delete', categoryObj.cid, posterUid); - assert(!canDeleteTopics); - }); - - it('should get privilege settings', async () => { - const data = await apiCategories.getPrivileges({ uid: adminUid }, categoryObj.cid); - assert(data.labels); - assert(data.labels.users); - assert(data.labels.groups); - assert(data.keys.users); - assert(data.keys.groups); - assert(data.users); - assert(data.groups); - }); - - it('should copy privileges to children', async () => { - const parentCategory = await Categories.create({ name: 'parent' }); - const parentCid = parentCategory.cid; - const child1 = await Categories.create({ name: 'child1', parentCid: parentCid }); - const child2 = await Categories.create({ name: 'child2', parentCid: child1.cid }); - await apiCategories.setPrivilege({ uid: adminUid }, { - cid: parentCid, - privilege: 'groups:topics:delete', - set: true, - member: 'registered-users', - }); - await socketCategories.copyPrivilegesToChildren({ uid: adminUid }, { cid: parentCid, group: '' }); - const canDelete = await privileges.categories.can('topics:delete', child2.cid, posterUid); - assert(canDelete); - }); - - it('should create category with settings from', (done) => { - let child1Cid; - let parentCid; - async.waterfall([ - function (next) { - Categories.create({ name: 'copy from', description: 'copy me' }, next); - }, - function (category, next) { - parentCid = category.cid; - Categories.create({ name: 'child1', description: 'will be gone', cloneFromCid: parentCid }, next); - }, - function (category, next) { - child1Cid = category.cid; - assert.equal(category.description, 'copy me'); - next(); - }, - ], done); - }); - - it('should copy settings from', (done) => { - let child1Cid; - let parentCid; - async.waterfall([ - function (next) { - Categories.create({ name: 'parent', description: 'copy me' }, next); - }, - function (category, next) { - parentCid = category.cid; - Categories.create({ name: 'child1' }, next); - }, - function (category, next) { - child1Cid = category.cid; - socketCategories.copySettingsFrom( - { uid: adminUid }, - { fromCid: parentCid, toCid: child1Cid, copyParent: true }, - next - ); - }, - function (destinationCategory, next) { - Categories.getCategoryField(child1Cid, 'description', next); - }, - function (description, next) { - assert.equal(description, 'copy me'); - next(); - }, - ], done); - }); - - it('should copy privileges from another category', async () => { - const parent = await Categories.create({ name: 'parent', description: 'copy me' }); - const parentCid = parent.cid; - const child1 = await Categories.create({ name: 'child1' }); - await apiCategories.setPrivilege({ uid: adminUid }, { - cid: parentCid, - privilege: 'groups:topics:delete', - set: true, - member: 'registered-users', - }); - await socketCategories.copyPrivilegesFrom({ uid: adminUid }, { fromCid: parentCid, toCid: child1.cid }); - const canDelete = await privileges.categories.can('topics:delete', child1.cid, posterUid); - assert(canDelete); - }); - - it('should copy privileges from another category for a single group', async () => { - const parent = await Categories.create({ name: 'parent', description: 'copy me' }); - const parentCid = parent.cid; - const child1 = await Categories.create({ name: 'child1' }); - await apiCategories.setPrivilege({ uid: adminUid }, { - cid: parentCid, - privilege: 'groups:topics:delete', - set: true, - member: 'registered-users', - }); - await socketCategories.copyPrivilegesFrom({ uid: adminUid }, { fromCid: parentCid, toCid: child1.cid, group: 'registered-users' }); - const canDelete = await privileges.categories.can('topics:delete', child1.cid, 0); - assert(!canDelete); - }); - }); - - it('should get active users', (done) => { - Categories.create({ - name: 'test', - }, (err, category) => { - assert.ifError(err); - Topics.post({ - uid: posterUid, - cid: category.cid, - title: 'Test Topic Title', - content: 'The content of test topic', - }, (err) => { - assert.ifError(err); - Categories.getActiveUsers(category.cid, (err, uids) => { - assert.ifError(err); - assert.equal(uids[0], posterUid); - done(); - }); - }); - }); - }); - - describe('tag whitelist', () => { - let cid; - const socketTopics = require('../src/socket.io/topics'); - before((done) => { - Categories.create({ - name: 'test', - }, (err, category) => { - assert.ifError(err); - cid = category.cid; - done(); - }); - }); - - it('should error if data is invalid', (done) => { - socketTopics.isTagAllowed({ uid: posterUid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should return true if category whitelist is empty', (done) => { - socketTopics.isTagAllowed({ uid: posterUid }, { tag: 'notallowed', cid: cid }, (err, allowed) => { - assert.ifError(err); - assert(allowed); - done(); - }); - }); - - it('should add tags to category whitelist', (done) => { - const data = {}; - data[cid] = { - tagWhitelist: 'nodebb,jquery,javascript', - }; - Categories.update(data, (err) => { - assert.ifError(err); - db.getSortedSetRange(`cid:${cid}:tag:whitelist`, 0, -1, (err, tagWhitelist) => { - assert.ifError(err); - assert.deepEqual(['nodebb', 'jquery', 'javascript'], tagWhitelist); - done(); - }); - }); - }); - - it('should return false if category whitelist does not have tag', (done) => { - socketTopics.isTagAllowed({ uid: posterUid }, { tag: 'notallowed', cid: cid }, (err, allowed) => { - assert.ifError(err); - assert(!allowed); - done(); - }); - }); - - it('should return true if category whitelist has tag', (done) => { - socketTopics.isTagAllowed({ uid: posterUid }, { tag: 'nodebb', cid: cid }, (err, allowed) => { - assert.ifError(err); - assert(allowed); - done(); - }); - }); - - it('should post a topic with only allowed tags', (done) => { - Topics.post({ - uid: posterUid, - cid: cid, - title: 'Test Topic Title', - content: 'The content of test topic', - tags: ['nodebb', 'jquery', 'notallowed'], - }, (err, data) => { - assert.ifError(err); - assert.equal(data.topicData.tags.length, 2); - done(); - }); - }); - }); - - - describe('privileges', () => { - const privileges = require('../src/privileges'); - - it('should return empty array if uids is empty array', (done) => { - privileges.categories.filterUids('find', categoryObj.cid, [], (err, uids) => { - assert.ifError(err); - assert.equal(uids.length, 0); - done(); - }); - }); - - it('should filter uids by privilege', (done) => { - privileges.categories.filterUids('find', categoryObj.cid, [1, 2, 3, 4], (err, uids) => { - assert.ifError(err); - assert.deepEqual(uids, [1, 2]); - done(); - }); - }); - - it('should load category user privileges', (done) => { - privileges.categories.userPrivileges(categoryObj.cid, 1, (err, data) => { - assert.ifError(err); - assert.deepEqual(data, { - find: false, - 'posts:delete': false, - read: false, - 'topics:reply': false, - 'topics:read': false, - 'topics:create': false, - 'topics:tag': false, - 'topics:delete': false, - 'topics:schedule': false, - 'posts:edit': false, - 'posts:history': false, - 'posts:upvote': false, - 'posts:downvote': false, - purge: false, - 'posts:view_deleted': false, - moderate: false, - }); - - done(); - }); - }); - - it('should load global user privileges', (done) => { - privileges.global.userPrivileges(1, (err, data) => { - assert.ifError(err); - assert.deepEqual(data, { - ban: false, - mute: false, - invite: false, - chat: false, - 'search:content': false, - 'search:users': false, - 'search:tags': false, - 'view:users:info': false, - 'upload:post:image': false, - 'upload:post:file': false, - signature: false, - 'local:login': false, - 'group:create': false, - 'view:users': false, - 'view:tags': false, - 'view:groups': false, - }); - - done(); - }); - }); - - it('should load category group privileges', (done) => { - privileges.categories.groupPrivileges(categoryObj.cid, 'registered-users', (err, data) => { - assert.ifError(err); - assert.deepEqual(data, { - 'groups:find': true, - 'groups:posts:edit': true, - 'groups:posts:history': true, - 'groups:posts:upvote': true, - 'groups:posts:downvote': true, - 'groups:topics:delete': false, - 'groups:topics:create': true, - 'groups:topics:reply': true, - 'groups:topics:tag': true, - 'groups:topics:schedule': false, - 'groups:posts:delete': true, - 'groups:read': true, - 'groups:topics:read': true, - 'groups:purge': false, - 'groups:posts:view_deleted': false, - 'groups:moderate': false, - }); - - done(); - }); - }); - - it('should load global group privileges', (done) => { - privileges.global.groupPrivileges('registered-users', (err, data) => { - assert.ifError(err); - assert.deepEqual(data, { - 'groups:ban': false, - 'groups:mute': false, - 'groups:invite': false, - 'groups:chat': true, - 'groups:search:content': true, - 'groups:search:users': true, - 'groups:search:tags': true, - 'groups:view:users': true, - 'groups:view:users:info': false, - 'groups:view:tags': true, - 'groups:view:groups': true, - 'groups:upload:post:image': true, - 'groups:upload:post:file': false, - 'groups:signature': true, - 'groups:local:login': true, - 'groups:group:create': false, - }); - - done(); - }); - }); - - it('should return false if cid is falsy', (done) => { - privileges.categories.isUserAllowedTo('find', null, adminUid, (err, isAllowed) => { - assert.ifError(err); - assert.equal(isAllowed, false); - done(); - }); - }); - - describe('Categories.getModeratorUids', () => { - before((done) => { - async.series([ - async.apply(groups.create, { name: 'testGroup' }), - async.apply(groups.join, 'cid:1:privileges:groups:moderate', 'testGroup'), - async.apply(groups.join, 'testGroup', 1), - ], done); - }); - - it('should retrieve all users with moderator bit in category privilege', (done) => { - Categories.getModeratorUids([1, 2], (err, uids) => { - assert.ifError(err); - assert.strictEqual(uids.length, 2); - assert(uids[0].includes('1')); - assert.strictEqual(uids[1].length, 0); - done(); - }); - }); - - it('should not fail when there are multiple groups', (done) => { - async.series([ - async.apply(groups.create, { name: 'testGroup2' }), - async.apply(groups.join, 'cid:1:privileges:groups:moderate', 'testGroup2'), - async.apply(groups.join, 'testGroup2', 1), - function (next) { - Categories.getModeratorUids([1, 2], (err, uids) => { - assert.ifError(err); - assert(uids[0].includes('1')); - next(); - }); - }, - ], done); - }); - - after((done) => { - async.series([ - async.apply(groups.leave, 'cid:1:privileges:groups:moderate', 'testGroup'), - async.apply(groups.leave, 'cid:1:privileges:groups:moderate', 'testGroup2'), - async.apply(groups.destroy, 'testGroup'), - async.apply(groups.destroy, 'testGroup2'), - ], done); - }); - }); - }); - - - describe('getTopicIds', () => { - const plugins = require('../src/plugins'); - it('should get topic ids with filter', (done) => { - function method(data, callback) { - data.tids = [1, 2, 3]; - callback(null, data); - } - - plugins.hooks.register('my-test-plugin', { - hook: 'filter:categories.getTopicIds', - method: method, - }); - - Categories.getTopicIds({ - cid: categoryObj.cid, - start: 0, - stop: 19, - }, (err, tids) => { - assert.ifError(err); - assert.deepEqual(tids, [1, 2, 3]); - plugins.hooks.unregister('my-test-plugin', 'filter:categories.getTopicIds', method); - done(); - }); - }); - }); - - it('should return nested children categories', async () => { - const rootCategory = await Categories.create({ name: 'root' }); - const child1 = await Categories.create({ name: 'child1', parentCid: rootCategory.cid }); - const child2 = await Categories.create({ name: 'child2', parentCid: child1.cid }); - const data = await Categories.getCategoryById({ - uid: 1, - cid: rootCategory.cid, - start: 0, - stop: 19, - }); - assert.strictEqual(child1.cid, data.children[0].cid); - assert.strictEqual(child2.cid, data.children[0].children[0].cid); - }); + let categoryObject; + let posterUid; + let adminUid; + + before(done => { + async.series({ + posterUid(next) { + User.create({username: 'poster'}, next); + }, + adminUid(next) { + User.create({username: 'admin'}, next); + }, + }, (error, results) => { + assert.ifError(error); + posterUid = results.posterUid; + adminUid = results.adminUid; + groups.join('administrators', adminUid, done); + }); + }); + + it('should create a new category', done => { + Categories.create({ + name: 'Test Category & NodeBB', + description: 'Test category created by testing script', + icon: 'fa-check', + blockclass: 'category-blue', + order: '5', + }, (error, category) => { + assert.ifError(error); + + categoryObject = category; + done(); + }); + }); + + it('should retrieve a newly created category by its ID', done => { + Categories.getCategoryById({ + cid: categoryObject.cid, + start: 0, + stop: -1, + uid: 0, + }, (error, categoryData) => { + assert.ifError(error); + + assert(categoryData); + assert.equal('Test Category & NodeBB', categoryData.name); + assert.equal(categoryObject.description, categoryData.description); + assert.strictEqual(categoryObject.disabled, 0); + done(); + }); + }); + + it('should return null if category does not exist', done => { + Categories.getCategoryById({ + cid: 123_123_123, + start: 0, + stop: -1, + }, (error, categoryData) => { + assert.ifError(error); + assert.strictEqual(categoryData, null); + done(); + }); + }); + + it('should get all categories', done => { + Categories.getAllCategories(1, (error, data) => { + assert.ifError(error); + assert(Array.isArray(data)); + assert.equal(data[0].cid, categoryObject.cid); + done(); + }); + }); + + it('should load a category route', done => { + request(`${nconf.get('url')}/api/category/${categoryObject.cid}/test-category`, {json: true}, (error, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 200); + assert.equal(body.name, 'Test Category & NodeBB'); + assert(body); + done(); + }); + }); + + describe('Categories.getRecentTopicReplies', () => { + it('should not throw', done => { + Categories.getCategoryById({ + cid: categoryObject.cid, + set: `cid:${categoryObject.cid}:tids`, + reverse: true, + start: 0, + stop: -1, + uid: 0, + }, (error, categoryData) => { + assert.ifError(error); + Categories.getRecentTopicReplies(categoryData, 0, {}, error_ => { + assert.ifError(error_); + done(); + }); + }); + }); + }); + + describe('.getCategoryTopics', () => { + it('should return a list of topics', done => { + Categories.getCategoryTopics({ + cid: categoryObject.cid, + start: 0, + stop: 10, + uid: 0, + sort: 'oldest_to_newest', + }, (error, result) => { + assert.equal(error, null); + + assert(Array.isArray(result.topics)); + assert(result.topics.every(topic => topic instanceof Object)); + + done(); + }); + }); + + it('should return a list of topics by a specific user', done => { + Categories.getCategoryTopics({ + cid: categoryObject.cid, + start: 0, + stop: 10, + uid: 0, + targetUid: 1, + sort: 'oldest_to_newest', + }, (error, result) => { + assert.equal(error, null); + assert(Array.isArray(result.topics)); + assert(result.topics.every(topic => topic instanceof Object && topic.uid === '1')); + + done(); + }); + }); + }); + + describe('Categories.moveRecentReplies', () => { + let moveCid; + let moveTid; + before(done => { + async.parallel({ + category(next) { + Categories.create({ + name: 'Test Category 2', + description: 'Test category created by testing script', + }, next); + }, + topic(next) { + Topics.post({ + uid: posterUid, + cid: categoryObject.cid, + title: 'Test Topic Title', + content: 'The content of test topic', + }, next); + }, + }, (error, results) => { + if (error) { + return done(error); + } + + moveCid = results.category.cid; + moveTid = results.topic.topicData.tid; + Topics.reply({uid: posterUid, content: 'test post', tid: moveTid}, error_ => { + done(error_); + }); + }); + }); + + it('should move posts from one category to another', done => { + Categories.moveRecentReplies(moveTid, categoryObject.cid, moveCid, error => { + assert.ifError(error); + db.getSortedSetRange(`cid:${categoryObject.cid}:pids`, 0, -1, (error, pids) => { + assert.ifError(error); + assert.equal(pids.length, 0); + db.getSortedSetRange(`cid:${moveCid}:pids`, 0, -1, (error, pids) => { + assert.ifError(error); + assert.equal(pids.length, 2); + done(); + }); + }); + }); + }); + }); + + describe('api/socket methods', () => { + const socketCategories = require('../src/socket.io/categories'); + const apiCategories = require('../src/api/categories'); + before(async () => { + await Topics.post({ + uid: posterUid, + cid: categoryObject.cid, + title: 'Test Topic Title', + content: 'The content of test topic', + tags: ['nodebb'], + }); + const data = await Topics.post({ + uid: posterUid, + cid: categoryObject.cid, + title: 'will delete', + content: 'The content of deleted topic', + }); + const newData = await Topics.post({ + uid: posterUid, + cid: categoryObject.cid, + title: 'will private', + content: 'The content of private topic', + }); + await Topics.delete(data.topicData.tid, adminUid); + await Topics.private(newData.topicData.tid, adminUid); + }); + + it('should get recent replies in category', done => { + socketCategories.getRecentReplies({uid: posterUid}, categoryObject.cid, (error, data) => { + assert.ifError(error); + assert(Array.isArray(data)); + done(); + }); + }); + + it('should get categories', done => { + socketCategories.get({uid: posterUid}, {}, (error, data) => { + assert.ifError(error); + assert(Array.isArray(data)); + done(); + }); + }); + + it('should get watched categories', done => { + socketCategories.getWatchedCategories({uid: posterUid}, {}, (error, data) => { + assert.ifError(error); + assert(Array.isArray(data)); + done(); + }); + }); + + it('should load more topics', done => { + socketCategories.loadMore({uid: posterUid}, { + cid: categoryObject.cid, + after: 0, + query: { + author: 'poster', + tag: 'nodebb', + }, + }, (error, data) => { + assert.ifError(error); + assert(Array.isArray(data.topics)); + assert.equal(data.topics[0].user.username, 'poster'); + assert.equal(data.topics[0].tags[0].value, 'nodebb'); + assert.equal(data.topics[0].category.cid, categoryObject.cid); + done(); + }); + }); + + it('should not show deleted topic titles', async () => { + const data = await socketCategories.loadMore({uid: 0}, { + cid: categoryObject.cid, + after: 0, + }); + + assert.deepStrictEqual( + data.topics.map(t => t.title), + ['[[topic:topic_is_private]]', '[[topic:topic_is_deleted]]', 'Test Topic Title', 'Test Topic Title'], + ); + }); + + it('should not show privated topic titles', async () => { + const data = await socketCategories.loadMore({uid: 0}, { + cid: categoryObject.cid, + after: 0, + }); + + assert.deepStrictEqual( + data.topics.map(t => t.title), + ['[[topic:topic_is_private]]', '[[topic:topic_is_deleted]]', 'Test Topic Title', 'Test Topic Title'], + ); + }); + + it('should show privated topic titles', async () => { + const data = await socketCategories.loadMore({uid: posterUid}, { + cid: categoryObject.cid, + after: 0, + }); + + assert.deepStrictEqual( + data.topics.map(t => t.title), + ['will private', 'will delete', 'Test Topic Title', 'Test Topic Title'], + ); + }); + + it('should load topic count', done => { + socketCategories.getTopicCount({uid: posterUid}, categoryObject.cid, (error, topicCount) => { + assert.ifError(error); + assert.strictEqual(topicCount, 4); + done(); + }); + }); + + it('should load category by privilege', done => { + socketCategories.getCategoriesByPrivilege({uid: posterUid}, 'find', (error, data) => { + assert.ifError(error); + assert(Array.isArray(data)); + done(); + }); + }); + + it('should get move categories', done => { + socketCategories.getMoveCategories({uid: posterUid}, {}, (error, data) => { + assert.ifError(error); + assert(Array.isArray(data)); + done(); + }); + }); + + it('should ignore category', done => { + socketCategories.ignore({uid: posterUid}, {cid: categoryObject.cid}, error => { + assert.ifError(error); + Categories.isIgnored([categoryObject.cid], posterUid, (error, isIgnored) => { + assert.ifError(error); + assert.equal(isIgnored[0], true); + Categories.getIgnorers(categoryObject.cid, 0, -1, (error, ignorers) => { + assert.ifError(error); + assert.deepEqual(ignorers, [posterUid]); + done(); + }); + }); + }); + }); + + it('should watch category', done => { + socketCategories.watch({uid: posterUid}, {cid: categoryObject.cid}, error => { + assert.ifError(error); + Categories.isIgnored([categoryObject.cid], posterUid, (error, isIgnored) => { + assert.ifError(error); + assert.equal(isIgnored[0], false); + done(); + }); + }); + }); + + it('should error if watch state does not exist', done => { + socketCategories.setWatchState({uid: posterUid}, {cid: categoryObject.cid, state: 'invalid-state'}, error => { + assert.equal(error.message, '[[error:invalid-watch-state]]'); + done(); + }); + }); + + it('should check if user is moderator', done => { + socketCategories.isModerator({uid: posterUid}, {}, (error, isModerator) => { + assert.ifError(error); + assert(!isModerator); + done(); + }); + }); + + it('should get category data', async () => { + const data = await apiCategories.get({uid: posterUid}, {cid: categoryObject.cid}); + assert.equal(categoryObject.cid, data.cid); + }); + }); + + describe('admin api/socket methods', () => { + const socketCategories = require('../src/socket.io/admin/categories'); + const apiCategories = require('../src/api/categories'); + let cid; + before(async () => { + const category = await apiCategories.create({uid: adminUid}, { + name: 'update name', + description: 'update description', + parentCid: categoryObject.cid, + icon: 'fa-check', + order: '5', + }); + cid = category.cid; + }); + + it('should return error with invalid data', async () => { + let error; + try { + await apiCategories.update({uid: adminUid}, null); + } catch (error_) { + error = error_; + } + + assert.strictEqual(error.message, '[[error:invalid-data]]'); + }); + + it('should error if you try to set parent as self', async () => { + const updateData = {}; + updateData[cid] = { + parentCid: cid, + }; + let error; + try { + await apiCategories.update({uid: adminUid}, updateData); + } catch (error_) { + error = error_; + } + + assert.strictEqual(error.message, '[[error:cant-set-self-as-parent]]'); + }); + + it('should error if you try to set child as parent', async () => { + const parentCategory = await Categories.create({name: 'parent 1', description: 'poor parent'}); + const parentCid = parentCategory.cid; + const childCategory = await Categories.create({name: 'child1', description: 'wanna be parent', parentCid}); + const child1Cid = childCategory.cid; + const updateData = {}; + updateData[parentCid] = { + parentCid: child1Cid, + }; + let error; + try { + await apiCategories.update({uid: adminUid}, updateData); + } catch (error_) { + error = error_; + } + + assert.strictEqual(error.message, '[[error:cant-set-child-as-parent]]'); + }); + + it('should update category data', async () => { + const updateData = {}; + updateData[cid] = { + name: 'new name', + description: 'new description', + parentCid: 0, + order: 3, + icon: 'fa-hammer', + }; + await apiCategories.update({uid: adminUid}, updateData); + + const data = await Categories.getCategoryData(cid); + assert.equal(data.name, updateData[cid].name); + assert.equal(data.description, updateData[cid].description); + assert.equal(data.parentCid, updateData[cid].parentCid); + assert.equal(data.order, updateData[cid].order); + assert.equal(data.icon, updateData[cid].icon); + }); + + it('should properly order categories', async () => { + const p1 = await Categories.create({ + name: 'p1', description: 'd', parentCid: 0, order: 1, + }); + const c1 = await Categories.create({ + name: 'c1', description: 'd1', parentCid: p1.cid, order: 1, + }); + const c2 = await Categories.create({ + name: 'c2', description: 'd2', parentCid: p1.cid, order: 2, + }); + const c3 = await Categories.create({ + name: 'c3', description: 'd3', parentCid: p1.cid, order: 3, + }); + // Move c1 to second place + await apiCategories.update({uid: adminUid}, {[c1.cid]: {order: 2}}); + let cids = await db.getSortedSetRange(`cid:${p1.cid}:children`, 0, -1); + assert.deepStrictEqual(cids.map(Number), [c2.cid, c1.cid, c3.cid]); + + // Move c3 to front + await apiCategories.update({uid: adminUid}, {[c3.cid]: {order: 1}}); + cids = await db.getSortedSetRange(`cid:${p1.cid}:children`, 0, -1); + assert.deepStrictEqual(cids.map(Number), [c3.cid, c2.cid, c1.cid]); + }); + + it('should not remove category from parent if parent is set again to same category', async () => { + const parentCat = await Categories.create({name: 'parent', description: 'poor parent'}); + const updateData = {}; + updateData[cid] = { + parentCid: parentCat.cid, + }; + await Categories.update(updateData); + let data = await Categories.getCategoryData(cid); + assert.equal(data.parentCid, updateData[cid].parentCid); + let childrenCids = await db.getSortedSetRange(`cid:${parentCat.cid}:children`, 0, -1); + assert(childrenCids.includes(String(cid))); + + // Update again to same parent + await Categories.update(updateData); + data = await Categories.getCategoryData(cid); + assert.equal(data.parentCid, updateData[cid].parentCid); + childrenCids = await db.getSortedSetRange(`cid:${parentCat.cid}:children`, 0, -1); + assert(childrenCids.includes(String(cid))); + }); + + it('should purge category', async () => { + const category = await Categories.create({ + name: 'purge me', + description: 'update description', + }); + await Topics.post({ + uid: posterUid, + cid: category.cid, + title: 'Test Topic Title', + content: 'The content of test topic', + }); + await apiCategories.delete({uid: adminUid}, {cid: category.cid}); + const data = await Categories.getCategoryById(category.cid); + assert.strictEqual(data, null); + }); + + it('should get all category names', done => { + socketCategories.getNames({uid: adminUid}, {}, (error, data) => { + assert.ifError(error); + assert(Array.isArray(data)); + done(); + }); + }); + + it('should give privilege', async () => { + await apiCategories.setPrivilege({uid: adminUid}, { + cid: categoryObject.cid, privilege: ['groups:topics:delete'], set: true, member: 'registered-users', + }); + const canDeleteTopics = await privileges.categories.can('topics:delete', categoryObject.cid, posterUid); + assert(canDeleteTopics); + }); + + it('should remove privilege', async () => { + await apiCategories.setPrivilege({uid: adminUid}, { + cid: categoryObject.cid, privilege: 'groups:topics:delete', set: false, member: 'registered-users', + }); + const canDeleteTopics = await privileges.categories.can('topics:delete', categoryObject.cid, posterUid); + assert(!canDeleteTopics); + }); + + it('should get privilege settings', async () => { + const data = await apiCategories.getPrivileges({uid: adminUid}, categoryObject.cid); + assert(data.labels); + assert(data.labels.users); + assert(data.labels.groups); + assert(data.keys.users); + assert(data.keys.groups); + assert(data.users); + assert(data.groups); + }); + + it('should copy privileges to children', async () => { + const parentCategory = await Categories.create({name: 'parent'}); + const parentCid = parentCategory.cid; + const child1 = await Categories.create({name: 'child1', parentCid}); + const child2 = await Categories.create({name: 'child2', parentCid: child1.cid}); + await apiCategories.setPrivilege({uid: adminUid}, { + cid: parentCid, + privilege: 'groups:topics:delete', + set: true, + member: 'registered-users', + }); + await socketCategories.copyPrivilegesToChildren({uid: adminUid}, {cid: parentCid, group: ''}); + const canDelete = await privileges.categories.can('topics:delete', child2.cid, posterUid); + assert(canDelete); + }); + + it('should create category with settings from', done => { + let child1Cid; + let parentCid; + async.waterfall([ + function (next) { + Categories.create({name: 'copy from', description: 'copy me'}, next); + }, + function (category, next) { + parentCid = category.cid; + Categories.create({name: 'child1', description: 'will be gone', cloneFromCid: parentCid}, next); + }, + function (category, next) { + child1Cid = category.cid; + assert.equal(category.description, 'copy me'); + next(); + }, + ], done); + }); + + it('should copy settings from', done => { + let child1Cid; + let parentCid; + async.waterfall([ + function (next) { + Categories.create({name: 'parent', description: 'copy me'}, next); + }, + function (category, next) { + parentCid = category.cid; + Categories.create({name: 'child1'}, next); + }, + function (category, next) { + child1Cid = category.cid; + socketCategories.copySettingsFrom( + {uid: adminUid}, + {fromCid: parentCid, toCid: child1Cid, copyParent: true}, + next, + ); + }, + function (destinationCategory, next) { + Categories.getCategoryField(child1Cid, 'description', next); + }, + function (description, next) { + assert.equal(description, 'copy me'); + next(); + }, + ], done); + }); + + it('should copy privileges from another category', async () => { + const parent = await Categories.create({name: 'parent', description: 'copy me'}); + const parentCid = parent.cid; + const child1 = await Categories.create({name: 'child1'}); + await apiCategories.setPrivilege({uid: adminUid}, { + cid: parentCid, + privilege: 'groups:topics:delete', + set: true, + member: 'registered-users', + }); + await socketCategories.copyPrivilegesFrom({uid: adminUid}, {fromCid: parentCid, toCid: child1.cid}); + const canDelete = await privileges.categories.can('topics:delete', child1.cid, posterUid); + assert(canDelete); + }); + + it('should copy privileges from another category for a single group', async () => { + const parent = await Categories.create({name: 'parent', description: 'copy me'}); + const parentCid = parent.cid; + const child1 = await Categories.create({name: 'child1'}); + await apiCategories.setPrivilege({uid: adminUid}, { + cid: parentCid, + privilege: 'groups:topics:delete', + set: true, + member: 'registered-users', + }); + await socketCategories.copyPrivilegesFrom({uid: adminUid}, {fromCid: parentCid, toCid: child1.cid, group: 'registered-users'}); + const canDelete = await privileges.categories.can('topics:delete', child1.cid, 0); + assert(!canDelete); + }); + }); + + it('should get active users', done => { + Categories.create({ + name: 'test', + }, (error, category) => { + assert.ifError(error); + Topics.post({ + uid: posterUid, + cid: category.cid, + title: 'Test Topic Title', + content: 'The content of test topic', + }, error_ => { + assert.ifError(error_); + Categories.getActiveUsers(category.cid, (error, uids) => { + assert.ifError(error); + assert.equal(uids[0], posterUid); + done(); + }); + }); + }); + }); + + describe('tag whitelist', () => { + let cid; + const socketTopics = require('../src/socket.io/topics'); + before(done => { + Categories.create({ + name: 'test', + }, (error, category) => { + assert.ifError(error); + cid = category.cid; + done(); + }); + }); + + it('should error if data is invalid', done => { + socketTopics.isTagAllowed({uid: posterUid}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should return true if category whitelist is empty', done => { + socketTopics.isTagAllowed({uid: posterUid}, {tag: 'notallowed', cid}, (error, allowed) => { + assert.ifError(error); + assert(allowed); + done(); + }); + }); + + it('should add tags to category whitelist', done => { + const data = {}; + data[cid] = { + tagWhitelist: 'nodebb,jquery,javascript', + }; + Categories.update(data, error => { + assert.ifError(error); + db.getSortedSetRange(`cid:${cid}:tag:whitelist`, 0, -1, (error, tagInclude) => { + assert.ifError(error); + assert.deepEqual(['nodebb', 'jquery', 'javascript'], tagInclude); + done(); + }); + }); + }); + + it('should return false if category whitelist does not have tag', done => { + socketTopics.isTagAllowed({uid: posterUid}, {tag: 'notallowed', cid}, (error, allowed) => { + assert.ifError(error); + assert(!allowed); + done(); + }); + }); + + it('should return true if category whitelist has tag', done => { + socketTopics.isTagAllowed({uid: posterUid}, {tag: 'nodebb', cid}, (error, allowed) => { + assert.ifError(error); + assert(allowed); + done(); + }); + }); + + it('should post a topic with only allowed tags', done => { + Topics.post({ + uid: posterUid, + cid, + title: 'Test Topic Title', + content: 'The content of test topic', + tags: ['nodebb', 'jquery', 'notallowed'], + }, (error, data) => { + assert.ifError(error); + assert.equal(data.topicData.tags.length, 2); + done(); + }); + }); + }); + + describe('privileges', () => { + const privileges = require('../src/privileges'); + + it('should return empty array if uids is empty array', done => { + privileges.categories.filterUids('find', categoryObject.cid, [], (error, uids) => { + assert.ifError(error); + assert.equal(uids.length, 0); + done(); + }); + }); + + it('should filter uids by privilege', done => { + privileges.categories.filterUids('find', categoryObject.cid, [1, 2, 3, 4], (error, uids) => { + assert.ifError(error); + assert.deepEqual(uids, [1, 2]); + done(); + }); + }); + + it('should load category user privileges', done => { + privileges.categories.userPrivileges(categoryObject.cid, 1, (error, data) => { + assert.ifError(error); + assert.deepEqual(data, { + find: false, + 'posts:delete': false, + read: false, + 'topics:reply': false, + 'topics:read': false, + 'topics:create': false, + 'topics:tag': false, + 'topics:delete': false, + 'topics:schedule': false, + 'posts:edit': false, + 'posts:history': false, + 'posts:upvote': false, + 'posts:downvote': false, + purge: false, + 'posts:view_deleted': false, + moderate: false, + }); + + done(); + }); + }); + + it('should load global user privileges', done => { + privileges.global.userPrivileges(1, (error, data) => { + assert.ifError(error); + assert.deepEqual(data, { + ban: false, + mute: false, + invite: false, + chat: false, + 'search:content': false, + 'search:users': false, + 'search:tags': false, + 'view:users:info': false, + 'upload:post:image': false, + 'upload:post:file': false, + signature: false, + 'local:login': false, + 'group:create': false, + 'view:users': false, + 'view:tags': false, + 'view:groups': false, + }); + + done(); + }); + }); + + it('should load category group privileges', done => { + privileges.categories.groupPrivileges(categoryObject.cid, 'registered-users', (error, data) => { + assert.ifError(error); + assert.deepEqual(data, { + 'groups:find': true, + 'groups:posts:edit': true, + 'groups:posts:history': true, + 'groups:posts:upvote': true, + 'groups:posts:downvote': true, + 'groups:topics:delete': false, + 'groups:topics:create': true, + 'groups:topics:reply': true, + 'groups:topics:tag': true, + 'groups:topics:schedule': false, + 'groups:posts:delete': true, + 'groups:read': true, + 'groups:topics:read': true, + 'groups:purge': false, + 'groups:posts:view_deleted': false, + 'groups:moderate': false, + }); + + done(); + }); + }); + + it('should load global group privileges', done => { + privileges.global.groupPrivileges('registered-users', (error, data) => { + assert.ifError(error); + assert.deepEqual(data, { + 'groups:ban': false, + 'groups:mute': false, + 'groups:invite': false, + 'groups:chat': true, + 'groups:search:content': true, + 'groups:search:users': true, + 'groups:search:tags': true, + 'groups:view:users': true, + 'groups:view:users:info': false, + 'groups:view:tags': true, + 'groups:view:groups': true, + 'groups:upload:post:image': true, + 'groups:upload:post:file': false, + 'groups:signature': true, + 'groups:local:login': true, + 'groups:group:create': false, + }); + + done(); + }); + }); + + it('should return false if cid is falsy', done => { + privileges.categories.isUserAllowedTo('find', null, adminUid, (error, isAllowed) => { + assert.ifError(error); + assert.equal(isAllowed, false); + done(); + }); + }); + + describe('Categories.getModeratorUids', () => { + before(done => { + async.series([ + async.apply(groups.create, {name: 'testGroup'}), + async.apply(groups.join, 'cid:1:privileges:groups:moderate', 'testGroup'), + async.apply(groups.join, 'testGroup', 1), + ], done); + }); + + it('should retrieve all users with moderator bit in category privilege', done => { + Categories.getModeratorUids([1, 2], (error, uids) => { + assert.ifError(error); + assert.strictEqual(uids.length, 2); + assert(uids[0].includes('1')); + assert.strictEqual(uids[1].length, 0); + done(); + }); + }); + + it('should not fail when there are multiple groups', done => { + async.series([ + async.apply(groups.create, {name: 'testGroup2'}), + async.apply(groups.join, 'cid:1:privileges:groups:moderate', 'testGroup2'), + async.apply(groups.join, 'testGroup2', 1), + function (next) { + Categories.getModeratorUids([1, 2], (error, uids) => { + assert.ifError(error); + assert(uids[0].includes('1')); + next(); + }); + }, + ], done); + }); + + after(done => { + async.series([ + async.apply(groups.leave, 'cid:1:privileges:groups:moderate', 'testGroup'), + async.apply(groups.leave, 'cid:1:privileges:groups:moderate', 'testGroup2'), + async.apply(groups.destroy, 'testGroup'), + async.apply(groups.destroy, 'testGroup2'), + ], done); + }); + }); + }); + + describe('getTopicIds', () => { + const plugins = require('../src/plugins'); + it('should get topic ids with filter', done => { + function method(data, callback) { + data.tids = [1, 2, 3]; + callback(null, data); + } + + plugins.hooks.register('my-test-plugin', { + hook: 'filter:categories.getTopicIds', + method, + }); + + Categories.getTopicIds({ + cid: categoryObject.cid, + start: 0, + stop: 19, + }, (error, tids) => { + assert.ifError(error); + assert.deepEqual(tids, [1, 2, 3]); + plugins.hooks.unregister('my-test-plugin', 'filter:categories.getTopicIds', method); + done(); + }); + }); + }); + + it('should return nested children categories', async () => { + const rootCategory = await Categories.create({name: 'root'}); + const child1 = await Categories.create({name: 'child1', parentCid: rootCategory.cid}); + const child2 = await Categories.create({name: 'child2', parentCid: child1.cid}); + const data = await Categories.getCategoryById({ + uid: 1, + cid: rootCategory.cid, + start: 0, + stop: 19, + }); + assert.strictEqual(child1.cid, data.children[0].cid); + assert.strictEqual(child2.cid, data.children[0].children[0].cid); + }); }); diff --git a/test/controllers-admin.js b/test/controllers-admin.js index bd66b0b..030ed0f 100644 --- a/test/controllers-admin.js +++ b/test/controllers-admin.js @@ -1,959 +1,972 @@ 'use strict'; +const assert = require('node:assert'); const async = require('async'); -const assert = require('assert'); const nconf = require('nconf'); const request = require('request'); - -const db = require('./mocks/databasemock'); const categories = require('../src/categories'); const topics = require('../src/topics'); const user = require('../src/user'); const groups = require('../src/groups'); -const helpers = require('./helpers'); const meta = require('../src/meta'); +const db = require('./mocks/databasemock'); +const helpers = require('./helpers'); describe('Admin Controllers', () => { - let tid; - let cid; - let pid; - let regularPid; - let adminUid; - let regularUid; - let regular2Uid; - let moderatorUid; - let jar; - - before((done) => { - async.series({ - category: function (next) { - categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - }, next); - }, - adminUid: function (next) { - user.create({ username: 'admin', password: 'barbar' }, next); - }, - regularUid: function (next) { - user.create({ username: 'regular', password: 'regularpwd' }, next); - }, - regular2Uid: function (next) { - user.create({ username: 'regular2' }, next); - }, - moderatorUid: function (next) { - user.create({ username: 'moderator', password: 'modmod' }, next); - }, - }, async (err, results) => { - if (err) { - return done(err); - } - adminUid = results.adminUid; - regularUid = results.regularUid; - regular2Uid = results.regular2Uid; - moderatorUid = results.moderatorUid; - cid = results.category.cid; - - const adminPost = await topics.post({ uid: adminUid, title: 'test topic title', content: 'test topic content', cid: results.category.cid }); - assert.ifError(err); - tid = adminPost.topicData.tid; - pid = adminPost.postData.pid; - - const regularPost = await topics.post({ uid: regular2Uid, title: 'regular user\'s test topic title', content: 'test topic content', cid: results.category.cid }); - regularPid = regularPost.postData.pid; - done(); - }); - }); - - it('should 403 if user is not admin', (done) => { - helpers.loginUser('admin', 'barbar', (err, data) => { - assert.ifError(err); - jar = data.jar; - request(`${nconf.get('url')}/admin`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 403); - assert(body); - done(); - }); - }); - }); - - it('should load admin dashboard', (done) => { - groups.join('administrators', adminUid, (err) => { - assert.ifError(err); - const dashboards = [ - '/admin', '/admin/dashboard/logins', '/admin/dashboard/users', '/admin/dashboard/topics', '/admin/dashboard/searches', - ]; - async.each(dashboards, (url, next) => { - request(`${nconf.get('url')}${url}`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200, url); - assert(body); - - next(); - }); - }, done); - }); - }); - - it('should load admin analytics', (done) => { - request(`${nconf.get('url')}/api/admin/analytics?units=hours`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - assert(body.query); - assert(body.result); - done(); - }); - }); - - it('should load groups page', (done) => { - request(`${nconf.get('url')}/admin/manage/groups`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load groups detail page', (done) => { - request(`${nconf.get('url')}/admin/manage/groups/administrators`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load global privileges page', (done) => { - request(`${nconf.get('url')}/admin/manage/privileges`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load admin privileges page', (done) => { - request(`${nconf.get('url')}/admin/manage/privileges/admin`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load privileges page for category 1', (done) => { - request(`${nconf.get('url')}/admin/manage/privileges/1`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load manage digests', (done) => { - request(`${nconf.get('url')}/admin/manage/digest`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load manage uploads', (done) => { - request(`${nconf.get('url')}/admin/manage/uploads`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load general settings page', (done) => { - request(`${nconf.get('url')}/admin/settings`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load email settings page', (done) => { - request(`${nconf.get('url')}/admin/settings/email`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load user settings page', (done) => { - request(`${nconf.get('url')}/admin/settings/user`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load info page for a user', (done) => { - request(`${nconf.get('url')}/api/user/regular/info`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body.history); - assert(Array.isArray(body.history.flags)); - assert(Array.isArray(body.history.bans)); - assert(Array.isArray(body.sessions)); - done(); - }); - }); - - it('should 404 for edit/email page if user does not exist', (done) => { - request(`${nconf.get('url')}/api/user/doesnotexist/edit/email`, { jar: jar, json: true }, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - done(); - }); - }); - - it('should load /admin/settings/homepage', (done) => { - request(`${nconf.get('url')}/api/admin/settings/homepage`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body.routes); - done(); - }); - }); - - it('should load /admin/advanced/database', (done) => { - request(`${nconf.get('url')}/api/admin/advanced/database`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - - if (nconf.get('redis')) { - assert(body.redis); - } else if (nconf.get('mongo')) { - assert(body.mongo); - } else if (nconf.get('postgres')) { - assert(body.postgres); - } - done(); - }); - }); - - it('should load /admin/extend/plugins', function (done) { - this.timeout(50000); - request(`${nconf.get('url')}/api/admin/extend/plugins`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert(body.hasOwnProperty('installed')); - assert(body.hasOwnProperty('upgradeCount')); - assert(body.hasOwnProperty('download')); - assert(body.hasOwnProperty('incompatible')); - done(); - }); - }); - - it('should load /admin/manage/users', (done) => { - request(`${nconf.get('url')}/api/admin/manage/users`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 200); - assert(body); - assert(body.users.length > 0); - done(); - }); - }); - - - it('should load /admin/manage/users?filters=banned', (done) => { - request(`${nconf.get('url')}/api/admin/manage/users?filters=banned`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 200); - assert(body); - assert.strictEqual(body.users.length, 0); - done(); - }); - }); - - it('should load /admin/manage/users?filters=banned&filters=verified', (done) => { - request(`${nconf.get('url')}/api/admin/manage/users?filters=banned&filters=verified`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 200); - assert(body); - assert.strictEqual(body.users.length, 0); - done(); - }); - }); - - it('should load /admin/manage/users?query=admin', (done) => { - request(`${nconf.get('url')}/api/admin/manage/users?query=admin`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 200); - assert(body); - assert.strictEqual(body.users[0].username, 'admin'); - done(); - }); - }); - - it('should return empty results if query is too short', (done) => { - request(`${nconf.get('url')}/api/admin/manage/users?query=a`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 200); - assert(body); - assert.strictEqual(body.users.length, 0); - done(); - }); - }); - - it('should load /admin/manage/registration', (done) => { - request(`${nconf.get('url')}/api/admin/manage/registration`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should 404 if users is not privileged', (done) => { - request(`${nconf.get('url')}/api/registration-queue`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - assert(body); - done(); - }); - }); - - it('should load /api/registration-queue', (done) => { - request(`${nconf.get('url')}/api/registration-queue`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /admin/manage/admins-mods', (done) => { - request(`${nconf.get('url')}/api/admin/manage/admins-mods`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /admin/users/csv', (done) => { - const socketAdmin = require('../src/socket.io/admin'); - socketAdmin.user.exportUsersCSV({ uid: adminUid }, {}, (err) => { - assert.ifError(err); - setTimeout(() => { - request(`${nconf.get('url')}/api/admin/users/csv`, { - jar: jar, - headers: { - referer: `${nconf.get('url')}/admin/manage/users`, - }, - }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }, 2000); - }); - }); - - it('should return 403 if no referer', (done) => { - request(`${nconf.get('url')}/api/admin/groups/administrators/csv`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 403); - assert.equal(body, '[[error:invalid-origin]]'); - done(); - }); - }); - - it('should return 403 if referer is not /api/admin/groups/administrators/csv', (done) => { - request(`${nconf.get('url')}/api/admin/groups/administrators/csv`, { - jar: jar, - headers: { - referer: '/topic/1/test', - }, - }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 403); - assert.equal(body, '[[error:invalid-origin]]'); - done(); - }); - }); - - it('should load /api/admin/groups/administrators/csv', (done) => { - request(`${nconf.get('url')}/api/admin/groups/administrators/csv`, { - jar: jar, - headers: { - referer: `${nconf.get('url')}/admin/manage/groups`, - }, - }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /admin/advanced/hooks', (done) => { - request(`${nconf.get('url')}/api/admin/advanced/hooks`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /admin/advanced/cache', (done) => { - request(`${nconf.get('url')}/api/admin/advanced/cache`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /api/admin/advanced/cache/dump and 404 with no query param', (done) => { - request(`${nconf.get('url')}/api/admin/advanced/cache/dump`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - assert(body); - done(); - }); - }); - - it('should load /api/admin/advanced/cache/dump', (done) => { - request(`${nconf.get('url')}/api/admin/advanced/cache/dump?name=post`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /admin/advanced/errors', (done) => { - request(`${nconf.get('url')}/api/admin/advanced/errors`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /admin/advanced/errors/export', (done) => { - meta.errors.clear((err) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/admin/advanced/errors/export`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.strictEqual(body, ''); - done(); - }); - }); - }); - - it('should load /admin/advanced/logs', (done) => { - const fs = require('fs'); - fs.appendFile(meta.logs.path, 'dummy log', (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/admin/advanced/logs`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - }); - - it('should load /admin/settings/navigation', (done) => { - const navigation = require('../src/navigation/admin'); - const data = require('../install/data/navigation.json'); - - navigation.save(data, (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/admin/settings/navigation`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert(body); - assert(body.available); - assert(body.enabled); - done(); - }); - }); - }); - - it('should load /admin/development/info', (done) => { - request(`${nconf.get('url')}/api/admin/development/info`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /admin/development/logger', (done) => { - request(`${nconf.get('url')}/api/admin/development/logger`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /admin/advanced/events', (done) => { - request(`${nconf.get('url')}/api/admin/advanced/events`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /admin/manage/categories', (done) => { - request(`${nconf.get('url')}/api/admin/manage/categories`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /admin/manage/categories/1', (done) => { - request(`${nconf.get('url')}/api/admin/manage/categories/1`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /admin/manage/catgories?cid=', async () => { - const { cid: rootCid } = await categories.create({ name: 'parent category' }); - const { cid: childCid } = await categories.create({ name: 'child category', parentCid: rootCid }); - const { res, body } = await helpers.request('get', `/api/admin/manage/categories?cid=${rootCid}`, { - jar: jar, - json: true, - }); - assert.strictEqual(res.statusCode, 200); - assert.strictEqual(body.categoriesTree[0].cid, rootCid); - assert.strictEqual(body.categoriesTree[0].children[0].cid, childCid); - assert.strictEqual(body.breadcrumbs[0].text, '[[admin/manage/categories:top-level]]'); - assert.strictEqual(body.breadcrumbs[1].text, 'parent category'); - }); - - it('should load /admin/manage/categories/1/analytics', (done) => { - request(`${nconf.get('url')}/api/admin/manage/categories/1/analytics`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /admin/extend/rewards', (done) => { - request(`${nconf.get('url')}/api/admin/extend/rewards`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /admin/extend/widgets', (done) => { - request(`${nconf.get('url')}/api/admin/extend/widgets`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /admin/settings/languages', (done) => { - request(`${nconf.get('url')}/api/admin/settings/languages`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /admin/settings/social', (done) => { - const socketAdmin = require('../src/socket.io/admin'); - socketAdmin.social.savePostSharingNetworks({ uid: adminUid }, ['facebook', 'twitter'], (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/admin/settings/social`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert(body); - body = body.posts.map(network => network && network.id); - assert(body.includes('facebook')); - assert(body.includes('twitter')); - done(); - }); - }); - }); - - it('should load /admin/manage/tags', (done) => { - request(`${nconf.get('url')}/api/admin/manage/tags`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('/post-queue should 404 for regular user', (done) => { - request(`${nconf.get('url')}/api/post-queue`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert(body); - assert.equal(res.statusCode, 404); - done(); - }); - }); - - it('should load /post-queue', (done) => { - request(`${nconf.get('url')}/api/post-queue`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('/ip-blacklist should 404 for regular user', (done) => { - request(`${nconf.get('url')}/api/ip-blacklist`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert(body); - assert.equal(res.statusCode, 404); - done(); - }); - }); - - it('should load /ip-blacklist', (done) => { - request(`${nconf.get('url')}/api/ip-blacklist`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /admin/appearance/themes', (done) => { - request(`${nconf.get('url')}/api/admin/appearance/themes`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /admin/appearance/customise', (done) => { - request(`${nconf.get('url')}/api/admin/appearance/customise`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /recent in maintenance mode', (done) => { - meta.config.maintenanceMode = 1; - request(`${nconf.get('url')}/api/recent`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - meta.config.maintenanceMode = 0; - done(); - }); - }); - - describe('mods page', () => { - let moderatorJar; - let regularJar; - before(async () => { - moderatorJar = (await helpers.loginUser('moderator', 'modmod')).jar; - regularJar = (await helpers.loginUser('regular', 'regularpwd')).jar; - await groups.join(`cid:${cid}:privileges:moderate`, moderatorUid); - }); - - it('should error with no privileges', (done) => { - request(`${nconf.get('url')}/api/flags`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.deepStrictEqual(body, { - status: { - code: 'not-authorised', - message: 'A valid login session was not found. Please log in and try again.', - }, - response: {}, - }); - done(); - }); - }); - - it('should load flags page data', (done) => { - request(`${nconf.get('url')}/api/flags`, { jar: moderatorJar, json: true }, (err, res, body) => { - assert.ifError(err); - assert(body); - assert(body.flags); - assert(body.filters); - assert.equal(body.filters.cid.indexOf(cid), -1); - done(); - }); - }); - - it('should return a 404 if flag does not exist', (done) => { - request(`${nconf.get('url')}/api/flags/123123123`, { - jar: moderatorJar, - json: true, - headers: { - Accept: 'text/html, application/json', - }, - }, (err, res, body) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 404); - done(); - }); - }); - - it('should error when you attempt to flag a privileged user\'s post', async () => { - const { res, body } = await helpers.request('post', '/api/v3/flags', { - json: true, - jar: regularJar, - form: { - id: pid, - type: 'post', - reason: 'spam', - }, - }); - assert.strictEqual(res.statusCode, 400); - assert.strictEqual(body.status.code, 'bad-request'); - assert.strictEqual(body.status.message, 'You are not allowed to flag the profiles or content of privileged users (moderators/global moderators/admins)'); - }); - - it('should error with not enough reputation to flag', async () => { - const oldValue = meta.config['min:rep:flag']; - meta.config['min:rep:flag'] = 1000; - const { res, body } = await helpers.request('post', '/api/v3/flags', { - json: true, - jar: regularJar, - form: { - id: regularPid, - type: 'post', - reason: 'spam', - }, - }); - assert.strictEqual(res.statusCode, 400); - assert.strictEqual(body.status.code, 'bad-request'); - assert.strictEqual(body.status.message, 'You need 1000 reputation to flag this post'); - - meta.config['min:rep:flag'] = oldValue; - }); - - it('should return flag details', async () => { - const oldValue = meta.config['min:rep:flag']; - meta.config['min:rep:flag'] = 0; - const result = await helpers.request('post', '/api/v3/flags', { - json: true, - jar: regularJar, - form: { - id: regularPid, - type: 'post', - reason: 'spam', - }, - }); - meta.config['min:rep:flag'] = oldValue; - - const flagsResult = await helpers.request('get', `/api/flags`, { - json: true, - jar: moderatorJar, - }); - - assert(flagsResult.body); - assert(Array.isArray(flagsResult.body.flags)); - const { flagId } = flagsResult.body.flags[0]; - - const { body } = await helpers.request('get', `/api/flags/${flagId}`, { - json: true, - jar: moderatorJar, - }); - assert(body.reports); - assert(Array.isArray(body.reports)); - assert.strictEqual(body.reports[0].reporter.username, 'regular'); - }); - }); - - it('should escape special characters in config', (done) => { - const plugins = require('../src/plugins'); - function onConfigGet(config, callback) { - config.someValue = '"foo"'; - config.otherValue = "'123'"; - config.script = ''; - callback(null, config); - } - plugins.hooks.register('somePlugin', { hook: 'filter:config.get', method: onConfigGet }); - request(`${nconf.get('url')}/admin`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - assert(body.includes('"someValue":"\\\\"foo\\\\""')); - assert(body.includes('"otherValue":"\\\'123\\\'"')); - assert(body.includes('"script":"<\\/script>"')); - request(nconf.get('url'), { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - assert(body.includes('"someValue":"\\\\"foo\\\\""')); - assert(body.includes('"otherValue":"\\\'123\\\'"')); - assert(body.includes('"script":"<\\/script>"')); - plugins.hooks.unregister('somePlugin', 'filter:config.get', onConfigGet); - done(); - }); - }); - }); - - describe('admin page privileges', () => { - let userJar; - let uid; - const privileges = require('../src/privileges'); - before(async () => { - uid = await user.create({ username: 'regularjoe', password: 'barbar' }); - userJar = (await helpers.loginUser('regularjoe', 'barbar')).jar; - }); - - describe('routeMap parsing', () => { - it('should allow normal user access to admin pages', async function () { - this.timeout(50000); - function makeRequest(url) { - return new Promise((resolve, reject) => { - request(url, { jar: userJar, json: true }, (err, res, body) => { - if (err) reject(err); - else resolve(res); - }); - }); - } - const uploadRoutes = [ - 'category/uploadpicture', - 'uploadfavicon', - 'uploadTouchIcon', - 'uploadMaskableIcon', - 'uploadlogo', - 'uploadOgImage', - 'uploadDefaultAvatar', - ]; - const adminRoutes = Object.keys(privileges.admin.routeMap) - .filter(route => !uploadRoutes.includes(route)); - for (const route of adminRoutes) { - /* eslint-disable no-await-in-loop */ - await privileges.admin.rescind([privileges.admin.routeMap[route]], uid); - let res = await makeRequest(`${nconf.get('url')}/api/admin/${route}`); - assert.strictEqual(res.statusCode, 403); - - await privileges.admin.give([privileges.admin.routeMap[route]], uid); - res = await makeRequest(`${nconf.get('url')}/api/admin/${route}`); - assert.strictEqual(res.statusCode, 200); - - await privileges.admin.rescind([privileges.admin.routeMap[route]], uid); - } - - for (const route of adminRoutes) { - /* eslint-disable no-await-in-loop */ - await privileges.admin.rescind([privileges.admin.routeMap[route]], uid); - let res = await makeRequest(`${nconf.get('url')}/api/admin`); - assert.strictEqual(res.statusCode, 403); - - await privileges.admin.give([privileges.admin.routeMap[route]], uid); - res = await makeRequest(`${nconf.get('url')}/api/admin`); - assert.strictEqual(res.statusCode, 200); - - await privileges.admin.rescind([privileges.admin.routeMap[route]], uid); - } - }); - }); - - describe('routePrefixMap parsing', () => { - it('should allow normal user access to admin pages', async () => { - // this.timeout(50000); - function makeRequest(url) { - return new Promise((resolve, reject) => { - request(url, { jar: userJar, json: true }, (err, res, body) => { - if (err) reject(err); - else resolve(res); - }); - }); - } - for (const route of Object.keys(privileges.admin.routePrefixMap)) { - /* eslint-disable no-await-in-loop */ - await privileges.admin.rescind([privileges.admin.routePrefixMap[route]], uid); - let res = await makeRequest(`${nconf.get('url')}/api/admin/${route}foobar/derp`); - assert.strictEqual(res.statusCode, 403); - - await privileges.admin.give([privileges.admin.routePrefixMap[route]], uid); - res = await makeRequest(`${nconf.get('url')}/api/admin/${route}foobar/derp`); - assert.strictEqual(res.statusCode, 404); - - await privileges.admin.rescind([privileges.admin.routePrefixMap[route]], uid); - } - }); - }); - - it('should list all admin privileges', async () => { - const privs = await privileges.admin.getPrivilegeList(); - assert.deepStrictEqual(privs, [ - 'admin:dashboard', - 'admin:categories', - 'admin:privileges', - 'admin:admins-mods', - 'admin:users', - 'admin:groups', - 'admin:tags', - 'admin:settings', - 'groups:admin:dashboard', - 'groups:admin:categories', - 'groups:admin:privileges', - 'groups:admin:admins-mods', - 'groups:admin:users', - 'groups:admin:groups', - 'groups:admin:tags', - 'groups:admin:settings', - ]); - }); - it('should list user admin privileges', async () => { - const privs = await privileges.admin.userPrivileges(adminUid); - assert.deepStrictEqual(privs, { - 'admin:dashboard': false, - 'admin:categories': false, - 'admin:privileges': false, - 'admin:admins-mods': false, - 'admin:users': false, - 'admin:groups': false, - 'admin:tags': false, - 'admin:settings': false, - }); - }); - - it('should check if group has admin group privilege', async () => { - await groups.create({ name: 'some-special-group', private: 1, hidden: 1 }); - await privileges.admin.give(['groups:admin:users', 'groups:admin:groups'], 'some-special-group'); - const can = await privileges.admin.canGroup('admin:users', 'some-special-group'); - assert.strictEqual(can, true); - const privs = await privileges.admin.groupPrivileges('some-special-group'); - assert.deepStrictEqual(privs, { - 'groups:admin:dashboard': false, - 'groups:admin:categories': false, - 'groups:admin:privileges': false, - 'groups:admin:admins-mods': false, - 'groups:admin:users': true, - 'groups:admin:groups': true, - 'groups:admin:tags': false, - 'groups:admin:settings': false, - }); - }); - - it('should not have admin:privileges', async () => { - const res = await privileges.admin.list(regularUid); - assert.strictEqual(res.keys.users.includes('admin:privileges'), false); - assert.strictEqual(res.keys.groups.includes('admin:privileges'), false); - }); - }); + let tid; + let cid; + let pid; + let regularPid; + let adminUid; + let regularUid; + let regular2Uid; + let moderatorUid; + let jar; + + before(done => { + async.series({ + category(next) { + categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + }, next); + }, + adminUid(next) { + user.create({username: 'admin', password: 'barbar'}, next); + }, + regularUid(next) { + user.create({username: 'regular', password: 'regularpwd'}, next); + }, + regular2Uid(next) { + user.create({username: 'regular2'}, next); + }, + moderatorUid(next) { + user.create({username: 'moderator', password: 'modmod'}, next); + }, + }, async (error, results) => { + if (error) { + return done(error); + } + + adminUid = results.adminUid; + regularUid = results.regularUid; + regular2Uid = results.regular2Uid; + moderatorUid = results.moderatorUid; + cid = results.category.cid; + + const adminPost = await topics.post({ + uid: adminUid, title: 'test topic title', content: 'test topic content', cid: results.category.cid, + }); + assert.ifError(error); + tid = adminPost.topicData.tid; + pid = adminPost.postData.pid; + + const regularPost = await topics.post({ + uid: regular2Uid, title: 'regular user\'s test topic title', content: 'test topic content', cid: results.category.cid, + }); + regularPid = regularPost.postData.pid; + done(); + }); + }); + + it('should 403 if user is not admin', done => { + helpers.loginUser('admin', 'barbar', (error, data) => { + assert.ifError(error); + jar = data.jar; + request(`${nconf.get('url')}/admin`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 403); + assert(body); + done(); + }); + }); + }); + + it('should load admin dashboard', done => { + groups.join('administrators', adminUid, error => { + assert.ifError(error); + const dashboards = [ + '/admin', '/admin/dashboard/logins', '/admin/dashboard/users', '/admin/dashboard/topics', '/admin/dashboard/searches', + ]; + async.each(dashboards, (url, next) => { + request(`${nconf.get('url')}${url}`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200, url); + assert(body); + + next(); + }); + }, done); + }); + }); + + it('should load admin analytics', done => { + request(`${nconf.get('url')}/api/admin/analytics?units=hours`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + assert(body.query); + assert(body.result); + done(); + }); + }); + + it('should load groups page', done => { + request(`${nconf.get('url')}/admin/manage/groups`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load groups detail page', done => { + request(`${nconf.get('url')}/admin/manage/groups/administrators`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load global privileges page', done => { + request(`${nconf.get('url')}/admin/manage/privileges`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load admin privileges page', done => { + request(`${nconf.get('url')}/admin/manage/privileges/admin`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load privileges page for category 1', done => { + request(`${nconf.get('url')}/admin/manage/privileges/1`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load manage digests', done => { + request(`${nconf.get('url')}/admin/manage/digest`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load manage uploads', done => { + request(`${nconf.get('url')}/admin/manage/uploads`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load general settings page', done => { + request(`${nconf.get('url')}/admin/settings`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load email settings page', done => { + request(`${nconf.get('url')}/admin/settings/email`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load user settings page', done => { + request(`${nconf.get('url')}/admin/settings/user`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load info page for a user', done => { + request(`${nconf.get('url')}/api/user/regular/info`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body.history); + assert(Array.isArray(body.history.flags)); + assert(Array.isArray(body.history.bans)); + assert(Array.isArray(body.sessions)); + done(); + }); + }); + + it('should 404 for edit/email page if user does not exist', done => { + request(`${nconf.get('url')}/api/user/doesnotexist/edit/email`, {jar, json: true}, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + done(); + }); + }); + + it('should load /admin/settings/homepage', done => { + request(`${nconf.get('url')}/api/admin/settings/homepage`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body.routes); + done(); + }); + }); + + it('should load /admin/advanced/database', done => { + request(`${nconf.get('url')}/api/admin/advanced/database`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + + if (nconf.get('redis')) { + assert(body.redis); + } else if (nconf.get('mongo')) { + assert(body.mongo); + } else if (nconf.get('postgres')) { + assert(body.postgres); + } + + done(); + }); + }); + + it('should load /admin/extend/plugins', function (done) { + this.timeout(50_000); + request(`${nconf.get('url')}/api/admin/extend/plugins`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert(body.hasOwnProperty('installed')); + assert(body.hasOwnProperty('upgradeCount')); + assert(body.hasOwnProperty('download')); + assert(body.hasOwnProperty('incompatible')); + done(); + }); + }); + + it('should load /admin/manage/users', done => { + request(`${nconf.get('url')}/api/admin/manage/users`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.strictEqual(res.statusCode, 200); + assert(body); + assert(body.users.length > 0); + done(); + }); + }); + + it('should load /admin/manage/users?filters=banned', done => { + request(`${nconf.get('url')}/api/admin/manage/users?filters=banned`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.strictEqual(res.statusCode, 200); + assert(body); + assert.strictEqual(body.users.length, 0); + done(); + }); + }); + + it('should load /admin/manage/users?filters=banned&filters=verified', done => { + request(`${nconf.get('url')}/api/admin/manage/users?filters=banned&filters=verified`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.strictEqual(res.statusCode, 200); + assert(body); + assert.strictEqual(body.users.length, 0); + done(); + }); + }); + + it('should load /admin/manage/users?query=admin', done => { + request(`${nconf.get('url')}/api/admin/manage/users?query=admin`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.strictEqual(res.statusCode, 200); + assert(body); + assert.strictEqual(body.users[0].username, 'admin'); + done(); + }); + }); + + it('should return empty results if query is too short', done => { + request(`${nconf.get('url')}/api/admin/manage/users?query=a`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.strictEqual(res.statusCode, 200); + assert(body); + assert.strictEqual(body.users.length, 0); + done(); + }); + }); + + it('should load /admin/manage/registration', done => { + request(`${nconf.get('url')}/api/admin/manage/registration`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should 404 if users is not privileged', done => { + request(`${nconf.get('url')}/api/registration-queue`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + assert(body); + done(); + }); + }); + + it('should load /api/registration-queue', done => { + request(`${nconf.get('url')}/api/registration-queue`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /admin/manage/admins-mods', done => { + request(`${nconf.get('url')}/api/admin/manage/admins-mods`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /admin/users/csv', done => { + const socketAdmin = require('../src/socket.io/admin'); + socketAdmin.user.exportUsersCSV({uid: adminUid}, {}, error => { + assert.ifError(error); + setTimeout(() => { + request(`${nconf.get('url')}/api/admin/users/csv`, { + jar, + headers: { + referer: `${nconf.get('url')}/admin/manage/users`, + }, + }, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }, 2000); + }); + }); + + it('should return 403 if no referer', done => { + request(`${nconf.get('url')}/api/admin/groups/administrators/csv`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 403); + assert.equal(body, '[[error:invalid-origin]]'); + done(); + }); + }); + + it('should return 403 if referer is not /api/admin/groups/administrators/csv', done => { + request(`${nconf.get('url')}/api/admin/groups/administrators/csv`, { + jar, + headers: { + referer: '/topic/1/test', + }, + }, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 403); + assert.equal(body, '[[error:invalid-origin]]'); + done(); + }); + }); + + it('should load /api/admin/groups/administrators/csv', done => { + request(`${nconf.get('url')}/api/admin/groups/administrators/csv`, { + jar, + headers: { + referer: `${nconf.get('url')}/admin/manage/groups`, + }, + }, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /admin/advanced/hooks', done => { + request(`${nconf.get('url')}/api/admin/advanced/hooks`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /admin/advanced/cache', done => { + request(`${nconf.get('url')}/api/admin/advanced/cache`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /api/admin/advanced/cache/dump and 404 with no query param', done => { + request(`${nconf.get('url')}/api/admin/advanced/cache/dump`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + assert(body); + done(); + }); + }); + + it('should load /api/admin/advanced/cache/dump', done => { + request(`${nconf.get('url')}/api/admin/advanced/cache/dump?name=post`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /admin/advanced/errors', done => { + request(`${nconf.get('url')}/api/admin/advanced/errors`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /admin/advanced/errors/export', done => { + meta.errors.clear(error => { + assert.ifError(error); + request(`${nconf.get('url')}/api/admin/advanced/errors/export`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.strictEqual(body, ''); + done(); + }); + }); + }); + + it('should load /admin/advanced/logs', done => { + const fs = require('node:fs'); + fs.appendFile(meta.logs.path, 'dummy log', error => { + assert.ifError(error); + request(`${nconf.get('url')}/api/admin/advanced/logs`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + }); + + it('should load /admin/settings/navigation', done => { + const navigation = require('../src/navigation/admin'); + const data = require('../install/data/navigation.json'); + + navigation.save(data, error => { + assert.ifError(error); + request(`${nconf.get('url')}/api/admin/settings/navigation`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert(body); + assert(body.available); + assert(body.enabled); + done(); + }); + }); + }); + + it('should load /admin/development/info', done => { + request(`${nconf.get('url')}/api/admin/development/info`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /admin/development/logger', done => { + request(`${nconf.get('url')}/api/admin/development/logger`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /admin/advanced/events', done => { + request(`${nconf.get('url')}/api/admin/advanced/events`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /admin/manage/categories', done => { + request(`${nconf.get('url')}/api/admin/manage/categories`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /admin/manage/categories/1', done => { + request(`${nconf.get('url')}/api/admin/manage/categories/1`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /admin/manage/catgories?cid=', async () => { + const {cid: rootCid} = await categories.create({name: 'parent category'}); + const {cid: childCid} = await categories.create({name: 'child category', parentCid: rootCid}); + const {res, body} = await helpers.request('get', `/api/admin/manage/categories?cid=${rootCid}`, { + jar, + json: true, + }); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(body.categoriesTree[0].cid, rootCid); + assert.strictEqual(body.categoriesTree[0].children[0].cid, childCid); + assert.strictEqual(body.breadcrumbs[0].text, '[[admin/manage/categories:top-level]]'); + assert.strictEqual(body.breadcrumbs[1].text, 'parent category'); + }); + + it('should load /admin/manage/categories/1/analytics', done => { + request(`${nconf.get('url')}/api/admin/manage/categories/1/analytics`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /admin/extend/rewards', done => { + request(`${nconf.get('url')}/api/admin/extend/rewards`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /admin/extend/widgets', done => { + request(`${nconf.get('url')}/api/admin/extend/widgets`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /admin/settings/languages', done => { + request(`${nconf.get('url')}/api/admin/settings/languages`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /admin/settings/social', done => { + const socketAdmin = require('../src/socket.io/admin'); + socketAdmin.social.savePostSharingNetworks({uid: adminUid}, ['facebook', 'twitter'], error => { + assert.ifError(error); + request(`${nconf.get('url')}/api/admin/settings/social`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert(body); + body = body.posts.map(network => network && network.id); + assert(body.includes('facebook')); + assert(body.includes('twitter')); + done(); + }); + }); + }); + + it('should load /admin/manage/tags', done => { + request(`${nconf.get('url')}/api/admin/manage/tags`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('/post-queue should 404 for regular user', done => { + request(`${nconf.get('url')}/api/post-queue`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert(body); + assert.equal(res.statusCode, 404); + done(); + }); + }); + + it('should load /post-queue', done => { + request(`${nconf.get('url')}/api/post-queue`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('/ip-blacklist should 404 for regular user', done => { + request(`${nconf.get('url')}/api/ip-blacklist`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert(body); + assert.equal(res.statusCode, 404); + done(); + }); + }); + + it('should load /ip-blacklist', done => { + request(`${nconf.get('url')}/api/ip-blacklist`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /admin/appearance/themes', done => { + request(`${nconf.get('url')}/api/admin/appearance/themes`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /admin/appearance/customise', done => { + request(`${nconf.get('url')}/api/admin/appearance/customise`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /recent in maintenance mode', done => { + meta.config.maintenanceMode = 1; + request(`${nconf.get('url')}/api/recent`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + meta.config.maintenanceMode = 0; + done(); + }); + }); + + describe('mods page', () => { + let moderatorJar; + let regularJar; + before(async () => { + moderatorJar = (await helpers.loginUser('moderator', 'modmod')).jar; + regularJar = (await helpers.loginUser('regular', 'regularpwd')).jar; + await groups.join(`cid:${cid}:privileges:moderate`, moderatorUid); + }); + + it('should error with no privileges', done => { + request(`${nconf.get('url')}/api/flags`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.deepStrictEqual(body, { + status: { + code: 'not-authorised', + message: 'A valid login session was not found. Please log in and try again.', + }, + response: {}, + }); + done(); + }); + }); + + it('should load flags page data', done => { + request(`${nconf.get('url')}/api/flags`, {jar: moderatorJar, json: true}, (error, res, body) => { + assert.ifError(error); + assert(body); + assert(body.flags); + assert(body.filters); + assert.equal(body.filters.cid.indexOf(cid), -1); + done(); + }); + }); + + it('should return a 404 if flag does not exist', done => { + request(`${nconf.get('url')}/api/flags/123123123`, { + jar: moderatorJar, + json: true, + headers: { + Accept: 'text/html, application/json', + }, + }, (error, res, body) => { + assert.ifError(error); + assert.strictEqual(res.statusCode, 404); + done(); + }); + }); + + it('should error when you attempt to flag a privileged user\'s post', async () => { + const {res, body} = await helpers.request('post', '/api/v3/flags', { + json: true, + jar: regularJar, + form: { + id: pid, + type: 'post', + reason: 'spam', + }, + }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(body.status.code, 'bad-request'); + assert.strictEqual(body.status.message, 'You are not allowed to flag the profiles or content of privileged users (moderators/global moderators/admins)'); + }); + + it('should error with not enough reputation to flag', async () => { + const oldValue = meta.config['min:rep:flag']; + meta.config['min:rep:flag'] = 1000; + const {res, body} = await helpers.request('post', '/api/v3/flags', { + json: true, + jar: regularJar, + form: { + id: regularPid, + type: 'post', + reason: 'spam', + }, + }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(body.status.code, 'bad-request'); + assert.strictEqual(body.status.message, 'You need 1000 reputation to flag this post'); + + meta.config['min:rep:flag'] = oldValue; + }); + + it('should return flag details', async () => { + const oldValue = meta.config['min:rep:flag']; + meta.config['min:rep:flag'] = 0; + const result = await helpers.request('post', '/api/v3/flags', { + json: true, + jar: regularJar, + form: { + id: regularPid, + type: 'post', + reason: 'spam', + }, + }); + meta.config['min:rep:flag'] = oldValue; + + const flagsResult = await helpers.request('get', '/api/flags', { + json: true, + jar: moderatorJar, + }); + + assert(flagsResult.body); + assert(Array.isArray(flagsResult.body.flags)); + const {flagId} = flagsResult.body.flags[0]; + + const {body} = await helpers.request('get', `/api/flags/${flagId}`, { + json: true, + jar: moderatorJar, + }); + assert(body.reports); + assert(Array.isArray(body.reports)); + assert.strictEqual(body.reports[0].reporter.username, 'regular'); + }); + }); + + it('should escape special characters in config', done => { + const plugins = require('../src/plugins'); + function onConfigGet(config, callback) { + config.someValue = '"foo"'; + config.otherValue = '\'123\''; + config.script = ''; + callback(null, config); + } + + plugins.hooks.register('somePlugin', {hook: 'filter:config.get', method: onConfigGet}); + request(`${nconf.get('url')}/admin`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + assert(body.includes('"someValue":"\\\\"foo\\\\""')); + assert(body.includes('"otherValue":"\\\'123\\\'"')); + assert(body.includes('"script":"<\\/script>"')); + request(nconf.get('url'), {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + assert(body.includes('"someValue":"\\\\"foo\\\\""')); + assert(body.includes('"otherValue":"\\\'123\\\'"')); + assert(body.includes('"script":"<\\/script>"')); + plugins.hooks.unregister('somePlugin', 'filter:config.get', onConfigGet); + done(); + }); + }); + }); + + describe('admin page privileges', () => { + let userJar; + let uid; + const privileges = require('../src/privileges'); + before(async () => { + uid = await user.create({username: 'regularjoe', password: 'barbar'}); + userJar = (await helpers.loginUser('regularjoe', 'barbar')).jar; + }); + + describe('routeMap parsing', () => { + it('should allow normal user access to admin pages', async function () { + this.timeout(50_000); + function makeRequest(url) { + return new Promise((resolve, reject) => { + request(url, {jar: userJar, json: true}, (error, res, body) => { + if (error) { + reject(error); + } else { + resolve(res); + } + }); + }); + } + + const uploadRoutes = new Set([ + 'category/uploadpicture', + 'uploadfavicon', + 'uploadTouchIcon', + 'uploadMaskableIcon', + 'uploadlogo', + 'uploadOgImage', + 'uploadDefaultAvatar', + ]); + const adminRoutes = Object.keys(privileges.admin.routeMap) + .filter(route => !uploadRoutes.has(route)); + for (const route of adminRoutes) { + /* eslint-disable no-await-in-loop */ + await privileges.admin.rescind([privileges.admin.routeMap[route]], uid); + let res = await makeRequest(`${nconf.get('url')}/api/admin/${route}`); + assert.strictEqual(res.statusCode, 403); + + await privileges.admin.give([privileges.admin.routeMap[route]], uid); + res = await makeRequest(`${nconf.get('url')}/api/admin/${route}`); + assert.strictEqual(res.statusCode, 200); + + await privileges.admin.rescind([privileges.admin.routeMap[route]], uid); + } + + for (const route of adminRoutes) { + /* eslint-disable no-await-in-loop */ + await privileges.admin.rescind([privileges.admin.routeMap[route]], uid); + let res = await makeRequest(`${nconf.get('url')}/api/admin`); + assert.strictEqual(res.statusCode, 403); + + await privileges.admin.give([privileges.admin.routeMap[route]], uid); + res = await makeRequest(`${nconf.get('url')}/api/admin`); + assert.strictEqual(res.statusCode, 200); + + await privileges.admin.rescind([privileges.admin.routeMap[route]], uid); + } + }); + }); + + describe('routePrefixMap parsing', () => { + it('should allow normal user access to admin pages', async () => { + // This.timeout(50000); + function makeRequest(url) { + return new Promise((resolve, reject) => { + request(url, {jar: userJar, json: true}, (error, res, body) => { + if (error) { + reject(error); + } else { + resolve(res); + } + }); + }); + } + + for (const route of Object.keys(privileges.admin.routePrefixMap)) { + /* eslint-disable no-await-in-loop */ + await privileges.admin.rescind([privileges.admin.routePrefixMap[route]], uid); + let res = await makeRequest(`${nconf.get('url')}/api/admin/${route}foobar/derp`); + assert.strictEqual(res.statusCode, 403); + + await privileges.admin.give([privileges.admin.routePrefixMap[route]], uid); + res = await makeRequest(`${nconf.get('url')}/api/admin/${route}foobar/derp`); + assert.strictEqual(res.statusCode, 404); + + await privileges.admin.rescind([privileges.admin.routePrefixMap[route]], uid); + } + }); + }); + + it('should list all admin privileges', async () => { + const privs = await privileges.admin.getPrivilegeList(); + assert.deepStrictEqual(privs, [ + 'admin:dashboard', + 'admin:categories', + 'admin:privileges', + 'admin:admins-mods', + 'admin:users', + 'admin:groups', + 'admin:tags', + 'admin:settings', + 'groups:admin:dashboard', + 'groups:admin:categories', + 'groups:admin:privileges', + 'groups:admin:admins-mods', + 'groups:admin:users', + 'groups:admin:groups', + 'groups:admin:tags', + 'groups:admin:settings', + ]); + }); + it('should list user admin privileges', async () => { + const privs = await privileges.admin.userPrivileges(adminUid); + assert.deepStrictEqual(privs, { + 'admin:dashboard': false, + 'admin:categories': false, + 'admin:privileges': false, + 'admin:admins-mods': false, + 'admin:users': false, + 'admin:groups': false, + 'admin:tags': false, + 'admin:settings': false, + }); + }); + + it('should check if group has admin group privilege', async () => { + await groups.create({name: 'some-special-group', private: 1, hidden: 1}); + await privileges.admin.give(['groups:admin:users', 'groups:admin:groups'], 'some-special-group'); + const can = await privileges.admin.canGroup('admin:users', 'some-special-group'); + assert.strictEqual(can, true); + const privs = await privileges.admin.groupPrivileges('some-special-group'); + assert.deepStrictEqual(privs, { + 'groups:admin:dashboard': false, + 'groups:admin:categories': false, + 'groups:admin:privileges': false, + 'groups:admin:admins-mods': false, + 'groups:admin:users': true, + 'groups:admin:groups': true, + 'groups:admin:tags': false, + 'groups:admin:settings': false, + }); + }); + + it('should not have admin:privileges', async () => { + const res = await privileges.admin.list(regularUid); + assert.strictEqual(res.keys.users.includes('admin:privileges'), false); + assert.strictEqual(res.keys.groups.includes('admin:privileges'), false); + }); + }); }); diff --git a/test/controllers.js b/test/controllers.js index 22d3d35..027813d 100644 --- a/test/controllers.js +++ b/test/controllers.js @@ -1,14 +1,12 @@ 'use strict'; -const async = require('async'); -const assert = require('assert'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); const nconf = require('nconf'); const request = require('request'); const requestAsync = require('request-promise-native'); -const fs = require('fs'); -const path = require('path'); - -const db = require('./mocks/databasemock'); +const async = require('async'); const categories = require('../src/categories'); const topics = require('../src/topics'); const posts = require('../src/posts'); @@ -19,2587 +17,2604 @@ const translator = require('../src/translator'); const privileges = require('../src/privileges'); const plugins = require('../src/plugins'); const utils = require('../src/utils'); +const db = require('./mocks/databasemock'); const helpers = require('./helpers'); describe('Controllers', () => { - let tid; - let cid; - let pid; - let fooUid; - let adminUid; - let category; - - before(async () => { - category = await categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - }); - cid = category.cid; - - fooUid = await user.create({ username: 'foo', password: 'barbar', gdpr_consent: true }); - await user.setUserField(fooUid, 'email', 'foo@test.com'); - await user.email.confirmByUid(fooUid); - - adminUid = await user.create({ username: 'admin', password: 'barbar', gdpr_consent: true }); - await groups.join('administrators', adminUid); - - const navigation = require('../src/navigation/admin'); - const data = require('../install/data/navigation.json'); - - await navigation.save(data); - - const result = await topics.post({ uid: fooUid, title: 'test topic title', content: 'test topic content', cid: cid }); - tid = result.topicData.tid; - pid = result.postData.pid; - }); - - it('should load /config with csrf_token', (done) => { - request({ - url: `${nconf.get('url')}/api/config`, - json: true, - }, (err, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 200); - assert(body.csrf_token); - done(); - }); - }); - - it('should load /config with no csrf_token as spider', (done) => { - request({ - url: `${nconf.get('url')}/api/config`, - json: true, - headers: { - 'user-agent': 'yandex', - }, - }, (err, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 200); - assert.strictEqual(body.csrf_token, false); - assert.strictEqual(body.uid, -1); - assert.strictEqual(body.loggedIn, false); - done(); - }); - }); - - describe('homepage', () => { - function hookMethod(hookData) { - assert(hookData.req); - assert(hookData.res); - assert(hookData.next); - - hookData.res.render('mycustompage', { - works: true, - }); - } - const message = utils.generateUUID(); - const name = 'mycustompage.tpl'; - const tplPath = path.join(nconf.get('views_dir'), name); - - before(async () => { - plugins.hooks.register('myTestPlugin', { - hook: 'action:homepage.get:mycustompage', - method: hookMethod, - }); - - fs.writeFileSync(tplPath, message); - await meta.templates.compileTemplate(name, message); - }); - - it('should load default', (done) => { - request(nconf.get('url'), (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load unread', (done) => { - meta.configs.set('homePageRoute', 'unread', (err) => { - assert.ifError(err); - - request(nconf.get('url'), (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - }); - - it('should load recent', (done) => { - meta.configs.set('homePageRoute', 'recent', (err) => { - assert.ifError(err); - - request(nconf.get('url'), (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - }); - - it('should load top', (done) => { - meta.configs.set('homePageRoute', 'top', (err) => { - assert.ifError(err); - - request(nconf.get('url'), (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - }); - - it('should load popular', (done) => { - meta.configs.set('homePageRoute', 'popular', (err) => { - assert.ifError(err); - - request(nconf.get('url'), (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - }); - - it('should load category', (done) => { - meta.configs.set('homePageRoute', 'category/1/test-category', (err) => { - assert.ifError(err); - - request(nconf.get('url'), (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - }); - - it('should not load breadcrumbs on home page route', (done) => { - request(`${nconf.get('url')}/api`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - assert(!body.breadcrumbs); - done(); - }); - }); - - it('should redirect to custom', (done) => { - meta.configs.set('homePageRoute', 'groups', (err) => { - assert.ifError(err); - - request(nconf.get('url'), (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - }); - - it('should 404 if custom does not exist', (done) => { - meta.configs.set('homePageRoute', 'this-route-does-not-exist', (err) => { - assert.ifError(err); - - request(nconf.get('url'), (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - assert(body); - done(); - }); - }); - }); - - it('api should work with hook', (done) => { - meta.configs.set('homePageRoute', 'mycustompage', (err) => { - assert.ifError(err); - - request(`${nconf.get('url')}/api`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(body.works, true); - assert.equal(body.template.mycustompage, true); - - done(); - }); - }); - }); - - it('should render with hook', (done) => { - meta.configs.set('homePageRoute', 'mycustompage', (err) => { - assert.ifError(err); - - request(nconf.get('url'), (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.ok(body); - assert.ok(body.indexOf('
    { - plugins.hooks.unregister('myTestPlugin', 'action:homepage.get:custom', hookMethod); - fs.unlinkSync(tplPath); - fs.unlinkSync(tplPath.replace(/\.tpl$/, '.js')); - }); - }); - - it('should load /reset without code', (done) => { - request(`${nconf.get('url')}/reset`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /reset with invalid code', (done) => { - request(`${nconf.get('url')}/reset/123123`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /login', (done) => { - request(`${nconf.get('url')}/login`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /register', (done) => { - request(`${nconf.get('url')}/register`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /register/complete', (done) => { - const data = { - username: 'interstitial', - password: '123456', - 'password-confirm': '123456', - 'account-type': 'student', - email: 'test@me.com', - }; - - const jar = request.jar(); - request({ - url: `${nconf.get('url')}/api/config`, - json: true, - jar: jar, - }, (err, response, body) => { - assert.ifError(err); - - request.post(`${nconf.get('url')}/register`, { - form: data, - json: true, - jar: jar, - headers: { - 'x-csrf-token': body.csrf_token, - }, - }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.strictEqual(body.next, `${nconf.get('relative_path')}/register/complete`); - request(`${nconf.get('url')}/api/register/complete`, { - jar: jar, - json: true, - }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body.sections); - assert(body.errors); - assert(body.title); - done(); - }); - }); - }); - }); - - describe('registration interstitials', () => { - describe('email update', () => { - let jar; - let token; - const dummyEmailerHook = async (data) => {}; - - before(async () => { - // Attach an emailer hook so related requests do not error - plugins.hooks.register('emailer-test', { - hook: 'filter:email.send', - method: dummyEmailerHook, - }); - - jar = await helpers.registerUser({ - username: utils.generateUUID().slice(0, 10), - password: utils.generateUUID(), - 'account-type': 'student', - }); - token = await helpers.getCsrfToken(jar); - - meta.config.requireEmailAddress = 1; - }); - - after(() => { - meta.config.requireEmailAddress = 0; - plugins.hooks.unregister('emailer-test', 'filter:email.send'); - }); - - it('email interstitial should still apply if empty email entered and requireEmailAddress is enabled', async () => { - let res = await requestAsync(`${nconf.get('url')}/register/complete`, { - method: 'post', - jar, - json: true, - followRedirect: false, - simple: false, - resolveWithFullResponse: true, - headers: { - 'x-csrf-token': token, - }, - form: { - email: '', - }, - }); - - assert.strictEqual(res.headers.location, `${nconf.get('relative_path')}/register/complete`); - - res = await requestAsync(`${nconf.get('url')}/api/register/complete`, { - jar, - json: true, - resolveWithFullResponse: true, - }); - assert.strictEqual(res.statusCode, 200); - assert(res.body.errors.length); - assert(res.body.errors.includes('[[error:invalid-email]]')); - }); - - it('gdpr interstitial should still apply if email requirement is disabled', async () => { - meta.config.requireEmailAddress = 0; - - const res = await requestAsync(`${nconf.get('url')}/api/register/complete`, { - jar, - json: true, - resolveWithFullResponse: true, - }); - - assert(!res.body.errors.includes('[[error:invalid-email]]')); - assert(!res.body.errors.includes('[[error:gdpr_consent_denied]]')); - }); - - it('should error if userData is falsy', async () => { - try { - await user.interstitials.email({ userData: null }); - assert(false); - } catch (err) { - assert.strictEqual(err.message, '[[error:invalid-data]]'); - } - }); - - it('should throw error if email is not valid', async () => { - const uid = await user.create({ username: 'interstiuser1' }); - try { - const result = await user.interstitials.email({ - userData: { uid: uid, updateEmail: true }, - req: { uid: uid }, - interstitials: [], - }); - assert.strictEqual(result.interstitials[0].template, 'partials/email_update'); - await result.interstitials[0].callback({ uid }, { - email: 'invalidEmail', - }); - assert(false); - } catch (err) { - assert.strictEqual(err.message, '[[error:invalid-email]]'); - } - }); - - it('should set req.session.emailChanged to 1', async () => { - const uid = await user.create({ username: 'interstiuser2' }); - const result = await user.interstitials.email({ - userData: { uid: uid, updateEmail: true }, - req: { uid: uid, session: {} }, - interstitials: [], - }); - - await result.interstitials[0].callback({ uid: uid }, { - email: 'interstiuser2@nodebb.org', - }); - assert.strictEqual(result.req.session.emailChanged, 1); - }); - - it('should set email if admin is changing it', async () => { - const uid = await user.create({ username: 'interstiuser3' }); - const result = await user.interstitials.email({ - userData: { uid: uid, updateEmail: true }, - req: { uid: adminUid }, - interstitials: [], - }); - - await result.interstitials[0].callback({ uid: uid }, { - email: 'interstiuser3@nodebb.org', - }); - const userData = await user.getUserData(uid); - assert.strictEqual(userData.email, 'interstiuser3@nodebb.org'); - assert.strictEqual(userData['email:confirmed'], 1); - }); - - it('should throw error if user tries to edit other users email', async () => { - const uid = await user.create({ username: 'interstiuser4' }); - try { - const result = await user.interstitials.email({ - userData: { uid: uid, updateEmail: true }, - req: { uid: 1000 }, - interstitials: [], - }); - - await result.interstitials[0].callback({ uid: uid }, { - email: 'derp@derp.com', - }); - assert(false); - } catch (err) { - assert.strictEqual(err.message, '[[error:no-privileges]]'); - } - }); - - it('should remove current email', async () => { - const uid = await user.create({ username: 'interstiuser5' }); - await user.setUserField(uid, 'email', 'interstiuser5@nodebb.org'); - await user.email.confirmByUid(uid); - - const result = await user.interstitials.email({ - userData: { uid: uid, updateEmail: true }, - req: { uid: uid, session: { id: 0 } }, - interstitials: [], - }); - - await result.interstitials[0].callback({ uid: uid }, { - email: '', - }); - const userData = await user.getUserData(uid); - assert.strictEqual(userData.email, ''); - assert.strictEqual(userData['email:confirmed'], 0); - }); - - it('should require a password (if one is set) for email change', async () => { - try { - const [username, password] = [utils.generateUUID().slice(0, 10), utils.generateUUID()]; - const uid = await user.create({ username, password }); - await user.setUserField(uid, 'email', `${username}@nodebb.org`); - await user.email.confirmByUid(uid); - - const result = await user.interstitials.email({ - userData: { uid: uid, updateEmail: true }, - req: { uid: uid, session: { id: 0 } }, - interstitials: [], - }); - - await result.interstitials[0].callback({ uid: uid }, { - email: `${username}@nodebb.com`, - }); - } catch (err) { - assert.strictEqual(err.message, '[[error:invalid-password]]'); - } - }); - - it('should require a password (if one is set) for email clearing', async () => { - try { - const [username, password] = [utils.generateUUID().slice(0, 10), utils.generateUUID()]; - const uid = await user.create({ username, password }); - await user.setUserField(uid, 'email', `${username}@nodebb.org`); - await user.email.confirmByUid(uid); - - const result = await user.interstitials.email({ - userData: { uid: uid, updateEmail: true }, - req: { uid: uid, session: { id: 0 } }, - interstitials: [], - }); - - await result.interstitials[0].callback({ uid: uid }, { - email: '', - }); - } catch (err) { - assert.strictEqual(err.message, '[[error:invalid-password]]'); - } - }); - - it('should successfully issue validation request if the correct password is passed in', async () => { - const [username, password] = [utils.generateUUID().slice(0, 10), utils.generateUUID()]; - const uid = await user.create({ username, password }); - await user.setUserField(uid, 'email', `${username}@nodebb.org`); - await user.email.confirmByUid(uid); - - const result = await user.interstitials.email({ - userData: { uid: uid, updateEmail: true }, - req: { uid: uid, session: { id: 0 } }, - interstitials: [], - }); - - await result.interstitials[0].callback({ uid }, { - email: `${username}@nodebb.com`, - password, - }); - - const pending = await user.email.isValidationPending(uid, `${username}@nodebb.com`); - assert.strictEqual(pending, true); - await user.setUserField(uid, 'email', `${username}@nodebb.com`); - await user.email.confirmByUid(uid); - const userData = await user.getUserData(uid); - assert.strictEqual(userData.email, `${username}@nodebb.com`); - assert.strictEqual(userData['email:confirmed'], 1); - }); - }); - - describe('gdpr', () => { - let jar; - let token; - - before(async () => { - jar = await helpers.registerUser({ - username: utils.generateUUID().slice(0, 10), - password: utils.generateUUID(), - 'account-type': 'student', - }); - token = await helpers.getCsrfToken(jar); - }); - - it('registration should succeed once gdpr prompts are agreed to', async () => { - const res = await requestAsync(`${nconf.get('url')}/register/complete`, { - method: 'post', - jar, - json: true, - followRedirect: false, - simple: false, - resolveWithFullResponse: true, - headers: { - 'x-csrf-token': token, - }, - form: { - gdpr_agree_data: 'on', - gdpr_agree_email: 'on', - }, - }); - - assert.strictEqual(res.statusCode, 302); - assert.strictEqual(res.headers.location, `${nconf.get('relative_path')}/`); - }); - }); - }); - - it('should load /robots.txt', (done) => { - request(`${nconf.get('url')}/robots.txt`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /manifest.webmanifest', (done) => { - request(`${nconf.get('url')}/manifest.webmanifest`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /outgoing?url=', (done) => { - request(`${nconf.get('url')}/outgoing?url=http://youtube.com`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should 404 on /outgoing with no url', (done) => { - request(`${nconf.get('url')}/outgoing`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - assert(body); - done(); - }); - }); - - it('should 404 on /outgoing with javascript: protocol', (done) => { - request(`${nconf.get('url')}/outgoing?url=javascript:alert(1);`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - assert(body); - done(); - }); - }); - - it('should 404 on /outgoing with invalid url', (done) => { - request(`${nconf.get('url')}/outgoing?url=derp`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - assert(body); - done(); - }); - }); - - it('should load /tos', (done) => { - meta.config.termsOfUse = 'please accept our tos'; - request(`${nconf.get('url')}/tos`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - - it('should load 404 if meta.config.termsOfUse is empty', (done) => { - meta.config.termsOfUse = ''; - request(`${nconf.get('url')}/tos`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - assert(body); - done(); - }); - }); - - it('should load /sping', (done) => { - request(`${nconf.get('url')}/sping`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(body, 'healthy'); - done(); - }); - }); - - it('should load /ping', (done) => { - request(`${nconf.get('url')}/ping`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(body, '200'); - done(); - }); - }); - - it('should handle 404', (done) => { - request(`${nconf.get('url')}/arouteinthevoid`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - assert(body); - done(); - }); - }); - - it('should load topic rss feed', (done) => { - request(`${nconf.get('url')}/topic/${tid}.rss`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load category rss feed', (done) => { - request(`${nconf.get('url')}/category/${cid}.rss`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load topics rss feed', (done) => { - request(`${nconf.get('url')}/topics.rss`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load recent rss feed', (done) => { - request(`${nconf.get('url')}/recent.rss`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load top rss feed', (done) => { - request(`${nconf.get('url')}/top.rss`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load popular rss feed', (done) => { - request(`${nconf.get('url')}/popular.rss`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load popular rss feed with term', (done) => { - request(`${nconf.get('url')}/popular/day.rss`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load recent posts rss feed', (done) => { - request(`${nconf.get('url')}/recentposts.rss`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load category recent posts rss feed', (done) => { - request(`${nconf.get('url')}/category/${cid}/recentposts.rss`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load user topics rss feed', (done) => { - request(`${nconf.get('url')}/user/foo/topics.rss`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load tag rss feed', (done) => { - request(`${nconf.get('url')}/tags/nodebb.rss`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load client.css', (done) => { - request(`${nconf.get('url')}/assets/client.css`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load admin.css', (done) => { - request(`${nconf.get('url')}/assets/admin.css`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load sitemap.xml', (done) => { - request(`${nconf.get('url')}/sitemap.xml`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load sitemap/pages.xml', (done) => { - request(`${nconf.get('url')}/sitemap/pages.xml`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load sitemap/categories.xml', (done) => { - request(`${nconf.get('url')}/sitemap/categories.xml`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load sitemap/topics/1.xml', (done) => { - request(`${nconf.get('url')}/sitemap/topics.1.xml`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load robots.txt', (done) => { - request(`${nconf.get('url')}/robots.txt`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load theme screenshot', (done) => { - request(`${nconf.get('url')}/css/previews/nodebb-theme-persona`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load users page', (done) => { - request(`${nconf.get('url')}/users`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load users page', (done) => { - request(`${nconf.get('url')}/users?section=online`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should error if guests do not have search privilege', (done) => { - request(`${nconf.get('url')}/api/users?query=bar§ion=sort-posts`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 500); - assert(body); - assert.equal(body.error, '[[error:no-privileges]]'); - done(); - }); - }); - - it('should load users search page', (done) => { - privileges.global.give(['groups:search:users'], 'guests', (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/users?query=bar§ion=sort-posts`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - privileges.global.rescind(['groups:search:users'], 'guests', done); - }); - }); - }); - - it('should load groups page', (done) => { - request(`${nconf.get('url')}/groups`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load group details page', (done) => { - groups.create({ - name: 'group-details', - description: 'Foobar!', - hidden: 0, - }, (err) => { - assert.ifError(err); - groups.join('group-details', fooUid, (err) => { - assert.ifError(err); - topics.post({ - uid: fooUid, - title: 'topic title', - content: 'test topic content', - cid: cid, - }, (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/groups/group-details`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - assert.equal(body.posts[0].content, 'test topic content'); - done(); - }); - }); - }); - }); - }); - - it('should load group members page', (done) => { - request(`${nconf.get('url')}/groups/group-details/members`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should 404 when trying to load group members of hidden group', (done) => { - const groups = require('../src/groups'); - groups.create({ - name: 'hidden-group', - description: 'Foobar!', - hidden: 1, - }, (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/groups/hidden-group/members`, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - done(); - }); - }); - }); - - it('should get recent posts', (done) => { - request(`${nconf.get('url')}/api/recent/posts/month`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should get post data', (done) => { - request(`${nconf.get('url')}/api/v3/posts/${pid}`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should get topic data', (done) => { - request(`${nconf.get('url')}/api/v3/topics/${tid}`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should get category data', (done) => { - request(`${nconf.get('url')}/api/v3/categories/${cid}`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - - describe('revoke session', () => { - let uid; - let jar; - let csrf_token; - - before(async () => { - uid = await user.create({ username: 'revokeme', password: 'barbar' }); - const login = await helpers.loginUser('revokeme', 'barbar'); - jar = login.jar; - csrf_token = login.csrf_token; - }); - - it('should fail to revoke session with missing uuid', (done) => { - request.del(`${nconf.get('url')}/api/user/revokeme/session`, { - jar: jar, - headers: { - 'x-csrf-token': csrf_token, - }, - }, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - done(); - }); - }); - - it('should fail if user doesn\'t exist', (done) => { - request.del(`${nconf.get('url')}/api/v3/users/doesnotexist/sessions/1112233`, { - jar: jar, - headers: { - 'x-csrf-token': csrf_token, - }, - }, (err, res, body) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 404); - const parsedResponse = JSON.parse(body); - assert.deepStrictEqual(parsedResponse.response, {}); - assert.deepStrictEqual(parsedResponse.status, { - code: 'not-found', - message: 'User does not exist', - }); - done(); - }); - }); - - it('should revoke user session', (done) => { - db.getSortedSetRange(`uid:${uid}:sessions`, 0, -1, (err, sids) => { - assert.ifError(err); - const sid = sids[0]; - - db.sessionStore.get(sid, (err, sessionObj) => { - assert.ifError(err); - request.del(`${nconf.get('url')}/api/v3/users/${uid}/sessions/${sessionObj.meta.uuid}`, { - jar: jar, - headers: { - 'x-csrf-token': csrf_token, - }, - }, (err, res, body) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 200); - assert.deepStrictEqual(JSON.parse(body), { - status: { - code: 'ok', - message: 'OK', - }, - response: {}, - }); - done(); - }); - }); - }); - }); - }); - - describe('widgets', () => { - const widgets = require('../src/widgets'); - - before((done) => { - async.waterfall([ - function (next) { - widgets.reset(next); - }, - function (next) { - const data = { - template: 'categories.tpl', - location: 'sidebar', - widgets: [ - { - widget: 'html', - data: { - html: 'test', - title: '', - container: '', - }, - }, - ], - }; - - widgets.setArea(data, next); - }, - ], done); - }); - - it('should return {} if there are no widgets', (done) => { - request(`${nconf.get('url')}/api/category/${cid}`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body.widgets); - assert.equal(Object.keys(body.widgets).length, 0); - done(); - }); - }); - - it('should render templates', (done) => { - const url = `${nconf.get('url')}/api/categories`; - request(url, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body.widgets); - assert(body.widgets.sidebar); - assert.equal(body.widgets.sidebar[0].html, 'test'); - done(); - }); - }); - - it('should reset templates', (done) => { - widgets.resetTemplates(['categories', 'category'], (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/categories`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body.widgets); - assert.equal(Object.keys(body.widgets).length, 0); - done(); - }); - }); - }); - }); - - describe('tags', () => { - let tid; - before((done) => { - topics.post({ - uid: fooUid, - title: 'topic title', - content: 'test topic content', - cid: cid, - tags: ['nodebb', 'bug', 'test'], - }, (err, result) => { - assert.ifError(err); - tid = result.topicData.tid; - done(); - }); - }); - - it('should render tags page', (done) => { - request(`${nconf.get('url')}/api/tags`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - assert(Array.isArray(body.tags)); - done(); - }); - }); - - it('should render tag page with no topics', (done) => { - request(`${nconf.get('url')}/api/tags/notag`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - assert(Array.isArray(body.topics)); - assert.equal(body.topics.length, 0); - done(); - }); - }); - - it('should render tag page with 1 topic', (done) => { - request(`${nconf.get('url')}/api/tags/nodebb`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - assert(Array.isArray(body.topics)); - assert.equal(body.topics.length, 1); - done(); - }); - }); - }); - - - describe('maintenance mode', () => { - before((done) => { - meta.config.maintenanceMode = 1; - done(); - }); - after((done) => { - meta.config.maintenanceMode = 0; - done(); - }); - - it('should return 503 in maintenance mode', (done) => { - request(`${nconf.get('url')}/recent`, { json: true }, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 503); - done(); - }); - }); - - it('should return 503 in maintenance mode', (done) => { - request(`${nconf.get('url')}/api/recent`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 503); - assert(body); - done(); - }); - }); - - it('should return 200 in maintenance mode', (done) => { - request(`${nconf.get('url')}/api/login`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should return 200 if guests are allowed', (done) => { - const oldValue = meta.config.groupsExemptFromMaintenanceMode; - meta.config.groupsExemptFromMaintenanceMode.push('guests'); - request(`${nconf.get('url')}/api/recent`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 200); - assert(body); - meta.config.groupsExemptFromMaintenanceMode = oldValue; - done(); - }); - }); - }); - - describe('account pages', () => { - let jar; - let csrf_token; - - before(async () => { - ({ jar, csrf_token } = await helpers.loginUser('foo', 'barbar')); - }); - - it('should redirect to account page with logged in user', (done) => { - request(`${nconf.get('url')}/api/login`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(res.headers['x-redirect'], '/user/foo'); - assert.equal(body, '/user/foo'); - done(); - }); - }); - - it('should 404 if uid is not a number', (done) => { - request(`${nconf.get('url')}/api/uid/test`, { json: true }, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - done(); - }); - }); - - it('should redirect to userslug', (done) => { - request(`${nconf.get('url')}/api/uid/${fooUid}`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(res.headers['x-redirect'], '/user/foo'); - assert.equal(body, '/user/foo'); - done(); - }); - }); - - it('should redirect to userslug and keep query params', (done) => { - request(`${nconf.get('url')}/api/uid/${fooUid}/topics?foo=bar`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(res.headers['x-redirect'], '/user/foo/topics?foo=bar'); - assert.equal(body, '/user/foo/topics?foo=bar'); - done(); - }); - }); - - it('should 404 if user does not exist', (done) => { - request(`${nconf.get('url')}/api/uid/123123`, { json: true }, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - done(); - }); - }); - - describe('/me/*', () => { - it('should redirect to user profile', (done) => { - request(`${nconf.get('url')}/me`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body.includes('"template":{"name":"account/profile","account/profile":true}')); - assert(body.includes('"username":"foo"')); - done(); - }); - }); - it('api should redirect to /user/[userslug]/bookmarks', (done) => { - request(`${nconf.get('url')}/api/me/bookmarks`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(res.headers['x-redirect'], '/user/foo/bookmarks'); - assert.equal(body, '/user/foo/bookmarks'); - done(); - }); - }); - it('api should redirect to /user/[userslug]/edit/username', (done) => { - request(`${nconf.get('url')}/api/me/edit/username`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(res.headers['x-redirect'], '/user/foo/edit/username'); - assert.equal(body, '/user/foo/edit/username'); - done(); - }); - }); - it('should redirect to login if user is not logged in', (done) => { - request(`${nconf.get('url')}/me/bookmarks`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body.includes('Login to your account'), body.slice(0, 500)); - done(); - }); - }); - }); - - it('should 401 if user is not logged in', (done) => { - request(`${nconf.get('url')}/api/admin`, { json: true }, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 401); - done(); - }); - }); - - it('should 403 if user is not admin', (done) => { - request(`${nconf.get('url')}/api/admin`, { jar: jar, json: true }, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 403); - done(); - }); - }); - - it('should load /user/foo/posts', (done) => { - request(`${nconf.get('url')}/api/user/foo/posts`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should 401 if not logged in', (done) => { - request(`${nconf.get('url')}/api/user/foo/bookmarks`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 401); - assert(body); - done(); - }); - }); - - it('should load /user/foo/bookmarks', (done) => { - request(`${nconf.get('url')}/api/user/foo/bookmarks`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /user/foo/upvoted', (done) => { - request(`${nconf.get('url')}/api/user/foo/upvoted`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /user/foo/downvoted', (done) => { - request(`${nconf.get('url')}/api/user/foo/downvoted`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /user/foo/best', (done) => { - request(`${nconf.get('url')}/api/user/foo/best`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /user/foo/controversial', (done) => { - request(`${nconf.get('url')}/api/user/foo/controversial`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /user/foo/watched', (done) => { - request(`${nconf.get('url')}/api/user/foo/watched`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /user/foo/ignored', (done) => { - request(`${nconf.get('url')}/api/user/foo/ignored`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /user/foo/topics', (done) => { - request(`${nconf.get('url')}/api/user/foo/topics`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /user/foo/blocks', (done) => { - request(`${nconf.get('url')}/api/user/foo/blocks`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /user/foo/consent', (done) => { - request(`${nconf.get('url')}/api/user/foo/consent`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /user/foo/sessions', (done) => { - request(`${nconf.get('url')}/api/user/foo/sessions`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /user/foo/categories', (done) => { - request(`${nconf.get('url')}/api/user/foo/categories`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load /user/foo/uploads', (done) => { - request(`${nconf.get('url')}/api/user/foo/uploads`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should export users posts', (done) => { - request(`${nconf.get('url')}/api/user/foo/export/posts`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should export users uploads', (done) => { - request(`${nconf.get('url')}/api/user/foo/export/uploads`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should export users profile', (done) => { - request(`${nconf.get('url')}/api/user/foo/export/profile`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load notifications page', (done) => { - const notifications = require('../src/notifications'); - const notifData = { - bodyShort: '[[notifications:user_posted_to, test1, test2]]', - bodyLong: 'some post content', - pid: 1, - path: `/post/${1}`, - nid: `new_post:tid:${1}:pid:${1}:uid:${fooUid}`, - tid: 1, - from: fooUid, - mergeId: `notifications:user_posted_to|${1}`, - topicTitle: 'topic title', - }; - async.waterfall([ - function (next) { - notifications.create(notifData, next); - }, - function (notification, next) { - notifications.push(notification, fooUid, next); - }, - function (next) { - setTimeout(next, 2500); - }, - function (next) { - request(`${nconf.get('url')}/api/notifications`, { jar: jar, json: true }, next); - }, - function (res, body, next) { - assert.equal(res.statusCode, 200); - assert(body); - const notif = body.notifications[0]; - assert.equal(notif.bodyShort, notifData.bodyShort); - assert.equal(notif.bodyLong, notifData.bodyLong); - assert.equal(notif.pid, notifData.pid); - assert.equal(notif.path, nconf.get('relative_path') + notifData.path); - assert.equal(notif.nid, notifData.nid); - next(); - }, - ], done); - }); - - it('should 404 if user does not exist', (done) => { - request(`${nconf.get('url')}/api/user/email/doesnotexist`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - assert(body); - done(); - }); - }); - - it('should load user by uid', (done) => { - request(`${nconf.get('url')}/api/user/uid/${fooUid}`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load user by username', (done) => { - request(`${nconf.get('url')}/api/user/username/foo`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should NOT load user by email (by default)', async () => { - const res = await requestAsync(`${nconf.get('url')}/api/user/email/foo@test.com`, { - resolveWithFullResponse: true, - simple: false, - }); - - assert.strictEqual(res.statusCode, 404); - }); - - it('should load user by email if user has elected to show their email', async () => { - await user.setSetting(fooUid, 'showemail', 1); - const res = await requestAsync(`${nconf.get('url')}/api/user/email/foo@test.com`, { - resolveWithFullResponse: true, - }); - assert.strictEqual(res.statusCode, 200); - assert(res.body); - await user.setSetting(fooUid, 'showemail', 0); - }); - - it('should return 401 if user does not have view:users privilege', (done) => { - privileges.global.rescind(['groups:view:users'], 'guests', (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/user/foo`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 401); - assert.deepEqual(body, { - response: {}, - status: { - code: 'not-authorised', - message: 'A valid login session was not found. Please log in and try again.', - }, - }); - privileges.global.give(['groups:view:users'], 'guests', done); - }); - }); - }); - - it('should return false if user can not edit user', (done) => { - user.create({ username: 'regularJoe', password: 'barbar' }, (err) => { - assert.ifError(err); - helpers.loginUser('regularJoe', 'barbar', (err, data) => { - assert.ifError(err); - const { jar } = data; - request(`${nconf.get('url')}/api/user/foo/info`, { jar: jar, json: true }, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 403); - request(`${nconf.get('url')}/api/user/foo/edit`, { jar: jar, json: true }, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 403); - done(); - }); - }); - }); - }); - }); - - it('should load correct user', (done) => { - request(`${nconf.get('url')}/api/user/FOO`, { jar: jar, json: true }, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - done(); - }); - }); - - it('should redirect', (done) => { - request(`${nconf.get('url')}/user/FOO`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should 404 if user does not exist', (done) => { - request(`${nconf.get('url')}/api/user/doesnotexist`, { jar: jar }, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - done(); - }); - }); - - it('should not increase profile view if you visit your own profile', (done) => { - request(`${nconf.get('url')}/api/user/foo`, { jar: jar }, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - setTimeout(() => { - user.getUserField(fooUid, 'profileviews', (err, viewcount) => { - assert.ifError(err); - assert(viewcount === 0); - done(); - }); - }, 500); - }); - }); - - it('should not increase profile view if a guest visits a profile', (done) => { - request(`${nconf.get('url')}/api/user/foo`, {}, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - setTimeout(() => { - user.getUserField(fooUid, 'profileviews', (err, viewcount) => { - assert.ifError(err); - assert(viewcount === 0); - done(); - }); - }, 500); - }); - }); - - it('should increase profile view', (done) => { - helpers.loginUser('regularJoe', 'barbar', (err, data) => { - assert.ifError(err); - const { jar } = data; - request(`${nconf.get('url')}/api/user/foo`, { jar: jar }, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - setTimeout(() => { - user.getUserField(fooUid, 'profileviews', (err, viewcount) => { - assert.ifError(err); - assert(viewcount > 0); - done(); - }); - }, 500); - }); - }); - }); - - it('should parse about me', (done) => { - user.setUserFields(fooUid, { picture: '/path/to/picture', aboutme: 'hi i am a bot' }, (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/user/foo`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(body.aboutme, 'hi i am a bot'); - assert.equal(body.picture, '/path/to/picture'); - done(); - }); - }); - }); - - it('should not return reputation if reputation is disabled', (done) => { - meta.config['reputation:disabled'] = 1; - request(`${nconf.get('url')}/api/user/foo`, { json: true }, (err, res, body) => { - meta.config['reputation:disabled'] = 0; - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(!body.hasOwnProperty('reputation')); - done(); - }); - }); - - it('should only return posts that are not deleted', (done) => { - let topicData; - let pidToDelete; - async.waterfall([ - function (next) { - topics.post({ uid: fooUid, title: 'visible', content: 'some content', cid: cid }, next); - }, - function (data, next) { - topicData = data.topicData; - topics.reply({ uid: fooUid, content: '1st reply', tid: topicData.tid }, next); - }, - function (postData, next) { - pidToDelete = postData.pid; - topics.reply({ uid: fooUid, content: '2nd reply', tid: topicData.tid }, next); - }, - function (postData, next) { - posts.delete(pidToDelete, fooUid, next); - }, - function (next) { - request(`${nconf.get('url')}/api/user/foo`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - const contents = body.posts.map(p => p.content); - assert(!contents.includes('1st reply')); - done(); - }); - }, - ], done); - }); - - it('should return selected group title', (done) => { - groups.create({ - name: 'selectedGroup', - }, (err) => { - assert.ifError(err); - user.create({ username: 'groupie' }, (err, uid) => { - assert.ifError(err); - groups.join('selectedGroup', uid, (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/user/groupie`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(Array.isArray(body.selectedGroup)); - assert.equal(body.selectedGroup[0].name, 'selectedGroup'); - done(); - }); - }); - }); - }); - }); - - it('should 404 if user does not exist', (done) => { - groups.join('administrators', fooUid, (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/user/doesnotexist/edit`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - groups.leave('administrators', fooUid, done); - }); - }); - }); - - it('should render edit/password', (done) => { - request(`${nconf.get('url')}/api/user/foo/edit/password`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - done(); - }); - }); - - it('should render edit/email', async () => { - const res = await requestAsync(`${nconf.get('url')}/api/user/foo/edit/email`, { - jar, - json: true, - resolveWithFullResponse: true, - }); - - assert.strictEqual(res.statusCode, 200); - assert.strictEqual(res.body, '/register/complete'); - - await requestAsync({ - uri: `${nconf.get('url')}/register/abort?_csrf=${csrf_token}`, - method: 'post', - jar, - simple: false, - }); - }); - - it('should render edit/username', (done) => { - request(`${nconf.get('url')}/api/user/foo/edit/username`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - done(); - }); - }); - }); - - describe('account follow page', () => { - const socketUser = require('../src/socket.io/user'); - const apiUser = require('../src/api/users'); - let uid; - before(async () => { - uid = await user.create({ username: 'follower' }); - await apiUser.follow({ uid: uid }, { uid: fooUid }); - const isFollowing = await socketUser.isFollowing({ uid: uid }, { uid: fooUid }); - assert(isFollowing); - }); - - it('should get followers page', (done) => { - request(`${nconf.get('url')}/api/user/foo/followers`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(body.users[0].username, 'follower'); - done(); - }); - }); - - it('should get following page', (done) => { - request(`${nconf.get('url')}/api/user/follower/following`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(body.users[0].username, 'foo'); - done(); - }); - }); - - it('should return empty after unfollow', async () => { - await apiUser.unfollow({ uid: uid }, { uid: fooUid }); - const { res, body } = await helpers.request('get', `/api/user/foo/followers`, { json: true }); - assert.equal(res.statusCode, 200); - assert.equal(body.users.length, 0); - }); - }); - - describe('post redirect', () => { - let jar; - before(async () => { - ({ jar } = await helpers.loginUser('foo', 'barbar')); - }); - - it('should 404 for invalid pid', (done) => { - request(`${nconf.get('url')}/api/post/fail`, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - done(); - }); - }); - - it('should 403 if user does not have read privilege', (done) => { - privileges.categories.rescind(['groups:topics:read'], category.cid, 'registered-users', (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/post/${pid}`, { jar: jar }, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 403); - privileges.categories.give(['groups:topics:read'], category.cid, 'registered-users', done); - }); - }); - }); - - it('should return correct post path', (done) => { - request(`${nconf.get('url')}/api/post/${pid}`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(res.headers['x-redirect'], '/topic/1/test-topic-title/1'); - assert.equal(body, '/topic/1/test-topic-title/1'); - done(); - }); - }); - }); - - describe('cookie consent', () => { - it('should return relevant data in configs API route', (done) => { - request(`${nconf.get('url')}/api/config`, (err, res, body) => { - let parsed; - assert.ifError(err); - assert.equal(res.statusCode, 200); - - try { - parsed = JSON.parse(body); - } catch (e) { - assert.ifError(e); - } - - assert.ok(parsed.cookies); - assert.equal(translator.escape('[[global:cookies.message]]'), parsed.cookies.message); - assert.equal(translator.escape('[[global:cookies.accept]]'), parsed.cookies.dismiss); - assert.equal(translator.escape('[[global:cookies.learn_more]]'), parsed.cookies.link); - - done(); - }); - }); - - it('response should be parseable when entries have apostrophes', (done) => { - meta.configs.set('cookieConsentMessage', 'Julian\'s Message', (err) => { - assert.ifError(err); - - request(`${nconf.get('url')}/api/config`, (err, res, body) => { - let parsed; - assert.ifError(err); - assert.equal(res.statusCode, 200); - - try { - parsed = JSON.parse(body); - } catch (e) { - assert.ifError(e); - } - - assert.equal('Julian's Message', parsed.cookies.message); - done(); - }); - }); - }); - }); - - it('should return osd data', (done) => { - request(`${nconf.get('url')}/osd.xml`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - describe('handle errors', () => { - const plugins = require('../src/plugins'); - after((done) => { - plugins.loadedHooks['filter:router.page'] = undefined; - done(); - }); - - it('should handle topic malformed uri', (done) => { - request(`${nconf.get('url')}/topic/1/a%AFc`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should handle category malformed uri', (done) => { - request(`${nconf.get('url')}/category/1/a%AFc`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should handle malformed uri ', (done) => { - request(`${nconf.get('url')}/user/a%AFc`, (err, res, body) => { - assert.ifError(err); - assert(body); - assert.equal(res.statusCode, 400); - done(); - }); - }); - - it('should handle malformed uri in api', (done) => { - request(`${nconf.get('url')}/api/user/a%AFc`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 400); - assert.equal(body.error, '[[global:400.title]]'); - done(); - }); - }); - - it('should handle CSRF error', (done) => { - plugins.loadedHooks['filter:router.page'] = plugins.loadedHooks['filter:router.page'] || []; - plugins.loadedHooks['filter:router.page'].push({ - method: function (req, res, next) { - const err = new Error('csrf-error'); - err.code = 'EBADCSRFTOKEN'; - next(err); - }, - }); - - request(`${nconf.get('url')}/users`, {}, (err, res) => { - plugins.loadedHooks['filter:router.page'] = []; - assert.ifError(err); - assert.equal(res.statusCode, 403); - done(); - }); - }); - - it('should handle black-list error', (done) => { - plugins.loadedHooks['filter:router.page'] = plugins.loadedHooks['filter:router.page'] || []; - plugins.loadedHooks['filter:router.page'].push({ - method: function (req, res, next) { - const err = new Error('blacklist error message'); - err.code = 'blacklisted-ip'; - next(err); - }, - }); - - request(`${nconf.get('url')}/users`, {}, (err, res, body) => { - plugins.loadedHooks['filter:router.page'] = []; - assert.ifError(err); - assert.equal(res.statusCode, 403); - assert.equal(body, 'blacklist error message'); - done(); - }); - }); - - it('should handle page redirect through error', (done) => { - plugins.loadedHooks['filter:router.page'] = plugins.loadedHooks['filter:router.page'] || []; - plugins.loadedHooks['filter:router.page'].push({ - method: function (req, res, next) { - const err = new Error('redirect'); - err.status = 302; - err.path = '/popular'; - plugins.loadedHooks['filter:router.page'] = []; - next(err); - }, - }); - - request(`${nconf.get('url')}/users`, {}, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should handle api page redirect through error', (done) => { - plugins.loadedHooks['filter:router.page'] = plugins.loadedHooks['filter:router.page'] || []; - plugins.loadedHooks['filter:router.page'].push({ - method: function (req, res, next) { - const err = new Error('redirect'); - err.status = 308; - err.path = '/api/popular'; - plugins.loadedHooks['filter:router.page'] = []; - next(err); - }, - }); - - request(`${nconf.get('url')}/api/users`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(res.headers['x-redirect'], '/api/popular'); - assert(body, '/api/popular'); - done(); - }); - }); - - it('should handle error page', (done) => { - plugins.loadedHooks['filter:router.page'] = plugins.loadedHooks['filter:router.page'] || []; - plugins.loadedHooks['filter:router.page'].push({ - method: function (req, res, next) { - const err = new Error('regular error'); - next(err); - }, - }); - - request(`${nconf.get('url')}/users`, (err, res, body) => { - plugins.loadedHooks['filter:router.page'] = []; - assert.ifError(err); - assert.equal(res.statusCode, 500); - assert(body); - done(); - }); - }); - }); - - describe('category', () => { - let jar; - before(async () => { - ({ jar } = await helpers.loginUser('foo', 'barbar')); - }); - - it('should return 404 if cid is not a number', (done) => { - request(`${nconf.get('url')}/api/category/fail`, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - done(); - }); - }); - - it('should return 404 if topic index is not a number', (done) => { - request(`${nconf.get('url')}/api/category/${category.slug}/invalidtopicindex`, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - done(); - }); - }); - - it('should 404 if category does not exist', (done) => { - request(`${nconf.get('url')}/api/category/123123`, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - done(); - }); - }); - - it('should 404 if category is disabled', (done) => { - categories.create({ name: 'disabled' }, (err, category) => { - assert.ifError(err); - categories.setCategoryField(category.cid, 'disabled', 1, (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/category/${category.slug}`, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - done(); - }); - }); - }); - }); - - it('should return 401 if not allowed to read', (done) => { - categories.create({ name: 'hidden' }, (err, category) => { - assert.ifError(err); - privileges.categories.rescind(['groups:read'], category.cid, 'guests', (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/category/${category.slug}`, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 401); - done(); - }); - }); - }); - }); - - it('should redirect if topic index is negative', (done) => { - request(`${nconf.get('url')}/api/category/${category.slug}/-10`, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.ok(res.headers['x-redirect']); - done(); - }); - }); - - it('should 404 if page is not found', (done) => { - user.setSetting(fooUid, 'usePagination', 1, (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/category/${category.slug}?page=100`, { jar: jar, json: true }, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - done(); - }); - }); - }); - - it('should load page 1 if req.query.page is not sent', (done) => { - request(`${nconf.get('url')}/api/category/${category.slug}`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(body.pagination.currentPage, 1); - done(); - }); - }); - - it('should sort topics by most posts', (done) => { - async.waterfall([ - function (next) { - categories.create({ name: 'most-posts-category' }, next); - }, - function (category, next) { - async.waterfall([ - function (next) { - topics.post({ uid: fooUid, cid: category.cid, title: 'topic 1', content: 'topic 1 OP' }, next); - }, - function (data, next) { - topics.post({ uid: fooUid, cid: category.cid, title: 'topic 2', content: 'topic 2 OP' }, next); - }, - function (data, next) { - topics.reply({ uid: fooUid, content: 'topic 2 reply', tid: data.topicData.tid }, next); - }, - function (postData, next) { - request(`${nconf.get('url')}/api/category/${category.slug}?sort=most_posts`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(body.topics[0].title, 'topic 2'); - assert.equal(body.topics[0].postcount, 2); - assert.equal(body.topics[1].postcount, 1); - next(); - }); - }, - ], (err) => { - next(err); - }); - }, - ], done); - }); - - it('should load a specific users topics from a category with tags', (done) => { - async.waterfall([ - function (next) { - categories.create({ name: 'filtered-category' }, next); - }, - function (category, next) { - async.waterfall([ - function (next) { - topics.post({ uid: fooUid, cid: category.cid, title: 'topic 1', content: 'topic 1 OP', tags: ['java', 'cpp'] }, next); - }, - function (data, next) { - topics.post({ uid: fooUid, cid: category.cid, title: 'topic 2', content: 'topic 2 OP', tags: ['node', 'javascript'] }, next); - }, - function (data, next) { - topics.post({ uid: fooUid, cid: category.cid, title: 'topic 3', content: 'topic 3 OP', tags: ['java', 'cpp', 'best'] }, next); - }, - function (data, next) { - request(`${nconf.get('url')}/api/category/${category.slug}?tag=node&author=foo`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(body.topics[0].title, 'topic 2'); - next(); - }); - }, - function (next) { - request(`${nconf.get('url')}/api/category/${category.slug}?tag[]=java&tag[]=cpp`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(body.topics[0].title, 'topic 3'); - assert.equal(body.topics[1].title, 'topic 1'); - next(); - }); - }, - ], (err) => { - next(err); - }); - }, - ], done); - }); - - it('should redirect if category is a link', (done) => { - let cid; - let category; - async.waterfall([ - function (next) { - categories.create({ name: 'redirect', link: 'https://nodebb.org' }, next); - }, - function (_category, next) { - category = _category; - cid = category.cid; - request(`${nconf.get('url')}/api/category/${category.slug}`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(res.headers['x-redirect'], 'https://nodebb.org'); - assert.equal(body, 'https://nodebb.org'); - next(); - }); - }, - function (next) { - categories.setCategoryField(cid, 'link', '/recent', next); - }, - function (next) { - request(`${nconf.get('url')}/api/category/${category.slug}`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(res.headers['x-redirect'], '/recent'); - assert.equal(body, '/recent'); - next(); - }); - }, - ], done); - }); - - it('should get recent topic replies from children categories', (done) => { - let parentCategory; - let childCategory1; - let childCategory2; - - async.waterfall([ - function (next) { - categories.create({ name: 'parent category', backgroundImage: 'path/to/some/image' }, next); - }, - function (category, next) { - parentCategory = category; - async.waterfall([ - function (next) { - categories.create({ name: 'child category 1', parentCid: category.cid }, next); - }, - function (category, next) { - childCategory1 = category; - categories.create({ name: 'child category 2', parentCid: parentCategory.cid }, next); - }, - function (category, next) { - childCategory2 = category; - topics.post({ uid: fooUid, cid: childCategory2.cid, title: 'topic 1', content: 'topic 1 OP' }, next); - }, - function (data, next) { - request(`${nconf.get('url')}/api/category/${parentCategory.slug}`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(body.children[0].posts[0].content, 'topic 1 OP'); - next(); - }); - }, - ], (err) => { - next(err); - }); - }, - ], done); - }); - - it('should create 2 pages of topics', (done) => { - async.waterfall([ - function (next) { - categories.create({ name: 'category with 2 pages' }, next); - }, - function (category, next) { - const titles = []; - for (let i = 0; i < 30; i++) { - titles.push(`topic title ${i}`); - } - - async.waterfall([ - function (next) { - async.eachSeries(titles, (title, next) => { - topics.post({ uid: fooUid, cid: category.cid, title: title, content: 'does not really matter' }, next); - }, next); - }, - function (next) { - user.getSettings(fooUid, next); - }, - function (settings, next) { - request(`${nconf.get('url')}/api/category/${category.slug}`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(body.topics.length, settings.topicsPerPage); - assert.equal(body.pagination.pageCount, 2); - next(); - }); - }, - ], (err) => { - next(err); - }); - }, - ], done); - }); - - it('should load categories', async () => { - const helpers = require('../src/controllers/helpers'); - const data = await helpers.getCategories('cid:0:children', 1, 'topics:read', 0); - assert(data.categories.length > 0); - assert.strictEqual(data.selectedCategory, null); - assert.deepStrictEqual(data.selectedCids, []); - }); - - it('should load categories by states', async () => { - const helpers = require('../src/controllers/helpers'); - const data = await helpers.getCategoriesByStates(1, 1, Object.values(categories.watchStates), 'topics:read'); - assert.deepStrictEqual(data.selectedCategory.cid, 1); - assert.deepStrictEqual(data.selectedCids, [1]); - }); - - it('should load categories by states', async () => { - const helpers = require('../src/controllers/helpers'); - const data = await helpers.getCategoriesByStates(1, 0, [categories.watchStates.ignoring], 'topics:read'); - assert(data.categories.length === 0); - assert.deepStrictEqual(data.selectedCategory, null); - assert.deepStrictEqual(data.selectedCids, []); - }); - }); - - describe('unread', () => { - let jar; - before(async () => { - ({ jar } = await helpers.loginUser('foo', 'barbar')); - }); - - it('should load unread page', (done) => { - request(`${nconf.get('url')}/api/unread`, { jar: jar }, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - done(); - }); - }); - - it('should 404 if filter is invalid', (done) => { - request(`${nconf.get('url')}/api/unread/doesnotexist`, { jar: jar }, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - done(); - }); - }); - - it('should return total unread count', (done) => { - request(`${nconf.get('url')}/api/unread/total?filter=new`, { jar: jar }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(body, 0); - done(); - }); - }); - - it('should redirect if page is out of bounds', (done) => { - request(`${nconf.get('url')}/api/unread?page=-1`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(res.headers['x-redirect'], '/unread?page=1'); - assert.equal(body, '/unread?page=1'); - done(); - }); - }); - }); - - describe('admin middlewares', () => { - it('should redirect to login', (done) => { - request(`${nconf.get('url')}//api/admin/advanced/database`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 401); - done(); - }); - }); - - it('should redirect to login', (done) => { - request(`${nconf.get('url')}//admin/advanced/database`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body.includes('Login to your account')); - done(); - }); - }); - }); - - describe('composer', () => { - let csrf_token; - let jar; - - before(async () => { - const login = await helpers.loginUser('foo', 'barbar'); - jar = login.jar; - csrf_token = login.csrf_token; - }); - - it('should load the composer route', (done) => { - request(`${nconf.get('url')}/api/compose?cid=1`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body.title); - assert(body.template); - assert.equal(body.url, `${nconf.get('relative_path')}/compose`); - done(); - }); - }); - - it('should load the composer route if disabled by plugin', (done) => { - function hookMethod(hookData, callback) { - hookData.templateData.disabled = true; - callback(null, hookData); - } - - plugins.hooks.register('myTestPlugin', { - hook: 'filter:composer.build', - method: hookMethod, - }); - - request(`${nconf.get('url')}/api/compose?cid=1`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body.title); - assert.strictEqual(body.template.name, ''); - assert.strictEqual(body.url, `${nconf.get('relative_path')}/compose`); - - plugins.hooks.unregister('myTestPlugin', 'filter:composer.build', hookMethod); - done(); - }); - }); - - it('should error with invalid data', (done) => { - request.post(`${nconf.get('url')}/compose`, { - form: { - content: 'a new reply', - }, - jar: jar, - headers: { - 'x-csrf-token': csrf_token, - }, - }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 400); - request.post(`${nconf.get('url')}/compose`, { - form: { - tid: tid, - }, - jar: jar, - headers: { - 'x-csrf-token': csrf_token, - }, - }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 400); - done(); - }); - }); - }); - - it('should create a new topic and reply by composer route', (done) => { - const data = { - cid: cid, - title: 'no js is good', - content: 'a topic with noscript', - }; - request.post(`${nconf.get('url')}/compose`, { - form: data, - jar: jar, - headers: { - 'x-csrf-token': csrf_token, - }, - }, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 302); - request.post(`${nconf.get('url')}/compose`, { - form: { - tid: tid, - content: 'a new reply', - }, - jar: jar, - headers: { - 'x-csrf-token': csrf_token, - }, - }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 302); - done(); - }); - }); - }); - }); - - describe('test routes', () => { - if (process.env.NODE_ENV === 'development') { - it('should load debug route', (done) => { - request(`${nconf.get('url')}/debug/test`, {}, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - assert(body); - done(); - }); - }); - - it('should load redoc read route', (done) => { - request(`${nconf.get('url')}/debug/spec/read`, {}, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load redoc write route', (done) => { - request(`${nconf.get('url')}/debug/spec/write`, {}, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load 404 for invalid type', (done) => { - request(`${nconf.get('url')}/debug/spec/doesnotexist`, {}, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - assert(body); - done(); - }); - }); - } - }); - - after((done) => { - const analytics = require('../src/analytics'); - analytics.writeData(done); - }); + let tid; + let cid; + let pid; + let fooUid; + let adminUid; + let category; + + before(async () => { + category = await categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + }); + cid = category.cid; + + fooUid = await user.create({username: 'foo', password: 'barbar', gdpr_consent: true}); + await user.setUserField(fooUid, 'email', 'foo@test.com'); + await user.email.confirmByUid(fooUid); + + adminUid = await user.create({username: 'admin', password: 'barbar', gdpr_consent: true}); + await groups.join('administrators', adminUid); + + const navigation = require('../src/navigation/admin'); + const data = require('../install/data/navigation.json'); + + await navigation.save(data); + + const result = await topics.post({ + uid: fooUid, title: 'test topic title', content: 'test topic content', cid, + }); + tid = result.topicData.tid; + pid = result.postData.pid; + }); + + it('should load /config with csrf_token', done => { + request({ + url: `${nconf.get('url')}/api/config`, + json: true, + }, (error, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 200); + assert(body.csrf_token); + done(); + }); + }); + + it('should load /config with no csrf_token as spider', done => { + request({ + url: `${nconf.get('url')}/api/config`, + json: true, + headers: { + 'user-agent': 'yandex', + }, + }, (error, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 200); + assert.strictEqual(body.csrf_token, false); + assert.strictEqual(body.uid, -1); + assert.strictEqual(body.loggedIn, false); + done(); + }); + }); + + describe('homepage', () => { + function hookMethod(hookData) { + assert(hookData.req); + assert(hookData.res); + assert(hookData.next); + + hookData.res.render('mycustompage', { + works: true, + }); + } + + const message = utils.generateUUID(); + const name = 'mycustompage.tpl'; + const tplPath = path.join(nconf.get('views_dir'), name); + + before(async () => { + plugins.hooks.register('myTestPlugin', { + hook: 'action:homepage.get:mycustompage', + method: hookMethod, + }); + + fs.writeFileSync(tplPath, message); + await meta.templates.compileTemplate(name, message); + }); + + it('should load default', done => { + request(nconf.get('url'), (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load unread', done => { + meta.configs.set('homePageRoute', 'unread', error => { + assert.ifError(error); + + request(nconf.get('url'), (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + }); + + it('should load recent', done => { + meta.configs.set('homePageRoute', 'recent', error => { + assert.ifError(error); + + request(nconf.get('url'), (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + }); + + it('should load top', done => { + meta.configs.set('homePageRoute', 'top', error => { + assert.ifError(error); + + request(nconf.get('url'), (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + }); + + it('should load popular', done => { + meta.configs.set('homePageRoute', 'popular', error => { + assert.ifError(error); + + request(nconf.get('url'), (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + }); + + it('should load category', done => { + meta.configs.set('homePageRoute', 'category/1/test-category', error => { + assert.ifError(error); + + request(nconf.get('url'), (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + }); + + it('should not load breadcrumbs on home page route', done => { + request(`${nconf.get('url')}/api`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + assert(!body.breadcrumbs); + done(); + }); + }); + + it('should redirect to custom', done => { + meta.configs.set('homePageRoute', 'groups', error => { + assert.ifError(error); + + request(nconf.get('url'), (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + }); + + it('should 404 if custom does not exist', done => { + meta.configs.set('homePageRoute', 'this-route-does-not-exist', error => { + assert.ifError(error); + + request(nconf.get('url'), (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + assert(body); + done(); + }); + }); + }); + + it('api should work with hook', done => { + meta.configs.set('homePageRoute', 'mycustompage', error => { + assert.ifError(error); + + request(`${nconf.get('url')}/api`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(body.works, true); + assert.equal(body.template.mycustompage, true); + + done(); + }); + }); + }); + + it('should render with hook', done => { + meta.configs.set('homePageRoute', 'mycustompage', error => { + assert.ifError(error); + + request(nconf.get('url'), (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.ok(body); + assert.ok(body.indexOf('
    { + plugins.hooks.unregister('myTestPlugin', 'action:homepage.get:custom', hookMethod); + fs.unlinkSync(tplPath); + fs.unlinkSync(tplPath.replace(/\.tpl$/, '.js')); + }); + }); + + it('should load /reset without code', done => { + request(`${nconf.get('url')}/reset`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /reset with invalid code', done => { + request(`${nconf.get('url')}/reset/123123`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /login', done => { + request(`${nconf.get('url')}/login`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /register', done => { + request(`${nconf.get('url')}/register`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /register/complete', done => { + const data = { + username: 'interstitial', + password: '123456', + 'password-confirm': '123456', + 'account-type': 'student', + email: 'test@me.com', + }; + + const jar = request.jar(); + request({ + url: `${nconf.get('url')}/api/config`, + json: true, + jar, + }, (error, response, body) => { + assert.ifError(error); + + request.post(`${nconf.get('url')}/register`, { + form: data, + json: true, + jar, + headers: { + 'x-csrf-token': body.csrf_token, + }, + }, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.strictEqual(body.next, `${nconf.get('relative_path')}/register/complete`); + request(`${nconf.get('url')}/api/register/complete`, { + jar, + json: true, + }, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body.sections); + assert(body.errors); + assert(body.title); + done(); + }); + }); + }); + }); + + describe('registration interstitials', () => { + describe('email update', () => { + let jar; + let token; + const dummyEmailerHook = async data => {}; + + before(async () => { + // Attach an emailer hook so related requests do not error + plugins.hooks.register('emailer-test', { + hook: 'filter:email.send', + method: dummyEmailerHook, + }); + + jar = await helpers.registerUser({ + username: utils.generateUUID().slice(0, 10), + password: utils.generateUUID(), + 'account-type': 'student', + }); + token = await helpers.getCsrfToken(jar); + + meta.config.requireEmailAddress = 1; + }); + + after(() => { + meta.config.requireEmailAddress = 0; + plugins.hooks.unregister('emailer-test', 'filter:email.send'); + }); + + it('email interstitial should still apply if empty email entered and requireEmailAddress is enabled', async () => { + let res = await requestAsync(`${nconf.get('url')}/register/complete`, { + method: 'post', + jar, + json: true, + followRedirect: false, + simple: false, + resolveWithFullResponse: true, + headers: { + 'x-csrf-token': token, + }, + form: { + email: '', + }, + }); + + assert.strictEqual(res.headers.location, `${nconf.get('relative_path')}/register/complete`); + + res = await requestAsync(`${nconf.get('url')}/api/register/complete`, { + jar, + json: true, + resolveWithFullResponse: true, + }); + assert.strictEqual(res.statusCode, 200); + assert(res.body.errors.length); + assert(res.body.errors.includes('[[error:invalid-email]]')); + }); + + it('gdpr interstitial should still apply if email requirement is disabled', async () => { + meta.config.requireEmailAddress = 0; + + const res = await requestAsync(`${nconf.get('url')}/api/register/complete`, { + jar, + json: true, + resolveWithFullResponse: true, + }); + + assert(!res.body.errors.includes('[[error:invalid-email]]')); + assert(!res.body.errors.includes('[[error:gdpr_consent_denied]]')); + }); + + it('should error if userData is falsy', async () => { + try { + await user.interstitials.email({userData: null}); + assert(false); + } catch (error) { + assert.strictEqual(error.message, '[[error:invalid-data]]'); + } + }); + + it('should throw error if email is not valid', async () => { + const uid = await user.create({username: 'interstiuser1'}); + try { + const result = await user.interstitials.email({ + userData: {uid, updateEmail: true}, + req: {uid}, + interstitials: [], + }); + assert.strictEqual(result.interstitials[0].template, 'partials/email_update'); + await result.interstitials[0].callback({uid}, { + email: 'invalidEmail', + }); + assert(false); + } catch (error) { + assert.strictEqual(error.message, '[[error:invalid-email]]'); + } + }); + + it('should set req.session.emailChanged to 1', async () => { + const uid = await user.create({username: 'interstiuser2'}); + const result = await user.interstitials.email({ + userData: {uid, updateEmail: true}, + req: {uid, session: {}}, + interstitials: [], + }); + + await result.interstitials[0].callback({uid}, { + email: 'interstiuser2@nodebb.org', + }); + assert.strictEqual(result.req.session.emailChanged, 1); + }); + + it('should set email if admin is changing it', async () => { + const uid = await user.create({username: 'interstiuser3'}); + const result = await user.interstitials.email({ + userData: {uid, updateEmail: true}, + req: {uid: adminUid}, + interstitials: [], + }); + + await result.interstitials[0].callback({uid}, { + email: 'interstiuser3@nodebb.org', + }); + const userData = await user.getUserData(uid); + assert.strictEqual(userData.email, 'interstiuser3@nodebb.org'); + assert.strictEqual(userData['email:confirmed'], 1); + }); + + it('should throw error if user tries to edit other users email', async () => { + const uid = await user.create({username: 'interstiuser4'}); + try { + const result = await user.interstitials.email({ + userData: {uid, updateEmail: true}, + req: {uid: 1000}, + interstitials: [], + }); + + await result.interstitials[0].callback({uid}, { + email: 'derp@derp.com', + }); + assert(false); + } catch (error) { + assert.strictEqual(error.message, '[[error:no-privileges]]'); + } + }); + + it('should remove current email', async () => { + const uid = await user.create({username: 'interstiuser5'}); + await user.setUserField(uid, 'email', 'interstiuser5@nodebb.org'); + await user.email.confirmByUid(uid); + + const result = await user.interstitials.email({ + userData: {uid, updateEmail: true}, + req: {uid, session: {id: 0}}, + interstitials: [], + }); + + await result.interstitials[0].callback({uid}, { + email: '', + }); + const userData = await user.getUserData(uid); + assert.strictEqual(userData.email, ''); + assert.strictEqual(userData['email:confirmed'], 0); + }); + + it('should require a password (if one is set) for email change', async () => { + try { + const [username, password] = [utils.generateUUID().slice(0, 10), utils.generateUUID()]; + const uid = await user.create({username, password}); + await user.setUserField(uid, 'email', `${username}@nodebb.org`); + await user.email.confirmByUid(uid); + + const result = await user.interstitials.email({ + userData: {uid, updateEmail: true}, + req: {uid, session: {id: 0}}, + interstitials: [], + }); + + await result.interstitials[0].callback({uid}, { + email: `${username}@nodebb.com`, + }); + } catch (error) { + assert.strictEqual(error.message, '[[error:invalid-password]]'); + } + }); + + it('should require a password (if one is set) for email clearing', async () => { + try { + const [username, password] = [utils.generateUUID().slice(0, 10), utils.generateUUID()]; + const uid = await user.create({username, password}); + await user.setUserField(uid, 'email', `${username}@nodebb.org`); + await user.email.confirmByUid(uid); + + const result = await user.interstitials.email({ + userData: {uid, updateEmail: true}, + req: {uid, session: {id: 0}}, + interstitials: [], + }); + + await result.interstitials[0].callback({uid}, { + email: '', + }); + } catch (error) { + assert.strictEqual(error.message, '[[error:invalid-password]]'); + } + }); + + it('should successfully issue validation request if the correct password is passed in', async () => { + const [username, password] = [utils.generateUUID().slice(0, 10), utils.generateUUID()]; + const uid = await user.create({username, password}); + await user.setUserField(uid, 'email', `${username}@nodebb.org`); + await user.email.confirmByUid(uid); + + const result = await user.interstitials.email({ + userData: {uid, updateEmail: true}, + req: {uid, session: {id: 0}}, + interstitials: [], + }); + + await result.interstitials[0].callback({uid}, { + email: `${username}@nodebb.com`, + password, + }); + + const pending = await user.email.isValidationPending(uid, `${username}@nodebb.com`); + assert.strictEqual(pending, true); + await user.setUserField(uid, 'email', `${username}@nodebb.com`); + await user.email.confirmByUid(uid); + const userData = await user.getUserData(uid); + assert.strictEqual(userData.email, `${username}@nodebb.com`); + assert.strictEqual(userData['email:confirmed'], 1); + }); + }); + + describe('gdpr', () => { + let jar; + let token; + + before(async () => { + jar = await helpers.registerUser({ + username: utils.generateUUID().slice(0, 10), + password: utils.generateUUID(), + 'account-type': 'student', + }); + token = await helpers.getCsrfToken(jar); + }); + + it('registration should succeed once gdpr prompts are agreed to', async () => { + const res = await requestAsync(`${nconf.get('url')}/register/complete`, { + method: 'post', + jar, + json: true, + followRedirect: false, + simple: false, + resolveWithFullResponse: true, + headers: { + 'x-csrf-token': token, + }, + form: { + gdpr_agree_data: 'on', + gdpr_agree_email: 'on', + }, + }); + + assert.strictEqual(res.statusCode, 302); + assert.strictEqual(res.headers.location, `${nconf.get('relative_path')}/`); + }); + }); + }); + + it('should load /robots.txt', done => { + request(`${nconf.get('url')}/robots.txt`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /manifest.webmanifest', done => { + request(`${nconf.get('url')}/manifest.webmanifest`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /outgoing?url=', done => { + request(`${nconf.get('url')}/outgoing?url=http://youtube.com`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should 404 on /outgoing with no url', done => { + request(`${nconf.get('url')}/outgoing`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + assert(body); + done(); + }); + }); + + it('should 404 on /outgoing with javascript: protocol', done => { + request(`${nconf.get('url')}/outgoing?url=javascript:alert(1);`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + assert(body); + done(); + }); + }); + + it('should 404 on /outgoing with invalid url', done => { + request(`${nconf.get('url')}/outgoing?url=derp`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + assert(body); + done(); + }); + }); + + it('should load /tos', done => { + meta.config.termsOfUse = 'please accept our tos'; + request(`${nconf.get('url')}/tos`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load 404 if meta.config.termsOfUse is empty', done => { + meta.config.termsOfUse = ''; + request(`${nconf.get('url')}/tos`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + assert(body); + done(); + }); + }); + + it('should load /sping', done => { + request(`${nconf.get('url')}/sping`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(body, 'healthy'); + done(); + }); + }); + + it('should load /ping', done => { + request(`${nconf.get('url')}/ping`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(body, '200'); + done(); + }); + }); + + it('should handle 404', done => { + request(`${nconf.get('url')}/arouteinthevoid`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + assert(body); + done(); + }); + }); + + it('should load topic rss feed', done => { + request(`${nconf.get('url')}/topic/${tid}.rss`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load category rss feed', done => { + request(`${nconf.get('url')}/category/${cid}.rss`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load topics rss feed', done => { + request(`${nconf.get('url')}/topics.rss`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load recent rss feed', done => { + request(`${nconf.get('url')}/recent.rss`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load top rss feed', done => { + request(`${nconf.get('url')}/top.rss`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load popular rss feed', done => { + request(`${nconf.get('url')}/popular.rss`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load popular rss feed with term', done => { + request(`${nconf.get('url')}/popular/day.rss`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load recent posts rss feed', done => { + request(`${nconf.get('url')}/recentposts.rss`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load category recent posts rss feed', done => { + request(`${nconf.get('url')}/category/${cid}/recentposts.rss`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load user topics rss feed', done => { + request(`${nconf.get('url')}/user/foo/topics.rss`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load tag rss feed', done => { + request(`${nconf.get('url')}/tags/nodebb.rss`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load client.css', done => { + request(`${nconf.get('url')}/assets/client.css`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load admin.css', done => { + request(`${nconf.get('url')}/assets/admin.css`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load sitemap.xml', done => { + request(`${nconf.get('url')}/sitemap.xml`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load sitemap/pages.xml', done => { + request(`${nconf.get('url')}/sitemap/pages.xml`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load sitemap/categories.xml', done => { + request(`${nconf.get('url')}/sitemap/categories.xml`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load sitemap/topics/1.xml', done => { + request(`${nconf.get('url')}/sitemap/topics.1.xml`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load robots.txt', done => { + request(`${nconf.get('url')}/robots.txt`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load theme screenshot', done => { + request(`${nconf.get('url')}/css/previews/nodebb-theme-persona`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load users page', done => { + request(`${nconf.get('url')}/users`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load users page', done => { + request(`${nconf.get('url')}/users?section=online`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should error if guests do not have search privilege', done => { + request(`${nconf.get('url')}/api/users?query=bar§ion=sort-posts`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 500); + assert(body); + assert.equal(body.error, '[[error:no-privileges]]'); + done(); + }); + }); + + it('should load users search page', done => { + privileges.global.give(['groups:search:users'], 'guests', error => { + assert.ifError(error); + request(`${nconf.get('url')}/users?query=bar§ion=sort-posts`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + privileges.global.rescind(['groups:search:users'], 'guests', done); + }); + }); + }); + + it('should load groups page', done => { + request(`${nconf.get('url')}/groups`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load group details page', done => { + groups.create({ + name: 'group-details', + description: 'Foobar!', + hidden: 0, + }, error => { + assert.ifError(error); + groups.join('group-details', fooUid, error => { + assert.ifError(error); + topics.post({ + uid: fooUid, + title: 'topic title', + content: 'test topic content', + cid, + }, error => { + assert.ifError(error); + request(`${nconf.get('url')}/api/groups/group-details`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + assert.equal(body.posts[0].content, 'test topic content'); + done(); + }); + }); + }); + }); + }); + + it('should load group members page', done => { + request(`${nconf.get('url')}/groups/group-details/members`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should 404 when trying to load group members of hidden group', done => { + const groups = require('../src/groups'); + groups.create({ + name: 'hidden-group', + description: 'Foobar!', + hidden: 1, + }, error => { + assert.ifError(error); + request(`${nconf.get('url')}/groups/hidden-group/members`, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + done(); + }); + }); + }); + + it('should get recent posts', done => { + request(`${nconf.get('url')}/api/recent/posts/month`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should get post data', done => { + request(`${nconf.get('url')}/api/v3/posts/${pid}`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should get topic data', done => { + request(`${nconf.get('url')}/api/v3/topics/${tid}`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should get category data', done => { + request(`${nconf.get('url')}/api/v3/categories/${cid}`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + describe('revoke session', () => { + let uid; + let jar; + let csrf_token; + + before(async () => { + uid = await user.create({username: 'revokeme', password: 'barbar'}); + const login = await helpers.loginUser('revokeme', 'barbar'); + jar = login.jar; + csrf_token = login.csrf_token; + }); + + it('should fail to revoke session with missing uuid', done => { + request.del(`${nconf.get('url')}/api/user/revokeme/session`, { + jar, + headers: { + 'x-csrf-token': csrf_token, + }, + }, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + done(); + }); + }); + + it('should fail if user doesn\'t exist', done => { + request.del(`${nconf.get('url')}/api/v3/users/doesnotexist/sessions/1112233`, { + jar, + headers: { + 'x-csrf-token': csrf_token, + }, + }, (error, res, body) => { + assert.ifError(error); + assert.strictEqual(res.statusCode, 404); + const parsedResponse = JSON.parse(body); + assert.deepStrictEqual(parsedResponse.response, {}); + assert.deepStrictEqual(parsedResponse.status, { + code: 'not-found', + message: 'User does not exist', + }); + done(); + }); + }); + + it('should revoke user session', done => { + db.getSortedSetRange(`uid:${uid}:sessions`, 0, -1, (error, sids) => { + assert.ifError(error); + const sid = sids[0]; + + db.sessionStore.get(sid, (error, sessionObject) => { + assert.ifError(error); + request.del(`${nconf.get('url')}/api/v3/users/${uid}/sessions/${sessionObject.meta.uuid}`, { + jar, + headers: { + 'x-csrf-token': csrf_token, + }, + }, (error, res, body) => { + assert.ifError(error); + assert.strictEqual(res.statusCode, 200); + assert.deepStrictEqual(JSON.parse(body), { + status: { + code: 'ok', + message: 'OK', + }, + response: {}, + }); + done(); + }); + }); + }); + }); + }); + + describe('widgets', () => { + const widgets = require('../src/widgets'); + + before(done => { + async.waterfall([ + function (next) { + widgets.reset(next); + }, + function (next) { + const data = { + template: 'categories.tpl', + location: 'sidebar', + widgets: [ + { + widget: 'html', + data: { + html: 'test', + title: '', + container: '', + }, + }, + ], + }; + + widgets.setArea(data, next); + }, + ], done); + }); + + it('should return {} if there are no widgets', done => { + request(`${nconf.get('url')}/api/category/${cid}`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body.widgets); + assert.equal(Object.keys(body.widgets).length, 0); + done(); + }); + }); + + it('should render templates', done => { + const url = `${nconf.get('url')}/api/categories`; + request(url, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body.widgets); + assert(body.widgets.sidebar); + assert.equal(body.widgets.sidebar[0].html, 'test'); + done(); + }); + }); + + it('should reset templates', done => { + widgets.resetTemplates(['categories', 'category'], error => { + assert.ifError(error); + request(`${nconf.get('url')}/api/categories`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body.widgets); + assert.equal(Object.keys(body.widgets).length, 0); + done(); + }); + }); + }); + }); + + describe('tags', () => { + let tid; + before(done => { + topics.post({ + uid: fooUid, + title: 'topic title', + content: 'test topic content', + cid, + tags: ['nodebb', 'bug', 'test'], + }, (error, result) => { + assert.ifError(error); + tid = result.topicData.tid; + done(); + }); + }); + + it('should render tags page', done => { + request(`${nconf.get('url')}/api/tags`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + assert(Array.isArray(body.tags)); + done(); + }); + }); + + it('should render tag page with no topics', done => { + request(`${nconf.get('url')}/api/tags/notag`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + assert(Array.isArray(body.topics)); + assert.equal(body.topics.length, 0); + done(); + }); + }); + + it('should render tag page with 1 topic', done => { + request(`${nconf.get('url')}/api/tags/nodebb`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + assert(Array.isArray(body.topics)); + assert.equal(body.topics.length, 1); + done(); + }); + }); + }); + + describe('maintenance mode', () => { + before(done => { + meta.config.maintenanceMode = 1; + done(); + }); + after(done => { + meta.config.maintenanceMode = 0; + done(); + }); + + it('should return 503 in maintenance mode', done => { + request(`${nconf.get('url')}/recent`, {json: true}, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 503); + done(); + }); + }); + + it('should return 503 in maintenance mode', done => { + request(`${nconf.get('url')}/api/recent`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 503); + assert(body); + done(); + }); + }); + + it('should return 200 in maintenance mode', done => { + request(`${nconf.get('url')}/api/login`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should return 200 if guests are allowed', done => { + const oldValue = meta.config.groupsExemptFromMaintenanceMode; + meta.config.groupsExemptFromMaintenanceMode.push('guests'); + request(`${nconf.get('url')}/api/recent`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.strictEqual(res.statusCode, 200); + assert(body); + meta.config.groupsExemptFromMaintenanceMode = oldValue; + done(); + }); + }); + }); + + describe('account pages', () => { + let jar; + let csrf_token; + + before(async () => { + ({jar, csrf_token} = await helpers.loginUser('foo', 'barbar')); + }); + + it('should redirect to account page with logged in user', done => { + request(`${nconf.get('url')}/api/login`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(res.headers['x-redirect'], '/user/foo'); + assert.equal(body, '/user/foo'); + done(); + }); + }); + + it('should 404 if uid is not a number', done => { + request(`${nconf.get('url')}/api/uid/test`, {json: true}, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + done(); + }); + }); + + it('should redirect to userslug', done => { + request(`${nconf.get('url')}/api/uid/${fooUid}`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(res.headers['x-redirect'], '/user/foo'); + assert.equal(body, '/user/foo'); + done(); + }); + }); + + it('should redirect to userslug and keep query params', done => { + request(`${nconf.get('url')}/api/uid/${fooUid}/topics?foo=bar`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(res.headers['x-redirect'], '/user/foo/topics?foo=bar'); + assert.equal(body, '/user/foo/topics?foo=bar'); + done(); + }); + }); + + it('should 404 if user does not exist', done => { + request(`${nconf.get('url')}/api/uid/123123`, {json: true}, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + done(); + }); + }); + + describe('/me/*', () => { + it('should redirect to user profile', done => { + request(`${nconf.get('url')}/me`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body.includes('"template":{"name":"account/profile","account/profile":true}')); + assert(body.includes('"username":"foo"')); + done(); + }); + }); + it('api should redirect to /user/[userslug]/bookmarks', done => { + request(`${nconf.get('url')}/api/me/bookmarks`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(res.headers['x-redirect'], '/user/foo/bookmarks'); + assert.equal(body, '/user/foo/bookmarks'); + done(); + }); + }); + it('api should redirect to /user/[userslug]/edit/username', done => { + request(`${nconf.get('url')}/api/me/edit/username`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(res.headers['x-redirect'], '/user/foo/edit/username'); + assert.equal(body, '/user/foo/edit/username'); + done(); + }); + }); + it('should redirect to login if user is not logged in', done => { + request(`${nconf.get('url')}/me/bookmarks`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body.includes('Login to your account'), body.slice(0, 500)); + done(); + }); + }); + }); + + it('should 401 if user is not logged in', done => { + request(`${nconf.get('url')}/api/admin`, {json: true}, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 401); + done(); + }); + }); + + it('should 403 if user is not admin', done => { + request(`${nconf.get('url')}/api/admin`, {jar, json: true}, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 403); + done(); + }); + }); + + it('should load /user/foo/posts', done => { + request(`${nconf.get('url')}/api/user/foo/posts`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should 401 if not logged in', done => { + request(`${nconf.get('url')}/api/user/foo/bookmarks`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 401); + assert(body); + done(); + }); + }); + + it('should load /user/foo/bookmarks', done => { + request(`${nconf.get('url')}/api/user/foo/bookmarks`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /user/foo/upvoted', done => { + request(`${nconf.get('url')}/api/user/foo/upvoted`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /user/foo/downvoted', done => { + request(`${nconf.get('url')}/api/user/foo/downvoted`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /user/foo/best', done => { + request(`${nconf.get('url')}/api/user/foo/best`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /user/foo/controversial', done => { + request(`${nconf.get('url')}/api/user/foo/controversial`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /user/foo/watched', done => { + request(`${nconf.get('url')}/api/user/foo/watched`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /user/foo/ignored', done => { + request(`${nconf.get('url')}/api/user/foo/ignored`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /user/foo/topics', done => { + request(`${nconf.get('url')}/api/user/foo/topics`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /user/foo/blocks', done => { + request(`${nconf.get('url')}/api/user/foo/blocks`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /user/foo/consent', done => { + request(`${nconf.get('url')}/api/user/foo/consent`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /user/foo/sessions', done => { + request(`${nconf.get('url')}/api/user/foo/sessions`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /user/foo/categories', done => { + request(`${nconf.get('url')}/api/user/foo/categories`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load /user/foo/uploads', done => { + request(`${nconf.get('url')}/api/user/foo/uploads`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should export users posts', done => { + request(`${nconf.get('url')}/api/user/foo/export/posts`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should export users uploads', done => { + request(`${nconf.get('url')}/api/user/foo/export/uploads`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should export users profile', done => { + request(`${nconf.get('url')}/api/user/foo/export/profile`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load notifications page', done => { + const notifications = require('../src/notifications'); + const notificationData = { + bodyShort: '[[notifications:user_posted_to, test1, test2]]', + bodyLong: 'some post content', + pid: 1, + path: `/post/${1}`, + nid: `new_post:tid:${1}:pid:${1}:uid:${fooUid}`, + tid: 1, + from: fooUid, + mergeId: `notifications:user_posted_to|${1}`, + topicTitle: 'topic title', + }; + async.waterfall([ + function (next) { + notifications.create(notificationData, next); + }, + function (notification, next) { + notifications.push(notification, fooUid, next); + }, + function (next) { + setTimeout(next, 2500); + }, + function (next) { + request(`${nconf.get('url')}/api/notifications`, {jar, json: true}, next); + }, + function (res, body, next) { + assert.equal(res.statusCode, 200); + assert(body); + const notification = body.notifications[0]; + assert.equal(notification.bodyShort, notificationData.bodyShort); + assert.equal(notification.bodyLong, notificationData.bodyLong); + assert.equal(notification.pid, notificationData.pid); + assert.equal(notification.path, nconf.get('relative_path') + notificationData.path); + assert.equal(notification.nid, notificationData.nid); + next(); + }, + ], done); + }); + + it('should 404 if user does not exist', done => { + request(`${nconf.get('url')}/api/user/email/doesnotexist`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + assert(body); + done(); + }); + }); + + it('should load user by uid', done => { + request(`${nconf.get('url')}/api/user/uid/${fooUid}`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load user by username', done => { + request(`${nconf.get('url')}/api/user/username/foo`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should NOT load user by email (by default)', async () => { + const res = await requestAsync(`${nconf.get('url')}/api/user/email/foo@test.com`, { + resolveWithFullResponse: true, + simple: false, + }); + + assert.strictEqual(res.statusCode, 404); + }); + + it('should load user by email if user has elected to show their email', async () => { + await user.setSetting(fooUid, 'showemail', 1); + const res = await requestAsync(`${nconf.get('url')}/api/user/email/foo@test.com`, { + resolveWithFullResponse: true, + }); + assert.strictEqual(res.statusCode, 200); + assert(res.body); + await user.setSetting(fooUid, 'showemail', 0); + }); + + it('should return 401 if user does not have view:users privilege', done => { + privileges.global.rescind(['groups:view:users'], 'guests', error => { + assert.ifError(error); + request(`${nconf.get('url')}/api/user/foo`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 401); + assert.deepEqual(body, { + response: {}, + status: { + code: 'not-authorised', + message: 'A valid login session was not found. Please log in and try again.', + }, + }); + privileges.global.give(['groups:view:users'], 'guests', done); + }); + }); + }); + + it('should return false if user can not edit user', done => { + user.create({username: 'regularJoe', password: 'barbar'}, error => { + assert.ifError(error); + helpers.loginUser('regularJoe', 'barbar', (error, data) => { + assert.ifError(error); + const {jar} = data; + request(`${nconf.get('url')}/api/user/foo/info`, {jar, json: true}, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 403); + request(`${nconf.get('url')}/api/user/foo/edit`, {jar, json: true}, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 403); + done(); + }); + }); + }); + }); + }); + + it('should load correct user', done => { + request(`${nconf.get('url')}/api/user/FOO`, {jar, json: true}, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + done(); + }); + }); + + it('should redirect', done => { + request(`${nconf.get('url')}/user/FOO`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should 404 if user does not exist', done => { + request(`${nconf.get('url')}/api/user/doesnotexist`, {jar}, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + done(); + }); + }); + + it('should not increase profile view if you visit your own profile', done => { + request(`${nconf.get('url')}/api/user/foo`, {jar}, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + setTimeout(() => { + user.getUserField(fooUid, 'profileviews', (error, viewcount) => { + assert.ifError(error); + assert(viewcount === 0); + done(); + }); + }, 500); + }); + }); + + it('should not increase profile view if a guest visits a profile', done => { + request(`${nconf.get('url')}/api/user/foo`, {}, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + setTimeout(() => { + user.getUserField(fooUid, 'profileviews', (error, viewcount) => { + assert.ifError(error); + assert(viewcount === 0); + done(); + }); + }, 500); + }); + }); + + it('should increase profile view', done => { + helpers.loginUser('regularJoe', 'barbar', (error, data) => { + assert.ifError(error); + const {jar} = data; + request(`${nconf.get('url')}/api/user/foo`, {jar}, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + setTimeout(() => { + user.getUserField(fooUid, 'profileviews', (error, viewcount) => { + assert.ifError(error); + assert(viewcount > 0); + done(); + }); + }, 500); + }); + }); + }); + + it('should parse about me', done => { + user.setUserFields(fooUid, {picture: '/path/to/picture', aboutme: 'hi i am a bot'}, error => { + assert.ifError(error); + request(`${nconf.get('url')}/api/user/foo`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(body.aboutme, 'hi i am a bot'); + assert.equal(body.picture, '/path/to/picture'); + done(); + }); + }); + }); + + it('should not return reputation if reputation is disabled', done => { + meta.config['reputation:disabled'] = 1; + request(`${nconf.get('url')}/api/user/foo`, {json: true}, (error, res, body) => { + meta.config['reputation:disabled'] = 0; + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(!body.hasOwnProperty('reputation')); + done(); + }); + }); + + it('should only return posts that are not deleted', done => { + let topicData; + let pidToDelete; + async.waterfall([ + function (next) { + topics.post({ + uid: fooUid, title: 'visible', content: 'some content', cid, + }, next); + }, + function (data, next) { + topicData = data.topicData; + topics.reply({uid: fooUid, content: '1st reply', tid: topicData.tid}, next); + }, + function (postData, next) { + pidToDelete = postData.pid; + topics.reply({uid: fooUid, content: '2nd reply', tid: topicData.tid}, next); + }, + function (postData, next) { + posts.delete(pidToDelete, fooUid, next); + }, + function (next) { + request(`${nconf.get('url')}/api/user/foo`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + const contents = body.posts.map(p => p.content); + assert(!contents.includes('1st reply')); + done(); + }); + }, + ], done); + }); + + it('should return selected group title', done => { + groups.create({ + name: 'selectedGroup', + }, error => { + assert.ifError(error); + user.create({username: 'groupie'}, (error, uid) => { + assert.ifError(error); + groups.join('selectedGroup', uid, error_ => { + assert.ifError(error_); + request(`${nconf.get('url')}/api/user/groupie`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(Array.isArray(body.selectedGroup)); + assert.equal(body.selectedGroup[0].name, 'selectedGroup'); + done(); + }); + }); + }); + }); + }); + + it('should 404 if user does not exist', done => { + groups.join('administrators', fooUid, error => { + assert.ifError(error); + request(`${nconf.get('url')}/api/user/doesnotexist/edit`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + groups.leave('administrators', fooUid, done); + }); + }); + }); + + it('should render edit/password', done => { + request(`${nconf.get('url')}/api/user/foo/edit/password`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + done(); + }); + }); + + it('should render edit/email', async () => { + const res = await requestAsync(`${nconf.get('url')}/api/user/foo/edit/email`, { + jar, + json: true, + resolveWithFullResponse: true, + }); + + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.body, '/register/complete'); + + await requestAsync({ + uri: `${nconf.get('url')}/register/abort?_csrf=${csrf_token}`, + method: 'post', + jar, + simple: false, + }); + }); + + it('should render edit/username', done => { + request(`${nconf.get('url')}/api/user/foo/edit/username`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + done(); + }); + }); + }); + + describe('account follow page', () => { + const socketUser = require('../src/socket.io/user'); + const apiUser = require('../src/api/users'); + let uid; + before(async () => { + uid = await user.create({username: 'follower'}); + await apiUser.follow({uid}, {uid: fooUid}); + const isFollowing = await socketUser.isFollowing({uid}, {uid: fooUid}); + assert(isFollowing); + }); + + it('should get followers page', done => { + request(`${nconf.get('url')}/api/user/foo/followers`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(body.users[0].username, 'follower'); + done(); + }); + }); + + it('should get following page', done => { + request(`${nconf.get('url')}/api/user/follower/following`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(body.users[0].username, 'foo'); + done(); + }); + }); + + it('should return empty after unfollow', async () => { + await apiUser.unfollow({uid}, {uid: fooUid}); + const {res, body} = await helpers.request('get', '/api/user/foo/followers', {json: true}); + assert.equal(res.statusCode, 200); + assert.equal(body.users.length, 0); + }); + }); + + describe('post redirect', () => { + let jar; + before(async () => { + ({jar} = await helpers.loginUser('foo', 'barbar')); + }); + + it('should 404 for invalid pid', done => { + request(`${nconf.get('url')}/api/post/fail`, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + done(); + }); + }); + + it('should 403 if user does not have read privilege', done => { + privileges.categories.rescind(['groups:topics:read'], category.cid, 'registered-users', error => { + assert.ifError(error); + request(`${nconf.get('url')}/api/post/${pid}`, {jar}, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 403); + privileges.categories.give(['groups:topics:read'], category.cid, 'registered-users', done); + }); + }); + }); + + it('should return correct post path', done => { + request(`${nconf.get('url')}/api/post/${pid}`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(res.headers['x-redirect'], '/topic/1/test-topic-title/1'); + assert.equal(body, '/topic/1/test-topic-title/1'); + done(); + }); + }); + }); + + describe('cookie consent', () => { + it('should return relevant data in configs API route', done => { + request(`${nconf.get('url')}/api/config`, (error, res, body) => { + let parsed; + assert.ifError(error); + assert.equal(res.statusCode, 200); + + try { + parsed = JSON.parse(body); + } catch (error) { + assert.ifError(error); + } + + assert.ok(parsed.cookies); + assert.equal(translator.escape('[[global:cookies.message]]'), parsed.cookies.message); + assert.equal(translator.escape('[[global:cookies.accept]]'), parsed.cookies.dismiss); + assert.equal(translator.escape('[[global:cookies.learn_more]]'), parsed.cookies.link); + + done(); + }); + }); + + it('response should be parseable when entries have apostrophes', done => { + meta.configs.set('cookieConsentMessage', 'Julian\'s Message', error => { + assert.ifError(error); + + request(`${nconf.get('url')}/api/config`, (error, res, body) => { + let parsed; + assert.ifError(error); + assert.equal(res.statusCode, 200); + + try { + parsed = JSON.parse(body); + } catch (error) { + assert.ifError(error); + } + + assert.equal('Julian's Message', parsed.cookies.message); + done(); + }); + }); + }); + }); + + it('should return osd data', done => { + request(`${nconf.get('url')}/osd.xml`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + describe('handle errors', () => { + const plugins = require('../src/plugins'); + after(done => { + plugins.loadedHooks['filter:router.page'] = undefined; + done(); + }); + + it('should handle topic malformed uri', done => { + request(`${nconf.get('url')}/topic/1/a%AFc`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should handle category malformed uri', done => { + request(`${nconf.get('url')}/category/1/a%AFc`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should handle malformed uri ', done => { + request(`${nconf.get('url')}/user/a%AFc`, (error, res, body) => { + assert.ifError(error); + assert(body); + assert.equal(res.statusCode, 400); + done(); + }); + }); + + it('should handle malformed uri in api', done => { + request(`${nconf.get('url')}/api/user/a%AFc`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 400); + assert.equal(body.error, '[[global:400.title]]'); + done(); + }); + }); + + it('should handle CSRF error', done => { + plugins.loadedHooks['filter:router.page'] = plugins.loadedHooks['filter:router.page'] || []; + plugins.loadedHooks['filter:router.page'].push({ + method(request_, res, next) { + const error = new Error('csrf-error'); + error.code = 'EBADCSRFTOKEN'; + next(error); + }, + }); + + request(`${nconf.get('url')}/users`, {}, (error, res) => { + plugins.loadedHooks['filter:router.page'] = []; + assert.ifError(error); + assert.equal(res.statusCode, 403); + done(); + }); + }); + + it('should handle black-list error', done => { + plugins.loadedHooks['filter:router.page'] = plugins.loadedHooks['filter:router.page'] || []; + plugins.loadedHooks['filter:router.page'].push({ + method(request_, res, next) { + const error = new Error('blacklist error message'); + error.code = 'blacklisted-ip'; + next(error); + }, + }); + + request(`${nconf.get('url')}/users`, {}, (error, res, body) => { + plugins.loadedHooks['filter:router.page'] = []; + assert.ifError(error); + assert.equal(res.statusCode, 403); + assert.equal(body, 'blacklist error message'); + done(); + }); + }); + + it('should handle page redirect through error', done => { + plugins.loadedHooks['filter:router.page'] = plugins.loadedHooks['filter:router.page'] || []; + plugins.loadedHooks['filter:router.page'].push({ + method(request_, res, next) { + const error = new Error('redirect'); + error.status = 302; + error.path = '/popular'; + plugins.loadedHooks['filter:router.page'] = []; + next(error); + }, + }); + + request(`${nconf.get('url')}/users`, {}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should handle api page redirect through error', done => { + plugins.loadedHooks['filter:router.page'] = plugins.loadedHooks['filter:router.page'] || []; + plugins.loadedHooks['filter:router.page'].push({ + method(request_, res, next) { + const error = new Error('redirect'); + error.status = 308; + error.path = '/api/popular'; + plugins.loadedHooks['filter:router.page'] = []; + next(error); + }, + }); + + request(`${nconf.get('url')}/api/users`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(res.headers['x-redirect'], '/api/popular'); + assert(body, '/api/popular'); + done(); + }); + }); + + it('should handle error page', done => { + plugins.loadedHooks['filter:router.page'] = plugins.loadedHooks['filter:router.page'] || []; + plugins.loadedHooks['filter:router.page'].push({ + method(request_, res, next) { + const error = new Error('regular error'); + next(error); + }, + }); + + request(`${nconf.get('url')}/users`, (error, res, body) => { + plugins.loadedHooks['filter:router.page'] = []; + assert.ifError(error); + assert.equal(res.statusCode, 500); + assert(body); + done(); + }); + }); + }); + + describe('category', () => { + let jar; + before(async () => { + ({jar} = await helpers.loginUser('foo', 'barbar')); + }); + + it('should return 404 if cid is not a number', done => { + request(`${nconf.get('url')}/api/category/fail`, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + done(); + }); + }); + + it('should return 404 if topic index is not a number', done => { + request(`${nconf.get('url')}/api/category/${category.slug}/invalidtopicindex`, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + done(); + }); + }); + + it('should 404 if category does not exist', done => { + request(`${nconf.get('url')}/api/category/123123`, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + done(); + }); + }); + + it('should 404 if category is disabled', done => { + categories.create({name: 'disabled'}, (error, category) => { + assert.ifError(error); + categories.setCategoryField(category.cid, 'disabled', 1, error_ => { + assert.ifError(error_); + request(`${nconf.get('url')}/api/category/${category.slug}`, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + done(); + }); + }); + }); + }); + + it('should return 401 if not allowed to read', done => { + categories.create({name: 'hidden'}, (error, category) => { + assert.ifError(error); + privileges.categories.rescind(['groups:read'], category.cid, 'guests', error_ => { + assert.ifError(error_); + request(`${nconf.get('url')}/api/category/${category.slug}`, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 401); + done(); + }); + }); + }); + }); + + it('should redirect if topic index is negative', done => { + request(`${nconf.get('url')}/api/category/${category.slug}/-10`, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.ok(res.headers['x-redirect']); + done(); + }); + }); + + it('should 404 if page is not found', done => { + user.setSetting(fooUid, 'usePagination', 1, error => { + assert.ifError(error); + request(`${nconf.get('url')}/api/category/${category.slug}?page=100`, {jar, json: true}, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + done(); + }); + }); + }); + + it('should load page 1 if req.query.page is not sent', done => { + request(`${nconf.get('url')}/api/category/${category.slug}`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(body.pagination.currentPage, 1); + done(); + }); + }); + + it('should sort topics by most posts', done => { + async.waterfall([ + function (next) { + categories.create({name: 'most-posts-category'}, next); + }, + function (category, next) { + async.waterfall([ + function (next) { + topics.post({ + uid: fooUid, cid: category.cid, title: 'topic 1', content: 'topic 1 OP', + }, next); + }, + function (data, next) { + topics.post({ + uid: fooUid, cid: category.cid, title: 'topic 2', content: 'topic 2 OP', + }, next); + }, + function (data, next) { + topics.reply({uid: fooUid, content: 'topic 2 reply', tid: data.topicData.tid}, next); + }, + function (postData, next) { + request(`${nconf.get('url')}/api/category/${category.slug}?sort=most_posts`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(body.topics[0].title, 'topic 2'); + assert.equal(body.topics[0].postcount, 2); + assert.equal(body.topics[1].postcount, 1); + next(); + }); + }, + ], error => { + next(error); + }); + }, + ], done); + }); + + it('should load a specific users topics from a category with tags', done => { + async.waterfall([ + function (next) { + categories.create({name: 'filtered-category'}, next); + }, + function (category, next) { + async.waterfall([ + function (next) { + topics.post({ + uid: fooUid, cid: category.cid, title: 'topic 1', content: 'topic 1 OP', tags: ['java', 'cpp'], + }, next); + }, + function (data, next) { + topics.post({ + uid: fooUid, cid: category.cid, title: 'topic 2', content: 'topic 2 OP', tags: ['node', 'javascript'], + }, next); + }, + function (data, next) { + topics.post({ + uid: fooUid, cid: category.cid, title: 'topic 3', content: 'topic 3 OP', tags: ['java', 'cpp', 'best'], + }, next); + }, + function (data, next) { + request(`${nconf.get('url')}/api/category/${category.slug}?tag=node&author=foo`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(body.topics[0].title, 'topic 2'); + next(); + }); + }, + function (next) { + request(`${nconf.get('url')}/api/category/${category.slug}?tag[]=java&tag[]=cpp`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(body.topics[0].title, 'topic 3'); + assert.equal(body.topics[1].title, 'topic 1'); + next(); + }); + }, + ], error => { + next(error); + }); + }, + ], done); + }); + + it('should redirect if category is a link', done => { + let cid; + let category; + async.waterfall([ + function (next) { + categories.create({name: 'redirect', link: 'https://nodebb.org'}, next); + }, + function (_category, next) { + category = _category; + cid = category.cid; + request(`${nconf.get('url')}/api/category/${category.slug}`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(res.headers['x-redirect'], 'https://nodebb.org'); + assert.equal(body, 'https://nodebb.org'); + next(); + }); + }, + function (next) { + categories.setCategoryField(cid, 'link', '/recent', next); + }, + function (next) { + request(`${nconf.get('url')}/api/category/${category.slug}`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(res.headers['x-redirect'], '/recent'); + assert.equal(body, '/recent'); + next(); + }); + }, + ], done); + }); + + it('should get recent topic replies from children categories', done => { + let parentCategory; + let childCategory1; + let childCategory2; + + async.waterfall([ + function (next) { + categories.create({name: 'parent category', backgroundImage: 'path/to/some/image'}, next); + }, + function (category, next) { + parentCategory = category; + async.waterfall([ + function (next) { + categories.create({name: 'child category 1', parentCid: category.cid}, next); + }, + function (category, next) { + childCategory1 = category; + categories.create({name: 'child category 2', parentCid: parentCategory.cid}, next); + }, + function (category, next) { + childCategory2 = category; + topics.post({ + uid: fooUid, cid: childCategory2.cid, title: 'topic 1', content: 'topic 1 OP', + }, next); + }, + function (data, next) { + request(`${nconf.get('url')}/api/category/${parentCategory.slug}`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(body.children[0].posts[0].content, 'topic 1 OP'); + next(); + }); + }, + ], error => { + next(error); + }); + }, + ], done); + }); + + it('should create 2 pages of topics', done => { + async.waterfall([ + function (next) { + categories.create({name: 'category with 2 pages'}, next); + }, + function (category, next) { + const titles = []; + for (let i = 0; i < 30; i++) { + titles.push(`topic title ${i}`); + } + + async.waterfall([ + function (next) { + async.eachSeries(titles, (title, next) => { + topics.post({ + uid: fooUid, cid: category.cid, title, content: 'does not really matter', + }, next); + }, next); + }, + function (next) { + user.getSettings(fooUid, next); + }, + function (settings, next) { + request(`${nconf.get('url')}/api/category/${category.slug}`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(body.topics.length, settings.topicsPerPage); + assert.equal(body.pagination.pageCount, 2); + next(); + }); + }, + ], error => { + next(error); + }); + }, + ], done); + }); + + it('should load categories', async () => { + const helpers = require('../src/controllers/helpers'); + const data = await helpers.getCategories('cid:0:children', 1, 'topics:read', 0); + assert(data.categories.length > 0); + assert.strictEqual(data.selectedCategory, null); + assert.deepStrictEqual(data.selectedCids, []); + }); + + it('should load categories by states', async () => { + const helpers = require('../src/controllers/helpers'); + const data = await helpers.getCategoriesByStates(1, 1, Object.values(categories.watchStates), 'topics:read'); + assert.deepStrictEqual(data.selectedCategory.cid, 1); + assert.deepStrictEqual(data.selectedCids, [1]); + }); + + it('should load categories by states', async () => { + const helpers = require('../src/controllers/helpers'); + const data = await helpers.getCategoriesByStates(1, 0, [categories.watchStates.ignoring], 'topics:read'); + assert(data.categories.length === 0); + assert.deepStrictEqual(data.selectedCategory, null); + assert.deepStrictEqual(data.selectedCids, []); + }); + }); + + describe('unread', () => { + let jar; + before(async () => { + ({jar} = await helpers.loginUser('foo', 'barbar')); + }); + + it('should load unread page', done => { + request(`${nconf.get('url')}/api/unread`, {jar}, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + done(); + }); + }); + + it('should 404 if filter is invalid', done => { + request(`${nconf.get('url')}/api/unread/doesnotexist`, {jar}, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + done(); + }); + }); + + it('should return total unread count', done => { + request(`${nconf.get('url')}/api/unread/total?filter=new`, {jar}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(body, 0); + done(); + }); + }); + + it('should redirect if page is out of bounds', done => { + request(`${nconf.get('url')}/api/unread?page=-1`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(res.headers['x-redirect'], '/unread?page=1'); + assert.equal(body, '/unread?page=1'); + done(); + }); + }); + }); + + describe('admin middlewares', () => { + it('should redirect to login', done => { + request(`${nconf.get('url')}//api/admin/advanced/database`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 401); + done(); + }); + }); + + it('should redirect to login', done => { + request(`${nconf.get('url')}//admin/advanced/database`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body.includes('Login to your account')); + done(); + }); + }); + }); + + describe('composer', () => { + let csrf_token; + let jar; + + before(async () => { + const login = await helpers.loginUser('foo', 'barbar'); + jar = login.jar; + csrf_token = login.csrf_token; + }); + + it('should load the composer route', done => { + request(`${nconf.get('url')}/api/compose?cid=1`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body.title); + assert(body.template); + assert.equal(body.url, `${nconf.get('relative_path')}/compose`); + done(); + }); + }); + + it('should load the composer route if disabled by plugin', done => { + function hookMethod(hookData, callback) { + hookData.templateData.disabled = true; + callback(null, hookData); + } + + plugins.hooks.register('myTestPlugin', { + hook: 'filter:composer.build', + method: hookMethod, + }); + + request(`${nconf.get('url')}/api/compose?cid=1`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body.title); + assert.strictEqual(body.template.name, ''); + assert.strictEqual(body.url, `${nconf.get('relative_path')}/compose`); + + plugins.hooks.unregister('myTestPlugin', 'filter:composer.build', hookMethod); + done(); + }); + }); + + it('should error with invalid data', done => { + request.post(`${nconf.get('url')}/compose`, { + form: { + content: 'a new reply', + }, + jar, + headers: { + 'x-csrf-token': csrf_token, + }, + }, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 400); + request.post(`${nconf.get('url')}/compose`, { + form: { + tid, + }, + jar, + headers: { + 'x-csrf-token': csrf_token, + }, + }, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 400); + done(); + }); + }); + }); + + it('should create a new topic and reply by composer route', done => { + const data = { + cid, + title: 'no js is good', + content: 'a topic with noscript', + }; + request.post(`${nconf.get('url')}/compose`, { + form: data, + jar, + headers: { + 'x-csrf-token': csrf_token, + }, + }, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 302); + request.post(`${nconf.get('url')}/compose`, { + form: { + tid, + content: 'a new reply', + }, + jar, + headers: { + 'x-csrf-token': csrf_token, + }, + }, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 302); + done(); + }); + }); + }); + }); + + describe('test routes', () => { + if (process.env.NODE_ENV === 'development') { + it('should load debug route', done => { + request(`${nconf.get('url')}/debug/test`, {}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + assert(body); + done(); + }); + }); + + it('should load redoc read route', done => { + request(`${nconf.get('url')}/debug/spec/read`, {}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load redoc write route', done => { + request(`${nconf.get('url')}/debug/spec/write`, {}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load 404 for invalid type', done => { + request(`${nconf.get('url')}/debug/spec/doesnotexist`, {}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + assert(body); + done(); + }); + }); + } + }); + + after(done => { + const analytics = require('../src/analytics'); + analytics.writeData(done); + }); }); diff --git a/test/coverPhoto.js b/test/coverPhoto.js index 534c71b..2341087 100644 --- a/test/coverPhoto.js +++ b/test/coverPhoto.js @@ -1,24 +1,23 @@ 'use strict'; -const assert = require('assert'); +const assert = require('node:assert'); const nconf = require('nconf'); - -const db = require('./mocks/databasemock'); const coverPhoto = require('../src/coverPhoto'); const meta = require('../src/meta'); +const db = require('./mocks/databasemock'); describe('coverPhoto', () => { - it('should get default group cover', (done) => { - meta.config['groups:defaultCovers'] = '/assets/image1.png, /assets/image2.png'; - const result = coverPhoto.getDefaultGroupCover('registered-users'); - assert.equal(result, `${nconf.get('relative_path')}/assets/image2.png`); - done(); - }); + it('should get default group cover', done => { + meta.config['groups:defaultCovers'] = '/assets/image1.png, /assets/image2.png'; + const result = coverPhoto.getDefaultGroupCover('registered-users'); + assert.equal(result, `${nconf.get('relative_path')}/assets/image2.png`); + done(); + }); - it('should get default default profile cover', (done) => { - meta.config['profile:defaultCovers'] = ' /assets/image1.png, /assets/image2.png '; - const result = coverPhoto.getDefaultProfileCover(1); - assert.equal(result, `${nconf.get('relative_path')}/assets/image2.png`); - done(); - }); + it('should get default default profile cover', done => { + meta.config['profile:defaultCovers'] = ' /assets/image1.png, /assets/image2.png '; + const result = coverPhoto.getDefaultProfileCover(1); + assert.equal(result, `${nconf.get('relative_path')}/assets/image2.png`); + done(); + }); }); diff --git a/test/database.js b/test/database.js index 2a18853..d67675f 100644 --- a/test/database.js +++ b/test/database.js @@ -1,66 +1,76 @@ 'use strict'; - -const assert = require('assert'); +const assert = require('node:assert'); const nconf = require('nconf'); const db = require('./mocks/databasemock'); - describe('Test database', () => { - it('should work', () => { - assert.doesNotThrow(() => { - require('./mocks/databasemock'); - }); - }); + it('should work', () => { + assert.doesNotThrow(() => { + require('./mocks/databasemock'); + }); + }); + + describe('info', () => { + it('should return info about database', done => { + db.info(db.client, (error, info) => { + assert.ifError(error); + assert(info); + done(); + }); + }); + + it('should not error and return info if client is falsy', done => { + db.info(null, (error, info) => { + assert.ifError(error); + assert(info); + done(); + }); + }); + }); + + describe('checkCompatibility', () => { + it('should not throw', done => { + db.checkCompatibility(done); + }); + + it('should return error with a too low version', done => { + const databaseName = nconf.get('database'); + switch (databaseName) { + case 'redis': { + db.checkCompatibilityVersion('2.4.0', error => { + assert.equal(error.message, 'Your Redis version is not new enough to support NodeBB, please upgrade Redis to v2.8.9 or higher.'); + done(); + }); - describe('info', () => { - it('should return info about database', (done) => { - db.info(db.client, (err, info) => { - assert.ifError(err); - assert(info); - done(); - }); - }); + break; + } - it('should not error and return info if client is falsy', (done) => { - db.info(null, (err, info) => { - assert.ifError(err); - assert(info); - done(); - }); - }); - }); + case 'mongo': { + db.checkCompatibilityVersion('1.8.0', error => { + assert.equal(error.message, 'The `mongodb` package is out-of-date, please run `./nodebb setup` again.'); + done(); + }); - describe('checkCompatibility', () => { - it('should not throw', (done) => { - db.checkCompatibility(done); - }); + break; + } - it('should return error with a too low version', (done) => { - const dbName = nconf.get('database'); - if (dbName === 'redis') { - db.checkCompatibilityVersion('2.4.0', (err) => { - assert.equal(err.message, 'Your Redis version is not new enough to support NodeBB, please upgrade Redis to v2.8.9 or higher.'); - done(); - }); - } else if (dbName === 'mongo') { - db.checkCompatibilityVersion('1.8.0', (err) => { - assert.equal(err.message, 'The `mongodb` package is out-of-date, please run `./nodebb setup` again.'); - done(); - }); - } else if (dbName === 'postgres') { - db.checkCompatibilityVersion('6.3.0', (err) => { - assert.equal(err.message, 'The `pg` package is out-of-date, please run `./nodebb setup` again.'); - done(); - }); - } - }); - }); + case 'postgres': { + db.checkCompatibilityVersion('6.3.0', error => { + assert.equal(error.message, 'The `pg` package is out-of-date, please run `./nodebb setup` again.'); + done(); + }); + break; + } + // No default + } + }); + }); - require('./database/keys'); - require('./database/list'); - require('./database/sets'); - require('./database/hash'); - require('./database/sorted'); + require('./database/keys'); + require('./database/list'); + require('./database/sets'); + require('./database/hash'); + require('./database/sorted'); }); diff --git a/test/database/hash.js b/test/database/hash.js index 5b2ac04..d390273 100644 --- a/test/database/hash.js +++ b/test/database/hash.js @@ -1,677 +1,680 @@ 'use strict'; - +const assert = require('node:assert'); const async = require('async'); -const assert = require('assert'); const db = require('../mocks/databasemock'); describe('Hash methods', () => { - const testData = { - name: 'baris', - lastname: 'usakli', - age: 99, - }; - - beforeEach((done) => { - db.setObject('hashTestObject', testData, done); - }); - - describe('setObject()', () => { - it('should create a object', (done) => { - db.setObject('testObject1', { foo: 'baris', bar: 99 }, function (err) { - assert.ifError(err); - assert(arguments.length < 2); - done(); - }); - }); - - it('should set two objects to same data', async () => { - const data = { foo: 'baz', test: '1' }; - await db.setObject(['multiObject1', 'multiObject2'], data); - const result = await db.getObjects(['multiObject1', 'multiObject2']); - assert.deepStrictEqual(result[0], data); - assert.deepStrictEqual(result[1], data); - }); - - it('should do nothing if key is falsy', (done) => { - db.setObject('', { foo: 1, derp: 2 }, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should do nothing if data is falsy', (done) => { - db.setObject('falsy', null, (err) => { - assert.ifError(err); - db.exists('falsy', (err, exists) => { - assert.ifError(err); - assert.equal(exists, false); - done(); - }); - }); - }); - - it('should not error if a key is empty string', (done) => { - db.setObject('emptyField', { '': '', b: 1 }, (err) => { - assert.ifError(err); - db.getObject('emptyField', (err, data) => { - assert.ifError(err); - done(); - }); - }); - }); - - it('should work for field names with "." in them', (done) => { - db.setObject('dotObject', { 'my.dot.field': 'foo' }, (err) => { - assert.ifError(err); - db.getObject('dotObject', (err, data) => { - assert.ifError(err); - assert.equal(data['my.dot.field'], 'foo'); - done(); - }); - }); - }); - - it('should set multiple keys to different objects', async () => { - await db.setObjectBulk([ - ['bulkKey1', { foo: '1' }], - ['bulkKey2', { baz: 'baz' }], - ]); - const result = await db.getObjects(['bulkKey1', 'bulkKey2']); - assert.deepStrictEqual(result, [{ foo: '1' }, { baz: 'baz' }]); - }); - - it('should not error if object is empty', async () => { - await db.setObjectBulk([ - ['bulkKey3', { foo: '1' }], - ['bulkKey4', { }], - ]); - const result = await db.getObjects(['bulkKey3', 'bulkKey4']); - assert.deepStrictEqual(result, [{ foo: '1' }, null]); - }); - - it('should update existing object on second call', async () => { - await db.setObjectBulk([['bulkKey3.5', { foo: '1' }]]); - await db.setObjectBulk([['bulkKey3.5', { baz: '2' }]]); - const result = await db.getObject('bulkKey3.5'); - assert.deepStrictEqual(result, { foo: '1', baz: '2' }); - }); - - it('should not error if object is empty', async () => { - await db.setObjectBulk([['bulkKey5', {}]]); - const result = await db.getObjects(['bulkKey5']); - assert.deepStrictEqual(result, [null]); - }); - - it('should not error if object is empty', async () => { - const keys = ['bulkKey6', 'bulkKey7']; - const data = {}; - - await db.setObject(keys, data); - const result = await db.getObjects(keys); - assert.deepStrictEqual(result, [null, null]); - }); - - it('should not error if object is empty', async () => { - await db.setObject('emptykey', {}); - const result = await db.getObject('emptykey'); - assert.deepStrictEqual(result, null); - }); - }); - - describe('setObjectField()', () => { - it('should create a new object with field', (done) => { - db.setObjectField('testObject2', 'name', 'ginger', function (err) { - assert.ifError(err); - assert(arguments.length < 2); - done(); - }); - }); - - it('should add a new field to an object', (done) => { - db.setObjectField('testObject2', 'type', 'cat', function (err) { - assert.ifError(err, null); - assert(arguments.length < 2); - done(); - }); - }); - - it('should set two objects fields to same data', async () => { - const data = { foo: 'baz', test: '1' }; - await db.setObjectField(['multiObject1', 'multiObject2'], 'myField', '2'); - const result = await db.getObjects(['multiObject1', 'multiObject2']); - assert.deepStrictEqual(result[0].myField, '2'); - assert.deepStrictEqual(result[1].myField, '2'); - }); - - it('should work for field names with "." in them', (done) => { - db.setObjectField('dotObject2', 'my.dot.field', 'foo2', (err) => { - assert.ifError(err); - db.getObjectField('dotObject2', 'my.dot.field', (err, value) => { - assert.ifError(err); - assert.equal(value, 'foo2'); - done(); - }); - }); - }); - - it('should work for field names with "." in them when they are cached', (done) => { - db.setObjectField('dotObject3', 'my.dot.field', 'foo2', (err) => { - assert.ifError(err); - db.getObject('dotObject3', (err, data) => { - assert.ifError(err); - db.getObjectField('dotObject3', 'my.dot.field', (err, value) => { - assert.ifError(err); - assert.equal(value, 'foo2'); - done(); - }); - }); - }); - }); - }); - - describe('getObject()', () => { - it('should return falsy if object does not exist', (done) => { - db.getObject('doesnotexist', function (err, data) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(!!data, false); - done(); - }); - }); - - it('should retrieve an object', (done) => { - db.getObject('hashTestObject', (err, data) => { - assert.equal(err, null); - assert.equal(data.name, testData.name); - assert.equal(data.age, testData.age); - assert.equal(data.lastname, 'usakli'); - done(); - }); - }); - - it('should return null if key is falsy', (done) => { - db.getObject(null, function (err, data) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.equal(data, null); - done(); - }); - }); - - it('should return fields if given', async () => { - const data = await db.getObject('hashTestObject', ['name', 'age']); - assert.strictEqual(data.name, 'baris'); - assert.strictEqual(parseInt(data.age, 10), 99); - }); - }); - - describe('getObjects()', () => { - before((done) => { - async.parallel([ - async.apply(db.setObject, 'testObject4', { name: 'baris' }), - async.apply(db.setObjectField, 'testObject5', 'name', 'ginger'), - ], done); - }); - - it('should return 3 objects with correct data', (done) => { - db.getObjects(['testObject4', 'testObject5', 'doesnotexist'], function (err, objects) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(Array.isArray(objects) && objects.length === 3, true); - assert.equal(objects[0].name, 'baris'); - assert.equal(objects[1].name, 'ginger'); - assert.equal(!!objects[2], false); - done(); - }); - }); - - it('should return fields if given', async () => { - await db.setObject('fieldsObj1', { foo: 'foo', baz: 'baz', herp: 'herp' }); - await db.setObject('fieldsObj2', { foo: 'foo2', baz: 'baz2', herp: 'herp2', onlyin2: 'onlyin2' }); - const data = await db.getObjects(['fieldsObj1', 'fieldsObj2'], ['baz', 'doesnotexist', 'onlyin2']); - assert.strictEqual(data[0].baz, 'baz'); - assert.strictEqual(data[0].doesnotexist, null); - assert.strictEqual(data[0].onlyin2, null); - assert.strictEqual(data[1].baz, 'baz2'); - assert.strictEqual(data[1].doesnotexist, null); - assert.strictEqual(data[1].onlyin2, 'onlyin2'); - }); - }); - - describe('getObjectField()', () => { - it('should return falsy if object does not exist', (done) => { - db.getObjectField('doesnotexist', 'fieldName', function (err, value) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(!!value, false); - done(); - }); - }); - - it('should return falsy if field does not exist', (done) => { - db.getObjectField('hashTestObject', 'fieldName', function (err, value) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(!!value, false); - done(); - }); - }); - - it('should get an objects field', (done) => { - db.getObjectField('hashTestObject', 'lastname', function (err, value) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(value, 'usakli'); - done(); - }); - }); - - it('should return null if key is falsy', (done) => { - db.getObjectField(null, 'test', function (err, data) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.equal(data, null); - done(); - }); - }); - - it('should return null and not error', async () => { - const data = await db.getObjectField('hashTestObject', ['field1', 'field2']); - assert.strictEqual(data, null); - }); - }); - - describe('getObjectFields()', () => { - it('should return an object with falsy values', (done) => { - db.getObjectFields('doesnotexist', ['field1', 'field2'], function (err, object) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(typeof object, 'object'); - assert.equal(!!object.field1, false); - assert.equal(!!object.field2, false); - done(); - }); - }); - - it('should return an object with correct fields', (done) => { - db.getObjectFields('hashTestObject', ['lastname', 'age', 'field1'], function (err, object) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(typeof object, 'object'); - assert.equal(object.lastname, 'usakli'); - assert.equal(object.age, 99); - assert.equal(!!object.field1, false); - done(); - }); - }); - - it('should return null if key is falsy', (done) => { - db.getObjectFields(null, ['test', 'foo'], function (err, data) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.equal(data, null); - done(); - }); - }); - }); - - describe('getObjectsFields()', () => { - before((done) => { - async.parallel([ - async.apply(db.setObject, 'testObject8', { name: 'baris', age: 99 }), - async.apply(db.setObject, 'testObject9', { name: 'ginger', age: 3 }), - ], done); - }); - - it('should return an array of objects with correct values', (done) => { - db.getObjectsFields(['testObject8', 'testObject9', 'doesnotexist'], ['name', 'age'], function (err, objects) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(Array.isArray(objects), true); - assert.equal(objects.length, 3); - assert.equal(objects[0].name, 'baris'); - assert.equal(objects[0].age, 99); - assert.equal(objects[1].name, 'ginger'); - assert.equal(objects[1].age, 3); - assert.equal(!!objects[2].name, false); - done(); - }); - }); - - it('should return undefined for all fields if object does not exist', (done) => { - db.getObjectsFields(['doesnotexist1', 'doesnotexist2'], ['name', 'age'], (err, data) => { - assert.ifError(err); - assert(Array.isArray(data)); - assert.equal(data[0].name, null); - assert.equal(data[0].age, null); - assert.equal(data[1].name, null); - assert.equal(data[1].age, null); - done(); - }); - }); - - it('should return all fields if fields is empty array', async () => { - const objects = await db.getObjectsFields(['testObject8', 'testObject9', 'doesnotexist'], []); - assert(Array.isArray(objects)); - assert.strict(objects.length, 3); - assert.strictEqual(objects[0].name, 'baris'); - assert.strictEqual(Number(objects[0].age), 99); - assert.strictEqual(objects[1].name, 'ginger'); - assert.strictEqual(Number(objects[1].age), 3); - assert.strictEqual(!!objects[2], false); - }); - - it('should return objects if fields is not an array', async () => { - const objects = await db.getObjectsFields(['testObject8', 'testObject9', 'doesnotexist'], undefined); - assert.strictEqual(objects[0].name, 'baris'); - assert.strictEqual(Number(objects[0].age), 99); - assert.strictEqual(objects[1].name, 'ginger'); - assert.strictEqual(Number(objects[1].age), 3); - assert.strictEqual(!!objects[2], false); - }); - }); - - describe('getObjectKeys()', () => { - it('should return an empty array for a object that does not exist', (done) => { - db.getObjectKeys('doesnotexist', function (err, keys) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(Array.isArray(keys) && keys.length === 0, true); - done(); - }); - }); - - it('should return an array of keys for the object\'s fields', (done) => { - db.getObjectKeys('hashTestObject', function (err, keys) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(Array.isArray(keys) && keys.length === 3, true); - keys.forEach((key) => { - assert.notEqual(['name', 'lastname', 'age'].indexOf(key), -1); - }); - done(); - }); - }); - }); - - describe('getObjectValues()', () => { - it('should return an empty array for a object that does not exist', (done) => { - db.getObjectValues('doesnotexist', function (err, values) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(Array.isArray(values) && values.length === 0, true); - done(); - }); - }); - - it('should return an array of values for the object\'s fields', (done) => { - db.getObjectValues('hashTestObject', function (err, values) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(Array.isArray(values) && values.length === 3, true); - assert.deepEqual(['baris', 'usakli', 99].sort(), values.sort()); - done(); - }); - }); - }); - - describe('isObjectField()', () => { - it('should return false if object does not exist', (done) => { - db.isObjectField('doesnotexist', 'field1', function (err, value) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(value, false); - done(); - }); - }); - - it('should return false if field does not exist', (done) => { - db.isObjectField('hashTestObject', 'field1', function (err, value) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(value, false); - done(); - }); - }); - - it('should return true if field exists', (done) => { - db.isObjectField('hashTestObject', 'name', function (err, value) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(value, true); - done(); - }); - }); - - it('should not error if field is falsy', async () => { - const value = await db.isObjectField('hashTestObjectEmpty', ''); - assert.strictEqual(value, false); - }); - }); - - - describe('isObjectFields()', () => { - it('should return an array of false if object does not exist', (done) => { - db.isObjectFields('doesnotexist', ['field1', 'field2'], function (err, values) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(values, [false, false]); - done(); - }); - }); - - it('should return false if field does not exist', (done) => { - db.isObjectFields('hashTestObject', ['name', 'age', 'field1'], function (err, values) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(values, [true, true, false]); - done(); - }); - }); - - it('should not error if one field is falsy', async () => { - const values = await db.isObjectFields('hashTestObject', ['name', '']); - assert.deepStrictEqual(values, [true, false]); - }); - }); - - describe('deleteObjectField()', () => { - before((done) => { - db.setObject('testObject10', { foo: 'bar', delete: 'this', delete1: 'this', delete2: 'this' }, done); - }); - - it('should delete an objects field', (done) => { - db.deleteObjectField('testObject10', 'delete', function (err) { - assert.ifError(err); - assert(arguments.length < 2); - db.isObjectField('testObject10', 'delete', (err, isField) => { - assert.ifError(err); - assert.equal(isField, false); - done(); - }); - }); - }); - - it('should delete multiple fields of the object', (done) => { - db.deleteObjectFields('testObject10', ['delete1', 'delete2'], function (err) { - assert.ifError(err); - assert(arguments.length < 2); - async.parallel({ - delete1: async.apply(db.isObjectField, 'testObject10', 'delete1'), - delete2: async.apply(db.isObjectField, 'testObject10', 'delete2'), - }, (err, results) => { - assert.ifError(err); - assert.equal(results.delete1, false); - assert.equal(results.delete2, false); - done(); - }); - }); - }); - - it('should delete multiple fields of multiple objects', async () => { - await db.setObject('deleteFields1', { foo: 'foo1', baz: '2' }); - await db.setObject('deleteFields2', { foo: 'foo2', baz: '3' }); - await db.deleteObjectFields(['deleteFields1', 'deleteFields2'], ['baz']); - const obj1 = await db.getObject('deleteFields1'); - const obj2 = await db.getObject('deleteFields2'); - assert.deepStrictEqual(obj1, { foo: 'foo1' }); - assert.deepStrictEqual(obj2, { foo: 'foo2' }); - }); - - it('should not error if fields is empty array', async () => { - await db.deleteObjectFields('someKey', []); - }); - - it('should not error if key is undefined', (done) => { - db.deleteObjectField(undefined, 'someField', (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should not error if key is null', (done) => { - db.deleteObjectField(null, 'someField', (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should not error if field is undefined', (done) => { - db.deleteObjectField('someKey', undefined, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should not error if one of the fields is undefined', async () => { - await db.deleteObjectFields('someKey', ['best', undefined]); - }); - - it('should not error if field is null', (done) => { - db.deleteObjectField('someKey', null, (err) => { - assert.ifError(err); - done(); - }); - }); - }); - - describe('incrObjectField()', () => { - before((done) => { - db.setObject('testObject11', { age: 99 }, done); - }); - - it('should set an objects field to 1 if object does not exist', (done) => { - db.incrObjectField('testObject12', 'field1', function (err, newValue) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.strictEqual(newValue, 1); - done(); - }); - }); - - it('should increment an object fields by 1 and return it', (done) => { - db.incrObjectField('testObject11', 'age', function (err, newValue) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.strictEqual(newValue, 100); - done(); - }); - }); - }); - - describe('decrObjectField()', () => { - before((done) => { - db.setObject('testObject13', { age: 99 }, done); - }); - - it('should set an objects field to -1 if object does not exist', (done) => { - db.decrObjectField('testObject14', 'field1', function (err, newValue) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(newValue, -1); - done(); - }); - }); - - it('should decrement an object fields by 1 and return it', (done) => { - db.decrObjectField('testObject13', 'age', function (err, newValue) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(newValue, 98); - done(); - }); - }); - - it('should decrement multiple objects field by 1 and return an array of new values', (done) => { - db.decrObjectField(['testObject13', 'testObject14', 'decrTestObject'], 'age', (err, data) => { - assert.ifError(err); - assert.equal(data[0], 97); - assert.equal(data[1], -1); - assert.equal(data[2], -1); - done(); - }); - }); - }); - - describe('incrObjectFieldBy()', () => { - before((done) => { - db.setObject('testObject15', { age: 100 }, done); - }); - - it('should set an objects field to 5 if object does not exist', (done) => { - db.incrObjectFieldBy('testObject16', 'field1', 5, function (err, newValue) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.equal(newValue, 5); - done(); - }); - }); - - it('should increment an object fields by passed in value and return it', (done) => { - db.incrObjectFieldBy('testObject15', 'age', 11, function (err, newValue) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.equal(newValue, 111); - done(); - }); - }); - - it('should increment an object fields by passed in value and return it', (done) => { - db.incrObjectFieldBy('testObject15', 'age', '11', (err, newValue) => { - assert.ifError(err); - assert.equal(newValue, 122); - done(); - }); - }); - - it('should return null if value is NaN', (done) => { - db.incrObjectFieldBy('testObject15', 'lastonline', 'notanumber', (err, newValue) => { - assert.ifError(err); - assert.strictEqual(newValue, null); - db.isObjectField('testObject15', 'lastonline', (err, isField) => { - assert.ifError(err); - assert(!isField); - done(); - }); - }); - }); - }); - - describe('incrObjectFieldByBulk', () => { - before(async () => { - await db.setObject('testObject16', { age: 100 }); - }); - - it('should increment multiple object fields', async () => { - await db.incrObjectFieldByBulk([ - ['testObject16', { age: 5, newField: 10 }], - ['testObject17', { newField: -5 }], - ]); - const d = await db.getObjects(['testObject16', 'testObject17']); - assert.equal(d[0].age, 105); - assert.equal(d[0].newField, 10); - assert.equal(d[1].newField, -5); - }); - }); + const testData = { + name: 'baris', + lastname: 'usakli', + age: 99, + }; + + beforeEach(done => { + db.setObject('hashTestObject', testData, done); + }); + + describe('setObject()', () => { + it('should create a object', done => { + db.setObject('testObject1', {foo: 'baris', bar: 99}, function (error) { + assert.ifError(error); + assert(arguments.length < 2); + done(); + }); + }); + + it('should set two objects to same data', async () => { + const data = {foo: 'baz', test: '1'}; + await db.setObject(['multiObject1', 'multiObject2'], data); + const result = await db.getObjects(['multiObject1', 'multiObject2']); + assert.deepStrictEqual(result[0], data); + assert.deepStrictEqual(result[1], data); + }); + + it('should do nothing if key is falsy', done => { + db.setObject('', {foo: 1, derp: 2}, error => { + assert.ifError(error); + done(); + }); + }); + + it('should do nothing if data is falsy', done => { + db.setObject('falsy', null, error => { + assert.ifError(error); + db.exists('falsy', (error, exists) => { + assert.ifError(error); + assert.equal(exists, false); + done(); + }); + }); + }); + + it('should not error if a key is empty string', done => { + db.setObject('emptyField', {'': '', b: 1}, error => { + assert.ifError(error); + db.getObject('emptyField', (error, data) => { + assert.ifError(error); + done(); + }); + }); + }); + + it('should work for field names with "." in them', done => { + db.setObject('dotObject', {'my.dot.field': 'foo'}, error => { + assert.ifError(error); + db.getObject('dotObject', (error, data) => { + assert.ifError(error); + assert.equal(data['my.dot.field'], 'foo'); + done(); + }); + }); + }); + + it('should set multiple keys to different objects', async () => { + await db.setObjectBulk([ + ['bulkKey1', {foo: '1'}], + ['bulkKey2', {baz: 'baz'}], + ]); + const result = await db.getObjects(['bulkKey1', 'bulkKey2']); + assert.deepStrictEqual(result, [{foo: '1'}, {baz: 'baz'}]); + }); + + it('should not error if object is empty', async () => { + await db.setObjectBulk([ + ['bulkKey3', {foo: '1'}], + ['bulkKey4', {}], + ]); + const result = await db.getObjects(['bulkKey3', 'bulkKey4']); + assert.deepStrictEqual(result, [{foo: '1'}, null]); + }); + + it('should update existing object on second call', async () => { + await db.setObjectBulk([['bulkKey3.5', {foo: '1'}]]); + await db.setObjectBulk([['bulkKey3.5', {baz: '2'}]]); + const result = await db.getObject('bulkKey3.5'); + assert.deepStrictEqual(result, {foo: '1', baz: '2'}); + }); + + it('should not error if object is empty', async () => { + await db.setObjectBulk([['bulkKey5', {}]]); + const result = await db.getObjects(['bulkKey5']); + assert.deepStrictEqual(result, [null]); + }); + + it('should not error if object is empty', async () => { + const keys = ['bulkKey6', 'bulkKey7']; + const data = {}; + + await db.setObject(keys, data); + const result = await db.getObjects(keys); + assert.deepStrictEqual(result, [null, null]); + }); + + it('should not error if object is empty', async () => { + await db.setObject('emptykey', {}); + const result = await db.getObject('emptykey'); + assert.deepStrictEqual(result, null); + }); + }); + + describe('setObjectField()', () => { + it('should create a new object with field', done => { + db.setObjectField('testObject2', 'name', 'ginger', function (error) { + assert.ifError(error); + assert(arguments.length < 2); + done(); + }); + }); + + it('should add a new field to an object', done => { + db.setObjectField('testObject2', 'type', 'cat', function (error) { + assert.ifError(error, null); + assert(arguments.length < 2); + done(); + }); + }); + + it('should set two objects fields to same data', async () => { + const data = {foo: 'baz', test: '1'}; + await db.setObjectField(['multiObject1', 'multiObject2'], 'myField', '2'); + const result = await db.getObjects(['multiObject1', 'multiObject2']); + assert.deepStrictEqual(result[0].myField, '2'); + assert.deepStrictEqual(result[1].myField, '2'); + }); + + it('should work for field names with "." in them', done => { + db.setObjectField('dotObject2', 'my.dot.field', 'foo2', error => { + assert.ifError(error); + db.getObjectField('dotObject2', 'my.dot.field', (error, value) => { + assert.ifError(error); + assert.equal(value, 'foo2'); + done(); + }); + }); + }); + + it('should work for field names with "." in them when they are cached', done => { + db.setObjectField('dotObject3', 'my.dot.field', 'foo2', error => { + assert.ifError(error); + db.getObject('dotObject3', (error, data) => { + assert.ifError(error); + db.getObjectField('dotObject3', 'my.dot.field', (error, value) => { + assert.ifError(error); + assert.equal(value, 'foo2'); + done(); + }); + }); + }); + }); + }); + + describe('getObject()', () => { + it('should return falsy if object does not exist', done => { + db.getObject('doesnotexist', function (error, data) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(Boolean(data), false); + done(); + }); + }); + + it('should retrieve an object', done => { + db.getObject('hashTestObject', (error, data) => { + assert.equal(error, null); + assert.equal(data.name, testData.name); + assert.equal(data.age, testData.age); + assert.equal(data.lastname, 'usakli'); + done(); + }); + }); + + it('should return null if key is falsy', done => { + db.getObject(null, function (error, data) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.equal(data, null); + done(); + }); + }); + + it('should return fields if given', async () => { + const data = await db.getObject('hashTestObject', ['name', 'age']); + assert.strictEqual(data.name, 'baris'); + assert.strictEqual(Number.parseInt(data.age, 10), 99); + }); + }); + + describe('getObjects()', () => { + before(done => { + async.parallel([ + async.apply(db.setObject, 'testObject4', {name: 'baris'}), + async.apply(db.setObjectField, 'testObject5', 'name', 'ginger'), + ], done); + }); + + it('should return 3 objects with correct data', done => { + db.getObjects(['testObject4', 'testObject5', 'doesnotexist'], function (error, objects) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(Array.isArray(objects) && objects.length === 3, true); + assert.equal(objects[0].name, 'baris'); + assert.equal(objects[1].name, 'ginger'); + assert.equal(Boolean(objects[2]), false); + done(); + }); + }); + + it('should return fields if given', async () => { + await db.setObject('fieldsObj1', {foo: 'foo', baz: 'baz', herp: 'herp'}); + await db.setObject('fieldsObj2', { + foo: 'foo2', baz: 'baz2', herp: 'herp2', onlyin2: 'onlyin2', + }); + const data = await db.getObjects(['fieldsObj1', 'fieldsObj2'], ['baz', 'doesnotexist', 'onlyin2']); + assert.strictEqual(data[0].baz, 'baz'); + assert.strictEqual(data[0].doesnotexist, null); + assert.strictEqual(data[0].onlyin2, null); + assert.strictEqual(data[1].baz, 'baz2'); + assert.strictEqual(data[1].doesnotexist, null); + assert.strictEqual(data[1].onlyin2, 'onlyin2'); + }); + }); + + describe('getObjectField()', () => { + it('should return falsy if object does not exist', done => { + db.getObjectField('doesnotexist', 'fieldName', function (error, value) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(Boolean(value), false); + done(); + }); + }); + + it('should return falsy if field does not exist', done => { + db.getObjectField('hashTestObject', 'fieldName', function (error, value) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(Boolean(value), false); + done(); + }); + }); + + it('should get an objects field', done => { + db.getObjectField('hashTestObject', 'lastname', function (error, value) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(value, 'usakli'); + done(); + }); + }); + + it('should return null if key is falsy', done => { + db.getObjectField(null, 'test', function (error, data) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.equal(data, null); + done(); + }); + }); + + it('should return null and not error', async () => { + const data = await db.getObjectField('hashTestObject', ['field1', 'field2']); + assert.strictEqual(data, null); + }); + }); + + describe('getObjectFields()', () => { + it('should return an object with falsy values', done => { + db.getObjectFields('doesnotexist', ['field1', 'field2'], function (error, object) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(typeof object, 'object'); + assert.equal(Boolean(object.field1), false); + assert.equal(Boolean(object.field2), false); + done(); + }); + }); + + it('should return an object with correct fields', done => { + db.getObjectFields('hashTestObject', ['lastname', 'age', 'field1'], function (error, object) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(typeof object, 'object'); + assert.equal(object.lastname, 'usakli'); + assert.equal(object.age, 99); + assert.equal(Boolean(object.field1), false); + done(); + }); + }); + + it('should return null if key is falsy', done => { + db.getObjectFields(null, ['test', 'foo'], function (error, data) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.equal(data, null); + done(); + }); + }); + }); + + describe('getObjectsFields()', () => { + before(done => { + async.parallel([ + async.apply(db.setObject, 'testObject8', {name: 'baris', age: 99}), + async.apply(db.setObject, 'testObject9', {name: 'ginger', age: 3}), + ], done); + }); + + it('should return an array of objects with correct values', done => { + db.getObjectsFields(['testObject8', 'testObject9', 'doesnotexist'], ['name', 'age'], function (error, objects) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(Array.isArray(objects), true); + assert.equal(objects.length, 3); + assert.equal(objects[0].name, 'baris'); + assert.equal(objects[0].age, 99); + assert.equal(objects[1].name, 'ginger'); + assert.equal(objects[1].age, 3); + assert.equal(Boolean(objects[2].name), false); + done(); + }); + }); + + it('should return undefined for all fields if object does not exist', done => { + db.getObjectsFields(['doesnotexist1', 'doesnotexist2'], ['name', 'age'], (error, data) => { + assert.ifError(error); + assert(Array.isArray(data)); + assert.equal(data[0].name, null); + assert.equal(data[0].age, null); + assert.equal(data[1].name, null); + assert.equal(data[1].age, null); + done(); + }); + }); + + it('should return all fields if fields is empty array', async () => { + const objects = await db.getObjectsFields(['testObject8', 'testObject9', 'doesnotexist'], []); + assert(Array.isArray(objects)); + assert.strict(objects.length, 3); + assert.strictEqual(objects[0].name, 'baris'); + assert.strictEqual(Number(objects[0].age), 99); + assert.strictEqual(objects[1].name, 'ginger'); + assert.strictEqual(Number(objects[1].age), 3); + assert.strictEqual(Boolean(objects[2]), false); + }); + + it('should return objects if fields is not an array', async () => { + const objects = await db.getObjectsFields(['testObject8', 'testObject9', 'doesnotexist'], undefined); + assert.strictEqual(objects[0].name, 'baris'); + assert.strictEqual(Number(objects[0].age), 99); + assert.strictEqual(objects[1].name, 'ginger'); + assert.strictEqual(Number(objects[1].age), 3); + assert.strictEqual(Boolean(objects[2]), false); + }); + }); + + describe('getObjectKeys()', () => { + it('should return an empty array for a object that does not exist', done => { + db.getObjectKeys('doesnotexist', function (error, keys) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(Array.isArray(keys) && keys.length === 0, true); + done(); + }); + }); + + it('should return an array of keys for the object\'s fields', done => { + db.getObjectKeys('hashTestObject', function (error, keys) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(Array.isArray(keys) && keys.length === 3, true); + for (const key of keys) { + assert.notEqual(['name', 'lastname', 'age'].indexOf(key), -1); + } + + done(); + }); + }); + }); + + describe('getObjectValues()', () => { + it('should return an empty array for a object that does not exist', done => { + db.getObjectValues('doesnotexist', function (error, values) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(Array.isArray(values) && values.length === 0, true); + done(); + }); + }); + + it('should return an array of values for the object\'s fields', done => { + db.getObjectValues('hashTestObject', function (error, values) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(Array.isArray(values) && values.length === 3, true); + assert.deepEqual(['baris', 'usakli', 99].sort(), values.sort()); + done(); + }); + }); + }); + + describe('isObjectField()', () => { + it('should return false if object does not exist', done => { + db.isObjectField('doesnotexist', 'field1', function (error, value) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(value, false); + done(); + }); + }); + + it('should return false if field does not exist', done => { + db.isObjectField('hashTestObject', 'field1', function (error, value) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(value, false); + done(); + }); + }); + + it('should return true if field exists', done => { + db.isObjectField('hashTestObject', 'name', function (error, value) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(value, true); + done(); + }); + }); + + it('should not error if field is falsy', async () => { + const value = await db.isObjectField('hashTestObjectEmpty', ''); + assert.strictEqual(value, false); + }); + }); + + describe('isObjectFields()', () => { + it('should return an array of false if object does not exist', done => { + db.isObjectFields('doesnotexist', ['field1', 'field2'], function (error, values) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(values, [false, false]); + done(); + }); + }); + + it('should return false if field does not exist', done => { + db.isObjectFields('hashTestObject', ['name', 'age', 'field1'], function (error, values) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(values, [true, true, false]); + done(); + }); + }); + + it('should not error if one field is falsy', async () => { + const values = await db.isObjectFields('hashTestObject', ['name', '']); + assert.deepStrictEqual(values, [true, false]); + }); + }); + + describe('deleteObjectField()', () => { + before(done => { + db.setObject('testObject10', { + foo: 'bar', delete: 'this', delete1: 'this', delete2: 'this', + }, done); + }); + + it('should delete an objects field', done => { + db.deleteObjectField('testObject10', 'delete', function (error) { + assert.ifError(error); + assert(arguments.length < 2); + db.isObjectField('testObject10', 'delete', (error, isField) => { + assert.ifError(error); + assert.equal(isField, false); + done(); + }); + }); + }); + + it('should delete multiple fields of the object', done => { + db.deleteObjectFields('testObject10', ['delete1', 'delete2'], function (error) { + assert.ifError(error); + assert(arguments.length < 2); + async.parallel({ + delete1: async.apply(db.isObjectField, 'testObject10', 'delete1'), + delete2: async.apply(db.isObjectField, 'testObject10', 'delete2'), + }, (error, results) => { + assert.ifError(error); + assert.equal(results.delete1, false); + assert.equal(results.delete2, false); + done(); + }); + }); + }); + + it('should delete multiple fields of multiple objects', async () => { + await db.setObject('deleteFields1', {foo: 'foo1', baz: '2'}); + await db.setObject('deleteFields2', {foo: 'foo2', baz: '3'}); + await db.deleteObjectFields(['deleteFields1', 'deleteFields2'], ['baz']); + const object1 = await db.getObject('deleteFields1'); + const object2 = await db.getObject('deleteFields2'); + assert.deepStrictEqual(object1, {foo: 'foo1'}); + assert.deepStrictEqual(object2, {foo: 'foo2'}); + }); + + it('should not error if fields is empty array', async () => { + await db.deleteObjectFields('someKey', []); + }); + + it('should not error if key is undefined', done => { + db.deleteObjectField(undefined, 'someField', error => { + assert.ifError(error); + done(); + }); + }); + + it('should not error if key is null', done => { + db.deleteObjectField(null, 'someField', error => { + assert.ifError(error); + done(); + }); + }); + + it('should not error if field is undefined', done => { + db.deleteObjectField('someKey', undefined, error => { + assert.ifError(error); + done(); + }); + }); + + it('should not error if one of the fields is undefined', async () => { + await db.deleteObjectFields('someKey', ['best', undefined]); + }); + + it('should not error if field is null', done => { + db.deleteObjectField('someKey', null, error => { + assert.ifError(error); + done(); + }); + }); + }); + + describe('incrObjectField()', () => { + before(done => { + db.setObject('testObject11', {age: 99}, done); + }); + + it('should set an objects field to 1 if object does not exist', done => { + db.incrObjectField('testObject12', 'field1', function (error, newValue) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.strictEqual(newValue, 1); + done(); + }); + }); + + it('should increment an object fields by 1 and return it', done => { + db.incrObjectField('testObject11', 'age', function (error, newValue) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.strictEqual(newValue, 100); + done(); + }); + }); + }); + + describe('decrObjectField()', () => { + before(done => { + db.setObject('testObject13', {age: 99}, done); + }); + + it('should set an objects field to -1 if object does not exist', done => { + db.decrObjectField('testObject14', 'field1', function (error, newValue) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(newValue, -1); + done(); + }); + }); + + it('should decrement an object fields by 1 and return it', done => { + db.decrObjectField('testObject13', 'age', function (error, newValue) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(newValue, 98); + done(); + }); + }); + + it('should decrement multiple objects field by 1 and return an array of new values', done => { + db.decrObjectField(['testObject13', 'testObject14', 'decrTestObject'], 'age', (error, data) => { + assert.ifError(error); + assert.equal(data[0], 97); + assert.equal(data[1], -1); + assert.equal(data[2], -1); + done(); + }); + }); + }); + + describe('incrObjectFieldBy()', () => { + before(done => { + db.setObject('testObject15', {age: 100}, done); + }); + + it('should set an objects field to 5 if object does not exist', done => { + db.incrObjectFieldBy('testObject16', 'field1', 5, function (error, newValue) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.equal(newValue, 5); + done(); + }); + }); + + it('should increment an object fields by passed in value and return it', done => { + db.incrObjectFieldBy('testObject15', 'age', 11, function (error, newValue) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.equal(newValue, 111); + done(); + }); + }); + + it('should increment an object fields by passed in value and return it', done => { + db.incrObjectFieldBy('testObject15', 'age', '11', (error, newValue) => { + assert.ifError(error); + assert.equal(newValue, 122); + done(); + }); + }); + + it('should return null if value is NaN', done => { + db.incrObjectFieldBy('testObject15', 'lastonline', 'notanumber', (error, newValue) => { + assert.ifError(error); + assert.strictEqual(newValue, null); + db.isObjectField('testObject15', 'lastonline', (error, isField) => { + assert.ifError(error); + assert(!isField); + done(); + }); + }); + }); + }); + + describe('incrObjectFieldByBulk', () => { + before(async () => { + await db.setObject('testObject16', {age: 100}); + }); + + it('should increment multiple object fields', async () => { + await db.incrObjectFieldByBulk([ + ['testObject16', {age: 5, newField: 10}], + ['testObject17', {newField: -5}], + ]); + const d = await db.getObjects(['testObject16', 'testObject17']); + assert.equal(d[0].age, 105); + assert.equal(d[0].newField, 10); + assert.equal(d[1].newField, -5); + }); + }); }); diff --git a/test/database/keys.js b/test/database/keys.js index d483961..e816b2f 100644 --- a/test/database/keys.js +++ b/test/database/keys.js @@ -1,353 +1,355 @@ 'use strict'; - +const assert = require('node:assert'); const async = require('async'); -const assert = require('assert'); const db = require('../mocks/databasemock'); describe('Key methods', () => { - beforeEach((done) => { - db.set('testKey', 'testValue', done); - }); - - it('should set a key without error', (done) => { - db.set('testKey', 'testValue', function (err) { - assert.ifError(err); - assert(arguments.length < 2); - done(); - }); - }); - - it('should get a key without error', (done) => { - db.get('testKey', function (err, value) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.strictEqual(value, 'testValue'); - done(); - }); - }); - - it('should return null if key does not exist', (done) => { - db.get('doesnotexist', (err, value) => { - assert.ifError(err); - assert.equal(value, null); - done(); - }); - }); - - it('should return true if key exist', (done) => { - db.exists('testKey', function (err, exists) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.strictEqual(exists, true); - done(); - }); - }); - - it('should return false if key does not exist', (done) => { - db.exists('doesnotexist', function (err, exists) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.strictEqual(exists, false); - done(); - }); - }); - - it('should work for an array of keys', (done) => { - db.exists(['testKey', 'doesnotexist'], (err, exists) => { - assert.ifError(err); - assert.deepStrictEqual(exists, [true, false]); - done(); - }); - }); - - describe('scan', () => { - it('should scan keys for pattern', async () => { - await db.sortedSetAdd('ip:123:uid', 1, 'a'); - await db.sortedSetAdd('ip:123:uid', 2, 'b'); - await db.sortedSetAdd('ip:124:uid', 2, 'b'); - await db.sortedSetAdd('ip:1:uid', 1, 'a'); - await db.sortedSetAdd('ip:23:uid', 1, 'a'); - const data = await db.scan({ match: 'ip:1*' }); - assert.equal(data.length, 3); - assert(data.includes('ip:123:uid')); - assert(data.includes('ip:124:uid')); - assert(data.includes('ip:1:uid')); - }); - }); - - it('should delete a key without error', (done) => { - db.delete('testKey', function (err) { - assert.ifError(err); - assert(arguments.length < 2); - - db.get('testKey', (err, value) => { - assert.ifError(err); - assert.equal(false, !!value); - done(); - }); - }); - }); - - it('should return false if key was deleted', (done) => { - db.delete('testKey', function (err) { - assert.ifError(err); - assert(arguments.length < 2); - db.exists('testKey', (err, exists) => { - assert.ifError(err); - assert.strictEqual(exists, false); - done(); - }); - }); - }); - - it('should delete all keys passed in', (done) => { - async.parallel([ - function (next) { - db.set('key1', 'value1', next); - }, - function (next) { - db.set('key2', 'value2', next); - }, - ], (err) => { - if (err) { - return done(err); - } - db.deleteAll(['key1', 'key2'], function (err) { - assert.ifError(err); - assert.equal(arguments.length, 1); - async.parallel({ - key1exists: function (next) { - db.exists('key1', next); - }, - key2exists: function (next) { - db.exists('key2', next); - }, - }, (err, results) => { - assert.ifError(err); - assert.equal(results.key1exists, false); - assert.equal(results.key2exists, false); - done(); - }); - }); - }); - }); - - it('should delete all sorted set elements', (done) => { - async.parallel([ - function (next) { - db.sortedSetAdd('deletezset', 1, 'value1', next); - }, - function (next) { - db.sortedSetAdd('deletezset', 2, 'value2', next); - }, - ], (err) => { - if (err) { - return done(err); - } - db.delete('deletezset', (err) => { - assert.ifError(err); - async.parallel({ - key1exists: function (next) { - db.isSortedSetMember('deletezset', 'value1', next); - }, - key2exists: function (next) { - db.isSortedSetMember('deletezset', 'value2', next); - }, - }, (err, results) => { - assert.ifError(err); - assert.equal(results.key1exists, false); - assert.equal(results.key2exists, false); - done(); - }); - }); - }); - }); - - describe('increment', () => { - it('should initialize key to 1', (done) => { - db.increment('keyToIncrement', (err, value) => { - assert.ifError(err); - assert.strictEqual(parseInt(value, 10), 1); - done(); - }); - }); - - it('should increment key to 2', (done) => { - db.increment('keyToIncrement', (err, value) => { - assert.ifError(err); - assert.strictEqual(parseInt(value, 10), 2); - done(); - }); - }); - - it('should set then increment a key', (done) => { - db.set('myIncrement', 1, (err) => { - assert.ifError(err); - db.increment('myIncrement', (err, value) => { - assert.ifError(err); - assert.equal(value, 2); - db.get('myIncrement', (err, value) => { - assert.ifError(err); - assert.equal(value, 2); - done(); - }); - }); - }); - }); - - it('should return the correct value', (done) => { - db.increment('testingCache', (err) => { - assert.ifError(err); - db.get('testingCache', (err, value) => { - assert.ifError(err); - assert.equal(value, 1); - db.increment('testingCache', (err) => { - assert.ifError(err); - db.get('testingCache', (err, value) => { - assert.ifError(err); - assert.equal(value, 2); - done(); - }); - }); - }); - }); - }); - }); - - describe('rename', () => { - it('should rename key to new name', (done) => { - db.set('keyOldName', 'renamedKeyValue', (err) => { - if (err) { - return done(err); - } - db.rename('keyOldName', 'keyNewName', function (err) { - assert.ifError(err); - assert(arguments.length < 2); - - db.get('keyNewName', (err, value) => { - assert.ifError(err); - assert.equal(value, 'renamedKeyValue'); - done(); - }); - }); - }); - }); - - it('should rename multiple keys', (done) => { - db.sortedSetAdd('zsettorename', [1, 2, 3], ['value1', 'value2', 'value3'], (err) => { - assert.ifError(err); - db.rename('zsettorename', 'newzsetname', (err) => { - assert.ifError(err); - db.exists('zsettorename', (err, exists) => { - assert.ifError(err); - assert(!exists); - db.getSortedSetRange('newzsetname', 0, -1, (err, values) => { - assert.ifError(err); - assert.deepEqual(['value1', 'value2', 'value3'], values); - done(); - }); - }); - }); - }); - }); - - it('should not error if old key does not exist', (done) => { - db.rename('doesnotexist', 'anotherdoesnotexist', (err) => { - assert.ifError(err); - db.exists('anotherdoesnotexist', (err, exists) => { - assert.ifError(err); - assert(!exists); - done(); - }); - }); - }); - }); - - describe('type', () => { - it('should return null if key does not exist', (done) => { - db.type('doesnotexist', (err, type) => { - assert.ifError(err); - assert.strictEqual(type, null); - done(); - }); - }); - - it('should return hash as type', (done) => { - db.setObject('typeHash', { foo: 1 }, (err) => { - assert.ifError(err); - db.type('typeHash', (err, type) => { - assert.ifError(err); - assert.equal(type, 'hash'); - done(); - }); - }); - }); - - it('should return zset as type', (done) => { - db.sortedSetAdd('typeZset', 123, 'value1', (err) => { - assert.ifError(err); - db.type('typeZset', (err, type) => { - assert.ifError(err); - assert.equal(type, 'zset'); - done(); - }); - }); - }); - - it('should return set as type', (done) => { - db.setAdd('typeSet', 'value1', (err) => { - assert.ifError(err); - db.type('typeSet', (err, type) => { - assert.ifError(err); - assert.equal(type, 'set'); - done(); - }); - }); - }); - - it('should return list as type', (done) => { - db.listAppend('typeList', 'value1', (err) => { - assert.ifError(err); - db.type('typeList', (err, type) => { - assert.ifError(err); - assert.equal(type, 'list'); - done(); - }); - }); - }); - - it('should return string as type', (done) => { - db.set('typeString', 'value1', (err) => { - assert.ifError(err); - db.type('typeString', (err, type) => { - assert.ifError(err); - assert.equal(type, 'string'); - done(); - }); - }); - }); - - it('should expire a key using seconds', (done) => { - db.expire('testKey', 86400, (err) => { - assert.ifError(err); - db.ttl('testKey', (err, ttl) => { - assert.ifError(err); - assert.equal(Math.round(86400 / 1000), Math.round(ttl / 1000)); - done(); - }); - }); - }); - - it('should expire a key using milliseconds', (done) => { - db.pexpire('testKey', 86400000, (err) => { - assert.ifError(err); - db.pttl('testKey', (err, pttl) => { - assert.ifError(err); - assert.equal(Math.round(86400000 / 1000000), Math.round(pttl / 1000000)); - done(); - }); - }); - }); - }); + beforeEach(done => { + db.set('testKey', 'testValue', done); + }); + + it('should set a key without error', done => { + db.set('testKey', 'testValue', function (error) { + assert.ifError(error); + assert(arguments.length < 2); + done(); + }); + }); + + it('should get a key without error', done => { + db.get('testKey', function (error, value) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.strictEqual(value, 'testValue'); + done(); + }); + }); + + it('should return null if key does not exist', done => { + db.get('doesnotexist', (error, value) => { + assert.ifError(error); + assert.equal(value, null); + done(); + }); + }); + + it('should return true if key exist', done => { + db.exists('testKey', function (error, exists) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.strictEqual(exists, true); + done(); + }); + }); + + it('should return false if key does not exist', done => { + db.exists('doesnotexist', function (error, exists) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.strictEqual(exists, false); + done(); + }); + }); + + it('should work for an array of keys', done => { + db.exists(['testKey', 'doesnotexist'], (error, exists) => { + assert.ifError(error); + assert.deepStrictEqual(exists, [true, false]); + done(); + }); + }); + + describe('scan', () => { + it('should scan keys for pattern', async () => { + await db.sortedSetAdd('ip:123:uid', 1, 'a'); + await db.sortedSetAdd('ip:123:uid', 2, 'b'); + await db.sortedSetAdd('ip:124:uid', 2, 'b'); + await db.sortedSetAdd('ip:1:uid', 1, 'a'); + await db.sortedSetAdd('ip:23:uid', 1, 'a'); + const data = await db.scan({match: 'ip:1*'}); + assert.equal(data.length, 3); + assert(data.includes('ip:123:uid')); + assert(data.includes('ip:124:uid')); + assert(data.includes('ip:1:uid')); + }); + }); + + it('should delete a key without error', done => { + db.delete('testKey', function (error) { + assert.ifError(error); + assert(arguments.length < 2); + + db.get('testKey', (error, value) => { + assert.ifError(error); + assert.equal(false, Boolean(value)); + done(); + }); + }); + }); + + it('should return false if key was deleted', done => { + db.delete('testKey', function (error) { + assert.ifError(error); + assert(arguments.length < 2); + db.exists('testKey', (error, exists) => { + assert.ifError(error); + assert.strictEqual(exists, false); + done(); + }); + }); + }); + + it('should delete all keys passed in', done => { + async.parallel([ + function (next) { + db.set('key1', 'value1', next); + }, + function (next) { + db.set('key2', 'value2', next); + }, + ], error => { + if (error) { + return done(error); + } + + db.deleteAll(['key1', 'key2'], function (error) { + assert.ifError(error); + assert.equal(arguments.length, 1); + async.parallel({ + key1exists(next) { + db.exists('key1', next); + }, + key2exists(next) { + db.exists('key2', next); + }, + }, (error, results) => { + assert.ifError(error); + assert.equal(results.key1exists, false); + assert.equal(results.key2exists, false); + done(); + }); + }); + }); + }); + + it('should delete all sorted set elements', done => { + async.parallel([ + function (next) { + db.sortedSetAdd('deletezset', 1, 'value1', next); + }, + function (next) { + db.sortedSetAdd('deletezset', 2, 'value2', next); + }, + ], error => { + if (error) { + return done(error); + } + + db.delete('deletezset', error => { + assert.ifError(error); + async.parallel({ + key1exists(next) { + db.isSortedSetMember('deletezset', 'value1', next); + }, + key2exists(next) { + db.isSortedSetMember('deletezset', 'value2', next); + }, + }, (error, results) => { + assert.ifError(error); + assert.equal(results.key1exists, false); + assert.equal(results.key2exists, false); + done(); + }); + }); + }); + }); + + describe('increment', () => { + it('should initialize key to 1', done => { + db.increment('keyToIncrement', (error, value) => { + assert.ifError(error); + assert.strictEqual(Number.parseInt(value, 10), 1); + done(); + }); + }); + + it('should increment key to 2', done => { + db.increment('keyToIncrement', (error, value) => { + assert.ifError(error); + assert.strictEqual(Number.parseInt(value, 10), 2); + done(); + }); + }); + + it('should set then increment a key', done => { + db.set('myIncrement', 1, error => { + assert.ifError(error); + db.increment('myIncrement', (error, value) => { + assert.ifError(error); + assert.equal(value, 2); + db.get('myIncrement', (error, value) => { + assert.ifError(error); + assert.equal(value, 2); + done(); + }); + }); + }); + }); + + it('should return the correct value', done => { + db.increment('testingCache', error => { + assert.ifError(error); + db.get('testingCache', (error, value) => { + assert.ifError(error); + assert.equal(value, 1); + db.increment('testingCache', error_ => { + assert.ifError(error_); + db.get('testingCache', (error, value) => { + assert.ifError(error); + assert.equal(value, 2); + done(); + }); + }); + }); + }); + }); + }); + + describe('rename', () => { + it('should rename key to new name', done => { + db.set('keyOldName', 'renamedKeyValue', error => { + if (error) { + return done(error); + } + + db.rename('keyOldName', 'keyNewName', function (error) { + assert.ifError(error); + assert(arguments.length < 2); + + db.get('keyNewName', (error, value) => { + assert.ifError(error); + assert.equal(value, 'renamedKeyValue'); + done(); + }); + }); + }); + }); + + it('should rename multiple keys', done => { + db.sortedSetAdd('zsettorename', [1, 2, 3], ['value1', 'value2', 'value3'], error => { + assert.ifError(error); + db.rename('zsettorename', 'newzsetname', error => { + assert.ifError(error); + db.exists('zsettorename', (error, exists) => { + assert.ifError(error); + assert(!exists); + db.getSortedSetRange('newzsetname', 0, -1, (error, values) => { + assert.ifError(error); + assert.deepEqual(['value1', 'value2', 'value3'], values); + done(); + }); + }); + }); + }); + }); + + it('should not error if old key does not exist', done => { + db.rename('doesnotexist', 'anotherdoesnotexist', error => { + assert.ifError(error); + db.exists('anotherdoesnotexist', (error, exists) => { + assert.ifError(error); + assert(!exists); + done(); + }); + }); + }); + }); + + describe('type', () => { + it('should return null if key does not exist', done => { + db.type('doesnotexist', (error, type) => { + assert.ifError(error); + assert.strictEqual(type, null); + done(); + }); + }); + + it('should return hash as type', done => { + db.setObject('typeHash', {foo: 1}, error => { + assert.ifError(error); + db.type('typeHash', (error, type) => { + assert.ifError(error); + assert.equal(type, 'hash'); + done(); + }); + }); + }); + + it('should return zset as type', done => { + db.sortedSetAdd('typeZset', 123, 'value1', error => { + assert.ifError(error); + db.type('typeZset', (error, type) => { + assert.ifError(error); + assert.equal(type, 'zset'); + done(); + }); + }); + }); + + it('should return set as type', done => { + db.setAdd('typeSet', 'value1', error => { + assert.ifError(error); + db.type('typeSet', (error, type) => { + assert.ifError(error); + assert.equal(type, 'set'); + done(); + }); + }); + }); + + it('should return list as type', done => { + db.listAppend('typeList', 'value1', error => { + assert.ifError(error); + db.type('typeList', (error, type) => { + assert.ifError(error); + assert.equal(type, 'list'); + done(); + }); + }); + }); + + it('should return string as type', done => { + db.set('typeString', 'value1', error => { + assert.ifError(error); + db.type('typeString', (error, type) => { + assert.ifError(error); + assert.equal(type, 'string'); + done(); + }); + }); + }); + + it('should expire a key using seconds', done => { + db.expire('testKey', 86_400, error => { + assert.ifError(error); + db.ttl('testKey', (error, ttl) => { + assert.ifError(error); + assert.equal(Math.round(86_400 / 1000), Math.round(ttl / 1000)); + done(); + }); + }); + }); + + it('should expire a key using milliseconds', done => { + db.pexpire('testKey', 86_400_000, error => { + assert.ifError(error); + db.pttl('testKey', (error, pttl) => { + assert.ifError(error); + assert.equal(Math.round(86_400_000 / 1_000_000), Math.round(pttl / 1_000_000)); + done(); + }); + }); + }); + }); }); diff --git a/test/database/list.js b/test/database/list.js index 6fba872..00ad1b2 100644 --- a/test/database/list.js +++ b/test/database/list.js @@ -1,256 +1,255 @@ 'use strict'; - +const assert = require('node:assert'); const async = require('async'); -const assert = require('assert'); const db = require('../mocks/databasemock'); describe('List methods', () => { - describe('listAppend()', () => { - it('should append to a list', (done) => { - db.listAppend('testList1', 5, function (err) { - assert.ifError(err); - assert.equal(arguments.length, 1); - done(); - }); - }); - - it('should not add anyhing if key is falsy', (done) => { - db.listAppend(null, 3, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should append each element to list', async () => { - await db.listAppend('arrayListAppend', ['a', 'b', 'c']); - let values = await db.getListRange('arrayListAppend', 0, -1); - assert.deepStrictEqual(values, ['a', 'b', 'c']); - - await db.listAppend('arrayListAppend', ['d', 'e']); - values = await db.getListRange('arrayListAppend', 0, -1); - assert.deepStrictEqual(values, ['a', 'b', 'c', 'd', 'e']); - }); - }); - - describe('listPrepend()', () => { - it('should prepend to a list', (done) => { - db.listPrepend('testList2', 3, function (err) { - assert.equal(err, null); - assert.equal(arguments.length, 1); - done(); - }); - }); - - it('should prepend 2 more elements to a list', (done) => { - async.series([ - function (next) { - db.listPrepend('testList2', 2, next); - }, - function (next) { - db.listPrepend('testList2', 1, next); - }, - ], (err) => { - assert.equal(err, null); - done(); - }); - }); - - it('should not add anyhing if key is falsy', (done) => { - db.listPrepend(null, 3, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should prepend each element to list', async () => { - await db.listPrepend('arrayListPrepend', ['a', 'b', 'c']); - let values = await db.getListRange('arrayListPrepend', 0, -1); - assert.deepStrictEqual(values, ['c', 'b', 'a']); - - await db.listPrepend('arrayListPrepend', ['d', 'e']); - values = await db.getListRange('arrayListPrepend', 0, -1); - assert.deepStrictEqual(values, ['e', 'd', 'c', 'b', 'a']); - }); - }); - - describe('getListRange()', () => { - before((done) => { - async.series([ - function (next) { - db.listAppend('testList3', 7, next); - }, - function (next) { - db.listPrepend('testList3', 3, next); - }, - function (next) { - db.listAppend('testList4', 5, next); - }, - ], done); - }); - - it('should return an empty list', (done) => { - db.getListRange('doesnotexist', 0, -1, function (err, list) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(Array.isArray(list), true); - assert.equal(list.length, 0); - done(); - }); - }); - - it('should return a list with one element', (done) => { - db.getListRange('testList4', 0, 0, (err, list) => { - assert.equal(err, null); - assert.equal(Array.isArray(list), true); - assert.equal(list[0], 5); - done(); - }); - }); - - it('should return a list with 2 elements 3, 7', (done) => { - db.getListRange('testList3', 0, -1, (err, list) => { - assert.equal(err, null); - assert.equal(Array.isArray(list), true); - assert.equal(list.length, 2); - assert.deepEqual(list, ['3', '7']); - done(); - }); - }); - - it('should not get anything if key is falsy', (done) => { - db.getListRange(null, 0, -1, (err, data) => { - assert.ifError(err); - assert.equal(data, undefined); - done(); - }); - }); - }); - - describe('listRemoveLast()', () => { - before((done) => { - async.series([ - function (next) { - db.listAppend('testList7', 12, next); - }, - function (next) { - db.listPrepend('testList7', 9, next); - }, - ], done); - }); - - it('should remove the last element of list and return it', (done) => { - db.listRemoveLast('testList7', function (err, lastElement) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(lastElement, '12'); - done(); - }); - }); - - it('should not remove anyhing if key is falsy', (done) => { - db.listRemoveLast(null, (err) => { - assert.ifError(err); - done(); - }); - }); - }); - - describe('listRemoveAll()', () => { - before((done) => { - async.series([ - async.apply(db.listAppend, 'testList5', 1), - async.apply(db.listAppend, 'testList5', 1), - async.apply(db.listAppend, 'testList5', 1), - async.apply(db.listAppend, 'testList5', 2), - async.apply(db.listAppend, 'testList5', 5), - ], done); - }); - - it('should remove all the matching elements of list', (done) => { - db.listRemoveAll('testList5', '1', function (err) { - assert.equal(err, null); - assert.equal(arguments.length, 1); - - db.getListRange('testList5', 0, -1, (err, list) => { - assert.equal(err, null); - assert.equal(Array.isArray(list), true); - assert.equal(list.length, 2); - assert.equal(list.indexOf('1'), -1); - done(); - }); - }); - }); - - it('should not remove anyhing if key is falsy', (done) => { - db.listRemoveAll(null, 3, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should remove multiple elements from list', async () => { - await db.listAppend('multiRemoveList', ['a', 'b', 'c', 'd', 'e']); - const initial = await db.getListRange('multiRemoveList', 0, -1); - assert.deepStrictEqual(initial, ['a', 'b', 'c', 'd', 'e']); - await db.listRemoveAll('multiRemoveList', ['b', 'd']); - const values = await db.getListRange('multiRemoveList', 0, -1); - assert.deepStrictEqual(values, ['a', 'c', 'e']); - }); - }); - - describe('listTrim()', () => { - it('should trim list to a certain range', (done) => { - const list = ['1', '2', '3', '4', '5']; - async.eachSeries(list, (value, next) => { - db.listAppend('testList6', value, next); - }, (err) => { - if (err) { - return done(err); - } - - db.listTrim('testList6', 0, 2, function (err) { - assert.equal(err, null); - assert.equal(arguments.length, 1); - db.getListRange('testList6', 0, -1, (err, list) => { - assert.equal(err, null); - assert.equal(list.length, 3); - assert.deepEqual(list, ['1', '2', '3']); - done(); - }); - }); - }); - }); - - it('should not add anyhing if key is falsy', (done) => { - db.listTrim(null, 0, 3, (err) => { - assert.ifError(err); - done(); - }); - }); - }); - - describe('listLength', () => { - it('should get the length of a list', (done) => { - db.listAppend('getLengthList', 1, (err) => { - assert.ifError(err); - db.listAppend('getLengthList', 2, (err) => { - assert.ifError(err); - db.listLength('getLengthList', (err, length) => { - assert.ifError(err); - assert.equal(length, 2); - done(); - }); - }); - }); - }); - - it('should return 0 if list does not have any elements', (done) => { - db.listLength('doesnotexist', (err, length) => { - assert.ifError(err); - assert.strictEqual(length, 0); - done(); - }); - }); - }); + describe('listAppend()', () => { + it('should append to a list', done => { + db.listAppend('testList1', 5, function (error) { + assert.ifError(error); + assert.equal(arguments.length, 1); + done(); + }); + }); + + it('should not add anyhing if key is falsy', done => { + db.listAppend(null, 3, error => { + assert.ifError(error); + done(); + }); + }); + + it('should append each element to list', async () => { + await db.listAppend('arrayListAppend', ['a', 'b', 'c']); + let values = await db.getListRange('arrayListAppend', 0, -1); + assert.deepStrictEqual(values, ['a', 'b', 'c']); + + await db.listAppend('arrayListAppend', ['d', 'e']); + values = await db.getListRange('arrayListAppend', 0, -1); + assert.deepStrictEqual(values, ['a', 'b', 'c', 'd', 'e']); + }); + }); + + describe('listPrepend()', () => { + it('should prepend to a list', done => { + db.listPrepend('testList2', 3, function (error) { + assert.equal(error, null); + assert.equal(arguments.length, 1); + done(); + }); + }); + + it('should prepend 2 more elements to a list', done => { + async.series([ + function (next) { + db.listPrepend('testList2', 2, next); + }, + function (next) { + db.listPrepend('testList2', 1, next); + }, + ], error => { + assert.equal(error, null); + done(); + }); + }); + + it('should not add anyhing if key is falsy', done => { + db.listPrepend(null, 3, error => { + assert.ifError(error); + done(); + }); + }); + + it('should prepend each element to list', async () => { + await db.listPrepend('arrayListPrepend', ['a', 'b', 'c']); + let values = await db.getListRange('arrayListPrepend', 0, -1); + assert.deepStrictEqual(values, ['c', 'b', 'a']); + + await db.listPrepend('arrayListPrepend', ['d', 'e']); + values = await db.getListRange('arrayListPrepend', 0, -1); + assert.deepStrictEqual(values, ['e', 'd', 'c', 'b', 'a']); + }); + }); + + describe('getListRange()', () => { + before(done => { + async.series([ + function (next) { + db.listAppend('testList3', 7, next); + }, + function (next) { + db.listPrepend('testList3', 3, next); + }, + function (next) { + db.listAppend('testList4', 5, next); + }, + ], done); + }); + + it('should return an empty list', done => { + db.getListRange('doesnotexist', 0, -1, function (error, list) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(Array.isArray(list), true); + assert.equal(list.length, 0); + done(); + }); + }); + + it('should return a list with one element', done => { + db.getListRange('testList4', 0, 0, (error, list) => { + assert.equal(error, null); + assert.equal(Array.isArray(list), true); + assert.equal(list[0], 5); + done(); + }); + }); + + it('should return a list with 2 elements 3, 7', done => { + db.getListRange('testList3', 0, -1, (error, list) => { + assert.equal(error, null); + assert.equal(Array.isArray(list), true); + assert.equal(list.length, 2); + assert.deepEqual(list, ['3', '7']); + done(); + }); + }); + + it('should not get anything if key is falsy', done => { + db.getListRange(null, 0, -1, (error, data) => { + assert.ifError(error); + assert.equal(data, undefined); + done(); + }); + }); + }); + + describe('listRemoveLast()', () => { + before(done => { + async.series([ + function (next) { + db.listAppend('testList7', 12, next); + }, + function (next) { + db.listPrepend('testList7', 9, next); + }, + ], done); + }); + + it('should remove the last element of list and return it', done => { + db.listRemoveLast('testList7', function (error, lastElement) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(lastElement, '12'); + done(); + }); + }); + + it('should not remove anyhing if key is falsy', done => { + db.listRemoveLast(null, error => { + assert.ifError(error); + done(); + }); + }); + }); + + describe('listRemoveAll()', () => { + before(done => { + async.series([ + async.apply(db.listAppend, 'testList5', 1), + async.apply(db.listAppend, 'testList5', 1), + async.apply(db.listAppend, 'testList5', 1), + async.apply(db.listAppend, 'testList5', 2), + async.apply(db.listAppend, 'testList5', 5), + ], done); + }); + + it('should remove all the matching elements of list', done => { + db.listRemoveAll('testList5', '1', function (error) { + assert.equal(error, null); + assert.equal(arguments.length, 1); + + db.getListRange('testList5', 0, -1, (error, list) => { + assert.equal(error, null); + assert.equal(Array.isArray(list), true); + assert.equal(list.length, 2); + assert.equal(list.indexOf('1'), -1); + done(); + }); + }); + }); + + it('should not remove anyhing if key is falsy', done => { + db.listRemoveAll(null, 3, error => { + assert.ifError(error); + done(); + }); + }); + + it('should remove multiple elements from list', async () => { + await db.listAppend('multiRemoveList', ['a', 'b', 'c', 'd', 'e']); + const initial = await db.getListRange('multiRemoveList', 0, -1); + assert.deepStrictEqual(initial, ['a', 'b', 'c', 'd', 'e']); + await db.listRemoveAll('multiRemoveList', ['b', 'd']); + const values = await db.getListRange('multiRemoveList', 0, -1); + assert.deepStrictEqual(values, ['a', 'c', 'e']); + }); + }); + + describe('listTrim()', () => { + it('should trim list to a certain range', done => { + const list = ['1', '2', '3', '4', '5']; + async.eachSeries(list, (value, next) => { + db.listAppend('testList6', value, next); + }, error => { + if (error) { + return done(error); + } + + db.listTrim('testList6', 0, 2, function (error) { + assert.equal(error, null); + assert.equal(arguments.length, 1); + db.getListRange('testList6', 0, -1, (error, list) => { + assert.equal(error, null); + assert.equal(list.length, 3); + assert.deepEqual(list, ['1', '2', '3']); + done(); + }); + }); + }); + }); + + it('should not add anyhing if key is falsy', done => { + db.listTrim(null, 0, 3, error => { + assert.ifError(error); + done(); + }); + }); + }); + + describe('listLength', () => { + it('should get the length of a list', done => { + db.listAppend('getLengthList', 1, error => { + assert.ifError(error); + db.listAppend('getLengthList', 2, error => { + assert.ifError(error); + db.listLength('getLengthList', (error, length) => { + assert.ifError(error); + assert.equal(length, 2); + done(); + }); + }); + }); + }); + + it('should return 0 if list does not have any elements', done => { + db.listLength('doesnotexist', (error, length) => { + assert.ifError(error); + assert.strictEqual(length, 0); + done(); + }); + }); + }); }); diff --git a/test/database/sets.js b/test/database/sets.js index 77789d2..542f299 100644 --- a/test/database/sets.js +++ b/test/database/sets.js @@ -1,288 +1,287 @@ 'use strict'; - +const assert = require('node:assert'); const async = require('async'); -const assert = require('assert'); const db = require('../mocks/databasemock'); describe('Set methods', () => { - describe('setAdd()', () => { - it('should add to a set', (done) => { - db.setAdd('testSet1', 5, function (err) { - assert.equal(err, null); - assert.equal(arguments.length, 1); - done(); - }); - }); - - it('should add an array to a set', (done) => { - db.setAdd('testSet1', [1, 2, 3, 4], function (err) { - assert.equal(err, null); - assert.equal(arguments.length, 1); - done(); - }); - }); - - it('should not do anything if values array is empty', async () => { - await db.setAdd('emptyArraySet', []); - const members = await db.getSetMembers('emptyArraySet'); - const exists = await db.exists('emptyArraySet'); - assert.deepStrictEqual(members, []); - assert(!exists); - }); - }); - - describe('getSetMembers()', () => { - before((done) => { - db.setAdd('testSet2', [1, 2, 3, 4, 5], done); - }); - - it('should return an empty set', (done) => { - db.getSetMembers('doesnotexist', function (err, set) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(Array.isArray(set), true); - assert.equal(set.length, 0); - done(); - }); - }); - - it('should return a set with all elements', (done) => { - db.getSetMembers('testSet2', (err, set) => { - assert.equal(err, null); - assert.equal(set.length, 5); - set.forEach((value) => { - assert.notEqual(['1', '2', '3', '4', '5'].indexOf(value), -1); - }); - - done(); - }); - }); - }); - - describe('setsAdd()', () => { - it('should add to multiple sets', (done) => { - db.setsAdd(['set1', 'set2'], 'value', function (err) { - assert.equal(err, null); - assert.equal(arguments.length, 1); - done(); - }); - }); - - it('should not error if keys is empty array', (done) => { - db.setsAdd([], 'value', (err) => { - assert.ifError(err); - done(); - }); - }); - }); - - describe('getSetsMembers()', () => { - before((done) => { - db.setsAdd(['set3', 'set4'], 'value', done); - }); - - it('should return members of two sets', (done) => { - db.getSetsMembers(['set3', 'set4'], function (err, sets) { - assert.equal(err, null); - assert.equal(Array.isArray(sets), true); - assert.equal(arguments.length, 2); - assert.equal(Array.isArray(sets[0]) && Array.isArray(sets[1]), true); - assert.strictEqual(sets[0][0], 'value'); - assert.strictEqual(sets[1][0], 'value'); - done(); - }); - }); - }); - - describe('isSetMember()', () => { - before((done) => { - db.setAdd('testSet3', 5, done); - }); - - it('should return false if element is not member of set', (done) => { - db.isSetMember('testSet3', 10, function (err, isMember) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(isMember, false); - done(); - }); - }); - - it('should return true if element is a member of set', (done) => { - db.isSetMember('testSet3', 5, function (err, isMember) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(isMember, true); - done(); - }); - }); - }); - - describe('isSetMembers()', () => { - before((done) => { - db.setAdd('testSet4', [1, 2, 3, 4, 5], done); - }); - - it('should return an array of booleans', (done) => { - db.isSetMembers('testSet4', ['1', '2', '10', '3'], function (err, members) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(Array.isArray(members), true); - assert.deepEqual(members, [true, true, false, true]); - done(); - }); - }); - }); - - describe('isMemberOfSets()', () => { - before((done) => { - db.setsAdd(['set1', 'set2'], 'value', done); - }); - - it('should return an array of booleans', (done) => { - db.isMemberOfSets(['set1', 'testSet1', 'set2', 'doesnotexist'], 'value', function (err, members) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(Array.isArray(members), true); - assert.deepEqual(members, [true, false, true, false]); - done(); - }); - }); - }); - - describe('setCount()', () => { - before((done) => { - db.setAdd('testSet5', [1, 2, 3, 4, 5], done); - }); - - it('should return the element count of set', (done) => { - db.setCount('testSet5', function (err, count) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.strictEqual(count, 5); - done(); - }); - }); - - it('should return 0 if set does not exist', (done) => { - db.setCount('doesnotexist', (err, count) => { - assert.ifError(err); - assert.strictEqual(count, 0); - done(); - }); - }); - }); - - describe('setsCount()', () => { - before((done) => { - async.parallel([ - async.apply(db.setAdd, 'set5', [1, 2, 3, 4, 5]), - async.apply(db.setAdd, 'set6', 1), - async.apply(db.setAdd, 'set7', 2), - ], done); - }); - - it('should return the element count of sets', (done) => { - db.setsCount(['set5', 'set6', 'set7', 'doesnotexist'], function (err, counts) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(Array.isArray(counts), true); - assert.deepEqual(counts, [5, 1, 1, 0]); - done(); - }); - }); - }); - - describe('setRemove()', () => { - before((done) => { - db.setAdd('testSet6', [1, 2], done); - }); - - it('should remove a element from set', (done) => { - db.setRemove('testSet6', '2', function (err) { - assert.equal(err, null); - assert.equal(arguments.length, 1); - - db.isSetMember('testSet6', '2', (err, isMember) => { - assert.equal(err, null); - assert.equal(isMember, false); - done(); - }); - }); - }); - - it('should remove multiple elements from set', (done) => { - db.setAdd('multiRemoveSet', [1, 2, 3, 4, 5], (err) => { - assert.ifError(err); - db.setRemove('multiRemoveSet', [1, 3, 5], (err) => { - assert.ifError(err); - db.getSetMembers('multiRemoveSet', (err, members) => { - assert.ifError(err); - assert(members.includes('2')); - assert(members.includes('4')); - done(); - }); - }); - }); - }); - - it('should remove multiple values from multiple keys', (done) => { - db.setAdd('multiSetTest1', ['one', 'two', 'three', 'four'], (err) => { - assert.ifError(err); - db.setAdd('multiSetTest2', ['three', 'four', 'five', 'six'], (err) => { - assert.ifError(err); - db.setRemove(['multiSetTest1', 'multiSetTest2'], ['three', 'four', 'five', 'doesnt exist'], (err) => { - assert.ifError(err); - db.getSetsMembers(['multiSetTest1', 'multiSetTest2'], (err, members) => { - assert.ifError(err); - assert.equal(members[0].length, 2); - assert.equal(members[1].length, 1); - assert(members[0].includes('one')); - assert(members[0].includes('two')); - assert(members[1].includes('six')); - done(); - }); - }); - }); - }); - }); - }); - - describe('setsRemove()', () => { - before((done) => { - db.setsAdd(['set1', 'set2'], 'value', done); - }); - - it('should remove a element from multiple sets', (done) => { - db.setsRemove(['set1', 'set2'], 'value', function (err) { - assert.equal(err, null); - assert.equal(arguments.length, 1); - db.isMemberOfSets(['set1', 'set2'], 'value', (err, members) => { - assert.equal(err, null); - assert.deepEqual(members, [false, false]); - done(); - }); - }); - }); - }); - - describe('setRemoveRandom()', () => { - before((done) => { - db.setAdd('testSet7', [1, 2, 3, 4, 5], done); - }); - - it('should remove a random element from set', (done) => { - db.setRemoveRandom('testSet7', function (err, element) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - - db.isSetMember('testSet', element, (err, ismember) => { - assert.equal(err, null); - assert.equal(ismember, false); - done(); - }); - }); - }); - }); + describe('setAdd()', () => { + it('should add to a set', done => { + db.setAdd('testSet1', 5, function (error) { + assert.equal(error, null); + assert.equal(arguments.length, 1); + done(); + }); + }); + + it('should add an array to a set', done => { + db.setAdd('testSet1', [1, 2, 3, 4], function (error) { + assert.equal(error, null); + assert.equal(arguments.length, 1); + done(); + }); + }); + + it('should not do anything if values array is empty', async () => { + await db.setAdd('emptyArraySet', []); + const members = await db.getSetMembers('emptyArraySet'); + const exists = await db.exists('emptyArraySet'); + assert.deepStrictEqual(members, []); + assert(!exists); + }); + }); + + describe('getSetMembers()', () => { + before(done => { + db.setAdd('testSet2', [1, 2, 3, 4, 5], done); + }); + + it('should return an empty set', done => { + db.getSetMembers('doesnotexist', function (error, set) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(Array.isArray(set), true); + assert.equal(set.length, 0); + done(); + }); + }); + + it('should return a set with all elements', done => { + db.getSetMembers('testSet2', (error, set) => { + assert.equal(error, null); + assert.equal(set.length, 5); + for (const value of set) { + assert.notEqual(['1', '2', '3', '4', '5'].indexOf(value), -1); + } + + done(); + }); + }); + }); + + describe('setsAdd()', () => { + it('should add to multiple sets', done => { + db.setsAdd(['set1', 'set2'], 'value', function (error) { + assert.equal(error, null); + assert.equal(arguments.length, 1); + done(); + }); + }); + + it('should not error if keys is empty array', done => { + db.setsAdd([], 'value', error => { + assert.ifError(error); + done(); + }); + }); + }); + + describe('getSetsMembers()', () => { + before(done => { + db.setsAdd(['set3', 'set4'], 'value', done); + }); + + it('should return members of two sets', done => { + db.getSetsMembers(['set3', 'set4'], function (error, sets) { + assert.equal(error, null); + assert.equal(Array.isArray(sets), true); + assert.equal(arguments.length, 2); + assert.equal(Array.isArray(sets[0]) && Array.isArray(sets[1]), true); + assert.strictEqual(sets[0][0], 'value'); + assert.strictEqual(sets[1][0], 'value'); + done(); + }); + }); + }); + + describe('isSetMember()', () => { + before(done => { + db.setAdd('testSet3', 5, done); + }); + + it('should return false if element is not member of set', done => { + db.isSetMember('testSet3', 10, function (error, isMember) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(isMember, false); + done(); + }); + }); + + it('should return true if element is a member of set', done => { + db.isSetMember('testSet3', 5, function (error, isMember) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(isMember, true); + done(); + }); + }); + }); + + describe('isSetMembers()', () => { + before(done => { + db.setAdd('testSet4', [1, 2, 3, 4, 5], done); + }); + + it('should return an array of booleans', done => { + db.isSetMembers('testSet4', ['1', '2', '10', '3'], function (error, members) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(Array.isArray(members), true); + assert.deepEqual(members, [true, true, false, true]); + done(); + }); + }); + }); + + describe('isMemberOfSets()', () => { + before(done => { + db.setsAdd(['set1', 'set2'], 'value', done); + }); + + it('should return an array of booleans', done => { + db.isMemberOfSets(['set1', 'testSet1', 'set2', 'doesnotexist'], 'value', function (error, members) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(Array.isArray(members), true); + assert.deepEqual(members, [true, false, true, false]); + done(); + }); + }); + }); + + describe('setCount()', () => { + before(done => { + db.setAdd('testSet5', [1, 2, 3, 4, 5], done); + }); + + it('should return the element count of set', done => { + db.setCount('testSet5', function (error, count) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.strictEqual(count, 5); + done(); + }); + }); + + it('should return 0 if set does not exist', done => { + db.setCount('doesnotexist', (error, count) => { + assert.ifError(error); + assert.strictEqual(count, 0); + done(); + }); + }); + }); + + describe('setsCount()', () => { + before(done => { + async.parallel([ + async.apply(db.setAdd, 'set5', [1, 2, 3, 4, 5]), + async.apply(db.setAdd, 'set6', 1), + async.apply(db.setAdd, 'set7', 2), + ], done); + }); + + it('should return the element count of sets', done => { + db.setsCount(['set5', 'set6', 'set7', 'doesnotexist'], function (error, counts) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(Array.isArray(counts), true); + assert.deepEqual(counts, [5, 1, 1, 0]); + done(); + }); + }); + }); + + describe('setRemove()', () => { + before(done => { + db.setAdd('testSet6', [1, 2], done); + }); + + it('should remove a element from set', done => { + db.setRemove('testSet6', '2', function (error) { + assert.equal(error, null); + assert.equal(arguments.length, 1); + + db.isSetMember('testSet6', '2', (error, isMember) => { + assert.equal(error, null); + assert.equal(isMember, false); + done(); + }); + }); + }); + + it('should remove multiple elements from set', done => { + db.setAdd('multiRemoveSet', [1, 2, 3, 4, 5], error => { + assert.ifError(error); + db.setRemove('multiRemoveSet', [1, 3, 5], error => { + assert.ifError(error); + db.getSetMembers('multiRemoveSet', (error, members) => { + assert.ifError(error); + assert(members.includes('2')); + assert(members.includes('4')); + done(); + }); + }); + }); + }); + + it('should remove multiple values from multiple keys', done => { + db.setAdd('multiSetTest1', ['one', 'two', 'three', 'four'], error => { + assert.ifError(error); + db.setAdd('multiSetTest2', ['three', 'four', 'five', 'six'], error => { + assert.ifError(error); + db.setRemove(['multiSetTest1', 'multiSetTest2'], ['three', 'four', 'five', 'doesnt exist'], error => { + assert.ifError(error); + db.getSetsMembers(['multiSetTest1', 'multiSetTest2'], (error, members) => { + assert.ifError(error); + assert.equal(members[0].length, 2); + assert.equal(members[1].length, 1); + assert(members[0].includes('one')); + assert(members[0].includes('two')); + assert(members[1].includes('six')); + done(); + }); + }); + }); + }); + }); + }); + + describe('setsRemove()', () => { + before(done => { + db.setsAdd(['set1', 'set2'], 'value', done); + }); + + it('should remove a element from multiple sets', done => { + db.setsRemove(['set1', 'set2'], 'value', function (error) { + assert.equal(error, null); + assert.equal(arguments.length, 1); + db.isMemberOfSets(['set1', 'set2'], 'value', (error, members) => { + assert.equal(error, null); + assert.deepEqual(members, [false, false]); + done(); + }); + }); + }); + }); + + describe('setRemoveRandom()', () => { + before(done => { + db.setAdd('testSet7', [1, 2, 3, 4, 5], done); + }); + + it('should remove a random element from set', done => { + db.setRemoveRandom('testSet7', function (error, element) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + + db.isSetMember('testSet', element, (error, ismember) => { + assert.equal(error, null); + assert.equal(ismember, false); + done(); + }); + }); + }); + }); }); diff --git a/test/database/sorted.js b/test/database/sorted.js index 2511cfc..7990833 100644 --- a/test/database/sorted.js +++ b/test/database/sorted.js @@ -1,1629 +1,1633 @@ 'use strict'; - +const assert = require('node:assert'); const async = require('async'); -const assert = require('assert'); const db = require('../mocks/databasemock'); describe('Sorted Set methods', () => { - before((done) => { - async.parallel([ - function (next) { - db.sortedSetAdd('sortedSetTest1', [1.1, 1.2, 1.3], ['value1', 'value2', 'value3'], next); - }, - function (next) { - db.sortedSetAdd('sortedSetTest2', [1, 4], ['value1', 'value4'], next); - }, - function (next) { - db.sortedSetAdd('sortedSetTest3', [2, 4], ['value2', 'value4'], next); - }, - function (next) { - db.sortedSetAdd('sortedSetTest4', [1, 1, 2, 3, 5], ['b', 'a', 'd', 'e', 'c'], next); - }, - function (next) { - db.sortedSetAdd('sortedSetLex', [0, 0, 0, 0], ['a', 'b', 'c', 'd'], next); - }, - ], done); - }); - - describe('sortedSetScan', () => { - it('should find matches in sorted set containing substring', async () => { - await db.sortedSetAdd('scanzset', [1, 2, 3, 4, 5, 6], ['aaaa', 'bbbb', 'bbcc', 'ddd', 'dddd', 'fghbc']); - const data = await db.getSortedSetScan({ - key: 'scanzset', - match: '*bc*', - }); - assert(data.includes('bbcc')); - assert(data.includes('fghbc')); - }); - - it('should find matches in sorted set with scores', async () => { - const data = await db.getSortedSetScan({ - key: 'scanzset', - match: '*bc*', - withScores: true, - }); - data.sort((a, b) => a.score - b.score); - assert.deepStrictEqual(data, [{ value: 'bbcc', score: 3 }, { value: 'fghbc', score: 6 }]); - }); - - it('should find matches in sorted set with a limit', async () => { - await db.sortedSetAdd('scanzset2', [1, 2, 3, 4, 5, 6], ['aaab', 'bbbb', 'bbcb', 'ddb', 'dddd', 'fghbc']); - const data = await db.getSortedSetScan({ - key: 'scanzset2', - match: '*b*', - limit: 2, - }); - assert.equal(data.length, 2); - }); - - it('should work for special characters', async () => { - await db.sortedSetAdd('scanzset3', [1, 2, 3, 4, 5], ['aaab{', 'bbbb', 'bbcb{', 'ddb', 'dddd']); - const data = await db.getSortedSetScan({ - key: 'scanzset3', - match: '*b{', - limit: 2, - }); - assert(data.includes('aaab{')); - assert(data.includes('bbcb{')); - }); - - it('should find everything starting with string', async () => { - await db.sortedSetAdd('scanzset4', [1, 2, 3, 4, 5], ['aaab{', 'bbbb', 'bbcb', 'ddb', 'dddd']); - const data = await db.getSortedSetScan({ - key: 'scanzset4', - match: 'b*', - limit: 2, - }); - assert(data.includes('bbbb')); - assert(data.includes('bbcb')); - }); - - it('should find everything ending with string', async () => { - await db.sortedSetAdd('scanzset5', [1, 2, 3, 4, 5, 6], ['aaab{', 'bbbb', 'bbcb', 'ddb', 'dddd', 'adb']); - const data = await db.getSortedSetScan({ - key: 'scanzset5', - match: '*db', - }); - assert.equal(data.length, 2); - assert(data.includes('ddb')); - assert(data.includes('adb')); - }); - }); - - describe('sortedSetAdd()', () => { - it('should add an element to a sorted set', (done) => { - db.sortedSetAdd('sorted1', 1, 'value1', function (err) { - assert.equal(err, null); - assert.equal(arguments.length, 1); - done(); - }); - }); - - it('should add two elements to a sorted set', (done) => { - db.sortedSetAdd('sorted2', [1, 2], ['value1', 'value2'], function (err) { - assert.equal(err, null); - assert.equal(arguments.length, 1); - done(); - }); - }); - - it('should gracefully handle adding the same element twice', (done) => { - db.sortedSetAdd('sorted2', [1, 2], ['value1', 'value1'], function (err) { - assert.equal(err, null); - assert.equal(arguments.length, 1); - - db.sortedSetScore('sorted2', 'value1', function (err, score) { - assert.equal(err, null); - assert.equal(score, 2); - assert.equal(arguments.length, 2); - - done(); - }); - }); - }); - - it('should error if score is null', (done) => { - db.sortedSetAdd('errorScore', null, 'value1', (err) => { - assert.equal(err.message, '[[error:invalid-score, null]]'); - done(); - }); - }); - - it('should error if any score is undefined', (done) => { - db.sortedSetAdd('errorScore', [1, undefined], ['value1', 'value2'], (err) => { - assert.equal(err.message, '[[error:invalid-score, undefined]]'); - done(); - }); - }); - - it('should add null value as `null` string', (done) => { - db.sortedSetAdd('nullValueZSet', 1, null, (err) => { - assert.ifError(err); - db.getSortedSetRange('nullValueZSet', 0, -1, (err, values) => { - assert.ifError(err); - assert.strictEqual(values[0], 'null'); - done(); - }); - }); - }); - }); - - describe('sortedSetsAdd()', () => { - it('should add an element to two sorted sets', (done) => { - db.sortedSetsAdd(['sorted1', 'sorted2'], 3, 'value3', function (err) { - assert.equal(err, null); - assert.equal(arguments.length, 1); - done(); - }); - }); - - it('should add an element to two sorted sets with different scores', (done) => { - db.sortedSetsAdd(['sorted1', 'sorted2'], [4, 5], 'value4', (err) => { - assert.ifError(err); - db.sortedSetsScore(['sorted1', 'sorted2'], 'value4', (err, scores) => { - assert.ifError(err); - assert.deepStrictEqual(scores, [4, 5]); - done(); - }); - }); - }); - - - it('should error if keys.length is different than scores.length', (done) => { - db.sortedSetsAdd(['sorted1', 'sorted2'], [4], 'value4', (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should error if score is null', (done) => { - db.sortedSetsAdd(['sorted1', 'sorted2'], null, 'value1', (err) => { - assert.equal(err.message, '[[error:invalid-score, null]]'); - done(); - }); - }); - - it('should error if scores has null', async () => { - let err; - try { - await db.sortedSetsAdd(['sorted1', 'sorted2'], [1, null], 'dontadd'); - } catch (_err) { - err = _err; - } - assert.equal(err.message, '[[error:invalid-score, 1,]]'); - assert.strictEqual(await db.isSortedSetMember('sorted1', 'dontadd'), false); - assert.strictEqual(await db.isSortedSetMember('sorted2', 'dontadd'), false); - }); - }); - - describe('sortedSetAddMulti()', () => { - it('should add elements into multiple sorted sets with different scores', (done) => { - db.sortedSetAddBulk([ - ['bulk1', 1, 'item1'], - ['bulk2', 2, 'item1'], - ['bulk2', 3, 'item2'], - ['bulk3', 4, 'item3'], - ], function (err) { - assert.ifError(err); - assert.equal(arguments.length, 1); - db.getSortedSetRevRangeWithScores(['bulk1', 'bulk2', 'bulk3'], 0, -1, (err, data) => { - assert.ifError(err); - assert.deepStrictEqual(data, [{ value: 'item3', score: 4 }, - { value: 'item2', score: 3 }, - { value: 'item1', score: 2 }, - { value: 'item1', score: 1 }]); - done(); - }); - }); - }); - it('should not error if data is undefined', (done) => { - db.sortedSetAddBulk(undefined, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should error if score is null', async () => { - let err; - try { - await db.sortedSetAddBulk([ - ['bulk4', 0, 'dontadd'], - ['bulk5', null, 'dontadd'], - ]); - } catch (_err) { - err = _err; - } - assert.equal(err.message, '[[error:invalid-score, null]]'); - assert.strictEqual(await db.isSortedSetMember('bulk4', 'dontadd'), false); - assert.strictEqual(await db.isSortedSetMember('bulk5', 'dontadd'), false); - }); - }); - - describe('getSortedSetRange()', () => { - it('should return the lowest scored element', (done) => { - db.getSortedSetRange('sortedSetTest1', 0, 0, function (err, value) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(value, ['value1']); - done(); - }); - }); - - it('should return elements sorted by score lowest to highest', (done) => { - db.getSortedSetRange('sortedSetTest1', 0, -1, function (err, values) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(values, ['value1', 'value2', 'value3']); - done(); - }); - }); - - it('should return empty array if set does not exist', (done) => { - db.getSortedSetRange('doesnotexist', 0, -1, (err, values) => { - assert.ifError(err); - assert(Array.isArray(values)); - assert.equal(values.length, 0); - done(); - }); - }); - - it('should handle negative start/stop', (done) => { - db.sortedSetAdd('negatives', [1, 2, 3, 4, 5], ['1', '2', '3', '4', '5'], (err) => { - assert.ifError(err); - db.getSortedSetRange('negatives', -2, -4, (err, data) => { - assert.ifError(err); - assert.deepEqual(data, []); - done(); - }); - }); - }); - - it('should handle negative start/stop', (done) => { - db.getSortedSetRange('negatives', -4, -2, (err, data) => { - assert.ifError(err); - assert.deepEqual(data, ['2', '3', '4']); - done(); - }); - }); - - it('should handle negative start/stop', (done) => { - db.getSortedSetRevRange('negatives', -4, -2, (err, data) => { - assert.ifError(err); - assert.deepEqual(data, ['4', '3', '2']); - done(); - }); - }); - - it('should handle negative start/stop', (done) => { - db.getSortedSetRange('negatives', -5, -1, (err, data) => { - assert.ifError(err); - assert.deepEqual(data, ['1', '2', '3', '4', '5']); - done(); - }); - }); - - it('should handle negative start/stop', (done) => { - db.getSortedSetRange('negatives', 0, -2, (err, data) => { - assert.ifError(err); - assert.deepEqual(data, ['1', '2', '3', '4']); - done(); - }); - }); - - it('should return empty array if keys is empty array', (done) => { - db.getSortedSetRange([], 0, -1, (err, data) => { - assert.ifError(err); - assert.deepStrictEqual(data, []); - done(); - }); - }); - - it('should return duplicates if two sets have same elements', async () => { - await db.sortedSetAdd('dupezset1', [1, 2], ['value 1', 'value 2']); - await db.sortedSetAdd('dupezset2', [2, 3], ['value 2', 'value 3']); - const data = await db.getSortedSetRange(['dupezset1', 'dupezset2'], 0, -1); - assert.deepStrictEqual(data, ['value 1', 'value 2', 'value 2', 'value 3']); - }); - - it('should return correct number of elements', async () => { - await db.sortedSetAdd('dupezset3', [1, 2, 3], ['value 1', 'value 2', 'value3']); - await db.sortedSetAdd('dupezset4', [0, 5], ['value 0', 'value5']); - const data = await db.getSortedSetRevRange(['dupezset3', 'dupezset4'], 0, 1); - assert.deepStrictEqual(data, ['value5', 'value3']); - }); - - it('should work with big arrays (length > 100) ', async function () { - this.timeout(100000); - const keys = []; - for (let i = 0; i < 400; i++) { - /* eslint-disable no-await-in-loop */ - const bulkAdd = []; - keys.push(`testzset${i}`); - for (let k = 0; k < 100; k++) { - bulkAdd.push([`testzset${i}`, 1000000 + k + (i * 100), k + (i * 100)]); - } - await db.sortedSetAddBulk(bulkAdd); - } - - let data = await db.getSortedSetRevRange(keys, 0, 3); - assert.deepStrictEqual(data, ['39999', '39998', '39997', '39996']); - - data = await db.getSortedSetRevRangeWithScores(keys, 0, 3); - assert.deepStrictEqual(data, [ - { value: '39999', score: 1039999 }, - { value: '39998', score: 1039998 }, - { value: '39997', score: 1039997 }, - { value: '39996', score: 1039996 }, - ]); - - data = await db.getSortedSetRevRange(keys, 0, -1); - assert.equal(data.length, 40000); - - data = await db.getSortedSetRange(keys, 9998, 10002); - assert.deepStrictEqual(data, ['9998', '9999', '10000', '10001', '10002']); - }); - }); - - describe('getSortedSetRevRange()', () => { - it('should return the highest scored element', (done) => { - db.getSortedSetRevRange('sortedSetTest1', 0, 0, function (err, value) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(value, ['value3']); - done(); - }); - }); - - it('should return elements sorted by score highest to lowest', (done) => { - db.getSortedSetRevRange('sortedSetTest1', 0, -1, function (err, values) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(values, ['value3', 'value2', 'value1']); - done(); - }); - }); - }); - - describe('getSortedSetRangeWithScores()', () => { - it('should return array of elements sorted by score lowest to highest with scores', (done) => { - db.getSortedSetRangeWithScores('sortedSetTest1', 0, -1, function (err, values) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(values, [{ value: 'value1', score: 1.1 }, { value: 'value2', score: 1.2 }, { value: 'value3', score: 1.3 }]); - done(); - }); - }); - }); - - describe('getSortedSetRevRangeWithScores()', () => { - it('should return array of elements sorted by score highest to lowest with scores', (done) => { - db.getSortedSetRevRangeWithScores('sortedSetTest1', 0, -1, function (err, values) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(values, [{ value: 'value3', score: 1.3 }, { value: 'value2', score: 1.2 }, { value: 'value1', score: 1.1 }]); - done(); - }); - }); - }); - - describe('getSortedSetRangeByScore()', () => { - it('should get count elements with score between min max sorted by score lowest to highest', (done) => { - db.getSortedSetRangeByScore('sortedSetTest1', 0, -1, '-inf', 1.2, function (err, values) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(values, ['value1', 'value2']); - done(); - }); - }); - - it('should return empty array if set does not exist', (done) => { - db.getSortedSetRangeByScore('doesnotexist', 0, -1, '-inf', 0, (err, values) => { - assert.ifError(err); - assert(Array.isArray(values)); - assert.equal(values.length, 0); - done(); - }); - }); - - it('should return empty array if count is 0', (done) => { - db.getSortedSetRevRangeByScore('sortedSetTest1', 0, 0, '+inf', '-inf', (err, values) => { - assert.ifError(err); - assert.deepEqual(values, []); - done(); - }); - }); - - it('should return elements from 1 to end', (done) => { - db.getSortedSetRevRangeByScore('sortedSetTest1', 1, -1, '+inf', '-inf', (err, values) => { - assert.ifError(err); - assert.deepEqual(values, ['value2', 'value1']); - done(); - }); - }); - - it('should return elements from 3 to last', (done) => { - db.sortedSetAdd('partialZset', [1, 2, 3, 4, 5], ['value1', 'value2', 'value3', 'value4', 'value5'], (err) => { - assert.ifError(err); - db.getSortedSetRangeByScore('partialZset', 3, 10, '-inf', '+inf', (err, data) => { - assert.ifError(err); - assert.deepStrictEqual(data, ['value4', 'value5']); - done(); - }); - }); - }); - }); - - describe('getSortedSetRevRangeByScore()', () => { - it('should get count elements with score between max min sorted by score highest to lowest', (done) => { - db.getSortedSetRevRangeByScore('sortedSetTest1', 0, -1, '+inf', 1.2, function (err, values) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(values, ['value3', 'value2']); - done(); - }); - }); - }); - - describe('getSortedSetRangeByScoreWithScores()', () => { - it('should get count elements with score between min max sorted by score lowest to highest with scores', (done) => { - db.getSortedSetRangeByScoreWithScores('sortedSetTest1', 0, -1, '-inf', 1.2, function (err, values) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(values, [{ value: 'value1', score: 1.1 }, { value: 'value2', score: 1.2 }]); - done(); - }); - }); - }); - - describe('getSortedSetRevRangeByScoreWithScores()', () => { - it('should get count elements with score between max min sorted by score highest to lowest', (done) => { - db.getSortedSetRevRangeByScoreWithScores('sortedSetTest1', 0, -1, '+inf', 1.2, function (err, values) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(values, [{ value: 'value3', score: 1.3 }, { value: 'value2', score: 1.2 }]); - done(); - }); - }); - - it('should work with an array of keys', async () => { - await db.sortedSetAddBulk([ - ['byScoreWithScoresKeys1', 1, 'value1'], - ['byScoreWithScoresKeys2', 2, 'value2'], - ]); - const data = await db.getSortedSetRevRangeByScoreWithScores(['byScoreWithScoresKeys1', 'byScoreWithScoresKeys2'], 0, -1, 5, -5); - assert.deepStrictEqual(data, [{ value: 'value2', score: 2 }, { value: 'value1', score: 1 }]); - }); - }); - - describe('sortedSetCount()', () => { - it('should return 0 for a sorted set that does not exist', (done) => { - db.sortedSetCount('doesnotexist', 0, 10, function (err, count) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(count, 0); - done(); - }); - }); - - it('should return number of elements between scores min max inclusive', (done) => { - db.sortedSetCount('sortedSetTest1', '-inf', 1.2, function (err, count) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(count, 2); - done(); - }); - }); - - it('should return number of elements between scores -inf +inf inclusive', (done) => { - db.sortedSetCount('sortedSetTest1', '-inf', '+inf', function (err, count) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(count, 3); - done(); - }); - }); - }); - - describe('sortedSetCard()', () => { - it('should return 0 for a sorted set that does not exist', (done) => { - db.sortedSetCard('doesnotexist', function (err, count) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(count, 0); - done(); - }); - }); - - it('should return number of elements in a sorted set', (done) => { - db.sortedSetCard('sortedSetTest1', function (err, count) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(count, 3); - done(); - }); - }); - }); - - describe('sortedSetsCard()', () => { - it('should return the number of elements in sorted sets', (done) => { - db.sortedSetsCard(['sortedSetTest1', 'sortedSetTest2', 'doesnotexist'], function (err, counts) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.deepEqual(counts, [3, 2, 0]); - done(); - }); - }); - - it('should return empty array if keys is falsy', (done) => { - db.sortedSetsCard(undefined, function (err, counts) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.deepEqual(counts, []); - done(); - }); - }); - - it('should return empty array if keys is empty array', (done) => { - db.sortedSetsCard([], function (err, counts) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.deepEqual(counts, []); - done(); - }); - }); - }); - - describe('sortedSetsCardSum()', () => { - it('should return the total number of elements in sorted sets', (done) => { - db.sortedSetsCardSum(['sortedSetTest1', 'sortedSetTest2', 'doesnotexist'], function (err, sum) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.equal(sum, 5); - done(); - }); - }); - - it('should return 0 if keys is falsy', (done) => { - db.sortedSetsCardSum(undefined, function (err, counts) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.deepEqual(counts, 0); - done(); - }); - }); - - it('should return 0 if keys is empty array', (done) => { - db.sortedSetsCardSum([], function (err, counts) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.deepEqual(counts, 0); - done(); - }); - }); - - it('should return the total number of elements in sorted set', (done) => { - db.sortedSetsCardSum('sortedSetTest1', function (err, sum) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.equal(sum, 3); - done(); - }); - }); - }); - - describe('sortedSetRank()', () => { - it('should return falsy if sorted set does not exist', (done) => { - db.sortedSetRank('doesnotexist', 'value1', function (err, rank) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(!!rank, false); - done(); - }); - }); - - it('should return falsy if element isnt in sorted set', (done) => { - db.sortedSetRank('sortedSetTest1', 'value5', function (err, rank) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(!!rank, false); - done(); - }); - }); - - it('should return the rank of the element in the sorted set sorted by lowest to highest score', (done) => { - db.sortedSetRank('sortedSetTest1', 'value1', function (err, rank) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(rank, 0); - done(); - }); - }); - - it('should return the rank sorted by the score and then the value (a)', (done) => { - db.sortedSetRank('sortedSetTest4', 'a', function (err, rank) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(rank, 0); - done(); - }); - }); - - it('should return the rank sorted by the score and then the value (b)', (done) => { - db.sortedSetRank('sortedSetTest4', 'b', function (err, rank) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(rank, 1); - done(); - }); - }); - - it('should return the rank sorted by the score and then the value (c)', (done) => { - db.sortedSetRank('sortedSetTest4', 'c', function (err, rank) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(rank, 4); - done(); - }); - }); - }); - - describe('sortedSetRevRank()', () => { - it('should return falsy if sorted set doesnot exist', (done) => { - db.sortedSetRevRank('doesnotexist', 'value1', function (err, rank) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(!!rank, false); - done(); - }); - }); - - it('should return falsy if element isnt in sorted set', (done) => { - db.sortedSetRevRank('sortedSetTest1', 'value5', function (err, rank) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(!!rank, false); - done(); - }); - }); - - it('should return the rank of the element in the sorted set sorted by highest to lowest score', (done) => { - db.sortedSetRevRank('sortedSetTest1', 'value1', function (err, rank) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(rank, 2); - done(); - }); - }); - }); - - describe('sortedSetsRanks()', () => { - it('should return the ranks of values in sorted sets', (done) => { - db.sortedSetsRanks(['sortedSetTest1', 'sortedSetTest2'], ['value1', 'value4'], function (err, ranks) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(ranks, [0, 1]); - done(); - }); - }); - }); - - describe('sortedSetRanks()', () => { - it('should return the ranks of values in a sorted set', (done) => { - db.sortedSetRanks('sortedSetTest1', ['value2', 'value1', 'value3', 'value4'], function (err, ranks) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(ranks, [1, 0, 2, null]); - done(); - }); - }); - - it('should return the ranks of values in a sorted set in reverse', (done) => { - db.sortedSetRevRanks('sortedSetTest1', ['value2', 'value1', 'value3', 'value4'], function (err, ranks) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(ranks, [1, 2, 0, null]); - done(); - }); - }); - }); - - describe('sortedSetScore()', () => { - it('should return falsy if sorted set does not exist', (done) => { - db.sortedSetScore('doesnotexist', 'value1', function (err, score) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(!!score, false); - assert.strictEqual(score, null); - done(); - }); - }); - - it('should return falsy if element is not in sorted set', (done) => { - db.sortedSetScore('sortedSetTest1', 'value5', function (err, score) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.equal(!!score, false); - assert.strictEqual(score, null); - done(); - }); - }); - - it('should return the score of an element', (done) => { - db.sortedSetScore('sortedSetTest1', 'value2', function (err, score) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.strictEqual(score, 1.2); - done(); - }); - }); - - it('should not error if key is undefined', (done) => { - db.sortedSetScore(undefined, 1, (err, score) => { - assert.ifError(err); - assert.strictEqual(score, null); - done(); - }); - }); - - it('should not error if value is undefined', (done) => { - db.sortedSetScore('sortedSetTest1', undefined, (err, score) => { - assert.ifError(err); - assert.strictEqual(score, null); - done(); - }); - }); - }); - - describe('sortedSetsScore()', () => { - it('should return the scores of value in sorted sets', (done) => { - db.sortedSetsScore(['sortedSetTest1', 'sortedSetTest2', 'doesnotexist'], 'value1', function (err, scores) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(scores, [1.1, 1, null]); - done(); - }); - }); - - it('should return scores even if some keys are undefined', (done) => { - db.sortedSetsScore(['sortedSetTest1', undefined, 'doesnotexist'], 'value1', function (err, scores) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(scores, [1.1, null, null]); - done(); - }); - }); - - it('should return empty array if keys is empty array', (done) => { - db.sortedSetsScore([], 'value1', function (err, scores) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(scores, []); - done(); - }); - }); - }); - - describe('sortedSetScores()', () => { - before((done) => { - db.sortedSetAdd('zeroScore', 0, 'value1', done); - }); - - it('should return 0 if score is 0', (done) => { - db.sortedSetScores('zeroScore', ['value1'], (err, scores) => { - assert.ifError(err); - assert.strictEqual(scores[0], 0); - done(); - }); - }); - - it('should return the scores of value in sorted sets', (done) => { - db.sortedSetScores('sortedSetTest1', ['value2', 'value1', 'doesnotexist'], function (err, scores) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.deepStrictEqual(scores, [1.2, 1.1, null]); - done(); - }); - }); - - it('should return scores even if some values are undefined', (done) => { - db.sortedSetScores('sortedSetTest1', ['value2', undefined, 'doesnotexist'], function (err, scores) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.deepStrictEqual(scores, [1.2, null, null]); - done(); - }); - }); - - it('should return empty array if values is an empty array', (done) => { - db.sortedSetScores('sortedSetTest1', [], function (err, scores) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.deepStrictEqual(scores, []); - done(); - }); - }); - - it('should return scores properly', (done) => { - db.sortedSetsScore(['zeroScore', 'sortedSetTest1', 'doesnotexist'], 'value1', function (err, scores) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.deepStrictEqual(scores, [0, 1.1, null]); - done(); - }); - }); - }); - - describe('isSortedSetMember()', () => { - before((done) => { - db.sortedSetAdd('zeroscore', 0, 'itemwithzeroscore', done); - }); - - it('should return false if sorted set does not exist', (done) => { - db.isSortedSetMember('doesnotexist', 'value1', function (err, isMember) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.equal(isMember, false); - done(); - }); - }); - - it('should return false if element is not in sorted set', (done) => { - db.isSortedSetMember('sorted2', 'value5', function (err, isMember) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.equal(isMember, false); - done(); - }); - }); - - it('should return true if element is in sorted set', (done) => { - db.isSortedSetMember('sortedSetTest1', 'value2', function (err, isMember) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.strictEqual(isMember, true); - done(); - }); - }); - - it('should return true if element is in sorted set with score 0', (done) => { - db.isSortedSetMember('zeroscore', 'itemwithzeroscore', (err, isMember) => { - assert.ifError(err); - assert.strictEqual(isMember, true); - done(); - }); - }); - }); - - describe('isSortedSetMembers()', () => { - it('should return an array of booleans indicating membership', (done) => { - db.isSortedSetMembers('sortedSetTest1', ['value1', 'value2', 'value5'], function (err, isMembers) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(isMembers, [true, true, false]); - done(); - }); - }); - - it('should return true if element is in sorted set with score 0', (done) => { - db.isSortedSetMembers('zeroscore', ['itemwithzeroscore'], function (err, isMembers) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.deepEqual(isMembers, [true]); - done(); - }); - }); - }); - - describe('isMemberOfSortedSets', () => { - it('should return true for members false for non members', (done) => { - db.isMemberOfSortedSets(['doesnotexist', 'sortedSetTest1', 'sortedSetTest2'], 'value2', function (err, isMembers) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(isMembers, [false, true, false]); - done(); - }); - }); - - it('should return empty array if keys is empty array', (done) => { - db.isMemberOfSortedSets([], 'value2', function (err, isMembers) { - assert.ifError(err); - assert.equal(arguments.length, 2); - assert.deepEqual(isMembers, []); - done(); - }); - }); - }); - - describe('getSortedSetsMembers', () => { - it('should return members of a sorted set', async () => { - const result = await db.getSortedSetMembers('sortedSetTest1'); - result.forEach((element) => { - assert(['value1', 'value2', 'value3'].includes(element)); - }); - }); - - it('should return members of multiple sorted sets', (done) => { - db.getSortedSetsMembers(['doesnotexist', 'sortedSetTest1'], function (err, sortedSets) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(sortedSets[0], []); - sortedSets[0].forEach((element) => { - assert.notEqual(['value1', 'value2', 'value3'].indexOf(element), -1); - }); - - done(); - }); - }); - }); - - describe('sortedSetUnionCard', () => { - it('should return the number of elements in the union', (done) => { - db.sortedSetUnionCard(['sortedSetTest2', 'sortedSetTest3'], (err, count) => { - assert.ifError(err); - assert.equal(count, 3); - done(); - }); - }); - }); - - describe('getSortedSetUnion()', () => { - it('should return an array of values from both sorted sets sorted by scores lowest to highest', (done) => { - db.getSortedSetUnion({ sets: ['sortedSetTest2', 'sortedSetTest3'], start: 0, stop: -1 }, function (err, values) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(values, ['value1', 'value2', 'value4']); - done(); - }); - }); - - it('should return an array of values and scores from both sorted sets sorted by scores lowest to highest', (done) => { - db.getSortedSetUnion({ sets: ['sortedSetTest2', 'sortedSetTest3'], start: 0, stop: -1, withScores: true }, function (err, data) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(data, [{ value: 'value1', score: 1 }, { value: 'value2', score: 2 }, { value: 'value4', score: 8 }]); - done(); - }); - }); - }); - - describe('getSortedSetRevUnion()', () => { - it('should return an array of values from both sorted sets sorted by scores highest to lowest', (done) => { - db.getSortedSetRevUnion({ sets: ['sortedSetTest2', 'sortedSetTest3'], start: 0, stop: -1 }, function (err, values) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.deepEqual(values, ['value4', 'value2', 'value1']); - done(); - }); - }); - }); - - describe('sortedSetIncrBy()', () => { - it('should create a sorted set with a field set to 1', (done) => { - db.sortedSetIncrBy('sortedIncr', 1, 'field1', function (err, newValue) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.strictEqual(newValue, 1); - db.sortedSetScore('sortedIncr', 'field1', (err, score) => { - assert.equal(err, null); - assert.strictEqual(score, 1); - done(); - }); - }); - }); - - it('should increment a field of a sorted set by 5', (done) => { - db.sortedSetIncrBy('sortedIncr', 5, 'field1', function (err, newValue) { - assert.equal(err, null); - assert.equal(arguments.length, 2); - assert.strictEqual(newValue, 6); - db.sortedSetScore('sortedIncr', 'field1', (err, score) => { - assert.equal(err, null); - assert.strictEqual(score, 6); - done(); - }); - }); - }); - - it('should increment fields of sorted sets with a single call', async () => { - const data = await db.sortedSetIncrByBulk([ - ['sortedIncrBulk1', 1, 'value1'], - ['sortedIncrBulk2', 2, 'value2'], - ['sortedIncrBulk3', 3, 'value3'], - ['sortedIncrBulk3', 4, 'value4'], - ]); - assert.deepStrictEqual(data, [1, 2, 3, 4]); - assert.deepStrictEqual( - await db.getSortedSetRangeWithScores('sortedIncrBulk1', 0, -1), - [{ value: 'value1', score: 1 }], - ); - assert.deepStrictEqual( - await db.getSortedSetRangeWithScores('sortedIncrBulk2', 0, -1), - [{ value: 'value2', score: 2 }], - ); - assert.deepStrictEqual( - await db.getSortedSetRangeWithScores('sortedIncrBulk3', 0, -1), - [ - { value: 'value3', score: 3 }, - { value: 'value4', score: 4 }, - ], - ); - }); - - it('should increment the same field', async () => { - const data1 = await db.sortedSetIncrByBulk([ - ['sortedIncrBulk5', 5, 'value5'], - ]); - - const data2 = await db.sortedSetIncrByBulk([ - ['sortedIncrBulk5', 5, 'value5'], - ]); - assert.deepStrictEqual( - await db.getSortedSetRangeWithScores('sortedIncrBulk5', 0, -1), - [ - { value: 'value5', score: 10 }, - ], - ); - }); - }); - - - describe('sortedSetRemove()', () => { - before((done) => { - db.sortedSetAdd('sorted3', [1, 2], ['value1', 'value2'], done); - }); - - it('should remove an element from a sorted set', (done) => { - db.sortedSetRemove('sorted3', 'value2', function (err) { - assert.equal(err, null); - assert.equal(arguments.length, 1); - db.isSortedSetMember('sorted3', 'value2', (err, isMember) => { - assert.equal(err, null); - assert.equal(isMember, false); - done(); - }); - }); - }); - - it('should not think the sorted set exists if the last element is removed', async () => { - await db.sortedSetRemove('sorted3', 'value1'); - assert.strictEqual(await db.exists('sorted3'), false); - }); - - it('should remove multiple values from multiple keys', (done) => { - db.sortedSetAdd('multiTest1', [1, 2, 3, 4], ['one', 'two', 'three', 'four'], (err) => { - assert.ifError(err); - db.sortedSetAdd('multiTest2', [3, 4, 5, 6], ['three', 'four', 'five', 'six'], (err) => { - assert.ifError(err); - db.sortedSetRemove(['multiTest1', 'multiTest2'], ['two', 'three', 'four', 'five', 'doesnt exist'], (err) => { - assert.ifError(err); - db.getSortedSetsMembers(['multiTest1', 'multiTest2'], (err, members) => { - assert.ifError(err); - assert.equal(members[0].length, 1); - assert.equal(members[1].length, 1); - assert.deepEqual(members, [['one'], ['six']]); - done(); - }); - }); - }); - }); - }); - - it('should remove value from multiple keys', async () => { - await db.sortedSetAdd('multiTest3', [1, 2, 3, 4], ['one', 'two', 'three', 'four']); - await db.sortedSetAdd('multiTest4', [3, 4, 5, 6], ['three', 'four', 'five', 'six']); - await db.sortedSetRemove(['multiTest3', 'multiTest4'], 'three'); - assert.deepStrictEqual(await db.getSortedSetRange('multiTest3', 0, -1), ['one', 'two', 'four']); - assert.deepStrictEqual(await db.getSortedSetRange('multiTest4', 0, -1), ['four', 'five', 'six']); - }); - - it('should remove multiple values from multiple keys', (done) => { - db.sortedSetAdd('multiTest5', [1], ['one'], (err) => { - assert.ifError(err); - db.sortedSetAdd('multiTest6', [2], ['two'], (err) => { - assert.ifError(err); - db.sortedSetAdd('multiTest7', [3], [333], (err) => { - assert.ifError(err); - db.sortedSetRemove(['multiTest5', 'multiTest6', 'multiTest7'], ['one', 'two', 333], (err) => { - assert.ifError(err); - db.getSortedSetsMembers(['multiTest5', 'multiTest6', 'multiTest7'], (err, members) => { - assert.ifError(err); - assert.deepEqual(members, [[], [], []]); - done(); - }); - }); - }); - }); - }); - }); - - it('should not remove anything if values is empty array', (done) => { - db.sortedSetAdd('removeNothing', [1, 2, 3], ['val1', 'val2', 'val3'], (err) => { - assert.ifError(err); - db.sortedSetRemove('removeNothing', [], (err) => { - assert.ifError(err); - db.getSortedSetRange('removeNothing', 0, -1, (err, data) => { - assert.ifError(err); - assert.deepStrictEqual(data, ['val1', 'val2', 'val3']); - done(); - }); - }); - }); - }); - - it('should do a bulk remove', async () => { - await db.sortedSetAddBulk([ - ['bulkRemove1', 1, 'value1'], - ['bulkRemove1', 2, 'value2'], - ['bulkRemove2', 3, 'value2'], - ]); - await db.sortedSetRemoveBulk([ - ['bulkRemove1', 'value1'], - ['bulkRemove1', 'value2'], - ['bulkRemove2', 'value2'], - ]); - const members = await db.getSortedSetsMembers(['bulkRemove1', 'bulkRemove2']); - assert.deepStrictEqual(members, [[], []]); - }); - - it('should not remove wrong elements in bulk remove', async () => { - await db.sortedSetAddBulk([ - ['bulkRemove4', 1, 'value1'], - ['bulkRemove4', 2, 'value2'], - ['bulkRemove4', 3, 'value4'], - ['bulkRemove5', 1, 'value1'], - ['bulkRemove5', 2, 'value2'], - ['bulkRemove5', 3, 'value3'], - ]); - await db.sortedSetRemoveBulk([ - ['bulkRemove4', 'value1'], - ['bulkRemove4', 'value3'], - ['bulkRemove5', 'value1'], - ['bulkRemove5', 'value4'], - ]); - const members = await Promise.all([ - db.getSortedSetRange('bulkRemove4', 0, -1), - db.getSortedSetRange('bulkRemove5', 0, -1), - ]); - assert.deepStrictEqual(members[0], ['value2', 'value4']); - assert.deepStrictEqual(members[1], ['value2', 'value3']); - }); - }); - - describe('sortedSetsRemove()', () => { - before((done) => { - async.parallel([ - async.apply(db.sortedSetAdd, 'sorted4', [1, 2], ['value1', 'value2']), - async.apply(db.sortedSetAdd, 'sorted5', [1, 2], ['value1', 'value3']), - ], done); - }); - - it('should remove element from multiple sorted sets', (done) => { - db.sortedSetsRemove(['sorted4', 'sorted5'], 'value1', function (err) { - assert.equal(err, null); - assert.equal(arguments.length, 1); - db.sortedSetsScore(['sorted4', 'sorted5'], 'value1', (err, scores) => { - assert.equal(err, null); - assert.deepStrictEqual(scores, [null, null]); - done(); - }); - }); - }); - }); - - describe('sortedSetsRemoveRangeByScore()', () => { - before((done) => { - db.sortedSetAdd('sorted6', [1, 2, 3, 4, 5], ['value1', 'value2', 'value3', 'value4', 'value5'], done); - }); - - it('should remove elements with scores between min max inclusive', (done) => { - db.sortedSetsRemoveRangeByScore(['sorted6'], 4, 5, function (err) { - assert.ifError(err); - assert.equal(arguments.length, 1); - db.getSortedSetRange('sorted6', 0, -1, (err, values) => { - assert.ifError(err); - assert.deepEqual(values, ['value1', 'value2', 'value3']); - done(); - }); - }); - }); - - it('should remove elements with if strin score is passed in', (done) => { - db.sortedSetAdd('sortedForRemove', [11, 22, 33], ['value1', 'value2', 'value3'], (err) => { - assert.ifError(err); - db.sortedSetsRemoveRangeByScore(['sortedForRemove'], '22', '22', (err) => { - assert.ifError(err); - db.getSortedSetRange('sortedForRemove', 0, -1, (err, values) => { - assert.ifError(err); - assert.deepEqual(values, ['value1', 'value3']); - done(); - }); - }); - }); - }); - }); - - describe('getSortedSetIntersect', () => { - before((done) => { - async.parallel([ - function (next) { - db.sortedSetAdd('interSet1', [1, 2, 3], ['value1', 'value2', 'value3'], next); - }, - function (next) { - db.sortedSetAdd('interSet2', [4, 5, 6], ['value2', 'value3', 'value5'], next); - }, - ], done); - }); - - it('should return the intersection of two sets', (done) => { - db.getSortedSetIntersect({ - sets: ['interSet1', 'interSet2'], - start: 0, - stop: -1, - }, (err, data) => { - assert.ifError(err); - assert.deepEqual(['value2', 'value3'], data); - done(); - }); - }); - - it('should return the intersection of two sets with scores', (done) => { - db.getSortedSetIntersect({ - sets: ['interSet1', 'interSet2'], - start: 0, - stop: -1, - withScores: true, - }, (err, data) => { - assert.ifError(err); - assert.deepEqual([{ value: 'value2', score: 6 }, { value: 'value3', score: 8 }], data); - done(); - }); - }); - - it('should return the reverse intersection of two sets', (done) => { - db.getSortedSetRevIntersect({ - sets: ['interSet1', 'interSet2'], - start: 0, - stop: 2, - }, (err, data) => { - assert.ifError(err); - assert.deepEqual(['value3', 'value2'], data); - done(); - }); - }); - - it('should return the intersection of two sets with scores aggregate MIN', (done) => { - db.getSortedSetIntersect({ - sets: ['interSet1', 'interSet2'], - start: 0, - stop: -1, - withScores: true, - aggregate: 'MIN', - }, (err, data) => { - assert.ifError(err); - assert.deepEqual([{ value: 'value2', score: 2 }, { value: 'value3', score: 3 }], data); - done(); - }); - }); - - it('should return the intersection of two sets with scores aggregate MAX', (done) => { - db.getSortedSetIntersect({ - sets: ['interSet1', 'interSet2'], - start: 0, - stop: -1, - withScores: true, - aggregate: 'MAX', - }, (err, data) => { - assert.ifError(err); - assert.deepEqual([{ value: 'value2', score: 4 }, { value: 'value3', score: 5 }], data); - done(); - }); - }); - - it('should return the intersection with scores modified by weights', (done) => { - db.getSortedSetIntersect({ - sets: ['interSet1', 'interSet2'], - start: 0, - stop: -1, - withScores: true, - weights: [1, 0.5], - }, (err, data) => { - assert.ifError(err); - assert.deepEqual([{ value: 'value2', score: 4 }, { value: 'value3', score: 5.5 }], data); - done(); - }); - }); - - it('should return empty array if sets do not exist', (done) => { - db.getSortedSetIntersect({ - sets: ['interSet10', 'interSet12'], - start: 0, - stop: -1, - }, (err, data) => { - assert.ifError(err); - assert.equal(data.length, 0); - done(); - }); - }); - - it('should return empty array if one set does not exist', (done) => { - db.getSortedSetIntersect({ - sets: ['interSet1', 'interSet12'], - start: 0, - stop: -1, - }, (err, data) => { - assert.ifError(err); - assert.equal(data.length, 0); - done(); - }); - }); - - it('should return correct results if sorting by different zset', async () => { - await db.sortedSetAdd('bigzset', [1, 2, 3, 4, 5, 6], ['a', 'b', 'c', 'd', 'e', 'f']); - await db.sortedSetAdd('smallzset', [3, 2, 1], ['b', 'e', 'g']); - const data = await db.getSortedSetRevIntersect({ - sets: ['bigzset', 'smallzset'], - start: 0, - stop: 19, - weights: [1, 0], - withScores: true, - }); - assert.deepStrictEqual(data, [{ value: 'e', score: 5 }, { value: 'b', score: 2 }]); - const data2 = await db.getSortedSetRevIntersect({ - sets: ['bigzset', 'smallzset'], - start: 0, - stop: 19, - weights: [0, 1], - withScores: true, - }); - assert.deepStrictEqual(data2, [{ value: 'b', score: 3 }, { value: 'e', score: 2 }]); - }); - - it('should return correct results when intersecting big zsets', async () => { - const scores = []; - const values = []; - for (let i = 0; i < 30000; i++) { - scores.push((i + 1) * 1000); - values.push(String(i + 1)); - } - await db.sortedSetAdd('verybigzset', scores, values); - - scores.length = 0; - values.length = 0; - for (let i = 15000; i < 45000; i++) { - scores.push((i + 1) * 1000); - values.push(String(i + 1)); - } - await db.sortedSetAdd('anotherbigzset', scores, values); - const data = await db.getSortedSetRevIntersect({ - sets: ['verybigzset', 'anotherbigzset'], - start: 0, - stop: 3, - weights: [1, 0], - withScores: true, - }); - assert.deepStrictEqual(data, [ - { value: '30000', score: 30000000 }, - { value: '29999', score: 29999000 }, - { value: '29998', score: 29998000 }, - { value: '29997', score: 29997000 }, - ]); - }); - }); - - describe('sortedSetIntersectCard', () => { - before((done) => { - async.parallel([ - function (next) { - db.sortedSetAdd('interCard1', [0, 0, 0], ['value1', 'value2', 'value3'], next); - }, - function (next) { - db.sortedSetAdd('interCard2', [0, 0, 0], ['value2', 'value3', 'value4'], next); - }, - function (next) { - db.sortedSetAdd('interCard3', [0, 0, 0], ['value3', 'value4', 'value5'], next); - }, - function (next) { - db.sortedSetAdd('interCard4', [0, 0, 0], ['value4', 'value5', 'value6'], next); - }, - ], done); - }); - - it('should return # of elements in intersection', (done) => { - db.sortedSetIntersectCard(['interCard1', 'interCard2', 'interCard3'], (err, count) => { - assert.ifError(err); - assert.strictEqual(count, 1); - done(); - }); - }); - - it('should return 0 if intersection is empty', (done) => { - db.sortedSetIntersectCard(['interCard1', 'interCard4'], (err, count) => { - assert.ifError(err); - assert.strictEqual(count, 0); - done(); - }); - }); - }); - - describe('getSortedSetRangeByLex', () => { - it('should return an array of all values', (done) => { - db.getSortedSetRangeByLex('sortedSetLex', '-', '+', (err, data) => { - assert.ifError(err); - assert.deepEqual(data, ['a', 'b', 'c', 'd']); - done(); - }); - }); - - it('should return an array with an inclusive range by default', (done) => { - db.getSortedSetRangeByLex('sortedSetLex', 'a', 'd', (err, data) => { - assert.ifError(err); - assert.deepEqual(data, ['a', 'b', 'c', 'd']); - done(); - }); - }); - - it('should return an array with an inclusive range', (done) => { - db.getSortedSetRangeByLex('sortedSetLex', '[a', '[d', (err, data) => { - assert.ifError(err); - assert.deepEqual(data, ['a', 'b', 'c', 'd']); - done(); - }); - }); - - it('should return an array with an exclusive range', (done) => { - db.getSortedSetRangeByLex('sortedSetLex', '(a', '(d', (err, data) => { - assert.ifError(err); - assert.deepEqual(data, ['b', 'c']); - done(); - }); - }); - - it('should return an array limited to the first two values', (done) => { - db.getSortedSetRangeByLex('sortedSetLex', '-', '+', 0, 2, (err, data) => { - assert.ifError(err); - assert.deepEqual(data, ['a', 'b']); - done(); - }); - }); - - it('should return correct result', async () => { - await db.sortedSetAdd('sortedSetLexSearch', [0, 0, 0], ['baris:usakli:1', 'baris usakli:2', 'baris soner:3']); - const query = 'baris:'; - const min = query; - const max = query.slice(0, -1) + String.fromCharCode(query.charCodeAt(query.length - 1) + 1); - const result = await db.getSortedSetRangeByLex('sortedSetLexSearch', min, max, 0, -1); - assert.deepStrictEqual(result, ['baris:usakli:1']); - }); - }); - - describe('getSortedSetRevRangeByLex', () => { - it('should return an array of all values reversed', (done) => { - db.getSortedSetRevRangeByLex('sortedSetLex', '+', '-', (err, data) => { - assert.ifError(err); - assert.deepEqual(data, ['d', 'c', 'b', 'a']); - done(); - }); - }); - - it('should return an array with an inclusive range by default reversed', (done) => { - db.getSortedSetRevRangeByLex('sortedSetLex', 'd', 'a', (err, data) => { - assert.ifError(err); - assert.deepEqual(data, ['d', 'c', 'b', 'a']); - done(); - }); - }); - - it('should return an array with an inclusive range reversed', (done) => { - db.getSortedSetRevRangeByLex('sortedSetLex', '[d', '[a', (err, data) => { - assert.ifError(err); - assert.deepEqual(data, ['d', 'c', 'b', 'a']); - done(); - }); - }); - - it('should return an array with an exclusive range reversed', (done) => { - db.getSortedSetRevRangeByLex('sortedSetLex', '(d', '(a', (err, data) => { - assert.ifError(err); - assert.deepEqual(data, ['c', 'b']); - done(); - }); - }); - - it('should return an array limited to the first two values reversed', (done) => { - db.getSortedSetRevRangeByLex('sortedSetLex', '+', '-', 0, 2, (err, data) => { - assert.ifError(err); - assert.deepEqual(data, ['d', 'c']); - done(); - }); - }); - }); - - describe('sortedSetLexCount', () => { - it('should return the count of all values', (done) => { - db.sortedSetLexCount('sortedSetLex', '-', '+', (err, data) => { - assert.ifError(err); - assert.strictEqual(data, 4); - done(); - }); - }); - - it('should return the count with an inclusive range by default', (done) => { - db.sortedSetLexCount('sortedSetLex', 'a', 'd', (err, data) => { - assert.ifError(err); - assert.strictEqual(data, 4); - done(); - }); - }); - - it('should return the count with an inclusive range', (done) => { - db.sortedSetLexCount('sortedSetLex', '[a', '[d', (err, data) => { - assert.ifError(err); - assert.strictEqual(data, 4); - done(); - }); - }); - - it('should return the count with an exclusive range', (done) => { - db.sortedSetLexCount('sortedSetLex', '(a', '(d', (err, data) => { - assert.ifError(err); - assert.strictEqual(data, 2); - done(); - }); - }); - }); - - describe('sortedSetRemoveRangeByLex', () => { - before((done) => { - db.sortedSetAdd('sortedSetLex2', [0, 0, 0, 0, 0, 0, 0], ['a', 'b', 'c', 'd', 'e', 'f', 'g'], done); - }); - - it('should remove an inclusive range by default', (done) => { - db.sortedSetRemoveRangeByLex('sortedSetLex2', 'a', 'b', function (err) { - assert.ifError(err); - assert.equal(arguments.length, 1); - db.getSortedSetRangeByLex('sortedSetLex2', '-', '+', (err, data) => { - assert.ifError(err); - assert.deepEqual(data, ['c', 'd', 'e', 'f', 'g']); - done(); - }); - }); - }); - - it('should remove an inclusive range', (done) => { - db.sortedSetRemoveRangeByLex('sortedSetLex2', '[c', '[d', function (err) { - assert.ifError(err); - assert.equal(arguments.length, 1); - db.getSortedSetRangeByLex('sortedSetLex2', '-', '+', (err, data) => { - assert.ifError(err); - assert.deepEqual(data, ['e', 'f', 'g']); - done(); - }); - }); - }); - - it('should remove an exclusive range', (done) => { - db.sortedSetRemoveRangeByLex('sortedSetLex2', '(e', '(g', function (err) { - assert.ifError(err); - assert.equal(arguments.length, 1); - db.getSortedSetRangeByLex('sortedSetLex2', '-', '+', (err, data) => { - assert.ifError(err); - assert.deepEqual(data, ['e', 'g']); - done(); - }); - }); - }); - - it('should remove all values', (done) => { - db.sortedSetRemoveRangeByLex('sortedSetLex2', '-', '+', function (err) { - assert.ifError(err); - assert.equal(arguments.length, 1); - db.getSortedSetRangeByLex('sortedSetLex2', '-', '+', (err, data) => { - assert.ifError(err); - assert.deepEqual(data, []); - done(); - }); - }); - }); - }); + before(done => { + async.parallel([ + function (next) { + db.sortedSetAdd('sortedSetTest1', [1.1, 1.2, 1.3], ['value1', 'value2', 'value3'], next); + }, + function (next) { + db.sortedSetAdd('sortedSetTest2', [1, 4], ['value1', 'value4'], next); + }, + function (next) { + db.sortedSetAdd('sortedSetTest3', [2, 4], ['value2', 'value4'], next); + }, + function (next) { + db.sortedSetAdd('sortedSetTest4', [1, 1, 2, 3, 5], ['b', 'a', 'd', 'e', 'c'], next); + }, + function (next) { + db.sortedSetAdd('sortedSetLex', [0, 0, 0, 0], ['a', 'b', 'c', 'd'], next); + }, + ], done); + }); + + describe('sortedSetScan', () => { + it('should find matches in sorted set containing substring', async () => { + await db.sortedSetAdd('scanzset', [1, 2, 3, 4, 5, 6], ['aaaa', 'bbbb', 'bbcc', 'ddd', 'dddd', 'fghbc']); + const data = await db.getSortedSetScan({ + key: 'scanzset', + match: '*bc*', + }); + assert(data.includes('bbcc')); + assert(data.includes('fghbc')); + }); + + it('should find matches in sorted set with scores', async () => { + const data = await db.getSortedSetScan({ + key: 'scanzset', + match: '*bc*', + withScores: true, + }); + data.sort((a, b) => a.score - b.score); + assert.deepStrictEqual(data, [{value: 'bbcc', score: 3}, {value: 'fghbc', score: 6}]); + }); + + it('should find matches in sorted set with a limit', async () => { + await db.sortedSetAdd('scanzset2', [1, 2, 3, 4, 5, 6], ['aaab', 'bbbb', 'bbcb', 'ddb', 'dddd', 'fghbc']); + const data = await db.getSortedSetScan({ + key: 'scanzset2', + match: '*b*', + limit: 2, + }); + assert.equal(data.length, 2); + }); + + it('should work for special characters', async () => { + await db.sortedSetAdd('scanzset3', [1, 2, 3, 4, 5], ['aaab{', 'bbbb', 'bbcb{', 'ddb', 'dddd']); + const data = await db.getSortedSetScan({ + key: 'scanzset3', + match: '*b{', + limit: 2, + }); + assert(data.includes('aaab{')); + assert(data.includes('bbcb{')); + }); + + it('should find everything starting with string', async () => { + await db.sortedSetAdd('scanzset4', [1, 2, 3, 4, 5], ['aaab{', 'bbbb', 'bbcb', 'ddb', 'dddd']); + const data = await db.getSortedSetScan({ + key: 'scanzset4', + match: 'b*', + limit: 2, + }); + assert(data.includes('bbbb')); + assert(data.includes('bbcb')); + }); + + it('should find everything ending with string', async () => { + await db.sortedSetAdd('scanzset5', [1, 2, 3, 4, 5, 6], ['aaab{', 'bbbb', 'bbcb', 'ddb', 'dddd', 'adb']); + const data = await db.getSortedSetScan({ + key: 'scanzset5', + match: '*db', + }); + assert.equal(data.length, 2); + assert(data.includes('ddb')); + assert(data.includes('adb')); + }); + }); + + describe('sortedSetAdd()', () => { + it('should add an element to a sorted set', done => { + db.sortedSetAdd('sorted1', 1, 'value1', function (error) { + assert.equal(error, null); + assert.equal(arguments.length, 1); + done(); + }); + }); + + it('should add two elements to a sorted set', done => { + db.sortedSetAdd('sorted2', [1, 2], ['value1', 'value2'], function (error) { + assert.equal(error, null); + assert.equal(arguments.length, 1); + done(); + }); + }); + + it('should gracefully handle adding the same element twice', done => { + db.sortedSetAdd('sorted2', [1, 2], ['value1', 'value1'], function (error) { + assert.equal(error, null); + assert.equal(arguments.length, 1); + + db.sortedSetScore('sorted2', 'value1', function (error, score) { + assert.equal(error, null); + assert.equal(score, 2); + assert.equal(arguments.length, 2); + + done(); + }); + }); + }); + + it('should error if score is null', done => { + db.sortedSetAdd('errorScore', null, 'value1', error => { + assert.equal(error.message, '[[error:invalid-score, null]]'); + done(); + }); + }); + + it('should error if any score is undefined', done => { + db.sortedSetAdd('errorScore', [1, undefined], ['value1', 'value2'], error => { + assert.equal(error.message, '[[error:invalid-score, undefined]]'); + done(); + }); + }); + + it('should add null value as `null` string', done => { + db.sortedSetAdd('nullValueZSet', 1, null, error => { + assert.ifError(error); + db.getSortedSetRange('nullValueZSet', 0, -1, (error, values) => { + assert.ifError(error); + assert.strictEqual(values[0], 'null'); + done(); + }); + }); + }); + }); + + describe('sortedSetsAdd()', () => { + it('should add an element to two sorted sets', done => { + db.sortedSetsAdd(['sorted1', 'sorted2'], 3, 'value3', function (error) { + assert.equal(error, null); + assert.equal(arguments.length, 1); + done(); + }); + }); + + it('should add an element to two sorted sets with different scores', done => { + db.sortedSetsAdd(['sorted1', 'sorted2'], [4, 5], 'value4', error => { + assert.ifError(error); + db.sortedSetsScore(['sorted1', 'sorted2'], 'value4', (error, scores) => { + assert.ifError(error); + assert.deepStrictEqual(scores, [4, 5]); + done(); + }); + }); + }); + + it('should error if keys.length is different than scores.length', done => { + db.sortedSetsAdd(['sorted1', 'sorted2'], [4], 'value4', error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should error if score is null', done => { + db.sortedSetsAdd(['sorted1', 'sorted2'], null, 'value1', error => { + assert.equal(error.message, '[[error:invalid-score, null]]'); + done(); + }); + }); + + it('should error if scores has null', async () => { + let error; + try { + await db.sortedSetsAdd(['sorted1', 'sorted2'], [1, null], 'dontadd'); + } catch (error_) { + error = error_; + } + + assert.equal(error.message, '[[error:invalid-score, 1,]]'); + assert.strictEqual(await db.isSortedSetMember('sorted1', 'dontadd'), false); + assert.strictEqual(await db.isSortedSetMember('sorted2', 'dontadd'), false); + }); + }); + + describe('sortedSetAddMulti()', () => { + it('should add elements into multiple sorted sets with different scores', done => { + db.sortedSetAddBulk([ + ['bulk1', 1, 'item1'], + ['bulk2', 2, 'item1'], + ['bulk2', 3, 'item2'], + ['bulk3', 4, 'item3'], + ], function (error) { + assert.ifError(error); + assert.equal(arguments.length, 1); + db.getSortedSetRevRangeWithScores(['bulk1', 'bulk2', 'bulk3'], 0, -1, (error, data) => { + assert.ifError(error); + assert.deepStrictEqual(data, [{value: 'item3', score: 4}, + {value: 'item2', score: 3}, + {value: 'item1', score: 2}, + {value: 'item1', score: 1}]); + done(); + }); + }); + }); + it('should not error if data is undefined', done => { + db.sortedSetAddBulk(undefined, error => { + assert.ifError(error); + done(); + }); + }); + + it('should error if score is null', async () => { + let error; + try { + await db.sortedSetAddBulk([ + ['bulk4', 0, 'dontadd'], + ['bulk5', null, 'dontadd'], + ]); + } catch (error_) { + error = error_; + } + + assert.equal(error.message, '[[error:invalid-score, null]]'); + assert.strictEqual(await db.isSortedSetMember('bulk4', 'dontadd'), false); + assert.strictEqual(await db.isSortedSetMember('bulk5', 'dontadd'), false); + }); + }); + + describe('getSortedSetRange()', () => { + it('should return the lowest scored element', done => { + db.getSortedSetRange('sortedSetTest1', 0, 0, function (error, value) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(value, ['value1']); + done(); + }); + }); + + it('should return elements sorted by score lowest to highest', done => { + db.getSortedSetRange('sortedSetTest1', 0, -1, function (error, values) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(values, ['value1', 'value2', 'value3']); + done(); + }); + }); + + it('should return empty array if set does not exist', done => { + db.getSortedSetRange('doesnotexist', 0, -1, (error, values) => { + assert.ifError(error); + assert(Array.isArray(values)); + assert.equal(values.length, 0); + done(); + }); + }); + + it('should handle negative start/stop', done => { + db.sortedSetAdd('negatives', [1, 2, 3, 4, 5], ['1', '2', '3', '4', '5'], error => { + assert.ifError(error); + db.getSortedSetRange('negatives', -2, -4, (error, data) => { + assert.ifError(error); + assert.deepEqual(data, []); + done(); + }); + }); + }); + + it('should handle negative start/stop', done => { + db.getSortedSetRange('negatives', -4, -2, (error, data) => { + assert.ifError(error); + assert.deepEqual(data, ['2', '3', '4']); + done(); + }); + }); + + it('should handle negative start/stop', done => { + db.getSortedSetRevRange('negatives', -4, -2, (error, data) => { + assert.ifError(error); + assert.deepEqual(data, ['4', '3', '2']); + done(); + }); + }); + + it('should handle negative start/stop', done => { + db.getSortedSetRange('negatives', -5, -1, (error, data) => { + assert.ifError(error); + assert.deepEqual(data, ['1', '2', '3', '4', '5']); + done(); + }); + }); + + it('should handle negative start/stop', done => { + db.getSortedSetRange('negatives', 0, -2, (error, data) => { + assert.ifError(error); + assert.deepEqual(data, ['1', '2', '3', '4']); + done(); + }); + }); + + it('should return empty array if keys is empty array', done => { + db.getSortedSetRange([], 0, -1, (error, data) => { + assert.ifError(error); + assert.deepStrictEqual(data, []); + done(); + }); + }); + + it('should return duplicates if two sets have same elements', async () => { + await db.sortedSetAdd('dupezset1', [1, 2], ['value 1', 'value 2']); + await db.sortedSetAdd('dupezset2', [2, 3], ['value 2', 'value 3']); + const data = await db.getSortedSetRange(['dupezset1', 'dupezset2'], 0, -1); + assert.deepStrictEqual(data, ['value 1', 'value 2', 'value 2', 'value 3']); + }); + + it('should return correct number of elements', async () => { + await db.sortedSetAdd('dupezset3', [1, 2, 3], ['value 1', 'value 2', 'value3']); + await db.sortedSetAdd('dupezset4', [0, 5], ['value 0', 'value5']); + const data = await db.getSortedSetRevRange(['dupezset3', 'dupezset4'], 0, 1); + assert.deepStrictEqual(data, ['value5', 'value3']); + }); + + it('should work with big arrays (length > 100) ', async function () { + this.timeout(100_000); + const keys = []; + for (let i = 0; i < 400; i++) { + /* eslint-disable no-await-in-loop */ + const bulkAdd = []; + keys.push(`testzset${i}`); + for (let k = 0; k < 100; k++) { + bulkAdd.push([`testzset${i}`, 1_000_000 + k + (i * 100), k + (i * 100)]); + } + + await db.sortedSetAddBulk(bulkAdd); + } + + let data = await db.getSortedSetRevRange(keys, 0, 3); + assert.deepStrictEqual(data, ['39999', '39998', '39997', '39996']); + + data = await db.getSortedSetRevRangeWithScores(keys, 0, 3); + assert.deepStrictEqual(data, [ + {value: '39999', score: 1_039_999}, + {value: '39998', score: 1_039_998}, + {value: '39997', score: 1_039_997}, + {value: '39996', score: 1_039_996}, + ]); + + data = await db.getSortedSetRevRange(keys, 0, -1); + assert.equal(data.length, 40_000); + + data = await db.getSortedSetRange(keys, 9998, 10_002); + assert.deepStrictEqual(data, ['9998', '9999', '10000', '10001', '10002']); + }); + }); + + describe('getSortedSetRevRange()', () => { + it('should return the highest scored element', done => { + db.getSortedSetRevRange('sortedSetTest1', 0, 0, function (error, value) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(value, ['value3']); + done(); + }); + }); + + it('should return elements sorted by score highest to lowest', done => { + db.getSortedSetRevRange('sortedSetTest1', 0, -1, function (error, values) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(values, ['value3', 'value2', 'value1']); + done(); + }); + }); + }); + + describe('getSortedSetRangeWithScores()', () => { + it('should return array of elements sorted by score lowest to highest with scores', done => { + db.getSortedSetRangeWithScores('sortedSetTest1', 0, -1, function (error, values) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(values, [{value: 'value1', score: 1.1}, {value: 'value2', score: 1.2}, {value: 'value3', score: 1.3}]); + done(); + }); + }); + }); + + describe('getSortedSetRevRangeWithScores()', () => { + it('should return array of elements sorted by score highest to lowest with scores', done => { + db.getSortedSetRevRangeWithScores('sortedSetTest1', 0, -1, function (error, values) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(values, [{value: 'value3', score: 1.3}, {value: 'value2', score: 1.2}, {value: 'value1', score: 1.1}]); + done(); + }); + }); + }); + + describe('getSortedSetRangeByScore()', () => { + it('should get count elements with score between min max sorted by score lowest to highest', done => { + db.getSortedSetRangeByScore('sortedSetTest1', 0, -1, '-inf', 1.2, function (error, values) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(values, ['value1', 'value2']); + done(); + }); + }); + + it('should return empty array if set does not exist', done => { + db.getSortedSetRangeByScore('doesnotexist', 0, -1, '-inf', 0, (error, values) => { + assert.ifError(error); + assert(Array.isArray(values)); + assert.equal(values.length, 0); + done(); + }); + }); + + it('should return empty array if count is 0', done => { + db.getSortedSetRevRangeByScore('sortedSetTest1', 0, 0, '+inf', '-inf', (error, values) => { + assert.ifError(error); + assert.deepEqual(values, []); + done(); + }); + }); + + it('should return elements from 1 to end', done => { + db.getSortedSetRevRangeByScore('sortedSetTest1', 1, -1, '+inf', '-inf', (error, values) => { + assert.ifError(error); + assert.deepEqual(values, ['value2', 'value1']); + done(); + }); + }); + + it('should return elements from 3 to last', done => { + db.sortedSetAdd('partialZset', [1, 2, 3, 4, 5], ['value1', 'value2', 'value3', 'value4', 'value5'], error => { + assert.ifError(error); + db.getSortedSetRangeByScore('partialZset', 3, 10, '-inf', '+inf', (error, data) => { + assert.ifError(error); + assert.deepStrictEqual(data, ['value4', 'value5']); + done(); + }); + }); + }); + }); + + describe('getSortedSetRevRangeByScore()', () => { + it('should get count elements with score between max min sorted by score highest to lowest', done => { + db.getSortedSetRevRangeByScore('sortedSetTest1', 0, -1, '+inf', 1.2, function (error, values) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(values, ['value3', 'value2']); + done(); + }); + }); + }); + + describe('getSortedSetRangeByScoreWithScores()', () => { + it('should get count elements with score between min max sorted by score lowest to highest with scores', done => { + db.getSortedSetRangeByScoreWithScores('sortedSetTest1', 0, -1, '-inf', 1.2, function (error, values) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(values, [{value: 'value1', score: 1.1}, {value: 'value2', score: 1.2}]); + done(); + }); + }); + }); + + describe('getSortedSetRevRangeByScoreWithScores()', () => { + it('should get count elements with score between max min sorted by score highest to lowest', done => { + db.getSortedSetRevRangeByScoreWithScores('sortedSetTest1', 0, -1, '+inf', 1.2, function (error, values) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(values, [{value: 'value3', score: 1.3}, {value: 'value2', score: 1.2}]); + done(); + }); + }); + + it('should work with an array of keys', async () => { + await db.sortedSetAddBulk([ + ['byScoreWithScoresKeys1', 1, 'value1'], + ['byScoreWithScoresKeys2', 2, 'value2'], + ]); + const data = await db.getSortedSetRevRangeByScoreWithScores(['byScoreWithScoresKeys1', 'byScoreWithScoresKeys2'], 0, -1, 5, -5); + assert.deepStrictEqual(data, [{value: 'value2', score: 2}, {value: 'value1', score: 1}]); + }); + }); + + describe('sortedSetCount()', () => { + it('should return 0 for a sorted set that does not exist', done => { + db.sortedSetCount('doesnotexist', 0, 10, function (error, count) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(count, 0); + done(); + }); + }); + + it('should return number of elements between scores min max inclusive', done => { + db.sortedSetCount('sortedSetTest1', '-inf', 1.2, function (error, count) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(count, 2); + done(); + }); + }); + + it('should return number of elements between scores -inf +inf inclusive', done => { + db.sortedSetCount('sortedSetTest1', '-inf', '+inf', function (error, count) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(count, 3); + done(); + }); + }); + }); + + describe('sortedSetCard()', () => { + it('should return 0 for a sorted set that does not exist', done => { + db.sortedSetCard('doesnotexist', function (error, count) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(count, 0); + done(); + }); + }); + + it('should return number of elements in a sorted set', done => { + db.sortedSetCard('sortedSetTest1', function (error, count) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(count, 3); + done(); + }); + }); + }); + + describe('sortedSetsCard()', () => { + it('should return the number of elements in sorted sets', done => { + db.sortedSetsCard(['sortedSetTest1', 'sortedSetTest2', 'doesnotexist'], function (error, counts) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.deepEqual(counts, [3, 2, 0]); + done(); + }); + }); + + it('should return empty array if keys is falsy', done => { + db.sortedSetsCard(undefined, function (error, counts) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.deepEqual(counts, []); + done(); + }); + }); + + it('should return empty array if keys is empty array', done => { + db.sortedSetsCard([], function (error, counts) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.deepEqual(counts, []); + done(); + }); + }); + }); + + describe('sortedSetsCardSum()', () => { + it('should return the total number of elements in sorted sets', done => { + db.sortedSetsCardSum(['sortedSetTest1', 'sortedSetTest2', 'doesnotexist'], function (error, sum) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.equal(sum, 5); + done(); + }); + }); + + it('should return 0 if keys is falsy', done => { + db.sortedSetsCardSum(undefined, function (error, counts) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.deepEqual(counts, 0); + done(); + }); + }); + + it('should return 0 if keys is empty array', done => { + db.sortedSetsCardSum([], function (error, counts) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.deepEqual(counts, 0); + done(); + }); + }); + + it('should return the total number of elements in sorted set', done => { + db.sortedSetsCardSum('sortedSetTest1', function (error, sum) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.equal(sum, 3); + done(); + }); + }); + }); + + describe('sortedSetRank()', () => { + it('should return falsy if sorted set does not exist', done => { + db.sortedSetRank('doesnotexist', 'value1', function (error, rank) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(Boolean(rank), false); + done(); + }); + }); + + it('should return falsy if element isnt in sorted set', done => { + db.sortedSetRank('sortedSetTest1', 'value5', function (error, rank) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(Boolean(rank), false); + done(); + }); + }); + + it('should return the rank of the element in the sorted set sorted by lowest to highest score', done => { + db.sortedSetRank('sortedSetTest1', 'value1', function (error, rank) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(rank, 0); + done(); + }); + }); + + it('should return the rank sorted by the score and then the value (a)', done => { + db.sortedSetRank('sortedSetTest4', 'a', function (error, rank) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(rank, 0); + done(); + }); + }); + + it('should return the rank sorted by the score and then the value (b)', done => { + db.sortedSetRank('sortedSetTest4', 'b', function (error, rank) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(rank, 1); + done(); + }); + }); + + it('should return the rank sorted by the score and then the value (c)', done => { + db.sortedSetRank('sortedSetTest4', 'c', function (error, rank) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(rank, 4); + done(); + }); + }); + }); + + describe('sortedSetRevRank()', () => { + it('should return falsy if sorted set doesnot exist', done => { + db.sortedSetRevRank('doesnotexist', 'value1', function (error, rank) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(Boolean(rank), false); + done(); + }); + }); + + it('should return falsy if element isnt in sorted set', done => { + db.sortedSetRevRank('sortedSetTest1', 'value5', function (error, rank) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(Boolean(rank), false); + done(); + }); + }); + + it('should return the rank of the element in the sorted set sorted by highest to lowest score', done => { + db.sortedSetRevRank('sortedSetTest1', 'value1', function (error, rank) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(rank, 2); + done(); + }); + }); + }); + + describe('sortedSetsRanks()', () => { + it('should return the ranks of values in sorted sets', done => { + db.sortedSetsRanks(['sortedSetTest1', 'sortedSetTest2'], ['value1', 'value4'], function (error, ranks) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(ranks, [0, 1]); + done(); + }); + }); + }); + + describe('sortedSetRanks()', () => { + it('should return the ranks of values in a sorted set', done => { + db.sortedSetRanks('sortedSetTest1', ['value2', 'value1', 'value3', 'value4'], function (error, ranks) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(ranks, [1, 0, 2, null]); + done(); + }); + }); + + it('should return the ranks of values in a sorted set in reverse', done => { + db.sortedSetRevRanks('sortedSetTest1', ['value2', 'value1', 'value3', 'value4'], function (error, ranks) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(ranks, [1, 2, 0, null]); + done(); + }); + }); + }); + + describe('sortedSetScore()', () => { + it('should return falsy if sorted set does not exist', done => { + db.sortedSetScore('doesnotexist', 'value1', function (error, score) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(Boolean(score), false); + assert.strictEqual(score, null); + done(); + }); + }); + + it('should return falsy if element is not in sorted set', done => { + db.sortedSetScore('sortedSetTest1', 'value5', function (error, score) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.equal(Boolean(score), false); + assert.strictEqual(score, null); + done(); + }); + }); + + it('should return the score of an element', done => { + db.sortedSetScore('sortedSetTest1', 'value2', function (error, score) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.strictEqual(score, 1.2); + done(); + }); + }); + + it('should not error if key is undefined', done => { + db.sortedSetScore(undefined, 1, (error, score) => { + assert.ifError(error); + assert.strictEqual(score, null); + done(); + }); + }); + + it('should not error if value is undefined', done => { + db.sortedSetScore('sortedSetTest1', undefined, (error, score) => { + assert.ifError(error); + assert.strictEqual(score, null); + done(); + }); + }); + }); + + describe('sortedSetsScore()', () => { + it('should return the scores of value in sorted sets', done => { + db.sortedSetsScore(['sortedSetTest1', 'sortedSetTest2', 'doesnotexist'], 'value1', function (error, scores) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(scores, [1.1, 1, null]); + done(); + }); + }); + + it('should return scores even if some keys are undefined', done => { + db.sortedSetsScore(['sortedSetTest1', undefined, 'doesnotexist'], 'value1', function (error, scores) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(scores, [1.1, null, null]); + done(); + }); + }); + + it('should return empty array if keys is empty array', done => { + db.sortedSetsScore([], 'value1', function (error, scores) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(scores, []); + done(); + }); + }); + }); + + describe('sortedSetScores()', () => { + before(done => { + db.sortedSetAdd('zeroScore', 0, 'value1', done); + }); + + it('should return 0 if score is 0', done => { + db.sortedSetScores('zeroScore', ['value1'], (error, scores) => { + assert.ifError(error); + assert.strictEqual(scores[0], 0); + done(); + }); + }); + + it('should return the scores of value in sorted sets', done => { + db.sortedSetScores('sortedSetTest1', ['value2', 'value1', 'doesnotexist'], function (error, scores) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.deepStrictEqual(scores, [1.2, 1.1, null]); + done(); + }); + }); + + it('should return scores even if some values are undefined', done => { + db.sortedSetScores('sortedSetTest1', ['value2', undefined, 'doesnotexist'], function (error, scores) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.deepStrictEqual(scores, [1.2, null, null]); + done(); + }); + }); + + it('should return empty array if values is an empty array', done => { + db.sortedSetScores('sortedSetTest1', [], function (error, scores) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.deepStrictEqual(scores, []); + done(); + }); + }); + + it('should return scores properly', done => { + db.sortedSetsScore(['zeroScore', 'sortedSetTest1', 'doesnotexist'], 'value1', function (error, scores) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.deepStrictEqual(scores, [0, 1.1, null]); + done(); + }); + }); + }); + + describe('isSortedSetMember()', () => { + before(done => { + db.sortedSetAdd('zeroscore', 0, 'itemwithzeroscore', done); + }); + + it('should return false if sorted set does not exist', done => { + db.isSortedSetMember('doesnotexist', 'value1', function (error, isMember) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.equal(isMember, false); + done(); + }); + }); + + it('should return false if element is not in sorted set', done => { + db.isSortedSetMember('sorted2', 'value5', function (error, isMember) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.equal(isMember, false); + done(); + }); + }); + + it('should return true if element is in sorted set', done => { + db.isSortedSetMember('sortedSetTest1', 'value2', function (error, isMember) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.strictEqual(isMember, true); + done(); + }); + }); + + it('should return true if element is in sorted set with score 0', done => { + db.isSortedSetMember('zeroscore', 'itemwithzeroscore', (error, isMember) => { + assert.ifError(error); + assert.strictEqual(isMember, true); + done(); + }); + }); + }); + + describe('isSortedSetMembers()', () => { + it('should return an array of booleans indicating membership', done => { + db.isSortedSetMembers('sortedSetTest1', ['value1', 'value2', 'value5'], function (error, isMembers) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(isMembers, [true, true, false]); + done(); + }); + }); + + it('should return true if element is in sorted set with score 0', done => { + db.isSortedSetMembers('zeroscore', ['itemwithzeroscore'], function (error, isMembers) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.deepEqual(isMembers, [true]); + done(); + }); + }); + }); + + describe('isMemberOfSortedSets', () => { + it('should return true for members false for non members', done => { + db.isMemberOfSortedSets(['doesnotexist', 'sortedSetTest1', 'sortedSetTest2'], 'value2', function (error, isMembers) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(isMembers, [false, true, false]); + done(); + }); + }); + + it('should return empty array if keys is empty array', done => { + db.isMemberOfSortedSets([], 'value2', function (error, isMembers) { + assert.ifError(error); + assert.equal(arguments.length, 2); + assert.deepEqual(isMembers, []); + done(); + }); + }); + }); + + describe('getSortedSetsMembers', () => { + it('should return members of a sorted set', async () => { + const result = await db.getSortedSetMembers('sortedSetTest1'); + for (const element of result) { + assert(['value1', 'value2', 'value3'].includes(element)); + } + }); + + it('should return members of multiple sorted sets', done => { + db.getSortedSetsMembers(['doesnotexist', 'sortedSetTest1'], function (error, sortedSets) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(sortedSets[0], []); + for (const element of sortedSets[0]) { + assert.notEqual(['value1', 'value2', 'value3'].indexOf(element), -1); + } + + done(); + }); + }); + }); + + describe('sortedSetUnionCard', () => { + it('should return the number of elements in the union', done => { + db.sortedSetUnionCard(['sortedSetTest2', 'sortedSetTest3'], (error, count) => { + assert.ifError(error); + assert.equal(count, 3); + done(); + }); + }); + }); + + describe('getSortedSetUnion()', () => { + it('should return an array of values from both sorted sets sorted by scores lowest to highest', done => { + db.getSortedSetUnion({sets: ['sortedSetTest2', 'sortedSetTest3'], start: 0, stop: -1}, function (error, values) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(values, ['value1', 'value2', 'value4']); + done(); + }); + }); + + it('should return an array of values and scores from both sorted sets sorted by scores lowest to highest', done => { + db.getSortedSetUnion({ + sets: ['sortedSetTest2', 'sortedSetTest3'], start: 0, stop: -1, withScores: true, + }, function (error, data) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(data, [{value: 'value1', score: 1}, {value: 'value2', score: 2}, {value: 'value4', score: 8}]); + done(); + }); + }); + }); + + describe('getSortedSetRevUnion()', () => { + it('should return an array of values from both sorted sets sorted by scores highest to lowest', done => { + db.getSortedSetRevUnion({sets: ['sortedSetTest2', 'sortedSetTest3'], start: 0, stop: -1}, function (error, values) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.deepEqual(values, ['value4', 'value2', 'value1']); + done(); + }); + }); + }); + + describe('sortedSetIncrBy()', () => { + it('should create a sorted set with a field set to 1', done => { + db.sortedSetIncrBy('sortedIncr', 1, 'field1', function (error, newValue) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.strictEqual(newValue, 1); + db.sortedSetScore('sortedIncr', 'field1', (error, score) => { + assert.equal(error, null); + assert.strictEqual(score, 1); + done(); + }); + }); + }); + + it('should increment a field of a sorted set by 5', done => { + db.sortedSetIncrBy('sortedIncr', 5, 'field1', function (error, newValue) { + assert.equal(error, null); + assert.equal(arguments.length, 2); + assert.strictEqual(newValue, 6); + db.sortedSetScore('sortedIncr', 'field1', (error, score) => { + assert.equal(error, null); + assert.strictEqual(score, 6); + done(); + }); + }); + }); + + it('should increment fields of sorted sets with a single call', async () => { + const data = await db.sortedSetIncrByBulk([ + ['sortedIncrBulk1', 1, 'value1'], + ['sortedIncrBulk2', 2, 'value2'], + ['sortedIncrBulk3', 3, 'value3'], + ['sortedIncrBulk3', 4, 'value4'], + ]); + assert.deepStrictEqual(data, [1, 2, 3, 4]); + assert.deepStrictEqual( + await db.getSortedSetRangeWithScores('sortedIncrBulk1', 0, -1), + [{value: 'value1', score: 1}], + ); + assert.deepStrictEqual( + await db.getSortedSetRangeWithScores('sortedIncrBulk2', 0, -1), + [{value: 'value2', score: 2}], + ); + assert.deepStrictEqual( + await db.getSortedSetRangeWithScores('sortedIncrBulk3', 0, -1), + [ + {value: 'value3', score: 3}, + {value: 'value4', score: 4}, + ], + ); + }); + + it('should increment the same field', async () => { + const data1 = await db.sortedSetIncrByBulk([ + ['sortedIncrBulk5', 5, 'value5'], + ]); + + const data2 = await db.sortedSetIncrByBulk([ + ['sortedIncrBulk5', 5, 'value5'], + ]); + assert.deepStrictEqual( + await db.getSortedSetRangeWithScores('sortedIncrBulk5', 0, -1), + [ + {value: 'value5', score: 10}, + ], + ); + }); + }); + + describe('sortedSetRemove()', () => { + before(done => { + db.sortedSetAdd('sorted3', [1, 2], ['value1', 'value2'], done); + }); + + it('should remove an element from a sorted set', done => { + db.sortedSetRemove('sorted3', 'value2', function (error) { + assert.equal(error, null); + assert.equal(arguments.length, 1); + db.isSortedSetMember('sorted3', 'value2', (error, isMember) => { + assert.equal(error, null); + assert.equal(isMember, false); + done(); + }); + }); + }); + + it('should not think the sorted set exists if the last element is removed', async () => { + await db.sortedSetRemove('sorted3', 'value1'); + assert.strictEqual(await db.exists('sorted3'), false); + }); + + it('should remove multiple values from multiple keys', done => { + db.sortedSetAdd('multiTest1', [1, 2, 3, 4], ['one', 'two', 'three', 'four'], error => { + assert.ifError(error); + db.sortedSetAdd('multiTest2', [3, 4, 5, 6], ['three', 'four', 'five', 'six'], error => { + assert.ifError(error); + db.sortedSetRemove(['multiTest1', 'multiTest2'], ['two', 'three', 'four', 'five', 'doesnt exist'], error => { + assert.ifError(error); + db.getSortedSetsMembers(['multiTest1', 'multiTest2'], (error, members) => { + assert.ifError(error); + assert.equal(members[0].length, 1); + assert.equal(members[1].length, 1); + assert.deepEqual(members, [['one'], ['six']]); + done(); + }); + }); + }); + }); + }); + + it('should remove value from multiple keys', async () => { + await db.sortedSetAdd('multiTest3', [1, 2, 3, 4], ['one', 'two', 'three', 'four']); + await db.sortedSetAdd('multiTest4', [3, 4, 5, 6], ['three', 'four', 'five', 'six']); + await db.sortedSetRemove(['multiTest3', 'multiTest4'], 'three'); + assert.deepStrictEqual(await db.getSortedSetRange('multiTest3', 0, -1), ['one', 'two', 'four']); + assert.deepStrictEqual(await db.getSortedSetRange('multiTest4', 0, -1), ['four', 'five', 'six']); + }); + + it('should remove multiple values from multiple keys', done => { + db.sortedSetAdd('multiTest5', [1], ['one'], error => { + assert.ifError(error); + db.sortedSetAdd('multiTest6', [2], ['two'], error => { + assert.ifError(error); + db.sortedSetAdd('multiTest7', [3], [333], error => { + assert.ifError(error); + db.sortedSetRemove(['multiTest5', 'multiTest6', 'multiTest7'], ['one', 'two', 333], error => { + assert.ifError(error); + db.getSortedSetsMembers(['multiTest5', 'multiTest6', 'multiTest7'], (error, members) => { + assert.ifError(error); + assert.deepEqual(members, [[], [], []]); + done(); + }); + }); + }); + }); + }); + }); + + it('should not remove anything if values is empty array', done => { + db.sortedSetAdd('removeNothing', [1, 2, 3], ['val1', 'val2', 'val3'], error => { + assert.ifError(error); + db.sortedSetRemove('removeNothing', [], error => { + assert.ifError(error); + db.getSortedSetRange('removeNothing', 0, -1, (error, data) => { + assert.ifError(error); + assert.deepStrictEqual(data, ['val1', 'val2', 'val3']); + done(); + }); + }); + }); + }); + + it('should do a bulk remove', async () => { + await db.sortedSetAddBulk([ + ['bulkRemove1', 1, 'value1'], + ['bulkRemove1', 2, 'value2'], + ['bulkRemove2', 3, 'value2'], + ]); + await db.sortedSetRemoveBulk([ + ['bulkRemove1', 'value1'], + ['bulkRemove1', 'value2'], + ['bulkRemove2', 'value2'], + ]); + const members = await db.getSortedSetsMembers(['bulkRemove1', 'bulkRemove2']); + assert.deepStrictEqual(members, [[], []]); + }); + + it('should not remove wrong elements in bulk remove', async () => { + await db.sortedSetAddBulk([ + ['bulkRemove4', 1, 'value1'], + ['bulkRemove4', 2, 'value2'], + ['bulkRemove4', 3, 'value4'], + ['bulkRemove5', 1, 'value1'], + ['bulkRemove5', 2, 'value2'], + ['bulkRemove5', 3, 'value3'], + ]); + await db.sortedSetRemoveBulk([ + ['bulkRemove4', 'value1'], + ['bulkRemove4', 'value3'], + ['bulkRemove5', 'value1'], + ['bulkRemove5', 'value4'], + ]); + const members = await Promise.all([ + db.getSortedSetRange('bulkRemove4', 0, -1), + db.getSortedSetRange('bulkRemove5', 0, -1), + ]); + assert.deepStrictEqual(members[0], ['value2', 'value4']); + assert.deepStrictEqual(members[1], ['value2', 'value3']); + }); + }); + + describe('sortedSetsRemove()', () => { + before(done => { + async.parallel([ + async.apply(db.sortedSetAdd, 'sorted4', [1, 2], ['value1', 'value2']), + async.apply(db.sortedSetAdd, 'sorted5', [1, 2], ['value1', 'value3']), + ], done); + }); + + it('should remove element from multiple sorted sets', done => { + db.sortedSetsRemove(['sorted4', 'sorted5'], 'value1', function (error) { + assert.equal(error, null); + assert.equal(arguments.length, 1); + db.sortedSetsScore(['sorted4', 'sorted5'], 'value1', (error, scores) => { + assert.equal(error, null); + assert.deepStrictEqual(scores, [null, null]); + done(); + }); + }); + }); + }); + + describe('sortedSetsRemoveRangeByScore()', () => { + before(done => { + db.sortedSetAdd('sorted6', [1, 2, 3, 4, 5], ['value1', 'value2', 'value3', 'value4', 'value5'], done); + }); + + it('should remove elements with scores between min max inclusive', done => { + db.sortedSetsRemoveRangeByScore(['sorted6'], 4, 5, function (error) { + assert.ifError(error); + assert.equal(arguments.length, 1); + db.getSortedSetRange('sorted6', 0, -1, (error, values) => { + assert.ifError(error); + assert.deepEqual(values, ['value1', 'value2', 'value3']); + done(); + }); + }); + }); + + it('should remove elements with if strin score is passed in', done => { + db.sortedSetAdd('sortedForRemove', [11, 22, 33], ['value1', 'value2', 'value3'], error => { + assert.ifError(error); + db.sortedSetsRemoveRangeByScore(['sortedForRemove'], '22', '22', error => { + assert.ifError(error); + db.getSortedSetRange('sortedForRemove', 0, -1, (error, values) => { + assert.ifError(error); + assert.deepEqual(values, ['value1', 'value3']); + done(); + }); + }); + }); + }); + }); + + describe('getSortedSetIntersect', () => { + before(done => { + async.parallel([ + function (next) { + db.sortedSetAdd('interSet1', [1, 2, 3], ['value1', 'value2', 'value3'], next); + }, + function (next) { + db.sortedSetAdd('interSet2', [4, 5, 6], ['value2', 'value3', 'value5'], next); + }, + ], done); + }); + + it('should return the intersection of two sets', done => { + db.getSortedSetIntersect({ + sets: ['interSet1', 'interSet2'], + start: 0, + stop: -1, + }, (error, data) => { + assert.ifError(error); + assert.deepEqual(['value2', 'value3'], data); + done(); + }); + }); + + it('should return the intersection of two sets with scores', done => { + db.getSortedSetIntersect({ + sets: ['interSet1', 'interSet2'], + start: 0, + stop: -1, + withScores: true, + }, (error, data) => { + assert.ifError(error); + assert.deepEqual([{value: 'value2', score: 6}, {value: 'value3', score: 8}], data); + done(); + }); + }); + + it('should return the reverse intersection of two sets', done => { + db.getSortedSetRevIntersect({ + sets: ['interSet1', 'interSet2'], + start: 0, + stop: 2, + }, (error, data) => { + assert.ifError(error); + assert.deepEqual(['value3', 'value2'], data); + done(); + }); + }); + + it('should return the intersection of two sets with scores aggregate MIN', done => { + db.getSortedSetIntersect({ + sets: ['interSet1', 'interSet2'], + start: 0, + stop: -1, + withScores: true, + aggregate: 'MIN', + }, (error, data) => { + assert.ifError(error); + assert.deepEqual([{value: 'value2', score: 2}, {value: 'value3', score: 3}], data); + done(); + }); + }); + + it('should return the intersection of two sets with scores aggregate MAX', done => { + db.getSortedSetIntersect({ + sets: ['interSet1', 'interSet2'], + start: 0, + stop: -1, + withScores: true, + aggregate: 'MAX', + }, (error, data) => { + assert.ifError(error); + assert.deepEqual([{value: 'value2', score: 4}, {value: 'value3', score: 5}], data); + done(); + }); + }); + + it('should return the intersection with scores modified by weights', done => { + db.getSortedSetIntersect({ + sets: ['interSet1', 'interSet2'], + start: 0, + stop: -1, + withScores: true, + weights: [1, 0.5], + }, (error, data) => { + assert.ifError(error); + assert.deepEqual([{value: 'value2', score: 4}, {value: 'value3', score: 5.5}], data); + done(); + }); + }); + + it('should return empty array if sets do not exist', done => { + db.getSortedSetIntersect({ + sets: ['interSet10', 'interSet12'], + start: 0, + stop: -1, + }, (error, data) => { + assert.ifError(error); + assert.equal(data.length, 0); + done(); + }); + }); + + it('should return empty array if one set does not exist', done => { + db.getSortedSetIntersect({ + sets: ['interSet1', 'interSet12'], + start: 0, + stop: -1, + }, (error, data) => { + assert.ifError(error); + assert.equal(data.length, 0); + done(); + }); + }); + + it('should return correct results if sorting by different zset', async () => { + await db.sortedSetAdd('bigzset', [1, 2, 3, 4, 5, 6], ['a', 'b', 'c', 'd', 'e', 'f']); + await db.sortedSetAdd('smallzset', [3, 2, 1], ['b', 'e', 'g']); + const data = await db.getSortedSetRevIntersect({ + sets: ['bigzset', 'smallzset'], + start: 0, + stop: 19, + weights: [1, 0], + withScores: true, + }); + assert.deepStrictEqual(data, [{value: 'e', score: 5}, {value: 'b', score: 2}]); + const data2 = await db.getSortedSetRevIntersect({ + sets: ['bigzset', 'smallzset'], + start: 0, + stop: 19, + weights: [0, 1], + withScores: true, + }); + assert.deepStrictEqual(data2, [{value: 'b', score: 3}, {value: 'e', score: 2}]); + }); + + it('should return correct results when intersecting big zsets', async () => { + const scores = []; + const values = []; + for (let i = 0; i < 30_000; i++) { + scores.push((i + 1) * 1000); + values.push(String(i + 1)); + } + + await db.sortedSetAdd('verybigzset', scores, values); + + scores.length = 0; + values.length = 0; + for (let i = 15_000; i < 45_000; i++) { + scores.push((i + 1) * 1000); + values.push(String(i + 1)); + } + + await db.sortedSetAdd('anotherbigzset', scores, values); + const data = await db.getSortedSetRevIntersect({ + sets: ['verybigzset', 'anotherbigzset'], + start: 0, + stop: 3, + weights: [1, 0], + withScores: true, + }); + assert.deepStrictEqual(data, [ + {value: '30000', score: 30_000_000}, + {value: '29999', score: 29_999_000}, + {value: '29998', score: 29_998_000}, + {value: '29997', score: 29_997_000}, + ]); + }); + }); + + describe('sortedSetIntersectCard', () => { + before(done => { + async.parallel([ + function (next) { + db.sortedSetAdd('interCard1', [0, 0, 0], ['value1', 'value2', 'value3'], next); + }, + function (next) { + db.sortedSetAdd('interCard2', [0, 0, 0], ['value2', 'value3', 'value4'], next); + }, + function (next) { + db.sortedSetAdd('interCard3', [0, 0, 0], ['value3', 'value4', 'value5'], next); + }, + function (next) { + db.sortedSetAdd('interCard4', [0, 0, 0], ['value4', 'value5', 'value6'], next); + }, + ], done); + }); + + it('should return # of elements in intersection', done => { + db.sortedSetIntersectCard(['interCard1', 'interCard2', 'interCard3'], (error, count) => { + assert.ifError(error); + assert.strictEqual(count, 1); + done(); + }); + }); + + it('should return 0 if intersection is empty', done => { + db.sortedSetIntersectCard(['interCard1', 'interCard4'], (error, count) => { + assert.ifError(error); + assert.strictEqual(count, 0); + done(); + }); + }); + }); + + describe('getSortedSetRangeByLex', () => { + it('should return an array of all values', done => { + db.getSortedSetRangeByLex('sortedSetLex', '-', '+', (error, data) => { + assert.ifError(error); + assert.deepEqual(data, ['a', 'b', 'c', 'd']); + done(); + }); + }); + + it('should return an array with an inclusive range by default', done => { + db.getSortedSetRangeByLex('sortedSetLex', 'a', 'd', (error, data) => { + assert.ifError(error); + assert.deepEqual(data, ['a', 'b', 'c', 'd']); + done(); + }); + }); + + it('should return an array with an inclusive range', done => { + db.getSortedSetRangeByLex('sortedSetLex', '[a', '[d', (error, data) => { + assert.ifError(error); + assert.deepEqual(data, ['a', 'b', 'c', 'd']); + done(); + }); + }); + + it('should return an array with an exclusive range', done => { + db.getSortedSetRangeByLex('sortedSetLex', '(a', '(d', (error, data) => { + assert.ifError(error); + assert.deepEqual(data, ['b', 'c']); + done(); + }); + }); + + it('should return an array limited to the first two values', done => { + db.getSortedSetRangeByLex('sortedSetLex', '-', '+', 0, 2, (error, data) => { + assert.ifError(error); + assert.deepEqual(data, ['a', 'b']); + done(); + }); + }); + + it('should return correct result', async () => { + await db.sortedSetAdd('sortedSetLexSearch', [0, 0, 0], ['baris:usakli:1', 'baris usakli:2', 'baris soner:3']); + const query = 'baris:'; + const min = query; + const max = query.slice(0, -1) + String.fromCharCode(query.charCodeAt(query.length - 1) + 1); + const result = await db.getSortedSetRangeByLex('sortedSetLexSearch', min, max, 0, -1); + assert.deepStrictEqual(result, ['baris:usakli:1']); + }); + }); + + describe('getSortedSetRevRangeByLex', () => { + it('should return an array of all values reversed', done => { + db.getSortedSetRevRangeByLex('sortedSetLex', '+', '-', (error, data) => { + assert.ifError(error); + assert.deepEqual(data, ['d', 'c', 'b', 'a']); + done(); + }); + }); + + it('should return an array with an inclusive range by default reversed', done => { + db.getSortedSetRevRangeByLex('sortedSetLex', 'd', 'a', (error, data) => { + assert.ifError(error); + assert.deepEqual(data, ['d', 'c', 'b', 'a']); + done(); + }); + }); + + it('should return an array with an inclusive range reversed', done => { + db.getSortedSetRevRangeByLex('sortedSetLex', '[d', '[a', (error, data) => { + assert.ifError(error); + assert.deepEqual(data, ['d', 'c', 'b', 'a']); + done(); + }); + }); + + it('should return an array with an exclusive range reversed', done => { + db.getSortedSetRevRangeByLex('sortedSetLex', '(d', '(a', (error, data) => { + assert.ifError(error); + assert.deepEqual(data, ['c', 'b']); + done(); + }); + }); + + it('should return an array limited to the first two values reversed', done => { + db.getSortedSetRevRangeByLex('sortedSetLex', '+', '-', 0, 2, (error, data) => { + assert.ifError(error); + assert.deepEqual(data, ['d', 'c']); + done(); + }); + }); + }); + + describe('sortedSetLexCount', () => { + it('should return the count of all values', done => { + db.sortedSetLexCount('sortedSetLex', '-', '+', (error, data) => { + assert.ifError(error); + assert.strictEqual(data, 4); + done(); + }); + }); + + it('should return the count with an inclusive range by default', done => { + db.sortedSetLexCount('sortedSetLex', 'a', 'd', (error, data) => { + assert.ifError(error); + assert.strictEqual(data, 4); + done(); + }); + }); + + it('should return the count with an inclusive range', done => { + db.sortedSetLexCount('sortedSetLex', '[a', '[d', (error, data) => { + assert.ifError(error); + assert.strictEqual(data, 4); + done(); + }); + }); + + it('should return the count with an exclusive range', done => { + db.sortedSetLexCount('sortedSetLex', '(a', '(d', (error, data) => { + assert.ifError(error); + assert.strictEqual(data, 2); + done(); + }); + }); + }); + + describe('sortedSetRemoveRangeByLex', () => { + before(done => { + db.sortedSetAdd('sortedSetLex2', [0, 0, 0, 0, 0, 0, 0], ['a', 'b', 'c', 'd', 'e', 'f', 'g'], done); + }); + + it('should remove an inclusive range by default', done => { + db.sortedSetRemoveRangeByLex('sortedSetLex2', 'a', 'b', function (error) { + assert.ifError(error); + assert.equal(arguments.length, 1); + db.getSortedSetRangeByLex('sortedSetLex2', '-', '+', (error, data) => { + assert.ifError(error); + assert.deepEqual(data, ['c', 'd', 'e', 'f', 'g']); + done(); + }); + }); + }); + + it('should remove an inclusive range', done => { + db.sortedSetRemoveRangeByLex('sortedSetLex2', '[c', '[d', function (error) { + assert.ifError(error); + assert.equal(arguments.length, 1); + db.getSortedSetRangeByLex('sortedSetLex2', '-', '+', (error, data) => { + assert.ifError(error); + assert.deepEqual(data, ['e', 'f', 'g']); + done(); + }); + }); + }); + + it('should remove an exclusive range', done => { + db.sortedSetRemoveRangeByLex('sortedSetLex2', '(e', '(g', function (error) { + assert.ifError(error); + assert.equal(arguments.length, 1); + db.getSortedSetRangeByLex('sortedSetLex2', '-', '+', (error, data) => { + assert.ifError(error); + assert.deepEqual(data, ['e', 'g']); + done(); + }); + }); + }); + + it('should remove all values', done => { + db.sortedSetRemoveRangeByLex('sortedSetLex2', '-', '+', function (error) { + assert.ifError(error); + assert.equal(arguments.length, 1); + db.getSortedSetRangeByLex('sortedSetLex2', '-', '+', (error, data) => { + assert.ifError(error); + assert.deepEqual(data, []); + done(); + }); + }); + }); + }); }); diff --git a/test/defer-logger.js b/test/defer-logger.js index 89de46b..0d60d7f 100644 --- a/test/defer-logger.js +++ b/test/defer-logger.js @@ -6,32 +6,32 @@ const Transport = require('winston-transport'); const winstonLogged = []; class DeferLogger extends Transport { - constructor(opts) { - super(opts); - this.logged = opts.logged; - } - - log(info, callback) { - setImmediate(() => { - this.emit('logged', info); - }); - - this.logged.push([info.level, info.message]); - callback(); - } + constructor(options) { + super(options); + this.logged = options.logged; + } + + log(info, callback) { + setImmediate(() => { + this.emit('logged', info); + }); + + this.logged.push([info.level, info.message]); + callback(); + } } before(() => { - // defer winston logs until the end - winston.clear(); + // Defer winston logs until the end + winston.clear(); - winston.add(new DeferLogger({ logged: winstonLogged })); + winston.add(new DeferLogger({logged: winstonLogged})); }); after(() => { - console.log('\n\n'); + console.log('\n\n'); - winstonLogged.forEach((args) => { - console.log(`${args[0]} ${args[1]}`); - }); + for (const arguments_ of winstonLogged) { + console.log(`${arguments_[0]} ${arguments_[1]}`); + } }); diff --git a/test/emailer.js b/test/emailer.js index 4c26171..3e9e932 100644 --- a/test/emailer.js +++ b/test/emailer.js @@ -1,200 +1,207 @@ 'use strict'; -const { SMTPServer } = require('smtp-server'); -const assert = require('assert'); -const fs = require('fs'); -const path = require('path'); - -const db = require('./mocks/databasemock'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const {SMTPServer} = require('smtp-server'); const Plugins = require('../src/plugins'); const Emailer = require('../src/emailer'); const user = require('../src/user'); const meta = require('../src/meta'); const Meta = require('../src/meta'); +const db = require('./mocks/databasemock'); describe('emailer', () => { - let onMail = function (address, session, callback) { callback(); }; - let onTo = function (address, session, callback) { callback(); }; - - const template = 'test'; - const email = 'test@example.org'; - const language = 'en-GB'; - const params = { - subject: 'Welcome to NodeBB', - }; - - before((done) => { - const server = new SMTPServer({ - allowInsecureAuth: true, - onAuth: function (auth, session, callback) { - callback(null, { - user: auth.username, - }); - }, - onMailFrom: function (address, session, callback) { - onMail(address, session, callback); - }, - onRcptTo: function (address, session, callback) { - onTo(address, session, callback); - }, - }); - - server.on('error', (err) => { - throw err; - }); - server.listen(4000, done); - }); - - // TODO: test sendmail here at some point - - it('plugin hook should work', (done) => { - const error = new Error(); - const method = function (data, next) { - assert(data); - assert.equal(data.to, email); - assert.equal(data.subject, `[NodeBB] ${params.subject}`); - - next(error); - }; - - Plugins.hooks.register('emailer-test', { - hook: 'filter:email.send', - method, - }); - - Emailer.sendToEmail(template, email, language, params, (err) => { - assert.equal(err, error); - - Plugins.hooks.unregister('emailer-test', 'filter:email.send', method); - done(); - }); - }); - - it('should build custom template on config change', (done) => { - const text = 'a random string of text'; - - // make sure it's not already set - Emailer.renderAndTranslate('test', {}, 'en-GB', (err, output) => { - assert.ifError(err); - - assert.notEqual(output, text); - - Meta.configs.set('email:custom:test', text, (err) => { - assert.ifError(err); - - // wait for pubsub stuff - setTimeout(() => { - Emailer.renderAndTranslate('test', {}, 'en-GB', (err, output) => { - assert.ifError(err); - - assert.equal(output, text); - done(); - }); - }, 2000); - }); - }); - }); - - it('should send via SMTP', (done) => { - const from = 'admin@example.org'; - const username = 'another@example.com'; - - onMail = function (address, session, callback) { - assert.equal(address.address, from); - assert.equal(session.user, username); - - callback(); - }; - - onTo = function (address, session, callback) { - assert.equal(address.address, email); - - callback(); - done(); - }; - - Meta.configs.setMultiple({ - 'email:smtpTransport:enabled': '1', - 'email:smtpTransport:user': username, - 'email:smtpTransport:pass': 'anything', - 'email:smtpTransport:service': 'nodebb-custom-smtp', - 'email:smtpTransport:port': 4000, - 'email:smtpTransport:host': 'localhost', - 'email:smtpTransport:security': 'NONE', - 'email:from': from, - }, (err) => { - assert.ifError(err); - - // delay so emailer has a chance to update after config changes - setTimeout(() => { - assert.equal(Emailer.fallbackTransport, Emailer.transports.smtp); - - Emailer.sendToEmail(template, email, language, params, (err) => { - assert.ifError(err); - }); - }, 200); - }); - }); - - after((done) => { - fs.unlinkSync(path.join(__dirname, '../build/public/templates/emails/test.js')); - Meta.configs.setMultiple({ - 'email:smtpTransport:enabled': '0', - 'email:custom:test': '', - }, done); - }); - - describe('emailer.send()', () => { - let recipientUid; - - before(async () => { - recipientUid = await user.create({ username: 'recipient', email: 'test@example.org' }); - await user.email.confirmByUid(recipientUid); - }); - - it('should not send email to a banned user', async () => { - const method = async () => { - assert(false); // if thrown, email was sent - }; - Plugins.hooks.register('emailer-test', { - hook: 'filter:email.send', - method, - }); - - await user.bans.ban(recipientUid); - await Emailer.send('test', recipientUid, {}); - - Plugins.hooks.unregister('emailer-test', 'filter:email.send', method); - }); - - it('should return true if the template is "banned"', async () => { - const method = async () => { - assert(true); // if thrown, email was sent - }; - Plugins.hooks.register('emailer-test', { - hook: 'filter:email.send', - method, - }); - - await Emailer.send('banned', recipientUid, {}); - Plugins.hooks.unregister('emailer-test', 'filter:email.send', method); - }); - - it('should return true if system settings allow sending to banned users', async () => { - const method = async () => { - assert(true); // if thrown, email was sent - }; - Plugins.hooks.register('emailer-test', { - hook: 'filter:email.send', - method, - }); - - meta.config.sendEmailToBanned = 1; - await Emailer.send('test', recipientUid, {}); - meta.config.sendEmailToBanned = 0; - await user.bans.unban(recipientUid); - - Plugins.hooks.unregister('emailer-test', 'filter:email.send', method); - }); - }); + let onMail = function (address, session, callback) { + callback(); + }; + + let onTo = function (address, session, callback) { + callback(); + }; + + const template = 'test'; + const email = 'test@example.org'; + const language = 'en-GB'; + const parameters = { + subject: 'Welcome to NodeBB', + }; + + before(done => { + const server = new SMTPServer({ + allowInsecureAuth: true, + onAuth(auth, session, callback) { + callback(null, { + user: auth.username, + }); + }, + onMailFrom(address, session, callback) { + onMail(address, session, callback); + }, + onRcptTo(address, session, callback) { + onTo(address, session, callback); + }, + }); + + server.on('error', error => { + throw error; + }); + server.listen(4000, done); + }); + + // TODO: test sendmail here at some point + + it('plugin hook should work', done => { + const error = new Error(); + const method = function (data, next) { + assert(data); + assert.equal(data.to, email); + assert.equal(data.subject, `[NodeBB] ${parameters.subject}`); + + next(error); + }; + + Plugins.hooks.register('emailer-test', { + hook: 'filter:email.send', + method, + }); + + Emailer.sendToEmail(template, email, language, parameters, error_ => { + assert.equal(error_, error); + + Plugins.hooks.unregister('emailer-test', 'filter:email.send', method); + done(); + }); + }); + + it('should build custom template on config change', done => { + const text = 'a random string of text'; + + // Make sure it's not already set + Emailer.renderAndTranslate('test', {}, 'en-GB', (error, output) => { + assert.ifError(error); + + assert.notEqual(output, text); + + Meta.configs.set('email:custom:test', text, error_ => { + assert.ifError(error_); + + // Wait for pubsub stuff + setTimeout(() => { + Emailer.renderAndTranslate('test', {}, 'en-GB', (error, output) => { + assert.ifError(error); + + assert.equal(output, text); + done(); + }); + }, 2000); + }); + }); + }); + + it('should send via SMTP', done => { + const from = 'admin@example.org'; + const username = 'another@example.com'; + + onMail = function (address, session, callback) { + assert.equal(address.address, from); + assert.equal(session.user, username); + + callback(); + }; + + onTo = function (address, session, callback) { + assert.equal(address.address, email); + + callback(); + done(); + }; + + Meta.configs.setMultiple({ + 'email:smtpTransport:enabled': '1', + 'email:smtpTransport:user': username, + 'email:smtpTransport:pass': 'anything', + 'email:smtpTransport:service': 'nodebb-custom-smtp', + 'email:smtpTransport:port': 4000, + 'email:smtpTransport:host': 'localhost', + 'email:smtpTransport:security': 'NONE', + 'email:from': from, + }, error => { + assert.ifError(error); + + // Delay so emailer has a chance to update after config changes + setTimeout(() => { + assert.equal(Emailer.fallbackTransport, Emailer.transports.smtp); + + Emailer.sendToEmail(template, email, language, parameters, error => { + assert.ifError(error); + }); + }, 200); + }); + }); + + after(done => { + fs.unlinkSync(path.join(__dirname, '../build/public/templates/emails/test.js')); + Meta.configs.setMultiple({ + 'email:smtpTransport:enabled': '0', + 'email:custom:test': '', + }, done); + }); + + describe('emailer.send()', () => { + let recipientUid; + + before(async () => { + recipientUid = await user.create({username: 'recipient', email: 'test@example.org'}); + await user.email.confirmByUid(recipientUid); + }); + + it('should not send email to a banned user', async () => { + const method = async () => { + assert(false); // If thrown, email was sent + }; + + Plugins.hooks.register('emailer-test', { + hook: 'filter:email.send', + method, + }); + + await user.bans.ban(recipientUid); + await Emailer.send('test', recipientUid, {}); + + Plugins.hooks.unregister('emailer-test', 'filter:email.send', method); + }); + + it('should return true if the template is "banned"', async () => { + const method = async () => { + assert(true); // If thrown, email was sent + }; + + Plugins.hooks.register('emailer-test', { + hook: 'filter:email.send', + method, + }); + + await Emailer.send('banned', recipientUid, {}); + Plugins.hooks.unregister('emailer-test', 'filter:email.send', method); + }); + + it('should return true if system settings allow sending to banned users', async () => { + const method = async () => { + assert(true); // If thrown, email was sent + }; + + Plugins.hooks.register('emailer-test', { + hook: 'filter:email.send', + method, + }); + + meta.config.sendEmailToBanned = 1; + await Emailer.send('test', recipientUid, {}); + meta.config.sendEmailToBanned = 0; + await user.bans.unban(recipientUid); + + Plugins.hooks.unregister('emailer-test', 'filter:email.send', method); + }); + }); }); diff --git a/test/feeds.js b/test/feeds.js index 4d26071..5c6c7ee 100644 --- a/test/feeds.js +++ b/test/feeds.js @@ -1,199 +1,199 @@ 'use strict'; -const assert = require('assert'); +const assert = require('node:assert'); const async = require('async'); const request = require('request'); const nconf = require('nconf'); - -const db = require('./mocks/databasemock'); const topics = require('../src/topics'); const categories = require('../src/categories'); const groups = require('../src/groups'); const user = require('../src/user'); const meta = require('../src/meta'); const privileges = require('../src/privileges'); +const db = require('./mocks/databasemock'); const helpers = require('./helpers'); describe('feeds', () => { - let tid; - let pid; - let fooUid; - let cid; - before((done) => { - meta.config['feeds:disableRSS'] = 1; - async.series({ - category: function (next) { - categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - }, next); - }, - user: function (next) { - user.create({ username: 'foo', password: 'barbar', email: 'foo@test.com' }, next); - }, - }, (err, results) => { - if (err) { - return done(err); - } - cid = results.category.cid; - fooUid = results.user; - - topics.post({ uid: results.user, title: 'test topic title', content: 'test topic content', cid: results.category.cid }, (err, result) => { - tid = result.topicData.tid; - pid = result.postData.pid; - done(err); - }); - }); - }); - - - it('should 404', (done) => { - const feedUrls = [ - `${nconf.get('url')}/topic/${tid}.rss`, - `${nconf.get('url')}/category/${cid}.rss`, - `${nconf.get('url')}/topics.rss`, - `${nconf.get('url')}/recent.rss`, - `${nconf.get('url')}/top.rss`, - `${nconf.get('url')}/popular.rss`, - `${nconf.get('url')}/popular/day.rss`, - `${nconf.get('url')}/recentposts.rss`, - `${nconf.get('url')}/category/${cid}/recentposts.rss`, - `${nconf.get('url')}/user/foo/topics.rss`, - `${nconf.get('url')}/tags/nodebb.rss`, - ]; - async.eachSeries(feedUrls, (url, next) => { - request(url, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - next(); - }); - }, (err) => { - assert.ifError(err); - meta.config['feeds:disableRSS'] = 0; - done(); - }); - }); - - it('should 404 if topic does not exist', (done) => { - request(`${nconf.get('url')}/topic/${1000}.rss`, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - done(); - }); - }); - - it('should 404 if category id is not a number', (done) => { - request(`${nconf.get('url')}/category/invalid.rss`, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - done(); - }); - }); - - it('should redirect if we do not have read privilege', (done) => { - privileges.categories.rescind(['groups:topics:read'], cid, 'guests', (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/topic/${tid}.rss`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - assert(body.includes('Login to your account')); - privileges.categories.give(['groups:topics:read'], cid, 'guests', done); - }); - }); - }); - - it('should 404 if user is not found', (done) => { - request(`${nconf.get('url')}/user/doesnotexist/topics.rss`, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - done(); - }); - }); - - it('should redirect if we do not have read privilege', (done) => { - privileges.categories.rescind(['groups:read'], cid, 'guests', (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/category/${cid}.rss`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - assert(body.includes('Login to your account')); - privileges.categories.give(['groups:read'], cid, 'guests', done); - }); - }); - }); - - describe('private feeds and tokens', () => { - let jar; - let rssToken; - before(async () => { - ({ jar } = await helpers.loginUser('foo', 'barbar')); - }); - - it('should load feed if its not private', (done) => { - request(`${nconf.get('url')}/category/${cid}.rss`, { }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - - it('should not allow access if uid or token is missing', (done) => { - privileges.categories.rescind(['groups:read'], cid, 'guests', (err) => { - assert.ifError(err); - async.parallel({ - test1: function (next) { - request(`${nconf.get('url')}/category/${cid}.rss?uid=${fooUid}`, { }, next); - }, - test2: function (next) { - request(`${nconf.get('url')}/category/${cid}.rss?token=sometoken`, { }, next); - }, - }, (err, results) => { - assert.ifError(err); - assert.equal(results.test1[0].statusCode, 200); - assert.equal(results.test2[0].statusCode, 200); - assert(results.test1[0].body.includes('Login to your account')); - assert(results.test2[0].body.includes('Login to your account')); - done(); - }); - }); - }); - - it('should not allow access if token is wrong', (done) => { - request(`${nconf.get('url')}/category/${cid}.rss?uid=${fooUid}&token=sometoken`, { }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body.includes('Login to your account')); - done(); - }); - }); - - it('should allow access if token is correct', (done) => { - request(`${nconf.get('url')}/api/category/${cid}`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - rssToken = body.rssFeedUrl.split('token')[1].slice(1); - request(`${nconf.get('url')}/category/${cid}.rss?uid=${fooUid}&token=${rssToken}`, { }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body.startsWith(' { - privileges.categories.rescind(['groups:read'], cid, 'registered-users', (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/category/${cid}.rss?uid=${fooUid}&token=${rssToken}`, { }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body.includes('Login to your account')); - done(); - }); - }); - }); - }); + let tid; + let pid; + let fooUid; + let cid; + before(done => { + meta.config['feeds:disableRSS'] = 1; + async.series({ + category(next) { + categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + }, next); + }, + user(next) { + user.create({username: 'foo', password: 'barbar', email: 'foo@test.com'}, next); + }, + }, (error, results) => { + if (error) { + return done(error); + } + + cid = results.category.cid; + fooUid = results.user; + + topics.post({ + uid: results.user, title: 'test topic title', content: 'test topic content', cid: results.category.cid, + }, (error, result) => { + tid = result.topicData.tid; + pid = result.postData.pid; + done(error); + }); + }); + }); + + it('should 404', done => { + const feedUrls = [ + `${nconf.get('url')}/topic/${tid}.rss`, + `${nconf.get('url')}/category/${cid}.rss`, + `${nconf.get('url')}/topics.rss`, + `${nconf.get('url')}/recent.rss`, + `${nconf.get('url')}/top.rss`, + `${nconf.get('url')}/popular.rss`, + `${nconf.get('url')}/popular/day.rss`, + `${nconf.get('url')}/recentposts.rss`, + `${nconf.get('url')}/category/${cid}/recentposts.rss`, + `${nconf.get('url')}/user/foo/topics.rss`, + `${nconf.get('url')}/tags/nodebb.rss`, + ]; + async.eachSeries(feedUrls, (url, next) => { + request(url, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + next(); + }); + }, error => { + assert.ifError(error); + meta.config['feeds:disableRSS'] = 0; + done(); + }); + }); + + it('should 404 if topic does not exist', done => { + request(`${nconf.get('url')}/topic/${1000}.rss`, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + done(); + }); + }); + + it('should 404 if category id is not a number', done => { + request(`${nconf.get('url')}/category/invalid.rss`, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + done(); + }); + }); + + it('should redirect if we do not have read privilege', done => { + privileges.categories.rescind(['groups:topics:read'], cid, 'guests', error => { + assert.ifError(error); + request(`${nconf.get('url')}/topic/${tid}.rss`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + assert(body.includes('Login to your account')); + privileges.categories.give(['groups:topics:read'], cid, 'guests', done); + }); + }); + }); + + it('should 404 if user is not found', done => { + request(`${nconf.get('url')}/user/doesnotexist/topics.rss`, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + done(); + }); + }); + + it('should redirect if we do not have read privilege', done => { + privileges.categories.rescind(['groups:read'], cid, 'guests', error => { + assert.ifError(error); + request(`${nconf.get('url')}/category/${cid}.rss`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + assert(body.includes('Login to your account')); + privileges.categories.give(['groups:read'], cid, 'guests', done); + }); + }); + }); + + describe('private feeds and tokens', () => { + let jar; + let rssToken; + before(async () => { + ({jar} = await helpers.loginUser('foo', 'barbar')); + }); + + it('should load feed if its not private', done => { + request(`${nconf.get('url')}/category/${cid}.rss`, {}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should not allow access if uid or token is missing', done => { + privileges.categories.rescind(['groups:read'], cid, 'guests', error => { + assert.ifError(error); + async.parallel({ + test1(next) { + request(`${nconf.get('url')}/category/${cid}.rss?uid=${fooUid}`, {}, next); + }, + test2(next) { + request(`${nconf.get('url')}/category/${cid}.rss?token=sometoken`, {}, next); + }, + }, (error, results) => { + assert.ifError(error); + assert.equal(results.test1[0].statusCode, 200); + assert.equal(results.test2[0].statusCode, 200); + assert(results.test1[0].body.includes('Login to your account')); + assert(results.test2[0].body.includes('Login to your account')); + done(); + }); + }); + }); + + it('should not allow access if token is wrong', done => { + request(`${nconf.get('url')}/category/${cid}.rss?uid=${fooUid}&token=sometoken`, {}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body.includes('Login to your account')); + done(); + }); + }); + + it('should allow access if token is correct', done => { + request(`${nconf.get('url')}/api/category/${cid}`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + rssToken = body.rssFeedUrl.split('token')[1].slice(1); + request(`${nconf.get('url')}/category/${cid}.rss?uid=${fooUid}&token=${rssToken}`, {}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body.startsWith(' { + privileges.categories.rescind(['groups:read'], cid, 'registered-users', error => { + assert.ifError(error); + request(`${nconf.get('url')}/category/${cid}.rss?uid=${fooUid}&token=${rssToken}`, {}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body.includes('Login to your account')); + done(); + }); + }); + }); + }); }); diff --git a/test/file.js b/test/file.js index 82efab7..0209a25 100644 --- a/test/file.js +++ b/test/file.js @@ -1,122 +1,121 @@ 'use strict'; -const assert = require('assert'); -const fs = require('fs'); -const path = require('path'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); const nconf = require('nconf'); - const utils = require('../src/utils'); const file = require('../src/file'); describe('file', () => { - const filename = `${utils.generateUUID()}.png`; - const folder = 'files'; - const uploadPath = path.join(nconf.get('upload_path'), folder, filename); - const tempPath = path.join(__dirname, './files/test.png'); - - afterEach((done) => { - fs.unlink(uploadPath, () => { - done(); - }); - }); - - describe('copyFile', () => { - it('should copy a file', (done) => { - fs.copyFile(tempPath, uploadPath, (err) => { - assert.ifError(err); - - assert(file.existsSync(uploadPath)); - - const srcContent = fs.readFileSync(tempPath, 'utf8'); - const destContent = fs.readFileSync(uploadPath, 'utf8'); - - assert.strictEqual(srcContent, destContent); - done(); - }); - }); - - it('should override an existing file', (done) => { - fs.writeFileSync(uploadPath, 'hsdkjhgkjsfhkgj'); - - fs.copyFile(tempPath, uploadPath, (err) => { - assert.ifError(err); - - assert(file.existsSync(uploadPath)); - - const srcContent = fs.readFileSync(tempPath, 'utf8'); - const destContent = fs.readFileSync(uploadPath, 'utf8'); - - assert.strictEqual(srcContent, destContent); - done(); - }); - }); - - it('should error if source file does not exist', (done) => { - fs.copyFile(`${tempPath}0000000000`, uploadPath, (err) => { - assert(err); - assert.strictEqual(err.code, 'ENOENT'); - - done(); - }); - }); - - it('should error if existing file is read only', (done) => { - fs.writeFileSync(uploadPath, 'hsdkjhgkjsfhkgj'); - fs.chmodSync(uploadPath, '444'); - - fs.copyFile(tempPath, uploadPath, (err) => { - assert(err); - assert(err.code === 'EPERM' || err.code === 'EACCES'); - - done(); - }); - }); - }); - - describe('saveFileToLocal', () => { - it('should work', (done) => { - file.saveFileToLocal(filename, folder, tempPath, (err) => { - assert.ifError(err); - - assert(file.existsSync(uploadPath)); - - const oldFile = fs.readFileSync(tempPath, 'utf8'); - const newFile = fs.readFileSync(uploadPath, 'utf8'); - assert.strictEqual(oldFile, newFile); - - done(); - }); - }); - - it('should error if source does not exist', (done) => { - file.saveFileToLocal(filename, folder, `${tempPath}000000000`, (err) => { - assert(err); - assert.strictEqual(err.code, 'ENOENT'); - - done(); - }); - }); - - it('should error if folder is relative', (done) => { - file.saveFileToLocal(filename, '../../text', `${tempPath}000000000`, (err) => { - assert(err); - assert.strictEqual(err.message, '[[error:invalid-path]]'); - done(); - }); - }); - }); - - it('should walk directory', (done) => { - file.walk(__dirname, (err, data) => { - assert.ifError(err); - assert(Array.isArray(data)); - done(); - }); - }); - - it('should convert mime type to extension', (done) => { - assert.equal(file.typeToExtension('image/png'), '.png'); - assert.equal(file.typeToExtension(''), ''); - done(); - }); + const filename = `${utils.generateUUID()}.png`; + const folder = 'files'; + const uploadPath = path.join(nconf.get('upload_path'), folder, filename); + const temporaryPath = path.join(__dirname, './files/test.png'); + + afterEach(done => { + fs.unlink(uploadPath, () => { + done(); + }); + }); + + describe('copyFile', () => { + it('should copy a file', done => { + fs.copyFile(temporaryPath, uploadPath, error => { + assert.ifError(error); + + assert(file.existsSync(uploadPath)); + + const sourceContent = fs.readFileSync(temporaryPath, 'utf8'); + const destinationContent = fs.readFileSync(uploadPath, 'utf8'); + + assert.strictEqual(sourceContent, destinationContent); + done(); + }); + }); + + it('should override an existing file', done => { + fs.writeFileSync(uploadPath, 'hsdkjhgkjsfhkgj'); + + fs.copyFile(temporaryPath, uploadPath, error => { + assert.ifError(error); + + assert(file.existsSync(uploadPath)); + + const sourceContent = fs.readFileSync(temporaryPath, 'utf8'); + const destinationContent = fs.readFileSync(uploadPath, 'utf8'); + + assert.strictEqual(sourceContent, destinationContent); + done(); + }); + }); + + it('should error if source file does not exist', done => { + fs.copyFile(`${temporaryPath}0000000000`, uploadPath, error => { + assert(error); + assert.strictEqual(error.code, 'ENOENT'); + + done(); + }); + }); + + it('should error if existing file is read only', done => { + fs.writeFileSync(uploadPath, 'hsdkjhgkjsfhkgj'); + fs.chmodSync(uploadPath, '444'); + + fs.copyFile(temporaryPath, uploadPath, error => { + assert(error); + assert(error.code === 'EPERM' || error.code === 'EACCES'); + + done(); + }); + }); + }); + + describe('saveFileToLocal', () => { + it('should work', done => { + file.saveFileToLocal(filename, folder, temporaryPath, error => { + assert.ifError(error); + + assert(file.existsSync(uploadPath)); + + const oldFile = fs.readFileSync(temporaryPath, 'utf8'); + const newFile = fs.readFileSync(uploadPath, 'utf8'); + assert.strictEqual(oldFile, newFile); + + done(); + }); + }); + + it('should error if source does not exist', done => { + file.saveFileToLocal(filename, folder, `${temporaryPath}000000000`, error => { + assert(error); + assert.strictEqual(error.code, 'ENOENT'); + + done(); + }); + }); + + it('should error if folder is relative', done => { + file.saveFileToLocal(filename, '../../text', `${temporaryPath}000000000`, error => { + assert(error); + assert.strictEqual(error.message, '[[error:invalid-path]]'); + done(); + }); + }); + }); + + it('should walk directory', done => { + file.walk(__dirname, (error, data) => { + assert.ifError(error); + assert(Array.isArray(data)); + done(); + }); + }); + + it('should convert mime type to extension', done => { + assert.equal(file.typeToExtension('image/png'), '.png'); + assert.equal(file.typeToExtension(''), ''); + done(); + }); }); diff --git a/test/flags.js b/test/flags.js index a2b2ebd..713f164 100644 --- a/test/flags.js +++ b/test/flags.js @@ -1,16 +1,13 @@ 'use strict'; -const assert = require('assert'); -const nconf = require('nconf'); -const async = require('async'); +const assert = require('node:assert'); +const util = require('node:util'); const request = require('request-promise-native'); -const util = require('util'); +const async = require('async'); +const nconf = require('nconf'); const sleep = util.promisify(setTimeout); -const db = require('./mocks/databasemock'); -const helpers = require('./helpers'); - const Flags = require('../src/flags'); const Categories = require('../src/categories'); const Topics = require('../src/topics'); @@ -21,1133 +18,1137 @@ const Meta = require('../src/meta'); const Privileges = require('../src/privileges'); const utils = require('../src/utils'); const api = require('../src/api'); +const helpers = require('./helpers'); +const db = require('./mocks/databasemock'); describe('Flags', () => { - let uid1; - let adminUid; - let uid3; - let moderatorUid; - let jar; - let csrfToken; - let category; - before(async () => { - // Create some stuff to flag - uid1 = await User.create({ username: 'testUser', password: 'abcdef', email: 'b@c.com' }); - - adminUid = await User.create({ username: 'testUser2', password: 'abcdef', email: 'c@d.com' }); - await Groups.join('administrators', adminUid); - - category = await Categories.create({ - name: 'test category', - }); - await Topics.post({ - cid: category.cid, - uid: uid1, - title: 'Topic to flag', - content: 'This is flaggable content', - }); - - uid3 = await User.create({ - username: 'unprivileged', password: 'abcdef', email: 'd@e.com', - }); - - moderatorUid = await User.create({ - username: 'moderator', password: 'abcdef', - }); - await Privileges.categories.give(['moderate'], category.cid, [moderatorUid]); - - const login = await helpers.loginUser('moderator', 'abcdef'); - jar = login.jar; - csrfToken = login.csrf_token; - }); - - describe('.create()', () => { - it('should create a flag and return its data', (done) => { - Flags.create('post', 1, 1, 'Test flag', (err, flagData) => { - assert.ifError(err); - const compare = { - flagId: 1, - targetId: 1, - type: 'post', - state: 'open', - target_readable: 'Post 1', - }; - assert(flagData); - for (const key of Object.keys(compare)) { - assert.ok(flagData[key], `undefined key ${key}`); - assert.equal(flagData[key], compare[key]); - } - - done(); - }); - }); - - it('should add the flag to the byCid zset for category 1 if it is of type post', (done) => { - db.isSortedSetMember(`flags:byCid:${1}`, 1, (err, isMember) => { - assert.ifError(err); - assert.ok(isMember); - done(); - }); - }); - - it('should add the flag to the byPid zset for pid 1 if it is of type post', (done) => { - db.isSortedSetMember(`flags:byPid:${1}`, 1, (err, isMember) => { - assert.ifError(err); - assert.ok(isMember); - done(); - }); - }); - }); - - describe('.exists()', () => { - it('should return Boolean True if a flag matching the flag hash already exists', (done) => { - Flags.exists('post', 1, 1, (err, exists) => { - assert.ifError(err); - assert.strictEqual(true, exists); - done(); - }); - }); - - it('should return Boolean False if a flag matching the flag hash does not already exists', (done) => { - Flags.exists('post', 1, 2, (err, exists) => { - assert.ifError(err); - assert.strictEqual(false, exists); - done(); - }); - }); - }); - - describe('.targetExists()', () => { - it('should return Boolean True if the targeted element exists', (done) => { - Flags.targetExists('post', 1, (err, exists) => { - assert.ifError(err); - assert.strictEqual(true, exists); - done(); - }); - }); - - it('should return Boolean False if the targeted element does not exist', (done) => { - Flags.targetExists('post', 15, (err, exists) => { - assert.ifError(err); - assert.strictEqual(false, exists); - done(); - }); - }); - }); - - describe('.get()', () => { - it('should retrieve and display a flag\'s data', (done) => { - Flags.get(1, (err, flagData) => { - assert.ifError(err); - const compare = { - flagId: 1, - targetId: 1, - type: 'post', - state: 'open', - target_readable: 'Post 1', - }; - assert(flagData); - for (const key of Object.keys(compare)) { - assert.ok(flagData[key], `undefined key ${key}`); - assert.equal(flagData[key], compare[key]); - } - - done(); - }); - }); - - it('should show user history for admins', async () => { - await Groups.join('administrators', moderatorUid); - const flagData = await request({ - uri: `${nconf.get('url')}/api/flags/1`, - jar, - headers: { - 'x-csrf-token': csrfToken, - }, - json: true, - }); - - assert(flagData.history); - assert(Array.isArray(flagData.history)); - - await Groups.leave('administrators', moderatorUid); - }); - - it('should show user history for global moderators', async () => { - await Groups.join('Global Moderators', moderatorUid); - const flagData = await request({ - uri: `${nconf.get('url')}/api/flags/1`, - jar, - headers: { - 'x-csrf-token': csrfToken, - }, - json: true, - }); - - assert(flagData.history); - assert(Array.isArray(flagData.history)); - - await Groups.leave('Global Moderators', moderatorUid); - }); - - it('should NOT show user history for regular moderators', async () => { - const flagData = await request({ - uri: `${nconf.get('url')}/api/flags/1`, - jar, - headers: { - 'x-csrf-token': csrfToken, - }, - json: true, - }); - - assert(flagData.hasOwnProperty('history')); - assert(flagData.history === null); - }); - }); - - describe('.list()', () => { - it('should show a list of flags (with one item)', (done) => { - Flags.list({ - filters: {}, - uid: 1, - }, (err, payload) => { - assert.ifError(err); - assert.ok(payload.hasOwnProperty('flags')); - assert.ok(payload.hasOwnProperty('page')); - assert.ok(payload.hasOwnProperty('pageCount')); - assert.ok(Array.isArray(payload.flags)); - assert.equal(payload.flags.length, 1); - - Flags.get(payload.flags[0].flagId, (err, flagData) => { - assert.ifError(err); - assert.equal(payload.flags[0].flagId, flagData.flagId); - assert.equal(payload.flags[0].description, flagData.description); - done(); - }); - }); - }); - - describe('(with filters)', () => { - it('should return a filtered list of flags if said filters are passed in', (done) => { - Flags.list({ - filters: { - state: 'open', - }, - uid: 1, - }, (err, payload) => { - assert.ifError(err); - assert.ok(payload.hasOwnProperty('flags')); - assert.ok(payload.hasOwnProperty('page')); - assert.ok(payload.hasOwnProperty('pageCount')); - assert.ok(Array.isArray(payload.flags)); - assert.strictEqual(1, parseInt(payload.flags[0].flagId, 10)); - done(); - }); - }); - - it('should return no flags if a filter with no matching flags is used', (done) => { - Flags.list({ - filters: { - state: 'rejected', - }, - uid: 1, - }, (err, payload) => { - assert.ifError(err); - assert.ok(payload.hasOwnProperty('flags')); - assert.ok(payload.hasOwnProperty('page')); - assert.ok(payload.hasOwnProperty('pageCount')); - assert.ok(Array.isArray(payload.flags)); - assert.strictEqual(0, payload.flags.length); - done(); - }); - }); - - it('should return a flag when filtered by cid 1', (done) => { - Flags.list({ - filters: { - cid: 1, - }, - uid: 1, - }, (err, payload) => { - assert.ifError(err); - assert.ok(payload.hasOwnProperty('flags')); - assert.ok(payload.hasOwnProperty('page')); - assert.ok(payload.hasOwnProperty('pageCount')); - assert.ok(Array.isArray(payload.flags)); - assert.strictEqual(1, payload.flags.length); - done(); - }); - }); - - it('shouldn\'t return a flag when filtered by cid 2', (done) => { - Flags.list({ - filters: { - cid: 2, - }, - uid: 1, - }, (err, payload) => { - assert.ifError(err); - assert.ok(payload.hasOwnProperty('flags')); - assert.ok(payload.hasOwnProperty('page')); - assert.ok(payload.hasOwnProperty('pageCount')); - assert.ok(Array.isArray(payload.flags)); - assert.strictEqual(0, payload.flags.length); - done(); - }); - }); - - it('should return a flag when filtered by both cid 1 and 2', (done) => { - Flags.list({ - filters: { - cid: [1, 2], - }, - uid: 1, - }, (err, payload) => { - assert.ifError(err); - assert.ok(payload.hasOwnProperty('flags')); - assert.ok(payload.hasOwnProperty('page')); - assert.ok(payload.hasOwnProperty('pageCount')); - assert.ok(Array.isArray(payload.flags)); - assert.strictEqual(1, payload.flags.length); - done(); - }); - }); - - it('should return one flag if filtered by both cid 1 and 2 and open state', (done) => { - Flags.list({ - filters: { - cid: [1, 2], - state: 'open', - }, - uid: 1, - }, (err, payload) => { - assert.ifError(err); - assert.ok(payload.hasOwnProperty('flags')); - assert.ok(payload.hasOwnProperty('page')); - assert.ok(payload.hasOwnProperty('pageCount')); - assert.ok(Array.isArray(payload.flags)); - assert.strictEqual(1, payload.flags.length); - done(); - }); - }); - - it('should return no flag if filtered by both cid 1 and 2 and non-open state', (done) => { - Flags.list({ - filters: { - cid: [1, 2], - state: 'resolved', - }, - uid: 1, - }, (err, payload) => { - assert.ifError(err); - assert.ok(payload.hasOwnProperty('flags')); - assert.ok(payload.hasOwnProperty('page')); - assert.ok(payload.hasOwnProperty('pageCount')); - assert.ok(Array.isArray(payload.flags)); - assert.strictEqual(0, payload.flags.length); - done(); - }); - }); - }); - - describe('(with sort)', () => { - before(async () => { - // Create a second flag to test sorting - const post = await Topics.reply({ - tid: 1, - uid: uid1, - content: 'this is a reply -- flag me', - }); - await Flags.create('post', post.pid, adminUid, 'another flag'); - await Flags.create('post', 1, uid3, 'additional flag report'); - }); - - it('should return sorted flags latest first if no sort is passed in', async () => { - const payload = await Flags.list({ - uid: adminUid, - }); - - assert(payload.flags.every((cur, idx) => { - if (idx === payload.flags.length - 1) { - return true; - } - - const next = payload.flags[idx + 1]; - return parseInt(cur.datetime, 10) > parseInt(next.datetime, 10); - })); - }); - - it('should return sorted flags oldest first if "oldest" sort is passed in', async () => { - const payload = await Flags.list({ - uid: adminUid, - sort: 'oldest', - }); - - assert(payload.flags.every((cur, idx) => { - if (idx === payload.flags.length - 1) { - return true; - } - - const next = payload.flags[idx + 1]; - return parseInt(cur.datetime, 10) < parseInt(next.datetime, 10); - })); - }); - - it('should return flags with more reports first if "reports" sort is passed in', async () => { - const payload = await Flags.list({ - uid: adminUid, - sort: 'reports', - }); - - assert(payload.flags.every((cur, idx) => { - if (idx === payload.flags.length - 1) { - return true; - } - - const next = payload.flags[idx + 1]; - return parseInt(cur.heat, 10) >= parseInt(next.heat, 10); - })); - }); - }); - }); - - describe('.update()', () => { - it('should alter a flag\'s various attributes and persist them to the database', (done) => { - Flags.update(1, adminUid, { - state: 'wip', - assignee: adminUid, - }, (err) => { - assert.ifError(err); - db.getObjectFields('flag:1', ['state', 'assignee'], (err, data) => { - if (err) { - throw err; - } - - assert.strictEqual('wip', data.state); - assert.ok(!isNaN(parseInt(data.assignee, 10))); - assert.strictEqual(adminUid, parseInt(data.assignee, 10)); - done(); - }); - }); - }); - - it('should persist to the flag\'s history', (done) => { - Flags.getHistory(1, (err, history) => { - if (err) { - throw err; - } - - history.forEach((change) => { - switch (change.attribute) { - case 'state': - assert.strictEqual('[[flags:state-wip]]', change.value); - break; - - case 'assignee': - assert.strictEqual(1, change.value); - break; - } - }); - - done(); - }); - }); - - it('should allow assignment if user is an admin and do nothing otherwise', async () => { - await Flags.update(1, adminUid, { - assignee: adminUid, - }); - let assignee = await db.getObjectField('flag:1', 'assignee'); - assert.strictEqual(adminUid, parseInt(assignee, 10)); - - await Flags.update(1, adminUid, { - assignee: uid3, - }); - assignee = await db.getObjectField('flag:1', 'assignee'); - assert.strictEqual(adminUid, parseInt(assignee, 10)); - }); - - it('should allow assignment if user is a global mod and do nothing otherwise', async () => { - await Groups.join('Global Moderators', uid3); - - await Flags.update(1, uid3, { - assignee: uid3, - }); - let assignee = await db.getObjectField('flag:1', 'assignee'); - assert.strictEqual(uid3, parseInt(assignee, 10)); - - await Flags.update(1, uid3, { - assignee: uid1, - }); - assignee = await db.getObjectField('flag:1', 'assignee'); - assert.strictEqual(uid3, parseInt(assignee, 10)); - - await Groups.leave('Global Moderators', uid3); - }); - - it('should allow assignment if user is a mod of the category, do nothing otherwise', async () => { - await Groups.join(`cid:${category.cid}:privileges:moderate`, uid3); - - await Flags.update(1, uid3, { - assignee: uid3, - }); - let assignee = await db.getObjectField('flag:1', 'assignee'); - assert.strictEqual(uid3, parseInt(assignee, 10)); - - await Flags.update(1, uid3, { - assignee: uid1, - }); - assignee = await db.getObjectField('flag:1', 'assignee'); - assert.strictEqual(uid3, parseInt(assignee, 10)); - - await Groups.leave(`cid:${category.cid}:privileges:moderate`, uid3); - }); - - it('should do nothing when you attempt to set a bogus state', async () => { - await Flags.update(1, adminUid, { - state: 'hocus pocus', - }); - - const state = await db.getObjectField('flag:1', 'state'); - assert.strictEqual('wip', state); - }); - - describe('resolve/reject', () => { - let result; - let flagObj; - beforeEach(async () => { - result = await Topics.post({ - cid: category.cid, - uid: uid3, - title: 'Topic to flag', - content: 'This is flaggable content', - }); - flagObj = await api.flags.create({ uid: uid1 }, { type: 'post', id: result.postData.pid, reason: 'spam' }); - await sleep(2000); - }); - - it('should rescind notification if flag is resolved', async () => { - let userNotifs = await User.notifications.getAll(adminUid); - assert(userNotifs.includes(`flag:post:${result.postData.pid}`)); - - await Flags.update(flagObj.flagId, adminUid, { - state: 'resolved', - }); - - userNotifs = await User.notifications.getAll(adminUid); - assert(!userNotifs.includes(`flag:post:${result.postData.pid}`)); - }); - - it('should rescind notification if flag is rejected', async () => { - let userNotifs = await User.notifications.getAll(adminUid); - assert(userNotifs.includes(`flag:post:${result.postData.pid}`)); - - await Flags.update(flagObj.flagId, adminUid, { - state: 'rejected', - }); - - userNotifs = await User.notifications.getAll(adminUid); - assert(!userNotifs.includes(`flag:post:${result.postData.pid}`)); - }); - - it('should do nothing if flag is resolved but ACP action is not "rescind"', async () => { - Meta.config['flags:actionOnResolve'] = ''; - - let userNotifs = await User.notifications.getAll(adminUid); - assert(userNotifs.includes(`flag:post:${result.postData.pid}`)); - - await Flags.update(flagObj.flagId, adminUid, { - state: 'resolved', - }); - - userNotifs = await User.notifications.getAll(adminUid); - assert(userNotifs.includes(`flag:post:${result.postData.pid}`)); - - delete Meta.config['flags:actionOnResolve']; - }); - - it('should do nothing if flag is rejected but ACP action is not "rescind"', async () => { - Meta.config['flags:actionOnReject'] = ''; - - let userNotifs = await User.notifications.getAll(adminUid); - assert(userNotifs.includes(`flag:post:${result.postData.pid}`)); - - await Flags.update(flagObj.flagId, adminUid, { - state: 'rejected', - }); - - userNotifs = await User.notifications.getAll(adminUid); - assert(userNotifs.includes(`flag:post:${result.postData.pid}`)); - - delete Meta.config['flags:actionOnReject']; - }); - }); - }); - - describe('.getTarget()', () => { - it('should return a post\'s data if queried with type "post"', (done) => { - Flags.getTarget('post', 1, 1, (err, data) => { - assert.ifError(err); - const compare = { - uid: 1, - pid: 1, - content: 'This is flaggable content', - }; - - for (const key of Object.keys(compare)) { - assert.ok(data[key]); - assert.equal(data[key], compare[key]); - } - - done(); - }); - }); - - it('should return a user\'s data if queried with type "user"', (done) => { - Flags.getTarget('user', 1, 1, (err, data) => { - assert.ifError(err); - const compare = { - uid: 1, - username: 'testUser', - email: 'b@c.com', - }; - - for (const key of Object.keys(compare)) { - assert.ok(data[key]); - assert.equal(data[key], compare[key]); - } - - done(); - }); - }); - - it('should return a plain object with no properties if the target no longer exists', (done) => { - Flags.getTarget('user', 15, 1, (err, data) => { - assert.ifError(err); - assert.strictEqual(0, Object.keys(data).length); - done(); - }); - }); - }); - - describe('.validate()', () => { - it('should error out if type is post and post is deleted', (done) => { - Posts.delete(1, 1, (err) => { - if (err) { - throw err; - } - - Flags.validate({ - type: 'post', - id: 1, - uid: 1, - }, (err) => { - assert.ok(err); - assert.strictEqual('[[error:post-deleted]]', err.message); - Posts.restore(1, 1, done); - }); - }); - }); - - it('should not pass validation if flag threshold is set and user rep does not meet it', (done) => { - Meta.configs.set('min:rep:flag', '50', (err) => { - assert.ifError(err); - - Flags.validate({ - type: 'post', - id: 1, - uid: 3, - }, (err) => { - assert.ok(err); - assert.strictEqual('[[error:not-enough-reputation-to-flag, 50]]', err.message); - Meta.configs.set('min:rep:flag', 0, done); - }); - }); - }); - - it('should not error if user blocked target', async () => { - const apiFlags = require('../src/api/flags'); - const reporterUid = await User.create({ username: 'reporter' }); - const reporteeUid = await User.create({ username: 'reportee' }); - await User.blocks.add(reporteeUid, reporterUid); - const data = await Topics.post({ - cid: 1, - uid: reporteeUid, - title: 'Another topic', - content: 'This is flaggable content', - }); - await apiFlags.create({ uid: reporterUid }, { - type: 'post', - id: data.postData.pid, - reason: 'spam', - }); - }); - - it('should send back error if reporter does not exist', (done) => { - Flags.validate({ uid: 123123123, id: 1, type: 'post' }, (err) => { - assert.equal(err.message, '[[error:no-user]]'); - done(); - }); - }); - }); - - describe('.appendNote()', () => { - it('should add a note to a flag', (done) => { - Flags.appendNote(1, 1, 'this is my note', (err) => { - assert.ifError(err); - - db.getSortedSetRange('flag:1:notes', 0, -1, (err, notes) => { - if (err) { - throw err; - } - - assert.strictEqual('[1,"this is my note"]', notes[0]); - setTimeout(done, 10); - }); - }); - }); - - it('should be a JSON string', (done) => { - db.getSortedSetRange('flag:1:notes', 0, -1, (err, notes) => { - if (err) { - throw err; - } - - try { - JSON.parse(notes[0]); - } catch (e) { - assert.ifError(e); - } - - done(); - }); - }); - - it('should insert a note in the past if a datetime is passed in', async () => { - await Flags.appendNote(1, 1, 'this is the first note', 1626446956652); - const note = (await db.getSortedSetRange('flag:1:notes', 0, 0)).pop(); - assert.strictEqual('[1,"this is the first note"]', note); - }); - }); - - describe('.getNotes()', () => { - before((done) => { - // Add a second note - Flags.appendNote(1, 1, 'this is the second note', done); - }); - - it('return should match a predefined spec', (done) => { - Flags.getNotes(1, (err, notes) => { - assert.ifError(err); - const compare = { - uid: 1, - content: 'this is my note', - }; - - const data = notes[1]; - for (const key of Object.keys(compare)) { - assert.ok(data[key]); - assert.strictEqual(data[key], compare[key]); - } - - done(); - }); - }); - - it('should retrieve a list of notes, from newest to oldest', (done) => { - Flags.getNotes(1, (err, notes) => { - assert.ifError(err); - assert(notes[0].datetime > notes[1].datetime, `${notes[0].datetime}-${notes[1].datetime}`); - assert.strictEqual('this is the second note', notes[0].content); - done(); - }); - }); - }); - - describe('.appendHistory()', () => { - let entries; - before((done) => { - db.sortedSetCard('flag:1:history', (err, count) => { - entries = count; - done(err); - }); - }); - - it('should add a new entry into a flag\'s history', (done) => { - Flags.appendHistory(1, 1, { - state: 'rejected', - }, (err) => { - assert.ifError(err); - - Flags.getHistory(1, (err, history) => { - if (err) { - throw err; - } - - // 1 for the new event appended, 2 for username/email change - assert.strictEqual(entries + 3, history.length); - done(); - }); - }); - }); - }); - - describe('.getHistory()', () => { - it('should retrieve a flag\'s history', (done) => { - Flags.getHistory(1, (err, history) => { - assert.ifError(err); - assert.strictEqual(history[0].fields.state, '[[flags:state-rejected]]'); - done(); - }); - }); - }); - - describe('(v3 API)', () => { - let pid; - let tid; - let jar; - let csrfToken; - before(async () => { - const login = await helpers.loginUser('testUser2', 'abcdef'); - jar = login.jar; - csrfToken = login.csrf_token; - - const result = await Topics.post({ - cid: 1, - uid: 1, - title: 'Another topic', - content: 'This is flaggable content', - }); - pid = result.postData.pid; - tid = result.topicData.tid; - }); - - describe('.create()', () => { - it('should create a flag with no errors', async () => { - await request({ - method: 'post', - uri: `${nconf.get('url')}/api/v3/flags`, - jar, - headers: { - 'x-csrf-token': csrfToken, - }, - body: { - type: 'post', - id: pid, - reason: 'foobar', - }, - json: true, - }); - - const exists = await Flags.exists('post', pid, 2); - assert(exists); - }); - - it('should escape flag reason', async () => { - const postData = await Topics.reply({ - tid: tid, - uid: 1, - content: 'This is flaggable content', - }); - - const { response } = await request({ - method: 'post', - uri: `${nconf.get('url')}/api/v3/flags`, - jar, - headers: { - 'x-csrf-token': csrfToken, - }, - body: { - type: 'post', - id: postData.pid, - reason: '"', - }, - json: true, - }); - - const flagData = await Flags.get(response.flagId); - assert.strictEqual(flagData.reports[0].value, '"<script>alert('ok');</script>'); - }); - - it('should not allow flagging post in private category', async () => { - const category = await Categories.create({ name: 'private category' }); - - await Privileges.categories.rescind(['groups:topics:read'], category.cid, 'registered-users'); - await Groups.join('private category', uid3); - const result = await Topics.post({ - cid: category.cid, - uid: uid3, - title: 'private topic', - content: 'private post', - }); - const login = await helpers.loginUser('unprivileged', 'abcdef'); - const jar3 = login.jar; - const config = await request({ - url: `${nconf.get('url')}/api/config`, - json: true, - jar: jar3, - }); - const csrfToken = config.csrf_token; - const { statusCode, body } = await request({ - method: 'post', - uri: `${nconf.get('url')}/api/v3/flags`, - jar: jar3, - headers: { - 'x-csrf-token': csrfToken, - }, - body: { - type: 'post', - id: result.postData.pid, - reason: 'foobar', - }, - json: true, - simple: false, - resolveWithFullResponse: true, - }); - assert.strictEqual(statusCode, 403); - - // Handle dev mode test - delete body.stack; - - assert.deepStrictEqual(body, { - status: { - code: 'forbidden', - message: 'You do not have enough privileges for this action.', - }, - response: {}, - }); - }); - }); - - describe('.update()', () => { - it('should update a flag\'s properties', async () => { - const { response } = await request({ - method: 'put', - uri: `${nconf.get('url')}/api/v3/flags/2`, - jar, - headers: { - 'x-csrf-token': csrfToken, - }, - body: { - state: 'wip', - }, - json: true, - }); - - const { history } = response; - assert(Array.isArray(history)); - assert(history[0].fields.hasOwnProperty('state')); - assert.strictEqual('[[flags:state-wip]]', history[0].fields.state); - }); - }); - - describe('.appendNote()', () => { - it('should append a note to the flag', async () => { - const { response } = await request({ - method: 'post', - uri: `${nconf.get('url')}/api/v3/flags/2/notes`, - jar, - headers: { - 'x-csrf-token': csrfToken, - }, - body: { - note: 'lorem ipsum dolor sit amet', - datetime: 1626446956652, - }, - json: true, - }); - - assert(response.hasOwnProperty('notes')); - assert(Array.isArray(response.notes)); - assert.strictEqual('lorem ipsum dolor sit amet', response.notes[0].content); - assert.strictEqual(2, response.notes[0].uid); - - assert(response.hasOwnProperty('history')); - assert(Array.isArray(response.history)); - assert.strictEqual(1, Object.keys(response.history[response.history.length - 1].fields).length); - assert(response.history[response.history.length - 1].fields.hasOwnProperty('notes')); - }); - }); - - describe('.deleteNote()', () => { - it('should delete a note from a flag', async () => { - const { response } = await request({ - method: 'delete', - uri: `${nconf.get('url')}/api/v3/flags/2/notes/1626446956652`, - jar, - headers: { - 'x-csrf-token': csrfToken, - }, - json: true, - }); - - assert(Array.isArray(response.history)); - assert(Array.isArray(response.notes)); - assert.strictEqual(response.notes.length, 0); - }); - }); - - describe('access control', () => { - let uid; - let jar; - let csrf_token; - let requests; - - let flaggerUid; - let flagId; - - const noteTime = Date.now(); - - before(async () => { - uid = await User.create({ username: 'flags-access-control', password: 'abcdef' }); - ({ jar, csrf_token } = await helpers.loginUser('flags-access-control', 'abcdef')); - - flaggerUid = await User.create({ username: 'flags-access-control-flagger', password: 'abcdef' }); - }); - - beforeEach(async () => { - // Reset uid back to unprivileged user - await Groups.leave('administrators', uid); - await Groups.leave('Global Moderators', uid); - await Privileges.categories.rescind(['moderate'], 1, [uid]); - - const { postData } = await Topics.post({ - uid, - cid: 1, - title: utils.generateUUID(), - content: utils.generateUUID(), - }); - - ({ flagId } = await Flags.create('post', postData.pid, flaggerUid, 'spam')); - requests = new Set([ - { - method: 'get', - uri: `${nconf.get('url')}/api/v3/flags/${flagId}`, - jar, - headers: { - 'x-csrf-token': csrf_token, - }, - json: true, - simple: false, - resolveWithFullResponse: true, - }, - { - method: 'put', - uri: `${nconf.get('url')}/api/v3/flags/${flagId}`, - jar, - headers: { - 'x-csrf-token': csrf_token, - }, - body: { - state: 'wip', - }, - json: true, - simple: false, - resolveWithFullResponse: true, - }, - { - method: 'post', - uri: `${nconf.get('url')}/api/v3/flags/${flagId}/notes`, - jar, - headers: { - 'x-csrf-token': csrf_token, - }, - body: { - note: 'test note', - datetime: noteTime, - }, - json: true, - simple: false, - resolveWithFullResponse: true, - }, - { - method: 'delete', - uri: `${nconf.get('url')}/api/v3/flags/${flagId}/notes/${noteTime}`, - jar, - headers: { - 'x-csrf-token': csrf_token, - }, - json: true, - simple: false, - resolveWithFullResponse: true, - }, - { - method: 'delete', - uri: `${nconf.get('url')}/api/v3/flags/${flagId}`, - jar, - headers: { - 'x-csrf-token': csrf_token, - }, - json: true, - simple: false, - resolveWithFullResponse: true, - }, - ]); - }); - - it('should not allow access to privileged flag endpoints to guests', async () => { - for (let opts of requests) { - opts = { ...opts }; - delete opts.jar; - delete opts.headers; - - // eslint-disable-next-line no-await-in-loop - const { statusCode } = await request(opts); - assert(statusCode.toString().startsWith(4), `${opts.method.toUpperCase()} ${opts.uri} => ${statusCode}`); - } - }); - - it('should not allow access to privileged flag endpoints to regular users', async () => { - for (const opts of requests) { - // eslint-disable-next-line no-await-in-loop - const { statusCode } = await request(opts); - assert(statusCode.toString().startsWith(4), `${opts.method.toUpperCase()} ${opts.uri} => ${statusCode}`); - } - }); - - it('should allow access to privileged endpoints to administrators', async () => { - await Groups.join('administrators', uid); - - for (const opts of requests) { - // eslint-disable-next-line no-await-in-loop - const { statusCode } = await request(opts); - assert.strictEqual(statusCode, 200, `${opts.method.toUpperCase()} ${opts.uri} => ${statusCode}`); - } - }); - - it('should allow access to privileged endpoints to global moderators', async () => { - await Groups.join('Global Moderators', uid); - - for (const opts of requests) { - // eslint-disable-next-line no-await-in-loop - const { statusCode } = await request(opts); - assert.strictEqual(statusCode, 200, `${opts.method.toUpperCase()} ${opts.uri} => ${statusCode}`); - } - }); - - it('should allow access to privileged endpoints to moderators if the flag target is a post in a cid they moderate', async () => { - await Privileges.categories.give(['moderate'], 1, [uid]); - - for (const opts of requests) { - // eslint-disable-next-line no-await-in-loop - const { statusCode } = await request(opts); - assert.strictEqual(statusCode, 200, `${opts.method.toUpperCase()} ${opts.uri} => ${statusCode}`); - } - }); - - it('should NOT allow access to privileged endpoints to moderators if the flag target is a post in a cid they DO NOT moderate', async () => { - // This is a new category the user will moderate, but the flagged post is in a different category - const { cid } = await Categories.create({ - name: utils.generateUUID(), - }); - await Privileges.categories.give(['moderate'], cid, [uid]); - - for (const opts of requests) { - // eslint-disable-next-line no-await-in-loop - const { statusCode } = await request(opts); - assert(statusCode.toString().startsWith(4), `${opts.method.toUpperCase()} ${opts.uri} => ${statusCode}`); - } - }); - }); - }); + let uid1; + let adminUid; + let uid3; + let moderatorUid; + let jar; + let csrfToken; + let category; + before(async () => { + // Create some stuff to flag + uid1 = await User.create({username: 'testUser', password: 'abcdef', email: 'b@c.com'}); + + adminUid = await User.create({username: 'testUser2', password: 'abcdef', email: 'c@d.com'}); + await Groups.join('administrators', adminUid); + + category = await Categories.create({ + name: 'test category', + }); + await Topics.post({ + cid: category.cid, + uid: uid1, + title: 'Topic to flag', + content: 'This is flaggable content', + }); + + uid3 = await User.create({ + username: 'unprivileged', password: 'abcdef', email: 'd@e.com', + }); + + moderatorUid = await User.create({ + username: 'moderator', password: 'abcdef', + }); + await Privileges.categories.give(['moderate'], category.cid, [moderatorUid]); + + const login = await helpers.loginUser('moderator', 'abcdef'); + jar = login.jar; + csrfToken = login.csrf_token; + }); + + describe('.create()', () => { + it('should create a flag and return its data', done => { + Flags.create('post', 1, 1, 'Test flag', (error, flagData) => { + assert.ifError(error); + const compare = { + flagId: 1, + targetId: 1, + type: 'post', + state: 'open', + target_readable: 'Post 1', + }; + assert(flagData); + for (const key of Object.keys(compare)) { + assert.ok(flagData[key], `undefined key ${key}`); + assert.equal(flagData[key], compare[key]); + } + + done(); + }); + }); + + it('should add the flag to the byCid zset for category 1 if it is of type post', done => { + db.isSortedSetMember(`flags:byCid:${1}`, 1, (error, isMember) => { + assert.ifError(error); + assert.ok(isMember); + done(); + }); + }); + + it('should add the flag to the byPid zset for pid 1 if it is of type post', done => { + db.isSortedSetMember(`flags:byPid:${1}`, 1, (error, isMember) => { + assert.ifError(error); + assert.ok(isMember); + done(); + }); + }); + }); + + describe('.exists()', () => { + it('should return Boolean True if a flag matching the flag hash already exists', done => { + Flags.exists('post', 1, 1, (error, exists) => { + assert.ifError(error); + assert.strictEqual(true, exists); + done(); + }); + }); + + it('should return Boolean False if a flag matching the flag hash does not already exists', done => { + Flags.exists('post', 1, 2, (error, exists) => { + assert.ifError(error); + assert.strictEqual(false, exists); + done(); + }); + }); + }); + + describe('.targetExists()', () => { + it('should return Boolean True if the targeted element exists', done => { + Flags.targetExists('post', 1, (error, exists) => { + assert.ifError(error); + assert.strictEqual(true, exists); + done(); + }); + }); + + it('should return Boolean False if the targeted element does not exist', done => { + Flags.targetExists('post', 15, (error, exists) => { + assert.ifError(error); + assert.strictEqual(false, exists); + done(); + }); + }); + }); + + describe('.get()', () => { + it('should retrieve and display a flag\'s data', done => { + Flags.get(1, (error, flagData) => { + assert.ifError(error); + const compare = { + flagId: 1, + targetId: 1, + type: 'post', + state: 'open', + target_readable: 'Post 1', + }; + assert(flagData); + for (const key of Object.keys(compare)) { + assert.ok(flagData[key], `undefined key ${key}`); + assert.equal(flagData[key], compare[key]); + } + + done(); + }); + }); + + it('should show user history for admins', async () => { + await Groups.join('administrators', moderatorUid); + const flagData = await request({ + uri: `${nconf.get('url')}/api/flags/1`, + jar, + headers: { + 'x-csrf-token': csrfToken, + }, + json: true, + }); + + assert(flagData.history); + assert(Array.isArray(flagData.history)); + + await Groups.leave('administrators', moderatorUid); + }); + + it('should show user history for global moderators', async () => { + await Groups.join('Global Moderators', moderatorUid); + const flagData = await request({ + uri: `${nconf.get('url')}/api/flags/1`, + jar, + headers: { + 'x-csrf-token': csrfToken, + }, + json: true, + }); + + assert(flagData.history); + assert(Array.isArray(flagData.history)); + + await Groups.leave('Global Moderators', moderatorUid); + }); + + it('should NOT show user history for regular moderators', async () => { + const flagData = await request({ + uri: `${nconf.get('url')}/api/flags/1`, + jar, + headers: { + 'x-csrf-token': csrfToken, + }, + json: true, + }); + + assert(flagData.hasOwnProperty('history')); + assert(flagData.history === null); + }); + }); + + describe('.list()', () => { + it('should show a list of flags (with one item)', done => { + Flags.list({ + filters: {}, + uid: 1, + }, (error, payload) => { + assert.ifError(error); + assert.ok(payload.hasOwnProperty('flags')); + assert.ok(payload.hasOwnProperty('page')); + assert.ok(payload.hasOwnProperty('pageCount')); + assert.ok(Array.isArray(payload.flags)); + assert.equal(payload.flags.length, 1); + + Flags.get(payload.flags[0].flagId, (error, flagData) => { + assert.ifError(error); + assert.equal(payload.flags[0].flagId, flagData.flagId); + assert.equal(payload.flags[0].description, flagData.description); + done(); + }); + }); + }); + + describe('(with filters)', () => { + it('should return a filtered list of flags if said filters are passed in', done => { + Flags.list({ + filters: { + state: 'open', + }, + uid: 1, + }, (error, payload) => { + assert.ifError(error); + assert.ok(payload.hasOwnProperty('flags')); + assert.ok(payload.hasOwnProperty('page')); + assert.ok(payload.hasOwnProperty('pageCount')); + assert.ok(Array.isArray(payload.flags)); + assert.strictEqual(1, Number.parseInt(payload.flags[0].flagId, 10)); + done(); + }); + }); + + it('should return no flags if a filter with no matching flags is used', done => { + Flags.list({ + filters: { + state: 'rejected', + }, + uid: 1, + }, (error, payload) => { + assert.ifError(error); + assert.ok(payload.hasOwnProperty('flags')); + assert.ok(payload.hasOwnProperty('page')); + assert.ok(payload.hasOwnProperty('pageCount')); + assert.ok(Array.isArray(payload.flags)); + assert.strictEqual(0, payload.flags.length); + done(); + }); + }); + + it('should return a flag when filtered by cid 1', done => { + Flags.list({ + filters: { + cid: 1, + }, + uid: 1, + }, (error, payload) => { + assert.ifError(error); + assert.ok(payload.hasOwnProperty('flags')); + assert.ok(payload.hasOwnProperty('page')); + assert.ok(payload.hasOwnProperty('pageCount')); + assert.ok(Array.isArray(payload.flags)); + assert.strictEqual(1, payload.flags.length); + done(); + }); + }); + + it('shouldn\'t return a flag when filtered by cid 2', done => { + Flags.list({ + filters: { + cid: 2, + }, + uid: 1, + }, (error, payload) => { + assert.ifError(error); + assert.ok(payload.hasOwnProperty('flags')); + assert.ok(payload.hasOwnProperty('page')); + assert.ok(payload.hasOwnProperty('pageCount')); + assert.ok(Array.isArray(payload.flags)); + assert.strictEqual(0, payload.flags.length); + done(); + }); + }); + + it('should return a flag when filtered by both cid 1 and 2', done => { + Flags.list({ + filters: { + cid: [1, 2], + }, + uid: 1, + }, (error, payload) => { + assert.ifError(error); + assert.ok(payload.hasOwnProperty('flags')); + assert.ok(payload.hasOwnProperty('page')); + assert.ok(payload.hasOwnProperty('pageCount')); + assert.ok(Array.isArray(payload.flags)); + assert.strictEqual(1, payload.flags.length); + done(); + }); + }); + + it('should return one flag if filtered by both cid 1 and 2 and open state', done => { + Flags.list({ + filters: { + cid: [1, 2], + state: 'open', + }, + uid: 1, + }, (error, payload) => { + assert.ifError(error); + assert.ok(payload.hasOwnProperty('flags')); + assert.ok(payload.hasOwnProperty('page')); + assert.ok(payload.hasOwnProperty('pageCount')); + assert.ok(Array.isArray(payload.flags)); + assert.strictEqual(1, payload.flags.length); + done(); + }); + }); + + it('should return no flag if filtered by both cid 1 and 2 and non-open state', done => { + Flags.list({ + filters: { + cid: [1, 2], + state: 'resolved', + }, + uid: 1, + }, (error, payload) => { + assert.ifError(error); + assert.ok(payload.hasOwnProperty('flags')); + assert.ok(payload.hasOwnProperty('page')); + assert.ok(payload.hasOwnProperty('pageCount')); + assert.ok(Array.isArray(payload.flags)); + assert.strictEqual(0, payload.flags.length); + done(); + }); + }); + }); + + describe('(with sort)', () => { + before(async () => { + // Create a second flag to test sorting + const post = await Topics.reply({ + tid: 1, + uid: uid1, + content: 'this is a reply -- flag me', + }); + await Flags.create('post', post.pid, adminUid, 'another flag'); + await Flags.create('post', 1, uid3, 'additional flag report'); + }); + + it('should return sorted flags latest first if no sort is passed in', async () => { + const payload = await Flags.list({ + uid: adminUid, + }); + + assert(payload.flags.every((current, index) => { + if (index === payload.flags.length - 1) { + return true; + } + + const next = payload.flags[index + 1]; + return Number.parseInt(current.datetime, 10) > Number.parseInt(next.datetime, 10); + })); + }); + + it('should return sorted flags oldest first if "oldest" sort is passed in', async () => { + const payload = await Flags.list({ + uid: adminUid, + sort: 'oldest', + }); + + assert(payload.flags.every((current, index) => { + if (index === payload.flags.length - 1) { + return true; + } + + const next = payload.flags[index + 1]; + return Number.parseInt(current.datetime, 10) < Number.parseInt(next.datetime, 10); + })); + }); + + it('should return flags with more reports first if "reports" sort is passed in', async () => { + const payload = await Flags.list({ + uid: adminUid, + sort: 'reports', + }); + + assert(payload.flags.every((current, index) => { + if (index === payload.flags.length - 1) { + return true; + } + + const next = payload.flags[index + 1]; + return Number.parseInt(current.heat, 10) >= Number.parseInt(next.heat, 10); + })); + }); + }); + }); + + describe('.update()', () => { + it('should alter a flag\'s various attributes and persist them to the database', done => { + Flags.update(1, adminUid, { + state: 'wip', + assignee: adminUid, + }, error => { + assert.ifError(error); + db.getObjectFields('flag:1', ['state', 'assignee'], (error, data) => { + if (error) { + throw error; + } + + assert.strictEqual('wip', data.state); + assert.ok(!isNaN(Number.parseInt(data.assignee, 10))); + assert.strictEqual(adminUid, Number.parseInt(data.assignee, 10)); + done(); + }); + }); + }); + + it('should persist to the flag\'s history', done => { + Flags.getHistory(1, (error, history) => { + if (error) { + throw error; + } + + for (const change of history) { + switch (change.attribute) { + case 'state': { + assert.strictEqual('[[flags:state-wip]]', change.value); + break; + } + + case 'assignee': { + assert.strictEqual(1, change.value); + break; + } + } + } + + done(); + }); + }); + + it('should allow assignment if user is an admin and do nothing otherwise', async () => { + await Flags.update(1, adminUid, { + assignee: adminUid, + }); + let assignee = await db.getObjectField('flag:1', 'assignee'); + assert.strictEqual(adminUid, Number.parseInt(assignee, 10)); + + await Flags.update(1, adminUid, { + assignee: uid3, + }); + assignee = await db.getObjectField('flag:1', 'assignee'); + assert.strictEqual(adminUid, Number.parseInt(assignee, 10)); + }); + + it('should allow assignment if user is a global mod and do nothing otherwise', async () => { + await Groups.join('Global Moderators', uid3); + + await Flags.update(1, uid3, { + assignee: uid3, + }); + let assignee = await db.getObjectField('flag:1', 'assignee'); + assert.strictEqual(uid3, Number.parseInt(assignee, 10)); + + await Flags.update(1, uid3, { + assignee: uid1, + }); + assignee = await db.getObjectField('flag:1', 'assignee'); + assert.strictEqual(uid3, Number.parseInt(assignee, 10)); + + await Groups.leave('Global Moderators', uid3); + }); + + it('should allow assignment if user is a mod of the category, do nothing otherwise', async () => { + await Groups.join(`cid:${category.cid}:privileges:moderate`, uid3); + + await Flags.update(1, uid3, { + assignee: uid3, + }); + let assignee = await db.getObjectField('flag:1', 'assignee'); + assert.strictEqual(uid3, Number.parseInt(assignee, 10)); + + await Flags.update(1, uid3, { + assignee: uid1, + }); + assignee = await db.getObjectField('flag:1', 'assignee'); + assert.strictEqual(uid3, Number.parseInt(assignee, 10)); + + await Groups.leave(`cid:${category.cid}:privileges:moderate`, uid3); + }); + + it('should do nothing when you attempt to set a bogus state', async () => { + await Flags.update(1, adminUid, { + state: 'hocus pocus', + }); + + const state = await db.getObjectField('flag:1', 'state'); + assert.strictEqual('wip', state); + }); + + describe('resolve/reject', () => { + let result; + let flagObject; + beforeEach(async () => { + result = await Topics.post({ + cid: category.cid, + uid: uid3, + title: 'Topic to flag', + content: 'This is flaggable content', + }); + flagObject = await api.flags.create({uid: uid1}, {type: 'post', id: result.postData.pid, reason: 'spam'}); + await sleep(2000); + }); + + it('should rescind notification if flag is resolved', async () => { + let userNotifs = await User.notifications.getAll(adminUid); + assert(userNotifs.includes(`flag:post:${result.postData.pid}`)); + + await Flags.update(flagObject.flagId, adminUid, { + state: 'resolved', + }); + + userNotifs = await User.notifications.getAll(adminUid); + assert(!userNotifs.includes(`flag:post:${result.postData.pid}`)); + }); + + it('should rescind notification if flag is rejected', async () => { + let userNotifs = await User.notifications.getAll(adminUid); + assert(userNotifs.includes(`flag:post:${result.postData.pid}`)); + + await Flags.update(flagObject.flagId, adminUid, { + state: 'rejected', + }); + + userNotifs = await User.notifications.getAll(adminUid); + assert(!userNotifs.includes(`flag:post:${result.postData.pid}`)); + }); + + it('should do nothing if flag is resolved but ACP action is not "rescind"', async () => { + Meta.config['flags:actionOnResolve'] = ''; + + let userNotifs = await User.notifications.getAll(adminUid); + assert(userNotifs.includes(`flag:post:${result.postData.pid}`)); + + await Flags.update(flagObject.flagId, adminUid, { + state: 'resolved', + }); + + userNotifs = await User.notifications.getAll(adminUid); + assert(userNotifs.includes(`flag:post:${result.postData.pid}`)); + + delete Meta.config['flags:actionOnResolve']; + }); + + it('should do nothing if flag is rejected but ACP action is not "rescind"', async () => { + Meta.config['flags:actionOnReject'] = ''; + + let userNotifs = await User.notifications.getAll(adminUid); + assert(userNotifs.includes(`flag:post:${result.postData.pid}`)); + + await Flags.update(flagObject.flagId, adminUid, { + state: 'rejected', + }); + + userNotifs = await User.notifications.getAll(adminUid); + assert(userNotifs.includes(`flag:post:${result.postData.pid}`)); + + delete Meta.config['flags:actionOnReject']; + }); + }); + }); + + describe('.getTarget()', () => { + it('should return a post\'s data if queried with type "post"', done => { + Flags.getTarget('post', 1, 1, (error, data) => { + assert.ifError(error); + const compare = { + uid: 1, + pid: 1, + content: 'This is flaggable content', + }; + + for (const key of Object.keys(compare)) { + assert.ok(data[key]); + assert.equal(data[key], compare[key]); + } + + done(); + }); + }); + + it('should return a user\'s data if queried with type "user"', done => { + Flags.getTarget('user', 1, 1, (error, data) => { + assert.ifError(error); + const compare = { + uid: 1, + username: 'testUser', + email: 'b@c.com', + }; + + for (const key of Object.keys(compare)) { + assert.ok(data[key]); + assert.equal(data[key], compare[key]); + } + + done(); + }); + }); + + it('should return a plain object with no properties if the target no longer exists', done => { + Flags.getTarget('user', 15, 1, (error, data) => { + assert.ifError(error); + assert.strictEqual(0, Object.keys(data).length); + done(); + }); + }); + }); + + describe('.validate()', () => { + it('should error out if type is post and post is deleted', done => { + Posts.delete(1, 1, error => { + if (error) { + throw error; + } + + Flags.validate({ + type: 'post', + id: 1, + uid: 1, + }, error => { + assert.ok(error); + assert.strictEqual('[[error:post-deleted]]', error.message); + Posts.restore(1, 1, done); + }); + }); + }); + + it('should not pass validation if flag threshold is set and user rep does not meet it', done => { + Meta.configs.set('min:rep:flag', '50', error => { + assert.ifError(error); + + Flags.validate({ + type: 'post', + id: 1, + uid: 3, + }, error => { + assert.ok(error); + assert.strictEqual('[[error:not-enough-reputation-to-flag, 50]]', error.message); + Meta.configs.set('min:rep:flag', 0, done); + }); + }); + }); + + it('should not error if user blocked target', async () => { + const apiFlags = require('../src/api/flags'); + const reporterUid = await User.create({username: 'reporter'}); + const reporteeUid = await User.create({username: 'reportee'}); + await User.blocks.add(reporteeUid, reporterUid); + const data = await Topics.post({ + cid: 1, + uid: reporteeUid, + title: 'Another topic', + content: 'This is flaggable content', + }); + await apiFlags.create({uid: reporterUid}, { + type: 'post', + id: data.postData.pid, + reason: 'spam', + }); + }); + + it('should send back error if reporter does not exist', done => { + Flags.validate({uid: 123_123_123, id: 1, type: 'post'}, error => { + assert.equal(error.message, '[[error:no-user]]'); + done(); + }); + }); + }); + + describe('.appendNote()', () => { + it('should add a note to a flag', done => { + Flags.appendNote(1, 1, 'this is my note', error => { + assert.ifError(error); + + db.getSortedSetRange('flag:1:notes', 0, -1, (error, notes) => { + if (error) { + throw error; + } + + assert.strictEqual('[1,"this is my note"]', notes[0]); + setTimeout(done, 10); + }); + }); + }); + + it('should be a JSON string', done => { + db.getSortedSetRange('flag:1:notes', 0, -1, (error, notes) => { + if (error) { + throw error; + } + + try { + JSON.parse(notes[0]); + } catch (error) { + assert.ifError(error); + } + + done(); + }); + }); + + it('should insert a note in the past if a datetime is passed in', async () => { + await Flags.appendNote(1, 1, 'this is the first note', 1_626_446_956_652); + const note = (await db.getSortedSetRange('flag:1:notes', 0, 0)).pop(); + assert.strictEqual('[1,"this is the first note"]', note); + }); + }); + + describe('.getNotes()', () => { + before(done => { + // Add a second note + Flags.appendNote(1, 1, 'this is the second note', done); + }); + + it('return should match a predefined spec', done => { + Flags.getNotes(1, (error, notes) => { + assert.ifError(error); + const compare = { + uid: 1, + content: 'this is my note', + }; + + const data = notes[1]; + for (const key of Object.keys(compare)) { + assert.ok(data[key]); + assert.strictEqual(data[key], compare[key]); + } + + done(); + }); + }); + + it('should retrieve a list of notes, from newest to oldest', done => { + Flags.getNotes(1, (error, notes) => { + assert.ifError(error); + assert(notes[0].datetime > notes[1].datetime, `${notes[0].datetime}-${notes[1].datetime}`); + assert.strictEqual('this is the second note', notes[0].content); + done(); + }); + }); + }); + + describe('.appendHistory()', () => { + let entries; + before(done => { + db.sortedSetCard('flag:1:history', (error, count) => { + entries = count; + done(error); + }); + }); + + it('should add a new entry into a flag\'s history', done => { + Flags.appendHistory(1, 1, { + state: 'rejected', + }, error => { + assert.ifError(error); + + Flags.getHistory(1, (error, history) => { + if (error) { + throw error; + } + + // 1 for the new event appended, 2 for username/email change + assert.strictEqual(entries + 3, history.length); + done(); + }); + }); + }); + }); + + describe('.getHistory()', () => { + it('should retrieve a flag\'s history', done => { + Flags.getHistory(1, (error, history) => { + assert.ifError(error); + assert.strictEqual(history[0].fields.state, '[[flags:state-rejected]]'); + done(); + }); + }); + }); + + describe('(v3 API)', () => { + let pid; + let tid; + let jar; + let csrfToken; + before(async () => { + const login = await helpers.loginUser('testUser2', 'abcdef'); + jar = login.jar; + csrfToken = login.csrf_token; + + const result = await Topics.post({ + cid: 1, + uid: 1, + title: 'Another topic', + content: 'This is flaggable content', + }); + pid = result.postData.pid; + tid = result.topicData.tid; + }); + + describe('.create()', () => { + it('should create a flag with no errors', async () => { + await request({ + method: 'post', + uri: `${nconf.get('url')}/api/v3/flags`, + jar, + headers: { + 'x-csrf-token': csrfToken, + }, + body: { + type: 'post', + id: pid, + reason: 'foobar', + }, + json: true, + }); + + const exists = await Flags.exists('post', pid, 2); + assert(exists); + }); + + it('should escape flag reason', async () => { + const postData = await Topics.reply({ + tid, + uid: 1, + content: 'This is flaggable content', + }); + + const {response} = await request({ + method: 'post', + uri: `${nconf.get('url')}/api/v3/flags`, + jar, + headers: { + 'x-csrf-token': csrfToken, + }, + body: { + type: 'post', + id: postData.pid, + reason: '"', + }, + json: true, + }); + + const flagData = await Flags.get(response.flagId); + assert.strictEqual(flagData.reports[0].value, '"<script>alert('ok');</script>'); + }); + + it('should not allow flagging post in private category', async () => { + const category = await Categories.create({name: 'private category'}); + + await Privileges.categories.rescind(['groups:topics:read'], category.cid, 'registered-users'); + await Groups.join('private category', uid3); + const result = await Topics.post({ + cid: category.cid, + uid: uid3, + title: 'private topic', + content: 'private post', + }); + const login = await helpers.loginUser('unprivileged', 'abcdef'); + const jar3 = login.jar; + const config = await request({ + url: `${nconf.get('url')}/api/config`, + json: true, + jar: jar3, + }); + const csrfToken = config.csrf_token; + const {statusCode, body} = await request({ + method: 'post', + uri: `${nconf.get('url')}/api/v3/flags`, + jar: jar3, + headers: { + 'x-csrf-token': csrfToken, + }, + body: { + type: 'post', + id: result.postData.pid, + reason: 'foobar', + }, + json: true, + simple: false, + resolveWithFullResponse: true, + }); + assert.strictEqual(statusCode, 403); + + // Handle dev mode test + delete body.stack; + + assert.deepStrictEqual(body, { + status: { + code: 'forbidden', + message: 'You do not have enough privileges for this action.', + }, + response: {}, + }); + }); + }); + + describe('.update()', () => { + it('should update a flag\'s properties', async () => { + const {response} = await request({ + method: 'put', + uri: `${nconf.get('url')}/api/v3/flags/2`, + jar, + headers: { + 'x-csrf-token': csrfToken, + }, + body: { + state: 'wip', + }, + json: true, + }); + + const {history} = response; + assert(Array.isArray(history)); + assert(history[0].fields.hasOwnProperty('state')); + assert.strictEqual('[[flags:state-wip]]', history[0].fields.state); + }); + }); + + describe('.appendNote()', () => { + it('should append a note to the flag', async () => { + const {response} = await request({ + method: 'post', + uri: `${nconf.get('url')}/api/v3/flags/2/notes`, + jar, + headers: { + 'x-csrf-token': csrfToken, + }, + body: { + note: 'lorem ipsum dolor sit amet', + datetime: 1_626_446_956_652, + }, + json: true, + }); + + assert(response.hasOwnProperty('notes')); + assert(Array.isArray(response.notes)); + assert.strictEqual('lorem ipsum dolor sit amet', response.notes[0].content); + assert.strictEqual(2, response.notes[0].uid); + + assert(response.hasOwnProperty('history')); + assert(Array.isArray(response.history)); + assert.strictEqual(1, Object.keys(response.history.at(-1).fields).length); + assert(response.history.at(-1).fields.hasOwnProperty('notes')); + }); + }); + + describe('.deleteNote()', () => { + it('should delete a note from a flag', async () => { + const {response} = await request({ + method: 'delete', + uri: `${nconf.get('url')}/api/v3/flags/2/notes/1626446956652`, + jar, + headers: { + 'x-csrf-token': csrfToken, + }, + json: true, + }); + + assert(Array.isArray(response.history)); + assert(Array.isArray(response.notes)); + assert.strictEqual(response.notes.length, 0); + }); + }); + + describe('access control', () => { + let uid; + let jar; + let csrf_token; + let requests; + + let flaggerUid; + let flagId; + + const noteTime = Date.now(); + + before(async () => { + uid = await User.create({username: 'flags-access-control', password: 'abcdef'}); + ({jar, csrf_token} = await helpers.loginUser('flags-access-control', 'abcdef')); + + flaggerUid = await User.create({username: 'flags-access-control-flagger', password: 'abcdef'}); + }); + + beforeEach(async () => { + // Reset uid back to unprivileged user + await Groups.leave('administrators', uid); + await Groups.leave('Global Moderators', uid); + await Privileges.categories.rescind(['moderate'], 1, [uid]); + + const {postData} = await Topics.post({ + uid, + cid: 1, + title: utils.generateUUID(), + content: utils.generateUUID(), + }); + + ({flagId} = await Flags.create('post', postData.pid, flaggerUid, 'spam')); + requests = new Set([ + { + method: 'get', + uri: `${nconf.get('url')}/api/v3/flags/${flagId}`, + jar, + headers: { + 'x-csrf-token': csrf_token, + }, + json: true, + simple: false, + resolveWithFullResponse: true, + }, + { + method: 'put', + uri: `${nconf.get('url')}/api/v3/flags/${flagId}`, + jar, + headers: { + 'x-csrf-token': csrf_token, + }, + body: { + state: 'wip', + }, + json: true, + simple: false, + resolveWithFullResponse: true, + }, + { + method: 'post', + uri: `${nconf.get('url')}/api/v3/flags/${flagId}/notes`, + jar, + headers: { + 'x-csrf-token': csrf_token, + }, + body: { + note: 'test note', + datetime: noteTime, + }, + json: true, + simple: false, + resolveWithFullResponse: true, + }, + { + method: 'delete', + uri: `${nconf.get('url')}/api/v3/flags/${flagId}/notes/${noteTime}`, + jar, + headers: { + 'x-csrf-token': csrf_token, + }, + json: true, + simple: false, + resolveWithFullResponse: true, + }, + { + method: 'delete', + uri: `${nconf.get('url')}/api/v3/flags/${flagId}`, + jar, + headers: { + 'x-csrf-token': csrf_token, + }, + json: true, + simple: false, + resolveWithFullResponse: true, + }, + ]); + }); + + it('should not allow access to privileged flag endpoints to guests', async () => { + for (let options of requests) { + options = {...options}; + delete options.jar; + delete options.headers; + + // eslint-disable-next-line no-await-in-loop + const {statusCode} = await request(options); + assert(statusCode.toString().startsWith(4), `${options.method.toUpperCase()} ${options.uri} => ${statusCode}`); + } + }); + + it('should not allow access to privileged flag endpoints to regular users', async () => { + for (const options of requests) { + // eslint-disable-next-line no-await-in-loop + const {statusCode} = await request(options); + assert(statusCode.toString().startsWith(4), `${options.method.toUpperCase()} ${options.uri} => ${statusCode}`); + } + }); + + it('should allow access to privileged endpoints to administrators', async () => { + await Groups.join('administrators', uid); + + for (const options of requests) { + // eslint-disable-next-line no-await-in-loop + const {statusCode} = await request(options); + assert.strictEqual(statusCode, 200, `${options.method.toUpperCase()} ${options.uri} => ${statusCode}`); + } + }); + + it('should allow access to privileged endpoints to global moderators', async () => { + await Groups.join('Global Moderators', uid); + + for (const options of requests) { + // eslint-disable-next-line no-await-in-loop + const {statusCode} = await request(options); + assert.strictEqual(statusCode, 200, `${options.method.toUpperCase()} ${options.uri} => ${statusCode}`); + } + }); + + it('should allow access to privileged endpoints to moderators if the flag target is a post in a cid they moderate', async () => { + await Privileges.categories.give(['moderate'], 1, [uid]); + + for (const options of requests) { + // eslint-disable-next-line no-await-in-loop + const {statusCode} = await request(options); + assert.strictEqual(statusCode, 200, `${options.method.toUpperCase()} ${options.uri} => ${statusCode}`); + } + }); + + it('should NOT allow access to privileged endpoints to moderators if the flag target is a post in a cid they DO NOT moderate', async () => { + // This is a new category the user will moderate, but the flagged post is in a different category + const {cid} = await Categories.create({ + name: utils.generateUUID(), + }); + await Privileges.categories.give(['moderate'], cid, [uid]); + + for (const options of requests) { + // eslint-disable-next-line no-await-in-loop + const {statusCode} = await request(options); + assert(statusCode.toString().startsWith(4), `${options.method.toUpperCase()} ${options.uri} => ${statusCode}`); + } + }); + }); + }); }); diff --git a/test/groups.js b/test/groups.js index 7cffd69..f40ba7f 100644 --- a/test/groups.js +++ b/test/groups.js @@ -1,1483 +1,1503 @@ 'use strict'; -const assert = require('assert'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); const async = require('async'); -const fs = require('fs'); -const path = require('path'); const nconf = require('nconf'); - -const db = require('./mocks/databasemock'); -const helpers = require('./helpers'); const Groups = require('../src/groups'); const User = require('../src/user'); const socketGroups = require('../src/socket.io/groups'); const apiGroups = require('../src/api/groups'); const meta = require('../src/meta'); const navigation = require('../src/navigation/admin'); - +const db = require('./mocks/databasemock'); +const helpers = require('./helpers'); describe('Groups', () => { - let adminUid; - let testUid; - before(async () => { - const navData = require('../install/data/navigation.json'); - await navigation.save(navData); - - await Groups.create({ - name: 'Test', - description: 'Foobar!', - }); - - await Groups.create({ - name: 'PrivateNoJoin', - description: 'Private group', - private: 1, - disableJoinRequests: 1, - }); - - await Groups.create({ - name: 'PrivateCanJoin', - description: 'Private group', - private: 1, - disableJoinRequests: 0, - }); - - await Groups.create({ - name: 'PrivateNoLeave', - description: 'Private group', - private: 1, - disableLeave: 1, - }); - - await Groups.create({ - name: 'Global Moderators', - userTitle: 'Global Moderator', - description: 'Forum wide moderators', - hidden: 0, - private: 1, - disableJoinRequests: 1, - }); - - // Also create a hidden group - await Groups.join('Hidden', 'Test'); - // create another group that starts with test for search/sort - await Groups.create({ name: 'Test2', description: 'Foobar!' }); - - testUid = await User.create({ - username: 'testuser', - email: 'b@c.com', - }); - - adminUid = await User.create({ - username: 'admin', - email: 'admin@admin.com', - password: '123456', - }); - await Groups.join('administrators', adminUid); - }); - - describe('.list()', () => { - it('should list the groups present', (done) => { - Groups.getGroupsFromSet('groups:visible:createtime', 0, -1, (err, groups) => { - assert.ifError(err); - assert.equal(groups.length, 5); - done(); - }); - }); - }); - - describe('.get()', () => { - before((done) => { - Groups.join('Test', testUid, done); - }); - - it('with no options, should show group information', (done) => { - Groups.get('Test', {}, (err, groupObj) => { - assert.ifError(err); - assert.equal(typeof groupObj, 'object'); - assert(Array.isArray(groupObj.members)); - assert.strictEqual(groupObj.name, 'Test'); - assert.strictEqual(groupObj.description, 'Foobar!'); - assert.strictEqual(groupObj.memberCount, 1); - assert.equal(typeof groupObj.members[0], 'object'); - - done(); - }); - }); - - it('should return null if group does not exist', (done) => { - Groups.get('doesnotexist', {}, (err, groupObj) => { - assert.ifError(err); - assert.strictEqual(groupObj, null); - done(); - }); - }); - }); - - describe('.search()', () => { - const socketGroups = require('../src/socket.io/groups'); - - it('should return empty array if query is falsy', (done) => { - Groups.search(null, {}, (err, groups) => { - assert.ifError(err); - assert.equal(0, groups.length); - done(); - }); - }); - - it('should return the groups when search query is empty', (done) => { - socketGroups.search({ uid: adminUid }, { query: '' }, (err, groups) => { - assert.ifError(err); - assert.equal(5, groups.length); - done(); - }); - }); - - it('should return the "Test" group when searched for', (done) => { - socketGroups.search({ uid: adminUid }, { query: 'test' }, (err, groups) => { - assert.ifError(err); - assert.equal(2, groups.length); - assert.strictEqual('Test', groups[0].name); - done(); - }); - }); - - it('should return the "Test" group when searched for and sort by member count', (done) => { - Groups.search('test', { filterHidden: true, sort: 'count' }, (err, groups) => { - assert.ifError(err); - assert.equal(2, groups.length); - assert.strictEqual('Test', groups[0].name); - done(); - }); - }); - - it('should return the "Test" group when searched for and sort by creation time', (done) => { - Groups.search('test', { filterHidden: true, sort: 'date' }, (err, groups) => { - assert.ifError(err); - assert.equal(2, groups.length); - assert.strictEqual('Test', groups[1].name); - done(); - }); - }); - - it('should return all users if no query', (done) => { - function createAndJoinGroup(username, email, callback) { - async.waterfall([ - function (next) { - User.create({ username: username, email: email }, next); - }, - function (uid, next) { - Groups.join('Test', uid, next); - }, - ], callback); - } - async.series([ - function (next) { - createAndJoinGroup('newuser', 'newuser@b.com', next); - }, - function (next) { - createAndJoinGroup('bob', 'bob@b.com', next); - }, - ], (err) => { - assert.ifError(err); - - socketGroups.searchMembers({ uid: adminUid }, { groupName: 'Test', query: '' }, (err, data) => { - assert.ifError(err); - assert.equal(data.users.length, 3); - done(); - }); - }); - }); - - it('should search group members', (done) => { - socketGroups.searchMembers({ uid: adminUid }, { groupName: 'Test', query: 'test' }, (err, data) => { - assert.ifError(err); - assert.strictEqual('testuser', data.users[0].username); - done(); - }); - }); - - it('should not return hidden groups', async () => { - await Groups.create({ - name: 'hiddenGroup', - hidden: '1', - }); - const result = await socketGroups.search({ uid: testUid }, { query: 'hiddenGroup' }); - assert.equal(result.length, 0); - }); - }); - - describe('.isMember()', () => { - it('should return boolean true when a user is in a group', (done) => { - Groups.isMember(1, 'Test', (err, isMember) => { - assert.ifError(err); - assert.strictEqual(isMember, true); - done(); - }); - }); - - it('should return boolean false when a user is not in a group', (done) => { - Groups.isMember(2, 'Test', (err, isMember) => { - assert.ifError(err); - assert.strictEqual(isMember, false); - done(); - }); - }); - - it('should return true for uid 0 and guests group', (done) => { - Groups.isMembers([1, 0], 'guests', (err, isMembers) => { - assert.ifError(err); - assert.deepStrictEqual(isMembers, [false, true]); - done(); - }); - }); - - it('should return true for uid 0 and guests group', (done) => { - Groups.isMemberOfGroups(0, ['guests', 'registered-users'], (err, isMembers) => { - assert.ifError(err); - assert.deepStrictEqual(isMembers, [true, false]); - done(); - }); - }); - }); - - describe('.isMemberOfGroupList', () => { - it('should report that a user is part of a groupList, if they are', (done) => { - Groups.isMemberOfGroupList(1, 'Hidden', (err, isMember) => { - assert.ifError(err); - assert.strictEqual(isMember, true); - done(); - }); - }); - - it('should report that a user is not part of a groupList, if they are not', (done) => { - Groups.isMemberOfGroupList(2, 'Hidden', (err, isMember) => { - assert.ifError(err); - assert.strictEqual(isMember, false); - done(); - }); - }); - }); - - describe('.exists()', () => { - it('should verify that the test group exists', (done) => { - Groups.exists('Test', (err, exists) => { - assert.ifError(err); - assert.strictEqual(exists, true); - done(); - }); - }); - - it('should verify that a fake group does not exist', (done) => { - Groups.exists('Derp', (err, exists) => { - assert.ifError(err); - assert.strictEqual(exists, false); - done(); - }); - }); - - it('should check if group exists using an array', (done) => { - Groups.exists(['Test', 'Derp'], (err, groupsExists) => { - assert.ifError(err); - assert.strictEqual(groupsExists[0], true); - assert.strictEqual(groupsExists[1], false); - done(); - }); - }); - }); - - describe('.create()', () => { - it('should create another group', (done) => { - Groups.create({ - name: 'foo', - description: 'bar', - }, (err) => { - assert.ifError(err); - Groups.get('foo', {}, done); - }); - }); - - it('should create a hidden group if hidden is 1', (done) => { - Groups.create({ - name: 'hidden group', - hidden: '1', - }, (err) => { - assert.ifError(err); - db.isSortedSetMember('groups:visible:memberCount', 'visible group', (err, isMember) => { - assert.ifError(err); - assert(!isMember); - done(); - }); - }); - }); - - it('should create a visible group if hidden is 0', (done) => { - Groups.create({ - name: 'visible group', - hidden: '0', - }, (err) => { - assert.ifError(err); - db.isSortedSetMember('groups:visible:memberCount', 'visible group', (err, isMember) => { - assert.ifError(err); - assert(isMember); - done(); - }); - }); - }); - - it('should create a visible group if hidden is not passed in', (done) => { - Groups.create({ - name: 'visible group 2', - }, (err) => { - assert.ifError(err); - db.isSortedSetMember('groups:visible:memberCount', 'visible group 2', (err, isMember) => { - assert.ifError(err); - assert(isMember); - done(); - }); - }); - }); - - it('should fail to create group with duplicate group name', (done) => { - Groups.create({ name: 'foo' }, (err) => { - assert(err); - assert.equal(err.message, '[[error:group-already-exists]]'); - done(); - }); - }); - - it('should fail to create group if slug is empty', (done) => { - Groups.create({ name: '>>>>' }, (err) => { - assert.equal(err.message, '[[error:invalid-group-name]]'); - done(); - }); - }); - - it('should fail if group name is invalid', (done) => { - Groups.create({ name: 'not/valid' }, (err) => { - assert.equal(err.message, '[[error:invalid-group-name]]'); - done(); - }); - }); - - it('should fail if group name is invalid', (done) => { - Groups.create({ name: ['array/'] }, (err) => { - assert.equal(err.message, '[[error:invalid-group-name]]'); - done(); - }); - }); - - it('should fail if group name is invalid', async () => { - try { - await apiGroups.create({ uid: adminUid }, { name: ['test', 'administrators'] }); - } catch (err) { - return assert.equal(err.message, '[[error:invalid-group-name]]'); - } - assert(false); - }); - - it('should not create a system group', async () => { - await apiGroups.create({ uid: adminUid }, { name: 'mysystemgroup', system: true }); - const data = await Groups.getGroupData('mysystemgroup'); - assert.strictEqual(data.system, 0); - }); - - it('should fail if group name is invalid', (done) => { - Groups.create({ name: 'not:valid' }, (err) => { - assert.equal(err.message, '[[error:invalid-group-name]]'); - done(); - }); - }); - - it('should return falsy for userTitleEnabled', (done) => { - Groups.create({ name: 'userTitleEnabledGroup' }, (err) => { - assert.ifError(err); - Groups.setGroupField('userTitleEnabledGroup', 'userTitleEnabled', 0, (err) => { - assert.ifError(err); - Groups.getGroupData('userTitleEnabledGroup', (err, data) => { - assert.ifError(err); - assert.strictEqual(data.userTitleEnabled, 0); - done(); - }); - }); - }); - }); - }); - - describe('.hide()', () => { - it('should mark the group as hidden', (done) => { - Groups.hide('foo', (err) => { - assert.ifError(err); - - Groups.get('foo', {}, (err, groupObj) => { - assert.ifError(err); - assert.strictEqual(1, groupObj.hidden); - done(); - }); - }); - }); - }); - - describe('.update()', () => { - before((done) => { - Groups.create({ - name: 'updateTestGroup', - description: 'bar', - system: 0, - hidden: 0, - }, done); - }); - - it('should change an aspect of a group', (done) => { - Groups.update('updateTestGroup', { - description: 'baz', - }, (err) => { - assert.ifError(err); - - Groups.get('updateTestGroup', {}, (err, groupObj) => { - assert.ifError(err); - assert.strictEqual('baz', groupObj.description); - done(); - }); - }); - }); - - it('should rename a group and not break navigation routes', async () => { - await Groups.update('updateTestGroup', { - name: 'updateTestGroup?', - }); - - const groupObj = await Groups.get('updateTestGroup?', {}); - assert.strictEqual('updateTestGroup?', groupObj.name); - assert.strictEqual('updatetestgroup', groupObj.slug); - - const navItems = await navigation.get(); - assert.strictEqual(navItems[0].route, '/categories'); - }); - - it('should fail if system groups is being renamed', (done) => { - Groups.update('administrators', { - name: 'administrators_fail', - }, (err) => { - assert.equal(err.message, '[[error:not-allowed-to-rename-system-group]]'); - done(); - }); - }); - - it('should fail to rename if group name is invalid', async () => { - try { - await apiGroups.update({ uid: adminUid }, { slug: ['updateTestGroup?'], values: {} }); - } catch (err) { - return assert.strictEqual(err.message, '[[error:invalid-group-name]]'); - } - assert(false); - }); - - it('should fail to rename if group name is too short', async () => { - try { - const slug = await Groups.getGroupField('updateTestGroup?', 'slug'); - await apiGroups.update({ uid: adminUid }, { slug: slug, name: '' }); - } catch (err) { - return assert.strictEqual(err.message, '[[error:group-name-too-short]]'); - } - assert(false); - }); - - it('should fail to rename if group name is invalid', async () => { - try { - const slug = await Groups.getGroupField('updateTestGroup?', 'slug'); - await apiGroups.update({ uid: adminUid }, { slug: slug, name: ['invalid'] }); - } catch (err) { - return assert.strictEqual(err.message, '[[error:invalid-group-name]]'); - } - assert(false); - }); - - it('should fail to rename if group name is invalid', async () => { - try { - const slug = await Groups.getGroupField('updateTestGroup?', 'slug'); - await apiGroups.update({ uid: adminUid }, { slug: slug, name: 'cid:0:privileges:ban' }); - } catch (err) { - return assert.strictEqual(err.message, '[[error:invalid-group-name]]'); - } - assert(false); - }); - - it('should fail to rename if group name is too long', async () => { - try { - const slug = await Groups.getGroupField('updateTestGroup?', 'slug'); - await apiGroups.update({ uid: adminUid }, { slug: slug, name: 'verylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstring' }); - } catch (err) { - return assert.strictEqual(err.message, '[[error:group-name-too-long]]'); - } - assert(false); - }); - - it('should fail to rename if group name is invalid', async () => { - const slug = await Groups.getGroupField('updateTestGroup?', 'slug'); - const invalidNames = ['test:test', 'another/test', '---']; - for (const name of invalidNames) { - try { - // eslint-disable-next-line no-await-in-loop - await apiGroups.update({ uid: adminUid }, { slug: slug, name: name }); - assert(false); - } catch (err) { - assert.strictEqual(err.message, '[[error:invalid-group-name]]'); - } - } - }); - - it('should fail to rename group to an existing group', (done) => { - Groups.create({ - name: 'group2', - system: 0, - hidden: 0, - }, (err) => { - assert.ifError(err); - Groups.update('group2', { - name: 'updateTestGroup?', - }, (err) => { - assert.equal(err.message, '[[error:group-already-exists]]'); - done(); - }); - }); - }); - }); - - describe('.destroy()', () => { - before((done) => { - Groups.join('foobar?', 1, done); - }); - - it('should destroy a group', (done) => { - Groups.destroy('foobar?', (err) => { - assert.ifError(err); - - Groups.get('foobar?', {}, (err, groupObj) => { - assert.ifError(err); - assert.strictEqual(groupObj, null); - done(); - }); - }); - }); - - it('should also remove the members set', (done) => { - db.exists('group:foo:members', (err, exists) => { - assert.ifError(err); - assert.strictEqual(false, exists); - done(); - }); - }); - - it('should remove group from privilege groups', (done) => { - const privileges = require('../src/privileges'); - const cid = 1; - const groupName = '1'; - const uid = 1; - async.waterfall([ - function (next) { - Groups.create({ name: groupName }, next); - }, - function (groupData, next) { - privileges.categories.give(['groups:topics:create'], cid, groupName, next); - }, - function (next) { - Groups.isMember(groupName, 'cid:1:privileges:groups:topics:create', next); - }, - function (isMember, next) { - assert(isMember); - Groups.destroy(groupName, next); - }, - function (next) { - Groups.isMember(groupName, 'cid:1:privileges:groups:topics:create', next); - }, - function (isMember, next) { - assert(!isMember); - Groups.isMember(uid, 'registered-users', next); - }, - function (isMember, next) { - assert(isMember); - next(); - }, - ], done); - }); - }); - - describe('.join()', () => { - before((done) => { - Groups.leave('Test', testUid, done); - }); - - it('should add a user to a group', (done) => { - Groups.join('Test', testUid, (err) => { - assert.ifError(err); - - Groups.isMember(testUid, 'Test', (err, isMember) => { - assert.ifError(err); - assert.strictEqual(true, isMember); - - done(); - }); - }); - }); - - it('should fail to add user to admin group', async () => { - const oldValue = meta.config.allowPrivateGroups; - try { - meta.config.allowPrivateGroups = false; - const newUid = await User.create({ username: 'newadmin' }); - await apiGroups.join({ uid: newUid }, { slug: ['test', 'administrators'], uid: newUid }, 1); - const isMember = await Groups.isMember(newUid, 'administrators'); - assert(!isMember); - } catch (err) { - assert.strictEqual(err.message, '[[error:no-group]]'); - } - meta.config.allowPrivateGroups = oldValue; - }); - - it('should fail to add user to group if group name is invalid', (done) => { - Groups.join(0, 1, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - Groups.join(null, 1, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - Groups.join(undefined, 1, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - }); - }); - - it('should fail to add user to group if uid is invalid', (done) => { - Groups.join('Test', 0, (err) => { - assert.equal(err.message, '[[error:invalid-uid]]'); - Groups.join('Test', null, (err) => { - assert.equal(err.message, '[[error:invalid-uid]]'); - Groups.join('Test', undefined, (err) => { - assert.equal(err.message, '[[error:invalid-uid]]'); - done(); - }); - }); - }); - }); - - it('should add user to Global Moderators group', async () => { - const uid = await User.create({ username: 'glomod' }); - const slug = await Groups.getGroupField('Global Moderators', 'slug'); - await apiGroups.join({ uid: adminUid }, { slug: slug, uid: uid }); - const isGlobalMod = await User.isGlobalModerator(uid); - assert.strictEqual(isGlobalMod, true); - }); - - it('should add user to multiple groups', (done) => { - const groupNames = ['test-hidden1', 'Test', 'test-hidden2', 'empty group']; - Groups.create({ name: 'empty group' }, (err) => { - assert.ifError(err); - Groups.join(groupNames, testUid, (err) => { - assert.ifError(err); - Groups.isMemberOfGroups(testUid, groupNames, (err, isMembers) => { - assert.ifError(err); - assert(isMembers.every(Boolean)); - db.sortedSetScores('groups:visible:memberCount', groupNames, (err, memberCounts) => { - assert.ifError(err); - // hidden groups are not in "groups:visible:memberCount" so they are null - assert.deepEqual(memberCounts, [null, 3, null, 1]); - done(); - }); - }); - }); - }); - }); - - it('should set group title when user joins the group', (done) => { - const groupName = 'this will be title'; - User.create({ username: 'needstitle' }, (err, uid) => { - assert.ifError(err); - Groups.create({ name: groupName }, (err) => { - assert.ifError(err); - Groups.join([groupName], uid, (err) => { - assert.ifError(err); - User.getUserData(uid, (err, data) => { - assert.ifError(err); - assert.equal(data.groupTitle, `["${groupName}"]`); - assert.deepEqual(data.groupTitleArray, [groupName]); - done(); - }); - }); - }); - }); - }); - - it('should fail to add user to system group', async () => { - const uid = await User.create({ username: 'eviluser' }); - const oldValue = meta.config.allowPrivateGroups; - meta.config.allowPrivateGroups = 0; - async function test(groupName) { - let err; - try { - const slug = await Groups.getGroupField(groupName, 'slug'); - await apiGroups.join({ uid: uid }, { slug: slug, uid: uid }); - const isMember = await Groups.isMember(uid, groupName); - assert.strictEqual(isMember, false); - } catch (_err) { - err = _err; - } - assert.strictEqual(err.message, '[[error:not-allowed]]'); - } - const groups = ['Global Moderators', 'verified-users', 'unverified-users']; - for (const g of groups) { - // eslint-disable-next-line no-await-in-loop - await test(g); - } - meta.config.allowPrivateGroups = oldValue; - }); - - it('should allow admins to join private groups', async () => { - await apiGroups.join({ uid: adminUid }, { uid: adminUid, slug: 'global-moderators' }); - assert(await Groups.isMember(adminUid, 'Global Moderators')); - }); - }); - - describe('.leave()', () => { - it('should remove a user from a group', (done) => { - Groups.leave('Test', testUid, (err) => { - assert.ifError(err); - - Groups.isMember(testUid, 'Test', (err, isMember) => { - assert.ifError(err); - assert.strictEqual(false, isMember); - - done(); - }); - }); - }); - }); - - describe('.leaveAllGroups()', () => { - it('should remove a user from all groups', (done) => { - Groups.leaveAllGroups(testUid, (err) => { - assert.ifError(err); - - const groups = ['Test', 'Hidden']; - async.every(groups, (group, next) => { - Groups.isMember(testUid, group, (err, isMember) => { - next(err, !isMember); - }); - }, (err, result) => { - assert.ifError(err); - assert(result); - - done(); - }); - }); - }); - }); - - describe('.show()', () => { - it('should make a group visible', (done) => { - Groups.show('Test', function (err) { - assert.ifError(err); - assert.equal(arguments.length, 1); - db.isSortedSetMember('groups:visible:createtime', 'Test', (err, isMember) => { - assert.ifError(err); - assert.strictEqual(isMember, true); - done(); - }); - }); - }); - }); - - describe('.hide()', () => { - it('should make a group hidden', (done) => { - Groups.hide('Test', function (err) { - assert.ifError(err); - assert.equal(arguments.length, 1); - db.isSortedSetMember('groups:visible:createtime', 'Test', (err, isMember) => { - assert.ifError(err); - assert.strictEqual(isMember, false); - done(); - }); - }); - }); - }); - - describe('socket methods', () => { - it('should error if data is null', (done) => { - socketGroups.before({ uid: 0 }, 'groups.join', null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should not error if data is valid', (done) => { - socketGroups.before({ uid: 0 }, 'groups.join', {}, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should return error if not logged in', async () => { - try { - await apiGroups.join({ uid: 0 }, {}); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:invalid-uid]]'); - } - }); - - it('should return error if group name is special', async () => { - try { - await apiGroups.join({ uid: testUid }, { slug: 'administrators', uid: testUid }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:not-allowed]]'); - } - }); - - it('should error if group does not exist', async () => { - try { - await apiGroups.join({ uid: adminUid }, { slug: 'doesnotexist', uid: adminUid }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:no-group]]'); - } - }); - - it('should join test group', async () => { - meta.config.allowPrivateGroups = 0; - await apiGroups.join({ uid: adminUid }, { slug: 'test', uid: adminUid }); - const isMember = await Groups.isMember(adminUid, 'Test'); - assert(isMember); - }); - - it('should error if not logged in', async () => { - try { - await apiGroups.leave({ uid: 0 }, {}); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:invalid-uid]]'); - } - }); - - it('should return error if group name is special', async () => { - try { - await apiGroups.leave({ uid: adminUid }, { slug: 'administrators', uid: adminUid }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:cant-remove-self-as-admin]]'); - } - }); - - it('should leave test group', async () => { - await apiGroups.leave({ uid: adminUid }, { slug: 'test', uid: adminUid }); - const isMember = await Groups.isMember(adminUid, 'Test'); - assert(!isMember); - }); - - it('should fail to join if group is private and join requests are disabled', async () => { - meta.config.allowPrivateGroups = 1; - try { - await apiGroups.join({ uid: testUid }, { slug: 'privatenojoin', uid: testUid }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:group-join-disabled]]'); - } - }); - - it('should fail to leave if group is private and leave is disabled', async () => { - await Groups.join('PrivateNoLeave', testUid); - const isMember = await Groups.isMember(testUid, 'PrivateNoLeave'); - assert(isMember); - try { - await apiGroups.leave({ uid: testUid }, { slug: 'privatenoleave', uid: testUid }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:group-leave-disabled]]'); - } - }); - - it('should join if user is admin', async () => { - await apiGroups.join({ uid: adminUid }, { slug: 'privatecanjoin', uid: adminUid }); - const isMember = await Groups.isMember(adminUid, 'PrivateCanJoin'); - assert(isMember); - }); - - it('should request membership for regular user', async () => { - await apiGroups.join({ uid: testUid }, { slug: 'privatecanjoin', uid: testUid }); - const isPending = await Groups.isPending(testUid, 'PrivateCanJoin'); - assert(isPending); - }); - - it('should reject membership of user', (done) => { - socketGroups.reject({ uid: adminUid }, { groupName: 'PrivateCanJoin', toUid: testUid }, (err) => { - assert.ifError(err); - Groups.isInvited(testUid, 'PrivateCanJoin', (err, invited) => { - assert.ifError(err); - assert.equal(invited, false); - done(); - }); - }); - }); - - it('should error if not owner or admin', (done) => { - socketGroups.accept({ uid: 0 }, { groupName: 'PrivateCanJoin', toUid: testUid }, (err) => { - assert.equal(err.message, '[[error:no-privileges]]'); - done(); - }); - }); - - it('should accept membership of user', async () => { - await apiGroups.join({ uid: testUid }, { slug: 'privatecanjoin', uid: testUid }); - await socketGroups.accept({ uid: adminUid }, { groupName: 'PrivateCanJoin', toUid: testUid }); - const isMember = await Groups.isMember(testUid, 'PrivateCanJoin'); - assert(isMember); - }); - - it('should reject/accept all memberships requests', async () => { - async function requestMembership(uid1, uid2) { - await apiGroups.join({ uid: uid1 }, { slug: 'privatecanjoin', uid: uid1 }); - await apiGroups.join({ uid: uid2 }, { slug: 'privatecanjoin', uid: uid2 }); - } - const [uid1, uid2] = await Promise.all([ - User.create({ username: 'groupuser1' }), - User.create({ username: 'groupuser2' }), - ]); - await requestMembership(uid1, uid2); - await socketGroups.rejectAll({ uid: adminUid }, { groupName: 'PrivateCanJoin' }); - const pending = await Groups.getPending('PrivateCanJoin'); - assert.equal(pending.length, 0); - await requestMembership(uid1, uid2); - await socketGroups.acceptAll({ uid: adminUid }, { groupName: 'PrivateCanJoin' }); - const isMembers = await Groups.isMembers([uid1, uid2], 'PrivateCanJoin'); - assert.deepStrictEqual(isMembers, [true, true]); - }); - - it('should issue invite to user', (done) => { - User.create({ username: 'invite1' }, (err, uid) => { - assert.ifError(err); - socketGroups.issueInvite({ uid: adminUid }, { groupName: 'PrivateCanJoin', toUid: uid }, (err) => { - assert.ifError(err); - Groups.isInvited(uid, 'PrivateCanJoin', (err, isInvited) => { - assert.ifError(err); - assert(isInvited); - done(); - }); - }); - }); - }); - - it('should fail with invalid data', (done) => { - socketGroups.issueMassInvite({ uid: adminUid }, { groupName: 'PrivateCanJoin', usernames: null }, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should issue mass invite to users', (done) => { - User.create({ username: 'invite2' }, (err, uid) => { - assert.ifError(err); - socketGroups.issueMassInvite({ uid: adminUid }, { groupName: 'PrivateCanJoin', usernames: 'invite1, invite2' }, (err) => { - assert.ifError(err); - Groups.isInvited([adminUid, uid], 'PrivateCanJoin', (err, isInvited) => { - assert.ifError(err); - assert.deepStrictEqual(isInvited, [false, true]); - done(); - }); - }); - }); - }); - - it('should rescind invite', (done) => { - User.create({ username: 'invite3' }, (err, uid) => { - assert.ifError(err); - socketGroups.issueInvite({ uid: adminUid }, { groupName: 'PrivateCanJoin', toUid: uid }, (err) => { - assert.ifError(err); - socketGroups.rescindInvite({ uid: adminUid }, { groupName: 'PrivateCanJoin', toUid: uid }, (err) => { - assert.ifError(err); - Groups.isInvited(uid, 'PrivateCanJoin', (err, isInvited) => { - assert.ifError(err); - assert(!isInvited); - done(); - }); - }); - }); - }); - }); - - it('should error if user is not invited', (done) => { - socketGroups.acceptInvite({ uid: adminUid }, { groupName: 'PrivateCanJoin' }, (err) => { - assert.equal(err.message, '[[error:not-invited]]'); - done(); - }); - }); - - it('should accept invite', (done) => { - User.create({ username: 'invite4' }, (err, uid) => { - assert.ifError(err); - socketGroups.issueInvite({ uid: adminUid }, { groupName: 'PrivateCanJoin', toUid: uid }, (err) => { - assert.ifError(err); - socketGroups.acceptInvite({ uid: uid }, { groupName: 'PrivateCanJoin' }, (err) => { - assert.ifError(err); - Groups.isMember(uid, 'PrivateCanJoin', (err, isMember) => { - assert.ifError(err); - assert(isMember); - done(); - }); - }); - }); - }); - }); - - it('should reject invite', (done) => { - User.create({ username: 'invite5' }, (err, uid) => { - assert.ifError(err); - socketGroups.issueInvite({ uid: adminUid }, { groupName: 'PrivateCanJoin', toUid: uid }, (err) => { - assert.ifError(err); - socketGroups.rejectInvite({ uid: uid }, { groupName: 'PrivateCanJoin' }, (err) => { - assert.ifError(err); - Groups.isInvited(uid, 'PrivateCanJoin', (err, isInvited) => { - assert.ifError(err); - assert(!isInvited); - done(); - }); - }); - }); - }); - }); - - it('should grant ownership to user', async () => { - await apiGroups.grant({ uid: adminUid }, { slug: 'privatecanjoin', uid: testUid }); - const isOwner = await Groups.ownership.isOwner(testUid, 'PrivateCanJoin'); - assert(isOwner); - }); - - it('should rescind ownership from user', async () => { - await apiGroups.rescind({ uid: adminUid }, { slug: 'privatecanjoin', uid: testUid }); - const isOwner = await Groups.ownership.isOwner(testUid, 'PrivateCanJoin'); - assert(!isOwner); - }); - - it('should fail to kick user with invalid data', (done) => { - socketGroups.kick({ uid: adminUid }, { groupName: 'PrivateCanJoin', uid: adminUid }, (err) => { - assert.equal(err.message, '[[error:cant-kick-self]]'); - done(); - }); - }); - - it('should kick user from group', (done) => { - socketGroups.kick({ uid: adminUid }, { groupName: 'PrivateCanJoin', uid: testUid }, (err) => { - assert.ifError(err); - Groups.isMember(testUid, 'PrivateCanJoin', (err, isMember) => { - assert.ifError(err); - assert(!isMember); - done(); - }); - }); - }); - - it('should fail to create group with invalid data', async () => { - try { - await apiGroups.create({ uid: 0 }, {}); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:no-privileges]]'); - } - }); - - it('should fail to create group if group creation is disabled', async () => { - try { - await apiGroups.create({ uid: testUid }, { name: 'avalidname' }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:no-privileges]]'); - } - }); - - it('should fail to create group if name is privilege group', async () => { - try { - await apiGroups.create({ uid: 1 }, { name: 'cid:1:privileges:groups:find' }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:invalid-group-name]]'); - } - }); - - it('should create/update group', async () => { - const groupData = await apiGroups.create({ uid: adminUid }, { name: 'createupdategroup' }); - assert(groupData); - const data = { - slug: 'createupdategroup', - name: 'renamedupdategroup', - description: 'cat group', - userTitle: 'cats', - userTitleEnabled: 1, - disableJoinRequests: 1, - hidden: 1, - private: 0, - }; - await apiGroups.update({ uid: adminUid }, data); - const updatedData = await Groups.get('renamedupdategroup', {}); - assert.equal(updatedData.name, 'renamedupdategroup'); - assert.equal(updatedData.userTitle, 'cats'); - assert.equal(updatedData.description, 'cat group'); - assert.equal(updatedData.hidden, true); - assert.equal(updatedData.disableJoinRequests, true); - assert.equal(updatedData.private, false); - }); - - it('should fail to create a group with name guests', async () => { - try { - await apiGroups.create({ uid: adminUid }, { name: 'guests' }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:invalid-group-name]]'); - } - }); - - it('should fail to rename guests group', async () => { - const data = { - slug: 'guests', - name: 'guests2', - }; - - try { - await apiGroups.update({ uid: adminUid }, data); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:invalid-group-name]]'); - } - }); - - it('should delete group', async () => { - await apiGroups.delete({ uid: adminUid }, { slug: 'renamedupdategroup' }); - const exists = await Groups.exists('renamedupdategroup'); - assert(!exists); - }); - - it('should fail to delete group if name is special', async () => { - const specialGroups = [ - 'administrators', 'registered-users', 'verified-users', - 'unverified-users', 'global-moderators', - ]; - for (const slug of specialGroups) { - try { - // eslint-disable-next-line no-await-in-loop - await apiGroups.delete({ uid: adminUid }, { slug: slug }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:not-allowed]]'); - } - } - }); - - it('should fail to delete group if name is special', async () => { - try { - await apiGroups.delete({ uid: adminUid }, { slug: 'guests' }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:invalid-group-name]]'); - } - }); - - it('should fail to load more groups with invalid data', (done) => { - socketGroups.loadMore({ uid: adminUid }, {}, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should load more groups', (done) => { - socketGroups.loadMore({ uid: adminUid }, { after: 0, sort: 'count' }, (err, data) => { - assert.ifError(err); - assert(Array.isArray(data.groups)); - done(); - }); - }); - - it('should fail to load more members with invalid data', (done) => { - socketGroups.loadMoreMembers({ uid: adminUid }, {}, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should load more members', (done) => { - socketGroups.loadMoreMembers({ uid: adminUid }, { after: 0, groupName: 'PrivateCanJoin' }, (err, data) => { - assert.ifError(err); - assert(Array.isArray(data.users)); - done(); - }); - }); - }); - - describe('api methods', () => { - const apiGroups = require('../src/api/groups'); - it('should fail to create group with invalid data', async () => { - let err; - try { - await apiGroups.create({ uid: adminUid }, null); - } catch (_err) { - err = _err; - } - assert.strictEqual(err.message, '[[error:invalid-data]]'); - }); - - it('should fail to create group if group name is privilege group', async () => { - let err; - try { - await apiGroups.create({ uid: adminUid }, { name: 'cid:1:privileges:read' }); - } catch (_err) { - err = _err; - } - assert.strictEqual(err.message, '[[error:invalid-group-name]]'); - }); - - it('should create a group', async () => { - const groupData = await apiGroups.create({ uid: adminUid }, { name: 'newgroup', description: 'group created by admin' }); - assert.equal(groupData.name, 'newgroup'); - assert.equal(groupData.description, 'group created by admin'); - assert.equal(groupData.private, 1); - assert.equal(groupData.hidden, 0); - assert.equal(groupData.memberCount, 1); - }); - - it('should fail to join with invalid data', async () => { - let err; - try { - await apiGroups.join({ uid: adminUid }, null); - } catch (_err) { - err = _err; - } - assert.strictEqual(err.message, '[[error:invalid-data]]'); - }); - - it('should add user to group', async () => { - await apiGroups.join({ uid: adminUid }, { uid: testUid, slug: 'newgroup' }); - const isMember = await Groups.isMember(testUid, 'newgroup'); - assert(isMember); - }); - - it('should not error if user is already member', async () => { - await apiGroups.join({ uid: adminUid }, { uid: testUid, slug: 'newgroup' }); - }); - - it('it should fail with invalid data', async () => { - let err; - try { - await apiGroups.leave({ uid: adminUid }, null); - } catch (_err) { - err = _err; - } - assert.strictEqual(err.message, '[[error:invalid-data]]'); - }); - - it('it should fail if admin tries to remove self', async () => { - let err; - try { - await apiGroups.leave({ uid: adminUid }, { uid: adminUid, slug: 'administrators' }); - } catch (_err) { - err = _err; - } - assert.strictEqual(err.message, '[[error:cant-remove-self-as-admin]]'); - }); - - it('should not error if user is not member', async () => { - await apiGroups.leave({ uid: adminUid }, { uid: 3, slug: 'newgroup' }); - }); - - it('should fail if trying to remove someone else from group', async () => { - let err; - try { - await apiGroups.leave({ uid: testUid }, { uid: adminUid, slug: 'newgroup' }); - } catch (_err) { - err = _err; - } - assert.strictEqual(err.message, '[[error:no-privileges]]'); - }); - - it('should remove user from group', async () => { - await apiGroups.leave({ uid: adminUid }, { uid: testUid, slug: 'newgroup' }); - const isMember = await Groups.isMember(testUid, 'newgroup'); - assert(!isMember); - }); - - it('should fail with invalid data', async () => { - let err; - try { - await apiGroups.update({ uid: adminUid }, null); - } catch (_err) { - err = _err; - } - assert.strictEqual(err.message, '[[error:invalid-data]]'); - }); - - it('should update group', async () => { - const data = { - slug: 'newgroup', - name: 'renamedgroup', - description: 'cat group', - userTitle: 'cats', - userTitleEnabled: 1, - disableJoinRequests: 1, - hidden: 1, - private: 0, - }; - await apiGroups.update({ uid: adminUid }, data); - const groupData = await Groups.get('renamedgroup', {}); - assert.equal(groupData.name, 'renamedgroup'); - assert.equal(groupData.userTitle, 'cats'); - assert.equal(groupData.description, 'cat group'); - assert.equal(groupData.hidden, true); - assert.equal(groupData.disableJoinRequests, true); - assert.equal(groupData.private, false); - }); - }); - - describe('groups cover', () => { - const socketGroups = require('../src/socket.io/groups'); - let regularUid; - const logoPath = path.join(__dirname, '../test/files/test.png'); - const imagePath = path.join(__dirname, '../test/files/groupcover.png'); - before((done) => { - User.create({ username: 'regularuser', password: '123456' }, (err, uid) => { - assert.ifError(err); - regularUid = uid; - async.series([ - function (next) { - Groups.join('Test', adminUid, next); - }, - function (next) { - Groups.join('Test', regularUid, next); - }, - function (next) { - helpers.copyFile(logoPath, imagePath, next); - }, - ], done); - }); - }); - - it('should fail if user is not logged in or not owner', (done) => { - socketGroups.cover.update({ uid: 0 }, { imageData: 'asd' }, (err) => { - assert.equal(err.message, '[[error:no-privileges]]'); - socketGroups.cover.update({ uid: regularUid }, { groupName: 'Test', imageData: 'asd' }, (err) => { - assert.equal(err.message, '[[error:no-privileges]]'); - done(); - }); - }); - }); - - it('should upload group cover image from file', (done) => { - const data = { - groupName: 'Test', - file: { - path: imagePath, - type: 'image/png', - }, - }; - Groups.updateCover({ uid: adminUid }, data, (err, data) => { - assert.ifError(err); - Groups.getGroupFields('Test', ['cover:url'], (err, groupData) => { - assert.ifError(err); - assert.equal(nconf.get('relative_path') + data.url, groupData['cover:url']); - if (nconf.get('relative_path')) { - assert(!data.url.startsWith(nconf.get('relative_path'))); - assert(groupData['cover:url'].startsWith(nconf.get('relative_path')), groupData['cover:url']); - } - done(); - }); - }); - }); - - - it('should upload group cover image from data', (done) => { - const data = { - groupName: 'Test', - imageData: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAgCAYAAAABtRhCAAAACXBIWXMAAC4jAAAuIwF4pT92AAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADqYAAAOpgAABdvkl/FRgAACcJJREFUeNqMl9tvnNV6xn/f+s5z8DCeg88Zj+NYdhJH4KShFoJAIkzVphLVJnsDaiV6gUKaC2qQUFVATbnoValAakuQYKMqBKUUJCgI9XBBSmOROMqGoCStHbA9sWM7nrFn/I3n9B17kcwoabfarj9gvet53+d9nmdJAwMDAAgh8DyPtbU1XNfFMAwkScK2bTzPw/M8dF1/SAhxKAiCxxVF2aeqqqTr+q+Af+7o6Ch0d3f/69TU1KwkSRiGwbFjx3jmmWd47rnn+OGHH1BVFYX/5QRBkPQ87xeSJP22YRi/oapqStM0PM/D931kWSYIgnHf98cXFxepVqtomjZt2/Zf2bb990EQ4Pv+PXfeU1CSpGYhfN9/TgjxQTQaJQgCwuEwQRBQKpUwDAPTNPF9n0ajAYDv+8zPzzM+Pr6/Wq2eqdVqfxOJRA6Zpnn57hrivyEC0IQQZ4Mg+MAwDCKRCJIkUa/XEUIQi8XQNI1QKIQkSQghUBQFIQSmaTI7OwtAuVxOTE9Pfzc9Pf27lUqlBUgulUoUi0VKpRKqqg4EQfAfiqLsDIfDAC0E4XCYaDSKEALXdalUKvfM1/d9hBBYlkUul2N4eJi3335bcl33mW+++aaUz+cvSJKE8uKLL6JpGo7j8Omnn/7d+vp6sr+/HyEEjuMgyzKu6yJJEsViEVVV8TyPjY2NVisV5fZkTNMkkUhw8+ZN6vU6Kysr7Nmzh9OnT7/12GOPDS8sLByT7rQR4A9XV1d/+cILLzA9PU0kEmF4eBhFUTh//jyWZaHrOkII0uk0jUaDWq1GJpOhWCyysrLC1tYWnuehqir79+9H13W6urp48803+f7773n++ef/4G7S/H4ikUCSJNbX11trcuvWLcrlMrIs4zgODzzwABMTE/i+T7lcpq2tjUqlwubmJrZts7y8jBCCkZERGo0G2WyWkydPkkql6Onp+eMmwihwc3JyMvrWW2+RTCYBcF0XWZbRdZ3l5WX27NnD008/TSwWQ1VVyuVy63GhUIhEIkEqlcJxHCzLIhaLMTQ0xJkzZ7Btm3379lmS53kIIczZ2dnFsbGxRK1Wo729HQDP8zAMg5WVFXp7e5mcnKSzs5N8Po/rutTrdVzXbQmHrutEo1FM00RVVXp7e0kkEgRBwMWLF9F1vaxUq1UikUjtlVdeuV6pVBJ9fX3Ytn2bwrLMysoKXV1dTE5OkslksCwLTdMwDANVVdnY2CAIApLJJJFIBMdxiMfj7Nq1C1VViUajLQCvvvrqkhKJRJiZmfmdb7/99jeTySSyLLfWodFoEAqFOH78OLt37yaXy2GaJoqisLy8zNTUFFevXiUIAtrb29m5cyePPPJIa+cymQz1eh2A0dFRCoXCsgIwNTW1J5/P093dTbFYRJZlJEmiWq1y4MABxsbGqNVqhEIh6vU6QRBQLpcxDIPh4WE8z2NxcZFTp05x7tw5Xn755ZY6dXZ2tliZzWa/EwD1ev3RsbExxsfHSafTVCoVGo0Gqqqya9cuIpEIQgh832dtbY3FxUUA+vr62LZtG2NjYxw5coTDhw+ztLTEyZMnuXr1KoVC4R4d3bt375R84sQJEY/H/2Jubq7N9326urqwbZt6vY5pmhw5coS+vr4W9YvFIrdu3WJqagohBFeuXOHcuXOtue7evRtN01rtfO+991haWmJkZGQrkUi8JIC9iqL0BkFAIpFACMETTzxBV1cXiUSC7u5uHMfB8zyCIMA0TeLxONlsFlmW8X2fwcFBHMdhfn6eer1Oe3s7Dz30EBMTE1y6dImjR49y6tSppR07dqwrjuM8+OWXXzI0NMTly5e5du0aQ0NDTExMkMvlCIKAIAhaIh2LxQiHw0QiEfL5POl0mlqtRq1Wo6OjA8uykGWZdDrN0tISvb29vPPOOzz++OPk83lELpf7rXfffRfDMOjo6MBxHEqlEocOHWLHjh00Gg0kSULTNIS4bS6qqhKPxxkaGmJ4eJjR0VH279/PwMAA27dvJ5vN4vs+X331FR9//DGzs7OEQiE++eQTlPb29keuX7/OtWvXOH78ONVqlZs3b9LW1kYmk8F13dZeCiGQJAnXdRFCYBgGsiwjhMC2bQqFAkEQoOs6P/74Iw8++CCDg4Pous6xY8f47LPPkIIguDo2Nrbzxo0bfPjhh9i2zczMTHNvcF2XpsZalkWj0cB1Xe4o1O3YoCisra3x008/EY/H6erqAuDAgQNEIhGCIODQoUP/ubCwMCKAjx599FHW19f56KOP6OjooFgsks/niUajKIqCbds4joMQAiFESxxs226xd2Zmhng8Tl9fH67r0mg0sG2bbDZLpVIhl8vd5gHwtysrKy8Dcdd1mZubo6enh1gsRrVabZlrk6VND/R9n3q9TqVSQdd1QqEQi4uLnD9/nlKpxODgIHv37gXAcRyCICiFQiHEzp07i1988cUfKYpCIpHANE22b9/eUhNFUVotDIKghc7zPCzLolKpsLW1RVtbG0EQ4DgOmqbR09NDM1qUSiWAPwdQ7ujjmf7+/kQymfxrSZJQVZWtra2WG+i63iKH53m4rku1WqVcLmNZFu3t7S2x7+/vJ51O89prr7VYfenSpcPAP1UqFeSHH36YeDxOKpW6eP/9988Bv9d09nw+T7VapVKptJjZnE2tVmNtbY1cLke5XGZra4vNzU16enp49tlnGRgYaD7iTxqNxgexWIzDhw+jNEPQHV87NT8/f+PChQtnR0ZGqFarrUVuOsDds2u2b2FhgVQqRSQSYWFhgStXrtDf308ymcwBf3nw4EEOHjx4O5c2lURVVRzHYXp6+t8uX7785IULFz7LZDLous59991HOBy+h31N9xgdHSWTyVCtVhkaGmLfvn1MT08zPz/PzMzM6c8//9xr+uE9QViWZer1OhsbGxiG8fns7OzPc7ncx729vXR3d1OpVNi2bRuhUAhZljEMA9/3sW0bVVVZWlri4sWLjI+P8/rrr/P111/z5JNPXrIs69cn76ZeGoaBpmm0tbX9Q6FQeHhubu7fC4UCkUiE1dVVstks8Xgc0zSRZZlGo9ESAdM02djYoNFo8MYbb2BZ1mYoFOKuZPjr/xZBEHCHred83x/b3Nz8l/X19aRlWWxsbNDZ2cnw8DDhcBjf96lWq/T09HD06FGeeuopXnrpJc6ePUs6nb4hhPi/C959ZFn+TtO0lG3bJ0ql0p85jsPW1haFQoG2tjYkSWpF/Uwmw9raGu+//z7A977vX2+GrP93wSZiTdNOGIbxy3K5/DPHcfYXCoVe27Yzpmm2m6bppVKp/Orqqnv69OmoZVn/mEwm/9TzvP9x138NAMpJ4VFTBr6SAAAAAElFTkSuQmCC', - }; - socketGroups.cover.update({ uid: adminUid }, data, (err, data) => { - assert.ifError(err); - Groups.getGroupFields('Test', ['cover:url'], (err, groupData) => { - assert.ifError(err); - assert.equal(nconf.get('relative_path') + data.url, groupData['cover:url']); - done(); - }); - }); - }); - - it('should fail to upload group cover with invalid image', (done) => { - const data = { - groupName: 'Test', - file: { - path: imagePath, - type: 'image/png', - }, - }; - socketGroups.cover.update({ uid: adminUid }, data, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should fail to upload group cover with invalid image', (done) => { - const data = { - groupName: 'Test', - imageData: 'data:image/svg;base64,iVBORw0KGgoAAAANSUhEUgAAABwA', - }; - socketGroups.cover.update({ uid: adminUid }, data, (err, data) => { - assert.equal(err.message, '[[error:invalid-image]]'); - done(); - }); - }); - - it('should update group cover position', (done) => { - const data = { - groupName: 'Test', - position: '50% 50%', - }; - socketGroups.cover.update({ uid: adminUid }, data, (err) => { - assert.ifError(err); - Groups.getGroupFields('Test', ['cover:position'], (err, groupData) => { - assert.ifError(err); - assert.equal('50% 50%', groupData['cover:position']); - done(); - }); - }); - }); - - it('should fail to update cover position if group name is missing', (done) => { - Groups.updateCoverPosition('', '50% 50%', (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should fail to remove cover if not logged in', (done) => { - socketGroups.cover.remove({ uid: 0 }, { groupName: 'Test' }, (err) => { - assert.equal(err.message, '[[error:no-privileges]]'); - done(); - }); - }); - - it('should fail to remove cover if not owner', (done) => { - socketGroups.cover.remove({ uid: regularUid }, { groupName: 'Test' }, (err) => { - assert.equal(err.message, '[[error:no-privileges]]'); - done(); - }); - }); - - it('should remove cover', async () => { - const fields = ['cover:url', 'cover:thumb:url']; - const values = await Groups.getGroupFields('Test', fields); - await socketGroups.cover.remove({ uid: adminUid }, { groupName: 'Test' }); - - fields.forEach((field) => { - const filename = values[field].split('/').pop(); - const filePath = path.join(nconf.get('upload_path'), 'files', filename); - assert.strictEqual(fs.existsSync(filePath), false); - }); - - const groupData = await db.getObjectFields('group:Test', ['cover:url']); - assert(!groupData['cover:url']); - }); - }); + let adminUid; + let testUid; + before(async () => { + const navData = require('../install/data/navigation.json'); + await navigation.save(navData); + + await Groups.create({ + name: 'Test', + description: 'Foobar!', + }); + + await Groups.create({ + name: 'PrivateNoJoin', + description: 'Private group', + private: 1, + disableJoinRequests: 1, + }); + + await Groups.create({ + name: 'PrivateCanJoin', + description: 'Private group', + private: 1, + disableJoinRequests: 0, + }); + + await Groups.create({ + name: 'PrivateNoLeave', + description: 'Private group', + private: 1, + disableLeave: 1, + }); + + await Groups.create({ + name: 'Global Moderators', + userTitle: 'Global Moderator', + description: 'Forum wide moderators', + hidden: 0, + private: 1, + disableJoinRequests: 1, + }); + + // Also create a hidden group + await Groups.join('Hidden', 'Test'); + // Create another group that starts with test for search/sort + await Groups.create({name: 'Test2', description: 'Foobar!'}); + + testUid = await User.create({ + username: 'testuser', + email: 'b@c.com', + }); + + adminUid = await User.create({ + username: 'admin', + email: 'admin@admin.com', + password: '123456', + }); + await Groups.join('administrators', adminUid); + }); + + describe('.list()', () => { + it('should list the groups present', done => { + Groups.getGroupsFromSet('groups:visible:createtime', 0, -1, (error, groups) => { + assert.ifError(error); + assert.equal(groups.length, 5); + done(); + }); + }); + }); + + describe('.get()', () => { + before(done => { + Groups.join('Test', testUid, done); + }); + + it('with no options, should show group information', done => { + Groups.get('Test', {}, (error, groupObject) => { + assert.ifError(error); + assert.equal(typeof groupObject, 'object'); + assert(Array.isArray(groupObject.members)); + assert.strictEqual(groupObject.name, 'Test'); + assert.strictEqual(groupObject.description, 'Foobar!'); + assert.strictEqual(groupObject.memberCount, 1); + assert.equal(typeof groupObject.members[0], 'object'); + + done(); + }); + }); + + it('should return null if group does not exist', done => { + Groups.get('doesnotexist', {}, (error, groupObject) => { + assert.ifError(error); + assert.strictEqual(groupObject, null); + done(); + }); + }); + }); + + describe('.search()', () => { + const socketGroups = require('../src/socket.io/groups'); + + it('should return empty array if query is falsy', done => { + Groups.search(null, {}, (error, groups) => { + assert.ifError(error); + assert.equal(0, groups.length); + done(); + }); + }); + + it('should return the groups when search query is empty', done => { + socketGroups.search({uid: adminUid}, {query: ''}, (error, groups) => { + assert.ifError(error); + assert.equal(5, groups.length); + done(); + }); + }); + + it('should return the "Test" group when searched for', done => { + socketGroups.search({uid: adminUid}, {query: 'test'}, (error, groups) => { + assert.ifError(error); + assert.equal(2, groups.length); + assert.strictEqual('Test', groups[0].name); + done(); + }); + }); + + it('should return the "Test" group when searched for and sort by member count', done => { + Groups.search('test', {filterHidden: true, sort: 'count'}, (error, groups) => { + assert.ifError(error); + assert.equal(2, groups.length); + assert.strictEqual('Test', groups[0].name); + done(); + }); + }); + + it('should return the "Test" group when searched for and sort by creation time', done => { + Groups.search('test', {filterHidden: true, sort: 'date'}, (error, groups) => { + assert.ifError(error); + assert.equal(2, groups.length); + assert.strictEqual('Test', groups[1].name); + done(); + }); + }); + + it('should return all users if no query', done => { + function createAndJoinGroup(username, email, callback) { + async.waterfall([ + function (next) { + User.create({username, email}, next); + }, + function (uid, next) { + Groups.join('Test', uid, next); + }, + ], callback); + } + + async.series([ + function (next) { + createAndJoinGroup('newuser', 'newuser@b.com', next); + }, + function (next) { + createAndJoinGroup('bob', 'bob@b.com', next); + }, + ], error => { + assert.ifError(error); + + socketGroups.searchMembers({uid: adminUid}, {groupName: 'Test', query: ''}, (error, data) => { + assert.ifError(error); + assert.equal(data.users.length, 3); + done(); + }); + }); + }); + + it('should search group members', done => { + socketGroups.searchMembers({uid: adminUid}, {groupName: 'Test', query: 'test'}, (error, data) => { + assert.ifError(error); + assert.strictEqual('testuser', data.users[0].username); + done(); + }); + }); + + it('should not return hidden groups', async () => { + await Groups.create({ + name: 'hiddenGroup', + hidden: '1', + }); + const result = await socketGroups.search({uid: testUid}, {query: 'hiddenGroup'}); + assert.equal(result.length, 0); + }); + }); + + describe('.isMember()', () => { + it('should return boolean true when a user is in a group', done => { + Groups.isMember(1, 'Test', (error, isMember) => { + assert.ifError(error); + assert.strictEqual(isMember, true); + done(); + }); + }); + + it('should return boolean false when a user is not in a group', done => { + Groups.isMember(2, 'Test', (error, isMember) => { + assert.ifError(error); + assert.strictEqual(isMember, false); + done(); + }); + }); + + it('should return true for uid 0 and guests group', done => { + Groups.isMembers([1, 0], 'guests', (error, isMembers) => { + assert.ifError(error); + assert.deepStrictEqual(isMembers, [false, true]); + done(); + }); + }); + + it('should return true for uid 0 and guests group', done => { + Groups.isMemberOfGroups(0, ['guests', 'registered-users'], (error, isMembers) => { + assert.ifError(error); + assert.deepStrictEqual(isMembers, [true, false]); + done(); + }); + }); + }); + + describe('.isMemberOfGroupList', () => { + it('should report that a user is part of a groupList, if they are', done => { + Groups.isMemberOfGroupList(1, 'Hidden', (error, isMember) => { + assert.ifError(error); + assert.strictEqual(isMember, true); + done(); + }); + }); + + it('should report that a user is not part of a groupList, if they are not', done => { + Groups.isMemberOfGroupList(2, 'Hidden', (error, isMember) => { + assert.ifError(error); + assert.strictEqual(isMember, false); + done(); + }); + }); + }); + + describe('.exists()', () => { + it('should verify that the test group exists', done => { + Groups.exists('Test', (error, exists) => { + assert.ifError(error); + assert.strictEqual(exists, true); + done(); + }); + }); + + it('should verify that a fake group does not exist', done => { + Groups.exists('Derp', (error, exists) => { + assert.ifError(error); + assert.strictEqual(exists, false); + done(); + }); + }); + + it('should check if group exists using an array', done => { + Groups.exists(['Test', 'Derp'], (error, groupsExists) => { + assert.ifError(error); + assert.strictEqual(groupsExists[0], true); + assert.strictEqual(groupsExists[1], false); + done(); + }); + }); + }); + + describe('.create()', () => { + it('should create another group', done => { + Groups.create({ + name: 'foo', + description: 'bar', + }, error => { + assert.ifError(error); + Groups.get('foo', {}, done); + }); + }); + + it('should create a hidden group if hidden is 1', done => { + Groups.create({ + name: 'hidden group', + hidden: '1', + }, error => { + assert.ifError(error); + db.isSortedSetMember('groups:visible:memberCount', 'visible group', (error, isMember) => { + assert.ifError(error); + assert(!isMember); + done(); + }); + }); + }); + + it('should create a visible group if hidden is 0', done => { + Groups.create({ + name: 'visible group', + hidden: '0', + }, error => { + assert.ifError(error); + db.isSortedSetMember('groups:visible:memberCount', 'visible group', (error, isMember) => { + assert.ifError(error); + assert(isMember); + done(); + }); + }); + }); + + it('should create a visible group if hidden is not passed in', done => { + Groups.create({ + name: 'visible group 2', + }, error => { + assert.ifError(error); + db.isSortedSetMember('groups:visible:memberCount', 'visible group 2', (error, isMember) => { + assert.ifError(error); + assert(isMember); + done(); + }); + }); + }); + + it('should fail to create group with duplicate group name', done => { + Groups.create({name: 'foo'}, error => { + assert(error); + assert.equal(error.message, '[[error:group-already-exists]]'); + done(); + }); + }); + + it('should fail to create group if slug is empty', done => { + Groups.create({name: '>>>>'}, error => { + assert.equal(error.message, '[[error:invalid-group-name]]'); + done(); + }); + }); + + it('should fail if group name is invalid', done => { + Groups.create({name: 'not/valid'}, error => { + assert.equal(error.message, '[[error:invalid-group-name]]'); + done(); + }); + }); + + it('should fail if group name is invalid', done => { + Groups.create({name: ['array/']}, error => { + assert.equal(error.message, '[[error:invalid-group-name]]'); + done(); + }); + }); + + it('should fail if group name is invalid', async () => { + try { + await apiGroups.create({uid: adminUid}, {name: ['test', 'administrators']}); + } catch (error) { + return assert.equal(error.message, '[[error:invalid-group-name]]'); + } + + assert(false); + }); + + it('should not create a system group', async () => { + await apiGroups.create({uid: adminUid}, {name: 'mysystemgroup', system: true}); + const data = await Groups.getGroupData('mysystemgroup'); + assert.strictEqual(data.system, 0); + }); + + it('should fail if group name is invalid', done => { + Groups.create({name: 'not:valid'}, error => { + assert.equal(error.message, '[[error:invalid-group-name]]'); + done(); + }); + }); + + it('should return falsy for userTitleEnabled', done => { + Groups.create({name: 'userTitleEnabledGroup'}, error => { + assert.ifError(error); + Groups.setGroupField('userTitleEnabledGroup', 'userTitleEnabled', 0, error => { + assert.ifError(error); + Groups.getGroupData('userTitleEnabledGroup', (error, data) => { + assert.ifError(error); + assert.strictEqual(data.userTitleEnabled, 0); + done(); + }); + }); + }); + }); + }); + + describe('.hide()', () => { + it('should mark the group as hidden', done => { + Groups.hide('foo', error => { + assert.ifError(error); + + Groups.get('foo', {}, (error, groupObject) => { + assert.ifError(error); + assert.strictEqual(1, groupObject.hidden); + done(); + }); + }); + }); + }); + + describe('.update()', () => { + before(done => { + Groups.create({ + name: 'updateTestGroup', + description: 'bar', + system: 0, + hidden: 0, + }, done); + }); + + it('should change an aspect of a group', done => { + Groups.update('updateTestGroup', { + description: 'baz', + }, error => { + assert.ifError(error); + + Groups.get('updateTestGroup', {}, (error, groupObject) => { + assert.ifError(error); + assert.strictEqual('baz', groupObject.description); + done(); + }); + }); + }); + + it('should rename a group and not break navigation routes', async () => { + await Groups.update('updateTestGroup', { + name: 'updateTestGroup?', + }); + + const groupObject = await Groups.get('updateTestGroup?', {}); + assert.strictEqual('updateTestGroup?', groupObject.name); + assert.strictEqual('updatetestgroup', groupObject.slug); + + const navItems = await navigation.get(); + assert.strictEqual(navItems[0].route, '/categories'); + }); + + it('should fail if system groups is being renamed', done => { + Groups.update('administrators', { + name: 'administrators_fail', + }, error => { + assert.equal(error.message, '[[error:not-allowed-to-rename-system-group]]'); + done(); + }); + }); + + it('should fail to rename if group name is invalid', async () => { + try { + await apiGroups.update({uid: adminUid}, {slug: ['updateTestGroup?'], values: {}}); + } catch (error) { + return assert.strictEqual(error.message, '[[error:invalid-group-name]]'); + } + + assert(false); + }); + + it('should fail to rename if group name is too short', async () => { + try { + const slug = await Groups.getGroupField('updateTestGroup?', 'slug'); + await apiGroups.update({uid: adminUid}, {slug, name: ''}); + } catch (error) { + return assert.strictEqual(error.message, '[[error:group-name-too-short]]'); + } + + assert(false); + }); + + it('should fail to rename if group name is invalid', async () => { + try { + const slug = await Groups.getGroupField('updateTestGroup?', 'slug'); + await apiGroups.update({uid: adminUid}, {slug, name: ['invalid']}); + } catch (error) { + return assert.strictEqual(error.message, '[[error:invalid-group-name]]'); + } + + assert(false); + }); + + it('should fail to rename if group name is invalid', async () => { + try { + const slug = await Groups.getGroupField('updateTestGroup?', 'slug'); + await apiGroups.update({uid: adminUid}, {slug, name: 'cid:0:privileges:ban'}); + } catch (error) { + return assert.strictEqual(error.message, '[[error:invalid-group-name]]'); + } + + assert(false); + }); + + it('should fail to rename if group name is too long', async () => { + try { + const slug = await Groups.getGroupField('updateTestGroup?', 'slug'); + await apiGroups.update({uid: adminUid}, {slug, name: 'verylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstringverylongstring'}); + } catch (error) { + return assert.strictEqual(error.message, '[[error:group-name-too-long]]'); + } + + assert(false); + }); + + it('should fail to rename if group name is invalid', async () => { + const slug = await Groups.getGroupField('updateTestGroup?', 'slug'); + const invalidNames = ['test:test', 'another/test', '---']; + for (const name of invalidNames) { + try { + // eslint-disable-next-line no-await-in-loop + await apiGroups.update({uid: adminUid}, {slug, name}); + assert(false); + } catch (error) { + assert.strictEqual(error.message, '[[error:invalid-group-name]]'); + } + } + }); + + it('should fail to rename group to an existing group', done => { + Groups.create({ + name: 'group2', + system: 0, + hidden: 0, + }, error => { + assert.ifError(error); + Groups.update('group2', { + name: 'updateTestGroup?', + }, error => { + assert.equal(error.message, '[[error:group-already-exists]]'); + done(); + }); + }); + }); + }); + + describe('.destroy()', () => { + before(done => { + Groups.join('foobar?', 1, done); + }); + + it('should destroy a group', done => { + Groups.destroy('foobar?', error => { + assert.ifError(error); + + Groups.get('foobar?', {}, (error, groupObject) => { + assert.ifError(error); + assert.strictEqual(groupObject, null); + done(); + }); + }); + }); + + it('should also remove the members set', done => { + db.exists('group:foo:members', (error, exists) => { + assert.ifError(error); + assert.strictEqual(false, exists); + done(); + }); + }); + + it('should remove group from privilege groups', done => { + const privileges = require('../src/privileges'); + const cid = 1; + const groupName = '1'; + const uid = 1; + async.waterfall([ + function (next) { + Groups.create({name: groupName}, next); + }, + function (groupData, next) { + privileges.categories.give(['groups:topics:create'], cid, groupName, next); + }, + function (next) { + Groups.isMember(groupName, 'cid:1:privileges:groups:topics:create', next); + }, + function (isMember, next) { + assert(isMember); + Groups.destroy(groupName, next); + }, + function (next) { + Groups.isMember(groupName, 'cid:1:privileges:groups:topics:create', next); + }, + function (isMember, next) { + assert(!isMember); + Groups.isMember(uid, 'registered-users', next); + }, + function (isMember, next) { + assert(isMember); + next(); + }, + ], done); + }); + }); + + describe('.join()', () => { + before(done => { + Groups.leave('Test', testUid, done); + }); + + it('should add a user to a group', done => { + Groups.join('Test', testUid, error => { + assert.ifError(error); + + Groups.isMember(testUid, 'Test', (error, isMember) => { + assert.ifError(error); + assert.strictEqual(true, isMember); + + done(); + }); + }); + }); + + it('should fail to add user to admin group', async () => { + const oldValue = meta.config.allowPrivateGroups; + try { + meta.config.allowPrivateGroups = false; + const newUid = await User.create({username: 'newadmin'}); + await apiGroups.join({uid: newUid}, {slug: ['test', 'administrators'], uid: newUid}, 1); + const isMember = await Groups.isMember(newUid, 'administrators'); + assert(!isMember); + } catch (error) { + assert.strictEqual(error.message, '[[error:no-group]]'); + } + + meta.config.allowPrivateGroups = oldValue; + }); + + it('should fail to add user to group if group name is invalid', done => { + Groups.join(0, 1, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + Groups.join(null, 1, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + Groups.join(undefined, 1, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + }); + }); + + it('should fail to add user to group if uid is invalid', done => { + Groups.join('Test', 0, error => { + assert.equal(error.message, '[[error:invalid-uid]]'); + Groups.join('Test', null, error => { + assert.equal(error.message, '[[error:invalid-uid]]'); + Groups.join('Test', undefined, error => { + assert.equal(error.message, '[[error:invalid-uid]]'); + done(); + }); + }); + }); + }); + + it('should add user to Global Moderators group', async () => { + const uid = await User.create({username: 'glomod'}); + const slug = await Groups.getGroupField('Global Moderators', 'slug'); + await apiGroups.join({uid: adminUid}, {slug, uid}); + const isGlobalModule = await User.isGlobalModerator(uid); + assert.strictEqual(isGlobalModule, true); + }); + + it('should add user to multiple groups', done => { + const groupNames = ['test-hidden1', 'Test', 'test-hidden2', 'empty group']; + Groups.create({name: 'empty group'}, error => { + assert.ifError(error); + Groups.join(groupNames, testUid, error => { + assert.ifError(error); + Groups.isMemberOfGroups(testUid, groupNames, (error, isMembers) => { + assert.ifError(error); + assert(isMembers.every(Boolean)); + db.sortedSetScores('groups:visible:memberCount', groupNames, (error, memberCounts) => { + assert.ifError(error); + // Hidden groups are not in "groups:visible:memberCount" so they are null + assert.deepEqual(memberCounts, [null, 3, null, 1]); + done(); + }); + }); + }); + }); + }); + + it('should set group title when user joins the group', done => { + const groupName = 'this will be title'; + User.create({username: 'needstitle'}, (error, uid) => { + assert.ifError(error); + Groups.create({name: groupName}, error_ => { + assert.ifError(error_); + Groups.join([groupName], uid, error_ => { + assert.ifError(error_); + User.getUserData(uid, (error, data) => { + assert.ifError(error); + assert.equal(data.groupTitle, `["${groupName}"]`); + assert.deepEqual(data.groupTitleArray, [groupName]); + done(); + }); + }); + }); + }); + }); + + it('should fail to add user to system group', async () => { + const uid = await User.create({username: 'eviluser'}); + const oldValue = meta.config.allowPrivateGroups; + meta.config.allowPrivateGroups = 0; + async function test(groupName) { + let error; + try { + const slug = await Groups.getGroupField(groupName, 'slug'); + await apiGroups.join({uid}, {slug, uid}); + const isMember = await Groups.isMember(uid, groupName); + assert.strictEqual(isMember, false); + } catch (error_) { + error = error_; + } + + assert.strictEqual(error.message, '[[error:not-allowed]]'); + } + + const groups = ['Global Moderators', 'verified-users', 'unverified-users']; + for (const g of groups) { + // eslint-disable-next-line no-await-in-loop + await test(g); + } + + meta.config.allowPrivateGroups = oldValue; + }); + + it('should allow admins to join private groups', async () => { + await apiGroups.join({uid: adminUid}, {uid: adminUid, slug: 'global-moderators'}); + assert(await Groups.isMember(adminUid, 'Global Moderators')); + }); + }); + + describe('.leave()', () => { + it('should remove a user from a group', done => { + Groups.leave('Test', testUid, error => { + assert.ifError(error); + + Groups.isMember(testUid, 'Test', (error, isMember) => { + assert.ifError(error); + assert.strictEqual(false, isMember); + + done(); + }); + }); + }); + }); + + describe('.leaveAllGroups()', () => { + it('should remove a user from all groups', done => { + Groups.leaveAllGroups(testUid, error => { + assert.ifError(error); + + const groups = ['Test', 'Hidden']; + async.every(groups, (group, next) => { + Groups.isMember(testUid, group, (error, isMember) => { + next(error, !isMember); + }); + }, (error, result) => { + assert.ifError(error); + assert(result); + + done(); + }); + }); + }); + }); + + describe('.show()', () => { + it('should make a group visible', done => { + Groups.show('Test', function (error) { + assert.ifError(error); + assert.equal(arguments.length, 1); + db.isSortedSetMember('groups:visible:createtime', 'Test', (error, isMember) => { + assert.ifError(error); + assert.strictEqual(isMember, true); + done(); + }); + }); + }); + }); + + describe('.hide()', () => { + it('should make a group hidden', done => { + Groups.hide('Test', function (error) { + assert.ifError(error); + assert.equal(arguments.length, 1); + db.isSortedSetMember('groups:visible:createtime', 'Test', (error, isMember) => { + assert.ifError(error); + assert.strictEqual(isMember, false); + done(); + }); + }); + }); + }); + + describe('socket methods', () => { + it('should error if data is null', done => { + socketGroups.before({uid: 0}, 'groups.join', null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should not error if data is valid', done => { + socketGroups.before({uid: 0}, 'groups.join', {}, error => { + assert.ifError(error); + done(); + }); + }); + + it('should return error if not logged in', async () => { + try { + await apiGroups.join({uid: 0}, {}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:invalid-uid]]'); + } + }); + + it('should return error if group name is special', async () => { + try { + await apiGroups.join({uid: testUid}, {slug: 'administrators', uid: testUid}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:not-allowed]]'); + } + }); + + it('should error if group does not exist', async () => { + try { + await apiGroups.join({uid: adminUid}, {slug: 'doesnotexist', uid: adminUid}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:no-group]]'); + } + }); + + it('should join test group', async () => { + meta.config.allowPrivateGroups = 0; + await apiGroups.join({uid: adminUid}, {slug: 'test', uid: adminUid}); + const isMember = await Groups.isMember(adminUid, 'Test'); + assert(isMember); + }); + + it('should error if not logged in', async () => { + try { + await apiGroups.leave({uid: 0}, {}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:invalid-uid]]'); + } + }); + + it('should return error if group name is special', async () => { + try { + await apiGroups.leave({uid: adminUid}, {slug: 'administrators', uid: adminUid}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:cant-remove-self-as-admin]]'); + } + }); + + it('should leave test group', async () => { + await apiGroups.leave({uid: adminUid}, {slug: 'test', uid: adminUid}); + const isMember = await Groups.isMember(adminUid, 'Test'); + assert(!isMember); + }); + + it('should fail to join if group is private and join requests are disabled', async () => { + meta.config.allowPrivateGroups = 1; + try { + await apiGroups.join({uid: testUid}, {slug: 'privatenojoin', uid: testUid}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:group-join-disabled]]'); + } + }); + + it('should fail to leave if group is private and leave is disabled', async () => { + await Groups.join('PrivateNoLeave', testUid); + const isMember = await Groups.isMember(testUid, 'PrivateNoLeave'); + assert(isMember); + try { + await apiGroups.leave({uid: testUid}, {slug: 'privatenoleave', uid: testUid}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:group-leave-disabled]]'); + } + }); + + it('should join if user is admin', async () => { + await apiGroups.join({uid: adminUid}, {slug: 'privatecanjoin', uid: adminUid}); + const isMember = await Groups.isMember(adminUid, 'PrivateCanJoin'); + assert(isMember); + }); + + it('should request membership for regular user', async () => { + await apiGroups.join({uid: testUid}, {slug: 'privatecanjoin', uid: testUid}); + const isPending = await Groups.isPending(testUid, 'PrivateCanJoin'); + assert(isPending); + }); + + it('should reject membership of user', done => { + socketGroups.reject({uid: adminUid}, {groupName: 'PrivateCanJoin', toUid: testUid}, error => { + assert.ifError(error); + Groups.isInvited(testUid, 'PrivateCanJoin', (error, invited) => { + assert.ifError(error); + assert.equal(invited, false); + done(); + }); + }); + }); + + it('should error if not owner or admin', done => { + socketGroups.accept({uid: 0}, {groupName: 'PrivateCanJoin', toUid: testUid}, error => { + assert.equal(error.message, '[[error:no-privileges]]'); + done(); + }); + }); + + it('should accept membership of user', async () => { + await apiGroups.join({uid: testUid}, {slug: 'privatecanjoin', uid: testUid}); + await socketGroups.accept({uid: adminUid}, {groupName: 'PrivateCanJoin', toUid: testUid}); + const isMember = await Groups.isMember(testUid, 'PrivateCanJoin'); + assert(isMember); + }); + + it('should reject/accept all memberships requests', async () => { + async function requestMembership(uid1, uid2) { + await apiGroups.join({uid: uid1}, {slug: 'privatecanjoin', uid: uid1}); + await apiGroups.join({uid: uid2}, {slug: 'privatecanjoin', uid: uid2}); + } + + const [uid1, uid2] = await Promise.all([ + User.create({username: 'groupuser1'}), + User.create({username: 'groupuser2'}), + ]); + await requestMembership(uid1, uid2); + await socketGroups.rejectAll({uid: adminUid}, {groupName: 'PrivateCanJoin'}); + const pending = await Groups.getPending('PrivateCanJoin'); + assert.equal(pending.length, 0); + await requestMembership(uid1, uid2); + await socketGroups.acceptAll({uid: adminUid}, {groupName: 'PrivateCanJoin'}); + const isMembers = await Groups.isMembers([uid1, uid2], 'PrivateCanJoin'); + assert.deepStrictEqual(isMembers, [true, true]); + }); + + it('should issue invite to user', done => { + User.create({username: 'invite1'}, (error, uid) => { + assert.ifError(error); + socketGroups.issueInvite({uid: adminUid}, {groupName: 'PrivateCanJoin', toUid: uid}, error_ => { + assert.ifError(error_); + Groups.isInvited(uid, 'PrivateCanJoin', (error, isInvited) => { + assert.ifError(error); + assert(isInvited); + done(); + }); + }); + }); + }); + + it('should fail with invalid data', done => { + socketGroups.issueMassInvite({uid: adminUid}, {groupName: 'PrivateCanJoin', usernames: null}, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should issue mass invite to users', done => { + User.create({username: 'invite2'}, (error, uid) => { + assert.ifError(error); + socketGroups.issueMassInvite({uid: adminUid}, {groupName: 'PrivateCanJoin', usernames: 'invite1, invite2'}, error_ => { + assert.ifError(error_); + Groups.isInvited([adminUid, uid], 'PrivateCanJoin', (error, isInvited) => { + assert.ifError(error); + assert.deepStrictEqual(isInvited, [false, true]); + done(); + }); + }); + }); + }); + + it('should rescind invite', done => { + User.create({username: 'invite3'}, (error, uid) => { + assert.ifError(error); + socketGroups.issueInvite({uid: adminUid}, {groupName: 'PrivateCanJoin', toUid: uid}, error_ => { + assert.ifError(error_); + socketGroups.rescindInvite({uid: adminUid}, {groupName: 'PrivateCanJoin', toUid: uid}, error_ => { + assert.ifError(error_); + Groups.isInvited(uid, 'PrivateCanJoin', (error, isInvited) => { + assert.ifError(error); + assert(!isInvited); + done(); + }); + }); + }); + }); + }); + + it('should error if user is not invited', done => { + socketGroups.acceptInvite({uid: adminUid}, {groupName: 'PrivateCanJoin'}, error => { + assert.equal(error.message, '[[error:not-invited]]'); + done(); + }); + }); + + it('should accept invite', done => { + User.create({username: 'invite4'}, (error, uid) => { + assert.ifError(error); + socketGroups.issueInvite({uid: adminUid}, {groupName: 'PrivateCanJoin', toUid: uid}, error_ => { + assert.ifError(error_); + socketGroups.acceptInvite({uid}, {groupName: 'PrivateCanJoin'}, error_ => { + assert.ifError(error_); + Groups.isMember(uid, 'PrivateCanJoin', (error, isMember) => { + assert.ifError(error); + assert(isMember); + done(); + }); + }); + }); + }); + }); + + it('should reject invite', done => { + User.create({username: 'invite5'}, (error, uid) => { + assert.ifError(error); + socketGroups.issueInvite({uid: adminUid}, {groupName: 'PrivateCanJoin', toUid: uid}, error_ => { + assert.ifError(error_); + socketGroups.rejectInvite({uid}, {groupName: 'PrivateCanJoin'}, error_ => { + assert.ifError(error_); + Groups.isInvited(uid, 'PrivateCanJoin', (error, isInvited) => { + assert.ifError(error); + assert(!isInvited); + done(); + }); + }); + }); + }); + }); + + it('should grant ownership to user', async () => { + await apiGroups.grant({uid: adminUid}, {slug: 'privatecanjoin', uid: testUid}); + const isOwner = await Groups.ownership.isOwner(testUid, 'PrivateCanJoin'); + assert(isOwner); + }); + + it('should rescind ownership from user', async () => { + await apiGroups.rescind({uid: adminUid}, {slug: 'privatecanjoin', uid: testUid}); + const isOwner = await Groups.ownership.isOwner(testUid, 'PrivateCanJoin'); + assert(!isOwner); + }); + + it('should fail to kick user with invalid data', done => { + socketGroups.kick({uid: adminUid}, {groupName: 'PrivateCanJoin', uid: adminUid}, error => { + assert.equal(error.message, '[[error:cant-kick-self]]'); + done(); + }); + }); + + it('should kick user from group', done => { + socketGroups.kick({uid: adminUid}, {groupName: 'PrivateCanJoin', uid: testUid}, error => { + assert.ifError(error); + Groups.isMember(testUid, 'PrivateCanJoin', (error, isMember) => { + assert.ifError(error); + assert(!isMember); + done(); + }); + }); + }); + + it('should fail to create group with invalid data', async () => { + try { + await apiGroups.create({uid: 0}, {}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:no-privileges]]'); + } + }); + + it('should fail to create group if group creation is disabled', async () => { + try { + await apiGroups.create({uid: testUid}, {name: 'avalidname'}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:no-privileges]]'); + } + }); + + it('should fail to create group if name is privilege group', async () => { + try { + await apiGroups.create({uid: 1}, {name: 'cid:1:privileges:groups:find'}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:invalid-group-name]]'); + } + }); + + it('should create/update group', async () => { + const groupData = await apiGroups.create({uid: adminUid}, {name: 'createupdategroup'}); + assert(groupData); + const data = { + slug: 'createupdategroup', + name: 'renamedupdategroup', + description: 'cat group', + userTitle: 'cats', + userTitleEnabled: 1, + disableJoinRequests: 1, + hidden: 1, + private: 0, + }; + await apiGroups.update({uid: adminUid}, data); + const updatedData = await Groups.get('renamedupdategroup', {}); + assert.equal(updatedData.name, 'renamedupdategroup'); + assert.equal(updatedData.userTitle, 'cats'); + assert.equal(updatedData.description, 'cat group'); + assert.equal(updatedData.hidden, true); + assert.equal(updatedData.disableJoinRequests, true); + assert.equal(updatedData.private, false); + }); + + it('should fail to create a group with name guests', async () => { + try { + await apiGroups.create({uid: adminUid}, {name: 'guests'}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:invalid-group-name]]'); + } + }); + + it('should fail to rename guests group', async () => { + const data = { + slug: 'guests', + name: 'guests2', + }; + + try { + await apiGroups.update({uid: adminUid}, data); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:invalid-group-name]]'); + } + }); + + it('should delete group', async () => { + await apiGroups.delete({uid: adminUid}, {slug: 'renamedupdategroup'}); + const exists = await Groups.exists('renamedupdategroup'); + assert(!exists); + }); + + it('should fail to delete group if name is special', async () => { + const specialGroups = [ + 'administrators', + 'registered-users', + 'verified-users', + 'unverified-users', + 'global-moderators', + ]; + for (const slug of specialGroups) { + try { + // eslint-disable-next-line no-await-in-loop + await apiGroups.delete({uid: adminUid}, {slug}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:not-allowed]]'); + } + } + }); + + it('should fail to delete group if name is special', async () => { + try { + await apiGroups.delete({uid: adminUid}, {slug: 'guests'}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:invalid-group-name]]'); + } + }); + + it('should fail to load more groups with invalid data', done => { + socketGroups.loadMore({uid: adminUid}, {}, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should load more groups', done => { + socketGroups.loadMore({uid: adminUid}, {after: 0, sort: 'count'}, (error, data) => { + assert.ifError(error); + assert(Array.isArray(data.groups)); + done(); + }); + }); + + it('should fail to load more members with invalid data', done => { + socketGroups.loadMoreMembers({uid: adminUid}, {}, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should load more members', done => { + socketGroups.loadMoreMembers({uid: adminUid}, {after: 0, groupName: 'PrivateCanJoin'}, (error, data) => { + assert.ifError(error); + assert(Array.isArray(data.users)); + done(); + }); + }); + }); + + describe('api methods', () => { + const apiGroups = require('../src/api/groups'); + it('should fail to create group with invalid data', async () => { + let error; + try { + await apiGroups.create({uid: adminUid}, null); + } catch (error_) { + error = error_; + } + + assert.strictEqual(error.message, '[[error:invalid-data]]'); + }); + + it('should fail to create group if group name is privilege group', async () => { + let error; + try { + await apiGroups.create({uid: adminUid}, {name: 'cid:1:privileges:read'}); + } catch (error_) { + error = error_; + } + + assert.strictEqual(error.message, '[[error:invalid-group-name]]'); + }); + + it('should create a group', async () => { + const groupData = await apiGroups.create({uid: adminUid}, {name: 'newgroup', description: 'group created by admin'}); + assert.equal(groupData.name, 'newgroup'); + assert.equal(groupData.description, 'group created by admin'); + assert.equal(groupData.private, 1); + assert.equal(groupData.hidden, 0); + assert.equal(groupData.memberCount, 1); + }); + + it('should fail to join with invalid data', async () => { + let error; + try { + await apiGroups.join({uid: adminUid}, null); + } catch (error_) { + error = error_; + } + + assert.strictEqual(error.message, '[[error:invalid-data]]'); + }); + + it('should add user to group', async () => { + await apiGroups.join({uid: adminUid}, {uid: testUid, slug: 'newgroup'}); + const isMember = await Groups.isMember(testUid, 'newgroup'); + assert(isMember); + }); + + it('should not error if user is already member', async () => { + await apiGroups.join({uid: adminUid}, {uid: testUid, slug: 'newgroup'}); + }); + + it('it should fail with invalid data', async () => { + let error; + try { + await apiGroups.leave({uid: adminUid}, null); + } catch (error_) { + error = error_; + } + + assert.strictEqual(error.message, '[[error:invalid-data]]'); + }); + + it('it should fail if admin tries to remove self', async () => { + let error; + try { + await apiGroups.leave({uid: adminUid}, {uid: adminUid, slug: 'administrators'}); + } catch (error_) { + error = error_; + } + + assert.strictEqual(error.message, '[[error:cant-remove-self-as-admin]]'); + }); + + it('should not error if user is not member', async () => { + await apiGroups.leave({uid: adminUid}, {uid: 3, slug: 'newgroup'}); + }); + + it('should fail if trying to remove someone else from group', async () => { + let error; + try { + await apiGroups.leave({uid: testUid}, {uid: adminUid, slug: 'newgroup'}); + } catch (error_) { + error = error_; + } + + assert.strictEqual(error.message, '[[error:no-privileges]]'); + }); + + it('should remove user from group', async () => { + await apiGroups.leave({uid: adminUid}, {uid: testUid, slug: 'newgroup'}); + const isMember = await Groups.isMember(testUid, 'newgroup'); + assert(!isMember); + }); + + it('should fail with invalid data', async () => { + let error; + try { + await apiGroups.update({uid: adminUid}, null); + } catch (error_) { + error = error_; + } + + assert.strictEqual(error.message, '[[error:invalid-data]]'); + }); + + it('should update group', async () => { + const data = { + slug: 'newgroup', + name: 'renamedgroup', + description: 'cat group', + userTitle: 'cats', + userTitleEnabled: 1, + disableJoinRequests: 1, + hidden: 1, + private: 0, + }; + await apiGroups.update({uid: adminUid}, data); + const groupData = await Groups.get('renamedgroup', {}); + assert.equal(groupData.name, 'renamedgroup'); + assert.equal(groupData.userTitle, 'cats'); + assert.equal(groupData.description, 'cat group'); + assert.equal(groupData.hidden, true); + assert.equal(groupData.disableJoinRequests, true); + assert.equal(groupData.private, false); + }); + }); + + describe('groups cover', () => { + const socketGroups = require('../src/socket.io/groups'); + let regularUid; + const logoPath = path.join(__dirname, '../test/files/test.png'); + const imagePath = path.join(__dirname, '../test/files/groupcover.png'); + before(done => { + User.create({username: 'regularuser', password: '123456'}, (error, uid) => { + assert.ifError(error); + regularUid = uid; + async.series([ + function (next) { + Groups.join('Test', adminUid, next); + }, + function (next) { + Groups.join('Test', regularUid, next); + }, + function (next) { + helpers.copyFile(logoPath, imagePath, next); + }, + ], done); + }); + }); + + it('should fail if user is not logged in or not owner', done => { + socketGroups.cover.update({uid: 0}, {imageData: 'asd'}, error => { + assert.equal(error.message, '[[error:no-privileges]]'); + socketGroups.cover.update({uid: regularUid}, {groupName: 'Test', imageData: 'asd'}, error => { + assert.equal(error.message, '[[error:no-privileges]]'); + done(); + }); + }); + }); + + it('should upload group cover image from file', done => { + const data = { + groupName: 'Test', + file: { + path: imagePath, + type: 'image/png', + }, + }; + Groups.updateCover({uid: adminUid}, data, (error, data) => { + assert.ifError(error); + Groups.getGroupFields('Test', ['cover:url'], (error, groupData) => { + assert.ifError(error); + assert.equal(nconf.get('relative_path') + data.url, groupData['cover:url']); + if (nconf.get('relative_path')) { + assert(!data.url.startsWith(nconf.get('relative_path'))); + assert(groupData['cover:url'].startsWith(nconf.get('relative_path')), groupData['cover:url']); + } + + done(); + }); + }); + }); + + it('should upload group cover image from data', done => { + const data = { + groupName: 'Test', + imageData: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAgCAYAAAABtRhCAAAACXBIWXMAAC4jAAAuIwF4pT92AAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADqYAAAOpgAABdvkl/FRgAACcJJREFUeNqMl9tvnNV6xn/f+s5z8DCeg88Zj+NYdhJH4KShFoJAIkzVphLVJnsDaiV6gUKaC2qQUFVATbnoValAakuQYKMqBKUUJCgI9XBBSmOROMqGoCStHbA9sWM7nrFn/I3n9B17kcwoabfarj9gvet53+d9nmdJAwMDAAgh8DyPtbU1XNfFMAwkScK2bTzPw/M8dF1/SAhxKAiCxxVF2aeqqqTr+q+Af+7o6Ch0d3f/69TU1KwkSRiGwbFjx3jmmWd47rnn+OGHH1BVFYX/5QRBkPQ87xeSJP22YRi/oapqStM0PM/D931kWSYIgnHf98cXFxepVqtomjZt2/Zf2bb990EQ4Pv+PXfeU1CSpGYhfN9/TgjxQTQaJQgCwuEwQRBQKpUwDAPTNPF9n0ajAYDv+8zPzzM+Pr6/Wq2eqdVqfxOJRA6Zpnn57hrivyEC0IQQZ4Mg+MAwDCKRCJIkUa/XEUIQi8XQNI1QKIQkSQghUBQFIQSmaTI7OwtAuVxOTE9Pfzc9Pf27lUqlBUgulUoUi0VKpRKqqg4EQfAfiqLsDIfDAC0E4XCYaDSKEALXdalUKvfM1/d9hBBYlkUul2N4eJi3335bcl33mW+++aaUz+cvSJKE8uKLL6JpGo7j8Omnn/7d+vp6sr+/HyEEjuMgyzKu6yJJEsViEVVV8TyPjY2NVisV5fZkTNMkkUhw8+ZN6vU6Kysr7Nmzh9OnT7/12GOPDS8sLByT7rQR4A9XV1d/+cILLzA9PU0kEmF4eBhFUTh//jyWZaHrOkII0uk0jUaDWq1GJpOhWCyysrLC1tYWnuehqir79+9H13W6urp48803+f7773n++ef/4G7S/H4ikUCSJNbX11trcuvWLcrlMrIs4zgODzzwABMTE/i+T7lcpq2tjUqlwubmJrZts7y8jBCCkZERGo0G2WyWkydPkkql6Onp+eMmwihwc3JyMvrWW2+RTCYBcF0XWZbRdZ3l5WX27NnD008/TSwWQ1VVyuVy63GhUIhEIkEqlcJxHCzLIhaLMTQ0xJkzZ7Btm3379lmS53kIIczZ2dnFsbGxRK1Wo729HQDP8zAMg5WVFXp7e5mcnKSzs5N8Po/rutTrdVzXbQmHrutEo1FM00RVVXp7e0kkEgRBwMWLF9F1vaxUq1UikUjtlVdeuV6pVBJ9fX3Ytn2bwrLMysoKXV1dTE5OkslksCwLTdMwDANVVdnY2CAIApLJJJFIBMdxiMfj7Nq1C1VViUajLQCvvvrqkhKJRJiZmfmdb7/99jeTySSyLLfWodFoEAqFOH78OLt37yaXy2GaJoqisLy8zNTUFFevXiUIAtrb29m5cyePPPJIa+cymQz1eh2A0dFRCoXCsgIwNTW1J5/P093dTbFYRJZlJEmiWq1y4MABxsbGqNVqhEIh6vU6QRBQLpcxDIPh4WE8z2NxcZFTp05x7tw5Xn755ZY6dXZ2tliZzWa/EwD1ev3RsbExxsfHSafTVCoVGo0Gqqqya9cuIpEIQgh832dtbY3FxUUA+vr62LZtG2NjYxw5coTDhw+ztLTEyZMnuXr1KoVC4R4d3bt375R84sQJEY/H/2Jubq7N9326urqwbZt6vY5pmhw5coS+vr4W9YvFIrdu3WJqagohBFeuXOHcuXOtue7evRtN01rtfO+991haWmJkZGQrkUi8JIC9iqL0BkFAIpFACMETTzxBV1cXiUSC7u5uHMfB8zyCIMA0TeLxONlsFlmW8X2fwcFBHMdhfn6eer1Oe3s7Dz30EBMTE1y6dImjR49y6tSppR07dqwrjuM8+OWXXzI0NMTly5e5du0aQ0NDTExMkMvlCIKAIAhaIh2LxQiHw0QiEfL5POl0mlqtRq1Wo6OjA8uykGWZdDrN0tISvb29vPPOOzz++OPk83lELpf7rXfffRfDMOjo6MBxHEqlEocOHWLHjh00Gg0kSULTNIS4bS6qqhKPxxkaGmJ4eJjR0VH279/PwMAA27dvJ5vN4vs+X331FR9//DGzs7OEQiE++eQTlPb29keuX7/OtWvXOH78ONVqlZs3b9LW1kYmk8F13dZeCiGQJAnXdRFCYBgGsiwjhMC2bQqFAkEQoOs6P/74Iw8++CCDg4Pous6xY8f47LPPkIIguDo2Nrbzxo0bfPjhh9i2zczMTHNvcF2XpsZalkWj0cB1Xe4o1O3YoCisra3x008/EY/H6erqAuDAgQNEIhGCIODQoUP/ubCwMCKAjx599FHW19f56KOP6OjooFgsks/niUajKIqCbds4joMQAiFESxxs226xd2Zmhng8Tl9fH67r0mg0sG2bbDZLpVIhl8vd5gHwtysrKy8Dcdd1mZubo6enh1gsRrVabZlrk6VND/R9n3q9TqVSQdd1QqEQi4uLnD9/nlKpxODgIHv37gXAcRyCICiFQiHEzp07i1988cUfKYpCIpHANE22b9/eUhNFUVotDIKghc7zPCzLolKpsLW1RVtbG0EQ4DgOmqbR09NDM1qUSiWAPwdQ7ujjmf7+/kQymfxrSZJQVZWtra2WG+i63iKH53m4rku1WqVcLmNZFu3t7S2x7+/vJ51O89prr7VYfenSpcPAP1UqFeSHH36YeDxOKpW6eP/9988Bv9d09nw+T7VapVKptJjZnE2tVmNtbY1cLke5XGZra4vNzU16enp49tlnGRgYaD7iTxqNxgexWIzDhw+jNEPQHV87NT8/f+PChQtnR0ZGqFarrUVuOsDds2u2b2FhgVQqRSQSYWFhgStXrtDf308ymcwBf3nw4EEOHjx4O5c2lURVVRzHYXp6+t8uX7785IULFz7LZDLous59991HOBy+h31N9xgdHSWTyVCtVhkaGmLfvn1MT08zPz/PzMzM6c8//9xr+uE9QViWZer1OhsbGxiG8fns7OzPc7ncx729vXR3d1OpVNi2bRuhUAhZljEMA9/3sW0bVVVZWlri4sWLjI+P8/rrr/P111/z5JNPXrIs69cn76ZeGoaBpmm0tbX9Q6FQeHhubu7fC4UCkUiE1dVVstks8Xgc0zSRZZlGo9ESAdM02djYoNFo8MYbb2BZ1mYoFOKuZPjr/xZBEHCHred83x/b3Nz8l/X19aRlWWxsbNDZ2cnw8DDhcBjf96lWq/T09HD06FGeeuopXnrpJc6ePUs6nb4hhPi/C959ZFn+TtO0lG3bJ0ql0p85jsPW1haFQoG2tjYkSWpF/Uwmw9raGu+//z7A977vX2+GrP93wSZiTdNOGIbxy3K5/DPHcfYXCoVe27Yzpmm2m6bppVKp/Orqqnv69OmoZVn/mEwm/9TzvP9x138NAMpJ4VFTBr6SAAAAAElFTkSuQmCC', + }; + socketGroups.cover.update({uid: adminUid}, data, (error, data) => { + assert.ifError(error); + Groups.getGroupFields('Test', ['cover:url'], (error, groupData) => { + assert.ifError(error); + assert.equal(nconf.get('relative_path') + data.url, groupData['cover:url']); + done(); + }); + }); + }); + + it('should fail to upload group cover with invalid image', done => { + const data = { + groupName: 'Test', + file: { + path: imagePath, + type: 'image/png', + }, + }; + socketGroups.cover.update({uid: adminUid}, data, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should fail to upload group cover with invalid image', done => { + const data = { + groupName: 'Test', + imageData: 'data:image/svg;base64,iVBORw0KGgoAAAANSUhEUgAAABwA', + }; + socketGroups.cover.update({uid: adminUid}, data, (error, data) => { + assert.equal(error.message, '[[error:invalid-image]]'); + done(); + }); + }); + + it('should update group cover position', done => { + const data = { + groupName: 'Test', + position: '50% 50%', + }; + socketGroups.cover.update({uid: adminUid}, data, error => { + assert.ifError(error); + Groups.getGroupFields('Test', ['cover:position'], (error, groupData) => { + assert.ifError(error); + assert.equal('50% 50%', groupData['cover:position']); + done(); + }); + }); + }); + + it('should fail to update cover position if group name is missing', done => { + Groups.updateCoverPosition('', '50% 50%', error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should fail to remove cover if not logged in', done => { + socketGroups.cover.remove({uid: 0}, {groupName: 'Test'}, error => { + assert.equal(error.message, '[[error:no-privileges]]'); + done(); + }); + }); + + it('should fail to remove cover if not owner', done => { + socketGroups.cover.remove({uid: regularUid}, {groupName: 'Test'}, error => { + assert.equal(error.message, '[[error:no-privileges]]'); + done(); + }); + }); + + it('should remove cover', async () => { + const fields = ['cover:url', 'cover:thumb:url']; + const values = await Groups.getGroupFields('Test', fields); + await socketGroups.cover.remove({uid: adminUid}, {groupName: 'Test'}); + + for (const field of fields) { + const filename = values[field].split('/').pop(); + const filePath = path.join(nconf.get('upload_path'), 'files', filename); + assert.strictEqual(fs.existsSync(filePath), false); + } + + const groupData = await db.getObjectFields('group:Test', ['cover:url']); + assert(!groupData['cover:url']); + }); + }); }); diff --git a/test/helpers/index.js b/test/helpers/index.js index 953203a..100c5f6 100644 --- a/test/helpers/index.js +++ b/test/helpers/index.js @@ -1,232 +1,240 @@ 'use strict'; +const fs = require('node:fs'); const request = require('request'); const requestAsync = require('request-promise-native'); const nconf = require('nconf'); -const fs = require('fs'); const winston = require('winston'); - const utils = require('../../src/utils'); const helpers = module.exports; -helpers.getCsrfToken = async (jar) => { - const { csrf_token: token } = await requestAsync({ - url: `${nconf.get('url')}/api/config`, - json: true, - jar, - }); +helpers.getCsrfToken = async jar => { + const {csrf_token: token} = await requestAsync({ + url: `${nconf.get('url')}/api/config`, + json: true, + jar, + }); - return token; + return token; }; helpers.request = async function (method, uri, options) { - const ignoreMethods = ['GET', 'HEAD', 'OPTIONS']; - const lowercaseMethod = String(method).toLowerCase(); - let csrf_token; - if (!ignoreMethods.some(method => method.toLowerCase() === lowercaseMethod)) { - csrf_token = await helpers.getCsrfToken(options.jar); - } - - return new Promise((resolve, reject) => { - options.headers = options.headers || {}; - if (csrf_token) { - options.headers['x-csrf-token'] = csrf_token; - } - request[lowercaseMethod](`${nconf.get('url')}${uri}`, options, (err, res, body) => { - if (err) reject(err); - else resolve({ res, body }); - }); - }); + const ignoreMethods = ['GET', 'HEAD', 'OPTIONS']; + const lowercaseMethod = String(method).toLowerCase(); + let csrf_token; + if (!ignoreMethods.some(method => method.toLowerCase() === lowercaseMethod)) { + csrf_token = await helpers.getCsrfToken(options.jar); + } + + return new Promise((resolve, reject) => { + options.headers = options.headers || {}; + if (csrf_token) { + options.headers['x-csrf-token'] = csrf_token; + } + + request[lowercaseMethod](`${nconf.get('url')}${uri}`, options, (error, res, body) => { + if (error) { + reject(error); + } else { + resolve({res, body}); + } + }); + }); }; helpers.loginUser = function (username, password, callback) { - const jar = request.jar(); - - request({ - url: `${nconf.get('url')}/api/config`, - json: true, - jar: jar, - }, (err, res, body) => { - if (err || res.statusCode !== 200) { - return callback(err || new Error('[[error:invalid-response]]')); - } - const { csrf_token } = body; - request.post(`${nconf.get('url')}/login`, { - form: { - username: username, - password: password, - }, - json: true, - jar: jar, - headers: { - 'x-csrf-token': csrf_token, - }, - }, (err, res, body) => { - if (err) { - return callback(err || new Error('[[error:invalid-response]]')); - } - callback(null, { jar, res, body, csrf_token: csrf_token }); - }); - }); + const jar = request.jar(); + + request({ + url: `${nconf.get('url')}/api/config`, + json: true, + jar, + }, (error, res, body) => { + if (error || res.statusCode !== 200) { + return callback(error || new Error('[[error:invalid-response]]')); + } + + const {csrf_token} = body; + request.post(`${nconf.get('url')}/login`, { + form: { + username, + password, + }, + json: true, + jar, + headers: { + 'x-csrf-token': csrf_token, + }, + }, (error, res, body) => { + if (error) { + return callback(error || new Error('[[error:invalid-response]]')); + } + + callback(null, { + jar, res, body, csrf_token, + }); + }); + }); }; - helpers.logoutUser = function (jar, callback) { - request({ - url: `${nconf.get('url')}/api/config`, - json: true, - jar: jar, - }, (err, response, body) => { - if (err) { - return callback(err, response, body); - } - - request.post(`${nconf.get('url')}/logout`, { - form: {}, - json: true, - jar: jar, - headers: { - 'x-csrf-token': body.csrf_token, - }, - }, (err, response, body) => { - callback(err, response, body); - }); - }); + request({ + url: `${nconf.get('url')}/api/config`, + json: true, + jar, + }, (error, response, body) => { + if (error) { + return callback(error, response, body); + } + + request.post(`${nconf.get('url')}/logout`, { + form: {}, + json: true, + jar, + headers: { + 'x-csrf-token': body.csrf_token, + }, + }, (error, response, body) => { + callback(error, response, body); + }); + }); }; helpers.connectSocketIO = function (res, callback) { - const io = require('socket.io-client'); - let cookies = res.headers['set-cookie']; - cookies = cookies.filter(c => /express.sid=[^;]+;/.test(c)); - const cookie = cookies[0]; - const socket = io(nconf.get('base_url'), { - path: `${nconf.get('relative_path')}/socket.io`, - extraHeaders: { - Origin: nconf.get('url'), - Cookie: cookie, - }, - }); - - socket.on('connect', () => { - callback(null, socket); - }); - - socket.on('error', (err) => { - callback(err); - }); + const io = require('socket.io-client'); + let cookies = res.headers['set-cookie']; + cookies = cookies.filter(c => /express.sid=[^;]+;/.test(c)); + const cookie = cookies[0]; + const socket = io(nconf.get('base_url'), { + path: `${nconf.get('relative_path')}/socket.io`, + extraHeaders: { + Origin: nconf.get('url'), + Cookie: cookie, + }, + }); + + socket.on('connect', () => { + callback(null, socket); + }); + + socket.on('error', error => { + callback(error); + }); }; helpers.uploadFile = function (uploadEndPoint, filePath, body, jar, csrf_token, callback) { - let formData = { - files: [ - fs.createReadStream(filePath), - fs.createReadStream(filePath), // see https://github.com/request/request/issues/2445 - ], - }; - formData = utils.merge(formData, body); - request.post({ - url: uploadEndPoint, - formData: formData, - json: true, - jar: jar, - headers: { - 'x-csrf-token': csrf_token, - }, - }, (err, res, body) => { - if (err) { - return callback(err); - } - if (res.statusCode !== 200) { - winston.error(JSON.stringify(body)); - } - callback(null, res, body); - }); + let formData = { + files: [ + fs.createReadStream(filePath), + fs.createReadStream(filePath), // See https://github.com/request/request/issues/2445 + ], + }; + formData = utils.merge(formData, body); + request.post({ + url: uploadEndPoint, + formData, + json: true, + jar, + headers: { + 'x-csrf-token': csrf_token, + }, + }, (error, res, body) => { + if (error) { + return callback(error); + } + + if (res.statusCode !== 200) { + winston.error(JSON.stringify(body)); + } + + callback(null, res, body); + }); }; helpers.registerUser = function (data, callback) { - const jar = request.jar(); - request({ - url: `${nconf.get('url')}/api/config`, - json: true, - jar: jar, - }, (err, response, body) => { - if (err) { - return callback(err); - } - - if (!data.hasOwnProperty('password-confirm')) { - data['password-confirm'] = data.password; - } - - request.post(`${nconf.get('url')}/register`, { - form: data, - json: true, - jar: jar, - headers: { - 'x-csrf-token': body.csrf_token, - }, - }, (err, response, body) => { - callback(err, jar, response, body); - }); - }); + const jar = request.jar(); + request({ + url: `${nconf.get('url')}/api/config`, + json: true, + jar, + }, (error, response, body) => { + if (error) { + return callback(error); + } + + if (!data.hasOwnProperty('password-confirm')) { + data['password-confirm'] = data.password; + } + + request.post(`${nconf.get('url')}/register`, { + form: data, + json: true, + jar, + headers: { + 'x-csrf-token': body.csrf_token, + }, + }, (error, response, body) => { + callback(error, jar, response, body); + }); + }); }; // http://stackoverflow.com/a/14387791/583363 helpers.copyFile = function (source, target, callback) { - let cbCalled = false; - - const rd = fs.createReadStream(source); - rd.on('error', (err) => { - done(err); - }); - const wr = fs.createWriteStream(target); - wr.on('error', (err) => { - done(err); - }); - wr.on('close', () => { - done(); - }); - rd.pipe(wr); - - function done(err) { - if (!cbCalled) { - callback(err); - cbCalled = true; - } - } + let callbackCalled = false; + + const rd = fs.createReadStream(source); + rd.on('error', error => { + done(error); + }); + const wr = fs.createWriteStream(target); + wr.on('error', error => { + done(error); + }); + wr.on('close', () => { + done(); + }); + rd.pipe(wr); + + function done(error) { + if (!callbackCalled) { + callback(error); + callbackCalled = true; + } + } }; helpers.invite = async function (body, uid, jar, csrf_token) { - const res = await requestAsync.post(`${nconf.get('url')}/api/v3/users/${uid}/invites`, { - jar: jar, - // using "form" since client "api" module make requests with "application/x-www-form-urlencoded" content-type - form: body, - headers: { - 'x-csrf-token': csrf_token, - }, - simple: false, - resolveWithFullResponse: true, - }); - - res.body = JSON.parse(res.body); - return { res, body }; + const res = await requestAsync.post(`${nconf.get('url')}/api/v3/users/${uid}/invites`, { + jar, + // Using "form" since client "api" module make requests with "application/x-www-form-urlencoded" content-type + form: body, + headers: { + 'x-csrf-token': csrf_token, + }, + simple: false, + resolveWithFullResponse: true, + }); + + res.body = JSON.parse(res.body); + return {res, body}; }; helpers.createFolder = function (path, folderName, jar, csrf_token) { - return requestAsync.put(`${nconf.get('url')}/api/v3/files/folder`, { - jar, - body: { - path, - folderName, - }, - json: true, - headers: { - 'x-csrf-token': csrf_token, - }, - simple: false, - resolveWithFullResponse: true, - }); + return requestAsync.put(`${nconf.get('url')}/api/v3/files/folder`, { + jar, + body: { + path, + folderName, + }, + json: true, + headers: { + 'x-csrf-token': csrf_token, + }, + simple: false, + resolveWithFullResponse: true, + }); }; require('../../src/promisify')(helpers); diff --git a/test/i18n.js b/test/i18n.js index f7dbf5b..144ee42 100644 --- a/test/i18n.js +++ b/test/i18n.js @@ -2,122 +2,121 @@ // For tests relating to the translator module, check translator.js -const assert = require('assert'); -const path = require('path'); -const fs = require('fs'); - +const assert = require('node:assert'); +const path = require('node:path'); +const fs = require('node:fs'); const file = require('../src/file'); - const db = require('./mocks/databasemock'); describe('i18n', () => { - let folders; - - before(async function () { - if (process.env.GITHUB_EVENT_NAME === 'pull_request') { - this.skip(); - } - - folders = await fs.promises.readdir(path.resolve(__dirname, '../public/language')); - folders = folders.filter(f => f !== 'README.md'); - }); - - it('should contain folders named after the language code', async () => { - const valid = /(?:README.md|^[a-z]{2}(?:-[A-Z]{2})?$|^[a-z]{2}(?:-x-[a-z]+)?$)/; // good luck - - folders.forEach((folder) => { - assert(valid.test(folder)); - }); - }); - - // There has to be a better way to generate tests asynchronously... - it('', async () => { - const sourcePath = path.resolve(__dirname, '../public/language/en-GB'); - const fullPaths = await file.walk(sourcePath); - const sourceFiles = fullPaths.map(path => path.replace(sourcePath, '')); - const sourceStrings = new Map(); - - describe('source language file structure', () => { - it('should only contain valid JSON files', async () => { - try { - fullPaths.forEach((fullPath) => { - if (fullPath.endsWith('_DO_NOT_EDIT_FILES_HERE.md')) { - return; - } - - const hash = require(fullPath); - sourceStrings.set(fullPath.replace(sourcePath, ''), hash); - }); - } catch (e) { - assert(!e, `Invalid JSON found: ${e.message}`); - } - }); - }); - - folders.forEach((language) => { - describe(`"${language}" file structure`, () => { - let files; - - before(async () => { - const translationPath = path.resolve(__dirname, `../public/language/${language}`); - files = (await file.walk(translationPath)).map(path => path.replace(translationPath, '')); - }); - - it('translations should contain every language file contained in the source language directory', () => { - sourceFiles.forEach((relativePath) => { - assert(files.includes(relativePath), `${relativePath.slice(1)} was found in source files but was not found in language "${language}" (likely not internationalized)`); - }); - }); - - it('should not contain any extraneous files not included in the source language directory', () => { - files.forEach((relativePath) => { - assert(sourceFiles.includes(relativePath), `${relativePath.slice(1)} was found in language "${language}" but there is no source file for it (likely removed from en-GB)`); - }); - }); - }); - - describe(`"${language}" file contents`, () => { - let fullPaths; - const translationPath = path.resolve(__dirname, `../public/language/${language}`); - const strings = new Map(); - - before(async () => { - fullPaths = await file.walk(translationPath); - }); - - it('should contain only valid JSON files', () => { - try { - fullPaths.forEach((fullPath) => { - if (fullPath.endsWith('_DO_NOT_EDIT_FILES_HERE.md')) { - return; - } - - const hash = require(fullPath); - strings.set(fullPath.replace(translationPath, ''), hash); - }); - } catch (e) { - assert(!e, `Invalid JSON found: ${e.message}`); - } - }); - - it('should contain every translation key contained in its source counterpart', () => { - const sourceArr = Array.from(sourceStrings.keys()); - sourceArr.forEach((namespace) => { - const sourceKeys = Object.keys(sourceStrings.get(namespace)); - const translationKeys = Object.keys(strings.get(namespace)); - - assert(sourceKeys && translationKeys); - sourceKeys.forEach((key) => { - assert(translationKeys.includes(key), `${namespace.slice(1, -5)}:${key} missing in ${language}`); - }); - assert.strictEqual( - sourceKeys.length, - translationKeys.length, - `Extra keys found in namespace ${namespace.slice(1, -5)} for language "${language}"` - ); - }); - }); - }); - }); - }); + let folders; + + before(async function () { + if (process.env.GITHUB_EVENT_NAME === 'pull_request') { + this.skip(); + } + + folders = await fs.promises.readdir(path.resolve(__dirname, '../public/language')); + folders = folders.filter(f => f !== 'README.md'); + }); + + it('should contain folders named after the language code', async () => { + const valid = /README.md|^[a-z]{2}(?:-[A-Z]{2})?$|^[a-z]{2}(?:-x-[a-z]+)?$/; // Good luck + + for (const folder of folders) { + assert(valid.test(folder)); + } + }); + + // There has to be a better way to generate tests asynchronously... + it('', async () => { + const sourcePath = path.resolve(__dirname, '../public/language/en-GB'); + const fullPaths = await file.walk(sourcePath); + const sourceFiles = fullPaths.map(path => path.replace(sourcePath, '')); + const sourceStrings = new Map(); + + describe('source language file structure', () => { + it('should only contain valid JSON files', async () => { + try { + for (const fullPath of fullPaths) { + if (fullPath.endsWith('_DO_NOT_EDIT_FILES_HERE.md')) { + continue; + } + + const hash = require(fullPath); + sourceStrings.set(fullPath.replace(sourcePath, ''), hash); + } + } catch (error) { + assert(!error, `Invalid JSON found: ${error.message}`); + } + }); + }); + + for (const language of folders) { + describe(`"${language}" file structure`, () => { + let files; + + before(async () => { + const translationPath = path.resolve(__dirname, `../public/language/${language}`); + files = (await file.walk(translationPath)).map(path => path.replace(translationPath, '')); + }); + + it('translations should contain every language file contained in the source language directory', () => { + for (const relativePath of sourceFiles) { + assert(files.includes(relativePath), `${relativePath.slice(1)} was found in source files but was not found in language "${language}" (likely not internationalized)`); + } + }); + + it('should not contain any extraneous files not included in the source language directory', () => { + for (const relativePath of files) { + assert(sourceFiles.includes(relativePath), `${relativePath.slice(1)} was found in language "${language}" but there is no source file for it (likely removed from en-GB)`); + } + }); + }); + + describe(`"${language}" file contents`, () => { + let fullPaths; + const translationPath = path.resolve(__dirname, `../public/language/${language}`); + const strings = new Map(); + + before(async () => { + fullPaths = await file.walk(translationPath); + }); + + it('should contain only valid JSON files', () => { + try { + for (const fullPath of fullPaths) { + if (fullPath.endsWith('_DO_NOT_EDIT_FILES_HERE.md')) { + continue; + } + + const hash = require(fullPath); + strings.set(fullPath.replace(translationPath, ''), hash); + } + } catch (error) { + assert(!error, `Invalid JSON found: ${error.message}`); + } + }); + + it('should contain every translation key contained in its source counterpart', () => { + const sourceArray = Array.from(sourceStrings.keys()); + for (const namespace of sourceArray) { + const sourceKeys = Object.keys(sourceStrings.get(namespace)); + const translationKeys = Object.keys(strings.get(namespace)); + + assert(sourceKeys && translationKeys); + for (const key of sourceKeys) { + assert(translationKeys.includes(key), `${namespace.slice(1, -5)}:${key} missing in ${language}`); + } + + assert.strictEqual( + sourceKeys.length, + translationKeys.length, + `Extra keys found in namespace ${namespace.slice(1, -5)} for language "${language}"`, + ); + } + }); + }); + } + }); }); diff --git a/test/image.js b/test/image.js index 0d44a74..cd4f77e 100644 --- a/test/image.js +++ b/test/image.js @@ -1,38 +1,37 @@ 'use strict'; -const assert = require('assert'); -const path = require('path'); - -const db = require('./mocks/databasemock'); +const assert = require('node:assert'); +const path = require('node:path'); const image = require('../src/image'); const file = require('../src/file'); +const db = require('./mocks/databasemock'); describe('image', () => { - it('should normalise image', (done) => { - image.normalise(path.join(__dirname, 'files/normalise.jpg'), '.jpg', (err) => { - assert.ifError(err); - file.exists(path.join(__dirname, 'files/normalise.jpg.png'), (err, exists) => { - assert.ifError(err); - assert(exists); - done(); - }); - }); - }); + it('should normalise image', done => { + image.normalise(path.join(__dirname, 'files/normalise.jpg'), '.jpg', error => { + assert.ifError(error); + file.exists(path.join(__dirname, 'files/normalise.jpg.png'), (error, exists) => { + assert.ifError(error); + assert(exists); + done(); + }); + }); + }); - it('should resize an image', (done) => { - image.resizeImage({ - path: path.join(__dirname, 'files/normalise.jpg'), - target: path.join(__dirname, 'files/normalise-resized.jpg'), - width: 50, - height: 40, - }, (err) => { - assert.ifError(err); - image.size(path.join(__dirname, 'files/normalise-resized.jpg'), (err, bitmap) => { - assert.ifError(err); - assert.equal(bitmap.width, 50); - assert.equal(bitmap.height, 40); - done(); - }); - }); - }); + it('should resize an image', done => { + image.resizeImage({ + path: path.join(__dirname, 'files/normalise.jpg'), + target: path.join(__dirname, 'files/normalise-resized.jpg'), + width: 50, + height: 40, + }, error => { + assert.ifError(error); + image.size(path.join(__dirname, 'files/normalise-resized.jpg'), (error, bitmap) => { + assert.ifError(error); + assert.equal(bitmap.width, 50); + assert.equal(bitmap.height, 40); + done(); + }); + }); + }); }); diff --git a/test/locale-detect.js b/test/locale-detect.js index fcf85a9..a84a847 100644 --- a/test/locale-detect.js +++ b/test/locale-detect.js @@ -1,46 +1,45 @@ 'use strict'; -const assert = require('assert'); +const assert = require('node:assert'); const nconf = require('nconf'); const request = require('request'); - -const db = require('./mocks/databasemock'); const meta = require('../src/meta'); +const db = require('./mocks/databasemock'); describe('Language detection', () => { - it('should detect the language for a guest', (done) => { - meta.configs.set('autoDetectLang', 1, (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/config`, { - headers: { - 'Accept-Language': 'de-DE,de;q=0.5', - }, - json: true, - }, (err, res, body) => { - assert.ifError(err); - assert.ok(body); + it('should detect the language for a guest', done => { + meta.configs.set('autoDetectLang', 1, error => { + assert.ifError(error); + request(`${nconf.get('url')}/api/config`, { + headers: { + 'Accept-Language': 'de-DE,de;q=0.5', + }, + json: true, + }, (error, res, body) => { + assert.ifError(error); + assert.ok(body); - assert.strictEqual(body.userLang, 'de'); - done(); - }); - }); - }); + assert.strictEqual(body.userLang, 'de'); + done(); + }); + }); + }); - it('should do nothing when disabled', (done) => { - meta.configs.set('autoDetectLang', 0, (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/config`, { - headers: { - 'Accept-Language': 'de-DE,de;q=0.5', - }, - json: true, - }, (err, res, body) => { - assert.ifError(err); - assert.ok(body); + it('should do nothing when disabled', done => { + meta.configs.set('autoDetectLang', 0, error => { + assert.ifError(error); + request(`${nconf.get('url')}/api/config`, { + headers: { + 'Accept-Language': 'de-DE,de;q=0.5', + }, + json: true, + }, (error, res, body) => { + assert.ifError(error); + assert.ok(body); - assert.strictEqual(body.userLang, 'en-GB'); - done(); - }); - }); - }); + assert.strictEqual(body.userLang, 'en-GB'); + done(); + }); + }); + }); }); diff --git a/test/messaging.js b/test/messaging.js index ee4e2e8..8de4a97 100644 --- a/test/messaging.js +++ b/test/messaging.js @@ -1,868 +1,868 @@ 'use strict'; -const assert = require('assert'); -const async = require('async'); -const request = require('request-promise-native'); +const assert = require('node:assert'); +const util = require('node:util'); const nconf = require('nconf'); -const util = require('util'); +const request = require('request-promise-native'); +const async = require('async'); const sleep = util.promisify(setTimeout); -const db = require('./mocks/databasemock'); const meta = require('../src/meta'); const User = require('../src/user'); const Groups = require('../src/groups'); const Messaging = require('../src/messaging'); -const helpers = require('./helpers'); const socketModules = require('../src/socket.io/modules'); const utils = require('../src/utils'); const translator = require('../src/translator'); +const helpers = require('./helpers'); +const db = require('./mocks/databasemock'); describe('Messaging Library', () => { - const mocks = { - users: { - foo: {}, // the admin - bar: {}, - baz: {}, // the user with chat restriction enabled - herp: {}, - }, - }; - let roomId; - - let chatMessageDelay; - - const callv3API = async (method, path, body, user) => { - const options = { - method, - body, - json: true, - jar: mocks.users[user].jar, - resolveWithFullResponse: true, - simple: false, - }; - - if (method !== 'get') { - options.headers = { - 'x-csrf-token': mocks.users[user].csrf, - }; - } - - return request(`${nconf.get('url')}/api/v3${path}`, options); - }; - - before(async () => { - // Create 3 users: 1 admin, 2 regular - ({ - foo: mocks.users.foo.uid, - bar: mocks.users.bar.uid, - baz: mocks.users.baz.uid, - herp: mocks.users.herp.uid, - } = await utils.promiseParallel({ - foo: User.create({ username: 'foo', password: 'barbar' }), // admin - bar: User.create({ username: 'bar', password: 'bazbaz' }), // admin - baz: User.create({ username: 'baz', password: 'quuxquux' }), // restricted user - herp: User.create({ username: 'herp', password: 'derpderp' }), // a regular user - })); - - await Groups.join('administrators', mocks.users.foo.uid); - await User.setSetting(mocks.users.baz.uid, 'restrictChat', '1'); - - ({ jar: mocks.users.foo.jar, csrf_token: mocks.users.foo.csrf } = await util.promisify(helpers.loginUser)('foo', 'barbar')); - ({ jar: mocks.users.bar.jar, csrf_token: mocks.users.bar.csrf } = await util.promisify(helpers.loginUser)('bar', 'bazbaz')); - ({ jar: mocks.users.baz.jar, csrf_token: mocks.users.baz.csrf } = await util.promisify(helpers.loginUser)('baz', 'quuxquux')); - ({ jar: mocks.users.herp.jar, csrf_token: mocks.users.herp.csrf } = await util.promisify(helpers.loginUser)('herp', 'derpderp')); - - chatMessageDelay = meta.config.chatMessageDelay; - meta.config.chatMessageDelay = 0; - }); - - after(() => { - meta.configs.chatMessageDelay = chatMessageDelay; - }); - - describe('.canMessage()', () => { - it('should allow messages to be sent to an unrestricted user', (done) => { - Messaging.canMessageUser(mocks.users.baz.uid, mocks.users.herp.uid, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should NOT allow messages to be sent to a restricted user', async () => { - await User.setSetting(mocks.users.baz.uid, 'restrictChat', '1'); - try { - await Messaging.canMessageUser(mocks.users.herp.uid, mocks.users.baz.uid); - } catch (err) { - assert.strictEqual(err.message, '[[error:chat-restricted]]'); - } - }); - - it('should always allow admins through', (done) => { - Messaging.canMessageUser(mocks.users.foo.uid, mocks.users.baz.uid, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should allow messages to be sent to a restricted user if restricted user follows sender', (done) => { - User.follow(mocks.users.baz.uid, mocks.users.herp.uid, () => { - Messaging.canMessageUser(mocks.users.herp.uid, mocks.users.baz.uid, (err) => { - assert.ifError(err); - done(); - }); - }); - }); - }); - - describe('rooms', () => { - it('should fail to create a new chat room with invalid data', async () => { - const { body } = await callv3API('post', '/chats', {}, 'foo'); - assert.equal(body.status.message, await translator.translate('[[error:required-parameters-missing, uids]]')); - }); - - it('should return rate limit error on second try', async () => { - const oldValue = meta.config.chatMessageDelay; - meta.config.chatMessageDelay = 1000; - - await callv3API('post', '/chats', { - uids: [mocks.users.baz.uid], - }, 'foo'); - - const { statusCode, body } = await callv3API('post', `/chats`, { - uids: [mocks.users.baz.uid], - }, 'foo'); - - assert.equal(statusCode, 400); - assert.equal(body.status.code, 'bad-request'); - assert.equal(body.status.message, await translator.translate('[[error:too-many-messages]]')); - meta.config.chatMessageDelay = oldValue; - }); - - it('should create a new chat room', async () => { - await User.setSetting(mocks.users.baz.uid, 'restrictChat', '0'); - const { body } = await callv3API('post', `/chats`, { - uids: [mocks.users.baz.uid], - }, 'foo'); - await User.setSetting(mocks.users.baz.uid, 'restrictChat', '1'); - - roomId = body.response.roomId; - assert(roomId); - - await util.promisify(socketModules.chats.canMessage)({ uid: mocks.users.foo.uid }, roomId); - }); - - it('should send a user-join system message when a chat room is created', async () => { - const { body } = await callv3API('get', `/chats/${roomId}`, {}, 'foo'); - const { messages } = body.response; - assert.equal(messages.length, 2); - assert.strictEqual(messages[0].system, true); - assert.strictEqual(messages[0].content, 'user-join'); - - const { statusCode, body: body2 } = await callv3API('put', `/chats/${roomId}/messages/${messages[0].messageId}`, { - message: 'test', - }, 'foo'); - assert.strictEqual(statusCode, 400); - assert.equal(body2.status.message, await translator.translate('[[error:cant-edit-chat-message]]')); - }); - - it('should fail to add user to room with invalid data', async () => { - let { statusCode, body } = await callv3API('post', `/chats/${roomId}/users`, {}, 'foo'); - assert.strictEqual(statusCode, 400); - assert.strictEqual(body.status.message, await translator.translate('[[error:required-parameters-missing, uids]]')); - - ({ statusCode, body } = await callv3API('post', `/chats/${roomId}/users`, { uids: [null] }, 'foo')); - assert.strictEqual(statusCode, 400); - assert.strictEqual(body.status.message, await translator.translate('[[error:no-user]]')); - }); - - it('should add a user to room', async () => { - await callv3API('post', `/chats/${roomId}/users`, { uids: [mocks.users.herp.uid] }, 'foo'); - const isInRoom = await Messaging.isUserInRoom(mocks.users.herp.uid, roomId); - assert(isInRoom); - }); - - it('should get users in room', async () => { - const { body } = await callv3API('get', `/chats/${roomId}/users`, {}, 'foo'); - assert(Array.isArray(body.response.users)); - assert.strictEqual(body.response.users.length, 3); - }); - - it('should throw error if user is not in room', async () => { - const { statusCode, body } = await callv3API('get', `/chats/${roomId}/users`, {}, 'bar'); - assert.strictEqual(statusCode, 403); - assert.equal(body.status.message, await translator.translate('[[error:no-privileges]]')); - }); - - it('should fail to add users to room if max is reached', async () => { - meta.config.maximumUsersInChatRoom = 2; - const { statusCode, body } = await callv3API('post', `/chats/${roomId}/users`, { uids: [mocks.users.bar.uid] }, 'foo'); - assert.strictEqual(statusCode, 400); - assert.equal(body.status.message, await translator.translate('[[error:cant-add-more-users-to-chat-room]]')); - meta.config.maximumUsersInChatRoom = 0; - }); - - it('should fail to add users to room if user does not exist', async () => { - const { statusCode, body } = await callv3API('post', `/chats/${roomId}/users`, { uids: [98237498234] }, 'foo'); - assert.strictEqual(statusCode, 400); - assert.strictEqual(body.status.message, await translator.translate('[[error:no-user]]')); - }); - - it('should fail to add self to room', async () => { - const { statusCode, body } = await callv3API('post', `/chats/${roomId}/users`, { uids: [mocks.users.foo.uid] }, 'foo'); - assert.strictEqual(statusCode, 400); - assert.strictEqual(body.status.message, await translator.translate('[[error:cant-chat-with-yourself]]')); - }); - - it('should fail to leave room with invalid data', async () => { - let { statusCode, body } = await callv3API('delete', `/chats/${roomId}/users`, {}, 'foo'); - assert.strictEqual(statusCode, 400); - assert.strictEqual(body.status.message, await translator.translate('[[error:required-parameters-missing, uids]]')); - - ({ statusCode, body } = await callv3API('delete', `/chats/${roomId}/users`, { uids: [98237423] }, 'foo')); - assert.strictEqual(statusCode, 400); - assert.strictEqual(body.status.message, await translator.translate('[[error:no-user]]')); - }); - - it('should leave the chat room', async () => { - await callv3API('delete', `/chats/${roomId}/users/${mocks.users.baz.uid}`, {}, 'baz'); - const isUserInRoom = await Messaging.isUserInRoom(mocks.users.baz.uid, roomId); - assert.equal(isUserInRoom, false); - const data = await Messaging.getRoomData(roomId); - assert.equal(data.owner, mocks.users.foo.uid); - }); - - it('should send a user-leave system message when a user leaves the chat room', (done) => { - socketModules.chats.getMessages( - { uid: mocks.users.foo.uid }, - { uid: mocks.users.foo.uid, roomId: roomId, start: 0 }, - (err, messages) => { - assert.ifError(err); - assert.equal(messages.length, 4); - const message = messages.pop(); - assert.strictEqual(message.system, true); - assert.strictEqual(message.content, 'user-leave'); - done(); - } - ); - }); - - it('should not send a user-leave system message when a user tries to leave a room they are not in', async () => { - await callv3API('delete', `/chats/${roomId}/users/${mocks.users.baz.uid}`, {}, 'baz'); - const messages = await socketModules.chats.getMessages( - { uid: mocks.users.foo.uid }, - { uid: mocks.users.foo.uid, roomId: roomId, start: 0 } - ); - assert.equal(messages.length, 4); - let message = messages.pop(); - assert.strictEqual(message.system, true); - assert.strictEqual(message.content, 'user-leave'); - - // The message before should still be a user-join - message = messages.pop(); - assert.strictEqual(message.system, true); - assert.strictEqual(message.content, 'user-join'); - }); - - it('should change owner when owner leaves room', async () => { - const { body } = await callv3API('post', '/chats', { - uids: [mocks.users.foo.uid], - }, 'herp'); - - await callv3API('post', `/chats/${body.response.roomId}/users`, { uids: [mocks.users.baz.uid] }, 'herp'); - await util.promisify(socketModules.chats.leave)({ uid: mocks.users.herp.uid }, body.response.roomId); - - const data = await Messaging.getRoomData(body.response.roomId); - assert.equal(data.owner, mocks.users.foo.uid); - }); - - it('should change owner if owner is deleted', async () => { - const sender = await User.create({ username: 'deleted_chat_user', password: 'barbar' }); - const { jar: senderJar, csrf_token: senderCsrf } = await util.promisify(helpers.loginUser)('deleted_chat_user', 'barbar'); - - const receiver = await User.create({ username: 'receiver' }); - const { response } = await request(`${nconf.get('url')}/api/v3/chats`, { - method: 'post', - json: true, - jar: senderJar, - body: { - uids: [receiver], - }, - headers: { - 'x-csrf-token': senderCsrf, - }, - }); - await User.deleteAccount(sender); - const data = await Messaging.getRoomData(response.roomId); - assert.equal(data.owner, receiver); - }); - - it('should fail to remove user from room', async () => { - let { statusCode, body } = await callv3API('delete', `/chats/${roomId}/users`, {}, 'foo'); - assert.strictEqual(statusCode, 400); - assert.strictEqual(body.status.message, await translator.translate('[[error:required-parameters-missing, uids]]')); - - ({ statusCode, body } = await callv3API('delete', `/chats/${roomId}/users`, { uids: [null] }, 'foo')); - assert.strictEqual(statusCode, 400); - assert.strictEqual(body.status.message, await translator.translate('[[error:no-user]]')); - }); - - it('should fail to remove user from room if user does not exist', async () => { - const { statusCode, body } = await callv3API('delete', `/chats/${roomId}/users`, { uids: [99] }, 'foo'); - assert.strictEqual(statusCode, 400); - assert.strictEqual(body.status.message, await translator.translate('[[error:no-user]]')); - }); - - it('should remove user from room', async () => { - const { statusCode, body } = await callv3API('post', `/chats`, { - uids: [mocks.users.herp.uid], - }, 'foo'); - const { roomId } = body.response; - assert.strictEqual(statusCode, 200); - - let isInRoom = await Messaging.isUserInRoom(mocks.users.herp.uid, roomId); - assert(isInRoom); - - await callv3API('delete', `/chats/${roomId}/users`, { uids: [mocks.users.herp.uid] }, 'foo'); - isInRoom = await Messaging.isUserInRoom(mocks.users.herp.uid, roomId); - assert(!isInRoom); - }); - - it('should fail to send a message to room with invalid data', async () => { - let { body } = await callv3API('post', `/chats/abc`, { message: 'test' }, 'foo'); - assert.strictEqual(body.status.message, await translator.translate('[[error:invalid-data]]')); - - ({ body } = await callv3API('post', `/chats/1`, {}, 'foo')); - assert.strictEqual(body.status.message, await translator.translate('[[error:required-parameters-missing, message]]')); - }); - - it('should fail to send chat if content is empty', async () => { - const { body } = await callv3API('post', `/chats/${roomId}`, { - message: ' ', - }, 'foo'); - const { status, response } = body; - - assert.deepStrictEqual(response, {}); - assert.equal(status.message, await translator.translate('[[error:invalid-chat-message]]')); - }); - - it('should send a message to a room', async () => { - const socketMock = { uid: mocks.users.foo.uid }; - const { body } = await callv3API('post', `/chats/${roomId}`, { roomId: roomId, message: 'first chat message' }, 'foo'); - const messageData = body.response; - assert(messageData); - assert.equal(messageData.content, 'first chat message'); - assert(messageData.fromUser); - assert(messageData.roomId, roomId); - const raw = - await util.promisify(socketModules.chats.getRaw)(socketMock, { mid: messageData.mid }); - assert.equal(raw, 'first chat message'); - }); - - it('should fail to send second message due to rate limit', async () => { - const socketMock = { uid: mocks.users.foo.uid }; - const oldValue = meta.config.chatMessageDelay; - meta.config.chatMessageDelay = 1000; - - await callv3API('post', `/chats/${roomId}`, { roomId: roomId, message: 'first chat message' }, 'foo'); - const { body } = await callv3API('post', `/chats/${roomId}`, { roomId: roomId, message: 'first chat message' }, 'foo'); - const { status } = body; - assert.equal(status.message, await translator.translate('[[error:too-many-messages]]')); - meta.config.chatMessageDelay = oldValue; - }); - - it('should return invalid-data error', (done) => { - socketModules.chats.getRaw({ uid: mocks.users.foo.uid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - socketModules.chats.getRaw({ uid: mocks.users.foo.uid }, {}, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - }); - - it('should return not allowed error if mid is not in room', async () => { - const socketMock = { uid: mocks.users.foo.uid }; - const uids = await User.create({ username: 'dummy' }); - let { body } = await callv3API('post', '/chats', { uids: [uids] }, 'baz'); - const myRoomId = body.response.roomId; - assert(myRoomId); - - try { - await util.promisify(socketModules.chats.getRaw)({ uid: mocks.users.baz.uid }, { mid: 200 }); - } catch (err) { - assert(err); - assert.equal(err.message, '[[error:not-allowed]]'); - } - - ({ body } = await callv3API('post', `/chats/${myRoomId}`, { roomId: myRoomId, message: 'admin will see this' }, 'baz')); - const message = body.response; - const raw = await util.promisify(socketModules.chats.getRaw)(socketMock, { mid: message.mid }); - assert.equal(raw, 'admin will see this'); - }); - - - it('should notify offline users of message', async () => { - meta.config.notificationSendDelay = 0.1; - - const { body } = await callv3API('post', '/chats', { uids: [mocks.users.baz.uid] }, 'foo'); - const { roomId } = body.response; - assert(roomId); - - await callv3API('post', `/chats/${roomId}/users`, { uids: [mocks.users.herp.uid] }, 'foo'); - await db.sortedSetAdd('users:online', Date.now() - ((meta.config.onlineCutoff * 60000) + 50000), mocks.users.herp.uid); - - await callv3API('post', `/chats/${roomId}`, { roomId: roomId, message: 'second chat message **bold** text' }, 'foo'); - await sleep(3000); - const data = await User.notifications.get(mocks.users.herp.uid); - assert(data.unread[0]); - const notification = data.unread[0]; - assert.strictEqual(notification.bodyShort, '[[notifications:new_message_from, foo]]'); - assert.strictEqual(notification.nid, `chat_${mocks.users.foo.uid}_${roomId}`); - assert.strictEqual(notification.path, `${nconf.get('relative_path')}/chats/${roomId}`); - }); - - it('should fail to get messages from room with invalid data', (done) => { - const socketMock = { uid: mocks.users.foo.uid }; - socketModules.chats.getMessages({ uid: null }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - socketModules.chats.getMessages(socketMock, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - socketModules.chats.getMessages(socketMock, { uid: null }, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - socketModules.chats.getMessages(socketMock, { uid: 1, roomId: null }, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - }); - }); - }); - - it('should get messages from room', (done) => { - socketModules.chats.getMessages({ uid: mocks.users.foo.uid }, { - uid: mocks.users.foo.uid, - roomId: roomId, - start: 0, - }, (err, messages) => { - assert.ifError(err); - assert(Array.isArray(messages)); - - // Filter out system messages - messages = messages.filter(message => !message.system); - assert.equal(messages[0].roomId, roomId); - assert.equal(messages[0].fromuid, mocks.users.foo.uid); - done(); - }); - }); - - it('should fail to mark read with invalid data', (done) => { - socketModules.chats.markRead({ uid: null }, roomId, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - socketModules.chats.markRead({ uid: mocks.users.foo.uid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - }); - - it('should not error if user is not in room', (done) => { - socketModules.chats.markRead({ uid: mocks.users.herp.uid }, 10, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should mark room read', (done) => { - socketModules.chats.markRead({ uid: mocks.users.foo.uid }, roomId, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should mark all rooms read', (done) => { - socketModules.chats.markAllRead({ uid: mocks.users.foo.uid }, {}, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should fail to rename room with invalid data', async () => { - let { body } = await callv3API('put', `/chats/${roomId}`, { name: null }, 'foo'); - assert.strictEqual(body.status.message, await translator.translate('[[error:invalid-data]]')); - - ({ body } = await callv3API('put', `/chats/${roomId}`, {}, 'foo')); - assert.strictEqual(body.status.message, await translator.translate('[[error:required-parameters-missing, name]]')); - }); - - it('should rename room', async () => { - const { statusCode } = await callv3API('put', `/chats/${roomId}`, { name: 'new room name' }, 'foo'); - assert.strictEqual(statusCode, 200); - }); - - it('should send a room-rename system message when a room is renamed', (done) => { - socketModules.chats.getMessages( - { uid: mocks.users.foo.uid }, - { uid: mocks.users.foo.uid, roomId: roomId, start: 0 }, - (err, messages) => { - assert.ifError(err); - const message = messages.pop(); - assert.strictEqual(message.system, true); - assert.strictEqual(message.content, 'room-rename, new room name'); - done(); - } - ); - }); - - it('should fail to load room with invalid-data', async () => { - const { body } = await callv3API('get', `/chats/abc`, {}, 'foo'); - assert.strictEqual(body.status.message, await translator.translate('[[error:invalid-data]]')); - }); - - it('should fail to load room if user is not in', async () => { - const { body } = await callv3API('get', `/chats/${roomId}`, {}, 'baz'); - assert.strictEqual(body.status.message, await translator.translate('[[error:no-privileges]]')); - }); - - it('should load chat room', async () => { - const { body } = await callv3API('get', `/chats/${roomId}`, {}, 'foo'); - assert.strictEqual(body.response.roomName, 'new room name'); - }); - - it('should return true if user is dnd', (done) => { - db.setObjectField(`user:${mocks.users.herp.uid}`, 'status', 'dnd', (err) => { - assert.ifError(err); - socketModules.chats.isDnD({ uid: mocks.users.foo.uid }, mocks.users.herp.uid, (err, isDnD) => { - assert.ifError(err); - assert(isDnD); - done(); - }); - }); - }); - - it('should fail to load recent chats with invalid data', (done) => { - socketModules.chats.getRecentChats({ uid: mocks.users.foo.uid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - socketModules.chats.getRecentChats({ uid: mocks.users.foo.uid }, { after: null }, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - socketModules.chats.getRecentChats({ uid: mocks.users.foo.uid }, { after: 0, uid: null }, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - }); - }); - - it('should load recent chats of user', (done) => { - socketModules.chats.getRecentChats( - { uid: mocks.users.foo.uid }, - { after: 0, uid: mocks.users.foo.uid }, - (err, data) => { - assert.ifError(err); - assert(Array.isArray(data.rooms)); - done(); - } - ); - }); - - it('should escape teaser', async () => { - await callv3API('post', `/chats/${roomId}`, { roomId: roomId, message: ' { - socketModules.chats.hasPrivateChat({ uid: null }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - socketModules.chats.hasPrivateChat({ uid: mocks.users.foo.uid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - }); - - it('should check if user has private chat with another uid', (done) => { - socketModules.chats.hasPrivateChat({ uid: mocks.users.foo.uid }, mocks.users.herp.uid, (err, roomId) => { - assert.ifError(err); - assert(roomId); - done(); - }); - }); - }); - - describe('edit/delete', () => { - const socketModules = require('../src/socket.io/modules'); - let mid; - let mid2; - before(async () => { - await callv3API('post', `/chats/${roomId}/users`, { uids: [mocks.users.baz.uid] }, 'foo'); - let { body } = await callv3API('post', `/chats/${roomId}`, { roomId: roomId, message: 'first chat message' }, 'foo'); - mid = body.response.mid; - ({ body } = await callv3API('post', `/chats/${roomId}`, { roomId: roomId, message: 'second chat message' }, 'baz')); - mid2 = body.response.mid; - }); - - after(async () => { - await callv3API('delete', `/chats/${roomId}/users/${mocks.users.baz.uid}`, {}, 'baz'); - }); - - it('should fail to edit message with invalid data', async () => { - let { statusCode, body } = await callv3API('put', `/chats/1/messages/10000`, { message: 'foo' }, 'foo'); - assert.strictEqual(statusCode, 400); - assert.strictEqual(body.status.message, await translator.translate('[[error:invalid-mid]]')); - - ({ statusCode, body } = await callv3API('put', `/chats/${roomId}/messages/${mid}`, {}, 'foo')); - assert.strictEqual(statusCode, 400); - assert.strictEqual(body.status.message, await translator.translate('[[error:invalid-chat-message]]')); - }); - - it('should fail to edit message if new content is empty string', async () => { - const { statusCode, body } = await callv3API('put', `/chats/${roomId}/messages/${mid}`, { message: ' ' }, 'foo'); - assert.strictEqual(statusCode, 400); - assert.strictEqual(body.status.message, await translator.translate('[[error:invalid-chat-message]]')); - }); - - it('should fail to edit message if not own message', async () => { - const { statusCode, body } = await callv3API('put', `/chats/${roomId}/messages/${mid}`, { message: 'message edited' }, 'herp'); - assert.strictEqual(statusCode, 400); - assert.strictEqual(body.status.message, await translator.translate('[[error:cant-edit-chat-message]]')); - }); - - it('should fail to edit message if message not in room', async () => { - const { statusCode, body } = await callv3API('put', `/chats/${roomId}/messages/1014`, { message: 'message edited' }, 'herp'); - assert.strictEqual(statusCode, 400); - assert.strictEqual(body.status.message, await translator.translate('[[error:invalid-mid]]')); - }); - - it('should edit message', async () => { - let { statusCode, body } = await callv3API('put', `/chats/${roomId}/messages/${mid}`, { message: 'message edited' }, 'foo'); - assert.strictEqual(statusCode, 200); - assert.strictEqual(body.response.content, 'message edited'); - - ({ statusCode, body } = await callv3API('get', `/chats/${roomId}/messages/${mid}`, {}, 'foo')); - assert.strictEqual(statusCode, 200); - assert.strictEqual(body.response.content, 'message edited'); - }); - - it('should fail to delete message with invalid data', (done) => { - socketModules.chats.delete({ uid: mocks.users.foo.uid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - socketModules.chats.delete({ uid: mocks.users.foo.uid }, { roomId: null }, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - socketModules.chats.delete({ uid: mocks.users.foo.uid }, { roomId: 1, messageId: null }, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - }); - }); - - it('should fail to delete message if not owner', (done) => { - socketModules.chats.delete({ uid: mocks.users.herp.uid }, { messageId: mid, roomId: roomId }, (err) => { - assert.equal(err.message, '[[error:cant-delete-chat-message]]'); - done(); - }); - }); - - it('should mark the message as deleted', (done) => { - socketModules.chats.delete({ uid: mocks.users.foo.uid }, { messageId: mid, roomId: roomId }, (err) => { - assert.ifError(err); - db.getObjectField(`message:${mid}`, 'deleted', (err, value) => { - assert.ifError(err); - assert.strictEqual(1, parseInt(value, 10)); - done(); - }); - }); - }); - - it('should show deleted message to original users', (done) => { - socketModules.chats.getMessages( - { uid: mocks.users.foo.uid }, - { uid: mocks.users.foo.uid, roomId: roomId, start: 0 }, - (err, messages) => { - assert.ifError(err); - - // Reduce messages to their mids - const mids = messages.reduce((mids, cur) => { - mids.push(cur.messageId); - return mids; - }, []); - - assert(mids.includes(mid)); - done(); - } - ); - }); - - it('should not show deleted message to other users', (done) => { - socketModules.chats.getMessages( - { uid: mocks.users.herp.uid }, - { uid: mocks.users.herp.uid, roomId: roomId, start: 0 }, - (err, messages) => { - assert.ifError(err); - messages.forEach((msg) => { - assert(!msg.deleted || msg.content === '[[modules:chat.message-deleted]]', msg.content); - assert(!msg.deleted || msg.cleanedContent, '[[modules:chat.message-deleted]]', msg.content); - }); - done(); - } - ); - }); - - it('should error out if a message is deleted again', (done) => { - socketModules.chats.delete({ uid: mocks.users.foo.uid }, { messageId: mid, roomId: roomId }, (err) => { - assert.strictEqual('[[error:chat-deleted-already]]', err.message); - done(); - }); - }); - - it('should restore the message', (done) => { - socketModules.chats.restore({ uid: mocks.users.foo.uid }, { messageId: mid, roomId: roomId }, (err) => { - assert.ifError(err); - db.getObjectField(`message:${mid}`, 'deleted', (err, value) => { - assert.ifError(err); - assert.strictEqual(0, parseInt(value, 10)); - done(); - }); - }); - }); - - it('should error out if a message is restored again', (done) => { - socketModules.chats.restore({ uid: mocks.users.foo.uid }, { messageId: mid, roomId: roomId }, (err) => { - assert.strictEqual('[[error:chat-restored-already]]', err.message); - done(); - }); - }); - - describe('disabled via ACP', () => { - before(async () => { - meta.config.disableChatMessageEditing = true; - }); - - after(async () => { - meta.config.disableChatMessageEditing = false; - }); - - it('should error out for regular users', async () => { - try { - await socketModules.chats.delete({ uid: mocks.users.baz.uid }, { messageId: mid2, roomId: roomId }); - } catch (err) { - assert.strictEqual('[[error:chat-message-editing-disabled]]', err.message); - } - }); - - it('should succeed for administrators', async () => { - await socketModules.chats.delete({ uid: mocks.users.foo.uid }, { messageId: mid2, roomId: roomId }); - await socketModules.chats.restore({ uid: mocks.users.foo.uid }, { messageId: mid2, roomId: roomId }); - }); - - it('should succeed for global moderators', async () => { - await Groups.join(['Global Moderators'], mocks.users.baz.uid); - - await socketModules.chats.delete({ uid: mocks.users.foo.uid }, { messageId: mid2, roomId: roomId }); - await socketModules.chats.restore({ uid: mocks.users.foo.uid }, { messageId: mid2, roomId: roomId }); - - await Groups.leave(['Global Moderators'], mocks.users.baz.uid); - }); - }); - }); - - describe('controller', () => { - it('should 404 if chat is disabled', async () => { - meta.config.disableChat = 1; - const response = await request(`${nconf.get('url')}/user/baz/chats`, { - resolveWithFullResponse: true, - simple: false, - }); - - assert.equal(response.statusCode, 404); - }); - - it('should 500 for guest with no privilege error', async () => { - meta.config.disableChat = 0; - const response = await request(`${nconf.get('url')}/api/user/baz/chats`, { - resolveWithFullResponse: true, - simple: false, - json: true, - }); - const { body } = response; - - assert.equal(response.statusCode, 500); - assert.equal(body.error, '[[error:no-privileges]]'); - }); - - it('should 404 for non-existent user', async () => { - const response = await request(`${nconf.get('url')}/user/doesntexist/chats`, { - resolveWithFullResponse: true, - simple: false, - }); - - assert.equal(response.statusCode, 404); - }); - }); - - describe('logged in chat controller', () => { - let jar; - before(async () => { - ({ jar } = await helpers.loginUser('herp', 'derpderp')); - }); - - it('should return chats page data', async () => { - const response = await request(`${nconf.get('url')}/api/user/herp/chats`, { - resolveWithFullResponse: true, - simple: false, - json: true, - jar, - }); - const { body } = response; - - assert.equal(response.statusCode, 200); - assert(Array.isArray(body.rooms)); - assert.equal(body.rooms.length, 2); - assert.equal(body.title, '[[pages:chats]]'); - }); - - it('should return room data', async () => { - const response = await request(`${nconf.get('url')}/api/user/herp/chats/${roomId}`, { - resolveWithFullResponse: true, - simple: false, - json: true, - jar, - }); - const { body } = response; - - assert.equal(response.statusCode, 200); - assert.equal(body.roomId, roomId); - assert.equal(body.isOwner, false); - }); - - it('should redirect to chats page', async () => { - const res = await request(`${nconf.get('url')}/api/chats`, { - resolveWithFullResponse: true, - simple: false, - jar, - json: true, - }); - const { body } = res; - - assert.equal(res.statusCode, 200); - assert.equal(res.headers['x-redirect'], '/user/herp/chats'); - assert.equal(body, '/user/herp/chats'); - }); - - it('should return 404 if user is not in room', async () => { - const data = await util.promisify(helpers.loginUser)('baz', 'quuxquux'); - const response = await request(`${nconf.get('url')}/api/user/baz/chats/${roomId}`, { - resolveWithFullResponse: true, - simple: false, - json: true, - jar: data.jar, - }); - - assert.equal(response.statusCode, 404); - }); - }); + const mocks = { + users: { + foo: {}, // The admin + bar: {}, + baz: {}, // The user with chat restriction enabled + herp: {}, + }, + }; + let roomId; + + let chatMessageDelay; + + const callv3API = async (method, path, body, user) => { + const options = { + method, + body, + json: true, + jar: mocks.users[user].jar, + resolveWithFullResponse: true, + simple: false, + }; + + if (method !== 'get') { + options.headers = { + 'x-csrf-token': mocks.users[user].csrf, + }; + } + + return request(`${nconf.get('url')}/api/v3${path}`, options); + }; + + before(async () => { + // Create 3 users: 1 admin, 2 regular + ({ + foo: mocks.users.foo.uid, + bar: mocks.users.bar.uid, + baz: mocks.users.baz.uid, + herp: mocks.users.herp.uid, + } = await utils.promiseParallel({ + foo: User.create({username: 'foo', password: 'barbar'}), // Admin + bar: User.create({username: 'bar', password: 'bazbaz'}), // Admin + baz: User.create({username: 'baz', password: 'quuxquux'}), // Restricted user + herp: User.create({username: 'herp', password: 'derpderp'}), // A regular user + })); + + await Groups.join('administrators', mocks.users.foo.uid); + await User.setSetting(mocks.users.baz.uid, 'restrictChat', '1'); + + ({jar: mocks.users.foo.jar, csrf_token: mocks.users.foo.csrf} = await util.promisify(helpers.loginUser)('foo', 'barbar')); + ({jar: mocks.users.bar.jar, csrf_token: mocks.users.bar.csrf} = await util.promisify(helpers.loginUser)('bar', 'bazbaz')); + ({jar: mocks.users.baz.jar, csrf_token: mocks.users.baz.csrf} = await util.promisify(helpers.loginUser)('baz', 'quuxquux')); + ({jar: mocks.users.herp.jar, csrf_token: mocks.users.herp.csrf} = await util.promisify(helpers.loginUser)('herp', 'derpderp')); + + chatMessageDelay = meta.config.chatMessageDelay; + meta.config.chatMessageDelay = 0; + }); + + after(() => { + meta.configs.chatMessageDelay = chatMessageDelay; + }); + + describe('.canMessage()', () => { + it('should allow messages to be sent to an unrestricted user', done => { + Messaging.canMessageUser(mocks.users.baz.uid, mocks.users.herp.uid, error => { + assert.ifError(error); + done(); + }); + }); + + it('should NOT allow messages to be sent to a restricted user', async () => { + await User.setSetting(mocks.users.baz.uid, 'restrictChat', '1'); + try { + await Messaging.canMessageUser(mocks.users.herp.uid, mocks.users.baz.uid); + } catch (error) { + assert.strictEqual(error.message, '[[error:chat-restricted]]'); + } + }); + + it('should always allow admins through', done => { + Messaging.canMessageUser(mocks.users.foo.uid, mocks.users.baz.uid, error => { + assert.ifError(error); + done(); + }); + }); + + it('should allow messages to be sent to a restricted user if restricted user follows sender', done => { + User.follow(mocks.users.baz.uid, mocks.users.herp.uid, () => { + Messaging.canMessageUser(mocks.users.herp.uid, mocks.users.baz.uid, error => { + assert.ifError(error); + done(); + }); + }); + }); + }); + + describe('rooms', () => { + it('should fail to create a new chat room with invalid data', async () => { + const {body} = await callv3API('post', '/chats', {}, 'foo'); + assert.equal(body.status.message, await translator.translate('[[error:required-parameters-missing, uids]]')); + }); + + it('should return rate limit error on second try', async () => { + const oldValue = meta.config.chatMessageDelay; + meta.config.chatMessageDelay = 1000; + + await callv3API('post', '/chats', { + uids: [mocks.users.baz.uid], + }, 'foo'); + + const {statusCode, body} = await callv3API('post', '/chats', { + uids: [mocks.users.baz.uid], + }, 'foo'); + + assert.equal(statusCode, 400); + assert.equal(body.status.code, 'bad-request'); + assert.equal(body.status.message, await translator.translate('[[error:too-many-messages]]')); + meta.config.chatMessageDelay = oldValue; + }); + + it('should create a new chat room', async () => { + await User.setSetting(mocks.users.baz.uid, 'restrictChat', '0'); + const {body} = await callv3API('post', '/chats', { + uids: [mocks.users.baz.uid], + }, 'foo'); + await User.setSetting(mocks.users.baz.uid, 'restrictChat', '1'); + + roomId = body.response.roomId; + assert(roomId); + + await util.promisify(socketModules.chats.canMessage)({uid: mocks.users.foo.uid}, roomId); + }); + + it('should send a user-join system message when a chat room is created', async () => { + const {body} = await callv3API('get', `/chats/${roomId}`, {}, 'foo'); + const {messages} = body.response; + assert.equal(messages.length, 2); + assert.strictEqual(messages[0].system, true); + assert.strictEqual(messages[0].content, 'user-join'); + + const {statusCode, body: body2} = await callv3API('put', `/chats/${roomId}/messages/${messages[0].messageId}`, { + message: 'test', + }, 'foo'); + assert.strictEqual(statusCode, 400); + assert.equal(body2.status.message, await translator.translate('[[error:cant-edit-chat-message]]')); + }); + + it('should fail to add user to room with invalid data', async () => { + let {statusCode, body} = await callv3API('post', `/chats/${roomId}/users`, {}, 'foo'); + assert.strictEqual(statusCode, 400); + assert.strictEqual(body.status.message, await translator.translate('[[error:required-parameters-missing, uids]]')); + + ({statusCode, body} = await callv3API('post', `/chats/${roomId}/users`, {uids: [null]}, 'foo')); + assert.strictEqual(statusCode, 400); + assert.strictEqual(body.status.message, await translator.translate('[[error:no-user]]')); + }); + + it('should add a user to room', async () => { + await callv3API('post', `/chats/${roomId}/users`, {uids: [mocks.users.herp.uid]}, 'foo'); + const isInRoom = await Messaging.isUserInRoom(mocks.users.herp.uid, roomId); + assert(isInRoom); + }); + + it('should get users in room', async () => { + const {body} = await callv3API('get', `/chats/${roomId}/users`, {}, 'foo'); + assert(Array.isArray(body.response.users)); + assert.strictEqual(body.response.users.length, 3); + }); + + it('should throw error if user is not in room', async () => { + const {statusCode, body} = await callv3API('get', `/chats/${roomId}/users`, {}, 'bar'); + assert.strictEqual(statusCode, 403); + assert.equal(body.status.message, await translator.translate('[[error:no-privileges]]')); + }); + + it('should fail to add users to room if max is reached', async () => { + meta.config.maximumUsersInChatRoom = 2; + const {statusCode, body} = await callv3API('post', `/chats/${roomId}/users`, {uids: [mocks.users.bar.uid]}, 'foo'); + assert.strictEqual(statusCode, 400); + assert.equal(body.status.message, await translator.translate('[[error:cant-add-more-users-to-chat-room]]')); + meta.config.maximumUsersInChatRoom = 0; + }); + + it('should fail to add users to room if user does not exist', async () => { + const {statusCode, body} = await callv3API('post', `/chats/${roomId}/users`, {uids: [98_237_498_234]}, 'foo'); + assert.strictEqual(statusCode, 400); + assert.strictEqual(body.status.message, await translator.translate('[[error:no-user]]')); + }); + + it('should fail to add self to room', async () => { + const {statusCode, body} = await callv3API('post', `/chats/${roomId}/users`, {uids: [mocks.users.foo.uid]}, 'foo'); + assert.strictEqual(statusCode, 400); + assert.strictEqual(body.status.message, await translator.translate('[[error:cant-chat-with-yourself]]')); + }); + + it('should fail to leave room with invalid data', async () => { + let {statusCode, body} = await callv3API('delete', `/chats/${roomId}/users`, {}, 'foo'); + assert.strictEqual(statusCode, 400); + assert.strictEqual(body.status.message, await translator.translate('[[error:required-parameters-missing, uids]]')); + + ({statusCode, body} = await callv3API('delete', `/chats/${roomId}/users`, {uids: [98_237_423]}, 'foo')); + assert.strictEqual(statusCode, 400); + assert.strictEqual(body.status.message, await translator.translate('[[error:no-user]]')); + }); + + it('should leave the chat room', async () => { + await callv3API('delete', `/chats/${roomId}/users/${mocks.users.baz.uid}`, {}, 'baz'); + const isUserInRoom = await Messaging.isUserInRoom(mocks.users.baz.uid, roomId); + assert.equal(isUserInRoom, false); + const data = await Messaging.getRoomData(roomId); + assert.equal(data.owner, mocks.users.foo.uid); + }); + + it('should send a user-leave system message when a user leaves the chat room', done => { + socketModules.chats.getMessages( + {uid: mocks.users.foo.uid}, + {uid: mocks.users.foo.uid, roomId, start: 0}, + (error, messages) => { + assert.ifError(error); + assert.equal(messages.length, 4); + const message = messages.pop(); + assert.strictEqual(message.system, true); + assert.strictEqual(message.content, 'user-leave'); + done(); + }, + ); + }); + + it('should not send a user-leave system message when a user tries to leave a room they are not in', async () => { + await callv3API('delete', `/chats/${roomId}/users/${mocks.users.baz.uid}`, {}, 'baz'); + const messages = await socketModules.chats.getMessages( + {uid: mocks.users.foo.uid}, + {uid: mocks.users.foo.uid, roomId, start: 0}, + ); + assert.equal(messages.length, 4); + let message = messages.pop(); + assert.strictEqual(message.system, true); + assert.strictEqual(message.content, 'user-leave'); + + // The message before should still be a user-join + message = messages.pop(); + assert.strictEqual(message.system, true); + assert.strictEqual(message.content, 'user-join'); + }); + + it('should change owner when owner leaves room', async () => { + const {body} = await callv3API('post', '/chats', { + uids: [mocks.users.foo.uid], + }, 'herp'); + + await callv3API('post', `/chats/${body.response.roomId}/users`, {uids: [mocks.users.baz.uid]}, 'herp'); + await util.promisify(socketModules.chats.leave)({uid: mocks.users.herp.uid}, body.response.roomId); + + const data = await Messaging.getRoomData(body.response.roomId); + assert.equal(data.owner, mocks.users.foo.uid); + }); + + it('should change owner if owner is deleted', async () => { + const sender = await User.create({username: 'deleted_chat_user', password: 'barbar'}); + const {jar: senderJar, csrf_token: senderCsrf} = await util.promisify(helpers.loginUser)('deleted_chat_user', 'barbar'); + + const receiver = await User.create({username: 'receiver'}); + const {response} = await request(`${nconf.get('url')}/api/v3/chats`, { + method: 'post', + json: true, + jar: senderJar, + body: { + uids: [receiver], + }, + headers: { + 'x-csrf-token': senderCsrf, + }, + }); + await User.deleteAccount(sender); + const data = await Messaging.getRoomData(response.roomId); + assert.equal(data.owner, receiver); + }); + + it('should fail to remove user from room', async () => { + let {statusCode, body} = await callv3API('delete', `/chats/${roomId}/users`, {}, 'foo'); + assert.strictEqual(statusCode, 400); + assert.strictEqual(body.status.message, await translator.translate('[[error:required-parameters-missing, uids]]')); + + ({statusCode, body} = await callv3API('delete', `/chats/${roomId}/users`, {uids: [null]}, 'foo')); + assert.strictEqual(statusCode, 400); + assert.strictEqual(body.status.message, await translator.translate('[[error:no-user]]')); + }); + + it('should fail to remove user from room if user does not exist', async () => { + const {statusCode, body} = await callv3API('delete', `/chats/${roomId}/users`, {uids: [99]}, 'foo'); + assert.strictEqual(statusCode, 400); + assert.strictEqual(body.status.message, await translator.translate('[[error:no-user]]')); + }); + + it('should remove user from room', async () => { + const {statusCode, body} = await callv3API('post', '/chats', { + uids: [mocks.users.herp.uid], + }, 'foo'); + const {roomId} = body.response; + assert.strictEqual(statusCode, 200); + + let isInRoom = await Messaging.isUserInRoom(mocks.users.herp.uid, roomId); + assert(isInRoom); + + await callv3API('delete', `/chats/${roomId}/users`, {uids: [mocks.users.herp.uid]}, 'foo'); + isInRoom = await Messaging.isUserInRoom(mocks.users.herp.uid, roomId); + assert(!isInRoom); + }); + + it('should fail to send a message to room with invalid data', async () => { + let {body} = await callv3API('post', '/chats/abc', {message: 'test'}, 'foo'); + assert.strictEqual(body.status.message, await translator.translate('[[error:invalid-data]]')); + + ({body} = await callv3API('post', '/chats/1', {}, 'foo')); + assert.strictEqual(body.status.message, await translator.translate('[[error:required-parameters-missing, message]]')); + }); + + it('should fail to send chat if content is empty', async () => { + const {body} = await callv3API('post', `/chats/${roomId}`, { + message: ' ', + }, 'foo'); + const {status, response} = body; + + assert.deepStrictEqual(response, {}); + assert.equal(status.message, await translator.translate('[[error:invalid-chat-message]]')); + }); + + it('should send a message to a room', async () => { + const socketMock = {uid: mocks.users.foo.uid}; + const {body} = await callv3API('post', `/chats/${roomId}`, {roomId, message: 'first chat message'}, 'foo'); + const messageData = body.response; + assert(messageData); + assert.equal(messageData.content, 'first chat message'); + assert(messageData.fromUser); + assert(messageData.roomId, roomId); + const raw + = await util.promisify(socketModules.chats.getRaw)(socketMock, {mid: messageData.mid}); + assert.equal(raw, 'first chat message'); + }); + + it('should fail to send second message due to rate limit', async () => { + const socketMock = {uid: mocks.users.foo.uid}; + const oldValue = meta.config.chatMessageDelay; + meta.config.chatMessageDelay = 1000; + + await callv3API('post', `/chats/${roomId}`, {roomId, message: 'first chat message'}, 'foo'); + const {body} = await callv3API('post', `/chats/${roomId}`, {roomId, message: 'first chat message'}, 'foo'); + const {status} = body; + assert.equal(status.message, await translator.translate('[[error:too-many-messages]]')); + meta.config.chatMessageDelay = oldValue; + }); + + it('should return invalid-data error', done => { + socketModules.chats.getRaw({uid: mocks.users.foo.uid}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + socketModules.chats.getRaw({uid: mocks.users.foo.uid}, {}, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + }); + + it('should return not allowed error if mid is not in room', async () => { + const socketMock = {uid: mocks.users.foo.uid}; + const uids = await User.create({username: 'dummy'}); + let {body} = await callv3API('post', '/chats', {uids: [uids]}, 'baz'); + const myRoomId = body.response.roomId; + assert(myRoomId); + + try { + await util.promisify(socketModules.chats.getRaw)({uid: mocks.users.baz.uid}, {mid: 200}); + } catch (error) { + assert(error); + assert.equal(error.message, '[[error:not-allowed]]'); + } + + ({body} = await callv3API('post', `/chats/${myRoomId}`, {roomId: myRoomId, message: 'admin will see this'}, 'baz')); + const message = body.response; + const raw = await util.promisify(socketModules.chats.getRaw)(socketMock, {mid: message.mid}); + assert.equal(raw, 'admin will see this'); + }); + + it('should notify offline users of message', async () => { + meta.config.notificationSendDelay = 0.1; + + const {body} = await callv3API('post', '/chats', {uids: [mocks.users.baz.uid]}, 'foo'); + const {roomId} = body.response; + assert(roomId); + + await callv3API('post', `/chats/${roomId}/users`, {uids: [mocks.users.herp.uid]}, 'foo'); + await db.sortedSetAdd('users:online', Date.now() - ((meta.config.onlineCutoff * 60_000) + 50_000), mocks.users.herp.uid); + + await callv3API('post', `/chats/${roomId}`, {roomId, message: 'second chat message **bold** text'}, 'foo'); + await sleep(3000); + const data = await User.notifications.get(mocks.users.herp.uid); + assert(data.unread[0]); + const notification = data.unread[0]; + assert.strictEqual(notification.bodyShort, '[[notifications:new_message_from, foo]]'); + assert.strictEqual(notification.nid, `chat_${mocks.users.foo.uid}_${roomId}`); + assert.strictEqual(notification.path, `${nconf.get('relative_path')}/chats/${roomId}`); + }); + + it('should fail to get messages from room with invalid data', done => { + const socketMock = {uid: mocks.users.foo.uid}; + socketModules.chats.getMessages({uid: null}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + socketModules.chats.getMessages(socketMock, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + socketModules.chats.getMessages(socketMock, {uid: null}, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + socketModules.chats.getMessages(socketMock, {uid: 1, roomId: null}, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + }); + }); + }); + + it('should get messages from room', done => { + socketModules.chats.getMessages({uid: mocks.users.foo.uid}, { + uid: mocks.users.foo.uid, + roomId, + start: 0, + }, (error, messages) => { + assert.ifError(error); + assert(Array.isArray(messages)); + + // Filter out system messages + messages = messages.filter(message => !message.system); + assert.equal(messages[0].roomId, roomId); + assert.equal(messages[0].fromuid, mocks.users.foo.uid); + done(); + }); + }); + + it('should fail to mark read with invalid data', done => { + socketModules.chats.markRead({uid: null}, roomId, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + socketModules.chats.markRead({uid: mocks.users.foo.uid}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + }); + + it('should not error if user is not in room', done => { + socketModules.chats.markRead({uid: mocks.users.herp.uid}, 10, error => { + assert.ifError(error); + done(); + }); + }); + + it('should mark room read', done => { + socketModules.chats.markRead({uid: mocks.users.foo.uid}, roomId, error => { + assert.ifError(error); + done(); + }); + }); + + it('should mark all rooms read', done => { + socketModules.chats.markAllRead({uid: mocks.users.foo.uid}, {}, error => { + assert.ifError(error); + done(); + }); + }); + + it('should fail to rename room with invalid data', async () => { + let {body} = await callv3API('put', `/chats/${roomId}`, {name: null}, 'foo'); + assert.strictEqual(body.status.message, await translator.translate('[[error:invalid-data]]')); + + ({body} = await callv3API('put', `/chats/${roomId}`, {}, 'foo')); + assert.strictEqual(body.status.message, await translator.translate('[[error:required-parameters-missing, name]]')); + }); + + it('should rename room', async () => { + const {statusCode} = await callv3API('put', `/chats/${roomId}`, {name: 'new room name'}, 'foo'); + assert.strictEqual(statusCode, 200); + }); + + it('should send a room-rename system message when a room is renamed', done => { + socketModules.chats.getMessages( + {uid: mocks.users.foo.uid}, + {uid: mocks.users.foo.uid, roomId, start: 0}, + (error, messages) => { + assert.ifError(error); + const message = messages.pop(); + assert.strictEqual(message.system, true); + assert.strictEqual(message.content, 'room-rename, new room name'); + done(); + }, + ); + }); + + it('should fail to load room with invalid-data', async () => { + const {body} = await callv3API('get', '/chats/abc', {}, 'foo'); + assert.strictEqual(body.status.message, await translator.translate('[[error:invalid-data]]')); + }); + + it('should fail to load room if user is not in', async () => { + const {body} = await callv3API('get', `/chats/${roomId}`, {}, 'baz'); + assert.strictEqual(body.status.message, await translator.translate('[[error:no-privileges]]')); + }); + + it('should load chat room', async () => { + const {body} = await callv3API('get', `/chats/${roomId}`, {}, 'foo'); + assert.strictEqual(body.response.roomName, 'new room name'); + }); + + it('should return true if user is dnd', done => { + db.setObjectField(`user:${mocks.users.herp.uid}`, 'status', 'dnd', error => { + assert.ifError(error); + socketModules.chats.isDnD({uid: mocks.users.foo.uid}, mocks.users.herp.uid, (error, isDnD) => { + assert.ifError(error); + assert(isDnD); + done(); + }); + }); + }); + + it('should fail to load recent chats with invalid data', done => { + socketModules.chats.getRecentChats({uid: mocks.users.foo.uid}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + socketModules.chats.getRecentChats({uid: mocks.users.foo.uid}, {after: null}, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + socketModules.chats.getRecentChats({uid: mocks.users.foo.uid}, {after: 0, uid: null}, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + }); + }); + + it('should load recent chats of user', done => { + socketModules.chats.getRecentChats( + {uid: mocks.users.foo.uid}, + {after: 0, uid: mocks.users.foo.uid}, + (error, data) => { + assert.ifError(error); + assert(Array.isArray(data.rooms)); + done(); + }, + ); + }); + + it('should escape teaser', async () => { + await callv3API('post', `/chats/${roomId}`, {roomId, message: ' { + socketModules.chats.hasPrivateChat({uid: null}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + socketModules.chats.hasPrivateChat({uid: mocks.users.foo.uid}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + }); + + it('should check if user has private chat with another uid', done => { + socketModules.chats.hasPrivateChat({uid: mocks.users.foo.uid}, mocks.users.herp.uid, (error, roomId) => { + assert.ifError(error); + assert(roomId); + done(); + }); + }); + }); + + describe('edit/delete', () => { + const socketModules = require('../src/socket.io/modules'); + let mid; + let mid2; + before(async () => { + await callv3API('post', `/chats/${roomId}/users`, {uids: [mocks.users.baz.uid]}, 'foo'); + let {body} = await callv3API('post', `/chats/${roomId}`, {roomId, message: 'first chat message'}, 'foo'); + mid = body.response.mid; + ({body} = await callv3API('post', `/chats/${roomId}`, {roomId, message: 'second chat message'}, 'baz')); + mid2 = body.response.mid; + }); + + after(async () => { + await callv3API('delete', `/chats/${roomId}/users/${mocks.users.baz.uid}`, {}, 'baz'); + }); + + it('should fail to edit message with invalid data', async () => { + let {statusCode, body} = await callv3API('put', '/chats/1/messages/10000', {message: 'foo'}, 'foo'); + assert.strictEqual(statusCode, 400); + assert.strictEqual(body.status.message, await translator.translate('[[error:invalid-mid]]')); + + ({statusCode, body} = await callv3API('put', `/chats/${roomId}/messages/${mid}`, {}, 'foo')); + assert.strictEqual(statusCode, 400); + assert.strictEqual(body.status.message, await translator.translate('[[error:invalid-chat-message]]')); + }); + + it('should fail to edit message if new content is empty string', async () => { + const {statusCode, body} = await callv3API('put', `/chats/${roomId}/messages/${mid}`, {message: ' '}, 'foo'); + assert.strictEqual(statusCode, 400); + assert.strictEqual(body.status.message, await translator.translate('[[error:invalid-chat-message]]')); + }); + + it('should fail to edit message if not own message', async () => { + const {statusCode, body} = await callv3API('put', `/chats/${roomId}/messages/${mid}`, {message: 'message edited'}, 'herp'); + assert.strictEqual(statusCode, 400); + assert.strictEqual(body.status.message, await translator.translate('[[error:cant-edit-chat-message]]')); + }); + + it('should fail to edit message if message not in room', async () => { + const {statusCode, body} = await callv3API('put', `/chats/${roomId}/messages/1014`, {message: 'message edited'}, 'herp'); + assert.strictEqual(statusCode, 400); + assert.strictEqual(body.status.message, await translator.translate('[[error:invalid-mid]]')); + }); + + it('should edit message', async () => { + let {statusCode, body} = await callv3API('put', `/chats/${roomId}/messages/${mid}`, {message: 'message edited'}, 'foo'); + assert.strictEqual(statusCode, 200); + assert.strictEqual(body.response.content, 'message edited'); + + ({statusCode, body} = await callv3API('get', `/chats/${roomId}/messages/${mid}`, {}, 'foo')); + assert.strictEqual(statusCode, 200); + assert.strictEqual(body.response.content, 'message edited'); + }); + + it('should fail to delete message with invalid data', done => { + socketModules.chats.delete({uid: mocks.users.foo.uid}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + socketModules.chats.delete({uid: mocks.users.foo.uid}, {roomId: null}, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + socketModules.chats.delete({uid: mocks.users.foo.uid}, {roomId: 1, messageId: null}, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + }); + }); + + it('should fail to delete message if not owner', done => { + socketModules.chats.delete({uid: mocks.users.herp.uid}, {messageId: mid, roomId}, error => { + assert.equal(error.message, '[[error:cant-delete-chat-message]]'); + done(); + }); + }); + + it('should mark the message as deleted', done => { + socketModules.chats.delete({uid: mocks.users.foo.uid}, {messageId: mid, roomId}, error => { + assert.ifError(error); + db.getObjectField(`message:${mid}`, 'deleted', (error, value) => { + assert.ifError(error); + assert.strictEqual(1, Number.parseInt(value, 10)); + done(); + }); + }); + }); + + it('should show deleted message to original users', done => { + socketModules.chats.getMessages( + {uid: mocks.users.foo.uid}, + {uid: mocks.users.foo.uid, roomId, start: 0}, + (error, messages) => { + assert.ifError(error); + + // Reduce messages to their mids + const mids = messages.reduce((mids, current) => { + mids.push(current.messageId); + return mids; + }, []); + + assert(mids.includes(mid)); + done(); + }, + ); + }); + + it('should not show deleted message to other users', done => { + socketModules.chats.getMessages( + {uid: mocks.users.herp.uid}, + {uid: mocks.users.herp.uid, roomId, start: 0}, + (error, messages) => { + assert.ifError(error); + for (const message of messages) { + assert(!message.deleted || message.content === '[[modules:chat.message-deleted]]', message.content); + assert(!message.deleted || message.cleanedContent, '[[modules:chat.message-deleted]]', message.content); + } + + done(); + }, + ); + }); + + it('should error out if a message is deleted again', done => { + socketModules.chats.delete({uid: mocks.users.foo.uid}, {messageId: mid, roomId}, error => { + assert.strictEqual('[[error:chat-deleted-already]]', error.message); + done(); + }); + }); + + it('should restore the message', done => { + socketModules.chats.restore({uid: mocks.users.foo.uid}, {messageId: mid, roomId}, error => { + assert.ifError(error); + db.getObjectField(`message:${mid}`, 'deleted', (error, value) => { + assert.ifError(error); + assert.strictEqual(0, Number.parseInt(value, 10)); + done(); + }); + }); + }); + + it('should error out if a message is restored again', done => { + socketModules.chats.restore({uid: mocks.users.foo.uid}, {messageId: mid, roomId}, error => { + assert.strictEqual('[[error:chat-restored-already]]', error.message); + done(); + }); + }); + + describe('disabled via ACP', () => { + before(async () => { + meta.config.disableChatMessageEditing = true; + }); + + after(async () => { + meta.config.disableChatMessageEditing = false; + }); + + it('should error out for regular users', async () => { + try { + await socketModules.chats.delete({uid: mocks.users.baz.uid}, {messageId: mid2, roomId}); + } catch (error) { + assert.strictEqual('[[error:chat-message-editing-disabled]]', error.message); + } + }); + + it('should succeed for administrators', async () => { + await socketModules.chats.delete({uid: mocks.users.foo.uid}, {messageId: mid2, roomId}); + await socketModules.chats.restore({uid: mocks.users.foo.uid}, {messageId: mid2, roomId}); + }); + + it('should succeed for global moderators', async () => { + await Groups.join(['Global Moderators'], mocks.users.baz.uid); + + await socketModules.chats.delete({uid: mocks.users.foo.uid}, {messageId: mid2, roomId}); + await socketModules.chats.restore({uid: mocks.users.foo.uid}, {messageId: mid2, roomId}); + + await Groups.leave(['Global Moderators'], mocks.users.baz.uid); + }); + }); + }); + + describe('controller', () => { + it('should 404 if chat is disabled', async () => { + meta.config.disableChat = 1; + const response = await request(`${nconf.get('url')}/user/baz/chats`, { + resolveWithFullResponse: true, + simple: false, + }); + + assert.equal(response.statusCode, 404); + }); + + it('should 500 for guest with no privilege error', async () => { + meta.config.disableChat = 0; + const response = await request(`${nconf.get('url')}/api/user/baz/chats`, { + resolveWithFullResponse: true, + simple: false, + json: true, + }); + const {body} = response; + + assert.equal(response.statusCode, 500); + assert.equal(body.error, '[[error:no-privileges]]'); + }); + + it('should 404 for non-existent user', async () => { + const response = await request(`${nconf.get('url')}/user/doesntexist/chats`, { + resolveWithFullResponse: true, + simple: false, + }); + + assert.equal(response.statusCode, 404); + }); + }); + + describe('logged in chat controller', () => { + let jar; + before(async () => { + ({jar} = await helpers.loginUser('herp', 'derpderp')); + }); + + it('should return chats page data', async () => { + const response = await request(`${nconf.get('url')}/api/user/herp/chats`, { + resolveWithFullResponse: true, + simple: false, + json: true, + jar, + }); + const {body} = response; + + assert.equal(response.statusCode, 200); + assert(Array.isArray(body.rooms)); + assert.equal(body.rooms.length, 2); + assert.equal(body.title, '[[pages:chats]]'); + }); + + it('should return room data', async () => { + const response = await request(`${nconf.get('url')}/api/user/herp/chats/${roomId}`, { + resolveWithFullResponse: true, + simple: false, + json: true, + jar, + }); + const {body} = response; + + assert.equal(response.statusCode, 200); + assert.equal(body.roomId, roomId); + assert.equal(body.isOwner, false); + }); + + it('should redirect to chats page', async () => { + const res = await request(`${nconf.get('url')}/api/chats`, { + resolveWithFullResponse: true, + simple: false, + jar, + json: true, + }); + const {body} = res; + + assert.equal(res.statusCode, 200); + assert.equal(res.headers['x-redirect'], '/user/herp/chats'); + assert.equal(body, '/user/herp/chats'); + }); + + it('should return 404 if user is not in room', async () => { + const data = await util.promisify(helpers.loginUser)('baz', 'quuxquux'); + const response = await request(`${nconf.get('url')}/api/user/baz/chats/${roomId}`, { + resolveWithFullResponse: true, + simple: false, + json: true, + jar: data.jar, + }); + + assert.equal(response.statusCode, 404); + }); + }); }); diff --git a/test/meta.js b/test/meta.js index 35b9838..bd31e05 100644 --- a/test/meta.js +++ b/test/meta.js @@ -1,611 +1,607 @@ 'use strict'; -const assert = require('assert'); +const assert = require('node:assert'); const async = require('async'); const request = require('request'); const nconf = require('nconf'); - -const db = require('./mocks/databasemock'); const meta = require('../src/meta'); const User = require('../src/user'); const Groups = require('../src/groups'); +const db = require('./mocks/databasemock'); describe('meta', () => { - let fooUid; - let bazUid; - let herpUid; - - before((done) => { - Groups.cache.reset(); - // Create 3 users: 1 admin, 2 regular - async.series([ - async.apply(User.create, { username: 'foo', password: 'barbar' }), // admin - async.apply(User.create, { username: 'baz', password: 'quuxquux' }), // restricted user - async.apply(User.create, { username: 'herp', password: 'derpderp' }), // regular user - ], (err, uids) => { - if (err) { - return done(err); - } - - fooUid = uids[0]; - bazUid = uids[1]; - herpUid = uids[2]; - - Groups.join('administrators', fooUid, done); - }); - }); - - describe('settings', () => { - const socketAdmin = require('../src/socket.io/admin'); - it('it should set setting', (done) => { - socketAdmin.settings.set({ uid: fooUid }, { hash: 'some:hash', values: { foo: '1', derp: 'value' } }, (err) => { - assert.ifError(err); - db.getObject('settings:some:hash', (err, data) => { - assert.ifError(err); - assert.equal(data.foo, '1'); - assert.equal(data.derp, 'value'); - done(); - }); - }); - }); - - it('it should get setting', (done) => { - socketAdmin.settings.get({ uid: fooUid }, { hash: 'some:hash' }, (err, data) => { - assert.ifError(err); - assert.equal(data.foo, '1'); - assert.equal(data.derp, 'value'); - done(); - }); - }); - - it('should not set setting if not empty', (done) => { - meta.settings.setOnEmpty('some:hash', { foo: 2 }, (err) => { - assert.ifError(err); - db.getObject('settings:some:hash', (err, data) => { - assert.ifError(err); - assert.equal(data.foo, '1'); - assert.equal(data.derp, 'value'); - done(); - }); - }); - }); - - it('should set setting if empty', (done) => { - meta.settings.setOnEmpty('some:hash', { empty: '2' }, (err) => { - assert.ifError(err); - db.getObject('settings:some:hash', (err, data) => { - assert.ifError(err); - assert.equal(data.foo, '1'); - assert.equal(data.derp, 'value'); - assert.equal(data.empty, '2'); - done(); - }); - }); - }); - - it('should set one and get one', (done) => { - meta.settings.setOne('some:hash', 'myField', 'myValue', (err) => { - assert.ifError(err); - meta.settings.getOne('some:hash', 'myField', (err, myValue) => { - assert.ifError(err); - assert.equal(myValue, 'myValue'); - done(); - }); - }); - }); - - it('should return null if setting field does not exist', async () => { - const val = await meta.settings.getOne('some:hash', 'does not exist'); - assert.strictEqual(val, null); - }); - - const someList = [ - { name: 'andrew', status: 'best' }, - { name: 'baris', status: 'wurst' }, - ]; - const anotherList = []; - - it('should set setting with sorted list', (done) => { - socketAdmin.settings.set({ uid: fooUid }, { hash: 'another:hash', values: { foo: '1', derp: 'value', someList: someList, anotherList: anotherList } }, (err) => { - if (err) { - return done(err); - } - - db.getObject('settings:another:hash', (err, data) => { - if (err) { - return done(err); - } - - assert.equal(data.foo, '1'); - assert.equal(data.derp, 'value'); - assert.equal(data.someList, undefined); - assert.equal(data.anotherList, undefined); - done(); - }); - }); - }); - - it('should get setting with sorted list', (done) => { - socketAdmin.settings.get({ uid: fooUid }, { hash: 'another:hash' }, (err, data) => { - assert.ifError(err); - assert.strictEqual(data.foo, '1'); - assert.strictEqual(data.derp, 'value'); - assert.deepStrictEqual(data.someList, someList); - assert.deepStrictEqual(data.anotherList, anotherList); - done(); - }); - }); - - it('should not set setting if not empty', (done) => { - meta.settings.setOnEmpty('some:hash', { foo: 2 }, (err) => { - assert.ifError(err); - db.getObject('settings:some:hash', (err, data) => { - assert.ifError(err); - assert.equal(data.foo, '1'); - assert.equal(data.derp, 'value'); - done(); - }); - }); - }); - - it('should not set setting with sorted list if not empty', (done) => { - meta.settings.setOnEmpty('another:hash', { foo: anotherList }, (err) => { - assert.ifError(err); - socketAdmin.settings.get({ uid: fooUid }, { hash: 'another:hash' }, (err, data) => { - assert.ifError(err); - assert.equal(data.foo, '1'); - assert.equal(data.derp, 'value'); - done(); - }); - }); - }); - - it('should set setting with sorted list if empty', (done) => { - meta.settings.setOnEmpty('another:hash', { empty: someList }, (err) => { - assert.ifError(err); - socketAdmin.settings.get({ uid: fooUid }, { hash: 'another:hash' }, (err, data) => { - assert.ifError(err); - assert.equal(data.foo, '1'); - assert.equal(data.derp, 'value'); - assert.deepEqual(data.empty, someList); - done(); - }); - }); - }); - - it('should set one and get one sorted list', (done) => { - meta.settings.setOne('another:hash', 'someList', someList, (err) => { - assert.ifError(err); - meta.settings.getOne('another:hash', 'someList', (err, _someList) => { - assert.ifError(err); - assert.deepEqual(_someList, someList); - done(); - }); - }); - }); - }); - - - describe('config', () => { - const socketAdmin = require('../src/socket.io/admin'); - before((done) => { - db.setObject('config', { minimumTagLength: 3, maximumTagLength: 15 }, done); - }); - - it('should get config fields', (done) => { - meta.configs.getFields(['minimumTagLength', 'maximumTagLength'], (err, data) => { - assert.ifError(err); - assert.strictEqual(data.minimumTagLength, 3); - assert.strictEqual(data.maximumTagLength, 15); - done(); - }); - }); - - it('should get the correct type and default value', (done) => { - meta.configs.set('loginAttempts', '', (err) => { - assert.ifError(err); - meta.configs.get('loginAttempts', (err, value) => { - assert.ifError(err); - assert.strictEqual(value, 5); - done(); - }); - }); - }); - - it('should get the correct type and correct value', (done) => { - meta.configs.set('loginAttempts', '0', (err) => { - assert.ifError(err); - meta.configs.get('loginAttempts', (err, value) => { - assert.ifError(err); - assert.strictEqual(value, 0); - done(); - }); - }); - }); - - it('should get the correct value', (done) => { - meta.configs.set('title', 123, (err) => { - assert.ifError(err); - meta.configs.get('title', (err, value) => { - assert.ifError(err); - assert.strictEqual(value, '123'); - done(); - }); - }); - }); - - it('should get the correct value', (done) => { - meta.configs.set('title', 0, (err) => { - assert.ifError(err); - meta.configs.get('title', (err, value) => { - assert.ifError(err); - assert.strictEqual(value, '0'); - done(); - }); - }); - }); - - it('should get the correct value', (done) => { - meta.configs.set('title', '', (err) => { - assert.ifError(err); - meta.configs.get('title', (err, value) => { - assert.ifError(err); - assert.strictEqual(value, ''); - done(); - }); - }); - }); - - it('should use default value if value is null', (done) => { - meta.configs.set('teaserPost', null, (err) => { - assert.ifError(err); - meta.configs.get('teaserPost', (err, value) => { - assert.ifError(err); - assert.strictEqual(value, 'last-reply'); - done(); - }); - }); - }); - - it('should fail if field is invalid', (done) => { - meta.configs.set('', 'someValue', (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should fail if data is invalid', (done) => { - socketAdmin.config.set({ uid: fooUid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should set multiple config values', (done) => { - socketAdmin.config.set({ uid: fooUid }, { key: 'someKey', value: 'someValue' }, (err) => { - assert.ifError(err); - meta.configs.getFields(['someKey'], (err, data) => { - assert.ifError(err); - assert.equal(data.someKey, 'someValue'); - done(); - }); - }); - }); - - it('should set config value', (done) => { - meta.configs.set('someField', 'someValue', (err) => { - assert.ifError(err); - meta.configs.getFields(['someField'], (err, data) => { - assert.ifError(err); - assert.strictEqual(data.someField, 'someValue'); - done(); - }); - }); - }); - - it('should get back string if field is not in defaults', (done) => { - meta.configs.set('numericField', 123, (err) => { - assert.ifError(err); - meta.configs.getFields(['numericField'], (err, data) => { - assert.ifError(err); - assert.strictEqual(data.numericField, 123); - done(); - }); - }); - }); - - it('should set boolean config value', (done) => { - meta.configs.set('booleanField', true, (err) => { - assert.ifError(err); - meta.configs.getFields(['booleanField'], (err, data) => { - assert.ifError(err); - assert.strictEqual(data.booleanField, true); - done(); - }); - }); - }); - - it('should set boolean config value', (done) => { - meta.configs.set('booleanField', 'false', (err) => { - assert.ifError(err); - meta.configs.getFields(['booleanField'], (err, data) => { - assert.ifError(err); - assert.strictEqual(data.booleanField, false); - done(); - }); - }); - }); - - it('should set string config value', (done) => { - meta.configs.set('stringField', '123', (err) => { - assert.ifError(err); - meta.configs.getFields(['stringField'], (err, data) => { - assert.ifError(err); - assert.strictEqual(data.stringField, 123); - done(); - }); - }); - }); - - it('should fail if data is invalid', (done) => { - socketAdmin.config.setMultiple({ uid: fooUid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should set multiple values', (done) => { - socketAdmin.config.setMultiple({ uid: fooUid }, { - someField1: 'someValue1', - someField2: 'someValue2', - customCSS: '.derp{color:#00ff00;}', - }, (err) => { - assert.ifError(err); - meta.configs.getFields(['someField1', 'someField2'], (err, data) => { - assert.ifError(err); - assert.equal(data.someField1, 'someValue1'); - assert.equal(data.someField2, 'someValue2'); - done(); - }); - }); - }); - - it('should not set config if not empty', (done) => { - meta.configs.setOnEmpty({ someField1: 'foo' }, (err) => { - assert.ifError(err); - meta.configs.get('someField1', (err, value) => { - assert.ifError(err); - assert.equal(value, 'someValue1'); - done(); - }); - }); - }); - - it('should remove config field', (done) => { - socketAdmin.config.remove({ uid: fooUid }, 'someField1', (err) => { - assert.ifError(err); - db.isObjectField('config', 'someField1', (err, isObjectField) => { - assert.ifError(err); - assert(!isObjectField); - done(); - }); - }); - }); - }); - - - describe('session TTL', () => { - it('should return 14 days in seconds', (done) => { - assert(meta.getSessionTTLSeconds(), 1209600); - done(); - }); - - it('should return 7 days in seconds', (done) => { - meta.config.loginDays = 7; - assert(meta.getSessionTTLSeconds(), 604800); - done(); - }); - - it('should return 2 days in seconds', (done) => { - meta.config.loginSeconds = 172800; - assert(meta.getSessionTTLSeconds(), 172800); - done(); - }); - }); - - describe('dependencies', () => { - it('should return ENOENT if module is not found', (done) => { - meta.dependencies.checkModule('some-module-that-does-not-exist', (err) => { - assert.equal(err.code, 'ENOENT'); - done(); - }); - }); - - it('should not error if module is a nodebb-plugin-*', (done) => { - meta.dependencies.checkModule('nodebb-plugin-somePlugin', (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should not error if module is nodebb-theme-*', (done) => { - meta.dependencies.checkModule('nodebb-theme-someTheme', (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should parse json package data', (done) => { - const pkgData = meta.dependencies.parseModuleData('nodebb-plugin-test', '{"a": 1}'); - assert.equal(pkgData.a, 1); - done(); - }); - - it('should return null data with invalid json', (done) => { - const pkgData = meta.dependencies.parseModuleData('nodebb-plugin-test', 'asdasd'); - assert.strictEqual(pkgData, null); - done(); - }); - - it('should return false if moduleData is falsy', (done) => { - assert(!meta.dependencies.doesSatisfy(null, '1.0.0')); - done(); - }); - - it('should return false if moduleData doesnt not satisfy package.json', (done) => { - assert(!meta.dependencies.doesSatisfy({ name: 'nodebb-plugin-test', version: '0.9.0' }, '1.0.0')); - done(); - }); - - it('should return true if _resolved is from github', (done) => { - assert(meta.dependencies.doesSatisfy({ name: 'nodebb-plugin-test', _resolved: 'https://github.com/some/repo', version: '0.9.0' }, '1.0.0')); - done(); - }); - }); - - describe('debugFork', () => { - let oldArgv; - before(() => { - oldArgv = process.execArgv; - process.execArgv = ['--debug=5858', '--foo=1']; - }); - - it('should detect debugging', (done) => { - let debugFork = require('../src/meta/debugFork'); - assert(!debugFork.debugging); - - const debugForkPath = require.resolve('../src/meta/debugFork'); - delete require.cache[debugForkPath]; - - debugFork = require('../src/meta/debugFork'); - assert(debugFork.debugging); - - done(); - }); - - after(() => { - process.execArgv = oldArgv; - }); - }); - - describe('Access-Control-Allow-Origin', () => { - it('Access-Control-Allow-Origin header should be empty', (done) => { - const jar = request.jar(); - request.get(`${nconf.get('url')}/api/search?term=bug`, { - form: {}, - json: true, - jar: jar, - }, (err, response, body) => { - assert.ifError(err); - assert.equal(response.headers['access-control-allow-origin'], undefined); - done(); - }); - }); - - it('should set proper Access-Control-Allow-Origin header', (done) => { - const jar = request.jar(); - const oldValue = meta.config['access-control-allow-origin']; - meta.config['access-control-allow-origin'] = 'test.com, mydomain.com'; - request.get(`${nconf.get('url')}/api/search?term=bug`, { - form: { - }, - json: true, - jar: jar, - headers: { - origin: 'mydomain.com', - }, - }, (err, response, body) => { - assert.ifError(err); - assert.equal(response.headers['access-control-allow-origin'], 'mydomain.com'); - meta.config['access-control-allow-origin'] = oldValue; - done(err); - }); - }); - - it('Access-Control-Allow-Origin header should be empty if origin does not match', (done) => { - const jar = request.jar(); - const oldValue = meta.config['access-control-allow-origin']; - meta.config['access-control-allow-origin'] = 'test.com, mydomain.com'; - request.get(`${nconf.get('url')}/api/search?term=bug`, { - form: { - }, - json: true, - jar: jar, - headers: { - origin: 'notallowed.com', - }, - }, (err, response, body) => { - assert.ifError(err); - assert.equal(response.headers['access-control-allow-origin'], undefined); - meta.config['access-control-allow-origin'] = oldValue; - done(err); - }); - }); - - it('should set proper Access-Control-Allow-Origin header', (done) => { - const jar = request.jar(); - const oldValue = meta.config['access-control-allow-origin-regex']; - meta.config['access-control-allow-origin-regex'] = 'match\\.this\\..+\\.domain.com, mydomain\\.com'; - request.get(`${nconf.get('url')}/api/search?term=bug`, { - form: { - }, - json: true, - jar: jar, - headers: { - origin: 'match.this.anything123.domain.com', - }, - }, (err, response, body) => { - assert.ifError(err); - assert.equal(response.headers['access-control-allow-origin'], 'match.this.anything123.domain.com'); - meta.config['access-control-allow-origin-regex'] = oldValue; - done(err); - }); - }); - - it('Access-Control-Allow-Origin header should be empty if origin does not match', (done) => { - const jar = request.jar(); - const oldValue = meta.config['access-control-allow-origin-regex']; - meta.config['access-control-allow-origin-regex'] = 'match\\.this\\..+\\.domain.com, mydomain\\.com'; - request.get(`${nconf.get('url')}/api/search?term=bug`, { - form: { - }, - json: true, - jar: jar, - headers: { - origin: 'notallowed.com', - }, - }, (err, response, body) => { - assert.ifError(err); - assert.equal(response.headers['access-control-allow-origin'], undefined); - meta.config['access-control-allow-origin-regex'] = oldValue; - done(err); - }); - }); - - it('should not error with invalid regexp', (done) => { - const jar = request.jar(); - const oldValue = meta.config['access-control-allow-origin-regex']; - meta.config['access-control-allow-origin-regex'] = '[match\\.this\\..+\\.domain.com, mydomain\\.com'; - request.get(`${nconf.get('url')}/api/search?term=bug`, { - form: { - }, - json: true, - jar: jar, - headers: { - origin: 'mydomain.com', - }, - }, (err, response, body) => { - assert.ifError(err); - assert.equal(response.headers['access-control-allow-origin'], 'mydomain.com'); - meta.config['access-control-allow-origin-regex'] = oldValue; - done(err); - }); - }); - }); - - it('should log targets', (done) => { - const aliases = require('../src/meta/aliases'); - aliases.buildTargets(); - done(); - }); + let fooUid; + let bazUid; + let herpUid; + + before(done => { + Groups.cache.reset(); + // Create 3 users: 1 admin, 2 regular + async.series([ + async.apply(User.create, {username: 'foo', password: 'barbar'}), // Admin + async.apply(User.create, {username: 'baz', password: 'quuxquux'}), // Restricted user + async.apply(User.create, {username: 'herp', password: 'derpderp'}), // Regular user + ], (error, uids) => { + if (error) { + return done(error); + } + + fooUid = uids[0]; + bazUid = uids[1]; + herpUid = uids[2]; + + Groups.join('administrators', fooUid, done); + }); + }); + + describe('settings', () => { + const socketAdmin = require('../src/socket.io/admin'); + it('it should set setting', done => { + socketAdmin.settings.set({uid: fooUid}, {hash: 'some:hash', values: {foo: '1', derp: 'value'}}, error => { + assert.ifError(error); + db.getObject('settings:some:hash', (error, data) => { + assert.ifError(error); + assert.equal(data.foo, '1'); + assert.equal(data.derp, 'value'); + done(); + }); + }); + }); + + it('it should get setting', done => { + socketAdmin.settings.get({uid: fooUid}, {hash: 'some:hash'}, (error, data) => { + assert.ifError(error); + assert.equal(data.foo, '1'); + assert.equal(data.derp, 'value'); + done(); + }); + }); + + it('should not set setting if not empty', done => { + meta.settings.setOnEmpty('some:hash', {foo: 2}, error => { + assert.ifError(error); + db.getObject('settings:some:hash', (error, data) => { + assert.ifError(error); + assert.equal(data.foo, '1'); + assert.equal(data.derp, 'value'); + done(); + }); + }); + }); + + it('should set setting if empty', done => { + meta.settings.setOnEmpty('some:hash', {empty: '2'}, error => { + assert.ifError(error); + db.getObject('settings:some:hash', (error, data) => { + assert.ifError(error); + assert.equal(data.foo, '1'); + assert.equal(data.derp, 'value'); + assert.equal(data.empty, '2'); + done(); + }); + }); + }); + + it('should set one and get one', done => { + meta.settings.setOne('some:hash', 'myField', 'myValue', error => { + assert.ifError(error); + meta.settings.getOne('some:hash', 'myField', (error, myValue) => { + assert.ifError(error); + assert.equal(myValue, 'myValue'); + done(); + }); + }); + }); + + it('should return null if setting field does not exist', async () => { + const value = await meta.settings.getOne('some:hash', 'does not exist'); + assert.strictEqual(value, null); + }); + + const someList = [ + {name: 'andrew', status: 'best'}, + {name: 'baris', status: 'wurst'}, + ]; + const anotherList = []; + + it('should set setting with sorted list', done => { + socketAdmin.settings.set({uid: fooUid}, { + hash: 'another:hash', values: { + foo: '1', derp: 'value', someList, anotherList, + }, + }, error => { + if (error) { + return done(error); + } + + db.getObject('settings:another:hash', (error, data) => { + if (error) { + return done(error); + } + + assert.equal(data.foo, '1'); + assert.equal(data.derp, 'value'); + assert.equal(data.someList, undefined); + assert.equal(data.anotherList, undefined); + done(); + }); + }); + }); + + it('should get setting with sorted list', done => { + socketAdmin.settings.get({uid: fooUid}, {hash: 'another:hash'}, (error, data) => { + assert.ifError(error); + assert.strictEqual(data.foo, '1'); + assert.strictEqual(data.derp, 'value'); + assert.deepStrictEqual(data.someList, someList); + assert.deepStrictEqual(data.anotherList, anotherList); + done(); + }); + }); + + it('should not set setting if not empty', done => { + meta.settings.setOnEmpty('some:hash', {foo: 2}, error => { + assert.ifError(error); + db.getObject('settings:some:hash', (error, data) => { + assert.ifError(error); + assert.equal(data.foo, '1'); + assert.equal(data.derp, 'value'); + done(); + }); + }); + }); + + it('should not set setting with sorted list if not empty', done => { + meta.settings.setOnEmpty('another:hash', {foo: anotherList}, error => { + assert.ifError(error); + socketAdmin.settings.get({uid: fooUid}, {hash: 'another:hash'}, (error, data) => { + assert.ifError(error); + assert.equal(data.foo, '1'); + assert.equal(data.derp, 'value'); + done(); + }); + }); + }); + + it('should set setting with sorted list if empty', done => { + meta.settings.setOnEmpty('another:hash', {empty: someList}, error => { + assert.ifError(error); + socketAdmin.settings.get({uid: fooUid}, {hash: 'another:hash'}, (error, data) => { + assert.ifError(error); + assert.equal(data.foo, '1'); + assert.equal(data.derp, 'value'); + assert.deepEqual(data.empty, someList); + done(); + }); + }); + }); + + it('should set one and get one sorted list', done => { + meta.settings.setOne('another:hash', 'someList', someList, error => { + assert.ifError(error); + meta.settings.getOne('another:hash', 'someList', (error, _someList) => { + assert.ifError(error); + assert.deepEqual(_someList, someList); + done(); + }); + }); + }); + }); + + describe('config', () => { + const socketAdmin = require('../src/socket.io/admin'); + before(done => { + db.setObject('config', {minimumTagLength: 3, maximumTagLength: 15}, done); + }); + + it('should get config fields', done => { + meta.configs.getFields(['minimumTagLength', 'maximumTagLength'], (error, data) => { + assert.ifError(error); + assert.strictEqual(data.minimumTagLength, 3); + assert.strictEqual(data.maximumTagLength, 15); + done(); + }); + }); + + it('should get the correct type and default value', done => { + meta.configs.set('loginAttempts', '', error => { + assert.ifError(error); + meta.configs.get('loginAttempts', (error, value) => { + assert.ifError(error); + assert.strictEqual(value, 5); + done(); + }); + }); + }); + + it('should get the correct type and correct value', done => { + meta.configs.set('loginAttempts', '0', error => { + assert.ifError(error); + meta.configs.get('loginAttempts', (error, value) => { + assert.ifError(error); + assert.strictEqual(value, 0); + done(); + }); + }); + }); + + it('should get the correct value', done => { + meta.configs.set('title', 123, error => { + assert.ifError(error); + meta.configs.get('title', (error, value) => { + assert.ifError(error); + assert.strictEqual(value, '123'); + done(); + }); + }); + }); + + it('should get the correct value', done => { + meta.configs.set('title', 0, error => { + assert.ifError(error); + meta.configs.get('title', (error, value) => { + assert.ifError(error); + assert.strictEqual(value, '0'); + done(); + }); + }); + }); + + it('should get the correct value', done => { + meta.configs.set('title', '', error => { + assert.ifError(error); + meta.configs.get('title', (error, value) => { + assert.ifError(error); + assert.strictEqual(value, ''); + done(); + }); + }); + }); + + it('should use default value if value is null', done => { + meta.configs.set('teaserPost', null, error => { + assert.ifError(error); + meta.configs.get('teaserPost', (error, value) => { + assert.ifError(error); + assert.strictEqual(value, 'last-reply'); + done(); + }); + }); + }); + + it('should fail if field is invalid', done => { + meta.configs.set('', 'someValue', error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should fail if data is invalid', done => { + socketAdmin.config.set({uid: fooUid}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should set multiple config values', done => { + socketAdmin.config.set({uid: fooUid}, {key: 'someKey', value: 'someValue'}, error => { + assert.ifError(error); + meta.configs.getFields(['someKey'], (error, data) => { + assert.ifError(error); + assert.equal(data.someKey, 'someValue'); + done(); + }); + }); + }); + + it('should set config value', done => { + meta.configs.set('someField', 'someValue', error => { + assert.ifError(error); + meta.configs.getFields(['someField'], (error, data) => { + assert.ifError(error); + assert.strictEqual(data.someField, 'someValue'); + done(); + }); + }); + }); + + it('should get back string if field is not in defaults', done => { + meta.configs.set('numericField', 123, error => { + assert.ifError(error); + meta.configs.getFields(['numericField'], (error, data) => { + assert.ifError(error); + assert.strictEqual(data.numericField, 123); + done(); + }); + }); + }); + + it('should set boolean config value', done => { + meta.configs.set('booleanField', true, error => { + assert.ifError(error); + meta.configs.getFields(['booleanField'], (error, data) => { + assert.ifError(error); + assert.strictEqual(data.booleanField, true); + done(); + }); + }); + }); + + it('should set boolean config value', done => { + meta.configs.set('booleanField', 'false', error => { + assert.ifError(error); + meta.configs.getFields(['booleanField'], (error, data) => { + assert.ifError(error); + assert.strictEqual(data.booleanField, false); + done(); + }); + }); + }); + + it('should set string config value', done => { + meta.configs.set('stringField', '123', error => { + assert.ifError(error); + meta.configs.getFields(['stringField'], (error, data) => { + assert.ifError(error); + assert.strictEqual(data.stringField, 123); + done(); + }); + }); + }); + + it('should fail if data is invalid', done => { + socketAdmin.config.setMultiple({uid: fooUid}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should set multiple values', done => { + socketAdmin.config.setMultiple({uid: fooUid}, { + someField1: 'someValue1', + someField2: 'someValue2', + customCSS: '.derp{color:#00ff00;}', + }, error => { + assert.ifError(error); + meta.configs.getFields(['someField1', 'someField2'], (error, data) => { + assert.ifError(error); + assert.equal(data.someField1, 'someValue1'); + assert.equal(data.someField2, 'someValue2'); + done(); + }); + }); + }); + + it('should not set config if not empty', done => { + meta.configs.setOnEmpty({someField1: 'foo'}, error => { + assert.ifError(error); + meta.configs.get('someField1', (error, value) => { + assert.ifError(error); + assert.equal(value, 'someValue1'); + done(); + }); + }); + }); + + it('should remove config field', done => { + socketAdmin.config.remove({uid: fooUid}, 'someField1', error => { + assert.ifError(error); + db.isObjectField('config', 'someField1', (error, isObjectField) => { + assert.ifError(error); + assert(!isObjectField); + done(); + }); + }); + }); + }); + + describe('session TTL', () => { + it('should return 14 days in seconds', done => { + assert(meta.getSessionTTLSeconds(), 1_209_600); + done(); + }); + + it('should return 7 days in seconds', done => { + meta.config.loginDays = 7; + assert(meta.getSessionTTLSeconds(), 604_800); + done(); + }); + + it('should return 2 days in seconds', done => { + meta.config.loginSeconds = 172_800; + assert(meta.getSessionTTLSeconds(), 172_800); + done(); + }); + }); + + describe('dependencies', () => { + it('should return ENOENT if module is not found', done => { + meta.dependencies.checkModule('some-module-that-does-not-exist', error => { + assert.equal(error.code, 'ENOENT'); + done(); + }); + }); + + it('should not error if module is a nodebb-plugin-*', done => { + meta.dependencies.checkModule('nodebb-plugin-somePlugin', error => { + assert.ifError(error); + done(); + }); + }); + + it('should not error if module is nodebb-theme-*', done => { + meta.dependencies.checkModule('nodebb-theme-someTheme', error => { + assert.ifError(error); + done(); + }); + }); + + it('should parse json package data', done => { + const packageData = meta.dependencies.parseModuleData('nodebb-plugin-test', '{"a": 1}'); + assert.equal(packageData.a, 1); + done(); + }); + + it('should return null data with invalid json', done => { + const packageData = meta.dependencies.parseModuleData('nodebb-plugin-test', 'asdasd'); + assert.strictEqual(packageData, null); + done(); + }); + + it('should return false if moduleData is falsy', done => { + assert(!meta.dependencies.doesSatisfy(null, '1.0.0')); + done(); + }); + + it('should return false if moduleData doesnt not satisfy package.json', done => { + assert(!meta.dependencies.doesSatisfy({name: 'nodebb-plugin-test', version: '0.9.0'}, '1.0.0')); + done(); + }); + + it('should return true if _resolved is from github', done => { + assert(meta.dependencies.doesSatisfy({name: 'nodebb-plugin-test', _resolved: 'https://github.com/some/repo', version: '0.9.0'}, '1.0.0')); + done(); + }); + }); + + describe('debugFork', () => { + let oldArgv; + before(() => { + oldArgv = process.execArgv; + process.execArgv = ['--debug=5858', '--foo=1']; + }); + + it('should detect debugging', done => { + let debugFork = require('../src/meta/debugFork'); + assert(!debugFork.debugging); + + const debugForkPath = require.resolve('../src/meta/debugFork'); + delete require.cache[debugForkPath]; + + debugFork = require('../src/meta/debugFork'); + assert(debugFork.debugging); + + done(); + }); + + after(() => { + process.execArgv = oldArgv; + }); + }); + + describe('Access-Control-Allow-Origin', () => { + it('Access-Control-Allow-Origin header should be empty', done => { + const jar = request.jar(); + request.get(`${nconf.get('url')}/api/search?term=bug`, { + form: {}, + json: true, + jar, + }, (error, response, body) => { + assert.ifError(error); + assert.equal(response.headers['access-control-allow-origin'], undefined); + done(); + }); + }); + + it('should set proper Access-Control-Allow-Origin header', done => { + const jar = request.jar(); + const oldValue = meta.config['access-control-allow-origin']; + meta.config['access-control-allow-origin'] = 'test.com, mydomain.com'; + request.get(`${nconf.get('url')}/api/search?term=bug`, { + form: {}, + json: true, + jar, + headers: { + origin: 'mydomain.com', + }, + }, (error, response, body) => { + assert.ifError(error); + assert.equal(response.headers['access-control-allow-origin'], 'mydomain.com'); + meta.config['access-control-allow-origin'] = oldValue; + done(error); + }); + }); + + it('Access-Control-Allow-Origin header should be empty if origin does not match', done => { + const jar = request.jar(); + const oldValue = meta.config['access-control-allow-origin']; + meta.config['access-control-allow-origin'] = 'test.com, mydomain.com'; + request.get(`${nconf.get('url')}/api/search?term=bug`, { + form: {}, + json: true, + jar, + headers: { + origin: 'notallowed.com', + }, + }, (error, response, body) => { + assert.ifError(error); + assert.equal(response.headers['access-control-allow-origin'], undefined); + meta.config['access-control-allow-origin'] = oldValue; + done(error); + }); + }); + + it('should set proper Access-Control-Allow-Origin header', done => { + const jar = request.jar(); + const oldValue = meta.config['access-control-allow-origin-regex']; + meta.config['access-control-allow-origin-regex'] = 'match\\.this\\..+\\.domain.com, mydomain\\.com'; + request.get(`${nconf.get('url')}/api/search?term=bug`, { + form: {}, + json: true, + jar, + headers: { + origin: 'match.this.anything123.domain.com', + }, + }, (error, response, body) => { + assert.ifError(error); + assert.equal(response.headers['access-control-allow-origin'], 'match.this.anything123.domain.com'); + meta.config['access-control-allow-origin-regex'] = oldValue; + done(error); + }); + }); + + it('Access-Control-Allow-Origin header should be empty if origin does not match', done => { + const jar = request.jar(); + const oldValue = meta.config['access-control-allow-origin-regex']; + meta.config['access-control-allow-origin-regex'] = 'match\\.this\\..+\\.domain.com, mydomain\\.com'; + request.get(`${nconf.get('url')}/api/search?term=bug`, { + form: {}, + json: true, + jar, + headers: { + origin: 'notallowed.com', + }, + }, (error, response, body) => { + assert.ifError(error); + assert.equal(response.headers['access-control-allow-origin'], undefined); + meta.config['access-control-allow-origin-regex'] = oldValue; + done(error); + }); + }); + + it('should not error with invalid regexp', done => { + const jar = request.jar(); + const oldValue = meta.config['access-control-allow-origin-regex']; + meta.config['access-control-allow-origin-regex'] = '[match\\.this\\..+\\.domain.com, mydomain\\.com'; + request.get(`${nconf.get('url')}/api/search?term=bug`, { + form: {}, + json: true, + jar, + headers: { + origin: 'mydomain.com', + }, + }, (error, response, body) => { + assert.ifError(error); + assert.equal(response.headers['access-control-allow-origin'], 'mydomain.com'); + meta.config['access-control-allow-origin-regex'] = oldValue; + done(error); + }); + }); + }); + + it('should log targets', done => { + const aliases = require('../src/meta/aliases'); + aliases.buildTargets(); + done(); + }); }); diff --git a/test/middleware.js b/test/middleware.js index 49cadf1..4a05842 100644 --- a/test/middleware.js +++ b/test/middleware.js @@ -1,196 +1,194 @@ 'use strict'; -const assert = require('assert'); +const assert = require('node:assert'); const nconf = require('nconf'); const request = require('request-promise-native'); -const db = require('./mocks/databasemock'); - const user = require('../src/user'); const groups = require('../src/groups'); const utils = require('../src/utils'); - +const db = require('./mocks/databasemock'); const helpers = require('./helpers'); describe('Middlewares', () => { - describe('expose', () => { - let adminUid; - - before(async () => { - adminUid = await user.create({ username: 'admin', password: '123456' }); - await groups.join('administrators', adminUid); - }); - - it('should expose res.locals.isAdmin = false', (done) => { - const middleware = require('../src/middleware'); - const resMock = { locals: {} }; - middleware.exposeAdmin({}, resMock, () => { - assert.strictEqual(resMock.locals.isAdmin, false); - done(); - }); - }); - - it('should expose res.locals.isAdmin = true', (done) => { - const middleware = require('../src/middleware'); - const reqMock = { user: { uid: adminUid } }; - const resMock = { locals: {} }; - middleware.exposeAdmin(reqMock, resMock, () => { - assert.strictEqual(resMock.locals.isAdmin, true); - done(); - }); - }); - - it('should expose privileges in res.locals.privileges and isSelf=true', (done) => { - const middleware = require('../src/middleware'); - const reqMock = { user: { uid: adminUid }, params: { uid: adminUid } }; - const resMock = { locals: {} }; - middleware.exposePrivileges(reqMock, resMock, () => { - assert(resMock.locals.privileges); - assert.strictEqual(resMock.locals.privileges.isAdmin, true); - assert.strictEqual(resMock.locals.privileges.isGmod, false); - assert.strictEqual(resMock.locals.privileges.isPrivileged, true); - assert.strictEqual(resMock.locals.privileges.isSelf, true); - done(); - }); - }); - - it('should expose privileges in res.locals.privileges and isSelf=false', (done) => { - const middleware = require('../src/middleware'); - const reqMock = { user: { uid: 0 }, params: { uid: adminUid } }; - const resMock = { locals: {} }; - middleware.exposePrivileges(reqMock, resMock, () => { - assert(resMock.locals.privileges); - assert.strictEqual(resMock.locals.privileges.isAdmin, false); - assert.strictEqual(resMock.locals.privileges.isGmod, false); - assert.strictEqual(resMock.locals.privileges.isPrivileged, false); - assert.strictEqual(resMock.locals.privileges.isSelf, false); - done(); - }); - }); - - it('should expose privilege set', (done) => { - const middleware = require('../src/middleware'); - const reqMock = { user: { uid: adminUid } }; - const resMock = { locals: {} }; - middleware.exposePrivilegeSet(reqMock, resMock, () => { - assert(resMock.locals.privileges); - assert.deepStrictEqual(resMock.locals.privileges, { - chat: true, - 'upload:post:image': true, - 'upload:post:file': true, - signature: true, - invite: true, - 'group:create': true, - 'search:content': true, - 'search:users': true, - 'search:tags': true, - 'view:users': true, - 'view:tags': true, - 'view:groups': true, - 'local:login': true, - ban: true, - mute: true, - 'view:users:info': true, - 'admin:dashboard': true, - 'admin:categories': true, - 'admin:privileges': true, - 'admin:admins-mods': true, - 'admin:users': true, - 'admin:groups': true, - 'admin:tags': true, - 'admin:settings': true, - superadmin: true, - }); - done(); - }); - }); - }); - - describe('cache-control header', () => { - let uid; - let jar; - - before(async () => { - uid = await user.create({ username: 'testuser', password: '123456' }); - ({ jar } = await helpers.loginUser('testuser', '123456')); - }); - - it('should be absent on non-existent routes, for guests', async () => { - const res = await request(`${nconf.get('url')}/${utils.generateUUID()}`, { - simple: false, - resolveWithFullResponse: true, - }); - - assert.strictEqual(res.statusCode, 404); - assert(!Object.keys(res.headers).includes('cache-control')); - }); - - it('should be set to "private" on non-existent routes, for logged in users', async () => { - const res = await request(`${nconf.get('url')}/${utils.generateUUID()}`, { - simple: false, - resolveWithFullResponse: true, - jar, - }); - - assert.strictEqual(res.statusCode, 404); - assert(Object.keys(res.headers).includes('cache-control')); - assert.strictEqual(res.headers['cache-control'], 'private'); - }); - - it('should be absent on regular routes, for guests', async () => { - const res = await request(nconf.get('url'), { - simple: false, - resolveWithFullResponse: true, - }); - - assert.strictEqual(res.statusCode, 200); - assert(!Object.keys(res.headers).includes('cache-control')); - }); - - it('should be absent on api routes, for guests', async () => { - const res = await request(`${nconf.get('url')}/api`, { - simple: false, - resolveWithFullResponse: true, - }); - - assert.strictEqual(res.statusCode, 200); - assert(!Object.keys(res.headers).includes('cache-control')); - }); - - it('should be set to "private" on regular routes, for logged-in users', async () => { - const res = await request(nconf.get('url'), { - simple: false, - resolveWithFullResponse: true, - jar, - }); - - assert.strictEqual(res.statusCode, 200); - assert(Object.keys(res.headers).includes('cache-control')); - assert.strictEqual(res.headers['cache-control'], 'private'); - }); - - it('should be set to "private" on api routes, for logged-in users', async () => { - const res = await request(`${nconf.get('url')}/api`, { - simple: false, - resolveWithFullResponse: true, - jar, - }); - - assert.strictEqual(res.statusCode, 200); - assert(Object.keys(res.headers).includes('cache-control')); - assert.strictEqual(res.headers['cache-control'], 'private'); - }); - - it('should be set to "private" on apiv3 routes, for logged-in users', async () => { - const res = await request(`${nconf.get('url')}/api/v3/users/${uid}`, { - simple: false, - resolveWithFullResponse: true, - jar, - }); - - assert.strictEqual(res.statusCode, 200); - assert(Object.keys(res.headers).includes('cache-control')); - assert.strictEqual(res.headers['cache-control'], 'private'); - }); - }); + describe('expose', () => { + let adminUid; + + before(async () => { + adminUid = await user.create({username: 'admin', password: '123456'}); + await groups.join('administrators', adminUid); + }); + + it('should expose res.locals.isAdmin = false', done => { + const middleware = require('../src/middleware'); + const resMock = {locals: {}}; + middleware.exposeAdmin({}, resMock, () => { + assert.strictEqual(resMock.locals.isAdmin, false); + done(); + }); + }); + + it('should expose res.locals.isAdmin = true', done => { + const middleware = require('../src/middleware'); + const requestMock = {user: {uid: adminUid}}; + const resMock = {locals: {}}; + middleware.exposeAdmin(requestMock, resMock, () => { + assert.strictEqual(resMock.locals.isAdmin, true); + done(); + }); + }); + + it('should expose privileges in res.locals.privileges and isSelf=true', done => { + const middleware = require('../src/middleware'); + const requestMock = {user: {uid: adminUid}, params: {uid: adminUid}}; + const resMock = {locals: {}}; + middleware.exposePrivileges(requestMock, resMock, () => { + assert(resMock.locals.privileges); + assert.strictEqual(resMock.locals.privileges.isAdmin, true); + assert.strictEqual(resMock.locals.privileges.isGmod, false); + assert.strictEqual(resMock.locals.privileges.isPrivileged, true); + assert.strictEqual(resMock.locals.privileges.isSelf, true); + done(); + }); + }); + + it('should expose privileges in res.locals.privileges and isSelf=false', done => { + const middleware = require('../src/middleware'); + const requestMock = {user: {uid: 0}, params: {uid: adminUid}}; + const resMock = {locals: {}}; + middleware.exposePrivileges(requestMock, resMock, () => { + assert(resMock.locals.privileges); + assert.strictEqual(resMock.locals.privileges.isAdmin, false); + assert.strictEqual(resMock.locals.privileges.isGmod, false); + assert.strictEqual(resMock.locals.privileges.isPrivileged, false); + assert.strictEqual(resMock.locals.privileges.isSelf, false); + done(); + }); + }); + + it('should expose privilege set', done => { + const middleware = require('../src/middleware'); + const requestMock = {user: {uid: adminUid}}; + const resMock = {locals: {}}; + middleware.exposePrivilegeSet(requestMock, resMock, () => { + assert(resMock.locals.privileges); + assert.deepStrictEqual(resMock.locals.privileges, { + chat: true, + 'upload:post:image': true, + 'upload:post:file': true, + signature: true, + invite: true, + 'group:create': true, + 'search:content': true, + 'search:users': true, + 'search:tags': true, + 'view:users': true, + 'view:tags': true, + 'view:groups': true, + 'local:login': true, + ban: true, + mute: true, + 'view:users:info': true, + 'admin:dashboard': true, + 'admin:categories': true, + 'admin:privileges': true, + 'admin:admins-mods': true, + 'admin:users': true, + 'admin:groups': true, + 'admin:tags': true, + 'admin:settings': true, + superadmin: true, + }); + done(); + }); + }); + }); + + describe('cache-control header', () => { + let uid; + let jar; + + before(async () => { + uid = await user.create({username: 'testuser', password: '123456'}); + ({jar} = await helpers.loginUser('testuser', '123456')); + }); + + it('should be absent on non-existent routes, for guests', async () => { + const res = await request(`${nconf.get('url')}/${utils.generateUUID()}`, { + simple: false, + resolveWithFullResponse: true, + }); + + assert.strictEqual(res.statusCode, 404); + assert(!Object.keys(res.headers).includes('cache-control')); + }); + + it('should be set to "private" on non-existent routes, for logged in users', async () => { + const res = await request(`${nconf.get('url')}/${utils.generateUUID()}`, { + simple: false, + resolveWithFullResponse: true, + jar, + }); + + assert.strictEqual(res.statusCode, 404); + assert(Object.keys(res.headers).includes('cache-control')); + assert.strictEqual(res.headers['cache-control'], 'private'); + }); + + it('should be absent on regular routes, for guests', async () => { + const res = await request(nconf.get('url'), { + simple: false, + resolveWithFullResponse: true, + }); + + assert.strictEqual(res.statusCode, 200); + assert(!Object.keys(res.headers).includes('cache-control')); + }); + + it('should be absent on api routes, for guests', async () => { + const res = await request(`${nconf.get('url')}/api`, { + simple: false, + resolveWithFullResponse: true, + }); + + assert.strictEqual(res.statusCode, 200); + assert(!Object.keys(res.headers).includes('cache-control')); + }); + + it('should be set to "private" on regular routes, for logged-in users', async () => { + const res = await request(nconf.get('url'), { + simple: false, + resolveWithFullResponse: true, + jar, + }); + + assert.strictEqual(res.statusCode, 200); + assert(Object.keys(res.headers).includes('cache-control')); + assert.strictEqual(res.headers['cache-control'], 'private'); + }); + + it('should be set to "private" on api routes, for logged-in users', async () => { + const res = await request(`${nconf.get('url')}/api`, { + simple: false, + resolveWithFullResponse: true, + jar, + }); + + assert.strictEqual(res.statusCode, 200); + assert(Object.keys(res.headers).includes('cache-control')); + assert.strictEqual(res.headers['cache-control'], 'private'); + }); + + it('should be set to "private" on apiv3 routes, for logged-in users', async () => { + const res = await request(`${nconf.get('url')}/api/v3/users/${uid}`, { + simple: false, + resolveWithFullResponse: true, + jar, + }); + + assert.strictEqual(res.statusCode, 200); + assert(Object.keys(res.headers).includes('cache-control')); + assert.strictEqual(res.headers['cache-control'], 'private'); + }); + }); }); diff --git a/test/mocks/databasemock.js b/test/mocks/databasemock.js index 795997e..cad8dbe 100644 --- a/test/mocks/databasemock.js +++ b/test/mocks/databasemock.js @@ -7,46 +7,45 @@ require('../../require-main'); -const path = require('path'); +const path = require('node:path'); +const url = require('node:url'); +const util = require('node:util'); const nconf = require('nconf'); -const url = require('url'); -const util = require('util'); process.env.NODE_ENV = process.env.TEST_ENV || 'production'; global.env = process.env.NODE_ENV || 'production'; - const winston = require('winston'); const packageInfo = require('../../package.json'); winston.add(new winston.transports.Console({ - format: winston.format.combine( - winston.format.splat(), - winston.format.simple() - ), + format: winston.format.combine( + winston.format.splat(), + winston.format.simple(), + ), })); try { - const fs = require('fs'); - const configJSON = fs.readFileSync(path.join(__dirname, '../../config.json'), 'utf-8'); - winston.info('configJSON'); - winston.info(configJSON); -} catch (err) { - console.error(err.stack); - throw err; + const fs = require('node:fs'); + const configJSON = fs.readFileSync(path.join(__dirname, '../../config.json'), 'utf8'); + winston.info('configJSON'); + winston.info(configJSON); +} catch (error) { + console.error(error.stack); + throw error; } -nconf.file({ file: path.join(__dirname, '../../config.json') }); +nconf.file({file: path.join(__dirname, '../../config.json')}); nconf.defaults({ - base_dir: path.join(__dirname, '../..'), - themes_path: path.join(__dirname, '../../themes'), - upload_path: 'test/uploads', - views_dir: path.join(__dirname, '../../build/public/templates'), - relative_path: '', + base_dir: path.join(__dirname, '../..'), + themes_path: path.join(__dirname, '../../themes'), + upload_path: 'test/uploads', + views_dir: path.join(__dirname, '../../build/public/templates'), + relative_path: '', }); const urlObject = url.parse(nconf.get('url')); -const relativePath = urlObject.pathname !== '/' ? urlObject.pathname : ''; +const relativePath = urlObject.pathname === '/' ? '' : urlObject.pathname; nconf.set('relative_path', relativePath); nconf.set('asset_base_url', `${relativePath}/assets`); nconf.set('upload_path', path.join(nconf.get('base_dir'), nconf.get('upload_path'))); @@ -54,76 +53,76 @@ nconf.set('upload_url', '/assets/uploads'); nconf.set('url_parsed', urlObject); nconf.set('base_url', `${urlObject.protocol}//${urlObject.host}`); nconf.set('secure', urlObject.protocol === 'https:'); -nconf.set('use_port', !!urlObject.port); +nconf.set('use_port', Boolean(urlObject.port)); nconf.set('port', urlObject.port || nconf.get('port') || (nconf.get('PORT_ENV_VAR') ? nconf.get(nconf.get('PORT_ENV_VAR')) : false) || 4567); -// cookies don't provide isolation by port: http://stackoverflow.com/a/16328399/122353 +// Cookies don't provide isolation by port: http://stackoverflow.com/a/16328399/122353 const domain = nconf.get('cookieDomain') || urlObject.hostname; const origins = nconf.get('socket.io:origins') || `${urlObject.protocol}//${domain}:*`; nconf.set('socket.io:origins', origins); if (nconf.get('isCluster') === undefined) { - nconf.set('isPrimary', true); - nconf.set('isCluster', false); - nconf.set('singleHostCluster', false); + nconf.set('isPrimary', true); + nconf.set('isCluster', false); + nconf.set('singleHostCluster', false); } -const dbType = nconf.get('database'); -const testDbConfig = nconf.get('test_database'); -const productionDbConfig = nconf.get(dbType); - -if (!testDbConfig) { - const errorText = 'test_database is not defined'; - winston.info( - '\n===========================================================\n' + - 'Please, add parameters for test database in config.json\n' + - 'For example (redis):\n' + - '"test_database": {\n' + - ' "host": "127.0.0.1",\n' + - ' "port": "6379",\n' + - ' "password": "",\n' + - ' "database": "1"\n' + - '}\n' + - ' or (mongo):\n' + - '"test_database": {\n' + - ' "host": "127.0.0.1",\n' + - ' "port": "27017",\n' + - ' "password": "",\n' + - ' "database": "1"\n' + - '}\n' + - ' or (mongo) in a replicaset\n' + - '"test_database": {\n' + - ' "host": "127.0.0.1,127.0.0.1,127.0.0.1",\n' + - ' "port": "27017,27018,27019",\n' + - ' "username": "",\n' + - ' "password": "",\n' + - ' "database": "nodebb_test"\n' + - '}\n' + - ' or (postgres):\n' + - '"test_database": {\n' + - ' "host": "127.0.0.1",\n' + - ' "port": "5432",\n' + - ' "username": "postgres",\n' + - ' "password": "",\n' + - ' "database": "nodebb_test"\n' + - '}\n' + - '===========================================================' - ); - winston.error(errorText); - throw new Error(errorText); +const databaseType = nconf.get('database'); +const testDatabaseConfig = nconf.get('test_database'); +const productionDatabaseConfig = nconf.get(databaseType); + +if (!testDatabaseConfig) { + const errorText = 'test_database is not defined'; + winston.info( + '\n===========================================================\n' + + 'Please, add parameters for test database in config.json\n' + + 'For example (redis):\n' + + '"test_database": {\n' + + ' "host": "127.0.0.1",\n' + + ' "port": "6379",\n' + + ' "password": "",\n' + + ' "database": "1"\n' + + '}\n' + + ' or (mongo):\n' + + '"test_database": {\n' + + ' "host": "127.0.0.1",\n' + + ' "port": "27017",\n' + + ' "password": "",\n' + + ' "database": "1"\n' + + '}\n' + + ' or (mongo) in a replicaset\n' + + '"test_database": {\n' + + ' "host": "127.0.0.1,127.0.0.1,127.0.0.1",\n' + + ' "port": "27017,27018,27019",\n' + + ' "username": "",\n' + + ' "password": "",\n' + + ' "database": "nodebb_test"\n' + + '}\n' + + ' or (postgres):\n' + + '"test_database": {\n' + + ' "host": "127.0.0.1",\n' + + ' "port": "5432",\n' + + ' "username": "postgres",\n' + + ' "password": "",\n' + + ' "database": "nodebb_test"\n' + + '}\n' + + '===========================================================', + ); + winston.error(errorText); + throw new Error(errorText); } -if (testDbConfig.database === productionDbConfig.database && - testDbConfig.host === productionDbConfig.host && - testDbConfig.port === productionDbConfig.port) { - const errorText = 'test_database has the same config as production db'; - winston.error(errorText); - throw new Error(errorText); +if (testDatabaseConfig.database === productionDatabaseConfig.database + && testDatabaseConfig.host === productionDatabaseConfig.host + && testDatabaseConfig.port === productionDatabaseConfig.port) { + const errorText = 'test_database has the same config as production db'; + winston.error(errorText); + throw new Error(errorText); } -nconf.set(dbType, testDbConfig); +nconf.set(databaseType, testDatabaseConfig); -winston.info('database config %s', dbType, testDbConfig); +winston.info('database config %s', databaseType, testDatabaseConfig); winston.info(`environment ${global.env}`); const db = require('../../src/database'); @@ -131,132 +130,140 @@ const db = require('../../src/database'); module.exports = db; before(async function () { - this.timeout(30000); - - // Parse out the relative_url and other goodies from the configured URL - const urlObject = url.parse(nconf.get('url')); - - nconf.set('core_templates_path', path.join(__dirname, '../../src/views')); - nconf.set('base_templates_path', path.join(nconf.get('themes_path'), 'nodebb-theme-persona/templates')); - nconf.set('theme_config', path.join(nconf.get('themes_path'), 'nodebb-theme-persona', 'theme.json')); - nconf.set('bcrypt_rounds', 1); - nconf.set('socket.io:origins', '*:*'); - nconf.set('version', packageInfo.version); - nconf.set('runJobs', false); - nconf.set('jobsDisabled', false); - - - await db.init(); - if (db.hasOwnProperty('createIndices')) { - await db.createIndices(); - } - await setupMockDefaults(); - await db.initSessionStore(); - - const meta = require('../../src/meta'); - nconf.set('theme_templates_path', meta.config['theme:templates'] ? path.join(nconf.get('themes_path'), meta.config['theme:id'], meta.config['theme:templates']) : nconf.get('base_templates_path')); - // nconf defaults, if not set in config - if (!nconf.get('sessionKey')) { - nconf.set('sessionKey', 'express.sid'); - } - - await meta.dependencies.check(); - - const webserver = require('../../src/webserver'); - const sockets = require('../../src/socket.io'); - await sockets.init(webserver.server); - - require('../../src/notifications').startJobs(); - require('../../src/user').startJobs(); - - await webserver.listen(); - - // Iterate over all of the test suites/contexts - this.test.parent.suites.forEach((suite) => { - // Attach an afterAll listener that resets the defaults - suite.afterAll(async () => { - await setupMockDefaults(); - }); - }); + this.timeout(30_000); + + // Parse out the relative_url and other goodies from the configured URL + const urlObject = url.parse(nconf.get('url')); + + nconf.set('core_templates_path', path.join(__dirname, '../../src/views')); + nconf.set('base_templates_path', path.join(nconf.get('themes_path'), 'nodebb-theme-persona/templates')); + nconf.set('theme_config', path.join(nconf.get('themes_path'), 'nodebb-theme-persona', 'theme.json')); + nconf.set('bcrypt_rounds', 1); + nconf.set('socket.io:origins', '*:*'); + nconf.set('version', packageInfo.version); + nconf.set('runJobs', false); + nconf.set('jobsDisabled', false); + + await db.init(); + if (db.hasOwnProperty('createIndices')) { + await db.createIndices(); + } + + await setupMockDefaults(); + await db.initSessionStore(); + + const meta = require('../../src/meta'); + nconf.set('theme_templates_path', meta.config['theme:templates'] ? path.join(nconf.get('themes_path'), meta.config['theme:id'], meta.config['theme:templates']) : nconf.get('base_templates_path')); + // Nconf defaults, if not set in config + if (!nconf.get('sessionKey')) { + nconf.set('sessionKey', 'express.sid'); + } + + await meta.dependencies.check(); + + const webserver = require('../../src/webserver'); + const sockets = require('../../src/socket.io'); + await sockets.init(webserver.server); + + require('../../src/notifications').startJobs(); + require('../../src/user').startJobs(); + + await webserver.listen(); + + // Iterate over all of the test suites/contexts + for (const suite of this.test.parent.suites) { + // Attach an afterAll listener that resets the defaults + suite.afterAll(async () => { + await setupMockDefaults(); + }); + } }); async function setupMockDefaults() { - const meta = require('../../src/meta'); - await db.emptydb(); - - winston.info('test_database flushed'); - await setupDefaultConfigs(meta); - - await meta.configs.init(); - meta.config.postDelay = 0; - meta.config.initialPostDelay = 0; - meta.config.newbiePostDelay = 0; - meta.config.autoDetectLang = 0; - - require('../../src/groups').cache.reset(); - require('../../src/posts/cache').reset(); - require('../../src/cache').reset(); - require('../../src/middleware/uploads').clearCache(); - // privileges must be given after cache reset - await giveDefaultGlobalPrivileges(); - await enableDefaultPlugins(); - - await meta.themes.set({ - type: 'local', - id: 'nodebb-theme-persona', - }); - - const rimraf = util.promisify(require('rimraf')); - await rimraf('test/uploads'); - - const mkdirp = require('mkdirp'); - - const folders = [ - 'test/uploads', - 'test/uploads/category', - 'test/uploads/files', - 'test/uploads/system', - 'test/uploads/profile', - ]; - for (const folder of folders) { - /* eslint-disable no-await-in-loop */ - await mkdirp(folder); - } + const meta = require('../../src/meta'); + await db.emptydb(); + + winston.info('test_database flushed'); + await setupDefaultConfigs(meta); + + await meta.configs.init(); + meta.config.postDelay = 0; + meta.config.initialPostDelay = 0; + meta.config.newbiePostDelay = 0; + meta.config.autoDetectLang = 0; + + require('../../src/groups').cache.reset(); + require('../../src/posts/cache').reset(); + require('../../src/cache').reset(); + require('../../src/middleware/uploads').clearCache(); + // Privileges must be given after cache reset + await giveDefaultGlobalPrivileges(); + await enableDefaultPlugins(); + + await meta.themes.set({ + type: 'local', + id: 'nodebb-theme-persona', + }); + + const rimraf = util.promisify(require('rimraf')); + await rimraf('test/uploads'); + + const mkdirp = require('mkdirp'); + + const folders = [ + 'test/uploads', + 'test/uploads/category', + 'test/uploads/files', + 'test/uploads/system', + 'test/uploads/profile', + ]; + for (const folder of folders) { + /* eslint-disable no-await-in-loop */ + await mkdirp(folder); + } } + db.setupMockDefaults = setupMockDefaults; async function setupDefaultConfigs(meta) { - winston.info('Populating database with default configs, if not already set...\n'); + winston.info('Populating database with default configs, if not already set...\n'); - const defaults = require(path.join(nconf.get('base_dir'), 'install/data/defaults.json')); - defaults.eventLoopCheckEnabled = 0; - defaults.minimumPasswordStrength = 0; - await meta.configs.setOnEmpty(defaults); + const defaults = require(path.join(nconf.get('base_dir'), 'install/data/defaults.json')); + defaults.eventLoopCheckEnabled = 0; + defaults.minimumPasswordStrength = 0; + await meta.configs.setOnEmpty(defaults); } async function giveDefaultGlobalPrivileges() { - winston.info('Giving default global privileges...\n'); - const privileges = require('../../src/privileges'); - await privileges.global.give([ - 'groups:chat', 'groups:upload:post:image', 'groups:signature', 'groups:search:content', - 'groups:search:users', 'groups:search:tags', 'groups:local:login', 'groups:view:users', - 'groups:view:tags', 'groups:view:groups', - ], 'registered-users'); - await privileges.global.give([ - 'groups:view:users', 'groups:view:tags', 'groups:view:groups', - ], 'guests'); + winston.info('Giving default global privileges...\n'); + const privileges = require('../../src/privileges'); + await privileges.global.give([ + 'groups:chat', + 'groups:upload:post:image', + 'groups:signature', + 'groups:search:content', + 'groups:search:users', + 'groups:search:tags', + 'groups:local:login', + 'groups:view:users', + 'groups:view:tags', + 'groups:view:groups', + ], 'registered-users'); + await privileges.global.give([ + 'groups:view:users', 'groups:view:tags', 'groups:view:groups', + ], 'guests'); } async function enableDefaultPlugins() { - winston.info('Enabling default plugins\n'); - const testPlugins = Array.isArray(nconf.get('test_plugins')) ? nconf.get('test_plugins') : []; - const defaultEnabled = [ - 'nodebb-plugin-dbsearch', - 'nodebb-widget-essentials', - 'nodebb-plugin-composer-default', - ].concat(testPlugins); + winston.info('Enabling default plugins\n'); + const testPlugins = Array.isArray(nconf.get('test_plugins')) ? nconf.get('test_plugins') : []; + const defaultEnabled = [ + 'nodebb-plugin-dbsearch', + 'nodebb-widget-essentials', + 'nodebb-plugin-composer-default', + ].concat(testPlugins); - winston.info('[install/enableDefaultPlugins] activating default plugins', defaultEnabled); + winston.info('[install/enableDefaultPlugins] activating default plugins', defaultEnabled); - await db.sortedSetAdd('plugins:active', Object.keys(defaultEnabled), defaultEnabled); + await db.sortedSetAdd('plugins:active', Object.keys(defaultEnabled), defaultEnabled); } diff --git a/test/notifications.js b/test/notifications.js index 0bd9dd9..fd84d98 100644 --- a/test/notifications.js +++ b/test/notifications.js @@ -1,11 +1,8 @@ 'use strict'; - -const assert = require('assert'); +const assert = require('node:assert'); const async = require('async'); const nconf = require('nconf'); - -const db = require('./mocks/databasemock'); const meta = require('../src/meta'); const user = require('../src/user'); const topics = require('../src/topics'); @@ -13,473 +10,474 @@ const categories = require('../src/categories'); const groups = require('../src/groups'); const notifications = require('../src/notifications'); const socketNotifications = require('../src/socket.io/notifications'); +const db = require('./mocks/databasemock'); describe('Notifications', () => { - let uid; - let notification; - - before((done) => { - user.create({ username: 'poster' }, (err, _uid) => { - if (err) { - return done(err); - } - - uid = _uid; - done(); - }); - }); - - it('should fail to create notification without a nid', (done) => { - notifications.create({}, (err) => { - assert.equal(err.message, '[[error:no-notification-id]]'); - done(); - }); - }); - - it('should create a notification', (done) => { - notifications.create({ - bodyShort: 'bodyShort', - nid: 'notification_id', - path: '/notification/path', - pid: 1, - }, (err, _notification) => { - notification = _notification; - assert.ifError(err); - assert(notification); - db.exists(`notifications:${notification.nid}`, (err, exists) => { - assert.ifError(err); - assert(exists); - db.isSortedSetMember('notifications', notification.nid, (err, isMember) => { - assert.ifError(err); - assert(isMember); - done(); - }); - }); - }); - }); - - it('should return null if pid is same and importance is lower', (done) => { - notifications.create({ - bodyShort: 'bodyShort', - nid: 'notification_id', - path: '/notification/path', - pid: 1, - importance: 1, - }, (err, notification) => { - assert.ifError(err); - assert.strictEqual(notification, null); - done(); - }); - }); - - it('should get empty array', (done) => { - notifications.getMultiple(null, (err, data) => { - assert.ifError(err); - assert(Array.isArray(data)); - assert.equal(data.length, 0); - done(); - }); - }); - - it('should get notifications', (done) => { - notifications.getMultiple([notification.nid], (err, notificationsData) => { - assert.ifError(err); - assert(Array.isArray(notificationsData)); - assert(notificationsData[0]); - assert.equal(notification.nid, notificationsData[0].nid); - done(); - }); - }); - - it('should do nothing', (done) => { - notifications.push(null, [], (err) => { - assert.ifError(err); - notifications.push({ nid: null }, [], (err) => { - assert.ifError(err); - notifications.push(notification, [], (err) => { - assert.ifError(err); - done(); - }); - }); - }); - }); - - it('should push a notification to uid', (done) => { - notifications.push(notification, [uid], (err) => { - assert.ifError(err); - setTimeout(() => { - db.isSortedSetMember(`uid:${uid}:notifications:unread`, notification.nid, (err, isMember) => { - assert.ifError(err); - assert(isMember); - done(); - }); - }, 2000); - }); - }); - - it('should push a notification to a group', (done) => { - notifications.pushGroup(notification, 'registered-users', (err) => { - assert.ifError(err); - setTimeout(() => { - db.isSortedSetMember(`uid:${uid}:notifications:unread`, notification.nid, (err, isMember) => { - assert.ifError(err); - assert(isMember); - done(); - }); - }, 2000); - }); - }); - - it('should push a notification to groups', (done) => { - notifications.pushGroups(notification, ['registered-users', 'administrators'], (err) => { - assert.ifError(err); - setTimeout(() => { - db.isSortedSetMember(`uid:${uid}:notifications:unread`, notification.nid, (err, isMember) => { - assert.ifError(err); - assert(isMember); - done(); - }); - }, 2000); - }); - }); - - it('should not mark anything with invalid uid or nid', (done) => { - socketNotifications.markRead({ uid: null }, null, (err) => { - assert.ifError(err); - socketNotifications.markRead({ uid: uid }, null, (err) => { - assert.ifError(err); - done(); - }); - }); - }); - - it('should mark a notification read', (done) => { - socketNotifications.markRead({ uid: uid }, notification.nid, (err) => { - assert.ifError(err); - db.isSortedSetMember(`uid:${uid}:notifications:unread`, notification.nid, (err, isMember) => { - assert.ifError(err); - assert.equal(isMember, false); - db.isSortedSetMember(`uid:${uid}:notifications:read`, notification.nid, (err, isMember) => { - assert.ifError(err); - assert.equal(isMember, true); - done(); - }); - }); - }); - }); - - it('should not mark anything with invalid uid or nid', (done) => { - socketNotifications.markUnread({ uid: null }, null, (err) => { - assert.ifError(err); - socketNotifications.markUnread({ uid: uid }, null, (err) => { - assert.ifError(err); - done(); - }); - }); - }); - - it('should error if notification does not exist', (done) => { - socketNotifications.markUnread({ uid: uid }, 123123, (err) => { - assert.equal(err.message, '[[error:no-notification]]'); - done(); - }); - }); - - it('should mark a notification unread', (done) => { - socketNotifications.markUnread({ uid: uid }, notification.nid, (err) => { - assert.ifError(err); - db.isSortedSetMember(`uid:${uid}:notifications:unread`, notification.nid, (err, isMember) => { - assert.ifError(err); - assert.equal(isMember, true); - db.isSortedSetMember(`uid:${uid}:notifications:read`, notification.nid, (err, isMember) => { - assert.ifError(err); - assert.equal(isMember, false); - socketNotifications.getCount({ uid: uid }, null, (err, count) => { - assert.ifError(err); - assert.equal(count, 1); - done(); - }); - }); - }); - }); - }); - - it('should mark all notifications read', (done) => { - socketNotifications.markAllRead({ uid: uid }, null, (err) => { - assert.ifError(err); - db.isSortedSetMember(`uid:${uid}:notifications:unread`, notification.nid, (err, isMember) => { - assert.ifError(err); - assert.equal(isMember, false); - db.isSortedSetMember(`uid:${uid}:notifications:read`, notification.nid, (err, isMember) => { - assert.ifError(err); - assert.equal(isMember, true); - done(); - }); - }); - }); - }); - - it('should not do anything', (done) => { - socketNotifications.markAllRead({ uid: 1000 }, null, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should link to the first unread post in a watched topic', (done) => { - const categories = require('../src/categories'); - const topics = require('../src/topics'); - let watcherUid; - let cid; - let tid; - let pid; - - async.waterfall([ - function (next) { - user.create({ username: 'watcher' }, next); - }, - function (_watcherUid, next) { - watcherUid = _watcherUid; - - categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - }, next); - }, - function (category, next) { - cid = category.cid; - - topics.post({ - uid: watcherUid, - cid: cid, - title: 'Test Topic Title', - content: 'The content of test topic', - }, next); - }, - function (topic, next) { - tid = topic.topicData.tid; - - topics.follow(tid, watcherUid, next); - }, - function (next) { - topics.reply({ - uid: uid, - content: 'This is the first reply.', - tid: tid, - }, next); - }, - function (post, next) { - pid = post.pid; - - topics.reply({ - uid: uid, - content: 'This is the second reply.', - tid: tid, - }, next); - }, - function (post, next) { - // notifications are sent asynchronously with a 1 second delay. - setTimeout(next, 3000); - }, - function (next) { - user.notifications.get(watcherUid, next); - }, - function (notifications, next) { - assert.equal(notifications.unread.length, 1, 'there should be 1 unread notification'); - assert.equal(`${nconf.get('relative_path')}/post/${pid}`, notifications.unread[0].path, 'the notification should link to the first unread post'); - next(); - }, - ], (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should get notification by nid', (done) => { - socketNotifications.get({ uid: uid }, { nids: [notification.nid] }, (err, data) => { - assert.ifError(err); - assert.equal(data[0].bodyShort, 'bodyShort'); - assert.equal(data[0].nid, 'notification_id'); - assert.equal(data[0].path, `${nconf.get('relative_path')}/notification/path`); - done(); - }); - }); - - it('should get user\'s notifications', (done) => { - socketNotifications.get({ uid: uid }, {}, (err, data) => { - assert.ifError(err); - assert.equal(data.unread.length, 0); - assert.equal(data.read[0].nid, 'notification_id'); - done(); - }); - }); - - it('should error if not logged in', (done) => { - socketNotifications.deleteAll({ uid: 0 }, null, (err) => { - assert.equal(err.message, '[[error:no-privileges]]'); - done(); - }); - }); - - it('should delete all user notifications', (done) => { - socketNotifications.deleteAll({ uid: uid }, null, (err) => { - assert.ifError(err); - socketNotifications.get({ uid: uid }, {}, (err, data) => { - assert.ifError(err); - assert.equal(data.unread.length, 0); - assert.equal(data.read.length, 0); - done(); - }); - }); - }); - - it('should return empty with falsy uid', (done) => { - user.notifications.get(0, (err, data) => { - assert.ifError(err); - assert.equal(data.read.length, 0); - assert.equal(data.unread.length, 0); - done(); - }); - }); - - it('should get all notifications and filter', (done) => { - const nid = 'willbefiltered'; - notifications.create({ - bodyShort: 'bodyShort', - nid: nid, - path: '/notification/path', - type: 'post', - }, (err, notification) => { - assert.ifError(err); - notifications.push(notification, [uid], (err) => { - assert.ifError(err); - setTimeout(() => { - user.notifications.getAll(uid, 'post', (err, nids) => { - assert.ifError(err); - assert(nids.includes(nid)); - done(); - }); - }, 3000); - }); - }); - }); - - it('should not get anything if notifications does not exist', (done) => { - user.notifications.getNotifications(['doesnotexistnid1', 'doesnotexistnid2'], uid, (err, data) => { - assert.ifError(err); - assert.deepEqual(data, []); - done(); - }); - }); - - it('should get daily notifications', (done) => { - user.notifications.getDailyUnread(uid, (err, data) => { - assert.ifError(err); - assert.equal(data[0].nid, 'willbefiltered'); - done(); - }); - }); - - it('should return empty array for invalid interval', (done) => { - user.notifications.getUnreadInterval(uid, '2 aeons', (err, data) => { - assert.ifError(err); - assert.deepEqual(data, []); - done(); - }); - }); - - it('should return 0 for falsy uid', (done) => { - user.notifications.getUnreadCount(0, (err, count) => { - assert.ifError(err); - assert.equal(count, 0); - done(); - }); - }); - - it('should not do anything if uid is falsy', (done) => { - user.notifications.deleteAll(0, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should send notification to followers of user when he posts', (done) => { - let followerUid; - async.waterfall([ - function (next) { - user.create({ username: 'follower' }, next); - }, - function (_followerUid, next) { - followerUid = _followerUid; - user.follow(followerUid, uid, next); - }, - function (next) { - categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - }, next); - }, - function (category, next) { - topics.post({ - uid: uid, - cid: category.cid, - title: 'Test Topic Title', - content: 'The content of test topic', - }, next); - }, - function (data, next) { - setTimeout(next, 1100); - }, - function (next) { - user.notifications.getAll(followerUid, '', next); - }, - ], (err, data) => { - assert.ifError(err); - assert(data); - done(); - }); - }); - - it('should send welcome notification', (done) => { - meta.config.welcomeNotification = 'welcome to the forums'; - user.notifications.sendWelcomeNotification(uid, (err) => { - assert.ifError(err); - user.notifications.sendWelcomeNotification(uid, (err) => { - assert.ifError(err); - setTimeout(() => { - user.notifications.getAll(uid, '', (err, data) => { - meta.config.welcomeNotification = ''; - assert.ifError(err); - assert(data.includes(`welcome_${uid}`), data); - done(); - }); - }, 2000); - }); - }); - }); - - it('should prune notifications', (done) => { - notifications.create({ - bodyShort: 'bodyShort', - nid: 'tobedeleted', - path: '/notification/path', - }, (err, notification) => { - assert.ifError(err); - notifications.prune((err) => { - assert.ifError(err); - const month = 2592000000; - db.sortedSetAdd('notifications', Date.now() - (2 * month), notification.nid, (err) => { - assert.ifError(err); - notifications.prune((err) => { - assert.ifError(err); - notifications.get(notification.nid, (err, data) => { - assert.ifError(err); - assert(!data); - done(); - }); - }); - }); - }); - }); - }); + let uid; + let notification; + + before(done => { + user.create({username: 'poster'}, (error, _uid) => { + if (error) { + return done(error); + } + + uid = _uid; + done(); + }); + }); + + it('should fail to create notification without a nid', done => { + notifications.create({}, error => { + assert.equal(error.message, '[[error:no-notification-id]]'); + done(); + }); + }); + + it('should create a notification', done => { + notifications.create({ + bodyShort: 'bodyShort', + nid: 'notification_id', + path: '/notification/path', + pid: 1, + }, (error, _notification) => { + notification = _notification; + assert.ifError(error); + assert(notification); + db.exists(`notifications:${notification.nid}`, (error, exists) => { + assert.ifError(error); + assert(exists); + db.isSortedSetMember('notifications', notification.nid, (error, isMember) => { + assert.ifError(error); + assert(isMember); + done(); + }); + }); + }); + }); + + it('should return null if pid is same and importance is lower', done => { + notifications.create({ + bodyShort: 'bodyShort', + nid: 'notification_id', + path: '/notification/path', + pid: 1, + importance: 1, + }, (error, notification) => { + assert.ifError(error); + assert.strictEqual(notification, null); + done(); + }); + }); + + it('should get empty array', done => { + notifications.getMultiple(null, (error, data) => { + assert.ifError(error); + assert(Array.isArray(data)); + assert.equal(data.length, 0); + done(); + }); + }); + + it('should get notifications', done => { + notifications.getMultiple([notification.nid], (error, notificationsData) => { + assert.ifError(error); + assert(Array.isArray(notificationsData)); + assert(notificationsData[0]); + assert.equal(notification.nid, notificationsData[0].nid); + done(); + }); + }); + + it('should do nothing', done => { + notifications.push(null, [], error => { + assert.ifError(error); + notifications.push({nid: null}, [], error => { + assert.ifError(error); + notifications.push(notification, [], error => { + assert.ifError(error); + done(); + }); + }); + }); + }); + + it('should push a notification to uid', done => { + notifications.push(notification, [uid], error => { + assert.ifError(error); + setTimeout(() => { + db.isSortedSetMember(`uid:${uid}:notifications:unread`, notification.nid, (error, isMember) => { + assert.ifError(error); + assert(isMember); + done(); + }); + }, 2000); + }); + }); + + it('should push a notification to a group', done => { + notifications.pushGroup(notification, 'registered-users', error => { + assert.ifError(error); + setTimeout(() => { + db.isSortedSetMember(`uid:${uid}:notifications:unread`, notification.nid, (error, isMember) => { + assert.ifError(error); + assert(isMember); + done(); + }); + }, 2000); + }); + }); + + it('should push a notification to groups', done => { + notifications.pushGroups(notification, ['registered-users', 'administrators'], error => { + assert.ifError(error); + setTimeout(() => { + db.isSortedSetMember(`uid:${uid}:notifications:unread`, notification.nid, (error, isMember) => { + assert.ifError(error); + assert(isMember); + done(); + }); + }, 2000); + }); + }); + + it('should not mark anything with invalid uid or nid', done => { + socketNotifications.markRead({uid: null}, null, error => { + assert.ifError(error); + socketNotifications.markRead({uid}, null, error => { + assert.ifError(error); + done(); + }); + }); + }); + + it('should mark a notification read', done => { + socketNotifications.markRead({uid}, notification.nid, error => { + assert.ifError(error); + db.isSortedSetMember(`uid:${uid}:notifications:unread`, notification.nid, (error, isMember) => { + assert.ifError(error); + assert.equal(isMember, false); + db.isSortedSetMember(`uid:${uid}:notifications:read`, notification.nid, (error, isMember) => { + assert.ifError(error); + assert.equal(isMember, true); + done(); + }); + }); + }); + }); + + it('should not mark anything with invalid uid or nid', done => { + socketNotifications.markUnread({uid: null}, null, error => { + assert.ifError(error); + socketNotifications.markUnread({uid}, null, error => { + assert.ifError(error); + done(); + }); + }); + }); + + it('should error if notification does not exist', done => { + socketNotifications.markUnread({uid}, 123_123, error => { + assert.equal(error.message, '[[error:no-notification]]'); + done(); + }); + }); + + it('should mark a notification unread', done => { + socketNotifications.markUnread({uid}, notification.nid, error => { + assert.ifError(error); + db.isSortedSetMember(`uid:${uid}:notifications:unread`, notification.nid, (error, isMember) => { + assert.ifError(error); + assert.equal(isMember, true); + db.isSortedSetMember(`uid:${uid}:notifications:read`, notification.nid, (error, isMember) => { + assert.ifError(error); + assert.equal(isMember, false); + socketNotifications.getCount({uid}, null, (error, count) => { + assert.ifError(error); + assert.equal(count, 1); + done(); + }); + }); + }); + }); + }); + + it('should mark all notifications read', done => { + socketNotifications.markAllRead({uid}, null, error => { + assert.ifError(error); + db.isSortedSetMember(`uid:${uid}:notifications:unread`, notification.nid, (error, isMember) => { + assert.ifError(error); + assert.equal(isMember, false); + db.isSortedSetMember(`uid:${uid}:notifications:read`, notification.nid, (error, isMember) => { + assert.ifError(error); + assert.equal(isMember, true); + done(); + }); + }); + }); + }); + + it('should not do anything', done => { + socketNotifications.markAllRead({uid: 1000}, null, error => { + assert.ifError(error); + done(); + }); + }); + + it('should link to the first unread post in a watched topic', done => { + const categories = require('../src/categories'); + const topics = require('../src/topics'); + let watcherUid; + let cid; + let tid; + let pid; + + async.waterfall([ + function (next) { + user.create({username: 'watcher'}, next); + }, + function (_watcherUid, next) { + watcherUid = _watcherUid; + + categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + }, next); + }, + function (category, next) { + cid = category.cid; + + topics.post({ + uid: watcherUid, + cid, + title: 'Test Topic Title', + content: 'The content of test topic', + }, next); + }, + function (topic, next) { + tid = topic.topicData.tid; + + topics.follow(tid, watcherUid, next); + }, + function (next) { + topics.reply({ + uid, + content: 'This is the first reply.', + tid, + }, next); + }, + function (post, next) { + pid = post.pid; + + topics.reply({ + uid, + content: 'This is the second reply.', + tid, + }, next); + }, + function (post, next) { + // Notifications are sent asynchronously with a 1 second delay. + setTimeout(next, 3000); + }, + function (next) { + user.notifications.get(watcherUid, next); + }, + function (notifications, next) { + assert.equal(notifications.unread.length, 1, 'there should be 1 unread notification'); + assert.equal(`${nconf.get('relative_path')}/post/${pid}`, notifications.unread[0].path, 'the notification should link to the first unread post'); + next(); + }, + ], error => { + assert.ifError(error); + done(); + }); + }); + + it('should get notification by nid', done => { + socketNotifications.get({uid}, {nids: [notification.nid]}, (error, data) => { + assert.ifError(error); + assert.equal(data[0].bodyShort, 'bodyShort'); + assert.equal(data[0].nid, 'notification_id'); + assert.equal(data[0].path, `${nconf.get('relative_path')}/notification/path`); + done(); + }); + }); + + it('should get user\'s notifications', done => { + socketNotifications.get({uid}, {}, (error, data) => { + assert.ifError(error); + assert.equal(data.unread.length, 0); + assert.equal(data.read[0].nid, 'notification_id'); + done(); + }); + }); + + it('should error if not logged in', done => { + socketNotifications.deleteAll({uid: 0}, null, error => { + assert.equal(error.message, '[[error:no-privileges]]'); + done(); + }); + }); + + it('should delete all user notifications', done => { + socketNotifications.deleteAll({uid}, null, error => { + assert.ifError(error); + socketNotifications.get({uid}, {}, (error, data) => { + assert.ifError(error); + assert.equal(data.unread.length, 0); + assert.equal(data.read.length, 0); + done(); + }); + }); + }); + + it('should return empty with falsy uid', done => { + user.notifications.get(0, (error, data) => { + assert.ifError(error); + assert.equal(data.read.length, 0); + assert.equal(data.unread.length, 0); + done(); + }); + }); + + it('should get all notifications and filter', done => { + const nid = 'willbefiltered'; + notifications.create({ + bodyShort: 'bodyShort', + nid, + path: '/notification/path', + type: 'post', + }, (error, notification) => { + assert.ifError(error); + notifications.push(notification, [uid], error_ => { + assert.ifError(error_); + setTimeout(() => { + user.notifications.getAll(uid, 'post', (error, nids) => { + assert.ifError(error); + assert(nids.includes(nid)); + done(); + }); + }, 3000); + }); + }); + }); + + it('should not get anything if notifications does not exist', done => { + user.notifications.getNotifications(['doesnotexistnid1', 'doesnotexistnid2'], uid, (error, data) => { + assert.ifError(error); + assert.deepEqual(data, []); + done(); + }); + }); + + it('should get daily notifications', done => { + user.notifications.getDailyUnread(uid, (error, data) => { + assert.ifError(error); + assert.equal(data[0].nid, 'willbefiltered'); + done(); + }); + }); + + it('should return empty array for invalid interval', done => { + user.notifications.getUnreadInterval(uid, '2 aeons', (error, data) => { + assert.ifError(error); + assert.deepEqual(data, []); + done(); + }); + }); + + it('should return 0 for falsy uid', done => { + user.notifications.getUnreadCount(0, (error, count) => { + assert.ifError(error); + assert.equal(count, 0); + done(); + }); + }); + + it('should not do anything if uid is falsy', done => { + user.notifications.deleteAll(0, error => { + assert.ifError(error); + done(); + }); + }); + + it('should send notification to followers of user when he posts', done => { + let followerUid; + async.waterfall([ + function (next) { + user.create({username: 'follower'}, next); + }, + function (_followerUid, next) { + followerUid = _followerUid; + user.follow(followerUid, uid, next); + }, + function (next) { + categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + }, next); + }, + function (category, next) { + topics.post({ + uid, + cid: category.cid, + title: 'Test Topic Title', + content: 'The content of test topic', + }, next); + }, + function (data, next) { + setTimeout(next, 1100); + }, + function (next) { + user.notifications.getAll(followerUid, '', next); + }, + ], (error, data) => { + assert.ifError(error); + assert(data); + done(); + }); + }); + + it('should send welcome notification', done => { + meta.config.welcomeNotification = 'welcome to the forums'; + user.notifications.sendWelcomeNotification(uid, error => { + assert.ifError(error); + user.notifications.sendWelcomeNotification(uid, error => { + assert.ifError(error); + setTimeout(() => { + user.notifications.getAll(uid, '', (error, data) => { + meta.config.welcomeNotification = ''; + assert.ifError(error); + assert(data.includes(`welcome_${uid}`), data); + done(); + }); + }, 2000); + }); + }); + }); + + it('should prune notifications', done => { + notifications.create({ + bodyShort: 'bodyShort', + nid: 'tobedeleted', + path: '/notification/path', + }, (error, notification) => { + assert.ifError(error); + notifications.prune(error_ => { + assert.ifError(error_); + const month = 2_592_000_000; + db.sortedSetAdd('notifications', Date.now() - (2 * month), notification.nid, error_ => { + assert.ifError(error_); + notifications.prune(error_ => { + assert.ifError(error_); + notifications.get(notification.nid, (error, data) => { + assert.ifError(error); + assert(!data); + done(); + }); + }); + }); + }); + }); + }); }); diff --git a/test/package-install.js b/test/package-install.js index c269dd9..7cff393 100644 --- a/test/package-install.js +++ b/test/package-install.js @@ -1,111 +1,110 @@ 'use strict'; -const path = require('path'); -const fs = require('fs').promises; -const assert = require('assert'); - +const path = require('node:path'); +const fs = require('node:fs').promises; +const assert = require('node:assert'); const pkgInstall = require('../src/cli/package-install'); describe('Package install lib', () => { - /** + /** * Important: * - The tests here have a beforeEach() run prior to each test, it resets * package.json and install/package.json back to identical states. * - Update `source` and `current` for testing. */ - describe('updatePackageFile()', () => { - let source; - let current; - const sourcePackagePath = path.resolve(__dirname, '../install/package.json'); - const packageFilePath = path.resolve(__dirname, '../package.json'); - - before(async () => { - // Move `install/package.json` and `package.json` out of the way for now - await fs.copyFile(sourcePackagePath, path.resolve(__dirname, '../install/package.json.bak')); // safekeeping - await fs.copyFile(packageFilePath, path.resolve(__dirname, '../package.json.bak')); // safekeeping - }); - - beforeEach(async () => { - await fs.copyFile(path.resolve(__dirname, '../install/package.json.bak'), sourcePackagePath); - await fs.copyFile(sourcePackagePath, packageFilePath); // match files for testing - source = JSON.parse(await fs.readFile(sourcePackagePath)); - current = JSON.parse(await fs.readFile(packageFilePath)); - }); - - it('should remove non-`nodebb-` modules not specified in `install/package.json`', async () => { - source.dependencies.dotenv = '16.0.0'; - await fs.writeFile(packageFilePath, JSON.stringify(source, null, 4)); - delete source.dependencies.dotenv; - - pkgInstall.updatePackageFile(); - - // assert it removed the extra package - const packageCleaned = JSON.parse(await fs.readFile(packageFilePath, 'utf8')); - assert(!packageCleaned.dependencies.dotenv, 'dependency was not removed'); - }); - - it('should merge new root level properties from `install/package.json` into `package.json`', async () => { - source.bin = './nodebb'; - await fs.writeFile(sourcePackagePath, JSON.stringify(source, null, 4)); - - pkgInstall.updatePackageFile(); - const updated = JSON.parse(await fs.readFile(packageFilePath, 'utf8')); - assert.deepStrictEqual(updated, source); - }); - - it('should add new dependencies', async () => { - source.dependencies['nodebb-plugin-foobar'] = '1.0.0'; - await fs.writeFile(sourcePackagePath, JSON.stringify(source, null, 4)); - - pkgInstall.updatePackageFile(); - const updated = JSON.parse(await fs.readFile(packageFilePath, 'utf8')); - assert.deepStrictEqual(updated, source); - }); - - it('should update version on dependencies', async () => { - source.dependencies['nodebb-plugin-mentions'] = '1.0.0'; - await fs.writeFile(sourcePackagePath, JSON.stringify(source, null, 4)); - - pkgInstall.updatePackageFile(); - const updated = JSON.parse(await fs.readFile(packageFilePath, 'utf8')); - assert.deepStrictEqual(updated, source); - }); - - it('should deep merge nested objects', async () => { - current.scripts.postinstall = 'echo "I am a silly bean";'; - await fs.writeFile(packageFilePath, JSON.stringify(current, null, 4)); - source.scripts.preinstall = 'echo "What are you?";'; - await fs.writeFile(sourcePackagePath, JSON.stringify(source, null, 4)); - source.scripts.postinstall = 'echo "I am a silly bean";'; - - pkgInstall.updatePackageFile(); - const updated = JSON.parse(await fs.readFile(packageFilePath, 'utf8')); - assert.deepStrictEqual(updated, source); - assert.strictEqual(updated.scripts.postinstall, 'echo "I am a silly bean";'); - assert.strictEqual(updated.scripts.preinstall, 'echo "What are you?";'); - }); - - it('should remove extraneous devDependencies', async () => { - current.devDependencies.expect = '27.5.1'; - await fs.writeFile(packageFilePath, JSON.stringify(current, null, 4)); - - pkgInstall.updatePackageFile(); - const updated = JSON.parse(await fs.readFile(packageFilePath, 'utf8')); - assert.strictEqual(updated.devDependencies.hasOwnProperty('expect'), false); - }); - - it('should handle if there is no package.json', async () => { - await fs.unlink(packageFilePath); - - pkgInstall.updatePackageFile(); - const updated = JSON.parse(await fs.readFile(packageFilePath, 'utf8')); - assert.deepStrictEqual(updated, source); - }); - - after(async () => { - // Clean up - await fs.rename(path.resolve(__dirname, '../install/package.json.bak'), sourcePackagePath); - await fs.rename(path.resolve(__dirname, '../package.json.bak'), packageFilePath); - }); - }); + describe('updatePackageFile()', () => { + let source; + let current; + const sourcePackagePath = path.resolve(__dirname, '../install/package.json'); + const packageFilePath = path.resolve(__dirname, '../package.json'); + + before(async () => { + // Move `install/package.json` and `package.json` out of the way for now + await fs.copyFile(sourcePackagePath, path.resolve(__dirname, '../install/package.json.bak')); // Safekeeping + await fs.copyFile(packageFilePath, path.resolve(__dirname, '../package.json.bak')); // Safekeeping + }); + + beforeEach(async () => { + await fs.copyFile(path.resolve(__dirname, '../install/package.json.bak'), sourcePackagePath); + await fs.copyFile(sourcePackagePath, packageFilePath); // Match files for testing + source = JSON.parse(await fs.readFile(sourcePackagePath)); + current = JSON.parse(await fs.readFile(packageFilePath)); + }); + + it('should remove non-`nodebb-` modules not specified in `install/package.json`', async () => { + source.dependencies.dotenv = '16.0.0'; + await fs.writeFile(packageFilePath, JSON.stringify(source, null, 4)); + delete source.dependencies.dotenv; + + pkgInstall.updatePackageFile(); + + // Assert it removed the extra package + const packageCleaned = JSON.parse(await fs.readFile(packageFilePath, 'utf8')); + assert(!packageCleaned.dependencies.dotenv, 'dependency was not removed'); + }); + + it('should merge new root level properties from `install/package.json` into `package.json`', async () => { + source.bin = './nodebb'; + await fs.writeFile(sourcePackagePath, JSON.stringify(source, null, 4)); + + pkgInstall.updatePackageFile(); + const updated = JSON.parse(await fs.readFile(packageFilePath, 'utf8')); + assert.deepStrictEqual(updated, source); + }); + + it('should add new dependencies', async () => { + source.dependencies['nodebb-plugin-foobar'] = '1.0.0'; + await fs.writeFile(sourcePackagePath, JSON.stringify(source, null, 4)); + + pkgInstall.updatePackageFile(); + const updated = JSON.parse(await fs.readFile(packageFilePath, 'utf8')); + assert.deepStrictEqual(updated, source); + }); + + it('should update version on dependencies', async () => { + source.dependencies['nodebb-plugin-mentions'] = '1.0.0'; + await fs.writeFile(sourcePackagePath, JSON.stringify(source, null, 4)); + + pkgInstall.updatePackageFile(); + const updated = JSON.parse(await fs.readFile(packageFilePath, 'utf8')); + assert.deepStrictEqual(updated, source); + }); + + it('should deep merge nested objects', async () => { + current.scripts.postinstall = 'echo "I am a silly bean";'; + await fs.writeFile(packageFilePath, JSON.stringify(current, null, 4)); + source.scripts.preinstall = 'echo "What are you?";'; + await fs.writeFile(sourcePackagePath, JSON.stringify(source, null, 4)); + source.scripts.postinstall = 'echo "I am a silly bean";'; + + pkgInstall.updatePackageFile(); + const updated = JSON.parse(await fs.readFile(packageFilePath, 'utf8')); + assert.deepStrictEqual(updated, source); + assert.strictEqual(updated.scripts.postinstall, 'echo "I am a silly bean";'); + assert.strictEqual(updated.scripts.preinstall, 'echo "What are you?";'); + }); + + it('should remove extraneous devDependencies', async () => { + current.devDependencies.expect = '27.5.1'; + await fs.writeFile(packageFilePath, JSON.stringify(current, null, 4)); + + pkgInstall.updatePackageFile(); + const updated = JSON.parse(await fs.readFile(packageFilePath, 'utf8')); + assert.strictEqual(updated.devDependencies.hasOwnProperty('expect'), false); + }); + + it('should handle if there is no package.json', async () => { + await fs.unlink(packageFilePath); + + pkgInstall.updatePackageFile(); + const updated = JSON.parse(await fs.readFile(packageFilePath, 'utf8')); + assert.deepStrictEqual(updated, source); + }); + + after(async () => { + // Clean up + await fs.rename(path.resolve(__dirname, '../install/package.json.bak'), sourcePackagePath); + await fs.rename(path.resolve(__dirname, '../package.json.bak'), packageFilePath); + }); + }); }); diff --git a/test/pagination.js b/test/pagination.js index 52d2147..33ef3da 100644 --- a/test/pagination.js +++ b/test/pagination.js @@ -1,39 +1,38 @@ 'use strict'; - -const assert = require('assert'); +const assert = require('node:assert'); const pagination = require('../src/pagination'); describe('Pagination', () => { - it('should create empty pagination for 1 page', (done) => { - const data = pagination.create(1, 1); - assert.equal(data.pages.length, 0); - assert.equal(data.rel.length, 0); - assert.equal(data.pageCount, 1); - assert.equal(data.prev.page, 1); - assert.equal(data.next.page, 1); - done(); - }); + it('should create empty pagination for 1 page', done => { + const data = pagination.create(1, 1); + assert.equal(data.pages.length, 0); + assert.equal(data.rel.length, 0); + assert.equal(data.pageCount, 1); + assert.equal(data.prev.page, 1); + assert.equal(data.next.page, 1); + done(); + }); - it('should create pagination for 10 pages', (done) => { - const data = pagination.create(2, 10); - // [1, (2), 3, 4, 5, separator, 9, 10] - assert.equal(data.pages.length, 8); - assert.equal(data.rel.length, 2); - assert.equal(data.pageCount, 10); - assert.equal(data.prev.page, 1); - assert.equal(data.next.page, 3); - done(); - }); + it('should create pagination for 10 pages', done => { + const data = pagination.create(2, 10); + // [1, (2), 3, 4, 5, separator, 9, 10] + assert.equal(data.pages.length, 8); + assert.equal(data.rel.length, 2); + assert.equal(data.pageCount, 10); + assert.equal(data.prev.page, 1); + assert.equal(data.next.page, 3); + done(); + }); - it('should create pagination for 3 pages with query params', (done) => { - const data = pagination.create(1, 3, { key: 'value' }); - assert.equal(data.pages.length, 3); - assert.equal(data.rel.length, 1); - assert.equal(data.pageCount, 3); - assert.equal(data.prev.page, 1); - assert.equal(data.next.page, 2); - assert.equal(data.pages[0].qs, 'key=value&page=1'); - done(); - }); + it('should create pagination for 3 pages with query params', done => { + const data = pagination.create(1, 3, {key: 'value'}); + assert.equal(data.pages.length, 3); + assert.equal(data.rel.length, 1); + assert.equal(data.pageCount, 3); + assert.equal(data.prev.page, 1); + assert.equal(data.next.page, 2); + assert.equal(data.pages[0].qs, 'key=value&page=1'); + done(); + }); }); diff --git a/test/password.js b/test/password.js index 684bbee..5716c03 100644 --- a/test/password.js +++ b/test/password.js @@ -1,52 +1,51 @@ 'use strict'; -const assert = require('assert'); +const assert = require('node:assert'); const bcrypt = require('bcryptjs'); - const password = require('../src/password'); describe('Password', () => { - describe('.hash()', () => { - it('should return a password hash when called', async () => { - const hash = await password.hash(12, 'test'); - assert(hash.startsWith('$2a$')); - }); - }); - - describe('.compare()', async () => { - const salt = await bcrypt.genSalt(12); - - it('should correctly compare a password and a hash', async () => { - const hash = await password.hash(12, 'test'); - const match = await password.compare('test', hash, true); - assert(match); - }); - - it('should correctly handle comparison with no sha wrapping of the input (backwards compatibility)', async () => { - const hash = await bcrypt.hash('test', salt); - const match = await password.compare('test', hash, false); - assert(match); - }); - - it('should continue to function even with passwords > 73 characters', async () => { - const arr = []; - arr.length = 100; - const hash = await password.hash(12, arr.join('a')); - - arr.length = 150; - const match = await password.compare(arr.join('a'), hash, true); - assert.strictEqual(match, false); - }); - - it('should process a million-character long password quickly', async () => { - // ... because sha512 reduces it to a constant size - const arr = []; - const start = Date.now(); - arr.length = 1000000; - await password.hash(12, arr.join('a')); - const end = Date.now(); - - assert(end - start < 5000); - }); - }); + describe('.hash()', () => { + it('should return a password hash when called', async () => { + const hash = await password.hash(12, 'test'); + assert(hash.startsWith('$2a$')); + }); + }); + + describe('.compare()', async () => { + const salt = await bcrypt.genSalt(12); + + it('should correctly compare a password and a hash', async () => { + const hash = await password.hash(12, 'test'); + const match = await password.compare('test', hash, true); + assert(match); + }); + + it('should correctly handle comparison with no sha wrapping of the input (backwards compatibility)', async () => { + const hash = await bcrypt.hash('test', salt); + const match = await password.compare('test', hash, false); + assert(match); + }); + + it('should continue to function even with passwords > 73 characters', async () => { + const array = []; + array.length = 100; + const hash = await password.hash(12, array.join('a')); + + array.length = 150; + const match = await password.compare(array.join('a'), hash, true); + assert.strictEqual(match, false); + }); + + it('should process a million-character long password quickly', async () => { + // ... because sha512 reduces it to a constant size + const array = []; + const start = Date.now(); + array.length = 1_000_000; + await password.hash(12, array.join('a')); + const end = Date.now(); + + assert(end - start < 5000); + }); + }); }); diff --git a/test/plugins-installed.js b/test/plugins-installed.js index 1418a89..38b48cf 100644 --- a/test/plugins-installed.js +++ b/test/plugins-installed.js @@ -1,23 +1,23 @@ 'use strict'; +const path = require('node:path'); +const fs = require('node:fs'); const nconf = require('nconf'); -const path = require('path'); -const fs = require('fs'); const db = require('./mocks/databasemock'); const active = nconf.get('test_plugins') || []; const toTest = fs.readdirSync(path.join(__dirname, '../node_modules')) - .filter(p => p.startsWith('nodebb-') && active.includes(p)); + .filter(p => p.startsWith('nodebb-') && active.includes(p)); describe('Installed Plugins', () => { - toTest.forEach((plugin) => { - const pathToTests = path.join(__dirname, '../node_modules', plugin, 'test'); - try { - require(pathToTests); - } catch (err) { - if (err.code !== 'MODULE_NOT_FOUND') { - console.log(err.stack); - } - } - }); + for (const plugin of toTest) { + const pathToTests = path.join(__dirname, '../node_modules', plugin, 'test'); + try { + require(pathToTests); + } catch (error) { + if (error.code !== 'MODULE_NOT_FOUND') { + console.log(error.stack); + } + } + } }); diff --git a/test/plugins.js b/test/plugins.js index ec81ee0..94cc5d7 100644 --- a/test/plugins.js +++ b/test/plugins.js @@ -1,403 +1,414 @@ 'use strict'; -const assert = require('assert'); -const path = require('path'); +const assert = require('node:assert'); +const path = require('node:path'); +const fs = require('node:fs'); const nconf = require('nconf'); const request = require('request'); -const fs = require('fs'); - -const db = require('./mocks/databasemock'); const plugins = require('../src/plugins'); +const db = require('./mocks/databasemock'); describe('Plugins', () => { - it('should load plugin data', (done) => { - const pluginId = 'nodebb-plugin-markdown'; - plugins.loadPlugin(path.join(nconf.get('base_dir'), `node_modules/${pluginId}`), (err) => { - assert.ifError(err); - assert(plugins.libraries[pluginId]); - assert(plugins.loadedHooks['static:app.load']); - - done(); - }); - }); - - it('should return true if hook has listeners', (done) => { - assert(plugins.hooks.hasListeners('filter:parse.post')); - done(); - }); - - it('should register and fire a filter hook', (done) => { - function filterMethod1(data, callback) { - data.foo += 1; - callback(null, data); - } - function filterMethod2(data, callback) { - data.foo += 5; - callback(null, data); - } - - plugins.hooks.register('test-plugin', { hook: 'filter:test.hook', method: filterMethod1 }); - plugins.hooks.register('test-plugin', { hook: 'filter:test.hook', method: filterMethod2 }); - - plugins.hooks.fire('filter:test.hook', { foo: 1 }, (err, data) => { - assert.ifError(err); - assert.equal(data.foo, 7); - done(); - }); - }); - - it('should register and fire a filter hook having 3 methods', async () => { - function method1(data, callback) { - data.foo += 1; - callback(null, data); - } - async function method2(data) { - return new Promise((resolve) => { - data.foo += 5; - resolve(data); - }); - } - function method3(data) { - data.foo += 1; - return data; - } - - plugins.hooks.register('test-plugin', { hook: 'filter:test.hook2', method: method1 }); - plugins.hooks.register('test-plugin', { hook: 'filter:test.hook2', method: method2 }); - plugins.hooks.register('test-plugin', { hook: 'filter:test.hook2', method: method3 }); - - const data = await plugins.hooks.fire('filter:test.hook2', { foo: 1 }); - assert.strictEqual(data.foo, 8); - }); - - it('should not error with invalid hooks', async () => { - function method1(data, callback) { - data.foo += 1; - return data; - } - function method2(data, callback) { - data.foo += 2; - // this is invalid - callback(null, data); - return data; - } - - plugins.hooks.register('test-plugin', { hook: 'filter:test.hook3', method: method1 }); - plugins.hooks.register('test-plugin', { hook: 'filter:test.hook3', method: method2 }); - - const data = await plugins.hooks.fire('filter:test.hook3', { foo: 1 }); - assert.strictEqual(data.foo, 4); - }); - - it('should register and fire a filter hook that returns a promise that gets rejected', (done) => { - async function method(data) { - return new Promise((resolve, reject) => { - data.foo += 5; - reject(new Error('nope')); - }); - } - plugins.hooks.register('test-plugin', { hook: 'filter:test.hook4', method: method }); - plugins.hooks.fire('filter:test.hook4', { foo: 1 }, (err) => { - assert(err); - done(); - }); - }); - - it('should register and fire an action hook', (done) => { - function actionMethod(data) { - assert.equal(data.bar, 'test'); - done(); - } - - plugins.hooks.register('test-plugin', { hook: 'action:test.hook', method: actionMethod }); - plugins.hooks.fire('action:test.hook', { bar: 'test' }); - }); - - it('should register and fire a static hook', (done) => { - function actionMethod(data, callback) { - assert.equal(data.bar, 'test'); - callback(); - } - - plugins.hooks.register('test-plugin', { hook: 'static:test.hook', method: actionMethod }); - plugins.hooks.fire('static:test.hook', { bar: 'test' }, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should register and fire a static hook returning a promise', (done) => { - async function method(data) { - assert.equal(data.bar, 'test'); - return new Promise((resolve) => { - resolve(); - }); - } - plugins.hooks.register('test-plugin', { hook: 'static:test.hook', method: method }); - plugins.hooks.fire('static:test.hook', { bar: 'test' }, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should register and fire a static hook returning a promise that gets rejected with a error', (done) => { - async function method(data) { - assert.equal(data.bar, 'test'); - return new Promise((resolve, reject) => { - reject(new Error('just because')); - }); - } - plugins.hooks.register('test-plugin', { hook: 'static:test.hook', method: method }); - plugins.hooks.fire('static:test.hook', { bar: 'test' }, (err) => { - assert.strictEqual(err.message, 'just because'); - plugins.hooks.unregister('test-plugin', 'static:test.hook', method); - done(); - }); - }); - - it('should register and timeout a static hook returning a promise but takes too long', (done) => { - async function method(data) { - assert.equal(data.bar, 'test'); - return new Promise((resolve) => { - setTimeout(resolve, 6000); - }); - } - plugins.hooks.register('test-plugin', { hook: 'static:test.hook', method: method }); - plugins.hooks.fire('static:test.hook', { bar: 'test' }, (err) => { - assert.ifError(err); - plugins.hooks.unregister('test-plugin', 'static:test.hook', method); - done(); - }); - }); - - it('should get plugin data from nbbpm', (done) => { - plugins.get('nodebb-plugin-markdown', (err, data) => { - assert.ifError(err); - const keys = ['id', 'name', 'url', 'description', 'latest', 'installed', 'active', 'latest']; - assert.equal(data.name, 'nodebb-plugin-markdown'); - assert.equal(data.id, 'nodebb-plugin-markdown'); - keys.forEach((key) => { - assert(data.hasOwnProperty(key)); - }); - done(); - }); - }); - - it('should get a list of plugins', (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)); - }); - done(); - }); - }); - - it('should show installed plugins', (done) => { - const { nodeModulesPath } = plugins; - plugins.nodeModulesPath = path.join(__dirname, './mocks/plugin_modules'); - - plugins.showInstalled((err, pluginsData) => { - assert.ifError(err); - const paths = pluginsData.map(plugin => path.relative(plugins.nodeModulesPath, plugin.path).replace(/\\/g, '/')); - assert(paths.indexOf('nodebb-plugin-xyz') > -1); - assert(paths.indexOf('@nodebb/nodebb-plugin-abc') > -1); - - plugins.nodeModulesPath = nodeModulesPath; - done(); - }); - }); - - it('should submit usage info', (done) => { - plugins.submitUsageData((err) => { - assert.ifError(err); - done(); - }); - }); - - describe('install/activate/uninstall', () => { - let latest; - const pluginName = 'nodebb-plugin-imgur'; - const oldValue = process.env.NODE_ENV; - before((done) => { - process.env.NODE_ENV = 'development'; - done(); - }); - after((done) => { - process.env.NODE_ENV = oldValue; - done(); - }); - - it('should install a plugin', function (done) { - this.timeout(0); - plugins.toggleInstall(pluginName, '1.0.16', (err, pluginData) => { - assert.ifError(err); - latest = pluginData.latest; - - assert.equal(pluginData.name, pluginName); - assert.equal(pluginData.id, pluginName); - assert.equal(pluginData.url, 'https://github.com/barisusakli/nodebb-plugin-imgur#readme'); - assert.equal(pluginData.description, 'A Plugin that uploads images to imgur'); - assert.equal(pluginData.active, false); - assert.equal(pluginData.installed, true); - - const packageFile = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8')); - assert(packageFile.dependencies[pluginName]); - - done(); - }); - }); - - it('should activate plugin', (done) => { - plugins.toggleActive(pluginName, (err) => { - assert.ifError(err); - plugins.isActive(pluginName, (err, isActive) => { - assert.ifError(err); - assert(isActive); - done(); - }); - }); - }); - - it('should upgrade plugin', function (done) { - this.timeout(0); - plugins.upgrade(pluginName, 'latest', (err, isActive) => { - assert.ifError(err); - assert(isActive); - plugins.loadPluginInfo(path.join(nconf.get('base_dir'), 'node_modules', pluginName), (err, pluginInfo) => { - assert.ifError(err); - assert.equal(pluginInfo.version, latest); - done(); - }); - }); - }); - - it('should uninstall a plugin', function (done) { - this.timeout(0); - plugins.toggleInstall(pluginName, 'latest', (err, pluginData) => { - assert.ifError(err); - assert.equal(pluginData.installed, false); - assert.equal(pluginData.active, false); - - const packageFile = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8')); - assert(!packageFile.dependencies[pluginName]); - - done(); - }); - }); - }); - - describe('static assets', () => { - it('should 404 if resource does not exist', (done) => { - request.get(`${nconf.get('url')}/plugins/doesnotexist/should404.tpl`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - assert(body); - done(); - }); - }); - - it('should 404 if resource does not exist', (done) => { - const url = `${nconf.get('url')}/plugins/nodebb-plugin-dbsearch/dbsearch/templates/admin/plugins/should404.tpl`; - request.get(url, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 404); - assert(body); - done(); - }); - }); - - it('should get resource', (done) => { - const url = `${nconf.get('url')}/assets/templates/admin/plugins/dbsearch.tpl`; - request.get(url, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - }); - - 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(); - }); - }); - }); - }); + it('should load plugin data', done => { + const pluginId = 'nodebb-plugin-markdown'; + plugins.loadPlugin(path.join(nconf.get('base_dir'), `node_modules/${pluginId}`), error => { + assert.ifError(error); + assert(plugins.libraries[pluginId]); + assert(plugins.loadedHooks['static:app.load']); + + done(); + }); + }); + + it('should return true if hook has listeners', done => { + assert(plugins.hooks.hasListeners('filter:parse.post')); + done(); + }); + + it('should register and fire a filter hook', done => { + function filterMethod1(data, callback) { + data.foo += 1; + callback(null, data); + } + + function filterMethod2(data, callback) { + data.foo += 5; + callback(null, data); + } + + plugins.hooks.register('test-plugin', {hook: 'filter:test.hook', method: filterMethod1}); + plugins.hooks.register('test-plugin', {hook: 'filter:test.hook', method: filterMethod2}); + + plugins.hooks.fire('filter:test.hook', {foo: 1}, (error, data) => { + assert.ifError(error); + assert.equal(data.foo, 7); + done(); + }); + }); + + it('should register and fire a filter hook having 3 methods', async () => { + function method1(data, callback) { + data.foo += 1; + callback(null, data); + } + + async function method2(data) { + return new Promise(resolve => { + data.foo += 5; + resolve(data); + }); + } + + function method3(data) { + data.foo += 1; + return data; + } + + plugins.hooks.register('test-plugin', {hook: 'filter:test.hook2', method: method1}); + plugins.hooks.register('test-plugin', {hook: 'filter:test.hook2', method: method2}); + plugins.hooks.register('test-plugin', {hook: 'filter:test.hook2', method: method3}); + + const data = await plugins.hooks.fire('filter:test.hook2', {foo: 1}); + assert.strictEqual(data.foo, 8); + }); + + it('should not error with invalid hooks', async () => { + function method1(data, callback) { + data.foo += 1; + return data; + } + + function method2(data, callback) { + data.foo += 2; + // This is invalid + callback(null, data); + return data; + } + + plugins.hooks.register('test-plugin', {hook: 'filter:test.hook3', method: method1}); + plugins.hooks.register('test-plugin', {hook: 'filter:test.hook3', method: method2}); + + const data = await plugins.hooks.fire('filter:test.hook3', {foo: 1}); + assert.strictEqual(data.foo, 4); + }); + + it('should register and fire a filter hook that returns a promise that gets rejected', done => { + async function method(data) { + return new Promise((resolve, reject) => { + data.foo += 5; + reject(new Error('nope')); + }); + } + + plugins.hooks.register('test-plugin', {hook: 'filter:test.hook4', method}); + plugins.hooks.fire('filter:test.hook4', {foo: 1}, error => { + assert(error); + done(); + }); + }); + + it('should register and fire an action hook', done => { + function actionMethod(data) { + assert.equal(data.bar, 'test'); + done(); + } + + plugins.hooks.register('test-plugin', {hook: 'action:test.hook', method: actionMethod}); + plugins.hooks.fire('action:test.hook', {bar: 'test'}); + }); + + it('should register and fire a static hook', done => { + function actionMethod(data, callback) { + assert.equal(data.bar, 'test'); + callback(); + } + + plugins.hooks.register('test-plugin', {hook: 'static:test.hook', method: actionMethod}); + plugins.hooks.fire('static:test.hook', {bar: 'test'}, error => { + assert.ifError(error); + done(); + }); + }); + + it('should register and fire a static hook returning a promise', done => { + async function method(data) { + assert.equal(data.bar, 'test'); + return new Promise(resolve => { + resolve(); + }); + } + + plugins.hooks.register('test-plugin', {hook: 'static:test.hook', method}); + plugins.hooks.fire('static:test.hook', {bar: 'test'}, error => { + assert.ifError(error); + done(); + }); + }); + + it('should register and fire a static hook returning a promise that gets rejected with a error', done => { + async function method(data) { + assert.equal(data.bar, 'test'); + return new Promise((resolve, reject) => { + reject(new Error('just because')); + }); + } + + plugins.hooks.register('test-plugin', {hook: 'static:test.hook', method}); + plugins.hooks.fire('static:test.hook', {bar: 'test'}, error => { + assert.strictEqual(error.message, 'just because'); + plugins.hooks.unregister('test-plugin', 'static:test.hook', method); + done(); + }); + }); + + it('should register and timeout a static hook returning a promise but takes too long', done => { + async function method(data) { + assert.equal(data.bar, 'test'); + return new Promise(resolve => { + setTimeout(resolve, 6000); + }); + } + + plugins.hooks.register('test-plugin', {hook: 'static:test.hook', method}); + plugins.hooks.fire('static:test.hook', {bar: 'test'}, error => { + assert.ifError(error); + plugins.hooks.unregister('test-plugin', 'static:test.hook', method); + done(); + }); + }); + + it('should get plugin data from nbbpm', done => { + plugins.get('nodebb-plugin-markdown', (error, data) => { + assert.ifError(error); + const keys = ['id', 'name', 'url', 'description', 'latest', 'installed', 'active', 'latest']; + assert.equal(data.name, 'nodebb-plugin-markdown'); + assert.equal(data.id, 'nodebb-plugin-markdown'); + for (const key of keys) { + assert(data.hasOwnProperty(key)); + } + + done(); + }); + }); + + it('should get a list of plugins', done => { + plugins.list((error, data) => { + assert.ifError(error); + const keys = ['id', 'name', 'url', 'description', 'latest', 'installed', 'active', 'latest']; + assert(Array.isArray(data)); + for (const key of keys) { + assert(data[0].hasOwnProperty(key)); + } + + done(); + }); + }); + + it('should show installed plugins', done => { + const {nodeModulesPath} = plugins; + plugins.nodeModulesPath = path.join(__dirname, './mocks/plugin_modules'); + + plugins.showInstalled((error, pluginsData) => { + assert.ifError(error); + const paths = new Set(pluginsData.map(plugin => path.relative(plugins.nodeModulesPath, plugin.path).replaceAll('\\', '/'))); + assert(paths.has('nodebb-plugin-xyz')); + assert(paths.has('@nodebb/nodebb-plugin-abc')); + + plugins.nodeModulesPath = nodeModulesPath; + done(); + }); + }); + + it('should submit usage info', done => { + plugins.submitUsageData(error => { + assert.ifError(error); + done(); + }); + }); + + describe('install/activate/uninstall', () => { + let latest; + const pluginName = 'nodebb-plugin-imgur'; + const oldValue = process.env.NODE_ENV; + before(done => { + process.env.NODE_ENV = 'development'; + done(); + }); + after(done => { + process.env.NODE_ENV = oldValue; + done(); + }); + + it('should install a plugin', function (done) { + this.timeout(0); + plugins.toggleInstall(pluginName, '1.0.16', (error, pluginData) => { + assert.ifError(error); + latest = pluginData.latest; + + assert.equal(pluginData.name, pluginName); + assert.equal(pluginData.id, pluginName); + assert.equal(pluginData.url, 'https://github.com/barisusakli/nodebb-plugin-imgur#readme'); + assert.equal(pluginData.description, 'A Plugin that uploads images to imgur'); + assert.equal(pluginData.active, false); + assert.equal(pluginData.installed, true); + + const packageFile = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8')); + assert(packageFile.dependencies[pluginName]); + + done(); + }); + }); + + it('should activate plugin', done => { + plugins.toggleActive(pluginName, error => { + assert.ifError(error); + plugins.isActive(pluginName, (error, isActive) => { + assert.ifError(error); + assert(isActive); + done(); + }); + }); + }); + + it('should upgrade plugin', function (done) { + this.timeout(0); + plugins.upgrade(pluginName, 'latest', (error, isActive) => { + assert.ifError(error); + assert(isActive); + plugins.loadPluginInfo(path.join(nconf.get('base_dir'), 'node_modules', pluginName), (error, pluginInfo) => { + assert.ifError(error); + assert.equal(pluginInfo.version, latest); + done(); + }); + }); + }); + + it('should uninstall a plugin', function (done) { + this.timeout(0); + plugins.toggleInstall(pluginName, 'latest', (error, pluginData) => { + assert.ifError(error); + assert.equal(pluginData.installed, false); + assert.equal(pluginData.active, false); + + const packageFile = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8')); + assert(!packageFile.dependencies[pluginName]); + + done(); + }); + }); + }); + + describe('static assets', () => { + it('should 404 if resource does not exist', done => { + request.get(`${nconf.get('url')}/plugins/doesnotexist/should404.tpl`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + assert(body); + done(); + }); + }); + + it('should 404 if resource does not exist', done => { + const url = `${nconf.get('url')}/plugins/nodebb-plugin-dbsearch/dbsearch/templates/admin/plugins/should404.tpl`; + request.get(url, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 404); + assert(body); + done(); + }); + }); + + it('should get resource', done => { + const url = `${nconf.get('url')}/assets/templates/admin/plugins/dbsearch.tpl`; + request.get(url, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + }); + + 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], (error, isActive) => { + assert.ifError(error); + assert(isActive); + done(); + }); + }); + + it('should return inactive plugin state if not in configuration', done => { + plugins.isActive(inactivePlugin, (error, isActive) => { + assert.ifError(error); + assert(!isActive); + done(); + }); + }); + + it('should get a list of plugins from configuration', done => { + plugins.list((error, data) => { + assert.ifError(error); + const keys = ['id', 'name', 'url', 'description', 'latest', 'installed', 'active', 'latest']; + assert(Array.isArray(data)); + for (const key of keys) { + assert(data[0].hasOwnProperty(key)); + } + + for (const pluginData of data) { + assert.equal(pluginData.active, activePlugins.includes(pluginData.id)); + } + + done(); + }); + }); + + it('should return a list of only active plugins from configuration', done => { + plugins.getActive((error, data) => { + assert.ifError(error); + assert(Array.isArray(data)); + for (const pluginData of data) { + 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], (error, isActive) => { + assert.ifError(error); + 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, (error, isActive) => { + assert.ifError(error); + assert(!isActive); + done(); + }); + }); + }); + }); }); - diff --git a/test/posts.js b/test/posts.js index 615cb0b..5aafd60 100644 --- a/test/posts.js +++ b/test/posts.js @@ -1,16 +1,14 @@ 'use strict'; - -const assert = require('assert'); -const async = require('async'); +const assert = require('node:assert'); +const path = require('node:path'); +const util = require('node:util'); const request = require('request'); const nconf = require('nconf'); -const path = require('path'); -const util = require('util'); +const async = require('async'); const sleep = util.promisify(setTimeout); -const db = require('./mocks/databasemock'); const topics = require('../src/topics'); const posts = require('../src/posts'); const categories = require('../src/categories'); @@ -22,1312 +20,1346 @@ const apiPosts = require('../src/api/posts'); const apiTopics = require('../src/api/topics'); const meta = require('../src/meta'); const file = require('../src/file'); +const db = require('./mocks/databasemock'); const helpers = require('./helpers'); describe('Post\'s', () => { - let voterUid; - let voteeUid; - let adminUid; - let globalModUid; - let randomUserUid; - let postData; - let topicData; - let cid; - - before((done) => { - async.series({ - voterUid: function (next) { - user.create({ username: 'upvoter' }, next); - }, - voteeUid: function (next) { - user.create({ username: 'upvotee' }, next); - }, - adminUid: function (next) { - user.create({ username: 'TheAdmin', password: 'adminpwd' }, next); - }, - globalModUid: function (next) { - user.create({ username: 'globalmod', password: 'globalmodpwd' }, next); - }, - randomUserUid: function (next) { - user.create({ username: 'randomuser' }, next); - }, - category: function (next) { - categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - }, next); - }, - }, (err, results) => { - if (err) { - return done(err); - } - - voterUid = results.voterUid; - voteeUid = results.voteeUid; - adminUid = results.adminUid; - globalModUid = results.globalModUid; - randomUserUid = results.randomUserUid; - cid = results.category.cid; - - topics.post({ - uid: results.voteeUid, - cid: results.category.cid, - title: 'Test Topic Title', - content: 'The content of test topic', - }, (err, data) => { - if (err) { - return done(err); - } - postData = data.postData; - topicData = data.topicData; - - groups.join('administrators', adminUid); - groups.join('Global Moderators', globalModUid, done); - }); - }); - }); - - it('should update category teaser properly', async () => { - const util = require('util'); - const getCategoriesAsync = util.promisify(async (callback) => { - request(`${nconf.get('url')}/api/categories`, { json: true }, (err, res, body) => { - callback(err, body); - }); - }); - - const postResult = await topics.post({ uid: globalModUid, cid: cid, title: 'topic title', content: '123456789' }); - - let data = await getCategoriesAsync(); - assert.equal(data.categories[0].teaser.pid, postResult.postData.pid); - assert.equal(data.categories[0].posts[0].content, '123456789'); - assert.equal(data.categories[0].posts[0].pid, postResult.postData.pid); - - const newUid = await user.create({ username: 'teaserdelete' }); - const newPostResult = await topics.post({ uid: newUid, cid: cid, title: 'topic title', content: 'xxxxxxxx' }); - - data = await getCategoriesAsync(); - assert.equal(data.categories[0].teaser.pid, newPostResult.postData.pid); - assert.equal(data.categories[0].posts[0].content, 'xxxxxxxx'); - assert.equal(data.categories[0].posts[0].pid, newPostResult.postData.pid); - - await user.delete(1, newUid); - - data = await getCategoriesAsync(); - assert.equal(data.categories[0].teaser.pid, postResult.postData.pid); - assert.equal(data.categories[0].posts[0].content, '123456789'); - assert.equal(data.categories[0].posts[0].pid, postResult.postData.pid); - }); - - it('should change owner of post and topic properly', async () => { - const oldUid = await user.create({ username: 'olduser' }); - const newUid = await user.create({ username: 'newuser' }); - const postResult = await topics.post({ uid: oldUid, cid: cid, title: 'change owner', content: 'original post' }); - const postData = await topics.reply({ uid: oldUid, tid: postResult.topicData.tid, content: 'firstReply' }); - const pid1 = postResult.postData.pid; - const pid2 = postData.pid; - - assert.deepStrictEqual(await db.sortedSetScores(`tid:${postResult.topicData.tid}:posters`, [oldUid, newUid]), [2, null]); - - await posts.changeOwner([pid1, pid2], newUid); - - assert.deepStrictEqual(await db.sortedSetScores(`tid:${postResult.topicData.tid}:posters`, [oldUid, newUid]), [0, 2]); - - assert.deepStrictEqual(await posts.isOwner([pid1, pid2], oldUid), [false, false]); - assert.deepStrictEqual(await posts.isOwner([pid1, pid2], newUid), [true, true]); - - assert.strictEqual(await user.getUserField(oldUid, 'postcount'), 0); - assert.strictEqual(await user.getUserField(newUid, 'postcount'), 2); - - assert.strictEqual(await user.getUserField(oldUid, 'topiccount'), 0); - assert.strictEqual(await user.getUserField(newUid, 'topiccount'), 1); - - assert.strictEqual(await db.sortedSetScore('users:postcount', oldUid), 0); - assert.strictEqual(await db.sortedSetScore('users:postcount', newUid), 2); - - assert.strictEqual(await topics.isOwner(postResult.topicData.tid, oldUid), false); - assert.strictEqual(await topics.isOwner(postResult.topicData.tid, newUid), true); - }); - - it('should fail to change owner if new owner does not exist', async () => { - try { - await posts.changeOwner([1], '9999999'); - } catch (err) { - assert.strictEqual(err.message, '[[error:no-user]]'); - } - }); - - it('should fail to change owner if user is not authorized', async () => { - try { - await socketPosts.changeOwner({ uid: voterUid }, { pids: [1, 2], toUid: voterUid }); - } catch (err) { - assert.strictEqual(err.message, '[[error:no-privileges]]'); - } - }); - - it('should return falsy if post does not exist', (done) => { - posts.getPostData(9999, (err, postData) => { - assert.ifError(err); - assert.equal(postData, null); - done(); - }); - }); - - describe('voting', () => { - it('should fail to upvote post if group does not have upvote permission', async () => { - await privileges.categories.rescind(['groups:posts:upvote', 'groups:posts:downvote'], cid, 'registered-users'); - let err; - try { - await apiPosts.upvote({ uid: voterUid }, { pid: postData.pid, room_id: 'topic_1' }); - } catch (_err) { - err = _err; - } - assert.equal(err.message, '[[error:no-privileges]]'); - try { - await apiPosts.downvote({ uid: voterUid }, { pid: postData.pid, room_id: 'topic_1' }); - } catch (_err) { - err = _err; - } - assert.equal(err.message, '[[error:no-privileges]]'); - await privileges.categories.give(['groups:posts:upvote', 'groups:posts:downvote'], cid, 'registered-users'); - }); - - it('should upvote a post', async () => { - const result = await apiPosts.upvote({ uid: voterUid }, { pid: postData.pid, room_id: 'topic_1' }); - assert.equal(result.post.upvotes, 1); - assert.equal(result.post.downvotes, 0); - assert.equal(result.post.votes, 1); - assert.equal(result.user.reputation, 1); - const data = await posts.hasVoted(postData.pid, voterUid); - assert.equal(data.upvoted, true); - assert.equal(data.downvoted, false); - }); - - it('should add the pid to the :votes sorted set for that user', async () => { - const cid = await posts.getCidByPid(postData.pid); - const { uid, pid } = postData; - - const score = await db.sortedSetScore(`cid:${cid}:uid:${uid}:pids:votes`, pid); - assert.strictEqual(score, 1); - }); - - it('should get voters', (done) => { - socketPosts.getVoters({ uid: globalModUid }, { pid: postData.pid, cid: cid }, (err, data) => { - assert.ifError(err); - assert.equal(data.upvoteCount, 1); - assert.equal(data.downvoteCount, 0); - assert(Array.isArray(data.upvoters)); - assert.equal(data.upvoters[0].username, 'upvoter'); - done(); - }); - }); - - it('should get upvoters', (done) => { - socketPosts.getUpvoters({ uid: globalModUid }, [postData.pid], (err, data) => { - assert.ifError(err); - assert.equal(data[0].otherCount, 0); - assert.equal(data[0].usernames, 'upvoter'); - done(); - }); - }); - - it('should unvote a post', async () => { - const result = await apiPosts.unvote({ uid: voterUid }, { pid: postData.pid, room_id: 'topic_1' }); - assert.equal(result.post.upvotes, 0); - assert.equal(result.post.downvotes, 0); - assert.equal(result.post.votes, 0); - assert.equal(result.user.reputation, 0); - const data = await posts.hasVoted(postData.pid, voterUid); - assert.equal(data.upvoted, false); - assert.equal(data.downvoted, false); - }); - - it('should downvote a post', async () => { - const result = await apiPosts.downvote({ uid: voterUid }, { pid: postData.pid, room_id: 'topic_1' }); - assert.equal(result.post.upvotes, 0); - assert.equal(result.post.downvotes, 1); - assert.equal(result.post.votes, -1); - assert.equal(result.user.reputation, -1); - const data = await posts.hasVoted(postData.pid, voterUid); - assert.equal(data.upvoted, false); - assert.equal(data.downvoted, true); - }); - - it('should add the pid to the :votes sorted set for that user', async () => { - const cid = await posts.getCidByPid(postData.pid); - const { uid, pid } = postData; - - const score = await db.sortedSetScore(`cid:${cid}:uid:${uid}:pids:votes`, pid); - assert.strictEqual(score, -1); - }); - - it('should prevent downvoting more than total daily limit', async () => { - const oldValue = meta.config.downvotesPerDay; - meta.config.downvotesPerDay = 1; - let err; - const p1 = await topics.reply({ - uid: voteeUid, - tid: topicData.tid, - content: 'raw content', - }); - try { - await apiPosts.downvote({ uid: voterUid }, { pid: p1.pid, room_id: 'topic_1' }); - } catch (_err) { - err = _err; - } - assert.equal(err.message, '[[error:too-many-downvotes-today, 1]]'); - meta.config.downvotesPerDay = oldValue; - }); - - it('should prevent downvoting target user more than total daily limit', async () => { - const oldValue = meta.config.downvotesPerUserPerDay; - meta.config.downvotesPerUserPerDay = 1; - let err; - const p1 = await topics.reply({ - uid: voteeUid, - tid: topicData.tid, - content: 'raw content', - }); - try { - await apiPosts.downvote({ uid: voterUid }, { pid: p1.pid, room_id: 'topic_1' }); - } catch (_err) { - err = _err; - } - assert.equal(err.message, '[[error:too-many-downvotes-today-user, 1]]'); - meta.config.downvotesPerUserPerDay = oldValue; - }); - }); - - describe('bookmarking', () => { - it('should bookmark a post', async () => { - const data = await apiPosts.bookmark({ uid: voterUid }, { pid: postData.pid, room_id: `topic_${postData.tid}` }); - assert.equal(data.isBookmarked, true); - const hasBookmarked = await posts.hasBookmarked(postData.pid, voterUid); - assert.equal(hasBookmarked, true); - }); - - it('should unbookmark a post', async () => { - const data = await apiPosts.unbookmark({ uid: voterUid }, { pid: postData.pid, room_id: `topic_${postData.tid}` }); - assert.equal(data.isBookmarked, false); - const hasBookmarked = await posts.hasBookmarked([postData.pid], voterUid); - assert.equal(hasBookmarked[0], false); - }); - }); - - describe('pinning', () => { - it('should pin a post', async () => { - const topicOwnerUid = topicData.uid; - // Yes, the room_id is needed for the test to run - const data = await apiPosts.pin({ uid: topicOwnerUid }, { pid: postData.pid, room_id: `topic_${postData.tid}` }); - - assert.equal(data.pinned, true); - - const hasPinned = await posts.hasPinned([postData.pid]); - - assert.equal(hasPinned, true); - }); - - it('should unpin a post', async () => { - // Have the same user unpin the post - const topicOwnerUid = topicData.uid; - const data = await apiPosts.unpin({ uid: topicOwnerUid }, { pid: postData.pid, room_id: `topic_${postData.tid}` }); - - assert.equal(data.pinned, false); - - const hasPinned = await posts.hasPinned([postData.pid]); - - assert.equal(hasPinned, false); - }); - }); - - describe('resolving', () => { - it('should resolve a post', async () => { - const data = await apiPosts.resolve({ uid: voterUid }, { pid: postData.pid, room_id: `topic_${postData.tid}` }); - assert.equal(data.isResolved, true); - }); - }); - - describe('post tools', () => { - it('should error if data is invalid', (done) => { - socketPosts.loadPostTools({ uid: globalModUid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should load post tools', (done) => { - socketPosts.loadPostTools({ uid: globalModUid }, { pid: postData.pid, cid: cid }, (err, data) => { - assert.ifError(err); - assert(data.posts.display_edit_tools); - assert(data.posts.display_delete_tools); - assert(data.posts.display_moderator_tools); - assert(data.posts.display_move_tools); - done(); - }); - }); - - /* + let voterUid; + let voteeUid; + let adminUid; + let globalModuleUid; + let randomUserUid; + let postData; + let topicData; + let cid; + + before(done => { + async.series({ + voterUid(next) { + user.create({username: 'upvoter'}, next); + }, + voteeUid(next) { + user.create({username: 'upvotee'}, next); + }, + adminUid(next) { + user.create({username: 'TheAdmin', password: 'adminpwd'}, next); + }, + globalModUid(next) { + user.create({username: 'globalmod', password: 'globalmodpwd'}, next); + }, + randomUserUid(next) { + user.create({username: 'randomuser'}, next); + }, + category(next) { + categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + }, next); + }, + }, (error, results) => { + if (error) { + return done(error); + } + + voterUid = results.voterUid; + voteeUid = results.voteeUid; + adminUid = results.adminUid; + globalModuleUid = results.globalModUid; + randomUserUid = results.randomUserUid; + cid = results.category.cid; + + topics.post({ + uid: results.voteeUid, + cid: results.category.cid, + title: 'Test Topic Title', + content: 'The content of test topic', + }, (error, data) => { + if (error) { + return done(error); + } + + postData = data.postData; + topicData = data.topicData; + + groups.join('administrators', adminUid); + groups.join('Global Moderators', globalModuleUid, done); + }); + }); + }); + + it('should update category teaser properly', async () => { + const util = require('node:util'); + const getCategoriesAsync = util.promisify(async callback => { + request(`${nconf.get('url')}/api/categories`, {json: true}, (error, res, body) => { + callback(error, body); + }); + }); + + const postResult = await topics.post({ + uid: globalModuleUid, cid, title: 'topic title', content: '123456789', + }); + + let data = await getCategoriesAsync(); + assert.equal(data.categories[0].teaser.pid, postResult.postData.pid); + assert.equal(data.categories[0].posts[0].content, '123456789'); + assert.equal(data.categories[0].posts[0].pid, postResult.postData.pid); + + const newUid = await user.create({username: 'teaserdelete'}); + const newPostResult = await topics.post({ + uid: newUid, cid, title: 'topic title', content: 'xxxxxxxx', + }); + + data = await getCategoriesAsync(); + assert.equal(data.categories[0].teaser.pid, newPostResult.postData.pid); + assert.equal(data.categories[0].posts[0].content, 'xxxxxxxx'); + assert.equal(data.categories[0].posts[0].pid, newPostResult.postData.pid); + + await user.delete(1, newUid); + + data = await getCategoriesAsync(); + assert.equal(data.categories[0].teaser.pid, postResult.postData.pid); + assert.equal(data.categories[0].posts[0].content, '123456789'); + assert.equal(data.categories[0].posts[0].pid, postResult.postData.pid); + }); + + it('should change owner of post and topic properly', async () => { + const oldUid = await user.create({username: 'olduser'}); + const newUid = await user.create({username: 'newuser'}); + const postResult = await topics.post({ + uid: oldUid, cid, title: 'change owner', content: 'original post', + }); + const postData = await topics.reply({uid: oldUid, tid: postResult.topicData.tid, content: 'firstReply'}); + const pid1 = postResult.postData.pid; + const pid2 = postData.pid; + + assert.deepStrictEqual(await db.sortedSetScores(`tid:${postResult.topicData.tid}:posters`, [oldUid, newUid]), [2, null]); + + await posts.changeOwner([pid1, pid2], newUid); + + assert.deepStrictEqual(await db.sortedSetScores(`tid:${postResult.topicData.tid}:posters`, [oldUid, newUid]), [0, 2]); + + assert.deepStrictEqual(await posts.isOwner([pid1, pid2], oldUid), [false, false]); + assert.deepStrictEqual(await posts.isOwner([pid1, pid2], newUid), [true, true]); + + assert.strictEqual(await user.getUserField(oldUid, 'postcount'), 0); + assert.strictEqual(await user.getUserField(newUid, 'postcount'), 2); + + assert.strictEqual(await user.getUserField(oldUid, 'topiccount'), 0); + assert.strictEqual(await user.getUserField(newUid, 'topiccount'), 1); + + assert.strictEqual(await db.sortedSetScore('users:postcount', oldUid), 0); + assert.strictEqual(await db.sortedSetScore('users:postcount', newUid), 2); + + assert.strictEqual(await topics.isOwner(postResult.topicData.tid, oldUid), false); + assert.strictEqual(await topics.isOwner(postResult.topicData.tid, newUid), true); + }); + + it('should fail to change owner if new owner does not exist', async () => { + try { + await posts.changeOwner([1], '9999999'); + } catch (error) { + assert.strictEqual(error.message, '[[error:no-user]]'); + } + }); + + it('should fail to change owner if user is not authorized', async () => { + try { + await socketPosts.changeOwner({uid: voterUid}, {pids: [1, 2], toUid: voterUid}); + } catch (error) { + assert.strictEqual(error.message, '[[error:no-privileges]]'); + } + }); + + it('should return falsy if post does not exist', done => { + posts.getPostData(9999, (error, postData) => { + assert.ifError(error); + assert.equal(postData, null); + done(); + }); + }); + + describe('voting', () => { + it('should fail to upvote post if group does not have upvote permission', async () => { + await privileges.categories.rescind(['groups:posts:upvote', 'groups:posts:downvote'], cid, 'registered-users'); + let error; + try { + await apiPosts.upvote({uid: voterUid}, {pid: postData.pid, room_id: 'topic_1'}); + } catch (error_) { + error = error_; + } + + assert.equal(error.message, '[[error:no-privileges]]'); + try { + await apiPosts.downvote({uid: voterUid}, {pid: postData.pid, room_id: 'topic_1'}); + } catch (error_) { + error = error_; + } + + assert.equal(error.message, '[[error:no-privileges]]'); + await privileges.categories.give(['groups:posts:upvote', 'groups:posts:downvote'], cid, 'registered-users'); + }); + + it('should upvote a post', async () => { + const result = await apiPosts.upvote({uid: voterUid}, {pid: postData.pid, room_id: 'topic_1'}); + assert.equal(result.post.upvotes, 1); + assert.equal(result.post.downvotes, 0); + assert.equal(result.post.votes, 1); + assert.equal(result.user.reputation, 1); + const data = await posts.hasVoted(postData.pid, voterUid); + assert.equal(data.upvoted, true); + assert.equal(data.downvoted, false); + }); + + it('should add the pid to the :votes sorted set for that user', async () => { + const cid = await posts.getCidByPid(postData.pid); + const {uid, pid} = postData; + + const score = await db.sortedSetScore(`cid:${cid}:uid:${uid}:pids:votes`, pid); + assert.strictEqual(score, 1); + }); + + it('should get voters', done => { + socketPosts.getVoters({uid: globalModuleUid}, {pid: postData.pid, cid}, (error, data) => { + assert.ifError(error); + assert.equal(data.upvoteCount, 1); + assert.equal(data.downvoteCount, 0); + assert(Array.isArray(data.upvoters)); + assert.equal(data.upvoters[0].username, 'upvoter'); + done(); + }); + }); + + it('should get upvoters', done => { + socketPosts.getUpvoters({uid: globalModuleUid}, [postData.pid], (error, data) => { + assert.ifError(error); + assert.equal(data[0].otherCount, 0); + assert.equal(data[0].usernames, 'upvoter'); + done(); + }); + }); + + it('should unvote a post', async () => { + const result = await apiPosts.unvote({uid: voterUid}, {pid: postData.pid, room_id: 'topic_1'}); + assert.equal(result.post.upvotes, 0); + assert.equal(result.post.downvotes, 0); + assert.equal(result.post.votes, 0); + assert.equal(result.user.reputation, 0); + const data = await posts.hasVoted(postData.pid, voterUid); + assert.equal(data.upvoted, false); + assert.equal(data.downvoted, false); + }); + + it('should downvote a post', async () => { + const result = await apiPosts.downvote({uid: voterUid}, {pid: postData.pid, room_id: 'topic_1'}); + assert.equal(result.post.upvotes, 0); + assert.equal(result.post.downvotes, 1); + assert.equal(result.post.votes, -1); + assert.equal(result.user.reputation, -1); + const data = await posts.hasVoted(postData.pid, voterUid); + assert.equal(data.upvoted, false); + assert.equal(data.downvoted, true); + }); + + it('should add the pid to the :votes sorted set for that user', async () => { + const cid = await posts.getCidByPid(postData.pid); + const {uid, pid} = postData; + + const score = await db.sortedSetScore(`cid:${cid}:uid:${uid}:pids:votes`, pid); + assert.strictEqual(score, -1); + }); + + it('should prevent downvoting more than total daily limit', async () => { + const oldValue = meta.config.downvotesPerDay; + meta.config.downvotesPerDay = 1; + let error; + const p1 = await topics.reply({ + uid: voteeUid, + tid: topicData.tid, + content: 'raw content', + }); + try { + await apiPosts.downvote({uid: voterUid}, {pid: p1.pid, room_id: 'topic_1'}); + } catch (error_) { + error = error_; + } + + assert.equal(error.message, '[[error:too-many-downvotes-today, 1]]'); + meta.config.downvotesPerDay = oldValue; + }); + + it('should prevent downvoting target user more than total daily limit', async () => { + const oldValue = meta.config.downvotesPerUserPerDay; + meta.config.downvotesPerUserPerDay = 1; + let error; + const p1 = await topics.reply({ + uid: voteeUid, + tid: topicData.tid, + content: 'raw content', + }); + try { + await apiPosts.downvote({uid: voterUid}, {pid: p1.pid, room_id: 'topic_1'}); + } catch (error_) { + error = error_; + } + + assert.equal(error.message, '[[error:too-many-downvotes-today-user, 1]]'); + meta.config.downvotesPerUserPerDay = oldValue; + }); + }); + + describe('bookmarking', () => { + it('should bookmark a post', async () => { + const data = await apiPosts.bookmark({uid: voterUid}, {pid: postData.pid, room_id: `topic_${postData.tid}`}); + assert.equal(data.isBookmarked, true); + const hasBookmarked = await posts.hasBookmarked(postData.pid, voterUid); + assert.equal(hasBookmarked, true); + }); + + it('should unbookmark a post', async () => { + const data = await apiPosts.unbookmark({uid: voterUid}, {pid: postData.pid, room_id: `topic_${postData.tid}`}); + assert.equal(data.isBookmarked, false); + const hasBookmarked = await posts.hasBookmarked([postData.pid], voterUid); + assert.equal(hasBookmarked[0], false); + }); + }); + + describe('pinning', () => { + it('should pin a post', async () => { + const topicOwnerUid = topicData.uid; + // Yes, the room_id is needed for the test to run + const data = await apiPosts.pin({uid: topicOwnerUid}, {pid: postData.pid, room_id: `topic_${postData.tid}`}); + + assert.equal(data.pinned, true); + + const hasPinned = await posts.hasPinned([postData.pid]); + + assert.equal(hasPinned, true); + }); + + it('should unpin a post', async () => { + // Have the same user unpin the post + const topicOwnerUid = topicData.uid; + const data = await apiPosts.unpin({uid: topicOwnerUid}, {pid: postData.pid, room_id: `topic_${postData.tid}`}); + + assert.equal(data.pinned, false); + + const hasPinned = await posts.hasPinned([postData.pid]); + + assert.equal(hasPinned, false); + }); + }); + + describe('resolving', () => { + it('should resolve a post', async () => { + const data = await apiPosts.resolve({uid: voterUid}, {pid: postData.pid, room_id: `topic_${postData.tid}`}); + assert.equal(data.isResolved, true); + }); + }); + + describe('post tools', () => { + it('should error if data is invalid', done => { + socketPosts.loadPostTools({uid: globalModuleUid}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should load post tools', done => { + socketPosts.loadPostTools({uid: globalModuleUid}, {pid: postData.pid, cid}, (error, data) => { + assert.ifError(error); + assert(data.posts.display_edit_tools); + assert(data.posts.display_delete_tools); + assert(data.posts.display_moderator_tools); + assert(data.posts.display_move_tools); + done(); + }); + }); + + /* For pinned posts, we want: (1) The topic owner can see the pin button (2) Admins can see the pin button (3) Global moderators can see the pin button (4) The "random user" cannot see the pin button */ - // (1) - it('topic owner can see pin button', (done) => { - const topicOwnerUid = topicData.uid; - socketPosts.loadPostTools({ uid: topicOwnerUid }, { pid: postData.pid, cid: cid }, (err, data) => { - assert.ifError(err); - assert(data.posts.displayPin); - done(); - }); - }); - - // (2) - it('admin can see the pin button', (done) => { - socketPosts.loadPostTools({ uid: adminUid }, { pid: postData.pid, cid: cid }, (err, data) => { - assert.ifError(err); - assert(data.posts.displayPin); - done(); - }); - }); - - // (3) - it('global moderators can see the pin button', (done) => { - socketPosts.loadPostTools({ uid: globalModUid }, { pid: postData.pid, cid: cid }, (err, data) => { - assert.ifError(err); - assert(data.posts.displayPin); - done(); - }); - }); - - // (4) - it('random user cannot see the pin button', (done) => { - socketPosts.loadPostTools({ uid: randomUserUid }, { pid: postData.pid, cid: cid }, (err, data) => { - assert.ifError(err); - assert(!(data.posts.displayPin)); - done(); - }); - }); - }); - - describe('delete/restore/purge', () => { - async function createTopicWithReply() { - const topicPostData = await topics.post({ - uid: voterUid, - cid: cid, - title: 'topic to delete/restore/purge', - content: 'A post to delete/restore/purge', - }); - - const replyData = await topics.reply({ - uid: voterUid, - tid: topicPostData.topicData.tid, - timestamp: Date.now(), - content: 'A post to delete/restore and purge', - }); - return [topicPostData, replyData]; - } - - let tid; - let mainPid; - let replyPid; - - before(async () => { - const [topicPostData, replyData] = await createTopicWithReply(); - tid = topicPostData.topicData.tid; - mainPid = topicPostData.postData.pid; - replyPid = replyData.pid; - await privileges.categories.give(['groups:purge'], cid, 'registered-users'); - }); - - it('should error with invalid data', async () => { - try { - await apiPosts.delete({ uid: voterUid }, null); - } catch (err) { - return assert.equal(err.message, '[[error:invalid-data]]'); - } - assert(false); - }); - - it('should delete a post', async () => { - await apiPosts.delete({ uid: voterUid }, { pid: replyPid, tid: tid }); - const isDeleted = await posts.getPostField(replyPid, 'deleted'); - assert.strictEqual(isDeleted, 1); - }); - - it('should not see post content if global mod does not have posts:view_deleted privilege', (done) => { - async.waterfall([ - function (next) { - user.create({ username: 'global mod', password: '123456' }, next); - }, - function (uid, next) { - groups.join('Global Moderators', uid, next); - }, - function (next) { - privileges.categories.rescind(['groups:posts:view_deleted'], cid, 'Global Moderators', next); - }, - function (next) { - helpers.loginUser('global mod', '123456', (err, data) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/topic/${tid}`, { jar: data.jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(body.posts[1].content, '[[topic:post_is_deleted]]'); - privileges.categories.give(['groups:posts:view_deleted'], cid, 'Global Moderators', next); - }); - }); - }, - ], done); - }); - - it('should restore a post', async () => { - await apiPosts.restore({ uid: voterUid }, { pid: replyPid, tid: tid }); - const isDeleted = await posts.getPostField(replyPid, 'deleted'); - assert.strictEqual(isDeleted, 0); - }); - - it('should delete topic if last main post is deleted', async () => { - const data = await topics.post({ uid: voterUid, cid: cid, title: 'test topic', content: 'test topic' }); - await apiPosts.delete({ uid: globalModUid }, { pid: data.postData.pid }); - const deleted = await topics.getTopicField(data.topicData.tid, 'deleted'); - assert.strictEqual(deleted, 1); - }); - - it('should purge posts and purge topic', async () => { - const [topicPostData, replyData] = await createTopicWithReply(); - await apiPosts.purge({ uid: voterUid }, { pid: replyData.pid }); - await apiPosts.purge({ uid: voterUid }, { pid: topicPostData.postData.pid }); - const pidExists = await posts.exists(replyData.pid); - assert.strictEqual(pidExists, false); - const tidExists = await topics.exists(topicPostData.topicData.tid); - assert.strictEqual(tidExists, false); - }); - }); - - describe('edit', () => { - let pid; - let replyPid; - let tid; - before((done) => { - topics.post({ - uid: voterUid, - cid: cid, - title: 'topic to edit', - content: 'A post to edit', - tags: ['nodebb'], - }, (err, data) => { - assert.ifError(err); - pid = data.postData.pid; - tid = data.topicData.tid; - topics.reply({ - uid: voterUid, - tid: tid, - timestamp: Date.now(), - content: 'A reply to edit', - }, (err, data) => { - assert.ifError(err); - replyPid = data.pid; - privileges.categories.give(['groups:posts:edit'], cid, 'registered-users', done); - }); - }); - }); - - it('should error if user is not logged in', async () => { - try { - await apiPosts.edit({ uid: 0 }, { pid: pid, content: 'gg' }); - } catch (err) { - return assert.equal(err.message, '[[error:not-logged-in]]'); - } - assert(false); - }); - - it('should error if data is invalid or missing', async () => { - try { - await apiPosts.edit({ uid: voterUid }, {}); - } catch (err) { - return assert.equal(err.message, '[[error:invalid-data]]'); - } - assert(false); - }); - - it('should error if title is too short', async () => { - try { - await apiPosts.edit({ uid: voterUid }, { pid: pid, content: 'edited post content', title: 'a' }); - } catch (err) { - return assert.equal(err.message, `[[error:title-too-short, ${meta.config.minimumTitleLength}]]`); - } - assert(false); - }); - - it('should error if title is too long', async () => { - const longTitle = new Array(meta.config.maximumTitleLength + 2).join('a'); - try { - await apiPosts.edit({ uid: voterUid }, { pid: pid, content: 'edited post content', title: longTitle }); - } catch (err) { - return assert.equal(err.message, `[[error:title-too-long, ${meta.config.maximumTitleLength}]]`); - } - assert(false); - }); - - it('should error with too few tags', async () => { - const oldValue = meta.config.minimumTagsPerTopic; - meta.config.minimumTagsPerTopic = 1; - try { - await apiPosts.edit({ uid: voterUid }, { pid: pid, content: 'edited post content', tags: [] }); - } catch (err) { - assert.equal(err.message, `[[error:not-enough-tags, ${meta.config.minimumTagsPerTopic}]]`); - meta.config.minimumTagsPerTopic = oldValue; - return; - } - assert(false); - }); - - it('should error with too many tags', async () => { - const tags = []; - for (let i = 0; i < meta.config.maximumTagsPerTopic + 1; i += 1) { - tags.push(`tag${i}`); - } - try { - await apiPosts.edit({ uid: voterUid }, { pid: pid, content: 'edited post content', tags: tags }); - } catch (err) { - return assert.equal(err.message, `[[error:too-many-tags, ${meta.config.maximumTagsPerTopic}]]`); - } - assert(false); - }); - - it('should error if content is too short', async () => { - try { - await apiPosts.edit({ uid: voterUid }, { pid: pid, content: 'e' }); - } catch (err) { - return assert.equal(err.message, `[[error:content-too-short, ${meta.config.minimumPostLength}]]`); - } - assert(false); - }); - - it('should error if content is too long', async () => { - const longContent = new Array(meta.config.maximumPostLength + 2).join('a'); - try { - await apiPosts.edit({ uid: voterUid }, { pid: pid, content: longContent }); - } catch (err) { - return assert.equal(err.message, `[[error:content-too-long, ${meta.config.maximumPostLength}]]`); - } - assert(false); - }); - - it('should edit post', async () => { - const data = await apiPosts.edit({ uid: voterUid }, { - pid: pid, - content: 'edited post content', - title: 'edited title', - tags: ['edited'], - }); - - assert.strictEqual(data.content, 'edited post content'); - assert.strictEqual(data.editor, voterUid); - assert.strictEqual(data.topic.title, 'edited title'); - assert.strictEqual(data.topic.tags[0].value, 'edited'); - const res = await db.getObject(`post:${pid}`); - assert(!res.hasOwnProperty('bookmarks')); - }); - - it('should disallow post editing for new users if post was made past the threshold for editing', async () => { - meta.config.newbiePostEditDuration = 1; - await sleep(1000); - try { - await apiPosts.edit({ uid: voterUid }, { pid: pid, content: 'edited post content again', title: 'edited title again', tags: ['edited-twice'] }); - } catch (err) { - assert.equal(err.message, '[[error:post-edit-duration-expired, 1]]'); - meta.config.newbiePostEditDuration = 3600; - return; - } - assert(false); - }); - - it('should edit a deleted post', async () => { - await apiPosts.delete({ uid: voterUid }, { pid: pid, tid: tid }); - const data = await apiPosts.edit({ uid: voterUid }, { pid: pid, content: 'edited deleted content', title: 'edited deleted title', tags: ['deleted'] }); - assert.equal(data.content, 'edited deleted content'); - assert.equal(data.editor, voterUid); - assert.equal(data.topic.title, 'edited deleted title'); - assert.equal(data.topic.tags[0].value, 'deleted'); - }); - - it('should edit a reply post', async () => { - const data = await apiPosts.edit({ uid: voterUid }, { pid: replyPid, content: 'edited reply' }); - assert.equal(data.content, 'edited reply'); - assert.equal(data.editor, voterUid); - assert.equal(data.topic.isMainPost, false); - assert.equal(data.topic.renamed, false); - }); - - it('should return diffs', (done) => { - posts.diffs.get(replyPid, 0, (err, data) => { - assert.ifError(err); - assert(Array.isArray(data)); - assert(data[0].pid, replyPid); - assert(data[0].patch); - done(); - }); - }); - - it('should load diffs and reconstruct post', (done) => { - posts.diffs.load(replyPid, 0, voterUid, (err, data) => { - assert.ifError(err); - assert.equal(data.content, 'A reply to edit'); - done(); - }); - }); - - it('should not allow guests to view diffs', async () => { - let err = {}; - try { - await apiPosts.getDiffs({ uid: 0 }, { pid: 1 }); - } catch (_err) { - err = _err; - } - assert.strictEqual(err.message, '[[error:no-privileges]]'); - }); - - it('should allow registered-users group to view diffs', async () => { - const data = await apiPosts.getDiffs({ uid: 1 }, { pid: 1 }); - - assert.strictEqual('boolean', typeof data.editable); - assert.strictEqual(false, data.editable); - - assert.equal(true, Array.isArray(data.timestamps)); - assert.strictEqual(1, data.timestamps.length); - - assert.equal(true, Array.isArray(data.revisions)); - assert.strictEqual(data.timestamps.length, data.revisions.length); - ['timestamp', 'username'].every(prop => Object.keys(data.revisions[0]).includes(prop)); - }); - - it('should not delete first diff of a post', async () => { - const timestamps = await posts.diffs.list(replyPid); - await assert.rejects(async () => { - await posts.diffs.delete(replyPid, timestamps[0], voterUid); - }, { - message: '[[error:invalid-data]]', - }); - }); - - it('should delete a post diff', async () => { - await apiPosts.edit({ uid: voterUid }, { pid: replyPid, content: 'another edit has been made' }); - await apiPosts.edit({ uid: voterUid }, { pid: replyPid, content: 'most recent edit' }); - const timestamp = (await posts.diffs.list(replyPid)).pop(); - await posts.diffs.delete(replyPid, timestamp, voterUid); - const differentTimestamp = (await posts.diffs.list(replyPid)).pop(); - assert.notStrictEqual(timestamp, differentTimestamp); - }); - - it('should load (oldest) diff and reconstruct post correctly after a diff deletion', async () => { - const data = await posts.diffs.load(replyPid, 0, voterUid); - assert.strictEqual(data.content, 'A reply to edit'); - }); - }); - - describe('move', () => { - let replyPid; - let tid; - let moveTid; - - before(async () => { - const topic1 = await topics.post({ - uid: voterUid, - cid: cid, - title: 'topic 1', - content: 'some content', - }); - tid = topic1.topicData.tid; - const topic2 = await topics.post({ - uid: voterUid, - cid: cid, - title: 'topic 2', - content: 'some content', - }); - moveTid = topic2.topicData.tid; - - const reply = await topics.reply({ - uid: voterUid, - tid: tid, - timestamp: Date.now(), - content: 'A reply to move', - }); - replyPid = reply.pid; - }); - - it('should error if uid is not logged in', async () => { - try { - await apiPosts.move({ uid: 0 }, {}); - } catch (err) { - return assert.equal(err.message, '[[error:not-logged-in]]'); - } - assert(false); - }); - - it('should error if data is invalid', async () => { - try { - await apiPosts.move({ uid: globalModUid }, {}); - } catch (err) { - return assert.equal(err.message, '[[error:invalid-data]]'); - } - assert(false); - }); - - it('should error if user does not have move privilege', async () => { - try { - await apiPosts.move({ uid: voterUid }, { pid: replyPid, tid: moveTid }); - } catch (err) { - return assert.equal(err.message, '[[error:no-privileges]]'); - } - assert(false); - }); - - it('should move a post', async () => { - await apiPosts.move({ uid: globalModUid }, { pid: replyPid, tid: moveTid }); - const tid = await posts.getPostField(replyPid, 'tid'); - assert(tid, moveTid); - }); - - it('should fail to move post if not moderator of target category', async () => { - const cat1 = await categories.create({ name: 'Test Category', description: 'Test category created by testing script' }); - const cat2 = await categories.create({ name: 'Test Category', description: 'Test category created by testing script' }); - const result = await apiTopics.create({ uid: globalModUid }, { title: 'target topic', content: 'queued topic', cid: cat2.cid }); - const modUid = await user.create({ username: 'modofcat1' }); - const userPrivilegeList = await privileges.categories.getUserPrivilegeList(); - await privileges.categories.give(userPrivilegeList, cat1.cid, modUid); - let err; - try { - await apiPosts.move({ uid: modUid }, { pid: replyPid, tid: result.tid }); - } catch (_err) { - err = _err; - } - assert.strictEqual(err.message, '[[error:no-privileges]]'); - }); - }); - - describe('getPostSummaryByPids', () => { - it('should return empty array for empty pids', (done) => { - posts.getPostSummaryByPids([], 0, {}, (err, data) => { - assert.ifError(err); - assert.equal(data.length, 0); - done(); - }); - }); - - it('should get post summaries', (done) => { - posts.getPostSummaryByPids([postData.pid], 0, {}, (err, data) => { - assert.ifError(err); - assert(data[0].user); - assert(data[0].topic); - assert(data[0].category); - done(); - }); - }); - }); - - it('should get recent poster uids', (done) => { - topics.reply({ - uid: voterUid, - tid: topicData.tid, - timestamp: Date.now(), - content: 'some content', - }, (err) => { - assert.ifError(err); - posts.getRecentPosterUids(0, 1, (err, uids) => { - assert.ifError(err); - assert(Array.isArray(uids)); - assert.equal(uids.length, 2); - assert.equal(uids[0], voterUid); - done(); - }); - }); - }); - - describe('parse', () => { - it('should not crash and return falsy if post data is falsy', (done) => { - posts.parsePost(null, (err, postData) => { - assert.ifError(err); - assert.strictEqual(postData, null); - done(); - }); - }); - - it('should store post content in cache', (done) => { - const oldValue = global.env; - global.env = 'production'; - const postData = { - pid: 9999, - content: 'some post content', - }; - posts.parsePost(postData, (err) => { - assert.ifError(err); - posts.parsePost(postData, (err) => { - assert.ifError(err); - global.env = oldValue; - done(); - }); - }); - }); - - it('should parse signature and remove links and images', (done) => { - meta.config['signatures:disableLinks'] = 1; - meta.config['signatures:disableImages'] = 1; - const userData = { - signature: 'test derp', - }; - - posts.parseSignature(userData, 1, (err, data) => { - assert.ifError(err); - assert.equal(data.userData.signature, 'test derp'); - meta.config['signatures:disableLinks'] = 0; - meta.config['signatures:disableImages'] = 0; - done(); - }); - }); - - it('should turn relative links in post body to absolute urls', (done) => { - const nconf = require('nconf'); - const content = 'test youtube'; - const parsedContent = posts.relativeToAbsolute(content, posts.urlRegex); - assert.equal(parsedContent, `test youtube`); - done(); - }); - - it('should turn relative links in post body to absolute urls', (done) => { - const nconf = require('nconf'); - const content = 'test youtube some test '; - let parsedContent = posts.relativeToAbsolute(content, posts.urlRegex); - parsedContent = posts.relativeToAbsolute(parsedContent, posts.imgRegex); - assert.equal(parsedContent, `test youtube some test `); - done(); - }); - }); - - describe('socket methods', () => { - let pid; - before((done) => { - topics.reply({ - uid: voterUid, - tid: topicData.tid, - timestamp: Date.now(), - content: 'raw content', - }, (err, postData) => { - assert.ifError(err); - pid = postData.pid; - privileges.categories.rescind(['groups:topics:read'], cid, 'guests', done); - }); - }); - - it('should error with invalid data', async () => { - try { - await apiTopics.reply({ uid: 0 }, null); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:invalid-data]]'); - } - }); - - it('should error with invalid tid', async () => { - try { - await apiTopics.reply({ uid: 0 }, { tid: 0, content: 'derp' }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:invalid-data]]'); - } - }); - - it('should fail to get raw post because of privilege', (done) => { - socketPosts.getRawPost({ uid: 0 }, pid, (err) => { - assert.equal(err.message, '[[error:no-privileges]]'); - done(); - }); - }); - - it('should fail to get raw post because post is deleted', (done) => { - posts.setPostField(pid, 'deleted', 1, (err) => { - assert.ifError(err); - socketPosts.getRawPost({ uid: voterUid }, pid, (err) => { - assert.equal(err.message, '[[error:no-post]]'); - done(); - }); - }); - }); - - it('should get raw post content', (done) => { - posts.setPostField(pid, 'deleted', 0, (err) => { - assert.ifError(err); - socketPosts.getRawPost({ uid: voterUid }, pid, (err, postContent) => { - assert.ifError(err); - assert.equal(postContent, 'raw content'); - done(); - }); - }); - }); - - it('should get post', async () => { - const postData = await apiPosts.get({ uid: voterUid }, { pid }); - assert(postData); - }); - - it('should get post category', (done) => { - socketPosts.getCategory({ uid: voterUid }, pid, (err, postCid) => { - assert.ifError(err); - assert.equal(cid, postCid); - done(); - }); - }); - - it('should error with invalid data', (done) => { - socketPosts.getPidIndex({ uid: voterUid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should get pid index', (done) => { - socketPosts.getPidIndex({ uid: voterUid }, { pid: pid, tid: topicData.tid, topicPostSort: 'oldest_to_newest' }, (err, index) => { - assert.ifError(err); - assert.equal(index, 4); - done(); - }); - }); - - it('should get pid index in reverse', (done) => { - topics.reply({ - uid: voterUid, - tid: topicData.tid, - content: 'raw content', - }, (err, postData) => { - assert.ifError(err); - - socketPosts.getPidIndex({ uid: voterUid }, { pid: postData.pid, tid: topicData.tid, topicPostSort: 'newest_to_oldest' }, (err, index) => { - assert.ifError(err); - assert.equal(index, 1); - done(); - }); - }); - }); - }); - - describe('filterPidsByCid', () => { - it('should return pids as is if cid is falsy', (done) => { - posts.filterPidsByCid([1, 2, 3], null, (err, pids) => { - assert.ifError(err); - assert.deepEqual([1, 2, 3], pids); - done(); - }); - }); - - it('should filter pids by single cid', (done) => { - posts.filterPidsByCid([postData.pid, 100, 101], cid, (err, pids) => { - assert.ifError(err); - assert.deepEqual([postData.pid], pids); - done(); - }); - }); - - it('should filter pids by multiple cids', (done) => { - posts.filterPidsByCid([postData.pid, 100, 101], [cid, 2, 3], (err, pids) => { - assert.ifError(err); - assert.deepEqual([postData.pid], pids); - done(); - }); - }); - - it('should filter pids by multiple cids', (done) => { - posts.filterPidsByCid([postData.pid, 100, 101], [cid], (err, pids) => { - assert.ifError(err); - assert.deepEqual([postData.pid], pids); - done(); - }); - }); - }); - - it('should error if user does not exist', (done) => { - user.isReadyToPost(21123123, 1, (err) => { - assert.equal(err.message, '[[error:no-user]]'); - done(); - }); - }); - - describe('post queue', () => { - let uid; - let queueId; - let topicQueueId; - let jar; - before((done) => { - meta.config.postQueue = 1; - user.create({ username: 'newuser' }, (err, _uid) => { - assert.ifError(err); - uid = _uid; - done(); - }); - }); - - after((done) => { - meta.config.postQueue = 0; - meta.config.groupsExemptFromPostQueue = []; - done(); - }); - - it('should add topic to post queue', async () => { - const result = await apiTopics.create({ uid: uid }, { title: 'should be queued', content: 'queued topic content', cid: cid }); - assert.strictEqual(result.queued, true); - assert.equal(result.message, '[[success:post-queued]]'); - topicQueueId = result.id; - }); - - it('should add reply to post queue', async () => { - const result = await apiTopics.reply({ uid: uid }, { content: 'this is a queued reply', tid: topicData.tid }); - assert.strictEqual(result.queued, true); - assert.equal(result.message, '[[success:post-queued]]'); - queueId = result.id; - }); - - it('should load queued posts', (done) => { - helpers.loginUser('globalmod', 'globalmodpwd', (err, data) => { - jar = data.jar; - assert.ifError(err); - request(`${nconf.get('url')}/api/post-queue`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(body.posts[0].type, 'topic'); - assert.equal(body.posts[0].data.content, 'queued topic content'); - assert.equal(body.posts[1].type, 'reply'); - assert.equal(body.posts[1].data.content, 'this is a queued reply'); - done(); - }); - }); - }); - - it('should error if data is invalid', (done) => { - socketPosts.editQueuedContent({ uid: globalModUid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should edit post in queue', (done) => { - socketPosts.editQueuedContent({ uid: globalModUid }, { id: queueId, content: 'newContent' }, (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/post-queue`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(body.posts[1].type, 'reply'); - assert.equal(body.posts[1].data.content, 'newContent'); - done(); - }); - }); - }); - - it('should edit topic title in queue', (done) => { - socketPosts.editQueuedContent({ uid: globalModUid }, { id: topicQueueId, title: 'new topic title' }, (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/post-queue`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(body.posts[0].type, 'topic'); - assert.equal(body.posts[0].data.title, 'new topic title'); - done(); - }); - }); - }); - - it('should edit topic category in queue', (done) => { - socketPosts.editQueuedContent({ uid: globalModUid }, { id: topicQueueId, cid: 2 }, (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/post-queue`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(body.posts[0].type, 'topic'); - assert.equal(body.posts[0].data.cid, 2); - socketPosts.editQueuedContent({ uid: globalModUid }, { id: topicQueueId, cid: cid }, (err) => { - assert.ifError(err); - done(); - }); - }); - }); - }); - - it('should prevent regular users from approving posts', (done) => { - socketPosts.accept({ uid: uid }, { id: queueId }, (err) => { - assert.equal(err.message, '[[error:no-privileges]]'); - done(); - }); - }); - - it('should prevent regular users from approving non existing posts', (done) => { - socketPosts.accept({ uid: uid }, { id: 123123 }, (err) => { - assert.equal(err.message, '[[error:no-privileges]]'); - done(); - }); - }); - - it('should accept queued posts and submit', (done) => { - let ids; - async.waterfall([ - function (next) { - db.getSortedSetRange('post:queue', 0, -1, next); - }, - function (_ids, next) { - ids = _ids; - socketPosts.accept({ uid: globalModUid }, { id: ids[0] }, next); - }, - function (next) { - socketPosts.accept({ uid: globalModUid }, { id: ids[1] }, next); - }, - ], done); - }); - - it('should not crash if id does not exist', (done) => { - socketPosts.reject({ uid: globalModUid }, { id: '123123123' }, (err) => { - assert.equal(err.message, '[[error:no-privileges]]'); - done(); - }); - }); - - it('should bypass post queue if user is in exempt group', async () => { - const oldValue = meta.config.groupsExemptFromPostQueue; - meta.config.groupsExemptFromPostQueue = ['registered-users']; - const uid = await user.create({ username: 'mergeexemptuser' }); - const result = await apiTopics.create({ uid: uid, emit: () => {} }, { title: 'should not be queued', content: 'topic content', cid: cid }); - assert.strictEqual(result.title, 'should not be queued'); - meta.config.groupsExemptFromPostQueue = oldValue; - }); - - it('should update queued post\'s topic if target topic is merged', async () => { - const uid = await user.create({ username: 'mergetestsuser' }); - const result1 = await apiTopics.create({ uid: globalModUid }, { title: 'topic A', content: 'topic A content', cid: cid }); - const result2 = await apiTopics.create({ uid: globalModUid }, { title: 'topic B', content: 'topic B content', cid: cid }); - - const result = await apiTopics.reply({ uid: uid }, { content: 'the moved queued post', tid: result1.tid }); - - await topics.merge([ - result1.tid, result2.tid, - ], globalModUid, { mainTid: result2.tid }); - - let postData = await posts.getQueuedPosts(); - postData = postData.filter(p => parseInt(p.data.tid, 10) === parseInt(result2.tid, 10)); - assert.strictEqual(postData.length, 1); - assert.strictEqual(postData[0].data.content, 'the moved queued post'); - assert.strictEqual(postData[0].data.tid, result2.tid); - }); - }); - - describe('Topic Backlinks', () => { - let tid1; - before(async () => { - tid1 = await topics.post({ - uid: 1, - cid, - title: 'Topic backlink testing - topic 1', - content: 'Some text here for the OP', - }); - tid1 = tid1.topicData.tid; - }); - - describe('.syncBacklinks()', () => { - it('should error on invalid data', async () => { - try { - await topics.syncBacklinks(); - } catch (e) { - assert(e); - assert.strictEqual(e.message, '[[error:invalid-data]]'); - } - }); - - it('should do nothing if the post does not contain a link to a topic', async () => { - const backlinks = await topics.syncBacklinks({ - content: 'This is a post\'s content', - }); - - assert.strictEqual(backlinks, 0); - }); - - it('should create a backlink if it detects a topic link in a post', async () => { - const count = await topics.syncBacklinks({ - pid: 2, - content: `This is a link to [topic 1](${nconf.get('url')}/topic/1/abcdef)`, - }); - const events = await topics.events.get(1, 1); - const backlinks = await db.getSortedSetMembers('pid:2:backlinks'); - - assert.strictEqual(count, 1); - assert(events); - assert.strictEqual(events.length, 1); - assert(backlinks); - assert(backlinks.includes('1')); - }); - - it('should remove the backlink (but keep the event) if the post no longer contains a link to a topic', async () => { - const count = await topics.syncBacklinks({ - pid: 2, - content: 'This is a link to [nothing](http://example.org)', - }); - const events = await topics.events.get(1, 1); - const backlinks = await db.getSortedSetMembers('pid:2:backlinks'); - - assert.strictEqual(count, 0); - assert(events); - assert.strictEqual(events.length, 1); - assert(backlinks); - assert.strictEqual(backlinks.length, 0); - }); - }); - - describe('integration tests', () => { - it('should create a topic event in the referenced topic', async () => { - const topic = await topics.post({ - uid: 1, - cid, - title: 'Topic backlink testing - topic 2', - content: `Some text here for the OP – ${nconf.get('url')}/topic/${tid1}`, - }); - - const events = await topics.events.get(tid1, 1); - assert(events); - assert.strictEqual(events.length, 1); - assert.strictEqual(events[0].type, 'backlink'); - assert.strictEqual(parseInt(events[0].uid, 10), 1); - assert.strictEqual(events[0].href, `/post/${topic.postData.pid}`); - }); - - it('should not create a topic event if referenced topic is the same as current topic', async () => { - await topics.reply({ - uid: 1, - tid: tid1, - content: `Referencing itself – ${nconf.get('url')}/topic/${tid1}`, - }); - - const events = await topics.events.get(tid1, 1); - assert(events); - assert.strictEqual(events.length, 1); // should still equal 1 - }); - - it('should not show backlink events if the feature is disabled', async () => { - meta.config.topicBacklinks = 0; - - await topics.post({ - uid: 1, - cid, - title: 'Topic backlink testing - topic 3', - content: `Some text here for the OP – ${nconf.get('url')}/topic/${tid1}`, - }); - - const events = await topics.events.get(tid1, 1); - assert(events); - assert.strictEqual(events.length, 0); - }); - }); - }); + // (1) + it('topic owner can see pin button', done => { + const topicOwnerUid = topicData.uid; + socketPosts.loadPostTools({uid: topicOwnerUid}, {pid: postData.pid, cid}, (error, data) => { + assert.ifError(error); + assert(data.posts.displayPin); + done(); + }); + }); + + // (2) + it('admin can see the pin button', done => { + socketPosts.loadPostTools({uid: adminUid}, {pid: postData.pid, cid}, (error, data) => { + assert.ifError(error); + assert(data.posts.displayPin); + done(); + }); + }); + + // (3) + it('global moderators can see the pin button', done => { + socketPosts.loadPostTools({uid: globalModuleUid}, {pid: postData.pid, cid}, (error, data) => { + assert.ifError(error); + assert(data.posts.displayPin); + done(); + }); + }); + + // (4) + it('random user cannot see the pin button', done => { + socketPosts.loadPostTools({uid: randomUserUid}, {pid: postData.pid, cid}, (error, data) => { + assert.ifError(error); + assert(!(data.posts.displayPin)); + done(); + }); + }); + }); + + describe('delete/restore/purge', () => { + async function createTopicWithReply() { + const topicPostData = await topics.post({ + uid: voterUid, + cid, + title: 'topic to delete/restore/purge', + content: 'A post to delete/restore/purge', + }); + + const replyData = await topics.reply({ + uid: voterUid, + tid: topicPostData.topicData.tid, + timestamp: Date.now(), + content: 'A post to delete/restore and purge', + }); + return [topicPostData, replyData]; + } + + let tid; + let mainPid; + let replyPid; + + before(async () => { + const [topicPostData, replyData] = await createTopicWithReply(); + tid = topicPostData.topicData.tid; + mainPid = topicPostData.postData.pid; + replyPid = replyData.pid; + await privileges.categories.give(['groups:purge'], cid, 'registered-users'); + }); + + it('should error with invalid data', async () => { + try { + await apiPosts.delete({uid: voterUid}, null); + } catch (error) { + return assert.equal(error.message, '[[error:invalid-data]]'); + } + + assert(false); + }); + + it('should delete a post', async () => { + await apiPosts.delete({uid: voterUid}, {pid: replyPid, tid}); + const isDeleted = await posts.getPostField(replyPid, 'deleted'); + assert.strictEqual(isDeleted, 1); + }); + + it('should not see post content if global mod does not have posts:view_deleted privilege', done => { + async.waterfall([ + function (next) { + user.create({username: 'global mod', password: '123456'}, next); + }, + function (uid, next) { + groups.join('Global Moderators', uid, next); + }, + function (next) { + privileges.categories.rescind(['groups:posts:view_deleted'], cid, 'Global Moderators', next); + }, + function (next) { + helpers.loginUser('global mod', '123456', (error, data) => { + assert.ifError(error); + request(`${nconf.get('url')}/api/topic/${tid}`, {jar: data.jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(body.posts[1].content, '[[topic:post_is_deleted]]'); + privileges.categories.give(['groups:posts:view_deleted'], cid, 'Global Moderators', next); + }); + }); + }, + ], done); + }); + + it('should restore a post', async () => { + await apiPosts.restore({uid: voterUid}, {pid: replyPid, tid}); + const isDeleted = await posts.getPostField(replyPid, 'deleted'); + assert.strictEqual(isDeleted, 0); + }); + + it('should delete topic if last main post is deleted', async () => { + const data = await topics.post({ + uid: voterUid, cid, title: 'test topic', content: 'test topic', + }); + await apiPosts.delete({uid: globalModuleUid}, {pid: data.postData.pid}); + const deleted = await topics.getTopicField(data.topicData.tid, 'deleted'); + assert.strictEqual(deleted, 1); + }); + + it('should purge posts and purge topic', async () => { + const [topicPostData, replyData] = await createTopicWithReply(); + await apiPosts.purge({uid: voterUid}, {pid: replyData.pid}); + await apiPosts.purge({uid: voterUid}, {pid: topicPostData.postData.pid}); + const pidExists = await posts.exists(replyData.pid); + assert.strictEqual(pidExists, false); + const tidExists = await topics.exists(topicPostData.topicData.tid); + assert.strictEqual(tidExists, false); + }); + }); + + describe('edit', () => { + let pid; + let replyPid; + let tid; + before(done => { + topics.post({ + uid: voterUid, + cid, + title: 'topic to edit', + content: 'A post to edit', + tags: ['nodebb'], + }, (error, data) => { + assert.ifError(error); + pid = data.postData.pid; + tid = data.topicData.tid; + topics.reply({ + uid: voterUid, + tid, + timestamp: Date.now(), + content: 'A reply to edit', + }, (error, data) => { + assert.ifError(error); + replyPid = data.pid; + privileges.categories.give(['groups:posts:edit'], cid, 'registered-users', done); + }); + }); + }); + + it('should error if user is not logged in', async () => { + try { + await apiPosts.edit({uid: 0}, {pid, content: 'gg'}); + } catch (error) { + return assert.equal(error.message, '[[error:not-logged-in]]'); + } + + assert(false); + }); + + it('should error if data is invalid or missing', async () => { + try { + await apiPosts.edit({uid: voterUid}, {}); + } catch (error) { + return assert.equal(error.message, '[[error:invalid-data]]'); + } + + assert(false); + }); + + it('should error if title is too short', async () => { + try { + await apiPosts.edit({uid: voterUid}, {pid, content: 'edited post content', title: 'a'}); + } catch (error) { + return assert.equal(error.message, `[[error:title-too-short, ${meta.config.minimumTitleLength}]]`); + } + + assert(false); + }); + + it('should error if title is too long', async () => { + const longTitle = new Array(meta.config.maximumTitleLength + 2).join('a'); + try { + await apiPosts.edit({uid: voterUid}, {pid, content: 'edited post content', title: longTitle}); + } catch (error) { + return assert.equal(error.message, `[[error:title-too-long, ${meta.config.maximumTitleLength}]]`); + } + + assert(false); + }); + + it('should error with too few tags', async () => { + const oldValue = meta.config.minimumTagsPerTopic; + meta.config.minimumTagsPerTopic = 1; + try { + await apiPosts.edit({uid: voterUid}, {pid, content: 'edited post content', tags: []}); + } catch (error) { + assert.equal(error.message, `[[error:not-enough-tags, ${meta.config.minimumTagsPerTopic}]]`); + meta.config.minimumTagsPerTopic = oldValue; + return; + } + + assert(false); + }); + + it('should error with too many tags', async () => { + const tags = []; + for (let i = 0; i < meta.config.maximumTagsPerTopic + 1; i += 1) { + tags.push(`tag${i}`); + } + + try { + await apiPosts.edit({uid: voterUid}, {pid, content: 'edited post content', tags}); + } catch (error) { + return assert.equal(error.message, `[[error:too-many-tags, ${meta.config.maximumTagsPerTopic}]]`); + } + + assert(false); + }); + + it('should error if content is too short', async () => { + try { + await apiPosts.edit({uid: voterUid}, {pid, content: 'e'}); + } catch (error) { + return assert.equal(error.message, `[[error:content-too-short, ${meta.config.minimumPostLength}]]`); + } + + assert(false); + }); + + it('should error if content is too long', async () => { + const longContent = new Array(meta.config.maximumPostLength + 2).join('a'); + try { + await apiPosts.edit({uid: voterUid}, {pid, content: longContent}); + } catch (error) { + return assert.equal(error.message, `[[error:content-too-long, ${meta.config.maximumPostLength}]]`); + } + + assert(false); + }); + + it('should edit post', async () => { + const data = await apiPosts.edit({uid: voterUid}, { + pid, + content: 'edited post content', + title: 'edited title', + tags: ['edited'], + }); + + assert.strictEqual(data.content, 'edited post content'); + assert.strictEqual(data.editor, voterUid); + assert.strictEqual(data.topic.title, 'edited title'); + assert.strictEqual(data.topic.tags[0].value, 'edited'); + const res = await db.getObject(`post:${pid}`); + assert(!res.hasOwnProperty('bookmarks')); + }); + + it('should disallow post editing for new users if post was made past the threshold for editing', async () => { + meta.config.newbiePostEditDuration = 1; + await sleep(1000); + try { + await apiPosts.edit({uid: voterUid}, { + pid, content: 'edited post content again', title: 'edited title again', tags: ['edited-twice'], + }); + } catch (error) { + assert.equal(error.message, '[[error:post-edit-duration-expired, 1]]'); + meta.config.newbiePostEditDuration = 3600; + return; + } + + assert(false); + }); + + it('should edit a deleted post', async () => { + await apiPosts.delete({uid: voterUid}, {pid, tid}); + const data = await apiPosts.edit({uid: voterUid}, { + pid, content: 'edited deleted content', title: 'edited deleted title', tags: ['deleted'], + }); + assert.equal(data.content, 'edited deleted content'); + assert.equal(data.editor, voterUid); + assert.equal(data.topic.title, 'edited deleted title'); + assert.equal(data.topic.tags[0].value, 'deleted'); + }); + + it('should edit a reply post', async () => { + const data = await apiPosts.edit({uid: voterUid}, {pid: replyPid, content: 'edited reply'}); + assert.equal(data.content, 'edited reply'); + assert.equal(data.editor, voterUid); + assert.equal(data.topic.isMainPost, false); + assert.equal(data.topic.renamed, false); + }); + + it('should return diffs', done => { + posts.diffs.get(replyPid, 0, (error, data) => { + assert.ifError(error); + assert(Array.isArray(data)); + assert(data[0].pid, replyPid); + assert(data[0].patch); + done(); + }); + }); + + it('should load diffs and reconstruct post', done => { + posts.diffs.load(replyPid, 0, voterUid, (error, data) => { + assert.ifError(error); + assert.equal(data.content, 'A reply to edit'); + done(); + }); + }); + + it('should not allow guests to view diffs', async () => { + let error = {}; + try { + await apiPosts.getDiffs({uid: 0}, {pid: 1}); + } catch (error_) { + error = error_; + } + + assert.strictEqual(error.message, '[[error:no-privileges]]'); + }); + + it('should allow registered-users group to view diffs', async () => { + const data = await apiPosts.getDiffs({uid: 1}, {pid: 1}); + + assert.strictEqual('boolean', typeof data.editable); + assert.strictEqual(false, data.editable); + + assert.equal(true, Array.isArray(data.timestamps)); + assert.strictEqual(1, data.timestamps.length); + + assert.equal(true, Array.isArray(data.revisions)); + assert.strictEqual(data.timestamps.length, data.revisions.length); + ['timestamp', 'username'].every(property => Object.keys(data.revisions[0]).includes(property)); + }); + + it('should not delete first diff of a post', async () => { + const timestamps = await posts.diffs.list(replyPid); + await assert.rejects(async () => { + await posts.diffs.delete(replyPid, timestamps[0], voterUid); + }, { + message: '[[error:invalid-data]]', + }); + }); + + it('should delete a post diff', async () => { + await apiPosts.edit({uid: voterUid}, {pid: replyPid, content: 'another edit has been made'}); + await apiPosts.edit({uid: voterUid}, {pid: replyPid, content: 'most recent edit'}); + const timestamp = (await posts.diffs.list(replyPid)).pop(); + await posts.diffs.delete(replyPid, timestamp, voterUid); + const differentTimestamp = (await posts.diffs.list(replyPid)).pop(); + assert.notStrictEqual(timestamp, differentTimestamp); + }); + + it('should load (oldest) diff and reconstruct post correctly after a diff deletion', async () => { + const data = await posts.diffs.load(replyPid, 0, voterUid); + assert.strictEqual(data.content, 'A reply to edit'); + }); + }); + + describe('move', () => { + let replyPid; + let tid; + let moveTid; + + before(async () => { + const topic1 = await topics.post({ + uid: voterUid, + cid, + title: 'topic 1', + content: 'some content', + }); + tid = topic1.topicData.tid; + const topic2 = await topics.post({ + uid: voterUid, + cid, + title: 'topic 2', + content: 'some content', + }); + moveTid = topic2.topicData.tid; + + const reply = await topics.reply({ + uid: voterUid, + tid, + timestamp: Date.now(), + content: 'A reply to move', + }); + replyPid = reply.pid; + }); + + it('should error if uid is not logged in', async () => { + try { + await apiPosts.move({uid: 0}, {}); + } catch (error) { + return assert.equal(error.message, '[[error:not-logged-in]]'); + } + + assert(false); + }); + + it('should error if data is invalid', async () => { + try { + await apiPosts.move({uid: globalModuleUid}, {}); + } catch (error) { + return assert.equal(error.message, '[[error:invalid-data]]'); + } + + assert(false); + }); + + it('should error if user does not have move privilege', async () => { + try { + await apiPosts.move({uid: voterUid}, {pid: replyPid, tid: moveTid}); + } catch (error) { + return assert.equal(error.message, '[[error:no-privileges]]'); + } + + assert(false); + }); + + it('should move a post', async () => { + await apiPosts.move({uid: globalModuleUid}, {pid: replyPid, tid: moveTid}); + const tid = await posts.getPostField(replyPid, 'tid'); + assert(tid, moveTid); + }); + + it('should fail to move post if not moderator of target category', async () => { + const cat1 = await categories.create({name: 'Test Category', description: 'Test category created by testing script'}); + const cat2 = await categories.create({name: 'Test Category', description: 'Test category created by testing script'}); + const result = await apiTopics.create({uid: globalModuleUid}, {title: 'target topic', content: 'queued topic', cid: cat2.cid}); + const moduleUid = await user.create({username: 'modofcat1'}); + const userPrivilegeList = await privileges.categories.getUserPrivilegeList(); + await privileges.categories.give(userPrivilegeList, cat1.cid, moduleUid); + let error_; + try { + await apiPosts.move({uid: moduleUid}, {pid: replyPid, tid: result.tid}); + } catch (error) { + error_ = error; + } + + assert.strictEqual(error_.message, '[[error:no-privileges]]'); + }); + }); + + describe('getPostSummaryByPids', () => { + it('should return empty array for empty pids', done => { + posts.getPostSummaryByPids([], 0, {}, (error, data) => { + assert.ifError(error); + assert.equal(data.length, 0); + done(); + }); + }); + + it('should get post summaries', done => { + posts.getPostSummaryByPids([postData.pid], 0, {}, (error, data) => { + assert.ifError(error); + assert(data[0].user); + assert(data[0].topic); + assert(data[0].category); + done(); + }); + }); + }); + + it('should get recent poster uids', done => { + topics.reply({ + uid: voterUid, + tid: topicData.tid, + timestamp: Date.now(), + content: 'some content', + }, error => { + assert.ifError(error); + posts.getRecentPosterUids(0, 1, (error, uids) => { + assert.ifError(error); + assert(Array.isArray(uids)); + assert.equal(uids.length, 2); + assert.equal(uids[0], voterUid); + done(); + }); + }); + }); + + describe('parse', () => { + it('should not crash and return falsy if post data is falsy', done => { + posts.parsePost(null, (error, postData) => { + assert.ifError(error); + assert.strictEqual(postData, null); + done(); + }); + }); + + it('should store post content in cache', done => { + const oldValue = global.env; + global.env = 'production'; + const postData = { + pid: 9999, + content: 'some post content', + }; + posts.parsePost(postData, error => { + assert.ifError(error); + posts.parsePost(postData, error => { + assert.ifError(error); + global.env = oldValue; + done(); + }); + }); + }); + + it('should parse signature and remove links and images', done => { + meta.config['signatures:disableLinks'] = 1; + meta.config['signatures:disableImages'] = 1; + const userData = { + signature: 'test derp', + }; + + posts.parseSignature(userData, 1, (error, data) => { + assert.ifError(error); + assert.equal(data.userData.signature, 'test derp'); + meta.config['signatures:disableLinks'] = 0; + meta.config['signatures:disableImages'] = 0; + done(); + }); + }); + + it('should turn relative links in post body to absolute urls', done => { + const nconf = require('nconf'); + const content = 'test youtube'; + const parsedContent = posts.relativeToAbsolute(content, posts.urlRegex); + assert.equal(parsedContent, `test youtube`); + done(); + }); + + it('should turn relative links in post body to absolute urls', done => { + const nconf = require('nconf'); + const content = 'test youtube some test '; + let parsedContent = posts.relativeToAbsolute(content, posts.urlRegex); + parsedContent = posts.relativeToAbsolute(parsedContent, posts.imgRegex); + assert.equal(parsedContent, `test youtube some test `); + done(); + }); + }); + + describe('socket methods', () => { + let pid; + before(done => { + topics.reply({ + uid: voterUid, + tid: topicData.tid, + timestamp: Date.now(), + content: 'raw content', + }, (error, postData) => { + assert.ifError(error); + pid = postData.pid; + privileges.categories.rescind(['groups:topics:read'], cid, 'guests', done); + }); + }); + + it('should error with invalid data', async () => { + try { + await apiTopics.reply({uid: 0}, null); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:invalid-data]]'); + } + }); + + it('should error with invalid tid', async () => { + try { + await apiTopics.reply({uid: 0}, {tid: 0, content: 'derp'}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:invalid-data]]'); + } + }); + + it('should fail to get raw post because of privilege', done => { + socketPosts.getRawPost({uid: 0}, pid, error => { + assert.equal(error.message, '[[error:no-privileges]]'); + done(); + }); + }); + + it('should fail to get raw post because post is deleted', done => { + posts.setPostField(pid, 'deleted', 1, error => { + assert.ifError(error); + socketPosts.getRawPost({uid: voterUid}, pid, error => { + assert.equal(error.message, '[[error:no-post]]'); + done(); + }); + }); + }); + + it('should get raw post content', done => { + posts.setPostField(pid, 'deleted', 0, error => { + assert.ifError(error); + socketPosts.getRawPost({uid: voterUid}, pid, (error, postContent) => { + assert.ifError(error); + assert.equal(postContent, 'raw content'); + done(); + }); + }); + }); + + it('should get post', async () => { + const postData = await apiPosts.get({uid: voterUid}, {pid}); + assert(postData); + }); + + it('should get post category', done => { + socketPosts.getCategory({uid: voterUid}, pid, (error, postCid) => { + assert.ifError(error); + assert.equal(cid, postCid); + done(); + }); + }); + + it('should error with invalid data', done => { + socketPosts.getPidIndex({uid: voterUid}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should get pid index', done => { + socketPosts.getPidIndex({uid: voterUid}, {pid, tid: topicData.tid, topicPostSort: 'oldest_to_newest'}, (error, index) => { + assert.ifError(error); + assert.equal(index, 4); + done(); + }); + }); + + it('should get pid index in reverse', done => { + topics.reply({ + uid: voterUid, + tid: topicData.tid, + content: 'raw content', + }, (error, postData) => { + assert.ifError(error); + + socketPosts.getPidIndex({uid: voterUid}, {pid: postData.pid, tid: topicData.tid, topicPostSort: 'newest_to_oldest'}, (error, index) => { + assert.ifError(error); + assert.equal(index, 1); + done(); + }); + }); + }); + }); + + describe('filterPidsByCid', () => { + it('should return pids as is if cid is falsy', done => { + posts.filterPidsByCid([1, 2, 3], null, (error, pids) => { + assert.ifError(error); + assert.deepEqual([1, 2, 3], pids); + done(); + }); + }); + + it('should filter pids by single cid', done => { + posts.filterPidsByCid([postData.pid, 100, 101], cid, (error, pids) => { + assert.ifError(error); + assert.deepEqual([postData.pid], pids); + done(); + }); + }); + + it('should filter pids by multiple cids', done => { + posts.filterPidsByCid([postData.pid, 100, 101], [cid, 2, 3], (error, pids) => { + assert.ifError(error); + assert.deepEqual([postData.pid], pids); + done(); + }); + }); + + it('should filter pids by multiple cids', done => { + posts.filterPidsByCid([postData.pid, 100, 101], [cid], (error, pids) => { + assert.ifError(error); + assert.deepEqual([postData.pid], pids); + done(); + }); + }); + }); + + it('should error if user does not exist', done => { + user.isReadyToPost(21_123_123, 1, error => { + assert.equal(error.message, '[[error:no-user]]'); + done(); + }); + }); + + describe('post queue', () => { + let uid; + let queueId; + let topicQueueId; + let jar; + before(done => { + meta.config.postQueue = 1; + user.create({username: 'newuser'}, (error, _uid) => { + assert.ifError(error); + uid = _uid; + done(); + }); + }); + + after(done => { + meta.config.postQueue = 0; + meta.config.groupsExemptFromPostQueue = []; + done(); + }); + + it('should add topic to post queue', async () => { + const result = await apiTopics.create({uid}, {title: 'should be queued', content: 'queued topic content', cid}); + assert.strictEqual(result.queued, true); + assert.equal(result.message, '[[success:post-queued]]'); + topicQueueId = result.id; + }); + + it('should add reply to post queue', async () => { + const result = await apiTopics.reply({uid}, {content: 'this is a queued reply', tid: topicData.tid}); + assert.strictEqual(result.queued, true); + assert.equal(result.message, '[[success:post-queued]]'); + queueId = result.id; + }); + + it('should load queued posts', done => { + helpers.loginUser('globalmod', 'globalmodpwd', (error, data) => { + jar = data.jar; + assert.ifError(error); + request(`${nconf.get('url')}/api/post-queue`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(body.posts[0].type, 'topic'); + assert.equal(body.posts[0].data.content, 'queued topic content'); + assert.equal(body.posts[1].type, 'reply'); + assert.equal(body.posts[1].data.content, 'this is a queued reply'); + done(); + }); + }); + }); + + it('should error if data is invalid', done => { + socketPosts.editQueuedContent({uid: globalModuleUid}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should edit post in queue', done => { + socketPosts.editQueuedContent({uid: globalModuleUid}, {id: queueId, content: 'newContent'}, error => { + assert.ifError(error); + request(`${nconf.get('url')}/api/post-queue`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(body.posts[1].type, 'reply'); + assert.equal(body.posts[1].data.content, 'newContent'); + done(); + }); + }); + }); + + it('should edit topic title in queue', done => { + socketPosts.editQueuedContent({uid: globalModuleUid}, {id: topicQueueId, title: 'new topic title'}, error => { + assert.ifError(error); + request(`${nconf.get('url')}/api/post-queue`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(body.posts[0].type, 'topic'); + assert.equal(body.posts[0].data.title, 'new topic title'); + done(); + }); + }); + }); + + it('should edit topic category in queue', done => { + socketPosts.editQueuedContent({uid: globalModuleUid}, {id: topicQueueId, cid: 2}, error => { + assert.ifError(error); + request(`${nconf.get('url')}/api/post-queue`, {jar, json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(body.posts[0].type, 'topic'); + assert.equal(body.posts[0].data.cid, 2); + socketPosts.editQueuedContent({uid: globalModuleUid}, {id: topicQueueId, cid}, error_ => { + assert.ifError(error_); + done(); + }); + }); + }); + }); + + it('should prevent regular users from approving posts', done => { + socketPosts.accept({uid}, {id: queueId}, error => { + assert.equal(error.message, '[[error:no-privileges]]'); + done(); + }); + }); + + it('should prevent regular users from approving non existing posts', done => { + socketPosts.accept({uid}, {id: 123_123}, error => { + assert.equal(error.message, '[[error:no-privileges]]'); + done(); + }); + }); + + it('should accept queued posts and submit', done => { + let ids; + async.waterfall([ + function (next) { + db.getSortedSetRange('post:queue', 0, -1, next); + }, + function (_ids, next) { + ids = _ids; + socketPosts.accept({uid: globalModuleUid}, {id: ids[0]}, next); + }, + function (next) { + socketPosts.accept({uid: globalModuleUid}, {id: ids[1]}, next); + }, + ], done); + }); + + it('should not crash if id does not exist', done => { + socketPosts.reject({uid: globalModuleUid}, {id: '123123123'}, error => { + assert.equal(error.message, '[[error:no-privileges]]'); + done(); + }); + }); + + it('should bypass post queue if user is in exempt group', async () => { + const oldValue = meta.config.groupsExemptFromPostQueue; + meta.config.groupsExemptFromPostQueue = ['registered-users']; + const uid = await user.create({username: 'mergeexemptuser'}); + const result = await apiTopics.create({uid, emit() {}}, {title: 'should not be queued', content: 'topic content', cid}); + assert.strictEqual(result.title, 'should not be queued'); + meta.config.groupsExemptFromPostQueue = oldValue; + }); + + it('should update queued post\'s topic if target topic is merged', async () => { + const uid = await user.create({username: 'mergetestsuser'}); + const result1 = await apiTopics.create({uid: globalModuleUid}, {title: 'topic A', content: 'topic A content', cid}); + const result2 = await apiTopics.create({uid: globalModuleUid}, {title: 'topic B', content: 'topic B content', cid}); + + const result = await apiTopics.reply({uid}, {content: 'the moved queued post', tid: result1.tid}); + + await topics.merge([ + result1.tid, result2.tid, + ], globalModuleUid, {mainTid: result2.tid}); + + let postData = await posts.getQueuedPosts(); + postData = postData.filter(p => Number.parseInt(p.data.tid, 10) === Number.parseInt(result2.tid, 10)); + assert.strictEqual(postData.length, 1); + assert.strictEqual(postData[0].data.content, 'the moved queued post'); + assert.strictEqual(postData[0].data.tid, result2.tid); + }); + }); + + describe('Topic Backlinks', () => { + let tid1; + before(async () => { + tid1 = await topics.post({ + uid: 1, + cid, + title: 'Topic backlink testing - topic 1', + content: 'Some text here for the OP', + }); + tid1 = tid1.topicData.tid; + }); + + describe('.syncBacklinks()', () => { + it('should error on invalid data', async () => { + try { + await topics.syncBacklinks(); + } catch (error) { + assert(error); + assert.strictEqual(error.message, '[[error:invalid-data]]'); + } + }); + + it('should do nothing if the post does not contain a link to a topic', async () => { + const backlinks = await topics.syncBacklinks({ + content: 'This is a post\'s content', + }); + + assert.strictEqual(backlinks, 0); + }); + + it('should create a backlink if it detects a topic link in a post', async () => { + const count = await topics.syncBacklinks({ + pid: 2, + content: `This is a link to [topic 1](${nconf.get('url')}/topic/1/abcdef)`, + }); + const events = await topics.events.get(1, 1); + const backlinks = await db.getSortedSetMembers('pid:2:backlinks'); + + assert.strictEqual(count, 1); + assert(events); + assert.strictEqual(events.length, 1); + assert(backlinks); + assert(backlinks.includes('1')); + }); + + it('should remove the backlink (but keep the event) if the post no longer contains a link to a topic', async () => { + const count = await topics.syncBacklinks({ + pid: 2, + content: 'This is a link to [nothing](http://example.org)', + }); + const events = await topics.events.get(1, 1); + const backlinks = await db.getSortedSetMembers('pid:2:backlinks'); + + assert.strictEqual(count, 0); + assert(events); + assert.strictEqual(events.length, 1); + assert(backlinks); + assert.strictEqual(backlinks.length, 0); + }); + }); + + describe('integration tests', () => { + it('should create a topic event in the referenced topic', async () => { + const topic = await topics.post({ + uid: 1, + cid, + title: 'Topic backlink testing - topic 2', + content: `Some text here for the OP – ${nconf.get('url')}/topic/${tid1}`, + }); + + const events = await topics.events.get(tid1, 1); + assert(events); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].type, 'backlink'); + assert.strictEqual(Number.parseInt(events[0].uid, 10), 1); + assert.strictEqual(events[0].href, `/post/${topic.postData.pid}`); + }); + + it('should not create a topic event if referenced topic is the same as current topic', async () => { + await topics.reply({ + uid: 1, + tid: tid1, + content: `Referencing itself – ${nconf.get('url')}/topic/${tid1}`, + }); + + const events = await topics.events.get(tid1, 1); + assert(events); + assert.strictEqual(events.length, 1); // Should still equal 1 + }); + + it('should not show backlink events if the feature is disabled', async () => { + meta.config.topicBacklinks = 0; + + await topics.post({ + uid: 1, + cid, + title: 'Topic backlink testing - topic 3', + content: `Some text here for the OP – ${nconf.get('url')}/topic/${tid1}`, + }); + + const events = await topics.events.get(tid1, 1); + assert(events); + assert.strictEqual(events.length, 0); + }); + }); + }); }); describe('Posts\'', async () => { - let files; + let files; - before(async () => { - files = await file.walk(path.resolve(__dirname, './posts')); - }); + before(async () => { + files = await file.walk(path.resolve(__dirname, './posts')); + }); - it('subfolder tests', () => { - files.forEach((filePath) => { - require(filePath); - }); - }); + it('subfolder tests', () => { + for (const filePath of files) { + require(filePath); + } + }); }); diff --git a/test/posts/uploads.js b/test/posts/uploads.js index fb7e5dd..43fce46 100644 --- a/test/posts/uploads.js +++ b/test/posts/uploads.js @@ -1,16 +1,13 @@ 'use strict'; -const assert = require('assert'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const crypto = require('node:crypto'); const nconf = require('nconf'); const async = require('async'); -const crypto = require('crypto'); - const db = require('../mocks/databasemock'); - const categories = require('../../src/categories'); const topics = require('../../src/topics'); const posts = require('../../src/posts'); @@ -21,397 +18,399 @@ const utils = require('../../src/utils'); const _filenames = ['abracadabra.png', 'shazam.jpg', 'whoa.gif', 'amazeballs.jpg', 'wut.txt', 'test.bmp']; const _recreateFiles = () => { - // Create stub files for testing - _filenames.forEach(filename => fs.closeSync(fs.openSync(path.join(nconf.get('upload_path'), 'files', filename), 'w'))); + // Create stub files for testing + for (const filename of _filenames) { + fs.closeSync(fs.openSync(path.join(nconf.get('upload_path'), 'files', filename), 'w')); + } }; describe('upload methods', () => { - let pid; - let purgePid; - let cid; - let uid; - - before(async () => { - _recreateFiles(); - - uid = await user.create({ - username: 'uploads user', - password: 'abracadabra', - gdpr_consent: 1, - }); - - ({ cid } = await categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - })); - - const topicPostData = await topics.post({ - uid, - cid, - title: 'topic with some images', - content: 'here is an image [alt text](/assets/uploads/files/abracadabra.png) and another [alt text](/assets/uploads/files/shazam.jpg)', - }); - pid = topicPostData.postData.pid; - - const purgePostData = await topics.post({ - uid, - cid, - title: 'topic with some images, to be purged', - content: 'here is an image [alt text](/assets/uploads/files/whoa.gif) and another [alt text](/assets/uploads/files/amazeballs.jpg)', - }); - purgePid = purgePostData.postData.pid; - }); - - describe('.sync()', () => { - it('should properly add new images to the post\'s zset', (done) => { - posts.uploads.sync(pid, (err) => { - assert.ifError(err); - - db.sortedSetCard(`post:${pid}:uploads`, (err, length) => { - assert.ifError(err); - assert.strictEqual(length, 2); - done(); - }); - }); - }); - - it('should remove an image if it is edited out of the post', (done) => { - async.series([ - function (next) { - posts.edit({ - pid: pid, - uid, - content: 'here is an image [alt text](/assets/uploads/files/abracadabra.png)... AND NO MORE!', - }, next); - }, - async.apply(posts.uploads.sync, pid), - ], (err) => { - assert.ifError(err); - db.sortedSetCard(`post:${pid}:uploads`, (err, length) => { - assert.ifError(err); - assert.strictEqual(1, length); - done(); - }); - }); - }); - }); - - describe('.list()', () => { - it('should display the uploaded files for a specific post', (done) => { - posts.uploads.list(pid, (err, uploads) => { - assert.ifError(err); - assert.equal(true, Array.isArray(uploads)); - assert.strictEqual(1, uploads.length); - assert.equal('string', typeof uploads[0]); - done(); - }); - }); - }); - - describe('.isOrphan()', () => { - it('should return false if upload is not an orphan', (done) => { - posts.uploads.isOrphan('files/abracadabra.png', (err, isOrphan) => { - assert.ifError(err); - assert.equal(isOrphan, false); - done(); - }); - }); - - it('should return true if upload is an orphan', (done) => { - posts.uploads.isOrphan('files/shazam.jpg', (err, isOrphan) => { - assert.ifError(err); - assert.equal(true, isOrphan); - done(); - }); - }); - }); - - describe('.associate()', () => { - it('should add an image to the post\'s maintained list of uploads', (done) => { - async.waterfall([ - async.apply(posts.uploads.associate, pid, 'files/whoa.gif'), - async.apply(posts.uploads.list, pid), - ], (err, uploads) => { - assert.ifError(err); - assert.strictEqual(2, uploads.length); - assert.strictEqual(true, uploads.includes('files/whoa.gif')); - done(); - }); - }); - - it('should allow arrays to be passed in', (done) => { - async.waterfall([ - async.apply(posts.uploads.associate, pid, ['files/amazeballs.jpg', 'files/wut.txt']), - async.apply(posts.uploads.list, pid), - ], (err, uploads) => { - assert.ifError(err); - assert.strictEqual(4, uploads.length); - assert.strictEqual(true, uploads.includes('files/amazeballs.jpg')); - assert.strictEqual(true, uploads.includes('files/wut.txt')); - done(); - }); - }); - - it('should save a reverse association of md5sum to pid', (done) => { - const md5 = filename => crypto.createHash('md5').update(filename).digest('hex'); - - async.waterfall([ - async.apply(posts.uploads.associate, pid, ['files/test.bmp']), - function (next) { - db.getSortedSetRange(`upload:${md5('files/test.bmp')}:pids`, 0, -1, next); - }, - ], (err, pids) => { - assert.ifError(err); - assert.strictEqual(true, Array.isArray(pids)); - assert.strictEqual(true, pids.length > 0); - assert.equal(pid, pids[0]); - done(); - }); - }); - - it('should not associate a file that does not exist on the local disk', (done) => { - async.waterfall([ - async.apply(posts.uploads.associate, pid, ['files/nonexistant.xls']), - async.apply(posts.uploads.list, pid), - ], (err, uploads) => { - assert.ifError(err); - assert.strictEqual(uploads.length, 5); - assert.strictEqual(false, uploads.includes('files/nonexistant.xls')); - done(); - }); - }); - }); - - describe('.dissociate()', () => { - it('should remove an image from the post\'s maintained list of uploads', (done) => { - async.waterfall([ - async.apply(posts.uploads.dissociate, pid, 'files/whoa.gif'), - async.apply(posts.uploads.list, pid), - ], (err, uploads) => { - assert.ifError(err); - assert.strictEqual(4, uploads.length); - assert.strictEqual(false, uploads.includes('files/whoa.gif')); - done(); - }); - }); - - it('should allow arrays to be passed in', (done) => { - async.waterfall([ - async.apply(posts.uploads.dissociate, pid, ['files/amazeballs.jpg', 'files/wut.txt']), - async.apply(posts.uploads.list, pid), - ], (err, uploads) => { - assert.ifError(err); - assert.strictEqual(2, uploads.length); - assert.strictEqual(false, uploads.includes('files/amazeballs.jpg')); - assert.strictEqual(false, uploads.includes('files/wut.txt')); - done(); - }); - }); - - it('should remove the image\'s user association, if present', async () => { - _recreateFiles(); - await posts.uploads.associate(pid, 'files/wut.txt'); - await user.associateUpload(uid, 'files/wut.txt'); - await posts.uploads.dissociate(pid, 'files/wut.txt'); - - const userUploads = await db.getSortedSetMembers(`uid:${uid}:uploads`); - assert.strictEqual(userUploads.includes('files/wut.txt'), false); - }); - }); - - describe('.dissociateAll()', () => { - it('should remove all images from a post\'s maintained list of uploads', async () => { - await posts.uploads.dissociateAll(pid); - const uploads = await posts.uploads.list(pid); - - assert.equal(uploads.length, 0); - }); - }); - - describe('Dissociation on purge', () => { - it('should not dissociate images on post deletion', async () => { - await posts.delete(purgePid, 1); - const uploads = await posts.uploads.list(purgePid); - - assert.equal(uploads.length, 2); - }); - - it('should dissociate images on post purge', async () => { - await posts.purge(purgePid, 1); - const uploads = await posts.uploads.list(purgePid); - - assert.equal(uploads.length, 0); - }); - }); - - describe('Deletion from disk on purge', () => { - let postData; - - beforeEach(async () => { - _recreateFiles(); - - ({ postData } = await topics.post({ - uid, - cid, - title: 'Testing deletion from disk on purge', - content: 'these images: ![alt text](/assets/uploads/files/abracadabra.png) and another ![alt text](/assets/uploads/files/test.bmp)', - })); - }); - - afterEach(async () => { - await topics.purge(postData.tid, uid); - }); - - it('should purge the images from disk if the post is purged', async () => { - await posts.purge(postData.pid, uid); - assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files', 'abracadabra.png')), false); - assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files', 'test.bmp')), false); - }); - - it('should leave the images behind if `preserveOrphanedUploads` is enabled', async () => { - meta.config.preserveOrphanedUploads = 1; - - await posts.purge(postData.pid, uid); - assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files', 'abracadabra.png')), true); - assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files', 'test.bmp')), true); - - delete meta.config.preserveOrphanedUploads; - }); - - it('should leave images behind if they are used in another post', async () => { - const { postData: secondPost } = await topics.post({ - uid, - cid, - title: 'Second topic', - content: 'just abracadabra: ![alt text](/assets/uploads/files/abracadabra.png)', - }); - - await posts.purge(secondPost.pid, uid); - assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files', 'abracadabra.png')), true); - }); - }); - - describe('.deleteFromDisk()', () => { - beforeEach(() => { - _recreateFiles(); - }); - - it('should work if you pass in a string path', async () => { - await posts.uploads.deleteFromDisk('files/abracadabra.png'); - assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files/abracadabra.png')), false); - }); - - it('should throw an error if a non-string or non-array is passed', async () => { - try { - await posts.uploads.deleteFromDisk({ - files: ['files/abracadabra.png'], - }); - } catch (err) { - assert(!!err); - assert.strictEqual(err.message, '[[error:wrong-parameter-type, filePaths, object, array]]'); - } - }); - - it('should delete the files passed in, from disk', async () => { - await posts.uploads.deleteFromDisk(['files/abracadabra.png', 'files/shazam.jpg']); - - const existsOnDisk = await Promise.all(_filenames.map(async (filename) => { - const fullPath = path.resolve(nconf.get('upload_path'), 'files', filename); - return file.exists(fullPath); - })); - - assert.deepStrictEqual(existsOnDisk, [false, false, true, true, true, true]); - }); - - it('should not delete files if they are not in `uploads/files/` (path traversal)', async () => { - const tmpFilePath = path.resolve(os.tmpdir(), `derp${utils.generateUUID()}`); - await fs.promises.appendFile(tmpFilePath, ''); - await posts.uploads.deleteFromDisk(['../files/503.html', tmpFilePath]); - - assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), '../files/503.html')), true); - assert.strictEqual(await file.exists(tmpFilePath), true); - - await file.delete(tmpFilePath); - }); - - it('should delete files even if they are not orphans', async () => { - await topics.post({ - uid, - cid, - title: 'To be orphaned', - content: 'this image is not an orphan: ![wut](/assets/uploads/files/wut.txt)', - }); - - assert.strictEqual(await posts.uploads.isOrphan('files/wut.txt'), false); - await posts.uploads.deleteFromDisk(['files/wut.txt']); - - assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files/wut.txt')), false); - }); - }); + let pid; + let purgePid; + let cid; + let uid; + + before(async () => { + _recreateFiles(); + + uid = await user.create({ + username: 'uploads user', + password: 'abracadabra', + gdpr_consent: 1, + }); + + ({cid} = await categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + })); + + const topicPostData = await topics.post({ + uid, + cid, + title: 'topic with some images', + content: 'here is an image [alt text](/assets/uploads/files/abracadabra.png) and another [alt text](/assets/uploads/files/shazam.jpg)', + }); + pid = topicPostData.postData.pid; + + const purgePostData = await topics.post({ + uid, + cid, + title: 'topic with some images, to be purged', + content: 'here is an image [alt text](/assets/uploads/files/whoa.gif) and another [alt text](/assets/uploads/files/amazeballs.jpg)', + }); + purgePid = purgePostData.postData.pid; + }); + + describe('.sync()', () => { + it('should properly add new images to the post\'s zset', done => { + posts.uploads.sync(pid, error => { + assert.ifError(error); + + db.sortedSetCard(`post:${pid}:uploads`, (error, length) => { + assert.ifError(error); + assert.strictEqual(length, 2); + done(); + }); + }); + }); + + it('should remove an image if it is edited out of the post', done => { + async.series([ + function (next) { + posts.edit({ + pid, + uid, + content: 'here is an image [alt text](/assets/uploads/files/abracadabra.png)... AND NO MORE!', + }, next); + }, + async.apply(posts.uploads.sync, pid), + ], error => { + assert.ifError(error); + db.sortedSetCard(`post:${pid}:uploads`, (error, length) => { + assert.ifError(error); + assert.strictEqual(1, length); + done(); + }); + }); + }); + }); + + describe('.list()', () => { + it('should display the uploaded files for a specific post', done => { + posts.uploads.list(pid, (error, uploads) => { + assert.ifError(error); + assert.equal(true, Array.isArray(uploads)); + assert.strictEqual(1, uploads.length); + assert.equal('string', typeof uploads[0]); + done(); + }); + }); + }); + + describe('.isOrphan()', () => { + it('should return false if upload is not an orphan', done => { + posts.uploads.isOrphan('files/abracadabra.png', (error, isOrphan) => { + assert.ifError(error); + assert.equal(isOrphan, false); + done(); + }); + }); + + it('should return true if upload is an orphan', done => { + posts.uploads.isOrphan('files/shazam.jpg', (error, isOrphan) => { + assert.ifError(error); + assert.equal(true, isOrphan); + done(); + }); + }); + }); + + describe('.associate()', () => { + it('should add an image to the post\'s maintained list of uploads', done => { + async.waterfall([ + async.apply(posts.uploads.associate, pid, 'files/whoa.gif'), + async.apply(posts.uploads.list, pid), + ], (error, uploads) => { + assert.ifError(error); + assert.strictEqual(2, uploads.length); + assert.strictEqual(true, uploads.includes('files/whoa.gif')); + done(); + }); + }); + + it('should allow arrays to be passed in', done => { + async.waterfall([ + async.apply(posts.uploads.associate, pid, ['files/amazeballs.jpg', 'files/wut.txt']), + async.apply(posts.uploads.list, pid), + ], (error, uploads) => { + assert.ifError(error); + assert.strictEqual(4, uploads.length); + assert.strictEqual(true, uploads.includes('files/amazeballs.jpg')); + assert.strictEqual(true, uploads.includes('files/wut.txt')); + done(); + }); + }); + + it('should save a reverse association of md5sum to pid', done => { + const md5 = filename => crypto.createHash('md5').update(filename).digest('hex'); + + async.waterfall([ + async.apply(posts.uploads.associate, pid, ['files/test.bmp']), + function (next) { + db.getSortedSetRange(`upload:${md5('files/test.bmp')}:pids`, 0, -1, next); + }, + ], (error, pids) => { + assert.ifError(error); + assert.strictEqual(true, Array.isArray(pids)); + assert.strictEqual(true, pids.length > 0); + assert.equal(pid, pids[0]); + done(); + }); + }); + + it('should not associate a file that does not exist on the local disk', done => { + async.waterfall([ + async.apply(posts.uploads.associate, pid, ['files/nonexistant.xls']), + async.apply(posts.uploads.list, pid), + ], (error, uploads) => { + assert.ifError(error); + assert.strictEqual(uploads.length, 5); + assert.strictEqual(false, uploads.includes('files/nonexistant.xls')); + done(); + }); + }); + }); + + describe('.dissociate()', () => { + it('should remove an image from the post\'s maintained list of uploads', done => { + async.waterfall([ + async.apply(posts.uploads.dissociate, pid, 'files/whoa.gif'), + async.apply(posts.uploads.list, pid), + ], (error, uploads) => { + assert.ifError(error); + assert.strictEqual(4, uploads.length); + assert.strictEqual(false, uploads.includes('files/whoa.gif')); + done(); + }); + }); + + it('should allow arrays to be passed in', done => { + async.waterfall([ + async.apply(posts.uploads.dissociate, pid, ['files/amazeballs.jpg', 'files/wut.txt']), + async.apply(posts.uploads.list, pid), + ], (error, uploads) => { + assert.ifError(error); + assert.strictEqual(2, uploads.length); + assert.strictEqual(false, uploads.includes('files/amazeballs.jpg')); + assert.strictEqual(false, uploads.includes('files/wut.txt')); + done(); + }); + }); + + it('should remove the image\'s user association, if present', async () => { + _recreateFiles(); + await posts.uploads.associate(pid, 'files/wut.txt'); + await user.associateUpload(uid, 'files/wut.txt'); + await posts.uploads.dissociate(pid, 'files/wut.txt'); + + const userUploads = await db.getSortedSetMembers(`uid:${uid}:uploads`); + assert.strictEqual(userUploads.includes('files/wut.txt'), false); + }); + }); + + describe('.dissociateAll()', () => { + it('should remove all images from a post\'s maintained list of uploads', async () => { + await posts.uploads.dissociateAll(pid); + const uploads = await posts.uploads.list(pid); + + assert.equal(uploads.length, 0); + }); + }); + + describe('Dissociation on purge', () => { + it('should not dissociate images on post deletion', async () => { + await posts.delete(purgePid, 1); + const uploads = await posts.uploads.list(purgePid); + + assert.equal(uploads.length, 2); + }); + + it('should dissociate images on post purge', async () => { + await posts.purge(purgePid, 1); + const uploads = await posts.uploads.list(purgePid); + + assert.equal(uploads.length, 0); + }); + }); + + describe('Deletion from disk on purge', () => { + let postData; + + beforeEach(async () => { + _recreateFiles(); + + ({postData} = await topics.post({ + uid, + cid, + title: 'Testing deletion from disk on purge', + content: 'these images: ![alt text](/assets/uploads/files/abracadabra.png) and another ![alt text](/assets/uploads/files/test.bmp)', + })); + }); + + afterEach(async () => { + await topics.purge(postData.tid, uid); + }); + + it('should purge the images from disk if the post is purged', async () => { + await posts.purge(postData.pid, uid); + assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files', 'abracadabra.png')), false); + assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files', 'test.bmp')), false); + }); + + it('should leave the images behind if `preserveOrphanedUploads` is enabled', async () => { + meta.config.preserveOrphanedUploads = 1; + + await posts.purge(postData.pid, uid); + assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files', 'abracadabra.png')), true); + assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files', 'test.bmp')), true); + + delete meta.config.preserveOrphanedUploads; + }); + + it('should leave images behind if they are used in another post', async () => { + const {postData: secondPost} = await topics.post({ + uid, + cid, + title: 'Second topic', + content: 'just abracadabra: ![alt text](/assets/uploads/files/abracadabra.png)', + }); + + await posts.purge(secondPost.pid, uid); + assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files', 'abracadabra.png')), true); + }); + }); + + describe('.deleteFromDisk()', () => { + beforeEach(() => { + _recreateFiles(); + }); + + it('should work if you pass in a string path', async () => { + await posts.uploads.deleteFromDisk('files/abracadabra.png'); + assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files/abracadabra.png')), false); + }); + + it('should throw an error if a non-string or non-array is passed', async () => { + try { + await posts.uploads.deleteFromDisk({ + files: ['files/abracadabra.png'], + }); + } catch (error) { + assert(Boolean(error)); + assert.strictEqual(error.message, '[[error:wrong-parameter-type, filePaths, object, array]]'); + } + }); + + it('should delete the files passed in, from disk', async () => { + await posts.uploads.deleteFromDisk(['files/abracadabra.png', 'files/shazam.jpg']); + + const existsOnDisk = await Promise.all(_filenames.map(async filename => { + const fullPath = path.resolve(nconf.get('upload_path'), 'files', filename); + return file.exists(fullPath); + })); + + assert.deepStrictEqual(existsOnDisk, [false, false, true, true, true, true]); + }); + + it('should not delete files if they are not in `uploads/files/` (path traversal)', async () => { + const temporaryFilePath = path.resolve(os.tmpdir(), `derp${utils.generateUUID()}`); + await fs.promises.appendFile(temporaryFilePath, ''); + await posts.uploads.deleteFromDisk(['../files/503.html', temporaryFilePath]); + + assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), '../files/503.html')), true); + assert.strictEqual(await file.exists(temporaryFilePath), true); + + await file.delete(temporaryFilePath); + }); + + it('should delete files even if they are not orphans', async () => { + await topics.post({ + uid, + cid, + title: 'To be orphaned', + content: 'this image is not an orphan: ![wut](/assets/uploads/files/wut.txt)', + }); + + assert.strictEqual(await posts.uploads.isOrphan('files/wut.txt'), false); + await posts.uploads.deleteFromDisk(['files/wut.txt']); + + assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files/wut.txt')), false); + }); + }); }); describe('post uploads management', () => { - let topic; - let reply; - let uid; - let cid; - - before(async () => { - _recreateFiles(); - - uid = await user.create({ - username: 'uploads user', - password: 'abracadabra', - gdpr_consent: 1, - }); - - ({ cid } = await categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - })); - - const topicPostData = await topics.post({ - uid, - cid, - title: 'topic to test uploads with', - content: '[abcdef](/assets/uploads/files/abracadabra.png)', - }); - - const replyData = await topics.reply({ - uid, - tid: topicPostData.topicData.tid, - timestamp: Date.now(), - content: '[abcdef](/assets/uploads/files/shazam.jpg)', - }); - - topic = topicPostData; - reply = replyData; - }); - - it('should automatically sync uploads on topic create and reply', (done) => { - db.sortedSetsCard([`post:${topic.topicData.mainPid}:uploads`, `post:${reply.pid}:uploads`], (err, lengths) => { - assert.ifError(err); - assert.strictEqual(lengths[0], 1); - assert.strictEqual(lengths[1], 1); - done(); - }); - }); - - it('should automatically sync uploads on post edit', (done) => { - async.waterfall([ - async.apply(posts.edit, { - pid: reply.pid, - uid, - content: 'no uploads', - }), - function (postData, next) { - posts.uploads.list(reply.pid, next); - }, - ], (err, uploads) => { - assert.ifError(err); - assert.strictEqual(true, Array.isArray(uploads)); - assert.strictEqual(0, uploads.length); - done(); - }); - }); + let topic; + let reply; + let uid; + let cid; + + before(async () => { + _recreateFiles(); + + uid = await user.create({ + username: 'uploads user', + password: 'abracadabra', + gdpr_consent: 1, + }); + + ({cid} = await categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + })); + + const topicPostData = await topics.post({ + uid, + cid, + title: 'topic to test uploads with', + content: '[abcdef](/assets/uploads/files/abracadabra.png)', + }); + + const replyData = await topics.reply({ + uid, + tid: topicPostData.topicData.tid, + timestamp: Date.now(), + content: '[abcdef](/assets/uploads/files/shazam.jpg)', + }); + + topic = topicPostData; + reply = replyData; + }); + + it('should automatically sync uploads on topic create and reply', done => { + db.sortedSetsCard([`post:${topic.topicData.mainPid}:uploads`, `post:${reply.pid}:uploads`], (error, lengths) => { + assert.ifError(error); + assert.strictEqual(lengths[0], 1); + assert.strictEqual(lengths[1], 1); + done(); + }); + }); + + it('should automatically sync uploads on post edit', done => { + async.waterfall([ + async.apply(posts.edit, { + pid: reply.pid, + uid, + content: 'no uploads', + }), + function (postData, next) { + posts.uploads.list(reply.pid, next); + }, + ], (error, uploads) => { + assert.ifError(error); + assert.strictEqual(true, Array.isArray(uploads)); + assert.strictEqual(0, uploads.length); + done(); + }); + }); }); diff --git a/test/pubsub.js b/test/pubsub.js index 1e2200b..f7cd7fc 100644 --- a/test/pubsub.js +++ b/test/pubsub.js @@ -1,54 +1,53 @@ 'use strict'; -const assert = require('assert'); +const assert = require('node:assert'); const nconf = require('nconf'); - -const db = require('./mocks/databasemock'); const pubsub = require('../src/pubsub'); +const db = require('./mocks/databasemock'); describe('pubsub', () => { - it('should use the plain event emitter', (done) => { - nconf.set('isCluster', false); - pubsub.reset(); - pubsub.on('testEvent', (message) => { - assert.equal(message.foo, 1); - pubsub.removeAllListeners('testEvent'); - done(); - }); - pubsub.publish('testEvent', { foo: 1 }); - }); + it('should use the plain event emitter', done => { + nconf.set('isCluster', false); + pubsub.reset(); + pubsub.on('testEvent', message => { + assert.equal(message.foo, 1); + pubsub.removeAllListeners('testEvent'); + done(); + }); + pubsub.publish('testEvent', {foo: 1}); + }); - it('should use same event emitter', (done) => { - pubsub.on('dummyEvent', (message) => { - assert.equal(message.foo, 2); - pubsub.removeAllListeners('dummyEvent'); - pubsub.reset(); - done(); - }); - pubsub.publish('dummyEvent', { foo: 2 }); - }); + it('should use same event emitter', done => { + pubsub.on('dummyEvent', message => { + assert.equal(message.foo, 2); + pubsub.removeAllListeners('dummyEvent'); + pubsub.reset(); + done(); + }); + pubsub.publish('dummyEvent', {foo: 2}); + }); - it('should use singleHostCluster', (done) => { - const oldValue = nconf.get('singleHostCluster'); - nconf.set('singleHostCluster', true); - pubsub.on('testEvent', (message) => { - assert.equal(message.foo, 3); - nconf.set('singleHostCluster', oldValue); - pubsub.removeAllListeners('testEvent'); - done(); - }); - pubsub.publish('testEvent', { foo: 3 }); - }); + it('should use singleHostCluster', done => { + const oldValue = nconf.get('singleHostCluster'); + nconf.set('singleHostCluster', true); + pubsub.on('testEvent', message => { + assert.equal(message.foo, 3); + nconf.set('singleHostCluster', oldValue); + pubsub.removeAllListeners('testEvent'); + done(); + }); + pubsub.publish('testEvent', {foo: 3}); + }); - it('should use same event emitter', (done) => { - const oldValue = nconf.get('singleHostCluster'); - pubsub.on('dummyEvent', (message) => { - assert.equal(message.foo, 4); - nconf.set('singleHostCluster', oldValue); - pubsub.removeAllListeners('dummyEvent'); - pubsub.reset(); - done(); - }); - pubsub.publish('dummyEvent', { foo: 4 }); - }); + it('should use same event emitter', done => { + const oldValue = nconf.get('singleHostCluster'); + pubsub.on('dummyEvent', message => { + assert.equal(message.foo, 4); + nconf.set('singleHostCluster', oldValue); + pubsub.removeAllListeners('dummyEvent'); + pubsub.reset(); + done(); + }); + pubsub.publish('dummyEvent', {foo: 4}); + }); }); diff --git a/test/rewards.js b/test/rewards.js index 2db197e..1cd382c 100644 --- a/test/rewards.js +++ b/test/rewards.js @@ -1,79 +1,79 @@ 'use strict'; -const assert = require('assert'); +const assert = require('node:assert'); const async = require('async'); - -const db = require('./mocks/databasemock'); const meta = require('../src/meta'); const User = require('../src/user'); const Groups = require('../src/groups'); +const db = require('./mocks/databasemock'); describe('rewards', () => { - let adminUid; - let bazUid; - let herpUid; + let adminUid; + let bazUid; + let herpUid; + + before(done => { + // Create 3 users: 1 admin, 2 regular + async.series([ + async.apply(User.create, {username: 'foo'}), + async.apply(User.create, {username: 'baz'}), + async.apply(User.create, {username: 'herp'}), + ], (error, uids) => { + if (error) { + return done(error); + } - before((done) => { - // Create 3 users: 1 admin, 2 regular - async.series([ - async.apply(User.create, { username: 'foo' }), - async.apply(User.create, { username: 'baz' }), - async.apply(User.create, { username: 'herp' }), - ], (err, uids) => { - if (err) { - return done(err); - } + adminUid = uids[0]; + bazUid = uids[1]; + herpUid = uids[2]; - adminUid = uids[0]; - bazUid = uids[1]; - herpUid = uids[2]; + async.series([ + function (next) { + Groups.join('administrators', adminUid, next); + }, + function (next) { + Groups.join('rewardGroup', adminUid, next); + }, + ], done); + }); + }); - async.series([ - function (next) { - Groups.join('administrators', adminUid, next); - }, - function (next) { - Groups.join('rewardGroup', adminUid, next); - }, - ], done); - }); - }); + describe('rewards create', () => { + const socketAdmin = require('../src/socket.io/admin'); + const rewards = require('../src/rewards'); + it('it should save a reward', done => { + const data = [ + { + rewards: {groupname: 'Gamers'}, + condition: 'essentials/user.postcount', + conditional: 'greaterthan', + value: '10', + rid: 'essentials/add-to-group', + claimable: '1', + id: '', + disabled: false, + }, + ]; - describe('rewards create', () => { - const socketAdmin = require('../src/socket.io/admin'); - const rewards = require('../src/rewards'); - it('it should save a reward', (done) => { - const data = [ - { - rewards: { groupname: 'Gamers' }, - condition: 'essentials/user.postcount', - conditional: 'greaterthan', - value: '10', - rid: 'essentials/add-to-group', - claimable: '1', - id: '', - disabled: false, - }, - ]; + socketAdmin.rewards.save({uid: adminUid}, data, error => { + assert.ifError(error); + done(); + }); + }); - socketAdmin.rewards.save({ uid: adminUid }, data, (err) => { - assert.ifError(err); - done(); - }); - }); + it('should check condition', done => { + function method(next) { + next(null, 1); + } - it('should check condition', (done) => { - function method(next) { - next(null, 1); - } - rewards.checkConditionAndRewardUser({ - uid: adminUid, - condition: 'essentials/user.postcount', - method: method, - }, (err, data) => { - assert.ifError(err); - done(); - }); - }); - }); + rewards.checkConditionAndRewardUser({ + uid: adminUid, + condition: 'essentials/user.postcount', + method, + }, (error, data) => { + assert.ifError(error); + done(); + }); + }); + }); }); diff --git a/test/search-admin.js b/test/search-admin.js index cf9733a..b6c6cab 100644 --- a/test/search-admin.js +++ b/test/search-admin.js @@ -1,87 +1,86 @@ 'use strict'; - -const assert = require('assert'); +const assert = require('node:assert'); const search = require('../src/admin/search'); describe('admin search', () => { - describe('filterDirectories', () => { - it('should resolve all paths to relative paths', (done) => { - assert.deepEqual(search.filterDirectories([ - 'hfjksfd/fdsgagag/admin/gdhgfsdg/sggag.tpl', - ]), [ - 'admin/gdhgfsdg/sggag', - ]); - done(); - }); - it('should exclude .js files', (done) => { - assert.deepEqual(search.filterDirectories([ - 'hfjksfd/fdsgagag/admin/gdhgfsdg/sggag.tpl', - 'dfahdfsgf/admin/hgkfds/fdhsdfh.js', - ]), [ - 'admin/gdhgfsdg/sggag', - ]); - done(); - }); - it('should exclude partials', (done) => { - assert.deepEqual(search.filterDirectories([ - 'hfjksfd/fdsgagag/admin/gdhgfsdg/sggag.tpl', - 'dfahdfsgf/admin/partials/hgkfds/fdhsdfh.tpl', - ]), [ - 'admin/gdhgfsdg/sggag', - ]); - done(); - }); - it('should exclude files in the admin directory', (done) => { - assert.deepEqual(search.filterDirectories([ - 'hfjksfd/fdsgagag/admin/gdhgfsdg/sggag.tpl', - 'dfdasg/admin/hjkdfsk.tpl', - ]), [ - 'admin/gdhgfsdg/sggag', - ]); - done(); - }); - }); + describe('filterDirectories', () => { + it('should resolve all paths to relative paths', done => { + assert.deepEqual(search.filterDirectories([ + 'hfjksfd/fdsgagag/admin/gdhgfsdg/sggag.tpl', + ]), [ + 'admin/gdhgfsdg/sggag', + ]); + done(); + }); + it('should exclude .js files', done => { + assert.deepEqual(search.filterDirectories([ + 'hfjksfd/fdsgagag/admin/gdhgfsdg/sggag.tpl', + 'dfahdfsgf/admin/hgkfds/fdhsdfh.js', + ]), [ + 'admin/gdhgfsdg/sggag', + ]); + done(); + }); + it('should exclude partials', done => { + assert.deepEqual(search.filterDirectories([ + 'hfjksfd/fdsgagag/admin/gdhgfsdg/sggag.tpl', + 'dfahdfsgf/admin/partials/hgkfds/fdhsdfh.tpl', + ]), [ + 'admin/gdhgfsdg/sggag', + ]); + done(); + }); + it('should exclude files in the admin directory', done => { + assert.deepEqual(search.filterDirectories([ + 'hfjksfd/fdsgagag/admin/gdhgfsdg/sggag.tpl', + 'dfdasg/admin/hjkdfsk.tpl', + ]), [ + 'admin/gdhgfsdg/sggag', + ]); + done(); + }); + }); - describe('sanitize', () => { - it('should strip out scripts', (done) => { - assert.equal( - search.sanitize('Pellentesque tristique senectus' + - ' habitant morbi'), - 'Pellentesque tristique senectus' + - ' habitant morbi' - ); - done(); - }); - it('should remove all tags', (done) => { - assert.equal( - search.sanitize('

    Pellentesque habitant morbi tristique senectus' + - 'Aenean vitae est.Mauris eleifend leo.

    '), - 'Pellentesque habitant morbi tristique senectus' + - 'Aenean vitae est.Mauris eleifend leo.' - ); - done(); - }); - }); + describe('sanitize', () => { + it('should strip out scripts', done => { + assert.equal( + search.sanitize('Pellentesque tristique senectus' + + ' habitant morbi'), + 'Pellentesque tristique senectus' + + ' habitant morbi', + ); + done(); + }); + it('should remove all tags', done => { + assert.equal( + search.sanitize('

    Pellentesque habitant morbi tristique senectus' + + 'Aenean vitae est.Mauris eleifend leo.

    '), + 'Pellentesque habitant morbi tristique senectus' + + 'Aenean vitae est.Mauris eleifend leo.', + ); + done(); + }); + }); - describe('simplify', () => { - it('should remove all mustaches', (done) => { - assert.equal( - search.simplify('Pellentesque tristique {{senectus}}habitant morbi' + - 'liquam tincidunt {mauris.eu}risus'), - 'Pellentesque tristique habitant morbi' + - 'liquam tincidunt risus' - ); - done(); - }); - it('should collapse all whitespace', (done) => { - assert.equal( - search.simplify('Pellentesque tristique habitant morbi' + - ' \n\n liquam tincidunt mauris eu risus.'), - 'Pellentesque tristique habitant morbi' + - '\nliquam tincidunt mauris eu risus.' - ); - done(); - }); - }); + describe('simplify', () => { + it('should remove all mustaches', done => { + assert.equal( + search.simplify('Pellentesque tristique {{senectus}}habitant morbi' + + 'liquam tincidunt {mauris.eu}risus'), + 'Pellentesque tristique habitant morbi' + + 'liquam tincidunt risus', + ); + done(); + }); + it('should collapse all whitespace', done => { + assert.equal( + search.simplify('Pellentesque tristique habitant morbi' + + ' \n\n liquam tincidunt mauris eu risus.'), + 'Pellentesque tristique habitant morbi' + + '\nliquam tincidunt mauris eu risus.', + ); + done(); + }); + }); }); diff --git a/test/search.js b/test/search.js index 8316158..367d8e7 100644 --- a/test/search.js +++ b/test/search.js @@ -1,318 +1,316 @@ 'use strict'; - -const assert = require('assert'); +const assert = require('node:assert'); const async = require('async'); const request = require('request'); const nconf = require('nconf'); - -const db = require('./mocks/databasemock'); const topics = require('../src/topics'); const categories = require('../src/categories'); const user = require('../src/user'); const search = require('../src/search'); const privileges = require('../src/privileges'); +const db = require('./mocks/databasemock'); describe('Search', () => { - let phoebeUid; - let gingerUid; + let phoebeUid; + let gingerUid; - let topic1Data; - let topic2Data; - let post1Data; - let post2Data; - let post3Data; - let cid1; - let cid2; - let cid3; + let topic1Data; + let topic2Data; + let post1Data; + let post2Data; + let post3Data; + let cid1; + let cid2; + let cid3; - before((done) => { - async.waterfall([ - function (next) { - async.series({ - phoebe: function (next) { - user.create({ username: 'phoebe' }, next); - }, - ginger: function (next) { - user.create({ username: 'ginger' }, next); - }, - category1: function (next) { - categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - }, next); - }, - category2: function (next) { - categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - }, next); - }, - }, next); - }, - function (results, next) { - phoebeUid = results.phoebe; - gingerUid = results.ginger; - cid1 = results.category1.cid; - cid2 = results.category2.cid; + before(done => { + async.waterfall([ + function (next) { + async.series({ + phoebe(next) { + user.create({username: 'phoebe'}, next); + }, + ginger(next) { + user.create({username: 'ginger'}, next); + }, + category1(next) { + categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + }, next); + }, + category2(next) { + categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + }, next); + }, + }, next); + }, + function (results, next) { + phoebeUid = results.phoebe; + gingerUid = results.ginger; + cid1 = results.category1.cid; + cid2 = results.category2.cid; - async.waterfall([ - function (next) { - categories.create({ - name: 'Child Test Category', - description: 'Test category created by testing script', - parentCid: cid2, - }, next); - }, - function (category, next) { - cid3 = category.cid; - topics.post({ - uid: phoebeUid, - cid: cid1, - title: 'nodebb mongodb bugs', - content: 'avocado cucumber apple orange fox', - tags: ['nodebb', 'bug', 'plugin', 'nodebb-plugin', 'jquery'], - }, next); - }, - function (results, next) { - topic1Data = results.topicData; - post1Data = results.postData; + async.waterfall([ + function (next) { + categories.create({ + name: 'Child Test Category', + description: 'Test category created by testing script', + parentCid: cid2, + }, next); + }, + function (category, next) { + cid3 = category.cid; + topics.post({ + uid: phoebeUid, + cid: cid1, + title: 'nodebb mongodb bugs', + content: 'avocado cucumber apple orange fox', + tags: ['nodebb', 'bug', 'plugin', 'nodebb-plugin', 'jquery'], + }, next); + }, + function (results, next) { + topic1Data = results.topicData; + post1Data = results.postData; - topics.post({ - uid: gingerUid, - cid: cid2, - title: 'java mongodb redis', - content: 'avocado cucumber carrot armadillo', - tags: ['nodebb', 'bug', 'plugin', 'nodebb-plugin', 'javascript'], - }, next); - }, - function (results, next) { - topic2Data = results.topicData; - post2Data = results.postData; - topics.reply({ - uid: phoebeUid, - content: 'reply post apple', - tid: topic2Data.tid, - }, next); - }, - function (_post3Data, next) { - post3Data = _post3Data; - setTimeout(next, 500); - }, - ], next); - }, - ], done); - }); + topics.post({ + uid: gingerUid, + cid: cid2, + title: 'java mongodb redis', + content: 'avocado cucumber carrot armadillo', + tags: ['nodebb', 'bug', 'plugin', 'nodebb-plugin', 'javascript'], + }, next); + }, + function (results, next) { + topic2Data = results.topicData; + post2Data = results.postData; + topics.reply({ + uid: phoebeUid, + content: 'reply post apple', + tid: topic2Data.tid, + }, next); + }, + function (_post3Data, next) { + post3Data = _post3Data; + setTimeout(next, 500); + }, + ], next); + }, + ], done); + }); - it('should search term in titles and posts', (done) => { - const meta = require('../src/meta'); - const qs = `/api/search?term=cucumber&in=titlesposts&categories[]=${cid1}&by=phoebe&replies=1&repliesFilter=atleast&sortBy=timestamp&sortDirection=desc&showAs=posts`; - privileges.global.give(['groups:search:content'], 'guests', (err) => { - assert.ifError(err); - request({ - url: nconf.get('url') + qs, - json: true, - }, (err, response, body) => { - assert.ifError(err); - assert(body); - assert.equal(body.matchCount, 1); - assert.equal(body.posts.length, 1); - assert.equal(body.posts[0].pid, post1Data.pid); - assert.equal(body.posts[0].uid, phoebeUid); + it('should search term in titles and posts', done => { + const meta = require('../src/meta'); + const qs = `/api/search?term=cucumber&in=titlesposts&categories[]=${cid1}&by=phoebe&replies=1&repliesFilter=atleast&sortBy=timestamp&sortDirection=desc&showAs=posts`; + privileges.global.give(['groups:search:content'], 'guests', error => { + assert.ifError(error); + request({ + url: nconf.get('url') + qs, + json: true, + }, (error, response, body) => { + assert.ifError(error); + assert(body); + assert.equal(body.matchCount, 1); + assert.equal(body.posts.length, 1); + assert.equal(body.posts[0].pid, post1Data.pid); + assert.equal(body.posts[0].uid, phoebeUid); - privileges.global.rescind(['groups:search:content'], 'guests', done); - }); - }); - }); + privileges.global.rescind(['groups:search:content'], 'guests', done); + }); + }); + }); - it('should search for a user', (done) => { - search.search({ - query: 'gin', - searchIn: 'users', - }, (err, data) => { - assert.ifError(err); - assert(data); - assert.equal(data.matchCount, 1); - assert.equal(data.users.length, 1); - assert.equal(data.users[0].uid, gingerUid); - assert.equal(data.users[0].username, 'ginger'); - done(); - }); - }); + it('should search for a user', done => { + search.search({ + query: 'gin', + searchIn: 'users', + }, (error, data) => { + assert.ifError(error); + assert(data); + assert.equal(data.matchCount, 1); + assert.equal(data.users.length, 1); + assert.equal(data.users[0].uid, gingerUid); + assert.equal(data.users[0].username, 'ginger'); + done(); + }); + }); - it('should search for a tag', (done) => { - search.search({ - query: 'plug', - searchIn: 'tags', - }, (err, data) => { - assert.ifError(err); - assert(data); - assert.equal(data.matchCount, 1); - assert.equal(data.tags.length, 1); - assert.equal(data.tags[0].value, 'plugin'); - assert.equal(data.tags[0].score, 2); - done(); - }); - }); + it('should search for a tag', done => { + search.search({ + query: 'plug', + searchIn: 'tags', + }, (error, data) => { + assert.ifError(error); + assert(data); + assert.equal(data.matchCount, 1); + assert.equal(data.tags.length, 1); + assert.equal(data.tags[0].value, 'plugin'); + assert.equal(data.tags[0].score, 2); + done(); + }); + }); - it('should search for a category', async () => { - await categories.create({ - name: 'foo category', - description: 'Test category created by testing script', - }); - await categories.create({ - name: 'baz category', - description: 'Test category created by testing script', - }); - const result = await search.search({ - query: 'baz', - searchIn: 'categories', - }); - assert.strictEqual(result.matchCount, 1); - assert.strictEqual(result.categories[0].name, 'baz category'); - }); + it('should search for a category', async () => { + await categories.create({ + name: 'foo category', + description: 'Test category created by testing script', + }); + await categories.create({ + name: 'baz category', + description: 'Test category created by testing script', + }); + const result = await search.search({ + query: 'baz', + searchIn: 'categories', + }); + assert.strictEqual(result.matchCount, 1); + assert.strictEqual(result.categories[0].name, 'baz category'); + }); - it('should search for categories', async () => { - const socketCategories = require('../src/socket.io/categories'); - let data = await socketCategories.categorySearch({ uid: phoebeUid }, { query: 'baz', parentCid: 0 }); - assert.strictEqual(data[0].name, 'baz category'); - data = await socketCategories.categorySearch({ uid: phoebeUid }, { query: '', parentCid: 0 }); - assert.strictEqual(data.length, 5); - }); + it('should search for categories', async () => { + const socketCategories = require('../src/socket.io/categories'); + let data = await socketCategories.categorySearch({uid: phoebeUid}, {query: 'baz', parentCid: 0}); + assert.strictEqual(data[0].name, 'baz category'); + data = await socketCategories.categorySearch({uid: phoebeUid}, {query: '', parentCid: 0}); + assert.strictEqual(data.length, 5); + }); - it('should fail if searchIn is wrong', (done) => { - search.search({ - query: 'plug', - searchIn: '', - }, (err) => { - assert.equal(err.message, '[[error:unknown-search-filter]]'); - done(); - }); - }); + it('should fail if searchIn is wrong', done => { + search.search({ + query: 'plug', + searchIn: '', + }, error => { + assert.equal(error.message, '[[error:unknown-search-filter]]'); + done(); + }); + }); - it('should search with tags filter', (done) => { - search.search({ - query: 'mongodb', - searchIn: 'titles', - hasTags: ['nodebb', 'javascript'], - }, (err, data) => { - assert.ifError(err); - assert.equal(data.posts[0].tid, topic2Data.tid); - done(); - }); - }); + it('should search with tags filter', done => { + search.search({ + query: 'mongodb', + searchIn: 'titles', + hasTags: ['nodebb', 'javascript'], + }, (error, data) => { + assert.ifError(error); + assert.equal(data.posts[0].tid, topic2Data.tid); + done(); + }); + }); - it('should search with topics filter', (done) => { - search.search({ - query: 'avocado', - searchIn: 'posts', - topicName: ['java mongodb redis'], - }, (err, data) => { - assert.ifError(err); - assert.equal(data.posts[0].tid, topic2Data.tid); - done(); - }); - }); + it('should search with topics filter', done => { + search.search({ + query: 'avocado', + searchIn: 'posts', + topicName: ['java mongodb redis'], + }, (error, data) => { + assert.ifError(error); + assert.equal(data.posts[0].tid, topic2Data.tid); + done(); + }); + }); - it('should not find anything with topic filter', (done) => { - search.search({ - query: 'avocado', - searchIn: 'posts', - topicName: ['not a real topic'], - }, (err, data) => { - assert.ifError(err); - assert(Array.isArray(data.posts)); - assert(!data.matchCount); - done(); - }); - }); + it('should not find anything with topic filter', done => { + search.search({ + query: 'avocado', + searchIn: 'posts', + topicName: ['not a real topic'], + }, (error, data) => { + assert.ifError(error); + assert(Array.isArray(data.posts)); + assert(!data.matchCount); + done(); + }); + }); - it('should not crash if tags is not an array', (done) => { - search.search({ - query: 'mongodb', - searchIn: 'titles', - hasTags: 'nodebb,javascript', - }, (err, data) => { - assert.ifError(err); - done(); - }); - }); + it('should not crash if tags is not an array', done => { + search.search({ + query: 'mongodb', + searchIn: 'titles', + hasTags: 'nodebb,javascript', + }, (error, data) => { + assert.ifError(error); + done(); + }); + }); - it('should not find anything', (done) => { - search.search({ - query: 'xxxxxxxxxxxxxx', - searchIn: 'titles', - }, (err, data) => { - assert.ifError(err); - assert(Array.isArray(data.posts)); - assert(!data.matchCount); - done(); - }); - }); + it('should not find anything', done => { + search.search({ + query: 'xxxxxxxxxxxxxx', + searchIn: 'titles', + }, (error, data) => { + assert.ifError(error); + assert(Array.isArray(data.posts)); + assert(!data.matchCount); + done(); + }); + }); - it('should search child categories', (done) => { - async.waterfall([ - function (next) { - topics.post({ - uid: gingerUid, - cid: cid3, - title: 'child category topic', - content: 'avocado cucumber carrot armadillo', - }, next); - }, - function (result, next) { - search.search({ - query: 'avocado', - searchIn: 'titlesposts', - categories: [cid2], - searchChildren: true, - sortBy: 'topic.timestamp', - sortDirection: 'desc', - }, next); - }, - function (result, next) { - assert(result.posts.length, 2); - assert(result.posts[0].topic.title === 'child category topic'); - assert(result.posts[1].topic.title === 'java mongodb redis'); - next(); - }, - ], done); - }); + it('should search child categories', done => { + async.waterfall([ + function (next) { + topics.post({ + uid: gingerUid, + cid: cid3, + title: 'child category topic', + content: 'avocado cucumber carrot armadillo', + }, next); + }, + function (result, next) { + search.search({ + query: 'avocado', + searchIn: 'titlesposts', + categories: [cid2], + searchChildren: true, + sortBy: 'topic.timestamp', + sortDirection: 'desc', + }, next); + }, + function (result, next) { + assert(result.posts.length, 2); + assert(result.posts[0].topic.title === 'child category topic'); + assert(result.posts[1].topic.title === 'java mongodb redis'); + next(); + }, + ], done); + }); - it('should return json search data with no categories', (done) => { - const qs = '/api/search?term=cucumber&in=titlesposts&searchOnly=1'; - privileges.global.give(['groups:search:content'], 'guests', (err) => { - assert.ifError(err); - request({ - url: nconf.get('url') + qs, - json: true, - }, (err, response, body) => { - assert.ifError(err); - assert(body); - assert(body.hasOwnProperty('matchCount')); - assert(body.hasOwnProperty('pagination')); - assert(body.hasOwnProperty('pageCount')); - assert(body.hasOwnProperty('posts')); - assert(!body.hasOwnProperty('categories')); + it('should return json search data with no categories', done => { + const qs = '/api/search?term=cucumber&in=titlesposts&searchOnly=1'; + privileges.global.give(['groups:search:content'], 'guests', error => { + assert.ifError(error); + request({ + url: nconf.get('url') + qs, + json: true, + }, (error, response, body) => { + assert.ifError(error); + assert(body); + assert(body.hasOwnProperty('matchCount')); + assert(body.hasOwnProperty('pagination')); + assert(body.hasOwnProperty('pageCount')); + assert(body.hasOwnProperty('posts')); + assert(!body.hasOwnProperty('categories')); - privileges.global.rescind(['groups:search:content'], 'guests', done); - }); - }); - }); + privileges.global.rescind(['groups:search:content'], 'guests', done); + }); + }); + }); - it('should not crash without a search term', (done) => { - const qs = '/api/search'; - privileges.global.give(['groups:search:content'], 'guests', (err) => { - assert.ifError(err); - request({ - url: nconf.get('url') + qs, - json: true, - }, (err, response, body) => { - assert.ifError(err); - assert(body); - assert.strictEqual(response.statusCode, 200); - privileges.global.rescind(['groups:search:content'], 'guests', done); - }); - }); - }); + it('should not crash without a search term', done => { + const qs = '/api/search'; + privileges.global.give(['groups:search:content'], 'guests', error => { + assert.ifError(error); + request({ + url: nconf.get('url') + qs, + json: true, + }, (error, response, body) => { + assert.ifError(error); + assert(body); + assert.strictEqual(response.statusCode, 200); + privileges.global.rescind(['groups:search:content'], 'guests', done); + }); + }); + }); }); diff --git a/test/settings.js b/test/settings.js index 4ea1f72..d2b3670 100644 --- a/test/settings.js +++ b/test/settings.js @@ -1,59 +1,58 @@ 'use strict'; -const assert = require('assert'); +const assert = require('node:assert'); const nconf = require('nconf'); - -const db = require('./mocks/databasemock'); const settings = require('../src/settings'); +const db = require('./mocks/databasemock'); describe('settings v3', () => { - let settings1; - let settings2; - - it('should create a new settings object', (done) => { - settings1 = new settings('my-plugin', '1.0', { foo: 1, bar: { derp: 2 } }, done); - }); - - it('should get the saved settings ', (done) => { - assert.equal(settings1.get('foo'), 1); - assert.equal(settings1.get('bar.derp'), 2); - done(); - }); - - it('should create a new settings instance for same key', (done) => { - settings2 = new settings('my-plugin', '1.0', { foo: 1, bar: { derp: 2 } }, done); - }); - - it('should pass change between settings object over pubsub', (done) => { - settings1.set('foo', 3); - settings1.persist((err) => { - assert.ifError(err); - // give pubsub time to complete - setTimeout(() => { - assert.equal(settings2.get('foo'), 3); - done(); - }, 500); - }); - }); - - it('should set a nested value', (done) => { - settings1.set('bar.derp', 5); - assert.equal(settings1.get('bar.derp'), 5); - done(); - }); - - it('should reset the settings to default', (done) => { - settings1.reset((err) => { - assert.ifError(err); - assert.equal(settings1.get('foo'), 1); - assert.equal(settings1.get('bar.derp'), 2); - done(); - }); - }); - - it('should get value from default value', (done) => { - const newSettings = new settings('some-plugin', '1.0', { default: { value: 1 } }); - assert.equal(newSettings.get('default.value'), 1); - done(); - }); + let settings1; + let settings2; + + it('should create a new settings object', done => { + settings1 = new settings('my-plugin', '1.0', {foo: 1, bar: {derp: 2}}, done); + }); + + it('should get the saved settings ', done => { + assert.equal(settings1.get('foo'), 1); + assert.equal(settings1.get('bar.derp'), 2); + done(); + }); + + it('should create a new settings instance for same key', done => { + settings2 = new settings('my-plugin', '1.0', {foo: 1, bar: {derp: 2}}, done); + }); + + it('should pass change between settings object over pubsub', done => { + settings1.set('foo', 3); + settings1.persist(error => { + assert.ifError(error); + // Give pubsub time to complete + setTimeout(() => { + assert.equal(settings2.get('foo'), 3); + done(); + }, 500); + }); + }); + + it('should set a nested value', done => { + settings1.set('bar.derp', 5); + assert.equal(settings1.get('bar.derp'), 5); + done(); + }); + + it('should reset the settings to default', done => { + settings1.reset(error => { + assert.ifError(error); + assert.equal(settings1.get('foo'), 1); + assert.equal(settings1.get('bar.derp'), 2); + done(); + }); + }); + + it('should get value from default value', done => { + const newSettings = new settings('some-plugin', '1.0', {default: {value: 1}}); + assert.equal(newSettings.get('default.value'), 1); + done(); + }); }); diff --git a/test/socket.io.js b/test/socket.io.js index 28ba2b2..17e8860 100644 --- a/test/socket.io.js +++ b/test/socket.io.js @@ -1,807 +1,810 @@ 'use strict'; -// see https://gist.github.com/jfromaniello/4087861#gistcomment-1447029 - +// See https://gist.github.com/jfromaniello/4087861#gistcomment-1447029 process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; -const util = require('util'); +const util = require('node:util'); const sleep = util.promisify(setTimeout); -const assert = require('assert'); +const assert = require('node:assert'); const async = require('async'); const nconf = require('nconf'); const request = require('request'); const cookies = request.jar(); -const db = require('./mocks/databasemock'); const user = require('../src/user'); const groups = require('../src/groups'); const categories = require('../src/categories'); -const helpers = require('./helpers'); const meta = require('../src/meta'); const events = require('../src/events'); - const socketAdmin = require('../src/socket.io/admin'); +const helpers = require('./helpers'); +const db = require('./mocks/databasemock'); describe('socket.io', () => { - let io; - let cid; - let tid; - let adminUid; - let regularUid; - - before(async () => { - const data = await Promise.all([ - user.create({ username: 'admin', password: 'adminpwd' }), - user.create({ username: 'regular', password: 'regularpwd' }), - categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - }), - ]); - adminUid = data[0]; - await groups.join('administrators', data[0]); - - regularUid = data[1]; - await user.setUserField(regularUid, 'email', 'regular@test.com'); - await user.email.confirmByUid(regularUid); - - cid = data[2].cid; - }); - - - it('should connect and auth properly', (done) => { - request.get({ - url: `${nconf.get('url')}/api/config`, - jar: cookies, - json: true, - }, (err, res, body) => { - assert.ifError(err); - - request.post(`${nconf.get('url')}/login`, { - jar: cookies, - form: { - username: 'admin', - password: 'adminpwd', - }, - headers: { - 'x-csrf-token': body.csrf_token, - }, - json: true, - }, (err, res) => { - assert.ifError(err); - - helpers.connectSocketIO(res, (err, _io) => { - io = _io; - assert.ifError(err); - - done(); - }); - }); - }); - }); - - it('should return error for unknown event', (done) => { - io.emit('unknown.event', (err) => { - assert(err); - assert.equal(err.message, '[[error:invalid-event, unknown.event]]'); - done(); - }); - }); - - it('should return error for unknown event', (done) => { - io.emit('user.gdpr.__proto__.constructor.toString', (err) => { - assert(err); - assert.equal(err.message, '[[error:invalid-event, user.gdpr.__proto__.constructor.toString]]'); - done(); - }); - }); - - it('should return error for unknown event', (done) => { - io.emit('constructor.toString', (err) => { - assert(err); - assert.equal(err.message, '[[error:invalid-event, constructor.toString]]'); - done(); - }); - }); - - it('should get installed themes', (done) => { - const themes = ['nodebb-theme-persona']; - io.emit('admin.themes.getInstalled', (err, data) => { - assert.ifError(err); - assert(data); - const installed = data.map(theme => theme.id); - themes.forEach((theme) => { - assert.notEqual(installed.indexOf(theme), -1); - }); - done(); - }); - }); - - it('should ban a user', async () => { - const apiUser = require('../src/api/users'); - await apiUser.ban({ uid: adminUid }, { uid: regularUid, reason: 'spammer' }); - const data = await user.getLatestBanInfo(regularUid); - assert(data.uid); - assert(data.timestamp); - assert(data.hasOwnProperty('banned_until')); - assert(data.hasOwnProperty('banned_until_readable')); - assert.equal(data.reason, 'spammer'); - }); - - it('should return ban reason', (done) => { - user.bans.getReason(regularUid, (err, reason) => { - assert.ifError(err); - assert.equal(reason, 'spammer'); - done(); - }); - }); - - it('should unban a user', async () => { - const apiUser = require('../src/api/users'); - await apiUser.unban({ uid: adminUid }, { uid: regularUid }); - const isBanned = await user.bans.isBanned(regularUid); - assert(!isBanned); - }); - - it('should make user admin', (done) => { - socketAdmin.user.makeAdmins({ uid: adminUid }, [regularUid], (err) => { - assert.ifError(err); - groups.isMember(regularUid, 'administrators', (err, isMember) => { - assert.ifError(err); - assert(isMember); - done(); - }); - }); - }); - - it('should make user non-admin', (done) => { - socketAdmin.user.removeAdmins({ uid: adminUid }, [regularUid], (err) => { - assert.ifError(err); - groups.isMember(regularUid, 'administrators', (err, isMember) => { - assert.ifError(err); - assert(!isMember); - done(); - }); - }); - }); - - describe('user create/delete', () => { - let uid; - const apiUsers = require('../src/api/users'); - it('should create a user', async () => { - const userData = await apiUsers.create({ uid: adminUid }, { username: 'foo1' }); - uid = userData.uid; - const isMember = await groups.isMember(userData.uid, 'registered-users'); - assert(isMember); - }); - - it('should delete users', async () => { - await apiUsers.delete({ uid: adminUid }, { uid }); - await sleep(500); - const isMember = await groups.isMember(uid, 'registered-users'); - assert(!isMember); - }); - - it('should error if user does not exist', async () => { - let err; - try { - await apiUsers.deleteMany({ uid: adminUid }, { uids: [uid] }); - } catch (_err) { - err = _err; - } - assert.strictEqual(err.message, '[[error:no-user]]'); - }); - - it('should delete users and their content', async () => { - const userData = await apiUsers.create({ uid: adminUid }, { username: 'foo2' }); - await apiUsers.deleteMany({ uid: adminUid }, { uids: [userData.uid] }); - await sleep(500); - const isMember = await groups.isMember(userData.uid, 'registered-users'); - assert(!isMember); - }); - - it('should error with invalid data', async () => { - let err; - try { - await apiUsers.create({ uid: adminUid }, null); - } catch (_err) { - err = _err; - } - assert.strictEqual(err.message, '[[error:invalid-data]]'); - }); - }); - - it('should load user groups', async () => { - const { users } = await socketAdmin.user.loadGroups({ uid: adminUid }, [adminUid]); - assert.strictEqual(users[0].username, 'admin'); - assert(Array.isArray(users[0].groups)); - }); - - it('should reset lockouts', (done) => { - socketAdmin.user.resetLockouts({ uid: adminUid }, [regularUid], (err) => { - assert.ifError(err); - done(); - }); - }); - - describe('validation emails', () => { - const plugins = require('../src/plugins'); - - async function dummyEmailerHook(data) { - // pretend to handle sending emails - } - before(() => { - // Attach an emailer hook so related requests do not error - plugins.hooks.register('emailer-test', { - hook: 'filter:email.send', - method: dummyEmailerHook, - }); - }); - after(() => { - plugins.hooks.unregister('emailer-test', 'filter:email.send'); - }); - - it('should validate emails', (done) => { - socketAdmin.user.validateEmail({ uid: adminUid }, [regularUid], (err) => { - assert.ifError(err); - user.getUserField(regularUid, 'email:confirmed', (err, emailConfirmed) => { - assert.ifError(err); - assert.equal(parseInt(emailConfirmed, 10), 1); - done(); - }); - }); - }); - - it('should error with invalid uids', (done) => { - socketAdmin.user.sendValidationEmail({ uid: adminUid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should send validation email', (done) => { - socketAdmin.user.sendValidationEmail({ uid: adminUid }, [regularUid], (err) => { - assert.ifError(err); - done(); - }); - }); - }); - - it('should push unread notifications on reconnect', (done) => { - const socketMeta = require('../src/socket.io/meta'); - socketMeta.reconnected({ uid: 1 }, {}, (err) => { - assert.ifError(err); - done(); - }); - }); - - - it('should error if the room is missing', (done) => { - io.emit('meta.rooms.enter', null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should return if uid is 0', (done) => { - const socketMeta = require('../src/socket.io/meta'); - socketMeta.rooms.enter({ uid: 0 }, null, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should join a room', (done) => { - io.emit('meta.rooms.enter', { enter: 'recent_topics' }, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should leave current room', (done) => { - io.emit('meta.rooms.leaveCurrent', {}, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should get server time', (done) => { - io.emit('admin.getServerTime', null, (err, time) => { - assert.ifError(err); - assert(time); - done(); - }); - }); - - it('should error to get daily analytics with invalid data', (done) => { - io.emit('admin.analytics.get', null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should get daily analytics', (done) => { - io.emit('admin.analytics.get', { graph: 'traffic', units: 'days' }, (err, data) => { - assert.ifError(err); - assert(data); - assert(data.summary); - done(); - }); - }); - - it('should get hourly analytics', (done) => { - io.emit('admin.analytics.get', { graph: 'traffic', units: 'hours' }, (err, data) => { - assert.ifError(err); - assert(data); - assert(data.summary); - done(); - }); - }); - - it('should allow a custom date range for traffic graph analytics', (done) => { - io.emit('admin.analytics.get', { graph: 'traffic', units: 'days', amount: '7' }, (err, data) => { - assert.ifError(err); - assert(data); - assert(data.pageviews); - assert(data.uniqueVisitors); - assert.strictEqual(7, data.pageviews.length); - assert.strictEqual(7, data.uniqueVisitors.length); - done(); - }); - }); - - it('should return error', (done) => { - socketAdmin.before({ uid: 10 }, 'someMethod', {}, (err) => { - assert.equal(err.message, '[[error:no-privileges]]'); - done(); - }); - }); - - it('should get room stats', (done) => { - io.emit('meta.rooms.enter', { enter: 'topic_1' }, (err) => { - assert.ifError(err); - socketAdmin.rooms.getAll({ uid: 10 }, {}, (err) => { - assert.ifError(err); - setTimeout(() => { - socketAdmin.rooms.getAll({ uid: 10 }, {}, (err, data) => { - assert.ifError(err); - assert(data.hasOwnProperty('onlineGuestCount')); - assert(data.hasOwnProperty('onlineRegisteredCount')); - assert(data.hasOwnProperty('socketCount')); - assert(data.hasOwnProperty('topics')); - assert(data.hasOwnProperty('users')); - done(); - }); - }, 1000); - }); - }); - }); - - it('should get room stats', (done) => { - io.emit('meta.rooms.enter', { enter: 'category_1' }, (err) => { - assert.ifError(err); - socketAdmin.rooms.getAll({ uid: 10 }, {}, (err) => { - assert.ifError(err); - setTimeout(() => { - socketAdmin.rooms.getAll({ uid: 10 }, {}, (err, data) => { - assert.ifError(err); - assert.equal(data.users.category, 1, JSON.stringify(data, null, 4)); - done(); - }); - }, 1000); - }); - }); - }); - - it('should get admin search dictionary', (done) => { - socketAdmin.getSearchDict({ uid: adminUid }, {}, (err, data) => { - assert.ifError(err); - assert(Array.isArray(data)); - assert(data[0].namespace); - assert(data[0].translations); - assert(data[0].title); - done(); - }); - }); - - it('should fire event', (done) => { - io.on('testEvent', (data) => { - assert.equal(data.foo, 1); - done(); - }); - socketAdmin.fireEvent({ uid: adminUid }, { name: 'testEvent', payload: { foo: 1 } }, (err) => { - assert.ifError(err); - }); - }); - - it('should error with invalid data', (done) => { - socketAdmin.themes.set({ uid: adminUid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should set theme to bootswatch', (done) => { - socketAdmin.themes.set({ uid: adminUid }, { - type: 'bootswatch', - src: '//maxcdn.bootstrapcdn.com/bootswatch/latest/darkly/bootstrap.min.css', - id: 'darkly', - }, (err) => { - assert.ifError(err); - meta.configs.getFields(['theme:src', 'bootswatchSkin'], (err, fields) => { - assert.ifError(err); - assert.equal(fields['theme:src'], '//maxcdn.bootstrapcdn.com/bootswatch/latest/darkly/bootstrap.min.css'); - assert.equal(fields.bootswatchSkin, 'darkly'); - done(); - }); - }); - }); - - it('should set theme to local persona', (done) => { - socketAdmin.themes.set({ uid: adminUid }, { type: 'local', id: 'nodebb-theme-persona' }, (err) => { - assert.ifError(err); - meta.configs.get('theme:id', (err, id) => { - assert.ifError(err); - assert.equal(id, 'nodebb-theme-persona'); - done(); - }); - }); - }); - - it('should toggle plugin active', (done) => { - socketAdmin.plugins.toggleActive({ uid: adminUid }, 'nodebb-plugin-location-to-map', (err, data) => { - assert.ifError(err); - assert.deepEqual(data, { id: 'nodebb-plugin-location-to-map', active: true }); - done(); - }); - }); - - it('should toggle plugin install', function (done) { - this.timeout(0); - const oldValue = process.env.NODE_ENV; - process.env.NODE_ENV = 'development'; - socketAdmin.plugins.toggleInstall({ - uid: adminUid, - }, { - id: 'nodebb-plugin-location-to-map', - version: 'latest', - }, (err, data) => { - assert.ifError(err); - assert.equal(data.name, 'nodebb-plugin-location-to-map'); - process.env.NODE_ENV = oldValue; - done(); - }); - }); - - it('should get list of active plugins', (done) => { - socketAdmin.plugins.getActive({ uid: adminUid }, {}, (err, data) => { - assert.ifError(err); - assert(Array.isArray(data)); - done(); - }); - }); - - it('should order active plugins', (done) => { - const data = [ - { name: 'nodebb-theme-persona', order: 0 }, - { name: 'nodebb-plugin-dbsearch', order: 1 }, - { name: 'nodebb-plugin-markdown', order: 2 }, - { ignoreme: 'wrong data' }, - ]; - socketAdmin.plugins.orderActivePlugins({ uid: adminUid }, data, (err) => { - assert.ifError(err); - db.sortedSetRank('plugins:active', 'nodebb-plugin-dbsearch', (err, rank) => { - assert.ifError(err); - assert.equal(rank, 1); - done(); - }); - }); - }); - - it('should upgrade plugin', function (done) { - this.timeout(0); - const oldValue = process.env.NODE_ENV; - process.env.NODE_ENV = 'development'; - socketAdmin.plugins.upgrade({ - uid: adminUid, - }, { - id: 'nodebb-plugin-location-to-map', - version: 'latest', - }, (err) => { - assert.ifError(err); - process.env.NODE_ENV = oldValue; - done(); - }); - }); - - it('should error with invalid data', (done) => { - socketAdmin.widgets.set({ uid: adminUid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should error with invalid data', (done) => { - const data = [ - { - template: 'global', - location: 'sidebar', - widgets: [{ widget: 'html', data: { html: 'test', title: 'test', container: '' } }], - }, - ]; - socketAdmin.widgets.set({ uid: adminUid }, data, (err) => { - assert.ifError(err); - db.getObjectField('widgets:global', 'sidebar', (err, widgetData) => { - assert.ifError(err); - - assert.equal(JSON.parse(widgetData)[0].data.html, 'test'); - done(); - }); - }); - }); - - it('should clear sitemap cache', async () => { - await socketAdmin.settings.clearSitemapCache({ uid: adminUid }, {}); - }); - - it('should send test email', async () => { - const tpls = ['digest', 'banned', 'verify', 'welcome', 'notification', 'invitation']; - try { - for (const tpl of tpls) { - // eslint-disable-next-line no-await-in-loop - await socketAdmin.email.test({ uid: adminUid }, { template: tpl }); - } - } catch (err) { - if (err.message !== '[[error:sendmail-not-found]]') { - assert.ifError(err); - } - } - }); - - it('should not error when resending digests', async () => { - await socketAdmin.digest.resend({ uid: adminUid }, { action: 'resend-day', uid: adminUid }); - await socketAdmin.digest.resend({ uid: adminUid }, { action: 'resend-day' }); - }); - - it('should error with invalid interval', async () => { - const oldValue = meta.config.dailyDigestFreq; - meta.config.dailyDigestFreq = 'off'; - try { - await socketAdmin.digest.resend({ uid: adminUid }, { action: 'resend-' }); - } catch (err) { - assert.strictEqual(err.message, '[[error:digest-not-enabled]]'); - } - meta.config.dailyDigestFreq = oldValue; - }); - - it('should get logs', (done) => { - const fs = require('fs'); - const path = require('path'); - meta.logs.path = path.join(nconf.get('base_dir'), 'test/files', 'output.log'); - fs.appendFile(meta.logs.path, 'some logs', (err) => { - assert.ifError(err); - - socketAdmin.logs.get({ uid: adminUid }, {}, (err, data) => { - assert.ifError(err); - assert(data); - done(); - }); - }); - }); - - it('should clear logs', (done) => { - socketAdmin.logs.clear({ uid: adminUid }, {}, (err) => { - assert.ifError(err); - socketAdmin.logs.get({ uid: adminUid }, {}, (err, data) => { - assert.ifError(err); - assert.equal(data.length, 0); - done(); - }); - }); - }); - - it('should clear errors', (done) => { - socketAdmin.errors.clear({ uid: adminUid }, {}, (err) => { - assert.ifError(err); - db.exists('error:404', (err, exists) => { - assert.ifError(err); - assert(!exists); - done(); - }); - }); - }); - - it('should delete a single event', (done) => { - db.getSortedSetRevRange('events:time', 0, 0, (err, eids) => { - assert.ifError(err); - events.deleteEvents(eids, (err) => { - assert.ifError(err); - db.isSortedSetMembers('events:time', eids, (err, isMembers) => { - assert.ifError(err); - assert(!isMembers.includes(true)); - done(); - }); - }); - }); - }); - - it('should delete all events', (done) => { - events.deleteAll((err) => { - assert.ifError(err); - db.sortedSetCard('events:time', (err, count) => { - assert.ifError(err); - assert.equal(count, 0); - done(); - }); - }); - }); - - describe('logger', () => { - const logger = require('../src/logger'); - const index = require('../src/socket.io'); - const fs = require('fs'); - const path = require('path'); - - it('should enable logging', (done) => { - meta.config.loggerStatus = 1; - meta.config.loggerIOStatus = 1; - const loggerPath = path.join(__dirname, '..', 'logs', 'logger.log'); - logger.monitorConfig({ io: index.server }, { key: 'loggerPath', value: loggerPath }); - setTimeout(() => { - io.emit('meta.rooms.enter', { enter: 'recent_topics' }, (err) => { - assert.ifError(err); - fs.readFile(loggerPath, 'utf-8', (err, content) => { - assert.ifError(err); - assert(content); - done(); - }); - }); - }, 500); - }); - - after((done) => { - meta.config.loggerStatus = 0; - meta.config.loggerIOStatus = 0; - done(); - }); - }); - - describe('password reset', () => { - const socketUser = require('../src/socket.io/user'); - - it('should error if uids is not array', (done) => { - socketAdmin.user.sendPasswordResetEmail({ uid: adminUid }, null, (err) => { - assert.strictEqual(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should error if uid doesnt have email', (done) => { - socketAdmin.user.sendPasswordResetEmail({ uid: adminUid }, [adminUid], (err) => { - assert.strictEqual(err.message, '[[error:user-doesnt-have-email, admin]]'); - done(); - }); - }); - - it('should send password reset email', async () => { - await user.setUserField(adminUid, 'email', 'admin_test@nodebb.org'); - await user.email.confirmByUid(adminUid); - await socketAdmin.user.sendPasswordResetEmail({ uid: adminUid }, [adminUid]); - }); - - it('should error if uids is not array', (done) => { - socketAdmin.user.forcePasswordReset({ uid: adminUid }, null, (err) => { - assert.strictEqual(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should for password reset', async () => { - const then = Date.now(); - const uid = await user.create({ username: 'forceme', password: '123345' }); - await socketAdmin.user.forcePasswordReset({ uid: adminUid }, [uid]); - const pwExpiry = await user.getUserField(uid, 'passwordExpiry'); - const sleep = util.promisify(setTimeout); - await sleep(500); - assert(pwExpiry > then && pwExpiry < Date.now()); - }); - - it('should not error on valid email', (done) => { - socketUser.reset.send({ uid: 0 }, 'regular@test.com', (err) => { - assert.ifError(err); - - async.parallel({ - count: async.apply(db.sortedSetCount.bind(db), 'reset:issueDate', 0, Date.now()), - event: async.apply(events.getEvents, '', 0, 0), - }, (err, data) => { - assert.ifError(err); - assert.strictEqual(data.count, 2); - - // Event validity - assert.strictEqual(data.event.length, 1); - const event = data.event[0]; - assert.strictEqual(event.type, 'password-reset'); - assert.strictEqual(event.text, '[[success:success]]'); - - done(); - }); - }); - }); - - it('should not generate code if rate limited', (done) => { - socketUser.reset.send({ uid: 0 }, 'regular@test.com', (err) => { - assert.ifError(err); - - async.parallel({ - count: async.apply(db.sortedSetCount.bind(db), 'reset:issueDate', 0, Date.now()), - event: async.apply(events.getEvents, '', 0, 0), - }, (err, data) => { - assert.ifError(err); - assert.strictEqual(data.count, 2); - - // Event validity - assert.strictEqual(data.event.length, 1); - const event = data.event[0]; - assert.strictEqual(event.type, 'password-reset'); - assert.strictEqual(event.text, '[[error:reset-rate-limited]]'); - - done(); - }); - }); - }); - - it('should not error on invalid email (but not generate reset code)', (done) => { - socketUser.reset.send({ uid: 0 }, 'irregular@test.com', (err) => { - assert.ifError(err); - - db.sortedSetCount('reset:issueDate', 0, Date.now(), (err, count) => { - assert.ifError(err); - assert.strictEqual(count, 2); - done(); - }); - }); - }); - - it('should error on no email', (done) => { - socketUser.reset.send({ uid: 0 }, '', (err) => { - assert(err instanceof Error); - assert.strictEqual(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - }); - - it('should clear caches', async () => { - await socketAdmin.cache.clear({ uid: adminUid }, { name: 'post' }); - await socketAdmin.cache.clear({ uid: adminUid }, { name: 'object' }); - await socketAdmin.cache.clear({ uid: adminUid }, { name: 'group' }); - await socketAdmin.cache.clear({ uid: adminUid }, { name: 'local' }); - }); - - it('should toggle caches', async () => { - const caches = { - post: require('../src/posts/cache'), - object: require('../src/database').objectCache, - group: require('../src/groups').cache, - local: require('../src/cache'), - }; - - await socketAdmin.cache.toggle({ uid: adminUid }, { name: 'post', enabled: !caches.post.enabled }); - if (caches.object) { - await socketAdmin.cache.toggle({ uid: adminUid }, { name: 'object', enabled: !caches.object.enabled }); - } - await socketAdmin.cache.toggle({ uid: adminUid }, { name: 'group', enabled: !caches.group.enabled }); - await socketAdmin.cache.toggle({ uid: adminUid }, { name: 'local', enabled: !caches.local.enabled }); - - // call again to return back to original state - await socketAdmin.cache.toggle({ uid: adminUid }, { name: 'post', enabled: !caches.post.enabled }); - if (caches.object) { - await socketAdmin.cache.toggle({ uid: adminUid }, { name: 'object', enabled: !caches.object.enabled }); - } - await socketAdmin.cache.toggle({ uid: adminUid }, { name: 'group', enabled: !caches.group.enabled }); - await socketAdmin.cache.toggle({ uid: adminUid }, { name: 'local', enabled: !caches.local.enabled }); - }); + let io; + let cid; + let tid; + let adminUid; + let regularUid; + + before(async () => { + const data = await Promise.all([ + user.create({username: 'admin', password: 'adminpwd'}), + user.create({username: 'regular', password: 'regularpwd'}), + categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + }), + ]); + adminUid = data[0]; + await groups.join('administrators', data[0]); + + regularUid = data[1]; + await user.setUserField(regularUid, 'email', 'regular@test.com'); + await user.email.confirmByUid(regularUid); + + cid = data[2].cid; + }); + + it('should connect and auth properly', done => { + request.get({ + url: `${nconf.get('url')}/api/config`, + jar: cookies, + json: true, + }, (error, res, body) => { + assert.ifError(error); + + request.post(`${nconf.get('url')}/login`, { + jar: cookies, + form: { + username: 'admin', + password: 'adminpwd', + }, + headers: { + 'x-csrf-token': body.csrf_token, + }, + json: true, + }, (error, res) => { + assert.ifError(error); + + helpers.connectSocketIO(res, (error, _io) => { + io = _io; + assert.ifError(error); + + done(); + }); + }); + }); + }); + + it('should return error for unknown event', done => { + io.emit('unknown.event', error => { + assert(error); + assert.equal(error.message, '[[error:invalid-event, unknown.event]]'); + done(); + }); + }); + + it('should return error for unknown event', done => { + io.emit('user.gdpr.__proto__.constructor.toString', error => { + assert(error); + assert.equal(error.message, '[[error:invalid-event, user.gdpr.__proto__.constructor.toString]]'); + done(); + }); + }); + + it('should return error for unknown event', done => { + io.emit('constructor.toString', error => { + assert(error); + assert.equal(error.message, '[[error:invalid-event, constructor.toString]]'); + done(); + }); + }); + + it('should get installed themes', done => { + const themes = ['nodebb-theme-persona']; + io.emit('admin.themes.getInstalled', (error, data) => { + assert.ifError(error); + assert(data); + const installed = data.map(theme => theme.id); + for (const theme of themes) { + assert.notEqual(installed.indexOf(theme), -1); + } + + done(); + }); + }); + + it('should ban a user', async () => { + const apiUser = require('../src/api/users'); + await apiUser.ban({uid: adminUid}, {uid: regularUid, reason: 'spammer'}); + const data = await user.getLatestBanInfo(regularUid); + assert(data.uid); + assert(data.timestamp); + assert(data.hasOwnProperty('banned_until')); + assert(data.hasOwnProperty('banned_until_readable')); + assert.equal(data.reason, 'spammer'); + }); + + it('should return ban reason', done => { + user.bans.getReason(regularUid, (error, reason) => { + assert.ifError(error); + assert.equal(reason, 'spammer'); + done(); + }); + }); + + it('should unban a user', async () => { + const apiUser = require('../src/api/users'); + await apiUser.unban({uid: adminUid}, {uid: regularUid}); + const isBanned = await user.bans.isBanned(regularUid); + assert(!isBanned); + }); + + it('should make user admin', done => { + socketAdmin.user.makeAdmins({uid: adminUid}, [regularUid], error => { + assert.ifError(error); + groups.isMember(regularUid, 'administrators', (error, isMember) => { + assert.ifError(error); + assert(isMember); + done(); + }); + }); + }); + + it('should make user non-admin', done => { + socketAdmin.user.removeAdmins({uid: adminUid}, [regularUid], error => { + assert.ifError(error); + groups.isMember(regularUid, 'administrators', (error, isMember) => { + assert.ifError(error); + assert(!isMember); + done(); + }); + }); + }); + + describe('user create/delete', () => { + let uid; + const apiUsers = require('../src/api/users'); + it('should create a user', async () => { + const userData = await apiUsers.create({uid: adminUid}, {username: 'foo1'}); + uid = userData.uid; + const isMember = await groups.isMember(userData.uid, 'registered-users'); + assert(isMember); + }); + + it('should delete users', async () => { + await apiUsers.delete({uid: adminUid}, {uid}); + await sleep(500); + const isMember = await groups.isMember(uid, 'registered-users'); + assert(!isMember); + }); + + it('should error if user does not exist', async () => { + let error; + try { + await apiUsers.deleteMany({uid: adminUid}, {uids: [uid]}); + } catch (error_) { + error = error_; + } + + assert.strictEqual(error.message, '[[error:no-user]]'); + }); + + it('should delete users and their content', async () => { + const userData = await apiUsers.create({uid: adminUid}, {username: 'foo2'}); + await apiUsers.deleteMany({uid: adminUid}, {uids: [userData.uid]}); + await sleep(500); + const isMember = await groups.isMember(userData.uid, 'registered-users'); + assert(!isMember); + }); + + it('should error with invalid data', async () => { + let error; + try { + await apiUsers.create({uid: adminUid}, null); + } catch (error_) { + error = error_; + } + + assert.strictEqual(error.message, '[[error:invalid-data]]'); + }); + }); + + it('should load user groups', async () => { + const {users} = await socketAdmin.user.loadGroups({uid: adminUid}, [adminUid]); + assert.strictEqual(users[0].username, 'admin'); + assert(Array.isArray(users[0].groups)); + }); + + it('should reset lockouts', done => { + socketAdmin.user.resetLockouts({uid: adminUid}, [regularUid], error => { + assert.ifError(error); + done(); + }); + }); + + describe('validation emails', () => { + const plugins = require('../src/plugins'); + + async function dummyEmailerHook(data) { + // Pretend to handle sending emails + } + + before(() => { + // Attach an emailer hook so related requests do not error + plugins.hooks.register('emailer-test', { + hook: 'filter:email.send', + method: dummyEmailerHook, + }); + }); + after(() => { + plugins.hooks.unregister('emailer-test', 'filter:email.send'); + }); + + it('should validate emails', done => { + socketAdmin.user.validateEmail({uid: adminUid}, [regularUid], error => { + assert.ifError(error); + user.getUserField(regularUid, 'email:confirmed', (error, emailConfirmed) => { + assert.ifError(error); + assert.equal(Number.parseInt(emailConfirmed, 10), 1); + done(); + }); + }); + }); + + it('should error with invalid uids', done => { + socketAdmin.user.sendValidationEmail({uid: adminUid}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should send validation email', done => { + socketAdmin.user.sendValidationEmail({uid: adminUid}, [regularUid], error => { + assert.ifError(error); + done(); + }); + }); + }); + + it('should push unread notifications on reconnect', done => { + const socketMeta = require('../src/socket.io/meta'); + socketMeta.reconnected({uid: 1}, {}, error => { + assert.ifError(error); + done(); + }); + }); + + it('should error if the room is missing', done => { + io.emit('meta.rooms.enter', null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should return if uid is 0', done => { + const socketMeta = require('../src/socket.io/meta'); + socketMeta.rooms.enter({uid: 0}, null, error => { + assert.ifError(error); + done(); + }); + }); + + it('should join a room', done => { + io.emit('meta.rooms.enter', {enter: 'recent_topics'}, error => { + assert.ifError(error); + done(); + }); + }); + + it('should leave current room', done => { + io.emit('meta.rooms.leaveCurrent', {}, error => { + assert.ifError(error); + done(); + }); + }); + + it('should get server time', done => { + io.emit('admin.getServerTime', null, (error, time) => { + assert.ifError(error); + assert(time); + done(); + }); + }); + + it('should error to get daily analytics with invalid data', done => { + io.emit('admin.analytics.get', null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should get daily analytics', done => { + io.emit('admin.analytics.get', {graph: 'traffic', units: 'days'}, (error, data) => { + assert.ifError(error); + assert(data); + assert(data.summary); + done(); + }); + }); + + it('should get hourly analytics', done => { + io.emit('admin.analytics.get', {graph: 'traffic', units: 'hours'}, (error, data) => { + assert.ifError(error); + assert(data); + assert(data.summary); + done(); + }); + }); + + it('should allow a custom date range for traffic graph analytics', done => { + io.emit('admin.analytics.get', {graph: 'traffic', units: 'days', amount: '7'}, (error, data) => { + assert.ifError(error); + assert(data); + assert(data.pageviews); + assert(data.uniqueVisitors); + assert.strictEqual(7, data.pageviews.length); + assert.strictEqual(7, data.uniqueVisitors.length); + done(); + }); + }); + + it('should return error', done => { + socketAdmin.before({uid: 10}, 'someMethod', {}, error => { + assert.equal(error.message, '[[error:no-privileges]]'); + done(); + }); + }); + + it('should get room stats', done => { + io.emit('meta.rooms.enter', {enter: 'topic_1'}, error => { + assert.ifError(error); + socketAdmin.rooms.getAll({uid: 10}, {}, error => { + assert.ifError(error); + setTimeout(() => { + socketAdmin.rooms.getAll({uid: 10}, {}, (error, data) => { + assert.ifError(error); + assert(data.hasOwnProperty('onlineGuestCount')); + assert(data.hasOwnProperty('onlineRegisteredCount')); + assert(data.hasOwnProperty('socketCount')); + assert(data.hasOwnProperty('topics')); + assert(data.hasOwnProperty('users')); + done(); + }); + }, 1000); + }); + }); + }); + + it('should get room stats', done => { + io.emit('meta.rooms.enter', {enter: 'category_1'}, error => { + assert.ifError(error); + socketAdmin.rooms.getAll({uid: 10}, {}, error => { + assert.ifError(error); + setTimeout(() => { + socketAdmin.rooms.getAll({uid: 10}, {}, (error, data) => { + assert.ifError(error); + assert.equal(data.users.category, 1, JSON.stringify(data, null, 4)); + done(); + }); + }, 1000); + }); + }); + }); + + it('should get admin search dictionary', done => { + socketAdmin.getSearchDict({uid: adminUid}, {}, (error, data) => { + assert.ifError(error); + assert(Array.isArray(data)); + assert(data[0].namespace); + assert(data[0].translations); + assert(data[0].title); + done(); + }); + }); + + it('should fire event', done => { + io.on('testEvent', data => { + assert.equal(data.foo, 1); + done(); + }); + socketAdmin.fireEvent({uid: adminUid}, {name: 'testEvent', payload: {foo: 1}}, error => { + assert.ifError(error); + }); + }); + + it('should error with invalid data', done => { + socketAdmin.themes.set({uid: adminUid}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should set theme to bootswatch', done => { + socketAdmin.themes.set({uid: adminUid}, { + type: 'bootswatch', + src: '//maxcdn.bootstrapcdn.com/bootswatch/latest/darkly/bootstrap.min.css', + id: 'darkly', + }, error => { + assert.ifError(error); + meta.configs.getFields(['theme:src', 'bootswatchSkin'], (error, fields) => { + assert.ifError(error); + assert.equal(fields['theme:src'], '//maxcdn.bootstrapcdn.com/bootswatch/latest/darkly/bootstrap.min.css'); + assert.equal(fields.bootswatchSkin, 'darkly'); + done(); + }); + }); + }); + + it('should set theme to local persona', done => { + socketAdmin.themes.set({uid: adminUid}, {type: 'local', id: 'nodebb-theme-persona'}, error => { + assert.ifError(error); + meta.configs.get('theme:id', (error, id) => { + assert.ifError(error); + assert.equal(id, 'nodebb-theme-persona'); + done(); + }); + }); + }); + + it('should toggle plugin active', done => { + socketAdmin.plugins.toggleActive({uid: adminUid}, 'nodebb-plugin-location-to-map', (error, data) => { + assert.ifError(error); + assert.deepEqual(data, {id: 'nodebb-plugin-location-to-map', active: true}); + done(); + }); + }); + + it('should toggle plugin install', function (done) { + this.timeout(0); + const oldValue = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + socketAdmin.plugins.toggleInstall({ + uid: adminUid, + }, { + id: 'nodebb-plugin-location-to-map', + version: 'latest', + }, (error, data) => { + assert.ifError(error); + assert.equal(data.name, 'nodebb-plugin-location-to-map'); + process.env.NODE_ENV = oldValue; + done(); + }); + }); + + it('should get list of active plugins', done => { + socketAdmin.plugins.getActive({uid: adminUid}, {}, (error, data) => { + assert.ifError(error); + assert(Array.isArray(data)); + done(); + }); + }); + + it('should order active plugins', done => { + const data = [ + {name: 'nodebb-theme-persona', order: 0}, + {name: 'nodebb-plugin-dbsearch', order: 1}, + {name: 'nodebb-plugin-markdown', order: 2}, + {ignoreme: 'wrong data'}, + ]; + socketAdmin.plugins.orderActivePlugins({uid: adminUid}, data, error => { + assert.ifError(error); + db.sortedSetRank('plugins:active', 'nodebb-plugin-dbsearch', (error, rank) => { + assert.ifError(error); + assert.equal(rank, 1); + done(); + }); + }); + }); + + it('should upgrade plugin', function (done) { + this.timeout(0); + const oldValue = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + socketAdmin.plugins.upgrade({ + uid: adminUid, + }, { + id: 'nodebb-plugin-location-to-map', + version: 'latest', + }, error => { + assert.ifError(error); + process.env.NODE_ENV = oldValue; + done(); + }); + }); + + it('should error with invalid data', done => { + socketAdmin.widgets.set({uid: adminUid}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should error with invalid data', done => { + const data = [ + { + template: 'global', + location: 'sidebar', + widgets: [{widget: 'html', data: {html: 'test', title: 'test', container: ''}}], + }, + ]; + socketAdmin.widgets.set({uid: adminUid}, data, error => { + assert.ifError(error); + db.getObjectField('widgets:global', 'sidebar', (error, widgetData) => { + assert.ifError(error); + + assert.equal(JSON.parse(widgetData)[0].data.html, 'test'); + done(); + }); + }); + }); + + it('should clear sitemap cache', async () => { + await socketAdmin.settings.clearSitemapCache({uid: adminUid}, {}); + }); + + it('should send test email', async () => { + const tpls = ['digest', 'banned', 'verify', 'welcome', 'notification', 'invitation']; + try { + for (const tpl of tpls) { + // eslint-disable-next-line no-await-in-loop + await socketAdmin.email.test({uid: adminUid}, {template: tpl}); + } + } catch (error) { + if (error.message !== '[[error:sendmail-not-found]]') { + assert.ifError(error); + } + } + }); + + it('should not error when resending digests', async () => { + await socketAdmin.digest.resend({uid: adminUid}, {action: 'resend-day', uid: adminUid}); + await socketAdmin.digest.resend({uid: adminUid}, {action: 'resend-day'}); + }); + + it('should error with invalid interval', async () => { + const oldValue = meta.config.dailyDigestFreq; + meta.config.dailyDigestFreq = 'off'; + try { + await socketAdmin.digest.resend({uid: adminUid}, {action: 'resend-'}); + } catch (error) { + assert.strictEqual(error.message, '[[error:digest-not-enabled]]'); + } + + meta.config.dailyDigestFreq = oldValue; + }); + + it('should get logs', done => { + const fs = require('node:fs'); + const path = require('node:path'); + meta.logs.path = path.join(nconf.get('base_dir'), 'test/files', 'output.log'); + fs.appendFile(meta.logs.path, 'some logs', error => { + assert.ifError(error); + + socketAdmin.logs.get({uid: adminUid}, {}, (error, data) => { + assert.ifError(error); + assert(data); + done(); + }); + }); + }); + + it('should clear logs', done => { + socketAdmin.logs.clear({uid: adminUid}, {}, error => { + assert.ifError(error); + socketAdmin.logs.get({uid: adminUid}, {}, (error, data) => { + assert.ifError(error); + assert.equal(data.length, 0); + done(); + }); + }); + }); + + it('should clear errors', done => { + socketAdmin.errors.clear({uid: adminUid}, {}, error => { + assert.ifError(error); + db.exists('error:404', (error, exists) => { + assert.ifError(error); + assert(!exists); + done(); + }); + }); + }); + + it('should delete a single event', done => { + db.getSortedSetRevRange('events:time', 0, 0, (error, eids) => { + assert.ifError(error); + events.deleteEvents(eids, error_ => { + assert.ifError(error_); + db.isSortedSetMembers('events:time', eids, (error, isMembers) => { + assert.ifError(error); + assert(!isMembers.includes(true)); + done(); + }); + }); + }); + }); + + it('should delete all events', done => { + events.deleteAll(error => { + assert.ifError(error); + db.sortedSetCard('events:time', (error, count) => { + assert.ifError(error); + assert.equal(count, 0); + done(); + }); + }); + }); + + describe('logger', () => { + const logger = require('../src/logger'); + const index = require('../src/socket.io'); + const fs = require('node:fs'); + const path = require('node:path'); + + it('should enable logging', done => { + meta.config.loggerStatus = 1; + meta.config.loggerIOStatus = 1; + const loggerPath = path.join(__dirname, '..', 'logs', 'logger.log'); + logger.monitorConfig({io: index.server}, {key: 'loggerPath', value: loggerPath}); + setTimeout(() => { + io.emit('meta.rooms.enter', {enter: 'recent_topics'}, error => { + assert.ifError(error); + fs.readFile(loggerPath, 'utf8', (error, content) => { + assert.ifError(error); + assert(content); + done(); + }); + }); + }, 500); + }); + + after(done => { + meta.config.loggerStatus = 0; + meta.config.loggerIOStatus = 0; + done(); + }); + }); + + describe('password reset', () => { + const socketUser = require('../src/socket.io/user'); + + it('should error if uids is not array', done => { + socketAdmin.user.sendPasswordResetEmail({uid: adminUid}, null, error => { + assert.strictEqual(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should error if uid doesnt have email', done => { + socketAdmin.user.sendPasswordResetEmail({uid: adminUid}, [adminUid], error => { + assert.strictEqual(error.message, '[[error:user-doesnt-have-email, admin]]'); + done(); + }); + }); + + it('should send password reset email', async () => { + await user.setUserField(adminUid, 'email', 'admin_test@nodebb.org'); + await user.email.confirmByUid(adminUid); + await socketAdmin.user.sendPasswordResetEmail({uid: adminUid}, [adminUid]); + }); + + it('should error if uids is not array', done => { + socketAdmin.user.forcePasswordReset({uid: adminUid}, null, error => { + assert.strictEqual(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should for password reset', async () => { + const then = Date.now(); + const uid = await user.create({username: 'forceme', password: '123345'}); + await socketAdmin.user.forcePasswordReset({uid: adminUid}, [uid]); + const pwExpiry = await user.getUserField(uid, 'passwordExpiry'); + const sleep = util.promisify(setTimeout); + await sleep(500); + assert(pwExpiry > then && pwExpiry < Date.now()); + }); + + it('should not error on valid email', done => { + socketUser.reset.send({uid: 0}, 'regular@test.com', error => { + assert.ifError(error); + + async.parallel({ + count: async.apply(db.sortedSetCount.bind(db), 'reset:issueDate', 0, Date.now()), + event: async.apply(events.getEvents, '', 0, 0), + }, (error, data) => { + assert.ifError(error); + assert.strictEqual(data.count, 2); + + // Event validity + assert.strictEqual(data.event.length, 1); + const event = data.event[0]; + assert.strictEqual(event.type, 'password-reset'); + assert.strictEqual(event.text, '[[success:success]]'); + + done(); + }); + }); + }); + + it('should not generate code if rate limited', done => { + socketUser.reset.send({uid: 0}, 'regular@test.com', error => { + assert.ifError(error); + + async.parallel({ + count: async.apply(db.sortedSetCount.bind(db), 'reset:issueDate', 0, Date.now()), + event: async.apply(events.getEvents, '', 0, 0), + }, (error, data) => { + assert.ifError(error); + assert.strictEqual(data.count, 2); + + // Event validity + assert.strictEqual(data.event.length, 1); + const event = data.event[0]; + assert.strictEqual(event.type, 'password-reset'); + assert.strictEqual(event.text, '[[error:reset-rate-limited]]'); + + done(); + }); + }); + }); + + it('should not error on invalid email (but not generate reset code)', done => { + socketUser.reset.send({uid: 0}, 'irregular@test.com', error => { + assert.ifError(error); + + db.sortedSetCount('reset:issueDate', 0, Date.now(), (error, count) => { + assert.ifError(error); + assert.strictEqual(count, 2); + done(); + }); + }); + }); + + it('should error on no email', done => { + socketUser.reset.send({uid: 0}, '', error => { + assert(error instanceof Error); + assert.strictEqual(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + }); + + it('should clear caches', async () => { + await socketAdmin.cache.clear({uid: adminUid}, {name: 'post'}); + await socketAdmin.cache.clear({uid: adminUid}, {name: 'object'}); + await socketAdmin.cache.clear({uid: adminUid}, {name: 'group'}); + await socketAdmin.cache.clear({uid: adminUid}, {name: 'local'}); + }); + + it('should toggle caches', async () => { + const caches = { + post: require('../src/posts/cache'), + object: require('../src/database').objectCache, + group: require('../src/groups').cache, + local: require('../src/cache'), + }; + + await socketAdmin.cache.toggle({uid: adminUid}, {name: 'post', enabled: !caches.post.enabled}); + if (caches.object) { + await socketAdmin.cache.toggle({uid: adminUid}, {name: 'object', enabled: !caches.object.enabled}); + } + + await socketAdmin.cache.toggle({uid: adminUid}, {name: 'group', enabled: !caches.group.enabled}); + await socketAdmin.cache.toggle({uid: adminUid}, {name: 'local', enabled: !caches.local.enabled}); + + // Call again to return back to original state + await socketAdmin.cache.toggle({uid: adminUid}, {name: 'post', enabled: !caches.post.enabled}); + if (caches.object) { + await socketAdmin.cache.toggle({uid: adminUid}, {name: 'object', enabled: !caches.object.enabled}); + } + + await socketAdmin.cache.toggle({uid: adminUid}, {name: 'group', enabled: !caches.group.enabled}); + await socketAdmin.cache.toggle({uid: adminUid}, {name: 'local', enabled: !caches.local.enabled}); + }); }); diff --git a/test/template-helpers.js b/test/template-helpers.js index eb8eb16..384d745 100644 --- a/test/template-helpers.js +++ b/test/template-helpers.js @@ -1,238 +1,239 @@ 'use strict'; +const assert = require('node:assert'); const nconf = require('nconf'); -const assert = require('assert'); - -const db = require('./mocks/databasemock'); const helpers = require('../src/helpers'); +const db = require('./mocks/databasemock'); describe('helpers', () => { - it('should return false if item doesn\'t exist', (done) => { - const flag = helpers.displayMenuItem({ navigation: [] }, 0); - assert(!flag); - done(); - }); - - it('should return false if route is /users and user does not have view:users privilege', (done) => { - const flag = helpers.displayMenuItem({ - navigation: [{ route: '/users' }], - user: { - privileges: { - 'view:users': false, - }, - }, - }, 0); - assert(!flag); - done(); - }); - - it('should return false if route is /tags and user does not have view:tags privilege', (done) => { - const flag = helpers.displayMenuItem({ - navigation: [{ route: '/tags' }], - user: { - privileges: { - 'view:tags': false, - }, - }, - }, 0); - assert(!flag); - done(); - }); - - it('should return false if route is /groups and user does not have view:groups privilege', (done) => { - const flag = helpers.displayMenuItem({ - navigation: [{ route: '/groups' }], - user: { - privileges: { - 'view:groups': false, - }, - }, - }, 0); - assert(!flag); - done(); - }); - - it('should stringify object', (done) => { - const str = helpers.stringify({ a: 'herp < derp > and & quote "' }); - assert.equal(str, '{"a":"herp < derp > and & quote \\""}'); - done(); - }); - - it('should escape html', (done) => { - const str = helpers.escape('gdkfhgk < some > and &'); - assert.equal(str, 'gdkfhgk < some > and &'); - done(); - }); - - it('should return empty string if category is falsy', (done) => { - assert.equal(helpers.generateCategoryBackground(null), ''); - done(); - }); - - it('should generate category background', (done) => { - const category = { - bgColor: '#ff0000', - color: '#00ff00', - backgroundImage: '/assets/uploads/image.png', - imageClass: 'auto', - }; - const bg = helpers.generateCategoryBackground(category); - assert.equal(bg, 'background-color: #ff0000; color: #00ff00; background-image: url(/assets/uploads/image.png); background-size: auto;'); - done(); - }); - - it('should return empty string if category has no children', (done) => { - const category = { - children: [], - }; - const bg = helpers.generateChildrenCategories(category); - assert.equal(bg, ''); - done(); - }); - - it('should generate html for children', (done) => { - const category = { - children: [ - { - link: '', - bgColor: '#ff0000', - color: '#00ff00', - name: 'children', - }, - ], - }; - const html = helpers.generateChildrenCategories(category); - assert.equal(html, `children`); - done(); - }); - - it('should generate topic class', (done) => { - const className = helpers.generateTopicClass({ locked: true, pinned: true, deleted: true, unread: true }); - assert.equal(className, 'locked pinned deleted unread'); - done(); - }); - - it('should show leave button if isMember and group is not administrators', (done) => { - const btn = helpers.membershipBtn({ displayName: 'some group', name: 'some group', isMember: true }); - assert.equal(btn, ''); - done(); - }); - - it('should show pending button if isPending and group is not administrators', (done) => { - const btn = helpers.membershipBtn({ displayName: 'some group', name: 'some group', isPending: true }); - assert.equal(btn, ''); - done(); - }); - - it('should show reject invite button if isInvited', (done) => { - const btn = helpers.membershipBtn({ displayName: 'some group', name: 'some group', isInvited: true }); - assert.equal(btn, ''); - done(); - }); - - it('should show join button if join requests are not disabled and group is not administrators', (done) => { - const btn = helpers.membershipBtn({ displayName: 'some group', name: 'some group', disableJoinRequests: false }); - assert.equal(btn, ''); - done(); - }); - - it('should show nothing if group is administrators ', (done) => { - const btn = helpers.membershipBtn({ displayName: 'administrators', name: 'administrators' }); - assert.equal(btn, ''); - done(); - }); - - it('should spawn privilege states', (done) => { - const privs = { - find: true, - read: true, - }; - const html = helpers.spawnPrivilegeStates('guests', privs); - assert.equal(html, ''); - done(); - }); - - it('should render thumb as topic image', (done) => { - const topicObj = { thumb: '/uploads/1.png', user: { username: 'baris' } }; - const html = helpers.renderTopicImage(topicObj); - assert.equal(html, ``); - done(); - }); - - it('should render user picture as topic image', (done) => { - const topicObj = { thumb: '', user: { uid: 1, username: 'baris', picture: '/uploads/2.png' } }; - const html = helpers.renderTopicImage(topicObj); - assert.equal(html, ``); - done(); - }); - - it('should render digest avatar', (done) => { - const block = { teaser: { user: { username: 'baris', picture: '/uploads/1.png' } } }; - const html = helpers.renderDigestAvatar(block); - assert.equal(html, ``); - done(); - }); - - it('should render digest avatar', (done) => { - const block = { teaser: { user: { username: 'baris', 'icon:text': 'B', 'icon:bgColor': '#ff000' } } }; - const html = helpers.renderDigestAvatar(block); - assert.equal(html, `
    ${block.teaser.user['icon:text']}
    `); - done(); - }); - - it('should render digest avatar', (done) => { - const block = { user: { username: 'baris', picture: '/uploads/1.png' } }; - const html = helpers.renderDigestAvatar(block); - assert.equal(html, ``); - done(); - }); - - it('should render digest avatar', (done) => { - const block = { user: { username: 'baris', 'icon:text': 'B', 'icon:bgColor': '#ff000' } }; - const html = helpers.renderDigestAvatar(block); - assert.equal(html, `
    ${block.user['icon:text']}
    `); - done(); - }); - - it('shoud render user agent/browser icons', (done) => { - const html = helpers.userAgentIcons({ platform: 'Linux', browser: 'Chrome' }); - assert.equal(html, ''); - done(); - }); - - it('shoud render user agent/browser icons', (done) => { - const html = helpers.userAgentIcons({ platform: 'Microsoft Windows', browser: 'Firefox' }); - assert.equal(html, ''); - done(); - }); - - it('shoud render user agent/browser icons', (done) => { - const html = helpers.userAgentIcons({ platform: 'Apple Mac', browser: 'Safari' }); - assert.equal(html, ''); - done(); - }); - - it('shoud render user agent/browser icons', (done) => { - const html = helpers.userAgentIcons({ platform: 'Android', browser: 'IE' }); - assert.equal(html, ''); - done(); - }); - - it('shoud render user agent/browser icons', (done) => { - const html = helpers.userAgentIcons({ platform: 'iPad', browser: 'Edge' }); - assert.equal(html, ''); - done(); - }); - - it('shoud render user agent/browser icons', (done) => { - const html = helpers.userAgentIcons({ platform: 'iPhone', browser: 'unknow' }); - assert.equal(html, ''); - done(); - }); - - it('shoud render user agent/browser icons', (done) => { - const html = helpers.userAgentIcons({ platform: 'unknow', browser: 'unknown' }); - assert.equal(html, ''); - done(); - }); + it('should return false if item doesn\'t exist', done => { + const flag = helpers.displayMenuItem({navigation: []}, 0); + assert(!flag); + done(); + }); + + it('should return false if route is /users and user does not have view:users privilege', done => { + const flag = helpers.displayMenuItem({ + navigation: [{route: '/users'}], + user: { + privileges: { + 'view:users': false, + }, + }, + }, 0); + assert(!flag); + done(); + }); + + it('should return false if route is /tags and user does not have view:tags privilege', done => { + const flag = helpers.displayMenuItem({ + navigation: [{route: '/tags'}], + user: { + privileges: { + 'view:tags': false, + }, + }, + }, 0); + assert(!flag); + done(); + }); + + it('should return false if route is /groups and user does not have view:groups privilege', done => { + const flag = helpers.displayMenuItem({ + navigation: [{route: '/groups'}], + user: { + privileges: { + 'view:groups': false, + }, + }, + }, 0); + assert(!flag); + done(); + }); + + it('should stringify object', done => { + const string_ = helpers.stringify({a: 'herp < derp > and & quote "'}); + assert.equal(string_, '{"a":"herp < derp > and & quote \\""}'); + done(); + }); + + it('should escape html', done => { + const string_ = helpers.escape('gdkfhgk < some > and &'); + assert.equal(string_, 'gdkfhgk < some > and &'); + done(); + }); + + it('should return empty string if category is falsy', done => { + assert.equal(helpers.generateCategoryBackground(null), ''); + done(); + }); + + it('should generate category background', done => { + const category = { + bgColor: '#ff0000', + color: '#00ff00', + backgroundImage: '/assets/uploads/image.png', + imageClass: 'auto', + }; + const bg = helpers.generateCategoryBackground(category); + assert.equal(bg, 'background-color: #ff0000; color: #00ff00; background-image: url(/assets/uploads/image.png); background-size: auto;'); + done(); + }); + + it('should return empty string if category has no children', done => { + const category = { + children: [], + }; + const bg = helpers.generateChildrenCategories(category); + assert.equal(bg, ''); + done(); + }); + + it('should generate html for children', done => { + const category = { + children: [ + { + link: '', + bgColor: '#ff0000', + color: '#00ff00', + name: 'children', + }, + ], + }; + const html = helpers.generateChildrenCategories(category); + assert.equal(html, `children`); + done(); + }); + + it('should generate topic class', done => { + const className = helpers.generateTopicClass({ + locked: true, pinned: true, deleted: true, unread: true, + }); + assert.equal(className, 'locked pinned deleted unread'); + done(); + }); + + it('should show leave button if isMember and group is not administrators', done => { + const button = helpers.membershipBtn({displayName: 'some group', name: 'some group', isMember: true}); + assert.equal(button, ''); + done(); + }); + + it('should show pending button if isPending and group is not administrators', done => { + const button = helpers.membershipBtn({displayName: 'some group', name: 'some group', isPending: true}); + assert.equal(button, ''); + done(); + }); + + it('should show reject invite button if isInvited', done => { + const button = helpers.membershipBtn({displayName: 'some group', name: 'some group', isInvited: true}); + assert.equal(button, ''); + done(); + }); + + it('should show join button if join requests are not disabled and group is not administrators', done => { + const button = helpers.membershipBtn({displayName: 'some group', name: 'some group', disableJoinRequests: false}); + assert.equal(button, ''); + done(); + }); + + it('should show nothing if group is administrators ', done => { + const button = helpers.membershipBtn({displayName: 'administrators', name: 'administrators'}); + assert.equal(button, ''); + done(); + }); + + it('should spawn privilege states', done => { + const privs = { + find: true, + read: true, + }; + const html = helpers.spawnPrivilegeStates('guests', privs); + assert.equal(html, ''); + done(); + }); + + it('should render thumb as topic image', done => { + const topicObject = {thumb: '/uploads/1.png', user: {username: 'baris'}}; + const html = helpers.renderTopicImage(topicObject); + assert.equal(html, ``); + done(); + }); + + it('should render user picture as topic image', done => { + const topicObject = {thumb: '', user: {uid: 1, username: 'baris', picture: '/uploads/2.png'}}; + const html = helpers.renderTopicImage(topicObject); + assert.equal(html, ``); + done(); + }); + + it('should render digest avatar', done => { + const block = {teaser: {user: {username: 'baris', picture: '/uploads/1.png'}}}; + const html = helpers.renderDigestAvatar(block); + assert.equal(html, ``); + done(); + }); + + it('should render digest avatar', done => { + const block = {teaser: {user: {username: 'baris', 'icon:text': 'B', 'icon:bgColor': '#ff000'}}}; + const html = helpers.renderDigestAvatar(block); + assert.equal(html, `
    ${block.teaser.user['icon:text']}
    `); + done(); + }); + + it('should render digest avatar', done => { + const block = {user: {username: 'baris', picture: '/uploads/1.png'}}; + const html = helpers.renderDigestAvatar(block); + assert.equal(html, ``); + done(); + }); + + it('should render digest avatar', done => { + const block = {user: {username: 'baris', 'icon:text': 'B', 'icon:bgColor': '#ff000'}}; + const html = helpers.renderDigestAvatar(block); + assert.equal(html, `
    ${block.user['icon:text']}
    `); + done(); + }); + + it('shoud render user agent/browser icons', done => { + const html = helpers.userAgentIcons({platform: 'Linux', browser: 'Chrome'}); + assert.equal(html, ''); + done(); + }); + + it('shoud render user agent/browser icons', done => { + const html = helpers.userAgentIcons({platform: 'Microsoft Windows', browser: 'Firefox'}); + assert.equal(html, ''); + done(); + }); + + it('shoud render user agent/browser icons', done => { + const html = helpers.userAgentIcons({platform: 'Apple Mac', browser: 'Safari'}); + assert.equal(html, ''); + done(); + }); + + it('shoud render user agent/browser icons', done => { + const html = helpers.userAgentIcons({platform: 'Android', browser: 'IE'}); + assert.equal(html, ''); + done(); + }); + + it('shoud render user agent/browser icons', done => { + const html = helpers.userAgentIcons({platform: 'iPad', browser: 'Edge'}); + assert.equal(html, ''); + done(); + }); + + it('shoud render user agent/browser icons', done => { + const html = helpers.userAgentIcons({platform: 'iPhone', browser: 'unknow'}); + assert.equal(html, ''); + done(); + }); + + it('shoud render user agent/browser icons', done => { + const html = helpers.userAgentIcons({platform: 'unknow', browser: 'unknown'}); + assert.equal(html, ''); + done(); + }); }); diff --git a/test/topics.js b/test/topics.js index 5fbddf0..11131f4 100644 --- a/test/topics.js +++ b/test/topics.js @@ -1,17 +1,16 @@ 'use strict'; -const async = require('async'); -const path = require('path'); -const assert = require('assert'); +const path = require('node:path'); +const assert = require('node:assert'); +const util = require('node:util'); const validator = require('validator'); const mockdate = require('mockdate'); const nconf = require('nconf'); const request = require('request'); -const util = require('util'); +const async = require('async'); const sleep = util.promisify(setTimeout); -const db = require('./mocks/databasemock'); const file = require('../src/file'); const topics = require('../src/topics'); const posts = require('../src/posts'); @@ -20,2886 +19,3067 @@ const privileges = require('../src/privileges'); const meta = require('../src/meta'); const User = require('../src/user'); const groups = require('../src/groups'); -const helpers = require('./helpers'); const socketPosts = require('../src/socket.io/posts'); const socketTopics = require('../src/socket.io/topics'); const apiTopics = require('../src/api/topics'); +const helpers = require('./helpers'); +const db = require('./mocks/databasemock'); -const requestType = util.promisify((type, url, opts, cb) => { - request[type](url, opts, (err, res, body) => cb(err, { res: res, body: body })); +const requestType = util.promisify((type, url, options, callback) => { + request[type](url, options, (error, res, body) => callback(error, {res, body})); }); describe('Topic\'s', () => { - let topic; - let categoryObj; - let adminUid; - let globalModUid; - let adminJar; - let csrf_token; - let fooUid; - let badUserUid; - - before(async () => { - adminUid = await User.create({ username: 'admin', password: '123456' }); - globalModUid = await User.create({ username: 'globalmod', password: 'globalmodpwd' }); - fooUid = await User.create({ username: 'foo' }); - badUserUid = await User.create({ username: 'badUser' }); - await groups.join('administrators', adminUid); - await groups.join('Global Moderators', globalModUid); - const adminLogin = await helpers.loginUser('admin', '123456'); - adminJar = adminLogin.jar; - csrf_token = adminLogin.csrf_token; - - categoryObj = await categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - }); - topic = { - userId: adminUid, - categoryId: categoryObj.cid, - title: 'Test Topic Title', - content: 'The content of test topic', - }; - }); - - describe('.post', () => { - it('should fail to create topic with invalid data', async () => { - try { - await apiTopics.create({ uid: 0 }, null); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:invalid-data]]'); - } - }); - - it('should create a new topic with proper parameters', (done) => { - topics.post({ - uid: topic.userId, - title: topic.title, - content: topic.content, - cid: topic.categoryId, - }, (err, result) => { - assert.ifError(err); - assert(result); - topic.tid = result.topicData.tid; - done(); - }); - }); - - it('should get post count', (done) => { - socketTopics.postcount({ uid: adminUid }, topic.tid, (err, count) => { - assert.ifError(err); - assert.equal(count, 1); - done(); - }); - }); - - it('should load topic', async () => { - const data = await apiTopics.get({ uid: adminUid }, { tid: topic.tid }); - assert.equal(data.tid, topic.tid); - }); - - it('should fail to create new topic with invalid user id', (done) => { - topics.post({ uid: null, title: topic.title, content: topic.content, cid: topic.categoryId }, (err) => { - assert.equal(err.message, '[[error:no-privileges]]'); - done(); - }); - }); - - it('should fail to create new topic with empty title', (done) => { - topics.post({ uid: topic.userId, title: '', content: topic.content, cid: topic.categoryId }, (err) => { - assert.ok(err); - done(); - }); - }); - - it('should fail to create new topic with empty content', (done) => { - topics.post({ uid: topic.userId, title: topic.title, content: '', cid: topic.categoryId }, (err) => { - assert.ok(err); - done(); - }); - }); - - it('should fail to create new topic with non-existant category id', (done) => { - topics.post({ uid: topic.userId, title: topic.title, content: topic.content, cid: 99 }, (err) => { - assert.equal(err.message, '[[error:no-category]]', 'received no error'); - done(); - }); - }); - - it('should return false for falsy uid', (done) => { - topics.isOwner(topic.tid, 0, (err, isOwner) => { - assert.ifError(err); - assert(!isOwner); - done(); - }); - }); - - it('should fail to post a topic as guest with invalid csrf_token', async () => { - const categoryObj = await categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - }); - await privileges.categories.give(['groups:topics:create'], categoryObj.cid, 'guests'); - await privileges.categories.give(['groups:topics:reply'], categoryObj.cid, 'guests'); - const result = await requestType('post', `${nconf.get('url')}/api/v3/topics`, { - form: { - title: 'just a title', - cid: categoryObj.cid, - content: 'content for the main post', - }, - headers: { - 'x-csrf-token': 'invalid', - }, - json: true, - }); - assert.strictEqual(result.res.statusCode, 403); - assert.strictEqual(result.body, 'Forbidden'); - }); - - it('should fail to post a topic as guest if no privileges', async () => { - const categoryObj = await categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - }); - const jar = request.jar(); - const result = await helpers.request('post', `/api/v3/topics`, { - form: { - title: 'just a title', - cid: categoryObj.cid, - content: 'content for the main post', - }, - jar: jar, - json: true, - }); - assert.strictEqual(result.body.status.message, 'You do not have enough privileges for this action.'); - }); - - it('should post a topic as guest if guest group has privileges', async () => { - const categoryObj = await categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - }); - await privileges.categories.give(['groups:topics:create'], categoryObj.cid, 'guests'); - await privileges.categories.give(['groups:topics:reply'], categoryObj.cid, 'guests'); - - const jar = request.jar(); - const result = await helpers.request('post', `/api/v3/topics`, { - form: { - title: 'just a title', - cid: categoryObj.cid, - content: 'content for the main post', - }, - jar: jar, - json: true, - }); - - assert.strictEqual(result.body.status.code, 'ok'); - assert.strictEqual(result.body.response.title, 'just a title'); - assert.strictEqual(result.body.response.user.username, '[[global:guest]]'); - - const replyResult = await helpers.request('post', `/api/v3/topics/${result.body.response.tid}`, { - form: { - content: 'a reply by guest', - }, - jar: jar, - json: true, - }); - assert.strictEqual(replyResult.body.response.content, 'a reply by guest'); - assert.strictEqual(replyResult.body.response.user.username, '[[global:guest]]'); - }); - - it('should post a topic/reply as guest with handle if guest group has privileges', async () => { - const categoryObj = await categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - }); - await privileges.categories.give(['groups:topics:create'], categoryObj.cid, 'guests'); - await privileges.categories.give(['groups:topics:reply'], categoryObj.cid, 'guests'); - const oldValue = meta.config.allowGuestHandles; - meta.config.allowGuestHandles = 1; - const result = await helpers.request('post', `/api/v3/topics`, { - form: { - title: 'just a title', - cid: categoryObj.cid, - content: 'content for the main post', - handle: 'guest123', - }, - jar: request.jar(), - json: true, - }); - - assert.strictEqual(result.body.status.code, 'ok'); - assert.strictEqual(result.body.response.title, 'just a title'); - assert.strictEqual(result.body.response.user.username, 'guest123'); - assert.strictEqual(result.body.response.user.displayname, 'guest123'); - - const replyResult = await helpers.request('post', `/api/v3/topics/${result.body.response.tid}`, { - form: { - content: 'a reply by guest', - handle: 'guest124', - }, - jar: request.jar(), - json: true, - }); - assert.strictEqual(replyResult.body.response.content, 'a reply by guest'); - assert.strictEqual(replyResult.body.response.user.username, 'guest124'); - assert.strictEqual(replyResult.body.response.user.displayname, 'guest124'); - meta.config.allowGuestHandles = oldValue; - }); - }); - - describe('.reply', () => { - let newTopic; - let newPost; - - before((done) => { - topics.post({ - uid: topic.userId, - title: topic.title, - content: topic.content, - cid: topic.categoryId, - }, (err, result) => { - if (err) { - return done(err); - } - - newTopic = result.topicData; - newPost = result.postData; - done(); - }); - }); - - it('should create a new reply with proper parameters', (done) => { - topics.reply({ uid: topic.userId, content: 'test post', tid: newTopic.tid }, (err, result) => { - assert.equal(err, null, 'was created with error'); - assert.ok(result); - - done(); - }); - }); - - it('should handle direct replies', (done) => { - topics.reply({ uid: topic.userId, content: 'test reply', tid: newTopic.tid, toPid: newPost.pid }, (err, result) => { - assert.equal(err, null, 'was created with error'); - assert.ok(result); - - socketPosts.getReplies({ uid: 0 }, newPost.pid, (err, postData) => { - assert.ifError(err); - - assert.ok(postData); - - assert.equal(postData.length, 1, 'should have 1 result'); - assert.equal(postData[0].pid, result.pid, 'result should be the reply we added'); - - done(); - }); - }); - }); - - it('should error if pid is not a number', (done) => { - socketPosts.getReplies({ uid: 0 }, 'abc', (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should fail to create new reply with invalid user id', (done) => { - topics.reply({ uid: null, content: 'test post', tid: newTopic.tid }, (err) => { - assert.equal(err.message, '[[error:no-privileges]]'); - done(); - }); - }); - - it('should fail to create new reply with empty content', (done) => { - topics.reply({ uid: topic.userId, content: '', tid: newTopic.tid }, (err) => { - assert.ok(err); - done(); - }); - }); - - it('should fail to create new reply with invalid topic id', (done) => { - topics.reply({ uid: null, content: 'test post', tid: 99 }, (err) => { - assert.equal(err.message, '[[error:no-topic]]'); - done(); - }); - }); - - it('should fail to create new reply with invalid toPid', (done) => { - topics.reply({ uid: topic.userId, content: 'test post', tid: newTopic.tid, toPid: '"onmouseover=alert(1);//' }, (err) => { - assert.equal(err.message, '[[error:invalid-pid]]'); - done(); - }); - }); - - it('should delete nested relies properly', async () => { - const result = await topics.post({ uid: fooUid, title: 'nested test', content: 'main post', cid: topic.categoryId }); - const reply1 = await topics.reply({ uid: fooUid, content: 'reply post 1', tid: result.topicData.tid }); - const reply2 = await topics.reply({ uid: fooUid, content: 'reply post 2', tid: result.topicData.tid, toPid: reply1.pid }); - let replies = await socketPosts.getReplies({ uid: fooUid }, reply1.pid); - assert.strictEqual(replies.length, 1); - assert.strictEqual(replies[0].content, 'reply post 2'); - let toPid = await posts.getPostField(reply2.pid, 'toPid'); - assert.strictEqual(parseInt(toPid, 10), parseInt(reply1.pid, 10)); - await posts.purge(reply1.pid, fooUid); - replies = await socketPosts.getReplies({ uid: fooUid }, reply1.pid); - assert.strictEqual(replies.length, 0); - toPid = await posts.getPostField(reply2.pid, 'toPid'); - assert.strictEqual(toPid, null); - }); - }); - - describe('Get methods', () => { - let newTopic; - let newPost; - - before((done) => { - topics.post({ - uid: topic.userId, - title: topic.title, - content: topic.content, - cid: topic.categoryId, - }, (err, result) => { - if (err) { - return done(err); - } - - newTopic = result.topicData; - newPost = result.postData; - done(); - }); - }); - - - it('should not receive errors', (done) => { - topics.getTopicData(newTopic.tid, (err, topicData) => { - assert.ifError(err); - assert(typeof topicData.tid === 'number'); - assert(typeof topicData.uid === 'number'); - assert(typeof topicData.cid === 'number'); - assert(typeof topicData.mainPid === 'number'); - - assert(typeof topicData.timestamp === 'number'); - assert.strictEqual(topicData.postcount, 1); - assert.strictEqual(topicData.viewcount, 0); - assert.strictEqual(topicData.upvotes, 0); - assert.strictEqual(topicData.downvotes, 0); - assert.strictEqual(topicData.votes, 0); - assert.strictEqual(topicData.deleted, 0); - assert.strictEqual(topicData.private, 0); - assert.strictEqual(topicData.locked, 0); - assert.strictEqual(topicData.pinned, 0); - done(); - }); - }); - - it('should get a single field', (done) => { - topics.getTopicFields(newTopic.tid, ['slug'], (err, data) => { - assert.ifError(err); - assert(Object.keys(data).length === 1); - assert(data.hasOwnProperty('slug')); - done(); - }); - }); - - it('should get topic title by pid', (done) => { - topics.getTitleByPid(newPost.pid, (err, title) => { - assert.ifError(err); - assert.equal(title, topic.title); - done(); - }); - }); - - it('should get topic data by pid', (done) => { - topics.getTopicDataByPid(newPost.pid, (err, data) => { - assert.ifError(err); - assert.equal(data.tid, newTopic.tid); - done(); - }); - }); - - describe('.getTopicWithPosts', () => { - let tid; - before(async () => { - const result = await topics.post({ uid: topic.userId, title: 'page test', content: 'main post', cid: topic.categoryId }); - tid = result.topicData.tid; - for (let i = 0; i < 30; i++) { - // eslint-disable-next-line no-await-in-loop - await topics.reply({ uid: adminUid, content: `topic reply ${i + 1}`, tid: tid }); - } - }); - - it('should get a topic with posts and other data', async () => { - const topicData = await topics.getTopicData(tid); - const data = await topics.getTopicWithPosts(topicData, `tid:${tid}:posts`, topic.userId, 0, -1, false); - assert(data); - assert.equal(data.category.cid, topic.categoryId); - assert.equal(data.unreplied, false); - assert.equal(data.deleted, false); - assert.equal(data.locked, false); - assert.equal(data.pinned, false); - }); - - it('should return first 3 posts including main post', async () => { - const topicData = await topics.getTopicData(tid); - const data = await topics.getTopicWithPosts(topicData, `tid:${tid}:posts`, topic.userId, 0, 2, false); - assert.strictEqual(data.posts.length, 3); - assert.strictEqual(data.posts[0].content, 'main post'); - assert.strictEqual(data.posts[1].content, 'topic reply 1'); - assert.strictEqual(data.posts[2].content, 'topic reply 2'); - data.posts.forEach((post, index) => { - assert.strictEqual(post.index, index); - }); - }); - - it('should return 3 posts from 1 to 3 excluding main post', async () => { - const topicData = await topics.getTopicData(tid); - const start = 1; - const data = await topics.getTopicWithPosts(topicData, `tid:${tid}:posts`, topic.userId, start, 3, false); - assert.strictEqual(data.posts.length, 3); - assert.strictEqual(data.posts[0].content, 'topic reply 1'); - assert.strictEqual(data.posts[1].content, 'topic reply 2'); - assert.strictEqual(data.posts[2].content, 'topic reply 3'); - data.posts.forEach((post, index) => { - assert.strictEqual(post.index, index + start); - }); - }); - - it('should return main post and last 2 posts', async () => { - const topicData = await topics.getTopicData(tid); - const data = await topics.getTopicWithPosts(topicData, `tid:${tid}:posts`, topic.userId, 0, 2, true); - assert.strictEqual(data.posts.length, 3); - assert.strictEqual(data.posts[0].content, 'main post'); - assert.strictEqual(data.posts[1].content, 'topic reply 30'); - assert.strictEqual(data.posts[2].content, 'topic reply 29'); - data.posts.forEach((post, index) => { - assert.strictEqual(post.index, index); - }); - }); - - it('should return last 3 posts and not main post', async () => { - const topicData = await topics.getTopicData(tid); - const start = 1; - const data = await topics.getTopicWithPosts(topicData, `tid:${tid}:posts`, topic.userId, start, 3, true); - assert.strictEqual(data.posts.length, 3); - assert.strictEqual(data.posts[0].content, 'topic reply 30'); - assert.strictEqual(data.posts[1].content, 'topic reply 29'); - assert.strictEqual(data.posts[2].content, 'topic reply 28'); - data.posts.forEach((post, index) => { - assert.strictEqual(post.index, index + start); - }); - }); - - it('should return posts 29 to 27 posts and not main post', async () => { - const topicData = await topics.getTopicData(tid); - const start = 2; - const data = await topics.getTopicWithPosts(topicData, `tid:${tid}:posts`, topic.userId, start, 4, true); - assert.strictEqual(data.posts.length, 3); - assert.strictEqual(data.posts[0].content, 'topic reply 29'); - assert.strictEqual(data.posts[1].content, 'topic reply 28'); - assert.strictEqual(data.posts[2].content, 'topic reply 27'); - data.posts.forEach((post, index) => { - assert.strictEqual(post.index, index + start); - }); - }); - - it('should return 3 posts in reverse', async () => { - const topicData = await topics.getTopicData(tid); - const start = 28; - const data = await topics.getTopicWithPosts(topicData, `tid:${tid}:posts`, topic.userId, start, 30, true); - assert.strictEqual(data.posts.length, 3); - assert.strictEqual(data.posts[0].content, 'topic reply 3'); - assert.strictEqual(data.posts[1].content, 'topic reply 2'); - assert.strictEqual(data.posts[2].content, 'topic reply 1'); - data.posts.forEach((post, index) => { - assert.strictEqual(post.index, index + start); - }); - }); - - it('should get all posts with main post at the start', async () => { - const topicData = await topics.getTopicData(tid); - const data = await topics.getTopicWithPosts(topicData, `tid:${tid}:posts`, topic.userId, 0, -1, false); - assert.strictEqual(data.posts.length, 31); - assert.strictEqual(data.posts[0].content, 'main post'); - assert.strictEqual(data.posts[1].content, 'topic reply 1'); - assert.strictEqual(data.posts[data.posts.length - 1].content, 'topic reply 30'); - data.posts.forEach((post, index) => { - assert.strictEqual(post.index, index); - }); - }); - - it('should get all posts in reverse with main post at the start followed by reply 30', async () => { - const topicData = await topics.getTopicData(tid); - const data = await topics.getTopicWithPosts(topicData, `tid:${tid}:posts`, topic.userId, 0, -1, true); - assert.strictEqual(data.posts.length, 31); - assert.strictEqual(data.posts[0].content, 'main post'); - assert.strictEqual(data.posts[1].content, 'topic reply 30'); - assert.strictEqual(data.posts[data.posts.length - 1].content, 'topic reply 1'); - data.posts.forEach((post, index) => { - assert.strictEqual(post.index, index); - }); - }); - - it('should return empty array if first param is falsy', async () => { - const posts = await topics.getTopicPosts(null, `tid:${tid}:posts`, 0, 9, topic.userId, true); - assert.deepStrictEqual(posts, []); - }); - - it('should only return main post', async () => { - const topicData = await topics.getTopicData(tid); - const postsData = await topics.getTopicPosts(topicData, `tid:${tid}:posts`, 0, 0, topic.userId, false); - assert.strictEqual(postsData.length, 1); - assert.strictEqual(postsData[0].content, 'main post'); - }); - - it('should only return first reply', async () => { - const topicData = await topics.getTopicData(tid); - const postsData = await topics.getTopicPosts(topicData, `tid:${tid}:posts`, 1, 1, topic.userId, false); - assert.strictEqual(postsData.length, 1); - assert.strictEqual(postsData[0].content, 'topic reply 1'); - }); - - it('should return main post and first reply', async () => { - const topicData = await topics.getTopicData(tid); - const postsData = await topics.getTopicPosts(topicData, `tid:${tid}:posts`, 0, 1, topic.userId, false); - assert.strictEqual(postsData.length, 2); - assert.strictEqual(postsData[0].content, 'main post'); - assert.strictEqual(postsData[1].content, 'topic reply 1'); - }); - - it('should return posts in correct order', async () => { - const data = await socketTopics.loadMore({ uid: topic.userId }, { tid: tid, after: 20, direction: 1 }); - assert.strictEqual(data.posts.length, 11); - assert.strictEqual(data.posts[0].content, 'topic reply 20'); - assert.strictEqual(data.posts[1].content, 'topic reply 21'); - }); - - it('should return posts in correct order in reverse direction', async () => { - const data = await socketTopics.loadMore({ uid: topic.userId }, { tid: tid, after: 25, direction: -1 }); - assert.strictEqual(data.posts.length, 20); - assert.strictEqual(data.posts[0].content, 'topic reply 5'); - assert.strictEqual(data.posts[1].content, 'topic reply 6'); - }); - - it('should return all posts in correct order', async () => { - const topicData = await topics.getTopicData(tid); - const postsData = await topics.getTopicPosts(topicData, `tid:${tid}:posts`, 0, -1, topic.userId, false); - assert.strictEqual(postsData.length, 31); - assert.strictEqual(postsData[0].content, 'main post'); - for (let i = 1; i < 30; i++) { - assert.strictEqual(postsData[i].content, `topic reply ${i}`); - } - }); - }); - }); - - describe('Title escaping', () => { - it('should properly escape topic title', (done) => { - const title = '" new topic test'; - const titleEscaped = validator.escape(title); - - const topicPostData = { uid: topic.userId, title: title, content: topic.content, cid: topic.categoryId }; - topics.post(topicPostData, (err, result) => { - assert.ifError(err); - topics.getTopicData(result.topicData.tid, (err, topicData) => { - assert.ifError(err); - assert.strictEqual(topicData.titleRaw, title); - assert.strictEqual(topicData.title, titleEscaped); - done(); - }); - }); - }); - }); - - describe('tools/delete/restore/purge', () => { - let newTopic; - let new1Topic; - let followerUid; - let moveCid; - - before((done) => { - async.waterfall([ - function (next) { - topics.post({ - uid: topic.userId, - title: topic.title, - content: topic.content, - cid: topic.categoryId, - }, (err, result) => { - assert.ifError(err); - newTopic = result.topicData; - next(); - }); - }, - function (next) { - topics.post({ - uid: fooUid, - title: topic.title, - content: topic.content, - cid: topic.categoryId, - }, (err, result) => { - assert.ifError(err); - new1Topic = result.topicData; - next(); - }); - }, - function (next) { - User.create({ username: 'topicFollower', password: '123456' }, next); - }, - function (_uid, next) { - followerUid = _uid; - topics.follow(newTopic.tid, _uid, next); - }, - function (next) { - categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - }, (err, category) => { - if (err) { - return next(err); - } - moveCid = category.cid; - next(); - }); - }, - ], done); - }); - - it('should load topic tools', (done) => { - socketTopics.loadTopicTools({ uid: adminUid }, { tid: newTopic.tid }, (err, data) => { - assert.ifError(err); - assert(data); - done(); - }); - }); - - it('should delete the topic', async () => { - await apiTopics.delete({ uid: adminUid }, { tids: [newTopic.tid], cid: categoryObj.cid }); - const deleted = await topics.getTopicField(newTopic.tid, 'deleted'); - assert.strictEqual(deleted, 1); - }); - - it('should restore the topic', async () => { - await apiTopics.restore({ uid: adminUid }, { tids: [newTopic.tid], cid: categoryObj.cid }); - const deleted = await topics.getTopicField(newTopic.tid, 'deleted'); - assert.strictEqual(deleted, 0); - }); - - it('should private the topic', async () => { - await apiTopics.private({ uid: globalModUid }, { tids: [newTopic.tid], cid: categoryObj.cid }); - const privated = await topics.getTopicField(newTopic.tid, 'private'); - assert.strictEqual(privated, 1); - }); - - it('should public the topic', async () => { - await apiTopics.public({ uid: globalModUid }, { tids: [newTopic.tid], cid: categoryObj.cid }); - const privated = await topics.getTopicField(newTopic.tid, 'private'); - assert.strictEqual(privated, 0); - }); - - it('should allow owner to private the topic', async () => { - await apiTopics.private({ uid: fooUid }, { tids: [new1Topic.tid], cid: categoryObj.cid }); - const privated = await topics.getTopicField(new1Topic.tid, 'private'); - assert.strictEqual(privated, 1); - }); - - it('should allow owner to public the topic', async () => { - await apiTopics.public({ uid: fooUid }, { tids: [new1Topic.tid], cid: categoryObj.cid }); - const privated = await topics.getTopicField(new1Topic.tid, 'private'); - assert.strictEqual(privated, 0); - }); - - it('should lock topic', async () => { - await apiTopics.lock({ uid: adminUid }, { tids: [newTopic.tid], cid: categoryObj.cid }); - const isLocked = await topics.isLocked(newTopic.tid); - assert(isLocked); - }); - - it('should unlock topic', async () => { - await apiTopics.unlock({ uid: adminUid }, { tids: [newTopic.tid], cid: categoryObj.cid }); - const isLocked = await topics.isLocked(newTopic.tid); - assert(!isLocked); - }); - - it('should pin topic', async () => { - await apiTopics.pin({ uid: adminUid }, { tids: [newTopic.tid], cid: categoryObj.cid }); - const pinned = await topics.getTopicField(newTopic.tid, 'pinned'); - assert.strictEqual(pinned, 1); - }); - - it('should unpin topic', async () => { - await apiTopics.unpin({ uid: adminUid }, { tids: [newTopic.tid], cid: categoryObj.cid }); - const pinned = await topics.getTopicField(newTopic.tid, 'pinned'); - assert.strictEqual(pinned, 0); - }); - - it('should move all topics', (done) => { - socketTopics.moveAll({ uid: adminUid }, { cid: moveCid, currentCid: categoryObj.cid }, (err) => { - assert.ifError(err); - topics.getTopicField(newTopic.tid, 'cid', (err, cid) => { - assert.ifError(err); - assert.equal(cid, moveCid); - done(); - }); - }); - }); - - it('should move a topic', (done) => { - socketTopics.move({ uid: adminUid }, { cid: categoryObj.cid, tids: [newTopic.tid] }, (err) => { - assert.ifError(err); - topics.getTopicField(newTopic.tid, 'cid', (err, cid) => { - assert.ifError(err); - assert.equal(cid, categoryObj.cid); - done(); - }); - }); - }); - - it('should properly update sets when post is moved', (done) => { - let movedPost; - let previousPost; - let topic2LastReply; - let tid1; - let tid2; - const cid1 = topic.categoryId; - let cid2; - function checkCidSets(post1, post2, callback) { - async.waterfall([ - function (next) { - async.parallel({ - topicData: function (next) { - topics.getTopicsFields([tid1, tid2], ['lastposttime', 'postcount'], next); - }, - scores1: function (next) { - db.sortedSetsScore([ - `cid:${cid1}:tids`, - `cid:${cid1}:tids:lastposttime`, - `cid:${cid1}:tids:posts`, - ], tid1, next); - }, - scores2: function (next) { - db.sortedSetsScore([ - `cid:${cid2}:tids`, - `cid:${cid2}:tids:lastposttime`, - `cid:${cid2}:tids:posts`, - ], tid2, next); - }, - posts1: function (next) { - db.getSortedSetRangeWithScores(`tid:${tid1}:posts`, 0, -1, next); - }, - posts2: function (next) { - db.getSortedSetRangeWithScores(`tid:${tid2}:posts`, 0, -1, next); - }, - }, next); - }, - function (results, next) { - const assertMsg = `${JSON.stringify(results.posts1)}\n${JSON.stringify(results.posts2)}`; - assert.equal(results.topicData[0].postcount, results.scores1[2], assertMsg); - assert.equal(results.topicData[1].postcount, results.scores2[2], assertMsg); - assert.equal(results.topicData[0].lastposttime, post1.timestamp, assertMsg); - assert.equal(results.topicData[1].lastposttime, post2.timestamp, assertMsg); - assert.equal(results.topicData[0].lastposttime, results.scores1[0], assertMsg); - assert.equal(results.topicData[1].lastposttime, results.scores2[0], assertMsg); - assert.equal(results.topicData[0].lastposttime, results.scores1[1], assertMsg); - assert.equal(results.topicData[1].lastposttime, results.scores2[1], assertMsg); - - next(); - }, - ], callback); - } - - async.waterfall([ - function (next) { - categories.create({ - name: 'move to this category', - description: 'Test category created by testing script', - }, next); - }, - function (category, next) { - cid2 = category.cid; - topics.post({ uid: adminUid, title: 'topic1', content: 'topic 1 mainPost', cid: cid1 }, next); - }, - function (result, next) { - tid1 = result.topicData.tid; - topics.reply({ uid: adminUid, content: 'topic 1 reply 1', tid: tid1 }, next); - }, - function (postData, next) { - previousPost = postData; - topics.reply({ uid: adminUid, content: 'topic 1 reply 2', tid: tid1 }, next); - }, - function (postData, next) { - movedPost = postData; - topics.post({ uid: adminUid, title: 'topic2', content: 'topic 2 mainpost', cid: cid2 }, next); - }, - function (results, next) { - tid2 = results.topicData.tid; - topics.reply({ uid: adminUid, content: 'topic 2 reply 1', tid: tid2 }, next); - }, - function (postData, next) { - topic2LastReply = postData; - checkCidSets(movedPost, postData, next); - }, - function (next) { - db.isMemberOfSortedSets([`cid:${cid1}:pids`, `cid:${cid2}:pids`], movedPost.pid, next); - }, - function (isMember, next) { - assert.deepEqual(isMember, [true, false]); - categories.getCategoriesFields([cid1, cid2], ['post_count'], next); - }, - function (categoryData, next) { - assert.equal(categoryData[0].post_count, 4); - assert.equal(categoryData[1].post_count, 2); - topics.movePostToTopic(1, movedPost.pid, tid2, next); - }, - function (next) { - checkCidSets(previousPost, topic2LastReply, next); - }, - function (next) { - db.isMemberOfSortedSets([`cid:${cid1}:pids`, `cid:${cid2}:pids`], movedPost.pid, next); - }, - function (isMember, next) { - assert.deepEqual(isMember, [false, true]); - categories.getCategoriesFields([cid1, cid2], ['post_count'], next); - }, - function (categoryData, next) { - assert.equal(categoryData[0].post_count, 3); - assert.equal(categoryData[1].post_count, 3); - next(); - }, - ], done); - }); - - it('should fail to purge topic if user does not have privilege', async () => { - const topic1 = await topics.post({ - uid: adminUid, - title: 'topic for purge test', - content: 'topic content', - cid: categoryObj.cid, - }); - const tid1 = topic1.topicData.tid; - const globalModUid = await User.create({ username: 'global mod' }); - await groups.join('Global Moderators', globalModUid); - await privileges.categories.rescind(['groups:purge'], categoryObj.cid, 'Global Moderators'); - try { - await apiTopics.purge({ uid: globalModUid }, { tids: [tid1], cid: categoryObj.cid }); - } catch (err) { - assert.equal(err.message, '[[error:no-privileges]]'); - await privileges.categories.give(['groups:purge'], categoryObj.cid, 'Global Moderators'); - return; - } - assert(false); - }); - - it('should purge the topic', async () => { - await apiTopics.purge({ uid: adminUid }, { tids: [newTopic.tid], cid: categoryObj.cid }); - const isMember = await db.isSortedSetMember(`uid:${followerUid}:followed_tids`, newTopic.tid); - assert.strictEqual(false, isMember); - }); - - it('should not allow user to restore their topic if it was deleted by an admin', async () => { - const result = await topics.post({ - uid: fooUid, - title: 'topic for restore test', - content: 'topic content', - cid: categoryObj.cid, - }); - await apiTopics.delete({ uid: adminUid }, { tids: [result.topicData.tid], cid: categoryObj.cid }); - try { - await apiTopics.restore({ uid: fooUid }, { tids: [result.topicData.tid], cid: categoryObj.cid }); - } catch (err) { - return assert.strictEqual(err.message, '[[error:no-privileges]]'); - } - assert(false); - }); - - it('should not allow user to private the topic if they are not the owner', async () => { - const result = await topics.post({ - uid: fooUid, - title: 'topic for privating test', - content: 'topic content', - cid: categoryObj.cid, - }); - try { - await apiTopics.private({ uid: badUserUid }, { tids: [result.topicData.tid], cid: categoryObj.cid }); - } catch (err) { - return assert.strictEqual(err.message, '[[error:no-privileges]]'); - } - assert(false); - }); - }); - - describe('order pinned topics', () => { - let tid1; - let tid2; - let tid3; - before((done) => { - function createTopic(callback) { - topics.post({ - uid: topic.userId, - title: 'topic for test', - content: 'topic content', - cid: topic.categoryId, - }, callback); - } - async.series({ - topic1: function (next) { - createTopic(next); - }, - topic2: function (next) { - createTopic(next); - }, - topic3: function (next) { - createTopic(next); - }, - }, (err, results) => { - assert.ifError(err); - tid1 = results.topic1.topicData.tid; - tid2 = results.topic2.topicData.tid; - tid3 = results.topic3.topicData.tid; - async.series([ - function (next) { - topics.tools.pin(tid1, adminUid, next); - }, - function (next) { - // artificial timeout so pin time is different on redis sometimes scores are indentical - setTimeout(() => { - topics.tools.pin(tid2, adminUid, next); - }, 5); - }, - ], done); - }); - }); - - const socketTopics = require('../src/socket.io/topics'); - it('should error with invalid data', (done) => { - socketTopics.orderPinnedTopics({ uid: adminUid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should error with invalid data', (done) => { - socketTopics.orderPinnedTopics({ uid: adminUid }, [null, null], (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should error with unprivileged user', (done) => { - socketTopics.orderPinnedTopics({ uid: 0 }, { tid: tid1, order: 1 }, (err) => { - assert.equal(err.message, '[[error:no-privileges]]'); - done(); - }); - }); - - it('should not do anything if topics are not pinned', (done) => { - socketTopics.orderPinnedTopics({ uid: adminUid }, { tid: tid3, order: 1 }, (err) => { - assert.ifError(err); - db.isSortedSetMember(`cid:${topic.categoryId}:tids:pinned`, tid3, (err, isMember) => { - assert.ifError(err); - assert(!isMember); - done(); - }); - }); - }); - - it('should order pinned topics', (done) => { - db.getSortedSetRevRange(`cid:${topic.categoryId}:tids:pinned`, 0, -1, (err, pinnedTids) => { - assert.ifError(err); - assert.equal(pinnedTids[0], tid2); - assert.equal(pinnedTids[1], tid1); - socketTopics.orderPinnedTopics({ uid: adminUid }, { tid: tid1, order: 0 }, (err) => { - assert.ifError(err); - db.getSortedSetRevRange(`cid:${topic.categoryId}:tids:pinned`, 0, -1, (err, pinnedTids) => { - assert.ifError(err); - assert.equal(pinnedTids[0], tid1); - assert.equal(pinnedTids[1], tid2); - done(); - }); - }); - }); - }); - }); - - - describe('.ignore', () => { - let newTid; - let uid; - let newTopic; - before((done) => { - uid = topic.userId; - async.waterfall([ - function (done) { - topics.post({ uid: topic.userId, title: 'Topic to be ignored', content: 'Just ignore me, please!', cid: topic.categoryId }, (err, result) => { - if (err) { - return done(err); - } - - newTopic = result.topicData; - newTid = newTopic.tid; - done(); - }); - }, - function (done) { - topics.markUnread(newTid, uid, done); - }, - ], done); - }); - - it('should not appear in the unread list', (done) => { - async.waterfall([ - function (done) { - topics.ignore(newTid, uid, done); - }, - function (done) { - topics.getUnreadTopics({ cid: 0, uid: uid, start: 0, stop: -1, filter: '' }, done); - }, - function (results, done) { - const { topics } = results; - const tids = topics.map(topic => topic.tid); - assert.equal(tids.indexOf(newTid), -1, 'The topic appeared in the unread list.'); - done(); - }, - ], done); - }); - - it('should not appear as unread in the recent list', (done) => { - async.waterfall([ - function (done) { - topics.ignore(newTid, uid, done); - }, - function (done) { - topics.getLatestTopics({ - uid: uid, - start: 0, - stop: -1, - term: 'year', - }, done); - }, - function (results, done) { - const { topics } = results; - let topic; - let i; - for (i = 0; i < topics.length; i += 1) { - if (topics[i].tid === parseInt(newTid, 10)) { - assert.equal(false, topics[i].unread, 'ignored topic was marked as unread in recent list'); - return done(); - } - } - assert.ok(topic, 'topic didn\'t appear in the recent list'); - done(); - }, - ], done); - }); - - it('should appear as unread again when marked as reading', (done) => { - async.waterfall([ - function (done) { - topics.ignore(newTid, uid, done); - }, - function (done) { - topics.follow(newTid, uid, done); - }, - function (done) { - topics.getUnreadTopics({ cid: 0, uid: uid, start: 0, stop: -1, filter: '' }, done); - }, - function (results, done) { - const { topics } = results; - const tids = topics.map(topic => topic.tid); - assert.notEqual(tids.indexOf(newTid), -1, 'The topic did not appear in the unread list.'); - done(); - }, - ], done); - }); - - it('should appear as unread again when marked as following', (done) => { - async.waterfall([ - function (done) { - topics.ignore(newTid, uid, done); - }, - function (done) { - topics.follow(newTid, uid, done); - }, - function (done) { - topics.getUnreadTopics({ cid: 0, uid: uid, start: 0, stop: -1, filter: '' }, done); - }, - function (results, done) { - const { topics } = results; - const tids = topics.map(topic => topic.tid); - assert.notEqual(tids.indexOf(newTid), -1, 'The topic did not appear in the unread list.'); - done(); - }, - ], done); - }); - }); - - describe('.fork', () => { - let newTopic; - const replies = []; - let topicPids; - const originalBookmark = 6; - function postReply(next) { - topics.reply({ uid: topic.userId, content: `test post ${replies.length}`, tid: newTopic.tid }, (err, result) => { - assert.equal(err, null, 'was created with error'); - assert.ok(result); - replies.push(result); - next(); - }); - } - - before((done) => { - async.waterfall([ - function (next) { - groups.join('administrators', topic.userId, next); - }, - function (next) { - topics.post({ - uid: topic.userId, - title: topic.title, - content: topic.content, - cid: topic.categoryId, - }, (err, result) => { - assert.ifError(err); - newTopic = result.topicData; - next(); - }); - }, - function (next) { postReply(next); }, - function (next) { postReply(next); }, - function (next) { postReply(next); }, - function (next) { postReply(next); }, - function (next) { postReply(next); }, - function (next) { postReply(next); }, - function (next) { postReply(next); }, - function (next) { postReply(next); }, - function (next) { postReply(next); }, - function (next) { postReply(next); }, - function (next) { postReply(next); }, - function (next) { postReply(next); }, - function (next) { - topicPids = replies.map(reply => reply.pid); - socketTopics.bookmark({ uid: topic.userId }, { tid: newTopic.tid, index: originalBookmark }, next); - }, - ], done); - }); - - it('should fail with invalid data', (done) => { - socketTopics.bookmark({ uid: topic.userId }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should have 12 replies', (done) => { - assert.equal(12, replies.length); - done(); - }); - - it('should fail with invalid data', (done) => { - socketTopics.createTopicFromPosts({ uid: 0 }, null, (err) => { - assert.equal(err.message, '[[error:not-logged-in]]'); - done(); - }); - }); - - it('should fail with invalid data', (done) => { - socketTopics.createTopicFromPosts({ uid: adminUid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should not update the user\'s bookmark', (done) => { - async.waterfall([ - function (next) { - socketTopics.createTopicFromPosts({ uid: topic.userId }, { - title: 'Fork test, no bookmark update', - pids: topicPids.slice(-2), - fromTid: newTopic.tid, - }, next); - }, - function (forkedTopicData, next) { - topics.getUserBookmark(newTopic.tid, topic.userId, next); - }, - function (bookmark, next) { - assert.equal(originalBookmark, bookmark); - next(); - }, - ], done); - }); - - it('should update the user\'s bookmark ', (done) => { - async.waterfall([ - function (next) { - topics.createTopicFromPosts( - topic.userId, - 'Fork test, no bookmark update', - topicPids.slice(1, 3), - newTopic.tid, - next - ); - }, - function (forkedTopicData, next) { - topics.getUserBookmark(newTopic.tid, topic.userId, next); - }, - function (bookmark, next) { - assert.equal(originalBookmark - 2, bookmark); - next(); - }, - ], done); - }); - - it('should properly update topic vote count after forking', async () => { - const result = await topics.post({ uid: fooUid, cid: categoryObj.cid, title: 'fork vote test', content: 'main post' }); - const reply1 = await topics.reply({ tid: result.topicData.tid, uid: fooUid, content: 'test reply 1' }); - const reply2 = await topics.reply({ tid: result.topicData.tid, uid: fooUid, content: 'test reply 2' }); - const reply3 = await topics.reply({ tid: result.topicData.tid, uid: fooUid, content: 'test reply 3' }); - await posts.upvote(result.postData.pid, adminUid); - await posts.upvote(reply1.pid, adminUid); - assert.strictEqual(await db.sortedSetScore('topics:votes', result.topicData.tid), 1); - assert.strictEqual(await db.sortedSetScore(`cid:${categoryObj.cid}:tids:votes`, result.topicData.tid), 1); - const newTopic = await topics.createTopicFromPosts(adminUid, 'Fork test, vote update', [reply1.pid, reply2.pid], result.topicData.tid); - - assert.strictEqual(await db.sortedSetScore('topics:votes', newTopic.tid), 1); - assert.strictEqual(await db.sortedSetScore(`cid:${categoryObj.cid}:tids:votes`, newTopic.tid), 1); - assert.strictEqual(await topics.getTopicField(newTopic.tid, 'upvotes'), 1); - }); - }); - - describe('controller', () => { - let topicData; - - before((done) => { - topics.post({ - uid: topic.userId, - title: 'topic for controller test', - content: 'topic content', - cid: topic.categoryId, - thumb: 'http://i.imgur.com/64iBdBD.jpg', - }, (err, result) => { - assert.ifError(err); - assert.ok(result); - topicData = result.topicData; - done(); - }); - }); - - it('should load topic', (done) => { - request(`${nconf.get('url')}/topic/${topicData.slug}`, (err, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load topic api data', (done) => { - request(`${nconf.get('url')}/api/topic/${topicData.slug}`, { json: true }, (err, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 200); - assert.strictEqual(body._header.tags.meta.find(t => t.name === 'description').content, 'topic content'); - assert.strictEqual(body._header.tags.meta.find(t => t.property === 'og:description').content, 'topic content'); - done(); - }); - }); - - it('should 404 if post index is invalid', (done) => { - request(`${nconf.get('url')}/topic/${topicData.slug}/derp`, (err, response) => { - assert.ifError(err); - assert.equal(response.statusCode, 404); - done(); - }); - }); - - it('should 404 if topic does not exist', (done) => { - request(`${nconf.get('url')}/topic/123123/does-not-exist`, (err, response) => { - assert.ifError(err); - assert.equal(response.statusCode, 404); - done(); - }); - }); - - it('should 401 if not allowed to read as guest', (done) => { - const privileges = require('../src/privileges'); - privileges.categories.rescind(['groups:topics:read'], topicData.cid, 'guests', (err) => { - assert.ifError(err); - request(`${nconf.get('url')}/api/topic/${topicData.slug}`, (err, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 401); - assert(body); - privileges.categories.give(['groups:topics:read'], topicData.cid, 'guests', done); - }); - }); - }); - - it('should redirect to correct topic if slug is missing', (done) => { - request(`${nconf.get('url')}/topic/${topicData.tid}/herpderp/1?page=2`, (err, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should redirect if post index is out of range', (done) => { - request(`${nconf.get('url')}/api/topic/${topicData.slug}/-1`, { json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(res.headers['x-redirect'], `/topic/${topicData.tid}/topic-for-controller-test`); - assert.equal(body, `/topic/${topicData.tid}/topic-for-controller-test`); - done(); - }); - }); - - it('should 404 if page is out of bounds', (done) => { - const meta = require('../src/meta'); - meta.config.usePagination = 1; - request(`${nconf.get('url')}/topic/${topicData.slug}?page=100`, (err, response) => { - assert.ifError(err); - assert.equal(response.statusCode, 404); - done(); - }); - }); - - it('should mark topic read', (done) => { - request(`${nconf.get('url')}/topic/${topicData.slug}`, { - jar: adminJar, - }, (err, res) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - topics.hasReadTopics([topicData.tid], adminUid, (err, hasRead) => { - assert.ifError(err); - assert.equal(hasRead[0], true); - done(); - }); - }); - }); - - it('should 404 if tid is not a number', (done) => { - request(`${nconf.get('url')}/api/topic/teaser/nan`, { json: true }, (err, response) => { - assert.ifError(err); - assert.equal(response.statusCode, 404); - done(); - }); - }); - - it('should 403 if cant read', (done) => { - request(`${nconf.get('url')}/api/topic/teaser/${123123}`, { json: true }, (err, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 403); - assert.equal(body, '[[error:no-privileges]]'); - - done(); - }); - }); - - it('should load topic teaser', (done) => { - request(`${nconf.get('url')}/api/topic/teaser/${topicData.tid}`, { json: true }, (err, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 200); - assert(body); - assert.equal(body.tid, topicData.tid); - assert.equal(body.content, 'topic content'); - assert(body.user); - assert(body.topic); - assert(body.category); - done(); - }); - }); - - - it('should 404 if tid is not a number', (done) => { - request(`${nconf.get('url')}/api/topic/pagination/nan`, { json: true }, (err, response) => { - assert.ifError(err); - assert.equal(response.statusCode, 404); - done(); - }); - }); - - it('should 404 if tid does not exist', (done) => { - request(`${nconf.get('url')}/api/topic/pagination/1231231`, { json: true }, (err, response) => { - assert.ifError(err); - assert.equal(response.statusCode, 404); - done(); - }); - }); - - it('should load pagination', (done) => { - request(`${nconf.get('url')}/api/topic/pagination/${topicData.tid}`, { json: true }, (err, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 200); - assert(body); - assert.deepEqual(body.pagination, { - prev: { page: 1, active: false }, - next: { page: 1, active: false }, - first: { page: 1, active: true }, - last: { page: 1, active: true }, - rel: [], - pages: [], - currentPage: 1, - pageCount: 1, - }); - done(); - }); - }); - }); - - - describe('infinitescroll', () => { - const socketTopics = require('../src/socket.io/topics'); - let tid; - before((done) => { - topics.post({ - uid: topic.userId, - title: topic.title, - content: topic.content, - cid: topic.categoryId, - }, (err, result) => { - assert.ifError(err); - tid = result.topicData.tid; - done(); - }); - }); - - it('should error with invalid data', (done) => { - socketTopics.loadMore({ uid: adminUid }, {}, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should infinite load topic posts', (done) => { - socketTopics.loadMore({ uid: adminUid }, { tid: tid, after: 0, count: 10 }, (err, data) => { - assert.ifError(err); - assert(data.posts); - assert(data.privileges); - done(); - }); - }); - }); - - describe('suggested topics', () => { - let tid1; - let tid3; - before((done) => { - async.series({ - topic1: function (next) { - topics.post({ uid: adminUid, tags: ['nodebb'], title: 'topic title 1', content: 'topic 1 content', cid: topic.categoryId }, next); - }, - topic2: function (next) { - topics.post({ uid: adminUid, tags: ['nodebb'], title: 'topic title 2', content: 'topic 2 content', cid: topic.categoryId }, next); - }, - topic3: function (next) { - topics.post({ uid: adminUid, tags: [], title: 'topic title 3', content: 'topic 3 content', cid: topic.categoryId }, next); - }, - }, (err, results) => { - assert.ifError(err); - tid1 = results.topic1.topicData.tid; - tid3 = results.topic3.topicData.tid; - done(); - }); - }); - - it('should return suggested topics', (done) => { - topics.getSuggestedTopics(tid1, adminUid, 0, -1, (err, topics) => { - assert.ifError(err); - assert(Array.isArray(topics)); - done(); - }); - }); - - it('should return suggested topics', (done) => { - topics.getSuggestedTopics(tid3, adminUid, 0, 2, (err, topics) => { - assert.ifError(err); - assert(Array.isArray(topics)); - done(); - }); - }); - }); - - describe('unread', () => { - const socketTopics = require('../src/socket.io/topics'); - let tid; - let mainPid; - let uid; - before((done) => { - async.parallel({ - topic: function (next) { - topics.post({ uid: topic.userId, title: 'unread topic', content: 'unread topic content', cid: topic.categoryId }, next); - }, - joeUid: function (next) { - User.create({ username: 'regularJoe' }, next); - }, - }, (err, results) => { - assert.ifError(err); - tid = results.topic.topicData.tid; - mainPid = results.topic.postData.pid; - uid = results.joeUid; - done(); - }); - }); - - it('should fail with invalid data', (done) => { - socketTopics.markUnread({ uid: adminUid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should fail if topic does not exist', (done) => { - socketTopics.markUnread({ uid: adminUid }, 1231082, (err) => { - assert.equal(err.message, '[[error:no-topic]]'); - done(); - }); - }); - - it('should mark topic unread', (done) => { - socketTopics.markUnread({ uid: adminUid }, tid, (err) => { - assert.ifError(err); - topics.hasReadTopic(tid, adminUid, (err, hasRead) => { - assert.ifError(err); - assert.equal(hasRead, false); - done(); - }); - }); - }); - - it('should fail with invalid data', (done) => { - socketTopics.markAsRead({ uid: 0 }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should mark topic read', (done) => { - socketTopics.markAsRead({ uid: adminUid }, [tid], (err) => { - assert.ifError(err); - topics.hasReadTopic(tid, adminUid, (err, hasRead) => { - assert.ifError(err); - assert(hasRead); - done(); - }); - }); - }); - - it('should fail with invalid data', (done) => { - socketTopics.markTopicNotificationsRead({ uid: 0 }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should mark topic notifications read', async () => { - await apiTopics.follow({ uid: adminUid }, { tid: tid }); - const data = await topics.reply({ uid: uid, timestamp: Date.now(), content: 'some content', tid: tid }); - await sleep(2500); - let count = await User.notifications.getUnreadCount(adminUid); - assert.strictEqual(count, 1); - await socketTopics.markTopicNotificationsRead({ uid: adminUid }, [tid]); - count = await User.notifications.getUnreadCount(adminUid); - assert.strictEqual(count, 0); - }); - - it('should fail with invalid data', (done) => { - socketTopics.markAllRead({ uid: 0 }, null, (err) => { - assert.equal(err.message, '[[error:invalid-uid]]'); - done(); - }); - }); - - it('should mark all read', (done) => { - socketTopics.markUnread({ uid: adminUid }, tid, (err) => { - assert.ifError(err); - socketTopics.markAllRead({ uid: adminUid }, {}, (err) => { - assert.ifError(err); - topics.hasReadTopic(tid, adminUid, (err, hasRead) => { - assert.ifError(err); - assert(hasRead); - done(); - }); - }); - }); - }); - - it('should mark category topics read', (done) => { - socketTopics.markUnread({ uid: adminUid }, tid, (err) => { - assert.ifError(err); - socketTopics.markCategoryTopicsRead({ uid: adminUid }, topic.categoryId, (err) => { - assert.ifError(err); - topics.hasReadTopic(tid, adminUid, (err, hasRead) => { - assert.ifError(err); - assert(hasRead); - done(); - }); - }); - }); - }); - - it('should fail with invalid data', (done) => { - socketTopics.markAsUnreadForAll({ uid: adminUid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-tid]]'); - done(); - }); - }); - - it('should fail with invalid data', (done) => { - socketTopics.markAsUnreadForAll({ uid: 0 }, [tid], (err) => { - assert.equal(err.message, '[[error:no-privileges]]'); - done(); - }); - }); - - it('should fail if user is not admin', (done) => { - socketTopics.markAsUnreadForAll({ uid: uid }, [tid], (err) => { - assert.equal(err.message, '[[error:no-privileges]]'); - done(); - }); - }); - - it('should fail if topic does not exist', (done) => { - socketTopics.markAsUnreadForAll({ uid: uid }, [12312313], (err) => { - assert.equal(err.message, '[[error:no-topic]]'); - done(); - }); - }); - - it('should mark topic unread for everyone', (done) => { - socketTopics.markAsUnreadForAll({ uid: adminUid }, [tid], (err) => { - assert.ifError(err); - async.parallel({ - adminRead: function (next) { - topics.hasReadTopic(tid, adminUid, next); - }, - regularRead: function (next) { - topics.hasReadTopic(tid, uid, next); - }, - }, (err, results) => { - assert.ifError(err); - assert.equal(results.adminRead, false); - assert.equal(results.regularRead, false); - done(); - }); - }); - }); - - it('should not do anything if tids is empty array', (done) => { - socketTopics.markAsRead({ uid: adminUid }, [], (err, markedRead) => { - assert.ifError(err); - assert(!markedRead); - done(); - }); - }); - - it('should not return topics in category you cant read', (done) => { - let privateCid; - let privateTid; - async.waterfall([ - function (next) { - categories.create({ - name: 'private category', - description: 'private category', - }, next); - }, - function (category, next) { - privateCid = category.cid; - privileges.categories.rescind(['groups:topics:read'], category.cid, 'registered-users', next); - }, - function (next) { - topics.post({ uid: adminUid, title: 'topic in private category', content: 'registered-users cant see this', cid: privateCid }, next); - }, - function (data, next) { - privateTid = data.topicData.tid; - topics.getUnreadTids({ uid: uid }, next); - }, - function (unreadTids, next) { - unreadTids = unreadTids.map(String); - assert(!unreadTids.includes(String(privateTid))); - next(); - }, - ], done); - }); - - it('should not return topics in category you ignored/not watching', (done) => { - let ignoredCid; - let tid; - async.waterfall([ - function (next) { - categories.create({ - name: 'ignored category', - description: 'ignored category', - }, next); - }, - function (category, next) { - ignoredCid = category.cid; - privileges.categories.rescind(['groups:topics:read'], category.cid, 'registered-users', next); - }, - function (next) { - topics.post({ uid: adminUid, title: 'topic in private category', content: 'registered-users cant see this', cid: ignoredCid }, next); - }, - function (data, next) { - tid = data.topicData.tid; - User.ignoreCategory(uid, ignoredCid, next); - }, - function (next) { - topics.getUnreadTids({ uid: uid }, next); - }, - function (unreadTids, next) { - unreadTids = unreadTids.map(String); - assert(!unreadTids.includes(String(tid))); - next(); - }, - ], done); - }); - - it('should not return topic as unread if new post is from blocked user', (done) => { - let blockedUid; - let topic; - async.waterfall([ - function (next) { - topics.post({ uid: adminUid, title: 'will not get as unread', content: 'not unread', cid: categoryObj.cid }, next); - }, - function (result, next) { - topic = result.topicData; - User.create({ username: 'blockedunread' }, next); - }, - function (uid, next) { - blockedUid = uid; - User.blocks.add(uid, adminUid, next); - }, - function (next) { - topics.reply({ uid: blockedUid, content: 'post from blocked user', tid: topic.tid }, next); - }, - function (result, next) { - topics.getUnreadTids({ cid: 0, uid: adminUid }, next); - }, - function (unreadTids, next) { - assert(!unreadTids.includes(topic.tid)); - User.blocks.remove(blockedUid, adminUid, next); - }, - ], done); - }); - - it('should not return topic as unread if topic is deleted', async () => { - const uid = await User.create({ username: 'regularJoe' }); - const result = await topics.post({ uid: adminUid, title: 'deleted unread', content: 'not unread', cid: categoryObj.cid }); - await topics.delete(result.topicData.tid, adminUid); - const unreadTids = await topics.getUnreadTids({ cid: 0, uid: uid }); - assert(!unreadTids.includes(result.topicData.tid)); - }); - }); - - describe('tags', () => { - const socketTopics = require('../src/socket.io/topics'); - const socketAdmin = require('../src/socket.io/admin'); - - before((done) => { - async.series([ - function (next) { - topics.post({ uid: adminUid, tags: ['php', 'nosql', 'psql', 'nodebb', 'node icon'], title: 'topic title 1', content: 'topic 1 content', cid: topic.categoryId }, next); - }, - function (next) { - topics.post({ uid: adminUid, tags: ['javascript', 'mysql', 'python', 'nodejs'], title: 'topic title 2', content: 'topic 2 content', cid: topic.categoryId }, next); - }, - ], (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should return empty array if query is falsy', (done) => { - socketTopics.autocompleteTags({ uid: adminUid }, { query: '' }, (err, data) => { - assert.ifError(err); - assert.deepEqual([], data); - done(); - }); - }); - - it('should autocomplete tags', (done) => { - socketTopics.autocompleteTags({ uid: adminUid }, { query: 'p' }, (err, data) => { - assert.ifError(err); - ['php', 'psql', 'python'].forEach((tag) => { - assert.notEqual(data.indexOf(tag), -1); - }); - done(); - }); - }); - - it('should return empty array if query is falsy', (done) => { - socketTopics.searchTags({ uid: adminUid }, { query: '' }, (err, data) => { - assert.ifError(err); - assert.deepEqual([], data); - done(); - }); - }); - - it('should search tags', (done) => { - socketTopics.searchTags({ uid: adminUid }, { query: 'no' }, (err, data) => { - assert.ifError(err); - ['nodebb', 'nodejs', 'nosql'].forEach((tag) => { - assert.notEqual(data.indexOf(tag), -1); - }); - done(); - }); - }); - - it('should return empty array if query is falsy', (done) => { - socketTopics.searchAndLoadTags({ uid: adminUid }, { query: '' }, (err, data) => { - assert.ifError(err); - assert.equal(data.matchCount, 0); - assert.equal(data.pageCount, 1); - assert.deepEqual(data.tags, []); - done(); - }); - }); - - it('should search and load tags', (done) => { - socketTopics.searchAndLoadTags({ uid: adminUid }, { query: 'no' }, (err, data) => { - assert.ifError(err); - assert.equal(data.matchCount, 4); - assert.equal(data.pageCount, 1); - const tagData = [ - { value: 'nodebb', valueEscaped: 'nodebb', valueEncoded: 'nodebb', score: 3, class: 'nodebb' }, - { value: 'node icon', valueEscaped: 'node icon', valueEncoded: 'node%20icon', score: 1, class: 'node-icon' }, - { value: 'nodejs', valueEscaped: 'nodejs', valueEncoded: 'nodejs', score: 1, class: 'nodejs' }, - { value: 'nosql', valueEscaped: 'nosql', valueEncoded: 'nosql', score: 1, class: 'nosql' }, - ]; - assert.deepEqual(data.tags, tagData); - - done(); - }); - }); - - it('should return error if data is invalid', (done) => { - socketTopics.loadMoreTags({ uid: adminUid }, { after: 'asd' }, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should load more tags', (done) => { - socketTopics.loadMoreTags({ uid: adminUid }, { after: 0 }, (err, data) => { - assert.ifError(err); - assert(Array.isArray(data.tags)); - assert.equal(data.nextStart, 100); - done(); - }); - }); - - it('should error if data is invalid', (done) => { - socketAdmin.tags.create({ uid: adminUid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should error if tag is invalid', (done) => { - socketAdmin.tags.create({ uid: adminUid }, { tag: '' }, (err) => { - assert.equal(err.message, '[[error:invalid-tag]]'); - done(); - }); - }); - - it('should error if tag is too short', (done) => { - socketAdmin.tags.create({ uid: adminUid }, { tag: 'as' }, (err) => { - assert.equal(err.message, '[[error:tag-too-short]]'); - done(); - }); - }); - - it('should create empty tag', (done) => { - socketAdmin.tags.create({ uid: adminUid }, { tag: 'emptytag' }, (err) => { - assert.ifError(err); - db.sortedSetScore('tags:topic:count', 'emptytag', (err, score) => { - assert.ifError(err); - assert.equal(score, 0); - done(); - }); - }); - }); - - it('should do nothing if tag exists', (done) => { - socketAdmin.tags.create({ uid: adminUid }, { tag: 'emptytag' }, (err) => { - assert.ifError(err); - db.sortedSetScore('tags:topic:count', 'emptytag', (err, score) => { - assert.ifError(err); - assert.equal(score, 0); - done(); - }); - }); - }); - - - it('should rename tags', async () => { - const result1 = await topics.post({ uid: adminUid, tags: ['plugins'], title: 'topic tagged with plugins', content: 'topic 1 content', cid: topic.categoryId }); - const result2 = await topics.post({ uid: adminUid, tags: ['plugin'], title: 'topic tagged with plugin', content: 'topic 2 content', cid: topic.categoryId }); - const data1 = await topics.getTopicData(result2.topicData.tid); - - await socketAdmin.tags.rename({ uid: adminUid }, [{ - value: 'plugin', - newName: 'plugins', - }]); - - const tids = await topics.getTagTids('plugins', 0, -1); - assert.strictEqual(tids.length, 2); - const tags = await topics.getTopicTags(result2.topicData.tid); - - const data = await topics.getTopicData(result2.topicData.tid); - assert.strictEqual(tags.length, 1); - assert.strictEqual(tags[0], 'plugins'); - }); - - it('should return related topics', (done) => { - const meta = require('../src/meta'); - meta.config.maximumRelatedTopics = 2; - const topicData = { - tags: [{ value: 'javascript' }], - }; - topics.getRelatedTopics(topicData, 0, (err, data) => { - assert.ifError(err); - assert(Array.isArray(data)); - assert.equal(data[0].title, 'topic title 2'); - meta.config.maximumRelatedTopics = 0; - done(); - }); - }); - - it('should return error with invalid data', (done) => { - socketAdmin.tags.deleteTags({ uid: adminUid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should do nothing if arrays is empty', (done) => { - socketAdmin.tags.deleteTags({ uid: adminUid }, { tags: [] }, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should delete tags', (done) => { - socketAdmin.tags.create({ uid: adminUid }, { tag: 'emptytag2' }, (err) => { - assert.ifError(err); - socketAdmin.tags.deleteTags({ uid: adminUid }, { tags: ['emptytag', 'emptytag2', 'nodebb', 'nodejs'] }, (err) => { - assert.ifError(err); - db.getObjects(['tag:emptytag', 'tag:emptytag2'], (err, data) => { - assert.ifError(err); - assert(!data[0]); - assert(!data[1]); - done(); - }); - }); - }); - }); - - it('should delete tag', (done) => { - topics.deleteTag('javascript', (err) => { - assert.ifError(err); - db.getObject('tag:javascript', (err, data) => { - assert.ifError(err); - assert(!data); - done(); - }); - }); - }); - - it('should delete category tag as well', async () => { - const category = await categories.create({ name: 'delete category' }); - const { cid } = category; - await topics.post({ uid: adminUid, tags: ['willbedeleted', 'notthis'], title: 'tag topic', content: 'topic 1 content', cid: cid }); - let categoryTags = await topics.getCategoryTags(cid, 0, -1); - assert(categoryTags.includes('willbedeleted')); - assert(categoryTags.includes('notthis')); - await topics.deleteTags(['willbedeleted']); - categoryTags = await topics.getCategoryTags(cid, 0, -1); - assert(!categoryTags.includes('willbedeleted')); - assert(categoryTags.includes('notthis')); - }); - - it('should add and remove tags from topics properly', async () => { - const category = await categories.create({ name: 'add/remove category' }); - const { cid } = category; - const result = await topics.post({ uid: adminUid, tags: ['tag4', 'tag2', 'tag1', 'tag3'], title: 'tag topic', content: 'topic 1 content', cid: cid }); - const { tid } = result.topicData; - - let tags = await topics.getTopicTags(tid); - let categoryTags = await topics.getCategoryTags(cid, 0, -1); - assert.deepStrictEqual(tags.sort(), ['tag1', 'tag2', 'tag3', 'tag4']); - assert.deepStrictEqual(categoryTags.sort(), ['tag1', 'tag2', 'tag3', 'tag4']); - - await topics.addTags(['tag7', 'tag6', 'tag5'], [tid]); - tags = await topics.getTopicTags(tid); - categoryTags = await topics.getCategoryTags(cid, 0, -1); - assert.deepStrictEqual(tags.sort(), ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7']); - assert.deepStrictEqual(categoryTags.sort(), ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7']); - - await topics.removeTags(['tag1', 'tag3', 'tag5', 'tag7'], [tid]); - tags = await topics.getTopicTags(tid); - categoryTags = await topics.getCategoryTags(cid, 0, -1); - assert.deepStrictEqual(tags.sort(), ['tag2', 'tag4', 'tag6']); - assert.deepStrictEqual(categoryTags.sort(), ['tag2', 'tag4', 'tag6']); - }); - - it('should respect minTags', async () => { - const oldValue = meta.config.minimumTagsPerTopic; - meta.config.minimumTagsPerTopic = 2; - let err; - try { - await topics.post({ uid: adminUid, tags: ['tag4'], title: 'tag topic', content: 'topic 1 content', cid: topic.categoryId }); - } catch (_err) { - err = _err; - } - assert.equal(err.message, `[[error:not-enough-tags, ${meta.config.minimumTagsPerTopic}]]`); - meta.config.minimumTagsPerTopic = oldValue; - }); - - it('should respect maxTags', async () => { - const oldValue = meta.config.maximumTagsPerTopic; - meta.config.maximumTagsPerTopic = 2; - let err; - try { - await topics.post({ uid: adminUid, tags: ['tag1', 'tag2', 'tag3'], title: 'tag topic', content: 'topic 1 content', cid: topic.categoryId }); - } catch (_err) { - err = _err; - } - assert.equal(err.message, `[[error:too-many-tags, ${meta.config.maximumTagsPerTopic}]]`); - meta.config.maximumTagsPerTopic = oldValue; - }); - - it('should respect minTags per category', async () => { - const minTags = 2; - await categories.setCategoryField(topic.categoryId, 'minTags', minTags); - let err; - try { - await topics.post({ uid: adminUid, tags: ['tag4'], title: 'tag topic', content: 'topic 1 content', cid: topic.categoryId }); - } catch (_err) { - err = _err; - } - assert.equal(err.message, `[[error:not-enough-tags, ${minTags}]]`); - await db.deleteObjectField(`category:${topic.categoryId}`, 'minTags'); - }); - - it('should respect maxTags per category', async () => { - const maxTags = 2; - await categories.setCategoryField(topic.categoryId, 'maxTags', maxTags); - let err; - try { - await topics.post({ uid: adminUid, tags: ['tag1', 'tag2', 'tag3'], title: 'tag topic', content: 'topic 1 content', cid: topic.categoryId }); - } catch (_err) { - err = _err; - } - assert.equal(err.message, `[[error:too-many-tags, ${maxTags}]]`); - await db.deleteObjectField(`category:${topic.categoryId}`, 'maxTags'); - }); - - it('should create and delete category tags properly', async () => { - const category = await categories.create({ name: 'tag category 2' }); - const { cid } = category; - const title = 'test title'; - const postResult = await topics.post({ uid: adminUid, tags: ['cattag1', 'cattag2', 'cattag3'], title: title, content: 'topic 1 content', cid: cid }); - await topics.post({ uid: adminUid, tags: ['cattag1', 'cattag2'], title: title, content: 'topic 1 content', cid: cid }); - await topics.post({ uid: adminUid, tags: ['cattag1'], title: title, content: 'topic 1 content', cid: cid }); - let result = await topics.getCategoryTagsData(cid, 0, -1); - assert.deepStrictEqual(result, [ - { value: 'cattag1', score: 3, valueEscaped: 'cattag1', valueEncoded: 'cattag1', class: 'cattag1' }, - { value: 'cattag2', score: 2, valueEscaped: 'cattag2', valueEncoded: 'cattag2', class: 'cattag2' }, - { value: 'cattag3', score: 1, valueEscaped: 'cattag3', valueEncoded: 'cattag3', class: 'cattag3' }, - ]); - - // after purging values should update properly - await topics.purge(postResult.topicData.tid, adminUid); - result = await topics.getCategoryTagsData(cid, 0, -1); - assert.deepStrictEqual(result, [ - { value: 'cattag1', score: 2, valueEscaped: 'cattag1', valueEncoded: 'cattag1', class: 'cattag1' }, - { value: 'cattag2', score: 1, valueEscaped: 'cattag2', valueEncoded: 'cattag2', class: 'cattag2' }, - ]); - }); - - it('should update counts correctly if topic is moved between categories', async () => { - const category1 = await categories.create({ name: 'tag category 2' }); - const category2 = await categories.create({ name: 'tag category 2' }); - const cid1 = category1.cid; - const cid2 = category2.cid; - - const title = 'test title'; - const postResult = await topics.post({ uid: adminUid, tags: ['movedtag1', 'movedtag2'], title: title, content: 'topic 1 content', cid: cid1 }); - - await topics.post({ uid: adminUid, tags: ['movedtag1'], title: title, content: 'topic 1 content', cid: cid1 }); - await topics.post({ uid: adminUid, tags: ['movedtag2'], title: title, content: 'topic 1 content', cid: cid2 }); - - let result1 = await topics.getCategoryTagsData(cid1, 0, -1); - let result2 = await topics.getCategoryTagsData(cid2, 0, -1); - assert.deepStrictEqual(result1, [ - { value: 'movedtag1', score: 2, valueEscaped: 'movedtag1', valueEncoded: 'movedtag1', class: 'movedtag1' }, - { value: 'movedtag2', score: 1, valueEscaped: 'movedtag2', valueEncoded: 'movedtag2', class: 'movedtag2' }, - ]); - assert.deepStrictEqual(result2, [ - { value: 'movedtag2', score: 1, valueEscaped: 'movedtag2', valueEncoded: 'movedtag2', class: 'movedtag2' }, - ]); - - // after moving values should update properly - await topics.tools.move(postResult.topicData.tid, { cid: cid2, uid: adminUid }); - - result1 = await topics.getCategoryTagsData(cid1, 0, -1); - result2 = await topics.getCategoryTagsData(cid2, 0, -1); - assert.deepStrictEqual(result1, [ - { value: 'movedtag1', score: 1, valueEscaped: 'movedtag1', valueEncoded: 'movedtag1', class: 'movedtag1' }, - ]); - assert.deepStrictEqual(result2, [ - { value: 'movedtag2', score: 2, valueEscaped: 'movedtag2', valueEncoded: 'movedtag2', class: 'movedtag2' }, - { value: 'movedtag1', score: 1, valueEscaped: 'movedtag1', valueEncoded: 'movedtag1', class: 'movedtag1' }, - ]); - }); - - it('should not allow regular user to use system tags', async () => { - const oldValue = meta.config.systemTags; - meta.config.systemTags = 'moved,locked'; - let err; - try { - await topics.post({ - uid: fooUid, - tags: ['locked'], - title: 'i cant use this', - content: 'topic 1 content', - cid: categoryObj.cid, - }); - } catch (_err) { - err = _err; - } - assert.strictEqual(err.message, '[[error:cant-use-system-tag]]'); - meta.config.systemTags = oldValue; - }); - - it('should allow admin user to use system tags', async () => { - const oldValue = meta.config.systemTags; - meta.config.systemTags = 'moved,locked'; - const result = await topics.post({ - uid: adminUid, - tags: ['locked'], - title: 'I can use this tag', - content: 'topic 1 content', - cid: categoryObj.cid, - }); - assert.strictEqual(result.topicData.tags[0].value, 'locked'); - meta.config.systemTags = oldValue; - }); - - it('should not error if regular user edits topic after admin adds system tags', async () => { - const oldValue = meta.config.systemTags; - meta.config.systemTags = 'moved,locked'; - const result = await topics.post({ - uid: fooUid, - tags: ['one', 'two'], - title: 'topic with 2 tags', - content: 'topic content', - cid: categoryObj.cid, - }); - await posts.edit({ - pid: result.postData.pid, - uid: adminUid, - content: 'edited content', - tags: ['one', 'two', 'moved'], - }); - await posts.edit({ - pid: result.postData.pid, - uid: fooUid, - content: 'edited content', - tags: ['one', 'moved', 'two'], - }); - const tags = await topics.getTopicTags(result.topicData.tid); - assert.deepStrictEqual(tags.sort(), ['moved', 'one', 'two']); - meta.config.systemTags = oldValue; - }); - }); - - describe('follow/unfollow', () => { - const socketTopics = require('../src/socket.io/topics'); - let tid; - let followerUid; - before((done) => { - User.create({ username: 'follower' }, (err, uid) => { - if (err) { - return done(err); - } - followerUid = uid; - topics.post({ uid: adminUid, title: 'topic title', content: 'some content', cid: topic.categoryId }, (err, result) => { - if (err) { - return done(err); - } - tid = result.topicData.tid; - done(); - }); - }); - }); - - it('should error if not logged in', async () => { - try { - await apiTopics.ignore({ uid: 0 }, { tid: tid }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:not-logged-in]]'); - } - }); - - it('should filter ignoring uids', async () => { - await apiTopics.ignore({ uid: followerUid }, { tid: tid }); - const uids = await topics.filterIgnoringUids(tid, [adminUid, followerUid]); - assert.equal(uids.length, 1); - assert.equal(uids[0], adminUid); - }); - - it('should error with topic that does not exist', async () => { - try { - await apiTopics.follow({ uid: followerUid }, { tid: -1 }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:no-topic]]'); - } - }); - - it('should follow topic', (done) => { - topics.toggleFollow(tid, followerUid, (err, isFollowing) => { - assert.ifError(err); - assert(isFollowing); - socketTopics.isFollowed({ uid: followerUid }, tid, (err, isFollowing) => { - assert.ifError(err); - assert(isFollowing); - done(); - }); - }); - }); - }); - - describe('topics search', () => { - it('should error with invalid data', async () => { - try { - await topics.search(null, null); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:invalid-data]]'); - } - }); - - it('should return results', async () => { - const plugins = require('../src/plugins'); - plugins.hooks.register('myTestPlugin', { - hook: 'filter:topic.search', - method: function (data, callback) { - callback(null, [1, 2, 3]); - }, - }); - const results = await topics.search(topic.tid, 'test'); - assert.deepEqual(results, [1, 2, 3]); - }); - }); - - it('should check if user is moderator', (done) => { - socketTopics.isModerator({ uid: adminUid }, topic.tid, (err, isModerator) => { - assert.ifError(err); - assert(!isModerator); - done(); - }); - }); - - describe('teasers', () => { - let topic1; - let topic2; - before((done) => { - async.series([ - function (next) { - topics.post({ uid: adminUid, title: 'topic 1', content: 'content 1', cid: categoryObj.cid }, next); - }, - function (next) { - topics.post({ uid: adminUid, title: 'topic 2', content: 'content 2', cid: categoryObj.cid }, next); - }, - ], (err, results) => { - assert.ifError(err); - topic1 = results[0]; - topic2 = results[1]; - done(); - }); - }); - - after((done) => { - meta.config.teaserPost = ''; - done(); - }); - - - it('should return empty array if first param is empty', (done) => { - topics.getTeasers([], 1, (err, teasers) => { - assert.ifError(err); - assert.equal(0, teasers.length); - done(); - }); - }); - - it('should get teasers with 2 params', (done) => { - topics.getTeasers([topic1.topicData, topic2.topicData], 1, (err, teasers) => { - assert.ifError(err); - assert.deepEqual([undefined, undefined], teasers); - done(); - }); - }); - - it('should get teasers with first posts', (done) => { - meta.config.teaserPost = 'first'; - topics.getTeasers([topic1.topicData, topic2.topicData], 1, (err, teasers) => { - assert.ifError(err); - assert.equal(2, teasers.length); - assert(teasers[0]); - assert(teasers[1]); - assert(teasers[0].tid, topic1.topicData.tid); - assert(teasers[0].content, 'content 1'); - assert(teasers[0].user.username, 'admin'); - done(); - }); - }); - - it('should get teasers even if one topic is falsy', (done) => { - topics.getTeasers([null, topic2.topicData], 1, (err, teasers) => { - assert.ifError(err); - assert.equal(2, teasers.length); - assert.equal(undefined, teasers[0]); - assert(teasers[1]); - assert(teasers[1].tid, topic2.topicData.tid); - assert(teasers[1].content, 'content 2'); - assert(teasers[1].user.username, 'admin'); - done(); - }); - }); - - it('should get teasers with last posts', (done) => { - meta.config.teaserPost = 'last-post'; - topics.reply({ uid: adminUid, content: 'reply 1 content', tid: topic1.topicData.tid }, (err, result) => { - assert.ifError(err); - topic1.topicData.teaserPid = result.pid; - topics.getTeasers([topic1.topicData, topic2.topicData], 1, (err, teasers) => { - assert.ifError(err); - assert(teasers[0]); - assert(teasers[1]); - assert(teasers[0].tid, topic1.topicData.tid); - assert(teasers[0].content, 'reply 1 content'); - done(); - }); - }); - }); - - it('should get teasers by tids', (done) => { - topics.getTeasersByTids([topic2.topicData.tid, topic1.topicData.tid], 1, (err, teasers) => { - assert.ifError(err); - assert(2, teasers.length); - assert.equal(teasers[1].content, 'reply 1 content'); - done(); - }); - }); - - it('should return empty array ', (done) => { - topics.getTeasersByTids([], 1, (err, teasers) => { - assert.ifError(err); - assert.equal(0, teasers.length); - done(); - }); - }); - - it('should get teaser by tid', (done) => { - topics.getTeaser(topic2.topicData.tid, 1, (err, teaser) => { - assert.ifError(err); - assert(teaser); - assert.equal(teaser.content, 'content 2'); - done(); - }); - }); - - it('should not return teaser if user is blocked', (done) => { - let blockedUid; - async.waterfall([ - function (next) { - User.create({ username: 'blocked' }, next); - }, - function (uid, next) { - blockedUid = uid; - User.blocks.add(uid, adminUid, next); - }, - function (next) { - topics.reply({ uid: blockedUid, content: 'post from blocked user', tid: topic2.topicData.tid }, next); - }, - function (result, next) { - topics.getTeaser(topic2.topicData.tid, adminUid, next); - }, - function (teaser, next) { - assert.equal(teaser.content, 'content 2'); - User.blocks.remove(blockedUid, adminUid, next); - }, - ], done); - }); - }); - - describe('tag privilege', () => { - let uid; - let cid; - before((done) => { - async.waterfall([ - function (next) { - User.create({ username: 'tag_poster' }, next); - }, - function (_uid, next) { - uid = _uid; - categories.create({ name: 'tag category' }, next); - }, - function (categoryObj, next) { - cid = categoryObj.cid; - next(); - }, - ], done); - }); - - it('should fail to post if user does not have tag privilege', (done) => { - privileges.categories.rescind(['groups:topics:tag'], cid, 'registered-users', (err) => { - assert.ifError(err); - topics.post({ uid: uid, cid: cid, tags: ['tag1'], title: 'topic with tags', content: 'some content here' }, (err) => { - assert.equal(err.message, '[[error:no-privileges]]'); - done(); - }); - }); - }); - - it('should fail to edit if user does not have tag privilege', (done) => { - topics.post({ uid: uid, cid: cid, title: 'topic with tags', content: 'some content here' }, (err, result) => { - assert.ifError(err); - const { pid } = result.postData; - posts.edit({ pid: pid, uid: uid, content: 'edited content', tags: ['tag2'] }, (err) => { - assert.equal(err.message, '[[error:no-privileges]]'); - done(); - }); - }); - }); - - it('should be able to edit topic and add tags if allowed', (done) => { - privileges.categories.give(['groups:topics:tag'], cid, 'registered-users', (err) => { - assert.ifError(err); - topics.post({ uid: uid, cid: cid, tags: ['tag1'], title: 'topic with tags', content: 'some content here' }, (err, result) => { - assert.ifError(err); - posts.edit({ pid: result.postData.pid, uid: uid, content: 'edited content', tags: ['tag1', 'tag2'] }, (err, result) => { - assert.ifError(err); - const tags = result.topic.tags.map(tag => tag.value); - assert(tags.includes('tag1')); - assert(tags.includes('tag2')); - done(); - }); - }); - }); - }); - }); - - describe('topic merge', () => { - let uid; - let topic1Data; - let topic2Data; - - async function getTopic(tid) { - const topicData = await topics.getTopicData(tid); - return await topics.getTopicWithPosts(topicData, `tid:${topicData.tid}:posts`, adminUid, 0, 19, false); - } - - before((done) => { - async.waterfall([ - function (next) { - User.create({ username: 'mergevictim' }, next); - }, - function (_uid, next) { - uid = _uid; - topics.post({ uid: uid, cid: categoryObj.cid, title: 'topic 1', content: 'topic 1 OP' }, next); - }, - function (result, next) { - topic1Data = result.topicData; - topics.post({ uid: uid, cid: categoryObj.cid, title: 'topic 2', content: 'topic 2 OP' }, next); - }, - function (result, next) { - topic2Data = result.topicData; - topics.reply({ uid: uid, content: 'topic 1 reply', tid: topic1Data.tid }, next); - }, - function (postData, next) { - topics.reply({ uid: uid, content: 'topic 2 reply', tid: topic2Data.tid }, next); - }, - ], done); - }); - - it('should error if data is not an array', (done) => { - socketTopics.merge({ uid: 0 }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should error if user does not have privileges', (done) => { - socketTopics.merge({ uid: 0 }, { tids: [topic2Data.tid, topic1Data.tid] }, (err) => { - assert.equal(err.message, '[[error:no-privileges]]'); - done(); - }); - }); - - it('should merge 2 topics', async () => { - await socketTopics.merge({ uid: adminUid }, { - tids: [topic2Data.tid, topic1Data.tid], - }); - - const [topic1, topic2] = await Promise.all([ - getTopic(topic1Data.tid), - getTopic(topic2Data.tid), - ]); - - assert.equal(topic1.posts.length, 4); - assert.equal(topic2.posts.length, 0); - assert.equal(topic2.deleted, true); - - assert.equal(topic1.posts[0].content, 'topic 1 OP'); - assert.equal(topic1.posts[1].content, 'topic 2 OP'); - assert.equal(topic1.posts[2].content, 'topic 1 reply'); - assert.equal(topic1.posts[3].content, 'topic 2 reply'); - assert.equal(topic1.title, 'topic 1'); - }); - - it('should return properly for merged topic', (done) => { - request(`${nconf.get('url')}/api/topic/${topic2Data.slug}`, { jar: adminJar, json: true }, (err, response, body) => { - assert.ifError(err); - assert.equal(response.statusCode, 200); - assert(body); - assert.deepStrictEqual(body.posts, []); - done(); - }); - }); - - it('should merge 2 topics with options mainTid', async () => { - const topic1Result = await topics.post({ uid: uid, cid: categoryObj.cid, title: 'topic 1', content: 'topic 1 OP' }); - const topic2Result = await topics.post({ uid: uid, cid: categoryObj.cid, title: 'topic 2', content: 'topic 2 OP' }); - await topics.reply({ uid: uid, content: 'topic 1 reply', tid: topic1Result.topicData.tid }); - await topics.reply({ uid: uid, content: 'topic 2 reply', tid: topic2Result.topicData.tid }); - await socketTopics.merge({ uid: adminUid }, { - tids: [topic2Result.topicData.tid, topic1Result.topicData.tid], - options: { - mainTid: topic2Result.topicData.tid, - }, - }); - - const [topic1, topic2] = await Promise.all([ - getTopic(topic1Result.topicData.tid), - getTopic(topic2Result.topicData.tid), - ]); - - assert.equal(topic1.posts.length, 0); - assert.equal(topic2.posts.length, 4); - assert.equal(topic1.deleted, true); - - assert.equal(topic2.posts[0].content, 'topic 2 OP'); - assert.equal(topic2.posts[1].content, 'topic 1 OP'); - assert.equal(topic2.posts[2].content, 'topic 1 reply'); - assert.equal(topic2.posts[3].content, 'topic 2 reply'); - assert.equal(topic2.title, 'topic 2'); - }); - - it('should merge 2 topics with options newTopicTitle', async () => { - const topic1Result = await topics.post({ uid: uid, cid: categoryObj.cid, title: 'topic 1', content: 'topic 1 OP' }); - const topic2Result = await topics.post({ uid: uid, cid: categoryObj.cid, title: 'topic 2', content: 'topic 2 OP' }); - await topics.reply({ uid: uid, content: 'topic 1 reply', tid: topic1Result.topicData.tid }); - await topics.reply({ uid: uid, content: 'topic 2 reply', tid: topic2Result.topicData.tid }); - const mergeTid = await socketTopics.merge({ uid: adminUid }, { - tids: [topic2Result.topicData.tid, topic1Result.topicData.tid], - options: { - newTopicTitle: 'new merge topic', - }, - }); - - const [topic1, topic2, topic3] = await Promise.all([ - getTopic(topic1Result.topicData.tid), - getTopic(topic2Result.topicData.tid), - getTopic(mergeTid), - ]); - - assert.equal(topic1.posts.length, 0); - assert.equal(topic2.posts.length, 0); - assert.equal(topic3.posts.length, 4); - assert.equal(topic1.deleted, true); - assert.equal(topic2.deleted, true); - - assert.equal(topic3.posts[0].content, 'topic 1 OP'); - assert.equal(topic3.posts[1].content, 'topic 2 OP'); - assert.equal(topic3.posts[2].content, 'topic 1 reply'); - assert.equal(topic3.posts[3].content, 'topic 2 reply'); - assert.equal(topic3.title, 'new merge topic'); - }); - }); - - describe('sorted topics', () => { - let category; - before(async () => { - category = await categories.create({ name: 'sorted' }); - const topic1Result = await topics.post({ uid: topic.userId, cid: category.cid, title: 'old replied', content: 'topic 1 OP' }); - const topic2Result = await topics.post({ uid: topic.userId, cid: category.cid, title: 'most recent replied', content: 'topic 2 OP' }); - await topics.reply({ uid: topic.userId, content: 'topic 1 reply', tid: topic1Result.topicData.tid }); - await topics.reply({ uid: topic.userId, content: 'topic 2 reply', tid: topic2Result.topicData.tid }); - }); - - it('should get sorted topics in category', (done) => { - const filters = ['', 'watched', 'unreplied', 'new']; - async.map(filters, (filter, next) => { - topics.getSortedTopics({ - cids: [category.cid], - uid: topic.userId, - start: 0, - stop: -1, - filter: filter, - sort: 'votes', - }, next); - }, (err, data) => { - assert.ifError(err); - assert(data); - data.forEach((filterTopics) => { - assert(Array.isArray(filterTopics.topics)); - }); - done(); - }); - }); - it('should get topics recent replied first', async () => { - const data = await topics.getSortedTopics({ - cids: [category.cid], - uid: topic.userId, - start: 0, - stop: -1, - sort: 'recent', - }); - assert.strictEqual(data.topics[0].title, 'most recent replied'); - assert.strictEqual(data.topics[1].title, 'old replied'); - }); - - it('should get topics recent replied last', async () => { - const data = await topics.getSortedTopics({ - cids: [category.cid], - uid: topic.userId, - start: 0, - stop: -1, - sort: 'old', - }); - assert.strictEqual(data.topics[0].title, 'old replied'); - assert.strictEqual(data.topics[1].title, 'most recent replied'); - }); - }); - - describe('scheduled topics', () => { - let categoryObj; - let topicData; - let topic; - let adminApiOpts; - let postData; - const replyData = { - form: { - content: 'a reply by guest', - }, - json: true, - }; - - before(async () => { - adminApiOpts = { - json: true, - jar: adminJar, - headers: { - 'x-csrf-token': csrf_token, - }, - }; - categoryObj = await categories.create({ - name: 'Another Test Category', - description: 'Another test category created by testing script', - }); - topic = { - uid: adminUid, - cid: categoryObj.cid, - title: 'Scheduled Test Topic Title', - content: 'The content of scheduled test topic', - timestamp: new Date(Date.now() + 86400000).getTime(), - }; - }); - - it('should create a scheduled topic as pinned, deleted, included in "topics:scheduled" zset and with a timestamp in future', async () => { - topicData = (await topics.post(topic)).topicData; - topicData = await topics.getTopicData(topicData.tid); - - assert(topicData.pinned); - assert(topicData.deleted); - assert(topicData.scheduled); - assert(topicData.timestamp > Date.now()); - const score = await db.sortedSetScore('topics:scheduled', topicData.tid); - assert(score); - // should not be in regular category zsets - const isMember = await db.isMemberOfSortedSets([ - `cid:${categoryObj.cid}:tids`, - `cid:${categoryObj.cid}:tids:votes`, - `cid:${categoryObj.cid}:tids:posts`, - ], topicData.tid); - assert.deepStrictEqual(isMember, [false, false, false]); - }); - - it('should update poster\'s lastposttime with "action time"', async () => { - // src/user/posts.js:56 - const data = await User.getUsersFields([adminUid], ['lastposttime']); - assert.notStrictEqual(data[0].lastposttime, topicData.lastposttime); - }); - - it('should not load topic for an unprivileged user', async () => { - const response = await requestType('get', `${nconf.get('url')}/topic/${topicData.slug}`); - assert.strictEqual(response.statusCode, 404); - assert(response.body); - }); - - it('should load topic for a privileged user', async () => { - const response = (await requestType('get', `${nconf.get('url')}/topic/${topicData.slug}`, { jar: adminJar })).res; - assert.strictEqual(response.statusCode, 200); - assert(response.body); - }); - - it('should not be amongst topics of the category for an unprivileged user', async () => { - const response = await requestType('get', `${nconf.get('url')}/api/category/${categoryObj.slug}`, { json: true }); - assert.strictEqual(response.body.topics.filter(topic => topic.tid === topicData.tid).length, 0); - }); - - it('should be amongst topics of the category for a privileged user', async () => { - const response = await requestType('get', `${nconf.get('url')}/api/category/${categoryObj.slug}`, { json: true, jar: adminJar }); - const topic = response.body.topics.filter(topic => topic.tid === topicData.tid)[0]; - assert.strictEqual(topic && topic.tid, topicData.tid); - }); - - it('should load topic for guests if privilege is given', async () => { - await privileges.categories.give(['groups:topics:schedule'], categoryObj.cid, 'guests'); - const response = await requestType('get', `${nconf.get('url')}/topic/${topicData.slug}`); - assert.strictEqual(response.statusCode, 200); - assert(response.body); - }); - - it('should be amongst topics of the category for guests if privilege is given', async () => { - const response = await requestType('get', `${nconf.get('url')}/api/category/${categoryObj.slug}`, { json: true }); - const topic = response.body.topics.filter(topic => topic.tid === topicData.tid)[0]; - assert.strictEqual(topic && topic.tid, topicData.tid); - }); - - it('should not allow deletion of a scheduled topic', async () => { - const response = await requestType('delete', `${nconf.get('url')}/api/v3/topics/${topicData.tid}/state`, adminApiOpts); - assert.strictEqual(response.res.statusCode, 400); - }); - - it('should not allow to unpin a scheduled topic', async () => { - const response = await requestType('delete', `${nconf.get('url')}/api/v3/topics/${topicData.tid}/pin`, adminApiOpts); - assert.strictEqual(response.res.statusCode, 400); - }); - - it('should not allow to restore a scheduled topic', async () => { - const response = await requestType('put', `${nconf.get('url')}/api/v3/topics/${topicData.tid}/state`, adminApiOpts); - assert.strictEqual(response.res.statusCode, 400); - }); - - it('should not allow unprivileged to reply', async () => { - await privileges.categories.rescind(['groups:topics:schedule'], categoryObj.cid, 'guests'); - await privileges.categories.give(['groups:topics:reply'], categoryObj.cid, 'guests'); - const response = await requestType('post', `${nconf.get('url')}/api/v3/topics/${topicData.tid}`, replyData); - assert.strictEqual(response.res.statusCode, 403); - }); - - it('should allow guests to reply if privilege is given', async () => { - await privileges.categories.give(['groups:topics:schedule'], categoryObj.cid, 'guests'); - const response = await helpers.request('post', `/api/v3/topics/${topicData.tid}`, { - ...replyData, - jar: request.jar(), - }); - assert.strictEqual(response.body.response.content, 'a reply by guest'); - assert.strictEqual(response.body.response.user.username, '[[global:guest]]'); - }); - - it('should have replies with greater timestamp than the scheduled topics itself', async () => { - const response = await requestType('get', `${nconf.get('url')}/api/topic/${topicData.slug}`, { json: true }); - postData = response.body.posts[1]; - assert(postData.timestamp > response.body.posts[0].timestamp); - }); - - it('should have post edits with greater timestamp than the original', async () => { - const editData = { ...adminApiOpts, form: { content: 'an edit by the admin' } }; - const result = await requestType('put', `${nconf.get('url')}/api/v3/posts/${postData.pid}`, editData); - assert(result.body.response.edited > postData.timestamp); - - const diffsResult = await requestType('get', `${nconf.get('url')}/api/v3/posts/${postData.pid}/diffs`, adminApiOpts); - const { revisions } = diffsResult.body.response; - // diffs are LIFO - assert(revisions[0].timestamp > revisions[1].timestamp); - }); - - it('should able to reschedule', async () => { - const newDate = new Date(Date.now() + (5 * 86400000)).getTime(); - const editData = { ...adminApiOpts, form: { ...topic, pid: topicData.mainPid, timestamp: newDate } }; - const response = await requestType('put', `${nconf.get('url')}/api/v3/posts/${topicData.mainPid}`, editData); - - const editedTopic = await topics.getTopicFields(topicData.tid, ['lastposttime', 'timestamp']); - const editedPost = await posts.getPostFields(postData.pid, ['timestamp']); - assert(editedTopic.timestamp === newDate); - assert(editedPost.timestamp > editedTopic.timestamp); - - const scores = await db.sortedSetsScore([ - 'topics:scheduled', - `uid:${adminUid}:topics`, - 'topics:tid', - `cid:${topicData.cid}:uid:${adminUid}:tids`, - ], topicData.tid); - assert(scores.every(publishTime => publishTime === editedTopic.timestamp)); - }); - - it('should able to publish a scheduled topic', async () => { - const topicTimestamp = await topics.getTopicField(topicData.tid, 'timestamp'); - - mockdate.set(topicTimestamp); - await topics.scheduled.handleExpired(); - - topicData = await topics.getTopicData(topicData.tid); - assert(!topicData.pinned); - assert(!topicData.deleted); - // Should remove from topics:scheduled upon publishing - const score = await db.sortedSetScore('topics:scheduled', topicData.tid); - assert(!score); - }); - - it('should update poster\'s lastposttime after a ST published', async () => { - const data = await User.getUsersFields([adminUid], ['lastposttime']); - assert.strictEqual(adminUid, topicData.uid); - assert.strictEqual(data[0].lastposttime, topicData.lastposttime); - }); - - it('should not be able to schedule a "published" topic', async () => { - const newDate = new Date(Date.now() + 86400000).getTime(); - const editData = { ...adminApiOpts, form: { ...topic, pid: topicData.mainPid, timestamp: newDate } }; - const response = await requestType('put', `${nconf.get('url')}/api/v3/posts/${topicData.mainPid}`, editData); - assert.strictEqual(response.body.response.timestamp, Date.now()); - - mockdate.reset(); - }); - - it('should allow to purge a scheduled topic', async () => { - topicData = (await topics.post(topic)).topicData; - const response = await requestType('delete', `${nconf.get('url')}/api/v3/topics/${topicData.tid}`, adminApiOpts); - assert.strictEqual(response.res.statusCode, 200); - }); - - it('should remove from topics:scheduled on purge', async () => { - const score = await db.sortedSetScore('topics:scheduled', topicData.tid); - assert(!score); - }); - }); + let topic; + let categoryObject; + let adminUid; + let globalModuleUid; + let adminJar; + let csrf_token; + let fooUid; + let badUserUid; + + before(async () => { + adminUid = await User.create({username: 'admin', password: '123456'}); + globalModuleUid = await User.create({username: 'globalmod', password: 'globalmodpwd'}); + fooUid = await User.create({username: 'foo'}); + badUserUid = await User.create({username: 'badUser'}); + await groups.join('administrators', adminUid); + await groups.join('Global Moderators', globalModuleUid); + const adminLogin = await helpers.loginUser('admin', '123456'); + adminJar = adminLogin.jar; + csrf_token = adminLogin.csrf_token; + + categoryObject = await categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + }); + topic = { + userId: adminUid, + categoryId: categoryObject.cid, + title: 'Test Topic Title', + content: 'The content of test topic', + }; + }); + + describe('.post', () => { + it('should fail to create topic with invalid data', async () => { + try { + await apiTopics.create({uid: 0}, null); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:invalid-data]]'); + } + }); + + it('should create a new topic with proper parameters', done => { + topics.post({ + uid: topic.userId, + title: topic.title, + content: topic.content, + cid: topic.categoryId, + }, (error, result) => { + assert.ifError(error); + assert(result); + topic.tid = result.topicData.tid; + done(); + }); + }); + + it('should get post count', done => { + socketTopics.postcount({uid: adminUid}, topic.tid, (error, count) => { + assert.ifError(error); + assert.equal(count, 1); + done(); + }); + }); + + it('should load topic', async () => { + const data = await apiTopics.get({uid: adminUid}, {tid: topic.tid}); + assert.equal(data.tid, topic.tid); + }); + + it('should fail to create new topic with invalid user id', done => { + topics.post({ + uid: null, title: topic.title, content: topic.content, cid: topic.categoryId, + }, error => { + assert.equal(error.message, '[[error:no-privileges]]'); + done(); + }); + }); + + it('should fail to create new topic with empty title', done => { + topics.post({ + uid: topic.userId, title: '', content: topic.content, cid: topic.categoryId, + }, error => { + assert.ok(error); + done(); + }); + }); + + it('should fail to create new topic with empty content', done => { + topics.post({ + uid: topic.userId, title: topic.title, content: '', cid: topic.categoryId, + }, error => { + assert.ok(error); + done(); + }); + }); + + it('should fail to create new topic with non-existant category id', done => { + topics.post({ + uid: topic.userId, title: topic.title, content: topic.content, cid: 99, + }, error => { + assert.equal(error.message, '[[error:no-category]]', 'received no error'); + done(); + }); + }); + + it('should return false for falsy uid', done => { + topics.isOwner(topic.tid, 0, (error, isOwner) => { + assert.ifError(error); + assert(!isOwner); + done(); + }); + }); + + it('should fail to post a topic as guest with invalid csrf_token', async () => { + const categoryObject_ = await categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + }); + await privileges.categories.give(['groups:topics:create'], categoryObject_.cid, 'guests'); + await privileges.categories.give(['groups:topics:reply'], categoryObject_.cid, 'guests'); + const result = await requestType('post', `${nconf.get('url')}/api/v3/topics`, { + form: { + title: 'just a title', + cid: categoryObject_.cid, + content: 'content for the main post', + }, + headers: { + 'x-csrf-token': 'invalid', + }, + json: true, + }); + assert.strictEqual(result.res.statusCode, 403); + assert.strictEqual(result.body, 'Forbidden'); + }); + + it('should fail to post a topic as guest if no privileges', async () => { + const categoryObject_ = await categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + }); + const jar = request.jar(); + const result = await helpers.request('post', '/api/v3/topics', { + form: { + title: 'just a title', + cid: categoryObject_.cid, + content: 'content for the main post', + }, + jar, + json: true, + }); + assert.strictEqual(result.body.status.message, 'You do not have enough privileges for this action.'); + }); + + it('should post a topic as guest if guest group has privileges', async () => { + const categoryObject_ = await categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + }); + await privileges.categories.give(['groups:topics:create'], categoryObject_.cid, 'guests'); + await privileges.categories.give(['groups:topics:reply'], categoryObject_.cid, 'guests'); + + const jar = request.jar(); + const result = await helpers.request('post', '/api/v3/topics', { + form: { + title: 'just a title', + cid: categoryObject_.cid, + content: 'content for the main post', + }, + jar, + json: true, + }); + + assert.strictEqual(result.body.status.code, 'ok'); + assert.strictEqual(result.body.response.title, 'just a title'); + assert.strictEqual(result.body.response.user.username, '[[global:guest]]'); + + const replyResult = await helpers.request('post', `/api/v3/topics/${result.body.response.tid}`, { + form: { + content: 'a reply by guest', + }, + jar, + json: true, + }); + assert.strictEqual(replyResult.body.response.content, 'a reply by guest'); + assert.strictEqual(replyResult.body.response.user.username, '[[global:guest]]'); + }); + + it('should post a topic/reply as guest with handle if guest group has privileges', async () => { + const categoryObject_ = await categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + }); + await privileges.categories.give(['groups:topics:create'], categoryObject_.cid, 'guests'); + await privileges.categories.give(['groups:topics:reply'], categoryObject_.cid, 'guests'); + const oldValue = meta.config.allowGuestHandles; + meta.config.allowGuestHandles = 1; + const result = await helpers.request('post', '/api/v3/topics', { + form: { + title: 'just a title', + cid: categoryObject_.cid, + content: 'content for the main post', + handle: 'guest123', + }, + jar: request.jar(), + json: true, + }); + + assert.strictEqual(result.body.status.code, 'ok'); + assert.strictEqual(result.body.response.title, 'just a title'); + assert.strictEqual(result.body.response.user.username, 'guest123'); + assert.strictEqual(result.body.response.user.displayname, 'guest123'); + + const replyResult = await helpers.request('post', `/api/v3/topics/${result.body.response.tid}`, { + form: { + content: 'a reply by guest', + handle: 'guest124', + }, + jar: request.jar(), + json: true, + }); + assert.strictEqual(replyResult.body.response.content, 'a reply by guest'); + assert.strictEqual(replyResult.body.response.user.username, 'guest124'); + assert.strictEqual(replyResult.body.response.user.displayname, 'guest124'); + meta.config.allowGuestHandles = oldValue; + }); + }); + + describe('.reply', () => { + let newTopic; + let newPost; + + before(done => { + topics.post({ + uid: topic.userId, + title: topic.title, + content: topic.content, + cid: topic.categoryId, + }, (error, result) => { + if (error) { + return done(error); + } + + newTopic = result.topicData; + newPost = result.postData; + done(); + }); + }); + + it('should create a new reply with proper parameters', done => { + topics.reply({uid: topic.userId, content: 'test post', tid: newTopic.tid}, (error, result) => { + assert.equal(error, null, 'was created with error'); + assert.ok(result); + + done(); + }); + }); + + it('should handle direct replies', done => { + topics.reply({ + uid: topic.userId, content: 'test reply', tid: newTopic.tid, toPid: newPost.pid, + }, (error, result) => { + assert.equal(error, null, 'was created with error'); + assert.ok(result); + + socketPosts.getReplies({uid: 0}, newPost.pid, (error, postData) => { + assert.ifError(error); + + assert.ok(postData); + + assert.equal(postData.length, 1, 'should have 1 result'); + assert.equal(postData[0].pid, result.pid, 'result should be the reply we added'); + + done(); + }); + }); + }); + + it('should error if pid is not a number', done => { + socketPosts.getReplies({uid: 0}, 'abc', error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should fail to create new reply with invalid user id', done => { + topics.reply({uid: null, content: 'test post', tid: newTopic.tid}, error => { + assert.equal(error.message, '[[error:no-privileges]]'); + done(); + }); + }); + + it('should fail to create new reply with empty content', done => { + topics.reply({uid: topic.userId, content: '', tid: newTopic.tid}, error => { + assert.ok(error); + done(); + }); + }); + + it('should fail to create new reply with invalid topic id', done => { + topics.reply({uid: null, content: 'test post', tid: 99}, error => { + assert.equal(error.message, '[[error:no-topic]]'); + done(); + }); + }); + + it('should fail to create new reply with invalid toPid', done => { + topics.reply({ + uid: topic.userId, content: 'test post', tid: newTopic.tid, toPid: '"onmouseover=alert(1);//', + }, error => { + assert.equal(error.message, '[[error:invalid-pid]]'); + done(); + }); + }); + + it('should delete nested relies properly', async () => { + const result = await topics.post({ + uid: fooUid, title: 'nested test', content: 'main post', cid: topic.categoryId, + }); + const reply1 = await topics.reply({uid: fooUid, content: 'reply post 1', tid: result.topicData.tid}); + const reply2 = await topics.reply({ + uid: fooUid, content: 'reply post 2', tid: result.topicData.tid, toPid: reply1.pid, + }); + let replies = await socketPosts.getReplies({uid: fooUid}, reply1.pid); + assert.strictEqual(replies.length, 1); + assert.strictEqual(replies[0].content, 'reply post 2'); + let toPid = await posts.getPostField(reply2.pid, 'toPid'); + assert.strictEqual(Number.parseInt(toPid, 10), Number.parseInt(reply1.pid, 10)); + await posts.purge(reply1.pid, fooUid); + replies = await socketPosts.getReplies({uid: fooUid}, reply1.pid); + assert.strictEqual(replies.length, 0); + toPid = await posts.getPostField(reply2.pid, 'toPid'); + assert.strictEqual(toPid, null); + }); + }); + + describe('Get methods', () => { + let newTopic; + let newPost; + + before(done => { + topics.post({ + uid: topic.userId, + title: topic.title, + content: topic.content, + cid: topic.categoryId, + }, (error, result) => { + if (error) { + return done(error); + } + + newTopic = result.topicData; + newPost = result.postData; + done(); + }); + }); + + it('should not receive errors', done => { + topics.getTopicData(newTopic.tid, (error, topicData) => { + assert.ifError(error); + assert(typeof topicData.tid === 'number'); + assert(typeof topicData.uid === 'number'); + assert(typeof topicData.cid === 'number'); + assert(typeof topicData.mainPid === 'number'); + + assert(typeof topicData.timestamp === 'number'); + assert.strictEqual(topicData.postcount, 1); + assert.strictEqual(topicData.viewcount, 0); + assert.strictEqual(topicData.upvotes, 0); + assert.strictEqual(topicData.downvotes, 0); + assert.strictEqual(topicData.votes, 0); + assert.strictEqual(topicData.deleted, 0); + assert.strictEqual(topicData.private, 0); + assert.strictEqual(topicData.locked, 0); + assert.strictEqual(topicData.pinned, 0); + done(); + }); + }); + + it('should get a single field', done => { + topics.getTopicFields(newTopic.tid, ['slug'], (error, data) => { + assert.ifError(error); + assert(Object.keys(data).length === 1); + assert(data.hasOwnProperty('slug')); + done(); + }); + }); + + it('should get topic title by pid', done => { + topics.getTitleByPid(newPost.pid, (error, title) => { + assert.ifError(error); + assert.equal(title, topic.title); + done(); + }); + }); + + it('should get topic data by pid', done => { + topics.getTopicDataByPid(newPost.pid, (error, data) => { + assert.ifError(error); + assert.equal(data.tid, newTopic.tid); + done(); + }); + }); + + describe('.getTopicWithPosts', () => { + let tid; + before(async () => { + const result = await topics.post({ + uid: topic.userId, title: 'page test', content: 'main post', cid: topic.categoryId, + }); + tid = result.topicData.tid; + for (let i = 0; i < 30; i++) { + // eslint-disable-next-line no-await-in-loop + await topics.reply({uid: adminUid, content: `topic reply ${i + 1}`, tid}); + } + }); + + it('should get a topic with posts and other data', async () => { + const topicData = await topics.getTopicData(tid); + const data = await topics.getTopicWithPosts(topicData, `tid:${tid}:posts`, topic.userId, 0, -1, false); + assert(data); + assert.equal(data.category.cid, topic.categoryId); + assert.equal(data.unreplied, false); + assert.equal(data.deleted, false); + assert.equal(data.locked, false); + assert.equal(data.pinned, false); + }); + + it('should return first 3 posts including main post', async () => { + const topicData = await topics.getTopicData(tid); + const data = await topics.getTopicWithPosts(topicData, `tid:${tid}:posts`, topic.userId, 0, 2, false); + assert.strictEqual(data.posts.length, 3); + assert.strictEqual(data.posts[0].content, 'main post'); + assert.strictEqual(data.posts[1].content, 'topic reply 1'); + assert.strictEqual(data.posts[2].content, 'topic reply 2'); + for (const [index, post] of data.posts.entries()) { + assert.strictEqual(post.index, index); + } + }); + + it('should return 3 posts from 1 to 3 excluding main post', async () => { + const topicData = await topics.getTopicData(tid); + const start = 1; + const data = await topics.getTopicWithPosts(topicData, `tid:${tid}:posts`, topic.userId, start, 3, false); + assert.strictEqual(data.posts.length, 3); + assert.strictEqual(data.posts[0].content, 'topic reply 1'); + assert.strictEqual(data.posts[1].content, 'topic reply 2'); + assert.strictEqual(data.posts[2].content, 'topic reply 3'); + for (const [index, post] of data.posts.entries()) { + assert.strictEqual(post.index, index + start); + } + }); + + it('should return main post and last 2 posts', async () => { + const topicData = await topics.getTopicData(tid); + const data = await topics.getTopicWithPosts(topicData, `tid:${tid}:posts`, topic.userId, 0, 2, true); + assert.strictEqual(data.posts.length, 3); + assert.strictEqual(data.posts[0].content, 'main post'); + assert.strictEqual(data.posts[1].content, 'topic reply 30'); + assert.strictEqual(data.posts[2].content, 'topic reply 29'); + for (const [index, post] of data.posts.entries()) { + assert.strictEqual(post.index, index); + } + }); + + it('should return last 3 posts and not main post', async () => { + const topicData = await topics.getTopicData(tid); + const start = 1; + const data = await topics.getTopicWithPosts(topicData, `tid:${tid}:posts`, topic.userId, start, 3, true); + assert.strictEqual(data.posts.length, 3); + assert.strictEqual(data.posts[0].content, 'topic reply 30'); + assert.strictEqual(data.posts[1].content, 'topic reply 29'); + assert.strictEqual(data.posts[2].content, 'topic reply 28'); + for (const [index, post] of data.posts.entries()) { + assert.strictEqual(post.index, index + start); + } + }); + + it('should return posts 29 to 27 posts and not main post', async () => { + const topicData = await topics.getTopicData(tid); + const start = 2; + const data = await topics.getTopicWithPosts(topicData, `tid:${tid}:posts`, topic.userId, start, 4, true); + assert.strictEqual(data.posts.length, 3); + assert.strictEqual(data.posts[0].content, 'topic reply 29'); + assert.strictEqual(data.posts[1].content, 'topic reply 28'); + assert.strictEqual(data.posts[2].content, 'topic reply 27'); + for (const [index, post] of data.posts.entries()) { + assert.strictEqual(post.index, index + start); + } + }); + + it('should return 3 posts in reverse', async () => { + const topicData = await topics.getTopicData(tid); + const start = 28; + const data = await topics.getTopicWithPosts(topicData, `tid:${tid}:posts`, topic.userId, start, 30, true); + assert.strictEqual(data.posts.length, 3); + assert.strictEqual(data.posts[0].content, 'topic reply 3'); + assert.strictEqual(data.posts[1].content, 'topic reply 2'); + assert.strictEqual(data.posts[2].content, 'topic reply 1'); + for (const [index, post] of data.posts.entries()) { + assert.strictEqual(post.index, index + start); + } + }); + + it('should get all posts with main post at the start', async () => { + const topicData = await topics.getTopicData(tid); + const data = await topics.getTopicWithPosts(topicData, `tid:${tid}:posts`, topic.userId, 0, -1, false); + assert.strictEqual(data.posts.length, 31); + assert.strictEqual(data.posts[0].content, 'main post'); + assert.strictEqual(data.posts[1].content, 'topic reply 1'); + assert.strictEqual(data.posts.at(-1).content, 'topic reply 30'); + for (const [index, post] of data.posts.entries()) { + assert.strictEqual(post.index, index); + } + }); + + it('should get all posts in reverse with main post at the start followed by reply 30', async () => { + const topicData = await topics.getTopicData(tid); + const data = await topics.getTopicWithPosts(topicData, `tid:${tid}:posts`, topic.userId, 0, -1, true); + assert.strictEqual(data.posts.length, 31); + assert.strictEqual(data.posts[0].content, 'main post'); + assert.strictEqual(data.posts[1].content, 'topic reply 30'); + assert.strictEqual(data.posts.at(-1).content, 'topic reply 1'); + for (const [index, post] of data.posts.entries()) { + assert.strictEqual(post.index, index); + } + }); + + it('should return empty array if first param is falsy', async () => { + const posts = await topics.getTopicPosts(null, `tid:${tid}:posts`, 0, 9, topic.userId, true); + assert.deepStrictEqual(posts, []); + }); + + it('should only return main post', async () => { + const topicData = await topics.getTopicData(tid); + const postsData = await topics.getTopicPosts(topicData, `tid:${tid}:posts`, 0, 0, topic.userId, false); + assert.strictEqual(postsData.length, 1); + assert.strictEqual(postsData[0].content, 'main post'); + }); + + it('should only return first reply', async () => { + const topicData = await topics.getTopicData(tid); + const postsData = await topics.getTopicPosts(topicData, `tid:${tid}:posts`, 1, 1, topic.userId, false); + assert.strictEqual(postsData.length, 1); + assert.strictEqual(postsData[0].content, 'topic reply 1'); + }); + + it('should return main post and first reply', async () => { + const topicData = await topics.getTopicData(tid); + const postsData = await topics.getTopicPosts(topicData, `tid:${tid}:posts`, 0, 1, topic.userId, false); + assert.strictEqual(postsData.length, 2); + assert.strictEqual(postsData[0].content, 'main post'); + assert.strictEqual(postsData[1].content, 'topic reply 1'); + }); + + it('should return posts in correct order', async () => { + const data = await socketTopics.loadMore({uid: topic.userId}, {tid, after: 20, direction: 1}); + assert.strictEqual(data.posts.length, 11); + assert.strictEqual(data.posts[0].content, 'topic reply 20'); + assert.strictEqual(data.posts[1].content, 'topic reply 21'); + }); + + it('should return posts in correct order in reverse direction', async () => { + const data = await socketTopics.loadMore({uid: topic.userId}, {tid, after: 25, direction: -1}); + assert.strictEqual(data.posts.length, 20); + assert.strictEqual(data.posts[0].content, 'topic reply 5'); + assert.strictEqual(data.posts[1].content, 'topic reply 6'); + }); + + it('should return all posts in correct order', async () => { + const topicData = await topics.getTopicData(tid); + const postsData = await topics.getTopicPosts(topicData, `tid:${tid}:posts`, 0, -1, topic.userId, false); + assert.strictEqual(postsData.length, 31); + assert.strictEqual(postsData[0].content, 'main post'); + for (let i = 1; i < 30; i++) { + assert.strictEqual(postsData[i].content, `topic reply ${i}`); + } + }); + }); + }); + + describe('Title escaping', () => { + it('should properly escape topic title', done => { + const title = '" new topic test'; + const titleEscaped = validator.escape(title); + + const topicPostData = { + uid: topic.userId, title, content: topic.content, cid: topic.categoryId, + }; + topics.post(topicPostData, (error, result) => { + assert.ifError(error); + topics.getTopicData(result.topicData.tid, (error, topicData) => { + assert.ifError(error); + assert.strictEqual(topicData.titleRaw, title); + assert.strictEqual(topicData.title, titleEscaped); + done(); + }); + }); + }); + }); + + describe('tools/delete/restore/purge', () => { + let newTopic; + let new1Topic; + let followerUid; + let moveCid; + + before(done => { + async.waterfall([ + function (next) { + topics.post({ + uid: topic.userId, + title: topic.title, + content: topic.content, + cid: topic.categoryId, + }, (error, result) => { + assert.ifError(error); + newTopic = result.topicData; + next(); + }); + }, + function (next) { + topics.post({ + uid: fooUid, + title: topic.title, + content: topic.content, + cid: topic.categoryId, + }, (error, result) => { + assert.ifError(error); + new1Topic = result.topicData; + next(); + }); + }, + function (next) { + User.create({username: 'topicFollower', password: '123456'}, next); + }, + function (_uid, next) { + followerUid = _uid; + topics.follow(newTopic.tid, _uid, next); + }, + function (next) { + categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + }, (error, category) => { + if (error) { + return next(error); + } + + moveCid = category.cid; + next(); + }); + }, + ], done); + }); + + it('should load topic tools', done => { + socketTopics.loadTopicTools({uid: adminUid}, {tid: newTopic.tid}, (error, data) => { + assert.ifError(error); + assert(data); + done(); + }); + }); + + it('should delete the topic', async () => { + await apiTopics.delete({uid: adminUid}, {tids: [newTopic.tid], cid: categoryObject.cid}); + const deleted = await topics.getTopicField(newTopic.tid, 'deleted'); + assert.strictEqual(deleted, 1); + }); + + it('should restore the topic', async () => { + await apiTopics.restore({uid: adminUid}, {tids: [newTopic.tid], cid: categoryObject.cid}); + const deleted = await topics.getTopicField(newTopic.tid, 'deleted'); + assert.strictEqual(deleted, 0); + }); + + it('should private the topic', async () => { + await apiTopics.private({uid: globalModuleUid}, {tids: [newTopic.tid], cid: categoryObject.cid}); + const privated = await topics.getTopicField(newTopic.tid, 'private'); + assert.strictEqual(privated, 1); + }); + + it('should public the topic', async () => { + await apiTopics.public({uid: globalModuleUid}, {tids: [newTopic.tid], cid: categoryObject.cid}); + const privated = await topics.getTopicField(newTopic.tid, 'private'); + assert.strictEqual(privated, 0); + }); + + it('should allow owner to private the topic', async () => { + await apiTopics.private({uid: fooUid}, {tids: [new1Topic.tid], cid: categoryObject.cid}); + const privated = await topics.getTopicField(new1Topic.tid, 'private'); + assert.strictEqual(privated, 1); + }); + + it('should allow owner to public the topic', async () => { + await apiTopics.public({uid: fooUid}, {tids: [new1Topic.tid], cid: categoryObject.cid}); + const privated = await topics.getTopicField(new1Topic.tid, 'private'); + assert.strictEqual(privated, 0); + }); + + it('should lock topic', async () => { + await apiTopics.lock({uid: adminUid}, {tids: [newTopic.tid], cid: categoryObject.cid}); + const isLocked = await topics.isLocked(newTopic.tid); + assert(isLocked); + }); + + it('should unlock topic', async () => { + await apiTopics.unlock({uid: adminUid}, {tids: [newTopic.tid], cid: categoryObject.cid}); + const isLocked = await topics.isLocked(newTopic.tid); + assert(!isLocked); + }); + + it('should pin topic', async () => { + await apiTopics.pin({uid: adminUid}, {tids: [newTopic.tid], cid: categoryObject.cid}); + const pinned = await topics.getTopicField(newTopic.tid, 'pinned'); + assert.strictEqual(pinned, 1); + }); + + it('should unpin topic', async () => { + await apiTopics.unpin({uid: adminUid}, {tids: [newTopic.tid], cid: categoryObject.cid}); + const pinned = await topics.getTopicField(newTopic.tid, 'pinned'); + assert.strictEqual(pinned, 0); + }); + + it('should move all topics', done => { + socketTopics.moveAll({uid: adminUid}, {cid: moveCid, currentCid: categoryObject.cid}, error => { + assert.ifError(error); + topics.getTopicField(newTopic.tid, 'cid', (error, cid) => { + assert.ifError(error); + assert.equal(cid, moveCid); + done(); + }); + }); + }); + + it('should move a topic', done => { + socketTopics.move({uid: adminUid}, {cid: categoryObject.cid, tids: [newTopic.tid]}, error => { + assert.ifError(error); + topics.getTopicField(newTopic.tid, 'cid', (error, cid) => { + assert.ifError(error); + assert.equal(cid, categoryObject.cid); + done(); + }); + }); + }); + + it('should properly update sets when post is moved', done => { + let movedPost; + let previousPost; + let topic2LastReply; + let tid1; + let tid2; + const cid1 = topic.categoryId; + let cid2; + function checkCidSets(post1, post2, callback) { + async.waterfall([ + function (next) { + async.parallel({ + topicData(next) { + topics.getTopicsFields([tid1, tid2], ['lastposttime', 'postcount'], next); + }, + scores1(next) { + db.sortedSetsScore([ + `cid:${cid1}:tids`, + `cid:${cid1}:tids:lastposttime`, + `cid:${cid1}:tids:posts`, + ], tid1, next); + }, + scores2(next) { + db.sortedSetsScore([ + `cid:${cid2}:tids`, + `cid:${cid2}:tids:lastposttime`, + `cid:${cid2}:tids:posts`, + ], tid2, next); + }, + posts1(next) { + db.getSortedSetRangeWithScores(`tid:${tid1}:posts`, 0, -1, next); + }, + posts2(next) { + db.getSortedSetRangeWithScores(`tid:${tid2}:posts`, 0, -1, next); + }, + }, next); + }, + function (results, next) { + const assertMessage = `${JSON.stringify(results.posts1)}\n${JSON.stringify(results.posts2)}`; + assert.equal(results.topicData[0].postcount, results.scores1[2], assertMessage); + assert.equal(results.topicData[1].postcount, results.scores2[2], assertMessage); + assert.equal(results.topicData[0].lastposttime, post1.timestamp, assertMessage); + assert.equal(results.topicData[1].lastposttime, post2.timestamp, assertMessage); + assert.equal(results.topicData[0].lastposttime, results.scores1[0], assertMessage); + assert.equal(results.topicData[1].lastposttime, results.scores2[0], assertMessage); + assert.equal(results.topicData[0].lastposttime, results.scores1[1], assertMessage); + assert.equal(results.topicData[1].lastposttime, results.scores2[1], assertMessage); + + next(); + }, + ], callback); + } + + async.waterfall([ + function (next) { + categories.create({ + name: 'move to this category', + description: 'Test category created by testing script', + }, next); + }, + function (category, next) { + cid2 = category.cid; + topics.post({ + uid: adminUid, title: 'topic1', content: 'topic 1 mainPost', cid: cid1, + }, next); + }, + function (result, next) { + tid1 = result.topicData.tid; + topics.reply({uid: adminUid, content: 'topic 1 reply 1', tid: tid1}, next); + }, + function (postData, next) { + previousPost = postData; + topics.reply({uid: adminUid, content: 'topic 1 reply 2', tid: tid1}, next); + }, + function (postData, next) { + movedPost = postData; + topics.post({ + uid: adminUid, title: 'topic2', content: 'topic 2 mainpost', cid: cid2, + }, next); + }, + function (results, next) { + tid2 = results.topicData.tid; + topics.reply({uid: adminUid, content: 'topic 2 reply 1', tid: tid2}, next); + }, + function (postData, next) { + topic2LastReply = postData; + checkCidSets(movedPost, postData, next); + }, + function (next) { + db.isMemberOfSortedSets([`cid:${cid1}:pids`, `cid:${cid2}:pids`], movedPost.pid, next); + }, + function (isMember, next) { + assert.deepEqual(isMember, [true, false]); + categories.getCategoriesFields([cid1, cid2], ['post_count'], next); + }, + function (categoryData, next) { + assert.equal(categoryData[0].post_count, 4); + assert.equal(categoryData[1].post_count, 2); + topics.movePostToTopic(1, movedPost.pid, tid2, next); + }, + function (next) { + checkCidSets(previousPost, topic2LastReply, next); + }, + function (next) { + db.isMemberOfSortedSets([`cid:${cid1}:pids`, `cid:${cid2}:pids`], movedPost.pid, next); + }, + function (isMember, next) { + assert.deepEqual(isMember, [false, true]); + categories.getCategoriesFields([cid1, cid2], ['post_count'], next); + }, + function (categoryData, next) { + assert.equal(categoryData[0].post_count, 3); + assert.equal(categoryData[1].post_count, 3); + next(); + }, + ], done); + }); + + it('should fail to purge topic if user does not have privilege', async () => { + const topic1 = await topics.post({ + uid: adminUid, + title: 'topic for purge test', + content: 'topic content', + cid: categoryObject.cid, + }); + const tid1 = topic1.topicData.tid; + const globalModuleUid = await User.create({username: 'global mod'}); + await groups.join('Global Moderators', globalModuleUid); + await privileges.categories.rescind(['groups:purge'], categoryObject.cid, 'Global Moderators'); + try { + await apiTopics.purge({uid: globalModuleUid}, {tids: [tid1], cid: categoryObject.cid}); + } catch (error) { + assert.equal(error.message, '[[error:no-privileges]]'); + await privileges.categories.give(['groups:purge'], categoryObject.cid, 'Global Moderators'); + return; + } + + assert(false); + }); + + it('should purge the topic', async () => { + await apiTopics.purge({uid: adminUid}, {tids: [newTopic.tid], cid: categoryObject.cid}); + const isMember = await db.isSortedSetMember(`uid:${followerUid}:followed_tids`, newTopic.tid); + assert.strictEqual(false, isMember); + }); + + it('should not allow user to restore their topic if it was deleted by an admin', async () => { + const result = await topics.post({ + uid: fooUid, + title: 'topic for restore test', + content: 'topic content', + cid: categoryObject.cid, + }); + await apiTopics.delete({uid: adminUid}, {tids: [result.topicData.tid], cid: categoryObject.cid}); + try { + await apiTopics.restore({uid: fooUid}, {tids: [result.topicData.tid], cid: categoryObject.cid}); + } catch (error) { + return assert.strictEqual(error.message, '[[error:no-privileges]]'); + } + + assert(false); + }); + + it('should not allow user to private the topic if they are not the owner', async () => { + const result = await topics.post({ + uid: fooUid, + title: 'topic for privating test', + content: 'topic content', + cid: categoryObject.cid, + }); + try { + await apiTopics.private({uid: badUserUid}, {tids: [result.topicData.tid], cid: categoryObject.cid}); + } catch (error) { + return assert.strictEqual(error.message, '[[error:no-privileges]]'); + } + + assert(false); + }); + }); + + describe('order pinned topics', () => { + let tid1; + let tid2; + let tid3; + before(done => { + function createTopic(callback) { + topics.post({ + uid: topic.userId, + title: 'topic for test', + content: 'topic content', + cid: topic.categoryId, + }, callback); + } + + async.series({ + topic1(next) { + createTopic(next); + }, + topic2(next) { + createTopic(next); + }, + topic3(next) { + createTopic(next); + }, + }, (error, results) => { + assert.ifError(error); + tid1 = results.topic1.topicData.tid; + tid2 = results.topic2.topicData.tid; + tid3 = results.topic3.topicData.tid; + async.series([ + function (next) { + topics.tools.pin(tid1, adminUid, next); + }, + function (next) { + // Artificial timeout so pin time is different on redis sometimes scores are indentical + setTimeout(() => { + topics.tools.pin(tid2, adminUid, next); + }, 5); + }, + ], done); + }); + }); + + const socketTopics = require('../src/socket.io/topics'); + it('should error with invalid data', done => { + socketTopics.orderPinnedTopics({uid: adminUid}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should error with invalid data', done => { + socketTopics.orderPinnedTopics({uid: adminUid}, [null, null], error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should error with unprivileged user', done => { + socketTopics.orderPinnedTopics({uid: 0}, {tid: tid1, order: 1}, error => { + assert.equal(error.message, '[[error:no-privileges]]'); + done(); + }); + }); + + it('should not do anything if topics are not pinned', done => { + socketTopics.orderPinnedTopics({uid: adminUid}, {tid: tid3, order: 1}, error => { + assert.ifError(error); + db.isSortedSetMember(`cid:${topic.categoryId}:tids:pinned`, tid3, (error, isMember) => { + assert.ifError(error); + assert(!isMember); + done(); + }); + }); + }); + + it('should order pinned topics', done => { + db.getSortedSetRevRange(`cid:${topic.categoryId}:tids:pinned`, 0, -1, (error, pinnedTids) => { + assert.ifError(error); + assert.equal(pinnedTids[0], tid2); + assert.equal(pinnedTids[1], tid1); + socketTopics.orderPinnedTopics({uid: adminUid}, {tid: tid1, order: 0}, error_ => { + assert.ifError(error_); + db.getSortedSetRevRange(`cid:${topic.categoryId}:tids:pinned`, 0, -1, (error, pinnedTids) => { + assert.ifError(error); + assert.equal(pinnedTids[0], tid1); + assert.equal(pinnedTids[1], tid2); + done(); + }); + }); + }); + }); + }); + + describe('.ignore', () => { + let newTid; + let uid; + let newTopic; + before(done => { + uid = topic.userId; + async.waterfall([ + function (done) { + topics.post({ + uid: topic.userId, title: 'Topic to be ignored', content: 'Just ignore me, please!', cid: topic.categoryId, + }, (error, result) => { + if (error) { + return done(error); + } + + newTopic = result.topicData; + newTid = newTopic.tid; + done(); + }); + }, + function (done) { + topics.markUnread(newTid, uid, done); + }, + ], done); + }); + + it('should not appear in the unread list', done => { + async.waterfall([ + function (done) { + topics.ignore(newTid, uid, done); + }, + function (done) { + topics.getUnreadTopics({ + cid: 0, uid, start: 0, stop: -1, filter: '', + }, done); + }, + function (results, done) { + const {topics} = results; + const tids = topics.map(topic => topic.tid); + assert.equal(tids.indexOf(newTid), -1, 'The topic appeared in the unread list.'); + done(); + }, + ], done); + }); + + it('should not appear as unread in the recent list', done => { + async.waterfall([ + function (done) { + topics.ignore(newTid, uid, done); + }, + function (done) { + topics.getLatestTopics({ + uid, + start: 0, + stop: -1, + term: 'year', + }, done); + }, + function (results, done) { + const {topics} = results; + let topic; + let i; + for (i = 0; i < topics.length; i += 1) { + if (topics[i].tid === Number.parseInt(newTid, 10)) { + assert.equal(false, topics[i].unread, 'ignored topic was marked as unread in recent list'); + return done(); + } + } + + assert.ok(topic, 'topic didn\'t appear in the recent list'); + done(); + }, + ], done); + }); + + it('should appear as unread again when marked as reading', done => { + async.waterfall([ + function (done) { + topics.ignore(newTid, uid, done); + }, + function (done) { + topics.follow(newTid, uid, done); + }, + function (done) { + topics.getUnreadTopics({ + cid: 0, uid, start: 0, stop: -1, filter: '', + }, done); + }, + function (results, done) { + const {topics} = results; + const tids = topics.map(topic => topic.tid); + assert.notEqual(tids.indexOf(newTid), -1, 'The topic did not appear in the unread list.'); + done(); + }, + ], done); + }); + + it('should appear as unread again when marked as following', done => { + async.waterfall([ + function (done) { + topics.ignore(newTid, uid, done); + }, + function (done) { + topics.follow(newTid, uid, done); + }, + function (done) { + topics.getUnreadTopics({ + cid: 0, uid, start: 0, stop: -1, filter: '', + }, done); + }, + function (results, done) { + const {topics} = results; + const tids = topics.map(topic => topic.tid); + assert.notEqual(tids.indexOf(newTid), -1, 'The topic did not appear in the unread list.'); + done(); + }, + ], done); + }); + }); + + describe('.fork', () => { + let newTopic; + const replies = []; + let topicPids; + const originalBookmark = 6; + function postReply(next) { + topics.reply({uid: topic.userId, content: `test post ${replies.length}`, tid: newTopic.tid}, (error, result) => { + assert.equal(error, null, 'was created with error'); + assert.ok(result); + replies.push(result); + next(); + }); + } + + before(done => { + async.waterfall([ + function (next) { + groups.join('administrators', topic.userId, next); + }, + function (next) { + topics.post({ + uid: topic.userId, + title: topic.title, + content: topic.content, + cid: topic.categoryId, + }, (error, result) => { + assert.ifError(error); + newTopic = result.topicData; + next(); + }); + }, + function (next) { + postReply(next); + }, + function (next) { + postReply(next); + }, + function (next) { + postReply(next); + }, + function (next) { + postReply(next); + }, + function (next) { + postReply(next); + }, + function (next) { + postReply(next); + }, + function (next) { + postReply(next); + }, + function (next) { + postReply(next); + }, + function (next) { + postReply(next); + }, + function (next) { + postReply(next); + }, + function (next) { + postReply(next); + }, + function (next) { + postReply(next); + }, + function (next) { + topicPids = replies.map(reply => reply.pid); + socketTopics.bookmark({uid: topic.userId}, {tid: newTopic.tid, index: originalBookmark}, next); + }, + ], done); + }); + + it('should fail with invalid data', done => { + socketTopics.bookmark({uid: topic.userId}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should have 12 replies', done => { + assert.equal(12, replies.length); + done(); + }); + + it('should fail with invalid data', done => { + socketTopics.createTopicFromPosts({uid: 0}, null, error => { + assert.equal(error.message, '[[error:not-logged-in]]'); + done(); + }); + }); + + it('should fail with invalid data', done => { + socketTopics.createTopicFromPosts({uid: adminUid}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should not update the user\'s bookmark', done => { + async.waterfall([ + function (next) { + socketTopics.createTopicFromPosts({uid: topic.userId}, { + title: 'Fork test, no bookmark update', + pids: topicPids.slice(-2), + fromTid: newTopic.tid, + }, next); + }, + function (forkedTopicData, next) { + topics.getUserBookmark(newTopic.tid, topic.userId, next); + }, + function (bookmark, next) { + assert.equal(originalBookmark, bookmark); + next(); + }, + ], done); + }); + + it('should update the user\'s bookmark ', done => { + async.waterfall([ + function (next) { + topics.createTopicFromPosts( + topic.userId, + 'Fork test, no bookmark update', + topicPids.slice(1, 3), + newTopic.tid, + next, + ); + }, + function (forkedTopicData, next) { + topics.getUserBookmark(newTopic.tid, topic.userId, next); + }, + function (bookmark, next) { + assert.equal(originalBookmark - 2, bookmark); + next(); + }, + ], done); + }); + + it('should properly update topic vote count after forking', async () => { + const result = await topics.post({ + uid: fooUid, cid: categoryObject.cid, title: 'fork vote test', content: 'main post', + }); + const reply1 = await topics.reply({tid: result.topicData.tid, uid: fooUid, content: 'test reply 1'}); + const reply2 = await topics.reply({tid: result.topicData.tid, uid: fooUid, content: 'test reply 2'}); + const reply3 = await topics.reply({tid: result.topicData.tid, uid: fooUid, content: 'test reply 3'}); + await posts.upvote(result.postData.pid, adminUid); + await posts.upvote(reply1.pid, adminUid); + assert.strictEqual(await db.sortedSetScore('topics:votes', result.topicData.tid), 1); + assert.strictEqual(await db.sortedSetScore(`cid:${categoryObject.cid}:tids:votes`, result.topicData.tid), 1); + const newTopic = await topics.createTopicFromPosts(adminUid, 'Fork test, vote update', [reply1.pid, reply2.pid], result.topicData.tid); + + assert.strictEqual(await db.sortedSetScore('topics:votes', newTopic.tid), 1); + assert.strictEqual(await db.sortedSetScore(`cid:${categoryObject.cid}:tids:votes`, newTopic.tid), 1); + assert.strictEqual(await topics.getTopicField(newTopic.tid, 'upvotes'), 1); + }); + }); + + describe('controller', () => { + let topicData; + + before(done => { + topics.post({ + uid: topic.userId, + title: 'topic for controller test', + content: 'topic content', + cid: topic.categoryId, + thumb: 'http://i.imgur.com/64iBdBD.jpg', + }, (error, result) => { + assert.ifError(error); + assert.ok(result); + topicData = result.topicData; + done(); + }); + }); + + it('should load topic', done => { + request(`${nconf.get('url')}/topic/${topicData.slug}`, (error, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should load topic api data', done => { + request(`${nconf.get('url')}/api/topic/${topicData.slug}`, {json: true}, (error, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 200); + assert.strictEqual(body._header.tags.meta.find(t => t.name === 'description').content, 'topic content'); + assert.strictEqual(body._header.tags.meta.find(t => t.property === 'og:description').content, 'topic content'); + done(); + }); + }); + + it('should 404 if post index is invalid', done => { + request(`${nconf.get('url')}/topic/${topicData.slug}/derp`, (error, response) => { + assert.ifError(error); + assert.equal(response.statusCode, 404); + done(); + }); + }); + + it('should 404 if topic does not exist', done => { + request(`${nconf.get('url')}/topic/123123/does-not-exist`, (error, response) => { + assert.ifError(error); + assert.equal(response.statusCode, 404); + done(); + }); + }); + + it('should 401 if not allowed to read as guest', done => { + const privileges = require('../src/privileges'); + privileges.categories.rescind(['groups:topics:read'], topicData.cid, 'guests', error => { + assert.ifError(error); + request(`${nconf.get('url')}/api/topic/${topicData.slug}`, (error, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 401); + assert(body); + privileges.categories.give(['groups:topics:read'], topicData.cid, 'guests', done); + }); + }); + }); + + it('should redirect to correct topic if slug is missing', done => { + request(`${nconf.get('url')}/topic/${topicData.tid}/herpderp/1?page=2`, (error, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 200); + assert(body); + done(); + }); + }); + + it('should redirect if post index is out of range', done => { + request(`${nconf.get('url')}/api/topic/${topicData.slug}/-1`, {json: true}, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(res.headers['x-redirect'], `/topic/${topicData.tid}/topic-for-controller-test`); + assert.equal(body, `/topic/${topicData.tid}/topic-for-controller-test`); + done(); + }); + }); + + it('should 404 if page is out of bounds', done => { + const meta = require('../src/meta'); + meta.config.usePagination = 1; + request(`${nconf.get('url')}/topic/${topicData.slug}?page=100`, (error, response) => { + assert.ifError(error); + assert.equal(response.statusCode, 404); + done(); + }); + }); + + it('should mark topic read', done => { + request(`${nconf.get('url')}/topic/${topicData.slug}`, { + jar: adminJar, + }, (error, res) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + topics.hasReadTopics([topicData.tid], adminUid, (error, hasRead) => { + assert.ifError(error); + assert.equal(hasRead[0], true); + done(); + }); + }); + }); + + it('should 404 if tid is not a number', done => { + request(`${nconf.get('url')}/api/topic/teaser/nan`, {json: true}, (error, response) => { + assert.ifError(error); + assert.equal(response.statusCode, 404); + done(); + }); + }); + + it('should 403 if cant read', done => { + request(`${nconf.get('url')}/api/topic/teaser/${123_123}`, {json: true}, (error, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 403); + assert.equal(body, '[[error:no-privileges]]'); + + done(); + }); + }); + + it('should load topic teaser', done => { + request(`${nconf.get('url')}/api/topic/teaser/${topicData.tid}`, {json: true}, (error, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 200); + assert(body); + assert.equal(body.tid, topicData.tid); + assert.equal(body.content, 'topic content'); + assert(body.user); + assert(body.topic); + assert(body.category); + done(); + }); + }); + + it('should 404 if tid is not a number', done => { + request(`${nconf.get('url')}/api/topic/pagination/nan`, {json: true}, (error, response) => { + assert.ifError(error); + assert.equal(response.statusCode, 404); + done(); + }); + }); + + it('should 404 if tid does not exist', done => { + request(`${nconf.get('url')}/api/topic/pagination/1231231`, {json: true}, (error, response) => { + assert.ifError(error); + assert.equal(response.statusCode, 404); + done(); + }); + }); + + it('should load pagination', done => { + request(`${nconf.get('url')}/api/topic/pagination/${topicData.tid}`, {json: true}, (error, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 200); + assert(body); + assert.deepEqual(body.pagination, { + prev: {page: 1, active: false}, + next: {page: 1, active: false}, + first: {page: 1, active: true}, + last: {page: 1, active: true}, + rel: [], + pages: [], + currentPage: 1, + pageCount: 1, + }); + done(); + }); + }); + }); + + describe('infinitescroll', () => { + const socketTopics = require('../src/socket.io/topics'); + let tid; + before(done => { + topics.post({ + uid: topic.userId, + title: topic.title, + content: topic.content, + cid: topic.categoryId, + }, (error, result) => { + assert.ifError(error); + tid = result.topicData.tid; + done(); + }); + }); + + it('should error with invalid data', done => { + socketTopics.loadMore({uid: adminUid}, {}, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should infinite load topic posts', done => { + socketTopics.loadMore({uid: adminUid}, {tid, after: 0, count: 10}, (error, data) => { + assert.ifError(error); + assert(data.posts); + assert(data.privileges); + done(); + }); + }); + }); + + describe('suggested topics', () => { + let tid1; + let tid3; + before(done => { + async.series({ + topic1(next) { + topics.post({ + uid: adminUid, tags: ['nodebb'], title: 'topic title 1', content: 'topic 1 content', cid: topic.categoryId, + }, next); + }, + topic2(next) { + topics.post({ + uid: adminUid, tags: ['nodebb'], title: 'topic title 2', content: 'topic 2 content', cid: topic.categoryId, + }, next); + }, + topic3(next) { + topics.post({ + uid: adminUid, tags: [], title: 'topic title 3', content: 'topic 3 content', cid: topic.categoryId, + }, next); + }, + }, (error, results) => { + assert.ifError(error); + tid1 = results.topic1.topicData.tid; + tid3 = results.topic3.topicData.tid; + done(); + }); + }); + + it('should return suggested topics', done => { + topics.getSuggestedTopics(tid1, adminUid, 0, -1, (error, topics) => { + assert.ifError(error); + assert(Array.isArray(topics)); + done(); + }); + }); + + it('should return suggested topics', done => { + topics.getSuggestedTopics(tid3, adminUid, 0, 2, (error, topics) => { + assert.ifError(error); + assert(Array.isArray(topics)); + done(); + }); + }); + }); + + describe('unread', () => { + const socketTopics = require('../src/socket.io/topics'); + let tid; + let mainPid; + let uid; + before(done => { + async.parallel({ + topic(next) { + topics.post({ + uid: topic.userId, title: 'unread topic', content: 'unread topic content', cid: topic.categoryId, + }, next); + }, + joeUid(next) { + User.create({username: 'regularJoe'}, next); + }, + }, (error, results) => { + assert.ifError(error); + tid = results.topic.topicData.tid; + mainPid = results.topic.postData.pid; + uid = results.joeUid; + done(); + }); + }); + + it('should fail with invalid data', done => { + socketTopics.markUnread({uid: adminUid}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should fail if topic does not exist', done => { + socketTopics.markUnread({uid: adminUid}, 1_231_082, error => { + assert.equal(error.message, '[[error:no-topic]]'); + done(); + }); + }); + + it('should mark topic unread', done => { + socketTopics.markUnread({uid: adminUid}, tid, error => { + assert.ifError(error); + topics.hasReadTopic(tid, adminUid, (error, hasRead) => { + assert.ifError(error); + assert.equal(hasRead, false); + done(); + }); + }); + }); + + it('should fail with invalid data', done => { + socketTopics.markAsRead({uid: 0}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should mark topic read', done => { + socketTopics.markAsRead({uid: adminUid}, [tid], error => { + assert.ifError(error); + topics.hasReadTopic(tid, adminUid, (error, hasRead) => { + assert.ifError(error); + assert(hasRead); + done(); + }); + }); + }); + + it('should fail with invalid data', done => { + socketTopics.markTopicNotificationsRead({uid: 0}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should mark topic notifications read', async () => { + await apiTopics.follow({uid: adminUid}, {tid}); + const data = await topics.reply({ + uid, timestamp: Date.now(), content: 'some content', tid, + }); + await sleep(2500); + let count = await User.notifications.getUnreadCount(adminUid); + assert.strictEqual(count, 1); + await socketTopics.markTopicNotificationsRead({uid: adminUid}, [tid]); + count = await User.notifications.getUnreadCount(adminUid); + assert.strictEqual(count, 0); + }); + + it('should fail with invalid data', done => { + socketTopics.markAllRead({uid: 0}, null, error => { + assert.equal(error.message, '[[error:invalid-uid]]'); + done(); + }); + }); + + it('should mark all read', done => { + socketTopics.markUnread({uid: adminUid}, tid, error => { + assert.ifError(error); + socketTopics.markAllRead({uid: adminUid}, {}, error => { + assert.ifError(error); + topics.hasReadTopic(tid, adminUid, (error, hasRead) => { + assert.ifError(error); + assert(hasRead); + done(); + }); + }); + }); + }); + + it('should mark category topics read', done => { + socketTopics.markUnread({uid: adminUid}, tid, error => { + assert.ifError(error); + socketTopics.markCategoryTopicsRead({uid: adminUid}, topic.categoryId, error => { + assert.ifError(error); + topics.hasReadTopic(tid, adminUid, (error, hasRead) => { + assert.ifError(error); + assert(hasRead); + done(); + }); + }); + }); + }); + + it('should fail with invalid data', done => { + socketTopics.markAsUnreadForAll({uid: adminUid}, null, error => { + assert.equal(error.message, '[[error:invalid-tid]]'); + done(); + }); + }); + + it('should fail with invalid data', done => { + socketTopics.markAsUnreadForAll({uid: 0}, [tid], error => { + assert.equal(error.message, '[[error:no-privileges]]'); + done(); + }); + }); + + it('should fail if user is not admin', done => { + socketTopics.markAsUnreadForAll({uid}, [tid], error => { + assert.equal(error.message, '[[error:no-privileges]]'); + done(); + }); + }); + + it('should fail if topic does not exist', done => { + socketTopics.markAsUnreadForAll({uid}, [12_312_313], error => { + assert.equal(error.message, '[[error:no-topic]]'); + done(); + }); + }); + + it('should mark topic unread for everyone', done => { + socketTopics.markAsUnreadForAll({uid: adminUid}, [tid], error => { + assert.ifError(error); + async.parallel({ + adminRead(next) { + topics.hasReadTopic(tid, adminUid, next); + }, + regularRead(next) { + topics.hasReadTopic(tid, uid, next); + }, + }, (error, results) => { + assert.ifError(error); + assert.equal(results.adminRead, false); + assert.equal(results.regularRead, false); + done(); + }); + }); + }); + + it('should not do anything if tids is empty array', done => { + socketTopics.markAsRead({uid: adminUid}, [], (error, markedRead) => { + assert.ifError(error); + assert(!markedRead); + done(); + }); + }); + + it('should not return topics in category you cant read', done => { + let privateCid; + let privateTid; + async.waterfall([ + function (next) { + categories.create({ + name: 'private category', + description: 'private category', + }, next); + }, + function (category, next) { + privateCid = category.cid; + privileges.categories.rescind(['groups:topics:read'], category.cid, 'registered-users', next); + }, + function (next) { + topics.post({ + uid: adminUid, title: 'topic in private category', content: 'registered-users cant see this', cid: privateCid, + }, next); + }, + function (data, next) { + privateTid = data.topicData.tid; + topics.getUnreadTids({uid}, next); + }, + function (unreadTids, next) { + unreadTids = unreadTids.map(String); + assert(!unreadTids.includes(String(privateTid))); + next(); + }, + ], done); + }); + + it('should not return topics in category you ignored/not watching', done => { + let ignoredCid; + let tid; + async.waterfall([ + function (next) { + categories.create({ + name: 'ignored category', + description: 'ignored category', + }, next); + }, + function (category, next) { + ignoredCid = category.cid; + privileges.categories.rescind(['groups:topics:read'], category.cid, 'registered-users', next); + }, + function (next) { + topics.post({ + uid: adminUid, title: 'topic in private category', content: 'registered-users cant see this', cid: ignoredCid, + }, next); + }, + function (data, next) { + tid = data.topicData.tid; + User.ignoreCategory(uid, ignoredCid, next); + }, + function (next) { + topics.getUnreadTids({uid}, next); + }, + function (unreadTids, next) { + unreadTids = unreadTids.map(String); + assert(!unreadTids.includes(String(tid))); + next(); + }, + ], done); + }); + + it('should not return topic as unread if new post is from blocked user', done => { + let blockedUid; + let topic; + async.waterfall([ + function (next) { + topics.post({ + uid: adminUid, title: 'will not get as unread', content: 'not unread', cid: categoryObject.cid, + }, next); + }, + function (result, next) { + topic = result.topicData; + User.create({username: 'blockedunread'}, next); + }, + function (uid, next) { + blockedUid = uid; + User.blocks.add(uid, adminUid, next); + }, + function (next) { + topics.reply({uid: blockedUid, content: 'post from blocked user', tid: topic.tid}, next); + }, + function (result, next) { + topics.getUnreadTids({cid: 0, uid: adminUid}, next); + }, + function (unreadTids, next) { + assert(!unreadTids.includes(topic.tid)); + User.blocks.remove(blockedUid, adminUid, next); + }, + ], done); + }); + + it('should not return topic as unread if topic is deleted', async () => { + const uid = await User.create({username: 'regularJoe'}); + const result = await topics.post({ + uid: adminUid, title: 'deleted unread', content: 'not unread', cid: categoryObject.cid, + }); + await topics.delete(result.topicData.tid, adminUid); + const unreadTids = await topics.getUnreadTids({cid: 0, uid}); + assert(!unreadTids.includes(result.topicData.tid)); + }); + }); + + describe('tags', () => { + const socketTopics = require('../src/socket.io/topics'); + const socketAdmin = require('../src/socket.io/admin'); + + before(done => { + async.series([ + function (next) { + topics.post({ + uid: adminUid, tags: ['php', 'nosql', 'psql', 'nodebb', 'node icon'], title: 'topic title 1', content: 'topic 1 content', cid: topic.categoryId, + }, next); + }, + function (next) { + topics.post({ + uid: adminUid, tags: ['javascript', 'mysql', 'python', 'nodejs'], title: 'topic title 2', content: 'topic 2 content', cid: topic.categoryId, + }, next); + }, + ], error => { + assert.ifError(error); + done(); + }); + }); + + it('should return empty array if query is falsy', done => { + socketTopics.autocompleteTags({uid: adminUid}, {query: ''}, (error, data) => { + assert.ifError(error); + assert.deepEqual([], data); + done(); + }); + }); + + it('should autocomplete tags', done => { + socketTopics.autocompleteTags({uid: adminUid}, {query: 'p'}, (error, data) => { + assert.ifError(error); + for (const tag of ['php', 'psql', 'python']) { + assert.notEqual(data.indexOf(tag), -1); + } + + done(); + }); + }); + + it('should return empty array if query is falsy', done => { + socketTopics.searchTags({uid: adminUid}, {query: ''}, (error, data) => { + assert.ifError(error); + assert.deepEqual([], data); + done(); + }); + }); + + it('should search tags', done => { + socketTopics.searchTags({uid: adminUid}, {query: 'no'}, (error, data) => { + assert.ifError(error); + for (const tag of ['nodebb', 'nodejs', 'nosql']) { + assert.notEqual(data.indexOf(tag), -1); + } + + done(); + }); + }); + + it('should return empty array if query is falsy', done => { + socketTopics.searchAndLoadTags({uid: adminUid}, {query: ''}, (error, data) => { + assert.ifError(error); + assert.equal(data.matchCount, 0); + assert.equal(data.pageCount, 1); + assert.deepEqual(data.tags, []); + done(); + }); + }); + + it('should search and load tags', done => { + socketTopics.searchAndLoadTags({uid: adminUid}, {query: 'no'}, (error, data) => { + assert.ifError(error); + assert.equal(data.matchCount, 4); + assert.equal(data.pageCount, 1); + const tagData = [ + { + value: 'nodebb', valueEscaped: 'nodebb', valueEncoded: 'nodebb', score: 3, class: 'nodebb', + }, + { + value: 'node icon', valueEscaped: 'node icon', valueEncoded: 'node%20icon', score: 1, class: 'node-icon', + }, + { + value: 'nodejs', valueEscaped: 'nodejs', valueEncoded: 'nodejs', score: 1, class: 'nodejs', + }, + { + value: 'nosql', valueEscaped: 'nosql', valueEncoded: 'nosql', score: 1, class: 'nosql', + }, + ]; + assert.deepEqual(data.tags, tagData); + + done(); + }); + }); + + it('should return error if data is invalid', done => { + socketTopics.loadMoreTags({uid: adminUid}, {after: 'asd'}, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should load more tags', done => { + socketTopics.loadMoreTags({uid: adminUid}, {after: 0}, (error, data) => { + assert.ifError(error); + assert(Array.isArray(data.tags)); + assert.equal(data.nextStart, 100); + done(); + }); + }); + + it('should error if data is invalid', done => { + socketAdmin.tags.create({uid: adminUid}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should error if tag is invalid', done => { + socketAdmin.tags.create({uid: adminUid}, {tag: ''}, error => { + assert.equal(error.message, '[[error:invalid-tag]]'); + done(); + }); + }); + + it('should error if tag is too short', done => { + socketAdmin.tags.create({uid: adminUid}, {tag: 'as'}, error => { + assert.equal(error.message, '[[error:tag-too-short]]'); + done(); + }); + }); + + it('should create empty tag', done => { + socketAdmin.tags.create({uid: adminUid}, {tag: 'emptytag'}, error => { + assert.ifError(error); + db.sortedSetScore('tags:topic:count', 'emptytag', (error, score) => { + assert.ifError(error); + assert.equal(score, 0); + done(); + }); + }); + }); + + it('should do nothing if tag exists', done => { + socketAdmin.tags.create({uid: adminUid}, {tag: 'emptytag'}, error => { + assert.ifError(error); + db.sortedSetScore('tags:topic:count', 'emptytag', (error, score) => { + assert.ifError(error); + assert.equal(score, 0); + done(); + }); + }); + }); + + it('should rename tags', async () => { + const result1 = await topics.post({ + uid: adminUid, tags: ['plugins'], title: 'topic tagged with plugins', content: 'topic 1 content', cid: topic.categoryId, + }); + const result2 = await topics.post({ + uid: adminUid, tags: ['plugin'], title: 'topic tagged with plugin', content: 'topic 2 content', cid: topic.categoryId, + }); + const data1 = await topics.getTopicData(result2.topicData.tid); + + await socketAdmin.tags.rename({uid: adminUid}, [{ + value: 'plugin', + newName: 'plugins', + }]); + + const tids = await topics.getTagTids('plugins', 0, -1); + assert.strictEqual(tids.length, 2); + const tags = await topics.getTopicTags(result2.topicData.tid); + + const data = await topics.getTopicData(result2.topicData.tid); + assert.strictEqual(tags.length, 1); + assert.strictEqual(tags[0], 'plugins'); + }); + + it('should return related topics', done => { + const meta = require('../src/meta'); + meta.config.maximumRelatedTopics = 2; + const topicData = { + tags: [{value: 'javascript'}], + }; + topics.getRelatedTopics(topicData, 0, (error, data) => { + assert.ifError(error); + assert(Array.isArray(data)); + assert.equal(data[0].title, 'topic title 2'); + meta.config.maximumRelatedTopics = 0; + done(); + }); + }); + + it('should return error with invalid data', done => { + socketAdmin.tags.deleteTags({uid: adminUid}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should do nothing if arrays is empty', done => { + socketAdmin.tags.deleteTags({uid: adminUid}, {tags: []}, error => { + assert.ifError(error); + done(); + }); + }); + + it('should delete tags', done => { + socketAdmin.tags.create({uid: adminUid}, {tag: 'emptytag2'}, error => { + assert.ifError(error); + socketAdmin.tags.deleteTags({uid: adminUid}, {tags: ['emptytag', 'emptytag2', 'nodebb', 'nodejs']}, error => { + assert.ifError(error); + db.getObjects(['tag:emptytag', 'tag:emptytag2'], (error, data) => { + assert.ifError(error); + assert(!data[0]); + assert(!data[1]); + done(); + }); + }); + }); + }); + + it('should delete tag', done => { + topics.deleteTag('javascript', error => { + assert.ifError(error); + db.getObject('tag:javascript', (error, data) => { + assert.ifError(error); + assert(!data); + done(); + }); + }); + }); + + it('should delete category tag as well', async () => { + const category = await categories.create({name: 'delete category'}); + const {cid} = category; + await topics.post({ + uid: adminUid, tags: ['willbedeleted', 'notthis'], title: 'tag topic', content: 'topic 1 content', cid, + }); + let categoryTags = await topics.getCategoryTags(cid, 0, -1); + assert(categoryTags.includes('willbedeleted')); + assert(categoryTags.includes('notthis')); + await topics.deleteTags(['willbedeleted']); + categoryTags = await topics.getCategoryTags(cid, 0, -1); + assert(!categoryTags.includes('willbedeleted')); + assert(categoryTags.includes('notthis')); + }); + + it('should add and remove tags from topics properly', async () => { + const category = await categories.create({name: 'add/remove category'}); + const {cid} = category; + const result = await topics.post({ + uid: adminUid, tags: ['tag4', 'tag2', 'tag1', 'tag3'], title: 'tag topic', content: 'topic 1 content', cid, + }); + const {tid} = result.topicData; + + let tags = await topics.getTopicTags(tid); + let categoryTags = await topics.getCategoryTags(cid, 0, -1); + assert.deepStrictEqual(tags.sort(), ['tag1', 'tag2', 'tag3', 'tag4']); + assert.deepStrictEqual(categoryTags.sort(), ['tag1', 'tag2', 'tag3', 'tag4']); + + await topics.addTags(['tag7', 'tag6', 'tag5'], [tid]); + tags = await topics.getTopicTags(tid); + categoryTags = await topics.getCategoryTags(cid, 0, -1); + assert.deepStrictEqual(tags.sort(), ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7']); + assert.deepStrictEqual(categoryTags.sort(), ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7']); + + await topics.removeTags(['tag1', 'tag3', 'tag5', 'tag7'], [tid]); + tags = await topics.getTopicTags(tid); + categoryTags = await topics.getCategoryTags(cid, 0, -1); + assert.deepStrictEqual(tags.sort(), ['tag2', 'tag4', 'tag6']); + assert.deepStrictEqual(categoryTags.sort(), ['tag2', 'tag4', 'tag6']); + }); + + it('should respect minTags', async () => { + const oldValue = meta.config.minimumTagsPerTopic; + meta.config.minimumTagsPerTopic = 2; + let error; + try { + await topics.post({ + uid: adminUid, tags: ['tag4'], title: 'tag topic', content: 'topic 1 content', cid: topic.categoryId, + }); + } catch (error_) { + error = error_; + } + + assert.equal(error.message, `[[error:not-enough-tags, ${meta.config.minimumTagsPerTopic}]]`); + meta.config.minimumTagsPerTopic = oldValue; + }); + + it('should respect maxTags', async () => { + const oldValue = meta.config.maximumTagsPerTopic; + meta.config.maximumTagsPerTopic = 2; + let error; + try { + await topics.post({ + uid: adminUid, tags: ['tag1', 'tag2', 'tag3'], title: 'tag topic', content: 'topic 1 content', cid: topic.categoryId, + }); + } catch (error_) { + error = error_; + } + + assert.equal(error.message, `[[error:too-many-tags, ${meta.config.maximumTagsPerTopic}]]`); + meta.config.maximumTagsPerTopic = oldValue; + }); + + it('should respect minTags per category', async () => { + const minTags = 2; + await categories.setCategoryField(topic.categoryId, 'minTags', minTags); + let error; + try { + await topics.post({ + uid: adminUid, tags: ['tag4'], title: 'tag topic', content: 'topic 1 content', cid: topic.categoryId, + }); + } catch (error_) { + error = error_; + } + + assert.equal(error.message, `[[error:not-enough-tags, ${minTags}]]`); + await db.deleteObjectField(`category:${topic.categoryId}`, 'minTags'); + }); + + it('should respect maxTags per category', async () => { + const maxTags = 2; + await categories.setCategoryField(topic.categoryId, 'maxTags', maxTags); + let error; + try { + await topics.post({ + uid: adminUid, tags: ['tag1', 'tag2', 'tag3'], title: 'tag topic', content: 'topic 1 content', cid: topic.categoryId, + }); + } catch (error_) { + error = error_; + } + + assert.equal(error.message, `[[error:too-many-tags, ${maxTags}]]`); + await db.deleteObjectField(`category:${topic.categoryId}`, 'maxTags'); + }); + + it('should create and delete category tags properly', async () => { + const category = await categories.create({name: 'tag category 2'}); + const {cid} = category; + const title = 'test title'; + const postResult = await topics.post({ + uid: adminUid, tags: ['cattag1', 'cattag2', 'cattag3'], title, content: 'topic 1 content', cid, + }); + await topics.post({ + uid: adminUid, tags: ['cattag1', 'cattag2'], title, content: 'topic 1 content', cid, + }); + await topics.post({ + uid: adminUid, tags: ['cattag1'], title, content: 'topic 1 content', cid, + }); + let result = await topics.getCategoryTagsData(cid, 0, -1); + assert.deepStrictEqual(result, [ + { + value: 'cattag1', score: 3, valueEscaped: 'cattag1', valueEncoded: 'cattag1', class: 'cattag1', + }, + { + value: 'cattag2', score: 2, valueEscaped: 'cattag2', valueEncoded: 'cattag2', class: 'cattag2', + }, + { + value: 'cattag3', score: 1, valueEscaped: 'cattag3', valueEncoded: 'cattag3', class: 'cattag3', + }, + ]); + + // After purging values should update properly + await topics.purge(postResult.topicData.tid, adminUid); + result = await topics.getCategoryTagsData(cid, 0, -1); + assert.deepStrictEqual(result, [ + { + value: 'cattag1', score: 2, valueEscaped: 'cattag1', valueEncoded: 'cattag1', class: 'cattag1', + }, + { + value: 'cattag2', score: 1, valueEscaped: 'cattag2', valueEncoded: 'cattag2', class: 'cattag2', + }, + ]); + }); + + it('should update counts correctly if topic is moved between categories', async () => { + const category1 = await categories.create({name: 'tag category 2'}); + const category2 = await categories.create({name: 'tag category 2'}); + const cid1 = category1.cid; + const cid2 = category2.cid; + + const title = 'test title'; + const postResult = await topics.post({ + uid: adminUid, tags: ['movedtag1', 'movedtag2'], title, content: 'topic 1 content', cid: cid1, + }); + + await topics.post({ + uid: adminUid, tags: ['movedtag1'], title, content: 'topic 1 content', cid: cid1, + }); + await topics.post({ + uid: adminUid, tags: ['movedtag2'], title, content: 'topic 1 content', cid: cid2, + }); + + let result1 = await topics.getCategoryTagsData(cid1, 0, -1); + let result2 = await topics.getCategoryTagsData(cid2, 0, -1); + assert.deepStrictEqual(result1, [ + { + value: 'movedtag1', score: 2, valueEscaped: 'movedtag1', valueEncoded: 'movedtag1', class: 'movedtag1', + }, + { + value: 'movedtag2', score: 1, valueEscaped: 'movedtag2', valueEncoded: 'movedtag2', class: 'movedtag2', + }, + ]); + assert.deepStrictEqual(result2, [ + { + value: 'movedtag2', score: 1, valueEscaped: 'movedtag2', valueEncoded: 'movedtag2', class: 'movedtag2', + }, + ]); + + // After moving values should update properly + await topics.tools.move(postResult.topicData.tid, {cid: cid2, uid: adminUid}); + + result1 = await topics.getCategoryTagsData(cid1, 0, -1); + result2 = await topics.getCategoryTagsData(cid2, 0, -1); + assert.deepStrictEqual(result1, [ + { + value: 'movedtag1', score: 1, valueEscaped: 'movedtag1', valueEncoded: 'movedtag1', class: 'movedtag1', + }, + ]); + assert.deepStrictEqual(result2, [ + { + value: 'movedtag2', score: 2, valueEscaped: 'movedtag2', valueEncoded: 'movedtag2', class: 'movedtag2', + }, + { + value: 'movedtag1', score: 1, valueEscaped: 'movedtag1', valueEncoded: 'movedtag1', class: 'movedtag1', + }, + ]); + }); + + it('should not allow regular user to use system tags', async () => { + const oldValue = meta.config.systemTags; + meta.config.systemTags = 'moved,locked'; + let error; + try { + await topics.post({ + uid: fooUid, + tags: ['locked'], + title: 'i cant use this', + content: 'topic 1 content', + cid: categoryObject.cid, + }); + } catch (error_) { + error = error_; + } + + assert.strictEqual(error.message, '[[error:cant-use-system-tag]]'); + meta.config.systemTags = oldValue; + }); + + it('should allow admin user to use system tags', async () => { + const oldValue = meta.config.systemTags; + meta.config.systemTags = 'moved,locked'; + const result = await topics.post({ + uid: adminUid, + tags: ['locked'], + title: 'I can use this tag', + content: 'topic 1 content', + cid: categoryObject.cid, + }); + assert.strictEqual(result.topicData.tags[0].value, 'locked'); + meta.config.systemTags = oldValue; + }); + + it('should not error if regular user edits topic after admin adds system tags', async () => { + const oldValue = meta.config.systemTags; + meta.config.systemTags = 'moved,locked'; + const result = await topics.post({ + uid: fooUid, + tags: ['one', 'two'], + title: 'topic with 2 tags', + content: 'topic content', + cid: categoryObject.cid, + }); + await posts.edit({ + pid: result.postData.pid, + uid: adminUid, + content: 'edited content', + tags: ['one', 'two', 'moved'], + }); + await posts.edit({ + pid: result.postData.pid, + uid: fooUid, + content: 'edited content', + tags: ['one', 'moved', 'two'], + }); + const tags = await topics.getTopicTags(result.topicData.tid); + assert.deepStrictEqual(tags.sort(), ['moved', 'one', 'two']); + meta.config.systemTags = oldValue; + }); + }); + + describe('follow/unfollow', () => { + const socketTopics = require('../src/socket.io/topics'); + let tid; + let followerUid; + before(done => { + User.create({username: 'follower'}, (error, uid) => { + if (error) { + return done(error); + } + + followerUid = uid; + topics.post({ + uid: adminUid, title: 'topic title', content: 'some content', cid: topic.categoryId, + }, (error, result) => { + if (error) { + return done(error); + } + + tid = result.topicData.tid; + done(); + }); + }); + }); + + it('should error if not logged in', async () => { + try { + await apiTopics.ignore({uid: 0}, {tid}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:not-logged-in]]'); + } + }); + + it('should filter ignoring uids', async () => { + await apiTopics.ignore({uid: followerUid}, {tid}); + const uids = await topics.filterIgnoringUids(tid, [adminUid, followerUid]); + assert.equal(uids.length, 1); + assert.equal(uids[0], adminUid); + }); + + it('should error with topic that does not exist', async () => { + try { + await apiTopics.follow({uid: followerUid}, {tid: -1}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:no-topic]]'); + } + }); + + it('should follow topic', done => { + topics.toggleFollow(tid, followerUid, (error, isFollowing) => { + assert.ifError(error); + assert(isFollowing); + socketTopics.isFollowed({uid: followerUid}, tid, (error, isFollowing) => { + assert.ifError(error); + assert(isFollowing); + done(); + }); + }); + }); + }); + + describe('topics search', () => { + it('should error with invalid data', async () => { + try { + await topics.search(null, null); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:invalid-data]]'); + } + }); + + it('should return results', async () => { + const plugins = require('../src/plugins'); + plugins.hooks.register('myTestPlugin', { + hook: 'filter:topic.search', + method(data, callback) { + callback(null, [1, 2, 3]); + }, + }); + const results = await topics.search(topic.tid, 'test'); + assert.deepEqual(results, [1, 2, 3]); + }); + }); + + it('should check if user is moderator', done => { + socketTopics.isModerator({uid: adminUid}, topic.tid, (error, isModerator) => { + assert.ifError(error); + assert(!isModerator); + done(); + }); + }); + + describe('teasers', () => { + let topic1; + let topic2; + before(done => { + async.series([ + function (next) { + topics.post({ + uid: adminUid, title: 'topic 1', content: 'content 1', cid: categoryObject.cid, + }, next); + }, + function (next) { + topics.post({ + uid: adminUid, title: 'topic 2', content: 'content 2', cid: categoryObject.cid, + }, next); + }, + ], (error, results) => { + assert.ifError(error); + topic1 = results[0]; + topic2 = results[1]; + done(); + }); + }); + + after(done => { + meta.config.teaserPost = ''; + done(); + }); + + it('should return empty array if first param is empty', done => { + topics.getTeasers([], 1, (error, teasers) => { + assert.ifError(error); + assert.equal(0, teasers.length); + done(); + }); + }); + + it('should get teasers with 2 params', done => { + topics.getTeasers([topic1.topicData, topic2.topicData], 1, (error, teasers) => { + assert.ifError(error); + assert.deepEqual([undefined, undefined], teasers); + done(); + }); + }); + + it('should get teasers with first posts', done => { + meta.config.teaserPost = 'first'; + topics.getTeasers([topic1.topicData, topic2.topicData], 1, (error, teasers) => { + assert.ifError(error); + assert.equal(2, teasers.length); + assert(teasers[0]); + assert(teasers[1]); + assert(teasers[0].tid, topic1.topicData.tid); + assert(teasers[0].content, 'content 1'); + assert(teasers[0].user.username, 'admin'); + done(); + }); + }); + + it('should get teasers even if one topic is falsy', done => { + topics.getTeasers([null, topic2.topicData], 1, (error, teasers) => { + assert.ifError(error); + assert.equal(2, teasers.length); + assert.equal(undefined, teasers[0]); + assert(teasers[1]); + assert(teasers[1].tid, topic2.topicData.tid); + assert(teasers[1].content, 'content 2'); + assert(teasers[1].user.username, 'admin'); + done(); + }); + }); + + it('should get teasers with last posts', done => { + meta.config.teaserPost = 'last-post'; + topics.reply({uid: adminUid, content: 'reply 1 content', tid: topic1.topicData.tid}, (error, result) => { + assert.ifError(error); + topic1.topicData.teaserPid = result.pid; + topics.getTeasers([topic1.topicData, topic2.topicData], 1, (error, teasers) => { + assert.ifError(error); + assert(teasers[0]); + assert(teasers[1]); + assert(teasers[0].tid, topic1.topicData.tid); + assert(teasers[0].content, 'reply 1 content'); + done(); + }); + }); + }); + + it('should get teasers by tids', done => { + topics.getTeasersByTids([topic2.topicData.tid, topic1.topicData.tid], 1, (error, teasers) => { + assert.ifError(error); + assert(2, teasers.length); + assert.equal(teasers[1].content, 'reply 1 content'); + done(); + }); + }); + + it('should return empty array ', done => { + topics.getTeasersByTids([], 1, (error, teasers) => { + assert.ifError(error); + assert.equal(0, teasers.length); + done(); + }); + }); + + it('should get teaser by tid', done => { + topics.getTeaser(topic2.topicData.tid, 1, (error, teaser) => { + assert.ifError(error); + assert(teaser); + assert.equal(teaser.content, 'content 2'); + done(); + }); + }); + + it('should not return teaser if user is blocked', done => { + let blockedUid; + async.waterfall([ + function (next) { + User.create({username: 'blocked'}, next); + }, + function (uid, next) { + blockedUid = uid; + User.blocks.add(uid, adminUid, next); + }, + function (next) { + topics.reply({uid: blockedUid, content: 'post from blocked user', tid: topic2.topicData.tid}, next); + }, + function (result, next) { + topics.getTeaser(topic2.topicData.tid, adminUid, next); + }, + function (teaser, next) { + assert.equal(teaser.content, 'content 2'); + User.blocks.remove(blockedUid, adminUid, next); + }, + ], done); + }); + }); + + describe('tag privilege', () => { + let uid; + let cid; + before(done => { + async.waterfall([ + function (next) { + User.create({username: 'tag_poster'}, next); + }, + function (_uid, next) { + uid = _uid; + categories.create({name: 'tag category'}, next); + }, + function (categoryObject_, next) { + cid = categoryObject_.cid; + next(); + }, + ], done); + }); + + it('should fail to post if user does not have tag privilege', done => { + privileges.categories.rescind(['groups:topics:tag'], cid, 'registered-users', error => { + assert.ifError(error); + topics.post({ + uid, cid, tags: ['tag1'], title: 'topic with tags', content: 'some content here', + }, error => { + assert.equal(error.message, '[[error:no-privileges]]'); + done(); + }); + }); + }); + + it('should fail to edit if user does not have tag privilege', done => { + topics.post({ + uid, cid, title: 'topic with tags', content: 'some content here', + }, (error, result) => { + assert.ifError(error); + const {pid} = result.postData; + posts.edit({ + pid, uid, content: 'edited content', tags: ['tag2'], + }, error_ => { + assert.equal(error_.message, '[[error:no-privileges]]'); + done(); + }); + }); + }); + + it('should be able to edit topic and add tags if allowed', done => { + privileges.categories.give(['groups:topics:tag'], cid, 'registered-users', error => { + assert.ifError(error); + topics.post({ + uid, cid, tags: ['tag1'], title: 'topic with tags', content: 'some content here', + }, (error, result) => { + assert.ifError(error); + posts.edit({ + pid: result.postData.pid, uid, content: 'edited content', tags: ['tag1', 'tag2'], + }, (error, result) => { + assert.ifError(error); + const tags = new Set(result.topic.tags.map(tag => tag.value)); + assert(tags.has('tag1')); + assert(tags.has('tag2')); + done(); + }); + }); + }); + }); + }); + + describe('topic merge', () => { + let uid; + let topic1Data; + let topic2Data; + + async function getTopic(tid) { + const topicData = await topics.getTopicData(tid); + return await topics.getTopicWithPosts(topicData, `tid:${topicData.tid}:posts`, adminUid, 0, 19, false); + } + + before(done => { + async.waterfall([ + function (next) { + User.create({username: 'mergevictim'}, next); + }, + function (_uid, next) { + uid = _uid; + topics.post({ + uid, cid: categoryObject.cid, title: 'topic 1', content: 'topic 1 OP', + }, next); + }, + function (result, next) { + topic1Data = result.topicData; + topics.post({ + uid, cid: categoryObject.cid, title: 'topic 2', content: 'topic 2 OP', + }, next); + }, + function (result, next) { + topic2Data = result.topicData; + topics.reply({uid, content: 'topic 1 reply', tid: topic1Data.tid}, next); + }, + function (postData, next) { + topics.reply({uid, content: 'topic 2 reply', tid: topic2Data.tid}, next); + }, + ], done); + }); + + it('should error if data is not an array', done => { + socketTopics.merge({uid: 0}, null, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should error if user does not have privileges', done => { + socketTopics.merge({uid: 0}, {tids: [topic2Data.tid, topic1Data.tid]}, error => { + assert.equal(error.message, '[[error:no-privileges]]'); + done(); + }); + }); + + it('should merge 2 topics', async () => { + await socketTopics.merge({uid: adminUid}, { + tids: [topic2Data.tid, topic1Data.tid], + }); + + const [topic1, topic2] = await Promise.all([ + getTopic(topic1Data.tid), + getTopic(topic2Data.tid), + ]); + + assert.equal(topic1.posts.length, 4); + assert.equal(topic2.posts.length, 0); + assert.equal(topic2.deleted, true); + + assert.equal(topic1.posts[0].content, 'topic 1 OP'); + assert.equal(topic1.posts[1].content, 'topic 2 OP'); + assert.equal(topic1.posts[2].content, 'topic 1 reply'); + assert.equal(topic1.posts[3].content, 'topic 2 reply'); + assert.equal(topic1.title, 'topic 1'); + }); + + it('should return properly for merged topic', done => { + request(`${nconf.get('url')}/api/topic/${topic2Data.slug}`, {jar: adminJar, json: true}, (error, response, body) => { + assert.ifError(error); + assert.equal(response.statusCode, 200); + assert(body); + assert.deepStrictEqual(body.posts, []); + done(); + }); + }); + + it('should merge 2 topics with options mainTid', async () => { + const topic1Result = await topics.post({ + uid, cid: categoryObject.cid, title: 'topic 1', content: 'topic 1 OP', + }); + const topic2Result = await topics.post({ + uid, cid: categoryObject.cid, title: 'topic 2', content: 'topic 2 OP', + }); + await topics.reply({uid, content: 'topic 1 reply', tid: topic1Result.topicData.tid}); + await topics.reply({uid, content: 'topic 2 reply', tid: topic2Result.topicData.tid}); + await socketTopics.merge({uid: adminUid}, { + tids: [topic2Result.topicData.tid, topic1Result.topicData.tid], + options: { + mainTid: topic2Result.topicData.tid, + }, + }); + + const [topic1, topic2] = await Promise.all([ + getTopic(topic1Result.topicData.tid), + getTopic(topic2Result.topicData.tid), + ]); + + assert.equal(topic1.posts.length, 0); + assert.equal(topic2.posts.length, 4); + assert.equal(topic1.deleted, true); + + assert.equal(topic2.posts[0].content, 'topic 2 OP'); + assert.equal(topic2.posts[1].content, 'topic 1 OP'); + assert.equal(topic2.posts[2].content, 'topic 1 reply'); + assert.equal(topic2.posts[3].content, 'topic 2 reply'); + assert.equal(topic2.title, 'topic 2'); + }); + + it('should merge 2 topics with options newTopicTitle', async () => { + const topic1Result = await topics.post({ + uid, cid: categoryObject.cid, title: 'topic 1', content: 'topic 1 OP', + }); + const topic2Result = await topics.post({ + uid, cid: categoryObject.cid, title: 'topic 2', content: 'topic 2 OP', + }); + await topics.reply({uid, content: 'topic 1 reply', tid: topic1Result.topicData.tid}); + await topics.reply({uid, content: 'topic 2 reply', tid: topic2Result.topicData.tid}); + const mergeTid = await socketTopics.merge({uid: adminUid}, { + tids: [topic2Result.topicData.tid, topic1Result.topicData.tid], + options: { + newTopicTitle: 'new merge topic', + }, + }); + + const [topic1, topic2, topic3] = await Promise.all([ + getTopic(topic1Result.topicData.tid), + getTopic(topic2Result.topicData.tid), + getTopic(mergeTid), + ]); + + assert.equal(topic1.posts.length, 0); + assert.equal(topic2.posts.length, 0); + assert.equal(topic3.posts.length, 4); + assert.equal(topic1.deleted, true); + assert.equal(topic2.deleted, true); + + assert.equal(topic3.posts[0].content, 'topic 1 OP'); + assert.equal(topic3.posts[1].content, 'topic 2 OP'); + assert.equal(topic3.posts[2].content, 'topic 1 reply'); + assert.equal(topic3.posts[3].content, 'topic 2 reply'); + assert.equal(topic3.title, 'new merge topic'); + }); + }); + + describe('sorted topics', () => { + let category; + before(async () => { + category = await categories.create({name: 'sorted'}); + const topic1Result = await topics.post({ + uid: topic.userId, cid: category.cid, title: 'old replied', content: 'topic 1 OP', + }); + const topic2Result = await topics.post({ + uid: topic.userId, cid: category.cid, title: 'most recent replied', content: 'topic 2 OP', + }); + await topics.reply({uid: topic.userId, content: 'topic 1 reply', tid: topic1Result.topicData.tid}); + await topics.reply({uid: topic.userId, content: 'topic 2 reply', tid: topic2Result.topicData.tid}); + }); + + it('should get sorted topics in category', done => { + const filters = ['', 'watched', 'unreplied', 'new']; + async.map(filters, (filter, next) => { + topics.getSortedTopics({ + cids: [category.cid], + uid: topic.userId, + start: 0, + stop: -1, + filter, + sort: 'votes', + }, next); + }, (error, data) => { + assert.ifError(error); + assert(data); + for (const filterTopics of data) { + assert(Array.isArray(filterTopics.topics)); + } + + done(); + }); + }); + it('should get topics recent replied first', async () => { + const data = await topics.getSortedTopics({ + cids: [category.cid], + uid: topic.userId, + start: 0, + stop: -1, + sort: 'recent', + }); + assert.strictEqual(data.topics[0].title, 'most recent replied'); + assert.strictEqual(data.topics[1].title, 'old replied'); + }); + + it('should get topics recent replied last', async () => { + const data = await topics.getSortedTopics({ + cids: [category.cid], + uid: topic.userId, + start: 0, + stop: -1, + sort: 'old', + }); + assert.strictEqual(data.topics[0].title, 'old replied'); + assert.strictEqual(data.topics[1].title, 'most recent replied'); + }); + }); + + describe('scheduled topics', () => { + let categoryObject; + let topicData; + let topic; + let adminApiOptions; + let postData; + const replyData = { + form: { + content: 'a reply by guest', + }, + json: true, + }; + + before(async () => { + adminApiOptions = { + json: true, + jar: adminJar, + headers: { + 'x-csrf-token': csrf_token, + }, + }; + categoryObject = await categories.create({ + name: 'Another Test Category', + description: 'Another test category created by testing script', + }); + topic = { + uid: adminUid, + cid: categoryObject.cid, + title: 'Scheduled Test Topic Title', + content: 'The content of scheduled test topic', + timestamp: new Date(Date.now() + 86_400_000).getTime(), + }; + }); + + it('should create a scheduled topic as pinned, deleted, included in "topics:scheduled" zset and with a timestamp in future', async () => { + topicData = (await topics.post(topic)).topicData; + topicData = await topics.getTopicData(topicData.tid); + + assert(topicData.pinned); + assert(topicData.deleted); + assert(topicData.scheduled); + assert(topicData.timestamp > Date.now()); + const score = await db.sortedSetScore('topics:scheduled', topicData.tid); + assert(score); + // Should not be in regular category zsets + const isMember = await db.isMemberOfSortedSets([ + `cid:${categoryObject.cid}:tids`, + `cid:${categoryObject.cid}:tids:votes`, + `cid:${categoryObject.cid}:tids:posts`, + ], topicData.tid); + assert.deepStrictEqual(isMember, [false, false, false]); + }); + + it('should update poster\'s lastposttime with "action time"', async () => { + // Src/user/posts.js:56 + const data = await User.getUsersFields([adminUid], ['lastposttime']); + assert.notStrictEqual(data[0].lastposttime, topicData.lastposttime); + }); + + it('should not load topic for an unprivileged user', async () => { + const response = await requestType('get', `${nconf.get('url')}/topic/${topicData.slug}`); + assert.strictEqual(response.statusCode, 404); + assert(response.body); + }); + + it('should load topic for a privileged user', async () => { + const response = (await requestType('get', `${nconf.get('url')}/topic/${topicData.slug}`, {jar: adminJar})).res; + assert.strictEqual(response.statusCode, 200); + assert(response.body); + }); + + it('should not be amongst topics of the category for an unprivileged user', async () => { + const response = await requestType('get', `${nconf.get('url')}/api/category/${categoryObject.slug}`, {json: true}); + assert.strictEqual(response.body.topics.filter(topic => topic.tid === topicData.tid).length, 0); + }); + + it('should be amongst topics of the category for a privileged user', async () => { + const response = await requestType('get', `${nconf.get('url')}/api/category/${categoryObject.slug}`, {json: true, jar: adminJar}); + const topic = response.body.topics.find(topic => topic.tid === topicData.tid); + assert.strictEqual(topic && topic.tid, topicData.tid); + }); + + it('should load topic for guests if privilege is given', async () => { + await privileges.categories.give(['groups:topics:schedule'], categoryObject.cid, 'guests'); + const response = await requestType('get', `${nconf.get('url')}/topic/${topicData.slug}`); + assert.strictEqual(response.statusCode, 200); + assert(response.body); + }); + + it('should be amongst topics of the category for guests if privilege is given', async () => { + const response = await requestType('get', `${nconf.get('url')}/api/category/${categoryObject.slug}`, {json: true}); + const topic = response.body.topics.find(topic => topic.tid === topicData.tid); + assert.strictEqual(topic && topic.tid, topicData.tid); + }); + + it('should not allow deletion of a scheduled topic', async () => { + const response = await requestType('delete', `${nconf.get('url')}/api/v3/topics/${topicData.tid}/state`, adminApiOptions); + assert.strictEqual(response.res.statusCode, 400); + }); + + it('should not allow to unpin a scheduled topic', async () => { + const response = await requestType('delete', `${nconf.get('url')}/api/v3/topics/${topicData.tid}/pin`, adminApiOptions); + assert.strictEqual(response.res.statusCode, 400); + }); + + it('should not allow to restore a scheduled topic', async () => { + const response = await requestType('put', `${nconf.get('url')}/api/v3/topics/${topicData.tid}/state`, adminApiOptions); + assert.strictEqual(response.res.statusCode, 400); + }); + + it('should not allow unprivileged to reply', async () => { + await privileges.categories.rescind(['groups:topics:schedule'], categoryObject.cid, 'guests'); + await privileges.categories.give(['groups:topics:reply'], categoryObject.cid, 'guests'); + const response = await requestType('post', `${nconf.get('url')}/api/v3/topics/${topicData.tid}`, replyData); + assert.strictEqual(response.res.statusCode, 403); + }); + + it('should allow guests to reply if privilege is given', async () => { + await privileges.categories.give(['groups:topics:schedule'], categoryObject.cid, 'guests'); + const response = await helpers.request('post', `/api/v3/topics/${topicData.tid}`, { + ...replyData, + jar: request.jar(), + }); + assert.strictEqual(response.body.response.content, 'a reply by guest'); + assert.strictEqual(response.body.response.user.username, '[[global:guest]]'); + }); + + it('should have replies with greater timestamp than the scheduled topics itself', async () => { + const response = await requestType('get', `${nconf.get('url')}/api/topic/${topicData.slug}`, {json: true}); + postData = response.body.posts[1]; + assert(postData.timestamp > response.body.posts[0].timestamp); + }); + + it('should have post edits with greater timestamp than the original', async () => { + const editData = {...adminApiOptions, form: {content: 'an edit by the admin'}}; + const result = await requestType('put', `${nconf.get('url')}/api/v3/posts/${postData.pid}`, editData); + assert(result.body.response.edited > postData.timestamp); + + const diffsResult = await requestType('get', `${nconf.get('url')}/api/v3/posts/${postData.pid}/diffs`, adminApiOptions); + const {revisions} = diffsResult.body.response; + // Diffs are LIFO + assert(revisions[0].timestamp > revisions[1].timestamp); + }); + + it('should able to reschedule', async () => { + const newDate = new Date(Date.now() + (5 * 86_400_000)).getTime(); + const editData = {...adminApiOptions, form: {...topic, pid: topicData.mainPid, timestamp: newDate}}; + const response = await requestType('put', `${nconf.get('url')}/api/v3/posts/${topicData.mainPid}`, editData); + + const editedTopic = await topics.getTopicFields(topicData.tid, ['lastposttime', 'timestamp']); + const editedPost = await posts.getPostFields(postData.pid, ['timestamp']); + assert(editedTopic.timestamp === newDate); + assert(editedPost.timestamp > editedTopic.timestamp); + + const scores = await db.sortedSetsScore([ + 'topics:scheduled', + `uid:${adminUid}:topics`, + 'topics:tid', + `cid:${topicData.cid}:uid:${adminUid}:tids`, + ], topicData.tid); + assert(scores.every(publishTime => publishTime === editedTopic.timestamp)); + }); + + it('should able to publish a scheduled topic', async () => { + const topicTimestamp = await topics.getTopicField(topicData.tid, 'timestamp'); + + mockdate.set(topicTimestamp); + await topics.scheduled.handleExpired(); + + topicData = await topics.getTopicData(topicData.tid); + assert(!topicData.pinned); + assert(!topicData.deleted); + // Should remove from topics:scheduled upon publishing + const score = await db.sortedSetScore('topics:scheduled', topicData.tid); + assert(!score); + }); + + it('should update poster\'s lastposttime after a ST published', async () => { + const data = await User.getUsersFields([adminUid], ['lastposttime']); + assert.strictEqual(adminUid, topicData.uid); + assert.strictEqual(data[0].lastposttime, topicData.lastposttime); + }); + + it('should not be able to schedule a "published" topic', async () => { + const newDate = new Date(Date.now() + 86_400_000).getTime(); + const editData = {...adminApiOptions, form: {...topic, pid: topicData.mainPid, timestamp: newDate}}; + const response = await requestType('put', `${nconf.get('url')}/api/v3/posts/${topicData.mainPid}`, editData); + assert.strictEqual(response.body.response.timestamp, Date.now()); + + mockdate.reset(); + }); + + it('should allow to purge a scheduled topic', async () => { + topicData = (await topics.post(topic)).topicData; + const response = await requestType('delete', `${nconf.get('url')}/api/v3/topics/${topicData.tid}`, adminApiOptions); + assert.strictEqual(response.res.statusCode, 200); + }); + + it('should remove from topics:scheduled on purge', async () => { + const score = await db.sortedSetScore('topics:scheduled', topicData.tid); + assert(!score); + }); + }); }); describe('Topics\'', async () => { - let files; + let files; - before(async () => { - files = await file.walk(path.resolve(__dirname, './topics')); - }); + before(async () => { + files = await file.walk(path.resolve(__dirname, './topics')); + }); - it('subfolder tests', () => { - files.forEach((filePath) => { - require(filePath); - }); - }); + it('subfolder tests', () => { + for (const filePath of files) { + require(filePath); + } + }); }); diff --git a/test/topics/events.js b/test/topics/events.js index f123577..398bb9a 100644 --- a/test/topics/events.js +++ b/test/topics/events.js @@ -1,105 +1,103 @@ 'use strict'; -const assert = require('assert'); - +const assert = require('node:assert'); const db = require('../mocks/databasemock'); - const plugins = require('../../src/plugins'); const categories = require('../../src/categories'); const topics = require('../../src/topics'); const user = require('../../src/user'); describe('Topic Events', () => { - let fooUid; - let topic; - before(async () => { - fooUid = await user.create({ username: 'foo', password: '123456' }); - - const categoryObj = await categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - }); - topic = await topics.post({ - title: 'topic events testing', - content: 'foobar one two three', - uid: fooUid, - cid: 1, - }); - }); - - describe('.init()', () => { - before(() => { - topics.events._ready = false; - }); - - it('should allow a plugin to expose new event types', async () => { - await plugins.hooks.register('core', { - hook: 'filter:topicEvents.init', - method: async ({ types }) => { - types.foo = { - icon: 'bar', - text: 'baz', - quux: 'quux', - }; - - return { types }; - }, - }); - - await topics.events.init(); - - assert(topics.events._types.foo); - assert.deepStrictEqual(topics.events._types.foo, { - icon: 'bar', - text: 'baz', - quux: 'quux', - }); - }); - }); - - describe('.log()', () => { - it('should log and return a set of new events in the topic', async () => { - const events = await topics.events.log(topic.topicData.tid, { - type: 'foo', - }); - - assert(events); - assert(Array.isArray(events)); - events.forEach((event) => { - assert(['id', 'icon', 'text', 'timestamp', 'timestampISO', 'type', 'quux'].every(key => event.hasOwnProperty(key))); - }); - }); - }); - - describe('.get()', () => { - it('should get a topic\'s events', async () => { - const events = await topics.events.get(topic.topicData.tid); - - assert(events); - assert(Array.isArray(events)); - assert.strictEqual(events.length, 1); - events.forEach((event) => { - assert(['id', 'icon', 'text', 'timestamp', 'timestampISO', 'type', 'quux'].every(key => event.hasOwnProperty(key))); - }); - }); - }); - - describe('.purge()', () => { - let eventIds; - - before(async () => { - const events = await topics.events.get(topic.topicData.tid); - eventIds = events.map(event => event.id); - }); - - it('should purge topic\'s events from the database', async () => { - await topics.events.purge(topic.topicData.tid); - - const keys = [`topic:${topic.topicData.tid}:events`]; - keys.push(...eventIds.map(id => `topicEvent:${id}`)); - - const exists = await Promise.all(keys.map(key => db.exists(key))); - assert(exists.every(exists => !exists)); - }); - }); + let fooUid; + let topic; + before(async () => { + fooUid = await user.create({username: 'foo', password: '123456'}); + + const categoryObject = await categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + }); + topic = await topics.post({ + title: 'topic events testing', + content: 'foobar one two three', + uid: fooUid, + cid: 1, + }); + }); + + describe('.init()', () => { + before(() => { + topics.events._ready = false; + }); + + it('should allow a plugin to expose new event types', async () => { + await plugins.hooks.register('core', { + hook: 'filter:topicEvents.init', + async method({types}) { + types.foo = { + icon: 'bar', + text: 'baz', + quux: 'quux', + }; + + return {types}; + }, + }); + + await topics.events.init(); + + assert(topics.events._types.foo); + assert.deepStrictEqual(topics.events._types.foo, { + icon: 'bar', + text: 'baz', + quux: 'quux', + }); + }); + }); + + describe('.log()', () => { + it('should log and return a set of new events in the topic', async () => { + const events = await topics.events.log(topic.topicData.tid, { + type: 'foo', + }); + + assert(events); + assert(Array.isArray(events)); + for (const event of events) { + assert(['id', 'icon', 'text', 'timestamp', 'timestampISO', 'type', 'quux'].every(key => event.hasOwnProperty(key))); + } + }); + }); + + describe('.get()', () => { + it('should get a topic\'s events', async () => { + const events = await topics.events.get(topic.topicData.tid); + + assert(events); + assert(Array.isArray(events)); + assert.strictEqual(events.length, 1); + for (const event of events) { + assert(['id', 'icon', 'text', 'timestamp', 'timestampISO', 'type', 'quux'].every(key => event.hasOwnProperty(key))); + } + }); + }); + + describe('.purge()', () => { + let eventIds; + + before(async () => { + const events = await topics.events.get(topic.topicData.tid); + eventIds = events.map(event => event.id); + }); + + it('should purge topic\'s events from the database', async () => { + await topics.events.purge(topic.topicData.tid); + + const keys = [`topic:${topic.topicData.tid}:events`]; + keys.push(...eventIds.map(id => `topicEvent:${id}`)); + + const exists = await Promise.all(keys.map(key => db.exists(key))); + assert(exists.every(exists => !exists)); + }); + }); }); diff --git a/test/topics/thumbs.js b/test/topics/thumbs.js index 013fe8d..b1817f2 100644 --- a/test/topics/thumbs.js +++ b/test/topics/thumbs.js @@ -1,12 +1,10 @@ 'use strict'; -const fs = require('fs'); -const path = require('path'); -const assert = require('assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert'); const nconf = require('nconf'); - const db = require('../mocks/databasemock'); - const meta = require('../../src/meta'); const user = require('../../src/user'); const groups = require('../../src/groups'); @@ -16,422 +14,421 @@ const categories = require('../../src/categories'); const plugins = require('../../src/plugins'); const file = require('../../src/file'); const utils = require('../../src/utils'); - const helpers = require('../helpers'); describe('Topic thumbs', () => { - let topicObj; - let categoryObj; - let adminUid; - let adminJar; - let adminCSRF; - let fooJar; - let fooCSRF; - let fooUid; - const thumbPaths = [ - `${nconf.get('upload_path')}/files/test.png`, - `${nconf.get('upload_path')}/files/test2.png`, - 'https://example.org', - ]; - const relativeThumbPaths = thumbPaths.map(path => path.replace(nconf.get('upload_path'), '')); - const uuid = utils.generateUUID(); - - function createFiles() { - fs.closeSync(fs.openSync(path.resolve(__dirname, '../uploads', thumbPaths[0]), 'w')); - fs.closeSync(fs.openSync(path.resolve(__dirname, '../uploads', thumbPaths[1]), 'w')); - } - - before(async () => { - meta.config.allowTopicsThumbnail = 1; - - adminUid = await user.create({ username: 'admin', password: '123456' }); - fooUid = await user.create({ username: 'foo', password: '123456' }); - await groups.join('administrators', adminUid); - const adminLogin = await helpers.loginUser('admin', '123456'); - adminJar = adminLogin.jar; - adminCSRF = adminLogin.csrf_token; - const fooLogin = await helpers.loginUser('foo', '123456'); - fooJar = fooLogin.jar; - fooCSRF = fooLogin.csrf_token; - - categoryObj = await categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - }); - topicObj = await topics.post({ - uid: adminUid, - cid: categoryObj.cid, - title: 'Test Topic Title', - content: 'The content of test topic', - }); - - // Touch a couple files and associate it to a topic - createFiles(); - await db.sortedSetAdd(`topic:${topicObj.topicData.tid}:thumbs`, 0, `${relativeThumbPaths[0]}`); - }); - - it('should return bool for whether a thumb exists', async () => { - const exists = await topics.thumbs.exists(topicObj.topicData.tid, `${relativeThumbPaths[0]}`); - assert.strictEqual(exists, true); - }); - - describe('.get()', () => { - it('should return an array of thumbs', async () => { - require('../../src/cache').del(`topic:${topicObj.topicData.tid}:thumbs`); - const thumbs = await topics.thumbs.get(topicObj.topicData.tid); - assert.deepStrictEqual(thumbs, [{ - id: topicObj.topicData.tid, - name: 'test.png', - url: `${nconf.get('relative_path')}${nconf.get('upload_url')}${relativeThumbPaths[0]}`, - }]); - }); - - it('should return an array of an array of thumbs if multiple tids are passed in', async () => { - const thumbs = await topics.thumbs.get([topicObj.topicData.tid, topicObj.topicData.tid + 1]); - assert.deepStrictEqual(thumbs, [ - [{ - id: topicObj.topicData.tid, - name: 'test.png', - url: `${nconf.get('relative_path')}${nconf.get('upload_url')}${relativeThumbPaths[0]}`, - }], - [], - ]); - }); - }); - - describe('.associate()', () => { - let tid; - let mainPid; - - before(async () => { - topicObj = await topics.post({ - uid: adminUid, - cid: categoryObj.cid, - title: 'Test Topic Title', - content: 'The content of test topic', - }); - tid = topicObj.topicData.tid; - mainPid = topicObj.postData.pid; - }); - - it('should add an uploaded file to a zset', async () => { - await topics.thumbs.associate({ - id: tid, - path: relativeThumbPaths[0], - }); - - const exists = await db.isSortedSetMember(`topic:${tid}:thumbs`, relativeThumbPaths[0]); - assert(exists); - }); - - it('should also work with UUIDs', async () => { - await topics.thumbs.associate({ - id: uuid, - path: relativeThumbPaths[1], - score: 5, - }); - - const exists = await db.isSortedSetMember(`draft:${uuid}:thumbs`, relativeThumbPaths[1]); - assert(exists); - }); - - it('should also work with a URL', async () => { - await topics.thumbs.associate({ - id: tid, - path: relativeThumbPaths[2], - }); - - const exists = await db.isSortedSetMember(`topic:${tid}:thumbs`, relativeThumbPaths[2]); - assert(exists); - }); - - it('should have a score equal to the number of thumbs prior to addition', async () => { - const scores = await db.sortedSetScores(`topic:${tid}:thumbs`, [relativeThumbPaths[0], relativeThumbPaths[2]]); - assert.deepStrictEqual(scores, [0, 1]); - }); - - it('should update the relevant topic hash with the number of thumbnails', async () => { - const numThumbs = await topics.getTopicField(tid, 'numThumbs'); - assert.strictEqual(parseInt(numThumbs, 10), 2); - }); - - it('should successfully associate a thumb with a topic even if it already contains that thumbnail (updates score)', async () => { - await topics.thumbs.associate({ - id: tid, - path: relativeThumbPaths[0], - }); - - const score = await db.sortedSetScore(`topic:${tid}:thumbs`, relativeThumbPaths[0]); - - assert(isFinite(score)); // exists in set - assert.strictEqual(score, 2); - }); - - it('should update the score to be passed in as the third argument', async () => { - await topics.thumbs.associate({ - id: tid, - path: relativeThumbPaths[0], - score: 0, - }); - - const score = await db.sortedSetScore(`topic:${tid}:thumbs`, relativeThumbPaths[0]); - - assert(isFinite(score)); // exists in set - assert.strictEqual(score, 0); - }); - - it('should associate the thumbnail with that topic\'s main pid\'s uploads', async () => { - const uploads = await posts.uploads.list(mainPid); - assert(uploads.includes(relativeThumbPaths[0].slice(1))); - }); - - it('should maintain state in the topic\'s main pid\'s uploads if posts.uploads.sync() is called', async () => { - await posts.uploads.sync(mainPid); - const uploads = await posts.uploads.list(mainPid); - assert(uploads.includes(relativeThumbPaths[0].slice(1))); - }); - - it('should combine the thumbs uploaded to a UUID zset and combine it with a topic\'s thumb zset', async () => { - await topics.thumbs.migrate(uuid, tid); - - const thumbs = await topics.thumbs.get(tid); - assert.strictEqual(thumbs.length, 3); - assert.deepStrictEqual(thumbs, [ - { - id: tid, - name: 'test.png', - url: `${nconf.get('relative_path')}${nconf.get('upload_url')}${relativeThumbPaths[0]}`, - }, - { - id: tid, - name: 'example.org', - url: 'https://example.org', - }, - { - id: tid, - name: 'test2.png', - url: `${nconf.get('relative_path')}${nconf.get('upload_url')}${relativeThumbPaths[1]}`, - }, - ]); - }); - }); - - describe(`.delete()`, () => { - it('should remove a file from sorted set', async () => { - await topics.thumbs.associate({ - id: 1, - path: thumbPaths[0], - }); - await topics.thumbs.delete(1, relativeThumbPaths[0]); - - assert.strictEqual(await db.isSortedSetMember('topic:1:thumbs', relativeThumbPaths[0]), false); - }); - - it('should no longer be associated with that topic\'s main pid\'s uploads', async () => { - const mainPid = (await topics.getMainPids([1]))[0]; - const uploads = await posts.uploads.list(mainPid); - assert(!uploads.includes(path.basename(relativeThumbPaths[0]))); - }); - - it('should also work with UUIDs', async () => { - await topics.thumbs.associate({ - id: uuid, - path: thumbPaths[1], - }); - await topics.thumbs.delete(uuid, relativeThumbPaths[1]); - - assert.strictEqual(await db.isSortedSetMember(`draft:${uuid}:thumbs`, relativeThumbPaths[1]), false); - assert.strictEqual(await file.exists(thumbPaths[1]), false); - }); - - it('should also work with URLs', async () => { - await topics.thumbs.associate({ - id: uuid, - path: thumbPaths[2], - }); - await topics.thumbs.delete(uuid, relativeThumbPaths[2]); - - assert.strictEqual(await db.isSortedSetMember(`draft:${uuid}:thumbs`, relativeThumbPaths[2]), false); - }); - - it('should not delete the file from disk if not associated with the tid', async () => { - createFiles(); - await topics.thumbs.delete(uuid, thumbPaths[0]); - assert.strictEqual(await file.exists(thumbPaths[0]), true); - }); - - it('should handle an array of relative paths', async () => { - await topics.thumbs.associate({ id: 1, path: thumbPaths[0] }); - await topics.thumbs.associate({ id: 1, path: thumbPaths[1] }); - - await topics.thumbs.delete(1, [relativeThumbPaths[0], relativeThumbPaths[1]]); - }); - - it('should have no more thumbs left', async () => { - const associated = await db.isSortedSetMembers(`topic:1:thumbs`, [relativeThumbPaths[0], relativeThumbPaths[1]]); - assert.strictEqual(associated.some(Boolean), false); - }); - - it('should decrement numThumbs if dissociated one by one', async () => { - await topics.thumbs.associate({ id: 1, path: thumbPaths[0] }); - await topics.thumbs.associate({ id: 1, path: thumbPaths[1] }); - - await topics.thumbs.delete(1, [relativeThumbPaths[0]]); - let numThumbs = parseInt(await db.getObjectField('topic:1', 'numThumbs'), 10); - assert.strictEqual(numThumbs, 1); - - await topics.thumbs.delete(1, [relativeThumbPaths[1]]); - numThumbs = parseInt(await db.getObjectField('topic:1', 'numThumbs'), 10); - assert.strictEqual(numThumbs, 0); - }); - }); - - describe('.deleteAll()', () => { - before(async () => { - await Promise.all([ - topics.thumbs.associate({ id: 1, path: thumbPaths[0] }), - topics.thumbs.associate({ id: 1, path: thumbPaths[1] }), - ]); - createFiles(); - }); - - it('should have thumbs prior to tests', async () => { - const associated = await db.isSortedSetMembers(`topic:1:thumbs`, [relativeThumbPaths[0], relativeThumbPaths[1]]); - assert.strictEqual(associated.every(Boolean), true); - }); - - it('should not error out', async () => { - await topics.thumbs.deleteAll(1); - }); - - it('should remove all associated thumbs with that topic', async () => { - const associated = await db.isSortedSetMembers(`topic:1:thumbs`, [relativeThumbPaths[0], relativeThumbPaths[1]]); - assert.strictEqual(associated.some(Boolean), false); - }); - - it('should no longer have a :thumbs zset', async () => { - assert.strictEqual(await db.exists('topic:1:thumbs'), false); - }); - }); - - describe('HTTP calls to topic thumb routes', () => { - before(() => { - createFiles(); - }); - - it('should succeed with a valid tid', (done) => { - helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/1/thumbs`, path.join(__dirname, '../files/test.png'), {}, adminJar, adminCSRF, (err, res, body) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 200); - done(); - }); - }); - - it('should succeed with a uuid', (done) => { - helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/${uuid}/thumbs`, path.join(__dirname, '../files/test.png'), {}, adminJar, adminCSRF, (err, res, body) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 200); - done(); - }); - }); - - it('should succeed with uploader plugins', async () => { - const hookMethod = async () => ({ - name: 'test.png', - url: 'https://example.org', - }); - await plugins.hooks.register('test', { - hook: 'filter:uploadFile', - method: hookMethod, - }); - - await new Promise((resolve) => { - helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/${uuid}/thumbs`, path.join(__dirname, '../files/test.png'), {}, adminJar, adminCSRF, (err, res, body) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 200); - resolve(); - }); - }); - - await plugins.hooks.unregister('test', 'filter:uploadFile', hookMethod); - }); - - it('should fail with a non-existant tid', (done) => { - helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/4/thumbs`, path.join(__dirname, '../files/test.png'), {}, adminJar, adminCSRF, (err, res, body) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 404); - done(); - }); - }); - - it('should fail when garbage is passed in', (done) => { - helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/abracadabra/thumbs`, path.join(__dirname, '../files/test.png'), {}, adminJar, adminCSRF, (err, res, body) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 404); - done(); - }); - }); - - it('should fail when calling user cannot edit the tid', (done) => { - helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/2/thumbs`, path.join(__dirname, '../files/test.png'), {}, fooJar, fooCSRF, (err, res, body) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 403); - done(); - }); - }); - - it('should fail if thumbnails are not enabled', (done) => { - meta.config.allowTopicsThumbnail = 0; - - helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/${uuid}/thumbs`, path.join(__dirname, '../files/test.png'), {}, adminJar, adminCSRF, (err, res, body) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 503); - assert(body && body.status); - assert.strictEqual(body.status.message, 'Topic thumbnails are disabled.'); - done(); - }); - }); - - it('should fail if file is not image', (done) => { - meta.config.allowTopicsThumbnail = 1; - - helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/${uuid}/thumbs`, path.join(__dirname, '../files/503.html'), {}, adminJar, adminCSRF, (err, res, body) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 500); - assert(body && body.status); - assert.strictEqual(body.status.message, 'Invalid File'); - done(); - }); - }); - }); - - describe('behaviour on topic purge', () => { - let topicObj; - - before(async () => { - topicObj = await topics.post({ - uid: adminUid, - cid: categoryObj.cid, - title: 'Test Topic Title', - content: 'The content of test topic', - }); - - await Promise.all([ - topics.thumbs.associate({ id: topicObj.tid, path: thumbPaths[0] }), - topics.thumbs.associate({ id: topicObj.tid, path: thumbPaths[1] }), - ]); - createFiles(); - - await topics.purge(topicObj.tid, adminUid); - }); - - it('should no longer have a :thumbs zset', async () => { - assert.strictEqual(await db.exists(`topic:${topicObj.tid}:thumbs`), false); - }); - - it('should not leave post upload associations behind', async () => { - const uploads = await db.getSortedSetMembers(`post:${topicObj.postData.pid}:uploads`); - assert.strictEqual(uploads.length, 0); - }); - }); + let topicObject; + let categoryObject; + let adminUid; + let adminJar; + let adminCSRF; + let fooJar; + let fooCSRF; + let fooUid; + const thumbPaths = [ + `${nconf.get('upload_path')}/files/test.png`, + `${nconf.get('upload_path')}/files/test2.png`, + 'https://example.org', + ]; + const relativeThumbPaths = thumbPaths.map(path => path.replace(nconf.get('upload_path'), '')); + const uuid = utils.generateUUID(); + + function createFiles() { + fs.closeSync(fs.openSync(path.resolve(__dirname, '../uploads', thumbPaths[0]), 'w')); + fs.closeSync(fs.openSync(path.resolve(__dirname, '../uploads', thumbPaths[1]), 'w')); + } + + before(async () => { + meta.config.allowTopicsThumbnail = 1; + + adminUid = await user.create({username: 'admin', password: '123456'}); + fooUid = await user.create({username: 'foo', password: '123456'}); + await groups.join('administrators', adminUid); + const adminLogin = await helpers.loginUser('admin', '123456'); + adminJar = adminLogin.jar; + adminCSRF = adminLogin.csrf_token; + const fooLogin = await helpers.loginUser('foo', '123456'); + fooJar = fooLogin.jar; + fooCSRF = fooLogin.csrf_token; + + categoryObject = await categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + }); + topicObject = await topics.post({ + uid: adminUid, + cid: categoryObject.cid, + title: 'Test Topic Title', + content: 'The content of test topic', + }); + + // Touch a couple files and associate it to a topic + createFiles(); + await db.sortedSetAdd(`topic:${topicObject.topicData.tid}:thumbs`, 0, `${relativeThumbPaths[0]}`); + }); + + it('should return bool for whether a thumb exists', async () => { + const exists = await topics.thumbs.exists(topicObject.topicData.tid, `${relativeThumbPaths[0]}`); + assert.strictEqual(exists, true); + }); + + describe('.get()', () => { + it('should return an array of thumbs', async () => { + require('../../src/cache').del(`topic:${topicObject.topicData.tid}:thumbs`); + const thumbs = await topics.thumbs.get(topicObject.topicData.tid); + assert.deepStrictEqual(thumbs, [{ + id: topicObject.topicData.tid, + name: 'test.png', + url: `${nconf.get('relative_path')}${nconf.get('upload_url')}${relativeThumbPaths[0]}`, + }]); + }); + + it('should return an array of an array of thumbs if multiple tids are passed in', async () => { + const thumbs = await topics.thumbs.get([topicObject.topicData.tid, topicObject.topicData.tid + 1]); + assert.deepStrictEqual(thumbs, [ + [{ + id: topicObject.topicData.tid, + name: 'test.png', + url: `${nconf.get('relative_path')}${nconf.get('upload_url')}${relativeThumbPaths[0]}`, + }], + [], + ]); + }); + }); + + describe('.associate()', () => { + let tid; + let mainPid; + + before(async () => { + topicObject = await topics.post({ + uid: adminUid, + cid: categoryObject.cid, + title: 'Test Topic Title', + content: 'The content of test topic', + }); + tid = topicObject.topicData.tid; + mainPid = topicObject.postData.pid; + }); + + it('should add an uploaded file to a zset', async () => { + await topics.thumbs.associate({ + id: tid, + path: relativeThumbPaths[0], + }); + + const exists = await db.isSortedSetMember(`topic:${tid}:thumbs`, relativeThumbPaths[0]); + assert(exists); + }); + + it('should also work with UUIDs', async () => { + await topics.thumbs.associate({ + id: uuid, + path: relativeThumbPaths[1], + score: 5, + }); + + const exists = await db.isSortedSetMember(`draft:${uuid}:thumbs`, relativeThumbPaths[1]); + assert(exists); + }); + + it('should also work with a URL', async () => { + await topics.thumbs.associate({ + id: tid, + path: relativeThumbPaths[2], + }); + + const exists = await db.isSortedSetMember(`topic:${tid}:thumbs`, relativeThumbPaths[2]); + assert(exists); + }); + + it('should have a score equal to the number of thumbs prior to addition', async () => { + const scores = await db.sortedSetScores(`topic:${tid}:thumbs`, [relativeThumbPaths[0], relativeThumbPaths[2]]); + assert.deepStrictEqual(scores, [0, 1]); + }); + + it('should update the relevant topic hash with the number of thumbnails', async () => { + const numberThumbs = await topics.getTopicField(tid, 'numThumbs'); + assert.strictEqual(Number.parseInt(numberThumbs, 10), 2); + }); + + it('should successfully associate a thumb with a topic even if it already contains that thumbnail (updates score)', async () => { + await topics.thumbs.associate({ + id: tid, + path: relativeThumbPaths[0], + }); + + const score = await db.sortedSetScore(`topic:${tid}:thumbs`, relativeThumbPaths[0]); + + assert(isFinite(score)); // Exists in set + assert.strictEqual(score, 2); + }); + + it('should update the score to be passed in as the third argument', async () => { + await topics.thumbs.associate({ + id: tid, + path: relativeThumbPaths[0], + score: 0, + }); + + const score = await db.sortedSetScore(`topic:${tid}:thumbs`, relativeThumbPaths[0]); + + assert(isFinite(score)); // Exists in set + assert.strictEqual(score, 0); + }); + + it('should associate the thumbnail with that topic\'s main pid\'s uploads', async () => { + const uploads = await posts.uploads.list(mainPid); + assert(uploads.includes(relativeThumbPaths[0].slice(1))); + }); + + it('should maintain state in the topic\'s main pid\'s uploads if posts.uploads.sync() is called', async () => { + await posts.uploads.sync(mainPid); + const uploads = await posts.uploads.list(mainPid); + assert(uploads.includes(relativeThumbPaths[0].slice(1))); + }); + + it('should combine the thumbs uploaded to a UUID zset and combine it with a topic\'s thumb zset', async () => { + await topics.thumbs.migrate(uuid, tid); + + const thumbs = await topics.thumbs.get(tid); + assert.strictEqual(thumbs.length, 3); + assert.deepStrictEqual(thumbs, [ + { + id: tid, + name: 'test.png', + url: `${nconf.get('relative_path')}${nconf.get('upload_url')}${relativeThumbPaths[0]}`, + }, + { + id: tid, + name: 'example.org', + url: 'https://example.org', + }, + { + id: tid, + name: 'test2.png', + url: `${nconf.get('relative_path')}${nconf.get('upload_url')}${relativeThumbPaths[1]}`, + }, + ]); + }); + }); + + describe('.delete()', () => { + it('should remove a file from sorted set', async () => { + await topics.thumbs.associate({ + id: 1, + path: thumbPaths[0], + }); + await topics.thumbs.delete(1, relativeThumbPaths[0]); + + assert.strictEqual(await db.isSortedSetMember('topic:1:thumbs', relativeThumbPaths[0]), false); + }); + + it('should no longer be associated with that topic\'s main pid\'s uploads', async () => { + const [mainPid] = await topics.getMainPids([1]); + const uploads = await posts.uploads.list(mainPid); + assert(!uploads.includes(path.basename(relativeThumbPaths[0]))); + }); + + it('should also work with UUIDs', async () => { + await topics.thumbs.associate({ + id: uuid, + path: thumbPaths[1], + }); + await topics.thumbs.delete(uuid, relativeThumbPaths[1]); + + assert.strictEqual(await db.isSortedSetMember(`draft:${uuid}:thumbs`, relativeThumbPaths[1]), false); + assert.strictEqual(await file.exists(thumbPaths[1]), false); + }); + + it('should also work with URLs', async () => { + await topics.thumbs.associate({ + id: uuid, + path: thumbPaths[2], + }); + await topics.thumbs.delete(uuid, relativeThumbPaths[2]); + + assert.strictEqual(await db.isSortedSetMember(`draft:${uuid}:thumbs`, relativeThumbPaths[2]), false); + }); + + it('should not delete the file from disk if not associated with the tid', async () => { + createFiles(); + await topics.thumbs.delete(uuid, thumbPaths[0]); + assert.strictEqual(await file.exists(thumbPaths[0]), true); + }); + + it('should handle an array of relative paths', async () => { + await topics.thumbs.associate({id: 1, path: thumbPaths[0]}); + await topics.thumbs.associate({id: 1, path: thumbPaths[1]}); + + await topics.thumbs.delete(1, [relativeThumbPaths[0], relativeThumbPaths[1]]); + }); + + it('should have no more thumbs left', async () => { + const associated = await db.isSortedSetMembers('topic:1:thumbs', [relativeThumbPaths[0], relativeThumbPaths[1]]); + assert.strictEqual(associated.some(Boolean), false); + }); + + it('should decrement numThumbs if dissociated one by one', async () => { + await topics.thumbs.associate({id: 1, path: thumbPaths[0]}); + await topics.thumbs.associate({id: 1, path: thumbPaths[1]}); + + await topics.thumbs.delete(1, [relativeThumbPaths[0]]); + let numberThumbs = Number.parseInt(await db.getObjectField('topic:1', 'numThumbs'), 10); + assert.strictEqual(numberThumbs, 1); + + await topics.thumbs.delete(1, [relativeThumbPaths[1]]); + numberThumbs = Number.parseInt(await db.getObjectField('topic:1', 'numThumbs'), 10); + assert.strictEqual(numberThumbs, 0); + }); + }); + + describe('.deleteAll()', () => { + before(async () => { + await Promise.all([ + topics.thumbs.associate({id: 1, path: thumbPaths[0]}), + topics.thumbs.associate({id: 1, path: thumbPaths[1]}), + ]); + createFiles(); + }); + + it('should have thumbs prior to tests', async () => { + const associated = await db.isSortedSetMembers('topic:1:thumbs', [relativeThumbPaths[0], relativeThumbPaths[1]]); + assert.strictEqual(associated.every(Boolean), true); + }); + + it('should not error out', async () => { + await topics.thumbs.deleteAll(1); + }); + + it('should remove all associated thumbs with that topic', async () => { + const associated = await db.isSortedSetMembers('topic:1:thumbs', [relativeThumbPaths[0], relativeThumbPaths[1]]); + assert.strictEqual(associated.some(Boolean), false); + }); + + it('should no longer have a :thumbs zset', async () => { + assert.strictEqual(await db.exists('topic:1:thumbs'), false); + }); + }); + + describe('HTTP calls to topic thumb routes', () => { + before(() => { + createFiles(); + }); + + it('should succeed with a valid tid', done => { + helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/1/thumbs`, path.join(__dirname, '../files/test.png'), {}, adminJar, adminCSRF, (error, res, body) => { + assert.ifError(error); + assert.strictEqual(res.statusCode, 200); + done(); + }); + }); + + it('should succeed with a uuid', done => { + helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/${uuid}/thumbs`, path.join(__dirname, '../files/test.png'), {}, adminJar, adminCSRF, (error, res, body) => { + assert.ifError(error); + assert.strictEqual(res.statusCode, 200); + done(); + }); + }); + + it('should succeed with uploader plugins', async () => { + const hookMethod = async () => ({ + name: 'test.png', + url: 'https://example.org', + }); + await plugins.hooks.register('test', { + hook: 'filter:uploadFile', + method: hookMethod, + }); + + await new Promise(resolve => { + helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/${uuid}/thumbs`, path.join(__dirname, '../files/test.png'), {}, adminJar, adminCSRF, (error, res, body) => { + assert.ifError(error); + assert.strictEqual(res.statusCode, 200); + resolve(); + }); + }); + + await plugins.hooks.unregister('test', 'filter:uploadFile', hookMethod); + }); + + it('should fail with a non-existant tid', done => { + helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/4/thumbs`, path.join(__dirname, '../files/test.png'), {}, adminJar, adminCSRF, (error, res, body) => { + assert.ifError(error); + assert.strictEqual(res.statusCode, 404); + done(); + }); + }); + + it('should fail when garbage is passed in', done => { + helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/abracadabra/thumbs`, path.join(__dirname, '../files/test.png'), {}, adminJar, adminCSRF, (error, res, body) => { + assert.ifError(error); + assert.strictEqual(res.statusCode, 404); + done(); + }); + }); + + it('should fail when calling user cannot edit the tid', done => { + helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/2/thumbs`, path.join(__dirname, '../files/test.png'), {}, fooJar, fooCSRF, (error, res, body) => { + assert.ifError(error); + assert.strictEqual(res.statusCode, 403); + done(); + }); + }); + + it('should fail if thumbnails are not enabled', done => { + meta.config.allowTopicsThumbnail = 0; + + helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/${uuid}/thumbs`, path.join(__dirname, '../files/test.png'), {}, adminJar, adminCSRF, (error, res, body) => { + assert.ifError(error); + assert.strictEqual(res.statusCode, 503); + assert(body && body.status); + assert.strictEqual(body.status.message, 'Topic thumbnails are disabled.'); + done(); + }); + }); + + it('should fail if file is not image', done => { + meta.config.allowTopicsThumbnail = 1; + + helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/${uuid}/thumbs`, path.join(__dirname, '../files/503.html'), {}, adminJar, adminCSRF, (error, res, body) => { + assert.ifError(error); + assert.strictEqual(res.statusCode, 500); + assert(body && body.status); + assert.strictEqual(body.status.message, 'Invalid File'); + done(); + }); + }); + }); + + describe('behaviour on topic purge', () => { + let topicObject; + + before(async () => { + topicObject = await topics.post({ + uid: adminUid, + cid: categoryObject.cid, + title: 'Test Topic Title', + content: 'The content of test topic', + }); + + await Promise.all([ + topics.thumbs.associate({id: topicObject.tid, path: thumbPaths[0]}), + topics.thumbs.associate({id: topicObject.tid, path: thumbPaths[1]}), + ]); + createFiles(); + + await topics.purge(topicObject.tid, adminUid); + }); + + it('should no longer have a :thumbs zset', async () => { + assert.strictEqual(await db.exists(`topic:${topicObject.tid}:thumbs`), false); + }); + + it('should not leave post upload associations behind', async () => { + const uploads = await db.getSortedSetMembers(`post:${topicObject.postData.pid}:uploads`); + assert.strictEqual(uploads.length, 0); + }); + }); }); diff --git a/test/translator.js b/test/translator.js index 1507876..9e107c3 100644 --- a/test/translator.js +++ b/test/translator.js @@ -2,379 +2,382 @@ // For tests relating to Transifex configuration, check i18n.js -const assert = require('assert'); +const assert = require('node:assert'); const shim = require('../src/translator'); -const { Translator } = shim; +const {Translator} = shim; const db = require('./mocks/databasemock'); describe('Translator shim', () => { - describe('.translate()', () => { - it('should translate correctly', (done) => { - shim.translate('[[global:pagination.out_of, (foobar), [[global:home]]]]', (translated) => { - assert.strictEqual(translated, '(foobar) out of Home'); - done(); - }); - }); - - it('should accept a language parameter and adjust accordingly', (done) => { - shim.translate('[[global:home]]', 'de', (translated) => { - assert.strictEqual(translated, 'Übersicht'); - done(); - }); - }); - - it('should translate empty string properly', (done) => { - shim.translate('', 'en-GB', (translated) => { - assert.strictEqual(translated, ''); - done(); - }); - }); - - it('should translate empty string properly', async () => { - const translated = await shim.translate('', 'en-GB'); - assert.strictEqual(translated, ''); - }); - - it('should not allow path traversal', async () => { - const t = await shim.translate('[[../../../../config:secret]]'); - assert.strictEqual(t, 'secret'); - }); - }); + describe('.translate()', () => { + it('should translate correctly', done => { + shim.translate('[[global:pagination.out_of, (foobar), [[global:home]]]]', translated => { + assert.strictEqual(translated, '(foobar) out of Home'); + done(); + }); + }); + + it('should accept a language parameter and adjust accordingly', done => { + shim.translate('[[global:home]]', 'de', translated => { + assert.strictEqual(translated, 'Übersicht'); + done(); + }); + }); + + it('should translate empty string properly', done => { + shim.translate('', 'en-GB', translated => { + assert.strictEqual(translated, ''); + done(); + }); + }); + + it('should translate empty string properly', async () => { + const translated = await shim.translate('', 'en-GB'); + assert.strictEqual(translated, ''); + }); + + it('should not allow path traversal', async () => { + const t = await shim.translate('[[../../../../config:secret]]'); + assert.strictEqual(t, 'secret'); + }); + }); }); describe('new Translator(language)', () => { - it('should throw if not passed a language', (done) => { - assert.throws(() => { - new Translator(); - }, /language string/); - done(); - }); - - describe('.translate()', () => { - it('should handle basic translations', () => { - const translator = Translator.create('en-GB'); - - return translator.translate('[[global:home]]').then((translated) => { - assert.strictEqual(translated, 'Home'); - }); - }); - - it('should handle language keys in regular text', () => { - const translator = Translator.create('en-GB'); - - return translator.translate('Let\'s go [[global:home]]').then((translated) => { - assert.strictEqual(translated, 'Let\'s go Home'); - }); - }); - - it('should handle language keys in regular text with another language specified', () => { - const translator = Translator.create('de'); - - return translator.translate('[[global:home]] test').then((translated) => { - assert.strictEqual(translated, 'Übersicht test'); - }); - }); - - it('should handle language keys with parameters', () => { - const translator = Translator.create('en-GB'); - - return translator.translate('[[global:pagination.out_of, 1, 5]]').then((translated) => { - assert.strictEqual(translated, '1 out of 5'); - }); - }); - - it('should handle language keys inside language keys', () => { - const translator = Translator.create('en-GB'); - - return translator.translate('[[notifications:outgoing_link_message, [[global:guest]]]]').then((translated) => { - assert.strictEqual(translated, 'You are now leaving Guest'); - }); - }); - - it('should handle language keys inside language keys with multiple parameters', () => { - const translator = Translator.create('en-GB'); - - return translator.translate('[[notifications:user_posted_to, [[global:guest]], My Topic]]').then((translated) => { - assert.strictEqual(translated, 'Guest has posted a reply to: My Topic'); - }); - }); - - it('should handle language keys inside language keys with all parameters as language keys', () => { - const translator = Translator.create('en-GB'); - - return translator.translate('[[notifications:user_posted_to, [[global:guest]], [[global:guest]]]]').then((translated) => { - assert.strictEqual(translated, 'Guest has posted a reply to: Guest'); - }); - }); - - it('should properly handle parameters that contain square brackets', () => { - const translator = Translator.create('en-GB'); - - return translator.translate('[[global:pagination.out_of, [guest], [[global:home]]]]').then((translated) => { - assert.strictEqual(translated, '[guest] out of Home'); - }); - }); - - it('should properly handle parameters that contain parentheses', () => { - const translator = Translator.create('en-GB'); - - return translator.translate('[[global:pagination.out_of, (foobar), [[global:home]]]]').then((translated) => { - assert.strictEqual(translated, '(foobar) out of Home'); - }); - }); - - it('should escape language key parameters with HTML in them', () => { - const translator = Translator.create('en-GB'); - - const key = '[[global:403.login, test]]'; - return translator.translate(key).then((translated) => { - assert.strictEqual(translated, 'Perhaps you should try logging in?'); - }); - }); - - it('should not unescape html in parameters', () => { - const translator = Translator.create('en-GB'); - - const key = '[[pages:tag, some&tag]]'; - return translator.translate(key).then((translated) => { - assert.strictEqual(translated, 'Topics tagged under "some&tag"'); - }); - }); - - it('should translate escaped translation arguments properly', () => { - // https://github.com/NodeBB/NodeBB/issues/9206 - const translator = Translator.create('en-GB'); - - const key = '[[notifications:upvoted_your_post_in, test1, error: Error: [[error:group-name-too-long]] on NodeBB Upgrade]]'; - return translator.translate(key).then((translated) => { - assert.strictEqual(translated, 'test1 has upvoted your post in error: Error: [[error:group-name-too-long]] on NodeBB Upgrade.'); - }); - }); - - it('should properly escape and ignore % and \\, in arguments', () => { - const translator = Translator.create('en-GB'); - - const title = 'Test 1\\, 2\\, 3 %2 salmon'; - const key = `[[topic:composer.replying_to, ${title}]]`; - return translator.translate(key).then((translated) => { - assert.strictEqual(translated, 'Replying to Test 1, 2, 3 %2 salmon'); - }); - }); - - it('should not escape regular %', () => { - const translator = Translator.create('en-GB'); - - const title = '3 % salmon'; - const key = `[[topic:composer.replying_to, ${title}]]`; - return translator.translate(key).then((translated) => { - assert.strictEqual(translated, 'Replying to 3 % salmon'); - }); - }); - - it('should not translate [[derp] some text', () => { - const translator = Translator.create('en-GB'); - return translator.translate('[[derp] some text').then((translated) => { - assert.strictEqual('[[derp] some text', translated); - }); - }); - - it('should not translate [[derp]] some text', () => { - const translator = Translator.create('en-GB'); - return translator.translate('[[derp]] some text').then((translated) => { - assert.strictEqual('[[derp]] some text', translated); - }); - }); - - it('should not translate [[derp:xyz] some text', () => { - const translator = Translator.create('en-GB'); - return translator.translate('[[derp:xyz] some text').then((translated) => { - assert.strictEqual('[[derp:xyz] some text', translated); - }); - }); - - it('should translate keys with slashes properly', () => { - const translator = Translator.create('en-GB'); - return translator.translate('[[pages:users/latest]]').then((translated) => { - assert.strictEqual(translated, 'Latest Users'); - }); - }); - - it('should use key for unknown keys without arguments', () => { - const translator = Translator.create('en-GB'); - return translator.translate('[[unknown:key.without.args]]').then((translated) => { - assert.strictEqual(translated, 'key.without.args'); - }); - }); - - it('should use backup for unknown keys with arguments', () => { - const translator = Translator.create('en-GB'); - return translator.translate('[[unknown:key.with.args, arguments are here, derpity, derp]]').then((translated) => { - assert.strictEqual(translated, 'unknown:key.with.args, arguments are here, derpity, derp'); - }); - }); - - it('should ignore unclosed tokens', () => { - const translator = Translator.create('en-GB'); - return translator.translate('here is some stuff and other things [[abc:xyz, other random stuff should be fine here [[global:home]] and more things [[pages:users/latest]]').then((translated) => { - assert.strictEqual(translated, 'here is some stuff and other things abc:xyz, other random stuff should be fine here Home and more things Latest Users'); - }); - }); - }); + it('should throw if not passed a language', done => { + assert.throws(() => { + new Translator(); + }, /language string/); + done(); + }); + + describe('.translate()', () => { + it('should handle basic translations', () => { + const translator = Translator.create('en-GB'); + + return translator.translate('[[global:home]]').then(translated => { + assert.strictEqual(translated, 'Home'); + }); + }); + + it('should handle language keys in regular text', () => { + const translator = Translator.create('en-GB'); + + return translator.translate('Let\'s go [[global:home]]').then(translated => { + assert.strictEqual(translated, 'Let\'s go Home'); + }); + }); + + it('should handle language keys in regular text with another language specified', () => { + const translator = Translator.create('de'); + + return translator.translate('[[global:home]] test').then(translated => { + assert.strictEqual(translated, 'Übersicht test'); + }); + }); + + it('should handle language keys with parameters', () => { + const translator = Translator.create('en-GB'); + + return translator.translate('[[global:pagination.out_of, 1, 5]]').then(translated => { + assert.strictEqual(translated, '1 out of 5'); + }); + }); + + it('should handle language keys inside language keys', () => { + const translator = Translator.create('en-GB'); + + return translator.translate('[[notifications:outgoing_link_message, [[global:guest]]]]').then(translated => { + assert.strictEqual(translated, 'You are now leaving Guest'); + }); + }); + + it('should handle language keys inside language keys with multiple parameters', () => { + const translator = Translator.create('en-GB'); + + return translator.translate('[[notifications:user_posted_to, [[global:guest]], My Topic]]').then(translated => { + assert.strictEqual(translated, 'Guest has posted a reply to: My Topic'); + }); + }); + + it('should handle language keys inside language keys with all parameters as language keys', () => { + const translator = Translator.create('en-GB'); + + return translator.translate('[[notifications:user_posted_to, [[global:guest]], [[global:guest]]]]').then(translated => { + assert.strictEqual(translated, 'Guest has posted a reply to: Guest'); + }); + }); + + it('should properly handle parameters that contain square brackets', () => { + const translator = Translator.create('en-GB'); + + return translator.translate('[[global:pagination.out_of, [guest], [[global:home]]]]').then(translated => { + assert.strictEqual(translated, '[guest] out of Home'); + }); + }); + + it('should properly handle parameters that contain parentheses', () => { + const translator = Translator.create('en-GB'); + + return translator.translate('[[global:pagination.out_of, (foobar), [[global:home]]]]').then(translated => { + assert.strictEqual(translated, '(foobar) out of Home'); + }); + }); + + it('should escape language key parameters with HTML in them', () => { + const translator = Translator.create('en-GB'); + + const key = '[[global:403.login, test]]'; + return translator.translate(key).then(translated => { + assert.strictEqual(translated, 'Perhaps you should try logging in?'); + }); + }); + + it('should not unescape html in parameters', () => { + const translator = Translator.create('en-GB'); + + const key = '[[pages:tag, some&tag]]'; + return translator.translate(key).then(translated => { + assert.strictEqual(translated, 'Topics tagged under "some&tag"'); + }); + }); + + it('should translate escaped translation arguments properly', () => { + // https://github.com/NodeBB/NodeBB/issues/9206 + const translator = Translator.create('en-GB'); + + const key = '[[notifications:upvoted_your_post_in, test1, error: Error: [[error:group-name-too-long]] on NodeBB Upgrade]]'; + return translator.translate(key).then(translated => { + assert.strictEqual(translated, 'test1 has upvoted your post in error: Error: [[error:group-name-too-long]] on NodeBB Upgrade.'); + }); + }); + + it('should properly escape and ignore % and \\, in arguments', () => { + const translator = Translator.create('en-GB'); + + const title = 'Test 1\\, 2\\, 3 %2 salmon'; + const key = `[[topic:composer.replying_to, ${title}]]`; + return translator.translate(key).then(translated => { + assert.strictEqual(translated, 'Replying to Test 1, 2, 3 %2 salmon'); + }); + }); + + it('should not escape regular %', () => { + const translator = Translator.create('en-GB'); + + const title = '3 % salmon'; + const key = `[[topic:composer.replying_to, ${title}]]`; + return translator.translate(key).then(translated => { + assert.strictEqual(translated, 'Replying to 3 % salmon'); + }); + }); + + it('should not translate [[derp] some text', () => { + const translator = Translator.create('en-GB'); + return translator.translate('[[derp] some text').then(translated => { + assert.strictEqual('[[derp] some text', translated); + }); + }); + + it('should not translate [[derp]] some text', () => { + const translator = Translator.create('en-GB'); + return translator.translate('[[derp]] some text').then(translated => { + assert.strictEqual('[[derp]] some text', translated); + }); + }); + + it('should not translate [[derp:xyz] some text', () => { + const translator = Translator.create('en-GB'); + return translator.translate('[[derp:xyz] some text').then(translated => { + assert.strictEqual('[[derp:xyz] some text', translated); + }); + }); + + it('should translate keys with slashes properly', () => { + const translator = Translator.create('en-GB'); + return translator.translate('[[pages:users/latest]]').then(translated => { + assert.strictEqual(translated, 'Latest Users'); + }); + }); + + it('should use key for unknown keys without arguments', () => { + const translator = Translator.create('en-GB'); + return translator.translate('[[unknown:key.without.args]]').then(translated => { + assert.strictEqual(translated, 'key.without.args'); + }); + }); + + it('should use backup for unknown keys with arguments', () => { + const translator = Translator.create('en-GB'); + return translator.translate('[[unknown:key.with.args, arguments are here, derpity, derp]]').then(translated => { + assert.strictEqual(translated, 'unknown:key.with.args, arguments are here, derpity, derp'); + }); + }); + + it('should ignore unclosed tokens', () => { + const translator = Translator.create('en-GB'); + return translator.translate('here is some stuff and other things [[abc:xyz, other random stuff should be fine here [[global:home]] and more things [[pages:users/latest]]').then(translated => { + assert.strictEqual(translated, 'here is some stuff and other things abc:xyz, other random stuff should be fine here Home and more things Latest Users'); + }); + }); + }); }); describe('Translator.create()', () => { - it('should return an instance of Translator', (done) => { - const translator = Translator.create('en-GB'); - - assert(translator instanceof Translator); - done(); - }); - it('should return the same object for the same language', (done) => { - const one = Translator.create('de'); - const two = Translator.create('de'); - - assert.strictEqual(one, two); - done(); - }); - it('should default to defaultLang', (done) => { - const translator = Translator.create(); - - assert.strictEqual(translator.lang, 'en-GB'); - done(); - }); + it('should return an instance of Translator', done => { + const translator = Translator.create('en-GB'); + + assert(translator instanceof Translator); + done(); + }); + it('should return the same object for the same language', done => { + const one = Translator.create('de'); + const two = Translator.create('de'); + + assert.strictEqual(one, two); + done(); + }); + it('should default to defaultLang', done => { + const translator = Translator.create(); + + assert.strictEqual(translator.lang, 'en-GB'); + done(); + }); }); describe('Translator modules', () => { - it('should work before registered', () => { - const translator = Translator.create(); - - Translator.registerModule('test-custom-integer-format', lang => function (key, args) { - const num = parseInt(args[0], 10) || 0; - if (key === 'binary') { - return num.toString(2); - } - if (key === 'hex') { - return num.toString(16); - } - if (key === 'octal') { - return num.toString(8); - } - return num.toString(); - }); - - return translator.translate('[[test-custom-integer-format:octal, 24]]').then((translation) => { - assert.strictEqual(translation, '30'); - }); - }); - - it('should work after registered', () => { - const translator = Translator.create('de'); - - return translator.translate('[[test-custom-integer-format:octal, 23]]').then((translation) => { - assert.strictEqual(translation, '27'); - }); - }); - - it('registerModule be passed the language', (done) => { - Translator.registerModule('something', (lang) => { - assert.ok(lang); - }); - - const translator = Translator.create('fr_FR'); - done(); - }); + it('should work before registered', () => { + const translator = Translator.create(); + + Translator.registerModule('test-custom-integer-format', lang => function (key, arguments_) { + const number_ = Number.parseInt(arguments_[0], 10) || 0; + if (key === 'binary') { + return number_.toString(2); + } + + if (key === 'hex') { + return number_.toString(16); + } + + if (key === 'octal') { + return number_.toString(8); + } + + return number_.toString(); + }); + + return translator.translate('[[test-custom-integer-format:octal, 24]]').then(translation => { + assert.strictEqual(translation, '30'); + }); + }); + + it('should work after registered', () => { + const translator = Translator.create('de'); + + return translator.translate('[[test-custom-integer-format:octal, 23]]').then(translation => { + assert.strictEqual(translation, '27'); + }); + }); + + it('registerModule be passed the language', done => { + Translator.registerModule('something', lang => { + assert.ok(lang); + }); + + const translator = Translator.create('fr_FR'); + done(); + }); }); describe('Translator static methods', () => { - describe('.removePatterns', () => { - it('should remove translator patterns from text', (done) => { - assert.strictEqual( - Translator.removePatterns('Lorem ipsum dolor [[sit:amet]], consectetur adipiscing elit. [[sed:vitae, [[semper:dolor]]]] lorem'), - 'Lorem ipsum dolor , consectetur adipiscing elit. lorem' - ); - done(); - }); - }); - describe('.escape', () => { - it('should escape translation patterns within text', (done) => { - assert.strictEqual( - Translator.escape('some nice text [[global:home]] here'), - 'some nice text [[global:home]] here' - ); - done(); - }); - }); - - describe('.unescape', () => { - it('should unescape escaped translation patterns within text', (done) => { - assert.strictEqual( - Translator.unescape('some nice text \\[\\[global:home\\]\\] here'), - 'some nice text [[global:home]] here' - ); - assert.strictEqual( - Translator.unescape('some nice text [[global:home]] here'), - 'some nice text [[global:home]] here' - ); - done(); - }); - }); - - describe('.compile', () => { - it('should create a translator pattern from a key and list of arguments', (done) => { - assert.strictEqual( - Translator.compile('amazing:cool', 'awesome', 'great'), - '[[amazing:cool, awesome, great]]' - ); - done(); - }); - - it('should escape `%` and `,` in arguments', (done) => { - assert.strictEqual( - Translator.compile('amazing:cool', '100% awesome!', 'one, two, and three'), - '[[amazing:cool, 100% awesome!, one, two, and three]]' - ); - done(); - }); - }); - - describe('add translation', () => { - it('should add custom translations', async () => { - shim.addTranslation('en-GB', 'my-namespace', { foo: 'a custom translation' }); - const t = await shim.translate('this is best [[my-namespace:foo]]'); - assert.strictEqual(t, 'this is best a custom translation'); - }); - }); - - describe('translate nested keys', () => { - it('should handle nested translations', async () => { - shim.addTranslation('en-GB', 'my-namespace', { - key: { - key1: 'key1 translated', - key2: { - key3: 'key3 translated', - }, - }, - }); - const t1 = await shim.translate('this is best [[my-namespace:key.key1]]'); - const t2 = await shim.translate('this is best [[my-namespace:key.key2.key3]]'); - assert.strictEqual(t1, 'this is best key1 translated'); - assert.strictEqual(t2, 'this is best key3 translated'); - }); - it("should try the defaults if it didn't reach a string in a nested translation", async () => { - shim.addTranslation('en-GB', 'my-namespace', { - default1: { - default1: 'default1 translated', - '': 'incorrect priority', - }, - default2: { - '': 'default2 translated', - }, - }); - const d1 = await shim.translate('this is best [[my-namespace:default1]]'); - const d2 = await shim.translate('this is best [[my-namespace:default2]]'); - assert.strictEqual(d1, 'this is best default1 translated'); - assert.strictEqual(d2, 'this is best default2 translated'); - }); - }); + describe('.removePatterns', () => { + it('should remove translator patterns from text', done => { + assert.strictEqual( + Translator.removePatterns('Lorem ipsum dolor [[sit:amet]], consectetur adipiscing elit. [[sed:vitae, [[semper:dolor]]]] lorem'), + 'Lorem ipsum dolor , consectetur adipiscing elit. lorem', + ); + done(); + }); + }); + describe('.escape', () => { + it('should escape translation patterns within text', done => { + assert.strictEqual( + Translator.escape('some nice text [[global:home]] here'), + 'some nice text [[global:home]] here', + ); + done(); + }); + }); + + describe('.unescape', () => { + it('should unescape escaped translation patterns within text', done => { + assert.strictEqual( + Translator.unescape('some nice text \\[\\[global:home\\]\\] here'), + 'some nice text [[global:home]] here', + ); + assert.strictEqual( + Translator.unescape('some nice text [[global:home]] here'), + 'some nice text [[global:home]] here', + ); + done(); + }); + }); + + describe('.compile', () => { + it('should create a translator pattern from a key and list of arguments', done => { + assert.strictEqual( + Translator.compile('amazing:cool', 'awesome', 'great'), + '[[amazing:cool, awesome, great]]', + ); + done(); + }); + + it('should escape `%` and `,` in arguments', done => { + assert.strictEqual( + Translator.compile('amazing:cool', '100% awesome!', 'one, two, and three'), + '[[amazing:cool, 100% awesome!, one, two, and three]]', + ); + done(); + }); + }); + + describe('add translation', () => { + it('should add custom translations', async () => { + shim.addTranslation('en-GB', 'my-namespace', {foo: 'a custom translation'}); + const t = await shim.translate('this is best [[my-namespace:foo]]'); + assert.strictEqual(t, 'this is best a custom translation'); + }); + }); + + describe('translate nested keys', () => { + it('should handle nested translations', async () => { + shim.addTranslation('en-GB', 'my-namespace', { + key: { + key1: 'key1 translated', + key2: { + key3: 'key3 translated', + }, + }, + }); + const t1 = await shim.translate('this is best [[my-namespace:key.key1]]'); + const t2 = await shim.translate('this is best [[my-namespace:key.key2.key3]]'); + assert.strictEqual(t1, 'this is best key1 translated'); + assert.strictEqual(t2, 'this is best key3 translated'); + }); + it('should try the defaults if it didn\'t reach a string in a nested translation', async () => { + shim.addTranslation('en-GB', 'my-namespace', { + default1: { + default1: 'default1 translated', + '': 'incorrect priority', + }, + default2: { + '': 'default2 translated', + }, + }); + const d1 = await shim.translate('this is best [[my-namespace:default1]]'); + const d2 = await shim.translate('this is best [[my-namespace:default2]]'); + assert.strictEqual(d1, 'this is best default1 translated'); + assert.strictEqual(d2, 'this is best default2 translated'); + }); + }); }); diff --git a/test/upgrade.js b/test/upgrade.js index 7cd0bc5..3fc8d59 100644 --- a/test/upgrade.js +++ b/test/upgrade.js @@ -1,35 +1,35 @@ 'use strict'; -const assert = require('assert'); - -const db = require('./mocks/databasemock'); +const assert = require('node:assert'); const upgrade = require('../src/upgrade'); +const db = require('./mocks/databasemock'); describe('Upgrade', () => { - it('should get all upgrade scripts', async () => { - const files = await upgrade.getAll(); - assert(Array.isArray(files) && files.length > 0); - }); + it('should get all upgrade scripts', async () => { + const files = await upgrade.getAll(); + assert(Array.isArray(files) && files.length > 0); + }); + + it('should throw error', async () => { + let error; + try { + await upgrade.check(); + } catch (error_) { + error = error_; + } - it('should throw error', async () => { - let err; - try { - await upgrade.check(); - } catch (_err) { - err = _err; - } - assert.equal(err.message, 'schema-out-of-date'); - }); + assert.equal(error.message, 'schema-out-of-date'); + }); - it('should run all upgrades', async () => { - // for upgrade scripts to run - await db.set('schemaDate', 1); - await upgrade.run(); - }); + it('should run all upgrades', async () => { + // For upgrade scripts to run + await db.set('schemaDate', 1); + await upgrade.run(); + }); - it('should run particular upgrades', async () => { - const files = await upgrade.getAll(); - await db.set('schemaDate', 1); - await upgrade.runParticular(files.slice(0, 2)); - }); + it('should run particular upgrades', async () => { + const files = await upgrade.getAll(); + await db.set('schemaDate', 1); + await upgrade.runParticular(files.slice(0, 2)); + }); }); diff --git a/test/uploads.js b/test/uploads.js index 75d8961..6c6c01b 100644 --- a/test/uploads.js +++ b/test/uploads.js @@ -1,15 +1,13 @@ 'use strict'; const async = require('async'); -const assert = require('assert'); +const assert = require('node:assert'); const nconf = require('nconf'); -const path = require('path'); -const fs = require('fs').promises; -const request = require('request'); +const path = require('node:path'); +const fs = require('node:fs').promises; +const util = require('node:util'); const requestAsync = require('request-promise-native'); -const util = require('util'); - -const db = require('./mocks/databasemock'); +const request = require('request'); const categories = require('../src/categories'); const topics = require('../src/topics'); const posts = require('../src/posts'); @@ -18,566 +16,571 @@ const groups = require('../src/groups'); const privileges = require('../src/privileges'); const meta = require('../src/meta'); const socketUser = require('../src/socket.io/user'); -const helpers = require('./helpers'); const file = require('../src/file'); const image = require('../src/image'); +const helpers = require('./helpers'); +const db = require('./mocks/databasemock'); const emptyUploadsFolder = async () => { - const files = await fs.readdir(`${nconf.get('upload_path')}/files`); - await Promise.all(files.map(async (filename) => { - await file.delete(`${nconf.get('upload_path')}/files/${filename}`); - })); + const files = await fs.readdir(`${nconf.get('upload_path')}/files`); + await Promise.all(files.map(async filename => { + await file.delete(`${nconf.get('upload_path')}/files/${filename}`); + })); }; describe('Upload Controllers', () => { - let tid; - let cid; - let pid; - let adminUid; - let regularUid; - let maliciousUid; - - before((done) => { - async.series({ - category: function (next) { - categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - }, next); - }, - adminUid: function (next) { - user.create({ username: 'admin', password: 'barbar' }, next); - }, - regularUid: function (next) { - user.create({ username: 'regular', password: 'zugzug' }, next); - }, - maliciousUid: function (next) { - user.create({ username: 'malicioususer', password: 'herpderp' }, next); - }, - }, (err, results) => { - if (err) { - return done(err); - } - adminUid = results.adminUid; - regularUid = results.regularUid; - maliciousUid = results.maliciousUid; - cid = results.category.cid; - - topics.post({ uid: adminUid, title: 'test topic title', content: 'test topic content', cid: results.category.cid }, (err, result) => { - if (err) { - return done(err); - } - tid = result.topicData.tid; - pid = result.postData.pid; - groups.join('administrators', adminUid, done); - }); - }); - }); - - describe('regular user uploads rate limits', () => { - let jar; - let csrf_token; - - before(async () => { - ({ jar, csrf_token } = await helpers.loginUser('malicioususer', 'herpderp')); - await privileges.global.give(['groups:upload:post:file'], 'registered-users'); - }); - - it('should fail if the user exceeds the upload rate limit threshold', (done) => { - const oldValue = meta.config.allowedFileExtensions; - meta.config.allowedFileExtensions = 'png,jpg,bmp,html'; - require('../src/middleware/uploads').clearCache(); - - // why / 2? see: helpers.uploadFile for a weird quirk where we actually - // upload 2 files per upload in our tests. - const times = (meta.config.uploadRateLimitThreshold / 2) + 1; - async.timesSeries(times, (i, next) => { - helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/503.html'), {}, jar, csrf_token, (err, res, body) => { - if (i + 1 >= times) { - assert.strictEqual(res.statusCode, 500); - assert.strictEqual(body.error, '[[error:upload-ratelimit-reached]]'); - } else { - assert.ifError(err); - assert.strictEqual(res.statusCode, 200); - assert(body && body.status && body.response && body.response.images); - assert(Array.isArray(body.response.images)); - assert(body.response.images[0].url); - } - - next(err); - }); - }, (err) => { - meta.config.allowedFileExtensions = oldValue; - assert.ifError(err); - done(); - }); - }); - }); - - describe('regular user uploads', () => { - let jar; - let csrf_token; - - before(async () => { - meta.config.uploadRateLimitThreshold = 1000; - ({ jar, csrf_token } = await helpers.loginUser('regular', 'zugzug')); - await privileges.global.give(['groups:upload:post:file'], 'registered-users'); - }); - - it('should upload an image to a post', (done) => { - helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/test.png'), {}, jar, csrf_token, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body && body.status && body.response && body.response.images); - assert(Array.isArray(body.response.images)); - assert(body.response.images[0].url); - done(); - }); - }); - - it('should upload an image to a post and then delete the upload', (done) => { - helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/test.png'), {}, jar, csrf_token, (err, res, body) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 200); - assert(body && body.status && body.response && body.response.images); - assert(Array.isArray(body.response.images)); - assert(body.response.images[0].url); - const name = body.response.images[0].url.replace(`${nconf.get('relative_path') + nconf.get('upload_url')}/`, ''); - socketUser.deleteUpload({ uid: regularUid }, { uid: regularUid, name: name }, (err) => { - assert.ifError(err); - db.getSortedSetRange(`uid:${regularUid}:uploads`, 0, -1, (err, uploads) => { - assert.ifError(err); - assert.equal(uploads.includes(name), false); - done(); - }); - }); - }); - }); - - it('should not allow deleting if path is not correct', (done) => { - socketUser.deleteUpload({ uid: adminUid }, { uid: regularUid, name: '../../bkconfig.json' }, (err) => { - assert.equal(err.message, '[[error:invalid-path]]'); - done(); - }); - }); - - it('should not allow deleting if path is not correct', (done) => { - socketUser.deleteUpload({ uid: adminUid }, { uid: regularUid, name: '/files/../../bkconfig.json' }, (err) => { - assert.equal(err.message, '[[error:invalid-path]]'); - done(); - }); - }); - - it('should resize and upload an image to a post', (done) => { - const oldValue = meta.config.resizeImageWidth; - meta.config.resizeImageWidth = 10; - meta.config.resizeImageWidthThreshold = 10; - helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/test.png'), {}, jar, csrf_token, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body && body.status && body.response && body.response.images); - assert(Array.isArray(body.response.images)); - assert(body.response.images[0].url); - assert(body.response.images[0].url.match(/\/assets\/uploads\/files\/\d+-test-resized\.png/)); - meta.config.resizeImageWidth = oldValue; - meta.config.resizeImageWidthThreshold = 1520; - done(); - }); - }); - - it('should upload a file to a post', (done) => { - const oldValue = meta.config.allowedFileExtensions; - meta.config.allowedFileExtensions = 'png,jpg,bmp,html'; - helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/503.html'), {}, jar, csrf_token, (err, res, body) => { - meta.config.allowedFileExtensions = oldValue; - assert.ifError(err); - assert.strictEqual(res.statusCode, 200); - assert(body && body.status && body.response && body.response.images); - assert(Array.isArray(body.response.images)); - assert(body.response.images[0].url); - done(); - }); - }); - - it('should fail to upload image to post if image dimensions are too big', (done) => { - helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/toobig.png'), {}, jar, csrf_token, (err, res, body) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 500); - assert(body && body.status && body.status.message); - assert.strictEqual(body.status.message, 'Image dimensions are too big'); - done(); - }); - }); - - it('should fail to upload image to post if image is broken', (done) => { - helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/brokenimage.png'), {}, jar, csrf_token, (err, res, body) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 500); - assert(body && body.status && body.status.message); - assert.strictEqual(body.status.message, 'Input file contains unsupported image format'); - done(); - }); - }); - - it('should fail if file is not an image', (done) => { - image.isFileTypeAllowed(path.join(__dirname, '../test/files/notanimage.png'), (err) => { - assert.strictEqual(err.message, 'Input file contains unsupported image format'); - done(); - }); - }); - - it('should fail if file is not an image', (done) => { - image.isFileTypeAllowed(path.join(__dirname, '../test/files/notanimage.png'), (err) => { - assert.strictEqual(err.message, 'Input file contains unsupported image format'); - done(); - }); - }); - - it('should fail if file is not an image', (done) => { - image.size(path.join(__dirname, '../test/files/notanimage.png'), (err) => { - assert.strictEqual(err.message, 'Input file contains unsupported image format'); - done(); - }); - }); - - it('should fail if file is missing', (done) => { - image.size(path.join(__dirname, '../test/files/doesnotexist.png'), (err) => { - assert(err.message.startsWith('Input file is missing')); - done(); - }); - }); - - it('should not allow non image uploads', (done) => { - socketUser.updateCover({ uid: 1 }, { uid: 1, file: { path: '../../text.txt' } }, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should not allow non image uploads', (done) => { - socketUser.updateCover({ uid: 1 }, { uid: 1, imageData: 'data:text/html;base64,PHN2Zy9vbmxvYWQ9YWxlcnQoMik+' }, (err) => { - assert.equal(err.message, '[[error:invalid-image]]'); - done(); - }); - }); - - it('should not allow svg uploads', (done) => { - socketUser.updateCover({ uid: 1 }, { uid: 1, imageData: 'data:image/svg;base64,PHN2Zy9vbmxvYWQ9YWxlcnQoMik+' }, (err) => { - assert.equal(err.message, '[[error:invalid-image]]'); - done(); - }); - }); - - it('should not allow non image uploads', (done) => { - socketUser.uploadCroppedPicture({ uid: 1 }, { uid: 1, file: { path: '../../text.txt' } }, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should not allow non image uploads', (done) => { - socketUser.uploadCroppedPicture({ uid: 1 }, { uid: 1, imageData: 'data:text/html;base64,PHN2Zy9vbmxvYWQ9YWxlcnQoMik+' }, (err) => { - assert.equal(err.message, '[[error:invalid-image]]'); - done(); - }); - }); - - it('should not allow svg uploads', (done) => { - socketUser.uploadCroppedPicture({ uid: 1 }, { uid: 1, imageData: 'data:image/svg;base64,PHN2Zy9vbmxvYWQ9YWxlcnQoMik+' }, (err) => { - assert.equal(err.message, '[[error:invalid-image]]'); - done(); - }); - }); - - it('should delete users uploads if account is deleted', (done) => { - let uid; - let url; - const file = require('../src/file'); - - async.waterfall([ - function (next) { - user.create({ username: 'uploader', password: 'barbar' }, next); - }, - function (_uid, next) { - uid = _uid; - helpers.loginUser('uploader', 'barbar', next); - }, - function (data, next) { - helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/test.png'), {}, data.jar, data.csrf_token, next); - }, - function (res, body, next) { - assert(body && body.status && body.response && body.response.images); - assert(Array.isArray(body.response.images)); - assert(body.response.images[0].url); - url = body.response.images[0].url; - - user.delete(1, uid, next); - }, - function (userData, next) { - const filePath = path.join(nconf.get('upload_path'), url.replace('/assets/uploads', '')); - file.exists(filePath, next); - }, - function (exists, next) { - assert(!exists); - done(); - }, - ], done); - }); - - after(emptyUploadsFolder); - }); - - describe('admin uploads', () => { - let jar; - let csrf_token; - let regularJar; - let regular_csrf_token; - - before(async () => { - ({ jar, csrf_token } = await helpers.loginUser('admin', 'barbar')); - const regularLogin = await helpers.loginUser('regular', 'zugzug'); - regularJar = regularLogin.jar; - regular_csrf_token = regularLogin.csrf_token; - }); - - it('should upload site logo', (done) => { - helpers.uploadFile(`${nconf.get('url')}/api/admin/uploadlogo`, path.join(__dirname, '../test/files/test.png'), {}, jar, csrf_token, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(Array.isArray(body)); - assert.equal(body[0].url, `${nconf.get('relative_path')}/assets/uploads/system/site-logo.png`); - done(); - }); - }); - - it('should fail to upload invalid file type', (done) => { - helpers.uploadFile(`${nconf.get('url')}/api/admin/category/uploadpicture`, path.join(__dirname, '../test/files/503.html'), { params: JSON.stringify({ cid: cid }) }, jar, csrf_token, (err, res, body) => { - assert.ifError(err); - assert.equal(body.error, '[[error:invalid-image-type, image/png, image/jpeg, image/pjpeg, image/jpg, image/gif, image/svg+xml]]'); - done(); - }); - }); - - it('should fail to upload category image with invalid json params', (done) => { - helpers.uploadFile(`${nconf.get('url')}/api/admin/category/uploadpicture`, path.join(__dirname, '../test/files/test.png'), { params: 'invalid json' }, jar, csrf_token, (err, res, body) => { - assert.ifError(err); - assert.equal(body.error, '[[error:invalid-json]]'); - done(); - }); - }); - - it('should upload category image', (done) => { - helpers.uploadFile(`${nconf.get('url')}/api/admin/category/uploadpicture`, path.join(__dirname, '../test/files/test.png'), { params: JSON.stringify({ cid: cid }) }, jar, csrf_token, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(Array.isArray(body)); - assert.equal(body[0].url, `${nconf.get('relative_path')}/assets/uploads/category/category-1.png`); - done(); - }); - }); - - it('should upload default avatar', (done) => { - helpers.uploadFile(`${nconf.get('url')}/api/admin/uploadDefaultAvatar`, path.join(__dirname, '../test/files/test.png'), { }, jar, csrf_token, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(body[0].url, `${nconf.get('relative_path')}/assets/uploads/system/avatar-default.png`); - done(); - }); - }); - - it('should upload og image', (done) => { - helpers.uploadFile(`${nconf.get('url')}/api/admin/uploadOgImage`, path.join(__dirname, '../test/files/test.png'), { }, jar, csrf_token, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert.equal(body[0].url, `${nconf.get('relative_path')}/assets/uploads/system/og-image.png`); - done(); - }); - }); - - it('should upload favicon', (done) => { - helpers.uploadFile(`${nconf.get('url')}/api/admin/uploadfavicon`, path.join(__dirname, '../test/files/favicon.ico'), {}, jar, csrf_token, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(Array.isArray(body)); - assert.equal(body[0].url, '/assets/uploads/system/favicon.ico'); - done(); - }); - }); - - it('should upload touch icon', (done) => { - const touchiconAssetPath = '/assets/uploads/system/touchicon-orig.png'; - helpers.uploadFile(`${nconf.get('url')}/api/admin/uploadTouchIcon`, path.join(__dirname, '../test/files/test.png'), {}, jar, csrf_token, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(Array.isArray(body)); - assert.equal(body[0].url, touchiconAssetPath); - meta.config['brand:touchIcon'] = touchiconAssetPath; - request(`${nconf.get('url')}/apple-touch-icon`, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - }); - - it('should upload regular file', (done) => { - helpers.uploadFile(`${nconf.get('url')}/api/admin/upload/file`, path.join(__dirname, '../test/files/test.png'), { - params: JSON.stringify({ - folder: 'system', - }), - }, jar, csrf_token, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(Array.isArray(body)); - assert.equal(body[0].url, '/assets/uploads/system/test.png'); - assert(file.existsSync(path.join(nconf.get('upload_path'), 'system', 'test.png'))); - done(); - }); - }); - - it('should fail to upload regular file in wrong directory', (done) => { - helpers.uploadFile(`${nconf.get('url')}/api/admin/upload/file`, path.join(__dirname, '../test/files/test.png'), { - params: JSON.stringify({ - folder: '../../system', - }), - }, jar, csrf_token, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 500); - assert.strictEqual(body.error, '[[error:invalid-path]]'); - done(); - }); - }); - - describe('ACP uploads screen', () => { - it('should create a folder', async () => { - const res = await helpers.createFolder('', 'myfolder', jar, csrf_token); - assert.strictEqual(res.statusCode, 200); - assert(file.existsSync(path.join(nconf.get('upload_path'), 'myfolder'))); - }); - - it('should fail to create a folder if it already exists', async () => { - const res = await helpers.createFolder('', 'myfolder', jar, csrf_token); - assert.strictEqual(res.statusCode, 403); - assert.deepStrictEqual(res.body.status, { - code: 'forbidden', - message: 'Folder exists', - }); - }); - - it('should fail to create a folder as a non-admin', async () => { - const res = await helpers.createFolder('', 'hisfolder', regularJar, regular_csrf_token); - assert.strictEqual(res.statusCode, 403); - assert.deepStrictEqual(res.body.status, { - code: 'forbidden', - message: 'You are not authorised to make this call', - }); - }); - - it('should fail to create a folder in wrong directory', async () => { - const res = await helpers.createFolder('../traversing', 'unexpectedfolder', jar, csrf_token); - assert.strictEqual(res.statusCode, 403); - assert.deepStrictEqual(res.body.status, { - code: 'forbidden', - message: 'Invalid path', - }); - }); - - it('should use basename of given folderName to create new folder', async () => { - const res = await helpers.createFolder('/myfolder', '../another folder', jar, csrf_token); - assert.strictEqual(res.statusCode, 200); - const slugifiedName = 'another-folder'; - assert(file.existsSync(path.join(nconf.get('upload_path'), 'myfolder', slugifiedName))); - }); - - it('should fail to delete a file as a non-admin', async () => { - const res = await requestAsync.delete(`${nconf.get('url')}/api/v3/files`, { - body: { - path: '/system/test.png', - }, - jar: regularJar, - json: true, - headers: { - 'x-csrf-token': regular_csrf_token, - }, - simple: false, - resolveWithFullResponse: true, - }); - assert.strictEqual(res.statusCode, 403); - assert.deepStrictEqual(res.body.status, { - code: 'forbidden', - message: 'You are not authorised to make this call', - }); - }); - }); - - after(emptyUploadsFolder); - }); - - describe('library methods', () => { - describe('.getOrphans()', () => { - before(async () => { - const { jar, csrf_token } = await helpers.loginUser('regular', 'zugzug'); - await helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/test.png'), {}, jar, csrf_token); - }); - - it('should return files with no post associated with them', async () => { - const orphans = await posts.uploads.getOrphans(); - - assert.strictEqual(orphans.length, 2); - orphans.forEach((relPath) => { - assert(relPath.startsWith('files/')); - assert(relPath.endsWith('test.png')); - }); - }); - - after(emptyUploadsFolder); - }); - - describe('.cleanOrphans()', () => { - let _orphanExpiryDays; - - before(async () => { - const { jar, csrf_token } = await helpers.loginUser('regular', 'zugzug'); - await helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/test.png'), {}, jar, csrf_token); - - // modify all files in uploads folder to be 30 days old - const files = await fs.readdir(`${nconf.get('upload_path')}/files`); - const p30d = (Date.now() - (1000 * 60 * 60 * 24 * 30)) / 1000; - await Promise.all(files.map(async (filename) => { - await fs.utimes(`${nconf.get('upload_path')}/files/${filename}`, p30d, p30d); - })); - - _orphanExpiryDays = meta.config.orphanExpiryDays; - }); - - it('should not touch orphans if not configured to do so', async () => { - await posts.uploads.cleanOrphans(); - const orphans = await posts.uploads.getOrphans(); - - assert.strictEqual(orphans.length, 2); - }); - - it('should not touch orphans if they are newer than the configured expiry', async () => { - meta.config.orphanExpiryDays = 60; - await posts.uploads.cleanOrphans(); - const orphans = await posts.uploads.getOrphans(); - - assert.strictEqual(orphans.length, 2); - }); - - it('should delete orphans older than the configured number of days', async () => { - meta.config.orphanExpiryDays = 7; - await posts.uploads.cleanOrphans(); - const orphans = await posts.uploads.getOrphans(); - - assert.strictEqual(orphans.length, 0); - }); - - after(async () => { - await emptyUploadsFolder(); - meta.config.orphanExpiryDays = _orphanExpiryDays; - }); - }); - }); + let tid; + let cid; + let pid; + let adminUid; + let regularUid; + let maliciousUid; + + before(done => { + async.series({ + category(next) { + categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + }, next); + }, + adminUid(next) { + user.create({username: 'admin', password: 'barbar'}, next); + }, + regularUid(next) { + user.create({username: 'regular', password: 'zugzug'}, next); + }, + maliciousUid(next) { + user.create({username: 'malicioususer', password: 'herpderp'}, next); + }, + }, (error, results) => { + if (error) { + return done(error); + } + + adminUid = results.adminUid; + regularUid = results.regularUid; + maliciousUid = results.maliciousUid; + cid = results.category.cid; + + topics.post({ + uid: adminUid, title: 'test topic title', content: 'test topic content', cid: results.category.cid, + }, (error, result) => { + if (error) { + return done(error); + } + + tid = result.topicData.tid; + pid = result.postData.pid; + groups.join('administrators', adminUid, done); + }); + }); + }); + + describe('regular user uploads rate limits', () => { + let jar; + let csrf_token; + + before(async () => { + ({jar, csrf_token} = await helpers.loginUser('malicioususer', 'herpderp')); + await privileges.global.give(['groups:upload:post:file'], 'registered-users'); + }); + + it('should fail if the user exceeds the upload rate limit threshold', done => { + const oldValue = meta.config.allowedFileExtensions; + meta.config.allowedFileExtensions = 'png,jpg,bmp,html'; + require('../src/middleware/uploads').clearCache(); + + // Why / 2? see: helpers.uploadFile for a weird quirk where we actually + // upload 2 files per upload in our tests. + const times = (meta.config.uploadRateLimitThreshold / 2) + 1; + async.timesSeries(times, (i, next) => { + helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/503.html'), {}, jar, csrf_token, (error, res, body) => { + if (i + 1 >= times) { + assert.strictEqual(res.statusCode, 500); + assert.strictEqual(body.error, '[[error:upload-ratelimit-reached]]'); + } else { + assert.ifError(error); + assert.strictEqual(res.statusCode, 200); + assert(body && body.status && body.response && body.response.images); + assert(Array.isArray(body.response.images)); + assert(body.response.images[0].url); + } + + next(error); + }); + }, error => { + meta.config.allowedFileExtensions = oldValue; + assert.ifError(error); + done(); + }); + }); + }); + + describe('regular user uploads', () => { + let jar; + let csrf_token; + + before(async () => { + meta.config.uploadRateLimitThreshold = 1000; + ({jar, csrf_token} = await helpers.loginUser('regular', 'zugzug')); + await privileges.global.give(['groups:upload:post:file'], 'registered-users'); + }); + + it('should upload an image to a post', done => { + helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/test.png'), {}, jar, csrf_token, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body && body.status && body.response && body.response.images); + assert(Array.isArray(body.response.images)); + assert(body.response.images[0].url); + done(); + }); + }); + + it('should upload an image to a post and then delete the upload', done => { + helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/test.png'), {}, jar, csrf_token, (error, res, body) => { + assert.ifError(error); + assert.strictEqual(res.statusCode, 200); + assert(body && body.status && body.response && body.response.images); + assert(Array.isArray(body.response.images)); + assert(body.response.images[0].url); + const name = body.response.images[0].url.replace(`${nconf.get('relative_path') + nconf.get('upload_url')}/`, ''); + socketUser.deleteUpload({uid: regularUid}, {uid: regularUid, name}, error_ => { + assert.ifError(error_); + db.getSortedSetRange(`uid:${regularUid}:uploads`, 0, -1, (error, uploads) => { + assert.ifError(error); + assert.equal(uploads.includes(name), false); + done(); + }); + }); + }); + }); + + it('should not allow deleting if path is not correct', done => { + socketUser.deleteUpload({uid: adminUid}, {uid: regularUid, name: '../../bkconfig.json'}, error => { + assert.equal(error.message, '[[error:invalid-path]]'); + done(); + }); + }); + + it('should not allow deleting if path is not correct', done => { + socketUser.deleteUpload({uid: adminUid}, {uid: regularUid, name: '/files/../../bkconfig.json'}, error => { + assert.equal(error.message, '[[error:invalid-path]]'); + done(); + }); + }); + + it('should resize and upload an image to a post', done => { + const oldValue = meta.config.resizeImageWidth; + meta.config.resizeImageWidth = 10; + meta.config.resizeImageWidthThreshold = 10; + helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/test.png'), {}, jar, csrf_token, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body && body.status && body.response && body.response.images); + assert(Array.isArray(body.response.images)); + assert(body.response.images[0].url); + assert(body.response.images[0].url.match(/\/assets\/uploads\/files\/\d+-test-resized\.png/)); + meta.config.resizeImageWidth = oldValue; + meta.config.resizeImageWidthThreshold = 1520; + done(); + }); + }); + + it('should upload a file to a post', done => { + const oldValue = meta.config.allowedFileExtensions; + meta.config.allowedFileExtensions = 'png,jpg,bmp,html'; + helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/503.html'), {}, jar, csrf_token, (error, res, body) => { + meta.config.allowedFileExtensions = oldValue; + assert.ifError(error); + assert.strictEqual(res.statusCode, 200); + assert(body && body.status && body.response && body.response.images); + assert(Array.isArray(body.response.images)); + assert(body.response.images[0].url); + done(); + }); + }); + + it('should fail to upload image to post if image dimensions are too big', done => { + helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/toobig.png'), {}, jar, csrf_token, (error, res, body) => { + assert.ifError(error); + assert.strictEqual(res.statusCode, 500); + assert(body && body.status && body.status.message); + assert.strictEqual(body.status.message, 'Image dimensions are too big'); + done(); + }); + }); + + it('should fail to upload image to post if image is broken', done => { + helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/brokenimage.png'), {}, jar, csrf_token, (error, res, body) => { + assert.ifError(error); + assert.strictEqual(res.statusCode, 500); + assert(body && body.status && body.status.message); + assert.strictEqual(body.status.message, 'Input file contains unsupported image format'); + done(); + }); + }); + + it('should fail if file is not an image', done => { + image.isFileTypeAllowed(path.join(__dirname, '../test/files/notanimage.png'), error => { + assert.strictEqual(error.message, 'Input file contains unsupported image format'); + done(); + }); + }); + + it('should fail if file is not an image', done => { + image.isFileTypeAllowed(path.join(__dirname, '../test/files/notanimage.png'), error => { + assert.strictEqual(error.message, 'Input file contains unsupported image format'); + done(); + }); + }); + + it('should fail if file is not an image', done => { + image.size(path.join(__dirname, '../test/files/notanimage.png'), error => { + assert.strictEqual(error.message, 'Input file contains unsupported image format'); + done(); + }); + }); + + it('should fail if file is missing', done => { + image.size(path.join(__dirname, '../test/files/doesnotexist.png'), error => { + assert(error.message.startsWith('Input file is missing')); + done(); + }); + }); + + it('should not allow non image uploads', done => { + socketUser.updateCover({uid: 1}, {uid: 1, file: {path: '../../text.txt'}}, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should not allow non image uploads', done => { + socketUser.updateCover({uid: 1}, {uid: 1, imageData: 'data:text/html;base64,PHN2Zy9vbmxvYWQ9YWxlcnQoMik+'}, error => { + assert.equal(error.message, '[[error:invalid-image]]'); + done(); + }); + }); + + it('should not allow svg uploads', done => { + socketUser.updateCover({uid: 1}, {uid: 1, imageData: 'data:image/svg;base64,PHN2Zy9vbmxvYWQ9YWxlcnQoMik+'}, error => { + assert.equal(error.message, '[[error:invalid-image]]'); + done(); + }); + }); + + it('should not allow non image uploads', done => { + socketUser.uploadCroppedPicture({uid: 1}, {uid: 1, file: {path: '../../text.txt'}}, error => { + assert.equal(error.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should not allow non image uploads', done => { + socketUser.uploadCroppedPicture({uid: 1}, {uid: 1, imageData: 'data:text/html;base64,PHN2Zy9vbmxvYWQ9YWxlcnQoMik+'}, error => { + assert.equal(error.message, '[[error:invalid-image]]'); + done(); + }); + }); + + it('should not allow svg uploads', done => { + socketUser.uploadCroppedPicture({uid: 1}, {uid: 1, imageData: 'data:image/svg;base64,PHN2Zy9vbmxvYWQ9YWxlcnQoMik+'}, error => { + assert.equal(error.message, '[[error:invalid-image]]'); + done(); + }); + }); + + it('should delete users uploads if account is deleted', done => { + let uid; + let url; + const file = require('../src/file'); + + async.waterfall([ + function (next) { + user.create({username: 'uploader', password: 'barbar'}, next); + }, + function (_uid, next) { + uid = _uid; + helpers.loginUser('uploader', 'barbar', next); + }, + function (data, next) { + helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/test.png'), {}, data.jar, data.csrf_token, next); + }, + function (res, body, next) { + assert(body && body.status && body.response && body.response.images); + assert(Array.isArray(body.response.images)); + assert(body.response.images[0].url); + url = body.response.images[0].url; + + user.delete(1, uid, next); + }, + function (userData, next) { + const filePath = path.join(nconf.get('upload_path'), url.replace('/assets/uploads', '')); + file.exists(filePath, next); + }, + function (exists, next) { + assert(!exists); + done(); + }, + ], done); + }); + + after(emptyUploadsFolder); + }); + + describe('admin uploads', () => { + let jar; + let csrf_token; + let regularJar; + let regular_csrf_token; + + before(async () => { + ({jar, csrf_token} = await helpers.loginUser('admin', 'barbar')); + const regularLogin = await helpers.loginUser('regular', 'zugzug'); + regularJar = regularLogin.jar; + regular_csrf_token = regularLogin.csrf_token; + }); + + it('should upload site logo', done => { + helpers.uploadFile(`${nconf.get('url')}/api/admin/uploadlogo`, path.join(__dirname, '../test/files/test.png'), {}, jar, csrf_token, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(Array.isArray(body)); + assert.equal(body[0].url, `${nconf.get('relative_path')}/assets/uploads/system/site-logo.png`); + done(); + }); + }); + + it('should fail to upload invalid file type', done => { + helpers.uploadFile(`${nconf.get('url')}/api/admin/category/uploadpicture`, path.join(__dirname, '../test/files/503.html'), {params: JSON.stringify({cid})}, jar, csrf_token, (error, res, body) => { + assert.ifError(error); + assert.equal(body.error, '[[error:invalid-image-type, image/png, image/jpeg, image/pjpeg, image/jpg, image/gif, image/svg+xml]]'); + done(); + }); + }); + + it('should fail to upload category image with invalid json params', done => { + helpers.uploadFile(`${nconf.get('url')}/api/admin/category/uploadpicture`, path.join(__dirname, '../test/files/test.png'), {params: 'invalid json'}, jar, csrf_token, (error, res, body) => { + assert.ifError(error); + assert.equal(body.error, '[[error:invalid-json]]'); + done(); + }); + }); + + it('should upload category image', done => { + helpers.uploadFile(`${nconf.get('url')}/api/admin/category/uploadpicture`, path.join(__dirname, '../test/files/test.png'), {params: JSON.stringify({cid})}, jar, csrf_token, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(Array.isArray(body)); + assert.equal(body[0].url, `${nconf.get('relative_path')}/assets/uploads/category/category-1.png`); + done(); + }); + }); + + it('should upload default avatar', done => { + helpers.uploadFile(`${nconf.get('url')}/api/admin/uploadDefaultAvatar`, path.join(__dirname, '../test/files/test.png'), {}, jar, csrf_token, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(body[0].url, `${nconf.get('relative_path')}/assets/uploads/system/avatar-default.png`); + done(); + }); + }); + + it('should upload og image', done => { + helpers.uploadFile(`${nconf.get('url')}/api/admin/uploadOgImage`, path.join(__dirname, '../test/files/test.png'), {}, jar, csrf_token, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert.equal(body[0].url, `${nconf.get('relative_path')}/assets/uploads/system/og-image.png`); + done(); + }); + }); + + it('should upload favicon', done => { + helpers.uploadFile(`${nconf.get('url')}/api/admin/uploadfavicon`, path.join(__dirname, '../test/files/favicon.ico'), {}, jar, csrf_token, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(Array.isArray(body)); + assert.equal(body[0].url, '/assets/uploads/system/favicon.ico'); + done(); + }); + }); + + it('should upload touch icon', done => { + const touchiconAssetPath = '/assets/uploads/system/touchicon-orig.png'; + helpers.uploadFile(`${nconf.get('url')}/api/admin/uploadTouchIcon`, path.join(__dirname, '../test/files/test.png'), {}, jar, csrf_token, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(Array.isArray(body)); + assert.equal(body[0].url, touchiconAssetPath); + meta.config['brand:touchIcon'] = touchiconAssetPath; + request(`${nconf.get('url')}/apple-touch-icon`, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + }); + + it('should upload regular file', done => { + helpers.uploadFile(`${nconf.get('url')}/api/admin/upload/file`, path.join(__dirname, '../test/files/test.png'), { + params: JSON.stringify({ + folder: 'system', + }), + }, jar, csrf_token, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 200); + assert(Array.isArray(body)); + assert.equal(body[0].url, '/assets/uploads/system/test.png'); + assert(file.existsSync(path.join(nconf.get('upload_path'), 'system', 'test.png'))); + done(); + }); + }); + + it('should fail to upload regular file in wrong directory', done => { + helpers.uploadFile(`${nconf.get('url')}/api/admin/upload/file`, path.join(__dirname, '../test/files/test.png'), { + params: JSON.stringify({ + folder: '../../system', + }), + }, jar, csrf_token, (error, res, body) => { + assert.ifError(error); + assert.equal(res.statusCode, 500); + assert.strictEqual(body.error, '[[error:invalid-path]]'); + done(); + }); + }); + + describe('ACP uploads screen', () => { + it('should create a folder', async () => { + const res = await helpers.createFolder('', 'myfolder', jar, csrf_token); + assert.strictEqual(res.statusCode, 200); + assert(file.existsSync(path.join(nconf.get('upload_path'), 'myfolder'))); + }); + + it('should fail to create a folder if it already exists', async () => { + const res = await helpers.createFolder('', 'myfolder', jar, csrf_token); + assert.strictEqual(res.statusCode, 403); + assert.deepStrictEqual(res.body.status, { + code: 'forbidden', + message: 'Folder exists', + }); + }); + + it('should fail to create a folder as a non-admin', async () => { + const res = await helpers.createFolder('', 'hisfolder', regularJar, regular_csrf_token); + assert.strictEqual(res.statusCode, 403); + assert.deepStrictEqual(res.body.status, { + code: 'forbidden', + message: 'You are not authorised to make this call', + }); + }); + + it('should fail to create a folder in wrong directory', async () => { + const res = await helpers.createFolder('../traversing', 'unexpectedfolder', jar, csrf_token); + assert.strictEqual(res.statusCode, 403); + assert.deepStrictEqual(res.body.status, { + code: 'forbidden', + message: 'Invalid path', + }); + }); + + it('should use basename of given folderName to create new folder', async () => { + const res = await helpers.createFolder('/myfolder', '../another folder', jar, csrf_token); + assert.strictEqual(res.statusCode, 200); + const slugifiedName = 'another-folder'; + assert(file.existsSync(path.join(nconf.get('upload_path'), 'myfolder', slugifiedName))); + }); + + it('should fail to delete a file as a non-admin', async () => { + const res = await requestAsync.delete(`${nconf.get('url')}/api/v3/files`, { + body: { + path: '/system/test.png', + }, + jar: regularJar, + json: true, + headers: { + 'x-csrf-token': regular_csrf_token, + }, + simple: false, + resolveWithFullResponse: true, + }); + assert.strictEqual(res.statusCode, 403); + assert.deepStrictEqual(res.body.status, { + code: 'forbidden', + message: 'You are not authorised to make this call', + }); + }); + }); + + after(emptyUploadsFolder); + }); + + describe('library methods', () => { + describe('.getOrphans()', () => { + before(async () => { + const {jar, csrf_token} = await helpers.loginUser('regular', 'zugzug'); + await helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/test.png'), {}, jar, csrf_token); + }); + + it('should return files with no post associated with them', async () => { + const orphans = await posts.uploads.getOrphans(); + + assert.strictEqual(orphans.length, 2); + for (const relPath of orphans) { + assert(relPath.startsWith('files/')); + assert(relPath.endsWith('test.png')); + } + }); + + after(emptyUploadsFolder); + }); + + describe('.cleanOrphans()', () => { + let _orphanExpiryDays; + + before(async () => { + const {jar, csrf_token} = await helpers.loginUser('regular', 'zugzug'); + await helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/test.png'), {}, jar, csrf_token); + + // Modify all files in uploads folder to be 30 days old + const files = await fs.readdir(`${nconf.get('upload_path')}/files`); + const p30d = (Date.now() - (1000 * 60 * 60 * 24 * 30)) / 1000; + await Promise.all(files.map(async filename => { + await fs.utimes(`${nconf.get('upload_path')}/files/${filename}`, p30d, p30d); + })); + + _orphanExpiryDays = meta.config.orphanExpiryDays; + }); + + it('should not touch orphans if not configured to do so', async () => { + await posts.uploads.cleanOrphans(); + const orphans = await posts.uploads.getOrphans(); + + assert.strictEqual(orphans.length, 2); + }); + + it('should not touch orphans if they are newer than the configured expiry', async () => { + meta.config.orphanExpiryDays = 60; + await posts.uploads.cleanOrphans(); + const orphans = await posts.uploads.getOrphans(); + + assert.strictEqual(orphans.length, 2); + }); + + it('should delete orphans older than the configured number of days', async () => { + meta.config.orphanExpiryDays = 7; + await posts.uploads.cleanOrphans(); + const orphans = await posts.uploads.getOrphans(); + + assert.strictEqual(orphans.length, 0); + }); + + after(async () => { + await emptyUploadsFolder(); + meta.config.orphanExpiryDays = _orphanExpiryDays; + }); + }); + }); }); diff --git a/test/user.js b/test/user.js index fb87cbf..9fb254c 100644 --- a/test/user.js +++ b/test/user.js @@ -1,16 +1,14 @@ 'use strict'; -const assert = require('assert'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); const async = require('async'); -const fs = require('fs'); -const path = require('path'); const nconf = require('nconf'); const validator = require('validator'); const request = require('request'); const requestAsync = require('request-promise-native'); const jwt = require('jsonwebtoken'); - -const db = require('./mocks/databasemock'); const User = require('../src/user'); const Topics = require('../src/topics'); const Categories = require('../src/categories'); @@ -18,3070 +16,3082 @@ const Posts = require('../src/posts'); const Password = require('../src/password'); const groups = require('../src/groups'); const messaging = require('../src/messaging'); -const helpers = require('./helpers'); const meta = require('../src/meta'); const file = require('../src/file'); const socketUser = require('../src/socket.io/user'); const apiUser = require('../src/api/users'); const utils = require('../src/utils'); const privileges = require('../src/privileges'); +const db = require('./mocks/databasemock'); +const helpers = require('./helpers'); describe('User', () => { - let userData; - let testUid; - let testCid; - - const plugins = require('../src/plugins'); - - async function dummyEmailerHook(data) { - // pretend to handle sending emails - } - before((done) => { - // Attach an emailer hook so related requests do not error - plugins.hooks.register('emailer-test', { - hook: 'filter:email.send', - method: dummyEmailerHook, - }); - - Categories.create({ - name: 'Test Category', - description: 'A test', - order: 1, - }, (err, categoryObj) => { - if (err) { - return done(err); - } - - testCid = categoryObj.cid; - done(); - }); - }); - after(() => { - plugins.hooks.unregister('emailer-test', 'filter:email.send'); - }); - - beforeEach(() => { - userData = { - username: 'John Smith', - fullname: 'John Smith McNamara', - password: 'swordfish', - email: 'john@example.com', - callback: undefined, - }; - }); - - const goodImage = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAgCAYAAAABtRhCAAAACXBIWXMAAC4jAAAuIwF4pT92AAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADqYAAAOpgAABdvkl/FRgAACcJJREFUeNqMl9tvnNV6xn/f+s5z8DCeg88Zj+NYdhJH4KShFoJAIkzVphLVJnsDaiV6gUKaC2qQUFVATbnoValAakuQYKMqBKUUJCgI9XBBSmOROMqGoCStHbA9sWM7nrFn/I3n9B17kcwoabfarj9gvet53+d9nmdJAwMDAAgh8DyPtbU1XNfFMAwkScK2bTzPw/M8dF1/SAhxKAiCxxVF2aeqqqTr+q+Af+7o6Ch0d3f/69TU1KwkSRiGwbFjx3jmmWd47rnn+OGHH1BVFYX/5QRBkPQ87xeSJP22YRi/oapqStM0PM/D931kWSYIgnHf98cXFxepVqtomjZt2/Zf2bb990EQ4Pv+PXfeU1CSpGYhfN9/TgjxQTQaJQgCwuEwQRBQKpUwDAPTNPF9n0ajAYDv+8zPzzM+Pr6/Wq2eqdVqfxOJRA6Zpnn57hrivyEC0IQQZ4Mg+MAwDCKRCJIkUa/XEUIQi8XQNI1QKIQkSQghUBQFIQSmaTI7OwtAuVxOTE9Pfzc9Pf27lUqlBUgulUoUi0VKpRKqqg4EQfAfiqLsDIfDAC0E4XCYaDSKEALXdalUKvfM1/d9hBBYlkUul2N4eJi3335bcl33mW+++aaUz+cvSJKE8uKLL6JpGo7j8Omnn/7d+vp6sr+/HyEEjuMgyzKu6yJJEsViEVVV8TyPjY2NVisV5fZkTNMkkUhw8+ZN6vU6Kysr7Nmzh9OnT7/12GOPDS8sLByT7rQR4A9XV1d/+cILLzA9PU0kEmF4eBhFUTh//jyWZaHrOkII0uk0jUaDWq1GJpOhWCyysrLC1tYWnuehqir79+9H13W6urp48803+f7773n++ef/4G7S/H4ikUCSJNbX11trcuvWLcrlMrIs4zgODzzwABMTE/i+T7lcpq2tjUqlwubmJrZts7y8jBCCkZERGo0G2WyWkydPkkql6Onp+eMmwihwc3JyMvrWW2+RTCYBcF0XWZbRdZ3l5WX27NnD008/TSwWQ1VVyuVy63GhUIhEIkEqlcJxHCzLIhaLMTQ0xJkzZ7Btm3379lmS53kIIczZ2dnFsbGxRK1Wo729HQDP8zAMg5WVFXp7e5mcnKSzs5N8Po/rutTrdVzXbQmHrutEo1FM00RVVXp7e0kkEgRBwMWLF9F1vaxUq1UikUjtlVdeuV6pVBJ9fX3Ytn2bwrLMysoKXV1dTE5OkslksCwLTdMwDANVVdnY2CAIApLJJJFIBMdxiMfj7Nq1C1VViUajLQCvvvrqkhKJRJiZmfmdb7/99jeTySSyLLfWodFoEAqFOH78OLt37yaXy2GaJoqisLy8zNTUFFevXiUIAtrb29m5cyePPPJIa+cymQz1eh2A0dFRCoXCsgIwNTW1J5/P093dTbFYRJZlJEmiWq1y4MABxsbGqNVqhEIh6vU6QRBQLpcxDIPh4WE8z2NxcZFTp05x7tw5Xn755ZY6dXZ2tliZzWa/EwD1ev3RsbExxsfHSafTVCoVGo0Gqqqya9cuIpEIQgh832dtbY3FxUUA+vr62LZtG2NjYxw5coTDhw+ztLTEyZMnuXr1KoVC4R4d3bt375R84sQJEY/H/2Jubq7N9326urqwbZt6vY5pmhw5coS+vr4W9YvFIrdu3WJqagohBFeuXOHcuXOtue7evRtN01rtfO+991haWmJkZGQrkUi8JIC9iqL0BkFAIpFACMETTzxBV1cXiUSC7u5uHMfB8zyCIMA0TeLxONlsFlmW8X2fwcFBHMdhfn6eer1Oe3s7Dz30EBMTE1y6dImjR49y6tSppR07dqwrjuM8+OWXXzI0NMTly5e5du0aQ0NDTExMkMvlCIKAIAhaIh2LxQiHw0QiEfL5POl0mlqtRq1Wo6OjA8uykGWZdDrN0tISvb29vPPOOzz++OPk83lELpf7rXfffRfDMOjo6MBxHEqlEocOHWLHjh00Gg0kSULTNIS4bS6qqhKPxxkaGmJ4eJjR0VH279/PwMAA27dvJ5vN4vs+X331FR9//DGzs7OEQiE++eQTlPb29keuX7/OtWvXOH78ONVqlZs3b9LW1kYmk8F13dZeCiGQJAnXdRFCYBgGsiwjhMC2bQqFAkEQoOs6P/74Iw8++CCDg4Pous6xY8f47LPPkIIguDo2Nrbzxo0bfPjhh9i2zczMTHNvcF2XpsZalkWj0cB1Xe4o1O3YoCisra3x008/EY/H6erqAuDAgQNEIhGCIODQoUP/ubCwMCKAjx599FHW19f56KOP6OjooFgsks/niUajKIqCbds4joMQAiFESxxs226xd2Zmhng8Tl9fH67r0mg0sG2bbDZLpVIhl8vd5gHwtysrKy8Dcdd1mZubo6enh1gsRrVabZlrk6VND/R9n3q9TqVSQdd1QqEQi4uLnD9/nlKpxODgIHv37gXAcRyCICiFQiHEzp07i1988cUfKYpCIpHANE22b9/eUhNFUVotDIKghc7zPCzLolKpsLW1RVtbG0EQ4DgOmqbR09NDM1qUSiWAPwdQ7ujjmf7+/kQymfxrSZJQVZWtra2WG+i63iKH53m4rku1WqVcLmNZFu3t7S2x7+/vJ51O89prr7VYfenSpcPAP1UqFeSHH36YeDxOKpW6eP/9988Bv9d09nw+T7VapVKptJjZnE2tVmNtbY1cLke5XGZra4vNzU16enp49tlnGRgYaD7iTxqNxgexWIzDhw+jNEPQHV87NT8/f+PChQtnR0ZGqFarrUVuOsDds2u2b2FhgVQqRSQSYWFhgStXrtDf308ymcwBf3nw4EEOHjx4O5c2lURVVRzHYXp6+t8uX7785IULFz7LZDLous59991HOBy+h31N9xgdHSWTyVCtVhkaGmLfvn1MT08zPz/PzMzM6c8//9xr+uE9QViWZer1OhsbGxiG8fns7OzPc7ncx729vXR3d1OpVNi2bRuhUAhZljEMA9/3sW0bVVVZWlri4sWLjI+P8/rrr/P111/z5JNPXrIs69cn76ZeGoaBpmm0tbX9Q6FQeHhubu7fC4UCkUiE1dVVstks8Xgc0zSRZZlGo9ESAdM02djYoNFo8MYbb2BZ1mYoFOKuZPjr/xZBEHCHred83x/b3Nz8l/X19aRlWWxsbNDZ2cnw8DDhcBjf96lWq/T09HD06FGeeuopXnrpJc6ePUs6nb4hhPi/C959ZFn+TtO0lG3bJ0ql0p85jsPW1haFQoG2tjYkSWpF/Uwmw9raGu+//z7A977vX2+GrP93wSZiTdNOGIbxy3K5/DPHcfYXCoVe27Yzpmm2m6bppVKp/Orqqnv69OmoZVn/mEwm/9TzvP9x138NAMpJ4VFTBr6SAAAAAElFTkSuQmCC'; - - describe('.create(), when created', () => { - it('should be created properly', async () => { - testUid = await User.create({ username: userData.username, password: userData.password }); - assert.ok(testUid); - - await User.setUserField(testUid, 'email', userData.email); - await User.email.confirmByUid(testUid); - }); - - it('should be created properly', async () => { - const email = '

    test

    @gmail.com'; - const uid = await User.create({ username: 'weirdemail', email: email }); - const data = await User.getUserData(uid); - - const validationPending = await User.email.isValidationPending(uid, email); - assert.strictEqual(validationPending, true); - - assert.equal(data.email, '<h1>test</h1>@gmail.com'); - assert.strictEqual(data.profileviews, 0); - assert.strictEqual(data.reputation, 0); - assert.strictEqual(data.postcount, 0); - assert.strictEqual(data.topiccount, 0); - assert.strictEqual(data.lastposttime, 0); - assert.strictEqual(data.banned, false); - }); - - it('should have a valid email, if using an email', (done) => { - User.create({ username: userData.username, password: userData.password, email: 'fakeMail' }, (err) => { - assert(err); - assert.equal(err.message, '[[error:invalid-email]]'); - done(); - }); - }); - - it('should error with invalid password', (done) => { - User.create({ username: 'test', password: '1' }, (err) => { - assert.equal(err.message, '[[reset_password:password_too_short]]'); - done(); - }); - }); - - it('should error with invalid password', (done) => { - User.create({ username: 'test', password: {} }, (err) => { - assert.equal(err.message, '[[error:invalid-password]]'); - done(); - }); - }); - - it('should error with a too long password', (done) => { - let toolong = ''; - for (let i = 0; i < 5000; i++) { - toolong += 'a'; - } - User.create({ username: 'test', password: toolong }, (err) => { - assert.equal(err.message, '[[error:password-too-long]]'); - done(); - }); - }); - - it('should error if username is already taken or rename user', async () => { - let err; - async function tryCreate(data) { - try { - return await User.create(data); - } catch (_err) { - err = _err; - } - } - - const [uid1, uid2] = await Promise.all([ - tryCreate({ username: 'dupe1' }), - tryCreate({ username: 'dupe1' }), - ]); - if (err) { - assert.strictEqual(err.message, '[[error:username-taken]]'); - } else { - const userData = await User.getUsersFields([uid1, uid2], ['username']); - const userNames = userData.map(u => u.username); - // make sure only 1 dupe1 is created - assert.equal(userNames.filter(username => username === 'dupe1').length, 1); - assert.equal(userNames.filter(username => username === 'dupe1 0').length, 1); - } - }); - - it('should error if email is already taken', async () => { - let err; - async function tryCreate(data) { - try { - return await User.create(data); - } catch (_err) { - err = _err; - } - } - - await Promise.all([ - tryCreate({ username: 'notdupe1', email: 'dupe@dupe.com' }), - tryCreate({ username: 'notdupe2', email: 'dupe@dupe.com' }), - ]); - assert.strictEqual(err.message, '[[error:email-taken]]'); - }); - }); - - describe('.uniqueUsername()', () => { - it('should deal with collisions', (done) => { - const users = []; - for (let i = 0; i < 10; i += 1) { - users.push({ - username: 'Jane Doe', - email: `jane.doe${i}@example.com`, - }); - } - - async.series([ - function (next) { - async.eachSeries(users, (user, next) => { - User.create(user, next); - }, next); - }, - function (next) { - User.uniqueUsername({ - username: 'Jane Doe', - userslug: 'jane-doe', - }, (err, username) => { - assert.ifError(err); - - assert.strictEqual(username, 'Jane Doe 9'); - next(); - }); - }, - ], done); - }); - }); - - describe('.isModerator()', () => { - it('should return false', (done) => { - User.isModerator(testUid, testCid, (err, isModerator) => { - assert.equal(err, null); - assert.equal(isModerator, false); - done(); - }); - }); - - it('should return two false results', (done) => { - User.isModerator([testUid, testUid], testCid, (err, isModerator) => { - assert.equal(err, null); - assert.equal(isModerator[0], false); - assert.equal(isModerator[1], false); - done(); - }); - }); - - it('should return two false results', (done) => { - User.isModerator(testUid, [testCid, testCid], (err, isModerator) => { - assert.equal(err, null); - assert.equal(isModerator[0], false); - assert.equal(isModerator[1], false); - done(); - }); - }); - }); - - describe('.getModeratorUids()', () => { - before((done) => { - groups.join('cid:1:privileges:moderate', 1, done); - }); - - it('should retrieve all users with moderator bit in category privilege', (done) => { - User.getModeratorUids((err, uids) => { - assert.ifError(err); - assert.strictEqual(1, uids.length); - assert.strictEqual(1, parseInt(uids[0], 10)); - done(); - }); - }); - - after((done) => { - groups.leave('cid:1:privileges:moderate', 1, done); - }); - }); - - describe('.getModeratorUids()', () => { - before((done) => { - async.series([ - async.apply(groups.create, { name: 'testGroup' }), - async.apply(groups.join, 'cid:1:privileges:groups:moderate', 'testGroup'), - async.apply(groups.join, 'testGroup', 1), - ], done); - }); - - it('should retrieve all users with moderator bit in category privilege', (done) => { - User.getModeratorUids((err, uids) => { - assert.ifError(err); - assert.strictEqual(1, uids.length); - assert.strictEqual(1, parseInt(uids[0], 10)); - done(); - }); - }); - - after((done) => { - async.series([ - async.apply(groups.leave, 'cid:1:privileges:groups:moderate', 'testGroup'), - async.apply(groups.destroy, 'testGroup'), - ], done); - }); - }); - - describe('.isReadyToPost()', () => { - it('should error when a user makes two posts in quick succession', (done) => { - meta.config = meta.config || {}; - meta.config.postDelay = '10'; - - async.series([ - async.apply(Topics.post, { - uid: testUid, - title: 'Topic 1', - content: 'lorem ipsum', - cid: testCid, - }), - async.apply(Topics.post, { - uid: testUid, - title: 'Topic 2', - content: 'lorem ipsum', - cid: testCid, - }), - ], (err) => { - assert(err); - done(); - }); - }); - - it('should allow a post if the last post time is > 10 seconds', (done) => { - User.setUserField(testUid, 'lastposttime', +new Date() - (11 * 1000), () => { - Topics.post({ - uid: testUid, - title: 'Topic 3', - content: 'lorem ipsum', - cid: testCid, - }, (err) => { - assert.ifError(err); - done(); - }); - }); - }); - - it('should error when a new user posts if the last post time is 10 < 30 seconds', (done) => { - meta.config.newbiePostDelay = 30; - meta.config.newbiePostDelayThreshold = 3; - - User.setUserField(testUid, 'lastposttime', +new Date() - (20 * 1000), () => { - Topics.post({ - uid: testUid, - title: 'Topic 4', - content: 'lorem ipsum', - cid: testCid, - }, (err) => { - assert(err); - done(); - }); - }); - }); - - it('should not error if a non-newbie user posts if the last post time is 10 < 30 seconds', (done) => { - User.setUserFields(testUid, { - lastposttime: +new Date() - (20 * 1000), - reputation: 10, - }, () => { - Topics.post({ - uid: testUid, - title: 'Topic 5', - content: 'lorem ipsum', - cid: testCid, - }, (err) => { - assert.ifError(err); - done(); - }); - }); - }); - - it('should only post 1 topic out of 10', async () => { - await User.create({ username: 'flooder', password: '123456' }); - const { jar } = await helpers.loginUser('flooder', '123456'); - const titles = new Array(10).fill('topic title'); - const res = await Promise.allSettled(titles.map(async (title) => { - const { body } = await helpers.request('post', '/api/v3/topics', { - form: { - cid: testCid, - title: title, - content: 'the content', - }, - jar: jar, - json: true, - }); - return body.status; - })); - const failed = res.filter(res => res.value.code === 'bad-request'); - const success = res.filter(res => res.value.code === 'ok'); - assert.strictEqual(failed.length, 9); - assert.strictEqual(success.length, 1); - }); - }); - - describe('.search()', () => { - let adminUid; - let uid; - before(async () => { - adminUid = await User.create({ username: 'noteadmin' }); - await groups.join('administrators', adminUid); - }); - - it('should return an object containing an array of matching users', (done) => { - User.search({ query: 'john' }, (err, searchData) => { - assert.ifError(err); - uid = searchData.users[0].uid; - assert.equal(Array.isArray(searchData.users) && searchData.users.length > 0, true); - assert.equal(searchData.users[0].username, 'John Smith'); - done(); - }); - }); - - it('should search user', async () => { - const searchData = await apiUser.search({ uid: testUid }, { query: 'john' }); - assert.equal(searchData.users[0].username, 'John Smith'); - }); - - it('should error for guest', async () => { - try { - await apiUser.search({ uid: 0 }, { query: 'john' }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:no-privileges]]'); - } - }); - - it('should error with invalid data', async () => { - try { - await apiUser.search({ uid: testUid }, null); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:invalid-data]]'); - } - }); - - it('should error for unprivileged user', async () => { - try { - await apiUser.search({ uid: testUid }, { searchBy: 'ip', query: '123' }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:no-privileges]]'); - } - }); - - it('should error for unprivileged user', async () => { - try { - await apiUser.search({ uid: testUid }, { filters: ['banned'], query: '123' }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:no-privileges]]'); - } - }); - - it('should error for unprivileged user', async () => { - try { - await apiUser.search({ uid: testUid }, { filters: ['flagged'], query: '123' }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:no-privileges]]'); - } - }); - - it('should search users by ip', async () => { - const uid = await User.create({ username: 'ipsearch' }); - await db.sortedSetAdd('ip:1.1.1.1:uid', [1, 1], [testUid, uid]); - const data = await apiUser.search({ uid: adminUid }, { query: '1.1.1.1', searchBy: 'ip' }); - assert(Array.isArray(data.users)); - assert.equal(data.users.length, 2); - }); - - it('should search users by uid', async () => { - const data = await apiUser.search({ uid: testUid }, { query: uid, searchBy: 'uid' }); - assert(Array.isArray(data.users)); - assert.equal(data.users[0].uid, uid); - }); - - it('should search users by fullname', async () => { - const uid = await User.create({ username: 'fullnamesearch1', fullname: 'Mr. Fullname' }); - const data = await apiUser.search({ uid: adminUid }, { query: 'mr', searchBy: 'fullname' }); - assert(Array.isArray(data.users)); - assert.equal(data.users.length, 1); - assert.equal(uid, data.users[0].uid); - }); - - it('should search users by fullname', async () => { - const uid = await User.create({ username: 'fullnamesearch2', fullname: 'Baris:Usakli' }); - const data = await apiUser.search({ uid: adminUid }, { query: 'baris:', searchBy: 'fullname' }); - assert(Array.isArray(data.users)); - assert.equal(data.users.length, 1); - assert.equal(uid, data.users[0].uid); - }); - - it('should return empty array if query is empty', async () => { - const data = await apiUser.search({ uid: testUid }, { query: '' }); - assert.equal(data.users.length, 0); - }); - - it('should filter users', async () => { - const uid = await User.create({ username: 'ipsearch_filter' }); - await User.bans.ban(uid, 0, ''); - await User.setUserFields(uid, { flags: 10 }); - const data = await apiUser.search({ uid: adminUid }, { - query: 'ipsearch', - filters: ['online', 'banned', 'flagged'], - }); - assert.equal(data.users[0].username, 'ipsearch_filter'); - }); - - it('should sort results by username', (done) => { - async.waterfall([ - function (next) { - User.create({ username: 'brian' }, next); - }, - function (uid, next) { - User.create({ username: 'baris' }, next); - }, - function (uid, next) { - User.create({ username: 'bzari' }, next); - }, - function (uid, next) { - User.search({ - uid: testUid, - query: 'b', - sortBy: 'username', - paginate: false, - }, next); - }, - ], (err, data) => { - assert.ifError(err); - assert.equal(data.users[0].username, 'baris'); - assert.equal(data.users[1].username, 'brian'); - assert.equal(data.users[2].username, 'bzari'); - done(); - }); - }); - }); - - describe('.delete()', () => { - let uid; - before((done) => { - User.create({ username: 'usertodelete', password: '123456', email: 'delete@me.com' }, (err, newUid) => { - assert.ifError(err); - uid = newUid; - done(); - }); - }); - - it('should delete a user account', (done) => { - User.delete(1, uid, (err) => { - assert.ifError(err); - User.existsBySlug('usertodelete', (err, exists) => { - assert.ifError(err); - assert.equal(exists, false); - done(); - }); - }); - }); - - it('should not re-add user to users:postcount if post is purged after user account deletion', async () => { - const uid = await User.create({ username: 'olduserwithposts' }); - assert(await db.isSortedSetMember('users:postcount', uid)); - - const result = await Topics.post({ - uid: uid, - title: 'old user topic', - content: 'old user topic post content', - cid: testCid, - }); - assert.equal(await db.sortedSetScore('users:postcount', uid), 1); - await User.deleteAccount(uid); - assert(!await db.isSortedSetMember('users:postcount', uid)); - await Posts.purge(result.postData.pid, 1); - assert(!await db.isSortedSetMember('users:postcount', uid)); - }); - - it('should not re-add user to users:reputation if post is upvoted after user account deletion', async () => { - const uid = await User.create({ username: 'olduserwithpostsupvote' }); - assert(await db.isSortedSetMember('users:reputation', uid)); - - const result = await Topics.post({ - uid: uid, - title: 'old user topic', - content: 'old user topic post content', - cid: testCid, - }); - assert.equal(await db.sortedSetScore('users:reputation', uid), 0); - await User.deleteAccount(uid); - assert(!await db.isSortedSetMember('users:reputation', uid)); - await Posts.upvote(result.postData.pid, 1); - assert(!await db.isSortedSetMember('users:reputation', uid)); - }); - - it('should delete user even if they started a chat', async () => { - const socketModules = require('../src/socket.io/modules'); - const uid1 = await User.create({ username: 'chatuserdelete1' }); - const uid2 = await User.create({ username: 'chatuserdelete2' }); - const roomId = await messaging.newRoom(uid1, [uid2]); - await messaging.addMessage({ - uid: uid1, - content: 'hello', - roomId, - }); - await messaging.leaveRoom([uid2], roomId); - await User.delete(1, uid1); - assert.strictEqual(await User.exists(uid1), false); - }); - }); - - describe('passwordReset', () => { - let uid; - let code; - before(async () => { - uid = await User.create({ username: 'resetuser', password: '123456' }); - await User.setUserField(uid, 'email', 'reset@me.com'); - await User.email.confirmByUid(uid); - }); - - it('.generate() should generate a new reset code', (done) => { - User.reset.generate(uid, (err, _code) => { - assert.ifError(err); - assert(_code); - - code = _code; - done(); - }); - }); - - it('.generate() should invalidate a previous generated reset code', async () => { - const _code = await User.reset.generate(uid); - const valid = await User.reset.validate(code); - assert.strictEqual(valid, false); - - code = _code; - }); - - it('.validate() should ensure that this new code is valid', (done) => { - User.reset.validate(code, (err, valid) => { - assert.ifError(err); - assert.strictEqual(valid, true); - done(); - }); - }); - - it('.validate() should correctly identify an invalid code', (done) => { - User.reset.validate(`${code}abcdef`, (err, valid) => { - assert.ifError(err); - assert.strictEqual(valid, false); - done(); - }); - }); - - it('.send() should create a new reset code and reset password', async () => { - code = await User.reset.send('reset@me.com'); - }); - - it('.commit() should update the user\'s password and confirm their email', (done) => { - User.reset.commit(code, 'newpassword', (err) => { - assert.ifError(err); - - async.parallel({ - userData: function (next) { - User.getUserData(uid, next); - }, - password: function (next) { - db.getObjectField(`user:${uid}`, 'password', next); - }, - }, (err, results) => { - assert.ifError(err); - Password.compare('newpassword', results.password, true, (err, match) => { - assert.ifError(err); - assert(match); - assert.strictEqual(results.userData['email:confirmed'], 1); - done(); - }); - }); - }); - }); - - it('.should error if same password is used for reset', async () => { - const uid = await User.create({ username: 'badmemory', email: 'bad@memory.com', password: '123456' }); - const code = await User.reset.generate(uid); - let err; - try { - await User.reset.commit(code, '123456'); - } catch (_err) { - err = _err; - } - assert.strictEqual(err.message, '[[error:reset-same-password]]'); - }); - - it('should not validate email if password reset is due to expiry', async () => { - const uid = await User.create({ username: 'resetexpiry', email: 'reset@expiry.com', password: '123456' }); - let confirmed = await User.getUserField(uid, 'email:confirmed'); - let [verified, unverified] = await groups.isMemberOfGroups(uid, ['verified-users', 'unverified-users']); - assert.strictEqual(confirmed, 0); - assert.strictEqual(verified, false); - assert.strictEqual(unverified, true); - await User.setUserField(uid, 'passwordExpiry', Date.now()); - const code = await User.reset.generate(uid); - await User.reset.commit(code, '654321'); - confirmed = await User.getUserField(uid, 'email:confirmed'); - [verified, unverified] = await groups.isMemberOfGroups(uid, ['verified-users', 'unverified-users']); - assert.strictEqual(confirmed, 0); - assert.strictEqual(verified, false); - assert.strictEqual(unverified, true); - }); - }); - - describe('hash methods', () => { - it('should return uid from email', (done) => { - User.getUidByEmail('john@example.com', (err, uid) => { - assert.ifError(err); - assert.equal(parseInt(uid, 10), parseInt(testUid, 10)); - done(); - }); - }); - - it('should return uid from username', (done) => { - User.getUidByUsername('John Smith', (err, uid) => { - assert.ifError(err); - assert.equal(parseInt(uid, 10), parseInt(testUid, 10)); - done(); - }); - }); - - it('should return uid from userslug', (done) => { - User.getUidByUserslug('john-smith', (err, uid) => { - assert.ifError(err); - assert.equal(parseInt(uid, 10), parseInt(testUid, 10)); - done(); - }); - }); - - it('should get user data even if one uid is NaN', (done) => { - User.getUsersData([NaN, testUid], (err, data) => { - assert.ifError(err); - assert(data[0]); - assert.equal(data[0].username, '[[global:guest]]'); - assert(data[1]); - assert.equal(data[1].username, userData.username); - done(); - }); - }); - - it('should not return private user data', (done) => { - User.setUserFields(testUid, { - fb_token: '123123123', - another_secret: 'abcde', - postcount: '123', - }, (err) => { - assert.ifError(err); - User.getUserData(testUid, (err, userData) => { - assert.ifError(err); - assert(!userData.hasOwnProperty('fb_token')); - assert(!userData.hasOwnProperty('another_secret')); - assert(!userData.hasOwnProperty('password')); - assert(!userData.hasOwnProperty('rss_token')); - assert.strictEqual(userData.postcount, 123); - assert.strictEqual(userData.uid, testUid); - done(); - }); - }); - }); - - it('should not return password even if explicitly requested', (done) => { - User.getUserFields(testUid, ['password'], (err, payload) => { - assert.ifError(err); - assert(!payload.hasOwnProperty('password')); - done(); - }); - }); - - it('should not modify the fields array passed in', async () => { - const fields = ['username', 'email']; - await User.getUserFields(testUid, fields); - assert.deepStrictEqual(fields, ['username', 'email']); - }); - - it('should return an icon text and valid background if username and picture is explicitly requested', async () => { - const payload = await User.getUserFields(testUid, ['username', 'picture']); - const validBackgrounds = await User.getIconBackgrounds(testUid); - assert.strictEqual(payload['icon:text'], userData.username.slice(0, 1).toUpperCase()); - assert(payload['icon:bgColor']); - assert(validBackgrounds.includes(payload['icon:bgColor'])); - }); - - it('should return a valid background, even if an invalid background colour is set', async () => { - await User.setUserField(testUid, 'icon:bgColor', 'teal'); - const payload = await User.getUserFields(testUid, ['username', 'picture']); - const validBackgrounds = await User.getIconBackgrounds(testUid); - - assert(payload['icon:bgColor']); - assert(validBackgrounds.includes(payload['icon:bgColor'])); - }); - - it('should return private data if field is whitelisted', (done) => { - function filterMethod(data, callback) { - data.whitelist.push('another_secret'); - callback(null, data); - } - - plugins.hooks.register('test-plugin', { hook: 'filter:user.whitelistFields', method: filterMethod }); - User.getUserData(testUid, (err, userData) => { - assert.ifError(err); - assert(!userData.hasOwnProperty('fb_token')); - assert.equal(userData.another_secret, 'abcde'); - plugins.hooks.unregister('test-plugin', 'filter:user.whitelistFields', filterMethod); - done(); - }); - }); - - it('should return 0 as uid if username is falsy', (done) => { - User.getUidByUsername('', (err, uid) => { - assert.ifError(err); - assert.strictEqual(uid, 0); - done(); - }); - }); - - it('should get username by userslug', (done) => { - User.getUsernameByUserslug('john-smith', (err, username) => { - assert.ifError(err); - assert.strictEqual('John Smith', username); - done(); - }); - }); - - it('should get uids by emails', (done) => { - User.getUidsByEmails(['john@example.com'], (err, uids) => { - assert.ifError(err); - assert.equal(uids[0], testUid); - done(); - }); - }); - - it('should not get groupTitle for guests', (done) => { - User.getUserData(0, (err, userData) => { - assert.ifError(err); - assert.strictEqual(userData.groupTitle, ''); - assert.deepStrictEqual(userData.groupTitleArray, []); - done(); - }); - }); - - it('should load guest data', (done) => { - User.getUsersData([1, 0], (err, data) => { - assert.ifError(err); - assert.strictEqual(data[1].username, '[[global:guest]]'); - assert.strictEqual(data[1].userslug, ''); - assert.strictEqual(data[1].uid, 0); - done(); - }); - }); - }); - - describe('profile methods', () => { - let uid; - let jar; - let csrf_token; - - before(async () => { - const newUid = await User.create({ username: 'updateprofile', email: 'update@me.com', password: '123456' }); - uid = newUid; - - await User.setUserField(uid, 'email', 'update@me.com'); - await User.email.confirmByUid(uid); - - ({ jar, csrf_token } = await helpers.loginUser('updateprofile', '123456')); - }); - - it('should return error if not logged in', async () => { - try { - await apiUser.update({ uid: 0 }, { uid: 1 }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:invalid-uid]]'); - } - }); - - it('should return error if data is invalid', async () => { - try { - await apiUser.update({ uid: uid }, null); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:invalid-data]]'); - } - }); - - it('should return error if data is missing uid', async () => { - try { - await apiUser.update({ uid: uid }, { username: 'bip', email: 'bop' }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:invalid-data]]'); - } - }); - - describe('.updateProfile()', () => { - let uid; - - it('should update a user\'s profile', async () => { - uid = await User.create({ username: 'justforupdate', email: 'just@for.updated', password: '123456' }); - await User.setUserField(uid, 'email', 'just@for.updated'); - await User.email.confirmByUid(uid); - - const data = { - uid: uid, - username: 'updatedUserName', - email: 'updatedEmail@me.com', - fullname: 'updatedFullname', - website: 'http://nodebb.org', - location: 'izmir', - groupTitle: 'testGroup', - birthday: '01/01/1980', - signature: 'nodebb is good', - password: '123456', - }; - const result = await apiUser.update({ uid: uid }, { ...data, password: '123456', invalid: 'field' }); - assert.equal(result.username, 'updatedUserName'); - assert.equal(result.userslug, 'updatedusername'); - assert.equal(result.location, 'izmir'); - - const userData = await db.getObject(`user:${uid}`); - Object.keys(data).forEach((key) => { - if (key === 'email') { - assert.strictEqual(userData.email, 'just@for.updated'); // email remains the same until confirmed - } else if (key !== 'password') { - assert.equal(data[key], userData[key]); - } else { - assert(userData[key].startsWith('$2a$')); - } - }); - // updateProfile only saves valid fields - assert.strictEqual(userData.invalid, undefined); - }); - - it('should also generate an email confirmation code for the changed email', async () => { - const confirmSent = await User.email.isValidationPending(uid, 'updatedemail@me.com'); - assert.strictEqual(confirmSent, true); - }); - }); - - it('should change a user\'s password', async () => { - const uid = await User.create({ username: 'changepassword', password: '123456' }); - await apiUser.changePassword({ uid: uid }, { uid: uid, newPassword: '654321', currentPassword: '123456' }); - const correct = await User.isPasswordCorrect(uid, '654321', '127.0.0.1'); - assert(correct); - }); - - it('should not let user change another user\'s password', async () => { - const regularUserUid = await User.create({ username: 'regularuserpwdchange', password: 'regularuser1234' }); - const uid = await User.create({ username: 'changeadminpwd1', password: '123456' }); - try { - await apiUser.changePassword({ uid: uid }, { uid: regularUserUid, newPassword: '654321', currentPassword: '123456' }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[user:change_password_error_privileges]]'); - } - }); - - it('should not let user change admin\'s password', async () => { - const adminUid = await User.create({ username: 'adminpwdchange', password: 'admin1234' }); - await groups.join('administrators', adminUid); - const uid = await User.create({ username: 'changeadminpwd2', password: '123456' }); - try { - await apiUser.changePassword({ uid: uid }, { uid: adminUid, newPassword: '654321', currentPassword: '123456' }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[user:change_password_error_privileges]]'); - } - }); - - it('should let admin change another users password', async () => { - const adminUid = await User.create({ username: 'adminpwdchange2', password: 'admin1234' }); - await groups.join('administrators', adminUid); - const uid = await User.create({ username: 'forgotmypassword', password: '123456' }); - - await apiUser.changePassword({ uid: adminUid }, { uid: uid, newPassword: '654321' }); - const correct = await User.isPasswordCorrect(uid, '654321', '127.0.0.1'); - assert(correct); - }); - - it('should not let admin change their password if current password is incorrect', async () => { - const adminUid = await User.create({ username: 'adminforgotpwd', password: 'admin1234' }); - await groups.join('administrators', adminUid); - - try { - await apiUser.changePassword({ uid: adminUid }, { uid: adminUid, newPassword: '654321', currentPassword: 'wrongpwd' }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[user:change_password_error_wrong_current]]'); - } - }); - - it('should change username', async () => { - await apiUser.update({ uid: uid }, { uid: uid, username: 'updatedAgain', password: '123456' }); - const username = await db.getObjectField(`user:${uid}`, 'username'); - assert.equal(username, 'updatedAgain'); - }); - - it('should not let setting an empty username', async () => { - await apiUser.update({ uid: uid }, { uid: uid, username: '', password: '123456' }); - const username = await db.getObjectField(`user:${uid}`, 'username'); - assert.strictEqual(username, 'updatedAgain'); - }); - - it('should let updating profile if current username is above max length and it is not being changed', async () => { - const maxLength = meta.config.maximumUsernameLength + 1; - const longName = new Array(maxLength).fill('a').join(''); - const uid = await User.create({ username: longName }); - await apiUser.update({ uid: uid }, { uid: uid, username: longName, email: 'verylong@name.com' }); - const userData = await db.getObject(`user:${uid}`); - const awaitingValidation = await User.email.isValidationPending(uid, 'verylong@name.com'); - - assert.strictEqual(userData.username, longName); - assert.strictEqual(awaitingValidation, true); - }); - - it('should not update a user\'s username if it did not change', async () => { - await apiUser.update({ uid: uid }, { uid: uid, username: 'updatedAgain', password: '123456' }); - const data = await db.getSortedSetRevRange(`user:${uid}:usernames`, 0, -1); - assert.equal(data.length, 2); - assert(data[0].startsWith('updatedAgain')); - }); - - it('should not update a user\'s username if a password is not supplied', async () => { - try { - await apiUser.update({ uid: uid }, { uid: uid, username: 'updatedAgain', password: '' }); - assert(false); - } catch (err) { - assert.strictEqual(err.message, '[[error:invalid-password]]'); - } - }); - - it('should send validation email', async () => { - const uid = await User.create({ username: 'pooremailupdate', email: 'poor@update.me', password: '123456' }); - await User.email.expireValidation(uid); - await apiUser.update({ uid: uid }, { uid: uid, email: 'updatedAgain@me.com', password: '123456' }); - - assert.strictEqual(await User.email.isValidationPending(uid, 'updatedAgain@me.com'.toLowerCase()), true); - }); - - it('should update cover image', (done) => { - const position = '50.0301% 19.2464%'; - const coverData = { uid: uid, imageData: goodImage, position: position }; - socketUser.updateCover({ uid: uid }, coverData, (err, result) => { - assert.ifError(err); - assert(result.url); - db.getObjectFields(`user:${uid}`, ['cover:url', 'cover:position'], (err, data) => { - assert.ifError(err); - assert.equal(data['cover:url'], result.url); - assert.equal(data['cover:position'], position); - done(); - }); - }); - }); - - it('should remove cover image', async () => { - const coverPath = await User.getLocalCoverPath(uid); - await socketUser.removeCover({ uid: uid }, { uid: uid }); - const coverUrlNow = await db.getObjectField(`user:${uid}`, 'cover:url'); - assert.strictEqual(coverUrlNow, null); - assert.strictEqual(fs.existsSync(coverPath), false); - }); - - it('should set user status', (done) => { - socketUser.setStatus({ uid: uid }, 'away', (err, data) => { - assert.ifError(err); - assert.equal(data.uid, uid); - assert.equal(data.status, 'away'); - done(); - }); - }); - - it('should fail for invalid status', (done) => { - socketUser.setStatus({ uid: uid }, '12345', (err) => { - assert.equal(err.message, '[[error:invalid-user-status]]'); - done(); - }); - }); - - it('should get user status', (done) => { - socketUser.checkStatus({ uid: uid }, uid, (err, status) => { - assert.ifError(err); - assert.equal(status, 'away'); - done(); - }); - }); - - it('should change user picture', async () => { - await apiUser.changePicture({ uid: uid }, { type: 'default', uid: uid }); - const picture = await User.getUserField(uid, 'picture'); - assert.equal(picture, ''); - }); - - it('should let you set an external image', async () => { - const token = await helpers.getCsrfToken(jar); - const body = await requestAsync(`${nconf.get('url')}/api/v3/users/${uid}/picture`, { - jar, - method: 'put', - json: true, - headers: { - 'x-csrf-token': token, - }, - body: { - type: 'external', - url: 'https://example.org/picture.jpg', - }, - }); - - assert(body && body.status && body.response); - assert.strictEqual(body.status.code, 'ok'); - - const picture = await User.getUserField(uid, 'picture'); - assert.strictEqual(picture, validator.escape('https://example.org/picture.jpg')); - }); - - it('should fail to change user picture with invalid data', async () => { - try { - await apiUser.changePicture({ uid: uid }, null); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:invalid-data]]'); - } - }); - - it('should fail to change user picture with invalid uid', async () => { - try { - await apiUser.changePicture({ uid: 0 }, { uid: 1 }); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:no-privileges]]'); - } - }); - - it('should set user picture to uploaded', async () => { - await User.setUserField(uid, 'uploadedpicture', '/test'); - await apiUser.changePicture({ uid: uid }, { type: 'uploaded', uid: uid }); - const picture = await User.getUserField(uid, 'picture'); - assert.equal(picture, `${nconf.get('relative_path')}/test`); - }); - - it('should return error if profile image uploads disabled', (done) => { - meta.config.allowProfileImageUploads = 0; - const picture = { - path: path.join(nconf.get('base_dir'), 'test/files/test_copy.png'), - size: 7189, - name: 'test.png', - type: 'image/png', - }; - User.uploadCroppedPicture({ - callerUid: uid, - uid: uid, - file: picture, - }, (err) => { - assert.equal(err.message, '[[error:profile-image-uploads-disabled]]'); - meta.config.allowProfileImageUploads = 1; - done(); - }); - }); - - it('should return error if profile image has no mime type', (done) => { - User.uploadCroppedPicture({ - callerUid: uid, - uid: uid, - imageData: 'data:image/invalid;base64,R0lGODlhPQBEAPeoAJosM/', - }, (err) => { - assert.equal(err.message, '[[error:invalid-image]]'); - done(); - }); - }); - - describe('user.uploadCroppedPicture', () => { - const badImage = 'data:audio/mp3;base64,R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw=='; - - it('should upload cropped profile picture', async () => { - const result = await socketUser.uploadCroppedPicture({ uid: uid }, { uid: uid, imageData: goodImage }); - assert(result.url); - const data = await db.getObjectFields(`user:${uid}`, ['uploadedpicture', 'picture']); - assert.strictEqual(result.url, data.uploadedpicture); - assert.strictEqual(result.url, data.picture); - }); - - it('should upload cropped profile picture in chunks', async () => { - const socketUploads = require('../src/socket.io/uploads'); - const socketData = { - uid, - method: 'user.uploadCroppedPicture', - size: goodImage.length, - progress: 0, - }; - const chunkSize = 1000; - let result; - do { - const chunk = goodImage.slice(socketData.progress, socketData.progress + chunkSize); - socketData.progress += chunk.length; - // eslint-disable-next-line + let userData; + let testUid; + let testCid; + + const plugins = require('../src/plugins'); + + async function dummyEmailerHook(data) { + // Pretend to handle sending emails + } + + before(done => { + // Attach an emailer hook so related requests do not error + plugins.hooks.register('emailer-test', { + hook: 'filter:email.send', + method: dummyEmailerHook, + }); + + Categories.create({ + name: 'Test Category', + description: 'A test', + order: 1, + }, (error, categoryObject) => { + if (error) { + return done(error); + } + + testCid = categoryObject.cid; + done(); + }); + }); + after(() => { + plugins.hooks.unregister('emailer-test', 'filter:email.send'); + }); + + beforeEach(() => { + userData = { + username: 'John Smith', + fullname: 'John Smith McNamara', + password: 'swordfish', + email: 'john@example.com', + callback: undefined, + }; + }); + + const goodImage = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAgCAYAAAABtRhCAAAACXBIWXMAAC4jAAAuIwF4pT92AAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADqYAAAOpgAABdvkl/FRgAACcJJREFUeNqMl9tvnNV6xn/f+s5z8DCeg88Zj+NYdhJH4KShFoJAIkzVphLVJnsDaiV6gUKaC2qQUFVATbnoValAakuQYKMqBKUUJCgI9XBBSmOROMqGoCStHbA9sWM7nrFn/I3n9B17kcwoabfarj9gvet53+d9nmdJAwMDAAgh8DyPtbU1XNfFMAwkScK2bTzPw/M8dF1/SAhxKAiCxxVF2aeqqqTr+q+Af+7o6Ch0d3f/69TU1KwkSRiGwbFjx3jmmWd47rnn+OGHH1BVFYX/5QRBkPQ87xeSJP22YRi/oapqStM0PM/D931kWSYIgnHf98cXFxepVqtomjZt2/Zf2bb990EQ4Pv+PXfeU1CSpGYhfN9/TgjxQTQaJQgCwuEwQRBQKpUwDAPTNPF9n0ajAYDv+8zPzzM+Pr6/Wq2eqdVqfxOJRA6Zpnn57hrivyEC0IQQZ4Mg+MAwDCKRCJIkUa/XEUIQi8XQNI1QKIQkSQghUBQFIQSmaTI7OwtAuVxOTE9Pfzc9Pf27lUqlBUgulUoUi0VKpRKqqg4EQfAfiqLsDIfDAC0E4XCYaDSKEALXdalUKvfM1/d9hBBYlkUul2N4eJi3335bcl33mW+++aaUz+cvSJKE8uKLL6JpGo7j8Omnn/7d+vp6sr+/HyEEjuMgyzKu6yJJEsViEVVV8TyPjY2NVisV5fZkTNMkkUhw8+ZN6vU6Kysr7Nmzh9OnT7/12GOPDS8sLByT7rQR4A9XV1d/+cILLzA9PU0kEmF4eBhFUTh//jyWZaHrOkII0uk0jUaDWq1GJpOhWCyysrLC1tYWnuehqir79+9H13W6urp48803+f7773n++ef/4G7S/H4ikUCSJNbX11trcuvWLcrlMrIs4zgODzzwABMTE/i+T7lcpq2tjUqlwubmJrZts7y8jBCCkZERGo0G2WyWkydPkkql6Onp+eMmwihwc3JyMvrWW2+RTCYBcF0XWZbRdZ3l5WX27NnD008/TSwWQ1VVyuVy63GhUIhEIkEqlcJxHCzLIhaLMTQ0xJkzZ7Btm3379lmS53kIIczZ2dnFsbGxRK1Wo729HQDP8zAMg5WVFXp7e5mcnKSzs5N8Po/rutTrdVzXbQmHrutEo1FM00RVVXp7e0kkEgRBwMWLF9F1vaxUq1UikUjtlVdeuV6pVBJ9fX3Ytn2bwrLMysoKXV1dTE5OkslksCwLTdMwDANVVdnY2CAIApLJJJFIBMdxiMfj7Nq1C1VViUajLQCvvvrqkhKJRJiZmfmdb7/99jeTySSyLLfWodFoEAqFOH78OLt37yaXy2GaJoqisLy8zNTUFFevXiUIAtrb29m5cyePPPJIa+cymQz1eh2A0dFRCoXCsgIwNTW1J5/P093dTbFYRJZlJEmiWq1y4MABxsbGqNVqhEIh6vU6QRBQLpcxDIPh4WE8z2NxcZFTp05x7tw5Xn755ZY6dXZ2tliZzWa/EwD1ev3RsbExxsfHSafTVCoVGo0Gqqqya9cuIpEIQgh832dtbY3FxUUA+vr62LZtG2NjYxw5coTDhw+ztLTEyZMnuXr1KoVC4R4d3bt375R84sQJEY/H/2Jubq7N9326urqwbZt6vY5pmhw5coS+vr4W9YvFIrdu3WJqagohBFeuXOHcuXOtue7evRtN01rtfO+991haWmJkZGQrkUi8JIC9iqL0BkFAIpFACMETTzxBV1cXiUSC7u5uHMfB8zyCIMA0TeLxONlsFlmW8X2fwcFBHMdhfn6eer1Oe3s7Dz30EBMTE1y6dImjR49y6tSppR07dqwrjuM8+OWXXzI0NMTly5e5du0aQ0NDTExMkMvlCIKAIAhaIh2LxQiHw0QiEfL5POl0mlqtRq1Wo6OjA8uykGWZdDrN0tISvb29vPPOOzz++OPk83lELpf7rXfffRfDMOjo6MBxHEqlEocOHWLHjh00Gg0kSULTNIS4bS6qqhKPxxkaGmJ4eJjR0VH279/PwMAA27dvJ5vN4vs+X331FR9//DGzs7OEQiE++eQTlPb29keuX7/OtWvXOH78ONVqlZs3b9LW1kYmk8F13dZeCiGQJAnXdRFCYBgGsiwjhMC2bQqFAkEQoOs6P/74Iw8++CCDg4Pous6xY8f47LPPkIIguDo2Nrbzxo0bfPjhh9i2zczMTHNvcF2XpsZalkWj0cB1Xe4o1O3YoCisra3x008/EY/H6erqAuDAgQNEIhGCIODQoUP/ubCwMCKAjx599FHW19f56KOP6OjooFgsks/niUajKIqCbds4joMQAiFESxxs226xd2Zmhng8Tl9fH67r0mg0sG2bbDZLpVIhl8vd5gHwtysrKy8Dcdd1mZubo6enh1gsRrVabZlrk6VND/R9n3q9TqVSQdd1QqEQi4uLnD9/nlKpxODgIHv37gXAcRyCICiFQiHEzp07i1988cUfKYpCIpHANE22b9/eUhNFUVotDIKghc7zPCzLolKpsLW1RVtbG0EQ4DgOmqbR09NDM1qUSiWAPwdQ7ujjmf7+/kQymfxrSZJQVZWtra2WG+i63iKH53m4rku1WqVcLmNZFu3t7S2x7+/vJ51O89prr7VYfenSpcPAP1UqFeSHH36YeDxOKpW6eP/9988Bv9d09nw+T7VapVKptJjZnE2tVmNtbY1cLke5XGZra4vNzU16enp49tlnGRgYaD7iTxqNxgexWIzDhw+jNEPQHV87NT8/f+PChQtnR0ZGqFarrUVuOsDds2u2b2FhgVQqRSQSYWFhgStXrtDf308ymcwBf3nw4EEOHjx4O5c2lURVVRzHYXp6+t8uX7785IULFz7LZDLous59991HOBy+h31N9xgdHSWTyVCtVhkaGmLfvn1MT08zPz/PzMzM6c8//9xr+uE9QViWZer1OhsbGxiG8fns7OzPc7ncx729vXR3d1OpVNi2bRuhUAhZljEMA9/3sW0bVVVZWlri4sWLjI+P8/rrr/P111/z5JNPXrIs69cn76ZeGoaBpmm0tbX9Q6FQeHhubu7fC4UCkUiE1dVVstks8Xgc0zSRZZlGo9ESAdM02djYoNFo8MYbb2BZ1mYoFOKuZPjr/xZBEHCHred83x/b3Nz8l/X19aRlWWxsbNDZ2cnw8DDhcBjf96lWq/T09HD06FGeeuopXnrpJc6ePUs6nb4hhPi/C959ZFn+TtO0lG3bJ0ql0p85jsPW1haFQoG2tjYkSWpF/Uwmw9raGu+//z7A977vX2+GrP93wSZiTdNOGIbxy3K5/DPHcfYXCoVe27Yzpmm2m6bppVKp/Orqqnv69OmoZVn/mEwm/9TzvP9x138NAMpJ4VFTBr6SAAAAAElFTkSuQmCC'; + + describe('.create(), when created', () => { + it('should be created properly', async () => { + testUid = await User.create({username: userData.username, password: userData.password}); + assert.ok(testUid); + + await User.setUserField(testUid, 'email', userData.email); + await User.email.confirmByUid(testUid); + }); + + it('should be created properly', async () => { + const email = '

    test

    @gmail.com'; + const uid = await User.create({username: 'weirdemail', email}); + const data = await User.getUserData(uid); + + const validationPending = await User.email.isValidationPending(uid, email); + assert.strictEqual(validationPending, true); + + assert.equal(data.email, '<h1>test</h1>@gmail.com'); + assert.strictEqual(data.profileviews, 0); + assert.strictEqual(data.reputation, 0); + assert.strictEqual(data.postcount, 0); + assert.strictEqual(data.topiccount, 0); + assert.strictEqual(data.lastposttime, 0); + assert.strictEqual(data.banned, false); + }); + + it('should have a valid email, if using an email', done => { + User.create({username: userData.username, password: userData.password, email: 'fakeMail'}, error => { + assert(error); + assert.equal(error.message, '[[error:invalid-email]]'); + done(); + }); + }); + + it('should error with invalid password', done => { + User.create({username: 'test', password: '1'}, error => { + assert.equal(error.message, '[[reset_password:password_too_short]]'); + done(); + }); + }); + + it('should error with invalid password', done => { + User.create({username: 'test', password: {}}, error => { + assert.equal(error.message, '[[error:invalid-password]]'); + done(); + }); + }); + + it('should error with a too long password', done => { + let toolong = ''; + for (let i = 0; i < 5000; i++) { + toolong += 'a'; + } + + User.create({username: 'test', password: toolong}, error => { + assert.equal(error.message, '[[error:password-too-long]]'); + done(); + }); + }); + + it('should error if username is already taken or rename user', async () => { + let error; + async function tryCreate(data) { + try { + return await User.create(data); + } catch (error_) { + error = error_; + } + } + + const [uid1, uid2] = await Promise.all([ + tryCreate({username: 'dupe1'}), + tryCreate({username: 'dupe1'}), + ]); + if (error) { + assert.strictEqual(error.message, '[[error:username-taken]]'); + } else { + const userData = await User.getUsersFields([uid1, uid2], ['username']); + const userNames = userData.map(u => u.username); + // Make sure only 1 dupe1 is created + assert.equal(userNames.filter(username => username === 'dupe1').length, 1); + assert.equal(userNames.filter(username => username === 'dupe1 0').length, 1); + } + }); + + it('should error if email is already taken', async () => { + let error; + async function tryCreate(data) { + try { + return await User.create(data); + } catch (error_) { + error = error_; + } + } + + await Promise.all([ + tryCreate({username: 'notdupe1', email: 'dupe@dupe.com'}), + tryCreate({username: 'notdupe2', email: 'dupe@dupe.com'}), + ]); + assert.strictEqual(error.message, '[[error:email-taken]]'); + }); + }); + + describe('.uniqueUsername()', () => { + it('should deal with collisions', done => { + const users = []; + for (let i = 0; i < 10; i += 1) { + users.push({ + username: 'Jane Doe', + email: `jane.doe${i}@example.com`, + }); + } + + async.series([ + function (next) { + async.eachSeries(users, (user, next) => { + User.create(user, next); + }, next); + }, + function (next) { + User.uniqueUsername({ + username: 'Jane Doe', + userslug: 'jane-doe', + }, (error, username) => { + assert.ifError(error); + + assert.strictEqual(username, 'Jane Doe 9'); + next(); + }); + }, + ], done); + }); + }); + + describe('.isModerator()', () => { + it('should return false', done => { + User.isModerator(testUid, testCid, (error, isModerator) => { + assert.equal(error, null); + assert.equal(isModerator, false); + done(); + }); + }); + + it('should return two false results', done => { + User.isModerator([testUid, testUid], testCid, (error, isModerator) => { + assert.equal(error, null); + assert.equal(isModerator[0], false); + assert.equal(isModerator[1], false); + done(); + }); + }); + + it('should return two false results', done => { + User.isModerator(testUid, [testCid, testCid], (error, isModerator) => { + assert.equal(error, null); + assert.equal(isModerator[0], false); + assert.equal(isModerator[1], false); + done(); + }); + }); + }); + + describe('.getModeratorUids()', () => { + before(done => { + groups.join('cid:1:privileges:moderate', 1, done); + }); + + it('should retrieve all users with moderator bit in category privilege', done => { + User.getModeratorUids((error, uids) => { + assert.ifError(error); + assert.strictEqual(1, uids.length); + assert.strictEqual(1, Number.parseInt(uids[0], 10)); + done(); + }); + }); + + after(done => { + groups.leave('cid:1:privileges:moderate', 1, done); + }); + }); + + describe('.getModeratorUids()', () => { + before(done => { + async.series([ + async.apply(groups.create, {name: 'testGroup'}), + async.apply(groups.join, 'cid:1:privileges:groups:moderate', 'testGroup'), + async.apply(groups.join, 'testGroup', 1), + ], done); + }); + + it('should retrieve all users with moderator bit in category privilege', done => { + User.getModeratorUids((error, uids) => { + assert.ifError(error); + assert.strictEqual(1, uids.length); + assert.strictEqual(1, Number.parseInt(uids[0], 10)); + done(); + }); + }); + + after(done => { + async.series([ + async.apply(groups.leave, 'cid:1:privileges:groups:moderate', 'testGroup'), + async.apply(groups.destroy, 'testGroup'), + ], done); + }); + }); + + describe('.isReadyToPost()', () => { + it('should error when a user makes two posts in quick succession', done => { + meta.config = meta.config || {}; + meta.config.postDelay = '10'; + + async.series([ + async.apply(Topics.post, { + uid: testUid, + title: 'Topic 1', + content: 'lorem ipsum', + cid: testCid, + }), + async.apply(Topics.post, { + uid: testUid, + title: 'Topic 2', + content: 'lorem ipsum', + cid: testCid, + }), + ], error => { + assert(error); + done(); + }); + }); + + it('should allow a post if the last post time is > 10 seconds', done => { + User.setUserField(testUid, 'lastposttime', Date.now() - (11 * 1000), () => { + Topics.post({ + uid: testUid, + title: 'Topic 3', + content: 'lorem ipsum', + cid: testCid, + }, error => { + assert.ifError(error); + done(); + }); + }); + }); + + it('should error when a new user posts if the last post time is 10 < 30 seconds', done => { + meta.config.newbiePostDelay = 30; + meta.config.newbiePostDelayThreshold = 3; + + User.setUserField(testUid, 'lastposttime', Date.now() - (20 * 1000), () => { + Topics.post({ + uid: testUid, + title: 'Topic 4', + content: 'lorem ipsum', + cid: testCid, + }, error => { + assert(error); + done(); + }); + }); + }); + + it('should not error if a non-newbie user posts if the last post time is 10 < 30 seconds', done => { + User.setUserFields(testUid, { + lastposttime: Date.now() - (20 * 1000), + reputation: 10, + }, () => { + Topics.post({ + uid: testUid, + title: 'Topic 5', + content: 'lorem ipsum', + cid: testCid, + }, error => { + assert.ifError(error); + done(); + }); + }); + }); + + it('should only post 1 topic out of 10', async () => { + await User.create({username: 'flooder', password: '123456'}); + const {jar} = await helpers.loginUser('flooder', '123456'); + const titles = Array.from({length: 10}).fill('topic title'); + const res = await Promise.allSettled(titles.map(async title => { + const {body} = await helpers.request('post', '/api/v3/topics', { + form: { + cid: testCid, + title, + content: 'the content', + }, + jar, + json: true, + }); + return body.status; + })); + const failed = res.filter(res => res.value.code === 'bad-request'); + const success = res.filter(res => res.value.code === 'ok'); + assert.strictEqual(failed.length, 9); + assert.strictEqual(success.length, 1); + }); + }); + + describe('.search()', () => { + let adminUid; + let uid; + before(async () => { + adminUid = await User.create({username: 'noteadmin'}); + await groups.join('administrators', adminUid); + }); + + it('should return an object containing an array of matching users', done => { + User.search({query: 'john'}, (error, searchData) => { + assert.ifError(error); + uid = searchData.users[0].uid; + assert.equal(Array.isArray(searchData.users) && searchData.users.length > 0, true); + assert.equal(searchData.users[0].username, 'John Smith'); + done(); + }); + }); + + it('should search user', async () => { + const searchData = await apiUser.search({uid: testUid}, {query: 'john'}); + assert.equal(searchData.users[0].username, 'John Smith'); + }); + + it('should error for guest', async () => { + try { + await apiUser.search({uid: 0}, {query: 'john'}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:no-privileges]]'); + } + }); + + it('should error with invalid data', async () => { + try { + await apiUser.search({uid: testUid}, null); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:invalid-data]]'); + } + }); + + it('should error for unprivileged user', async () => { + try { + await apiUser.search({uid: testUid}, {searchBy: 'ip', query: '123'}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:no-privileges]]'); + } + }); + + it('should error for unprivileged user', async () => { + try { + await apiUser.search({uid: testUid}, {filters: ['banned'], query: '123'}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:no-privileges]]'); + } + }); + + it('should error for unprivileged user', async () => { + try { + await apiUser.search({uid: testUid}, {filters: ['flagged'], query: '123'}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:no-privileges]]'); + } + }); + + it('should search users by ip', async () => { + const uid = await User.create({username: 'ipsearch'}); + await db.sortedSetAdd('ip:1.1.1.1:uid', [1, 1], [testUid, uid]); + const data = await apiUser.search({uid: adminUid}, {query: '1.1.1.1', searchBy: 'ip'}); + assert(Array.isArray(data.users)); + assert.equal(data.users.length, 2); + }); + + it('should search users by uid', async () => { + const data = await apiUser.search({uid: testUid}, {query: uid, searchBy: 'uid'}); + assert(Array.isArray(data.users)); + assert.equal(data.users[0].uid, uid); + }); + + it('should search users by fullname', async () => { + const uid = await User.create({username: 'fullnamesearch1', fullname: 'Mr. Fullname'}); + const data = await apiUser.search({uid: adminUid}, {query: 'mr', searchBy: 'fullname'}); + assert(Array.isArray(data.users)); + assert.equal(data.users.length, 1); + assert.equal(uid, data.users[0].uid); + }); + + it('should search users by fullname', async () => { + const uid = await User.create({username: 'fullnamesearch2', fullname: 'Baris:Usakli'}); + const data = await apiUser.search({uid: adminUid}, {query: 'baris:', searchBy: 'fullname'}); + assert(Array.isArray(data.users)); + assert.equal(data.users.length, 1); + assert.equal(uid, data.users[0].uid); + }); + + it('should return empty array if query is empty', async () => { + const data = await apiUser.search({uid: testUid}, {query: ''}); + assert.equal(data.users.length, 0); + }); + + it('should filter users', async () => { + const uid = await User.create({username: 'ipsearch_filter'}); + await User.bans.ban(uid, 0, ''); + await User.setUserFields(uid, {flags: 10}); + const data = await apiUser.search({uid: adminUid}, { + query: 'ipsearch', + filters: ['online', 'banned', 'flagged'], + }); + assert.equal(data.users[0].username, 'ipsearch_filter'); + }); + + it('should sort results by username', done => { + async.waterfall([ + function (next) { + User.create({username: 'brian'}, next); + }, + function (uid, next) { + User.create({username: 'baris'}, next); + }, + function (uid, next) { + User.create({username: 'bzari'}, next); + }, + function (uid, next) { + User.search({ + uid: testUid, + query: 'b', + sortBy: 'username', + paginate: false, + }, next); + }, + ], (error, data) => { + assert.ifError(error); + assert.equal(data.users[0].username, 'baris'); + assert.equal(data.users[1].username, 'brian'); + assert.equal(data.users[2].username, 'bzari'); + done(); + }); + }); + }); + + describe('.delete()', () => { + let uid; + before(done => { + User.create({username: 'usertodelete', password: '123456', email: 'delete@me.com'}, (error, newUid) => { + assert.ifError(error); + uid = newUid; + done(); + }); + }); + + it('should delete a user account', done => { + User.delete(1, uid, error => { + assert.ifError(error); + User.existsBySlug('usertodelete', (error, exists) => { + assert.ifError(error); + assert.equal(exists, false); + done(); + }); + }); + }); + + it('should not re-add user to users:postcount if post is purged after user account deletion', async () => { + const uid = await User.create({username: 'olduserwithposts'}); + assert(await db.isSortedSetMember('users:postcount', uid)); + + const result = await Topics.post({ + uid, + title: 'old user topic', + content: 'old user topic post content', + cid: testCid, + }); + assert.equal(await db.sortedSetScore('users:postcount', uid), 1); + await User.deleteAccount(uid); + assert(!await db.isSortedSetMember('users:postcount', uid)); + await Posts.purge(result.postData.pid, 1); + assert(!await db.isSortedSetMember('users:postcount', uid)); + }); + + it('should not re-add user to users:reputation if post is upvoted after user account deletion', async () => { + const uid = await User.create({username: 'olduserwithpostsupvote'}); + assert(await db.isSortedSetMember('users:reputation', uid)); + + const result = await Topics.post({ + uid, + title: 'old user topic', + content: 'old user topic post content', + cid: testCid, + }); + assert.equal(await db.sortedSetScore('users:reputation', uid), 0); + await User.deleteAccount(uid); + assert(!await db.isSortedSetMember('users:reputation', uid)); + await Posts.upvote(result.postData.pid, 1); + assert(!await db.isSortedSetMember('users:reputation', uid)); + }); + + it('should delete user even if they started a chat', async () => { + const socketModules = require('../src/socket.io/modules'); + const uid1 = await User.create({username: 'chatuserdelete1'}); + const uid2 = await User.create({username: 'chatuserdelete2'}); + const roomId = await messaging.newRoom(uid1, [uid2]); + await messaging.addMessage({ + uid: uid1, + content: 'hello', + roomId, + }); + await messaging.leaveRoom([uid2], roomId); + await User.delete(1, uid1); + assert.strictEqual(await User.exists(uid1), false); + }); + }); + + describe('passwordReset', () => { + let uid; + let code; + before(async () => { + uid = await User.create({username: 'resetuser', password: '123456'}); + await User.setUserField(uid, 'email', 'reset@me.com'); + await User.email.confirmByUid(uid); + }); + + it('.generate() should generate a new reset code', done => { + User.reset.generate(uid, (error, _code) => { + assert.ifError(error); + assert(_code); + + code = _code; + done(); + }); + }); + + it('.generate() should invalidate a previous generated reset code', async () => { + const _code = await User.reset.generate(uid); + const valid = await User.reset.validate(code); + assert.strictEqual(valid, false); + + code = _code; + }); + + it('.validate() should ensure that this new code is valid', done => { + User.reset.validate(code, (error, valid) => { + assert.ifError(error); + assert.strictEqual(valid, true); + done(); + }); + }); + + it('.validate() should correctly identify an invalid code', done => { + User.reset.validate(`${code}abcdef`, (error, valid) => { + assert.ifError(error); + assert.strictEqual(valid, false); + done(); + }); + }); + + it('.send() should create a new reset code and reset password', async () => { + code = await User.reset.send('reset@me.com'); + }); + + it('.commit() should update the user\'s password and confirm their email', done => { + User.reset.commit(code, 'newpassword', error => { + assert.ifError(error); + + async.parallel({ + userData(next) { + User.getUserData(uid, next); + }, + password(next) { + db.getObjectField(`user:${uid}`, 'password', next); + }, + }, (error, results) => { + assert.ifError(error); + Password.compare('newpassword', results.password, true, (error, match) => { + assert.ifError(error); + assert(match); + assert.strictEqual(results.userData['email:confirmed'], 1); + done(); + }); + }); + }); + }); + + it('.should error if same password is used for reset', async () => { + const uid = await User.create({username: 'badmemory', email: 'bad@memory.com', password: '123456'}); + const code = await User.reset.generate(uid); + let error; + try { + await User.reset.commit(code, '123456'); + } catch (error_) { + error = error_; + } + + assert.strictEqual(error.message, '[[error:reset-same-password]]'); + }); + + it('should not validate email if password reset is due to expiry', async () => { + const uid = await User.create({username: 'resetexpiry', email: 'reset@expiry.com', password: '123456'}); + let confirmed = await User.getUserField(uid, 'email:confirmed'); + let [verified, unverified] = await groups.isMemberOfGroups(uid, ['verified-users', 'unverified-users']); + assert.strictEqual(confirmed, 0); + assert.strictEqual(verified, false); + assert.strictEqual(unverified, true); + await User.setUserField(uid, 'passwordExpiry', Date.now()); + const code = await User.reset.generate(uid); + await User.reset.commit(code, '654321'); + confirmed = await User.getUserField(uid, 'email:confirmed'); + [verified, unverified] = await groups.isMemberOfGroups(uid, ['verified-users', 'unverified-users']); + assert.strictEqual(confirmed, 0); + assert.strictEqual(verified, false); + assert.strictEqual(unverified, true); + }); + }); + + describe('hash methods', () => { + it('should return uid from email', done => { + User.getUidByEmail('john@example.com', (error, uid) => { + assert.ifError(error); + assert.equal(Number.parseInt(uid, 10), Number.parseInt(testUid, 10)); + done(); + }); + }); + + it('should return uid from username', done => { + User.getUidByUsername('John Smith', (error, uid) => { + assert.ifError(error); + assert.equal(Number.parseInt(uid, 10), Number.parseInt(testUid, 10)); + done(); + }); + }); + + it('should return uid from userslug', done => { + User.getUidByUserslug('john-smith', (error, uid) => { + assert.ifError(error); + assert.equal(Number.parseInt(uid, 10), Number.parseInt(testUid, 10)); + done(); + }); + }); + + it('should get user data even if one uid is NaN', done => { + User.getUsersData([Number.NaN, testUid], (error, data) => { + assert.ifError(error); + assert(data[0]); + assert.equal(data[0].username, '[[global:guest]]'); + assert(data[1]); + assert.equal(data[1].username, userData.username); + done(); + }); + }); + + it('should not return private user data', done => { + User.setUserFields(testUid, { + fb_token: '123123123', + another_secret: 'abcde', + postcount: '123', + }, error => { + assert.ifError(error); + User.getUserData(testUid, (error, userData) => { + assert.ifError(error); + assert(!userData.hasOwnProperty('fb_token')); + assert(!userData.hasOwnProperty('another_secret')); + assert(!userData.hasOwnProperty('password')); + assert(!userData.hasOwnProperty('rss_token')); + assert.strictEqual(userData.postcount, 123); + assert.strictEqual(userData.uid, testUid); + done(); + }); + }); + }); + + it('should not return password even if explicitly requested', done => { + User.getUserFields(testUid, ['password'], (error, payload) => { + assert.ifError(error); + assert(!payload.hasOwnProperty('password')); + done(); + }); + }); + + it('should not modify the fields array passed in', async () => { + const fields = ['username', 'email']; + await User.getUserFields(testUid, fields); + assert.deepStrictEqual(fields, ['username', 'email']); + }); + + it('should return an icon text and valid background if username and picture is explicitly requested', async () => { + const payload = await User.getUserFields(testUid, ['username', 'picture']); + const validBackgrounds = await User.getIconBackgrounds(testUid); + assert.strictEqual(payload['icon:text'], userData.username.slice(0, 1).toUpperCase()); + assert(payload['icon:bgColor']); + assert(validBackgrounds.includes(payload['icon:bgColor'])); + }); + + it('should return a valid background, even if an invalid background colour is set', async () => { + await User.setUserField(testUid, 'icon:bgColor', 'teal'); + const payload = await User.getUserFields(testUid, ['username', 'picture']); + const validBackgrounds = await User.getIconBackgrounds(testUid); + + assert(payload['icon:bgColor']); + assert(validBackgrounds.includes(payload['icon:bgColor'])); + }); + + it('should return private data if field is whitelisted', done => { + function filterMethod(data, callback) { + data.whitelist.push('another_secret'); + callback(null, data); + } + + plugins.hooks.register('test-plugin', {hook: 'filter:user.whitelistFields', method: filterMethod}); + User.getUserData(testUid, (error, userData) => { + assert.ifError(error); + assert(!userData.hasOwnProperty('fb_token')); + assert.equal(userData.another_secret, 'abcde'); + plugins.hooks.unregister('test-plugin', 'filter:user.whitelistFields', filterMethod); + done(); + }); + }); + + it('should return 0 as uid if username is falsy', done => { + User.getUidByUsername('', (error, uid) => { + assert.ifError(error); + assert.strictEqual(uid, 0); + done(); + }); + }); + + it('should get username by userslug', done => { + User.getUsernameByUserslug('john-smith', (error, username) => { + assert.ifError(error); + assert.strictEqual('John Smith', username); + done(); + }); + }); + + it('should get uids by emails', done => { + User.getUidsByEmails(['john@example.com'], (error, uids) => { + assert.ifError(error); + assert.equal(uids[0], testUid); + done(); + }); + }); + + it('should not get groupTitle for guests', done => { + User.getUserData(0, (error, userData) => { + assert.ifError(error); + assert.strictEqual(userData.groupTitle, ''); + assert.deepStrictEqual(userData.groupTitleArray, []); + done(); + }); + }); + + it('should load guest data', done => { + User.getUsersData([1, 0], (error, data) => { + assert.ifError(error); + assert.strictEqual(data[1].username, '[[global:guest]]'); + assert.strictEqual(data[1].userslug, ''); + assert.strictEqual(data[1].uid, 0); + done(); + }); + }); + }); + + describe('profile methods', () => { + let uid; + let jar; + let csrf_token; + + before(async () => { + const newUid = await User.create({username: 'updateprofile', email: 'update@me.com', password: '123456'}); + uid = newUid; + + await User.setUserField(uid, 'email', 'update@me.com'); + await User.email.confirmByUid(uid); + + ({jar, csrf_token} = await helpers.loginUser('updateprofile', '123456')); + }); + + it('should return error if not logged in', async () => { + try { + await apiUser.update({uid: 0}, {uid: 1}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:invalid-uid]]'); + } + }); + + it('should return error if data is invalid', async () => { + try { + await apiUser.update({uid}, null); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:invalid-data]]'); + } + }); + + it('should return error if data is missing uid', async () => { + try { + await apiUser.update({uid}, {username: 'bip', email: 'bop'}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:invalid-data]]'); + } + }); + + describe('.updateProfile()', () => { + let uid; + + it('should update a user\'s profile', async () => { + uid = await User.create({username: 'justforupdate', email: 'just@for.updated', password: '123456'}); + await User.setUserField(uid, 'email', 'just@for.updated'); + await User.email.confirmByUid(uid); + + const data = { + uid, + username: 'updatedUserName', + email: 'updatedEmail@me.com', + fullname: 'updatedFullname', + website: 'http://nodebb.org', + location: 'izmir', + groupTitle: 'testGroup', + birthday: '01/01/1980', + signature: 'nodebb is good', + password: '123456', + }; + const result = await apiUser.update({uid}, {...data, password: '123456', invalid: 'field'}); + assert.equal(result.username, 'updatedUserName'); + assert.equal(result.userslug, 'updatedusername'); + assert.equal(result.location, 'izmir'); + + const userData = await db.getObject(`user:${uid}`); + for (const key of Object.keys(data)) { + if (key === 'email') { + assert.strictEqual(userData.email, 'just@for.updated'); // Email remains the same until confirmed + } else if (key === 'password') { + assert(userData[key].startsWith('$2a$')); + } else { + assert.equal(data[key], userData[key]); + } + } + + // UpdateProfile only saves valid fields + assert.strictEqual(userData.invalid, undefined); + }); + + it('should also generate an email confirmation code for the changed email', async () => { + const confirmSent = await User.email.isValidationPending(uid, 'updatedemail@me.com'); + assert.strictEqual(confirmSent, true); + }); + }); + + it('should change a user\'s password', async () => { + const uid = await User.create({username: 'changepassword', password: '123456'}); + await apiUser.changePassword({uid}, {uid, newPassword: '654321', currentPassword: '123456'}); + const correct = await User.isPasswordCorrect(uid, '654321', '127.0.0.1'); + assert(correct); + }); + + it('should not let user change another user\'s password', async () => { + const regularUserUid = await User.create({username: 'regularuserpwdchange', password: 'regularuser1234'}); + const uid = await User.create({username: 'changeadminpwd1', password: '123456'}); + try { + await apiUser.changePassword({uid}, {uid: regularUserUid, newPassword: '654321', currentPassword: '123456'}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[user:change_password_error_privileges]]'); + } + }); + + it('should not let user change admin\'s password', async () => { + const adminUid = await User.create({username: 'adminpwdchange', password: 'admin1234'}); + await groups.join('administrators', adminUid); + const uid = await User.create({username: 'changeadminpwd2', password: '123456'}); + try { + await apiUser.changePassword({uid}, {uid: adminUid, newPassword: '654321', currentPassword: '123456'}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[user:change_password_error_privileges]]'); + } + }); + + it('should let admin change another users password', async () => { + const adminUid = await User.create({username: 'adminpwdchange2', password: 'admin1234'}); + await groups.join('administrators', adminUid); + const uid = await User.create({username: 'forgotmypassword', password: '123456'}); + + await apiUser.changePassword({uid: adminUid}, {uid, newPassword: '654321'}); + const correct = await User.isPasswordCorrect(uid, '654321', '127.0.0.1'); + assert(correct); + }); + + it('should not let admin change their password if current password is incorrect', async () => { + const adminUid = await User.create({username: 'adminforgotpwd', password: 'admin1234'}); + await groups.join('administrators', adminUid); + + try { + await apiUser.changePassword({uid: adminUid}, {uid: adminUid, newPassword: '654321', currentPassword: 'wrongpwd'}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[user:change_password_error_wrong_current]]'); + } + }); + + it('should change username', async () => { + await apiUser.update({uid}, {uid, username: 'updatedAgain', password: '123456'}); + const username = await db.getObjectField(`user:${uid}`, 'username'); + assert.equal(username, 'updatedAgain'); + }); + + it('should not let setting an empty username', async () => { + await apiUser.update({uid}, {uid, username: '', password: '123456'}); + const username = await db.getObjectField(`user:${uid}`, 'username'); + assert.strictEqual(username, 'updatedAgain'); + }); + + it('should let updating profile if current username is above max length and it is not being changed', async () => { + const maxLength = meta.config.maximumUsernameLength + 1; + const longName = new Array(maxLength).fill('a').join(''); + const uid = await User.create({username: longName}); + await apiUser.update({uid}, {uid, username: longName, email: 'verylong@name.com'}); + const userData = await db.getObject(`user:${uid}`); + const awaitingValidation = await User.email.isValidationPending(uid, 'verylong@name.com'); + + assert.strictEqual(userData.username, longName); + assert.strictEqual(awaitingValidation, true); + }); + + it('should not update a user\'s username if it did not change', async () => { + await apiUser.update({uid}, {uid, username: 'updatedAgain', password: '123456'}); + const data = await db.getSortedSetRevRange(`user:${uid}:usernames`, 0, -1); + assert.equal(data.length, 2); + assert(data[0].startsWith('updatedAgain')); + }); + + it('should not update a user\'s username if a password is not supplied', async () => { + try { + await apiUser.update({uid}, {uid, username: 'updatedAgain', password: ''}); + assert(false); + } catch (error) { + assert.strictEqual(error.message, '[[error:invalid-password]]'); + } + }); + + it('should send validation email', async () => { + const uid = await User.create({username: 'pooremailupdate', email: 'poor@update.me', password: '123456'}); + await User.email.expireValidation(uid); + await apiUser.update({uid}, {uid, email: 'updatedAgain@me.com', password: '123456'}); + + assert.strictEqual(await User.email.isValidationPending(uid, 'updatedAgain@me.com'.toLowerCase()), true); + }); + + it('should update cover image', done => { + const position = '50.0301% 19.2464%'; + const coverData = {uid, imageData: goodImage, position}; + socketUser.updateCover({uid}, coverData, (error, result) => { + assert.ifError(error); + assert(result.url); + db.getObjectFields(`user:${uid}`, ['cover:url', 'cover:position'], (error, data) => { + assert.ifError(error); + assert.equal(data['cover:url'], result.url); + assert.equal(data['cover:position'], position); + done(); + }); + }); + }); + + it('should remove cover image', async () => { + const coverPath = await User.getLocalCoverPath(uid); + await socketUser.removeCover({uid}, {uid}); + const coverUrlNow = await db.getObjectField(`user:${uid}`, 'cover:url'); + assert.strictEqual(coverUrlNow, null); + assert.strictEqual(fs.existsSync(coverPath), false); + }); + + it('should set user status', done => { + socketUser.setStatus({uid}, 'away', (error, data) => { + assert.ifError(error); + assert.equal(data.uid, uid); + assert.equal(data.status, 'away'); + done(); + }); + }); + + it('should fail for invalid status', done => { + socketUser.setStatus({uid}, '12345', error => { + assert.equal(error.message, '[[error:invalid-user-status]]'); + done(); + }); + }); + + it('should get user status', done => { + socketUser.checkStatus({uid}, uid, (error, status) => { + assert.ifError(error); + assert.equal(status, 'away'); + done(); + }); + }); + + it('should change user picture', async () => { + await apiUser.changePicture({uid}, {type: 'default', uid}); + const picture = await User.getUserField(uid, 'picture'); + assert.equal(picture, ''); + }); + + it('should let you set an external image', async () => { + const token = await helpers.getCsrfToken(jar); + const body = await requestAsync(`${nconf.get('url')}/api/v3/users/${uid}/picture`, { + jar, + method: 'put', + json: true, + headers: { + 'x-csrf-token': token, + }, + body: { + type: 'external', + url: 'https://example.org/picture.jpg', + }, + }); + + assert(body && body.status && body.response); + assert.strictEqual(body.status.code, 'ok'); + + const picture = await User.getUserField(uid, 'picture'); + assert.strictEqual(picture, validator.escape('https://example.org/picture.jpg')); + }); + + it('should fail to change user picture with invalid data', async () => { + try { + await apiUser.changePicture({uid}, null); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:invalid-data]]'); + } + }); + + it('should fail to change user picture with invalid uid', async () => { + try { + await apiUser.changePicture({uid: 0}, {uid: 1}); + assert(false); + } catch (error) { + assert.equal(error.message, '[[error:no-privileges]]'); + } + }); + + it('should set user picture to uploaded', async () => { + await User.setUserField(uid, 'uploadedpicture', '/test'); + await apiUser.changePicture({uid}, {type: 'uploaded', uid}); + const picture = await User.getUserField(uid, 'picture'); + assert.equal(picture, `${nconf.get('relative_path')}/test`); + }); + + it('should return error if profile image uploads disabled', done => { + meta.config.allowProfileImageUploads = 0; + const picture = { + path: path.join(nconf.get('base_dir'), 'test/files/test_copy.png'), + size: 7189, + name: 'test.png', + type: 'image/png', + }; + User.uploadCroppedPicture({ + callerUid: uid, + uid, + file: picture, + }, error => { + assert.equal(error.message, '[[error:profile-image-uploads-disabled]]'); + meta.config.allowProfileImageUploads = 1; + done(); + }); + }); + + it('should return error if profile image has no mime type', done => { + User.uploadCroppedPicture({ + callerUid: uid, + uid, + imageData: 'data:image/invalid;base64,R0lGODlhPQBEAPeoAJosM/', + }, error => { + assert.equal(error.message, '[[error:invalid-image]]'); + done(); + }); + }); + + describe('user.uploadCroppedPicture', () => { + const badImage = 'data:audio/mp3;base64,R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw=='; + + it('should upload cropped profile picture', async () => { + const result = await socketUser.uploadCroppedPicture({uid}, {uid, imageData: goodImage}); + assert(result.url); + const data = await db.getObjectFields(`user:${uid}`, ['uploadedpicture', 'picture']); + assert.strictEqual(result.url, data.uploadedpicture); + assert.strictEqual(result.url, data.picture); + }); + + it('should upload cropped profile picture in chunks', async () => { + const socketUploads = require('../src/socket.io/uploads'); + const socketData = { + uid, + method: 'user.uploadCroppedPicture', + size: goodImage.length, + progress: 0, + }; + const chunkSize = 1000; + let result; + do { + const chunk = goodImage.slice(socketData.progress, socketData.progress + chunkSize); + socketData.progress += chunk.length; + // eslint-disable-next-line result = await socketUploads.upload({ uid: uid }, { - chunk: chunk, - params: socketData, - }); - } while (socketData.progress < socketData.size); - - assert(result.url); - const data = await db.getObjectFields(`user:${uid}`, ['uploadedpicture', 'picture']); - assert.strictEqual(result.url, data.uploadedpicture); - assert.strictEqual(result.url, data.picture); - }); - - it('should error if both file and imageData are missing', (done) => { - User.uploadCroppedPicture({}, (err) => { - assert.equal('[[error:invalid-data]]', err.message); - done(); - }); - }); - - it('should error if file size is too big', (done) => { - const temp = meta.config.maximumProfileImageSize; - meta.config.maximumProfileImageSize = 1; - User.uploadCroppedPicture({ - callerUid: uid, - uid: 1, - imageData: goodImage, - }, (err) => { - assert.equal('[[error:file-too-big, 1]]', err.message); - - // Restore old value - meta.config.maximumProfileImageSize = temp; - done(); - }); - }); - - it('should not allow image data with bad MIME type to be passed in', (done) => { - User.uploadCroppedPicture({ - callerUid: uid, - uid: 1, - imageData: badImage, - }, (err) => { - assert.equal('[[error:invalid-image]]', err.message); - done(); - }); - }); - - it('should get profile pictures', (done) => { - socketUser.getProfilePictures({ uid: uid }, { uid: uid }, (err, data) => { - assert.ifError(err); - assert(data); - assert(Array.isArray(data)); - assert.equal(data[0].type, 'uploaded'); - assert.equal(data[0].text, '[[user:uploaded_picture]]'); - done(); - }); - }); - - it('should get default profile avatar', (done) => { - assert.strictEqual(User.getDefaultAvatar(), ''); - meta.config.defaultAvatar = 'https://path/to/default/avatar'; - assert.strictEqual(User.getDefaultAvatar(), meta.config.defaultAvatar); - meta.config.defaultAvatar = '/path/to/default/avatar'; - assert.strictEqual(User.getDefaultAvatar(), nconf.get('relative_path') + meta.config.defaultAvatar); - meta.config.defaultAvatar = ''; - done(); - }); - - it('should fail to get profile pictures with invalid data', (done) => { - socketUser.getProfilePictures({ uid: uid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - socketUser.getProfilePictures({ uid: uid }, { uid: null }, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - }); - - it('should remove uploaded picture', async () => { - const avatarPath = await User.getLocalAvatarPath(uid); - assert.notStrictEqual(avatarPath, false); - await socketUser.removeUploadedPicture({ uid: uid }, { uid: uid }); - const uploadedPicture = await User.getUserField(uid, 'uploadedpicture'); - assert.strictEqual(uploadedPicture, ''); - assert.strictEqual(fs.existsSync(avatarPath), false); - }); - - it('should fail to remove uploaded picture with invalid-data', (done) => { - socketUser.removeUploadedPicture({ uid: uid }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - socketUser.removeUploadedPicture({ uid: uid }, { }, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - socketUser.removeUploadedPicture({ uid: null }, { }, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - }); - }); - }); - - it('should load profile page', (done) => { - request(`${nconf.get('url')}/api/user/updatedagain`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load settings page', (done) => { - request(`${nconf.get('url')}/api/user/updatedagain/settings`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body.settings); - assert(body.languages); - assert(body.homePageRoutes); - done(); - }); - }); - - it('should load edit page', (done) => { - request(`${nconf.get('url')}/api/user/updatedagain/edit`, { jar: jar, json: true }, (err, res, body) => { - assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body); - done(); - }); - }); - - it('should load edit/email page', async () => { - const res = await requestAsync(`${nconf.get('url')}/api/user/updatedagain/edit/email`, { jar: jar, json: true, resolveWithFullResponse: true }); - assert.strictEqual(res.statusCode, 200); - assert(res.body); - - // Accessing this page will mark the user's account as needing an updated email, below code undo's. - await requestAsync({ - uri: `${nconf.get('url')}/register/abort?_csrf=${csrf_token}`, - jar, - method: 'POST', - simple: false, - }); - }); - - it('should load user\'s groups page', async () => { - await groups.create({ - name: 'Test', - description: 'Foobar!', - }); - - await groups.join('Test', uid); - const body = await requestAsync(`${nconf.get('url')}/api/user/updatedagain/groups`, { jar: jar, json: true }); - - assert(Array.isArray(body.groups)); - assert.equal(body.groups[0].name, 'Test'); - }); - }); - - describe('user info', () => { - let testUserUid; - let verifiedTestUserUid; - - before(async () => { - // Might be the first user thus a verified one if this test part is ran alone - verifiedTestUserUid = await User.create({ username: 'bannedUser', password: '123456', email: 'banneduser@example.com' }); - await User.setUserField(verifiedTestUserUid, 'email:confirmed', 1); - testUserUid = await User.create({ username: 'bannedUser2', password: '123456', email: 'banneduser2@example.com' }); - }); - - it('should return error if there is no ban reason', (done) => { - User.getLatestBanInfo(123, (err) => { - assert.equal(err.message, 'no-ban-info'); - done(); - }); - }); - - it('should get history from set', async () => { - const now = Date.now(); - await db.sortedSetAdd(`user:${testUserUid}:usernames`, now, `derp:${now}`); - const data = await User.getHistory(`user:${testUserUid}:usernames`); - assert.equal(data[0].value, 'derp'); - assert.equal(data[0].timestamp, now); - }); - - it('should return the correct ban reason', (done) => { - async.series([ - function (next) { - User.bans.ban(testUserUid, 0, '', (err) => { - assert.ifError(err); - next(err); - }); - }, - function (next) { - User.getModerationHistory(testUserUid, (err, data) => { - assert.ifError(err); - assert.equal(data.bans.length, 1, 'one ban'); - assert.equal(data.bans[0].reason, '[[user:info.banned-no-reason]]', 'no ban reason'); - - next(err); - }); - }, - ], (err) => { - assert.ifError(err); - User.bans.unban(testUserUid, (err) => { - assert.ifError(err); - done(); - }); - }); - }); - - it('should ban user permanently', (done) => { - User.bans.ban(testUserUid, (err) => { - assert.ifError(err); - User.bans.isBanned(testUserUid, (err, isBanned) => { - assert.ifError(err); - assert.equal(isBanned, true); - User.bans.unban(testUserUid, done); - }); - }); - }); - - it('should ban user temporarily', (done) => { - User.bans.ban(testUserUid, Date.now() + 2000, (err) => { - assert.ifError(err); - - User.bans.isBanned(testUserUid, (err, isBanned) => { - assert.ifError(err); - assert.equal(isBanned, true); - setTimeout(() => { - User.bans.isBanned(testUserUid, (err, isBanned) => { - assert.ifError(err); - assert.equal(isBanned, false); - User.bans.unban(testUserUid, done); - }); - }, 3000); - }); - }); - }); - - it('should error if until is NaN', (done) => { - User.bans.ban(testUserUid, 'asd', (err) => { - assert.equal(err.message, '[[error:ban-expiry-missing]]'); - done(); - }); - }); - - it('should be member of "banned-users" system group only after a ban', async () => { - await User.bans.ban(testUserUid); - - const systemGroups = groups.systemGroups.filter(group => group !== groups.BANNED_USERS); - const isMember = await groups.isMember(testUserUid, groups.BANNED_USERS); - const isMemberOfAny = await groups.isMemberOfAny(testUserUid, systemGroups); - - assert.strictEqual(isMember, true); - assert.strictEqual(isMemberOfAny, false); - }); - - it('should restore system group memberships after an unban (for an unverified user)', async () => { - await User.bans.unban(testUserUid); - - const isMemberOfGroups = await groups.isMemberOfGroups(testUserUid, groups.systemGroups); - const membership = new Map(groups.systemGroups.map((item, index) => [item, isMemberOfGroups[index]])); - - assert.strictEqual(membership.get('registered-users'), true); - assert.strictEqual(membership.get('verified-users'), false); - assert.strictEqual(membership.get('unverified-users'), true); - assert.strictEqual(membership.get(groups.BANNED_USERS), false); - // administrators cannot be banned - assert.strictEqual(membership.get('administrators'), false); - // This will not restored - assert.strictEqual(membership.get('Global Moderators'), false); - }); - - it('should restore system group memberships after an unban (for a verified user)', async () => { - await User.bans.ban(verifiedTestUserUid); - await User.bans.unban(verifiedTestUserUid); - - const isMemberOfGroups = await groups.isMemberOfGroups(verifiedTestUserUid, groups.systemGroups); - const membership = new Map(groups.systemGroups.map((item, index) => [item, isMemberOfGroups[index]])); - - assert.strictEqual(membership.get('verified-users'), true); - assert.strictEqual(membership.get('unverified-users'), false); - }); - - it('should be able to post in category for banned users', async () => { - const { cid } = await Categories.create({ - name: 'Test Category', - description: 'A test', - order: 1, - }); - const testUid = await User.create({ username: userData.username }); - await User.bans.ban(testUid); - let _err; - try { - await Topics.post({ title: 'banned topic', content: 'tttttttttttt', cid: cid, uid: testUid }); - } catch (err) { - _err = err; - } - assert.strictEqual(_err && _err.message, '[[error:no-privileges]]'); - - await Promise.all([ - privileges.categories.give(['groups:topics:create', 'groups:topics:reply'], cid, 'banned-users'), - privileges.categories.rescind(['groups:topics:create', 'groups:topics:reply'], cid, 'registered-users'), - ]); - - const result = await Topics.post({ title: 'banned topic', content: 'tttttttttttt', cid: cid, uid: testUid }); - assert(result); - assert.strictEqual(result.topicData.title, 'banned topic'); - }); - }); - - describe('Digest.getSubscribers', () => { - const uidIndex = {}; - - before((done) => { - const testUsers = ['daysub', 'offsub', 'nullsub', 'weeksub']; - async.each(testUsers, (username, next) => { - async.waterfall([ - async.apply(User.create, { username: username, email: `${username}@example.com` }), - function (uid, next) { - if (username === 'nullsub') { - return setImmediate(next); - } - - uidIndex[username] = uid; - - const sub = username.slice(0, -3); - async.parallel([ - async.apply(User.updateDigestSetting, uid, sub), - async.apply(User.setSetting, uid, 'dailyDigestFreq', sub), - ], next); - }, - ], next); - }, done); - }); - - it('should accurately build digest list given ACP default "null" (not set)', (done) => { - User.digest.getSubscribers('day', (err, subs) => { - assert.ifError(err); - assert.strictEqual(subs.length, 1); - - done(); - }); - }); - - it('should accurately build digest list given ACP default "day"', (done) => { - async.series([ - async.apply(meta.configs.set, 'dailyDigestFreq', 'day'), - function (next) { - User.digest.getSubscribers('day', (err, subs) => { - assert.ifError(err); - assert.strictEqual(subs.includes(uidIndex.daysub.toString()), true); // daysub is emailed - assert.strictEqual(subs.includes(uidIndex.weeksub.toString()), false); // weeksub isn't emailed - assert.strictEqual(subs.includes(uidIndex.offsub.toString()), false); // offsub isn't emailed - - next(); - }); - }, - ], done); - }); - - it('should accurately build digest list given ACP default "week"', (done) => { - async.series([ - async.apply(meta.configs.set, 'dailyDigestFreq', 'week'), - function (next) { - User.digest.getSubscribers('week', (err, subs) => { - assert.ifError(err); - assert.strictEqual(subs.includes(uidIndex.weeksub.toString()), true); // weeksub is emailed - assert.strictEqual(subs.includes(uidIndex.daysub.toString()), false); // daysub is emailed - assert.strictEqual(subs.includes(uidIndex.offsub.toString()), false); // offsub isn't emailed - - next(); - }); - }, - ], done); - }); - - it('should accurately build digest list given ACP default "off"', (done) => { - async.series([ - async.apply(meta.configs.set, 'dailyDigestFreq', 'off'), - function (next) { - User.digest.getSubscribers('day', (err, subs) => { - assert.ifError(err); - assert.strictEqual(subs.length, 1); - - next(); - }); - }, - ], done); - }); - }); - - describe('digests', () => { - let uid; - before((done) => { - async.waterfall([ - function (next) { - User.create({ username: 'digestuser', email: 'test@example.com' }, next); - }, - function (_uid, next) { - uid = _uid; - User.updateDigestSetting(uid, 'day', next); - }, - function (next) { - User.setSetting(uid, 'dailyDigestFreq', 'day', next); - }, - function (next) { - User.setSetting(uid, 'notificationType_test', 'notificationemail', next); - }, - ], done); - }); - - it('should send digests', (done) => { - const oldValue = meta.config.includeUnverifiedEmails; - meta.config.includeUnverifiedEmails = true; - User.digest.execute({ interval: 'day' }, (err) => { - assert.ifError(err); - meta.config.includeUnverifiedEmails = oldValue; - done(); - }); - }); - - it('should not send digests', (done) => { - User.digest.execute({ interval: 'month' }, (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should get delivery times', async () => { - const data = await User.digest.getDeliveryTimes(0, -1); - const users = data.users.filter(u => u.username === 'digestuser'); - assert.strictEqual(users[0].setting, 'day'); - }); - - describe('unsubscribe via POST', () => { - it('should unsubscribe from digest if one-click unsubscribe is POSTed', (done) => { - const token = jwt.sign({ - template: 'digest', - uid: uid, - }, nconf.get('secret')); - - request({ - method: 'post', - url: `${nconf.get('url')}/email/unsubscribe/${token}`, - }, (err, res) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 200); - - db.getObjectField(`user:${uid}:settings`, 'dailyDigestFreq', (err, value) => { - assert.ifError(err); - assert.strictEqual(value, 'off'); - done(); - }); - }); - }); - - it('should unsubscribe from notifications if one-click unsubscribe is POSTed', (done) => { - const token = jwt.sign({ - template: 'notification', - type: 'test', - uid: uid, - }, nconf.get('secret')); - - request({ - method: 'post', - url: `${nconf.get('url')}/email/unsubscribe/${token}`, - }, (err, res) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 200); - - db.getObjectField(`user:${uid}:settings`, 'notificationType_test', (err, value) => { - assert.ifError(err); - assert.strictEqual(value, 'notification'); - done(); - }); - }); - }); - - it('should return errors on missing template in token', (done) => { - const token = jwt.sign({ - uid: uid, - }, nconf.get('secret')); - - request({ - method: 'post', - url: `${nconf.get('url')}/email/unsubscribe/${token}`, - }, (err, res) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 404); - done(); - }); - }); - - it('should return errors on wrong template in token', (done) => { - const token = jwt.sign({ - template: 'user', - uid: uid, - }, nconf.get('secret')); - - request({ - method: 'post', - url: `${nconf.get('url')}/email/unsubscribe/${token}`, - }, (err, res) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 404); - done(); - }); - }); - - it('should return errors on missing token', (done) => { - request({ - method: 'post', - url: `${nconf.get('url')}/email/unsubscribe/`, - }, (err, res) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 404); - done(); - }); - }); - - it('should return errors on token signed with wrong secret (verify-failure)', (done) => { - const token = jwt.sign({ - template: 'notification', - type: 'test', - uid: uid, - }, `${nconf.get('secret')}aababacaba`); - - request({ - method: 'post', - url: `${nconf.get('url')}/email/unsubscribe/${token}`, - }, (err, res) => { - assert.ifError(err); - assert.strictEqual(res.statusCode, 403); - done(); - }); - }); - }); - }); - - describe('socket methods', () => { - const socketUser = require('../src/socket.io/user'); - let delUid; - - it('should fail with invalid data', (done) => { - meta.userOrGroupExists(null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should return true if user/group exists', (done) => { - meta.userOrGroupExists('registered-users', (err, exists) => { - assert.ifError(err); - assert(exists); - done(); - }); - }); - - it('should return true if user/group exists', (done) => { - meta.userOrGroupExists('John Smith', (err, exists) => { - assert.ifError(err); - assert(exists); - done(); - }); - }); - - it('should return false if user/group does not exists', (done) => { - meta.userOrGroupExists('doesnot exist', (err, exists) => { - assert.ifError(err); - assert(!exists); - done(); - }); - }); - - it('should delete user', async () => { - delUid = await User.create({ username: 'willbedeleted' }); - - // Upload some avatars and covers before deleting - meta.config['profile:keepAllUserImages'] = 1; - let result = await socketUser.uploadCroppedPicture({ uid: delUid }, { uid: delUid, imageData: goodImage }); - assert(result.url); - result = await socketUser.uploadCroppedPicture({ uid: delUid }, { uid: delUid, imageData: goodImage }); - assert(result.url); - - const position = '50.0301% 19.2464%'; - const coverData = { uid: delUid, imageData: goodImage, position: position }; - result = await socketUser.updateCover({ uid: delUid }, coverData); - assert(result.url); - result = await socketUser.updateCover({ uid: delUid }, coverData); - assert(result.url); - meta.config['profile:keepAllUserImages'] = 0; - - await apiUser.deleteAccount({ uid: delUid }, { uid: delUid }); - const exists = await meta.userOrGroupExists('willbedeleted'); - assert(!exists); - }); - - it('should clean profile images after account deletion', () => { - const allProfileFiles = fs.readdirSync(path.join(nconf.get('upload_path'), 'profile')); - const deletedUserImages = allProfileFiles.filter( - f => f.startsWith(`${delUid}-profilecover`) || f.startsWith(`${delUid}-profileavatar`) - ); - assert.strictEqual(deletedUserImages.length, 0); - }); - - it('should fail to delete user with wrong password', async () => { - const uid = await User.create({ username: 'willbedeletedpwd', password: '123456' }); - try { - await apiUser.deleteAccount({ uid: uid }, { uid: uid, password: '654321' }); - assert(false); - } catch (err) { - assert.strictEqual(err.message, '[[error:invalid-password]]'); - } - }); - - it('should delete user with correct password', async () => { - const uid = await User.create({ username: 'willbedeletedcorrectpwd', password: '123456' }); - await apiUser.deleteAccount({ uid: uid }, { uid: uid, password: '123456' }); - const exists = await User.exists(uid); - assert(!exists); - }); - - it('should fail to delete user if account deletion is not allowed', async () => { - const oldValue = meta.config.allowAccountDelete; - meta.config.allowAccountDelete = 0; - const uid = await User.create({ username: 'tobedeleted' }); - try { - await apiUser.deleteAccount({ uid: uid }, { uid: uid }); - assert(false); - } catch (err) { - assert.strictEqual(err.message, '[[error:account-deletion-disabled]]'); - } - meta.config.allowAccountDelete = oldValue; - }); - - it('should send reset email', (done) => { - socketUser.reset.send({ uid: 0 }, 'john@example.com', (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should return invalid-data error', (done) => { - socketUser.reset.send({ uid: 0 }, null, (err) => { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - - it('should not error', (done) => { - socketUser.reset.send({ uid: 0 }, 'doestnot@exist.com', (err) => { - assert.ifError(err); - done(); - }); - }); - - it('should commit reset', (done) => { - db.getObject('reset:uid', (err, data) => { - assert.ifError(err); - const code = Object.keys(data).find(code => parseInt(data[code], 10) === parseInt(testUid, 10)); - socketUser.reset.commit({ uid: 0 }, { code: code, password: 'pwdchange' }, (err) => { - assert.ifError(err); - done(); - }); - }); - }); - - it('should save user settings', async () => { - const data = { - uid: testUid, - settings: { - bootswatchSkin: 'default', - homePageRoute: 'none', - homePageCustom: '', - openOutgoingLinksInNewTab: 0, - scrollToMyPost: 1, - userLang: 'en-GB', - usePagination: 1, - topicsPerPage: '10', - postsPerPage: '5', - showemail: 1, - showfullname: 1, - restrictChat: 0, - followTopicsOnCreate: 1, - followTopicsOnReply: 1, - }, - }; - await apiUser.updateSettings({ uid: testUid }, data); - const userSettings = await User.getSettings(testUid); - assert.strictEqual(userSettings.usePagination, true); - }); - - it('should properly escape homePageRoute', async () => { - const data = { - uid: testUid, - settings: { - bootswatchSkin: 'default', - homePageRoute: 'category/6/testing-ground', - homePageCustom: '', - openOutgoingLinksInNewTab: 0, - scrollToMyPost: 1, - userLang: 'en-GB', - usePagination: 1, - topicsPerPage: '10', - postsPerPage: '5', - showemail: 1, - showfullname: 1, - restrictChat: 0, - followTopicsOnCreate: 1, - followTopicsOnReply: 1, - }, - }; - await apiUser.updateSettings({ uid: testUid }, data); - const userSettings = await User.getSettings(testUid); - assert.strictEqual(userSettings.homePageRoute, 'category/6/testing-ground'); - }); - - - it('should error if language is invalid', async () => { - const data = { - uid: testUid, - settings: { - userLang: '', - topicsPerPage: '10', - postsPerPage: '5', - }, - }; - try { - await apiUser.updateSettings({ uid: testUid }, data); - assert(false); - } catch (err) { - assert.equal(err.message, '[[error:invalid-language]]'); - } - }); - - it('should set moderation note', (done) => { - let adminUid; - async.waterfall([ - function (next) { - User.create({ username: 'noteadmin' }, next); - }, - function (_adminUid, next) { - adminUid = _adminUid; - groups.join('administrators', adminUid, next); - }, - function (next) { - socketUser.setModerationNote({ uid: adminUid }, { uid: testUid, note: 'this is a test user' }, next); - }, - function (next) { - setTimeout(next, 50); - }, - function (next) { - socketUser.setModerationNote({ uid: adminUid }, { uid: testUid, note: ' { - assert.ifError(err); - assert.equal(notes[0].note, '<svg/onload=alert(document.location);//'); - assert.equal(notes[0].uid, adminUid); - assert.equal(notes[1].note, 'this is a test user'); - assert(notes[0].timestamp); - done(); - }); - }); - - it('should get unread count 0 for guest', async () => { - const count = await socketUser.getUnreadCount({ uid: 0 }); - assert.strictEqual(count, 0); - }); - - it('should get unread count for user', async () => { - const count = await socketUser.getUnreadCount({ uid: testUid }); - assert.strictEqual(count, 4); - }); - - it('should get unread chat count 0 for guest', async () => { - const count = await socketUser.getUnreadChatCount({ uid: 0 }); - assert.strictEqual(count, 0); - }); - - it('should get unread chat count for user', async () => { - const count = await socketUser.getUnreadChatCount({ uid: testUid }); - assert.strictEqual(count, 0); - }); - - it('should get unread counts 0 for guest', async () => { - const counts = await socketUser.getUnreadCounts({ uid: 0 }); - assert.deepStrictEqual(counts, {}); - }); - - it('should get unread counts for user', async () => { - const counts = await socketUser.getUnreadCounts({ uid: testUid }); - assert.deepStrictEqual(counts, { - unreadChatCount: 0, - unreadCounts: { - '': 4, - new: 4, - unreplied: 4, - watched: 0, - }, - unreadNewTopicCount: 4, - unreadNotificationCount: 0, - unreadTopicCount: 4, - unreadUnrepliedTopicCount: 4, - unreadWatchedTopicCount: 0, - }); - }); - - it('should get user data by uid', async () => { - const userData = await socketUser.getUserByUID({ uid: testUid }, testUid); - assert.strictEqual(userData.uid, testUid); - }); - - it('should get user data by username', async () => { - const userData = await socketUser.getUserByUsername({ uid: testUid }, 'John Smith'); - assert.strictEqual(userData.uid, testUid); - }); - - it('should get user data by email', async () => { - const userData = await socketUser.getUserByEmail({ uid: testUid }, 'john@example.com'); - assert.strictEqual(userData.uid, testUid); - }); - - it('should check/consent gdpr status', async () => { - const consent = await socketUser.gdpr.check({ uid: testUid }, { uid: testUid }); - assert(!consent); - await socketUser.gdpr.consent({ uid: testUid }); - const consentAfter = await socketUser.gdpr.check({ uid: testUid }, { uid: testUid }); - assert(consentAfter); - }); - }); - - describe('approval queue', () => { - let oldRegistrationApprovalType; - let adminUid; - before((done) => { - oldRegistrationApprovalType = meta.config.registrationApprovalType; - meta.config.registrationApprovalType = 'admin-approval'; - User.create({ username: 'admin', password: '123456' }, (err, uid) => { - assert.ifError(err); - adminUid = uid; - groups.join('administrators', uid, done); - }); - }); - - after((done) => { - meta.config.registrationApprovalType = oldRegistrationApprovalType; - done(); - }); - - it('should add user to approval queue', (done) => { - helpers.registerUser({ - username: 'rejectme', - password: '123456', - 'password-confirm': '123456', - 'account-type': 'student', - email: ''); - global.window = dom.window; - global.document = dom.window.document; - global.jQuery = require('jquery'); - global.$ = global.jQuery; - const { $ } = global; - - const utils = require('../public/src/utils'); - - // https://github.com/jprichardson/string.js/blob/master/test/string.test.js - it('should decode HTML entities', (done) => { - assert.strictEqual( - utils.decodeHTMLEntities('Ken Thompson & Dennis Ritchie'), - 'Ken Thompson & Dennis Ritchie' - ); - assert.strictEqual( - utils.decodeHTMLEntities('3 < 4'), - '3 < 4' - ); - assert.strictEqual( - utils.decodeHTMLEntities('http://'), - 'http://' - ); - done(); - }); - it('should strip HTML tags', (done) => { - assert.strictEqual(utils.stripHTMLTags('

    just some text

    '), 'just some text'); - assert.strictEqual(utils.stripHTMLTags('

    just some text

    ', ['p']), 'just some text'); - assert.strictEqual(utils.stripHTMLTags('just some text', ['i']), 'just some text'); - assert.strictEqual(utils.stripHTMLTags('just some
    text
    ', ['i', 'div']), 'just some text'); - done(); - }); - - it('should preserve case if requested', (done) => { - assert.strictEqual(slugify('UPPER CASE', true), 'UPPER-CASE'); - done(); - }); - - it('should work if a number is passed in', (done) => { - assert.strictEqual(slugify(12345), '12345'); - done(); - }); - - describe('username validation', () => { - it('accepts latin-1 characters', () => { - const username = "John\"'-. Doeäâèéë1234"; - assert(utils.isUserNameValid(username), 'invalid username'); - }); - - it('rejects empty string', () => { - const username = ''; - assert.equal(utils.isUserNameValid(username), false, 'accepted as valid username'); - }); - - it('should reject new lines', () => { - assert.equal(utils.isUserNameValid('myusername\r\n'), false); - }); - - it('should reject new lines', () => { - assert.equal(utils.isUserNameValid('myusername\n'), false); - }); - - it('should reject tabs', () => { - assert.equal(utils.isUserNameValid('myusername\t'), false); - }); - - it('accepts square brackets', () => { - const username = '[best clan] julian'; - assert(utils.isUserNameValid(username), 'invalid username'); - }); - - it('accepts regular username', () => { - assert(utils.isUserNameValid('myusername'), 'invalid username'); - }); - - it('accepts quotes', () => { - assert(utils.isUserNameValid('baris "the best" usakli'), 'invalid username'); - }); - }); - - describe('email validation', () => { - it('accepts sample address', () => { - const email = 'sample@example.com'; - assert(utils.isEmailValid(email), 'invalid email'); - }); - it('rejects empty address', () => { - const email = ''; - assert.equal(utils.isEmailValid(email), false, 'accepted as valid email'); - }); - }); - - describe('UUID generation', () => { - it('return unique random value every time', () => { - delete require.cache[require.resolve('../src/utils')]; - const { generateUUID } = require('../src/utils'); - const uuid1 = generateUUID(); - const uuid2 = generateUUID(); - assert.notEqual(uuid1, uuid2, 'matches'); - }); - }); - - describe('cleanUpTag', () => { - it('should cleanUp a tag', (done) => { - const cleanedTag = utils.cleanUpTag(',/#!$^*;TaG1:{}=_`<>\'"~()?|'); - assert.equal(cleanedTag, 'tag1'); - done(); - }); - - it('should return empty string for invalid tags', (done) => { - assert.strictEqual(utils.cleanUpTag(undefined), ''); - assert.strictEqual(utils.cleanUpTag(null), ''); - assert.strictEqual(utils.cleanUpTag(false), ''); - assert.strictEqual(utils.cleanUpTag(1), ''); - assert.strictEqual(utils.cleanUpTag(0), ''); - done(); - }); - }); - - it('should remove punctuation', (done) => { - const removed = utils.removePunctuation('some text with , ! punctuation inside "'); - assert.equal(removed, 'some text with punctuation inside '); - done(); - }); - - it('should return true if string has language key', (done) => { - assert.equal(utils.hasLanguageKey('some text [[topic:title]] and [[user:reputaiton]]'), true); - done(); - }); - - it('should return false if string does not have language key', (done) => { - assert.equal(utils.hasLanguageKey('some text with no language keys'), false); - done(); - }); - - it('should shallow merge two objects', (done) => { - const a = { foo: 1, cat1: 'ginger' }; - const b = { baz: 2, cat2: 'phoebe' }; - const obj = utils.merge(a, b); - assert.strictEqual(obj.foo, 1); - assert.strictEqual(obj.baz, 2); - assert.strictEqual(obj.cat1, 'ginger'); - assert.strictEqual(obj.cat2, 'phoebe'); - done(); - }); - - it('should return the file extesion', (done) => { - assert.equal(utils.fileExtension('/path/to/some/file.png'), 'png'); - done(); - }); - - it('should return file mime type', (done) => { - assert.equal(utils.fileMimeType('/path/to/some/file.png'), 'image/png'); - done(); - }); - - it('should check if url is relative', (done) => { - assert.equal(utils.isRelativeUrl('/topic/1/slug'), true); - done(); - }); - - it('should check if url is relative', (done) => { - assert.equal(utils.isRelativeUrl('https://nodebb.org'), false); - done(); - }); - - it('should make number human readable', (done) => { - assert.equal(utils.makeNumberHumanReadable('1000'), '1.0k'); - done(); - }); - - it('should make number human readable', (done) => { - assert.equal(utils.makeNumberHumanReadable('1100000'), '1.1m'); - done(); - }); - - it('should make number human readable', (done) => { - assert.equal(utils.makeNumberHumanReadable('100'), '100'); - done(); - }); - - it('should make number human readable', (done) => { - assert.equal(utils.makeNumberHumanReadable(null), null); - done(); - }); - - it('should make numbers human readable on elements', (done) => { - const el = $('
    '); - utils.makeNumbersHumanReadable(el); - assert.equal(el.html(), '100.0k'); - done(); - }); - - it('should add commas to numbers', (done) => { - assert.equal(utils.addCommas('100'), '100'); - done(); - }); - - it('should add commas to numbers', (done) => { - assert.equal(utils.addCommas('1000'), '1,000'); - done(); - }); - - it('should add commas to numbers', (done) => { - assert.equal(utils.addCommas('1000000'), '1,000,000'); - done(); - }); - - it('should add commas to elements', (done) => { - const el = $('
    1000000
    '); - utils.addCommasToNumbers(el); - assert.equal(el.html(), '1,000,000'); - done(); - }); - - it('should return passed in value if invalid', (done) => { - // eslint-disable-next-line no-loss-of-precision - const bigInt = -111111111111111111; - const result = utils.toISOString(bigInt); - assert.equal(bigInt, result); - done(); - }); - - it('should return false if browser is not android', (done) => { - global.navigator = { - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.96 Safari/537.36', - }; - assert.equal(utils.isAndroidBrowser(), false); - done(); - }); - - it('should return true if browser is android', (done) => { - global.navigator = { - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Android /58.0.3029.96 Safari/537.36', - }; - assert.equal(utils.isAndroidBrowser(), true); - done(); - }); - - it('should return false if not touch device', (done) => { - assert(!utils.isTouchDevice()); - done(); - }); - - it('should check if element is in viewport', (done) => { - const el = $('
    some text
    '); - assert(utils.isElementInViewport(el)); - done(); - }); - - it('should get empty object for url params', (done) => { - const params = utils.params(); - assert.equal(Object.keys(params), 0); - done(); - }); - - it('should get url params', (done) => { - const params = utils.params({ url: 'http://nodebb.org?foo=1&bar=test&herp=2' }); - assert.strictEqual(params.foo, 1); - assert.strictEqual(params.bar, 'test'); - assert.strictEqual(params.herp, 2); - done(); - }); - - it('should get url params as arrays', (done) => { - const params = utils.params({ url: 'http://nodebb.org?foo=1&bar=test&herp[]=2&herp[]=3' }); - assert.strictEqual(params.foo, 1); - assert.strictEqual(params.bar, 'test'); - assert.deepStrictEqual(params.herp, [2, 3]); - done(); - }); - - it('should get a single param', (done) => { - assert.equal(utils.param('somekey'), undefined); - done(); - }); - - it('should get the full URLSearchParams object', async () => { - const params = utils.params({ url: 'http://nodebb.org?foo=1&bar=test&herp[]=2&herp[]=3', full: true }); - assert(params instanceof URLSearchParams); - assert.strictEqual(params.get('foo'), '1'); - assert.strictEqual(params.get('bar'), 'test'); - assert.strictEqual(params.get('herp[]'), '2'); - }); - - describe('toType', () => { - it('should return param as is if not string', (done) => { - assert.equal(123, utils.toType(123)); - done(); - }); - - it('should convert return string numbers as numbers', (done) => { - assert.equal(123, utils.toType('123')); - done(); - }); - - it('should convert string "false" to boolean false', (done) => { - assert.strictEqual(false, utils.toType('false')); - done(); - }); - - it('should convert string "true" to boolean true', (done) => { - assert.strictEqual(true, utils.toType('true')); - done(); - }); - - it('should parse json', (done) => { - const data = utils.toType('{"a":"1"}'); - assert.equal(data.a, '1'); - done(); - }); - - it('should return string as is if its not json,true,false or number', (done) => { - const regularStr = 'this is a regular string'; - assert.equal(regularStr, utils.toType(regularStr)); - done(); - }); - }); - - describe('utils.props', () => { - const data = {}; - - it('should set nested data', (done) => { - assert.equal(10, utils.props(data, 'a.b.c.d', 10)); - done(); - }); - - it('should return nested object', (done) => { - const obj = utils.props(data, 'a.b.c'); - assert.equal(obj.d, 10); - done(); - }); - - it('should returned undefined without throwing', (done) => { - assert.equal(utils.props(data, 'a.b.c.foo.bar'), undefined); - done(); - }); - - it('should return undefined if second param is null', (done) => { - assert.equal(utils.props(undefined, null), undefined); - done(); - }); - }); - - describe('isInternalURI', () => { - const target = { host: '', protocol: 'https' }; - const reference = { host: '', protocol: 'https' }; - - it('should return true if they match', (done) => { - assert(utils.isInternalURI(target, reference, '')); - done(); - }); - - it('should return true if they match', (done) => { - target.host = 'nodebb.org'; - reference.host = 'nodebb.org'; - assert(utils.isInternalURI(target, reference, '')); - done(); - }); - - it('should handle relative path', (done) => { - target.pathname = '/forum'; - assert(utils.isInternalURI(target, reference, '/forum')); - done(); - }); - - it('should return false if they do not match', (done) => { - target.pathname = ''; - reference.host = 'designcreateplay.com'; - assert(!utils.isInternalURI(target, reference)); - done(); - }); - }); - - it('escape html', (done) => { - const escaped = utils.escapeHTML('&<>'); - assert.equal(escaped, '&<>'); - done(); - }); - - it('should escape regex chars', (done) => { - const escaped = utils.escapeRegexChars('some text {}'); - assert.equal(escaped, 'some\\ text\\ \\{\\}'); - done(); - }); - - it('should get hours array', (done) => { - const currentHour = new Date().getHours(); - const hours = utils.getHoursArray(); - let index = hours.length - 1; - for (let i = currentHour, ii = currentHour - 24; i > ii; i -= 1) { - const hour = i < 0 ? 24 + i : i; - assert.equal(hours[index], `${hour}:00`); - index -= 1; - } - done(); - }); - - it('should get days array', (done) => { - const currentDay = new Date(Date.now()).getTime(); - const days = utils.getDaysArray(); - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - let index = 0; - for (let x = 29; x >= 0; x -= 1) { - const tmpDate = new Date(currentDay - (1000 * 60 * 60 * 24 * x)); - assert.equal(`${months[tmpDate.getMonth()]} ${tmpDate.getDate()}`, days[index]); - index += 1; - } - done(); - }); - - it('`utils.rtrim` should remove trailing space', (done) => { - assert.strictEqual(utils.rtrim(' thing '), ' thing'); - assert.strictEqual(utils.rtrim('\tthing\t\t'), '\tthing'); - assert.strictEqual(utils.rtrim('\t thing \t'), '\t thing'); - done(); - }); - - it('should profile function', (done) => { - const st = process.hrtime(); - setTimeout(() => { - process.profile('it took', st); - done(); - }, 500); - }); - - it('should return object with data', async () => { - const user = require('../src/user'); - const uid1 = await user.create({ username: 'promise1' }); - const uid2 = await user.create({ username: 'promise2' }); - const result = await utils.promiseParallel({ - user1: user.getUserData(uid1), - user2: user.getUserData(uid2), - }); - assert(result.hasOwnProperty('user1') && result.hasOwnProperty('user2')); - assert.strictEqual(result.user1.uid, uid1); - assert.strictEqual(result.user2.uid, uid2); - }); + // https://gist.github.com/robballou/9ee108758dc5e0e2d028 + // create some jsdom magic to allow jQuery to work + const dom = new JSDOM(''); + global.window = dom.window; + global.document = dom.window.document; + global.jQuery = require('jquery'); + global.$ = global.jQuery; + const {$} = global; + + const utils = require('../public/src/utils'); + + // https://github.com/jprichardson/string.js/blob/master/test/string.test.js + it('should decode HTML entities', done => { + assert.strictEqual( + utils.decodeHTMLEntities('Ken Thompson & Dennis Ritchie'), + 'Ken Thompson & Dennis Ritchie', + ); + assert.strictEqual( + utils.decodeHTMLEntities('3 < 4'), + '3 < 4', + ); + assert.strictEqual( + utils.decodeHTMLEntities('http://'), + 'http://', + ); + done(); + }); + it('should strip HTML tags', done => { + assert.strictEqual(utils.stripHTMLTags('

    just some text

    '), 'just some text'); + assert.strictEqual(utils.stripHTMLTags('

    just some text

    ', ['p']), 'just some text'); + assert.strictEqual(utils.stripHTMLTags('just some text', ['i']), 'just some text'); + assert.strictEqual(utils.stripHTMLTags('just some
    text
    ', ['i', 'div']), 'just some text'); + done(); + }); + + it('should preserve case if requested', done => { + assert.strictEqual(slugify('UPPER CASE', true), 'UPPER-CASE'); + done(); + }); + + it('should work if a number is passed in', done => { + assert.strictEqual(slugify(12_345), '12345'); + done(); + }); + + describe('username validation', () => { + it('accepts latin-1 characters', () => { + const username = 'John"\'-. Doeäâèéë1234'; + assert(utils.isUserNameValid(username), 'invalid username'); + }); + + it('rejects empty string', () => { + const username = ''; + assert.equal(utils.isUserNameValid(username), false, 'accepted as valid username'); + }); + + it('should reject new lines', () => { + assert.equal(utils.isUserNameValid('myusername\r\n'), false); + }); + + it('should reject new lines', () => { + assert.equal(utils.isUserNameValid('myusername\n'), false); + }); + + it('should reject tabs', () => { + assert.equal(utils.isUserNameValid('myusername\t'), false); + }); + + it('accepts square brackets', () => { + const username = '[best clan] julian'; + assert(utils.isUserNameValid(username), 'invalid username'); + }); + + it('accepts regular username', () => { + assert(utils.isUserNameValid('myusername'), 'invalid username'); + }); + + it('accepts quotes', () => { + assert(utils.isUserNameValid('baris "the best" usakli'), 'invalid username'); + }); + }); + + describe('email validation', () => { + it('accepts sample address', () => { + const email = 'sample@example.com'; + assert(utils.isEmailValid(email), 'invalid email'); + }); + it('rejects empty address', () => { + const email = ''; + assert.equal(utils.isEmailValid(email), false, 'accepted as valid email'); + }); + }); + + describe('UUID generation', () => { + it('return unique random value every time', () => { + delete require.cache[require.resolve('../src/utils')]; + const {generateUUID} = require('../src/utils'); + const uuid1 = generateUUID(); + const uuid2 = generateUUID(); + assert.notEqual(uuid1, uuid2, 'matches'); + }); + }); + + describe('cleanUpTag', () => { + it('should cleanUp a tag', done => { + const cleanedTag = utils.cleanUpTag(',/#!$^*;TaG1:{}=_`<>\'"~()?|'); + assert.equal(cleanedTag, 'tag1'); + done(); + }); + + it('should return empty string for invalid tags', done => { + assert.strictEqual(utils.cleanUpTag(undefined), ''); + assert.strictEqual(utils.cleanUpTag(null), ''); + assert.strictEqual(utils.cleanUpTag(false), ''); + assert.strictEqual(utils.cleanUpTag(1), ''); + assert.strictEqual(utils.cleanUpTag(0), ''); + done(); + }); + }); + + it('should remove punctuation', done => { + const removed = utils.removePunctuation('some text with , ! punctuation inside "'); + assert.equal(removed, 'some text with punctuation inside '); + done(); + }); + + it('should return true if string has language key', done => { + assert.equal(utils.hasLanguageKey('some text [[topic:title]] and [[user:reputaiton]]'), true); + done(); + }); + + it('should return false if string does not have language key', done => { + assert.equal(utils.hasLanguageKey('some text with no language keys'), false); + done(); + }); + + it('should shallow merge two objects', done => { + const a = {foo: 1, cat1: 'ginger'}; + const b = {baz: 2, cat2: 'phoebe'}; + const object = utils.merge(a, b); + assert.strictEqual(object.foo, 1); + assert.strictEqual(object.baz, 2); + assert.strictEqual(object.cat1, 'ginger'); + assert.strictEqual(object.cat2, 'phoebe'); + done(); + }); + + it('should return the file extesion', done => { + assert.equal(utils.fileExtension('/path/to/some/file.png'), 'png'); + done(); + }); + + it('should return file mime type', done => { + assert.equal(utils.fileMimeType('/path/to/some/file.png'), 'image/png'); + done(); + }); + + it('should check if url is relative', done => { + assert.equal(utils.isRelativeUrl('/topic/1/slug'), true); + done(); + }); + + it('should check if url is relative', done => { + assert.equal(utils.isRelativeUrl('https://nodebb.org'), false); + done(); + }); + + it('should make number human readable', done => { + assert.equal(utils.makeNumberHumanReadable('1000'), '1.0k'); + done(); + }); + + it('should make number human readable', done => { + assert.equal(utils.makeNumberHumanReadable('1100000'), '1.1m'); + done(); + }); + + it('should make number human readable', done => { + assert.equal(utils.makeNumberHumanReadable('100'), '100'); + done(); + }); + + it('should make number human readable', done => { + assert.equal(utils.makeNumberHumanReadable(null), null); + done(); + }); + + it('should make numbers human readable on elements', done => { + const element = $('
    '); + utils.makeNumbersHumanReadable(element); + assert.equal(element.html(), '100.0k'); + done(); + }); + + it('should add commas to numbers', done => { + assert.equal(utils.addCommas('100'), '100'); + done(); + }); + + it('should add commas to numbers', done => { + assert.equal(utils.addCommas('1000'), '1,000'); + done(); + }); + + it('should add commas to numbers', done => { + assert.equal(utils.addCommas('1000000'), '1,000,000'); + done(); + }); + + it('should add commas to elements', done => { + const element = $('
    1000000
    '); + utils.addCommasToNumbers(element); + assert.equal(element.html(), '1,000,000'); + done(); + }); + + it('should return passed in value if invalid', done => { + // eslint-disable-next-line no-loss-of-precision + const bigInt = -111_111_111_111_111_111; + const result = utils.toISOString(bigInt); + assert.equal(bigInt, result); + done(); + }); + + it('should return false if browser is not android', done => { + global.navigator = { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.96 Safari/537.36', + }; + assert.equal(utils.isAndroidBrowser(), false); + done(); + }); + + it('should return true if browser is android', done => { + global.navigator = { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Android /58.0.3029.96 Safari/537.36', + }; + assert.equal(utils.isAndroidBrowser(), true); + done(); + }); + + it('should return false if not touch device', done => { + assert(!utils.isTouchDevice()); + done(); + }); + + it('should check if element is in viewport', done => { + const element = $('
    some text
    '); + assert(utils.isElementInViewport(element)); + done(); + }); + + it('should get empty object for url params', done => { + const parameters = utils.params(); + assert.equal(Object.keys(parameters), 0); + done(); + }); + + it('should get url params', done => { + const parameters = utils.params({url: 'http://nodebb.org?foo=1&bar=test&herp=2'}); + assert.strictEqual(parameters.foo, 1); + assert.strictEqual(parameters.bar, 'test'); + assert.strictEqual(parameters.herp, 2); + done(); + }); + + it('should get url params as arrays', done => { + const parameters = utils.params({url: 'http://nodebb.org?foo=1&bar=test&herp[]=2&herp[]=3'}); + assert.strictEqual(parameters.foo, 1); + assert.strictEqual(parameters.bar, 'test'); + assert.deepStrictEqual(parameters.herp, [2, 3]); + done(); + }); + + it('should get a single param', done => { + assert.equal(utils.param('somekey'), undefined); + done(); + }); + + it('should get the full URLSearchParams object', async () => { + const parameters = utils.params({url: 'http://nodebb.org?foo=1&bar=test&herp[]=2&herp[]=3', full: true}); + assert(parameters instanceof URLSearchParams); + assert.strictEqual(parameters.get('foo'), '1'); + assert.strictEqual(parameters.get('bar'), 'test'); + assert.strictEqual(parameters.get('herp[]'), '2'); + }); + + describe('toType', () => { + it('should return param as is if not string', done => { + assert.equal(123, utils.toType(123)); + done(); + }); + + it('should convert return string numbers as numbers', done => { + assert.equal(123, utils.toType('123')); + done(); + }); + + it('should convert string "false" to boolean false', done => { + assert.strictEqual(false, utils.toType('false')); + done(); + }); + + it('should convert string "true" to boolean true', done => { + assert.strictEqual(true, utils.toType('true')); + done(); + }); + + it('should parse json', done => { + const data = utils.toType('{"a":"1"}'); + assert.equal(data.a, '1'); + done(); + }); + + it('should return string as is if its not json,true,false or number', done => { + const regularString = 'this is a regular string'; + assert.equal(regularString, utils.toType(regularString)); + done(); + }); + }); + + describe('utils.props', () => { + const data = {}; + + it('should set nested data', done => { + assert.equal(10, utils.props(data, 'a.b.c.d', 10)); + done(); + }); + + it('should return nested object', done => { + const object = utils.props(data, 'a.b.c'); + assert.equal(object.d, 10); + done(); + }); + + it('should returned undefined without throwing', done => { + assert.equal(utils.props(data, 'a.b.c.foo.bar'), undefined); + done(); + }); + + it('should return undefined if second param is null', done => { + assert.equal(utils.props(undefined, null), undefined); + done(); + }); + }); + + describe('isInternalURI', () => { + const target = {host: '', protocol: 'https'}; + const reference = {host: '', protocol: 'https'}; + + it('should return true if they match', done => { + assert(utils.isInternalURI(target, reference, '')); + done(); + }); + + it('should return true if they match', done => { + target.host = 'nodebb.org'; + reference.host = 'nodebb.org'; + assert(utils.isInternalURI(target, reference, '')); + done(); + }); + + it('should handle relative path', done => { + target.pathname = '/forum'; + assert(utils.isInternalURI(target, reference, '/forum')); + done(); + }); + + it('should return false if they do not match', done => { + target.pathname = ''; + reference.host = 'designcreateplay.com'; + assert(!utils.isInternalURI(target, reference)); + done(); + }); + }); + + it('escape html', done => { + const escaped = utils.escapeHTML('&<>'); + assert.equal(escaped, '&<>'); + done(); + }); + + it('should escape regex chars', done => { + const escaped = utils.escapeRegexChars('some text {}'); + assert.equal(escaped, 'some\\ text\\ \\{\\}'); + done(); + }); + + it('should get hours array', done => { + const currentHour = new Date().getHours(); + const hours = utils.getHoursArray(); + let index = hours.length - 1; + for (let i = currentHour, ii = currentHour - 24; i > ii; i -= 1) { + const hour = i < 0 ? 24 + i : i; + assert.equal(hours[index], `${hour}:00`); + index -= 1; + } + + done(); + }); + + it('should get days array', done => { + const currentDay = new Date(Date.now()).getTime(); + const days = utils.getDaysArray(); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + let index = 0; + for (let x = 29; x >= 0; x -= 1) { + const temporaryDate = new Date(currentDay - (1000 * 60 * 60 * 24 * x)); + assert.equal(`${months[temporaryDate.getMonth()]} ${temporaryDate.getDate()}`, days[index]); + index += 1; + } + + done(); + }); + + it('`utils.rtrim` should remove trailing space', done => { + assert.strictEqual(utils.rtrim(' thing '), ' thing'); + assert.strictEqual(utils.rtrim('\tthing\t\t'), '\tthing'); + assert.strictEqual(utils.rtrim('\t thing \t'), '\t thing'); + done(); + }); + + it('should profile function', done => { + const st = process.hrtime(); + setTimeout(() => { + process.profile('it took', st); + done(); + }, 500); + }); + + it('should return object with data', async () => { + const user = require('../src/user'); + const uid1 = await user.create({username: 'promise1'}); + const uid2 = await user.create({username: 'promise2'}); + const result = await utils.promiseParallel({ + user1: user.getUserData(uid1), + user2: user.getUserData(uid2), + }); + assert(result.hasOwnProperty('user1') && result.hasOwnProperty('user2')); + assert.strictEqual(result.user1.uid, uid1); + assert.strictEqual(result.user2.uid, uid2); + }); }); diff --git a/webpack.common.js b/webpack.common.js index 00ffffb..63a7442 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -1,72 +1,71 @@ 'use strict'; -const path = require('path'); -const url = require('url'); +const path = require('node:path'); +const url = require('node:url'); const nconf = require('nconf'); - const activePlugins = require('./build/active_plugins.json'); let relativePath = nconf.get('relative_path'); if (relativePath === undefined) { - nconf.file({ - file: path.resolve(__dirname, nconf.any(['config', 'CONFIG']) || 'config.json'), - }); + nconf.file({ + file: path.resolve(__dirname, nconf.any(['config', 'CONFIG']) || 'config.json'), + }); - const urlObject = url.parse(nconf.get('url')); - relativePath = urlObject.pathname !== '/' ? urlObject.pathname.replace(/\/+$/, '') : ''; + const urlObject = url.parse(nconf.get('url')); + relativePath = urlObject.pathname === '/' ? '' : urlObject.pathname.replace(/\/+$/, ''); } module.exports = { - plugins: [], - entry: { - nodebb: './build/public/src/client.js', - admin: './build/public/src/admin/admin.js', - }, - output: { - filename: '[name].min.js', - chunkFilename: '[name].[contenthash].min.js', - path: path.resolve(__dirname, 'build/public'), - publicPath: `${relativePath}/assets/`, - clean: { - keep(asset) { - return asset === 'installer.min.js' || - !asset.endsWith('.min.js'); - }, - }, - }, - watchOptions: { - poll: 500, - aggregateTimeout: 250, - }, - resolve: { - symlinks: false, - modules: [ - 'build/public/src/modules', - 'build/public/src', - 'node_modules', - ...activePlugins.map(p => `node_modules/${p}/node_modules`), - ], - extensions: ['.js', '.json', '.wasm', '.mjs'], - alias: { - assets: path.resolve(__dirname, 'build/public'), - forum: path.resolve(__dirname, 'build/public/src/client'), - admin: path.resolve(__dirname, 'build/public/src/admin'), - vendor: path.resolve(__dirname, 'public/vendor'), - benchpress: path.resolve(__dirname, 'node_modules/benchpressjs'), - Chart: path.resolve(__dirname, 'node_modules/chart.js'), - Sortable: path.resolve(__dirname, 'node_modules/sortablejs'), - cropper: path.resolve(__dirname, 'node_modules/cropperjs'), - 'jquery-ui/widgets': path.resolve(__dirname, 'node_modules/jquery-ui/ui/widgets'), - 'ace/ace': path.resolve(__dirname, 'build/public/src/modules/ace-editor.js'), - }, - }, - module: { - rules: [ - { - test: /\.(ts|tsx)$/, - exclude: /node_modules/, - loader: 'ignore-loader', - }, - ], - }, + plugins: [], + entry: { + nodebb: './build/public/src/client.js', + admin: './build/public/src/admin/admin.js', + }, + output: { + filename: '[name].min.js', + chunkFilename: '[name].[contenthash].min.js', + path: path.resolve(__dirname, 'build/public'), + publicPath: `${relativePath}/assets/`, + clean: { + keep(asset) { + return asset === 'installer.min.js' + || !asset.endsWith('.min.js'); + }, + }, + }, + watchOptions: { + poll: 500, + aggregateTimeout: 250, + }, + resolve: { + symlinks: false, + modules: [ + 'build/public/src/modules', + 'build/public/src', + 'node_modules', + ...activePlugins.map(p => `node_modules/${p}/node_modules`), + ], + extensions: ['.js', '.json', '.wasm', '.mjs'], + alias: { + assets: path.resolve(__dirname, 'build/public'), + forum: path.resolve(__dirname, 'build/public/src/client'), + admin: path.resolve(__dirname, 'build/public/src/admin'), + vendor: path.resolve(__dirname, 'public/vendor'), + benchpress: path.resolve(__dirname, 'node_modules/benchpressjs'), + Chart: path.resolve(__dirname, 'node_modules/chart.js'), + Sortable: path.resolve(__dirname, 'node_modules/sortablejs'), + cropper: path.resolve(__dirname, 'node_modules/cropperjs'), + 'jquery-ui/widgets': path.resolve(__dirname, 'node_modules/jquery-ui/ui/widgets'), + 'ace/ace': path.resolve(__dirname, 'build/public/src/modules/ace-editor.js'), + }, + }, + module: { + rules: [ + { + test: /\.(ts|tsx)$/, + exclude: /node_modules/, + loader: 'ignore-loader', + }, + ], + }, }; diff --git a/webpack.dev.js b/webpack.dev.js index 28edaf5..abc5960 100644 --- a/webpack.dev.js +++ b/webpack.dev.js @@ -1,9 +1,9 @@ 'use strict'; -const { merge } = require('webpack-merge'); +const {merge} = require('webpack-merge'); const common = require('./webpack.common'); module.exports = merge(common, { - mode: 'development', - // devtool: 'inline-source-map', + mode: 'development', + // Devtool: 'inline-source-map', }); diff --git a/webpack.installer.js b/webpack.installer.js index a0c61d3..26a7eb0 100644 --- a/webpack.installer.js +++ b/webpack.installer.js @@ -1,24 +1,24 @@ -// webpack config for webinstaller +// Webpack config for webinstaller 'use strict'; -const path = require('path'); +const path = require('node:path'); module.exports = { - mode: 'production', - entry: { - installer: './public/src/installer/install.js', - }, - output: { - filename: '[name].min.js', - path: path.resolve(__dirname, 'build/public'), - publicPath: `/assets/`, - }, - resolve: { - symlinks: false, - modules: [ - 'public/src', - 'node_modules', - ], - }, + mode: 'production', + entry: { + installer: './public/src/installer/install.js', + }, + output: { + filename: '[name].min.js', + path: path.resolve(__dirname, 'build/public'), + publicPath: '/assets/', + }, + resolve: { + symlinks: false, + modules: [ + 'public/src', + 'node_modules', + ], + }, }; diff --git a/webpack.prod.js b/webpack.prod.js index b10f291..e6baed9 100644 --- a/webpack.prod.js +++ b/webpack.prod.js @@ -1,23 +1,22 @@ 'use strict'; -const { merge } = require('webpack-merge'); +const {merge} = require('webpack-merge'); const TerserPlugin = require('terser-webpack-plugin'); const ProgressPlugin = require('progress-webpack-plugin'); - const common = require('./webpack.common'); module.exports = merge(common, { - mode: 'production', - plugins: [ - new ProgressPlugin(true), - ], - optimization: { - minimize: true, - minimizer: [ - new TerserPlugin({ - minify: TerserPlugin.esbuildMinify, - terserOptions: {}, - }), - ], - }, + mode: 'production', + plugins: [ + new ProgressPlugin(true), + ], + optimization: { + minimize: true, + minimizer: [ + new TerserPlugin({ + minify: TerserPlugin.esbuildMinify, + terserOptions: {}, + }), + ], + }, });