diff --git a/install/package.json b/install/package.json index 9dcf0b5b53..056f39cdb0 100644 --- a/install/package.json +++ b/install/package.json @@ -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", diff --git a/public/language/el/error.json b/public/language/el/error.json index bd0de100c1..9043105827 100644 --- a/public/language/el/error.json +++ b/public/language/el/error.json @@ -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.", diff --git a/public/language/en-GB/admin/settings/user.json b/public/language/en-GB/admin/settings/user.json index 4e43ab7be3..c8cc3c9c34 100644 --- a/public/language/en-GB/admin/settings/user.json +++ b/public/language/en-GB/admin/settings/user.json @@ -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", diff --git a/public/language/en-GB/user.json b/public/language/en-GB/user.json index 42039d997f..3e0fab1e63 100644 --- a/public/language/en-GB/user.json +++ b/public/language/en-GB/user.json @@ -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 ", + "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", diff --git a/public/openapi/components/schemas/SettingsObj.yaml b/public/openapi/components/schemas/SettingsObj.yaml index 2ccc8e161c..779d2e2fb4 100644 --- a/public/openapi/components/schemas/SettingsObj.yaml +++ b/public/openapi/components/schemas/SettingsObj.yaml @@ -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 diff --git a/public/src/client/account/settings.js b/public/src/client/account/settings.js index 0c079d5030..9445fcb340 100644 --- a/public/src/client/account/settings.js +++ b/public/src/client/account/settings.js @@ -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; }); diff --git a/public/src/modules/autocomplete.js b/public/src/modules/autocomplete.js index 13e4e56427..1a8532c207 100644 --- a/public/src/modules/autocomplete.js +++ b/public/src/modules/autocomplete.js @@ -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'], diff --git a/src/controllers/accounts/settings.js b/src/controllers/accounts/settings.js index a5ab46e3da..cc88056409 100644 --- a/src/controllers/accounts/settings.js +++ b/src/controllers/accounts/settings.js @@ -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; +}; diff --git a/src/messaging/index.js b/src/messaging/index.js index 0c8bd0eded..263fd53543 100644 --- a/src/messaging/index.js +++ b/src/messaging/index.js @@ -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', { diff --git a/src/upgrades/4.3.0/chat_allow_list.js b/src/upgrades/4.3.0/chat_allow_list.js new file mode 100644 index 0000000000..0136c527ef --- /dev/null +++ b/src/upgrades/4.3.0/chat_allow_list.js @@ -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, + }); + }, +}; diff --git a/src/user/settings.js b/src/user/settings.js index 5390f37580..48b9a8a491 100644 --- a/src/user/settings.js +++ b/src/user/settings.js @@ -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) => { diff --git a/src/views/admin/settings/user.tpl b/src/views/admin/settings/user.tpl index de23ba91d5..e37759a11b 100644 --- a/src/views/admin/settings/user.tpl +++ b/src/views/admin/settings/user.tpl @@ -296,8 +296,8 @@
- - + +
diff --git a/test/messaging.js b/test/messaging.js index 0dc7012fd3..4429fd6cd7 100644 --- a/test/messaging.js +++ b/test/messaging.js @@ -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); diff --git a/test/user.js b/test/user.js index dde35f5b18..3fb1592af7 100644 --- a/test/user.js +++ b/test/user.js @@ -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, },