diff --git a/CHANGELOG.md b/CHANGELOG.md index d8de9449cc..34402b38c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,48 @@ +#### v4.0.5 (2025-02-20) + +##### Chores + +* bump composer to 10.2.46 for #13132 (7520e4f6) +* up harmony (f82f00e5) +* up widgets (e23a14c1) +* up harmony (c0996a80) +* up dbsearch (d0a9ddea) +* up dbsearch (310fab65) +* add test helper to activitypub file (4bc0031f) +* incrementing version number - v4.0.4 (b1125cce) +* update changelog for v4.0.4 (d3b69a39) +* incrementing version number - v4.0.3 (2b65c735) +* incrementing version number - v4.0.2 (73fe5fcf) +* incrementing version number - v4.0.1 (a461b758) +* incrementing version number - v4.0.0 (c1eaee45) + +##### New Features + +* add upload button to quickreply (f67a0a12) +* remove activities older than a week (9997189a) + +##### Bug Fixes + +* typo (e63f1234) +* #13136, do not log 404s for AP requests (93f48409) +* #13129, serve category backgroundImage as actor `icon`, not `image` (b8200095) +* escape ip blacklist rules (625f4751) +* closes #13180, don't execute cron jobs if ap disabled (a410587c) +* #13172, Topics.addParentPosts not sending sourceContent in calling parsePosts (bb9687bd) +* #13179, fix context resolution failure bug with frequency (6245e33d) +* add back chronological sorting of asserted notes (de6e63bb) +* #13170, remove mime-type and regex test for "Emoji" attachment, wrap tag name in colons if not provided (92708d2f) +* closes #13176, check if uid is number when creating tokens (80cc1d34) +* notes.assertPrivate sanity checks (5e71d597) +* page index for single page, closes #13173 (b0e8058f) +* remove handle on category purge (4134a075) + +##### Tests + +* dont clear local when testing (669755d1) +* show objects on fail (f2824073) +* wait after post request (64318242) + #### v4.0.4 (2025-02-17) ##### Chores diff --git a/install/package.json b/install/package.json index 23cdac6288..74801adb49 100644 --- a/install/package.json +++ b/install/package.json @@ -39,24 +39,24 @@ "@textcomplete/contenteditable": "0.1.13", "@textcomplete/core": "0.1.13", "@textcomplete/textarea": "0.1.13", - "ace-builds": "1.37.5", + "ace-builds": "1.38.0", "archiver": "7.0.1", "async": "3.2.6", "autoprefixer": "10.4.20", "bcryptjs": "2.4.3", - "benchpressjs": "2.5.1", + "benchpressjs": "2.5.3", "body-parser": "1.20.3", "bootbox": "6.0.0", "bootstrap": "5.3.3", "bootswatch": "5.3.3", "chalk": "4.1.2", - "chart.js": "4.4.7", + "chart.js": "4.4.8", "cli-graph": "3.2.2", "clipboard": "2.0.11", "colors": "1.4.0", "commander": "12.1.0", "compare-versions": "6.1.1", - "compression": "1.7.5", + "compression": "1.8.0", "connect-flash": "0.1.1", "connect-mongo": "5.1.0", "connect-multiparty": "2.2.0", @@ -68,7 +68,7 @@ "csrf-sync": "4.0.3", "daemon": "1.1.0", "diff": "7.0.0", - "esbuild": "0.24.2", + "esbuild": "0.25.0", "express": "4.21.2", "express-session": "1.18.1", "express-useragent": "1.0.15", @@ -93,45 +93,45 @@ "lru-cache": "10.4.3", "mime": "3.0.0", "mkdirp": "3.0.1", - "mongodb": "6.12.0", + "mongodb": "6.13.1", "morgan": "1.10.0", "mousetrap": "1.6.5", "multiparty": "4.2.3", "nconf": "0.12.1", "nodebb-plugin-2factor": "7.5.9", - "nodebb-plugin-composer-default": "10.2.46", - "nodebb-plugin-dbsearch": "6.2.12", + "nodebb-plugin-composer-default": "10.2.47", + "nodebb-plugin-dbsearch": "6.2.13", "nodebb-plugin-emoji": "6.0.2", "nodebb-plugin-emoji-android": "4.1.1", "nodebb-plugin-markdown": "13.1.0", - "nodebb-plugin-mentions": "4.6.10", + "nodebb-plugin-mentions": "4.7.0", "nodebb-plugin-spam-be-gone": "2.3.1", - "nodebb-plugin-web-push": "0.7.2", + "nodebb-plugin-web-push": "0.7.3", "nodebb-rewards-essentials": "1.0.1", - "nodebb-theme-harmony": "2.0.28", + "nodebb-theme-harmony": "2.0.37", "nodebb-theme-lavender": "7.1.17", "nodebb-theme-peace": "2.2.39", "nodebb-theme-persona": "14.0.15", - "nodebb-widget-essentials": "7.0.34", - "nodemailer": "6.9.16", + "nodebb-widget-essentials": "7.0.35", + "nodemailer": "6.10.0", "nprogress": "0.2.0", "passport": "0.7.0", "passport-http-bearer": "1.0.1", "passport-local": "1.0.0", - "pg": "8.13.1", - "pg-cursor": "2.12.1", - "postcss": "8.5.1", + "pg": "8.13.3", + "pg-cursor": "2.12.3", + "postcss": "8.5.3", "postcss-clean": "1.2.0", "progress-webpack-plugin": "1.0.16", "prompt": "1.3.0", - "ioredis": "5.4.2", + "ioredis": "5.5.0", "rimraf": "5.0.10", "rss": "1.2.2", "rtlcss": "4.3.0", "sanitize-html": "2.14.0", - "sass": "1.83.4", + "sass": "1.85.0", "satori": "0.12.1", - "semver": "7.6.3", + "semver": "7.7.1", "serve-favicon": "2.5.0", "sharp": "0.32.6", "sitemap": "8.0.0", @@ -146,9 +146,9 @@ "timeago": "1.6.7", "tinycon": "0.6.8", "toobusy-js": "0.5.1", - "tough-cookie": "5.1.0", + "tough-cookie": "5.1.1", "validator": "13.12.0", - "webpack": "5.97.1", + "webpack": "5.98.0", "webpack-merge": "6.0.1", "winston": "3.17.0", "workerpool": "9.2.0", diff --git a/public/language/en-GB/category.json b/public/language/en-GB/category.json index 7e3c6630c5..f6cea780cb 100644 --- a/public/language/en-GB/category.json +++ b/public/language/en-GB/category.json @@ -1,7 +1,8 @@ { "category": "Category", "subcategories": "Subcategories", - + "uncategorized": "Uncategorized", + "uncategorized.description": "Topics that do not strictly fit in with any existing categories", "new-topic-button": "New Topic", "guest-login-post": "Log in to post", "no-topics": "There are no topics in this category.
Why don't you try posting one?", diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json index caa23964b4..d511ef8300 100644 --- a/public/language/en-GB/error.json +++ b/public/language/en-GB/error.json @@ -268,6 +268,7 @@ "invalid-plugin-id": "Invalid plugin ID", "plugin-not-whitelisted": "Unable to install plugin – only plugins whitelisted by the NodeBB Package Manager can be installed via the ACP", + "plugin-installation-via-acp-disabled": "Plugin installation via ACP is disabled", "plugins-set-in-configuration": "You are not allowed to change plugin state as they are defined at runtime (config.json, environmental variables or terminal arguments), please modify the configuration instead.", "theme-not-set-in-configuration": "When defining active plugins in configuration, changing themes requires adding the new theme to the list of active plugins before updating it in the ACP", diff --git a/public/openapi/components/schemas/PostObject.yaml b/public/openapi/components/schemas/PostObject.yaml index 00f840127a..502ea8044d 100644 --- a/public/openapi/components/schemas/PostObject.yaml +++ b/public/openapi/components/schemas/PostObject.yaml @@ -18,6 +18,9 @@ PostObject: For posts received via ActivityPub, it is the url of the original piece of content. content: type: string + sourceContent: + type: string + nullable: true uid: type: number description: A user identifier diff --git a/public/openapi/read/admin/development/info.yaml b/public/openapi/read/admin/development/info.yaml index 81b9e3f49e..1eeb77c1b3 100644 --- a/public/openapi/read/admin/development/info.yaml +++ b/public/openapi/read/admin/development/info.yaml @@ -98,6 +98,8 @@ get: type: boolean jobsDisabled: type: boolean + acpPluginInstallDisabled: + type: boolean git: type: object properties: diff --git a/public/src/client/topic/threadTools.js b/public/src/client/topic/threadTools.js index 38db844cdd..a83a9d86b5 100644 --- a/public/src/client/topic/threadTools.js +++ b/public/src/client/topic/threadTools.js @@ -85,12 +85,24 @@ define('forum/topic/threadTools', [ topicContainer.on('click', '[component="topic/event/delete"]', function () { const eventId = $(this).attr('data-topic-event-id'); - const eventEl = $(this).parents('[component="topic/event"]'); + const eventEl = $(this).parents('[data-topic-event-id]'); bootbox.confirm('[[topic:delete-event-confirm]]', (ok) => { if (ok) { api.del(`/topics/${tid}/events/${eventId}`, {}) .then(function () { + const itemsParent = eventEl.parents('[component="topic/event/items"]'); eventEl.remove(); + if (itemsParent.length) { + const childrenCount = itemsParent.children().length; + const eventParent = itemsParent.parents('[component="topic/event"]'); + if (!childrenCount) { + eventParent.remove(); + } else { + eventParent + .find('[data-bs-toggle]') + .translateText(`[[topic:announcers-x, ${childrenCount}]]`); + } + } }) .catch(alerts.error); } diff --git a/public/src/modules/logout.js b/public/src/modules/logout.js index 400d5c25e1..a44fb56cf0 100644 --- a/public/src/modules/logout.js +++ b/public/src/modules/logout.js @@ -1,6 +1,6 @@ 'use strict'; -define('logout', ['hooks'], function (hooks) { +define('logout', ['hooks', 'alerts'], function (hooks, alerts) { return function logout(redirect) { redirect = redirect === undefined ? true : redirect; hooks.fire('action:app.logout'); @@ -23,6 +23,9 @@ define('logout', ['hooks'], function (hooks) { } } }, + error: function (jqXHR) { + alerts.error(String(jqXHR.responseText || '[[error:logout-error]]')); + }, }); }; }); diff --git a/src/activitypub/actors.js b/src/activitypub/actors.js index bda513661c..0fa91f49c2 100644 --- a/src/activitypub/actors.js +++ b/src/activitypub/actors.js @@ -100,8 +100,16 @@ Actors.assert = async (ids, options = {}) => { try { activitypub.helpers.log(`[activitypub/actors] Processing ${id}`); const actor = (typeof id === 'object' && id.hasOwnProperty('id')) ? id : await activitypub.get('uid', 0, id, { cache: process.env.CI === 'true' }); + + let typeOk = false; + if (Array.isArray(actor.type)) { + typeOk = actor.type.some(type => activitypub._constants.acceptableActorTypes.has(type)); + } else { + typeOk = activitypub._constants.acceptableActorTypes.has(actor.type); + } + if ( - !activitypub._constants.acceptableActorTypes.has(actor.type) || + !typeOk || !activitypub._constants.requiredActorProps.every(prop => actor.hasOwnProperty(prop)) ) { return null; diff --git a/src/activitypub/inbox.js b/src/activitypub/inbox.js index 2887caac04..980709fb58 100644 --- a/src/activitypub/inbox.js +++ b/src/activitypub/inbox.js @@ -64,7 +64,7 @@ inbox.create = async (req) => { const { object } = req.body; // Alternative logic for non-public objects - const isPublic = [...object.to, ...object.cc].includes(activitypub._constants.publicAddress); + const isPublic = [...(object.to || []), ...(object.cc || [])].includes(activitypub._constants.publicAddress); if (!isPublic) { return await activitypub.notes.assertPrivate(object); } diff --git a/src/activitypub/mocks.js b/src/activitypub/mocks.js index 223d1a8cbd..4a2fa1e9b2 100644 --- a/src/activitypub/mocks.js +++ b/src/activitypub/mocks.js @@ -29,12 +29,14 @@ const Mocks = module.exports; * Done so the output HTML is stripped of all non-essential items; mainly classes from plugins.. */ const sanitizeConfig = { - allowedTags: sanitize.defaults.allowedTags.concat(['img']), + allowedTags: sanitize.defaults.allowedTags.concat(['img', 'picture', 'source']), allowedClasses: { '*': [], }, allowedAttributes: { a: ['href', 'rel'], + source: ['type', 'src', 'srcset', 'sizes', 'media', 'height', 'width'], + img: ['alt', 'height', 'ismap', 'src', 'usemap', 'width', 'srcset'], }, }; diff --git a/src/activitypub/notes.js b/src/activitypub/notes.js index 2b6a0fceaa..ed0b1f6791 100644 --- a/src/activitypub/notes.js +++ b/src/activitypub/notes.js @@ -234,7 +234,7 @@ Notes.assertPrivate = async (object) => { } const localUids = []; - const recipients = new Set([...object.to, ...object.cc]); + const recipients = new Set([...(object.to || []), ...(object.cc || [])]); await Promise.all(Array.from(recipients).map(async (value) => { const { type, id } = await activitypub.helpers.resolveLocalId(value); if (type === 'user') { diff --git a/src/categories/data.js b/src/categories/data.js index 2b4e029caf..8890abf670 100644 --- a/src/categories/data.js +++ b/src/categories/data.js @@ -15,8 +15,8 @@ const intFields = [ const worldCategory = { cid: -1, - name: 'Uncategorized', - description: 'Topics that do not strictly fit in with any existing categories', + name: '[[category:uncategorized]]', + description: '[[category:uncategorized.description]]', icon: 'fa-globe', imageClass: 'cover', bgColor: '#eee', diff --git a/src/controllers/activitypub/topics.js b/src/controllers/activitypub/topics.js index 7a871a6876..fac130eb97 100644 --- a/src/controllers/activitypub/topics.js +++ b/src/controllers/activitypub/topics.js @@ -66,7 +66,6 @@ controller.list = async function (req, res) { targetUid: targetUid, }; const data = await categories.getCategoryById(cidQuery); - data.name = '[[world:name]]'; delete data.children; const tids = await getTids(cidQuery); diff --git a/src/controllers/admin/info.js b/src/controllers/admin/info.js index 45ffe078b7..6f63faf8a9 100644 --- a/src/controllers/admin/info.js +++ b/src/controllers/admin/info.js @@ -88,6 +88,7 @@ async function getNodeInfo() { isPrimary: nconf.get('isPrimary'), runJobs: nconf.get('runJobs'), jobsDisabled: nconf.get('jobsDisabled'), + acpPluginInstallDisabled: nconf.get('acpPluginInstallDisabled'), }, }; diff --git a/src/controllers/admin/users.js b/src/controllers/admin/users.js index 4ea3ebb9e5..14e50bf9eb 100644 --- a/src/controllers/admin/users.js +++ b/src/controllers/admin/users.js @@ -199,7 +199,7 @@ async function loadUserInfo(callerUid, uids) { const confirmObj = confirmObjs[index]; user['email:expired'] = !confirmObj.expires || Date.now() >= confirmObj.expires; user['email:pending'] = confirmObj.expires && Date.now() < confirmObj.expires; - user.emailToConfirm = confirmObj.email; + user.emailToConfirm = validator.escape(String(confirmObj.email)); } } }); diff --git a/src/controllers/authentication.js b/src/controllers/authentication.js index 007d3ba6ac..c946ca8292 100644 --- a/src/controllers/authentication.js +++ b/src/controllers/authentication.js @@ -459,7 +459,7 @@ authenticationController.localLogin = async function (req, username, password, n } }; -authenticationController.logout = async function (req, res, next) { +authenticationController.logout = async function (req, res) { if (!req.loggedIn || !req.sessionID) { res.clearCookie(nconf.get('sessionKey'), meta.configs.cookie.get()); return res.status(200).send('not-logged-in'); @@ -475,21 +475,22 @@ authenticationController.logout = async function (req, res, next) { await user.setUserField(uid, 'lastonline', Date.now() - (meta.config.onlineCutoff * 60000)); await db.sortedSetAdd('users:online', Date.now() - (meta.config.onlineCutoff * 60000), uid); - await plugins.hooks.fire('static:user.loggedOut', { req: req, res: res, uid: uid, sessionID: sessionID }); + await plugins.hooks.fire('static:user.loggedOut', { req, res, uid, sessionID }); // Force session check for all connected socket.io clients with the same session id sockets.in(`sess_${sessionID}`).emit('checkSession', 0); const payload = { next: `${nconf.get('relative_path')}/`, }; - plugins.hooks.fire('filter:user.logout', payload); + await plugins.hooks.fire('filter:user.logout', payload); if (req.body.noscript === 'true') { return res.redirect(payload.next); } res.status(200).send(payload); } catch (err) { - next(err); + winston.error(`${req.method} ${req.originalUrl}\n${err.stack}`); + res.status(500).send(err.message); } }; diff --git a/src/emailer.js b/src/emailer.js index 1b545b1a35..6c57d6b44a 100644 --- a/src/emailer.js +++ b/src/emailer.js @@ -12,6 +12,7 @@ const fs = require('fs'); const _ = require('lodash'); const jwt = require('jsonwebtoken'); +const db = require('./database'); const User = require('./user'); const Plugins = require('./plugins'); const meta = require('./meta'); @@ -223,6 +224,13 @@ Emailer.send = async (template, uid, params) => { // 'welcome' and 'verify-email' explicitly used passed-in email address if (['welcome', 'verify-email'].includes(template)) { userData.email = params.email; + } else if (meta.config.includeUnverifiedEmails && !userData.email) { + // get unconfirmed email to use + const code = await db.get(`confirm:byUid:${uid}`); + const confirmObj = code ? await db.getObject(`confirm:${code}`) : null; + if (confirmObj && confirmObj.email) { + userData.email = String(confirmObj.email); + } } ({ template, userData, params } = await Plugins.hooks.fire('filter:email.prepare', { template, uid, userData, params })); diff --git a/src/posts/summary.js b/src/posts/summary.js index 89e6087036..5995514eb6 100644 --- a/src/posts/summary.js +++ b/src/posts/summary.js @@ -22,7 +22,7 @@ module.exports = function (Posts) { options.escape = options.hasOwnProperty('escape') ? options.escape : false; options.extraFields = options.hasOwnProperty('extraFields') ? options.extraFields : []; - const fields = ['pid', 'tid', 'toPid', 'url', 'content', 'uid', 'timestamp', 'deleted', 'upvotes', 'downvotes', 'replies', 'handle'].concat(options.extraFields); + const fields = ['pid', 'tid', 'toPid', 'url', 'content', 'sourceContent', 'uid', 'timestamp', 'deleted', 'upvotes', 'downvotes', 'replies', 'handle'].concat(options.extraFields); let posts = await Posts.getPostsFields(pids, fields); posts = posts.filter(Boolean); @@ -74,7 +74,7 @@ module.exports = function (Posts) { async function parsePosts(posts, options) { return await Promise.all(posts.map(async (post) => { - if (!post.content) { + if (!post.content && !post.sourceContent) { return post; } if (options.parse) { diff --git a/src/prestart.js b/src/prestart.js index b09ef5d9bc..57b5f0590e 100644 --- a/src/prestart.js +++ b/src/prestart.js @@ -58,6 +58,7 @@ function loadConfig(configFile) { isCluster: false, isPrimary: true, jobsDisabled: false, + acpPluginInstallDisabled: false, fontawesome: { pro: false, styles: '*', @@ -65,7 +66,7 @@ function loadConfig(configFile) { }); // Explicitly cast as Bool, loader.js passes in isCluster as string 'true'/'false' - const castAsBool = ['isCluster', 'isPrimary', 'jobsDisabled']; + const castAsBool = ['isCluster', 'isPrimary', 'jobsDisabled', 'acpPluginInstallDisabled']; nconf.stores.env.readOnly = false; castAsBool.forEach((prop) => { const value = nconf.get(prop); diff --git a/src/routes/well-known.js b/src/routes/well-known.js index becd1370f8..399668b679 100644 --- a/src/routes/well-known.js +++ b/src/routes/well-known.js @@ -39,8 +39,8 @@ module.exports = function (app, middleware, controllers) { const oneMonthAgo = addMonths(new Date(), -1); const sixMonthsAgo = addMonths(new Date(), -6); - const [{ postCount, userCount }, activeMonth, activeHalfyear] = await Promise.all([ - db.getObjectFields('global', ['postCount', 'userCount']), + const [{ postCount, topicCount, userCount }, activeMonth, activeHalfyear] = await Promise.all([ + db.getObjectFields('global', ['postCount', 'topicCount', 'userCount']), db.sortedSetCount('users:online', oneMonthAgo.getTime(), '+inf'), db.sortedSetCount('users:online', sixMonthsAgo.getTime(), '+inf'), ]); @@ -64,7 +64,8 @@ module.exports = function (app, middleware, controllers) { activeMonth: activeMonth, activeHalfyear: activeHalfyear, }, - localPosts: postCount, + localPosts: topicCount, + localComments: postCount - topicCount, }, openRegistrations: meta.config.registrationType === 'normal', metadata: { diff --git a/src/socket.io/admin/plugins.js b/src/socket.io/admin/plugins.js index 2d6f705be9..d926dfa0cf 100644 --- a/src/socket.io/admin/plugins.js +++ b/src/socket.io/admin/plugins.js @@ -22,6 +22,10 @@ Plugins.toggleActive = async function (socket, plugin_id) { }; Plugins.toggleInstall = async function (socket, data) { + const isInstalled = await plugins.isInstalled(data.id); + if (nconf.get('acpPluginInstallDisabled') && !isInstalled) { + throw new Error('[[error:plugin-installation-via-acp-disabled]]'); + } postsCache.reset(); await plugins.checkWhitelist(data.id, data.version); const pluginData = await plugins.toggleInstall(data.id, data.version); diff --git a/src/socket.io/helpers.js b/src/socket.io/helpers.js index 66e568d1b2..d80638458e 100644 --- a/src/socket.io/helpers.js +++ b/src/socket.io/helpers.js @@ -17,6 +17,10 @@ const batch = require('../batch'); const SocketHelpers = module.exports; SocketHelpers.notifyNew = async function (uid, type, result) { + const post = result.posts[0]; + if (post && post.topic && parseInt(post.topic.cid, 10) === -1) { + return; + } let uids = await user.getUidsFromSet('users:online', 0, -1); uids = uids.filter(toUid => parseInt(toUid, 10) !== uid); await batch.processArray(uids, async (uids) => { diff --git a/src/topics/events.js b/src/topics/events.js index 04dfe9b4d5..10ab9b8936 100644 --- a/src/topics/events.js +++ b/src/topics/events.js @@ -107,7 +107,7 @@ function renderUser(event) { if (!event.user || event.user.system) { return '[[global:system-user]]'; } - return `${helpers.buildAvatar(event.user, '16px', true)} ${event.user.username}`; + return `${helpers.buildAvatar(event.user, '16px', true)} ${event.user.displayname}`; } function renderTimeago(event) { diff --git a/src/topics/index.js b/src/topics/index.js index 07600e6311..5717f26126 100644 --- a/src/topics/index.js +++ b/src/topics/index.js @@ -198,6 +198,7 @@ Topics.getTopicWithPosts = async function (topicData, set, uid, start, stop, rev ); p.eventStart = undefined; p.eventEnd = undefined; + p.events = mergeConsecutiveShareEvents(p.events); }); topicData.category = category; @@ -230,6 +231,23 @@ Topics.getTopicWithPosts = async function (topicData, set, uid, start, stop, rev return result.topic; }; +function mergeConsecutiveShareEvents(arr) { + return arr.reduce((acc, curr) => { + const last = acc[acc.length - 1]; + if (last && last.type === curr.type && last.type === 'share') { + if (!last.items) { + last.items = [{ ...last }]; + ['user', 'text', 'timestamp', 'timestampISO'].forEach(field => delete last[field]); + } + last.items.push(curr); + } else { + acc.push(curr); + } + return acc; + }, []); +} + + async function getDeleter(topicData) { if (!parseInt(topicData.deleterUid, 10)) { return null; diff --git a/src/upgrades/3.6.0/category_tracking.js b/src/upgrades/3.6.0/category_tracking.js index a30be983b6..d57717cd54 100644 --- a/src/upgrades/3.6.0/category_tracking.js +++ b/src/upgrades/3.6.0/category_tracking.js @@ -3,7 +3,6 @@ 'use strict'; const db = require('../../database'); -const user = require('../../user'); const batch = require('../../batch'); module.exports = { @@ -18,7 +17,7 @@ module.exports = { } await batch.processSortedSet(`users:joindate`, async (uids) => { - const userSettings = await user.getMultipleUserSettings(uids); + const userSettings = await db.getObjects(uids.map(uid => `user:${uid}:settings`)); const change = userSettings.filter(s => s && s.categoryWatchState === 'watching'); await db.setObjectBulk( change.map(s => [`user:${s.uid}:settings`, { categoryWatchState: 'tracking' }]) diff --git a/src/user/email.js b/src/user/email.js index aec9379f41..c14c9c93fc 100644 --- a/src/user/email.js +++ b/src/user/email.js @@ -36,8 +36,10 @@ UserEmail.remove = async function (uid, sessionId) { email: '', 'email:confirmed': 0, }), - db.sortedSetRemove('email:uid', email.toLowerCase()), - db.sortedSetRemove('email:sorted', `${email.toLowerCase()}:${uid}`), + db.sortedSetRemoveBulk([ + ['email:uid', email.toLowerCase()], + ['email:sorted', `${email.toLowerCase()}:${uid}`], + ]), user.email.expireValidation(uid), sessionId ? user.auth.revokeAllSessions(uid, sessionId) : Promise.resolve(), events.log({ @@ -53,7 +55,7 @@ UserEmail.getEmailForValidation = async (uid) => { let email = ''; // check email from confirmObj const code = await db.get(`confirm:byUid:${uid}`); - const confirmObj = await db.getObject(`confirm:${code}`); + const confirmObj = code ? await db.getObject(`confirm:${code}`) : null; if (confirmObj && confirmObj.email && parseInt(uid, 10) === parseInt(confirmObj.uid, 10)) { email = confirmObj.email; } diff --git a/src/user/profile.js b/src/user/profile.js index c150675d6a..3009d0a3d5 100644 --- a/src/user/profile.js +++ b/src/user/profile.js @@ -282,6 +282,9 @@ module.exports = function (User) { if (oldEmail === newEmail) { return; } + if (await User.email.isValidationPending(uid, newEmail)) { + return; + } // 👉 Looking for email change logic? src/user/email.js (UserEmail.confirmByUid) if (newEmail) { diff --git a/src/views/partials/chats/parent.tpl b/src/views/partials/chats/parent.tpl index 2d2e66bf3b..68f91414c5 100644 --- a/src/views/partials/chats/parent.tpl +++ b/src/views/partials/chats/parent.tpl @@ -4,7 +4,7 @@
{buildAvatar(messages.parent.user, "14px", true, "not-responsive align-middle")} - {messages.parent.user.displayname} + {messages.parent.user.displayname}
diff --git a/src/views/partials/topic/post-parent.tpl b/src/views/partials/topic/post-parent.tpl index ae597e3b84..38f22da616 100644 --- a/src/views/partials/topic/post-parent.tpl +++ b/src/views/partials/topic/post-parent.tpl @@ -1,9 +1,9 @@
-
+
diff --git a/test/activitypub.js b/test/activitypub.js index d2c2334140..d05f50e4e6 100644 --- a/test/activitypub.js +++ b/test/activitypub.js @@ -350,7 +350,7 @@ describe('ActivityPub integration', () => { }); }); - describe.only('Category Actor endpoint', () => { + describe('Category Actor endpoint', () => { let cid; let slug; let description; diff --git a/test/activitypub/helpers.js b/test/activitypub/helpers.js new file mode 100644 index 0000000000..23fc29be14 --- /dev/null +++ b/test/activitypub/helpers.js @@ -0,0 +1,60 @@ +'use strict'; + +const utils = require('../../src/utils'); +const activitypub = require('../../src/activitypub'); + +const Helpers = module.exports; + +Helpers.mocks = {}; + +Helpers.mocks.note = (override = {}) => { + const baseUrl = 'https://example.org'; + const uuid = utils.generateUUID(); + const id = `${baseUrl}/object/${uuid}`; + const note = { + '@context': 'https://www.w3.org/ns/activitystreams', + id, + url: id, + type: 'Note', + to: ['https://www.w3.org/ns/activitystreams#Public'], + cc: ['https://example.org/user/foobar/followers'], + inReplyTo: null, + attributedTo: 'https://example.org/user/foobar', + name: utils.generateUUID(), + content: `

${utils.generateUUID()}

`, + published: new Date().toISOString(), + ...override, + }; + + // If any values contain the hardcoded string "remove", remove that prop + Object.entries(note).forEach(([key, value]) => { + if (value === 'remove') { + delete note[key]; + } + }); + activitypub._cache.set(`0;${id}`, note); + + return { id, note }; +}; + +Helpers.mocks.create = (object) => { + // object is optional, will generate a public note if undefined + const baseUrl = 'https://example.org'; + const uuid = utils.generateUUID(); + const id = `${baseUrl}/activity/${uuid}`; + + object = object || Helpers.mocks.note().note; + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + id, + type: 'Create', + to: ['https://www.w3.org/ns/activitystreams#Public'], + cc: ['https://example.org/user/foobar/followers'], + actor: 'https://example.org/user/foobar', + object, + }; + + activitypub._cache.set(`0;${id}`, activity); + + return { id, activity }; +}; diff --git a/test/activitypub/notes.js b/test/activitypub/notes.js index 482b706d14..6a27c5904b 100644 --- a/test/activitypub/notes.js +++ b/test/activitypub/notes.js @@ -1,15 +1,81 @@ 'use strict'; const assert = require('assert'); +const nconf = require('nconf'); const db = require('../../src/database'); +const meta = require('../../src/meta'); +const install = require('../../src/install'); const user = require('../../src/user'); const categories = require('../../src/categories'); +const posts = require('../../src/posts'); const topics = require('../../src/topics'); const activitypub = require('../../src/activitypub'); const utils = require('../../src/utils'); +const helpers = require('./helpers'); + describe('Notes', () => { + describe('Assertion', () => { + before(async () => { + meta.config.activitypubEnabled = 1; + await install.giveWorldPrivileges(); + }); + + describe('Public objects', () => { + it('should pull a remote root-level object by its id and create a new topic', async () => { + const { id } = helpers.mocks.note(); + const { tid, count } = await activitypub.notes.assert(0, id, { skipChecks: true }); + assert.strictEqual(count, 1); + + const exists = await topics.exists(tid); + assert(exists); + }); + + it('should assert if the cc property is missing', async () => { + const { id } = helpers.mocks.note({ cc: 'remove' }); + const { tid, count } = await activitypub.notes.assert(0, id, { skipChecks: true }); + assert.strictEqual(count, 1); + + const exists = await topics.exists(tid); + assert(exists); + }); + }); + + describe('Private objects', () => { + let recipientUid; + + before(async () => { + recipientUid = await user.create({ username: utils.generateUUID().slice(0, 8) }); + }); + + it('should NOT create a new topic or post when asserting a private note', async () => { + const { id, note } = helpers.mocks.note({ + to: [`${nconf.get('url')}/uid/${recipientUid}`], + cc: [], + }); + const { activity } = helpers.mocks.create(note); + const { roomId } = await activitypub.inbox.create({ body: activity }); + assert(roomId); + assert(utils.isNumber(roomId)); + + const exists = await posts.exists(id); + assert(!exists); + }); + + it('should still assert if the cc property is missing', async () => { + const { id, note } = helpers.mocks.note({ + to: [`${nconf.get('url')}/uid/${recipientUid}`], + cc: 'remove', + }); + const { activity } = helpers.mocks.create(note); + const { roomId } = await activitypub.inbox.create({ body: activity }); + assert(roomId); + assert(utils.isNumber(roomId)); + }); + }); + }); + describe('Inbox Synchronization', () => { let cid; let uid; diff --git a/test/mocks/databasemock.js b/test/mocks/databasemock.js index 587dd65dcc..6a568109b7 100644 --- a/test/mocks/databasemock.js +++ b/test/mocks/databasemock.js @@ -144,6 +144,7 @@ before(async function () { nconf.set('version', packageInfo.version); nconf.set('runJobs', false); nconf.set('jobsDisabled', false); + nconf.set('acpPluginInstallDisabled', false); await db.init();