From d3f653e6489ccc3c3bd4e2761a9aac084dae67e6 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 20:15:54 -0500 Subject: [PATCH 01/12] fix: require --- public/src/client/chats/events.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/public/src/client/chats/events.js b/public/src/client/chats/events.js index 3a7cd3b528..4e14f941e1 100644 --- a/public/src/client/chats/events.js +++ b/public/src/client/chats/events.js @@ -3,11 +3,10 @@ define('forum/chats/events', [ - 'forum/chats', 'forum/chats/messages', 'chat', 'components', -], function (Chats, messages, chatModule, components) { +], function (messages, chatModule, components) { const Events = {}; const events = { @@ -20,7 +19,10 @@ define('forum/chats/events', [ }; let chatNavWrapper = null; - Events.init = function () { + let Chats = null; + + Events.init = async function () { + Chats = await app.require('forum/chats'); chatNavWrapper = $('[component="chat/nav-wrapper"]'); Events.removeListeners(); for (const [eventName, handler] of Object.entries(events)) { From d867d8adbb373f61c4ef502e85a7934674bb26c0 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 20:50:04 -0500 Subject: [PATCH 02/12] fix: closes #10682, strip unicode control chars that explode rss module --- src/routes/feeds.js | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/routes/feeds.js b/src/routes/feeds.js index 18a780b4ef..3d680fe85f 100644 --- a/src/routes/feeds.js +++ b/src/routes/feeds.js @@ -61,6 +61,11 @@ async function validateTokenIfRequiresLogin(requiresLogin, cid, req, res) { return true; } +function stripUnicodeControlChars(str) { + // eslint-disable-next-line no-control-regex + return str.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, ''); +} + async function generateForTopic(req, res, next) { if (meta.config['feeds:disableRSS']) { return next(); @@ -80,20 +85,21 @@ async function generateForTopic(req, res, next) { if (await validateTokenIfRequiresLogin(!userPrivileges['topics:read'], topic.cid, req, res)) { const topicData = await topics.getTopicWithPosts(topic, `tid:${tid}:posts`, req.uid || req.query.uid || 0, 0, 24, true); + const mainPost = topicData.posts[0]; topics.modifyPostsByPrivilege(topicData, userPrivileges); - + const title = stripUnicodeControlChars(topicData.title); const feed = new rss({ - title: utils.stripHTMLTags(topicData.title, utils.tags), - description: topicData.posts.length ? topicData.posts[0].content : '', + title: utils.stripHTMLTags(title, utils.tags), + description: topicData.posts.length ? stripUnicodeControlChars(mainPost.content) : '', feed_url: `${nconf.get('url')}/topic/${tid}.rss`, site_url: `${nconf.get('url')}/topic/${topicData.slug}`, - image_url: topicData.posts.length ? topicData.posts[0].picture : '', - author: topicData.posts.length ? topicData.posts[0].username : '', + image_url: topicData.posts.length ? mainPost.picture : '', + author: topicData.posts.length ? mainPost.username : '', ttl: 60, }); if (topicData.posts.length > 0) { - feed.pubDate = new Date(parseInt(topicData.posts[0].timestamp, 10)).toUTCString(); + feed.pubDate = new Date(parseInt(mainPost.timestamp, 10)).toUTCString(); } const replies = topicData.posts.slice(1); replies.forEach((postData) => { @@ -103,8 +109,8 @@ async function generateForTopic(req, res, next) { ).toUTCString(); feed.item({ - title: `Reply to ${utils.stripHTMLTags(topicData.title, utils.tags)} on ${dateStamp}`, - description: postData.content, + title: `Reply to ${utils.stripHTMLTags(title, utils.tags)} on ${dateStamp}`, + description: stripUnicodeControlChars(postData.content), url: `${nconf.get('url')}/post/${postData.pid}`, author: postData.user ? postData.user.username : '', date: dateStamp, From 7bc9fe3b754ca2e81f6fe1944b058d4bff9db36f 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 21:08:38 -0500 Subject: [PATCH 03/12] refactor: dont include scheduled topics in unread --- src/topics/unread.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/topics/unread.js b/src/topics/unread.js index ed93f19abf..afdb3fc155 100644 --- a/src/topics/unread.js +++ b/src/topics/unread.js @@ -141,8 +141,8 @@ module.exports = function (Topics) { }); tids = await privileges.topics.filterTids('topics:read', tids, params.uid); - const topicData = (await Topics.getTopicsFields(tids, ['tid', 'cid', 'uid', 'postcount', 'deleted', 'scheduled', 'tags'])) - .filter(t => t.scheduled || !t.deleted); + const topicData = (await Topics.getTopicsFields(tids, ['tid', 'cid', 'uid', 'postcount', 'deleted', 'tags'])) + .filter(t => !t.deleted); const topicCids = _.uniq(topicData.map(topic => topic.cid)).filter(Boolean); const categoryWatchState = await categories.getWatchState(topicCids, params.uid); From ec4e7ef1b7b02e533edbf3b15bea1afc19a92fe2 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 22:19:22 -0500 Subject: [PATCH 04/12] fix: closes #13199 normalize accept header @julianlam tried accepts module ran into issues when the route was requested via browser or via $.ajax with the default headers, for example accepts(req).type(activitypub._constants.acceptableTypes) still returns true when /post/123 is loaded via browser or via $.ajax --- src/activitypub/helpers.js | 9 +++++---- src/activitypub/index.js | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/activitypub/helpers.js b/src/activitypub/helpers.js index 80135808ab..a966d26ac0 100644 --- a/src/activitypub/helpers.js +++ b/src/activitypub/helpers.js @@ -65,10 +65,11 @@ Helpers.isUri = (value) => { }); }; -Helpers.assertAccept = accept => (accept && accept.split(',').some((value) => { - const parts = value.split(';').map(v => v.trim()); - return activitypub._constants.acceptableTypes.includes(value || parts[0]); -})); +Helpers.assertAccept = (accept) => { + if (!accept) return false; + const normalized = accept.split(',').map(s => s.trim().replace(/\s*;\s*/g, ';')).join(','); + return activitypub._constants.acceptableTypes.some(type => normalized.includes(type)); +}; Helpers.isWebfinger = (value) => { // N.B. returns normalized handle, so truthy check! diff --git a/src/activitypub/index.js b/src/activitypub/index.js index 92dd10b544..4ab6159f2f 100644 --- a/src/activitypub/index.js +++ b/src/activitypub/index.js @@ -41,7 +41,7 @@ ActivityPub._constants = Object.freeze({ acceptablePublicAddresses: ['https://www.w3.org/ns/activitystreams#Public', 'as:Public', 'Public'], acceptableTypes: [ 'application/activity+json', - 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + 'application/ld+json;profile="https://www.w3.org/ns/activitystreams"', ], acceptedPostTypes: [ 'Note', 'Page', 'Article', 'Question', 'Video', From 1b08aef2d0fc5ea1aefa81b7a62c38f8fdb8786a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Wed, 21 Jan 2026 18:31:58 -0500 Subject: [PATCH 05/12] fix: wrap fields in quotes in user csv export --- src/user/admin.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/user/admin.js b/src/user/admin.js index 35598bbbd9..484f1e6712 100644 --- a/src/user/admin.js +++ b/src/user/admin.js @@ -55,7 +55,8 @@ module.exports = function (User) { fields: fieldsToExport, showIps: fieldsToExport.includes('ip'), }); - + const customUserFields = await db.getSortedSetRange('user-custom-fields', 0, -1); + const fieldsToWrapInQuotes = ['fullname', 'signature', 'aboutme', ...customUserFields]; if (!showIps && fields.includes('ip')) { fields.splice(fields.indexOf('ip'), 1); } @@ -76,6 +77,11 @@ module.exports = function (User) { if (Array.isArray(userIps[index])) { user.ip = userIps[index].join(','); } + fieldsToWrapInQuotes.forEach((field) => { + if (user[field]) { + user[field] = `"${String(user[field])}"`; + } + }); }); const opts = { fields, header: false }; From 3272ea576f717ae166e9c9cca08458266319029c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Wed, 21 Jan 2026 18:42:31 -0500 Subject: [PATCH 06/12] fix missing await on appendFile --- src/user/admin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/user/admin.js b/src/user/admin.js index 484f1e6712..b629e46628 100644 --- a/src/user/admin.js +++ b/src/user/admin.js @@ -64,7 +64,7 @@ module.exports = function (User) { path.join(baseDir, 'build/export', 'users.csv'), 'w' ); - fs.promises.appendFile(fd, `${fields.map(f => `"${f}"`).join(',')}\n`); + await fs.promises.appendFile(fd, `${fields.map(f => `"${f}"`).join(',')}\n`); await batch.processSortedSet('users:joindate', async (uids) => { const userFieldsToLoad = fields.filter(field => field !== 'ip' && field !== 'password'); const usersData = await User.getUsersFields(uids, userFieldsToLoad); From f29c9f064b93ca7f7607419abcc2c82f2728d834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Thu, 22 Jan 2026 11:31:59 -0500 Subject: [PATCH 07/12] fix: closes #13258, dont mark digest as delivered if it fails show fail count and sent count separately --- src/user/digest.js | 41 ++++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/src/user/digest.js b/src/user/digest.js index 77bc2e93e5..ae71856cec 100644 --- a/src/user/digest.js +++ b/src/user/digest.js @@ -84,6 +84,7 @@ Digest.getSubscribers = async function (interval) { Digest.send = async function (data) { let emailsSent = 0; + let emailsFailed = 0; if (!data || !data.subscribers || !data.subscribers.length) { return emailsSent; } @@ -100,6 +101,7 @@ Digest.send = async function (data) { return; } const userSettings = await user.getMultipleUserSettings(userData.map(u => u.uid)); + const successfullUids = []; await Promise.all(userData.map(async (userObj, index) => { const userSetting = userSettings[index]; const [publicRooms, notifications, topics] = await Promise.all([ @@ -124,34 +126,39 @@ Digest.send = async function (data) { } }); - emailsSent += 1; - await emailer.send('digest', userObj.uid, { - subject: `[[email:digest.subject, ${date.toLocaleDateString(userSetting.userLang)}]]`, - username: userObj.username, - userslug: userObj.userslug, - notifications: unreadNotifs, - publicRooms: publicRooms, - recent: topics.recent, - topTopics: topics.top, - popularTopics: topics.popular, - interval: data.interval, - showUnsubscribe: true, - }).catch((err) => { + try { + await emailer.send('digest', userObj.uid, { + subject: `[[email:digest.subject, ${date.toLocaleDateString(userSetting.userLang)}]]`, + username: userObj.username, + userslug: userObj.userslug, + notifications: unreadNotifs, + publicRooms: publicRooms, + recent: topics.recent, + topTopics: topics.top, + popularTopics: topics.popular, + interval: data.interval, + showUnsubscribe: true, + }); + emailsSent += 1; + successfullUids.push(userObj.uid); + } catch (err) { + emailsFailed += 1; if (!errorLogged) { winston.error(`[user/jobs] Could not send digest email\n[emailer.send] ${err.stack}`); errorLogged = true; } - }); + } })); - if (data.interval !== 'alltime') { + + if (data.interval !== 'alltime' && successfullUids.length) { const now = Date.now(); - await db.sortedSetAdd('digest:delivery', userData.map(() => now), userData.map(u => u.uid)); + await db.sortedSetAdd('digest:delivery', successfullUids.map(() => now), successfullUids); } }, { interval: 1000, batch: 100, }); - winston.info(`[user/jobs] Digest (${data.interval}) sending completed. ${emailsSent} emails sent.`); + winston.info(`[user/jobs] Digest (${data.interval}) sending completed. ${emailsSent} emails sent. ${emailsFailed} failures.`); return emailsSent; }; From e231c010f6b9612e63fe039bbbfd2bdb0ce6e692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Thu, 22 Jan 2026 11:49:16 -0500 Subject: [PATCH 08/12] refactor: get rid of map, move parallel calls into promise.all --- src/user/digest.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/user/digest.js b/src/user/digest.js index ae71856cec..89301dde85 100644 --- a/src/user/digest.js +++ b/src/user/digest.js @@ -163,25 +163,25 @@ Digest.send = async function (data) { }; Digest.getDeliveryTimes = async (start, stop) => { - const count = await db.sortedSetCard('users:joindate'); - const uids = await user.getUidsFromSet('users:joindate', start, stop); + const [count, uids] = await Promise.all([ + db.sortedSetCard('users:joindate'), + user.getUidsFromSet('users:joindate', start, stop), + ]); if (!uids.length) { - return []; + return { users: [], count }; } - const [scores, settings] = await Promise.all([ + const [scores, settings, userData] = await Promise.all([ // Grab the last time a digest was successfully delivered to these uids db.sortedSetScores('digest:delivery', uids), // Get users' digest settings Digest.getUsersInterval(uids), + user.getUsersFields(uids, ['username', 'picture']), ]); - // Populate user data - let userData = await user.getUsersFields(uids, ['username', 'picture']); - userData = userData.map((user, idx) => { + userData.forEach((user, idx) => { user.lastDelivery = scores[idx] ? new Date(scores[idx]).toISOString() : '[[admin/manage/digest:null]]'; user.setting = settings[idx]; - return user; }); return { From d2e1629f58e05655915e4b428a4793205458bc1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Thu, 22 Jan 2026 17:07:09 -0500 Subject: [PATCH 09/12] chore: up themes --- install/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/install/package.json b/install/package.json index 029661e25e..5203b0b8d1 100644 --- a/install/package.json +++ b/install/package.json @@ -107,10 +107,10 @@ "nodebb-plugin-spam-be-gone": "2.3.2", "nodebb-plugin-web-push": "0.7.6", "nodebb-rewards-essentials": "1.0.2", - "nodebb-theme-harmony": "2.1.35", + "nodebb-theme-harmony": "2.1.36", "nodebb-theme-lavender": "7.1.19", "nodebb-theme-peace": "2.2.49", - "nodebb-theme-persona": "14.1.25", + "nodebb-theme-persona": "14.1.26", "nodebb-widget-essentials": "7.0.41", "nodemailer": "7.0.12", "nprogress": "0.2.0", From d25e7726a7d52f86acc0e12bb67a794f3b87ceab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Fri, 23 Jan 2026 12:31:09 -0500 Subject: [PATCH 10/12] fix: #13918, make arrayLimit configurable increase default to 50 cap at 100 --- install/package.json | 1 + src/webserver.js | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/install/package.json b/install/package.json index 5203b0b8d1..f37a7e1684 100644 --- a/install/package.json +++ b/install/package.json @@ -124,6 +124,7 @@ "pretty": "^2.0.0", "progress-webpack-plugin": "1.0.16", "prompt": "1.3.0", + "qs": "6.14.1", "redis": "5.10.0", "rimraf": "6.1.2", "rss": "1.2.2", diff --git a/src/webserver.js b/src/webserver.js index 18f57faa40..1aa7f2387d 100644 --- a/src/webserver.js +++ b/src/webserver.js @@ -137,6 +137,12 @@ function setupExpressApp(app) { app.set('view engine', 'tpl'); app.set('views', viewsDir); app.set('json spaces', global.env === 'development' ? 4 : 0); + + // https://github.com/NodeBB/NodeBB/issues/13918 + const qs = require('qs'); + app.set('query parser', str => qs.parse(str, { + arrayLimit: Math.max(100, nconf.get('queryParser:arrayLimit') || 50), + })); app.use(flash()); app.enable('view cache'); From 090b9f55bce880b437cc43ae1788c3675f33bcd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Fri, 23 Jan 2026 12:31:46 -0500 Subject: [PATCH 11/12] fix: use min --- src/webserver.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webserver.js b/src/webserver.js index 1aa7f2387d..c5429c7bc6 100644 --- a/src/webserver.js +++ b/src/webserver.js @@ -141,7 +141,7 @@ function setupExpressApp(app) { // https://github.com/NodeBB/NodeBB/issues/13918 const qs = require('qs'); app.set('query parser', str => qs.parse(str, { - arrayLimit: Math.max(100, nconf.get('queryParser:arrayLimit') || 50), + arrayLimit: Math.min(100, nconf.get('queryParser:arrayLimit') || 50), })); app.use(flash()); From b2c6fbeddb31a499046fc039a44b367a0dd5efc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Fri, 23 Jan 2026 12:34:59 -0500 Subject: [PATCH 12/12] fix: #13919 --- public/src/admin/settings.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/public/src/admin/settings.js b/public/src/admin/settings.js index f189718426..29ede2f502 100644 --- a/public/src/admin/settings.js +++ b/public/src/admin/settings.js @@ -25,10 +25,13 @@ define('admin/settings', [ }); const offset = mainHader.outerHeight(true); // https://stackoverflow.com/a/11814275/583363 - tocList.find('a').on('click', function (event) { - event.preventDefault(); + tocList.find('a').on('click', function () { const href = $(this).attr('href'); - $(href)[0].scrollIntoView(); + const $target = $(href); + if (!$target.length) { + return; + } + $target.get(0).scrollIntoView(true); window.location.hash = href; scrollBy(0, -offset); setTimeout(() => {