From f9688b36b67e1706cafacce69f784a9e8e5aeaa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Thu, 11 Sep 2025 17:44:34 -0400 Subject: [PATCH 1/6] fix: port the try/catch for notes.assert from develop --- src/activitypub/notes.js | 393 ++++++++++++++++++++------------------- 1 file changed, 200 insertions(+), 193 deletions(-) diff --git a/src/activitypub/notes.js b/src/activitypub/notes.js index 50ce01bf3c..733aff354f 100644 --- a/src/activitypub/notes.js +++ b/src/activitypub/notes.js @@ -63,212 +63,219 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => { return null; } - if (!options.skipChecks) { - id = (await activitypub.checkHeader(id)) || id; - } - - let chain; - let context = await activitypub.contexts.get(uid, id); - if (context.tid) { - await unlock(id); - const { tid } = context; - return { tid, count: 0 }; - } else if (context.context) { - chain = Array.from(await activitypub.contexts.getItems(uid, context.context, { input })); - if (chain && chain.length) { - // Context resolves, use in later topic creation - context = context.context; - } - } else { - context = undefined; - } - - if (!chain || !chain.length) { - // Fall back to inReplyTo traversal on context retrieval failure - chain = Array.from(await Notes.getParentChain(uid, input)); - chain.reverse(); - } - - // Can't resolve — give up. - if (!chain.length) { - await unlock(id); - return null; - } - - // Reorder chain items by timestamp - chain = chain.sort((a, b) => a.timestamp - b.timestamp); - - const mainPost = chain[0]; - let { pid: mainPid, tid, uid: authorId, timestamp, title, content, sourceContent, _activitypub } = mainPost; - const hasTid = !!tid; - - const cid = hasTid ? await topics.getTopicField(tid, 'cid') : options.cid || -1; - - if (options.cid && cid === -1) { - // Move topic if currently uncategorized - await topics.tools.move(tid, { cid: options.cid, uid: 'system' }); - } - - const members = await db.isSortedSetMembers(`tid:${tid}:posts`, chain.slice(1).map(p => p.pid)); - members.unshift(await posts.exists(mainPid)); - if (tid && members.every(Boolean)) { - // All cached, return early. - activitypub.helpers.log('[notes/assert] No new notes to process.'); - await unlock(id); - return { tid, count: 0 }; - } - - if (hasTid) { - mainPid = await topics.getTopicField(tid, 'mainPid'); - } else { - // Check recipients/audience for category (local or remote) - const set = activitypub.helpers.makeSet(_activitypub, ['to', 'cc', 'audience']); - await activitypub.actors.assert(Array.from(set)); - - // Local - const resolved = await Promise.all(Array.from(set).map(async id => await activitypub.helpers.resolveLocalId(id))); - const recipientCids = resolved - .filter(Boolean) - .filter(({ type }) => type === 'category') - .map(obj => obj.id); - - // Remote - let remoteCid; - const assertedGroups = await categories.exists(Array.from(set)); - try { - const { hostname } = new URL(mainPid); - remoteCid = Array.from(set).filter((id, idx) => { - const { hostname: cidHostname } = new URL(id); - return assertedGroups[idx] && cidHostname === hostname; - }).shift(); - } catch (e) { - // noop - winston.error('[activitypub/notes.assert] Could not parse URL of mainPid', e.stack); + try { + if (!options.skipChecks) { + id = (await activitypub.checkHeader(id)) || id; } - if (remoteCid || recipientCids.length) { - // Overrides passed-in value, respect addressing from main post over booster - options.cid = remoteCid || recipientCids.shift(); + let chain; + let context = await activitypub.contexts.get(uid, id); + if (context.tid) { + await unlock(id); + const { tid } = context; + return { tid, count: 0 }; + } else if (context.context) { + chain = Array.from(await activitypub.contexts.getItems(uid, context.context, { input })); + if (chain && chain.length) { + // Context resolves, use in later topic creation + context = context.context; + } + } else { + context = undefined; } - // Auto-categorization (takes place only if all other categorization efforts fail) - if (!options.cid) { - options.cid = await assignCategory(mainPost); + if (!chain || !chain.length) { + // Fall back to inReplyTo traversal on context retrieval failure + chain = Array.from(await Notes.getParentChain(uid, input)); + chain.reverse(); } - // mainPid ok to leave as-is - if (!title) { - const sentences = tokenizer.sentences(content || sourceContent, { sanitize: true }); - title = sentences.shift(); - } - - // Remove any custom emoji from title - if (_activitypub && _activitypub.tag && Array.isArray(_activitypub.tag)) { - _activitypub.tag - .filter(tag => tag.type === 'Emoji') - .forEach((tag) => { - title = title.replace(new RegExp(tag.name, 'g'), ''); - }); - } - } - mainPid = utils.isNumber(mainPid) ? parseInt(mainPid, 10) : mainPid; - - // Relation & privilege check for local categories - const inputIndex = chain.map(n => n.pid).indexOf(id); - const hasRelation = - uid || hasTid || - options.skipChecks || options.cid || - await assertRelation(chain[inputIndex !== -1 ? inputIndex : 0]); - - const privilege = `topics:${tid ? 'reply' : 'create'}`; - const allowed = await privileges.categories.can(privilege, options.cid || cid, activitypub._constants.uid); - if (!hasRelation || !allowed) { - if (!hasRelation) { - activitypub.helpers.log(`[activitypub/notes.assert] Not asserting ${id} as it has no relation to existing tracked content.`); - } - - await unlock(id); - return null; - } - - tid = tid || utils.generateUUID(); - mainPost.tid = tid; - - const urlMap = chain.reduce((map, post) => (post.url ? map.set(post.url, post.id) : map), new Map()); - const unprocessed = chain.map((post) => { - post.tid = tid; // add tid to post hash - - // Ensure toPids in replies are ids - if (urlMap.has(post.toPid)) { - post.toPid = urlMap.get(post.toPid); - } - - return post; - }).filter((p, idx) => !members[idx]); - const count = unprocessed.length; - activitypub.helpers.log(`[notes/assert] ${count} new note(s) found.`); - - if (!hasTid) { - const { to, cc, attachment } = mainPost._activitypub; - const tags = await Notes._normalizeTags(mainPost._activitypub.tag || []); - - try { - await topics.post({ - tid, - uid: authorId, - cid: options.cid || cid, - pid: mainPid, - title, - timestamp, - tags, - content: mainPost.content, - sourceContent: mainPost.sourceContent, - _activitypub: mainPost._activitypub, - }); - unprocessed.shift(); - } catch (e) { - activitypub.helpers.log(`[activitypub/notes.assert] Could not post topic (${mainPost.pid}): ${e.message}`); + // Can't resolve — give up. + if (!chain.length) { + await unlock(id); return null; } - // These must come after topic is posted + // Reorder chain items by timestamp + chain = chain.sort((a, b) => a.timestamp - b.timestamp); + + const mainPost = chain[0]; + let { pid: mainPid, tid, uid: authorId, timestamp, title, content, sourceContent, _activitypub } = mainPost; + const hasTid = !!tid; + + const cid = hasTid ? await topics.getTopicField(tid, 'cid') : options.cid || -1; + + if (options.cid && cid === -1) { + // Move topic if currently uncategorized + await topics.tools.move(tid, { cid: options.cid, uid: 'system' }); + } + + const members = await db.isSortedSetMembers(`tid:${tid}:posts`, chain.slice(1).map(p => p.pid)); + members.unshift(await posts.exists(mainPid)); + if (tid && members.every(Boolean)) { + // All cached, return early. + activitypub.helpers.log('[notes/assert] No new notes to process.'); + await unlock(id); + return { tid, count: 0 }; + } + + if (hasTid) { + mainPid = await topics.getTopicField(tid, 'mainPid'); + } else { + // Check recipients/audience for category (local or remote) + const set = activitypub.helpers.makeSet(_activitypub, ['to', 'cc', 'audience']); + await activitypub.actors.assert(Array.from(set)); + + // Local + const resolved = await Promise.all(Array.from(set).map(async id => await activitypub.helpers.resolveLocalId(id))); + const recipientCids = resolved + .filter(Boolean) + .filter(({ type }) => type === 'category') + .map(obj => obj.id); + + // Remote + let remoteCid; + const assertedGroups = await categories.exists(Array.from(set)); + try { + const { hostname } = new URL(mainPid); + remoteCid = Array.from(set).filter((id, idx) => { + const { hostname: cidHostname } = new URL(id); + return assertedGroups[idx] && cidHostname === hostname; + }).shift(); + } catch (e) { + // noop + winston.error('[activitypub/notes.assert] Could not parse URL of mainPid', e.stack); + } + + if (remoteCid || recipientCids.length) { + // Overrides passed-in value, respect addressing from main post over booster + options.cid = remoteCid || recipientCids.shift(); + } + + // Auto-categorization (takes place only if all other categorization efforts fail) + if (!options.cid) { + options.cid = await assignCategory(mainPost); + } + + // mainPid ok to leave as-is + if (!title) { + const sentences = tokenizer.sentences(content || sourceContent, { sanitize: true }); + title = sentences.shift(); + } + + // Remove any custom emoji from title + if (_activitypub && _activitypub.tag && Array.isArray(_activitypub.tag)) { + _activitypub.tag + .filter(tag => tag.type === 'Emoji') + .forEach((tag) => { + title = title.replace(new RegExp(tag.name, 'g'), ''); + }); + } + } + mainPid = utils.isNumber(mainPid) ? parseInt(mainPid, 10) : mainPid; + + // Relation & privilege check for local categories + const inputIndex = chain.map(n => n.pid).indexOf(id); + const hasRelation = + uid || hasTid || + options.skipChecks || options.cid || + await assertRelation(chain[inputIndex !== -1 ? inputIndex : 0]); + + const privilege = `topics:${tid ? 'reply' : 'create'}`; + const allowed = await privileges.categories.can(privilege, options.cid || cid, activitypub._constants.uid); + if (!hasRelation || !allowed) { + if (!hasRelation) { + activitypub.helpers.log(`[activitypub/notes.assert] Not asserting ${id} as it has no relation to existing tracked content.`); + } + + await unlock(id); + return null; + } + + tid = tid || utils.generateUUID(); + mainPost.tid = tid; + + const urlMap = chain.reduce((map, post) => (post.url ? map.set(post.url, post.id) : map), new Map()); + const unprocessed = chain.map((post) => { + post.tid = tid; // add tid to post hash + + // Ensure toPids in replies are ids + if (urlMap.has(post.toPid)) { + post.toPid = urlMap.get(post.toPid); + } + + return post; + }).filter((p, idx) => !members[idx]); + const count = unprocessed.length; + activitypub.helpers.log(`[notes/assert] ${count} new note(s) found.`); + + if (!hasTid) { + const { to, cc, attachment } = mainPost._activitypub; + const tags = await Notes._normalizeTags(mainPost._activitypub.tag || []); + + try { + await topics.post({ + tid, + uid: authorId, + cid: options.cid || cid, + pid: mainPid, + title, + timestamp, + tags, + content: mainPost.content, + sourceContent: mainPost.sourceContent, + _activitypub: mainPost._activitypub, + }); + unprocessed.shift(); + } catch (e) { + activitypub.helpers.log(`[activitypub/notes.assert] Could not post topic (${mainPost.pid}): ${e.message}`); + await unlock(id); + return null; + } + + // These must come after topic is posted + await Promise.all([ + Notes.updateLocalRecipients(mainPid, { to, cc }), + mainPost._activitypub.image ? topics.thumbs.associate({ + id: tid, + path: mainPost._activitypub.image, + }) : null, + posts.attachments.update(mainPid, attachment), + ]); + + if (context) { + activitypub.helpers.log(`[activitypub/notes.assert] Associating tid ${tid} with context ${context}`); + await topics.setTopicField(tid, 'context', context); + } + } + + for (const post of unprocessed) { + const { to, cc, attachment } = post._activitypub; + + try { + // eslint-disable-next-line no-await-in-loop + await topics.reply(post); + // eslint-disable-next-line no-await-in-loop + await Promise.all([ + Notes.updateLocalRecipients(post.pid, { to, cc }), + posts.attachments.update(post.pid, attachment), + ]); + } catch (e) { + activitypub.helpers.log(`[activitypub/notes.assert] Could not add reply (${post.pid}): ${e.message}`); + } + } + await Promise.all([ - Notes.updateLocalRecipients(mainPid, { to, cc }), - mainPost._activitypub.image ? topics.thumbs.associate({ - id: tid, - path: mainPost._activitypub.image, - }) : null, - posts.attachments.update(mainPid, attachment), + Notes.syncUserInboxes(tid, uid), + unlock(id), ]); - if (context) { - activitypub.helpers.log(`[activitypub/notes.assert] Associating tid ${tid} with context ${context}`); - await topics.setTopicField(tid, 'context', context); - } + return { tid, count }; + } catch (e) { + winston.warn(`[activitypub/notes.assert] Could not assert ${id} (${e.message}), releasing lock.`); + await unlock(id); + return null; } - - for (const post of unprocessed) { - const { to, cc, attachment } = post._activitypub; - - try { - // eslint-disable-next-line no-await-in-loop - await topics.reply(post); - // eslint-disable-next-line no-await-in-loop - await Promise.all([ - Notes.updateLocalRecipients(post.pid, { to, cc }), - posts.attachments.update(post.pid, attachment), - ]); - } catch (e) { - activitypub.helpers.log(`[activitypub/notes.assert] Could not add reply (${post.pid}): ${e.message}`); - } - } - - await Promise.all([ - Notes.syncUserInboxes(tid, uid), - unlock(id), - ]); - - return { tid, count }; }; Notes.assertPrivate = async (object) => { From f9ddbebacc76c2601d5dbb8adf97b44cb32395b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Fri, 12 Sep 2025 11:33:53 -0400 Subject: [PATCH 2/6] fix: remove .auth call --- src/database/redis/connection.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/database/redis/connection.js b/src/database/redis/connection.js index 8792bbdf3f..7f0b1912f8 100644 --- a/src/database/redis/connection.js +++ b/src/database/redis/connection.js @@ -69,10 +69,6 @@ connection.connect = async function (options) { }).catch((err) => { winston.error('Error connecting to Redis:', err); }); - - if (options.password) { - cxn.auth({ password: options.password }); - } }); }; From 56fad0be0d0da2c1f3a12562d07a8f2adab3bffa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Fri, 12 Sep 2025 19:19:52 -0400 Subject: [PATCH 3/6] fix: check brand:touchIcon for correct path --- src/middleware/index.js | 14 ++++++++++---- test/controllers.js | 12 +++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/middleware/index.js b/src/middleware/index.js index 67d8e2faa0..14cb3138e1 100644 --- a/src/middleware/index.js +++ b/src/middleware/index.js @@ -145,12 +145,18 @@ middleware.logApiUsage = async function logApiUsage(req, res, next) { }; middleware.routeTouchIcon = function routeTouchIcon(req, res) { - if (meta.config['brand:touchIcon'] && validator.isURL(meta.config['brand:touchIcon'])) { - return res.redirect(meta.config['brand:touchIcon']); + const brandTouchIcon = meta.config['brand:touchIcon']; + if (brandTouchIcon && validator.isURL(brandTouchIcon)) { + return res.redirect(brandTouchIcon); } + let iconPath = ''; - if (meta.config['brand:touchIcon']) { - iconPath = path.join(nconf.get('upload_path'), meta.config['brand:touchIcon'].replace(/assets\/uploads/, '')); + if (brandTouchIcon) { + const uploadPath = nconf.get('upload_path'); + iconPath = path.join(uploadPath, brandTouchIcon.replace(/assets\/uploads/, '')); + if (!iconPath.startsWith(uploadPath)) { + return res.status(404).send('Not found'); + } } else { iconPath = path.join(nconf.get('base_dir'), 'public/images/touch/512.png'); } diff --git a/test/controllers.js b/test/controllers.js index b2174d8cf9..5ef313a6d2 100644 --- a/test/controllers.js +++ b/test/controllers.js @@ -6,8 +6,8 @@ const fs = require('fs'); const path = require('path'); const util = require('util'); -const request = require('../src/request'); const db = require('./mocks/databasemock'); +const request = require('../src/request'); const api = require('../src/api'); const categories = require('../src/categories'); const topics = require('../src/topics'); @@ -692,6 +692,16 @@ describe('Controllers', () => { assert(body); }); + it('should 404 if brand:touchIcon is not valid', async () => { + const oldValue = meta.config['brand:touchIcon']; + meta.config['brand:touchIcon'] = '../../not/valid'; + + const { response, body } = await request.get(`${nconf.get('url')}/apple-touch-icon`); + assert.strictEqual(response.statusCode, 404); + assert.strictEqual(body, 'Not found'); + meta.config['brand:touchIcon'] = oldValue; + }) + it('should error if guests do not have search privilege', async () => { const { response, body } = await request.get(`${nconf.get('url')}/api/users?query=bar§ion=sort-posts`); From a37521b0167bf3c10c1b856ed317589f4a08bede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Fri, 12 Sep 2025 19:27:07 -0400 Subject: [PATCH 4/6] lint: fix --- test/controllers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/controllers.js b/test/controllers.js index 5ef313a6d2..d79cf6de04 100644 --- a/test/controllers.js +++ b/test/controllers.js @@ -700,7 +700,7 @@ describe('Controllers', () => { assert.strictEqual(response.statusCode, 404); assert.strictEqual(body, 'Not found'); meta.config['brand:touchIcon'] = oldValue; - }) + }); it('should error if guests do not have search privilege', async () => { From e2dc592c4f21a48bd5f6c10b58773d0b755e32bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Fri, 12 Sep 2025 19:50:19 -0400 Subject: [PATCH 5/6] fix: favicon path --- src/webserver.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/webserver.js b/src/webserver.js index ab8dc5bc0d..18f57faa40 100644 --- a/src/webserver.js +++ b/src/webserver.js @@ -227,6 +227,9 @@ function setupHelmet(app) { function setupFavicon(app) { let faviconPath = meta.config['brand:favicon'] || 'favicon.ico'; faviconPath = path.join(nconf.get('base_dir'), 'public', faviconPath.replace(/assets\/uploads/, 'uploads')); + if (!faviconPath.startsWith(nconf.get('upload_path'))) { + faviconPath = path.join(nconf.get('base_dir'), 'public', 'favicon.ico'); + } if (file.existsSync(faviconPath)) { app.use(nconf.get('relative_path'), favicon(faviconPath)); } From 8a786c717e5366620d12c7b49d34fb0dffbe67a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Sat, 13 Sep 2025 17:40:09 -0400 Subject: [PATCH 6/6] fix: if reputation is disabled hide votes on /recent they were only hidden on category page --- install/package.json | 4 ++-- public/src/modules/topicList.js | 8 +++++--- src/controllers/recent.js | 1 + 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/install/package.json b/install/package.json index 9e94fa1ed3..55dee022a8 100644 --- a/install/package.json +++ b/install/package.json @@ -106,10 +106,10 @@ "nodebb-plugin-spam-be-gone": "2.3.2", "nodebb-plugin-web-push": "0.7.5", "nodebb-rewards-essentials": "1.0.2", - "nodebb-theme-harmony": "2.1.18", + "nodebb-theme-harmony": "2.1.19", "nodebb-theme-lavender": "7.1.19", "nodebb-theme-peace": "2.2.48", - "nodebb-theme-persona": "14.1.12", + "nodebb-theme-persona": "14.1.13", "nodebb-widget-essentials": "7.0.40", "nodemailer": "7.0.6", "nprogress": "0.2.0", diff --git a/public/src/modules/topicList.js b/public/src/modules/topicList.js index 6396bb9e8e..bc5a6e4abd 100644 --- a/public/src/modules/topicList.js +++ b/public/src/modules/topicList.js @@ -53,7 +53,7 @@ define('topicList', [ handleBack.init(function (after, handleBackCallback) { loadTopicsCallback(after, 1, function (data, loadCallback) { - onTopicsLoaded(templateName, data.topics, ajaxify.data.showSelect, 1, function () { + onTopicsLoaded(templateName, data, ajaxify.data.showSelect, 1, function () { handleBackCallback(); loadCallback(); }); @@ -166,7 +166,7 @@ define('topicList', [ } loadTopicsCallback(after, direction, function (data, done) { - onTopicsLoaded(templateName, data.topics, ajaxify.data.showSelect, direction, done); + onTopicsLoaded(templateName, data, ajaxify.data.showSelect, direction, done); }); }; @@ -187,7 +187,8 @@ define('topicList', [ }); } - function onTopicsLoaded(templateName, topics, showSelect, direction, callback) { + function onTopicsLoaded(templateName, data, showSelect, direction, callback) { + let { topics } = data; if (!topics || !topics.length) { $('#load-more-btn').hide(); return callback(); @@ -212,6 +213,7 @@ define('topicList', [ const tplData = { topics: topics, showSelect: showSelect, + 'reputation:disabled': data['reputation:disabled'], template: { name: templateName, }, diff --git a/src/controllers/recent.js b/src/controllers/recent.js index 5699fee1b7..656958463b 100644 --- a/src/controllers/recent.js +++ b/src/controllers/recent.js @@ -79,6 +79,7 @@ recentController.getData = async function (req, url, sort) { data.selectedTag = tagData.selectedTag; data.selectedTags = tagData.selectedTags; data['feeds:disableRSS'] = meta.config['feeds:disableRSS'] || 0; + data['reputation:disabled'] = meta.config['reputation:disabled']; if (!meta.config['feeds:disableRSS']) { data.rssFeedUrl = `${relative_path}/${url}.rss`; if (req.loggedIn) {