Skip to content
Open
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@
"mocha-lcov-reporter": "1.3.0",
"mockdate": "3.0.5",
"nyc": "17.1.0",
"smtp-server": "3.13.6"
"smtp-server": "3.13.6",
"standard": "^17.1.2"
},
"optionalDependencies": {
"sass-embedded": "1.88.0"
Expand Down
10 changes: 10 additions & 0 deletions public/openapi/components/schemas/PostObject.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ PostObject:
description: A user identifier
timestamp:
type: number
anonymous:
type: boolean
description:
True if this post was created anonymously. Non-owners/non-mods should
see the author masked in UI/SSR and API responses.
deleted:
type: boolean
upvotes:
Expand Down Expand Up @@ -195,6 +200,11 @@ PostDataObject:
type: string
timestamp:
type: number
anonymous:
type: boolean
description:
True if this post was created anonymously. Non-owners/non-mods should
see the author masked in UI/SSR and API responses.
votes:
type: number
deleted:
Expand Down
6 changes: 6 additions & 0 deletions public/openapi/read/categories.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ get:
type: number
timestamp:
type: number
anonymous:
type: boolean
description: Whether this post was created anonymously
content:
type: string
timestampISO:
Expand Down Expand Up @@ -146,6 +149,9 @@ get:
type: number
timestamp:
type: number
anonymous:
type: boolean
description: Whether this post was created anonymously
content:
type: string
sourceContent:
Expand Down
3 changes: 3 additions & 0 deletions public/openapi/read/index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ get:
type: number
timestamp:
type: number
anonymous:
type: boolean
description: Whether this post was created anonymously
content:
type: string
sourceContent:
Expand Down
14 changes: 14 additions & 0 deletions public/src/modules/quickreply.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ define('quickreply', [
},
});

// Add "Post anonymously" checkbox (idempotent)
const qrContainer = $('[component="topic/quickreply/container"]');
if (qrContainer.length && !qrContainer.find('#qr-anon').length) {
const anonToggle = $(
'<div class="form-check mt-2" id="qr-anon-wrap">' +
'<input class="form-check-input" type="checkbox" id="qr-anon">' +
'<label class="form-check-label" for="qr-anon">Post anonymously</label>' +
'</div>'
);
qrContainer.find('.quickreply-message').after(anonToggle);
}

