From cacb2023e816eea2978cbd300ff299feac65f343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Mon, 9 Mar 2026 21:24:27 -0400 Subject: [PATCH 1/6] dont log uuids in 404 From a3821ff7f4c7e99a627962f1d87aff8f1e93ef2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Mon, 9 Mar 2026 21:29:36 -0400 Subject: [PATCH 2/6] handle extensions --- src/meta/errors.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/meta/errors.js b/src/meta/errors.js index b5128215ba..c936b6675f 100644 --- a/src/meta/errors.js +++ b/src/meta/errors.js @@ -72,7 +72,10 @@ Errors.log404 = function (route) { route = route.slice(0, 512).replace(/\/$/, ''); // remove trailing slashes const segments = route.split('/'); - const containsUUID = segments.some(segment => validator.isUUID(segment)); + const containsUUID = segments.some((segment) => { + const cleanSegment = segment.split('.')[0]; + return validator.isUUID(cleanSegment); + }); if (containsUUID) { return; } From a391d01d3042cdbcf2518aece57d8b3f2285a243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Tue, 10 Mar 2026 09:49:23 -0400 Subject: [PATCH 3/6] fix: closes #14073, fix teasers from child categories add a test --- src/categories/recentreplies.js | 9 +++++---- test/categories.js | 26 ++++++++++++++++++-------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/categories/recentreplies.js b/src/categories/recentreplies.js index d5f4c99764..8e21647c70 100644 --- a/src/categories/recentreplies.js +++ b/src/categories/recentreplies.js @@ -99,7 +99,7 @@ module.exports = function (Categories) { const [topicData, crossposts] = await Promise.all([ topics.getTopicsFields( tids, - ['tid', 'mainPid', 'slug', 'title', 'teaserPid', 'cid', 'postcount'] + ['tid', 'uid', 'mainPid', 'slug', 'title', 'teaserPid', 'cid', 'postcount'] ), topics.crossposts.get(tids), ]); @@ -137,11 +137,12 @@ module.exports = function (Categories) { function assignTopicsToCategories(categories, topics) { categories.forEach((category) => { if (category) { + const categoryCid = String(category.cid); category.posts = topics.filter(t => t.cid && - (t.cid === category.cid || - (t.parentCids && t.parentCids.includes(category.cid)) || - (t.crossposts.some(({ cid }) => parseInt(cid, 10) === category.cid)) + (String(t.cid) === categoryCid || + (t.parentCids && t.parentCids.includes(categoryCid)) || + (t.crossposts.some(({ cid }) => String(cid) === categoryCid)) )) .sort((a, b) => b.timestamp - a.timestamp) .slice(0, parseInt(category.numRecentReplies, 10)); diff --git a/test/categories.js b/test/categories.js index 9f666af5dd..40c3039310 100644 --- a/test/categories.js +++ b/test/categories.js @@ -85,21 +85,31 @@ describe('Categories', () => { }); describe('Categories.getRecentTopicReplies', () => { - it('should not throw', (done) => { - Categories.getCategoryById({ + it('should not throw', async () => { + const categoryData = await Categories.getCategoryById({ cid: categoryObj.cid, set: `cid:${categoryObj.cid}:tids`, reverse: true, start: 0, stop: -1, uid: 0, - }, (err, categoryData) => { - assert.ifError(err); - Categories.getRecentTopicReplies(categoryData, 0, {}, (err) => { - assert.ifError(err); - done(); - }); }); + + await Categories.getRecentTopicReplies([categoryData], 0, {}); + }); + + it('should return posts in child category as teaser on parent category' , async () => { + const { cid: parentCid } = await Categories.create({ name: 'theparent' }); + const { cid: childCid } = await Categories.create({ name: 'thechild', parentCid }); + await Topics.post({ uid: posterUid, title: 'inparent', content: 'post in parent', cid: parentCid }); + await Topics.post({ uid: posterUid, title: 'inchild', content: 'post in child', cid: childCid }); + const categoryData = await Categories.getCategories([parentCid, childCid]); + Categories.getTree(categoryData, 0); + + await Categories.getRecentTopicReplies(categoryData, 0, {}), + assert.strictEqual(String(categoryData[0].cid), String(parentCid)); + assert.strictEqual(categoryData[0].posts[0].uid, posterUid); + assert.strictEqual(categoryData[0].posts[0].content, 'post in child'); }); }); From bdb91e826f4822745041d7aff3e588b50fc5fc8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Tue, 10 Mar 2026 10:42:15 -0400 Subject: [PATCH 4/6] test: fix spec --- public/openapi/read/categories.yaml | 4 ++++ public/openapi/read/index.yaml | 2 ++ 2 files changed, 6 insertions(+) diff --git a/public/openapi/read/categories.yaml b/public/openapi/read/categories.yaml index c78533e267..f6de63ed3e 100644 --- a/public/openapi/read/categories.yaml +++ b/public/openapi/read/categories.yaml @@ -78,6 +78,8 @@ get: properties: pid: type: number + uid: + type: number timestamp: type: number content: @@ -136,6 +138,8 @@ get: properties: pid: type: number + uid: + type: number tid: type: number timestamp: diff --git a/public/openapi/read/index.yaml b/public/openapi/read/index.yaml index 56e4048340..5854f446b3 100644 --- a/public/openapi/read/index.yaml +++ b/public/openapi/read/index.yaml @@ -138,6 +138,8 @@ get: properties: pid: type: number + uid: + type: number tid: type: number timestamp: From 92fcdd09ca5485680f5d64b54848523fbfa05fa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Tue, 10 Mar 2026 10:46:17 -0400 Subject: [PATCH 5/6] fix: closes #14074, only return url & name from uploads, add tests to post uploads and thumb uploads to check only name & url is returned --- src/controllers/uploads.js | 8 ++--- test/files/nodebb.svg | 1 + test/topics/thumbs.js | 3 +- test/uploads.js | 64 ++++++++++++++++++-------------------- 4 files changed, 37 insertions(+), 39 deletions(-) create mode 100644 test/files/nodebb.svg diff --git a/src/controllers/uploads.js b/src/controllers/uploads.js index fc17f44b4b..6452535977 100644 --- a/src/controllers/uploads.js +++ b/src/controllers/uploads.js @@ -75,12 +75,12 @@ async function uploadAsImage(req, uploadedFile) { let fileObj = await uploadsController.uploadFile(req.uid, uploadedFile); // sharp can't save svgs skip resize for them const isSVG = uploadedFile.type === 'image/svg+xml'; - if (isSVG || meta.config.resizeImageWidth === 0 || meta.config.resizeImageWidthThreshold === 0) { - return fileObj; + const resizeDisabled = meta.config.resizeImageWidth === 0 || meta.config.resizeImageWidthThreshold === 0; + if (!isSVG && !resizeDisabled) { + fileObj = await resizeImage({ ...fileObj, type: uploadedFile.type }); } - fileObj = await resizeImage({ ...fileObj, type: uploadedFile.type }); - return { url: fileObj.url }; + return { url: fileObj.url, name: fileObj.name }; } async function uploadAsFile(req, uploadedFile) { diff --git a/test/files/nodebb.svg b/test/files/nodebb.svg new file mode 100644 index 0000000000..27b38bd87a --- /dev/null +++ b/test/files/nodebb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/topics/thumbs.js b/test/topics/thumbs.js index b740596c74..85517588a0 100644 --- a/test/topics/thumbs.js +++ b/test/topics/thumbs.js @@ -240,8 +240,9 @@ describe('Topic thumbs', () => { }); it('should succeed with a valid tid', async () => { - const { response } = await helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/1/thumbs`, path.join(__dirname, '../files/test.png'), {}, adminJar, adminCSRF); + const { response, body } = await helpers.uploadFile(`${nconf.get('url')}/api/v3/topics/1/thumbs`, path.join(__dirname, '../files/test.png'), {}, adminJar, adminCSRF); assert.strictEqual(response.statusCode, 200); + assert.deepStrictEqual(Object.keys(body.response.images[0]), ['url', 'name']); }); it('should succeed with uploader plugins', async () => { diff --git a/test/uploads.js b/test/uploads.js index fdf30ed03d..1662a16e55 100644 --- a/test/uploads.js +++ b/test/uploads.js @@ -35,41 +35,23 @@ describe('Upload Controllers', () => { let regularUid; let maliciousUid; - before((done) => { - async.series({ - category: function (next) { - categories.create({ - name: 'Test Category', - description: 'Test category created by testing script', - }, next); - }, - adminUid: function (next) { - user.create({ username: 'admin', password: 'barbar' }, next); - }, - regularUid: function (next) { - user.create({ username: 'regular', password: 'zugzug' }, next); - }, - maliciousUid: function (next) { - user.create({ username: 'malicioususer', password: 'herpderp' }, next); - }, - }, (err, results) => { - if (err) { - return done(err); - } - adminUid = results.adminUid; - regularUid = results.regularUid; - maliciousUid = results.maliciousUid; - cid = results.category.cid; - - topics.post({ uid: adminUid, title: 'test topic title', content: 'test topic content', cid: results.category.cid }, (err, result) => { - if (err) { - return done(err); - } - tid = result.topicData.tid; - pid = result.postData.pid; - groups.join('administrators', adminUid, done); - }); + before(async () => { + const category = await categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', }); + cid = category.cid; + + adminUid = await user.create({ username: 'admin', password: 'barbar' }); + groups.join('administrators', adminUid); + + regularUid = await user.create({ username: 'regular', password: 'zugzug' }); + maliciousUid = await user.create({ username: 'malicioususer', password: 'herpderp' }); + + const result = await topics.post({ uid: adminUid, title: 'test topic title', content: 'test topic content', cid }); + + tid = result.topicData.tid; + pid = result.postData.pid; }); describe('regular user uploads rate limits', () => { @@ -119,6 +101,19 @@ describe('Upload Controllers', () => { assert(body && body.status && body.response && body.response.images); assert(Array.isArray(body.response.images)); assert(body.response.images[0].url); + assert.deepStrictEqual(Object.keys(body.response.images[0]), ['url', 'name']); + }); + + it('should upload an svg image to a post', async () => { + const oldValue = meta.config.allowedFileExtensions; + meta.config.allowedFileExtensions = 'png,jpg,bmp,html,svg'; + const { response, body } = await helpers.uploadFile(`${nconf.get('url')}/api/post/upload`, path.join(__dirname, '../test/files/nodebb.svg'), {}, jar, csrf_token); + assert.equal(response.statusCode, 200); + assert(body && body.status && body.response && body.response.images); + assert(Array.isArray(body.response.images)); + assert(body.response.images[0].url); + assert.deepStrictEqual(Object.keys(body.response.images[0]), ['url', 'name']); + meta.config.allowedFileExtensions = oldValue; }); it('should upload an image to a post and then delete the upload', async () => { @@ -192,6 +187,7 @@ describe('Upload Controllers', () => { assert(body && body.status && body.response && body.response.images); assert(Array.isArray(body.response.images)); assert(body.response.images[0].url); + assert.deepStrictEqual(Object.keys(body.response.images[0]), ['url', 'name']); }); it('should upload a file with utf8 characters in the name to a post', async () => { From f6f51f83c4471a386cf7d96d2ee6cbe871182ac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Mon, 9 Mar 2026 18:33:24 -0400 Subject: [PATCH 6/6] perf: make a single round trip for set(s)Remove use bulkWrite --- src/database/mongo/sets.js | 47 ++++++++++++++------------------------ src/database/redis/sets.js | 3 +++ test/database/sets.js | 25 ++++++++++---------- 3 files changed, 33 insertions(+), 42 deletions(-) diff --git a/src/database/mongo/sets.js b/src/database/mongo/sets.js index eacf60ed7a..784b6acd70 100644 --- a/src/database/mongo/sets.js +++ b/src/database/mongo/sets.js @@ -94,44 +94,31 @@ module.exports = function (module) { }; module.setRemove = async function (key, value) { - if (!Array.isArray(value)) { - value = [value]; - } - - value = value.map(v => helpers.valueToString(v)); - - const coll = module.client.collection('objects'); - await coll.updateMany({ - _key: Array.isArray(key) ? { $in: key } : key, - }, { - $pullAll: { members: value }, - }); - - await coll.deleteMany({ - _key: Array.isArray(key) ? { $in: key } : key, - members: { $size: 0 }, - }); + await bulkSetRemove(key, value); }; module.setsRemove = async function (keys, value) { if (!Array.isArray(keys) || !keys.length) { return; } - value = helpers.valueToString(value); - - const coll = module.client.collection('objects'); - await coll.updateMany({ - _key: { $in: keys }, - }, { - $pull: { members: value }, - }); - - await coll.deleteMany({ - _key: { $in: keys }, - members: { $size: 0 }, - }); + await bulkSetRemove(keys, value); }; + async function bulkSetRemove(key, values) { + const isKeyArray = Array.isArray(key); + if (!key || (isKeyArray && !key.length)) return; + + const filterKey = isKeyArray ? { $in: key } : key; + const update = Array.isArray(values) ? + { $pullAll: { members: values.map(helpers.valueToString) } } : + { $pull: { members: helpers.valueToString(values) } }; + + await module.client.collection('objects').bulkWrite([ + { updateMany: { filter: { _key: filterKey }, update: update } }, + { deleteMany: { filter: { _key: filterKey, members: { $size: 0 } } } }, + ], { ordered: true }); + } + module.isSetMember = async function (key, value) { if (!key) { return false; diff --git a/src/database/redis/sets.js b/src/database/redis/sets.js index 861bf275ea..141bc46919 100644 --- a/src/database/redis/sets.js +++ b/src/database/redis/sets.js @@ -57,6 +57,9 @@ module.exports = function (module) { }; module.setsRemove = async function (keys, value) { + if (!Array.isArray(keys) || !keys.length) { + return; + } const batch = module.client.batch(); keys.forEach(k => batch.sRem(String(k), String(value))); await helpers.execBatch(batch); diff --git a/test/database/sets.js b/test/database/sets.js index 05d969353f..4d017b640b 100644 --- a/test/database/sets.js +++ b/test/database/sets.js @@ -278,20 +278,21 @@ describe('Set methods', () => { }); describe('setsRemove()', () => { - before((done) => { - db.setsAdd(['set1', 'set2'], 'value', done); + it('should remove a element from multiple sets', async () => { + await db.setsAdd(['set1', 'set2'], 'value'); + await db.setRemove(['set1', 'set2'], 'value'); + const members = await db.isMemberOfSets(['set1', 'set2'], 'value'); + assert.deepStrictEqual(members, [false, false]); }); - it('should remove a element from multiple sets', (done) => { - db.setsRemove(['set1', 'set2'], 'value', function (err) { - assert.equal(err, null); - assert.equal(arguments.length, 1); - db.isMemberOfSets(['set1', 'set2'], 'value', (err, members) => { - assert.equal(err, null); - assert.deepEqual(members, [false, false]); - done(); - }); - }); + it('should remove the sets if they are empty', async () => { + await db.setAdd('setsRemoveSet3', ['a']); + await db.setAdd('setsRemoveSet4', ['a']); + await db.setsRemove(['setsRemoveSet3', 'setsRemoveSet4'], 'a'); + assert.deepStrictEqual( + await db.exists(['setsRemoveSet3', 'setsRemoveSet4']), + [false, false] + ); }); });