feat: chat allow/deny list, closes #13359

This commit is contained in:
Barış Soner Uşaklı
2025-04-25 11:54:11 -04:00
parent 7800016f2f
commit a5afad27e5
14 changed files with 200 additions and 26 deletions

View File

@@ -107,10 +107,10 @@
"nodebb-plugin-spam-be-gone": "2.3.1", "nodebb-plugin-spam-be-gone": "2.3.1",
"nodebb-plugin-web-push": "0.7.3", "nodebb-plugin-web-push": "0.7.3",
"nodebb-rewards-essentials": "1.0.1", "nodebb-rewards-essentials": "1.0.1",
"nodebb-theme-harmony": "2.1.9", "nodebb-theme-harmony": "2.1.10",
"nodebb-theme-lavender": "7.1.18", "nodebb-theme-lavender": "7.1.18",
"nodebb-theme-peace": "2.2.40", "nodebb-theme-peace": "2.2.40",
"nodebb-theme-persona": "14.1.7", "nodebb-theme-persona": "14.1.8",
"nodebb-widget-essentials": "7.0.36", "nodebb-widget-essentials": "7.0.36",
"nodemailer": "6.10.1", "nodemailer": "6.10.1",
"nprogress": "0.2.0", "nprogress": "0.2.0",

View File

@@ -154,6 +154,8 @@
"about-me-too-long": "Sorry, your about me cannot be longer than %1 character(s).", "about-me-too-long": "Sorry, your about me cannot be longer than %1 character(s).",
"cant-chat-with-yourself": "Δεν μπορείς να συνομιλήσεις με τον εαυτό σου!", "cant-chat-with-yourself": "Δεν μπορείς να συνομιλήσεις με τον εαυτό σου!",
"chat-restricted": "This user has restricted their chat messages. They must follow you before you can chat with them", "chat-restricted": "This user has restricted their chat messages. They must follow you before you can chat with them",
"chat-allow-list-user-already-added": "This user is already in your allow list",
"chat-deny-list-user-already-added": "This user is already in your deny list",
"chat-user-blocked": "You have been blocked by this user.", "chat-user-blocked": "You have been blocked by this user.",
"chat-disabled": "Chat system disabled", "chat-disabled": "Chat system disabled",
"too-many-messages": "You have sent too many messages, please wait awhile.", "too-many-messages": "You have sent too many messages, please wait awhile.",

View File

@@ -64,6 +64,7 @@
"show-email": "Show email", "show-email": "Show email",
"show-fullname": "Show fullname", "show-fullname": "Show fullname",
"restrict-chat": "Only allow chat messages from users I follow", "restrict-chat": "Only allow chat messages from users I follow",
"disable-incoming-chats": "Disable incoming chat messages",
"outgoing-new-tab": "Open outgoing links in new tab", "outgoing-new-tab": "Open outgoing links in new tab",
"topic-search": "Enable In-Topic Searching", "topic-search": "Enable In-Topic Searching",
"update-url-with-post-index": "Update url with post index while browsing topics", "update-url-with-post-index": "Update url with post index while browsing topics",

View File

@@ -111,6 +111,10 @@
"show-email": "Show My Email", "show-email": "Show My Email",
"show-fullname": "Show My Full Name", "show-fullname": "Show My Full Name",
"restrict-chats": "Only allow chat messages from users I follow", "restrict-chats": "Only allow chat messages from users I follow",
"disable-incoming-chats": "Disable incoming chat messages <a data-bs-toggle=\"tooltip\" href=\"#\" title=\"Admins and moderators can still send you messages\"><i class=\"fa-solid fa-circle-info\"></i></a>",
"chat-allow-list": "Allow chat messages from the following users",
"chat-deny-list": "Deny chat messages from the following users",
"chat-list-add-user": "Add user",
"digest-label": "Subscribe to Digest", "digest-label": "Subscribe to Digest",
"digest-description": "Subscribe to email updates for this forum (new notifications and topics) according to a set schedule", "digest-description": "Subscribe to email updates for this forum (new notifications and topics) according to a set schedule",
"digest-off": "Off", "digest-off": "Off",

View File

