From e229a7dae931dde368d40e226506b55098544459 Mon Sep 17 00:00:00 2001 From: Evelyn Zheng Date: Thu, 24 Oct 2024 00:36:35 -0400 Subject: [PATCH] fix lint --- lib/admin/search.js | 122 ++-- lib/admin/versions.js | 57 +- lib/als.js | 5 +- lib/analytics.js | 372 +++++----- lib/api/admin.js | 59 +- lib/api/categories.js | 369 +++++----- lib/api/chats.js | 667 ++++++++--------- lib/api/files.js | 7 +- lib/api/flags.js | 165 ++--- lib/api/groups.js | 541 +++++++------- lib/api/helpers.js | 197 ++--- lib/api/index.js | 26 +- lib/api/posts.js | 671 ++++++++--------- lib/api/search.js | 295 ++++---- lib/api/tags.js | 7 +- lib/api/topics.js | 414 +++++------ lib/api/users.js | 1211 ++++++++++++++++--------------- lib/api/utils.js | 147 ++-- lib/batch.js | 127 ++-- lib/cache.js | 9 +- lib/cache/lru.js | 222 +++--- lib/cache/ttl.js | 206 +++--- lib/cacheCreate.js | 2 +- lib/categories/activeusers.js | 19 +- lib/categories/create.js | 355 ++++----- lib/categories/data.js | 143 ++-- lib/categories/delete.js | 97 +-- lib/categories/index.js | 556 +++++++------- lib/categories/recentreplies.js | 303 ++++---- lib/categories/search.js | 125 ++-- lib/categories/topics.js | 459 ++++++------ lib/categories/unread.js | 67 +- lib/categories/update.js | 203 +++--- lib/categories/watch.js | 77 +- lib/cli/colors.js | 197 ++--- 35 files changed, 4267 insertions(+), 4232 deletions(-) diff --git a/lib/admin/search.js b/lib/admin/search.js index bc4803462a..e32e50ae59 100644 --- a/lib/admin/search.js +++ b/lib/admin/search.js @@ -7,90 +7,92 @@ const nconf = require('nconf'); const winston = require('winston'); const file = require('../file'); const { - Translator + Translator, } = require('../translator'); + function filterDirectories(directories) { - return directories.map(dir => dir.replace(/^.*(admin.*?).tpl$/, '$1').split(path.sep).join('/')).filter(dir => !dir.endsWith('.js') && !dir.includes('/partials/') && /\/.*\//.test(dir) && !/manage\/(category|group|category-analytics)$/.test(dir)); + return directories.map(dir => dir.replace(/^.*(admin.*?).tpl$/, '$1').split(path.sep).join('/')).filter(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) { - return sanitizeHTML(html, { - allowedTags: [], - allowedAttributes: [] - }); + return sanitizeHTML(html, { + allowedTags: [], + allowedAttributes: [], + }); } function simplify(translations) { - return translations.replace(/(?:\{{1,2}[^}]*?\}{1,2})/g, '').replace(/(?:[ \t]*[\n\r]+[ \t]*)+/g, '\n').replace(/[\t ]+/g, ' '); + return translations.replace(/(?:\{{1,2}[^}]*?\}{1,2})/g, '').replace(/(?:[ \t]*[\n\r]+[ \t]*)+/g, '\n').replace(/[\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(str => str[0].toUpperCase() + str.slice(1)).join(' > ') + .replace(/[^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: namespace, + translations: translations, + title: title, + }; } async function fallback(namespace) { - if (fallbackCache[namespace]) { - return fallbackCache[namespace]; - } - const params = await initFallback(namespace); - fallbackCache[namespace] = params; - return params; + if (fallbackCache[namespace]) { + return fallbackCache[namespace]; + } + const params = await initFallback(namespace); + fallbackCache[namespace] = params; + return params; } async function initDict(language) { - const namespaces = await getAdminNamespaces(); - return await Promise.all(namespaces.map(ns => buildNamespace(language, ns))); + 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); - } - 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) { + return await fallback(namespace); + } + 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 cache = {}; async function getDictionary(language) { - if (cache[language]) { - return cache[language]; - } - const params = await initDict(language); - cache[language] = params; - return params; + if (cache[language]) { + return cache[language]; + } + const params = await initDict(language); + cache[language] = params; + return params; } module.exports.getDictionary = getDictionary; module.exports.filterDirectories = filterDirectories; module.exports.simplify = simplify; module.exports.sanitize = sanitize; -require('../promisify')(module.exports); \ No newline at end of file +require('../promisify')(module.exports); diff --git a/lib/admin/versions.js b/lib/admin/versions.js index 30ce8983af..8817aaa7b6 100644 --- a/lib/admin/versions.js +++ b/lib/admin/versions.js @@ -2,39 +2,40 @@ const request = require('../request'); const meta = require('../meta'); + let versionCache = ''; let versionCacheLastModified = ''; const isPrerelease = /^v?\d+\.\d+\.\d+-.+$/; const latestReleaseUrl = 'https://api.github.com/repos/NodeBB/NodeBB/releases/latest'; async function getLatestVersion() { - 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; - } - const { - body: latestRelease, - response - } = await request.get(latestReleaseUrl, { - headers: headers, - timeout: 2000 - }); - if (response.statusCode === 304) { - return versionCache; - } - if (response.statusCode !== 200) { - throw new Error(response.statusText); - } - if (!latestRelease || !latestRelease.tag_name) { - throw new Error('[[error:cant-get-latest-release]]'); - } - const tagName = latestRelease.tag_name.replace(/^v/, ''); - versionCache = tagName; - versionCacheLastModified = response.headers['last-modified']; - return 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; + } + const { + body: latestRelease, + response, + } = await request.get(latestReleaseUrl, { + headers: headers, + timeout: 2000, + }); + if (response.statusCode === 304) { + return versionCache; + } + if (response.statusCode !== 200) { + throw new Error(response.statusText); + } + if (!latestRelease || !latestRelease.tag_name) { + throw new Error('[[error:cant-get-latest-release]]'); + } + const tagName = latestRelease.tag_name.replace(/^v/, ''); + versionCache = tagName; + versionCacheLastModified = response.headers['last-modified']; + return versionCache; } exports.getLatestVersion = getLatestVersion; exports.isPrerelease = isPrerelease; -require('../promisify')(exports); \ No newline at end of file +require('../promisify')(exports); diff --git a/lib/als.js b/lib/als.js index afe0932753..7219d36e3f 100644 --- a/lib/als.js +++ b/lib/als.js @@ -1,7 +1,8 @@ 'use strict'; const { - AsyncLocalStorage + AsyncLocalStorage, } = require('async_hooks'); + const asyncLocalStorage = new AsyncLocalStorage(); -module.exports = asyncLocalStorage; \ No newline at end of file +module.exports = asyncLocalStorage; diff --git a/lib/analytics.js b/lib/analytics.js index 95c8c3f420..2a5bf93a7c 100644 --- a/lib/analytics.js +++ b/lib/analytics.js @@ -6,6 +6,7 @@ const nconf = require('nconf'); const crypto = require('crypto'); const util = require('util'); const _ = require('lodash'); + const sleep = util.promisify(setTimeout); const db = require('./database'); const utils = require('./utils'); @@ -13,223 +14,224 @@ const plugins = require('./plugins'); const meta = require('./meta'); const pubsub = require('./pubsub'); const cacheCreate = require('./cache/lru'); + 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); 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: 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, + }); + 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]; - } - } + 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]; + } + } } Analytics.increment = function (keys, callback) { - keys = Array.isArray(keys) ? keys : [keys]; - plugins.hooks.fire('action:analytics.increment', { - keys: keys - }); - keys.forEach(key => { - local.counters[key] = local.counters[key] || 0; - local.counters[key] += 1; - }); - if (typeof callback === 'function') { - callback(); - } + keys = Array.isArray(keys) ? keys : [keys]; + plugins.hooks.fire('action:analytics.increment', { + keys: keys, + }); + keys.forEach((key) => { + local.counters[key] = local.counters[key] || 0; + local.counters[key] += 1; + }); + 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) { - 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) { + 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 = []; - 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)); - } - 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 dbQueue = []; + const incrByBulk = []; + 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)); + } + 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}`); + } }; Analytics.getHourlyStatsForSet = async function (set, hour, numHours) { - if (!set.startsWith('analytics:')) { - set = `analytics:${set}`; - } - const terms = {}; - const hoursArr = []; - 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); - } - const counts = await db.sortedSetScores(set, hoursArr); - hoursArr.forEach((term, index) => { - terms[term] = parseInt(counts[index], 10) || 0; - }); - const termsArr = []; - hoursArr.reverse(); - hoursArr.forEach(hour => { - termsArr.push(terms[hour]); - }); - return termsArr; + if (!set.startsWith('analytics:')) { + set = `analytics:${set}`; + } + const terms = {}; + const hoursArr = []; + 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); + } + const counts = await db.sortedSetScores(set, hoursArr); + hoursArr.forEach((term, index) => { + terms[term] = parseInt(counts[index], 10) || 0; + }); + const termsArr = []; + hoursArr.reverse(); + hoursArr.forEach((hour) => { + termsArr.push(terms[hour]); + }); + return termsArr; }; Analytics.getDailyStatsForSet = async function (set, day, numDays) { - if (!set.startsWith('analytics:')) { - set = `analytics:${set}`; - } - day = new Date(day); - day.setDate(day.getDate() + 1); - day.setHours(0, 0, 0, 0); - async function getHourlyStats(hour) { - const dayData = await Analytics.getHourlyStatsForSet(set, hour, 24); - return dayData.reduce((cur, next) => cur + next); - } - const hours = []; - while (numDays > 0) { - hours.push(day.getTime() - 1000 * 60 * 60 * 24 * (numDays - 1)); - numDays -= 1; - } - return await Promise.all(hours.map(getHourlyStats)); + if (!set.startsWith('analytics:')) { + set = `analytics:${set}`; + } + day = new Date(day); + day.setDate(day.getDate() + 1); + day.setHours(0, 0, 0, 0); + async function getHourlyStats(hour) { + const dayData = await Analytics.getHourlyStatsForSet(set, hour, 24); + return dayData.reduce((cur, next) => cur + next); + } + const hours = []; + while (numDays > 0) { + hours.push(day.getTime() - 1000 * 60 * 60 * 24 * (numDays - 1)); + numDays -= 1; + } + return await Promise.all(hours.map(getHourlyStats)); }; 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, cur) => sum + cur, 0), + thirty: thirty.reduce((sum, cur) => sum + cur, 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); \ No newline at end of file +require('./promisify')(Analytics); diff --git a/lib/api/admin.js b/lib/api/admin.js index 759691d712..237eaf5950 100644 --- a/lib/api/admin.js +++ b/lib/api/admin.js @@ -4,42 +4,43 @@ const meta = require('../meta'); const analytics = require('../analytics'); const privileges = require('../privileges'); const groups = require('../groups'); + const adminApi = module.exports; adminApi.updateSetting = async (caller, { - setting, - value + setting, + value, }) => { - const ok = await privileges.admin.can('admin:settings', caller.uid); - if (!ok) { - throw new Error('[[error:no-privileges]]'); - } - await meta.configs.set(setting, value); + const ok = await privileges.admin.can('admin:settings', caller.uid); + if (!ok) { + throw new Error('[[error:no-privileges]]'); + } + await meta.configs.set(setting, value); }; adminApi.getAnalyticsKeys = async () => { - const keys = await analytics.getKeys(); - return keys.sort((a, b) => a < b ? -1 : 1); + const keys = await analytics.getKeys(); + return keys.sort((a, b) => (a < b ? -1 : 1)); }; adminApi.getAnalyticsData = async (caller, { - set, - until, - amount, - units + set, + until, + amount, + units, }) => { - if (!amount) { - if (units === 'days') { - amount = 30; - } else { - amount = 24; - } - } - const getStats = units === 'days' ? analytics.getDailyStatsForSet : analytics.getHourlyStatsForSet; - return await getStats(`analytics:${set}`, parseInt(until, 10) || Date.now(), amount); + if (!amount) { + if (units === 'days') { + amount = 30; + } else { + amount = 24; + } + } + const getStats = units === 'days' ? analytics.getDailyStatsForSet : analytics.getHourlyStatsForSet; + return await getStats(`analytics:${set}`, parseInt(until, 10) || Date.now(), amount); }; adminApi.listGroups = async () => { - const payload = await groups.getNonPrivilegeGroups('groups:createtime', 0, -1, { - ephemeral: false - }); - return { - groups: payload - }; -}; \ No newline at end of file + const payload = await groups.getNonPrivilegeGroups('groups:createtime', 0, -1, { + ephemeral: false, + }); + return { + groups: payload, + }; +}; diff --git a/lib/api/categories.js b/lib/api/categories.js index 4a35ad5f1e..11719efa87 100644 --- a/lib/api/categories.js +++ b/lib/api/categories.js @@ -7,220 +7,221 @@ const events = require('../events'); const user = require('../user'); const groups = require('../groups'); const privileges = require('../privileges'); + const categoriesAPI = module.exports; const hasAdminPrivilege = async (uid, privilege = 'categories') => { - 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]]'); + } }; -categoriesAPI.list = async caller => { - async function getCategories() { - const cids = await categories.getCidsByPrivilege('categories:cid', caller.uid, 'find'); - return await categories.getCategoriesData(cids); - } - const [isAdmin, categoriesData] = await Promise.all([user.isAdministrator(caller.uid), getCategories()]); - return { - categories: categoriesData.filter(category => category && (!category.disabled || isAdmin)) - }; +categoriesAPI.list = async (caller) => { + async function getCategories() { + const cids = await categories.getCidsByPrivilege('categories:cid', caller.uid, 'find'); + return await categories.getCategoriesData(cids); + } + const [isAdmin, categoriesData] = await Promise.all([user.isAdministrator(caller.uid), getCategories()]); + return { + categories: categoriesData.filter(category => category && (!category.disabled || isAdmin)), + }; }; 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) { - await hasAdminPrivilege(caller.uid); - const response = await categories.create(data); - const categoryObjs = await categories.getCategories([response.cid]); - return categoryObjs[0]; + await hasAdminPrivilege(caller.uid); + const response = await categories.create(data); + const categoryObjs = await categories.getCategories([response.cid]); + return categoryObjs[0]; }; categoriesAPI.update = async function (caller, data) { - await hasAdminPrivilege(caller.uid); - if (!data) { - throw new Error('[[error:invalid-data]]'); - } - const { - cid, - values - } = data; - const payload = {}; - payload[cid] = values; - await categories.update(payload); + await hasAdminPrivilege(caller.uid); + if (!data) { + throw new Error('[[error:invalid-data]]'); + } + const { + cid, + values, + } = data; + const payload = {}; + payload[cid] = values; + await categories.update(payload); }; categoriesAPI.delete = async function (caller, { - cid + cid, }) { - await hasAdminPrivilege(caller.uid); - const name = await categories.getCategoryField(cid, 'name'); - await categories.purge(cid, caller.uid); - await events.log({ - type: 'category-purge', - uid: caller.uid, - ip: caller.ip, - cid: cid, - name: name - }); + await hasAdminPrivilege(caller.uid); + const name = await categories.getCategoryField(cid, 'name'); + await categories.purge(cid, caller.uid); + await events.log({ + type: 'category-purge', + uid: caller.uid, + ip: caller.ip, + cid: cid, + name: name, + }); }; categoriesAPI.getTopicCount = async (caller, { - cid + cid, }) => { - const allowed = await privileges.categories.can('find', cid, caller.uid); - if (!allowed) { - throw new Error('[[error:no-privileges]]'); - } - const count = await categories.getCategoryField(cid, 'topic_count'); - return { - count - }; + const allowed = await privileges.categories.can('find', cid, caller.uid); + if (!allowed) { + throw new Error('[[error:no-privileges]]'); + } + const count = await categories.getCategoryField(cid, 'topic_count'); + return { + count, + }; }; categoriesAPI.getPosts = async (caller, { - cid + cid, }) => await categories.getRecentReplies(cid, caller.uid, 0, 4); categoriesAPI.getChildren = async (caller, { - cid, - start + cid, + start, }) => { - if (!start || start < 0) { - start = 0; - } - start = parseInt(start, 10); - const allowed = await privileges.categories.can('read', cid, caller.uid); - if (!allowed) { - throw new Error('[[error:no-privileges]]'); - } - const category = await categories.getCategoryData(cid); - await categories.getChildrenTree(category, caller.uid); - const allCategories = []; - categories.flattenCategories(allCategories, category.children); - await categories.getRecentTopicReplies(allCategories, caller.uid); - const payload = category.children.slice(start, start + category.subCategoriesPerPage); - return { - categories: payload - }; + if (!start || start < 0) { + start = 0; + } + start = parseInt(start, 10); + const allowed = await privileges.categories.can('read', cid, caller.uid); + if (!allowed) { + throw new Error('[[error:no-privileges]]'); + } + const category = await categories.getCategoryData(cid); + await categories.getChildrenTree(category, caller.uid); + const allCategories = []; + categories.flattenCategories(allCategories, category.children); + await categories.getRecentTopicReplies(allCategories, caller.uid); + const payload = category.children.slice(start, start + category.subCategoriesPerPage); + return { + categories: payload, + }; }; categoriesAPI.getTopics = async (caller, data) => { - data.query = data.query || {}; - const [userPrivileges, settings, targetUid] = await Promise.all([privileges.categories.get(data.cid, caller.uid), user.getSettings(caller.uid), user.getUidByUserslug(data.query.author)]); - if (!userPrivileges.read) { - throw new Error('[[error:no-privileges]]'); - } - const infScrollTopicsPerPage = 20; - const sort = data.sort || data.categoryTopicSort || meta.config.categoryTopicSort || 'recently_replied'; - let start = Math.max(0, parseInt(data.after || 0, 10)); - if (parseInt(data.direction, 10) === -1) { - start -= infScrollTopicsPerPage; - } - let stop = start + infScrollTopicsPerPage - 1; - start = Math.max(0, start); - stop = Math.max(0, stop); - const result = await categories.getCategoryTopics({ - uid: caller.uid, - cid: data.cid, - start, - stop, - sort, - settings, - query: data.query, - tag: data.query.tag, - targetUid - }); - categories.modifyTopicsByPrivilege(result.topics, userPrivileges); - return { - ...result, - privileges: userPrivileges - }; + data.query = data.query || {}; + const [userPrivileges, settings, targetUid] = await Promise.all([privileges.categories.get(data.cid, caller.uid), user.getSettings(caller.uid), user.getUidByUserslug(data.query.author)]); + if (!userPrivileges.read) { + throw new Error('[[error:no-privileges]]'); + } + const infScrollTopicsPerPage = 20; + const sort = data.sort || data.categoryTopicSort || meta.config.categoryTopicSort || 'recently_replied'; + let start = Math.max(0, parseInt(data.after || 0, 10)); + if (parseInt(data.direction, 10) === -1) { + start -= infScrollTopicsPerPage; + } + let stop = start + infScrollTopicsPerPage - 1; + start = Math.max(0, start); + stop = Math.max(0, stop); + const result = await categories.getCategoryTopics({ + uid: caller.uid, + cid: data.cid, + start, + stop, + sort, + settings, + query: data.query, + tag: data.query.tag, + targetUid, + }); + categories.modifyTopicsByPrivilege(result.topics, userPrivileges); + return { + ...result, + privileges: userPrivileges, + }; }; categoriesAPI.setWatchState = async (caller, { - cid, - state, - uid + cid, + state, + uid, }) => { - let targetUid = caller.uid; - const cids = Array.isArray(cid) ? cid.map(cid => parseInt(cid, 10)) : [parseInt(cid, 10)]; - if (uid) { - targetUid = uid; - } - await user.isAdminOrGlobalModOrSelf(caller.uid, targetUid); - const allCids = await categories.getAllCidsFromSet('categories:cid'); - const categoryData = await categories.getCategoriesFields(allCids, ['cid', 'parentCid']); - let cat; - do { - cat = categoryData.find(c => !cids.includes(c.cid) && cids.includes(c.parentCid)); - if (cat) { - cids.push(cat.cid); - } - } while (cat); - await user.setCategoryWatchState(targetUid, cids, state); - await topics.pushUnreadCount(targetUid); - return { - cids - }; + let targetUid = caller.uid; + const cids = Array.isArray(cid) ? cid.map(cid => parseInt(cid, 10)) : [parseInt(cid, 10)]; + if (uid) { + targetUid = uid; + } + await user.isAdminOrGlobalModOrSelf(caller.uid, targetUid); + const allCids = await categories.getAllCidsFromSet('categories:cid'); + const categoryData = await categories.getCategoriesFields(allCids, ['cid', 'parentCid']); + let cat; + do { + cat = categoryData.find(c => !cids.includes(c.cid) && cids.includes(c.parentCid)); + if (cat) { + cids.push(cat.cid); + } + } while (cat); + await user.setCategoryWatchState(targetUid, cids, state); + await topics.pushUnreadCount(targetUid); + return { + cids, + }; }; categoriesAPI.getPrivileges = async (caller, { - cid + cid, }) => { - await hasAdminPrivilege(caller.uid, 'privileges'); - 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); - } - return responsePayload; + await hasAdminPrivilege(caller.uid, 'privileges'); + 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); + } + return responsePayload; }; categoriesAPI.setPrivilege = async (caller, data) => { - await hasAdminPrivilege(caller.uid, 'privileges'); - 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 - }); + await hasAdminPrivilege(caller.uid, 'privileges'); + 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, + }); }; categoriesAPI.setModerator = async (caller, { - cid, - member, - set + cid, + member, + set, }) => { - await hasAdminPrivilege(caller.uid, 'admins-mods'); - const privilegeList = await privileges.categories.getUserPrivilegeList(); - await categoriesAPI.setPrivilege(caller, { - cid, - privilege: privilegeList, - member, - set - }); -}; \ No newline at end of file + await hasAdminPrivilege(caller.uid, 'admins-mods'); + const privilegeList = await privileges.categories.getUserPrivilegeList(); + await categoriesAPI.setPrivilege(caller, { + cid, + privilege: privilegeList, + member, + set, + }); +}; diff --git a/lib/api/chats.js b/lib/api/chats.js index 26944e115c..fbc3900dff 100644 --- a/lib/api/chats.js +++ b/lib/api/chats.js @@ -12,403 +12,404 @@ const plugins = require('../plugins'); const utils = require('../utils'); const websockets = require('../socket.io'); const socketHelpers = require('../socket.io/helpers'); + const chatsAPI = module.exports; async function rateLimitExceeded(caller, field) { - const session = caller.request ? caller.request.session : caller.session; - const now = Date.now(); - const [isPrivileged, reputation] = await Promise.all([user.isPrivileged(caller.uid), user.getUserField(caller.uid, 'reputation')]); - const newbie = !isPrivileged && meta.config.newbieReputationThreshold > reputation; - const delay = newbie ? meta.config.newbieChatMessageDelay : meta.config.chatMessageDelay; - session[field] = session[field] || 0; - if (now - session[field] < delay) { - return true; - } - session[field] = now; - return false; + const session = caller.request ? caller.request.session : caller.session; + const now = Date.now(); + const [isPrivileged, reputation] = await Promise.all([user.isPrivileged(caller.uid), user.getUserField(caller.uid, 'reputation')]); + const newbie = !isPrivileged && meta.config.newbieReputationThreshold > reputation; + const delay = newbie ? meta.config.newbieChatMessageDelay : meta.config.chatMessageDelay; + session[field] = session[field] || 0; + if (now - session[field] < delay) { + return true; + } + session[field] = now; + return false; } chatsAPI.list = async (caller, { - uid = caller.uid, - start, - stop, - page, - perPage + uid = caller.uid, + start, + stop, + page, + perPage, } = {}) => { - if ((!utils.isNumber(start) || !utils.isNumber(stop)) && !utils.isNumber(page)) { - throw new Error('[[error:invalid-data]]'); - } - if (!start && !stop && page) { - winston.warn('[api/chats] Sending `page` and `perPage` to .list() is deprecated in favour of `start` and `stop`. The deprecated parameters will be removed in v4.'); - start = Math.max(0, page - 1) * perPage; - stop = start + perPage - 1; - } - return await messaging.getRecentChats(caller.uid, uid || caller.uid, start, stop); + if ((!utils.isNumber(start) || !utils.isNumber(stop)) && !utils.isNumber(page)) { + throw new Error('[[error:invalid-data]]'); + } + if (!start && !stop && page) { + winston.warn('[api/chats] Sending `page` and `perPage` to .list() is deprecated in favour of `start` and `stop`. The deprecated parameters will be removed in v4.'); + start = Math.max(0, page - 1) * perPage; + stop = start + perPage - 1; + } + return await messaging.getRecentChats(caller.uid, uid || caller.uid, start, stop); }; chatsAPI.create = async function (caller, data) { - if (await rateLimitExceeded(caller, 'lastChatRoomCreateTime')) { - throw new Error('[[error:too-many-messages]]'); - } - if (!data) { - throw new Error('[[error:invalid-data]]'); - } - const isPublic = data.type === 'public'; - const isAdmin = await user.isAdministrator(caller.uid); - if (isPublic && !isAdmin) { - throw new Error('[[error:no-privileges]]'); - } - if (!data.uids || !Array.isArray(data.uids)) { - throw new Error(`[[error:wrong-parameter-type, uids, ${typeof data.uids}, Array]]`); - } - if (!isPublic && !data.uids.length) { - throw new Error('[[error:no-users-selected]]'); - } - if (isPublic && (!Array.isArray(data.groups) || !data.groups.length)) { - throw new Error('[[error:no-groups-selected]]'); - } - data.notificationSetting = isPublic ? messaging.notificationSettings.ATMENTION : messaging.notificationSettings.ALLMESSAGES; - await Promise.all(data.uids.map(uid => messaging.canMessageUser(caller.uid, uid))); - const roomId = await messaging.newRoom(caller.uid, data); - return await messaging.getRoomData(roomId); + if (await rateLimitExceeded(caller, 'lastChatRoomCreateTime')) { + throw new Error('[[error:too-many-messages]]'); + } + if (!data) { + throw new Error('[[error:invalid-data]]'); + } + const isPublic = data.type === 'public'; + const isAdmin = await user.isAdministrator(caller.uid); + if (isPublic && !isAdmin) { + throw new Error('[[error:no-privileges]]'); + } + if (!data.uids || !Array.isArray(data.uids)) { + throw new Error(`[[error:wrong-parameter-type, uids, ${typeof data.uids}, Array]]`); + } + if (!isPublic && !data.uids.length) { + throw new Error('[[error:no-users-selected]]'); + } + if (isPublic && (!Array.isArray(data.groups) || !data.groups.length)) { + throw new Error('[[error:no-groups-selected]]'); + } + data.notificationSetting = isPublic ? messaging.notificationSettings.ATMENTION : messaging.notificationSettings.ALLMESSAGES; + await Promise.all(data.uids.map(uid => messaging.canMessageUser(caller.uid, uid))); + const roomId = await messaging.newRoom(caller.uid, data); + return await messaging.getRoomData(roomId); }; -chatsAPI.getUnread = async caller => { - const count = await messaging.getUnreadCount(caller.uid); - return { - count - }; +chatsAPI.getUnread = async (caller) => { + const count = await messaging.getUnreadCount(caller.uid); + return { + count, + }; }; chatsAPI.sortPublicRooms = async (caller, { - roomIds, - scores + roomIds, + scores, }) => { - [roomIds, scores].forEach(arr => { - if (!Array.isArray(arr) || !arr.every(value => isFinite(value))) { - throw new Error('[[error:invalid-data]]'); - } - }); - const isAdmin = await user.isAdministrator(caller.uid); - if (!isAdmin) { - throw new Error('[[error:no-privileges]]'); - } - await db.sortedSetAdd(`chat:rooms:public:order`, scores, roomIds); - require('../cache').del(`chat:rooms:public:order:all`); + [roomIds, scores].forEach((arr) => { + if (!Array.isArray(arr) || !arr.every(value => isFinite(value))) { + throw new Error('[[error:invalid-data]]'); + } + }); + const isAdmin = await user.isAdministrator(caller.uid); + if (!isAdmin) { + throw new Error('[[error:no-privileges]]'); + } + await db.sortedSetAdd(`chat:rooms:public:order`, scores, roomIds); + require('../cache').del(`chat:rooms:public:order:all`); }; chatsAPI.get = async (caller, { - uid, - roomId + uid, + roomId, }) => await messaging.loadRoom(caller.uid, { - uid, - roomId + uid, + roomId, }); chatsAPI.post = async (caller, data) => { - if (await rateLimitExceeded(caller, 'lastChatMessageTime')) { - throw new Error('[[error:too-many-messages]]'); - } - if (!data || !data.roomId || !caller.uid) { - throw new Error('[[error:invalid-data]]'); - } - ({ - 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, - toMid: data.toMid, - timestamp: Date.now(), - ip: caller.ip - }); - messaging.notifyUsersInRoom(caller.uid, data.roomId, message); - user.updateOnlineUsers(caller.uid); - return message; + if (await rateLimitExceeded(caller, 'lastChatMessageTime')) { + throw new Error('[[error:too-many-messages]]'); + } + if (!data || !data.roomId || !caller.uid) { + throw new Error('[[error:invalid-data]]'); + } + ({ + 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, + toMid: data.toMid, + timestamp: Date.now(), + ip: caller.ip, + }); + messaging.notifyUsersInRoom(caller.uid, data.roomId, message); + user.updateOnlineUsers(caller.uid); + return message; }; chatsAPI.update = async (caller, data) => { - if (!data || !data.roomId) { - throw new Error('[[error:invalid-data]]'); - } - if (data.hasOwnProperty('name')) { - if (!data.name && data.name !== '') { - throw new Error('[[error:invalid-data]]'); - } - await messaging.renameRoom(caller.uid, data.roomId, data.name); - } - const [roomData, isAdmin] = await Promise.all([messaging.getRoomData(data.roomId), user.isAdministrator(caller.uid)]); - if (!roomData) { - throw new Error('[[error:invalid-data]]'); - } - if (data.hasOwnProperty('groups')) { - if (roomData.public && isAdmin) { - await db.setObjectField(`chat:room:${data.roomId}`, 'groups', JSON.stringify(data.groups)); - } - } - if (data.hasOwnProperty('notificationSetting') && isAdmin) { - await db.setObjectField(`chat:room:${data.roomId}`, 'notificationSetting', data.notificationSetting); - } - const loadedRoom = await messaging.loadRoom(caller.uid, { - roomId: data.roomId - }); - if (data.hasOwnProperty('name')) { - const ioRoom = require('../socket.io').in(`chat_room_${data.roomId}`); - if (ioRoom) { - ioRoom.emit('event:chats.roomRename', { - roomId: data.roomId, - newName: validator.escape(String(data.name)), - chatWithMessage: loadedRoom.chatWithMessage - }); - } - } - return loadedRoom; + if (!data || !data.roomId) { + throw new Error('[[error:invalid-data]]'); + } + if (data.hasOwnProperty('name')) { + if (!data.name && data.name !== '') { + throw new Error('[[error:invalid-data]]'); + } + await messaging.renameRoom(caller.uid, data.roomId, data.name); + } + const [roomData, isAdmin] = await Promise.all([messaging.getRoomData(data.roomId), user.isAdministrator(caller.uid)]); + if (!roomData) { + throw new Error('[[error:invalid-data]]'); + } + if (data.hasOwnProperty('groups')) { + if (roomData.public && isAdmin) { + await db.setObjectField(`chat:room:${data.roomId}`, 'groups', JSON.stringify(data.groups)); + } + } + if (data.hasOwnProperty('notificationSetting') && isAdmin) { + await db.setObjectField(`chat:room:${data.roomId}`, 'notificationSetting', data.notificationSetting); + } + const loadedRoom = await messaging.loadRoom(caller.uid, { + roomId: data.roomId, + }); + if (data.hasOwnProperty('name')) { + const ioRoom = require('../socket.io').in(`chat_room_${data.roomId}`); + if (ioRoom) { + ioRoom.emit('event:chats.roomRename', { + roomId: data.roomId, + newName: validator.escape(String(data.name)), + chatWithMessage: loadedRoom.chatWithMessage, + }); + } + } + return loadedRoom; }; chatsAPI.rename = async (caller, data) => { - if (!data || !data.roomId || !data.name) { - throw new Error('[[error:invalid-data]]'); - } - return await chatsAPI.update(caller, data); + if (!data || !data.roomId || !data.name) { + throw new Error('[[error:invalid-data]]'); + } + return await chatsAPI.update(caller, data); }; chatsAPI.mark = async (caller, data) => { - if (!caller.uid || !data || !data.roomId) { - throw new Error('[[error:invalid-data]]'); - } - const { - roomId, - state - } = data; - if (state) { - await messaging.markUnread([caller.uid], roomId); - } else { - await messaging.markRead(caller.uid, roomId); - socketHelpers.emitToUids('event:chats.markedAsRead', { - roomId: roomId - }, [caller.uid]); - const nids = await user.notifications.getUnreadByField(caller.uid, 'roomId', [roomId]); - await notifications.markReadMultiple(nids, caller.uid); - user.notifications.pushCount(caller.uid); - } - socketHelpers.emitToUids('event:chats.mark', { - roomId, - state - }, [caller.uid]); - messaging.pushUnreadCount(caller.uid); + if (!caller.uid || !data || !data.roomId) { + throw new Error('[[error:invalid-data]]'); + } + const { + roomId, + state, + } = data; + if (state) { + await messaging.markUnread([caller.uid], roomId); + } else { + await messaging.markRead(caller.uid, roomId); + socketHelpers.emitToUids('event:chats.markedAsRead', { + roomId: roomId, + }, [caller.uid]); + const nids = await user.notifications.getUnreadByField(caller.uid, 'roomId', [roomId]); + await notifications.markReadMultiple(nids, caller.uid); + user.notifications.pushCount(caller.uid); + } + socketHelpers.emitToUids('event:chats.mark', { + roomId, + state, + }, [caller.uid]); + messaging.pushUnreadCount(caller.uid); }; chatsAPI.watch = async (caller, { - roomId, - state + roomId, + state, }) => { - const inRoom = await messaging.isUserInRoom(caller.uid, roomId); - if (!inRoom) { - throw new Error('[[error:no-privileges]]'); - } - await messaging.setUserNotificationSetting(caller.uid, roomId, state); + const inRoom = await messaging.isUserInRoom(caller.uid, roomId); + if (!inRoom) { + throw new Error('[[error:no-privileges]]'); + } + await messaging.setUserNotificationSetting(caller.uid, roomId, state); }; chatsAPI.toggleTyping = async (caller, { - roomId, - typing + roomId, + typing, }) => { - if (!utils.isNumber(roomId) || typeof typing !== 'boolean') { - throw new Error('[[error:invalid-data]]'); - } - const [isInRoom, username] = await Promise.all([messaging.isUserInRoom(caller.uid, roomId), user.getUserField(caller.uid, 'username')]); - if (!isInRoom) { - throw new Error('[[error:no-privileges]]'); - } - websockets.in(`chat_room_${roomId}`).emit('event:chats.typing', { - uid: caller.uid, - roomId, - typing, - username - }); + if (!utils.isNumber(roomId) || typeof typing !== 'boolean') { + throw new Error('[[error:invalid-data]]'); + } + const [isInRoom, username] = await Promise.all([messaging.isUserInRoom(caller.uid, roomId), user.getUserField(caller.uid, 'username')]); + if (!isInRoom) { + throw new Error('[[error:no-privileges]]'); + } + websockets.in(`chat_room_${roomId}`).emit('event:chats.typing', { + uid: caller.uid, + roomId, + typing, + username, + }); }; chatsAPI.users = async (caller, data) => { - const start = data.hasOwnProperty('start') ? data.start : 0; - const stop = start + 39; - const io = require('../socket.io'); - const [isOwner, isUserInRoom, users, isAdmin, onlineUids] = await Promise.all([messaging.isRoomOwner(caller.uid, data.roomId), messaging.isUserInRoom(caller.uid, data.roomId), messaging.getUsersInRoomFromSet(`chat:room:${data.roomId}:uids:online`, data.roomId, start, stop, true), user.isAdministrator(caller.uid), io.getUidsInRoom(`chat_room_${data.roomId}`)]); - if (!isUserInRoom) { - throw new Error('[[error:no-privileges]]'); - } - users.forEach(user => { - const isSelf = parseInt(user.uid, 10) === parseInt(caller.uid, 10); - user.canKick = isOwner && !isSelf; - user.canToggleOwner = (isAdmin || isOwner) && !isSelf; - user.online = parseInt(user.uid, 10) === parseInt(caller.uid, 10) || onlineUids.includes(String(user.uid)); - }); - return { - users - }; + const start = data.hasOwnProperty('start') ? data.start : 0; + const stop = start + 39; + const io = require('../socket.io'); + const [isOwner, isUserInRoom, users, isAdmin, onlineUids] = await Promise.all([messaging.isRoomOwner(caller.uid, data.roomId), messaging.isUserInRoom(caller.uid, data.roomId), messaging.getUsersInRoomFromSet(`chat:room:${data.roomId}:uids:online`, data.roomId, start, stop, true), user.isAdministrator(caller.uid), io.getUidsInRoom(`chat_room_${data.roomId}`)]); + if (!isUserInRoom) { + throw new Error('[[error:no-privileges]]'); + } + users.forEach((user) => { + const isSelf = parseInt(user.uid, 10) === parseInt(caller.uid, 10); + user.canKick = isOwner && !isSelf; + user.canToggleOwner = (isAdmin || isOwner) && !isSelf; + user.online = parseInt(user.uid, 10) === parseInt(caller.uid, 10) || onlineUids.includes(String(user.uid)); + }); + return { + users, + }; }; chatsAPI.invite = async (caller, data) => { - if (!data || !data.roomId || !Array.isArray(data.uids)) { - throw new Error('[[error:invalid-data]]'); - } - const roomData = await messaging.getRoomData(data.roomId); - if (!roomData) { - throw new Error('[[error:invalid-data]]'); - } - const userCount = await messaging.getUserCountInRoom(data.roomId); - const maxUsers = meta.config.maximumUsersInChatRoom; - if (!roomData.public && 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(uid => messaging.canMessageUser(caller.uid, uid))); - await messaging.addUsersToRoom(caller.uid, data.uids, data.roomId); - delete data.uids; - return chatsAPI.users(caller, data); + if (!data || !data.roomId || !Array.isArray(data.uids)) { + throw new Error('[[error:invalid-data]]'); + } + const roomData = await messaging.getRoomData(data.roomId); + if (!roomData) { + throw new Error('[[error:invalid-data]]'); + } + const userCount = await messaging.getUserCountInRoom(data.roomId); + const maxUsers = meta.config.maximumUsersInChatRoom; + if (!roomData.public && 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(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) => { - if (!data || !data.roomId) { - throw new Error('[[error:invalid-data]]'); - } - const uidsExist = await user.exists(data.uids); - if (!uidsExist.every(Boolean)) { - throw new Error('[[error:no-user]]'); - } - if (data.uids.length === 1 && parseInt(data.uids[0], 10) === caller.uid) { - await messaging.leaveRoom([caller.uid], data.roomId); - await socketHelpers.removeSocketsFromRoomByUids([caller.uid], data.roomId); - return []; - } - await messaging.removeUsersFromRoom(caller.uid, data.uids, data.roomId); - await socketHelpers.removeSocketsFromRoomByUids(data.uids, data.roomId); - delete data.uids; - return chatsAPI.users(caller, data); + if (!data || !data.roomId) { + throw new Error('[[error:invalid-data]]'); + } + const uidsExist = await user.exists(data.uids); + if (!uidsExist.every(Boolean)) { + throw new Error('[[error:no-user]]'); + } + if (data.uids.length === 1 && parseInt(data.uids[0], 10) === caller.uid) { + await messaging.leaveRoom([caller.uid], data.roomId); + await socketHelpers.removeSocketsFromRoomByUids([caller.uid], data.roomId); + return []; + } + await messaging.removeUsersFromRoom(caller.uid, data.uids, data.roomId); + await socketHelpers.removeSocketsFromRoomByUids(data.uids, data.roomId); + delete data.uids; + return chatsAPI.users(caller, data); }; chatsAPI.toggleOwner = async (caller, { - roomId, - uid, - state + roomId, + uid, + state, }) => { - const [isAdmin, inRoom, isRoomOwner] = await Promise.all([user.isAdministrator(caller.uid), messaging.isUserInRoom(caller.uid, roomId), messaging.isRoomOwner(caller.uid, roomId)]); - if (!isAdmin && (!inRoom || !isRoomOwner)) { - throw new Error('[[error:no-privileges]]'); - } - return await messaging.toggleOwner(uid, roomId, state); + const [isAdmin, inRoom, isRoomOwner] = await Promise.all([user.isAdministrator(caller.uid), messaging.isUserInRoom(caller.uid, roomId), messaging.isRoomOwner(caller.uid, roomId)]); + if (!isAdmin && (!inRoom || !isRoomOwner)) { + throw new Error('[[error:no-privileges]]'); + } + return await messaging.toggleOwner(uid, roomId, state); }; chatsAPI.listMessages = async (caller, { - uid = caller.uid, - roomId, - start = 0, - direction = null + uid = caller.uid, + roomId, + start = 0, + direction = null, } = {}) => { - if (!roomId) { - throw new Error('[[error:invalid-data]]'); - } - const count = 50; - let stop = start + count - 1; - if (direction === 1 || direction === -1) { - const msgCount = await db.getObjectField(`chat:room:${roomId}`, 'messageCount'); - start = msgCount - start; - if (direction === 1) { - start -= count + 1; - } - stop = start + count - 1; - start = Math.max(0, start); - if (stop <= -1) { - return { - messages: [] - }; - } - stop = Math.max(0, stop); - } - const messages = await messaging.getMessages({ - callerUid: caller.uid, - uid, - roomId, - start, - count: stop - start + 1 - }); - return { - messages - }; + if (!roomId) { + throw new Error('[[error:invalid-data]]'); + } + const count = 50; + let stop = start + count - 1; + if (direction === 1 || direction === -1) { + const msgCount = await db.getObjectField(`chat:room:${roomId}`, 'messageCount'); + start = msgCount - start; + if (direction === 1) { + start -= count + 1; + } + stop = start + count - 1; + start = Math.max(0, start); + if (stop <= -1) { + return { + messages: [], + }; + } + stop = Math.max(0, stop); + } + const messages = await messaging.getMessages({ + callerUid: caller.uid, + uid, + roomId, + start, + count: stop - start + 1, + }); + return { + messages, + }; }; chatsAPI.getPinnedMessages = async (caller, { - start, - roomId + start, + roomId, }) => { - start = parseInt(start, 10) || 0; - const isInRoom = await messaging.isUserInRoom(caller.uid, roomId); - if (!isInRoom) { - throw new Error('[[error:no-privileges]]'); - } - const messages = await messaging.getPinnedMessages(roomId, caller.uid, start, start + 49); - return { - messages - }; + start = parseInt(start, 10) || 0; + const isInRoom = await messaging.isUserInRoom(caller.uid, roomId); + if (!isInRoom) { + throw new Error('[[error:no-privileges]]'); + } + const messages = await messaging.getPinnedMessages(roomId, caller.uid, start, start + 49); + return { + messages, + }; }; chatsAPI.getMessage = async (caller, { - mid, - roomId + mid, + roomId, } = {}) => { - if (!mid || !roomId) { - throw new Error('[[error:invalid-data]]'); - } - const messages = await messaging.getMessagesData([mid], caller.uid, roomId, false); - return messages.pop(); + if (!mid || !roomId) { + throw new Error('[[error:invalid-data]]'); + } + const messages = await messaging.getMessagesData([mid], caller.uid, roomId, false); + return messages.pop(); }; chatsAPI.getRawMessage = async (caller, { - mid, - roomId + mid, + roomId, } = {}) => { - if (!mid || !roomId) { - throw new Error('[[error:invalid-data]]'); - } - const [isAdmin, canViewMessage, inRoom] = await Promise.all([user.isAdministrator(caller.uid), messaging.canViewMessage(mid, roomId, caller.uid), messaging.isUserInRoom(caller.uid, roomId)]); - if (!isAdmin && (!inRoom || !canViewMessage)) { - throw new Error('[[error:not-allowed]]'); - } - const content = await messaging.getMessageField(mid, 'content'); - return { - content - }; + if (!mid || !roomId) { + throw new Error('[[error:invalid-data]]'); + } + const [isAdmin, canViewMessage, inRoom] = await Promise.all([user.isAdministrator(caller.uid), messaging.canViewMessage(mid, roomId, caller.uid), messaging.isUserInRoom(caller.uid, roomId)]); + if (!isAdmin && (!inRoom || !canViewMessage)) { + throw new Error('[[error:not-allowed]]'); + } + const content = await messaging.getMessageField(mid, 'content'); + return { + content, + }; }; chatsAPI.getIpAddress = async (caller, { - mid + mid, }) => { - const allowed = await privileges.global.can('view:users:info', caller.uid); - if (!allowed) { - throw new Error('[[error:no-privileges]]'); - } - const ip = await messaging.getMessageField(mid, 'ip'); - return { - ip - }; + const allowed = await privileges.global.can('view:users:info', caller.uid); + if (!allowed) { + throw new Error('[[error:no-privileges]]'); + } + const ip = await messaging.getMessageField(mid, 'ip'); + return { + ip, + }; }; chatsAPI.editMessage = async (caller, { - mid, - roomId, - message + mid, + roomId, + message, }) => { - await messaging.canEdit(mid, caller.uid); - await messaging.editMessage(caller.uid, mid, roomId, message); + await messaging.canEdit(mid, caller.uid); + await messaging.editMessage(caller.uid, mid, roomId, message); }; chatsAPI.deleteMessage = async (caller, { - mid + mid, }) => { - await messaging.canDelete(mid, caller.uid); - await messaging.deleteMessage(mid, caller.uid); + await messaging.canDelete(mid, caller.uid); + await messaging.deleteMessage(mid, caller.uid); }; chatsAPI.restoreMessage = async (caller, { - mid + mid, }) => { - await messaging.canDelete(mid, caller.uid); - await messaging.restoreMessage(mid, caller.uid); + await messaging.canDelete(mid, caller.uid); + await messaging.restoreMessage(mid, caller.uid); }; chatsAPI.pinMessage = async (caller, { - roomId, - mid + roomId, + mid, }) => { - await messaging.canPin(roomId, caller.uid); - await messaging.pinMessage(mid, roomId); + await messaging.canPin(roomId, caller.uid); + await messaging.pinMessage(mid, roomId); }; chatsAPI.unpinMessage = async (caller, { - roomId, - mid + roomId, + mid, }) => { - await messaging.canPin(roomId, caller.uid); - await messaging.unpinMessage(mid, roomId); -}; \ No newline at end of file + await messaging.canPin(roomId, caller.uid); + await messaging.unpinMessage(mid, roomId); +}; diff --git a/lib/api/files.js b/lib/api/files.js index 80441bfd33..15cdb613e5 100644 --- a/lib/api/files.js +++ b/lib/api/files.js @@ -1,10 +1,11 @@ 'use strict'; const fs = require('fs').promises; + const filesApi = module.exports; filesApi.delete = async (_, { - path + path, }) => await fs.unlink(path); filesApi.createFolder = async (_, { - path -}) => await fs.mkdir(path); \ No newline at end of file + path, +}) => await fs.mkdir(path); diff --git a/lib/api/flags.js b/lib/api/flags.js index bc15205cc0..b82357e159 100644 --- a/lib/api/flags.js +++ b/lib/api/flags.js @@ -2,102 +2,103 @@ const user = require('../user'); 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 { - type, - id, - reason - } = data; - await flags.validate({ - uid: caller.uid, - type: type, - id: id - }); - const flagObj = await flags.create(type, id, caller.uid, reason); - flags.notify(flagObj, caller.uid); - return flagObj; + const required = ['type', 'id', 'reason']; + if (!required.every(prop => !!data[prop])) { + throw new Error('[[error:invalid-data]]'); + } + const { + type, + id, + reason, + } = data; + await flags.validate({ + uid: caller.uid, + type: type, + id: id, + }); + const flagObj = await flags.create(type, id, caller.uid, reason); + flags.notify(flagObj, caller.uid); + return flagObj; }; flagsApi.get = async (caller, { - flagId + flagId, }) => { - const isPrivileged = await user.isPrivileged(caller.uid); - if (!isPrivileged) { - throw new Error('[[error:no-privileges]]'); - } - return await flags.get(flagId); + const isPrivileged = await user.isPrivileged(caller.uid); + if (!isPrivileged) { + throw new Error('[[error:no-privileges]]'); + } + return await flags.get(flagId); }; flagsApi.update = async (caller, data) => { - const allowed = await user.isPrivileged(caller.uid); - if (!allowed) { - throw new Error('[[error:no-privileges]]'); - } - const { - flagId - } = data; - delete data.flagId; - await flags.update(flagId, caller.uid, data); - return await flags.getHistory(flagId); + const allowed = await user.isPrivileged(caller.uid); + if (!allowed) { + throw new Error('[[error:no-privileges]]'); + } + const { + flagId, + } = data; + delete data.flagId; + await flags.update(flagId, caller.uid, data); + return await flags.getHistory(flagId); }; flagsApi.delete = async (_, { - flagId + flagId, }) => await flags.purge([flagId]); flagsApi.rescind = async ({ - uid + uid, }, { - flagId + flagId, }) => { - const { - type, - targetId - } = await flags.get(flagId); - const exists = await flags.exists(type, targetId, uid); - if (!exists) { - throw new Error('[[error:no-flag]]'); - } - await flags.rescindReport(type, targetId, uid); + const { + type, + targetId, + } = await flags.get(flagId); + const exists = await flags.exists(type, targetId, uid); + if (!exists) { + throw new Error('[[error:no-flag]]'); + } + await flags.rescindReport(type, targetId, uid); }; 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) { - 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 (e) { + 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, + }; }; 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 - }; -}; \ No newline at end of file + 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, + }; +}; diff --git a/lib/api/groups.js b/lib/api/groups.js index f71e00bb64..3b83c71af2 100644 --- a/lib/api/groups.js +++ b/lib/api/groups.js @@ -8,318 +8,319 @@ const user = require('../user'); const meta = require('../meta'); const notifications = require('../notifications'); const slugify = require('../slugify'); + const groupsAPI = module.exports; groupsAPI.list = async (caller, data) => { - const groupsPerPage = 10; - const start = parseInt(data.after || 0, 10); - const stop = start + groupsPerPage - 1; - const groupData = await groups.getGroupsBySort(data.sort, start, stop); - return { - groups: groupData, - nextStart: stop + 1 - }; + const groupsPerPage = 10; + const start = parseInt(data.after || 0, 10); + const stop = start + groupsPerPage - 1; + const groupData = await groups.getGroupsBySort(data.sort, start, stop); + return { + groups: groupData, + nextStart: stop + 1, + }; }; 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); - delete data.slug; - await groups.update(groupName, data); - return await groups.getGroupData(data.name || 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); + 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: groupName, + }); }; groupsAPI.listMembers = async (caller, data) => { - const groupName = await groups.getGroupNameByGroupSlug(data.slug); - await canSearchMembers(caller.uid, groupName); - if (!(await privileges.global.can('search:users', caller.uid))) { - throw new Error('[[error:no-privileges]]'); - } - const { - query - } = data; - const after = parseInt(data.after || 0, 10); - let response; - if (query && query.length) { - response = await groups.searchMembers({ - uid: caller.uid, - query, - groupName - }); - response.nextStart = null; - } else { - response = { - users: await groups.getOwnersAndMembers(groupName, caller.uid, after, after + 19), - nextStart: after + 20, - matchCount: null, - timing: null - }; - } - return response; + const groupName = await groups.getGroupNameByGroupSlug(data.slug); + await canSearchMembers(caller.uid, groupName); + if (!(await privileges.global.can('search:users', caller.uid))) { + throw new Error('[[error:no-privileges]]'); + } + const { + query, + } = data; + const after = parseInt(data.after || 0, 10); + let response; + if (query && query.length) { + response = await groups.searchMembers({ + uid: caller.uid, + query, + groupName, + }); + response.nextStart = null; + } else { + response = { + users: await groups.getOwnersAndMembers(groupName, caller.uid, after, after + 19), + nextStart: after + 20, + matchCount: null, + timing: null, + }; + } + return response; }; 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, 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]]'); + } } 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 privileges.admin.can('admin:groups', caller.uid); - if (!isCallerAdmin && (groups.systemGroups.includes(groupName) || groups.isPrivilegeGroup(groupName))) { - throw new Error('[[error:not-allowed]]'); - } - const [groupData, userExists] = await Promise.all([groups.getGroupData(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) { - 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) { - await groups.join(groupName, data.uid); - logGroupEvent(caller, `group-${isSelf ? 'join' : 'add-member'}`, { - groupName: groupName, - targetUid: data.uid - }); - } else if (isSelf) { - await groups.requestMembership(groupName, caller.uid); - logGroupEvent(caller, 'group-request-membership', { - groupName: groupName, - targetUid: data.uid - }); - } else { - throw new Error('[[error:not-allowed]]'); - } + 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 privileges.admin.can('admin:groups', caller.uid); + if (!isCallerAdmin && (groups.systemGroups.includes(groupName) || groups.isPrivilegeGroup(groupName))) { + throw new Error('[[error:not-allowed]]'); + } + const [groupData, userExists] = await Promise.all([groups.getGroupData(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) { + 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) { + await groups.join(groupName, data.uid); + logGroupEvent(caller, `group-${isSelf ? 'join' : 'add-member'}`, { + groupName: groupName, + targetUid: data.uid, + }); + } else if (isSelf) { + await groups.requestMembership(groupName, caller.uid); + logGroupEvent(caller, 'group-request-membership', { + groupName: groupName, + targetUid: data.uid, + }); + } else { + throw new Error('[[error:not-allowed]]'); + } }; 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, isCallerOwner, userExists, isMember] = await Promise.all([groups.getGroupData(groupName), isOwner(caller, groupName, false), user.exists(data.uid), groups.isMember(data.uid, groupName)]); - if (!isMember) { - throw new Error('[[error:group-not-member]]'); - } - if (!userExists) { - throw new Error('[[error:invalid-uid]]'); - } - if (groupData.disableLeave && isSelf) { - throw new Error('[[error:group-leave-disabled]]'); - } - if (isSelf || 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-${isSelf ? 'leave' : 'kick'}`, { - 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 = 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, isCallerOwner, userExists, isMember] = await Promise.all([groups.getGroupData(groupName), isOwner(caller, groupName, false), user.exists(data.uid), groups.isMember(data.uid, groupName)]); + if (!isMember) { + throw new Error('[[error:group-not-member]]'); + } + if (!userExists) { + throw new Error('[[error:invalid-uid]]'); + } + if (groupData.disableLeave && isSelf) { + throw new Error('[[error:group-leave-disabled]]'); + } + if (isSelf || 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-${isSelf ? 'leave' : 'kick'}`, { + groupName: 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: 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, - 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, + }); }; groupsAPI.getPending = async (caller, { - slug + slug, }) => { - const groupName = await groups.getGroupNameByGroupSlug(slug); - await isOwner(caller, groupName); - return await groups.getPending(groupName); + const groupName = await groups.getGroupNameByGroupSlug(slug); + await isOwner(caller, groupName); + return await groups.getPending(groupName); }; groupsAPI.accept = async (caller, { - slug, - uid + slug, + uid, }) => { - const groupName = await groups.getGroupNameByGroupSlug(slug); - await isOwner(caller, groupName); - const isPending = await groups.isPending(uid, groupName); - if (!isPending) { - throw new Error('[[error:group-user-not-pending]]'); - } - await groups.acceptMembership(groupName, uid); - logGroupEvent(caller, 'group-accept-membership', { - groupName, - targetUid: uid - }); + const groupName = await groups.getGroupNameByGroupSlug(slug); + await isOwner(caller, groupName); + const isPending = await groups.isPending(uid, groupName); + if (!isPending) { + throw new Error('[[error:group-user-not-pending]]'); + } + await groups.acceptMembership(groupName, uid); + logGroupEvent(caller, 'group-accept-membership', { + groupName, + targetUid: uid, + }); }; groupsAPI.reject = async (caller, { - slug, - uid + slug, + uid, }) => { - const groupName = await groups.getGroupNameByGroupSlug(slug); - await isOwner(caller, groupName); - const isPending = await groups.isPending(uid, groupName); - if (!isPending) { - throw new Error('[[error:group-user-not-pending]]'); - } - await groups.rejectMembership(groupName, uid); - logGroupEvent(caller, 'group-reject-membership', { - groupName, - targetUid: uid - }); + const groupName = await groups.getGroupNameByGroupSlug(slug); + await isOwner(caller, groupName); + const isPending = await groups.isPending(uid, groupName); + if (!isPending) { + throw new Error('[[error:group-user-not-pending]]'); + } + await groups.rejectMembership(groupName, uid); + logGroupEvent(caller, 'group-reject-membership', { + groupName, + targetUid: uid, + }); }; groupsAPI.getInvites = async (caller, { - slug + slug, }) => { - const groupName = await groups.getGroupNameByGroupSlug(slug); - await isOwner(caller, groupName); - return await groups.getInvites(groupName); + const groupName = await groups.getGroupNameByGroupSlug(slug); + await isOwner(caller, groupName); + return await groups.getInvites(groupName); }; groupsAPI.issueInvite = async (caller, { - slug, - uid + slug, + uid, }) => { - const groupName = await groups.getGroupNameByGroupSlug(slug); - await isOwner(caller, groupName); - await groups.invite(groupName, uid); - logGroupEvent(caller, 'group-invite', { - groupName, - targetUid: uid - }); + const groupName = await groups.getGroupNameByGroupSlug(slug); + await isOwner(caller, groupName); + await groups.invite(groupName, uid); + logGroupEvent(caller, 'group-invite', { + groupName, + targetUid: uid, + }); }; groupsAPI.acceptInvite = async (caller, { - slug, - uid + slug, + uid, }) => { - const groupName = await groups.getGroupNameByGroupSlug(slug); - const invited = await groups.isInvited(uid, groupName); - if (caller.uid !== parseInt(uid, 10)) { - throw new Error('[[error:not-allowed]]'); - } - if (!invited) { - throw new Error('[[error:not-invited]]'); - } - await groups.acceptMembership(groupName, uid); - logGroupEvent(caller, 'group-invite-accept', { - groupName - }); + const groupName = await groups.getGroupNameByGroupSlug(slug); + const invited = await groups.isInvited(uid, groupName); + if (caller.uid !== parseInt(uid, 10)) { + throw new Error('[[error:not-allowed]]'); + } + if (!invited) { + throw new Error('[[error:not-invited]]'); + } + await groups.acceptMembership(groupName, uid); + logGroupEvent(caller, 'group-invite-accept', { + groupName, + }); }; groupsAPI.rejectInvite = async (caller, { - slug, - uid + slug, + uid, }) => { - const groupName = await groups.getGroupNameByGroupSlug(slug); - const owner = await isOwner(caller, groupName, false); - const invited = await groups.isInvited(uid, groupName); - if (!owner && caller.uid !== parseInt(uid, 10)) { - throw new Error('[[error:not-allowed]]'); - } - if (!invited) { - throw new Error('[[error:not-invited]]'); - } - await groups.rejectMembership(groupName, uid); - if (!owner) { - logGroupEvent(caller, 'group-invite-reject', { - groupName - }); - } + const groupName = await groups.getGroupNameByGroupSlug(slug); + const owner = await isOwner(caller, groupName, false); + const invited = await groups.isInvited(uid, groupName); + if (!owner && caller.uid !== parseInt(uid, 10)) { + throw new Error('[[error:not-allowed]]'); + } + if (!invited) { + throw new Error('[[error:not-invited]]'); + } + await groups.rejectMembership(groupName, uid); + if (!owner) { + logGroupEvent(caller, 'group-invite-reject', { + groupName, + }); + } }; async function isOwner(caller, groupName, throwOnFalse = true) { - 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 && throwOnFalse) { - throw new Error('[[error:no-privileges]]'); - } - return check; + 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 && throwOnFalse) { + throw new Error('[[error:no-privileges]]'); + } + return check; } function logGroupEvent(caller, event, additional) { - events.log({ - type: event, - uid: caller.uid, - ip: caller.ip, - ...additional - }); -} \ No newline at end of file + events.log({ + type: event, + uid: caller.uid, + ip: caller.ip, + ...additional, + }); +} diff --git a/lib/api/helpers.js b/lib/api/helpers.js index b9f087067f..4f8719cf3c 100644 --- a/lib/api/helpers.js +++ b/lib/api/helpers.js @@ -9,113 +9,114 @@ const plugins = require('../plugins'); 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; + data.uid = reqOrSocket.uid; + data.req = exports.buildReqObject(reqOrSocket, { + ...data, + }); + data.timestamp = Date.now(); + data.fromQueue = false; }; exports.buildReqObject = (req, payload) => { - req = req || {}; - const headers = req.headers || req.request && req.request.headers || {}; - const session = req.session || req.request && req.request.session || {}; - 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: session, - ip: req.ip, - host: host, - protocol: encrypted ? 'https' : 'http', - secure: encrypted, - url: referer, - path: referer.slice(referer.indexOf(host) + host.length), - baseUrl: req.baseUrl, - originalUrl: req.originalUrl, - headers: headers - }; + req = req || {}; + const headers = req.headers || req.request && req.request.headers || {}; + const session = req.session || req.request && req.request.session || {}; + 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: session, + ip: req.ip, + host: host, + protocol: encrypted ? 'https' : 'http', + secure: encrypted, + url: referer, + path: referer.slice(referer.indexOf(host) + host.length), + baseUrl: req.baseUrl, + originalUrl: req.originalUrl, + headers: headers, + }; }; exports.doTopicAction = async function (action, event, caller, { - tids + 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); - })); + 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); + })); }; async function logTopicAction(action, req, tid, title) { - 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) - }); + 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), + }); } 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]]'); - } - const filteredData = await plugins.hooks.fire(`filter:post.${command}`, { - data: data, - uid: caller.uid - }); - return await executeCommand(caller, command, eventName, notification, filteredData.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]]'); + } + const filteredData = await plugins.hooks.fire(`filter:post.${command}`, { + data: 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; -} \ No newline at end of file + 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/lib/api/index.js b/lib/api/index.js index 4bcaffe719..c454de93a5 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -1,16 +1,16 @@ 'use strict'; module.exports = { - admin: require('./admin'), - users: require('./users'), - groups: require('./groups'), - topics: require('./topics'), - tags: require('./tags'), - posts: require('./posts'), - chats: require('./chats'), - categories: require('./categories'), - search: require('./search'), - flags: require('./flags'), - files: require('./files'), - utils: require('./utils') -}; \ No newline at end of file + admin: require('./admin'), + users: require('./users'), + groups: require('./groups'), + topics: require('./topics'), + tags: require('./tags'), + posts: require('./posts'), + chats: require('./chats'), + categories: require('./categories'), + search: require('./search'), + flags: require('./flags'), + files: require('./files'), + utils: require('./utils'), +}; diff --git a/lib/api/posts.js b/lib/api/posts.js index 9246bef928..0fc04c5430 100644 --- a/lib/api/posts.js +++ b/lib/api/posts.js @@ -16,395 +16,396 @@ const privileges = require('../privileges'); const apiHelpers = require('./helpers'); const websockets = require('../socket.io'); const socketHelpers = require('../socket.io/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)]); - const userPrivilege = userPrivileges[0]; - if (!post || !userPrivilege.read || !userPrivilege['topics:read']) { - return null; - } - Object.assign(post, voted); - 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)]); + const userPrivilege = userPrivileges[0]; + if (!post || !userPrivilege.read || !userPrivilege['topics:read']) { + return null; + } + Object.assign(post, voted); + 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; }; postsAPI.getIndex = async (caller, { - pid, - sort + pid, + sort, }) => { - const tid = await posts.getPostField(pid, 'tid'); - const topicPrivileges = await privileges.topics.get(tid, caller.uid); - if (!topicPrivileges.read || !topicPrivileges['topics:read']) { - return null; - } - return await posts.getPidIndex(pid, tid, sort); + const tid = await posts.getPostField(pid, 'tid'); + const topicPrivileges = await privileges.topics.get(tid, caller.uid); + if (!topicPrivileges.read || !topicPrivileges['topics:read']) { + return null; + } + return await posts.getPidIndex(pid, tid, sort); }; postsAPI.getSummary = async (caller, { - pid + pid, }) => { - const tid = await posts.getPostField(pid, 'tid'); - const topicPrivileges = await privileges.topics.get(tid, caller.uid); - if (!topicPrivileges.read || !topicPrivileges['topics:read']) { - return null; - } - const postsData = await posts.getPostSummaryByPids([pid], caller.uid, { - stripTags: false - }); - posts.modifyPostByPrivilege(postsData[0], topicPrivileges); - return postsData[0]; + const tid = await posts.getPostField(pid, 'tid'); + const topicPrivileges = await privileges.topics.get(tid, caller.uid); + if (!topicPrivileges.read || !topicPrivileges['topics:read']) { + return null; + } + const postsData = await posts.getPostSummaryByPids([pid], caller.uid, { + stripTags: false, + }); + posts.modifyPostByPrivilege(postsData[0], topicPrivileges); + return postsData[0]; }; postsAPI.getRaw = async (caller, { - pid + pid, }) => { - const userPrivileges = await privileges.posts.get([pid], caller.uid); - const userPrivilege = userPrivileges[0]; - if (!userPrivilege['topics:read']) { - return null; - } - const postData = await posts.getPostFields(pid, ['content', 'deleted']); - const selfPost = caller.uid && caller.uid === parseInt(postData.uid, 10); - if (postData.deleted && !(userPrivilege.isAdminOrMod || selfPost)) { - return null; - } - postData.pid = pid; - const result = await plugins.hooks.fire('filter:post.getRawPost', { - uid: caller.uid, - postData: postData - }); - return result.postData.content; + const userPrivileges = await privileges.posts.get([pid], caller.uid); + const userPrivilege = userPrivileges[0]; + if (!userPrivilege['topics:read']) { + return null; + } + const postData = await posts.getPostFields(pid, ['content', 'deleted']); + const selfPost = caller.uid && caller.uid === parseInt(postData.uid, 10); + if (postData.deleted && !(userPrivilege.isAdminOrMod || selfPost)) { + return null; + } + postData.pid = pid; + const result = await plugins.hooks.fire('filter:post.getRawPost', { + uid: caller.uid, + postData: postData, + }); + return result.postData.content; }; 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]]'); - } - 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}]]`); - } else if (!(await posts.canUserPostContentWithLinks(caller.uid, data.content))) { - throw new Error(`[[error:not-enough-reputation-to-post-links, ${meta.config['min:rep:post-links']}]]`); - } - 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]]'); + } + 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}]]`); + } else if (!(await posts.canUserPostContentWithLinks(caller.uid, data.content))) { + throw new Error(`[[error:not-enough-reputation-to-post-links, ${meta.config['min:rep:post-links']}]]`); + } + 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; }; 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 - }); + 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 deleteOrRestoreTopicOf(command, pid, caller) { - const topic = await posts.getTopicFields(pid, ['tid', 'cid', 'deleted', 'scheduled']); - if (topic.scheduled) { - return; - } - 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']); + if (topic.scheduled) { + return; + } + 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]]'); - } - postsCache.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 || !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]]'); + } + postsCache.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: 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.getVoters = async function (caller, data) { - if (!data || !data.pid) { - throw new Error('[[error:invalid-data]]'); - } - const { - pid - } = data; - const cid = await posts.getCidByPid(pid); - const [canSeeUpvotes, canSeeDownvotes] = await Promise.all([canSeeVotes(caller.uid, cid, 'upvoteVisibility'), canSeeVotes(caller.uid, cid, 'downvoteVisibility')]); - if (!canSeeUpvotes && !canSeeDownvotes) { - throw new Error('[[error:no-privileges]]'); - } - const repSystemDisabled = meta.config['reputation:disabled']; - const showUpvotes = canSeeUpvotes && !repSystemDisabled; - const showDownvotes = canSeeDownvotes && !meta.config['downvote:disabled'] && !repSystemDisabled; - const [upvoteUids, downvoteUids] = await Promise.all([showUpvotes ? 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, - showUpvotes: showUpvotes, - showDownvotes: showDownvotes, - upvoters: upvoters, - downvoters: downvoters - }; + if (!data || !data.pid) { + throw new Error('[[error:invalid-data]]'); + } + const { + pid, + } = data; + const cid = await posts.getCidByPid(pid); + const [canSeeUpvotes, canSeeDownvotes] = await Promise.all([canSeeVotes(caller.uid, cid, 'upvoteVisibility'), canSeeVotes(caller.uid, cid, 'downvoteVisibility')]); + if (!canSeeUpvotes && !canSeeDownvotes) { + throw new Error('[[error:no-privileges]]'); + } + const repSystemDisabled = meta.config['reputation:disabled']; + const showUpvotes = canSeeUpvotes && !repSystemDisabled; + const showDownvotes = canSeeDownvotes && !meta.config['downvote:disabled'] && !repSystemDisabled; + const [upvoteUids, downvoteUids] = await Promise.all([showUpvotes ? 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, + showUpvotes: showUpvotes, + showDownvotes: showDownvotes, + upvoters: upvoters, + downvoters: downvoters, + }; }; postsAPI.getUpvoters = async function (caller, data) { - if (!data.pid) { - throw new Error('[[error:invalid-data]]'); - } - const { - pid - } = data; - const cid = await posts.getCidByPid(pid); - if (!(await canSeeVotes(caller.uid, cid, 'upvoteVisibility'))) { - throw new Error('[[error:no-privileges]]'); - } - let upvotedUids = (await posts.getUpvotedUidsByPids([pid]))[0]; - const cutoff = 6; - if (!upvotedUids.length) { - return { - otherCount: 0, - usernames: [], - cutoff - }; - } - let otherCount = 0; - if (upvotedUids.length > cutoff) { - otherCount = upvotedUids.length - (cutoff - 1); - upvotedUids = upvotedUids.slice(0, cutoff - 1); - } - const usernames = await user.getUsernamesByUids(upvotedUids); - return { - otherCount, - usernames, - cutoff - }; + if (!data.pid) { + throw new Error('[[error:invalid-data]]'); + } + const { + pid, + } = data; + const cid = await posts.getCidByPid(pid); + if (!(await canSeeVotes(caller.uid, cid, 'upvoteVisibility'))) { + throw new Error('[[error:no-privileges]]'); + } + let upvotedUids = (await posts.getUpvotedUidsByPids([pid]))[0]; + const cutoff = 6; + if (!upvotedUids.length) { + return { + otherCount: 0, + usernames: [], + cutoff, + }; + } + let otherCount = 0; + if (upvotedUids.length > cutoff) { + otherCount = upvotedUids.length - (cutoff - 1); + upvotedUids = upvotedUids.slice(0, cutoff - 1); + } + const usernames = await user.getUsernamesByUids(upvotedUids); + return { + otherCount, + usernames, + cutoff, + }; }; async function canSeeVotes(uid, cids, type) { - const isArray = Array.isArray(cids); - if (!isArray) { - cids = [cids]; - } - const uniqCids = _.uniq(cids); - const [canRead, isAdmin, isMod] = await Promise.all([privileges.categories.isUserAllowedTo('topics:read', uniqCids, uid), privileges.users.isAdministrator(uid), privileges.users.isModerator(uid, cids)]); - const cidToAllowed = _.zipObject(uniqCids, canRead); - const checks = cids.map((cid, index) => isAdmin || isMod[index] || cidToAllowed[cid] && (meta.config[type] === 'all' || meta.config[type] === 'loggedin' && parseInt(uid, 10) > 0)); - return isArray ? checks : checks[0]; + const isArray = Array.isArray(cids); + if (!isArray) { + cids = [cids]; + } + const uniqCids = _.uniq(cids); + const [canRead, isAdmin, isMod] = await Promise.all([privileges.categories.isUserAllowedTo('topics:read', uniqCids, uid), privileges.users.isAdministrator(uid), privileges.users.isModerator(uid, cids)]); + const cidToAllowed = _.zipObject(uniqCids, canRead); + const checks = cids.map((cid, index) => isAdmin || isMod[index] || cidToAllowed[cid] && (meta.config[type] === 'all' || meta.config[type] === 'loggedin' && parseInt(uid, 10) > 0)); + return isArray ? checks : checks[0]; } 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); }; 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.push(String(post.timestamp)); - return { - timestamps: timestamps, - revisions: timestamps.map((timestamp, idx) => ({ - timestamp: timestamp, - username: usernames[idx] - })), - deletable: isAdmin || isModerator, - 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(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.push(String(post.timestamp)); + return { + timestamps: timestamps, + revisions: timestamps.map((timestamp, idx) => ({ + timestamp: timestamp, + username: usernames[idx], + })), + deletable: isAdmin || isModerator, + editable: isAdmin || isModerator || parseInt(caller.uid, 10) === 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); }; postsAPI.deleteDiff = async (caller, { - pid, - timestamp + pid, + timestamp, }) => { - const cid = await posts.getCidByPid(pid); - const [isAdmin, isModerator] = await Promise.all([privileges.users.isAdministrator(caller.uid), privileges.users.isModerator(caller.uid, cid)]); - if (!(isAdmin || isModerator)) { - throw new Error('[[error:no-privileges]]'); - } - await posts.diffs.delete(pid, timestamp, caller.uid); + const cid = await posts.getCidByPid(pid); + const [isAdmin, isModerator] = await Promise.all([privileges.users.isAdministrator(caller.uid), privileges.users.isModerator(caller.uid, cid)]); + if (!(isAdmin || isModerator)) { + throw new Error('[[error:no-privileges]]'); + } + await posts.diffs.delete(pid, timestamp, caller.uid); }; postsAPI.getReplies = async (caller, { - pid + pid, }) => { - if (!utils.isNumber(pid)) { - throw new Error('[[error:invalid-data]]'); - } - const { - uid - } = caller; - const canRead = await privileges.posts.can('topics:read', pid, caller.uid); - if (!canRead) { - return null; - } - const { - topicPostSort - } = await user.getSettings(uid); - const pids = await posts.getPidsFromSet(`pid:${pid}:replies`, 0, -1, topicPostSort === 'newest_to_oldest'); - let [postData, postPrivileges] = await Promise.all([posts.getPostsByPids(pids, uid), privileges.posts.get(pids, uid)]); - postData = await topics.addPostData(postData, uid); - postData.forEach((postData, index) => posts.modifyPostByPrivilege(postData, postPrivileges[index])); - postData = postData.filter((postData, index) => postData && postPrivileges[index].read); - postData = await user.blocks.filter(uid, postData); - return postData; -}; \ No newline at end of file + if (!utils.isNumber(pid)) { + throw new Error('[[error:invalid-data]]'); + } + const { + uid, + } = caller; + const canRead = await privileges.posts.can('topics:read', pid, caller.uid); + if (!canRead) { + return null; + } + const { + topicPostSort, + } = await user.getSettings(uid); + const pids = await posts.getPidsFromSet(`pid:${pid}:replies`, 0, -1, topicPostSort === 'newest_to_oldest'); + let [postData, postPrivileges] = await Promise.all([posts.getPostsByPids(pids, uid), privileges.posts.get(pids, uid)]); + postData = await topics.addPostData(postData, uid); + postData.forEach((postData, index) => posts.modifyPostByPrivilege(postData, postPrivileges[index])); + postData = postData.filter((postData, index) => postData && postPrivileges[index].read); + postData = await user.blocks.filter(uid, postData); + return postData; +}; diff --git a/lib/api/search.js b/lib/api/search.js index 662669f3b4..09d6de3aff 100644 --- a/lib/api/search.js +++ b/lib/api/search.js @@ -9,162 +9,163 @@ const privileges = require('../privileges'); const meta = require('../meta'); const plugins = require('../plugins'); const controllersHelpers = require('../controllers/helpers'); + const searchApi = module.exports; searchApi.categories = async (caller, data) => { - let cids = []; - let matchedCids = []; - const privilege = data.privilege || 'topics:read'; - data.states = (data.states || ['watching', 'tracking', 'notwatching', 'ignoring']).map(state => categories.watchStates[state]); - data.parentCid = parseInt(data.parentCid || 0, 10); - if (data.search) { - ({ - cids, - matchedCids - } = await findMatchedCids(caller.uid, data)); - } else { - cids = await loadCids(caller.uid, data.parentCid); - } - const visibleCategories = await controllersHelpers.getVisibleCategories({ - cids, - uid: caller.uid, - states: data.states, - privilege, - showLinks: data.showLinks, - parentCid: data.parentCid - }); - 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); - 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: caller.uid - }); - return { - categories: result.categories - }; + let cids = []; + let matchedCids = []; + const privilege = data.privilege || 'topics:read'; + data.states = (data.states || ['watching', 'tracking', 'notwatching', 'ignoring']).map(state => categories.watchStates[state]); + data.parentCid = parseInt(data.parentCid || 0, 10); + if (data.search) { + ({ + cids, + matchedCids, + } = await findMatchedCids(caller.uid, data)); + } else { + cids = await loadCids(caller.uid, data.parentCid); + } + const visibleCategories = await controllersHelpers.getVisibleCategories({ + cids, + uid: caller.uid, + states: data.states, + privilege, + showLinks: data.showLinks, + parentCid: data.parentCid, + }); + 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); + 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: caller.uid, + }); + return { + categories: result.categories, + }; }; async function findMatchedCids(uid, data) { - const result = await categories.search({ - uid: uid, - query: data.search, - qs: data.query, - paginate: false - }); - let matchedCids = result.categories.map(c => c.cid); - 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])); - } - const rootCids = _.uniq(_.flatten(await Promise.all(matchedCids.map(categories.getParentCids)))); - const allChildCids = _.uniq(_.flatten(await Promise.all(matchedCids.map(categories.getChildrenCids)))); - return { - cids: _.uniq(rootCids.concat(allChildCids).concat(matchedCids)), - matchedCids: matchedCids - }; + const result = await categories.search({ + uid: uid, + query: data.search, + qs: data.query, + paginate: false, + }); + let matchedCids = result.categories.map(c => c.cid); + 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])); + } + const rootCids = _.uniq(_.flatten(await Promise.all(matchedCids.map(categories.getParentCids)))); + const allChildCids = _.uniq(_.flatten(await Promise.all(matchedCids.map(categories.getChildrenCids)))); + return { + cids: _.uniq(rootCids.concat(allChildCids).concat(matchedCids)), + 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); - } - })); - } - 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; + 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); + } + })); + } + 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; } searchApi.roomUsers = async (caller, { - query, - roomId + query, + roomId, }) => { - const [isAdmin, inRoom, isRoomOwner] = await Promise.all([user.isAdministrator(caller.uid), messaging.isUserInRoom(caller.uid, roomId), messaging.isRoomOwner(caller.uid, roomId)]); - if (!isAdmin && !inRoom) { - throw new Error('[[error:no-privileges]]'); - } - const results = await user.search({ - query, - paginate: false, - hardCap: -1, - uid: caller.uid - }); - const { - users - } = results; - const foundUids = users.map(user => user && user.uid); - const isUidInRoom = _.zipObject(foundUids, await messaging.isUsersInRoom(foundUids, roomId)); - const roomUsers = users.filter(user => isUidInRoom[user.uid]); - const isOwners = await messaging.isRoomOwner(roomUsers.map(u => u.uid), roomId); - roomUsers.forEach((user, index) => { - if (user) { - user.isOwner = isOwners[index]; - user.canKick = isRoomOwner && parseInt(user.uid, 10) !== parseInt(caller.uid, 10); - } - }); - roomUsers.sort((a, b) => { - if (a.isOwner && !b.isOwner) { - return -1; - } else if (!a.isOwner && b.isOwner) { - return 1; - } - return 0; - }); - return { - users: roomUsers - }; + const [isAdmin, inRoom, isRoomOwner] = await Promise.all([user.isAdministrator(caller.uid), messaging.isUserInRoom(caller.uid, roomId), messaging.isRoomOwner(caller.uid, roomId)]); + if (!isAdmin && !inRoom) { + throw new Error('[[error:no-privileges]]'); + } + const results = await user.search({ + query, + paginate: false, + hardCap: -1, + uid: caller.uid, + }); + const { + users, + } = results; + const foundUids = users.map(user => user && user.uid); + const isUidInRoom = _.zipObject(foundUids, await messaging.isUsersInRoom(foundUids, roomId)); + const roomUsers = users.filter(user => isUidInRoom[user.uid]); + const isOwners = await messaging.isRoomOwner(roomUsers.map(u => u.uid), roomId); + roomUsers.forEach((user, index) => { + if (user) { + user.isOwner = isOwners[index]; + user.canKick = isRoomOwner && parseInt(user.uid, 10) !== parseInt(caller.uid, 10); + } + }); + roomUsers.sort((a, b) => { + if (a.isOwner && !b.isOwner) { + return -1; + } else if (!a.isOwner && b.isOwner) { + return 1; + } + return 0; + }); + return { + users: roomUsers, + }; }; searchApi.roomMessages = async (caller, { - query, - roomId, - uid + query, + roomId, + uid, }) => { - const [roomData, inRoom] = await Promise.all([messaging.getRoomData(roomId), messaging.isUserInRoom(caller.uid, roomId)]); - if (!roomData) { - throw new Error('[[error:no-room]]'); - } - if (!inRoom) { - throw new Error('[[error:no-privileges]]'); - } - const { - ids - } = await plugins.hooks.fire('filter:messaging.searchMessages', { - content: query, - roomId: [roomId], - uid: [uid], - matchWords: 'any', - ids: [] - }); - let userjoinTimestamp = 0; - if (!roomData.public) { - userjoinTimestamp = await db.sortedSetScore(`chat:room:${roomId}:uids`, caller.uid); - } - let messageData = await messaging.getMessagesData(ids, caller.uid, roomId, false); - messageData = messageData.map(msg => { - if (msg) { - msg.newSet = true; - } - return msg; - }).filter(msg => msg && !msg.deleted && msg.timestamp > userjoinTimestamp); - return { - messages: messageData - }; -}; \ No newline at end of file + const [roomData, inRoom] = await Promise.all([messaging.getRoomData(roomId), messaging.isUserInRoom(caller.uid, roomId)]); + if (!roomData) { + throw new Error('[[error:no-room]]'); + } + if (!inRoom) { + throw new Error('[[error:no-privileges]]'); + } + const { + ids, + } = await plugins.hooks.fire('filter:messaging.searchMessages', { + content: query, + roomId: [roomId], + uid: [uid], + matchWords: 'any', + ids: [], + }); + let userjoinTimestamp = 0; + if (!roomData.public) { + userjoinTimestamp = await db.sortedSetScore(`chat:room:${roomId}:uids`, caller.uid); + } + let messageData = await messaging.getMessagesData(ids, caller.uid, roomId, false); + messageData = messageData.map((msg) => { + if (msg) { + msg.newSet = true; + } + return msg; + }).filter(msg => msg && !msg.deleted && msg.timestamp > userjoinTimestamp); + return { + messages: messageData, + }; +}; diff --git a/lib/api/tags.js b/lib/api/tags.js index 0ccfacd273..b804ab0c5f 100644 --- a/lib/api/tags.js +++ b/lib/api/tags.js @@ -1,10 +1,11 @@ 'use strict'; const topics = require('../topics'); + const tagsAPI = module.exports; tagsAPI.follow = async function (caller, data) { - await topics.followTag(data.tag, caller.uid); + await topics.followTag(data.tag, caller.uid); }; tagsAPI.unfollow = async function (caller, data) { - await topics.unfollowTag(data.tag, caller.uid); -}; \ No newline at end of file + await topics.unfollowTag(data.tag, caller.uid); +}; diff --git a/lib/api/topics.js b/lib/api/topics.js index 776ac7e6d2..2dc1bea695 100644 --- a/lib/api/topics.js +++ b/lib/api/topics.js @@ -7,279 +7,281 @@ const posts = require('../posts'); const meta = require('../meta'); const privileges = require('../privileges'); const apiHelpers = require('./helpers'); + const { - doTopicAction + doTopicAction, } = apiHelpers; const websockets = require('../socket.io'); const socketHelpers = require('../socket.io/helpers'); + const topicsAPI = module.exports; topicsAPI._checkThumbPrivileges = async function ({ - tid, - uid + tid, + uid, }) { - const isUUID = validator.isUUID(tid); - if (!isUUID && (isNaN(parseInt(tid, 10)) || !(await topics.exists(tid)))) { - throw new Error('[[error:no-topic]]'); - } - if (!isUUID && !(await privileges.topics.canEdit(tid, uid))) { - throw new Error('[[error:no-privileges]]'); - } + const isUUID = validator.isUUID(tid); + if (!isUUID && (isNaN(parseInt(tid, 10)) || !(await topics.exists(tid)))) { + throw new Error('[[error:no-topic]]'); + } + if (!isUUID && !(await privileges.topics.canEdit(tid, uid))) { + throw new Error('[[error:no-privileges]]'); + } }; 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 = 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; }; 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); - 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); + 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]; }; 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, { - tids, - expiry + tids, + expiry, }) { - await doTopicAction('pin', 'event:topic_pinned', caller, { - tids - }); - if (expiry) { - await Promise.all(tids.map(async tid => topics.tools.setPinExpiry(tid, expiry, caller.uid))); - } + await doTopicAction('pin', 'event:topic_pinned', caller, { + tids, + }); + if (expiry) { + await Promise.all(tids.map(async tid => topics.tools.setPinExpiry(tid, expiry, caller.uid))); + } }; 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.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); }; topicsAPI.updateTags = async (caller, { - tid, - tags + tid, + tags, }) => { - if (!(await privileges.topics.canEdit(tid, caller.uid))) { - throw new Error('[[error:no-privileges]]'); - } - const cid = await topics.getTopicField(tid, 'cid'); - await topics.validateTags(tags, cid, caller.uid, tid); - await topics.updateTopicTags(tid, tags); - return await topics.getTopicTagsObjects(tid); + if (!(await privileges.topics.canEdit(tid, caller.uid))) { + throw new Error('[[error:no-privileges]]'); + } + const cid = await topics.getTopicField(tid, 'cid'); + await topics.validateTags(tags, cid, caller.uid, tid); + await topics.updateTopicTags(tid, tags); + return await topics.getTopicTagsObjects(tid); }; topicsAPI.addTags = async (caller, { - tid, - tags + tid, + tags, }) => { - if (!(await privileges.topics.canEdit(tid, caller.uid))) { - throw new Error('[[error:no-privileges]]'); - } - const cid = await topics.getTopicField(tid, 'cid'); - await topics.validateTags(tags, cid, caller.uid, tid); - tags = await topics.filterTags(tags, cid); - await topics.addTags(tags, [tid]); - return await topics.getTopicTagsObjects(tid); + if (!(await privileges.topics.canEdit(tid, caller.uid))) { + throw new Error('[[error:no-privileges]]'); + } + const cid = await topics.getTopicField(tid, 'cid'); + await topics.validateTags(tags, cid, caller.uid, tid); + tags = await topics.filterTags(tags, cid); + await topics.addTags(tags, [tid]); + return await topics.getTopicTagsObjects(tid); }; topicsAPI.deleteTags = async (caller, { - tid + tid, }) => { - if (!(await privileges.topics.canEdit(tid, caller.uid))) { - throw new Error('[[error:no-privileges]]'); - } - await topics.deleteTopicTags(tid); + if (!(await privileges.topics.canEdit(tid, caller.uid))) { + throw new Error('[[error:no-privileges]]'); + } + await topics.deleteTopicTags(tid); }; topicsAPI.getThumbs = async (caller, { - tid + tid, }) => { - if (isFinite(tid)) { - const [exists, canRead] = await Promise.all([topics.exists(tid), privileges.topics.can('topics:read', tid, caller.uid)]); - if (!exists) { - throw new Error('[[error:not-found]]'); - } - if (!canRead) { - throw new Error('[[error:not-allowed]]'); - } - } - return await topics.thumbs.get(tid); + if (isFinite(tid)) { + const [exists, canRead] = await Promise.all([topics.exists(tid), privileges.topics.can('topics:read', tid, caller.uid)]); + if (!exists) { + throw new Error('[[error:not-found]]'); + } + if (!canRead) { + throw new Error('[[error:not-allowed]]'); + } + } + return await topics.thumbs.get(tid); }; topicsAPI.migrateThumbs = async (caller, { - from, - to + from, + to, }) => { - await Promise.all([topicsAPI._checkThumbPrivileges({ - tid: from, - uid: caller.uid - }), topicsAPI._checkThumbPrivileges({ - tid: to, - uid: caller.uid - })]); - await topics.thumbs.migrate(from, to); + await Promise.all([topicsAPI._checkThumbPrivileges({ + tid: from, + uid: caller.uid, + }), topicsAPI._checkThumbPrivileges({ + tid: to, + uid: caller.uid, + })]); + await topics.thumbs.migrate(from, to); }; topicsAPI.deleteThumb = async (caller, { - tid, - path + tid, + path, }) => { - await topicsAPI._checkThumbPrivileges({ - tid: tid, - uid: caller.uid - }); - await topics.thumbs.delete(tid, path); + await topicsAPI._checkThumbPrivileges({ + tid: tid, + uid: caller.uid, + }); + await topics.thumbs.delete(tid, path); }; topicsAPI.reorderThumbs = async (caller, { - tid, - path, - order + tid, + path, + order, }) => { - await topicsAPI._checkThumbPrivileges({ - tid: tid, - uid: caller.uid - }); - const exists = await topics.thumbs.exists(tid, path); - if (!exists) { - throw new Error('[[error:invalid-data]]'); - } - await topics.thumbs.associate({ - id: tid, - path: path, - score: order - }); + await topicsAPI._checkThumbPrivileges({ + tid: tid, + uid: caller.uid, + }); + const exists = await topics.thumbs.exists(tid, path); + if (!exists) { + throw new Error('[[error:invalid-data]]'); + } + await topics.thumbs.associate({ + id: tid, + path: path, + score: order, + }); }; topicsAPI.getEvents = async (caller, { - tid + tid, }) => { - if (!(await privileges.topics.can('topics:read', tid, caller.uid))) { - throw new Error('[[error:no-privileges]]'); - } - return await topics.events.get(tid, caller.uid); + if (!(await privileges.topics.can('topics:read', tid, caller.uid))) { + throw new Error('[[error:no-privileges]]'); + } + return await topics.events.get(tid, caller.uid); }; topicsAPI.deleteEvent = async (caller, { - tid, - eventId + tid, + eventId, }) => { - if (!(await privileges.topics.isAdminOrMod(tid, caller.uid))) { - throw new Error('[[error:no-privileges]]'); - } - await topics.events.purge(tid, [eventId]); + if (!(await privileges.topics.isAdminOrMod(tid, caller.uid))) { + throw new Error('[[error:no-privileges]]'); + } + await topics.events.purge(tid, [eventId]); }; topicsAPI.markRead = async (caller, { - tid + tid, }) => { - const hasMarked = await topics.markAsRead([tid], caller.uid); - const promises = [topics.markTopicNotificationsRead([tid], caller.uid)]; - if (hasMarked) { - promises.push(topics.pushUnreadCount(caller.uid)); - } - await Promise.all(promises); + const hasMarked = await topics.markAsRead([tid], caller.uid); + const promises = [topics.markTopicNotificationsRead([tid], caller.uid)]; + if (hasMarked) { + promises.push(topics.pushUnreadCount(caller.uid)); + } + await Promise.all(promises); }; topicsAPI.markUnread = async (caller, { - tid + tid, }) => { - if (!tid || caller.uid <= 0) { - throw new Error('[[error:invalid-data]]'); - } - await topics.markUnread(tid, caller.uid); - topics.pushUnreadCount(caller.uid); + if (!tid || caller.uid <= 0) { + throw new Error('[[error:invalid-data]]'); + } + await topics.markUnread(tid, caller.uid); + topics.pushUnreadCount(caller.uid); }; topicsAPI.bump = async (caller, { - tid + tid, }) => { - if (!tid) { - throw new Error('[[error:invalid-tid]]'); - } - const isAdminOrMod = await privileges.topics.isAdminOrMod(tid, caller.uid); - if (!isAdminOrMod) { - throw new Error('[[error:no-privileges]]'); - } - await topics.markAsUnreadForAll(tid); - topics.pushUnreadCount(caller.uid); -}; \ No newline at end of file + if (!tid) { + throw new Error('[[error:invalid-tid]]'); + } + const isAdminOrMod = await privileges.topics.isAdminOrMod(tid, caller.uid); + if (!isAdminOrMod) { + throw new Error('[[error:no-privileges]]'); + } + await topics.markAsUnreadForAll(tid); + topics.pushUnreadCount(caller.uid); +}; diff --git a/lib/api/users.js b/lib/api/users.js index 13d83bb520..779926d40d 100644 --- a/lib/api/users.js +++ b/lib/api/users.js @@ -17,701 +17,702 @@ const events = require('../events'); const translator = require('../translator'); const sockets = require('../socket.io'); const utils = require('../utils'); + const usersAPI = module.exports; 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]]'); + } }; usersAPI.create = async function (caller, data) { - if (!data) { - throw new Error('[[error:invalid-data]]'); - } - await hasAdminPrivilege(caller.uid, 'users'); - const uid = await user.create(data); - return await user.getUserData(uid); + if (!data) { + throw new Error('[[error:invalid-data]]'); + } + await hasAdminPrivilege(caller.uid, 'users'); + const uid = await user.create(data); + return await user.getUserData(uid); }; usersAPI.get = async (caller, { - uid + uid, }) => { - const canView = await privileges.global.can('view:users', caller.uid); - if (!canView) { - throw new Error('[[error:no-privileges]]'); - } - const userData = await user.getUserData(uid); - return await user.hidePrivateData(userData, caller.uid); + const canView = await privileges.global.can('view:users', caller.uid); + if (!canView) { + throw new Error('[[error:no-privileges]]'); + } + const userData = await user.getUserData(uid); + return await user.hidePrivateData(userData, caller.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)]); - 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 [isAdminOrGlobalMod, canEdit] = await Promise.all([user.isAdminOrGlobalMod(caller.uid), privileges.users.canEdit(caller.uid, data.uid)]); + 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; }; usersAPI.delete = async function (caller, { - uid, - password + uid, + password, }) { - await processDeletion({ - uid: uid, - method: 'delete', - password, - caller - }); + await processDeletion({ + uid: uid, + method: 'delete', + password, + caller, + }); }; usersAPI.deleteContent = async function (caller, { - uid, - password + uid, + password, }) { - await processDeletion({ - uid, - method: 'deleteContent', - password, - caller - }); + await processDeletion({ + uid, + method: 'deleteContent', + password, + caller, + }); }; usersAPI.deleteAccount = async function (caller, { - uid, - password + uid, + password, }) { - await processDeletion({ - uid, - method: 'deleteAccount', - password, - caller - }); + await processDeletion({ + uid, + method: 'deleteAccount', + password, + caller, + }); }; usersAPI.deleteMany = async function (caller, data) { - await hasAdminPrivilege(caller.uid, 'users'); - if (await canDeleteUids(data.uids)) { - await Promise.all(data.uids.map(uid => processDeletion({ - uid, - method: 'delete', - caller - }))); - } + await hasAdminPrivilege(caller.uid, 'users'); + 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 - }; - 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, + }; + 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.getStatus = async (caller, { - uid + uid, }) => { - const status = await db.getObjectField(`user:${uid}`, 'status'); - return { - status - }; + const status = await db.getObjectField(`user:${uid}`, 'status'); + return { + status, + }; }; usersAPI.getPrivateRoomId = async (caller, { - uid + uid, } = {}) => { - if (!uid) { - throw new Error('[[error:invalid-data]]'); - } - let roomId = await messaging.hasPrivateChat(caller.uid, uid); - roomId = parseInt(roomId, 10); - return { - roomId: roomId > 0 ? roomId : null - }; + if (!uid) { + throw new Error('[[error:invalid-data]]'); + } + let roomId = await messaging.hasPrivateChat(caller.uid, uid); + roomId = parseInt(roomId, 10); + return { + roomId: roomId > 0 ? roomId : null, + }; }; 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', 'role']); - 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', 'role']); + 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]); }; 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); + 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); + } }; usersAPI.unban = async function (caller, data) { - if (!(await privileges.users.hasBanPrivilege(caller.uid))) { - throw new Error('[[error:no-privileges]]'); - } - const unbanData = await user.bans.unban(data.uid, data.reason); - await db.setObjectField(`uid:${data.uid}:unban:${unbanData.timestamp}`, 'fromUid', caller.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]]'); + } + const unbanData = await user.bans.unban(data.uid, data.reason); + await db.setObjectField(`uid:${data.uid}:unban:${unbanData.timestamp}`, 'fromUid', caller.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 = { - type: 'mute', - 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 = { + type: 'mute', + 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']); - const now = Date.now(); - const unmuteKey = `uid:${data.uid}:unmute:${now}`; - const unmuteData = { - type: 'unmute', - fromUid: caller.uid, - uid: data.uid, - timestamp: now - }; - if (data.reason) { - unmuteData.reason = data.reason; - } - await db.sortedSetAdd(`uid:${data.uid}:unmutes:timestamp`, now, unmuteKey); - await db.setObject(unmuteKey, unmuteData); - 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']); + const now = Date.now(); + const unmuteKey = `uid:${data.uid}:unmute:${now}`; + const unmuteData = { + type: 'unmute', + fromUid: caller.uid, + uid: data.uid, + timestamp: now, + }; + if (data.reason) { + unmuteData.reason = data.reason; + } + await db.sortedSetAdd(`uid:${data.uid}:unmutes:timestamp`, now, unmuteKey); + await db.setObject(unmuteKey, unmuteData); + 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, + }); }; usersAPI.generateToken = async (caller, { - uid, - description + uid, + description, }) => { - const api = require('.'); - await hasAdminPrivilege(caller.uid, 'settings'); - if (parseInt(uid, 10) !== parseInt(caller.uid, 10)) { - throw new Error('[[error:invalid-uid]]'); - } - const tokenObj = await api.utils.tokens.generate({ - uid, - description - }); - return tokenObj.token; + const api = require('.'); + await hasAdminPrivilege(caller.uid, 'settings'); + if (parseInt(uid, 10) !== parseInt(caller.uid, 10)) { + throw new Error('[[error:invalid-uid]]'); + } + const tokenObj = await api.utils.tokens.generate({ + uid, + description, + }); + return tokenObj.token; }; usersAPI.deleteToken = async (caller, { - uid, - token + uid, + token, }) => { - const api = require('.'); - await hasAdminPrivilege(caller.uid, 'settings'); - if (parseInt(uid, 10) !== parseInt(caller.uid, 10)) { - throw new Error('[[error:invalid-uid]]'); - } - await api.utils.tokens.delete(token); - return true; + const api = require('.'); + await hasAdminPrivilege(caller.uid, 'settings'); + if (parseInt(uid, 10) !== parseInt(caller.uid, 10)) { + throw new Error('[[error:invalid-uid]]'); + } + await api.utils.tokens.delete(token); + return true; }; usersAPI.revokeSession = async (caller, { - uid, - uuid + uid, + uuid, }) => { - if (parseInt(uid, 10) !== caller.uid && !(await user.isAdminOrGlobalMod(caller.uid))) { - throw new Error('[[error:invalid-uid]]'); - } - const sids = await db.getSortedSetRange(`uid:${uid}:sessions`, 0, -1); - let _id; - for (const sid of sids) { - const sessionObj = await db.sessionStoreGet(sid); - if (sessionObj && sessionObj.meta && sessionObj.meta.uuid === uuid) { - _id = sid; - break; - } - } - if (!_id) { - throw new Error('[[error:no-session-found]]'); - } - await user.auth.revokeSession(_id, uid); + if (parseInt(uid, 10) !== caller.uid && !(await user.isAdminOrGlobalMod(caller.uid))) { + throw new Error('[[error:invalid-uid]]'); + } + const sids = await db.getSortedSetRange(`uid:${uid}:sessions`, 0, -1); + let _id; + for (const sid of sids) { + const sessionObj = await db.sessionStoreGet(sid); + if (sessionObj && sessionObj.meta && sessionObj.meta.uuid === uuid) { + _id = sid; + break; + } + } + if (!_id) { + throw new Error('[[error:no-session-found]]'); + } + await user.auth.revokeSession(_id, uid); }; usersAPI.invite = async (caller, { - emails, - groupsToJoin, - uid + emails, + groupsToJoin, + uid, }) => { - if (!emails || !Array.isArray(groupsToJoin)) { - throw new Error('[[error:invalid-data]]'); - } - if (parseInt(caller.uid, 10) !== parseInt(uid, 10)) { - throw new Error('[[error:no-privileges]]'); - } - const canInvite = await privileges.users.hasInvitePrivilege(caller.uid); - if (!canInvite) { - throw new Error('[[error:no-privileges]]'); - } - const { - registrationType - } = meta.config; - const isAdmin = await user.isAdministrator(caller.uid); - if (registrationType === 'admin-invite-only' && !isAdmin) { - throw new Error('[[error:no-privileges]]'); - } - const inviteGroups = (await groups.getUserInviteGroups(caller.uid)).map(group => group.name); - const cannotInvite = groupsToJoin.some(group => !inviteGroups.includes(group)); - if (groupsToJoin.length > 0 && cannotInvite) { - throw 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) { - let invites = 0; - if (max) { - invites = await user.getInvitesNumber(caller.uid); - } - if (!isAdmin && max && invites >= max) { - throw new Error(`[[error:invite-maximum-met, ${invites}, ${max}]]`); - } - await user.sendInvitationEmail(caller.uid, email, groupsToJoin); - } + if (!emails || !Array.isArray(groupsToJoin)) { + throw new Error('[[error:invalid-data]]'); + } + if (parseInt(caller.uid, 10) !== parseInt(uid, 10)) { + throw new Error('[[error:no-privileges]]'); + } + const canInvite = await privileges.users.hasInvitePrivilege(caller.uid); + if (!canInvite) { + throw new Error('[[error:no-privileges]]'); + } + const { + registrationType, + } = meta.config; + const isAdmin = await user.isAdministrator(caller.uid); + if (registrationType === 'admin-invite-only' && !isAdmin) { + throw new Error('[[error:no-privileges]]'); + } + const inviteGroups = (await groups.getUserInviteGroups(caller.uid)).map(group => group.name); + const cannotInvite = groupsToJoin.some(group => !inviteGroups.includes(group)); + if (groupsToJoin.length > 0 && cannotInvite) { + throw 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) { + let invites = 0; + if (max) { + invites = await user.getInvitesNumber(caller.uid); + } + if (!isAdmin && max && invites >= max) { + throw new Error(`[[error:invite-maximum-met, ${invites}, ${max}]]`); + } + await user.sendInvitationEmail(caller.uid, email, groupsToJoin); + } }; usersAPI.getInviteGroups = async (caller, { - uid + uid, }) => { - if (parseInt(uid, 10) !== parseInt(caller.uid, 10)) { - throw new Error('[[error:no-privileges]]'); - } - const userInviteGroups = await groups.getUserInviteGroups(uid); - return userInviteGroups.map(group => group.displayName); + if (parseInt(uid, 10) !== parseInt(caller.uid, 10)) { + throw new Error('[[error:no-privileges]]'); + } + const userInviteGroups = await groups.getUserInviteGroups(uid); + return userInviteGroups.map(group => group.displayName); }; usersAPI.addEmail = async (caller, { - email, - skipConfirmation, - uid + email, + skipConfirmation, + uid, }) => { - const isSelf = parseInt(caller.uid, 10) === parseInt(uid, 10); - const canEdit = await privileges.users.canEdit(caller.uid, uid); - if (skipConfirmation && canEdit && !isSelf) { - if (!email.length) { - await user.email.remove(uid); - } else { - if (!(await user.email.available(email))) { - throw new Error('[[error:email-taken]]'); - } - await user.setUserField(uid, 'email', email); - await user.email.confirmByUid(uid, caller.uid); - } - } else { - await usersAPI.update(caller, { - uid, - email - }); - } - return await db.getSortedSetRangeByScore('email:uid', 0, 500, uid, uid); + const isSelf = parseInt(caller.uid, 10) === parseInt(uid, 10); + const canEdit = await privileges.users.canEdit(caller.uid, uid); + if (skipConfirmation && canEdit && !isSelf) { + if (!email.length) { + await user.email.remove(uid); + } else { + if (!(await user.email.available(email))) { + throw new Error('[[error:email-taken]]'); + } + await user.setUserField(uid, 'email', email); + await user.email.confirmByUid(uid, caller.uid); + } + } else { + await usersAPI.update(caller, { + uid, + email, + }); + } + return await db.getSortedSetRangeByScore('email:uid', 0, 500, uid, uid); }; usersAPI.listEmails = async (caller, { - uid + uid, }) => { - const [isPrivileged, { - showemail - }] = await Promise.all([user.isPrivileged(caller.uid), user.getSettings(uid)]); - const isSelf = caller.uid === parseInt(uid, 10); - if (isSelf || isPrivileged || showemail) { - return await db.getSortedSetRangeByScore('email:uid', 0, 500, uid, uid); - } - return null; + const [isPrivileged, { + showemail, + }] = await Promise.all([user.isPrivileged(caller.uid), user.getSettings(uid)]); + const isSelf = caller.uid === parseInt(uid, 10); + if (isSelf || isPrivileged || showemail) { + return await db.getSortedSetRangeByScore('email:uid', 0, 500, uid, uid); + } + return null; }; usersAPI.getEmail = async (caller, { - uid, - email + uid, + email, }) => { - const [isPrivileged, { - showemail - }, exists] = await Promise.all([user.isPrivileged(caller.uid), user.getSettings(uid), db.isSortedSetMember('email:uid', email.toLowerCase())]); - const isSelf = caller.uid === parseInt(uid, 10); - return exists && (isSelf || isPrivileged || showemail); + const [isPrivileged, { + showemail, + }, exists] = await Promise.all([user.isPrivileged(caller.uid), user.getSettings(uid), db.isSortedSetMember('email:uid', email.toLowerCase())]); + const isSelf = caller.uid === parseInt(uid, 10); + return exists && (isSelf || isPrivileged || showemail); }; usersAPI.confirmEmail = async (caller, { - uid, - email, - sessionId + uid, + email, + sessionId, }) => { - const [pending, current, canManage] = await Promise.all([user.email.isValidationPending(uid, email), user.getUserField(uid, 'email'), privileges.admin.can('admin:users', caller.uid)]); - if (!canManage) { - throw new Error('[[error:no-privileges]]'); - } - if (pending) { - const code = await db.get(`confirm:byUid:${uid}`); - await user.email.confirmByCode(code, sessionId); - return true; - } else if (current && current === email) { - await user.email.confirmByUid(uid, caller.uid); - return true; - } - return false; + const [pending, current, canManage] = await Promise.all([user.email.isValidationPending(uid, email), user.getUserField(uid, 'email'), privileges.admin.can('admin:users', caller.uid)]); + if (!canManage) { + throw new Error('[[error:no-privileges]]'); + } + if (pending) { + const code = await db.get(`confirm:byUid:${uid}`); + await user.email.confirmByCode(code, sessionId); + return true; + } else if (current && current === email) { + await user.email.confirmByUid(uid, caller.uid); + return true; + } + return false; }; 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 = 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]]'); + } } async function processDeletion({ - uid, - method, - password, - caller + uid, + method, + password, + caller, }) { - const isTargetAdmin = await user.isAdministrator(uid); - const isSelf = parseInt(uid, 10) === parseInt(caller.uid, 10); - const hasAdminPrivilege = await privileges.admin.can('admin:users', caller.uid); - if (isSelf && meta.config.allowAccountDelete !== 1) { - throw new Error('[[error:account-deletion-disabled]]'); - } else if (!isSelf && !hasAdminPrivilege) { - throw new Error('[[error:no-privileges]]'); - } else if (isTargetAdmin) { - throw new Error('[[error:cant-delete-admin]'); - } - if (!hasAdminPrivilege && ['delete', 'deleteContent'].includes(method)) { - throw new Error('[[error:no-privileges]]'); - } - 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 - }); + const isTargetAdmin = await user.isAdministrator(uid); + const isSelf = parseInt(uid, 10) === parseInt(caller.uid, 10); + const hasAdminPrivilege = await privileges.admin.can('admin:users', caller.uid); + if (isSelf && meta.config.allowAccountDelete !== 1) { + throw new Error('[[error:account-deletion-disabled]]'); + } else if (!isSelf && !hasAdminPrivilege) { + throw new Error('[[error:no-privileges]]'); + } else if (isTargetAdmin) { + throw new Error('[[error:cant-delete-admin]'); + } + if (!hasAdminPrivilege && ['delete', 'deleteContent'].includes(method)) { + throw new Error('[[error:no-privileges]]'); + } + 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 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 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; } 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')) && !isPrivileged) { - throw new Error('[[error:no-privileges]]'); - } - return await user.search({ - uid: caller.uid, - query: data.query, - searchBy: data.searchBy || 'username', - page: data.page || 1, - sortBy: data.sortBy || 'lastonline', - filters: filters - }); + 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({ + uid: caller.uid, + query: data.query, + searchBy: data.searchBy || 'username', + page: data.page || 1, + sortBy: data.sortBy || 'lastonline', + filters: 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(); - 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: type, + picture: undefined, + }); + picture = returnData && returnData.picture; + } + const validBackgrounds = await user.getIconBackgrounds(); + 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']); }; const exportMetadata = new Map([['posts', ['csv', 'text/csv']], ['uploads', ['zip', 'application/zip']], ['profile', ['json', 'application/json']]]); const prepareExport = async ({ - uid, - type + uid, + type, }) => { - const [extension] = exportMetadata.get(type); - const filename = `${uid}_${type}.${extension}`; - try { - const stat = await fs.stat(path.join(__dirname, '../../build/export', filename)); - return stat; - } catch (e) { - return false; - } + const [extension] = exportMetadata.get(type); + const filename = `${uid}_${type}.${extension}`; + try { + const stat = await fs.stat(path.join(__dirname, '../../build/export', filename)); + return stat; + } catch (e) { + return false; + } }; usersAPI.checkExportByType = async (caller, { - uid, - type + uid, + type, }) => await prepareExport({ - uid, - type + uid, + type, }); usersAPI.getExportByType = async (caller, { - uid, - type + uid, + type, }) => { - const [extension, mime] = exportMetadata.get(type); - const filename = `${uid}_${type}.${extension}`; - const exists = await prepareExport({ - uid, - type - }); - if (exists) { - return { - filename, - mime - }; - } - return false; + const [extension, mime] = exportMetadata.get(type); + const filename = `${uid}_${type}.${extension}`; + const exists = await prepareExport({ + uid, + type, + }); + if (exists) { + return { + filename, + mime, + }; + } + return false; }; usersAPI.generateExport = async (caller, { - uid, - type + uid, + type, }) => { - const validTypes = ['profile', 'posts', 'uploads']; - if (!validTypes.includes(type)) { - throw new Error('[[error:invalid-data]]'); - } - if (!utils.isNumber(uid) || !(parseInt(uid, 10) > 0)) { - throw new Error('[[error:invalid-uid]]'); - } - 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 { - displayname - } = await user.getUserFields(uid, ['username']); - const n = await notifications.create({ - bodyShort: `[[notifications:${type}-exported, ${displayname}]]`, - path: `/api/v3/users/${uid}/exports/${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 - }); - }); -}; \ No newline at end of file + const validTypes = ['profile', 'posts', 'uploads']; + if (!validTypes.includes(type)) { + throw new Error('[[error:invalid-data]]'); + } + if (!utils.isNumber(uid) || !(parseInt(uid, 10) > 0)) { + throw new Error('[[error:invalid-uid]]'); + } + 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 { + displayname, + } = await user.getUserFields(uid, ['username']); + const n = await notifications.create({ + bodyShort: `[[notifications:${type}-exported, ${displayname}]]`, + path: `/api/v3/users/${uid}/exports/${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/lib/api/utils.js b/lib/api/utils.js index b447767d26..cc4cb3ce3f 100644 --- a/lib/api/utils.js +++ b/lib/api/utils.js @@ -3,94 +3,95 @@ const db = require('../database'); const user = require('../user'); const srcUtils = require('../utils'); + const utils = module.exports; utils.tokens = {}; utils.tokens.list = async (start = 0, stop = -1) => { - const tokens = await db.getSortedSetRange(`tokens:createtime`, start, stop); - return await utils.tokens.get(tokens); + const tokens = await db.getSortedSetRange(`tokens:createtime`, start, stop); + return await utils.tokens.get(tokens); }; utils.tokens.count = async () => await db.sortedSetCard('tokens:createtime'); -utils.tokens.get = async tokens => { - if (!tokens) { - throw new Error('[[error:invalid-data]]'); - } - let singular = false; - if (!Array.isArray(tokens)) { - tokens = [tokens]; - singular = true; - } - let [tokenObjs, lastSeen] = await Promise.all([db.getObjects(tokens.map(t => `token:${t}`)), utils.tokens.getLastSeen(tokens)]); - tokenObjs = tokenObjs.map((tokenObj, idx) => { - if (!tokenObj) { - return null; - } - tokenObj.token = tokens[idx]; - tokenObj.lastSeen = lastSeen[idx]; - tokenObj.lastSeenISO = lastSeen[idx] ? new Date(lastSeen[idx]).toISOString() : null; - tokenObj.timestampISO = new Date(parseInt(tokenObj.timestamp, 10)).toISOString(); - return tokenObj; - }); - return singular ? tokenObjs[0] : tokenObjs; +utils.tokens.get = async (tokens) => { + if (!tokens) { + throw new Error('[[error:invalid-data]]'); + } + let singular = false; + if (!Array.isArray(tokens)) { + tokens = [tokens]; + singular = true; + } + let [tokenObjs, lastSeen] = await Promise.all([db.getObjects(tokens.map(t => `token:${t}`)), utils.tokens.getLastSeen(tokens)]); + tokenObjs = tokenObjs.map((tokenObj, idx) => { + if (!tokenObj) { + return null; + } + tokenObj.token = tokens[idx]; + tokenObj.lastSeen = lastSeen[idx]; + tokenObj.lastSeenISO = lastSeen[idx] ? new Date(lastSeen[idx]).toISOString() : null; + tokenObj.timestampISO = new Date(parseInt(tokenObj.timestamp, 10)).toISOString(); + return tokenObj; + }); + return singular ? tokenObjs[0] : tokenObjs; }; utils.tokens.generate = async ({ - uid, - description + uid, + description, }) => { - if (parseInt(uid, 10) !== 0) { - const uidExists = await user.exists(uid); - if (!uidExists) { - throw new Error('[[error:no-user]]'); - } - } - const token = srcUtils.generateUUID(); - const timestamp = Date.now(); - return utils.tokens.add({ - token, - uid, - description, - timestamp - }); + if (parseInt(uid, 10) !== 0) { + const uidExists = await user.exists(uid); + if (!uidExists) { + throw new Error('[[error:no-user]]'); + } + } + const token = srcUtils.generateUUID(); + const timestamp = Date.now(); + return utils.tokens.add({ + token, + uid, + description, + timestamp, + }); }; utils.tokens.add = async ({ - token, - uid, - description = '', - timestamp = Date.now() + token, + uid, + description = '', + timestamp = Date.now(), }) => { - if (!token || uid === undefined) { - throw new Error('[[error:invalid-data]]'); - } - await Promise.all([db.setObject(`token:${token}`, { - uid, - description, - timestamp - }), db.sortedSetAdd(`tokens:createtime`, timestamp, token), db.sortedSetAdd(`tokens:uid`, uid, token)]); - return token; + if (!token || uid === undefined) { + throw new Error('[[error:invalid-data]]'); + } + await Promise.all([db.setObject(`token:${token}`, { + uid, + description, + timestamp, + }), db.sortedSetAdd(`tokens:createtime`, timestamp, token), db.sortedSetAdd(`tokens:uid`, uid, token)]); + return token; }; utils.tokens.update = async (token, { - uid, - description + uid, + description, }) => { - await Promise.all([db.setObject(`token:${token}`, { - uid, - description - }), db.sortedSetAdd(`tokens:uid`, uid, token)]); - return await utils.tokens.get(token); + await Promise.all([db.setObject(`token:${token}`, { + uid, + description, + }), db.sortedSetAdd(`tokens:uid`, uid, token)]); + return await utils.tokens.get(token); }; -utils.tokens.roll = async token => { - const [createTime, uid, lastSeen] = await db.sortedSetsScore([`tokens:createtime`, `tokens:uid`, `tokens:lastSeen`], token); - const newToken = srcUtils.generateUUID(); - const updates = [db.rename(`token:${token}`, `token:${newToken}`), db.sortedSetsRemove([`tokens:createtime`, `tokens:uid`, `tokens:lastSeen`], token), db.sortedSetAdd(`tokens:createtime`, createTime, newToken), db.sortedSetAdd(`tokens:uid`, uid, newToken)]; - if (lastSeen) { - updates.push(db.sortedSetAdd(`tokens:lastSeen`, lastSeen, newToken)); - } - await Promise.all(updates); - return newToken; +utils.tokens.roll = async (token) => { + const [createTime, uid, lastSeen] = await db.sortedSetsScore([`tokens:createtime`, `tokens:uid`, `tokens:lastSeen`], token); + const newToken = srcUtils.generateUUID(); + const updates = [db.rename(`token:${token}`, `token:${newToken}`), db.sortedSetsRemove([`tokens:createtime`, `tokens:uid`, `tokens:lastSeen`], token), db.sortedSetAdd(`tokens:createtime`, createTime, newToken), db.sortedSetAdd(`tokens:uid`, uid, newToken)]; + if (lastSeen) { + updates.push(db.sortedSetAdd(`tokens:lastSeen`, lastSeen, newToken)); + } + await Promise.all(updates); + return newToken; }; -utils.tokens.delete = async token => { - await Promise.all([db.delete(`token:${token}`), db.sortedSetsRemove([`tokens:createtime`, `tokens:uid`, `tokens:lastSeen`], token)]); +utils.tokens.delete = async (token) => { + await Promise.all([db.delete(`token:${token}`), db.sortedSetsRemove([`tokens:createtime`, `tokens:uid`, `tokens:lastSeen`], token)]); }; -utils.tokens.log = async token => { - await db.sortedSetAdd('tokens:lastSeen', Date.now(), token); +utils.tokens.log = async (token) => { + await db.sortedSetAdd('tokens:lastSeen', Date.now(), token); }; -utils.tokens.getLastSeen = async tokens => await db.sortedSetScores('tokens:lastSeen', tokens); \ No newline at end of file +utils.tokens.getLastSeen = async tokens => await db.sortedSetScores('tokens:lastSeen', tokens); diff --git a/lib/batch.js b/lib/batch.js index 69ff462201..c350f32c61 100644 --- a/lib/batch.js +++ b/lib/batch.js @@ -3,72 +3,73 @@ const util = require('util'); const db = require('./database'); const utils = require('./utils'); + const DEFAULT_BATCH_SIZE = 100; const sleep = util.promisify(setTimeout); exports.processSortedSet = async function (setKey, process, options) { - options = options || {}; - if (typeof process !== 'function') { - throw new Error('[[error:process-not-a-function]]'); - } - if (options.progress) { - options.progress.total = await db.sortedSetCard(setKey); - } - options.batch = options.batch || DEFAULT_BATCH_SIZE; - options.reverse = options.reverse || false; - if (db.processSortedSet && typeof options.doneIf !== 'function' && !utils.isNumber(options.alwaysStartAt)) { - return await db.processSortedSet(setKey, process, options); - } - options.doneIf = typeof options.doneIf === 'function' ? options.doneIf : function () {}; - let start = 0; - let stop = options.batch - 1; - if (process && process.constructor && process.constructor.name !== 'AsyncFunction') { - process = util.promisify(process); - } - const method = options.reverse ? 'getSortedSetRevRange' : 'getSortedSetRange'; - const isByScore = options.min && options.min !== '-inf' || options.max && options.max !== '+inf'; - const byScore = isByScore ? 'ByScore' : ''; - const withScores = options.withScores ? 'WithScores' : ''; - let iteration = 1; - const getFn = db[`${method}${byScore}${withScores}`]; - while (true) { - const ids = await getFn(setKey, start, isByScore ? stop - start + 1 : stop, options.reverse ? options.max : options.min, options.reverse ? options.min : options.max); - if (!ids.length || options.doneIf(start, stop, ids)) { - return; - } - if (iteration > 1 && options.interval) { - await sleep(options.interval); - } - await process(ids); - iteration += 1; - start += utils.isNumber(options.alwaysStartAt) ? options.alwaysStartAt : options.batch; - stop = start + options.batch - 1; - } + options = options || {}; + if (typeof process !== 'function') { + throw new Error('[[error:process-not-a-function]]'); + } + if (options.progress) { + options.progress.total = await db.sortedSetCard(setKey); + } + options.batch = options.batch || DEFAULT_BATCH_SIZE; + options.reverse = options.reverse || false; + if (db.processSortedSet && typeof options.doneIf !== 'function' && !utils.isNumber(options.alwaysStartAt)) { + return await db.processSortedSet(setKey, process, options); + } + options.doneIf = typeof options.doneIf === 'function' ? options.doneIf : function () {}; + let start = 0; + let stop = options.batch - 1; + if (process && process.constructor && process.constructor.name !== 'AsyncFunction') { + process = util.promisify(process); + } + const method = options.reverse ? 'getSortedSetRevRange' : 'getSortedSetRange'; + const isByScore = options.min && options.min !== '-inf' || options.max && options.max !== '+inf'; + const byScore = isByScore ? 'ByScore' : ''; + const withScores = options.withScores ? 'WithScores' : ''; + let iteration = 1; + const getFn = db[`${method}${byScore}${withScores}`]; + while (true) { + const ids = await getFn(setKey, start, isByScore ? stop - start + 1 : stop, options.reverse ? options.max : options.min, options.reverse ? options.min : options.max); + if (!ids.length || options.doneIf(start, stop, ids)) { + return; + } + if (iteration > 1 && options.interval) { + await sleep(options.interval); + } + await process(ids); + iteration += 1; + start += utils.isNumber(options.alwaysStartAt) ? options.alwaysStartAt : options.batch; + stop = start + options.batch - 1; + } }; exports.processArray = async function (array, process, options) { - options = options || {}; - if (!Array.isArray(array) || !array.length) { - return; - } - if (typeof process !== 'function') { - throw new Error('[[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); - } - let iteration = 1; - while (true) { - const currentBatch = array.slice(start, start + batch); - if (!currentBatch.length) { - return; - } - if (iteration > 1 && options.interval) { - await sleep(options.interval); - } - await process(currentBatch); - start += batch; - iteration += 1; - } + options = options || {}; + if (!Array.isArray(array) || !array.length) { + return; + } + if (typeof process !== 'function') { + throw new Error('[[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); + } + let iteration = 1; + while (true) { + const currentBatch = array.slice(start, start + batch); + if (!currentBatch.length) { + return; + } + if (iteration > 1 && options.interval) { + await sleep(options.interval); + } + await process(currentBatch); + start += batch; + iteration += 1; + } }; -require('./promisify')(exports); \ No newline at end of file +require('./promisify')(exports); diff --git a/lib/cache.js b/lib/cache.js index 6d9b8d3d9f..996faf9237 100644 --- a/lib/cache.js +++ b/lib/cache.js @@ -1,8 +1,9 @@ 'use strict'; const cacheCreate = require('./cache/lru'); + module.exports = cacheCreate({ - name: 'local', - max: 40000, - ttl: 0 -}); \ No newline at end of file + name: 'local', + max: 40000, + ttl: 0, +}); diff --git a/lib/cache/lru.js b/lib/cache/lru.js index c2550d55fb..e3f62688e7 100644 --- a/lib/cache/lru.js +++ b/lib/cache/lru.js @@ -1,114 +1,114 @@ 'use strict'; module.exports = function (opts) { - const { - LRUCache - } = require('lru-cache'); - const pubsub = require('../pubsub'); - const winston = require('winston'); - const chalk = require('chalk'); - 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.`); - opts[newProp] = opts[oldProp]; - delete opts[oldProp]; - } - }); - const lruCache = new LRUCache(opts); - const cache = {}; - cache.name = opts.name; - cache.hits = 0; - cache.misses = 0; - cache.enabled = opts.hasOwnProperty('enabled') ? opts.enabled : true; - const cacheSet = lruCache.set; - 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]; - }, - configurable: true, - enumerable: true - }); - }); - cache.set = function (key, value, ttl) { - if (!cache.enabled) { - return; - } - const opts = {}; - if (ttl) { - opts.ttl = ttl; - } - cacheSet.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; - cache.reset = function () { - pubsub.publish(`${cache.name}:lruCache:reset`); - localReset(); - }; - cache.clear = cache.reset; - function localReset() { - lruCache.clear(); - cache.hits = 0; - cache.misses = 0; - } - pubsub.on(`${cache.name}:lruCache:reset`, () => { - localReset(); - }); - pubsub.on(`${cache.name}:lruCache:del`, keys => { - if (Array.isArray(keys)) { - keys.forEach(key => lruCache.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 lruCache.dump(); - }; - cache.peek = function (key) { - return lruCache.peek(key); - }; - return cache; -}; \ No newline at end of file + const { + LRUCache, + } = require('lru-cache'); + const pubsub = require('../pubsub'); + const winston = require('winston'); + const chalk = require('chalk'); + 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.`); + opts[newProp] = opts[oldProp]; + delete opts[oldProp]; + } + }); + const lruCache = new LRUCache(opts); + const cache = {}; + cache.name = opts.name; + cache.hits = 0; + cache.misses = 0; + cache.enabled = opts.hasOwnProperty('enabled') ? opts.enabled : true; + const cacheSet = lruCache.set; + 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]; + }, + configurable: true, + enumerable: true, + }); + }); + cache.set = function (key, value, ttl) { + if (!cache.enabled) { + return; + } + const opts = {}; + if (ttl) { + opts.ttl = ttl; + } + cacheSet.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; + cache.reset = function () { + pubsub.publish(`${cache.name}:lruCache:reset`); + localReset(); + }; + cache.clear = cache.reset; + function localReset() { + lruCache.clear(); + cache.hits = 0; + cache.misses = 0; + } + pubsub.on(`${cache.name}:lruCache:reset`, () => { + localReset(); + }); + pubsub.on(`${cache.name}:lruCache:del`, (keys) => { + if (Array.isArray(keys)) { + keys.forEach(key => lruCache.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 lruCache.dump(); + }; + cache.peek = function (key) { + return lruCache.peek(key); + }; + return cache; +}; diff --git a/lib/cache/ttl.js b/lib/cache/ttl.js index b9da5d239e..cb8767da27 100644 --- a/lib/cache/ttl.js +++ b/lib/cache/ttl.js @@ -1,106 +1,106 @@ '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; - 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.has = key => { - if (!cache.enabled) { - return false; - } - return ttlCache.has(key); - }; - 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; -}; \ No newline at end of file + 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; + 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.has = (key) => { + if (!cache.enabled) { + return false; + } + return ttlCache.has(key); + }; + 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; +}; diff --git a/lib/cacheCreate.js b/lib/cacheCreate.js index 5f1c96f14e..14a5a7a79b 100644 --- a/lib/cacheCreate.js +++ b/lib/cacheCreate.js @@ -1,3 +1,3 @@ 'use strict'; -module.exports = require('./cache/lru'); \ No newline at end of file +module.exports = require('./cache/lru'); diff --git a/lib/categories/activeusers.js b/lib/categories/activeusers.js index 154e596f48..2ef1de9211 100644 --- a/lib/categories/activeusers.js +++ b/lib/categories/activeusers.js @@ -3,13 +3,14 @@ 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)); - }; -}; \ No newline at end of file + 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)); + }; +}; diff --git a/lib/categories/create.js b/lib/categories/create.js index 7314463e46..bfc86227f6 100644 --- a/lib/categories/create.js +++ b/lib/categories/create.js @@ -8,181 +8,182 @@ const privileges = require('../privileges'); const utils = require('../utils'); 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; - 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-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'); - await clearParentCategoryCache(parentCid); - 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 clearParentCategoryCache(parentCid) { - while (parseInt(parentCid, 10) >= 0) { - cache.del([`cid:${parentCid}:children`, `cid:${parentCid}:children:all`]); - if (parseInt(parentCid, 10) === 0) { - return; - } - parentCid = await Categories.getCategoryField(parentCid, 'parentCid'); - } - } - 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 = privileges.categories.getPrivilegesByFilter(filter); - if (group) { - privsToCopy = privsToCopy.map(priv => `groups:${priv}`); - } else { - privsToCopy = privsToCopy.concat(privsToCopy.map(priv => `groups:${priv}`)); - } - 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); - } -}; \ No newline at end of file + 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; + 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-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'); + await clearParentCategoryCache(parentCid); + 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 clearParentCategoryCache(parentCid) { + while (parseInt(parentCid, 10) >= 0) { + cache.del([`cid:${parentCid}:children`, `cid:${parentCid}:children:all`]); + if (parseInt(parentCid, 10) === 0) { + return; + } + parentCid = await Categories.getCategoryField(parentCid, 'parentCid'); + } + } + 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 = privileges.categories.getPrivilegesByFilter(filter); + if (group) { + privsToCopy = privsToCopy.map(priv => `groups:${priv}`); + } else { + privsToCopy = privsToCopy.concat(privsToCopy.map(priv => `groups:${priv}`)); + } + 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); + } +}; diff --git a/lib/categories/data.js b/lib/categories/data.js index ca59ece503..be6b8a087b 100644 --- a/lib/categories/data.js +++ b/lib/categories/data.js @@ -5,80 +5,81 @@ 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']; 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) { + 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); + }; }; 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 || 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; - } -} \ No newline at end of file + 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; + } +} diff --git a/lib/categories/delete.js b/lib/categories/delete.js index 9351b3c11a..185f020172 100644 --- a/lib/categories/delete.js +++ b/lib/categories/delete.js @@ -8,52 +8,53 @@ const topics = require('../topics'); const groups = require('../groups'); 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 - }); - 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 - }); - }; - 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); - 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}`)); - } - async function removeFromParent(cid) { - const [parentCid, children] = await Promise.all([Categories.getCategoryField(cid, 'parentCid'), db.getSortedSetRange(`cid:${cid}:children`, 0, -1)]); - const bulkAdd = []; - const childrenKeys = children.map(cid => { - bulkAdd.push(['cid:0:children', cid, cid]); - return `category:${cid}`; - }); - await Promise.all([db.sortedSetRemove(`cid:${parentCid}:children`, cid), db.setObjectField(childrenKeys, 'parentCid', 0), db.sortedSetAddBulk(bulkAdd)]); - 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`); - } -}; \ No newline at end of file + 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, + }); + }; + 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); + 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}`)); + } + async function removeFromParent(cid) { + const [parentCid, children] = await Promise.all([Categories.getCategoryField(cid, 'parentCid'), db.getSortedSetRange(`cid:${cid}:children`, 0, -1)]); + const bulkAdd = []; + const childrenKeys = children.map((cid) => { + bulkAdd.push(['cid:0:children', cid, cid]); + return `category:${cid}`; + }); + await Promise.all([db.sortedSetRemove(`cid:${parentCid}:children`, cid), db.setObjectField(childrenKeys, 'parentCid', 0), db.sortedSetAddBulk(bulkAdd)]); + 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/lib/categories/index.js b/lib/categories/index.js index ea14605a80..4df113cdb9 100644 --- a/lib/categories/index.js +++ b/lib/categories/index.js @@ -8,6 +8,7 @@ const plugins = require('../plugins'); const privileges = require('../privileges'); const cache = require('../cache'); const meta = require('../meta'); + const Categories = module.exports; require('./data')(Categories); require('./create')(Categories); @@ -19,330 +20,331 @@ require('./recentreplies')(Categories); require('./update')(Categories); 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]); - 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.isTracked = watchState[0] === Categories.watchStates.tracking; - 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]); + 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.isTracked = watchState[0] === Categories.watchStates.tracking; + 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, + }; }; 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 => parseInt(cid, 10)); + cache.set(key, cids); + return cids.slice(); }; Categories.getAllCategories = async function () { - const cids = await Categories.getAllCidsFromSet('categories:cid'); - return await Categories.getCategories(cids); + const cids = await Categories.getAllCidsFromSet('categories:cid'); + return await Categories.getCategories(cids); }; 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); + const cids = await Categories.getCidsByPrivilege(set, uid, privilege); + return await Categories.getCategories(cids); }; 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) { - return await privileges.categories.getUidsWithPrivilege(cids, 'moderate'); + return await privileges.categories.getUidsWithPrivilege(cids, 'moderate'); }; Categories.getCategories = async function (cids) { - if (!Array.isArray(cids)) { - throw new Error('[[error:invalid-cid]]'); - } - if (!cids.length) { - return []; - } - const [categories, tagWhitelist] = await Promise.all([Categories.getCategoriesData(cids), Categories.getTagWhitelist(cids)]); - categories.forEach((category, i) => { - if (category) { - category.tagWhitelist = tagWhitelist[i]; - } - }); - return categories; + if (!Array.isArray(cids)) { + throw new Error('[[error:invalid-cid]]'); + } + if (!cids.length) { + return []; + } + const [categories, tagWhitelist] = await Promise.all([Categories.getCategoriesData(cids), Categories.getTagWhitelist(cids)]); + categories.forEach((category, i) => { + if (category) { + category.tagWhitelist = tagWhitelist[i]; + } + }); + return categories; }; Categories.setUnread = async function (tree, cids, uid) { - if (uid <= 0) { - return; - } - const { - unreadCids - } = await topics.getUnreadData({ - uid: uid, - cid: cids - }); - if (!unreadCids.length) { - return; - } - function setCategoryUnread(category) { - if (category) { - category.unread = false; - if (unreadCids.includes(category.cid)) { - category.unread = category.topic_count > 0 && true; - } else if (category.children.length) { - category.children.forEach(setCategoryUnread); - category.unread = category.children.some(c => c && c.unread); - } - category['unread-class'] = category.unread ? 'unread' : ''; - } - } - tree.forEach(setCategoryUnread); + if (uid <= 0) { + return; + } + const { + unreadCids, + } = await topics.getUnreadData({ + uid: uid, + cid: cids, + }); + if (!unreadCids.length) { + return; + } + function setCategoryUnread(category) { + if (category) { + category.unread = false; + if (unreadCids.includes(category.cid)) { + category.unread = category.topic_count > 0 && true; + } else if (category.children.length) { + category.children.forEach(setCategoryUnread); + category.unread = category.children.some(c => c && c.unread); + } + category['unread-class'] = category.unread ? 'unread' : ''; + } + } + tree.forEach(setCategoryUnread); }; 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) { + 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]); }; Categories.filterTagWhitelist = function (tagWhitelist, isAdminOrMod) { - const systemTags = (meta.config.systemTags || '').split(','); - if (!isAdminOrMod && systemTags.length) { - return tagWhitelist.filter(tag => !systemTags.includes(tag)); - } - return tagWhitelist; + const systemTags = (meta.config.systemTags || '').split(','); + if (!isAdminOrMod && systemTags.length) { + return tagWhitelist.filter(tag => !systemTags.includes(tag)); + } + return tagWhitelist; }; 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)) { + category.children.forEach((child) => { + calculateTopicPostCount(child); + postCount += parseInt(child.totalPostCount, 10) || 0; + topicCount += 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) { + 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); - 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 => 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); + Categories.getTree([category].concat(childrenData), category.parentCid); } Categories.getChildrenTree = getChildrenTree; Categories.getParentCids = async function (currentCid) { - let cid = currentCid; - const parents = []; - while (parseInt(cid, 10)) { - cid = await Categories.getCategoryField(cid, 'parentCid'); - if (cid) { - parents.unshift(cid); - } - } - return parents; + let cid = currentCid; + const parents = []; + while (parseInt(cid, 10)) { + cid = await Categories.getCategoryField(cid, 'parentCid'); + 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(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(); }; 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); - } - } - }); + categoryData.forEach((category) => { + if (category) { + allCategories.push(category); + if (Array.isArray(category.children) && category.children.length) { + Categories.flattenCategories(allCategories, category.children); + } + } + }); }; 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 = 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; }; 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.sort((a, b) => { - if (a.order !== b.order) { - return a.order - b.order; - } - return a.cid - b.cid; - }); - 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)) { + 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.sort((a, b) => { + if (a.order !== b.order) { + return a.order - b.order; + } + return a.cid - b.cid; + }); + 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)); }; -require('../promisify')(Categories); \ No newline at end of file +require('../promisify')(Categories); diff --git a/lib/categories/recentreplies.js b/lib/categories/recentreplies.js index 95f0958bb7..4d5b0731b3 100644 --- a/lib/categories/recentreplies.js +++ b/lib/categories/recentreplies.js @@ -8,155 +8,156 @@ const topics = require('../topics'); const privileges = require('../privileges'); const plugins = require('../plugins'); const batch = require('../batch'); + module.exports = function (Categories) { - Categories.getRecentReplies = async function (cid, uid, start, stop) { - 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 { - 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 = topicData[index].tid; - teaser.uid = topicData[index].uid; - teaser.topic = { - tid: topicData[index].tid, - 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.timestamp - a.timestamp).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.timestamp - a.timestamp); - 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)); - } - Categories.moveRecentReplies = async function (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 - }); - }; -}; \ No newline at end of file + Categories.getRecentReplies = async function (cid, uid, start, stop) { + 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 { + 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 = topicData[index].tid; + teaser.uid = topicData[index].uid; + teaser.topic = { + tid: topicData[index].tid, + 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.timestamp - a.timestamp).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.timestamp - a.timestamp); + 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)); + } + Categories.moveRecentReplies = async function (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, + }); + }; +}; diff --git a/lib/categories/search.js b/lib/categories/search.js index d903318100..e870487735 100644 --- a/lib/categories/search.js +++ b/lib/categories/search.js @@ -4,66 +4,67 @@ 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; - const startTime = process.hrtime(); - let cids = await findCids(query, data.hardCap); - const result = await plugins.hooks.fire('filter:categories.search', { - data: data, - cids: cids, - uid: uid - }); - cids = await privileges.categories.filterCids('find', result.cids, uid); - const searchResult = { - matchCount: cids.length - }; - 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 childrenCids = await getChildrenCids(cids, uid); - const uniqCids = _.uniq(cids.concat(childrenCids)); - const categoryData = await Categories.getCategories(uniqCids); - 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; - }); - } - }); - 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 []; - } - const data = await db.getSortedSetScan({ - key: 'categories:name', - match: `*${String(query).toLowerCase()}*`, - limit: hardCap || 500 - }); - return data.map(data => 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); - } -}; \ No newline at end of file + 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 result = await plugins.hooks.fire('filter:categories.search', { + data: data, + cids: cids, + uid: uid, + }); + cids = await privileges.categories.filterCids('find', result.cids, uid); + const searchResult = { + matchCount: cids.length, + }; + 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 childrenCids = await getChildrenCids(cids, uid); + const uniqCids = _.uniq(cids.concat(childrenCids)); + const categoryData = await Categories.getCategories(uniqCids); + 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; + }); + } + }); + 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 []; + } + const data = await db.getSortedSetScan({ + key: 'categories:name', + match: `*${String(query).toLowerCase()}*`, + limit: hardCap || 500, + }); + return data.map(data => 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); + } +}; diff --git a/lib/categories/topics.js b/lib/categories/topics.js index d8ae4f9626..a63c9934b8 100644 --- a/lib/categories/topics.js +++ b/lib/categories/topics.js @@ -9,233 +9,234 @@ const user = require('../user'); const notifications = require('../notifications'); const translator = require('../translator'); const batch = require('../batch'); + 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); - const userRoles = await Promise.all(topicsData.map(async topic => { - const isAdmin = await user.isAdministrator(topic.uid); - const isMod = await user.isModerator(topic.uid); - if (isAdmin) { - return 'Administrator'; - } else if (isMod) { - return 'Moderator'; - } - return 'user'; - })); - topicsData.forEach((topic, index) => { - topic.role = userRoles[index] || 'user'; - }); - 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 [pinnedTids, set] = await Promise.all([Categories.getPinnedTids({ - ...data, - start: 0, - stop: -1 - }), Categories.buildTopicsSortedSet(data)]); - 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; - if (Array.isArray(set)) { - const weights = set.map((s, index) => index ? 0 : 1); - normalTids = await db.getSortedSetRevIntersect({ - sets: set, - start: start, - stop: stop, - weights: weights - }); - } else { - normalTids = await db.getSortedSetRevRange(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; - const sort = data.sort || data.settings && data.settings.categoryTopicSort || meta.config.categoryTopicSort || 'recently_replied'; - const sortToSet = { - recently_replied: `cid:${cid}:tids`, - recently_created: `cid:${cid}:tids:create`, - most_posts: `cid:${cid}:tids:posts`, - most_votes: `cid:${cid}:tids:votes`, - most_views: `cid:${cid}:tids:views` - }; - let set = sortToSet.hasOwnProperty(sort) ? sortToSet[sort] : `cid:${cid}:tids`; - 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) { - console.warn('[deprecated] Will be removed in 4.x'); - sort = sort || 'recently_replied'; - 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.isOwner) { - 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.unread = false; - 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); - }; - Categories.onTopicsMoved = async cids => { - await Promise.all(cids.map(async cid => { - await Promise.all([Categories.setCategoryField(cid, 'topic_count', await db.sortedSetCard(`cid:${cid}:tids:lastposttime`)), Categories.setCategoryField(cid, 'post_count', await db.sortedSetCard(`cid:${cid}:pids`)), 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.notifyCategoryFollowers = async (postData, exceptUid) => { - const { - cid - } = postData.topic; - const followers = []; - await batch.processSortedSet(`cid:${cid}:uid:watch:state`, async uids => { - followers.push(...(await privileges.categories.filterUids('topics:read', cid, uids))); - }, { - batch: 500, - min: Categories.watchStates.watching, - max: Categories.watchStates.watching - }); - const index = followers.indexOf(String(exceptUid)); - if (index !== -1) { - followers.splice(index, 1); - } - if (!followers.length) { - return; - } - const { - displayname - } = postData.user; - const categoryName = await Categories.getCategoryField(cid, 'name'); - const notifBase = 'notifications:user-posted-topic-in-category'; - const bodyShort = translator.compile(notifBase, displayname, categoryName); - const notification = await notifications.create({ - type: 'new-topic-in-category', - nid: `new_topic:tid:${postData.topic.tid}:uid:${exceptUid}`, - bodyShort: bodyShort, - bodyLong: postData.content, - pid: postData.pid, - path: `/post/${postData.pid}`, - tid: postData.topic.tid, - from: exceptUid - }); - notifications.push(notification, followers); - }; -}; \ No newline at end of file + 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); + const userRoles = await Promise.all(topicsData.map(async (topic) => { + const isAdmin = await user.isAdministrator(topic.uid); + const isMod = await user.isModerator(topic.uid); + if (isAdmin) { + return 'Administrator'; + } else if (isMod) { + return 'Moderator'; + } + return 'user'; + })); + topicsData.forEach((topic, index) => { + topic.role = userRoles[index] || 'user'; + }); + 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 [pinnedTids, set] = await Promise.all([Categories.getPinnedTids({ + ...data, + start: 0, + stop: -1, + }), Categories.buildTopicsSortedSet(data)]); + 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; + if (Array.isArray(set)) { + const weights = set.map((s, index) => (index ? 0 : 1)); + normalTids = await db.getSortedSetRevIntersect({ + sets: set, + start: start, + stop: stop, + weights: weights, + }); + } else { + normalTids = await db.getSortedSetRevRange(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; + const sort = data.sort || data.settings && data.settings.categoryTopicSort || meta.config.categoryTopicSort || 'recently_replied'; + const sortToSet = { + recently_replied: `cid:${cid}:tids`, + recently_created: `cid:${cid}:tids:create`, + most_posts: `cid:${cid}:tids:posts`, + most_votes: `cid:${cid}:tids:votes`, + most_views: `cid:${cid}:tids:views`, + }; + let set = sortToSet.hasOwnProperty(sort) ? sortToSet[sort] : `cid:${cid}:tids`; + 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) { + console.warn('[deprecated] Will be removed in 4.x'); + sort = sort || 'recently_replied'; + 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.isOwner) { + 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.unread = false; + 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); + }; + Categories.onTopicsMoved = async (cids) => { + await Promise.all(cids.map(async (cid) => { + await Promise.all([Categories.setCategoryField(cid, 'topic_count', await db.sortedSetCard(`cid:${cid}:tids:lastposttime`)), Categories.setCategoryField(cid, 'post_count', await db.sortedSetCard(`cid:${cid}:pids`)), 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.notifyCategoryFollowers = async (postData, exceptUid) => { + const { + cid, + } = postData.topic; + const followers = []; + await batch.processSortedSet(`cid:${cid}:uid:watch:state`, async (uids) => { + followers.push(...(await privileges.categories.filterUids('topics:read', cid, uids))); + }, { + batch: 500, + min: Categories.watchStates.watching, + max: Categories.watchStates.watching, + }); + const index = followers.indexOf(String(exceptUid)); + if (index !== -1) { + followers.splice(index, 1); + } + if (!followers.length) { + return; + } + const { + displayname, + } = postData.user; + const categoryName = await Categories.getCategoryField(cid, 'name'); + const notifBase = 'notifications:user-posted-topic-in-category'; + const bodyShort = translator.compile(notifBase, displayname, categoryName); + const notification = await notifications.create({ + type: 'new-topic-in-category', + nid: `new_topic:tid:${postData.topic.tid}:uid:${exceptUid}`, + bodyShort: bodyShort, + bodyLong: postData.content, + pid: postData.pid, + path: `/post/${postData.pid}`, + tid: postData.topic.tid, + from: exceptUid, + }); + notifications.push(notification, followers); + }; +}; diff --git a/lib/categories/unread.js b/lib/categories/unread.js index 4f4fcbc16e..21c7fac074 100644 --- a/lib/categories/unread.js +++ b/lib/categories/unread.js @@ -1,37 +1,38 @@ 'use strict'; const db = require('../database'); + module.exports = function (Categories) { - Categories.markAsRead = async function (cids, uid) { - console.warn('[deprecated] Categories.markAsRead deprecated'); - 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) { - console.warn('[deprecated] Categories.markAsUnreadForAll deprecated'); - if (!parseInt(cid, 10)) { - return; - } - await db.delete(`cid:${cid}:read_by_uid`); - }; - Categories.hasReadCategories = async function (cids, uid) { - console.warn('[deprecated] Categories.hasReadCategories deprecated, see Categories.setUnread'); - 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) { - console.warn('[deprecated] Categories.hasReadCategory deprecated, see Categories.setUnread'); - if (parseInt(uid, 10) <= 0) { - return false; - } - return await db.isSetMember(`cid:${cid}:read_by_uid`, uid); - }; -}; \ No newline at end of file + Categories.markAsRead = async function (cids, uid) { + console.warn('[deprecated] Categories.markAsRead deprecated'); + 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) { + console.warn('[deprecated] Categories.markAsUnreadForAll deprecated'); + if (!parseInt(cid, 10)) { + return; + } + await db.delete(`cid:${cid}:read_by_uid`); + }; + Categories.hasReadCategories = async function (cids, uid) { + console.warn('[deprecated] Categories.hasReadCategories deprecated, see Categories.setUnread'); + 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) { + console.warn('[deprecated] Categories.hasReadCategory deprecated, see Categories.setUnread'); + if (parseInt(uid, 10) <= 0) { + return false; + } + return await db.isSetMember(`cid:${cid}:read_by_uid`, uid); + }; +}; diff --git a/lib/categories/update.js b/lib/categories/update.js index 26385c8a07..243a107e58 100644 --- a/lib/categories/update.js +++ b/lib/categories/update.js @@ -7,105 +7,106 @@ const slugify = require('../slugify'); const translator = require('../translator'); 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); - 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) { - 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]]'); - } - if (childrenCids.length > 1) { - childrenCids.splice(Math.max(0, order - 1), 0, childrenCids.splice(currentIndex, 1)[0]); - } - 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); - } -}; \ No newline at end of file + 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); + 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) { + 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]]'); + } + if (childrenCids.length > 1) { + childrenCids.splice(Math.max(0, order - 1), 0, childrenCids.splice(currentIndex, 1)[0]); + } + 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/lib/categories/watch.js b/lib/categories/watch.js index c7d551087f..d6c252193b 100644 --- a/lib/categories/watch.js +++ b/lib/categories/watch.js @@ -2,42 +2,43 @@ const db = require('../database'); const user = require('../user'); + module.exports = function (Categories) { - Categories.watchStates = { - ignoring: 1, - notwatching: 2, - tracking: 3, - watching: 4 - }; - 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]); - }; -}; \ No newline at end of file + Categories.watchStates = { + ignoring: 1, + notwatching: 2, + tracking: 3, + watching: 4, + }; + 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]); + }; +}; diff --git a/lib/cli/colors.js b/lib/cli/colors.js index 4e72381102..165099c8de 100644 --- a/lib/cli/colors.js +++ b/lib/cli/colors.js @@ -1,115 +1,116 @@ 'use strict'; const { - Command + Command, } = require('commander'); const chalk = require('chalk'); + const colors = [{ - command: 'yellow', - option: 'cyan', - arg: 'magenta' + command: 'yellow', + option: 'cyan', + arg: 'magenta', }, { - command: 'green', - option: 'blue', - arg: 'red' + command: 'green', + option: 'blue', + arg: 'red', }, { - command: 'yellow', - option: 'cyan', - arg: 'magenta' + command: 'yellow', + option: 'cyan', + arg: 'magenta', }, { - command: 'green', - option: 'blue', - arg: 'red' + command: 'green', + option: 'blue', + arg: 'red', }]; function humanReadableArgName(arg) { - const nameOutput = arg.name() + (arg.variadic === true ? '...' : ''); - return arg.required ? `<${nameOutput}>` : `[${nameOutput}]`; + const nameOutput = arg.name() + (arg.variadic === true ? '...' : ''); + return arg.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; } 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(); - 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; - } - 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(); - 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]' : '') + 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; - 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)); - } - let output = [`Usage: ${helper.commandUsage(cmd)}`, '']; - const commandDescription = helper.commandDescription(cmd); - if (commandDescription.length > 0) { - output = output.concat([commandDescription, '']); - } - 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), '']); - } - 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), '']); - } - 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'); - } -}; \ No newline at end of file + commandUsage(cmd) { + const depth = cmd.depth(); + 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; + } + 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(); + 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]' : '') + 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; + 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)); + } + let output = [`Usage: ${helper.commandUsage(cmd)}`, '']; + const commandDescription = helper.commandDescription(cmd); + if (commandDescription.length > 0) { + output = output.concat([commandDescription, '']); + } + 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), '']); + } + 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), '']); + } + 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'); + }, +};