From 08b348d6af21897e80376b386700bbc136d5be81 Mon Sep 17 00:00:00 2001 From: Misty Release Bot Date: Wed, 25 Mar 2026 15:03:19 +0000 Subject: [PATCH 01/21] chore: incrementing version number - v4.10.1 --- install/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install/package.json b/install/package.json index 47294dcd3e..42a5dda294 100644 --- a/install/package.json +++ b/install/package.json @@ -2,7 +2,7 @@ "name": "nodebb", "license": "GPL-3.0", "description": "NodeBB Forum", - "version": "4.10.0", + "version": "4.10.1", "homepage": "https://www.nodebb.org", "repository": { "type": "git", From afe8683ef4a6af647c32ce10f16e4bcbdc35a3da Mon Sep 17 00:00:00 2001 From: Misty Release Bot Date: Wed, 25 Mar 2026 15:03:20 +0000 Subject: [PATCH 02/21] chore: update changelog for v4.10.1 --- CHANGELOG.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a6be3f690..8058f2350d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,64 @@ +#### v4.10.1 (2026-03-25) + +##### Chores + +* incrementing version number - v4.10.0 (5b703104) +* update changelog for v4.10.0 (c480df9e) +* incrementing version number - v4.9.2 (e6846052) +* incrementing version number - v4.9.1 (72e44c86) +* incrementing version number - v4.9.0 (3fdd1bef) +* incrementing version number - v4.8.1 (713ae0c0) +* incrementing version number - v4.8.0 (3fac737a) +* incrementing version number - v4.7.2 (cd419d8a) +* incrementing version number - v4.7.1 (afb88805) +* incrementing version number - v4.7.0 (e82d40f8) +* incrementing version number - v4.6.3 (9fc5b0f3) +* incrementing version number - v4.6.2 (f98747db) +* incrementing version number - v4.6.1 (f47aa678) +* incrementing version number - v4.6.0 (ee395bc5) +* incrementing version number - v4.5.2 (ad2da639) +* incrementing version number - v4.5.1 (69f4b61f) +* incrementing version number - v4.5.0 (f05c5d06) +* incrementing version number - v4.4.6 (074043ad) +* incrementing version number - v4.4.5 (6f106923) +* incrementing version number - v4.4.4 (d323af44) +* incrementing version number - v4.4.3 (d354c2eb) +* incrementing version number - v4.4.2 (55c510ae) +* incrementing version number - v4.4.1 (5ae79b4e) +* incrementing version number - v4.4.0 (0a75eee3) +* incrementing version number - v4.3.2 (b92b5d80) +* incrementing version number - v4.3.1 (308e6b9f) +* incrementing version number - v4.3.0 (bff291db) +* incrementing version number - v4.2.2 (17fecc24) +* incrementing version number - v4.2.1 (852a270c) +* incrementing version number - v4.2.0 (87581958) +* incrementing version number - v4.1.1 (b2afbb16) +* incrementing version number - v4.1.0 (36c80850) +* incrementing version number - v4.0.6 (4a52fb2e) +* incrementing version number - v4.0.5 (1792a62b) +* incrementing version number - v4.0.4 (b1125cce) +* incrementing version number - v4.0.3 (2b65c735) +* incrementing version number - v4.0.2 (73fe5fcf) +* incrementing version number - v4.0.1 (a461b758) +* incrementing version number - v4.0.0 (c1eaee45) + +##### New Features + +* add email share (43e7f0ab) + +##### Bug Fixes + +* #14123, aria-hidden fixes (72f48fd9) +* #14121, use normalizedPath when uploading (a10471fc) +* key name (52e42685) +* #14108, reset filter on notif dropdown open (ad1433e1) +* #14116, don't return ban reason if login credentials are incorrect (9bcef6b5) +* share url for ap posts, fallback to window.location.href if pid doesnt exist (361134f9) + +##### Refactors + +* work with different line-clamp values (9b885162) + #### v4.10.0 (2026-03-19) ##### Chores From 82d380a38d6a5d8c625e82c938ad5392f32fd0b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Wed, 25 Mar 2026 12:51:44 -0400 Subject: [PATCH 03/21] fix: ./nodebb upgrade on windows --- src/cli/upgrade-plugins.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/cli/upgrade-plugins.js b/src/cli/upgrade-plugins.js index 92cc980fa2..4949ef619f 100644 --- a/src/cli/upgrade-plugins.js +++ b/src/cli/upgrade-plugins.js @@ -148,8 +148,11 @@ async function upgradePlugins(unattended = false) { if (['y', 'Y', 'yes', 'YES'].includes(result.upgrade)) { console.log('\nUpgrading packages...'); const args = packageManagerInstallArgs.concat(found.map(suggestObj => `${suggestObj.name}@${suggestObj.suggested}`)); - - cproc.execFileSync(packageManagerExecutable, args, { stdio: 'ignore' }); + const options = { stdio: 'ignore' }; + if (process.platform === 'win32') { + options.shell = true; + } + cproc.execFileSync(packageManagerExecutable, args, options); } else { console.log(`${chalk.yellow('Package upgrades skipped')}. Check for upgrades at any time by running "${chalk.green('./nodebb upgrade -p')}".`); } From 835723482e2edcd8bd34f1390fbe6598d784f830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Thu, 26 Mar 2026 11:50:55 -0400 Subject: [PATCH 04/21] feat: add unreadNids to /api/notifications --- public/openapi/read/notifications.yaml | 5 +++++ src/controllers/accounts/notifications.js | 2 ++ 2 files changed, 7 insertions(+) diff --git a/public/openapi/read/notifications.yaml b/public/openapi/read/notifications.yaml index 015b6fae33..8dd723aabf 100644 --- a/public/openapi/read/notifications.yaml +++ b/public/openapi/read/notifications.yaml @@ -71,6 +71,11 @@ get: type: boolean readClass: type: string + unreadNids: + type: array + description: An array of notification ids that are unread. + items: + type: string filters: $ref: ../components/schemas/NotificationFilters.yaml#/FiltersArray regularFilters: diff --git a/src/controllers/accounts/notifications.js b/src/controllers/accounts/notifications.js index 301851ca36..00b94f0acb 100644 --- a/src/controllers/accounts/notifications.js +++ b/src/controllers/accounts/notifications.js @@ -61,6 +61,7 @@ notificationsController.get = async function (req, res, next) { const data = await user.notifications.getAllWithCounts(req.uid, selectedFilter.filter); let notifications = await user.notifications.getNotifications(data.nids, req.uid); + const unreadNids = notifications.filter(n => n && n.nid && !n.read).map(n => n.nid); allFilters.forEach((filterData) => { if (filterData && filterData.filter) { filterData.count = data.counts[filterData.filter] || 0; @@ -72,6 +73,7 @@ notificationsController.get = async function (req, res, next) { res.render('notifications', { notifications: notifications, + unreadNids, pagination: pagination.create(page, pageCount, req.query), filters: allFilters, regularFilters: regularFilters, From 1a0c2a21c7ffceb2a0f7d316bbe662c69b63d84d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Thu, 26 Mar 2026 12:43:21 -0400 Subject: [PATCH 05/21] fix: align-center user and name on post queue --- src/views/post-queue.tpl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/views/post-queue.tpl b/src/views/post-queue.tpl index 7366500f6e..569bfb68e3 100644 --- a/src/views/post-queue.tpl +++ b/src/views/post-queue.tpl @@ -100,9 +100,9 @@ {{{ end }}} -
+
{{{ if posts.user.userslug}}} - {buildAvatar(posts.user, "24px", true, "not-responsive")} {posts.user.username} + {buildAvatar(posts.user, "24px", true, "not-responsive")} {posts.user.username} {{{ else }}} {posts.user.username} {{{ end }}} From 4b503db49701e0f815e5c751435e3c2142fca5ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Fri, 27 Mar 2026 16:44:10 -0400 Subject: [PATCH 06/21] refactor: break long line --- src/database/mongo/sorted/add.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/database/mongo/sorted/add.js b/src/database/mongo/sorted/add.js index bc3a8bc8ec..82539ee8c8 100644 --- a/src/database/mongo/sorted/add.js +++ b/src/database/mongo/sorted/add.js @@ -83,7 +83,9 @@ module.exports = function (module) { if (!utils.isNumber(item[1])) { throw new Error(`[[error:invalid-score, ${item[1]}]]`); } - bulk.find({ _key: item[0], value: String(item[2]) }).upsert().updateOne({ $set: { score: parseFloat(item[1]) } }); + bulk.find({ _key: item[0], value: String(item[2]) }) + .upsert() + .updateOne({ $set: { score: parseFloat(item[1]) } }); }); await bulk.execute(); }; From 6c4e9284822e37b5f77100705a8441e09b1854a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Fri, 27 Mar 2026 16:45:20 -0400 Subject: [PATCH 07/21] fix: on exit, dont write analytics data on all nodes if you are running 4 nodebbs each one was calling writeData which could trigger duplicate key errors --- src/analytics.js | 14 +++++++++----- src/start.js | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/analytics.js b/src/analytics.js index e054e2e733..f64c651687 100644 --- a/src/analytics.js +++ b/src/analytics.js @@ -38,11 +38,7 @@ Analytics.init = async function () { runOnAllNodes: true, onTick: async () => { if (Analytics.pause) return; - publishLocalAnalytics(); - if (runJobs) { - await sleep(2000); - await Analytics.writeData(); - } + await Analytics.writeLocalData(); }, }); @@ -63,6 +59,14 @@ Analytics.init = async function () { } }; +Analytics.writeLocalData = async function () { + publishLocalAnalytics(); + if (runJobs) { + await sleep(2000); + await Analytics.writeData(); + } +}; + function publishLocalAnalytics() { pubsub.publish('analytics:publish', { local: local, diff --git a/src/start.js b/src/start.js index 00a129e33f..89a1683703 100644 --- a/src/start.js +++ b/src/start.js @@ -149,7 +149,7 @@ async function shutdown(code) { try { await require('./webserver').destroy(); winston.info('[app] Web server closed to connections.'); - await require('./analytics').writeData(); + await require('./analytics').writeLocalData(); winston.info('[app] Live analytics saved.'); const db = require('./database'); await db.delete('locks'); From b8fd88fba955db240dd3b8bd473ab9b9f19098aa Mon Sep 17 00:00:00 2001 From: Michele Di Maria Date: Sat, 28 Mar 2026 18:24:34 +0100 Subject: [PATCH 08/21] Fix the saving of the statistics on PosgreSQL #14124 (#14129) * fix: deduplicate postgres sorted set bulk ops to prevent pkey violation sortedSetIncrByBulk and sortedSetAddBulk did not deduplicate (key, value) pairs before INSERT, causing "duplicate key value violates unique constraint legacy_zset_pkey" errors since PostgreSQL ON CONFLICT only resolves against existing table rows, not within-statement duplicates. Also adds missing pageviews:ap metrics to analyticsKeys sorted set. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use upsert with RETURNING to prevent postgres analytics write failures Replace the INSERT ON CONFLICT DO NOTHING + separate SELECT verification pattern with INSERT ON CONFLICT DO UPDATE RETURNING. The old pattern had an unreliable gap between INSERT and SELECT causing random "failed to insert keys for objects" errors that blocked all analytics writes. The no-op upsert (DO UPDATE SET type = existing type) guarantees every row is returned via RETURNING, eliminating the need for a separate SELECT and the "missing keys" check entirely. Also deduplicates the keys array to prevent "cannot affect row a second time" errors with DO UPDATE. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/analytics.js | 6 ++++ src/database/postgres/helpers.js | 44 ++++++++--------------------- src/database/postgres/sorted.js | 21 +++++++++++--- src/database/postgres/sorted/add.js | 4 ++- 4 files changed, 38 insertions(+), 37 deletions(-) diff --git a/src/analytics.js b/src/analytics.js index f64c651687..e8e60c5e68 100644 --- a/src/analytics.js +++ b/src/analytics.js @@ -189,6 +189,12 @@ Analytics.writeData = async function () { incrByBulk.push(['analytics:pageviews:ap', total.apPageViews, today.getTime()]); incrByBulk.push(['analytics:pageviews:ap:month', total.apPageViews, month.getTime()]); total.apPageViews = 0; + if (!metrics.includes('pageviews:ap')) { + metrics.push('pageviews:ap'); + } + if (!metrics.includes('pageviews:ap:month')) { + metrics.push('pageviews:ap:month'); + } } if (total.uniquevisitors > 0) { diff --git a/src/database/postgres/helpers.js b/src/database/postgres/helpers.js index 85e0b63d07..8b92d3fe50 100644 --- a/src/database/postgres/helpers.js +++ b/src/database/postgres/helpers.js @@ -27,31 +27,25 @@ DELETE FROM "legacy_object" AND "expireAt" <= CURRENT_TIMESTAMP`, }); - await db.query({ - name: 'ensureLegacyObjectType1', + const res = await db.query({ + name: 'ensureLegacyObjectType_upsert', text: ` INSERT INTO "legacy_object" ("_key", "type") VALUES ($1::TEXT, $2::TEXT::LEGACY_OBJECT_TYPE) - ON CONFLICT - DO NOTHING`, + ON CONFLICT ("_key") + DO UPDATE SET "type" = "legacy_object"."type" + RETURNING "type"`, values: [key, type], }); - const res = await db.query({ - name: 'ensureLegacyObjectType2', - text: ` -SELECT "type" - FROM "legacy_object_live" - WHERE "_key" = $1::TEXT`, - values: [key], - }); - if (res.rows[0].type !== type) { throw new Error(`database: cannot insert ${JSON.stringify(key)} as ${type} because it already exists as ${res.rows[0].type}`); } }; helpers.ensureLegacyObjectsType = async function (db, keys, type) { + keys = [...new Set(keys)]; + await db.query({ name: 'ensureLegacyObjectTypeBefore', text: ` @@ -60,38 +54,24 @@ DELETE FROM "legacy_object" AND "expireAt" <= CURRENT_TIMESTAMP`, }); - await db.query({ - name: 'ensureLegacyObjectsType1', + const res = await db.query({ + name: 'ensureLegacyObjectsType_upsert', text: ` INSERT INTO "legacy_object" ("_key", "type") SELECT k, $2::TEXT::LEGACY_OBJECT_TYPE FROM UNNEST($1::TEXT[]) k - ON CONFLICT - DO NOTHING`, + ON CONFLICT ("_key") + DO UPDATE SET "type" = "legacy_object"."type" + RETURNING "_key", "type"`, values: [keys, type], }); - const res = await db.query({ - name: 'ensureLegacyObjectsType2', - text: ` -SELECT "_key", "type" - FROM "legacy_object_live" - WHERE "_key" = ANY($1::TEXT[])`, - values: [keys], - }); - const invalid = res.rows.filter(r => r.type !== type); if (invalid.length) { const parts = invalid.map(r => `${JSON.stringify(r._key)} is ${r.type}`); throw new Error(`database: cannot insert multiple objects as ${type} because they already exist: ${parts.join(', ')}`); } - - const missing = keys.filter(k => !res.rows.some(r => r._key === k)); - - if (missing.length) { - throw new Error(`database: failed to insert keys for objects: ${JSON.stringify(missing)}`); - } }; helpers.noop = function () {}; diff --git a/src/database/postgres/sorted.js b/src/database/postgres/sorted.js index 351fe3e059..50581a6ed4 100644 --- a/src/database/postgres/sorted.js +++ b/src/database/postgres/sorted.js @@ -551,16 +551,29 @@ RETURNING "score" s`, return []; } + // Deduplicate by (key, value) pair, summing increments for duplicates + const seen = new Map(); + const deduped = []; + data.forEach(([key, increment, value]) => { + value = helpers.valueToString(value); + increment = parseFloat(increment); + const mapKey = `${key}\0${value}`; + if (seen.has(mapKey)) { + deduped[seen.get(mapKey)][1] += increment; + } else { + seen.set(mapKey, deduped.length); + deduped.push([key, increment, value]); + } + }); + return await module.transaction(async (client) => { - await helpers.ensureLegacyObjectsType(client, data.map(item => item[0]), 'zset'); + await helpers.ensureLegacyObjectsType(client, deduped.map(item => item[0]), 'zset'); const values = []; const queryParams = []; let paramIndex = 1; - data.forEach(([key, increment, value]) => { - value = helpers.valueToString(value); - increment = parseFloat(increment); + deduped.forEach(([key, increment, value]) => { values.push(key, value, increment); queryParams.push(`($${paramIndex}::TEXT, $${paramIndex + 1}::TEXT, $${paramIndex + 2}::NUMERIC)`); paramIndex += 3; diff --git a/src/database/postgres/sorted/add.js b/src/database/postgres/sorted/add.js index 6f87416089..3db65cfde9 100644 --- a/src/database/postgres/sorted/add.js +++ b/src/database/postgres/sorted/add.js @@ -114,8 +114,10 @@ INSERT INTO "legacy_zset" ("_key", "value", "score") } keys.push(item[0]); scores.push(item[1]); - values.push(item[2]); + values.push(helpers.valueToString(item[2])); }); + const compositeKeys = keys.map((k, i) => `${k}\0${values[i]}`); + helpers.removeDuplicateValues(compositeKeys, keys, values, scores); await module.transaction(async (client) => { await helpers.ensureLegacyObjectsType(client, keys, 'zset'); await client.query({ From b04976ed3553aac681d2462c3d67902dbdcd0c8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Sat, 28 Mar 2026 13:35:47 -0400 Subject: [PATCH 09/21] test: dont create users parallel --- test/socket.io.js | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/test/socket.io.js b/test/socket.io.js index 45ffb43dd8..209f8dc056 100644 --- a/test/socket.io.js +++ b/test/socket.io.js @@ -30,20 +30,14 @@ describe('socket.io', () => { let regularUid; before(async () => { - const data = await Promise.all([ - user.create({ username: 'admin', password: 'adminpwd' }), - user.create({ username: 'regular', password: 'regularpwd', email: 'regular@test.com' }, { emailVerification: 'verify' }), - categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - }), - ]); - adminUid = data[0]; + adminUid = await user.create({ username: 'admin', password: 'adminpwd' }); await groups.join('administrators', adminUid); + regularUid = await user.create({ username: 'regular', password: 'regularpwd', email: 'regular@test.com' }, { emailVerification: 'verify' }); + ({ cid } = await categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + })); - regularUid = data[1]; - - cid = data[2].cid; await topics.post({ uid: adminUid, cid: cid, From 991e9778130d5b114b9c7955296d7b71eb03c5cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Sat, 28 Mar 2026 14:32:04 -0400 Subject: [PATCH 10/21] fix: try upsert type if it fails --- src/database/postgres/helpers.js | 36 ++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/database/postgres/helpers.js b/src/database/postgres/helpers.js index 8b92d3fe50..35c41012de 100644 --- a/src/database/postgres/helpers.js +++ b/src/database/postgres/helpers.js @@ -27,14 +27,14 @@ DELETE FROM "legacy_object" AND "expireAt" <= CURRENT_TIMESTAMP`, }); - const res = await db.query({ + const res = await tryUpsert(db, { name: 'ensureLegacyObjectType_upsert', text: ` -INSERT INTO "legacy_object" ("_key", "type") -VALUES ($1::TEXT, $2::TEXT::LEGACY_OBJECT_TYPE) - ON CONFLICT ("_key") - DO UPDATE SET "type" = "legacy_object"."type" - RETURNING "type"`, + INSERT INTO "legacy_object" ("_key", "type") + VALUES ($1::TEXT, $2::TEXT::LEGACY_OBJECT_TYPE) + ON CONFLICT ("_key") + DO UPDATE SET "type" = "legacy_object"."type" + RETURNING "type"`, values: [key, type], }); @@ -54,15 +54,15 @@ DELETE FROM "legacy_object" AND "expireAt" <= CURRENT_TIMESTAMP`, }); - const res = await db.query({ + const res = await tryUpsert(db, { name: 'ensureLegacyObjectsType_upsert', text: ` INSERT INTO "legacy_object" ("_key", "type") SELECT k, $2::TEXT::LEGACY_OBJECT_TYPE - FROM UNNEST($1::TEXT[]) k - ON CONFLICT ("_key") - DO UPDATE SET "type" = "legacy_object"."type" - RETURNING "_key", "type"`, +FROM UNNEST($1::TEXT[]) k + ON CONFLICT ("_key") + DO UPDATE SET "type" = "legacy_object"."type" + RETURNING "_key", "type"`, values: [keys, type], }); @@ -74,4 +74,18 @@ SELECT k, $2::TEXT::LEGACY_OBJECT_TYPE } }; +async function tryUpsert(db, queryConfig) { + let res; + try { + res = await db.query(queryConfig); + } catch (err) { + if (err.code === '23505') { // retry if failed due to error: unique constraint + res = await db.query(queryConfig); + } else { + throw err; + } + } + return res; +} + helpers.noop = function () {}; From 203f4cc7ff412dd8df5b806bb7fd572cc55904ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Sat, 28 Mar 2026 15:09:03 -0400 Subject: [PATCH 11/21] fix: try a save point in retry --- src/database/postgres/helpers.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/database/postgres/helpers.js b/src/database/postgres/helpers.js index 35c41012de..e3df40bad5 100644 --- a/src/database/postgres/helpers.js +++ b/src/database/postgres/helpers.js @@ -45,7 +45,9 @@ DELETE FROM "legacy_object" helpers.ensureLegacyObjectsType = async function (db, keys, type) { keys = [...new Set(keys)]; - + if (!keys.length) { + return; + } await db.query({ name: 'ensureLegacyObjectTypeBefore', text: ` @@ -76,11 +78,18 @@ FROM UNNEST($1::TEXT[]) k async function tryUpsert(db, queryConfig) { let res; + const savepoint = `upsert_${Math.random().toString(36).substring(7)}`; try { + await db.query(`SAVEPOINT ${savepoint}`); res = await db.query(queryConfig); + await db.query(`RELEASE SAVEPOINT ${savepoint}`); } catch (err) { if (err.code === '23505') { // retry if failed due to error: unique constraint + // Roll back to the savepoint to prevent + // error: current transaction is aborted, commands ignored until end of transaction block + await db.query(`ROLLBACK TO SAVEPOINT ${savepoint}`); res = await db.query(queryConfig); + await db.query(`RELEASE SAVEPOINT ${savepoint}`); } else { throw err; } From af0e3d96898eb4ca51917f49d94bf894bac1a3fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Mon, 30 Mar 2026 09:45:07 -0400 Subject: [PATCH 12/21] fix: closes #14133, don't modify displayName for system groups added a helper to just modify it for front end --- install/package.json | 6 +++--- public/src/modules/helpers.common.js | 5 +++++ src/groups/data.js | 3 --- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/install/package.json b/install/package.json index 42a5dda294..01011ea150 100644 --- a/install/package.json +++ b/install/package.json @@ -108,10 +108,10 @@ "nodebb-plugin-spam-be-gone": "2.3.2", "nodebb-plugin-web-push": "0.7.7", "nodebb-rewards-essentials": "1.0.2", - "nodebb-theme-harmony": "2.2.62", + "nodebb-theme-harmony": "2.2.63", "nodebb-theme-lavender": "7.1.21", - "nodebb-theme-peace": "2.2.57", - "nodebb-theme-persona": "14.2.33", + "nodebb-theme-peace": "2.2.58", + "nodebb-theme-persona": "14.2.34", "nodebb-widget-essentials": "7.0.43", "nodemailer": "8.0.3", "nprogress": "0.2.0", diff --git a/public/src/modules/helpers.common.js b/public/src/modules/helpers.common.js index 4893cea2fd..94a2d25307 100644 --- a/public/src/modules/helpers.common.js +++ b/public/src/modules/helpers.common.js @@ -17,6 +17,7 @@ module.exports = function (utils, Benchpress, relative_path) { generateCategoryBackground, generateChildrenCategories, generateTopicClass, + generateGroupDisplayName, membershipBtn, spawnPrivilegeStates, localeToHTML, @@ -167,6 +168,10 @@ module.exports = function (utils, Benchpress, relative_path) { return fields.filter(field => !!topic[field]).join(' '); } + function generateGroupDisplayName(group) { + return group.system ? group.displayName.replace(/-/g, ' ') : group.displayName; + } + // Groups helpers function membershipBtn(groupObj, btnClass = '') { if (groupObj.isMember && groupObj.name !== 'administrators') { diff --git a/src/groups/data.js b/src/groups/data.js index bef4d82fec..e585b653b5 100644 --- a/src/groups/data.js +++ b/src/groups/data.js @@ -133,9 +133,6 @@ module.exports = function (Groups) { if (hasField('name')) { group.nameEncoded = encodeURIComponent(group.name); group.displayName = validator.escape(String(group.name)); - if (Groups.systemGroups.includes(group.name)) { - group.displayName = group.displayName.replace(/-/g, ' '); - } } if (hasField('description')) { group.description = validator.escape(String(group.description || '')); From fb48ab34550906bf4020baa8cee806d13a6602bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Mon, 30 Mar 2026 19:42:58 -0400 Subject: [PATCH 13/21] fix: user image og:image --- src/controllers/topics.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/controllers/topics.js b/src/controllers/topics.js index 23a03d87e5..29167fcfb9 100644 --- a/src/controllers/topics.js +++ b/src/controllers/topics.js @@ -20,6 +20,7 @@ const activitypub = require('../activitypub'); const topicsController = module.exports; const url = nconf.get('url'); +const base_url = nconf.get('base_url'); const relative_path = nconf.get('relative_path'); const upload_url = nconf.get('upload_url'); const validSorts = ['oldest_to_newest', 'newest_to_oldest', 'most_votes']; @@ -356,8 +357,13 @@ function addOGImageTag(res, image) { } if (!imageUrl.startsWith('http')) { - // (https://domain.com/forum) + (/assets/uploads) + (/files/imagePath) - imageUrl = url + path.posix.join(upload_url, imageUrl); + if (imageUrl.startsWith(`${relative_path}${upload_url}`)) { + // (https://domain.com) + imageUrl (which starts with /relative_path/upload_url) + imageUrl = base_url + imageUrl; + } else { + // (https://domain.com/forum) + (/assets/uploads) + (/files/imagePath) + imageUrl = url + path.posix.join(upload_url, imageUrl); + } } res.locals.metaTags.push({ From f66317b7a729cecf3944898375f968ac347cba0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Fri, 3 Apr 2026 12:03:46 -0400 Subject: [PATCH 14/21] add extension to name, when upload profile pictures --- src/user/picture.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/user/picture.js b/src/user/picture.js index fe8a99c9a8..c60c95b721 100644 --- a/src/user/picture.js +++ b/src/user/picture.js @@ -144,7 +144,7 @@ module.exports = function (User) { const uploadedImage = await image.uploadImage(filename, `profile/uid-${updateUid}`, { uid: updateUid, path: normalizedPath, - name: 'profileAvatar', + name: `profileAvatar${extension}`, }); await User.updateProfile(callerUid, { From 072b1e864dfe649f75dae311e1f3ea45aef966a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Fri, 3 Apr 2026 17:01:48 -0400 Subject: [PATCH 15/21] dont escape viewport meta tag --- src/meta/tags.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/meta/tags.js b/src/meta/tags.js index c68fe95327..bd5a5b613a 100644 --- a/src/meta/tags.js +++ b/src/meta/tags.js @@ -21,6 +21,7 @@ Tags.parse = async (req, data, meta, link) => { name: 'viewport', // https://stackoverflow.com/a/77815388 for resizes-content content: 'width=device-width, initial-scale=1.0, interactive-widget=resizes-content', + noEscape: true, }, { name: 'content-type', content: 'text/html; charset=UTF-8', From 0568ef4310d4547e5ca239928f2130c33cc9d612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Fri, 3 Apr 2026 21:54:05 -0400 Subject: [PATCH 16/21] fix: #14147, dont create wrong backlinks syncBacklinks expects raw post content, html was passed to it from onNewPost since it was switched to use getPostSummary backlinkRegex was matching ${nconf.get('url')}/topic/1aef954c-d0dc-45cf-acf2-e3a59f6cc134/foo and return tid=1 instead of the uuid remove useless if check in syncBacklinks fixed parseInts on tids current - remove are both arrays use .length --- src/topics/create.js | 4 ++-- src/topics/posts.js | 24 +++++++++++------------- test/posts.js | 23 +++++++++++++++-------- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/src/topics/create.js b/src/topics/create.js index c96dadbd4b..d2b709333c 100644 --- a/src/topics/create.js +++ b/src/topics/create.js @@ -259,14 +259,14 @@ module.exports = function (Topics) { return postData; }; - async function onNewPost({ pid, tid, uid: postOwner }, { uid, handle }) { + async function onNewPost({ pid, tid, content, uid: postOwner }, { uid, handle }) { const [[postData], [userInfo]] = await Promise.all([ posts.getPostSummaryByPids([pid], uid, { extraFields: ['attachments'] }), posts.getUserInfoForPosts([postOwner], uid), ]); await Promise.all([ Topics.addParentPosts([postData], uid), - Topics.syncBacklinks(postData), + Topics.syncBacklinks({ ...postData, content }), Topics.markAsRead([tid], uid), ]); if (utils.isNumber(postOwner) && postData.category.cid === -1) { diff --git a/src/topics/posts.js b/src/topics/posts.js index 535d53ff1d..6b4473f361 100644 --- a/src/topics/posts.js +++ b/src/topics/posts.js @@ -14,7 +14,7 @@ const plugins = require('../plugins'); const utils = require('../utils'); const privileges = require('../privileges'); -const backlinkRegex = new RegExp(`(?:${nconf.get('url').replace('/', '\\/')}|\b|\\s)\\/topic\\/(\\d+)(?:\\/\\w+)?`, 'g'); +const backlinkRegex = new RegExp(`(?:${nconf.get('url').replace('/', '\\/')}|\b|\\s)\\/topic\\/([a-fA-F0-9-]+)(?=\\/|$|\\s)?`, 'g'); module.exports = function (Topics) { Topics.onNewPostMade = async function (postData) { @@ -447,29 +447,27 @@ module.exports = function (Topics) { throw new Error('[[error:invalid-data]]'); } - - let { content } = postData; + let { pid, uid, content } = postData; // ignore lines that start with `>` content = (content || '').split('\n').filter(line => !line.trim().startsWith('>')).join('\n'); // Scan post content for topic links const matches = [...content.matchAll(backlinkRegex)]; - if (!matches) { - return 0; - } - const { pid, uid, tid } = postData; - let add = _.uniq(matches.map(match => match[1]).map(tid => parseInt(tid, 10))); + let add = _.uniq(matches.map(match => match[1])); - const now = Date.now(); - const topicsExist = await Topics.exists(add); - const current = (await db.getSortedSetMembers(`pid:${pid}:backlinks`)).map(tid => parseInt(tid, 10)); + const [topicsExist, current] = await Promise.all([ + Topics.exists(add), + db.getSortedSetMembers(`pid:${pid}:backlinks`), + ]); const remove = current.filter(tid => !add.includes(tid)); - add = add.filter((_tid, idx) => topicsExist[idx] && !current.includes(_tid) && tid !== _tid); + const postTid = String(postData.tid); + add = add.filter((_tid, idx) => topicsExist[idx] && !current.includes(_tid) && postTid !== _tid); // Remove old backlinks await db.sortedSetRemove(`pid:${pid}:backlinks`, remove); // Add new backlinks + const now = Date.now(); await db.sortedSetAdd(`pid:${pid}:backlinks`, add.map(() => now), add); await Promise.all(add.map(async (tid) => { await Topics.events.log(tid, { @@ -479,6 +477,6 @@ module.exports = function (Topics) { }); })); - return add.length + (current - remove); + return add.length + (current.length - remove.length); }; }; diff --git a/test/posts.js b/test/posts.js index 9ca3809fa8..83f4fca899 100644 --- a/test/posts.js +++ b/test/posts.js @@ -1152,12 +1152,10 @@ describe('Post\'s', () => { describe('.syncBacklinks()', () => { it('should error on invalid data', async () => { - try { - await topics.syncBacklinks(); - } catch (e) { - assert(e); - assert.strictEqual(e.message, '[[error:invalid-data]]'); - } + await assert.rejects( + topics.syncBacklinks(), + { message: '[[error:invalid-data]]' }, + ); }); it('should do nothing if the post does not contain a link to a topic', async () => { @@ -1192,9 +1190,7 @@ describe('Post\'s', () => { const backlinks = await db.getSortedSetMembers('pid:2:backlinks'); assert.strictEqual(count, 0); - assert(events); assert.strictEqual(events.length, 1); - assert(backlinks); assert.strictEqual(backlinks.length, 0); }); @@ -1219,6 +1215,17 @@ describe('Post\'s', () => { assert(backlinks); assert.strictEqual(backlinks.length, 0); }); + + it('should not create a wrong backlink to topic/1 with AP topic url', async () => { + const { postData } = await topics.post({ + uid: 1, + cid, + title: 'Topic backlink testing - topic 2', + content: `testing ${nconf.get('url')}/topic/1aef954c-d0dc-45cf-acf2-e3a59f6cc134/foo`, + }); + const backlinks = await db.getSortedSetMembers(`pid:${postData.pid}:backlinks`); + assert.strictEqual(backlinks.length, 0); + }); }); describe('integration tests', () => { From 20e751f0e85e14765fab4e17372d3d1ad5c2d013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Fri, 3 Apr 2026 22:04:59 -0400 Subject: [PATCH 17/21] fix: remove optional --- src/topics/posts.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/topics/posts.js b/src/topics/posts.js index 6b4473f361..1e8526f0a6 100644 --- a/src/topics/posts.js +++ b/src/topics/posts.js @@ -14,7 +14,7 @@ const plugins = require('../plugins'); const utils = require('../utils'); const privileges = require('../privileges'); -const backlinkRegex = new RegExp(`(?:${nconf.get('url').replace('/', '\\/')}|\b|\\s)\\/topic\\/([a-fA-F0-9-]+)(?=\\/|$|\\s)?`, 'g'); +const backlinkRegex = new RegExp(`(?:${nconf.get('url').replace('/', '\\/')}|\b|\\s)\\/topic\\/([a-fA-F0-9-]+)(?=\\/|$|\\s)`, 'g'); module.exports = function (Topics) { Topics.onNewPostMade = async function (postData) { From 62b65e69aba0fe523347b80c6d2b064b04d07f85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Mon, 6 Apr 2026 17:19:55 -0400 Subject: [PATCH 18/21] fix: closes #14151, handle null req.body --- src/controllers/authentication.js | 1 + test/authentication.js | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/controllers/authentication.js b/src/controllers/authentication.js index fab6b8d8cc..c388023840 100644 --- a/src/controllers/authentication.js +++ b/src/controllers/authentication.js @@ -221,6 +221,7 @@ authenticationController.login = async (req, res, next) => { } const loginWith = meta.config.allowLoginWith || 'username-email'; + req.body = req.body || {}; req.body.username = String(req.body.username).trim(); const errorHandler = res.locals.noScriptErrors || helpers.noScriptErrors; try { diff --git a/test/authentication.js b/test/authentication.js index b1fbf66b32..4fb9fcb5ad 100644 --- a/test/authentication.js +++ b/test/authentication.js @@ -284,6 +284,21 @@ describe('authentication', () => { assert.equal(response.status, 500); }); + it('should fail to login if body is missing', async () => { + const jar = request.jar(); + const csrf_token = await helpers.getCsrfToken(jar); + + const { response, body } = await request.post(`${nconf.get('url')}/login`, { + body: null, + jar: jar, + headers: { + 'x-csrf-token': csrf_token, + }, + }); + assert.equal(response.status, 403); + assert.strictEqual(body, '[[error:invalid-username-or-password]]'); + }); + it('should fail to login if user does not exist', async () => { const { response, body } = await helpers.loginUser('doesnotexist', 'nopassword'); assert.equal(response.statusCode, 403); From 4366bdd0d8808804e7eeab2ae8ff1eb4ddae3cef Mon Sep 17 00:00:00 2001 From: Dirk Plate Date: Tue, 7 Apr 2026 15:11:54 +0200 Subject: [PATCH 19/21] fix: use file.exists instead of try/catch to detect missing email logo (#14154) The previous ENOENT check in getLogoSize never worked because image.size passes the path directly to sharp, which throws a plain Error with message "Input file is missing" rather than a Node.js ENOENT error. This caused saving any admin settings to fail when brand:logo was set but the x50 file was missing (e.g. after a fresh deployment). --- src/meta/configs.js | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/meta/configs.js b/src/meta/configs.js index f686b3dd72..56071868c4 100644 --- a/src/meta/configs.js +++ b/src/meta/configs.js @@ -6,6 +6,7 @@ const path = require('path'); const winston = require('winston'); const db = require('../database'); +const file = require('../file'); const pubsub = require('../pubsub'); const Meta = require('./index'); const translator = require('../translator'); @@ -212,20 +213,17 @@ async function getLogoSize(data) { if (!data['brand:logo']) { return; } + const x50Path = path.join(nconf.get('upload_path'), 'system', 'site-logo-x50.png'); let size; - try { - size = await image.size(path.join(nconf.get('upload_path'), 'system', 'site-logo-x50.png')); - } catch (err) { - if (err.code === 'ENOENT') { - // For whatever reason the x50 logo wasn't generated, gracefully error out - winston.warn('[logo] The email-safe logo doesn\'t seem to have been created, please re-upload your site logo.'); - size = { - height: 0, - width: 0, - }; - } else { - throw err; - } + if (await file.exists(x50Path)) { + size = await image.size(x50Path); + } else { + // For whatever reason the x50 logo wasn't generated, gracefully error out + winston.warn('[logo] The email-safe logo doesn\'t seem to have been created, please re-upload your site logo.'); + size = { + height: 0, + width: 0, + }; } data['brand:emailLogo'] = nconf.get('url') + path.join(nconf.get('upload_url'), 'system', 'site-logo-x50.png'); data['brand:emailLogo:height'] = size.height; From 55290da01aa3e5e7f764f51a92fc3b43f4e83ba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Wed, 8 Apr 2026 11:19:13 -0400 Subject: [PATCH 20/21] refactor: use renderHeaderType instead of two variables add middleware.admin.buildHeaderAsync so it can be called manually with await middleware.admin.buildHeaderAsync(req, res) --- src/middleware/admin.js | 17 +++++++++++++---- src/middleware/header.js | 2 +- src/middleware/render.js | 10 +++++----- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/middleware/admin.js b/src/middleware/admin.js index 84d34dfbd0..4d7aa44047 100644 --- a/src/middleware/admin.js +++ b/src/middleware/admin.js @@ -17,14 +17,23 @@ const controllers = { const middleware = module.exports; middleware.buildHeader = helpers.try(async (req, res, next) => { - res.locals.renderAdminHeader = true; + await doBuildHeader(req, res); + next(); +}); + +middleware.buildHeaderAsync = async (req, res) => { + await doBuildHeader(req, res); +}; + +async function doBuildHeader(req, res) { + res.locals.renderHeaderType = 'admin'; + res.locals.isAPI = false; if (req.method === 'GET') { await require('./index').applyCSRFasync(req, res); } - + await plugins.hooks.fire('filter:middleware.buildAdminHeader', { req: req, locals: res.locals }); res.locals.config = await controllers.admin.loadConfig(req); - next(); -}); +} middleware.checkPrivileges = helpers.try(async (req, res, next) => { // Kick out guests, obviously diff --git a/src/middleware/header.js b/src/middleware/header.js index 383ef8e94e..b462a82695 100644 --- a/src/middleware/header.js +++ b/src/middleware/header.js @@ -19,7 +19,7 @@ middleware.buildHeaderAsync = async (req, res) => { }; async function doBuildHeader(req, res) { - res.locals.renderHeader = true; + res.locals.renderHeaderType = 'client'; res.locals.isAPI = false; if (req.method === 'GET') { await require('./index').applyCSRFasync(req, res); diff --git a/src/middleware/render.js b/src/middleware/render.js index f348236884..3cbac1f22a 100644 --- a/src/middleware/render.js +++ b/src/middleware/render.js @@ -139,9 +139,9 @@ module.exports = function (middleware) { } async function loadHeaderFooterData(req, res, options) { - if (res.locals.renderHeader) { + if (res.locals.renderHeaderType === 'client') { return await loadClientHeaderFooterData(req, res, options); - } else if (res.locals.renderAdminHeader) { + } else if (res.locals.renderHeaderType === 'admin') { return await loadAdminHeaderFooterData(req, res, options); } return null; @@ -382,13 +382,13 @@ module.exports = function (middleware) { async function renderHeaderFooter(method, req, res, options, headerFooterData) { let str = ''; - if (res.locals.renderHeader) { + if (res.locals.renderHeaderType === 'client') { if (method === 'renderHeader') { str = await renderHeader(req, res, options, headerFooterData); } else if (method === 'renderFooter') { str = await renderFooter(req, res, options, headerFooterData); } - } else if (res.locals.renderAdminHeader) { + } else if (res.locals.renderHeaderType === 'admin') { if (method === 'renderHeader') { str = await renderAdminHeader(req, res, options, headerFooterData); } else if (method === 'renderFooter') { @@ -400,7 +400,7 @@ module.exports = function (middleware) { function getLang(req, res) { let language = (res.locals.config && res.locals.config.userLang) || 'en-GB'; - if (res.locals.renderAdminHeader) { + if (res.locals.renderHeaderType === 'admin') { language = (res.locals.config && res.locals.config.acpLang) || 'en-GB'; } return req.query.lang ? validator.escape(String(req.query.lang)) : language; From 9592c1762bd5e6f4b492e7fd84297bfddae92898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Wed, 8 Apr 2026 11:24:19 -0400 Subject: [PATCH 21/21] dont set isAPI, it is done by middleware.prepareAPI --- src/middleware/admin.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/middleware/admin.js b/src/middleware/admin.js index 4d7aa44047..bf68c380c4 100644 --- a/src/middleware/admin.js +++ b/src/middleware/admin.js @@ -27,7 +27,6 @@ middleware.buildHeaderAsync = async (req, res) => { async function doBuildHeader(req, res) { res.locals.renderHeaderType = 'admin'; - res.locals.isAPI = false; if (req.method === 'GET') { await require('./index').applyCSRFasync(req, res); }