From 672dcc5d142e34f36cb9621dca1e05c7d41ee1ea Mon Sep 17 00:00:00 2001 From: Misty Release Bot Date: Fri, 16 May 2025 16:37:49 +0000 Subject: [PATCH 01/21] chore: incrementing version number - v4.4.1 --- install/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install/package.json b/install/package.json index e6500e7908..abdbfcc2c0 100644 --- a/install/package.json +++ b/install/package.json @@ -2,7 +2,7 @@ "name": "nodebb", "license": "GPL-3.0", "description": "NodeBB Forum", - "version": "4.4.0", + "version": "4.4.1", "homepage": "https://www.nodebb.org", "repository": { "type": "git", From a686cf20624dfebf2077ddfb86054252deef7061 Mon Sep 17 00:00:00 2001 From: Misty Release Bot Date: Fri, 16 May 2025 16:37:49 +0000 Subject: [PATCH 02/21] chore: update changelog for v4.4.1 --- CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5728c63beb..45ae952b8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,47 @@ +#### v4.4.1 (2025-05-16) + +##### Chores + +* up themes (61a63851) +* incrementing version number - v4.4.0 (0a75eee3) +* update changelog for v4.4.0 (09cc91d5) +* incrementing version number - v4.3.2 (b92b5d80) +* incrementing version number - v4.3.1 (308e6b9f) +* incrementing version number - v4.3.0 (bff291db) +* incrementing version number - v4.2.2 (17fecc24) +* incrementing version number - v4.2.1 (852a270c) +* incrementing version number - v4.2.0 (87581958) +* incrementing version number - v4.1.1 (b2afbb16) +* incrementing version number - v4.1.0 (36c80850) +* incrementing version number - v4.0.6 (4a52fb2e) +* incrementing version number - v4.0.5 (1792a62b) +* incrementing version number - v4.0.4 (b1125cce) +* 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 + +* save width and height values into post attachment (3674fa57) +* use local date string for digest subject (3d96afb2) + +##### Bug Fixes + +* openapi schema to handle additional `attachments` field in postsobject (ce5ef1ab) +* group edit url (0a574d72) +* add attachments to getpostsummaries call in search, #13324 (8f9f3771) +* bring back auto-categorization if group and object are same-origin, handle Peertube putting channel names in `attributedTo` (a460a550) +* #13419, handle remote content with mediaType text/markdown (45a11d45) + +##### Refactors + +* create date once per digest.send (6c3e2a8e) + +##### Tests + +* fix tests to account for a460a55064e1280f36a0021e0510c7c557251030 (948bfe46) + #### v4.4.0 (2025-05-14) ##### Breaking Changes From a16bc7382cee1fe5c278ca05bd1c014203de8ff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Thu, 22 May 2025 11:01:05 -0400 Subject: [PATCH 03/21] chore: up harmony --- install/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install/package.json b/install/package.json index abdbfcc2c0..cce2fc7c1e 100644 --- a/install/package.json +++ b/install/package.json @@ -107,7 +107,7 @@ "nodebb-plugin-spam-be-gone": "2.3.2", "nodebb-plugin-web-push": "0.7.4", "nodebb-rewards-essentials": "1.0.2", - "nodebb-theme-harmony": "2.1.13", + "nodebb-theme-harmony": "2.1.14", "nodebb-theme-lavender": "7.1.19", "nodebb-theme-peace": "2.2.43", "nodebb-theme-persona": "14.1.12", From 99234b3f97af00c136d126fd748e8c6cbb1ff989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Thu, 22 May 2025 11:16:14 -0400 Subject: [PATCH 04/21] chore: up harmony --- install/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install/package.json b/install/package.json index cce2fc7c1e..331013f700 100644 --- a/install/package.json +++ b/install/package.json @@ -107,7 +107,7 @@ "nodebb-plugin-spam-be-gone": "2.3.2", "nodebb-plugin-web-push": "0.7.4", "nodebb-rewards-essentials": "1.0.2", - "nodebb-theme-harmony": "2.1.14", + "nodebb-theme-harmony": "2.1.15", "nodebb-theme-lavender": "7.1.19", "nodebb-theme-peace": "2.2.43", "nodebb-theme-persona": "14.1.12", From e70e990a1aa932a3f994b2205bacc2f35730aa01 Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Thu, 22 May 2025 14:13:41 -0400 Subject: [PATCH 05/21] feat: restrict access to ap.probe method to registered users, add rate limiting protection --- src/activitypub/index.js | 22 ++++++++++++++++++++++ src/controllers/activitypub/index.js | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/activitypub/index.js b/src/activitypub/index.js index c87a0654f3..4067405080 100644 --- a/src/activitypub/index.js +++ b/src/activitypub/index.js @@ -27,6 +27,9 @@ const probeCache = ttl({ max: 500, ttl: 1000 * 60 * 60, // 1 hour }); +const probeRateLimit = ttl({ + ttl: 1000 * 3, // 3 seconds +}); const ActivityPub = module.exports; @@ -506,6 +509,13 @@ ActivityPub.probe = async ({ uid, url }) => { * - Returns a relative path if already available, true if not, and false otherwise. */ + // Disable on config setting; restrict lookups to HTTPS-enabled URLs only + const { activitypubProbe } = meta.config; + const { protocol } = new URL(url); + if (!activitypubProbe || protocol !== 'https:') { + return false; + } + // Known resources const [isNote, isMessage, isActor, isActorUrl] = await Promise.all([ posts.exists(url), @@ -541,6 +551,17 @@ ActivityPub.probe = async ({ uid, url }) => { } } + // Guests not allowed to use expensive logic path + if (!uid) { + return false; + } + + // One request allowed every 3 seconds (configured at top) + const limited = probeRateLimit.get(uid); + if (limited) { + return false; + } + // Cached result if (probeCache.has(url)) { return probeCache.get(url); @@ -572,6 +593,7 @@ ActivityPub.probe = async ({ uid, url }) => { return false; } try { + probeRateLimit.set(uid, true); return await checkHeader(meta.config.activitypubProbeTimeout || 2000); } catch (e) { if (e.name === 'TimeoutError') { diff --git a/src/controllers/activitypub/index.js b/src/controllers/activitypub/index.js index 36054d6616..0e7112ddfd 100644 --- a/src/controllers/activitypub/index.js +++ b/src/controllers/activitypub/index.js @@ -31,7 +31,7 @@ Controller.fetch = async (req, res, next) => { if (typeof result === 'string') { return helpers.redirect(res, result); } else if (result) { - const { id, type } = await activitypub.get('uid', req.uid || 0, url.href); + const { id, type } = await activitypub.get('uid', req.uid, url.href); switch (true) { case activitypub._constants.acceptedPostTypes.includes(type): { return helpers.redirect(res, `/post/${encodeURIComponent(id)}`); From 30aa0fe6d25c0b5aac9df422818911e909c178aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Sat, 24 May 2025 11:49:49 -0400 Subject: [PATCH 06/21] chore: up dbsearch --- install/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install/package.json b/install/package.json index 331013f700..b47fba2015 100644 --- a/install/package.json +++ b/install/package.json @@ -99,7 +99,7 @@ "nconf": "0.13.0", "nodebb-plugin-2factor": "7.5.10", "nodebb-plugin-composer-default": "10.2.50", - "nodebb-plugin-dbsearch": "6.2.16", + "nodebb-plugin-dbsearch": "6.2.18", "nodebb-plugin-emoji": "6.0.2", "nodebb-plugin-emoji-android": "4.1.1", "nodebb-plugin-markdown": "13.2.1", From e2de0ec212bda29ae419f81fc92cad122da99e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Sat, 24 May 2025 16:50:53 -0400 Subject: [PATCH 07/21] chore: up dbsearch --- install/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install/package.json b/install/package.json index b47fba2015..c8298570fd 100644 --- a/install/package.json +++ b/install/package.json @@ -99,7 +99,7 @@ "nconf": "0.13.0", "nodebb-plugin-2factor": "7.5.10", "nodebb-plugin-composer-default": "10.2.50", - "nodebb-plugin-dbsearch": "6.2.18", + "nodebb-plugin-dbsearch": "6.2.19", "nodebb-plugin-emoji": "6.0.2", "nodebb-plugin-emoji-android": "4.1.1", "nodebb-plugin-markdown": "13.2.1", From fd2ae7261e0e8378c10e7b8863c368ce864d5edb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Sun, 25 May 2025 19:04:01 -0400 Subject: [PATCH 08/21] chore: up eslint stylistic --- install/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/install/package.json b/install/package.json index c8298570fd..29c2898f4f 100644 --- a/install/package.json +++ b/install/package.json @@ -162,8 +162,8 @@ "@commitlint/config-angular": "19.8.1", "coveralls": "3.1.1", "@eslint/js": "9.26.0", - "@stylistic/eslint-plugin-js": "4.2.0", - "eslint-config-nodebb": "1.1.4", + "@stylistic/eslint-plugin-js": "4.4.0", + "eslint-config-nodebb": "1.1.5", "eslint-plugin-import": "2.31.0", "grunt": "1.6.1", "grunt-contrib-watch": "1.1.0", From a888b868c70361b2298c2fa1eb4770085c2c6689 Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Mon, 26 May 2025 14:49:48 -0400 Subject: [PATCH 09/21] fix: additional tests for remote privileges, enforcing privileges for remote edits and deletes --- src/activitypub/inbox.js | 14 ++- test/activitypub/helpers.js | 23 ++++ test/activitypub/privileges.js | 204 +++++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 test/activitypub/privileges.js diff --git a/src/activitypub/inbox.js b/src/activitypub/inbox.js index ab02cef6db..0369b11248 100644 --- a/src/activitypub/inbox.js +++ b/src/activitypub/inbox.js @@ -95,6 +95,12 @@ inbox.update = async (req) => { try { switch (true) { case isNote: { + const cid = await posts.getCidByPid(object.id); + const allowed = await privileges.categories.can('posts:edit', cid, activitypub._constants.uid); + if (!allowed) { + throw new Error('[[error:no-privileges]]'); + } + const postData = await activitypub.mocks.post(object); postData.tags = await activitypub.notes._normalizeTags(postData._activitypub.tag, postData.cid); await posts.edit(postData); @@ -200,7 +206,7 @@ inbox.delete = async (req) => { const objectHostname = new URL(pid).hostname; if (actorHostname !== objectHostname) { - throw new Error('[[error:activitypub.origin-mismatch]]'); + return reject('Delete', object, actor); } const [isNote/* , isActor */] = await Promise.all([ @@ -210,6 +216,12 @@ inbox.delete = async (req) => { switch (true) { case isNote: { + const cid = await posts.getCidByPid(pid); + const allowed = await privileges.categories.can('posts:edit', cid, activitypub._constants.uid); + if (!allowed) { + return reject('Delete', object, actor); + } + const uid = await posts.getPostField(pid, 'uid'); await activitypub.feps.announce(pid, req.body); await api.posts[method]({ uid }, { pid }); diff --git a/test/activitypub/helpers.js b/test/activitypub/helpers.js index 2690910e06..3bad2f7002 100644 --- a/test/activitypub/helpers.js +++ b/test/activitypub/helpers.js @@ -202,3 +202,26 @@ Helpers.mocks.update = (override = {}) => { return { activity }; }; +Helpers.mocks.delete = (override = {}) => { + let actor = override.actor; + let object = override.object; + if (!actor) { + ({ id: actor } = Helpers.mocks.person()); + } + if (!object) { + ({ id: object } = Helpers.mocks.note()); + } + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + id: `${Helpers.mocks._baseUrl}/delete/${encodeURIComponent(object.id || object)}`, + type: 'Delete', + to: [activitypub._constants.publicAddress], + cc: [`${actor}/followers`], + actor, + object, + }; + + return { activity }; +}; + diff --git a/test/activitypub/privileges.js b/test/activitypub/privileges.js new file mode 100644 index 0000000000..2284b7203d --- /dev/null +++ b/test/activitypub/privileges.js @@ -0,0 +1,204 @@ +'use strict'; + +const assert = require('assert'); +const nconf = require('nconf'); + +const db = require('../mocks/databasemock'); +const user = require('../../src/user'); +const topics = require('../../src/topics'); +const posts = require('../../src/posts'); +const categories = require('../../src/categories'); +const privileges = require('../../src/privileges'); +const meta = require('../../src/meta'); +const install = require('../../src/install'); +const utils = require('../../src/utils'); +const activitypub = require('../../src/activitypub'); + +const helpers = require('./helpers'); + +describe('Privilege logic for remote users/content (ActivityPub)', () => { + before(async () => { + meta.config.activitypubEnabled = 1; + // await install.giveWorldPrivileges(); + }); + + describe('"fediverse" pseudo-user', () => { + describe('no privileges given', () => { + let uid; + let cid; + let topicData; + let postData; + let mainPid; + let handle; + + before(async () => { + uid = await user.create({ username: utils.generateUUID() }); + ({ cid } = await categories.create({ name: utils.generateUUID() })); + ({ topicData, postData } = await topics.post({ + cid, + uid, + title: utils.generateUUID(), + content: utils.generateUUID(), + })); + handle = await categories.getCategoryField(cid, 'handle'); + const privsToRemove = await privileges.categories.getGroupPrivilegeList(); + await privileges.categories.rescind(privsToRemove, cid, ['fediverse']); + }); + + describe('incoming requests', () => { + it('should not respond to a webfinger request to a category\'s handle', async () => { + const response = await activitypub.helpers.query(`${handle}@${nconf.get('url_parsed').hostname}`); + assert.strictEqual(response, false); + }); + + it('should not respond to a request for the category actor', async () => { + await assert.rejects( + activitypub.get('uid', uid, `${nconf.get('url')}/category/${cid}`), + { message: '[[error:activitypub.get-failed]]' } + ); + }); + + it('should not respond to a request for a topic collection', async () => { + await assert.rejects( + activitypub.get('uid', uid, `${nconf.get('url')}/topic/${topicData.tid}`), + { message: '[[error:activitypub.get-failed]]' } + ); + }); + + it('should not respond to a request for a post', async () => { + await assert.rejects( + activitypub.get('uid', uid, `${nconf.get('url')}/post/${topicData.mainPid}`), + { message: '[[error:activitypub.get-failed]]' } + ); + }); + }); + + describe('incoming activities', () => { + describe('Create(Note)', () => { + let note; + let activity; + + before(async () => { + ({ note } = helpers.mocks.note({ + cc: [`${nconf.get('url')}/category/${cid}`], + })); + ({ activity } = helpers.mocks.create(note)); + await activitypub.inbox.create({ body: activity }); + }); + + it('should not assert the note', async () => { + const exists = await posts.exists(note.id); + assert.strictEqual(exists, false); + }); + }); + + describe('Update(Note)', () => { + let note; + let activity; + + before(async () => { + ({ note } = helpers.mocks.note({ + cc: [`${nconf.get('url')}/category/${cid}`], + })); + ({ activity } = helpers.mocks.create(note)); + await privileges.categories.give(['groups:topics:create'], cid, ['fediverse']); + await activitypub.inbox.create({ body: activity }); + }); + + after(async () => { + await privileges.categories.rescind(['groups:topics:create'], cid, ['fediverse']); + }); + + it('should assert the note', async () => { + const exists = await posts.exists(note.id); + assert.strictEqual(exists, true); + }); + + it('should not allow edits to the note', async () => { + const oldContent = note.content; + note.content = 'new content'; + ({ activity } = helpers.mocks.update({ + object: note, + })); + + await activitypub.inbox.update({ body: activity }); + + const postData = await posts.getPostData(note.id); + assert.strictEqual(postData.content, oldContent); + assert.strictEqual(postData.edited, 0); + }); + }); + + describe('Delete(Note)', () => { + let note; + let activity; + + before(async () => { + ({ note } = helpers.mocks.note({ + cc: [`${nconf.get('url')}/category/${cid}`], + })); + ({ activity } = helpers.mocks.create(note)); + await privileges.categories.give(['groups:topics:create'], cid, ['fediverse']); + await activitypub.inbox.create({ body: activity }); + }); + + after(async () => { + await privileges.categories.rescind(['groups:topics:create'], cid, ['fediverse']); + }); + + it('should assert the note', async () => { + const exists = await posts.exists(note.id); + assert.strictEqual(exists, true); + }); + + it('should ignore remote deletion of said note', async () => { + ({ activity } = helpers.mocks.delete({ object: note })); + await activitypub.inbox.delete({ body: activity }); + + const exists = await posts.exists(note.id); + assert.strictEqual(exists, true); + }); + }); + }); + + describe('outgoing requests', () => { + it('should not federate out a new post', async () => { + + }); + + it('should not federate out a post edit', async () => { + + }); + + it('should not federate out a post deletion', async () => { + + }); + + it('should not federate out a post announce', async () => { + + }); + }); + }); + + describe('regular privilege set', () => { + let cid; + let handle; + + before(async () => { + ({ cid } = await categories.create({ name: utils.generateUUID() })); + handle = await categories.getCategoryField(cid, 'handle'); + const privsToRemove = await privileges.categories.getGroupPrivilegeList(); + }); + + describe('groups:find', () => { + it('should return webfinger response to a category\'s handle', async () => { + const response = await activitypub.helpers.query(`${handle}@${nconf.get('url_parsed').hostname}`); + + assert(response); + assert.strictEqual(response.subject, `acct:${handle}@${nconf.get('url_parsed').hostname}`); + assert.strictEqual(response.hostname, nconf.get('url_parsed').hostname); + }); + }); + }); + }); +}); \ No newline at end of file From b20a6ed0d700f0a02cdf5d9e42d6f0fd42b6f4e0 Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Wed, 28 May 2025 12:31:53 -0400 Subject: [PATCH 10/21] fix: missed handling zset on ap unfollow --- src/api/activitypub.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/activitypub.js b/src/api/activitypub.js index ce78f12d23..57a9bfe287 100644 --- a/src/api/activitypub.js +++ b/src/api/activitypub.js @@ -126,6 +126,7 @@ activitypubApi.unfollow = enabledCheck(async (caller, { type, id, actor }) => { await Promise.all([ db.sortedSetRemove(`followingRemote:${id}`, actor), db.sortedSetRemove(`followRequests:uid.${id}`, actor), + db.sortedSetRemove(`followersRemote:${actor}`, id), db.decrObjectField(`user:${id}`, 'followingRemoteCount'), ]); } else if (type === 'cid') { From 49b5268e529a403ca929797361f853c3c40f301d Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Wed, 28 May 2025 14:53:32 -0400 Subject: [PATCH 11/21] fix: send actor in undo(follow) --- src/api/activitypub.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/activitypub.js b/src/api/activitypub.js index 57a9bfe287..1f074f6776 100644 --- a/src/api/activitypub.js +++ b/src/api/activitypub.js @@ -119,6 +119,7 @@ activitypubApi.unfollow = enabledCheck(async (caller, { type, id, actor }) => { await activitypub.send(type, id, [actor], { id: `${nconf.get('url')}/${type}/${id}#activity/undo:follow/${encodeURIComponent(actor)}/${timestamp}`, type: 'Undo', + actor: object.actor, object, }); From 72417d82bd05c26dd8e38220fd9dc05792a07fdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Thu, 29 May 2025 11:36:46 -0400 Subject: [PATCH 12/21] fix: closes #13454, align dropdowns to opposite side on rtl --- public/scss/generics.scss | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/public/scss/generics.scss b/public/scss/generics.scss index 808d427ba5..0938613ec9 100644 --- a/public/scss/generics.scss +++ b/public/scss/generics.scss @@ -23,11 +23,13 @@ display: block; } } -.dropdown-left { - .dropdown-menu { --bs-position: start; } +html[data-dir="ltr"] { + .dropdown-left .dropdown-menu { --bs-position: start; } + .dropdown-right .dropdown-menu { --bs-position: end; } } -.dropdown-right { - .dropdown-menu { --bs-position: end; } +html[data-dir="rtl"] { + .dropdown-left .dropdown-menu { --bs-position: end; } + .dropdown-right .dropdown-menu { --bs-position: start; } } .category-dropdown-menu { From 0c1a61839efc6bfb57b945e223584c5c70c69177 Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Thu, 29 May 2025 12:49:56 -0400 Subject: [PATCH 13/21] test: fix groups:find webfinger test --- test/activitypub/privileges.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/activitypub/privileges.js b/test/activitypub/privileges.js index 2284b7203d..1c8d2ec7f2 100644 --- a/test/activitypub/privileges.js +++ b/test/activitypub/privileges.js @@ -4,6 +4,7 @@ const assert = require('assert'); const nconf = require('nconf'); const db = require('../mocks/databasemock'); +const request = require('../../src/request'); const user = require('../../src/user'); const topics = require('../../src/topics'); const posts = require('../../src/posts'); @@ -192,11 +193,12 @@ describe('Privilege logic for remote users/content (ActivityPub)', () => { describe('groups:find', () => { it('should return webfinger response to a category\'s handle', async () => { - const response = await activitypub.helpers.query(`${handle}@${nconf.get('url_parsed').hostname}`); + const { response, body } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=acct:${handle}@${nconf.get('url_parsed').host}`); assert(response); - assert.strictEqual(response.subject, `acct:${handle}@${nconf.get('url_parsed').hostname}`); - assert.strictEqual(response.hostname, nconf.get('url_parsed').hostname); + assert.strictEqual(response.statusCode, 200); + assert(body.links && body.links.length); + assert.strictEqual(body.subject, `acct:${handle}@${nconf.get('url_parsed').host}`); }); }); }); From 78de8c6da12919e60660142bff46ebde5ad23b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Fri, 30 May 2025 09:22:06 -0400 Subject: [PATCH 14/21] fix: allow guests to load topic tools if they have privilege to view them display errors from topics.loadTopicTools --- public/src/client/topic/threadTools.js | 2 +- src/socket.io/topics/tools.js | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/public/src/client/topic/threadTools.js b/public/src/client/topic/threadTools.js index a83a9d86b5..7a2519b069 100644 --- a/public/src/client/topic/threadTools.js +++ b/public/src/client/topic/threadTools.js @@ -225,7 +225,7 @@ define('forum/topic/threadTools', [ return; } dropdownMenu.html(helpers.generatePlaceholderWave([8, 8, 8])); - const data = await socket.emit('topics.loadTopicTools', { tid: ajaxify.data.tid, cid: ajaxify.data.cid }); + const data = await socket.emit('topics.loadTopicTools', { tid: ajaxify.data.tid, cid: ajaxify.data.cid }).catch(alerts.error); const html = await app.parseAndTranslate('partials/topic/topic-menu-list', data); $(dropdownMenu).attr('data-loaded', 'true').html(html); hooks.fire('action:topic.tools.load', { diff --git a/src/socket.io/topics/tools.js b/src/socket.io/topics/tools.js index 80bab41662..e4044a5272 100644 --- a/src/socket.io/topics/tools.js +++ b/src/socket.io/topics/tools.js @@ -6,9 +6,6 @@ const plugins = require('../../plugins'); module.exports = function (SocketTopics) { SocketTopics.loadTopicTools = async function (socket, data) { - if (!socket.uid) { - throw new Error('[[error:no-privileges]]'); - } if (!data) { throw new Error('[[error:invalid-data]]'); } @@ -21,7 +18,7 @@ module.exports = function (SocketTopics) { if (!topicData) { throw new Error('[[error:no-topic]]'); } - if (!userPrivileges['topics:read']) { + if (!userPrivileges['topics:read'] || !userPrivileges.view_thread_tools) { throw new Error('[[error:no-privileges]]'); } topicData.privileges = userPrivileges; From 390f6428506f98458045fe07b6f594508141cc4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Fri, 30 May 2025 11:00:08 -0400 Subject: [PATCH 15/21] fix: browser title translation --- src/middleware/render.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/middleware/render.js b/src/middleware/render.js index e01110936f..9741585428 100644 --- a/src/middleware/render.js +++ b/src/middleware/render.js @@ -150,6 +150,7 @@ module.exports = function (middleware) { async function loadClientHeaderFooterData(req, res, options) { const registrationType = meta.config.registrationType || 'normal'; res.locals.config = res.locals.config || {}; + const userLang = res.locals.config.userLang || meta.config.userLang || 'en-GB'; const templateValues = { title: meta.config.title || '', 'title:url': meta.config['title:url'] || '', @@ -180,9 +181,9 @@ module.exports = function (middleware) { blocks: user.blocks.list(req.uid), user: user.getUserData(req.uid), isEmailConfirmSent: req.uid <= 0 ? false : await user.email.isValidationPending(req.uid), - languageDirection: translator.translate('[[language:dir]]', res.locals.config.userLang), - timeagoCode: languages.userTimeagoCode(res.locals.config.userLang), - browserTitle: translator.translate(controllersHelpers.buildTitle(title)), + languageDirection: translator.translate('[[language:dir]]', userLang), + timeagoCode: languages.userTimeagoCode(userLang), + browserTitle: translator.translate(controllersHelpers.buildTitle(title), userLang), navigation: navigation.get(req.uid), roomIds: req.uid > 0 ? db.getSortedSetRevRange(`uid:${req.uid}:chat:rooms`, 0, 0) : [], }); From ebb88c1277945887062c1e669f5e4a93cbffd2ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Fri, 30 May 2025 11:45:04 -0400 Subject: [PATCH 16/21] feat: add action:post-queue.save fires after a post is added to the post queue --- src/posts/queue.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/posts/queue.js b/src/posts/queue.js index cc3b1078c8..9f6b21636d 100644 --- a/src/posts/queue.js +++ b/src/posts/queue.js @@ -188,13 +188,16 @@ module.exports = function (Posts) { data: data, }; payload = await plugins.hooks.fire('filter:post-queue.save', payload); - payload.data = JSON.stringify(data); await db.sortedSetAdd('post:queue', now, id); - await db.setObject(`post:queue:${id}`, payload); + await db.setObject(`post:queue:${id}`, { + ...payload, + data: JSON.stringify(payload.data), + }); await user.setUserField(data.uid, 'lastqueuetime', now); cache.del('post-queue'); + await plugins.hooks.fire('action:post-queue.save', payload); const cid = await getCid(type, data); const uids = await getNotificationUids(cid); const bodyLong = await parseBodyLong(cid, type, data); From 629eec7b5b17c55984dd690b281224d9139a57d4 Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Fri, 30 May 2025 16:49:15 -0400 Subject: [PATCH 17/21] =?UTF-8?q?fix:=20add=20try..catch=20wrapper=20aroun?= =?UTF-8?q?d=20Announce(Like)=20call=20to=20internal=20method=20so=20as=20?= =?UTF-8?q?to=20not=20return=20a=20500=20=E2=80=94=20just=20drop=20the=20L?= =?UTF-8?q?ike=20activity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/activitypub/inbox.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/activitypub/inbox.js b/src/activitypub/inbox.js index 0369b11248..9b0f5c6987 100644 --- a/src/activitypub/inbox.js +++ b/src/activitypub/inbox.js @@ -294,9 +294,13 @@ inbox.announce = async (req) => { const { id: localId } = await activitypub.helpers.resolveLocalId(id); const exists = await posts.exists(localId || id); if (exists) { - const result = await posts.upvote(localId || id, object.actor); - if (localId) { - socketHelpers.upvote(result, 'notifications:upvoted-your-post-in'); + try { + const result = await posts.upvote(localId || id, object.actor); + if (localId) { + socketHelpers.upvote(result, 'notifications:upvoted-your-post-in'); + } + } catch (e) { + // vote denied due to local limitations (frequency, privilege, etc.); noop. } } From 83a55f6adcd246920ba08415dcdf46505503c4a4 Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Sat, 31 May 2025 22:46:47 -0400 Subject: [PATCH 18/21] fix: don't throw on unknown post on Undo(Like) --- src/activitypub/inbox.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/activitypub/inbox.js b/src/activitypub/inbox.js index 9b0f5c6987..ec2f9d7fb0 100644 --- a/src/activitypub/inbox.js +++ b/src/activitypub/inbox.js @@ -546,7 +546,8 @@ inbox.undo = async (req) => { case 'Like': { const exists = await posts.exists(id); if (localType !== 'post' || !exists) { - throw new Error('[[error:invalid-pid]]'); + reject('Like', object, actor); + break; } const allowed = await privileges.posts.can('posts:upvote', id, activitypub._constants.uid); From cc9270262074b154fd6d3a5df7d1f354f3b4cb37 Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Sun, 1 Jun 2025 00:31:58 -0400 Subject: [PATCH 19/21] fix: add try..catch around topics.post in note assertion logic --- src/activitypub/notes.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/activitypub/notes.js b/src/activitypub/notes.js index 616fc3a44f..64c6dcd53f 100644 --- a/src/activitypub/notes.js +++ b/src/activitypub/notes.js @@ -196,8 +196,8 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => { const { to, cc, attachment } = mainPost._activitypub; const tags = await Notes._normalizeTags(mainPost._activitypub.tag || []); - await Promise.all([ - topics.post({ + try { + await topics.post({ tid, uid: authorId, cid: options.cid || cid, @@ -208,13 +208,16 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => { content: mainPost.content, sourceContent: mainPost.sourceContent, _activitypub: mainPost._activitypub, - }), - Notes.updateLocalRecipients(mainPid, { to, cc }), - ]); - unprocessed.shift(); + }); + unprocessed.shift(); + } catch (e) { + activitypub.helpers.log(`[activitypub/notes.assert] Could not post topic (${mainPost.pid}): ${e.message}`); + return null; + } // These must come after topic is posted await Promise.all([ + Notes.updateLocalRecipients(mainPid, { to, cc }), mainPost._activitypub.image ? topics.thumbs.associate({ id: tid, path: mainPost._activitypub.image, From b1022566da98b5b58f08a1efc76daba345eac232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Mon, 2 Jun 2025 09:55:20 -0400 Subject: [PATCH 20/21] fix: closes #13458, check if plugin is system plugin before activate/deactive/install/uninstall --- public/language/en-GB/error.json | 1 + src/plugins/install.js | 10 ++++++++++ src/socket.io/admin/plugins.js | 6 ++++++ 3 files changed, 17 insertions(+) diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json index 82a623ea32..ec51a88ead 100644 --- a/public/language/en-GB/error.json +++ b/public/language/en-GB/error.json @@ -271,6 +271,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", + "cannot-toggle-system-plugin": "You cannot toggle the state of a system plugin", "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/src/plugins/install.js b/src/plugins/install.js index 21d993226d..f3da1c3fb8 100644 --- a/src/plugins/install.js +++ b/src/plugins/install.js @@ -156,6 +156,16 @@ module.exports = function (Plugins) { } }; + Plugins.isSystemPlugin = async function (id) { + const pluginDir = path.join(paths.nodeModules, id, 'plugin.json'); + try { + const pluginData = JSON.parse(await fs.readFile(pluginDir, 'utf8')); + return pluginData && pluginData.system === true; + } catch (err) { + return false; + } + }; + Plugins.isActive = async function (id) { if (nconf.get('plugins:active')) { return nconf.get('plugins:active').includes(id); diff --git a/src/socket.io/admin/plugins.js b/src/socket.io/admin/plugins.js index 4489be42bd..32f5ff61d0 100644 --- a/src/socket.io/admin/plugins.js +++ b/src/socket.io/admin/plugins.js @@ -11,6 +11,9 @@ const { pluginNamePattern } = require('../../constants'); const Plugins = module.exports; Plugins.toggleActive = async function (socket, plugin_id) { + if (await plugins.isSystemPlugin(plugin_id)) { + throw new Error('[[error:cannot-toggle-system-plugin]]'); + } postsCache.reset(); const data = await plugins.toggleActive(plugin_id); await events.log({ @@ -22,6 +25,9 @@ Plugins.toggleActive = async function (socket, plugin_id) { }; Plugins.toggleInstall = async function (socket, data) { + if (await plugins.isSystemPlugin(data.id)) { + throw new Error('[[error:cannot-toggle-system-plugin]]'); + } const isInstalled = await plugins.isInstalled(data.id); const isStarterPlan = nconf.get('saas_plan') === 'starter'; if ((isStarterPlan || nconf.get('acpPluginInstallDisabled')) && !isInstalled) { From 524a1e8bfe403fa240e804f076943871264caf2f Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Sun, 1 Jun 2025 12:40:37 -0400 Subject: [PATCH 21/21] fix: return 200 for non-implemented activities instead of 501 --- src/controllers/activitypub/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/activitypub/index.js b/src/controllers/activitypub/index.js index 0e7112ddfd..de478a6021 100644 --- a/src/controllers/activitypub/index.js +++ b/src/controllers/activitypub/index.js @@ -145,7 +145,7 @@ Controller.postInbox = async (req, res) => { const method = String(req.body.type).toLowerCase(); if (!activitypub.inbox.hasOwnProperty(method)) { winston.warn(`[activitypub/inbox] Received Activity of type ${method} but unable to handle. Ignoring.`); - return res.sendStatus(501); + return res.sendStatus(200); } try {