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
81 changes: 80 additions & 1 deletion UserGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).<br>
This feature introduces flexible post visibility options to ensure students can:<br>
* 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:<br>
visibilityMode = public | anonymous | instructors

### How Instructor-Only Posts Work
Instructor-only posts are restricted at the read level.<br>
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<br>
./nodebb build<br>
./nodebb restart<br>
Run full test suite:<br>
npm test<br>

### 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

15 changes: 15 additions & 0 deletions public/openapi/components/schemas/PostObject.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -173,6 +182,12 @@ PostDataObject:
anonymous:
type: string
default: 'false'
visibilityMode:
type: string
enum:
- public
- anonymous
- instructors
timestamp:
type: number
votes:
Expand Down
6 changes: 6 additions & 0 deletions public/openapi/components/schemas/TopicObject.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,12 @@ TopicObjectSlim:
type: number
titleRaw:
type: string
visibilityMode:
type: string
enum:
- public
- anonymous
- instructors
locked:
type: number
pinned:
Expand Down
30 changes: 29 additions & 1 deletion public/src/modules/quickreply.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -60,14 +68,23 @@ 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 = {
tid: ajaxify.data.tid,
handle: undefined,
content: replyMsg,
anonymous: anonymous,
visibilityMode: visibilityMode,
};
const replyLen = replyMsg.length;
if (replyLen < parseInt(config.minimumPostLength, 10)) {
Expand All @@ -86,6 +103,7 @@ define('quickreply', [
}
if (data && data.queued) {
data.anonymous = anonymous;
data.visibilityMode = visibilityMode;
alerts.alert({
type: 'success',
title: '[[global:alert.success]]',
Expand All @@ -98,6 +116,7 @@ define('quickreply', [
});
}
data.anonymous = anonymous;
data.visibilityMode = visibilityMode;
element.val('');
storage.removeItem(qrDraftId);
QuickReply._autocomplete.hide();
Expand All @@ -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('');
});
Expand Down
40 changes: 33 additions & 7 deletions src/api/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
}

Expand All @@ -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;
Expand All @@ -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];
};
Expand All @@ -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;
Expand Down Expand Up @@ -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');
Expand Down
1 change: 1 addition & 0 deletions src/api/topics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/controllers/composer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
7 changes: 5 additions & 2 deletions src/controllers/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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);
}

Expand Down
6 changes: 6 additions & 0 deletions src/controllers/topics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
4 changes: 3 additions & 1 deletion src/posts/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
Expand Down
Loading