From d9493e082e16edad4082ecbd17d24c02de6aba42 Mon Sep 17 00:00:00 2001 From: Vicky Chen Date: Thu, 24 Oct 2024 10:30:42 -0400 Subject: [PATCH] successfully installed and add flow dependency to package.json file --- .babelrc | 4 + .flowconfig | 14 + lib/admin/search.js | 96 +++++ lib/admin/versions.js | 40 ++ lib/als.js | 7 + lib/analytics.js | 235 +++++++++++ lib/api/admin.js | 45 ++ lib/api/categories.js | 226 ++++++++++ lib/api/chats.js | 414 ++++++++++++++++++ lib/api/files.js | 10 + lib/api/flags.js | 103 +++++ lib/api/groups.js | 335 +++++++++++++++ lib/api/helpers.js | 121 ++++++ lib/api/index.js | 16 + lib/api/posts.js | 416 ++++++++++++++++++ lib/api/search.js | 170 ++++++++ lib/api/tags.js | 10 + lib/api/topics.js | 285 +++++++++++++ lib/api/users.js | 717 ++++++++++++++++++++++++++++++++ lib/api/utils.js | 96 +++++ lib/batch.js | 74 ++++ lib/cache.js | 8 + lib/cache/lru.js | 114 +++++ lib/cache/ttl.js | 106 +++++ lib/cacheCreate.js | 3 + lib/categories/activeusers.js | 15 + lib/categories/create.js | 188 +++++++++ lib/categories/data.js | 84 ++++ lib/categories/delete.js | 59 +++ lib/categories/index.js | 348 ++++++++++++++++ lib/categories/recentreplies.js | 162 ++++++++ lib/categories/search.js | 69 +++ lib/categories/topics.js | 228 ++++++++++ lib/categories/unread.js | 37 ++ lib/categories/update.js | 111 +++++ lib/categories/watch.js | 43 ++ lib/cli/colors.js | 115 +++++ 37 files changed, 5124 insertions(+) create mode 100644 .babelrc create mode 100644 .flowconfig create mode 100644 lib/admin/search.js create mode 100644 lib/admin/versions.js create mode 100644 lib/als.js create mode 100644 lib/analytics.js create mode 100644 lib/api/admin.js create mode 100644 lib/api/categories.js create mode 100644 lib/api/chats.js create mode 100644 lib/api/files.js create mode 100644 lib/api/flags.js create mode 100644 lib/api/groups.js create mode 100644 lib/api/helpers.js create mode 100644 lib/api/index.js create mode 100644 lib/api/posts.js create mode 100644 lib/api/search.js create mode 100644 lib/api/tags.js create mode 100644 lib/api/topics.js create mode 100644 lib/api/users.js create mode 100644 lib/api/utils.js create mode 100644 lib/batch.js create mode 100644 lib/cache.js create mode 100644 lib/cache/lru.js create mode 100644 lib/cache/ttl.js create mode 100644 lib/cacheCreate.js create mode 100644 lib/categories/activeusers.js create mode 100644 lib/categories/create.js create mode 100644 lib/categories/data.js create mode 100644 lib/categories/delete.js create mode 100644 lib/categories/index.js create mode 100644 lib/categories/recentreplies.js create mode 100644 lib/categories/search.js create mode 100644 lib/categories/topics.js create mode 100644 lib/categories/unread.js create mode 100644 lib/categories/update.js create mode 100644 lib/categories/watch.js create mode 100644 lib/cli/colors.js diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000000..d56290800c --- /dev/null +++ b/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["@babel/preset-flow"], + "plugins": ["babel-plugin-syntax-hermes-parser"], + } \ No newline at end of file diff --git a/.flowconfig b/.flowconfig new file mode 100644 index 0000000000..95fde81def --- /dev/null +++ b/.flowconfig @@ -0,0 +1,14 @@ +[ignore] + +[include] + +[libs] + +[lints] +untyped-type-import=error +internal-type=error +deprecated-type-bool=error + +[options] + +[strict] diff --git a/lib/admin/search.js b/lib/admin/search.js new file mode 100644 index 0000000000..bc4803462a --- /dev/null +++ b/lib/admin/search.js @@ -0,0 +1,96 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const sanitizeHTML = require('sanitize-html'); +const nconf = require('nconf'); +const winston = require('winston'); +const file = require('../file'); +const { + 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)); +} +async function getAdminNamespaces() { + const directories = await file.walk(path.resolve(nconf.get('views_dir'), 'admin')); + return filterDirectories(directories); +} +function sanitize(html) { + 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, ' '); +} +function nsToTitle(namespace) { + 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 + }; +} +async function fallback(namespace) { + 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))); +} +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 cache = {}; +async function getDictionary(language) { + 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 diff --git a/lib/admin/versions.js b/lib/admin/versions.js new file mode 100644 index 0000000000..30ce8983af --- /dev/null +++ b/lib/admin/versions.js @@ -0,0 +1,40 @@ +'use strict'; + +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; +} +exports.getLatestVersion = getLatestVersion; +exports.isPrerelease = isPrerelease; +require('../promisify')(exports); \ No newline at end of file diff --git a/lib/als.js b/lib/als.js new file mode 100644 index 0000000000..afe0932753 --- /dev/null +++ b/lib/als.js @@ -0,0 +1,7 @@ +'use strict'; + +const { + AsyncLocalStorage +} = require('async_hooks'); +const asyncLocalStorage = new AsyncLocalStorage(); +module.exports = asyncLocalStorage; \ No newline at end of file diff --git a/lib/analytics.js b/lib/analytics.js new file mode 100644 index 0000000000..95c8c3f420 --- /dev/null +++ b/lib/analytics.js @@ -0,0 +1,235 @@ +'use strict'; + +const cronJob = require('cron').CronJob; +const winston = require('winston'); +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'); +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 +}; +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); + }); + } +}; +function publishLocalAnalytics() { + 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]; + } + } +} +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(); + } +}; +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); + } + } +}; +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}`); + } +}; +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; +}; +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)); +}; +Analytics.getUnwrittenPageviews = function () { + 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) + }; +}; +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) + }); +}; +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) + }); +}; +Analytics.getBlacklistAnalytics = async function () { + 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 diff --git a/lib/api/admin.js b/lib/api/admin.js new file mode 100644 index 0000000000..759691d712 --- /dev/null +++ b/lib/api/admin.js @@ -0,0 +1,45 @@ +'use strict'; + +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 +}) => { + 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); +}; +adminApi.getAnalyticsData = async (caller, { + 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); +}; +adminApi.listGroups = async () => { + const payload = await groups.getNonPrivilegeGroups('groups:createtime', 0, -1, { + ephemeral: false + }); + return { + groups: payload + }; +}; \ No newline at end of file diff --git a/lib/api/categories.js b/lib/api/categories.js new file mode 100644 index 0000000000..4a35ad5f1e --- /dev/null +++ b/lib/api/categories.js @@ -0,0 +1,226 @@ +'use strict'; + +const meta = require('../meta'); +const categories = require('../categories'); +const topics = require('../topics'); +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]]'); + } +}; +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; +}; +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]; +}; +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); +}; +categoriesAPI.delete = async function (caller, { + 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 + }); +}; +categoriesAPI.getTopicCount = async (caller, { + 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 + }; +}; +categoriesAPI.getPosts = async (caller, { + cid +}) => await categories.getRecentReplies(cid, caller.uid, 0, 4); +categoriesAPI.getChildren = async (caller, { + 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 + }; +}; +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 + }; +}; +categoriesAPI.setWatchState = async (caller, { + 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 + }; +}; +categoriesAPI.getPrivileges = async (caller, { + 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; +}; +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 + }); +}; +categoriesAPI.setModerator = async (caller, { + 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 diff --git a/lib/api/chats.js b/lib/api/chats.js new file mode 100644 index 0000000000..26944e115c --- /dev/null +++ b/lib/api/chats.js @@ -0,0 +1,414 @@ +'use strict'; + +const validator = require('validator'); +const winston = require('winston'); +const db = require('../database'); +const user = require('../user'); +const meta = require('../meta'); +const messaging = require('../messaging'); +const notifications = require('../notifications'); +const privileges = require('../privileges'); +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; +} +chatsAPI.list = async (caller, { + 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); +}; +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); +}; +chatsAPI.getUnread = async caller => { + const count = await messaging.getUnreadCount(caller.uid); + return { + count + }; +}; +chatsAPI.sortPublicRooms = async (caller, { + 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`); +}; +chatsAPI.get = async (caller, { + uid, + roomId +}) => await messaging.loadRoom(caller.uid, { + 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; +}; +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; +}; +chatsAPI.rename = async (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); +}; +chatsAPI.watch = async (caller, { + 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 +}) => { + 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 + }; +}; +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); +}; +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); +}; +chatsAPI.toggleOwner = async (caller, { + 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); +}; +chatsAPI.listMessages = async (caller, { + 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 + }; +}; +chatsAPI.getPinnedMessages = async (caller, { + 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 + }; +}; +chatsAPI.getMessage = async (caller, { + mid, + roomId +} = {}) => { + 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 +} = {}) => { + 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 +}) => { + 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 +}) => { + await messaging.canEdit(mid, caller.uid); + await messaging.editMessage(caller.uid, mid, roomId, message); +}; +chatsAPI.deleteMessage = async (caller, { + mid +}) => { + await messaging.canDelete(mid, caller.uid); + await messaging.deleteMessage(mid, caller.uid); +}; +chatsAPI.restoreMessage = async (caller, { + mid +}) => { + await messaging.canDelete(mid, caller.uid); + await messaging.restoreMessage(mid, caller.uid); +}; +chatsAPI.pinMessage = async (caller, { + roomId, + mid +}) => { + await messaging.canPin(roomId, caller.uid); + await messaging.pinMessage(mid, roomId); +}; +chatsAPI.unpinMessage = async (caller, { + roomId, + mid +}) => { + await messaging.canPin(roomId, caller.uid); + await messaging.unpinMessage(mid, roomId); +}; \ No newline at end of file diff --git a/lib/api/files.js b/lib/api/files.js new file mode 100644 index 0000000000..80441bfd33 --- /dev/null +++ b/lib/api/files.js @@ -0,0 +1,10 @@ +'use strict'; + +const fs = require('fs').promises; +const filesApi = module.exports; +filesApi.delete = async (_, { + path +}) => await fs.unlink(path); +filesApi.createFolder = async (_, { + path +}) => await fs.mkdir(path); \ No newline at end of file diff --git a/lib/api/flags.js b/lib/api/flags.js new file mode 100644 index 0000000000..bc15205cc0 --- /dev/null +++ b/lib/api/flags.js @@ -0,0 +1,103 @@ +'use strict'; + +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; +}; +flagsApi.get = async (caller, { + 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); +}; +flagsApi.delete = async (_, { + flagId +}) => await flags.purge([flagId]); +flagsApi.rescind = async ({ + uid +}, { + 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); +}; +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 + }; +}; +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 diff --git a/lib/api/groups.js b/lib/api/groups.js new file mode 100644 index 0000000000..2fdc34b412 --- /dev/null +++ b/lib/api/groups.js @@ -0,0 +1,335 @@ +'use strict'; + +const validator = require('validator'); +const privileges = require('../privileges'); +const events = require('../events'); +const groups = require('../groups'); +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 + }; +}; +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; +}; +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); +}; +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 + }); +}; +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; +}; +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]]'); + } +} +function validateJoinRequest(caller, data) { + if (!data) { + throw new Error('[[error:invalid-data]]'); + } + if (caller.uid <= 0 || !data.uid) { + throw new Error('[[error:invalid-uid]]'); + } +} +async function checkPrivileges(caller, groupName) { + 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]]'); + } + return isCallerAdmin; +} +function isGroupJoinDisabledForCaller(isCallerAdmin, isSelf, groupData) { + return !isCallerAdmin && isSelf && groupData.private && groupData.disableJoinRequests; +} +groupsAPI.join = async function (caller, data) { + validateJoinRequest(caller, data); + const groupName = await groups.getGroupNameByGroupSlug(data.slug); + if (!groupName) { + throw new Error('[[error:no-group]]'); + } + const isCallerAdmin = await checkPrivileges(caller, groupName); + 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 (isGroupJoinDisabledForCaller(isCallerAdmin, isSelf, groupData)) { + 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 + }); +}; +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 + }); +}; +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 + }); +}; +groupsAPI.getPending = async (caller, { + slug +}) => { + const groupName = await groups.getGroupNameByGroupSlug(slug); + await isOwner(caller, groupName); + return await groups.getPending(groupName); +}; +groupsAPI.accept = async (caller, { + 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 + }); +}; +groupsAPI.reject = async (caller, { + 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 + }); +}; +groupsAPI.getInvites = async (caller, { + slug +}) => { + const groupName = await groups.getGroupNameByGroupSlug(slug); + await isOwner(caller, groupName); + return await groups.getInvites(groupName); +}; +groupsAPI.issueInvite = async (caller, { + slug, + 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 +}) => { + 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 +}) => { + 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; +} +function logGroupEvent(caller, event, additional) { + events.log({ + type: event, + uid: caller.uid, + ip: caller.ip, + ...additional + }); +} \ No newline at end of file diff --git a/lib/api/helpers.js b/lib/api/helpers.js new file mode 100644 index 0000000000..b9f087067f --- /dev/null +++ b/lib/api/helpers.js @@ -0,0 +1,121 @@ +'use strict'; + +const url = require('url'); +const user = require('../user'); +const topics = require('../topics'); +const posts = require('../posts'); +const privileges = require('../privileges'); +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; +}; +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 + }; +}; +exports.doTopicAction = async function (action, event, caller, { + tids +}) { + if (!Array.isArray(tids)) { + throw new Error('[[error:invalid-tid]]'); + } + const exists = await topics.exists(tids); + if (!exists.every(Boolean)) { + throw new Error('[[error:no-topic]]'); + } + if (typeof topics.tools[action] !== 'function') { + return; + } + const uids = await user.getUidsFromSet('users:online', 0, -1); + await Promise.all(tids.map(async tid => { + const title = await topics.getTopicField(tid, 'title'); + const data = await topics.tools[action](tid, caller.uid); + const notifyUids = await privileges.categories.filterUids('topics:read', data.cid, uids); + socketHelpers.emitToUids(event, data, notifyUids); + await logTopicAction(action, caller, tid, title); + })); +}; +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) + }); +} +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); +}; +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 diff --git a/lib/api/index.js b/lib/api/index.js new file mode 100644 index 0000000000..4bcaffe719 --- /dev/null +++ b/lib/api/index.js @@ -0,0 +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 diff --git a/lib/api/posts.js b/lib/api/posts.js new file mode 100644 index 0000000000..e056b0d622 --- /dev/null +++ b/lib/api/posts.js @@ -0,0 +1,416 @@ +'use strict'; + +const validator = require('validator'); +const _ = require('lodash'); +const db = require('../database'); +const utils = require('../utils'); +const user = require('../user'); +const posts = require('../posts'); +const postsCache = require('../posts/cache'); +const topics = require('../topics'); +const groups = require('../groups'); +const plugins = require('../plugins'); +const meta = require('../meta'); +const events = require('../events'); +const privileges = require('../privileges'); +const apiHelpers = require('./helpers'); +const websockets = require('../socket.io'); +const socketHelpers = require('../socket.io/helpers'); +const 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; +}; +postsAPI.getIndex = async (caller, { + 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); +}; +postsAPI.getSummary = async (caller, { + 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]; +}; +postsAPI.getRaw = async (caller, { + 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; +}; +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; +}; +postsAPI.delete = async function (caller, data) { + 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' + }); +}; +async function deleteOrRestore(caller, data, params) { + if (!data || !data.pid) { + throw new Error('[[error:invalid-data]]'); + } + const postData = await posts.tools[params.command](caller.uid, data.pid); + const results = await isMainAndLastPost(data.pid); + if (results.isMain && results.isLast) { + await deleteOrRestoreTopicOf(params.command, data.pid, caller); + } + websockets.in(`topic_${postData.tid}`).emit(params.event, postData); + await events.log({ + type: params.type, + uid: caller.uid, + pid: data.pid, + tid: postData.tid, + ip: caller.ip + }); +} +async function 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 + }); +} +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 + }); + } +}; +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 + }; +} +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'); + } +}; +postsAPI.upvote = async function (caller, 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); +}; +postsAPI.unvote = async function (caller, 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 + }; +}; +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 + }; +}; +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]; +} +postsAPI.bookmark = async function (caller, data) { + return await apiHelpers.postCommand(caller, 'bookmark', 'bookmarked', '', data); +}; +postsAPI.unbookmark = async function (caller, 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]]'); + } +} +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) + }; +}; +postsAPI.loadDiff = async (caller, data) => { + 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); +}; +postsAPI.deleteDiff = async (caller, { + 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); +}; +postsAPI.getReplies = async (caller, { + 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; +}; +postsAPI.endorse = async (caller, { + pid +}) => await posts.endorse(pid, caller.uid); +postsAPI.unendorse = async (caller, { + pid +}) => await posts.unendorse(pid, caller.uid); \ No newline at end of file diff --git a/lib/api/search.js b/lib/api/search.js new file mode 100644 index 0000000000..662669f3b4 --- /dev/null +++ b/lib/api/search.js @@ -0,0 +1,170 @@ +'use strict'; + +const _ = require('lodash'); +const db = require('../database'); +const user = require('../user'); +const categories = require('../categories'); +const messaging = require('../messaging'); +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 + }; +}; +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 + }; +} +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; +} +searchApi.roomUsers = async (caller, { + 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 + }; +}; +searchApi.roomMessages = async (caller, { + 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 diff --git a/lib/api/tags.js b/lib/api/tags.js new file mode 100644 index 0000000000..0ccfacd273 --- /dev/null +++ b/lib/api/tags.js @@ -0,0 +1,10 @@ +'use strict'; + +const topics = require('../topics'); +const tagsAPI = module.exports; +tagsAPI.follow = async function (caller, data) { + 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 diff --git a/lib/api/topics.js b/lib/api/topics.js new file mode 100644 index 0000000000..776ac7e6d2 --- /dev/null +++ b/lib/api/topics.js @@ -0,0 +1,285 @@ +'use strict'; + +const validator = require('validator'); +const user = require('../user'); +const topics = require('../topics'); +const posts = require('../posts'); +const meta = require('../meta'); +const privileges = require('../privileges'); +const apiHelpers = require('./helpers'); +const { + doTopicAction +} = apiHelpers; +const websockets = require('../socket.io'); +const socketHelpers = require('../socket.io/helpers'); +const topicsAPI = module.exports; +topicsAPI._checkThumbPrivileges = async function ({ + 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]]'); + } +}; +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; +}; +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; +}; +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]; +}; +topicsAPI.delete = async function (caller, data) { + 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 + }); +}; +topicsAPI.purge = async function (caller, data) { + await doTopicAction('purge', 'event:topic_purged', caller, { + tids: data.tids + }); +}; +topicsAPI.pin = async function (caller, { + 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))); + } +}; +topicsAPI.unpin = async function (caller, data) { + 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 + }); +}; +topicsAPI.unlock = async function (caller, data) { + await doTopicAction('unlock', 'event:topic_unlocked', caller, { + tids: data.tids + }); +}; +topicsAPI.follow = async function (caller, data) { + await topics.follow(data.tid, caller.uid); +}; +topicsAPI.ignore = async function (caller, data) { + await topics.ignore(data.tid, caller.uid); +}; +topicsAPI.unfollow = async function (caller, data) { + await topics.unfollow(data.tid, caller.uid); +}; +topicsAPI.updateTags = async (caller, { + 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); +}; +topicsAPI.addTags = async (caller, { + 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); +}; +topicsAPI.deleteTags = async (caller, { + tid +}) => { + if (!(await privileges.topics.canEdit(tid, caller.uid))) { + throw new Error('[[error:no-privileges]]'); + } + await topics.deleteTopicTags(tid); +}; +topicsAPI.getThumbs = async (caller, { + 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 +}) => { + 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 +}) => { + await topicsAPI._checkThumbPrivileges({ + tid: tid, + uid: caller.uid + }); + await topics.thumbs.delete(tid, path); +}; +topicsAPI.reorderThumbs = async (caller, { + 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 + }); +}; +topicsAPI.getEvents = async (caller, { + 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); +}; +topicsAPI.deleteEvent = async (caller, { + 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 +}) => { + 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 +}) => { + 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 +}) => { + 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 diff --git a/lib/api/users.js b/lib/api/users.js new file mode 100644 index 0000000000..24be3c2ec4 --- /dev/null +++ b/lib/api/users.js @@ -0,0 +1,717 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs').promises; +const validator = require('validator'); +const winston = require('winston'); +const db = require('../database'); +const user = require('../user'); +const groups = require('../groups'); +const meta = require('../meta'); +const messaging = require('../messaging'); +const flags = require('../flags'); +const privileges = require('../privileges'); +const notifications = require('../notifications'); +const plugins = require('../plugins'); +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]]'); + } +}; +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); +}; +usersAPI.get = async (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; +}; +usersAPI.delete = async function (caller, { + uid, + password +}) { + await processDeletion({ + uid: uid, + method: 'delete', + password, + caller + }); +}; +usersAPI.deleteContent = async function (caller, { + uid, + password +}) { + await processDeletion({ + uid, + method: 'deleteContent', + password, + caller + }); +}; +usersAPI.deleteAccount = async function (caller, { + uid, + password +}) { + await processDeletion({ + uid, + method: 'deleteAccount', + password, + caller + }); +}; +usersAPI.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 + }))); + } +}; +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); +}; +usersAPI.getStatus = async (caller, { + uid +}) => { + const status = await db.getObjectField(`user:${uid}`, 'status'); + return { + status + }; +}; +usersAPI.getPrivateRoomId = async (caller, { + 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 + }; +}; +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 + }); +}; +usersAPI.follow = async function (caller, data) { + await user.follow(caller.uid, data.uid); + plugins.hooks.fire('action:user.follow', { + fromUid: caller.uid, + toUid: data.uid + }); + const userData = await user.getUserFields(caller.uid, ['username', 'userslug']); + const { + displayname + } = userData; + const notifObj = await notifications.create({ + type: 'follow', + bodyShort: `[[notifications:user-started-following-you, ${displayname}]]`, + nid: `follow:${data.uid}:uid:${caller.uid}`, + from: caller.uid, + path: `/uid/${data.uid}/followers`, + mergeId: 'notifications:user-started-following-you' + }); + if (!notifObj) { + return; + } + notifObj.user = userData; + await notifications.push(notifObj, [data.uid]); +}; +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 + }); +}; +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); + } +}; +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 + }); +}; +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 + }); +}; +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 + }); +}; +usersAPI.generateToken = async (caller, { + 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; +}; +usersAPI.deleteToken = async (caller, { + 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; +}; +usersAPI.revokeSession = async (caller, { + 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); +}; +usersAPI.invite = async (caller, { + 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); + } +}; +usersAPI.getInviteGroups = async (caller, { + 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); +}; +usersAPI.addEmail = async (caller, { + 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); +}; +usersAPI.listEmails = async (caller, { + 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; +}; +usersAPI.getEmail = async (caller, { + 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); +}; +usersAPI.confirmEmail = async (caller, { + 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; +}; +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]]'); + } +} +async function processDeletion({ + 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 + }); +} +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; +} +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 + }); +}; +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']); +}; +const exportMetadata = new Map([['posts', ['csv', 'text/csv']], ['uploads', ['zip', 'application/zip']], ['profile', ['json', 'application/json']]]); +const prepareExport = async ({ + 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; + } +}; +usersAPI.checkExportByType = async (caller, { + uid, + type +}) => await prepareExport({ + uid, + type +}); +usersAPI.getExportByType = async (caller, { + 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; +}; +usersAPI.generateExport = async (caller, { + 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 diff --git a/lib/api/utils.js b/lib/api/utils.js new file mode 100644 index 0000000000..b447767d26 --- /dev/null +++ b/lib/api/utils.js @@ -0,0 +1,96 @@ +'use strict'; + +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); +}; +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.generate = async ({ + 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 + }); +}; +utils.tokens.add = async ({ + 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; +}; +utils.tokens.update = async (token, { + uid, + description +}) => { + 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.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.getLastSeen = async tokens => await db.sortedSetScores('tokens:lastSeen', tokens); \ No newline at end of file diff --git a/lib/batch.js b/lib/batch.js new file mode 100644 index 0000000000..69ff462201 --- /dev/null +++ b/lib/batch.js @@ -0,0 +1,74 @@ +'use strict'; + +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; + } +}; +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; + } +}; +require('./promisify')(exports); \ No newline at end of file diff --git a/lib/cache.js b/lib/cache.js new file mode 100644 index 0000000000..6d9b8d3d9f --- /dev/null +++ b/lib/cache.js @@ -0,0 +1,8 @@ +'use strict'; + +const cacheCreate = require('./cache/lru'); +module.exports = cacheCreate({ + name: 'local', + max: 40000, + ttl: 0 +}); \ No newline at end of file diff --git a/lib/cache/lru.js b/lib/cache/lru.js new file mode 100644 index 0000000000..c2550d55fb --- /dev/null +++ b/lib/cache/lru.js @@ -0,0 +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 diff --git a/lib/cache/ttl.js b/lib/cache/ttl.js new file mode 100644 index 0000000000..b9da5d239e --- /dev/null +++ b/lib/cache/ttl.js @@ -0,0 +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 diff --git a/lib/cacheCreate.js b/lib/cacheCreate.js new file mode 100644 index 0000000000..5f1c96f14e --- /dev/null +++ b/lib/cacheCreate.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./cache/lru'); \ No newline at end of file diff --git a/lib/categories/activeusers.js b/lib/categories/activeusers.js new file mode 100644 index 0000000000..154e596f48 --- /dev/null +++ b/lib/categories/activeusers.js @@ -0,0 +1,15 @@ +'use strict'; + +const _ = require('lodash'); +const posts = require('../posts'); +const db = require('../database'); +module.exports = function (Categories) { + Categories.getActiveUsers = async function (cids) { + if (!Array.isArray(cids)) { + cids = [cids]; + } + const pids = await db.getSortedSetRevRange(cids.map(cid => `cid:${cid}:pids`), 0, 24); + const postData = await posts.getPostsFields(pids, ['uid']); + return _.uniq(postData.map(post => post.uid).filter(uid => uid)); + }; +}; \ No newline at end of file diff --git a/lib/categories/create.js b/lib/categories/create.js new file mode 100644 index 0000000000..7314463e46 --- /dev/null +++ b/lib/categories/create.js @@ -0,0 +1,188 @@ +'use strict'; + +const async = require('async'); +const _ = require('lodash'); +const db = require('../database'); +const plugins = require('../plugins'); +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 diff --git a/lib/categories/data.js b/lib/categories/data.js new file mode 100644 index 0000000000..ca59ece503 --- /dev/null +++ b/lib/categories/data.js @@ -0,0 +1,84 @@ +'use strict'; + +const validator = require('validator'); +const db = require('../database'); +const meta = require('../meta'); +const plugins = require('../plugins'); +const utils = require('../utils'); +const intFields = ['cid', 'parentCid', 'disabled', 'isSection', 'order', 'topic_count', 'post_count', 'numRecentReplies', 'minTags', 'maxTags', 'postQueue', 'subCategoriesPerPage']; +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); + }; +}; +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]; + } +} +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 diff --git a/lib/categories/delete.js b/lib/categories/delete.js new file mode 100644 index 0000000000..9351b3c11a --- /dev/null +++ b/lib/categories/delete.js @@ -0,0 +1,59 @@ +'use strict'; + +const async = require('async'); +const db = require('../database'); +const batch = require('../batch'); +const plugins = require('../plugins'); +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 diff --git a/lib/categories/index.js b/lib/categories/index.js new file mode 100644 index 0000000000..ea14605a80 --- /dev/null +++ b/lib/categories/index.js @@ -0,0 +1,348 @@ +'use strict'; + +const _ = require('lodash'); +const db = require('../database'); +const user = require('../user'); +const topics = require('../topics'); +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); +require('./delete')(Categories); +require('./topics')(Categories); +require('./unread')(Categories); +require('./activeusers')(Categories); +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}`); +}; +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 + }; +}; +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(); +}; +Categories.getAllCategories = async function () { + 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); +}; +Categories.getCategoriesByPrivilege = async function (set, uid, privilege) { + 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']); +}; +Categories.getModeratorUids = async function (cids) { + 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; +}; +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); +}; +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]); +}; +Categories.filterTagWhitelist = function (tagWhitelist, isAdminOrMod) { + 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; +} +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]); +}; +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); +}; +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); +} +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; +}; +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(); +}; +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); + } + } + }); +}; +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; +}; +Categories.buildForSelect = async function (uid, privilege, 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); +}; +async function getSelectData(cids, 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)); +}; +require('../promisify')(Categories); \ No newline at end of file diff --git a/lib/categories/recentreplies.js b/lib/categories/recentreplies.js new file mode 100644 index 0000000000..95f0958bb7 --- /dev/null +++ b/lib/categories/recentreplies.js @@ -0,0 +1,162 @@ +'use strict'; + +const winston = require('winston'); +const _ = require('lodash'); +const db = require('../database'); +const posts = require('../posts'); +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 diff --git a/lib/categories/search.js b/lib/categories/search.js new file mode 100644 index 0000000000..d903318100 --- /dev/null +++ b/lib/categories/search.js @@ -0,0 +1,69 @@ +'use strict'; + +const _ = require('lodash'); +const privileges = require('../privileges'); +const plugins = require('../plugins'); +const db = require('../database'); +module.exports = function (Categories) { + Categories.search = async function (data) { + const query = data.query || ''; + const page = data.page || 1; + const uid = data.uid || 0; + const paginate = data.hasOwnProperty('paginate') ? data.paginate : true; + 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 diff --git a/lib/categories/topics.js b/lib/categories/topics.js new file mode 100644 index 0000000000..293dc27f19 --- /dev/null +++ b/lib/categories/topics.js @@ -0,0 +1,228 @@ +'use strict'; + +const db = require('../database'); +const topics = require('../topics'); +const plugins = require('../plugins'); +const meta = require('../meta'); +const privileges = require('../privileges'); +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); + 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 diff --git a/lib/categories/unread.js b/lib/categories/unread.js new file mode 100644 index 0000000000..4f4fcbc16e --- /dev/null +++ b/lib/categories/unread.js @@ -0,0 +1,37 @@ +'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 diff --git a/lib/categories/update.js b/lib/categories/update.js new file mode 100644 index 0000000000..26385c8a07 --- /dev/null +++ b/lib/categories/update.js @@ -0,0 +1,111 @@ +'use strict'; + +const db = require('../database'); +const meta = require('../meta'); +const utils = require('../utils'); +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 diff --git a/lib/categories/watch.js b/lib/categories/watch.js new file mode 100644 index 0000000000..c7d551087f --- /dev/null +++ b/lib/categories/watch.js @@ -0,0 +1,43 @@ +'use strict'; + +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 diff --git a/lib/cli/colors.js b/lib/cli/colors.js new file mode 100644 index 0000000000..4e72381102 --- /dev/null +++ b/lib/cli/colors.js @@ -0,0 +1,115 @@ +'use strict'; + +const { + Command +} = require('commander'); +const chalk = require('chalk'); +const colors = [{ + command: 'yellow', + option: 'cyan', + arg: 'magenta' +}, { + command: 'green', + option: 'blue', + arg: 'red' +}, { + command: 'yellow', + option: 'cyan', + arg: 'magenta' +}, { + command: 'green', + option: 'blue', + arg: 'red' +}]; +function humanReadableArgName(arg) { + 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; +} +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; +}; +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