From 512b1e7296b8f80853eb4c0a2feeceb2b1f8021e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Tue, 20 Jan 2026 11:17:30 -0500 Subject: [PATCH 1/7] fix: remove lowercase bidi controls as well --- public/src/utils.common.js | 2 +- test/utils.js | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/public/src/utils.common.js b/public/src/utils.common.js index 873292d22e..a35c6a2ea0 100644 --- a/public/src/utils.common.js +++ b/public/src/utils.common.js @@ -301,7 +301,7 @@ const utils = { return String(str).replace(new RegExp('<(\\/)?(' + (pattern || '[^\\s>]+') + ')(\\s+[^<>]*?)?\\s*(\\/)?>', 'gi'), ''); }, stripBidiControls: function (input) { - return input.replace(/[\u202A-\u202E\u2066-\u2069]/g, ''); + return input.replace(/[\u202A-\u202E\u2066-\u2069]/gi, ''); }, cleanUpTag: function (tag, maxLength) { if (typeof tag !== 'string' || !tag.length) { diff --git a/test/utils.js b/test/utils.js index 2e0ce72e8a..cd5ec12a4b 100644 --- a/test/utils.js +++ b/test/utils.js @@ -51,6 +51,12 @@ describe('Utility Methods', () => { assert.strictEqual(out, 'Hello World Dwellers'); }); + it('should remove common bidi embedding and override controls if they are lowercase', () => { + const input = '\u202aHello\u202c \u202bWorld\u202c \u202dDwellers\u202e'; + const out = utils.stripBidiControls(input); + assert.strictEqual(out, 'Hello World Dwellers'); + }); + it('should remove bidirectional isolate formatting characters', () => { const input = '\u2066abc\u2067def\u2068ghi\u2069'; const out = utils.stripBidiControls(input); From 2ba8907ac873c6cdd1b5bbac9272cd1fbea0c7da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Tue, 20 Jan 2026 11:57:22 -0500 Subject: [PATCH 2/7] refactor: tags were moved into topic hash a while ago --- src/topics/delete.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/topics/delete.js b/src/topics/delete.js index 03e756e1fd..9a6110fffc 100644 --- a/src/topics/delete.js +++ b/src/topics/delete.js @@ -73,14 +73,11 @@ module.exports = function (Topics) { }; Topics.purge = async function (tid, uid) { - const [deletedTopic, tags] = await Promise.all([ - Topics.getTopicData(tid), - Topics.getTopicTags(tid), - ]); + const deletedTopic = await Topics.getTopicData(tid); if (!deletedTopic) { return; } - deletedTopic.tags = tags; + deletedTopic.tags = deletedTopic.tags.map(tag => tag.value); await deleteFromFollowersIgnorers(tid); await Promise.all([ From 6b3ec63621d28de43b2797f2142748107dbb443b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Tue, 20 Jan 2026 12:05:52 -0500 Subject: [PATCH 3/7] refactor: add guards against bad data & infi loops --- src/categories/delete.js | 2 ++ src/events.js | 1 + src/user/delete.js | 8 +++++--- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/categories/delete.js b/src/categories/delete.js index c129cddbd2..243b310106 100644 --- a/src/categories/delete.js +++ b/src/categories/delete.js @@ -17,12 +17,14 @@ module.exports = function (Categories) { await async.eachLimit(tids, 10, async (tid) => { await topics.purgePostsAndTopic(tid, uid); }); + await db.sortedSetRemove(`cid:${cid}:tids`, tids); }, { alwaysStartAt: 0 }); const pinnedTids = await db.getSortedSetRevRange(`cid:${cid}:tids:pinned`, 0, -1); await async.eachLimit(pinnedTids, 10, async (tid) => { await topics.purgePostsAndTopic(tid, uid); }); + await db.sortedSetRemove(`cid:${cid}:tids:pinned`, pinnedTids); const categoryData = await Categories.getCategoryData(cid); await purgeCategory(cid, categoryData); plugins.hooks.fire('action:category.delete', { cid: cid, uid: uid, category: categoryData }); diff --git a/src/events.js b/src/events.js index 6a293c1bdd..6bd65c1b21 100644 --- a/src/events.js +++ b/src/events.js @@ -261,6 +261,7 @@ events.deleteEvents = async function (eids) { events.deleteAll = async function () { await batch.processSortedSet('events:time', async (eids) => { await events.deleteEvents(eids); + await db.sortedSetRemove('events:time', eids); }, { alwaysStartAt: 0, batch: 500 }); }; diff --git a/src/user/delete.js b/src/user/delete.js index 8b084b184b..65d8ccea65 100644 --- a/src/user/delete.js +++ b/src/user/delete.js @@ -43,15 +43,17 @@ module.exports = function (User) { async function deletePosts(callerUid, uid) { await batch.processSortedSet(`uid:${uid}:posts`, async (pids) => { await posts.purge(pids, callerUid); + await db.sortedSetRemove(`uid:${uid}:posts`, pids); }, { alwaysStartAt: 0, batch: 500 }); } async function deleteTopics(callerUid, uid) { - await batch.processSortedSet(`uid:${uid}:topics`, async (ids) => { - await async.eachSeries(ids, async (tid) => { + await batch.processSortedSet(`uid:${uid}:topics`, async (tids) => { + await async.eachSeries(tids, async (tid) => { await topics.purge(tid, callerUid); }); - }, { alwaysStartAt: 0 }); + await db.sortedSetRemove(`uid:${uid}:topics`, tids); + }, { alwaysStartAt: 0, batch: 100 }); } async function deleteUploads(callerUid, uid) { From 50c26dd5838401ae26b953ad596d5e4a1e63ba05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Tue, 20 Jan 2026 17:57:55 -0500 Subject: [PATCH 4/7] fix: closes #11499 --- src/search.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/search.js b/src/search.js index baf4d3c340..b8909b1b41 100644 --- a/src/search.js +++ b/src/search.js @@ -376,9 +376,9 @@ function sortPosts(posts, data) { } else { posts.sort((p1, p2) => { if (p1[fields[0]][fields[1]] > p2[fields[0]][fields[1]]) { - return direction; - } else if (p1[fields[0]][fields[1]] < p2[fields[0]][fields[1]]) { return -direction; + } else if (p1[fields[0]][fields[1]] < p2[fields[0]][fields[1]]) { + return direction; } return 0; }); From 07d2c9463ec27762cefde214baf1232d1fdfd9cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Tue, 20 Jan 2026 18:42:31 -0500 Subject: [PATCH 5/7] fix: remove bidi chars from displayname fixes chat teasers and probably every other place where display name is shown --- src/user/data.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/user/data.js b/src/user/data.js index cd2326281d..0620e159a2 100644 --- a/src/user/data.js +++ b/src/user/data.js @@ -333,7 +333,7 @@ module.exports = function (User) { user.displayname = validator.escape(String( meta.config.showFullnameAsDisplayName && showfullname && user.fullname ? - user.fullname : + utils.stripBidiControls(user.fullname) : user.username )); } From ab39e7f8aeac7125fb6adee7aa4103445db79b78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Tue, 20 Jan 2026 19:02:43 -0500 Subject: [PATCH 6/7] refactor: move chat page events to a new file --- public/src/client/chats.js | 84 ++-------------------- public/src/client/chats/events.js | 114 ++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 77 deletions(-) create mode 100644 public/src/client/chats/events.js diff --git a/public/src/client/chats.js b/public/src/client/chats.js index 4181bd79cb..09fa289c0c 100644 --- a/public/src/client/chats.js +++ b/public/src/client/chats.js @@ -11,6 +11,7 @@ define('forum/chats', [ 'forum/chats/user-list', 'forum/chats/message-search', 'forum/chats/pinned-messages', + 'forum/chats/events', 'autocomplete', 'hooks', 'bootbox', @@ -21,15 +22,15 @@ define('forum/chats', [ ], function ( components, mousetrap, recentChats, create, manage, messages, userList, messageSearch, pinnedMessages, - autocomplete, hooks, bootbox, alerts, chatModule, api, - uploadHelpers + events, autocomplete, hooks, bootbox, alerts, chatModule, + api, uploadHelpers ) { const Chats = { initialised: false, activeAutocomplete: {}, + newMessage: false, }; - let newMessage = false; let chatNavWrapper = null; $(window).on('action:ajaxify.start', function () { @@ -676,88 +677,17 @@ define('forum/chats', [ Chats.addGlobalEventListeners = function () { $(window).on('mousemove keypress click', function () { - if (newMessage && ajaxify.data.roomId) { + if (Chats.newMessage && ajaxify.data.roomId) { api.del(`/chats/${ajaxify.data.roomId}/state`, {}); - newMessage = false; + Chats.newMessage = false; } }); }; Chats.addSocketListeners = function () { - socket.on('event:chats.receive', function (data) { - if (chatModule.isFromBlockedUser(data.fromUid)) { - return; - } - if (parseInt(data.roomId, 10) === parseInt(ajaxify.data.roomId, 10)) { - data.self = parseInt(app.user.uid, 10) === parseInt(data.fromUid, 10) ? 1 : 0; - if (!newMessage) { - newMessage = data.self === 0; - } - data.message.self = data.self; - data.message.timestamp = Math.min(Date.now(), data.message.timestamp); - data.message.timestampISO = utils.toISOString(data.message.timestamp); - messages.appendChatMessage($('[component="chat/message/content"]'), data.message); - - Chats.updateTeaser(data.roomId, { - content: utils.stripHTMLTags(utils.decodeHTMLEntities(data.message.content)), - user: data.message.fromUser, - timestampISO: data.message.timestampISO, - }); - } - }); - - socket.on('event:chats.public.unread', function (data) { - if ( - chatModule.isFromBlockedUser(data.fromUid) || - chatModule.isLookingAtRoom(data.roomId) || - app.user.uid === parseInt(data.fromUid, 10) - ) { - return; - } - Chats.markChatPageElUnread(data); - Chats.increasePublicRoomUnreadCount(chatNavWrapper.find('[data-roomid=' + data.roomId + ']')); - }); - - socket.on('event:user_status_change', function (data) { - app.updateUserStatus($('.chats-list [data-uid="' + data.uid + '"] [component="user/status"]'), data.status); - }); + events.init(); messages.addSocketListeners(); - - socket.on('event:chats.roomRename', function (data) { - const roomEl = components.get('chat/recent/room', data.roomId); - if (roomEl.length) { - const titleEl = roomEl.find('[component="chat/room/title"]'); - ajaxify.data.roomName = data.newName; - titleEl.translateText(data.newName ? data.newName : ajaxify.data.usernames); - } - const titleEl = $(`[component="chat/main-wrapper"][data-roomid="${data.roomId}"] [component="chat/header/title"]`); - if (titleEl.length) { - titleEl.html( - data.newName ? - ` ${data.newName}` : - ajaxify.data.chatWithMessage - ); - } - }); - - socket.on('event:chats.mark', ({ roomId, state }) => { - const roomEls = $(`[component="chat/recent"] [data-roomid="${roomId}"], [component="chat/list"] [data-roomid="${roomId}"], [component="chat/public"] [data-roomid="${roomId}"]`); - roomEls.each((idx, el) => { - const roomEl = $(el); - chatModule.markChatElUnread(roomEl, state === 1); - if (state === 0) { - Chats.updatePublicRoomUnreadCount(roomEl, 0); - } - }); - }); - - socket.on('event:chats.typing', async (data) => { - if (data.uid === app.user.uid || chatModule.isFromBlockedUser(data.uid)) { - return; - } - chatModule.updateTypingUserList($(`[component="chat/main-wrapper"][data-roomid="${data.roomId}"]`), data); - }); }; Chats.updateTeaser = async function (roomId, teaser) { diff --git a/public/src/client/chats/events.js b/public/src/client/chats/events.js new file mode 100644 index 0000000000..3a7cd3b528 --- /dev/null +++ b/public/src/client/chats/events.js @@ -0,0 +1,114 @@ + +'use strict'; + + +define('forum/chats/events', [ + 'forum/chats', + 'forum/chats/messages', + 'chat', + 'components', +], function (Chats, messages, chatModule, components) { + const Events = {}; + + const events = { + 'event:chats.receive': chatsReceive, + 'event:chats.public.unread': publicChatUnread, + 'event:user_status_change': onUserStatusChange, + 'event:chats.roomRename': onRoomRename, + 'event:chats.mark': markChatState, + 'event:chats.typing': onChatTyping, + }; + let chatNavWrapper = null; + + Events.init = function () { + chatNavWrapper = $('[component="chat/nav-wrapper"]'); + Events.removeListeners(); + for (const [eventName, handler] of Object.entries(events)) { + socket.on(eventName, handler); + } + }; + + Events.removeListeners = function () { + for (const [eventName, handler] of Object.entries(events)) { + socket.removeListener(eventName, handler); + } + }; + + function chatsReceive(data) { + if (chatModule.isFromBlockedUser(data.fromUid)) { + return; + } + if (parseInt(data.roomId, 10) === parseInt(ajaxify.data.roomId, 10)) { + data.self = parseInt(app.user.uid, 10) === parseInt(data.fromUid, 10) ? 1 : 0; + if (!Chats.newMessage) { + Chats.newMessage = data.self === 0; + } + data.message.self = data.self; + data.message.timestamp = Math.min(Date.now(), data.message.timestamp); + data.message.timestampISO = utils.toISOString(data.message.timestamp); + messages.appendChatMessage($('[component="chat/message/content"]'), data.message); + + Chats.updateTeaser(data.roomId, { + content: utils.stripHTMLTags(utils.decodeHTMLEntities(data.message.content)), + user: data.message.fromUser, + timestampISO: data.message.timestampISO, + }); + } + } + + function publicChatUnread(data) { + if ( + !ajaxify.data.template.chats || + chatModule.isFromBlockedUser(data.fromUid) || + chatModule.isLookingAtRoom(data.roomId) || + app.user.uid === parseInt(data.fromUid, 10) + ) { + return; + } + Chats.markChatPageElUnread(data); + Chats.increasePublicRoomUnreadCount(chatNavWrapper.find('[data-roomid=' + data.roomId + ']')); + } + + function onUserStatusChange(data) { + app.updateUserStatus( + $(`.chats-list [data-uid="${data.uid}"] [component="user/status"]`), data.status + ); + } + + function onRoomRename(data) { + const roomEl = components.get('chat/recent/room', data.roomId); + if (roomEl.length) { + const titleEl = roomEl.find('[component="chat/room/title"]'); + ajaxify.data.roomName = data.newName; + titleEl.translateText(data.newName ? data.newName : ajaxify.data.usernames); + } + const titleEl = $(`[component="chat/main-wrapper"][data-roomid="${data.roomId}"] [component="chat/header/title"]`); + if (titleEl.length) { + titleEl.html( + data.newName ? + ` ${data.newName}` : + ajaxify.data.chatWithMessage + ); + } + } + + function markChatState({ roomId, state }) { + const roomEls = $(`[component="chat/recent"] [data-roomid="${roomId}"], [component="chat/list"] [data-roomid="${roomId}"], [component="chat/public"] [data-roomid="${roomId}"]`); + roomEls.each((idx, el) => { + const roomEl = $(el); + chatModule.markChatElUnread(roomEl, state === 1); + if (state === 0) { + Chats.updatePublicRoomUnreadCount(roomEl, 0); + } + }); + } + + function onChatTyping(data) { + if (data.uid === app.user.uid || chatModule.isFromBlockedUser(data.uid)) { + return; + } + chatModule.updateTypingUserList($(`[component="chat/main-wrapper"][data-roomid="${data.roomId}"]`), data); + } + + return Events; +}); From fffe039f465c7133b87fe16f671b47c02afd4a7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Tue, 20 Jan 2026 19:09:32 -0500 Subject: [PATCH 7/7] refactor: remove chats.initialized, all events handlers are removed before being added --- public/src/client/chats.js | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/public/src/client/chats.js b/public/src/client/chats.js index 09fa289c0c..65f7cd6685 100644 --- a/public/src/client/chats.js +++ b/public/src/client/chats.js @@ -26,7 +26,6 @@ define('forum/chats', [ api, uploadHelpers ) { const Chats = { - initialised: false, activeAutocomplete: {}, newMessage: false, }; @@ -55,10 +54,9 @@ define('forum/chats', [ socket.emit('modules.chats.enterPublic', ajaxify.data.publicRooms.map(r => r.roomId)); const env = utils.findBootstrapEnvironment(); chatNavWrapper = $('[component="chat/nav-wrapper"]'); - if (!Chats.initialised) { - Chats.addSocketListeners(); - Chats.addGlobalEventListeners(); - } + + Chats.addSocketListeners(); + Chats.addGlobalEventListeners(); recentChats.init(); @@ -69,7 +67,6 @@ define('forum/chats', [ Chats.addHotkeys(); } - Chats.initialised = true; const chatContentEl = $('[component="chat/message/content"]'); messages.wrapImagesInLinks(chatContentEl); if (ajaxify.data.scrollToIndex) { @@ -676,14 +673,18 @@ define('forum/chats', [ }; Chats.addGlobalEventListeners = function () { - $(window).on('mousemove keypress click', function () { - if (Chats.newMessage && ajaxify.data.roomId) { - api.del(`/chats/${ajaxify.data.roomId}/state`, {}); - Chats.newMessage = false; - } - }); + $(window).off('mousemove keypress click', onUserInteraction) + .on('mousemove keypress click', onUserInteraction); }; + function onUserInteraction() { + if (Chats.newMessage && ajaxify.data.roomId) { + // mark current room read on user interaction + api.del(`/chats/${ajaxify.data.roomId}/state`, {}); + Chats.newMessage = false; + } + } + Chats.addSocketListeners = function () { events.init();