diff --git a/UserGuide.md b/UserGuide.md
index 230ec86da2..973cdef243 100644
--- a/UserGuide.md
+++ b/UserGuide.md
@@ -6,4 +6,83 @@
## Sorting Posts by Attribute
-## Instructor-only Posts
+## Instructor-Only & Anonymous Posting
+### Overview
+Students may need to ask instructors questions that require sharing parts of their buggy code. In many CS courses, sharing code publicly (even unintentionally) can result in an Academic Integrity Violation (AIV).
+This feature introduces flexible post visibility options to ensure students can:
+* Ask questions publicly
+* Ask anonymously
+* Share posts visible only to instructors
+
+### Posting Options
+When creating a post (via New Topic or Quick Reply), users will now see a Visibility dropdown instead of the previous anonymous toggle.
+
+
+### Visibility Modes
+The dropdown includes three options:
+1) Post Publicly (default)
+ * Visible to all users who can access the topic
+ * Author identity is shown normally
+2) Post Anonymously
+ * Visible to all users
+ * Author identity is masked
+ * Fully compatible with the existing anonymous system
+3) Post to Instructors
+ * Visible only to:
+ * The post/topic author
+ * Administrators (instructors/moderators)
+ * Hidden from other students
+
+The selected option is stored as:
+visibilityMode = public | anonymous | instructors
+
+### How Instructor-Only Posts Work
+Instructor-only posts are restricted at the read level.
+Access is granted only if:
+* You are the author of the post, OR
+* You are an admin/moderator (instructor)
+
+
+All other users:
+* Cannot see the post
+* Cannot access its raw content
+* Will not see it in topic listings
+
+
+Instructor-only topics are also filtered out for non-author, non-admin users.
+
+### Anonymous Compatibility
+This update preserves full backward compatibility with the existing anonymous posting system:
+* The legacy “anonymous” flag still exists
+* visibilityMode = anonymous maps internally to the anonymous behavior
+* Author masking logic remains unchanged
+* Composer logic includes a compatibility fallback to prevent regressions
+
+
+### Where This Applies
+The visibility dropdown appears in:
+* New Topic Composer
+* Quick Reply Composer
+
+
+Both composers use the same visibility system.
+
+### Testing Instructions
+Build and Run
+./nodebb build
+./nodebb restart
+Run full test suite:
+npm test
+
+### Manual Verification
+1) Open Quick Reply and confirm the dropdown appears.
+2) Confirm default selection is Post Publicly.
+3) Create:
+ * One Public post
+ * One Anonymous post
+ * One Instructor-only post
+4) Verify:
+ * Anonymous posts mask identity
+ * Instructor-only posts are visible only to author + admin
+ * Regular users cannot see instructor-only posts
+
diff --git a/public/openapi/components/schemas/PostObject.yaml b/public/openapi/components/schemas/PostObject.yaml
index f6abfc8ea2..1231876e83 100644
--- a/public/openapi/components/schemas/PostObject.yaml
+++ b/public/openapi/components/schemas/PostObject.yaml
@@ -21,6 +21,15 @@ PostObject:
sourceContent:
type: string
nullable: true
+ anonymous:
+ type: string
+ default: 'false'
+ visibilityMode:
+ type: string
+ enum:
+ - public
+ - anonymous
+ - instructors
uid:
type: number
description: A user identifier
@@ -173,6 +182,12 @@ PostDataObject:
anonymous:
type: string
default: 'false'
+ visibilityMode:
+ type: string
+ enum:
+ - public
+ - anonymous
+ - instructors
timestamp:
type: number
votes:
diff --git a/public/openapi/components/schemas/TopicObject.yaml b/public/openapi/components/schemas/TopicObject.yaml
index 30ba9d3a77..04097459ba 100644
--- a/public/openapi/components/schemas/TopicObject.yaml
+++ b/public/openapi/components/schemas/TopicObject.yaml
@@ -235,6 +235,12 @@ TopicObjectSlim:
type: number
titleRaw:
type: string
+ visibilityMode:
+ type: string
+ enum:
+ - public
+ - anonymous
+ - instructors
locked:
type: number
pinned:
diff --git a/public/src/modules/quickreply.js b/public/src/modules/quickreply.js
index 099db7ada7..df97f44b9f 100644
--- a/public/src/modules/quickreply.js
+++ b/public/src/modules/quickreply.js
@@ -52,6 +52,14 @@ define('quickreply', [
element.val(text);
},
});
+ const visibilityEl = components.get('topic/quickreply/visibility');
+ const anonymizeEl = components.get('topic/quickreply/anonymize');
+ if (visibilityEl.length && anonymizeEl.length) {
+ anonymizeEl.prop('checked', visibilityEl.val() === 'anonymous');
+ visibilityEl.on('change', function () {
+ anonymizeEl.prop('checked', $(this).val() === 'anonymous');
+ });
+ }
let ready = true;
components.get('topic/quickreply/button').on('click', function (e) {
@@ -60,7 +68,15 @@ define('quickreply', [
return;
}
- var anonymous = (components.get('topic/quickreply/anonymize').prop('checked')) ? 'true' : 'false';
+ const visibilityEl = components.get('topic/quickreply/visibility');
+ const anonymousEl = components.get('topic/quickreply/anonymize');
+ let visibilityMode = 'public';
+ if (visibilityEl.length) {
+ visibilityMode = visibilityEl.val() || 'public';
+ } else if (anonymousEl.length && anonymousEl.prop('checked')) {
+ visibilityMode = 'anonymous';
+ }
+ var anonymous = visibilityMode === 'anonymous' ? 'true' : 'false';
const replyMsg = element.val();
const replyData = {
@@ -68,6 +84,7 @@ define('quickreply', [
handle: undefined,
content: replyMsg,
anonymous: anonymous,
+ visibilityMode: visibilityMode,
};
const replyLen = replyMsg.length;
if (replyLen < parseInt(config.minimumPostLength, 10)) {
@@ -86,6 +103,7 @@ define('quickreply', [
}
if (data && data.queued) {
data.anonymous = anonymous;
+ data.visibilityMode = visibilityMode;
alerts.alert({
type: 'success',
title: '[[global:alert.success]]',
@@ -98,6 +116,7 @@ define('quickreply', [
});
}
data.anonymous = anonymous;
+ data.visibilityMode = visibilityMode;
element.val('');
storage.removeItem(qrDraftId);
QuickReply._autocomplete.hide();
@@ -123,10 +142,19 @@ define('quickreply', [
e.preventDefault();
storage.removeItem(qrDraftId);
const textEl = components.get('topic/quickreply/text');
+ const visibilityEl = components.get('topic/quickreply/visibility');
+ const anonymizeEl = components.get('topic/quickreply/anonymize');
+ let visibilityMode = 'public';
+ if (visibilityEl.length) {
+ visibilityMode = visibilityEl.val() || 'public';
+ } else if (anonymizeEl.length && anonymizeEl.prop('checked')) {
+ visibilityMode = 'anonymous';
+ }
hooks.fire('action:composer.post.new', {
tid: ajaxify.data.tid,
title: ajaxify.data.titleRaw,
body: textEl.val(),
+ visibilityMode: visibilityMode,
});
textEl.val('');
});
diff --git a/src/api/posts.js b/src/api/posts.js
index c9a570dec4..99ceddb43c 100644
--- a/src/api/posts.js
+++ b/src/api/posts.js
@@ -19,6 +19,7 @@ const websockets = require('../socket.io');
const socketHelpers = require('../socket.io/helpers');
const translator = require('../translator');
const notifications = require('../notifications');
+const postVisibility = require('../posts/visibility');
const postsAPI = module.exports;
@@ -30,7 +31,8 @@ postsAPI.get = async function (caller, data) {
]);
const userPrivilege = userPrivileges[0];
- if (!post || !userPrivilege.read || !userPrivilege['topics:read']) {
+ const isAdmin = await postVisibility.isViewerAdmin(caller.uid);
+ if (!post || !userPrivilege.read || !userPrivilege['topics:read'] || !postVisibility.canViewPost(post, caller.uid, isAdmin)) {
return null;
}
@@ -46,7 +48,14 @@ postsAPI.get = async function (caller, data) {
};
postsAPI.getIndex = async (caller, { pid, sort }) => {
- const tid = await posts.getPostField(pid, 'tid');
+ const [tid, postData, isAdmin] = await Promise.all([
+ posts.getPostField(pid, 'tid'),
+ posts.getPostFields(pid, ['uid', 'visibilityMode', 'anonymous']),
+ postVisibility.isViewerAdmin(caller.uid),
+ ]);
+ if (!postVisibility.canViewPost(postData, caller.uid, isAdmin)) {
+ return null;
+ }
const topicPrivileges = await privileges.topics.get(tid, caller.uid);
if (!topicPrivileges.read || !topicPrivileges['topics:read']) {
return null;
@@ -63,6 +72,9 @@ postsAPI.getSummary = async (caller, { pid }) => {
}
const postsData = await posts.getPostSummaryByPids([pid], caller.uid, { stripTags: false });
+ if (!postsData.length) {
+ return null;
+ }
posts.modifyPostByPrivilege(postsData[0], topicPrivileges);
return postsData[0];
};
@@ -74,10 +86,17 @@ postsAPI.getRaw = async (caller, { pid }) => {
return null;
}
- const postData = await posts.getPostFields(pid, ['content', 'deleted']);
- const selfPost = caller.uid && caller.uid === parseInt(postData.uid, 10);
-
- if (postData.deleted && !(userPrivilege.isAdminOrMod || selfPost)) {
+ const [postData, isAdmin] = await Promise.all([
+ posts.getPostFields(pid, ['content', 'deleted', 'uid', 'visibilityMode', 'anonymous']),
+ postVisibility.isViewerAdmin(caller.uid),
+ ]);
+ if (!postData) {
+ return null;
+ }
+ if (!postVisibility.canViewPost(postData, caller.uid, isAdmin)) {
+ return null;
+ }
+ if (postData.deleted && !userPrivilege.isAdminOrMod) {
return null;
}
postData.pid = pid;
@@ -558,10 +577,17 @@ postsAPI.getReplies = async (caller, { pid }) => {
throw new Error('[[error:invalid-data]]');
}
const { uid } = caller;
- const canRead = await privileges.posts.can('topics:read', pid, caller.uid);
+ const [canRead, parentPost, isAdmin] = await Promise.all([
+ privileges.posts.can('topics:read', pid, caller.uid),
+ posts.getPostFields(pid, ['uid', 'visibilityMode', 'anonymous']),
+ postVisibility.isViewerAdmin(caller.uid),
+ ]);
if (!canRead) {
return null;
}
+ if (!postVisibility.canViewPost(parentPost, caller.uid, isAdmin)) {
+ return null;
+ }
const { topicPostSort } = await user.getSettings(uid);
const pids = await posts.getPidsFromSet(`pid:${pid}:replies`, 0, -1, topicPostSort === 'newest_to_oldest');
diff --git a/src/api/topics.js b/src/api/topics.js
index 054602e7a2..ae19491905 100644
--- a/src/api/topics.js
+++ b/src/api/topics.js
@@ -41,6 +41,7 @@ topicsAPI.get = async function (caller, data) {
!topic ||
!userPrivileges.read ||
!userPrivileges['topics:read'] ||
+ !await topics.canViewTopic(topic, caller.uid) ||
!privileges.topics.canViewDeletedScheduled(topic, userPrivileges)
) {
return null;
diff --git a/src/controllers/composer.js b/src/controllers/composer.js
index bc1e4283c3..0356d1aae0 100644
--- a/src/controllers/composer.js
+++ b/src/controllers/composer.js
@@ -47,6 +47,8 @@ exports.post = async function (req, res) {
timestamp: Date.now(),
content: body.content,
handle: body.handle,
+ visibilityMode: body.visibilityMode,
+ anonymous: body.anonymous,
fromQueue: false,
};
req.body.noscript = 'true';
diff --git a/src/controllers/posts.js b/src/controllers/posts.js
index 44f2b06649..e2443435f2 100644
--- a/src/controllers/posts.js
+++ b/src/controllers/posts.js
@@ -8,6 +8,7 @@ const posts = require('../posts');
const privileges = require('../privileges');
const activitypub = require('../activitypub');
const utils = require('../utils');
+const postVisibility = require('../posts/visibility');
const helpers = require('./helpers');
@@ -27,14 +28,16 @@ postsController.redirectToPost = async function (req, res, next) {
}
}
- const [canRead, path] = await Promise.all([
+ const [canRead, path, postData, isAdmin] = await Promise.all([
privileges.posts.can('topics:read', pid, req.uid),
posts.generatePostPath(pid, req.uid),
+ posts.getPostFields(pid, ['uid', 'visibilityMode', 'anonymous']),
+ postVisibility.isViewerAdmin(req.uid),
]);
if (!path) {
return next();
}
- if (!canRead) {
+ if (!canRead || !postVisibility.canViewPost(postData, req.uid, isAdmin)) {
return helpers.notAllowed(req, res);
}
diff --git a/src/controllers/topics.js b/src/controllers/topics.js
index 08958ce730..3eaf14ac1a 100644
--- a/src/controllers/topics.js
+++ b/src/controllers/topics.js
@@ -35,6 +35,9 @@ topicsController.get = async function getTopic(req, res, next) {
if (!topicData) {
return next();
}
+ if (!await topics.canViewTopic(topicData, req.uid)) {
+ return helpers.notAllowed(req, res);
+ }
const [
userPrivileges,
settings,
@@ -393,6 +396,9 @@ topicsController.pagination = async function (req, res, next) {
if (!topic) {
return next();
}
+ if (!await topics.canViewTopic(topic, req.uid)) {
+ return helpers.notAllowed(req, res);
+ }
const [userPrivileges, settings] = await Promise.all([
privileges.topics.get(tid, req.uid),
user.getSettings(req.uid),
diff --git a/src/posts/create.js b/src/posts/create.js
index 6d7103eadc..791218e5c7 100644
--- a/src/posts/create.js
+++ b/src/posts/create.js
@@ -9,6 +9,7 @@ const categories = require('../categories');
const groups = require('../groups');
const activitypub = require('../activitypub');
const utils = require('../utils');
+const postVisibility = require('./visibility');
module.exports = function (Posts) {
Posts.create = async function (data) {
@@ -29,7 +30,8 @@ module.exports = function (Posts) {
const pid = data.pid || await db.incrObjectField('global', 'nextPid');
let postData = { pid, uid, tid, content, sourceContent, timestamp };
- postData.anonymous = data.anonymous || false;
+ postData.visibilityMode = postVisibility.normalizeVisibilityMode(data.visibilityMode, data.anonymous);
+ postData.anonymous = postData.visibilityMode === 'anonymous' ? 'true' : 'false';
if (data.toPid) {
postData.toPid = data.toPid;
diff --git a/src/posts/edit.js b/src/posts/edit.js
index 0b8b6ff009..ba2c4570e2 100644
--- a/src/posts/edit.js
+++ b/src/posts/edit.js
@@ -13,6 +13,7 @@ const pubsub = require('../pubsub');
const utils = require('../utils');
const slugify = require('../slugify');
const translator = require('../translator');
+const postVisibility = require('./visibility');
module.exports = function (Posts) {
pubsub.on('post:edit', pid => Posts.clearCachedPost(pid));
@@ -128,6 +129,10 @@ module.exports = function (Posts) {
mainPid: data.pid,
timestamp: rescheduling(data, topicData) ? data.timestamp : topicData.timestamp,
};
+ if (Object.prototype.hasOwnProperty.call(data, 'visibilityMode') ||
+ Object.prototype.hasOwnProperty.call(data, 'anonymous')) {
+ newTopicData.visibilityMode = postVisibility.normalizeVisibilityMode(data.visibilityMode, data.anonymous);
+ }
if (title) {
newTopicData.title = title;
newTopicData.slug = `${tid}/${slugify(title) || 'topic'}`;
@@ -210,6 +215,13 @@ module.exports = function (Posts) {
editor: data.uid,
};
+ const hasVisibilityMode = Object.prototype.hasOwnProperty.call(data, 'visibilityMode');
+ const hasAnonymous = Object.prototype.hasOwnProperty.call(data, 'anonymous');
+ if (hasVisibilityMode || hasAnonymous) {
+ editPostData.visibilityMode = postVisibility.normalizeVisibilityMode(data.visibilityMode, data.anonymous);
+ editPostData.anonymous = editPostData.visibilityMode === 'anonymous' ? 'true' : 'false';
+ }
+
// For posts in scheduled topics, if edited before, use edit timestamp
editPostData.edited = topicData.scheduled ? (postData.edited || postData.timestamp) + 1 : Date.now();
diff --git a/src/posts/index.js b/src/posts/index.js
index 59c61381b9..c65899ffe7 100644
--- a/src/posts/index.js
+++ b/src/posts/index.js
@@ -7,6 +7,7 @@ const utils = require('../utils');
const user = require('../user');
const privileges = require('../privileges');
const plugins = require('../plugins');
+const postVisibility = require('./visibility');
const Posts = module.exports;
@@ -48,6 +49,8 @@ Posts.getPostsByPids = async function (pids, uid) {
}
let posts = await Posts.getPostsData(pids);
+ const isAdmin = await postVisibility.isViewerAdmin(uid);
+ posts = posts.filter(post => postVisibility.canViewPost(post, uid, isAdmin));
posts = await Promise.all(posts.map(Posts.parsePost));
const data = await plugins.hooks.fire('filter:post.getPosts', { posts: posts, uid: uid });
if (!data || !Array.isArray(data.posts)) {
diff --git a/src/posts/summary.js b/src/posts/summary.js
index 5995514eb6..d7a69a3f8d 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 postVisibility = require('./visibility');
module.exports = function (Posts) {
Posts.getPostSummaryByPids = async function (pids, uid, options) {
@@ -22,9 +23,11 @@ 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', 'visibilityMode'].concat(options.extraFields);
let posts = await Posts.getPostsFields(pids, fields);
+ const isAdmin = await postVisibility.isViewerAdmin(uid);
+ posts = posts.filter(post => postVisibility.canViewPost(post, uid, isAdmin));
posts = posts.filter(Boolean);
posts = await user.blocks.filter(uid, posts);
diff --git a/src/posts/visibility.js b/src/posts/visibility.js
new file mode 100644
index 0000000000..8ad0f6d54a
--- /dev/null
+++ b/src/posts/visibility.js
@@ -0,0 +1,44 @@
+'use strict';
+
+const user = require('../user');
+
+const VALID_MODES = new Set(['public', 'anonymous', 'instructors']);
+
+function normalizeVisibilityMode(mode, anonymous) {
+ if (VALID_MODES.has(mode)) {
+ return mode;
+ }
+
+ return anonymous === true || anonymous === 'true' ? 'anonymous' : 'public';
+}
+
+async function isViewerAdmin(uid) {
+ if (!parseInt(uid, 10)) {
+ return false;
+ }
+ return await user.isAdministrator(uid);
+}
+
+function canViewPost(post, uid, isAdmin) {
+ if (!post) {
+ return false;
+ }
+
+ const mode = normalizeVisibilityMode(post.visibilityMode, post.anonymous);
+ if (mode !== 'instructors') {
+ return true;
+ }
+
+ if (String(uid) === String(post.uid)) {
+ return true;
+ }
+
+ return !!isAdmin;
+}
+
+module.exports = {
+ VALID_MODES,
+ normalizeVisibilityMode,
+ isViewerAdmin,
+ canViewPost,
+};
diff --git a/src/topics/create.js b/src/topics/create.js
index 098fba2d41..1b5d967df4 100644
--- a/src/topics/create.js
+++ b/src/topics/create.js
@@ -13,6 +13,7 @@ const user = require('../user');
const activitypub = require('../activitypub');
const meta = require('../meta');
const posts = require('../posts');
+const postVisibility = require('../posts/visibility');
const privileges = require('../privileges');
const categories = require('../categories');
const translator = require('../translator');
@@ -35,6 +36,7 @@ module.exports = function (Topics) {
lastposttime: 0,
postcount: 0,
viewcount: 0,
+ visibilityMode: postVisibility.normalizeVisibilityMode(data.visibilityMode, data.anonymous),
};
if (Array.isArray(data.tags) && data.tags.length) {
diff --git a/src/topics/index.js b/src/topics/index.js
index 0f9c067535..d816590541 100644
--- a/src/topics/index.js
+++ b/src/topics/index.js
@@ -13,6 +13,7 @@ const categories = require('../categories');
const activitypub = require('../activitypub');
const privileges = require('../privileges');
const social = require('../social');
+const postVisibility = require('../posts/visibility');
const Topics = module.exports;
@@ -68,6 +69,10 @@ Topics.getTopicsByTids = async function (tids, options) {
if (typeof options === 'object') {
uid = options.uid;
}
+ tids = await Topics.filterTidsByVisibility(tids, uid);
+ if (!tids.length) {
+ return [];
+ }
async function loadTopics() {
const topics = await Topics.getTopicsData(tids);
@@ -159,6 +164,41 @@ Topics.getTopicsByTids = async function (tids, options) {
return hookResult.topics;
};
+Topics.filterTidsByVisibility = async function (tids, uid) {
+ if (!Array.isArray(tids) || !tids.length) {
+ return [];
+ }
+
+ const topicData = await Topics.getTopicsFields(tids, ['tid', 'uid', 'visibilityMode']);
+ const hasInstructorOnly = topicData.some(topic =>
+ topic && postVisibility.normalizeVisibilityMode(topic.visibilityMode) === 'instructors');
+ const isAdmin = hasInstructorOnly ? await postVisibility.isViewerAdmin(uid) : false;
+
+ return topicData
+ .filter((topic) => {
+ if (!topic) {
+ return false;
+ }
+ const mode = postVisibility.normalizeVisibilityMode(topic.visibilityMode);
+ if (mode !== 'instructors') {
+ return true;
+ }
+ return String(uid) === String(topic.uid) || isAdmin;
+ })
+ .map(topic => topic.tid);
+};
+
+Topics.canViewTopic = async function (topicData, uid) {
+ if (!topicData) {
+ return false;
+ }
+ const mode = postVisibility.normalizeVisibilityMode(topicData.visibilityMode);
+ if (mode !== 'instructors') {
+ return true;
+ }
+ return String(uid) === String(topicData.uid) || await postVisibility.isViewerAdmin(uid);
+};
+
Topics.getTopicWithPosts = async function (topicData, set, uid, start, stop, reverse) {
const [
posts,
diff --git a/src/topics/posts.js b/src/topics/posts.js
index 8108b81d8b..d6f8287dc1 100644
--- a/src/topics/posts.js
+++ b/src/topics/posts.js
@@ -13,6 +13,7 @@ const activitypub = require('../activitypub');
const plugins = require('../plugins');
const utils = require('../utils');
const privileges = require('../privileges');
+const postVisibility = require('../posts/visibility');
const backlinkRegex = new RegExp(`(?:${nconf.get('url').replace('/', '\\/')}|\b|\\s)\\/topic\\/(\\d+)(?:\\/\\w+)?`, 'g');
@@ -50,8 +51,12 @@ module.exports = function (Topics) {
if (!postData.length) {
return [];
}
+ const hasMainPost = topicData.mainPid &&
+ start === 0 &&
+ postData[0] &&
+ String(postData[0].pid) === String(topicData.mainPid);
let replies = postData;
- if (topicData.mainPid && start === 0) {
+ if (hasMainPost) {
postData[0].index = 0;
replies = postData.slice(1);
}
@@ -150,8 +155,10 @@ module.exports = function (Topics) {
postObj.user.displayname = postObj.user.username;
}
+ postObj.visibilityMode = postVisibility.normalizeVisibilityMode(postObj.visibilityMode, postObj.anonymous);
+
// Handle anonymous posts
- if (postObj.anonymous === 'true' && postObj.user) {
+ if (postObj.visibilityMode === 'anonymous' && postObj.user) {
postObj.user.username = 'Anonymous';
postObj.user.userslug = 'Anonymous';
postObj.user.displayname = 'Anonymous';
diff --git a/test/posts.js b/test/posts.js
index 30374b6569..5f5afe1b12 100644
--- a/test/posts.js
+++ b/test/posts.js
@@ -52,6 +52,107 @@ describe('Post\'s', () => {
await groups.join('Global Moderators', globalModUid);
});
+ describe('visibility modes', () => {
+ let authorUid;
+ let regularUid;
+ let adminUid;
+ let visibilityTopic;
+
+ before(async () => {
+ authorUid = await user.create({ username: 'visibility-author' });
+ regularUid = await user.create({ username: 'visibility-regular' });
+ adminUid = await user.create({ username: 'visibility-admin' });
+ await groups.join('administrators', adminUid);
+ visibilityTopic = await topics.post({
+ uid: authorUid,
+ cid: cid,
+ title: 'Visibility modes topic',
+ content: 'Main post for visibility tests',
+ visibilityMode: 'public',
+ });
+ });
+
+ it('should persist public visibility by default', async () => {
+ const reply = await topics.reply({
+ uid: authorUid,
+ tid: visibilityTopic.topicData.tid,
+ content: 'public visibility reply',
+ });
+ const fields = await posts.getPostFields(reply.pid, ['visibilityMode', 'anonymous']);
+ assert.strictEqual(fields.visibilityMode, 'public');
+ assert.strictEqual(fields.anonymous, 'false');
+ });
+
+ it('should persist anonymous visibility and keep masking behavior', async () => {
+ const reply = await topics.reply({
+ uid: authorUid,
+ tid: visibilityTopic.topicData.tid,
+ content: 'anonymous visibility reply',
+ visibilityMode: 'anonymous',
+ });
+ const fields = await posts.getPostFields(reply.pid, ['visibilityMode', 'anonymous']);
+ assert.strictEqual(fields.visibilityMode, 'anonymous');
+ assert.strictEqual(fields.anonymous, 'true');
+
+ const topicData = await topics.getTopicData(visibilityTopic.topicData.tid);
+ const postData = await topics.getTopicPosts(topicData, `tid:${topicData.tid}:posts`, 0, -1, regularUid, false);
+ const anonymousPost = postData.find(post => post.pid === reply.pid);
+ assert.strictEqual(anonymousPost.user.username, 'Anonymous');
+ });
+
+ it('should enforce instructor visibility for author/admin and hide from regular users', async () => {
+ const reply = await topics.reply({
+ uid: authorUid,
+ tid: visibilityTopic.topicData.tid,
+ content: 'instructor visibility reply',
+ visibilityMode: 'instructors',
+ });
+
+ const [authorView, adminView, regularView] = await Promise.all([
+ apiPosts.get({ uid: authorUid }, { pid: reply.pid }),
+ apiPosts.get({ uid: adminUid }, { pid: reply.pid }),
+ apiPosts.get({ uid: regularUid }, { pid: reply.pid }),
+ ]);
+ assert(authorView);
+ assert(adminView);
+ assert.strictEqual(regularView, null);
+
+ const topicData = await topics.getTopicData(visibilityTopic.topicData.tid);
+ const [authorPosts, adminPosts, regularPosts] = await Promise.all([
+ topics.getTopicPosts(topicData, `tid:${topicData.tid}:posts`, 0, -1, authorUid, false),
+ topics.getTopicPosts(topicData, `tid:${topicData.tid}:posts`, 0, -1, adminUid, false),
+ topics.getTopicPosts(topicData, `tid:${topicData.tid}:posts`, 0, -1, regularUid, false),
+ ]);
+ assert(authorPosts.some(post => post.pid === reply.pid));
+ assert(adminPosts.some(post => post.pid === reply.pid));
+ assert(!regularPosts.some(post => post.pid === reply.pid));
+ });
+
+ it('should hide instructor-only topics from non-admin non-authors', async () => {
+ const result = await topics.post({
+ uid: authorUid,
+ cid: cid,
+ title: 'Instructor only topic',
+ content: 'Main post visible to instructors only',
+ visibilityMode: 'instructors',
+ });
+ const tid = result.topicData.tid;
+ const [authorTopics, adminTopics, regularTopics, authorTopic, regularTopic] = await Promise.all([
+ topics.getTopicsByTids([tid], { uid: authorUid }),
+ topics.getTopicsByTids([tid], { uid: adminUid }),
+ topics.getTopicsByTids([tid], { uid: regularUid }),
+ apiTopics.get({ uid: authorUid }, { tid }),
+ apiTopics.get({ uid: regularUid }, { tid }),
+ ]);
+
+ assert.strictEqual(authorTopics.length, 1);
+ assert.strictEqual(adminTopics.length, 1);
+ assert.strictEqual(regularTopics.length, 0);
+ assert(authorTopic);
+ assert.strictEqual(regularTopic, null);
+ });
+ });
+
it('should update category teaser properly', async () => {
const getCategoriesAsync = async () => (await request.get(`${nconf.get('url')}/api/categories`, { })).body;
const postResult = await topics.post({ uid: globalModUid, cid: cid, title: 'topic title', content: '123456789' });
diff --git a/vendor/nodebb-plugin-composer-default/library.js b/vendor/nodebb-plugin-composer-default/library.js
index 82d7dc81fb..a902547112 100644
--- a/vendor/nodebb-plugin-composer-default/library.js
+++ b/vendor/nodebb-plugin-composer-default/library.js
@@ -16,6 +16,7 @@ const translator = require.main.require('./src/translator');
const utils = require.main.require('./src/utils');
const helpers = require.main.require('./src/controllers/helpers');
const SocketPlugins = require.main.require('./src/socket.io/plugins');
+const postVisibility = require.main.require('./src/posts/visibility');
const socketMethods = require('./websockets');
const plugin = module.exports;
@@ -147,6 +148,10 @@ plugin.filterComposerBuild = async function (hookData) {
const save_id = utils.generateSaveId(req.uid);
const discardRoute = generateDiscardRoute(req, topicData);
const body = await generateBody(req, postData);
+ const visibilityMode = postVisibility.normalizeVisibilityMode(
+ req.query.visibilityMode || (postData && postData.visibilityMode),
+ req.query.anonymous || (postData && postData.anonymous)
+ );
let action = 'topics.post';
let isMain = isMainPost;
@@ -190,6 +195,10 @@ plugin.filterComposerBuild = async function (hookData) {
topic: topicData,
thumb: topicData ? topicData.thumb : '',
body: body,
+ visibilityMode,
+ visibilityModePublic: visibilityMode === 'public',
+ visibilityModeAnonymous: visibilityMode === 'anonymous',
+ visibilityModeInstructors: visibilityMode === 'instructors',
isMain: isMain,
isTopicOrMain: !!req.query.cid || isMain,
diff --git a/vendor/nodebb-plugin-composer-default/static/lib/client.js b/vendor/nodebb-plugin-composer-default/static/lib/client.js
index 2b46e406b8..4ebe69abf1 100644
--- a/vendor/nodebb-plugin-composer-default/static/lib/client.js
+++ b/vendor/nodebb-plugin-composer-default/static/lib/client.js
@@ -16,13 +16,15 @@ $(document).ready(function () {
title: data.title || '',
body: data.body || '',
tags: data.tags || [],
+ visibilityMode: data.visibilityMode || 'public',
});
});
} else {
ajaxify.go(
'compose?cid=' + data.cid +
(data.title ? '&title=' + encodeURIComponent(data.title) : '') +
- (data.body ? '&body=' + encodeURIComponent(data.body) : '')
+ (data.body ? '&body=' + encodeURIComponent(data.body) : '') +
+ (data.visibilityMode ? '&visibilityMode=' + encodeURIComponent(data.visibilityMode) : '')
);
}
});
@@ -48,6 +50,7 @@ $(document).ready(function () {
toPid: data.pid,
title: data.title,
body: data.body,
+ visibilityMode: data.visibilityMode || 'public',
});
});
} else {
@@ -55,7 +58,8 @@ $(document).ready(function () {
'compose?tid=' + data.tid +
(data.pid ? '&toPid=' + data.pid : '') +
(data.title ? '&title=' + encodeURIComponent(data.title) : '') +
- (data.body ? '&body=' + encodeURIComponent(data.body) : '')
+ (data.body ? '&body=' + encodeURIComponent(data.body) : '') +
+ (data.visibilityMode ? '&visibilityMode=' + encodeURIComponent(data.visibilityMode) : '')
);
}
});
diff --git a/vendor/nodebb-plugin-composer-default/static/lib/composer.js b/vendor/nodebb-plugin-composer-default/static/lib/composer.js
index 3d09a192e6..5b205b37da 100644
--- a/vendor/nodebb-plugin-composer-default/static/lib/composer.js
+++ b/vendor/nodebb-plugin-composer-default/static/lib/composer.js
@@ -200,6 +200,7 @@ define('composer', [
body: data.body || '',
tags: data.tags || [],
thumbs: data.thumbs || [],
+ visibilityMode: data.visibilityMode || 'public',
modified: !!((data.title && data.title.length) || (data.body && data.body.length)),
isMain: true,
};
@@ -271,6 +272,7 @@ define('composer', [
toPid: data.toPid,
title: data.title,
body: translated,
+ visibilityMode: data.visibilityMode || 'public',
modified: !!(translated && translated.length),
isMain: false,
});
@@ -346,6 +348,10 @@ define('composer', [
postContainer.on('change', 'input, textarea', function () {
composer.posts[post_uuid].modified = true;
});
+ postContainer.on('change', '[component="composer/visibility"]', function () {
+ const anonymous = $(this).val() === 'anonymous';
+ postContainer.find('#anonymous').prop('checked', anonymous);
+ });
postContainer.on('click', '.composer-submit', function (e) {
e.preventDefault();
@@ -399,6 +405,8 @@ define('composer', [
drafts.init(postContainer, postData);
const draft = drafts.get(postData.save_id);
+ const currentMode = postContainer.find('[component="composer/visibility"]').val() || 'public';
+ postContainer.find('#anonymous').prop('checked', currentMode === 'anonymous');
preview.render(postContainer, function () {
preview.matchScroll(postContainer);
@@ -488,6 +496,10 @@ define('composer', [
// }
],
};
+ data.visibilityMode = postData.visibilityMode || (postData.anonymous === 'true' ? 'anonymous' : 'public');
+ data.visibilityModePublic = data.visibilityMode === 'public';
+ data.visibilityModeAnonymous = data.visibilityMode === 'anonymous';
+ data.visibilityModeInstructors = data.visibilityMode === 'instructors';
if (data.mobile) {
mobileHistoryAppend();
@@ -665,13 +677,21 @@ define('composer', [
var titleEl = postContainer.find('.title');
var bodyEl = postContainer.find('textarea');
var thumbEl = postContainer.find('input#topic-thumb-url');
+ var visibilityModeEl = postContainer.find('[component="composer/visibility"]');
var anonymousEl = postContainer.find('#anonymous');
var onComposeRoute = postData.hasOwnProperty('template') && postData.template.compose === true;
const submitBtn = postContainer.find('.composer-submit');
- var anonymous = (anonymousEl.prop('checked')) ? 'true' : 'false';
+ let visibilityMode = 'public';
+ if (visibilityModeEl.length) {
+ visibilityMode = visibilityModeEl.val() || 'public';
+ } else if (anonymousEl.length && anonymousEl.prop('checked')) {
+ visibilityMode = 'anonymous';
+ }
+ var anonymous = visibilityMode === 'anonymous' ? 'true' : 'false';
postData.anonymous = anonymous;
+ postData.visibilityMode = visibilityMode;
titleEl.val(titleEl.val().trim());
bodyEl.val(utils.rtrim(bodyEl.val()));
@@ -739,6 +759,7 @@ define('composer', [
thumbs: postData.thumbs || [],
timestamp: scheduler.getTimestamp(),
anonymous: anonymous,
+ visibilityMode: visibilityMode,
};
} else if (action === 'posts.reply') {
route = `/topics/${postData.tid}`;
@@ -749,6 +770,7 @@ define('composer', [
content: bodyEl.val(),
toPid: postData.toPid,
anonymous: anonymous,
+ visibilityMode: visibilityMode,
};
} else if (action === 'posts.edit') {
method = 'put';
@@ -763,6 +785,7 @@ define('composer', [
tags: tags.getTags(post_uuid),
timestamp: scheduler.getTimestamp(),
anonymous: anonymous,
+ visibilityMode: visibilityMode,
};
}
var submitHookData = {
diff --git a/vendor/nodebb-plugin-composer-default/static/templates/partials/composer-formatting.tpl b/vendor/nodebb-plugin-composer-default/static/templates/partials/composer-formatting.tpl
index 24377db9cc..c13c16a689 100644
--- a/vendor/nodebb-plugin-composer-default/static/templates/partials/composer-formatting.tpl
+++ b/vendor/nodebb-plugin-composer-default/static/templates/partials/composer-formatting.tpl
@@ -59,11 +59,14 @@