Skip to content
Closed
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
6 changes: 6 additions & 0 deletions public/openapi/components/schemas/PostObject.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +37 to +39

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code changes make sense, pretty good

why is anonymous of type number instead of boolean here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the convention that the other components use

Copy link

@cirex-web cirex-web Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which (existing) components?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This follows the existing NodeBB convention for DB-persisted boolean flags. Specifically: deleted, edited, and uid in PostDataObject (same file, lines 215/227), locked/pinned/scheduled in TopicObject.yaml, and banned/email:confirmed in UserObject.yaml all use type: number with 0/1 values. Since anonymous is stored as 0/1 in the DB and returned as-is (not converted to a boolean in processing code), type: number is consistent here.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fair enough

uid:
type: number
description: A user identifier
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions public/openapi/read/topic/topic_id.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ get:
type: boolean
isAdminOrMod:
type: boolean
isAdmin:
type: boolean
disabled:
type: number
tid:
Expand Down
11 changes: 10 additions & 1 deletion src/api/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -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);

Expand Down
1 change: 1 addition & 0 deletions src/posts/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/posts/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
21 changes: 21 additions & 0 deletions src/posts/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]]';
Expand Down
7 changes: 6 additions & 1 deletion src/posts/summary.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Expand All @@ -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.
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/privileges/topics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/topics/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
216 changes: 216 additions & 0 deletions test/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down