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: 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: diff --git a/src/api/posts.js b/src/api/posts.js index 7c892dec97..e2dff69047 100644 --- a/src/api/posts.js +++ b/src/api/posts.js @@ -42,6 +42,11 @@ postsAPI.get = async function (caller, data) { post.content = '[[topic:post-is-deleted]]'; } + if (post.anonymous === 1) { + const isAdmin = await user.isAdministrator(caller.uid); + posts.anonymizePost(post, isAdmin); + } + return post; }; @@ -571,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/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/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 5995514eb6..836233bdda 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) { @@ -22,7 +23,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); @@ -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,8 @@ module.exports = function (Posts) { post.user = uidToUser[post.uid]; Posts.overrideGuestHandle(post, post.handle); post.handle = undefined; + + 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/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..bb6dd61f84 100644 --- a/src/topics/posts.js +++ b/src/topics/posts.js @@ -163,6 +163,9 @@ 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 + 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']); post.display_delete_tools = topicPrivileges.isAdminOrMod || (post.selfPost && topicPrivileges['posts:delete']); diff --git a/test/posts.js b/test/posts.js index 0e8ad7fc4a..3b9064c4a8 100644 --- a/test/posts.js +++ b/test/posts.js @@ -1302,6 +1302,222 @@ 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 and preserve anonymous flag for admins', async () => { + const post = await apiPosts.get({ uid: adminUid }, { pid: anonPost.pid }); + assert.strictEqual(post.anonymous, 1); + 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 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, + 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.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); + }); + }); + }); }); describe('Posts\'', async () => {