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 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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;