mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-10-26 16:46:12 +01:00
feat: chat allow/deny list, closes #13359
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -358,19 +358,27 @@ 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) {
|
||||
throw new Error('[[error:chat-restricted]]');
|
||||
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', {
|
||||
|
||||
44
src/upgrades/4.3.0/chat_allow_list.js
Normal file
44
src/upgrades/4.3.0/chat_allow_list.js
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user