let ready = true;
components.get('topic/quickreply/button').on('click', function (e) {
e.preventDefault();
Expand All @@ -61,10 +73,12 @@ define('quickreply', [
}

const replyMsg = components.get('topic/quickreply/text').val();
const isAnonymous = $('#qr-anon').is(':checked');
const replyData = {
tid: ajaxify.data.tid,
handle: undefined,
content: replyMsg,
anonymous: !!isAnonymous,
};
const replyLen = replyMsg.length;
if (replyLen < parseInt(config.minimumPostLength, 10)) {
Expand Down
95 changes: 94 additions & 1 deletion src/api/categories.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,76 @@ const user = require('../user');
const groups = require('../groups');
const privileges = require('../privileges');
const utils = require('../utils');
const Posts = require('../posts');

const activitypubApi = require('./activitypub');

const categoriesAPI = module.exports;

// Mask authors shown on a category card if the underlying post was anonymous

async function maskTopicUsersIfAnonymous(caller, topic) {
if (!topic) return { maskedMain: false, maskedTeaser: false, maskedLast: false, reason: 'no-topic' };

// Returns a masked user object when the post is anonymous and should be masked,
// otherwise returns false. This avoids creating a local user object that is not
// attached back to the topic structure.
async function checkAndMaskByPid(pid) {
if (!pid) return false;
const row = await Posts.getPostFields(pid, ['anonymous', 'uid', 'pid']);
const isAnon = row && (row.anonymous === true || row.anonymous === 'true');
if (!isAnon) return false;
// owners/mods still see identity (use parseInt on both sides to avoid type mismatch)
const isOwner = caller.uid && parseInt(caller.uid, 10) === parseInt(row.uid, 10);
const canModerate = await privileges.posts.can('posts:moderate', row.pid, caller.uid);
if (isOwner || canModerate) return false;

// Build and return a fresh masked user object (do not mutate input object)
const masked = {
uid: 0,
username: 'Anonymous',
'username:escaped': 'Anonymous',
displayname: 'Anonymous',
'displayname:escaped': 'Anonymous',
userslug: null,
'userslug:escaped': '',
picture: null,
'icon:text': 'A',
'icon:bgColor': '#888',
};
return masked;
}

const mainPid = topic.mainPid || null;
const teaserPid = topic.teaser && topic.teaser.pid;
const lastPid = (topic.lastpost && topic.lastpost.pid) || topic.lastpostPid;

const mainMasked = await checkAndMaskByPid(mainPid, topic.user);
if (mainMasked) topic.user = mainMasked;

const teaserMasked = await checkAndMaskByPid(teaserPid, topic.teaser && topic.teaser.user);
if (teaserMasked) {
if (!topic.teaser) topic.teaser = {};
topic.teaser.user = teaserMasked;
// Mirror controller behavior: mark teaser as anonymous for templates/clients
topic.teaser.anonymous = true;
}

const lastMasked = await checkAndMaskByPid(lastPid, topic.lastpost && topic.lastpost.user);
if (lastMasked) {
if (topic.lastpost) topic.lastpost.user = lastMasked;
if (topic.lastpost) topic.lastpost.anonymous = true;
}

return {
maskedMain: !!mainMasked,
maskedTeaser: !!teaserMasked,
maskedLast: !!lastMasked,
reason: (!mainPid && !teaserPid && !lastPid) ? 'no-pids' : undefined,
};
}


const hasAdminPrivilege = async (uid, privilege = 'categories') => {
const ok = await privileges.admin.can(`admin:${privilege}`, uid);
if (!ok) {
Expand Down Expand Up @@ -140,6 +205,7 @@ categoriesAPI.getTopics = async (caller, data) => {

start = Math.max(0, start);
stop = Math.max(0, stop);

const result = await categories.getCategoryTopics({
uid: caller.uid,
cid: data.cid,
Expand All @@ -151,11 +217,38 @@ categoriesAPI.getTopics = async (caller, data) => {
tag: data.query.tag,
targetUid,
});

// Ensure schema-required `topics` always exists
result.topics = Array.isArray(result.topics) ? result.topics : [];

categories.modifyTopicsByPrivilege(result.topics, userPrivileges);

return { ...result, privileges: userPrivileges };
// Ensure each topic has mainPid so we can check the main post
const tids = (result.topics || []).map(t => t && t.tid).filter(Boolean);
if (tids.length) {
const mainRows = await topics.getTopicsFields(tids, ['mainPid']);
result.topics.forEach((t, i) => {
if (t && !t.mainPid) t.mainPid = mainRows[i] && mainRows[i].mainPid;
});
}

// generated by ChatGPT
// Mask topic owner / teaser / lastpost

await Promise.all((result.topics || []).map(t => maskTopicUsersIfAnonymous(caller, t)));


// Return shape that always includes `topics`
return {
topics: result.topics,
topicCount: result.topicCount,
nextStart: result.nextStart,
tagWhitelist: result.tagWhitelist,
privileges: userPrivileges,
};
};


categoriesAPI.setWatchState = async (caller, { cid, state, uid }) => {
let targetUid = caller.uid;
let cids = Array.isArray(cid) ? cid : [cid];
Expand Down
51 changes: 51 additions & 0 deletions src/api/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,41 @@ const notifications = require('../notifications');

const postsAPI = module.exports;

// --- Anonymous masking helper ---
async function maskAnonymousIfNeeded(caller, post) {
// only mask when the post is flagged anonymous
if (!post) {
return;
}
if (typeof post.anonymous === 'undefined') {
// This is the #1 cause of "masking didn't run" — the field isn't being fetched on read.
console.warn('[anon] post pid=%s missing `anonymous` field in read payload', post.pid);
return;
}
if (!post.anonymous) {
return;
}

// owners & moderators can still see identity
const selfPost = caller.uid && caller.uid === parseInt(post.uid, 10);
const canModerate = await privileges.posts.can('posts:moderate', post.pid, caller.uid);
if (selfPost || canModerate) {
return;
}

// hide identifying fields for everyone else
post.uid = 0;
delete post.handle;
if (post.user) {
post.user.uid = 0;
post.user.username = 'Anonymous';
post.user.userslug = null;
post.user.picture = null;
post.user.iconText = 'A';
post.user.iconBgColor = '#888';
}
}

postsAPI.get = async function (caller, data) {
const [userPrivileges, post, voted] = await Promise.all([
privileges.posts.get([data.pid], caller.uid),
Expand All @@ -42,6 +77,8 @@ postsAPI.get = async function (caller, data) {
post.content = '[[topic:post-is-deleted]]';
}

await maskAnonymousIfNeeded(caller, post);

return post;
};

Expand All @@ -63,7 +100,11 @@ postsAPI.getSummary = async (caller, { pid }) => {
}

const postsData = await posts.getPostSummaryByPids([pid], caller.uid, { stripTags: false });
if (!postsData[0] || typeof postsData[0].anonymous === 'undefined') {
console.warn('[anon] getSummary pid=%s came back without `anonymous` field; add it to your summary getter', pid);
}
posts.modifyPostByPrivilege(postsData[0], topicPrivileges);
await maskAnonymousIfNeeded(caller, postsData[0]);
return postsData[0];
};

Expand Down Expand Up @@ -118,6 +159,10 @@ postsAPI.edit = async function (caller, data) {
data.uid = caller.uid;
data.req = apiHelpers.buildReqObject(caller);
data.timestamp = parseInt(data.timestamp, 10) || Date.now();
// Pass through anonymous toggle if present
if (typeof data.anonymous === 'boolean') {
data.anonymous = !!data.anonymous;
}

const editResult = await posts.edit(data);
if (editResult.topic.isMainPost) {
Expand Down Expand Up @@ -579,6 +624,10 @@ postsAPI.getReplies = async (caller, { pid }) => {
postData = postData.filter((postData, index) => postData && postPrivileges[index].read);
postData = await user.blocks.filter(uid, postData);

if (postData.length && typeof postData[0].anonymous === 'undefined') {
console.warn('[anon] getReplies parent pid=%s: posts fetched without `anonymous` field; add it to getPostsByPids/post summaries', pid);
}
await Promise.all(postData.map(p => maskAnonymousIfNeeded(caller, p)));
return postData;
};

Expand Down Expand Up @@ -669,3 +718,5 @@ async function sendQueueNotification(type, targetUid, path, notificationText) {
const notifObj = await notifications.create(notifData);
await notifications.push(notifObj, [targetUid]);
}

module.exports = postsAPI;
Loading