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)
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
20 changes: 20 additions & 0 deletions src/api/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

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
22 changes: 21 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,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;
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
23 changes: 23 additions & 0 deletions src/topics/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
153 changes: 153 additions & 0 deletions test/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading