diff --git a/CHANGELOG.md b/CHANGELOG.md index 96dd2a28f1..beaab4c40c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,45 @@ +#### v4.5.1 (2025-09-04) + +##### Chores + +* up dbsearch (c07e81d2) +* incrementing version number - v4.5.0 (f05c5d06) +* update changelog for v4.5.0 (86d03b1e) +* 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 + +* use _variables.scss overrides from acp in custom skins and bootswatch skins as well (0c48e0e9) + +##### Bug Fixes + +* remove unused dependency (8d7e3537) +* remove test for 1b12 announce on topic move (as this no longer occurs) (9221d34f) +* use existing id if checkHeader returns false (e6996846) +* regression that caused Piefed (or potentially others) content to be dropped on receipt (86d9016f) +* remove faulty code that tried to announce a remote object but couldn't as the ID was not a number (7adfe39e) + #### v4.5.0 (2025-09-03) ##### Chores diff --git a/install/package.json b/install/package.json index 6a5457e093..f9e5b8b0ed 100644 --- a/install/package.json +++ b/install/package.json @@ -96,8 +96,8 @@ "mousetrap": "1.6.5", "multer": "2.0.2", "nconf": "0.13.0", - "nodebb-plugin-2factor": "7.5.10", - "nodebb-plugin-composer-default": "10.3.0", + "nodebb-plugin-2factor": "7.6.0", + "nodebb-plugin-composer-default": "10.3.1", "nodebb-plugin-dbsearch": "6.3.2", "nodebb-plugin-emoji": "6.0.3", "nodebb-plugin-emoji-android": "4.1.1", @@ -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.14", "nodebb-widget-essentials": "7.0.40", "nodemailer": "7.0.6", "nprogress": "0.2.0", @@ -201,4 +201,4 @@ "url": "https://github.com/barisusakli" } ] -} \ No newline at end of file +} diff --git a/public/language/en-GB/admin/manage/categories.json b/public/language/en-GB/admin/manage/categories.json index d66dd814a1..7532cd9cd1 100644 --- a/public/language/en-GB/admin/manage/categories.json +++ b/public/language/en-GB/admin/manage/categories.json @@ -17,6 +17,8 @@ "federatedDescription": "Federated Description", "federatedDescription.help": "This text will be appended to the category description when queried by other websites/apps.", "federatedDescription.default": "This is a forum category containing topical discussion. You can start new discussions by mentioning this category.", + "topic-template": "Topic Template", + "topic-template.help": "Define a template for new topics created in this category.", "bg-color": "Background Colour", "text-color": "Text Colour", "bg-image-size": "Background Image Size", diff --git a/public/openapi/read/popular.yaml b/public/openapi/read/popular.yaml index 67c7d5030f..fe6a0a6480 100644 --- a/public/openapi/read/popular.yaml +++ b/public/openapi/read/popular.yaml @@ -60,6 +60,8 @@ get: type: string feeds:disableRSS: type: number + reputation:disabled: + type: number rssFeedUrl: type: string title: diff --git a/public/openapi/read/recent.yaml b/public/openapi/read/recent.yaml index 74d3d91a27..848a306b79 100644 --- a/public/openapi/read/recent.yaml +++ b/public/openapi/read/recent.yaml @@ -58,6 +58,8 @@ get: type: string feeds:disableRSS: type: number + reputation:disabled: + type: number rssFeedUrl: type: string title: diff --git a/public/openapi/read/top.yaml b/public/openapi/read/top.yaml index 8594ca9f14..6f0cbc9bc1 100644 --- a/public/openapi/read/top.yaml +++ b/public/openapi/read/top.yaml @@ -71,6 +71,8 @@ get: type: string feeds:disableRSS: type: number + reputation:disabled: + type: number rssFeedUrl: type: string title: diff --git a/public/openapi/read/unread.yaml b/public/openapi/read/unread.yaml index ea69f666dd..77e9ec44f6 100644 --- a/public/openapi/read/unread.yaml +++ b/public/openapi/read/unread.yaml @@ -19,6 +19,8 @@ get: type: boolean showTopicTools: type: boolean + reputation:disabled: + type: number nextStart: type: number topics: diff --git a/public/src/modules/navigator.js b/public/src/modules/navigator.js index 8c557985c3..0532beb3b4 100644 --- a/public/src/modules/navigator.js +++ b/public/src/modules/navigator.js @@ -441,7 +441,13 @@ define('navigator', [ function generateUrl(index) { const pathname = window.location.pathname.replace(config.relative_path, ''); const parts = pathname.split('/'); - return parts[1] + '/' + parts[2] + '/' + parts[3] + (index ? '/' + index : ''); + const newUrl = parts[1] + '/' + parts[2] + '/' + parts[3] + (index ? '/' + index : ''); + const data = { + newUrl, + index, + }; + hooks.fire('action:navigator.generateUrl', data); + return data.newUrl; } navigator.getCount = () => count; 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/activitypub/inbox.js b/src/activitypub/inbox.js index 754720f208..ea5b032a1a 100644 --- a/src/activitypub/inbox.js +++ b/src/activitypub/inbox.js @@ -298,6 +298,7 @@ inbox.announce = async (req) => { const exists = await posts.exists(localId || id); if (exists) { try { + await activitypub.actors.assert(object.actor); const result = await posts.upvote(localId || id, object.actor); if (localId) { socketHelpers.upvote(result, 'notifications:upvoted-your-post-in'); diff --git a/src/activitypub/notes.js b/src/activitypub/notes.js index ce9371fd26..b85d686f1e 100644 --- a/src/activitypub/notes.js +++ b/src/activitypub/notes.js @@ -31,12 +31,19 @@ async function unlock(value) { Notes._normalizeTags = async (tag, cid) => { const systemTags = (meta.config.systemTags || '').split(','); const maxTags = await categories.getCategoryField(cid, 'maxTags'); - const tags = (tag || []) + let tags = tag || []; + + if (!Array.isArray(tags)) { // the "|| []" should handle null/undefined values... #famouslastwords + tags = [tags]; + } + + tags = tags + .filter(({ type }) => type === 'Hashtag') .map((tag) => { tag.name = tag.name.startsWith('#') ? tag.name.slice(1) : tag.name; return tag; }) - .filter(o => o.type === 'Hashtag' && !systemTags.includes(o.name)) + .filter(({ name }) => !systemTags.includes(name)) .map(t => t.name); if (tags.length > maxTags) { @@ -63,210 +70,218 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => { return null; } - 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 || process.env.hasOwnProperty('CI'))) { + 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 exists = await posts.exists(chain.map(p => p.pid)); + if (tid && exists.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) => !exists[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) => { @@ -399,15 +414,18 @@ async function assertRelation(post) { } async function assignCategory(post) { + activitypub.helpers.log('[activitypub] Checking auto-categorization rules.'); let cid = undefined; const rules = await activitypub.rules.list(); - const tags = await Notes._normalizeTags(post._activitypub.tag || []); + let tags = await Notes._normalizeTags(post._activitypub.tag || []); + tags = tags.map(tag => tag.toLowerCase()); cid = rules.reduce((cid, { type, value, cid: target }) => { if (!cid) { switch (type) { case 'hashtag': { - if (tags.includes(value)) { + if (tags.includes(value.toLowerCase())) { + activitypub.helpers.log(`[activitypub] - Rule match: #${value}; cid: ${target}`); return target; } break; diff --git a/src/controllers/activitypub/actors.js b/src/controllers/activitypub/actors.js index 0f7a348990..850585e379 100644 --- a/src/controllers/activitypub/actors.js +++ b/src/controllers/activitypub/actors.js @@ -136,6 +136,7 @@ Actors.topic = async function (req, res, next) { let collection; let pids; try { + // pids are used in generation of digest only. ([collection, pids] = await Promise.all([ activitypub.helpers.generateCollection({ set: `tid:${req.params.tid}:posts`, @@ -151,7 +152,6 @@ Actors.topic = async function (req, res, next) { } pids.push(mainPid); pids = pids.map(pid => (utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid)); - collection.totalItems += 1; // account for mainPid // Generate digest for ETag const digest = activitypub.helpers.generateDigest(new Set(pids)); @@ -168,15 +168,18 @@ Actors.topic = async function (req, res, next) { } res.set('ETag', digest); - // Convert pids to urls + // Add OP to collection on first (or only) page if (page || collection.totalItems < perPage) { collection.orderedItems = collection.orderedItems || []; - if (!page || page === 1) { // add OP to collection + if (!page || page === 1) { collection.orderedItems.unshift(mainPid); + collection.totalItems += 1; } - collection.orderedItems = collection.orderedItems.map(pid => (utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid)); } + // Convert pids to urls + collection.orderedItems = collection.orderedItems.map(pid => (utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid)); + const object = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${nconf.get('url')}/topic/${req.params.tid}${collection.orderedItems && page ? `?page=${page}` : ''}`, diff --git a/src/controllers/recent.js b/src/controllers/recent.js index 5699fee1b7..73d5348c0d 100644 --- a/src/controllers/recent.js +++ b/src/controllers/recent.js @@ -22,9 +22,9 @@ recentController.get = async function (req, res, next) { res.render('recent', data); }; -recentController.getData = async function (req, url, sort) { +recentController.getData = async function (req, url, sort, selectedTerm = 'alltime') { const page = parseInt(req.query.page, 10) || 1; - let term = helpers.terms[req.query.term]; + let term = helpers.terms[req.query.term || selectedTerm]; const { cid, tag } = req.query; const filter = req.query.filter || ''; @@ -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) { diff --git a/src/controllers/unread.js b/src/controllers/unread.js index 9ff73da6ff..871f3252c0 100644 --- a/src/controllers/unread.js +++ b/src/controllers/unread.js @@ -75,6 +75,7 @@ unreadController.get = async function (req, res) { data.selectedTags = tagData.selectedTags; data.filters = helpers.buildFilters(baseUrl, filter, req.query); data.selectedFilter = data.filters.find(filter => filter && filter.selected); + data['reputation:disabled'] = meta.config['reputation:disabled']; res.render('unread', data); }; diff --git a/src/database/postgres/main.js b/src/database/postgres/main.js index c0838b45a0..5b3c7f7e9d 100644 --- a/src/database/postgres/main.js +++ b/src/database/postgres/main.js @@ -85,7 +85,8 @@ module.exports = function (module) { text: ` SELECT o."_key" FROM "legacy_object_live" o - WHERE o."_key" LIKE '${match}'`, + WHERE o."_key" LIKE $1`, + values: [match], }); return res.rows.map(r => r._key); diff --git a/src/database/redis/connection.js b/src/database/redis/connection.js index fb2aceb33b..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(options.password); - } }); }; diff --git a/src/meta/minifier.js b/src/meta/minifier.js index 0ab268dc4f..324b827797 100644 --- a/src/meta/minifier.js +++ b/src/meta/minifier.js @@ -166,7 +166,7 @@ actions.buildCSS = async function buildCSS(data) { }; if (data.minify) { opts.silenceDeprecations = [ - 'legacy-js-api', 'mixed-decls', 'color-functions', + 'legacy-js-api', 'color-functions', 'global-builtin', 'import', ]; } 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/src/notifications.js b/src/notifications.js index bcf27c7d66..e71366417e 100644 --- a/src/notifications.js +++ b/src/notifications.js @@ -9,6 +9,7 @@ const _ = require('lodash'); const db = require('./database'); const User = require('./user'); +const categories = require('./categories'); const posts = require('./posts'); const groups = require('./groups'); const meta = require('./meta'); @@ -84,7 +85,24 @@ Notifications.getMultiple = async function (nids) { const notifications = await db.getObjects(keys); const userKeys = notifications.map(n => n && n.from); - const usersData = await User.getUsersFields(userKeys, ['username', 'userslug', 'picture']); + let [usersData, categoriesData] = await Promise.all([ + User.getUsersFields(userKeys, ['username', 'userslug', 'picture']), + categories.getCategoriesFields(userKeys, ['cid', 'name', 'slug', 'picture']), + ]); + // Merge valid categoriesData into usersData + usersData = usersData.map((userData, idx) => { + const categoryData = categoriesData[idx]; + if (!userData.uid && categoryData.cid) { + return { + username: categoryData.slug, + displayname: categoryData.name, + userslug: categoryData.slug, + picture: categoryData.picture, + }; + } + + return userData; + }); notifications.forEach((notification, index) => { if (notification) { diff --git a/src/socket.io/admin/email.js b/src/socket.io/admin/email.js index ed5bce7a60..b2a160b1f3 100644 --- a/src/socket.io/admin/email.js +++ b/src/socket.io/admin/email.js @@ -1,5 +1,7 @@ 'use strict'; +const winston = require('winston'); + const meta = require('../../meta'); const userDigest = require('../../user/digest'); const userEmail = require('../../user/email'); @@ -14,55 +16,59 @@ Email.test = async function (socket, data) { ...(data.payload || {}), subject: '[[email:test-email.subject]]', }; + try { + switch (data.template) { + case 'digest': + await userDigest.execute({ + interval: 'month', + subscribers: [socket.uid], + }); + break; - switch (data.template) { - case 'digest': - await userDigest.execute({ - interval: 'month', - subscribers: [socket.uid], - }); - break; + case 'banned': + Object.assign(payload, { + username: 'test-user', + until: utils.toISOString(Date.now()), + reason: 'Test Reason', + }); + await emailer.send(data.template, socket.uid, payload); + break; - case 'banned': - Object.assign(payload, { - username: 'test-user', - until: utils.toISOString(Date.now()), - reason: 'Test Reason', - }); - await emailer.send(data.template, socket.uid, payload); - break; + case 'verify-email': + case 'welcome': + await userEmail.sendValidationEmail(socket.uid, { + force: 1, + template: data.template, + subject: data.template === 'welcome' ? `[[email:welcome-to, ${meta.config.title || meta.config.browserTitle || 'NodeBB'}]]` : undefined, + }); + break; - case 'verify-email': - case 'welcome': - await userEmail.sendValidationEmail(socket.uid, { - force: 1, - template: data.template, - subject: data.template === 'welcome' ? `[[email:welcome-to, ${meta.config.title || meta.config.browserTitle || 'NodeBB'}]]` : undefined, - }); - break; + case 'notification': { + const notification = await notifications.create({ + type: 'test', + bodyShort: '[[email:notif.test.short]]', + bodyLong: '[[email:notif.test.long]]', + nid: `uid:${socket.uid}:test`, + path: '/', + from: socket.uid, + }); + await emailer.send('notification', socket.uid, { + path: notification.path, + subject: utils.stripHTMLTags(notification.subject || '[[notifications:new-notification]]'), + intro: utils.stripHTMLTags(notification.bodyShort), + body: notification.bodyLong || '', + notification, + showUnsubscribe: true, + }); + break; + } - case 'notification': { - const notification = await notifications.create({ - type: 'test', - bodyShort: '[[email:notif.test.short]]', - bodyLong: '[[email:notif.test.long]]', - nid: `uid:${socket.uid}:test`, - path: '/', - from: socket.uid, - }); - await emailer.send('notification', socket.uid, { - path: notification.path, - subject: utils.stripHTMLTags(notification.subject || '[[notifications:new-notification]]'), - intro: utils.stripHTMLTags(notification.bodyShort), - body: notification.bodyLong || '', - notification, - showUnsubscribe: true, - }); - break; + default: + await emailer.send(data.template, socket.uid, payload); + break; } - - default: - await emailer.send(data.template, socket.uid, payload); - break; + } catch (err) { + winston.error(err.stack); + throw err; } }; diff --git a/src/socket.io/helpers.js b/src/socket.io/helpers.js index d80638458e..5def5138d4 100644 --- a/src/socket.io/helpers.js +++ b/src/socket.io/helpers.js @@ -94,7 +94,10 @@ SocketHelpers.sendNotificationToPostOwner = async function (pid, fromuid, comman return; } fromuid = utils.isNumber(fromuid) ? parseInt(fromuid, 10) : fromuid; - const postData = await posts.getPostFields(pid, ['tid', 'uid', 'content']); + const [postData, fromCategory] = await Promise.all([ + posts.getPostFields(pid, ['tid', 'uid', 'content']), + !utils.isNumber(fromuid) && categories.exists(fromuid), + ]); const [canRead, isIgnoring] = await Promise.all([ privileges.posts.can('topics:read', pid, postData.uid), topics.isIgnoring([postData.tid], postData.uid), @@ -103,19 +106,17 @@ SocketHelpers.sendNotificationToPostOwner = async function (pid, fromuid, comman return; } const [userData, topicTitle, postObj] = await Promise.all([ - user.getUserFields(fromuid, ['username']), + fromCategory ? categories.getCategoryFields(fromuid, ['name']) : user.getUserFields(fromuid, ['username']), topics.getTopicField(postData.tid, 'title'), posts.parsePost(postData), ]); - const { displayname } = userData; - const title = utils.decodeHTMLEntities(topicTitle); const titleEscaped = title.replace(/%/g, '%').replace(/,/g, ','); const notifObj = await notifications.create({ type: command, - bodyShort: `[[${notification}, ${displayname}, ${titleEscaped}]]`, + bodyShort: `[[${notification}, ${userData.displayname || userData.name}, ${titleEscaped}]]`, bodyLong: postObj.content, pid: pid, tid: postData.tid, diff --git a/src/upgrades/1.10.0/view_deleted_privilege.js b/src/upgrades/1.10.0/view_deleted_privilege.js index a483bcf417..3b65f2d5b7 100644 --- a/src/upgrades/1.10.0/view_deleted_privilege.js +++ b/src/upgrades/1.10.0/view_deleted_privilege.js @@ -11,6 +11,7 @@ module.exports = { method: async function () { const { progress } = this; const cids = await db.getSortedSetRange('categories:cid', 0, -1); + progress.total = cids.length; for (const cid of cids) { const uids = await db.getSortedSetRange(`group:cid:${cid}:privileges:moderate:members`, 0, -1); for (const uid of uids) { diff --git a/src/upgrades/1.10.2/fix_category_topic_zsets.js b/src/upgrades/1.10.2/fix_category_topic_zsets.js index 999383feac..83b4d7b27f 100644 --- a/src/upgrades/1.10.2/fix_category_topic_zsets.js +++ b/src/upgrades/1.10.2/fix_category_topic_zsets.js @@ -1,5 +1,3 @@ -/* eslint-disable no-await-in-loop */ - 'use strict'; const db = require('../../database'); @@ -13,18 +11,24 @@ module.exports = { const { progress } = this; const topics = require('../../topics'); + progress.total = await db.sortedSetCard('topics:tid'); await batch.processSortedSet('topics:tid', async (tids) => { - for (const tid of tids) { - progress.incr(); - const topicData = await db.getObjectFields(`topic:${tid}`, ['cid', 'pinned', 'postcount']); - if (parseInt(topicData.pinned, 10) !== 1) { + progress.incr(tids.length); + const topicData = await db.getObjectFields( + tids.map(tid => `topic:${tid}`), + ['tid', 'cid', 'pinned', 'postcount'], + ); + const bulkAdd = []; + topicData.forEach((topic) => { + if (topic && parseInt(topic.pinned, 10) !== 1) { topicData.postcount = parseInt(topicData.postcount, 10) || 0; - await db.sortedSetAdd(`cid:${topicData.cid}:tids:posts`, topicData.postcount, tid); + bulkAdd.push([`cid:${topicData.cid}:tids:posts`, topicData.postcount, topicData.tid]); } - await topics.updateLastPostTimeFromLastPid(tid); - } + }); + await db.sortedSetAddBulk(bulkAdd); + await Promise.all(tids.map(tid => topics.updateLastPostTimeFromLastPid(tid))); }, { - progress: progress, + batch: 500, }); }, }; diff --git a/src/upgrades/1.10.2/upgrade_bans_to_hashes.js b/src/upgrades/1.10.2/upgrade_bans_to_hashes.js index 84c7a0ed4d..2bc55b4667 100644 --- a/src/upgrades/1.10.2/upgrade_bans_to_hashes.js +++ b/src/upgrades/1.10.2/upgrade_bans_to_hashes.js @@ -11,14 +11,23 @@ module.exports = { method: async function () { const { progress } = this; + progress.total = await db.sortedSetCard('users:joindate'); + await batch.processSortedSet('users:joindate', async (uids) => { - for (const uid of uids) { - progress.incr(); - const [bans, reasons, userData] = await Promise.all([ - db.getSortedSetRevRangeWithScores(`uid:${uid}:bans`, 0, -1), - db.getSortedSetRevRangeWithScores(`banned:${uid}:reasons`, 0, -1), - db.getObjectFields(`user:${uid}`, ['banned', 'banned:expire', 'joindate', 'lastposttime', 'lastonline']), - ]); + progress.incr(uids.length); + const [allUserData, allBans] = await Promise.all([ + db.getObjectsFields( + uids.map(uid => `user:${uid}`), + ['banned', 'banned:expire', 'joindate', 'lastposttime', 'lastonline'], + ), + db.getSortedSetsMembersWithScores( + uids.map(uid => `uid:${uid}:bans`) + ), + ]); + + await Promise.all(uids.map(async (uid, index) => { + const userData = allUserData[index]; + const bans = allBans[index] || []; // has no history, but is banned, create plain object with just uid and timestmap if (!bans.length && parseInt(userData.banned, 10)) { @@ -31,6 +40,7 @@ module.exports = { const banKey = `uid:${uid}:ban:${banTimestamp}`; await addBan(uid, banKey, { uid: uid, timestamp: banTimestamp }); } else if (bans.length) { + const reasons = await db.getSortedSetRevRangeWithScores(`banned:${uid}:reasons`, 0, -1); // process ban history for (const ban of bans) { const reasonData = reasons.find(reasonData => reasonData.score === ban.score); @@ -46,14 +56,16 @@ module.exports = { await addBan(uid, banKey, data); } } - } + })); }, { - progress: this.progress, + batch: 500, }); }, }; async function addBan(uid, key, data) { - await db.setObject(key, data); - await db.sortedSetAdd(`uid:${uid}:bans:timestamp`, data.timestamp, key); + await Promise.all([ + db.setObject(key, data), + db.sortedSetAdd(`uid:${uid}:bans:timestamp`, data.timestamp, key), + ]); } diff --git a/src/upgrades/1.10.2/username_email_history.js b/src/upgrades/1.10.2/username_email_history.js index 3b03568a69..8ee4306e3d 100644 --- a/src/upgrades/1.10.2/username_email_history.js +++ b/src/upgrades/1.10.2/username_email_history.js @@ -11,27 +11,34 @@ module.exports = { method: async function () { const { progress } = this; - await batch.processSortedSet('users:joindate', async (uids) => { - async function updateHistory(uid, set, fieldName) { - const count = await db.sortedSetCard(set); - if (count <= 0) { - // User has not changed their username/email before, record original username - const userData = await user.getUserFields(uid, [fieldName, 'joindate']); - if (userData && userData.joindate && userData[fieldName]) { - await db.sortedSetAdd(set, userData.joindate, [userData[fieldName], userData.joindate].join(':')); - } - } - } + progress.total = await db.sortedSetCard('users:joindate'); - await Promise.all(uids.map(async (uid) => { - await Promise.all([ - updateHistory(uid, `user:${uid}:usernames`, 'username'), - updateHistory(uid, `user:${uid}:emails`, 'email'), - ]); - progress.incr(); - })); + await batch.processSortedSet('users:joindate', async (uids) => { + const [usernameHistory, emailHistory, userData] = await Promise.all([ + db.sortedSetsCard(uids.map(uid => `user:${uid}:usernames`)), + db.sortedSetsCard(uids.map(uid => `user:${uid}:emails`)), + user.getUsersFields(uids, ['uid', 'username', 'email', 'joindate']), + ]); + + const bulkAdd = []; + userData.forEach((data, index) => { + const thisUsernameHistory = usernameHistory[index]; + const thisEmailHistory = emailHistory[index]; + if (thisUsernameHistory <= 0 && data && data.joindate && data.username) { + bulkAdd.push([ + `user:${data.uid}:usernames`, data.joindate, [data.username, data.joindate].join(':'), + ]); + } + if (thisEmailHistory <= 0 && data && data.joindate && data.email) { + bulkAdd.push([ + `user:${data.uid}:emails`, data.joindate, [data.email, data.joindate].join(':'), + ]); + } + }); + await db.sortedSetAddBulk(bulkAdd); + progress.incr(uids.length); }, { - progress: this.progress, + batch: 500, }); }, }; diff --git a/src/upgrades/1.12.1/clear_username_email_history.js b/src/upgrades/1.12.1/clear_username_email_history.js index 822b500884..0d36534502 100644 --- a/src/upgrades/1.12.1/clear_username_email_history.js +++ b/src/upgrades/1.12.1/clear_username_email_history.js @@ -1,45 +1,32 @@ 'use strict'; -const async = require('async'); + const db = require('../../database'); const user = require('../../user'); +const batch = require('../../batch'); module.exports = { name: 'Delete username email history for deleted users', timestamp: Date.UTC(2019, 2, 25), - method: function (callback) { + method: async function () { const { progress } = this; - let currentUid = 1; - db.getObjectField('global', 'nextUid', (err, nextUid) => { - if (err) { - return callback(err); - } - progress.total = nextUid; - async.whilst((next) => { - next(null, currentUid < nextUid); - }, - (next) => { - progress.incr(); - user.exists(currentUid, (err, exists) => { - if (err) { - return next(err); - } - if (exists) { - currentUid += 1; - return next(); - } - db.deleteAll([`user:${currentUid}:usernames`, `user:${currentUid}:emails`], (err) => { - if (err) { - return next(err); - } - currentUid += 1; - next(); - }); - }); - }, - (err) => { - callback(err); - }); + + progress.total = await db.getObjectField('global', 'nextUid'); + const allUids = []; + for (let i = 1; i < progress.total; i += 1) { + allUids.push(i); + } + await batch.processArray(allUids, async (uids) => { + const exists = await user.exists(uids); + const missingUids = uids.filter((uid, index) => !exists[index]); + const keysToDelete = [ + ...missingUids.map(uid => `user:${uid}:usernames`), + ...missingUids.map(uid => `user:${uid}:emails`), + ]; + await db.deleteAll(keysToDelete); + progress.incr(uids.length); + }, { + batch: 500, }); }, }; diff --git a/src/upgrades/1.12.1/moderation_notes_refactor.js b/src/upgrades/1.12.1/moderation_notes_refactor.js index 390273d74a..85118a9a0c 100644 --- a/src/upgrades/1.12.1/moderation_notes_refactor.js +++ b/src/upgrades/1.12.1/moderation_notes_refactor.js @@ -12,10 +12,12 @@ module.exports = { const { progress } = this; await batch.processSortedSet('users:joindate', async (uids) => { - await Promise.all(uids.map(async (uid) => { - progress.incr(); - - const notes = await db.getSortedSetRevRange(`uid:${uid}:moderation:notes`, 0, -1); + progress.incr(uids.length); + const allNotes = await db.getSortedSetsMembers( + uids.map(uid => `uid:${uid}:moderation:notes`) + ); + await Promise.all(uids.map(async (uid, index) => { + const notes = allNotes[index]; for (const note of notes) { const noteData = JSON.parse(note); noteData.timestamp = noteData.timestamp || Date.now(); diff --git a/src/upgrades/1.13.0/clean_post_topic_hash.js b/src/upgrades/1.13.0/clean_post_topic_hash.js index caa6dbd8f6..20cfd78c22 100644 --- a/src/upgrades/1.13.0/clean_post_topic_hash.js +++ b/src/upgrades/1.13.0/clean_post_topic_hash.js @@ -8,6 +8,7 @@ module.exports = { timestamp: Date.UTC(2019, 9, 7), method: async function () { const { progress } = this; + progress.total = await db.sortedSetCard('posts:pid') + await db.sortedSetCard('topics:tid'); await cleanPost(progress); await cleanTopic(progress); }, @@ -51,7 +52,6 @@ async function cleanPost(progress) { })); }, { batch: 500, - progress: progress, }); } @@ -90,6 +90,5 @@ async function cleanTopic(progress) { })); }, { batch: 500, - progress: progress, }); } diff --git a/src/upgrades/1.6.2/topics_lastposttime_zset.js b/src/upgrades/1.6.2/topics_lastposttime_zset.js index 1dee9feb1a..f299b19c01 100644 --- a/src/upgrades/1.6.2/topics_lastposttime_zset.js +++ b/src/upgrades/1.6.2/topics_lastposttime_zset.js @@ -1,29 +1,30 @@ 'use strict'; -const async = require('async'); - const db = require('../../database'); +const batch = require('../../batch'); module.exports = { name: 'New sorted set cid::tids:lastposttime', timestamp: Date.UTC(2017, 9, 30), - method: function (callback) { + method: async function () { const { progress } = this; + progress.total = await db.sortedSetCard('topics:tid'); - require('../../batch').processSortedSet('topics:tid', (tids, next) => { - async.eachSeries(tids, (tid, next) => { - db.getObjectFields(`topic:${tid}`, ['cid', 'timestamp', 'lastposttime'], (err, topicData) => { - if (err || !topicData) { - return next(err); - } - progress.incr(); - - const timestamp = topicData.lastposttime || topicData.timestamp || Date.now(); - db.sortedSetAdd(`cid:${topicData.cid}:tids:lastposttime`, timestamp, tid, next); - }, next); - }, next); + await batch.processSortedSet('topics:tid', async (tids) => { + const topicData = await db.getObjectsFields( + tids.map(tid => `topic:${tid}`), ['tid', 'cid', 'timestamp', 'lastposttime'] + ); + const bulkAdd = []; + topicData.forEach((data) => { + if (data && data.cid && data.tid) { + const timestamp = data.lastposttime || data.timestamp || Date.now(); + bulkAdd.push([`cid:${data.cid}:tids:lastposttime`, timestamp, data.tid]); + } + }); + await db.sortedSetAddBulk(bulkAdd); + progress.incr(tids.length); }, { - progress: this.progress, - }, callback); + batch: 500, + }); }, }; diff --git a/src/upgrades/1.7.1/notification-settings.js b/src/upgrades/1.7.1/notification-settings.js index fed592effb..e3693d4f04 100644 --- a/src/upgrades/1.7.1/notification-settings.js +++ b/src/upgrades/1.7.1/notification-settings.js @@ -8,23 +8,38 @@ module.exports = { timestamp: Date.UTC(2017, 10, 15), method: async function () { const { progress } = this; - + progress.total = await db.sortedSetCard('users:joindate'); await batch.processSortedSet('users:joindate', async (uids) => { - await Promise.all(uids.map(async (uid) => { - progress.incr(); - const userSettings = await db.getObjectFields(`user:${uid}:settings`, ['sendChatNotifications', 'sendPostNotifications']); - if (userSettings) { + + const userSettings = await db.getObjectsFields( + uids.map(uid => `user:${uid}:settings`), + ['sendChatNotifications', 'sendPostNotifications'], + ); + + const bulkSet = []; + userSettings.forEach((settings, index) => { + const set = {}; + if (settings) { if (parseInt(userSettings.sendChatNotifications, 10) === 1) { - await db.setObjectField(`user:${uid}:settings`, 'notificationType_new-chat', 'notificationemail'); + set['notificationType_new-chat'] = 'notificationemail'; } if (parseInt(userSettings.sendPostNotifications, 10) === 1) { - await db.setObjectField(`user:${uid}:settings`, 'notificationType_new-reply', 'notificationemail'); + set['notificationType_new-reply'] = 'notificationemail'; + } + if (Object.keys(set).length) { + bulkSet.push([`user:${uids[index]}:settings`, set]); } } - await db.deleteObjectFields(`user:${uid}:settings`, ['sendChatNotifications', 'sendPostNotifications']); - })); + }); + await db.setObjectBulk(bulkSet); + + await db.deleteObjectFields( + uids.map(uid => `user:${uid}:settings`), + ['sendChatNotifications', 'sendPostNotifications'], + ); + + progress.incr(uids.length); }, { - progress: progress, batch: 500, }); }, diff --git a/src/upgrades/1.7.3/topic_votes.js b/src/upgrades/1.7.3/topic_votes.js index 008aaece0a..d5f6b9fd57 100644 --- a/src/upgrades/1.7.3/topic_votes.js +++ b/src/upgrades/1.7.3/topic_votes.js @@ -10,32 +10,42 @@ module.exports = { method: async function () { const { progress } = this; - batch.processSortedSet('topics:tid', async (tids) => { - await Promise.all(tids.map(async (tid) => { - progress.incr(); - const topicData = await db.getObjectFields(`topic:${tid}`, ['mainPid', 'cid', 'pinned']); - if (topicData.mainPid && topicData.cid) { - const postData = await db.getObject(`post:${topicData.mainPid}`); - if (postData) { - const upvotes = parseInt(postData.upvotes, 10) || 0; - const downvotes = parseInt(postData.downvotes, 10) || 0; - const data = { - upvotes: upvotes, - downvotes: downvotes, - }; - const votes = upvotes - downvotes; - await Promise.all([ - db.setObject(`topic:${tid}`, data), - db.sortedSetAdd('topics:votes', votes, tid), - ]); - if (parseInt(topicData.pinned, 10) !== 1) { - await db.sortedSetAdd(`cid:${topicData.cid}:tids:votes`, votes, tid); - } + progress.total = await db.sortedSetCard('topics:tid'); + + await batch.processSortedSet('topics:tid', async (tids) => { + const topicsData = await db.getObjectsFields( + tids.map(tid => `topic:${tid}`), + ['tid', 'mainPid', 'cid', 'pinned'], + ); + const mainPids = topicsData.map(topicData => topicData && topicData.mainPid); + const mainPosts = await db.getObjects(mainPids.map(pid => `post:${pid}`)); + + const bulkSet = []; + const bulkAdd = []; + + topicsData.forEach((topicData, index) => { + const mainPost = mainPosts[index]; + if (mainPost && topicData && topicData.cid) { + const upvotes = parseInt(mainPost.upvotes, 10) || 0; + const downvotes = parseInt(mainPost.downvotes, 10) || 0; + const data = { + upvotes: upvotes, + downvotes: downvotes, + }; + const votes = upvotes - downvotes; + bulkSet.push([`topic:${topicData.tid}`, data]); + bulkAdd.push(['topics:votes', votes, topicData.tid]); + if (parseInt(topicData.pinned, 10) !== 1) { + bulkAdd.push([`cid:${topicData.cid}:tids:votes`, votes, topicData.tid]); } } - })); + }); + + await db.setObjectBulk(bulkSet); + await db.sortedSetAddBulk('topics:votes', bulkAdd); + + progress.incr(tids.length); }, { - progress: progress, batch: 500, }); }, diff --git a/src/upgrades/1.8.1/diffs_zset_to_listhash.js b/src/upgrades/1.8.1/diffs_zset_to_listhash.js index 370242fba1..277418a79e 100644 --- a/src/upgrades/1.8.1/diffs_zset_to_listhash.js +++ b/src/upgrades/1.8.1/diffs_zset_to_listhash.js @@ -1,57 +1,40 @@ 'use strict'; -const async = require('async'); const db = require('../../database'); const batch = require('../../batch'); - module.exports = { name: 'Reformatting post diffs to be stored in lists and hash instead of single zset', timestamp: Date.UTC(2018, 2, 15), - method: function (callback) { + method: async function () { const { progress } = this; - batch.processSortedSet('posts:pid', (pids, next) => { - async.each(pids, (pid, next) => { - db.getSortedSetRangeWithScores(`post:${pid}:diffs`, 0, -1, (err, diffs) => { - if (err) { - return next(err); - } + progress.total = await db.sortedSetCard('posts:pid'); - if (!diffs || !diffs.length) { - progress.incr(); - return next(); - } + await batch.processSortedSet('posts:pid', async (pids) => { + const postDiffs = await db.getSortedSetsMembersWithScores( + pids.map(pid => `post:${pid}:diffs`), + ); - // For each diff, push to list - async.each(diffs, (diff, next) => { - async.series([ - async.apply(db.delete.bind(db), `post:${pid}:diffs`), - async.apply(db.listPrepend.bind(db), `post:${pid}:diffs`, diff.score), - async.apply(db.setObject.bind(db), `diff:${pid}.${diff.score}`, { - pid: pid, - patch: diff.value, - }), - ], next); - }, (err) => { - if (err) { - return next(err); - } + await db.deleteAll(pids.map(pid => `post:${pid}:diffs`)); - progress.incr(); - return next(); - }); - }); - }, (err) => { - if (err) { - // Probably type error, ok to incr and continue - progress.incr(); + await Promise.all(postDiffs.map(async (diffs, index) => { + if (!diffs || !diffs.length) { + return; } - - return next(); - }); + diffs.reverse(); + const pid = pids[index]; + await db.listAppend(`post:${pid}:diffs`, diffs.map(d => d.score)); + await db.setObjectBulk( + diffs.map(d => ([`diff:${pid}.${d.score}`, { + pid: pid, + patch: d.value, + }])) + ); + })); + progress.incr(pids.length); }, { - progress: progress, - }, callback); + batch: 500, + }); }, }; diff --git a/src/upgrades/1.9.0/refresh_post_upload_associations.js b/src/upgrades/1.9.0/refresh_post_upload_associations.js index 44acfc079f..6183529641 100644 --- a/src/upgrades/1.9.0/refresh_post_upload_associations.js +++ b/src/upgrades/1.9.0/refresh_post_upload_associations.js @@ -1,21 +1,20 @@ 'use strict'; -const async = require('async'); +const db = require('../../database'); const posts = require('../../posts'); +const batch = require('../../batch'); module.exports = { name: 'Refresh post-upload associations', timestamp: Date.UTC(2018, 3, 16), - method: function (callback) { + method: async function () { const { progress } = this; - - require('../../batch').processSortedSet('posts:pid', (pids, next) => { - async.each(pids, (pid, next) => { - posts.uploads.sync(pid, next); - progress.incr(); - }, next); + progress.total = await db.sortedSetCard('posts:pid'); + await batch.processSortedSet('posts:pid', async (pids) => { + await Promise.all(pids.map(pid => posts.uploads.sync(pid))); + progress.incr(pids.length); }, { - progress: this.progress, - }, callback); + batch: 500, + }); }, }; diff --git a/src/upgrades/2.8.7/fix-email-sorted-sets.js b/src/upgrades/2.8.7/fix-email-sorted-sets.js index fcab69a8f4..84919e6774 100644 --- a/src/upgrades/2.8.7/fix-email-sorted-sets.js +++ b/src/upgrades/2.8.7/fix-email-sorted-sets.js @@ -26,7 +26,7 @@ module.exports = { } // user has email but doesn't match whats stored in user hash, gh#11259 - if (userData.email && userData.email.toLowerCase() !== email.toLowerCase()) { + if (userData.email && email && String(userData.email).toLowerCase() !== email.toLowerCase()) { bulkRemove.push(['email:uid', email]); bulkRemove.push(['email:sorted', `${email.toLowerCase()}:${uid}`]); } diff --git a/src/upgrades/3.7.0/category-read-by-uid.js b/src/upgrades/3.7.0/category-read-by-uid.js index 4ef564f53a..971620613e 100644 --- a/src/upgrades/3.7.0/category-read-by-uid.js +++ b/src/upgrades/3.7.0/category-read-by-uid.js @@ -9,6 +9,7 @@ module.exports = { method: async function () { const { progress } = this; const nextCid = await db.getObjectField('global', 'nextCid'); + progress.total = nextCid; const allCids = []; for (let i = 1; i <= nextCid; i++) { allCids.push(i); @@ -18,7 +19,6 @@ module.exports = { progress.incr(cids.length); }, { batch: 500, - progress, }); }, }; diff --git a/src/views/admin/manage/category.tpl b/src/views/admin/manage/category.tpl index 5d3b84c639..b935638ac4 100644 --- a/src/views/admin/manage/category.tpl +++ b/src/views/admin/manage/category.tpl @@ -46,6 +46,16 @@

+
+ + +

+ [[admin/manage/categories:topic-template.help]] +

+
+
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)); } diff --git a/test/controllers.js b/test/controllers.js index b2174d8cf9..d79cf6de04 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`);