@@ -31,9 +31,25 @@ Settings:
followTopicsOnReply: followTopicsOnReply:
type: boolean type: boolean
description: Automatically be notified of new posts in a topic, when you reply to that topic description: Automatically be notified of new posts in a topic, when you reply to that topic
restrictChat: disableIncomingChats:
type: boolean type: boolean
description: Do not allow other users to start chats with you (or add you to other chat rooms) description: Do not allow other users to start chats with you (or add you to other chat rooms)
chatAllowList:
type: array
items:
type: string
description: List of uids that can start chats with you
chatDenyList:
type: array
items:
type: string
description: List of uids that are not allowed to start chats with you
chatAllowListUsers:
type: array
description: List of users that can start chats with you
chatDenyListUsers:
type: array
description: List of users that are not allowed to start chats with you
topicSearchEnabled: topicSearchEnabled:
type: boolean type: boolean
description: Enable keyword searching within topics description: Enable keyword searching within topics

View File

@@ -2,8 +2,8 @@
define('forum/account/settings', [ define('forum/account/settings', [
'forum/account/header', 'components', 'api', 'alerts', 'hooks', 'forum/account/header', 'components', 'api', 'alerts', 'hooks', 'autocomplete',
], function (header, components, api, alerts, hooks) { ], function (header, components, api, alerts, hooks, autocomplete) {
const AccountSettings = {}; const AccountSettings = {};
let savedSkin = ''; let savedSkin = '';
// If page skin is changed but not saved, switch the skin back // If page skin is changed but not saved, switch the skin back
@@ -45,6 +45,8 @@ define('forum/account/settings', [
toggleCustomRoute(); toggleCustomRoute();
components.get('user/sessions').find('.timeago').timeago(); components.get('user/sessions').find('.timeago').timeago();
handleChatAllowDenyList();
}; };
function loadSettings() { function loadSettings() {
@@ -53,6 +55,9 @@ define('forum/account/settings', [
$('.account').find('input, textarea, select').each(function (id, input) { $('.account').find('input, textarea, select').each(function (id, input) {
input = $(input); input = $(input);
const setting = input.attr('data-property'); const setting = input.attr('data-property');
if (!setting) {
return;
}
if (input.is('select')) { if (input.is('select')) {
settings[setting] = input.val(); settings[setting] = input.val();
return; return;
@@ -68,6 +73,13 @@ define('forum/account/settings', [
} }
}); });
const chatAllowList = $('[component="chat/allow/list/user"][data-uid]')
.map((i, el) => $(el).data('uid')).get();
const chatDenyList = $('[component="chat/deny/list/user"][data-uid]')
.map((i, el) => $(el).data('uid')).get();
settings.chatAllowList = JSON.stringify(chatAllowList);
settings.chatDenyList = JSON.stringify(chatDenyList);
return settings; return settings;
} }
@@ -159,5 +171,56 @@ define('forum/account/settings', [
reskin(skin); reskin(skin);
}; };
function handleChatAllowDenyList() {
autocomplete.user($('#chatAllowListAdd'), async function (ev, selected) {
const { user } = selected.item;
if (!user || String(user.uid) === String(app.user.uid)) {
return;
}
if ($(`[component="chat/allow/list/user"][data-uid="${user.uid}"]`).length) {
return alerts.error('[[error:chat-allow-list-user-already-added]]');
}
const html = await app.parseAndTranslate('account/settings', 'settings.chatAllowListUsers', {
settings: { chatAllowListUsers: [selected.item.user] },
});
$('[component="chat/allow/list"]').append(html);
$('#chatAllowListAdd').val('');
toggleNoUsersElement();
});
autocomplete.user($('#chatDenyListAdd'), async function (ev, selected) {
const { user } = selected.item;
if (!user || String(user.uid) === String(app.user.uid)) {
return;
}
if ($(`[component="chat/deny/list/user"][data-uid="${user.uid}"]`).length) {
return alerts.error('[[error:chat-deny-list-user-already-added]]');
}
const html = await app.parseAndTranslate('account/settings', 'settings.chatDenyListUsers', {
settings: { chatDenyListUsers: [selected.item.user] },
});
$('[component="chat/deny/list"]').append(html);
$('#chatDenyListAdd').val('');
toggleNoUsersElement();
});
$('[component="chat/allow/list"]').on('click', '[component="chat/allow/delete"]', function () {
$(this).parent().remove();
toggleNoUsersElement();
});
$('[component="chat/deny/list"]').on('click', '[component="chat/deny/delete"]', function () {
$(this).parent().remove();
toggleNoUsersElement();
});
function toggleNoUsersElement() {
$('[component="chat/allow/list/no-users"]').toggleClass('hidden', !!$('[component="chat/allow/list/user"]').length);
$('[component="chat/deny/list/no-users"]').toggleClass('hidden', !!$('[component="chat/deny/list/user"]').length);
}
}
return AccountSettings; return AccountSettings;
}); });

View File

@@ -55,6 +55,7 @@ define('autocomplete', [
slug: user.userslug, slug: user.userslug,
username: user.username, username: user.username,
userslug: user.userslug, userslug: user.userslug,
displayname: user.displayname,
picture: user.picture, picture: user.picture,
banned: user.banned, banned: user.banned,
'icon:text': user['icon:text'], 'icon:text': user['icon:text'],

View File

@@ -43,6 +43,7 @@ settingsController.get = async function (req, res, next) {
getNotificationSettings(userData), getNotificationSettings(userData),
getHomePageRoutes(userData), getHomePageRoutes(userData),
getSkinOptions(userData), getSkinOptions(userData),
getChatAllowDenyList(userData),
]); ]);
userData.customSettings = data.customSettings; userData.customSettings = data.customSettings;
@@ -254,3 +255,13 @@ async function getSkinOptions(userData) {
}); });
return bootswatchSkinOptions; return bootswatchSkinOptions;
} }
async function getChatAllowDenyList(userData) {
const [chatAllowListUsers, chatDenyListUsers] = await Promise.all([
user.getUsersFields(userData.settings.chatAllowList, ['uid', 'username', 'picture']),
user.getUsersFields(userData.settings.chatDenyList, ['uid', 'username', 'picture']),
]);
userData.settings.chatAllowListUsers = chatAllowListUsers;
userData.settings.chatDenyListUsers = chatDenyListUsers;
};

