From 09736c0d3cd47c0dc30dd8ae2804ad2cd6aeeec9 Mon Sep 17 00:00:00 2001 From: Marcus Date: Fri, 27 Feb 2026 17:52:47 +0000 Subject: [PATCH 1/2] fix bug with anonymous addresses #23 --- .../nodebb-plugin-post-fields-logger/index.js | 126 ++++++++++++++++++ .../plugin.json | 19 +-- 2 files changed, 127 insertions(+), 18 deletions(-) diff --git a/vendor/nodebb-plugin-post-fields-logger/index.js b/vendor/nodebb-plugin-post-fields-logger/index.js index 1e9d096883..a912eb6ec3 100644 --- a/vendor/nodebb-plugin-post-fields-logger/index.js +++ b/vendor/nodebb-plugin-post-fields-logger/index.js @@ -9,6 +9,15 @@ function getUser() { return _user; } +// Lazy-load database module for reading isAnonymous in topic/teaser hooks +let _db = null; +function getDb() { + if (!_db) { + _db = require.main.require('./src/database'); + } + return _db; +} + const plugin = {}; // Anonymous user placeholder data @@ -41,6 +50,14 @@ plugin._setUserModule = function (mockUser) { _user = mockUser; }; +/** + * Allows injecting a mock database module for testing (used by onTopicsGet/onTeasersGet) + * @param {Object} mockDb - Mock database module with getObjects(keys, fields) + */ +plugin._setDb = function (mockDb) { + _db = mockDb; +}; + /** * Checks if the caller is a moderator or admin * @param {number|null} uid - The user ID of the caller @@ -168,5 +185,114 @@ plugin.onTopicsAddPostData = async function (hookData) { return hookData; }; +/** + * Returns true if the raw isAnonymous value from DB indicates anonymous (true, "true", etc.) + * @param {*} value - Value from post hash + * @returns {boolean} + */ +function isAnonymousValue(value) { + if (value === true || value === 1) { + return true; + } + if (typeof value === 'string' && value.toLowerCase() === 'true') { + return true; + } + return false; +} + +/** + * Load isAnonymous for the given PIDs from the database (no hooks). + * @param {number[]} pids - Post IDs + * @returns {Promise>} Map of pid -> isAnonymous + */ +async function loadIsAnonymousByPids(pids) { + if (!pids || !pids.length) { + return new Map(); + } + const db = getDb(); + const keys = pids.filter(Boolean).map((pid) => `post:${pid}`); + const rows = await db.getObjects(keys, ['pid', 'isAnonymous']); + const map = new Map(); + rows.forEach((row) => { + if (row && row.pid) { + map.set(parseInt(row.pid, 10), isAnonymousValue(row.isAnonymous)); + } + }); + return map; +} + +/** + * Hook handler for filter:topics.get + * Masks topic.user and topic.teaser.user for anonymous main/teaser posts on the topic list. + */ +plugin.onTopicsGet = async function (hookData) { + const topics = hookData.topics; + const uid = hookData.uid; + + if (!topics || !Array.isArray(topics) || !topics.length) { + return hookData; + } + + const isPrivileged = await plugin.isCallerPrivileged(uid); + if (isPrivileged) { + return hookData; + } + + const pids = []; + topics.forEach((topic) => { + if (topic && topic.mainPid) { + pids.push(topic.mainPid); + } + if (topic && topic.teaser && topic.teaser.pid) { + pids.push(topic.teaser.pid); + } + }); + + const anonByPid = await loadIsAnonymousByPids([...new Set(pids)]); + + topics.forEach((topic) => { + if (!topic) { + return; + } + if (topic.mainPid && anonByPid.get(parseInt(topic.mainPid, 10))) { + topic.user = { ...ANONYMOUS_USER }; + } + if (topic.teaser && topic.teaser.pid && anonByPid.get(parseInt(topic.teaser.pid, 10))) { + topic.teaser.user = { ...ANONYMOUS_USER }; + } + }); + + return hookData; +}; + +/** + * Hook handler for filter:teasers.get + * Masks teaser.user for anonymous teaser posts (e.g. category last-post). + */ +plugin.onTeasersGet = async function (hookData) { + const teasers = hookData.teasers; + const uid = hookData.uid; + + if (!teasers || !Array.isArray(teasers) || !teasers.length) { + return hookData; + } + + const isPrivileged = await plugin.isCallerPrivileged(uid); + if (isPrivileged) { + return hookData; + } + + const pids = teasers.filter((t) => t && t.pid).map((t) => t.pid); + const anonByPid = await loadIsAnonymousByPids(pids); + + teasers.forEach((teaser) => { + if (teaser && teaser.pid && anonByPid.get(parseInt(teaser.pid, 10))) { + teaser.user = { ...ANONYMOUS_USER }; + } + }); + + return hookData; +}; + module.exports = plugin; diff --git a/vendor/nodebb-plugin-post-fields-logger/plugin.json b/vendor/nodebb-plugin-post-fields-logger/plugin.json index 93d562869d..a3fc9978e2 100644 --- a/vendor/nodebb-plugin-post-fields-logger/plugin.json +++ b/vendor/nodebb-plugin-post-fields-logger/plugin.json @@ -1,18 +1 @@ -{ - "id": "nodebb-plugin-post-fields-logger", - "name": "Post Fields Logger", - "description": "A plugin that provides anonymous posting mode, hiding user identity from non-privileged users", - "url": "https://github.com/nodebb/nodebb-plugin-post-fields-logger", - "library": "./index.js", - "hooks": [ - { - "hook": "filter:post.getFields", - "method": "onPostGetFields" - }, - { - "hook": "filter:topics.addPostData", - "method": "onTopicsAddPostData" - } - ] -} - +{"id":"nodebb-plugin-post-fields-logger","name":"Post Fields Logger","description":"A plugin that provides anonymous posting mode, hiding user identity from non-privileged users","url":"https://github.com/nodebb/nodebb-plugin-post-fields-logger","library":"./index.js","hooks":[{"hook":"filter:post.getFields","method":"onPostGetFields"},{"hook":"filter:topics.addPostData","method":"onTopicsAddPostData"},{"hook":"filter:topics.get","method":"onTopicsGet"},{"hook":"filter:teasers.get","method":"onTeasersGet"}]} From 975db4872940c8c482c427731400ef95c42e89cc Mon Sep 17 00:00:00 2001 From: Marcus Date: Fri, 27 Feb 2026 17:53:00 +0000 Subject: [PATCH 2/2] add testing to anonymous bug fix --- .../test/anonymous-mode.js | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/vendor/nodebb-plugin-post-fields-logger/test/anonymous-mode.js b/vendor/nodebb-plugin-post-fields-logger/test/anonymous-mode.js index 633f7e6798..8ccd2c49dc 100644 --- a/vendor/nodebb-plugin-post-fields-logger/test/anonymous-mode.js +++ b/vendor/nodebb-plugin-post-fields-logger/test/anonymous-mode.js @@ -847,5 +847,123 @@ describe('Post Fields Logger Plugin - Anonymous Mode', () => { assert.strictEqual(plugin.ANONYMOUS_USER.username, 'Anonymous'); }); }); + + describe('onTopicsGet (topic list anonymous masking)', () => { + const anonMainPids = new Set([101]); + const anonTeaserPids = new Set([102]); + + before(() => { + plugin._setDb({ + getObjects: async (keys, fields) => keys.map((key) => { + const pid = parseInt(key.replace('post:', ''), 10); + const isAnonymous = anonMainPids.has(pid) || anonTeaserPids.has(pid); + return { pid, isAnonymous }; + }), + }); + }); + + after(() => { + plugin._setDb(null); + }); + + it('should mask topic.user and topic.teaser.user for anonymous posts when viewer is not privileged', async () => { + const hookData = { + uid: 100, + topics: [ + { + mainPid: 101, + teaser: { pid: 102, user: { uid: 50, username: 'realuser', displayname: 'Real User', userslug: 'realuser' } }, + user: { uid: 50, username: 'realuser', displayname: 'Real User', userslug: 'realuser' }, + }, + ], + }; + const result = await plugin.onTopicsGet(hookData); + + assert.strictEqual(result.topics[0].user.username, 'Anonymous'); + assert.strictEqual(result.topics[0].user.displayname, 'Anonymous'); + assert.strictEqual(result.topics[0].teaser.user.username, 'Anonymous'); + assert.strictEqual(result.topics[0].teaser.user.displayname, 'Anonymous'); + }); + + it('should NOT mask topic.user or topic.teaser.user when viewer is privileged (admin)', async () => { + const hookData = { + uid: 5, + topics: [ + { + mainPid: 101, + teaser: { pid: 102, user: { uid: 50, username: 'realuser', displayname: 'Real User' } }, + user: { uid: 50, username: 'realuser', displayname: 'Real User' }, + }, + ], + }; + const result = await plugin.onTopicsGet(hookData); + + assert.strictEqual(result.topics[0].user.username, 'realuser'); + assert.strictEqual(result.topics[0].teaser.user.username, 'realuser'); + }); + + it('should only mask main post user when main is anonymous and teaser is not', async () => { + anonMainPids.add(201); + // 202 is not in anonTeaserPids + const hookData = { + uid: 100, + topics: [ + { + mainPid: 201, + teaser: { pid: 202, user: { uid: 60, username: 'otheruser', displayname: 'Other User' } }, + user: { uid: 60, username: 'otheruser', displayname: 'Other User' }, + }, + ], + }; + const result = await plugin.onTopicsGet(hookData); + + assert.strictEqual(result.topics[0].user.username, 'Anonymous'); + // Teaser post 202 not in anon set - so user should stay + assert.strictEqual(result.topics[0].teaser.user.username, 'otheruser'); + anonMainPids.delete(201); + }); + }); + + describe('onTeasersGet (teaser anonymous masking)', () => { + const anonPids = new Set([301]); + + before(() => { + plugin._setDb({ + getObjects: async (keys, fields) => keys.map((key) => { + const pid = parseInt(key.replace('post:', ''), 10); + return { pid, isAnonymous: anonPids.has(pid) }; + }), + }); + }); + + after(() => { + plugin._setDb(null); + }); + + it('should mask teaser.user for anonymous teaser when viewer is not privileged', async () => { + const hookData = { + uid: 100, + teasers: [ + { pid: 301, user: { uid: 70, username: 'teaseruser', displayname: 'Teaser User', userslug: 'teaseruser' } }, + ], + }; + const result = await plugin.onTeasersGet(hookData); + + assert.strictEqual(result.teasers[0].user.username, 'Anonymous'); + assert.strictEqual(result.teasers[0].user.displayname, 'Anonymous'); + }); + + it('should NOT mask teaser.user when viewer is privileged', async () => { + const hookData = { + uid: 5, + teasers: [ + { pid: 301, user: { uid: 70, username: 'teaseruser', displayname: 'Teaser User' } }, + ], + }; + const result = await plugin.onTeasersGet(hookData); + + assert.strictEqual(result.teasers[0].user.username, 'teaseruser'); + }); + }); });