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-web-push": "0.7.3",
"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-peace": "2.2.40",
"nodebb-theme-persona": "14.1.7",
"nodebb-theme-persona": "14.1.8",
"nodebb-widget-essentials": "7.0.36",
"nodemailer": "6.10.1",
"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).",
"cant-chat-with-yourself": "Δεν μπορείς να συνομιλήσεις με τον εαυτό σου!",
"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-disabled": "Chat system disabled",
"too-many-messages": "You have sent too many messages, please wait awhile.",

View File

@@ -64,6 +64,7 @@
"show-email": "Show email",
"show-fullname": "Show fullname",
"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",
"topic-search": "Enable In-Topic Searching",
"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-fullname": "Show My Full Name",
"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-description": "Subscribe to email updates for this forum (new notifications and topics) according to a set schedule",
"digest-off": "Off",

View File

@@ -31,9 +31,25 @@ Settings:
followTopicsOnReply:
type: boolean
description: Automatically be notified of new posts in a topic, when you reply to that topic
restrictChat:
disableIncomingChats:
type: boolean
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:
type: boolean
description: Enable keyword searching within topics

View File

@@ -2,8 +2,8 @@
define('forum/account/settings', [
'forum/account/header', 'components', 'api', 'alerts', 'hooks',
], function (header, components, api, alerts, hooks) {
'forum/account/header', 'components', 'api', 'alerts', 'hooks', 'autocomplete',
], function (header, components, api, alerts, hooks, autocomplete) {
const AccountSettings = {};
let savedSkin = '';
// If page skin is changed but not saved, switch the skin back
@@ -45,6 +45,8 @@ define('forum/account/settings', [
toggleCustomRoute();
components.get('user/sessions').find('.timeago').timeago();
handleChatAllowDenyList();
};
function loadSettings() {
@@ -53,6 +55,9 @@ define('forum/account/settings', [
$('.account').find('input, textarea, select').each(function (id, input) {
input = $(input);
const setting = input.attr('data-property');
if (!setting) {
return;
}
if (input.is('select')) {
settings[setting] = input.val();
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;
}
@@ -159,5 +171,56 @@ define('forum/account/settings', [
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;
});

View File

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

View File

@@ -43,6 +43,7 @@ settingsController.get = async function (req, res, next) {
getNotificationSettings(userData),
getHomePageRoutes(userData),
getSkinOptions(userData),
getChatAllowDenyList(userData),
]);
userData.customSettings = data.customSettings;
@@ -254,3 +255,13 @@ async function getSkinOptions(userData) {
});
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]]');
}
const [settings, isAdmin, isModerator, isFollowing, isBlocked] = await Promise.all([
const [settings, isAdmin, isModerator, isBlocked] = await Promise.all([
user.getSettings(toUid),
user.isAdministrator(uid),
user.isModeratorOfAnyCategory(uid),
user.isFollowing(toUid, uid),
user.blocks.is(uid, toUid),
]);
if (isBlocked) {
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]]');
}
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', {
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.followTopicsOnReply = parseInt(getSetting(settings, 'followTopicsOnReply', 0), 10) === 1;
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.updateUrlWithPostIndex = parseInt(getSetting(settings, 'updateUrlWithPostIndex', 1), 10) === 1;
settings.bootswatchSkin = validator.escape(String(settings.bootswatchSkin || ''));
@@ -89,9 +89,19 @@ module.exports = function (User) {
settings[notificationType] = getSetting(settings, notificationType, 'notification');
});
settings.chatAllowList = parseJSONSetting(settings.chatAllowList || '[]', []).map(String);
settings.chatDenyList = parseJSONSetting(settings.chatDenyList || '[]', []).map(String);
return settings;
}
function parseJSONSetting(value, defaultValue) {
try {
return JSON.parse(value);
} catch (err) {
return defaultValue;
}
}
function getSetting(settings, key, defaultValue) {
if (settings[key] || settings[key] === 0) {
return settings[key];
@@ -145,7 +155,7 @@ module.exports = function (User) {
acpLang: data.acpLang || meta.config.defaultLang,
followTopicsOnCreate: data.followTopicsOnCreate,
followTopicsOnReply: data.followTopicsOnReply,
restrictChat: data.restrictChat,
disableIncomingChats: data.disableIncomingChats,
topicSearchEnabled: data.topicSearchEnabled,
updateUrlWithPostIndex: data.updateUrlWithPostIndex,
homePageRoute: ((data.homePageRoute === 'custom' ? data.homePageCustom : data.homePageRoute) || '').replace(/^\//, ''),
@@ -155,6 +165,8 @@ module.exports = function (User) {
categoryWatchState: data.categoryWatchState,
categoryTopicSort: data.categoryTopicSort,
topicPostSort: data.topicPostSort,
chatAllowList: data.chatAllowList,
chatDenyList: data.chatDenyList,
};
const notificationTypes = await notifications.getAllNotificationTypes();
notificationTypes.forEach((notificationType) => {

View File

@@ -296,8 +296,8 @@
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="restrictChat" data-field="restrictChat">
<label for="restrictChat" class="form-check-label">[[admin/settings/user:restrict-chat]]</label>
<input class="form-check-input" type="checkbox" id="disableIncomingChats" data-field="disableIncomingChats">
<label for="disableIncomingChats" class="form-check-label">[[admin/settings/user:disable-incoming-chats]]</label>
</div>
<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 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.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 () => {
await User.setSetting(mocks.users.baz.uid, 'restrictChat', '1');
await User.setSetting(mocks.users.baz.uid, 'disableIncomingMessages', '1');
try {
await Messaging.canMessageUser(mocks.users.herp.uid, mocks.users.baz.uid);
} 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) => {
User.follow(mocks.users.baz.uid, mocks.users.herp.uid, () => {
Messaging.canMessageUser(mocks.users.herp.uid, mocks.users.baz.uid, (err) => {
assert.ifError(err);
done();
});
});
it('should respect allow/deny list when sending chat messages', async () => {
const uid1 = await User.create({ username: 'allowdeny1', password: 'barbar' });
const uid2 = await User.create({ username: 'allowdeny2', password: 'bazbaz' });
const uid3 = await User.create({ username: 'allowdeny3', password: 'bazbaz' });
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 () => {
@@ -169,11 +181,11 @@ describe('Messaging Library', () => {
});
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`, {
uids: [mocks.users.baz.uid],
}, 'foo');
await User.setSetting(mocks.users.baz.uid, 'restrictChat', '1');
await User.setSetting(mocks.users.baz.uid, 'disableIncomingMessages', '1');
roomId = body.response.roomId;
assert(roomId);

View File

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