From 21d932feec2cb3c46cf311f1d62f85c2574fb9f4 Mon Sep 17 00:00:00 2001 From: JoshuaNam Date: Sat, 7 Feb 2026 20:54:33 +0000 Subject: [PATCH 1/6] Store anonymous flag on posts/replies (Issue #6) --- src/posts/create.js | 1 + src/posts/data.js | 2 +- src/posts/summary.js | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/posts/create.js b/src/posts/create.js index 044b07d699..48cbc10708 100644 --- a/src/posts/create.js +++ b/src/posts/create.js @@ -39,6 +39,7 @@ module.exports = function (Posts) { if (data.handle && !parseInt(uid, 10)) { postData.handle = data.handle; } + postData.anonymous = data.anonymous ? 1 : 0; if (_activitypub) { if (_activitypub.url) { postData.url = _activitypub.url; diff --git a/src/posts/data.js b/src/posts/data.js index 57bd089f91..28d280e752 100644 --- a/src/posts/data.js +++ b/src/posts/data.js @@ -6,7 +6,7 @@ const utils = require('../utils'); const intFields = [ 'uid', 'pid', 'tid', 'deleted', 'timestamp', 'upvotes', 'downvotes', 'deleterUid', 'edited', - 'replies', 'bookmarks', 'announces', + 'replies', 'bookmarks', 'announces', 'anonymous', ]; const groups = require('../groups'); const user = require('../user'); diff --git a/src/posts/summary.js b/src/posts/summary.js index 5995514eb6..5c22d00041 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', 'sourceContent', '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', 'anonymous'].concat(options.extraFields); let posts = await Posts.getPostsFields(pids, fields); posts = posts.filter(Boolean); From e341a89d790de2ece7021f5bfc4d2e2a1c8fc0d3 Mon Sep 17 00:00:00 2001 From: JoshuaNam Date: Fri, 13 Feb 2026 03:44:28 +0000 Subject: [PATCH 2/6] test(posts): add unit tests for anonymous replies feature --- test/posts.js | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/test/posts.js b/test/posts.js index 0e8ad7fc4a..d4ac833f6d 100644 --- a/test/posts.js +++ b/test/posts.js @@ -1302,6 +1302,159 @@ describe('Post\'s', () => { }); }); }); + + describe('Anonymous posts', () => { + let adminUid; + let regularUid; + let anonCid; + let anonTopicData; + let anonPost; + let normalPost; + + before(async () => { + adminUid = await user.create({ username: 'anon_admin' }); + await groups.join('administrators', adminUid); + regularUid = await user.create({ username: 'anon_regular' }); + ({ cid: anonCid } = await categories.create({ + name: 'Anon Test Category', + description: 'Category for anonymous post tests', + })); + anonTopicData = await topics.post({ + uid: regularUid, + cid: anonCid, + title: 'Anonymous Test Topic', + content: 'Topic for anonymous post tests', + }); + anonPost = await topics.reply({ + uid: regularUid, + tid: anonTopicData.topicData.tid, + content: 'anon reply', + anonymous: true, + }); + normalPost = await topics.reply({ + uid: regularUid, + tid: anonTopicData.topicData.tid, + content: 'normal reply', + anonymous: false, + }); + }); + + describe('Anonymous flag storage', () => { + it('should store anonymous flag as 1 when anonymous is true', async () => { + const value = await posts.getPostField(anonPost.pid, 'anonymous'); + assert.strictEqual(value, 1); + }); + + it('should store anonymous flag as 0 when anonymous is falsy', async () => { + const value = await posts.getPostField(normalPost.pid, 'anonymous'); + assert.strictEqual(value, 0); + }); + }); + + describe('API masking for regular users', () => { + it('should mask poster identity for regular users on anonymous posts', async () => { + const post = await apiPosts.get({ uid: regularUid }, { pid: anonPost.pid }); + assert.strictEqual(post.uid, 0); + assert.strictEqual(post.user.username, 'Anonymous'); + assert.strictEqual(post.user.displayname, 'Anonymous'); + assert.strictEqual(post.user['icon:text'], '?'); + }); + + it('should not have isAnonymous flag for regular users', async () => { + const post = await apiPosts.get({ uid: regularUid }, { pid: anonPost.pid }); + assert.strictEqual(post.isAnonymous, undefined); + }); + }); + + describe('Admin visibility', () => { + it('should expose real user data with isAnonymous flag for admins', async () => { + const post = await apiPosts.get({ uid: adminUid }, { pid: anonPost.pid }); + assert.strictEqual(post.isAnonymous, true); + assert.strictEqual(post.uid, regularUid); + }); + }); + + describe('Regression for non-anonymous posts', () => { + it('should not mask poster on non-anonymous posts for regular users', async () => { + const post = await apiPosts.get({ uid: regularUid }, { pid: normalPost.pid }); + assert.strictEqual(post.uid, regularUid); + assert.strictEqual(post.isAnonymous, undefined); + assert(!post.user || post.user.uid !== 0, 'non-anonymous post should not have masked user'); + }); + }); + + describe('Topic-level masking (modifyPostsByPrivilege)', () => { + it('should mask anonymous posts in topic view for regular users', () => { + const mockTopicData = { + uid: regularUid, + locked: false, + postSharing: [], + posts: [ + { + anonymous: 1, + uid: regularUid, + user: { uid: regularUid, username: 'anon_regular', displayname: 'anon_regular' }, + editor: { uid: regularUid }, + selfPost: true, + deleted: false, + index: 1, + }, + { + anonymous: 0, + uid: regularUid, + user: { uid: regularUid, username: 'anon_regular', displayname: 'anon_regular' }, + editor: null, + selfPost: true, + deleted: false, + index: 2, + }, + ], + }; + topics.modifyPostsByPrivilege(mockTopicData, { + uid: regularUid, + isAdmin: false, + isAdminOrMod: false, + 'posts:edit': false, + 'posts:delete': false, + }); + const anonPostResult = mockTopicData.posts[0]; + assert.strictEqual(anonPostResult.user.username, 'Anonymous'); + assert.strictEqual(anonPostResult.editor, null); + assert.strictEqual(anonPostResult.selfPost, false); + }); + + it('should set isAnonymous on anonymous posts in topic view for admins', () => { + const originalUser = { uid: regularUid, username: 'anon_regular', displayname: 'anon_regular' }; + const mockTopicData = { + uid: regularUid, + locked: false, + postSharing: [], + posts: [ + { + anonymous: 1, + uid: regularUid, + user: { ...originalUser }, + editor: null, + selfPost: false, + deleted: false, + index: 1, + }, + ], + }; + topics.modifyPostsByPrivilege(mockTopicData, { + uid: adminUid, + isAdmin: true, + isAdminOrMod: true, + 'posts:edit': true, + 'posts:delete': true, + }); + const anonPostResult = mockTopicData.posts[0]; + assert.strictEqual(anonPostResult.isAnonymous, true); + assert.strictEqual(anonPostResult.user.uid, regularUid); + assert.strictEqual(anonPostResult.user.username, 'anon_regular'); + }); + }); + }); }); describe('Posts\'', async () => { From 920b99f03f37f8949d6703aeb6ff1fc1aaf799b4 Mon Sep 17 00:00:00 2001 From: JoshuaNam Date: Thu, 12 Feb 2026 06:30:14 +0000 Subject: [PATCH 3/6] feat: hide poster identity on anonymous posts for non-admin users --- src/api/posts.js | 20 ++++++++++++++++++++ src/posts/summary.js | 20 ++++++++++++++++++++ src/privileges/topics.js | 1 + src/topics/posts.js | 23 +++++++++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/src/api/posts.js b/src/api/posts.js index 7c892dec97..fb0ddd1fe5 100644 --- a/src/api/posts.js +++ b/src/api/posts.js @@ -42,6 +42,26 @@ postsAPI.get = async function (caller, data) { post.content = '[[topic:post-is-deleted]]'; } + if (post.anonymous === 1) { + const isAdmin = await user.isAdministrator(caller.uid); + if (isAdmin) { + post.isAnonymous = true; + } else { + post.uid = 0; + post.user = { + uid: 0, + username: 'Anonymous', + displayname: 'Anonymous', + userslug: '', + picture: '', + signature: '', + status: 'offline', + 'icon:text': '?', + 'icon:bgColor': '#aaa', + }; + } + } + return post; }; diff --git a/src/posts/summary.js b/src/posts/summary.js index 5c22d00041..6fbac14b3b 100644 --- a/src/posts/summary.js +++ b/src/posts/summary.js @@ -10,6 +10,7 @@ const user = require('../user'); const plugins = require('../plugins'); const categories = require('../categories'); const utils = require('../utils'); +const privileges = require('../privileges'); module.exports = function (Posts) { Posts.getPostSummaryByPids = async function (pids, uid, options) { @@ -40,6 +41,8 @@ module.exports = function (Posts) { const tidToTopic = toObject('tid', topicsAndCategories.topics); const cidToCategory = toObject('cid', topicsAndCategories.categories); + const isAdmin = await privileges.users.isAdministrator(uid); + posts.forEach((post) => { // If the post author isn't represented in the retrieved users' data, // then it means they were deleted, assume guest. @@ -53,6 +56,23 @@ module.exports = function (Posts) { post.user = uidToUser[post.uid]; Posts.overrideGuestHandle(post, post.handle); post.handle = undefined; + + if (post.anonymous === 1) { + if (isAdmin) { + post.isAnonymous = true; + } else { + post.user = { + uid: 0, + username: 'Anonymous', + displayname: 'Anonymous', + userslug: '', + picture: '', + status: 'offline', + 'icon:text': '?', + 'icon:bgColor': '#aaa', + }; + } + } post.topic = tidToTopic[post.tid]; post.category = post.topic && cidToCategory[post.topic.cid]; post.isMainPost = post.topic && post.pid === post.topic.mainPid; diff --git a/src/privileges/topics.js b/src/privileges/topics.js index 5538e626d9..ef2a8ce3a6 100644 --- a/src/privileges/topics.js +++ b/src/privileges/topics.js @@ -64,6 +64,7 @@ privsTopics.get = async function (tid, uid) { view_deleted: isAdminOrMod || isOwner || privData['posts:view_deleted'], view_scheduled: privData['topics:schedule'] || isAdministrator, isAdminOrMod: isAdminOrMod, + isAdmin: isAdministrator, disabled: disabled, tid: tid, uid: uid, diff --git a/src/topics/posts.js b/src/topics/posts.js index a8535939e9..b3a0ba9cf6 100644 --- a/src/topics/posts.js +++ b/src/topics/posts.js @@ -163,6 +163,29 @@ module.exports = function (Topics) { const loggedIn = parseInt(topicPrivileges.uid, 10) > 0; topicData.posts.forEach((post) => { if (post) { + // Anonymize posts with anonymous flag for non-admin viewers + if (post.anonymous === 1) { + if (topicPrivileges.isAdmin === true) { + post.isAnonymous = true; + } else { + post.user = { + uid: 0, + username: 'Anonymous', + displayname: 'Anonymous', + userslug: '', + picture: '', + signature: '', + status: 'offline', + selectedGroups: [], + custom_profile_info: [], + 'icon:text': '?', + 'icon:bgColor': '#aaa', + }; + post.editor = null; + post.selfPost = false; + } + } + post.topicOwnerPost = parseInt(topicData.uid, 10) === parseInt(post.uid, 10); post.display_edit_tools = topicPrivileges.isAdminOrMod || (post.selfPost && topicPrivileges['posts:edit']); post.display_delete_tools = topicPrivileges.isAdminOrMod || (post.selfPost && topicPrivileges['posts:delete']); From 2fd45ab3756b3cd74b67f4bac1eac414ff836d15 Mon Sep 17 00:00:00 2001 From: JoshuaNam Date: Fri, 13 Feb 2026 04:14:36 +0000 Subject: [PATCH 4/6] fix(api): add anonymous field to PostObject OpenAPI schema --- public/openapi/components/schemas/PostObject.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/public/openapi/components/schemas/PostObject.yaml b/public/openapi/components/schemas/PostObject.yaml index f02b8a5b9c..7b8341f03c 100644 --- a/public/openapi/components/schemas/PostObject.yaml +++ b/public/openapi/components/schemas/PostObject.yaml @@ -34,6 +34,9 @@ PostObject: sourceContent: type: string nullable: true + anonymous: + type: number + description: Whether this post was made anonymously (1 for anonymous, 0 for not) uid: type: number description: A user identifier @@ -203,6 +206,9 @@ PostDataObject: type: string timestamp: type: number + anonymous: + type: number + description: Whether this post was made anonymously (1 for anonymous, 0 for not) votes: type: number deleted: From 4bfdc5d5220a73264fae4896897575c109cc3b86 Mon Sep 17 00:00:00 2001 From: JoshuaNam Date: Fri, 13 Feb 2026 08:34:39 +0000 Subject: [PATCH 5/6] fix(openapi): add missing isAdmin field to topic privileges schema --- public/openapi/read/topic/topic_id.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public/openapi/read/topic/topic_id.yaml b/public/openapi/read/topic/topic_id.yaml index 81b3e9531f..f4e4f8d8c9 100644 --- a/public/openapi/read/topic/topic_id.yaml +++ b/public/openapi/read/topic/topic_id.yaml @@ -150,6 +150,8 @@ get: type: boolean isAdminOrMod: type: boolean + isAdmin: + type: boolean disabled: type: number tid: From 84299cde5700e5cb574cb90b38aef91feaa752cd Mon Sep 17 00:00:00 2001 From: JoshuaNam Date: Sat, 14 Feb 2026 18:06:11 +0000 Subject: [PATCH 6/6] fix: remove isAnonymous, add masking to getReplies(), DRY up helper --- src/api/posts.js | 23 ++++---------- src/posts/index.js | 21 +++++++++++++ src/posts/summary.js | 17 +---------- src/topics/posts.js | 22 +------------- test/posts.js | 71 +++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 96 insertions(+), 58 deletions(-) diff --git a/src/api/posts.js b/src/api/posts.js index fb0ddd1fe5..e2dff69047 100644 --- a/src/api/posts.js +++ b/src/api/posts.js @@ -44,22 +44,7 @@ postsAPI.get = async function (caller, data) { if (post.anonymous === 1) { const isAdmin = await user.isAdministrator(caller.uid); - if (isAdmin) { - post.isAnonymous = true; - } else { - post.uid = 0; - post.user = { - uid: 0, - username: 'Anonymous', - displayname: 'Anonymous', - userslug: '', - picture: '', - signature: '', - status: 'offline', - 'icon:text': '?', - 'icon:bgColor': '#aaa', - }; - } + posts.anonymizePost(post, isAdmin); } return post; @@ -591,7 +576,11 @@ postsAPI.getReplies = async (caller, { pid }) => { privileges.posts.get(pids, uid), ]); postData = await topics.addPostData(postData, uid); - postData.forEach((postData, index) => posts.modifyPostByPrivilege(postData, postPrivileges[index])); + const isAdmin = await user.isAdministrator(uid); + postData.forEach((postData, index) => { + posts.modifyPostByPrivilege(postData, postPrivileges[index]); + posts.anonymizePost(postData, isAdmin); + }); postData = postData.filter((postData, index) => postData && postPrivileges[index].read); postData = await user.blocks.filter(uid, postData); diff --git a/src/posts/index.js b/src/posts/index.js index 59c61381b9..de6123e739 100644 --- a/src/posts/index.js +++ b/src/posts/index.js @@ -95,6 +95,27 @@ Posts.getPostIndices = async function (posts, uid) { return indices.map(index => (utils.isNumber(index) ? parseInt(index, 10) + 1 : 0)); }; +Posts.anonymizePost = function (post, isAdmin) { + if (post && post.anonymous === 1 && !isAdmin) { + post.uid = 0; + post.user = { + uid: 0, + username: 'Anonymous', + displayname: 'Anonymous', + userslug: '', + picture: '', + signature: '', + status: 'offline', + selectedGroups: [], + custom_profile_info: [], + 'icon:text': '?', + 'icon:bgColor': '#aaa', + }; + post.editor = null; + post.selfPost = false; + } +}; + Posts.modifyPostByPrivilege = function (post, privileges) { if (post && post.deleted && !(post.selfPost || privileges['posts:view_deleted'])) { post.content = '[[topic:post-is-deleted]]'; diff --git a/src/posts/summary.js b/src/posts/summary.js index 6fbac14b3b..836233bdda 100644 --- a/src/posts/summary.js +++ b/src/posts/summary.js @@ -57,22 +57,7 @@ module.exports = function (Posts) { Posts.overrideGuestHandle(post, post.handle); post.handle = undefined; - if (post.anonymous === 1) { - if (isAdmin) { - post.isAnonymous = true; - } else { - post.user = { - uid: 0, - username: 'Anonymous', - displayname: 'Anonymous', - userslug: '', - picture: '', - status: 'offline', - 'icon:text': '?', - 'icon:bgColor': '#aaa', - }; - } - } + Posts.anonymizePost(post, isAdmin); post.topic = tidToTopic[post.tid]; post.category = post.topic && cidToCategory[post.topic.cid]; post.isMainPost = post.topic && post.pid === post.topic.mainPid; diff --git a/src/topics/posts.js b/src/topics/posts.js index b3a0ba9cf6..bb6dd61f84 100644 --- a/src/topics/posts.js +++ b/src/topics/posts.js @@ -164,27 +164,7 @@ module.exports = function (Topics) { topicData.posts.forEach((post) => { if (post) { // Anonymize posts with anonymous flag for non-admin viewers - if (post.anonymous === 1) { - if (topicPrivileges.isAdmin === true) { - post.isAnonymous = true; - } else { - post.user = { - uid: 0, - username: 'Anonymous', - displayname: 'Anonymous', - userslug: '', - picture: '', - signature: '', - status: 'offline', - selectedGroups: [], - custom_profile_info: [], - 'icon:text': '?', - 'icon:bgColor': '#aaa', - }; - post.editor = null; - post.selfPost = false; - } - } + posts.anonymizePost(post, topicPrivileges.isAdmin === true); post.topicOwnerPost = parseInt(topicData.uid, 10) === parseInt(post.uid, 10); post.display_edit_tools = topicPrivileges.isAdminOrMod || (post.selfPost && topicPrivileges['posts:edit']); diff --git a/test/posts.js b/test/posts.js index d4ac833f6d..3b9064c4a8 100644 --- a/test/posts.js +++ b/test/posts.js @@ -1367,9 +1367,9 @@ describe('Post\'s', () => { }); describe('Admin visibility', () => { - it('should expose real user data with isAnonymous flag for admins', async () => { + it('should expose real user data and preserve anonymous flag for admins', async () => { const post = await apiPosts.get({ uid: adminUid }, { pid: anonPost.pid }); - assert.strictEqual(post.isAnonymous, true); + assert.strictEqual(post.anonymous, 1); assert.strictEqual(post.uid, regularUid); }); }); @@ -1423,7 +1423,34 @@ describe('Post\'s', () => { assert.strictEqual(anonPostResult.selfPost, false); }); - it('should set isAnonymous on anonymous posts in topic view for admins', () => { + it('should not set isAnonymous flag (removed)', () => { + const mockTopicData = { + uid: regularUid, + locked: false, + postSharing: [], + posts: [ + { + anonymous: 1, + uid: regularUid, + user: { uid: regularUid, username: 'anon_regular', displayname: 'anon_regular' }, + editor: null, + selfPost: false, + deleted: false, + index: 1, + }, + ], + }; + topics.modifyPostsByPrivilege(mockTopicData, { + uid: adminUid, + isAdmin: true, + isAdminOrMod: true, + 'posts:edit': true, + 'posts:delete': true, + }); + assert.strictEqual(mockTopicData.posts[0].isAnonymous, undefined); + }); + + it('should preserve real user data on anonymous posts in topic view for admins', () => { const originalUser = { uid: regularUid, username: 'anon_regular', displayname: 'anon_regular' }; const mockTopicData = { uid: regularUid, @@ -1449,11 +1476,47 @@ describe('Post\'s', () => { 'posts:delete': true, }); const anonPostResult = mockTopicData.posts[0]; - assert.strictEqual(anonPostResult.isAnonymous, true); + assert.strictEqual(anonPostResult.anonymous, 1); assert.strictEqual(anonPostResult.user.uid, regularUid); assert.strictEqual(anonPostResult.user.username, 'anon_regular'); }); }); + + describe('Replies endpoint masking', () => { + let parentPost; + let anonReply; + + before(async () => { + parentPost = await topics.reply({ + uid: adminUid, + tid: anonTopicData.topicData.tid, + content: 'parent post for reply test', + }); + anonReply = await topics.reply({ + uid: regularUid, + tid: anonTopicData.topicData.tid, + content: 'anonymous reply to parent', + anonymous: true, + toPid: parentPost.pid, + }); + }); + + it('should mask anonymous replies for regular users via getReplies', async () => { + const replies = await apiPosts.getReplies({ uid: regularUid }, { pid: parentPost.pid }); + const reply = replies.find(r => r.pid === anonReply.pid); + assert(reply, 'anonymous reply should be in replies'); + assert.strictEqual(reply.user.username, 'Anonymous'); + assert.strictEqual(reply.user.uid, 0); + }); + + it('should expose real user data on anonymous replies for admins via getReplies', async () => { + const replies = await apiPosts.getReplies({ uid: adminUid }, { pid: parentPost.pid }); + const reply = replies.find(r => r.pid === anonReply.pid); + assert(reply, 'anonymous reply should be in replies'); + assert.strictEqual(reply.anonymous, 1); + assert.strictEqual(reply.user.uid, regularUid); + }); + }); }); });