Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions vendor/nodebb-plugin-post-fields-logger/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<number, boolean>>} 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;

19 changes: 1 addition & 18 deletions vendor/nodebb-plugin-post-fields-logger/plugin.json
Original file line number Diff line number Diff line change
@@ -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"}]}
118 changes: 118 additions & 0 deletions vendor/nodebb-plugin-post-fields-logger/test/anonymous-mode.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});

Loading