diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000000..a2afbe7571 --- /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/.github/workflows/azure-deploy-f24.yml b/.github/workflows/azure-deploy-f24.yml index e35e0f7018..2a2a05a0dc 100644 --- a/.github/workflows/azure-deploy-f24.yml +++ b/.github/workflows/azure-deploy-f24.yml @@ -19,7 +19,7 @@ jobs: ./.github/workflows/test.yaml build-and-deploy: - if: github.repository == 'cmu-313/NodeBB' + if: github.repository == 'cmu-313/nodebb-f24-bluesleep' needs: lint-and-test runs-on: ubuntu-latest @@ -30,11 +30,12 @@ jobs: - name: Set up Node.js version uses: actions/setup-node@v3 with: - node-version: '20.x' + node-version: '20.17.0' - name: Set up NodeBB run: | - ./nodebb setup '{"url":"https://nodebb-f24.azurewebsites.net:443", + ./nodebb setup '{ + "url": "https://team-bluesleep-db-g9gncbexhhhjd7em.canadacentral-01.azurewebsites.net:443", "admin:username": "admin", "admin:password": "${{ secrets.ADMIN_PASSWORD }}", "admin:password:confirm": "${{ secrets.ADMIN_PASSWORD }}", @@ -42,13 +43,14 @@ jobs: "database": "redis", "redis:host": "${{ secrets.REDIS_HOST }}", "redis:port": "6379", - "redis:password": "${{ secrets.REDIS_PASSWORD }}" }' + "redis:password": "${{ secrets.REDIS_PASSWORD }}" + }' - name: 'Deploy to Azure Web App' id: deploy-to-webapp uses: azure/webapps-deploy@v2 with: - app-name: 'nodebb-f24' + app-name: 'team-bluesleep-db' slot-name: 'Production' - publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_BFAB97B1AB1441ACA7C63280F91AD3F3 }} + publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_8DA38339FCB64169A6856C5D3423DAA8 }} package: . diff --git a/.gitignore b/.gitignore index 42a1b3c705..fef6ff1a04 100644 --- a/.gitignore +++ b/.gitignore @@ -72,4 +72,5 @@ link-plugins.sh test.sh .docker/** -!**/.gitkeep \ No newline at end of file +!**/.gitkeep +node_modules diff --git a/README.md b/README.md index 6ef180f625..d99af63502 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +[![Review Assignment Due Date](https://classroom.github.com/assets/deadline-readme-button-22041afd0340ce965d47ae6ef1cefeee28c7c493a6346c4f15d667ab976d596c.svg)](https://classroom.github.com/a/ithVU1OO) # ![NodeBB](public/images/sm-card.png) [![Workflow](https://github.com/CMU-313/NodeBB/actions/workflows/test.yaml/badge.svg)](https://github.com/CMU-313/NodeBB/actions/workflows/test.yaml) diff --git a/UserGuide.md b/UserGuide.md new file mode 100644 index 0000000000..14e0261377 --- /dev/null +++ b/UserGuide.md @@ -0,0 +1,155 @@ +# User Guide +**Authors**: Angela Kim, Veronica Pimenova, Crystal Cheng, Eric Lin + +This is the user documentation for our project. It explains the features, setup instructions, and usage details. + +## Table of Contents +- [Feature Descriptions](#feature-descriptions) + - [Feature 1: Anonymous Posting](#feature-1-anonymous-posting) + - [Feature 2: isAnswered](#feature-2-isanswered) + - [Feature 3: Emoji Reactions](#feature-3-emoji-reactions) +- [Implementation Details](#implementation-details) + - [Feature 1: Anonymous Posting](#implementation-details-feature-1-anonymous-posting) + - [Feature 2: isAnswered](#implementation-details-feature-2-isanswered) + - [Feature 3: Emoji Reactions](#implementation-details-feature-3-emoji-reactions) +- [Testing](#testing) + - [Feature 1: Anonymous Posting](#testing-feature-1-anonymous-posting) + - [Feature 2: isAnswered](#testing-feature-2-isanswered) + - [Feature 3: Emoji Reactions](#testing-feature-3-emoji-reactions) +- [Usage](#usage) + - [Feature 1: Anonymous Posting](#usage-feature-1-anonymous-posting) + - [Feature 2: isAnswered](#usage-feature-2-isanswered) + - [Feature 3: Emoji Reactions](#usage-feature-3-emoji-reactions) +- [Automated Tests](#automated-tests) + +--- + +## Feature Descriptions + +### Feature 1: Anonymous Posting +The Anonymous Posting feature allows users to make a post without having their name/profile attached to the post publicly. This feature provides more flexibility for users, giving them the option to post anonymously. + +**Key Functionality**: +- **Mark post as anonymous**: When drafting a post, users will see a checkbox that allows them to modify the anonymous status of the post. +- **Post displays as anonymous**: Upon posting, the post shows up without a profile picture or name associated with the post author. + +### Feature 2: isAnswered +The `isAnswered` feature helps track the status of topics in the forum, allowing users to identify which topics have received a satisfactory response. It is useful for Q&A or support forums where users focus on unanswered queries. + +**Key Functionality**: +- **Mark Topics as Answered**: Users with the necessary privileges can mark a topic as answered. +- **Display Answer Status**: The status (`Answered` or `Unanswered`) is displayed on the topic list view. +- **Restrict Marking Privileges**: The ability to toggle the `isAnswered` status can be restricted to specific users. + +### Feature 3: Emoji Reactions +The Emoji Reactions feature lets users express their sentiments on posts by reacting with emojis. This provides a lightweight and intuitive way for users to engage with posts. + +**Key Functionality**: +- **React to Posts**: Users can choose from a set of emojis to react to a post. +- **View Reactions**: The total number of reactions and specific emojis used are displayed under each post. +- **Remove Reactions**: Users can remove their reactions if they no longer wish to associate them with a post. + +--- + +## Implementation Details + +### Implementation Details: Feature 1 (Anonymous Posting) +- **Backend Field Handling**: A field `isAnonymous` is added to handle the server requests associated with a post’s anonymity. + - Relevant Files: `src/posts/summary.js`, `src/topics/create.js` +- **Backend Schema**: The schema was expanded to include the `isAnonymous` field to match the response body of operations. +- **Frontend UI**: A checkbox shows up next to the topic title for users to handle the anonymous state of each post. +- **Integration**: When the checkbox is checked and a post is published, a frontend trigger reflects this change. + +### Implementation Details: Feature 2 (isAnswered) +- **Backend Schema**: A new column `isAnswered` was added to the `topics` table in the database to store the status of each topic. +- **API Endpoint (Internal)**: + - Endpoint: `/post/:pid/answered` + - Method: `PUT` + - Body: `{ "isAnswered": true }` +- **Frontend UI**: + - Checkbox Display: A checkbox is added next to each post. It is checked if the post is answered and unchecked if unanswered. + - UI Placement: The checkbox is visible both in the main topic list and within the post detail view. + +### Implementation Details: Feature 3 (Emoji Reactions) +- **Backend Schema**: No new column was added. Emoji reactions leverage NodeBB’s existing reaction system. +- **API Endpoints**: + - **Add Reaction**: + - Endpoint: `/post/:pid/reaction` + - Method: `PUT` + - Body: `{ "reaction": "😊" }` + - **Remove Reaction**: + - Endpoint: `/post/:pid/reaction` + - Method: `DELETE` + - Body: `{ "reaction": "😊" }` +- **Frontend UI**: + - Emoji Picker: Users see an emoji reaction icon next to the post content. Clicking it opens the emoji picker. + - Displaying Reactions: Selected emojis and counts are displayed below the post content. + +--- + +## Testing + +### Testing: Feature 1 (Anonymous Posting) +- **Front-End Testing**: + - Verify that the anonymous checkbox is visible in the post creation UI. + - Ensure the checkbox hides the user’s name and profile picture when posting. +- **Back-End Testing**: + - Test that the `isAnonymous` field updates correctly in the database. + - Verify the anonymous status persists across sessions. + +### Testing: Feature 2 (isAnswered) +- **Front-End Testing**: + - Ensure the checkbox toggles between "answered" and "unanswered" states. + - Verify the status is reflected correctly in both topic list and detail views. +- **Back-End Testing**: + - Test the API to confirm it updates the `isAnswered` status correctly. + - Verify that the database saves and retrieves the correct status. + +### Testing: Feature 3 (Emoji Reactions) +- **Front-End Testing**: + - Ensure the emoji picker appears and allows users to react to posts. + - Test that the reactions display correctly under the post and can be removed. +- **Back-End Testing**: + - Test the API to confirm reactions are added and removed correctly in the database. + - Verify that emoji counts update correctly when multiple users react. + +--- + +## Usage + +### Usage: Feature 1 (Anonymous Posting) +1. **Setting a post as anonymous**: + - Under a discussion, click "Create Topic". + - Check the "anonymous" checkbox near the post title. + - Submit the post. +2. **Viewing Anonymous Posts**: + - The post will display as any other post but without the author’s name or profile picture. +3. **Permissions**: + - Only the original poster can toggle the anonymous status. + +### Usage: Feature 2 (isAnswered) +1. **Marking a Post as Answered or Unanswered**: + - Navigate to the post, locate the checkbox next to the title, and toggle it. +2. **Viewing Status**: + - In the topic list and post view, the checkbox will indicate the post's current status. +3. **Permissions**: + - Only the original poster or users with permissions can toggle the answer status. + +### Usage: Feature 3 (Emoji Reactions) +1. **Adding a Reaction**: + - Navigate to the post and click the emoji icon. + - Select an emoji from the picker, and it will display under the post. +2. **Removing a Reaction**: + - Click on your selected emoji to remove it. +3. **Viewing Reactions**: + - Reactions and counts are visible under the post content. + +--- + +## Automated Tests +- The automated tests for these features can be found in the `/tests/` directory. These tests cover both front-end and back-end functionality for the Anonymous Posting, isAnswered, and Emoji Reactions features. + +**Testing Coverage**: +- The tests include basic functionality, user interaction scenarios, and edge cases, ensuring the robustness of the features. We believe the tests are sufficient as they verify key user interactions, API calls, and data persistence across sessions. + +--- diff --git a/UserGuide.pdf b/UserGuide.pdf new file mode 100644 index 0000000000..c7a5f3e379 Binary files /dev/null and b/UserGuide.pdf differ 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..f71e00bb64 --- /dev/null +++ b/lib/api/groups.js @@ -0,0 +1,325 @@ +'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]]'); + } +} +groupsAPI.join = async function (caller, data) { + if (!data) { + throw new Error('[[error:invalid-data]]'); + } + if (caller.uid <= 0 || !data.uid) { + throw new Error('[[error:invalid-uid]]'); + } + const groupName = await groups.getGroupNameByGroupSlug(data.slug); + if (!groupName) { + throw new Error('[[error:no-group]]'); + } + const isCallerAdmin = await privileges.admin.can('admin:groups', caller.uid); + if (!isCallerAdmin && (groups.systemGroups.includes(groupName) || groups.isPrivilegeGroup(groupName))) { + throw new Error('[[error:not-allowed]]'); + } + const [groupData, userExists] = await Promise.all([groups.getGroupData(groupName), user.exists(data.uid)]); + if (!userExists) { + throw new Error('[[error:invalid-uid]]'); + } + const isSelf = parseInt(caller.uid, 10) === parseInt(data.uid, 10); + if (!meta.config.allowPrivateGroups && isSelf) { + await groups.join(groupName, data.uid); + logGroupEvent(caller, 'group-join', { + groupName: groupName, + targetUid: data.uid + }); + return; + } + if (!isCallerAdmin && isSelf && groupData.private && groupData.disableJoinRequests) { + throw new Error('[[error:group-join-disabled]]'); + } + if (!groupData.private && isSelf || isCallerAdmin) { + await groups.join(groupName, data.uid); + logGroupEvent(caller, `group-${isSelf ? 'join' : 'add-member'}`, { + groupName: groupName, + targetUid: data.uid + }); + } else if (isSelf) { + await groups.requestMembership(groupName, caller.uid); + logGroupEvent(caller, 'group-request-membership', { + groupName: groupName, + targetUid: data.uid + }); + } else { + throw new Error('[[error:not-allowed]]'); + } +}; +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..9246bef928 --- /dev/null +++ b/lib/api/posts.js @@ -0,0 +1,410 @@ +'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; +}; \ 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 diff --git a/public/src/admin/extend/widgets.js b/public/src/admin/extend/widgets.js index 1238ee772b..70d0fc49eb 100644 --- a/public/src/admin/extend/widgets.js +++ b/public/src/admin/extend/widgets.js @@ -119,18 +119,16 @@ define('admin/extend/widgets', [ const data = $(this).find('form').serializeArray(); for (const d in data) { - if (data.hasOwnProperty(d)) { - if (data[d].name) { - if (widgetData[data[d].name]) { - if (!Array.isArray(widgetData[data[d].name])) { - widgetData[data[d].name] = [ - widgetData[data[d].name], - ]; - } - widgetData[data[d].name].push(data[d].value); - } else { - widgetData[data[d].name] = data[d].value; + if (data.hasOwnProperty(d) && data[d].name) { + const name = data[d].name; + const value = data[d].value; + if (widgetData[name]) { + if (!Array.isArray(widgetData[name])) { + widgetData[name] = [widgetData[name]]; } + widgetData[name].push(value); + } else { + widgetData[name] = value; } } } diff --git a/src/topics/tags.js b/src/topics/tags.js index daab4e5f77..dc1063b807 100644 --- a/src/topics/tags.js +++ b/src/topics/tags.js @@ -131,7 +131,7 @@ module.exports = function (Topics) { await renameTag(tagData.value, tagData.newName); } }; - + const updateTagValues = tags => tags.map(tagItem => tagItem.value); async function renameTag(tag, newTagName) { if (!newTagName || tag === newTagName) { return; @@ -158,7 +158,7 @@ module.exports = function (Topics) { // update 'tags' field in topic hash topicData.forEach((topic) => { - topic.tags = topic.tags.map(tagItem => tagItem.value); + topic.tags = updateTagValues(topic.tags); const index = topic.tags.indexOf(tag); if (index !== -1) { topic.tags.splice(index, 1, newTagName);