View File

@@ -358,20 +358,28 @@ Messaging.canMessageUser = async (uid, toUid) => {
throw new Error('[[error:no-privileges]]'); throw new Error('[[error:no-privileges]]');
} }
const [settings, isAdmin, isModerator, isFollowing, isBlocked] = await Promise.all([ const [settings, isAdmin, isModerator, isBlocked] = await Promise.all([
user.getSettings(toUid), user.getSettings(toUid),
user.isAdministrator(uid), user.isAdministrator(uid),
user.isModeratorOfAnyCategory(uid), user.isModeratorOfAnyCategory(uid),
user.isFollowing(toUid, uid),
user.blocks.is(uid, toUid), user.blocks.is(uid, toUid),
]); ]);
if (isBlocked) { if (isBlocked) {
throw new Error('[[error:chat-user-blocked]]'); throw new Error('[[error:chat-user-blocked]]');
} }
if (settings.restrictChat && !isAdmin && !isModerator && !isFollowing) { const isPrivileged = isAdmin || isModerator;
if (!isPrivileged) {
if (settings.disableIncomingChats) {
throw new Error('[[error:chat-restricted]]'); throw new Error('[[error:chat-restricted]]');
} }
if (settings.chatAllowList.length && !settings.chatAllowList.includes(String(uid))) {
throw new Error('[[error:chat-restricted]]');
}
if (settings.chatDenyList.length && settings.chatDenyList.includes(String(uid))) {
throw new Error('[[error:chat-restricted]]');
}
}
await plugins.hooks.fire('static:messaging.canMessageUser', { await plugins.hooks.fire('static:messaging.canMessageUser', {
uid: uid, uid: uid,

View File

@@ -0,0 +1,44 @@
'use strict';
const db = require('../../database');
const batch = require('../../batch');
module.exports = {
name: 'Set user chat allow list to the users following if they turned on restrict chat',
timestamp: Date.UTC(2025, 3, 25),
method: async function () {
const { progress } = this;
progress.total = await db.sortedSetCard('users:joindate');
await batch.processSortedSet('users:joindate', async (uids) => {
const keys = uids.map(uid => `user:${uid}:settings`);
const [userSettings, followingUids] = await Promise.all([
db.getObjects(keys),
db.getSortedSetsMembers(uids.map(uid => `following:${uid}`)),
]);
const bulkSet = [];
userSettings.forEach((settings, idx) => {
if (settings) {
const uid = uids[idx];
const followingUidsOfThisUser = followingUids[idx] || [];
if (parseInt(settings.restrictChat, 10) === 1 && followingUidsOfThisUser.length > 0) {
bulkSet.push([
`user:${uid}:settings`, { chatAllowList: JSON.stringify(followingUidsOfThisUser) },
]);
}
}
});
await db.setObjectBulk(bulkSet);
progress.incr(uids.length);
}, {
batch: 500,
});
},
};

View File

@@ -76,7 +76,7 @@ module.exports = function (User) {
settings.followTopicsOnCreate = parseInt(getSetting(settings, 'followTopicsOnCreate', 1), 10) === 1; settings.followTopicsOnCreate = parseInt(getSetting(settings, 'followTopicsOnCreate', 1), 10) === 1;
settings.followTopicsOnReply = parseInt(getSetting(settings, 'followTopicsOnReply', 0), 10) === 1; settings.followTopicsOnReply = parseInt(getSetting(settings, 'followTopicsOnReply', 0), 10) === 1;
settings.upvoteNotifFreq = getSetting(settings, 'upvoteNotifFreq', 'all'); settings.upvoteNotifFreq = getSetting(settings, 'upvoteNotifFreq', 'all');
settings.restrictChat = parseInt(getSetting(settings, 'restrictChat', 0), 10) === 1; settings.disableIncomingChats = parseInt(getSetting(settings, 'disableIncomingChats', 0), 10) === 1;
settings.topicSearchEnabled = parseInt(getSetting(settings, 'topicSearchEnabled', 0), 10) === 1; settings.topicSearchEnabled = parseInt(getSetting(settings, 'topicSearchEnabled', 0), 10) === 1;
settings.updateUrlWithPostIndex = parseInt(getSetting(settings, 'updateUrlWithPostIndex', 1), 10) === 1; settings.updateUrlWithPostIndex = parseInt(getSetting(settings, 'updateUrlWithPostIndex', 1), 10) === 1;
settings.bootswatchSkin = validator.escape(String(settings.bootswatchSkin || '')); settings.bootswatchSkin = validator.escape(String(settings.bootswatchSkin || ''));
@@ -89,9 +89,19 @@ module.exports = function (User) {
settings[notificationType] = getSetting(settings, notificationType, 'notification'); settings[notificationType] = getSetting(settings, notificationType, 'notification');
}); });
settings.chatAllowList = parseJSONSetting(settings.chatAllowList || '[]', []).map(String);
settings.chatDenyList = parseJSONSetting(settings.chatDenyList || '[]', []).map(String);
return settings; return settings;
} }
function parseJSONSetting(value, defaultValue) {
try {
return JSON.parse(value);
} catch (err) {
return defaultValue;
}
}
function getSetting(settings, key, defaultValue) { function getSetting(settings, key, defaultValue) {
if (settings[key] || settings[key] === 0) { if (settings[key] || settings[key] === 0) {
return settings[key]; return settings[key];
@@ -145,7 +155,7 @@ module.exports = function (User) {
acpLang: data.acpLang || meta.config.defaultLang, acpLang: data.acpLang || meta.config.defaultLang,
followTopicsOnCreate: data.followTopicsOnCreate, followTopicsOnCreate: data.followTopicsOnCreate,
followTopicsOnReply: data.followTopicsOnReply, followTopicsOnReply: data.followTopicsOnReply,
restrictChat: data.restrictChat, disableIncomingChats: data.disableIncomingChats,
topicSearchEnabled: data.topicSearchEnabled, topicSearchEnabled: data.topicSearchEnabled,
updateUrlWithPostIndex: data.updateUrlWithPostIndex, updateUrlWithPostIndex: data.updateUrlWithPostIndex,
homePageRoute: ((data.homePageRoute === 'custom' ? data.homePageCustom : data.homePageRoute) || '').replace(/^\//, ''), homePageRoute: ((data.homePageRoute === 'custom' ? data.homePageCustom : data.homePageRoute) || '').replace(/^\//, ''),
@@ -155,6 +165,8 @@ module.exports = function (User) {
categoryWatchState: data.categoryWatchState, categoryWatchState: data.categoryWatchState,
categoryTopicSort: data.categoryTopicSort, categoryTopicSort: data.categoryTopicSort,
topicPostSort: data.topicPostSort, topicPostSort: data.topicPostSort,
chatAllowList: data.chatAllowList,
chatDenyList: data.chatDenyList,
}; };
const notificationTypes = await notifications.getAllNotificationTypes(); const notificationTypes = await notifications.getAllNotificationTypes();
notificationTypes.forEach((notificationType) => { notificationTypes.forEach((notificationType) => {

View File

@@ -296,8 +296,8 @@
</div> </div>
<div class="form-check form-switch mb-3"> <div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="restrictChat" data-field="restrictChat"> <input class="form-check-input" type="checkbox" id="disableIncomingChats" data-field="disableIncomingChats">
<label for="restrictChat" class="form-check-label">[[admin/settings/user:restrict-chat]]</label> <label for="disableIncomingChats" class="form-check-label">[[admin/settings/user:disable-incoming-chats]]</label>
</div> </div>
<div class="form-check form-switch mb-3"> <div class="form-check form-switch mb-3">

View File

@@ -61,7 +61,7 @@ describe('Messaging Library', () => {
})); }));
await Groups.join('administrators', mocks.users.foo.uid); await Groups.join('administrators', mocks.users.foo.uid);
await User.setSetting(mocks.users.baz.uid, 'restrictChat', '1'); await User.setSetting(mocks.users.baz.uid, 'disableIncomingChats', '1');
({ jar: mocks.users.foo.jar, csrf_token: mocks.users.foo.csrf } = await helpers.loginUser('foo', 'barbar')); ({ jar: mocks.users.foo.jar, csrf_token: mocks.users.foo.csrf } = await helpers.loginUser('foo', 'barbar'));
({ jar: mocks.users.bar.jar, csrf_token: mocks.users.bar.csrf } = await helpers.loginUser('bar', 'bazbaz')); ({ jar: mocks.users.bar.jar, csrf_token: mocks.users.bar.csrf } = await helpers.loginUser('bar', 'bazbaz'));
@@ -85,7 +85,7 @@ describe('Messaging Library', () => {
}); });
it('should NOT allow messages to be sent to a restricted user', async () => { it('should NOT allow messages to be sent to a restricted user', async () => {
await User.setSetting(mocks.users.baz.uid, 'restrictChat', '1'); await User.setSetting(mocks.users.baz.uid, 'disableIncomingMessages', '1');
try { try {
await Messaging.canMessageUser(mocks.users.herp.uid, mocks.users.baz.uid); await Messaging.canMessageUser(mocks.users.herp.uid, mocks.users.baz.uid);
} catch (err) { } catch (err) {
@@ -100,13 +100,25 @@ describe('Messaging Library', () => {
}); });
}); });
it('should allow messages to be sent to a restricted user if restricted user follows sender', (done) => { it('should respect allow/deny list when sending chat messages', async () => {
User.follow(mocks.users.baz.uid, mocks.users.herp.uid, () => { const uid1 = await User.create({ username: 'allowdeny1', password: 'barbar' });
Messaging.canMessageUser(mocks.users.herp.uid, mocks.users.baz.uid, (err) => { const uid2 = await User.create({ username: 'allowdeny2', password: 'bazbaz' });
assert.ifError(err); const uid3 = await User.create({ username: 'allowdeny3', password: 'bazbaz' });
done(); await Messaging.canMessageUser(uid1, uid2);
});
}); // rejects uid1 only allows uid3 to chat
await User.setSetting(uid1, 'chatAllowList', JSON.stringify([uid3]));
await assert.rejects(
Messaging.canMessageUser(uid2, uid1),
{ message: '[[error:chat-restricted]]' },
);
// rejects uid2 denies chat from uid1
await User.setSetting(uid2, 'chatDenyList', JSON.stringify([uid1]));
await assert.rejects(
Messaging.canMessageUser(uid1, uid2),
{ message: '[[error:chat-restricted]]' },
);
}); });
it('should not allow messaging room if user is muted', async () => { it('should not allow messaging room if user is muted', async () => {
@@ -169,11 +181,11 @@ describe('Messaging Library', () => {
}); });
it('should create a new chat room', async () => { it('should create a new chat room', async () => {
await User.setSetting(mocks.users.baz.uid, 'restrictChat', '0'); await User.setSetting(mocks.users.baz.uid, 'disableIncomingMessages', '0');
const { body } = await callv3API('post', `/chats`, { const { body } = await callv3API('post', `/chats`, {
uids: [mocks.users.baz.uid], uids: [mocks.users.baz.uid],
}, 'foo'); }, 'foo');
await User.setSetting(mocks.users.baz.uid, 'restrictChat', '1'); await User.setSetting(mocks.users.baz.uid, 'disableIncomingMessages', '1');
roomId = body.response.roomId; roomId = body.response.roomId;
assert(roomId); assert(roomId);

View File

@@ -1629,7 +1629,7 @@ describe('User', () => {
postsPerPage: '5', postsPerPage: '5',
showemail: 1, showemail: 1,
showfullname: 1, showfullname: 1,
restrictChat: 0, disableIncomingMessages: 0,
followTopicsOnCreate: 1, followTopicsOnCreate: 1,
followTopicsOnReply: 1, followTopicsOnReply: 1,
}, },
@@ -1654,7 +1654,7 @@ describe('User', () => {
postsPerPage: '5', postsPerPage: '5',
showemail: 1, showemail: 1,
showfullname: 1, showfullname: 1,
restrictChat: 0, disableIncomingMessages: 0,
followTopicsOnCreate: 1, followTopicsOnCreate: 1,
followTopicsOnReply: 1, followTopicsOnReply: 1,
}, },