diff --git a/CHANGELOG.md b/CHANGELOG.md
index 783b4dd082..d8de9449cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,44 @@
+#### v4.0.4 (2025-02-17)
+
+##### Chores
+
+* up harmony (0fed9a76)
+* up harmony (ef2c606d)
+* up harmony (f1da510f)
+* up deps (fa366095)
+* up harmony (df07fcfa)
+* up harmony (de5caf8f)
+* up harmony (d1f78295)
+* incrementing version number - v4.0.3 (2b65c735)
+* update changelog for v4.0.3 (123e1635)
+* incrementing version number - v4.0.2 (73fe5fcf)
+* incrementing version number - v4.0.1 (a461b758)
+* incrementing version number - v4.0.0 (c1eaee45)
+* **i18n:** fallback strings for new resources: nodebb.themes-harmony (99210918)
+
+##### Bug Fixes
+
+* clear parsed post cache when updating a post's attachments, #13164 (33d7b9b3)
+* logic failure causing remote posts with image to not parse properly, #13164 (d936d5c0)
+* change the passed-in notificatiom id for `notifyTagFollowers` to contain the list of matched tags (04f51cc6)
+* actor.prune, dont try deleting same users (ffbe4b7b)
+* getLocalFollowCounts, show non existing deletes (cfbb8ff8)
+* return null if field isn't in hash (70a9f6d3)
+* getUserField so that it always returns null (e85662a5)
+* isArray check (224910b1)
+* sanity-check the id when mocking a post (5cbf3dd7)
+* missing actor on some local activities when federating out (040584f0)
+
+##### Performance Improvements
+
+* closes #13145, reduce calls in actors.prune (d590c2af)
+
+##### Refactors
+
+* single remove (77dd6dd0)
+* cleanup ip:recent (d8724708)
+* hooks button (c4b01330)
+
#### v4.0.3 (2025-02-09)
##### Chores
diff --git a/install/package.json b/install/package.json
index 94b5101af9..715b472581 100644
--- a/install/package.json
+++ b/install/package.json
@@ -99,8 +99,8 @@
"multiparty": "4.2.3",
"nconf": "0.12.1",
"nodebb-plugin-2factor": "7.5.9",
- "nodebb-plugin-composer-default": "10.2.45",
- "nodebb-plugin-dbsearch": "6.2.9",
+ "nodebb-plugin-composer-default": "10.2.46",
+ "nodebb-plugin-dbsearch": "6.2.12",
"nodebb-plugin-emoji": "6.0.2",
"nodebb-plugin-emoji-android": "4.1.1",
"nodebb-plugin-markdown": "13.1.0",
@@ -108,11 +108,11 @@
"nodebb-plugin-spam-be-gone": "2.3.1",
"nodebb-plugin-web-push": "0.7.2",
"nodebb-rewards-essentials": "1.0.1",
- "nodebb-theme-harmony": "2.0.25",
+ "nodebb-theme-harmony": "2.0.28",
"nodebb-theme-lavender": "7.1.17",
- "nodebb-theme-peace": "2.2.38",
- "nodebb-theme-persona": "14.0.14",
- "nodebb-widget-essentials": "7.0.32",
+ "nodebb-theme-peace": "2.2.39",
+ "nodebb-theme-persona": "14.0.15",
+ "nodebb-widget-essentials": "7.0.34",
"nodemailer": "6.9.16",
"nprogress": "0.2.0",
"passport": "0.7.0",
@@ -200,4 +200,4 @@
"url": "https://github.com/barisusakli"
}
]
-}
\ No newline at end of file
+}
diff --git a/public/src/modules/quickreply.js b/public/src/modules/quickreply.js
index ee12097eff..b0d5b1a672 100644
--- a/public/src/modules/quickreply.js
+++ b/public/src/modules/quickreply.js
@@ -38,6 +38,7 @@ define('quickreply', [
});
uploadHelpers.init({
+ uploadBtnEl: $('[component="topic/quickreply/upload/button"]'),
dragDropAreaEl: $('[component="topic/quickreply/container"] .quickreply-message'),
pasteEl: element,
uploadFormEl: $('[component="topic/quickreply/upload"]'),
diff --git a/public/src/modules/uploadHelpers.js b/public/src/modules/uploadHelpers.js
index ce6cb08476..49ed9fd9fb 100644
--- a/public/src/modules/uploadHelpers.js
+++ b/public/src/modules/uploadHelpers.js
@@ -41,6 +41,7 @@ define('uploadHelpers', ['alerts'], function (alerts) {
const fileInput = formEl.find('input[name="files[]"]');
options.uploadBtnEl.on('click', function () {
fileInput.trigger('click');
+ return false;
});
fileInput.on('change', function (e) {
const files = (e.target || {}).files ||
diff --git a/src/activitypub/contexts.js b/src/activitypub/contexts.js
index 12dcae1059..b6cb1890e6 100644
--- a/src/activitypub/contexts.js
+++ b/src/activitypub/contexts.js
@@ -55,8 +55,20 @@ Contexts.getItems = async (uid, id, options) => {
options.root = true;
}
- activitypub.helpers.log(`[activitypub/context] Retrieving context ${id}`);
- let { type, items, orderedItems, first, next } = await activitypub.get('uid', uid, id);
+ // Page object instead of id
+ let object;
+ if (!id && options.object) {
+ object = options.object;
+ } else {
+ activitypub.helpers.log(`[activitypub/context] Retrieving context/page ${id}`);
+ try {
+ object = await activitypub.get('uid', uid, id);
+ } catch (e) {
+ return false;
+ }
+ }
+ let { type, items, orderedItems, first, next } = object;
+
if (!acceptableTypes.includes(type)) {
return false;
}
@@ -84,14 +96,18 @@ Contexts.getItems = async (uid, id, options) => {
if (next) {
activitypub.helpers.log('[activitypub/context] Fetching next page...');
+ const isUrl = activitypub.helpers.isUri(next);
Array
- .from(await Contexts.getItems(uid, next, {
+ .from(await Contexts.getItems(uid, isUrl && next, {
...options,
root: false,
+ object: !isUrl && next,
}))
.forEach((item) => {
chain.add(item);
});
+
+ return chain;
}
// Handle special case where originating object is not actually part of the context collection
diff --git a/src/activitypub/helpers.js b/src/activitypub/helpers.js
index dd0b0c05ff..79aebc25dc 100644
--- a/src/activitypub/helpers.js
+++ b/src/activitypub/helpers.js
@@ -28,6 +28,16 @@ const sha256 = payload => crypto.createHash('sha256').update(payload).digest('he
const Helpers = module.exports;
+Helpers._test = (method, args) => {
+ // because I am lazy and I probably wrote some variant of this below code 1000 times already
+ setTimeout(async () => {
+ console.log(await method.apply(method, args));
+ }, 2500);
+};
+// process.nextTick(() => {
+// Helpers._test(activitypub.notes.assert, [1, `https://`]);
+// });
+
let _lastLog;
Helpers.log = (message) => {
if (!message) {
@@ -54,6 +64,11 @@ Helpers.isUri = (value) => {
});
};
+Helpers.assertAccept = accept => (accept && accept.split(',').some((value) => {
+ const parts = value.split(';').map(v => v.trim());
+ return activitypub._constants.acceptableTypes.includes(value || parts[0]);
+}));
+
Helpers.isWebfinger = (value) => {
// N.B. returns normalized handle, so truthy check!
if (webfingerRegex.test(value) && !Helpers.isUri(value)) {
diff --git a/src/activitypub/index.js b/src/activitypub/index.js
index 3eec80eae1..eb60b7a1a6 100644
--- a/src/activitypub/index.js
+++ b/src/activitypub/index.js
@@ -59,14 +59,21 @@ ActivityPub.instances = require('./instances');
ActivityPub.startJobs = () => {
ActivityPub.helpers.log('[activitypub/jobs] Registering jobs.');
new CronJob('0 0 * * *', async () => {
+ if (!meta.config.activitypubEnabled) {
+ return;
+ }
try {
await ActivityPub.notes.prune();
+ await db.sortedSetsRemoveRangeByScore(['activities:datetime'], '-inf', Date.now() - 604800000);
} catch (err) {
winston.error(err.stack);
}
}, null, true, null, null, false); // change last argument to true for debugging
new CronJob('*/30 * * * *', async () => {
+ if (!meta.config.activitypubEnabled) {
+ return;
+ }
try {
await ActivityPub.actors.prune();
} catch (err) {
diff --git a/src/activitypub/mocks.js b/src/activitypub/mocks.js
index 003d392333..223d1a8cbd 100644
--- a/src/activitypub/mocks.js
+++ b/src/activitypub/mocks.js
@@ -341,23 +341,24 @@ Mocks.actors.category = async (cid) => {
} = await categories.getCategoryData(cid);
const publicKey = await activitypub.getPublicKey('cid', cid);
- let image;
+ let icon;
if (backgroundImage) {
const filename = path.basename(utils.decodeHTMLEntities(backgroundImage));
- image = {
+ icon = {
type: 'Image',
mediaType: mime.getType(filename),
url: `${nconf.get('url')}${utils.decodeHTMLEntities(backgroundImage)}`,
};
+ } else {
+ icon = await categories.icons.get(cid);
+ icon = icon.get('png');
+ icon = {
+ type: 'Image',
+ mediaType: 'image/png',
+ url: `${nconf.get('url')}${icon}`,
+ };
}
- let icon = await categories.icons.get(cid);
- icon = icon.get('png');
- icon = {
- type: 'Image',
- mediaType: 'image/png',
- url: `${nconf.get('url')}${icon}`,
- };
return {
'@context': [
@@ -375,7 +376,7 @@ Mocks.actors.category = async (cid) => {
name,
preferredUsername,
summary,
- image,
+ // image, // todo once categories have cover photos
icon,
publicKey: {
diff --git a/src/activitypub/notes.js b/src/activitypub/notes.js
index 03cca7d9d8..2b6a0fceaa 100644
--- a/src/activitypub/notes.js
+++ b/src/activitypub/notes.js
@@ -71,7 +71,7 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => {
}
// Reorder chain items by timestamp
- // chain = chain.sort((a, b) => a.timestamp - b.timestamp);
+ chain = chain.sort((a, b) => a.timestamp - b.timestamp);
const mainPost = chain[0];
let { pid: mainPid, tid, uid: authorId, timestamp, name, content, sourceContent, _activitypub } = mainPost;
@@ -229,6 +229,10 @@ Notes.assertPrivate = async (object) => {
// Given an object, adds it to an existing chat or creates a new chat otherwise
// todo: context stuff
+ if (!object || !object.id || !activitypub.helpers.isUri(object.id)) {
+ return null;
+ }
+
const localUids = [];
const recipients = new Set([...object.to, ...object.cc]);
await Promise.all(Array.from(recipients).map(async (value) => {
diff --git a/src/analytics.js b/src/analytics.js
index 45e8f698d9..b70aec7e5c 100644
--- a/src/analytics.js
+++ b/src/analytics.js
@@ -28,8 +28,11 @@ const total = _.cloneDeep(local);
const runJobs = nconf.get('runJobs');
+Analytics.pause = false;
+
Analytics.init = async function () {
new cronJob('*/10 * * * * *', (async () => {
+ if (Analytics.pause) return;
publishLocalAnalytics();
if (runJobs) {
await sleep(2000);
diff --git a/src/api/utils.js b/src/api/utils.js
index 67e496a5f5..06d6ce741d 100644
--- a/src/api/utils.js
+++ b/src/api/utils.js
@@ -52,6 +52,9 @@ utils.tokens.get = async (tokens) => {
};
utils.tokens.generate = async ({ uid, description }) => {
+ if (!srcUtils.isNumber(uid)) {
+ throw new Error('[[error:invalid-uid]]');
+ }
if (parseInt(uid, 10) !== 0) {
const uidExists = await user.exists(uid);
if (!uidExists) {
@@ -66,7 +69,7 @@ utils.tokens.generate = async ({ uid, description }) => {
};
utils.tokens.add = async ({ token, uid, description = '', timestamp = Date.now() }) => {
- if (!token || uid === undefined) {
+ if (!token || uid === undefined || !srcUtils.isNumber(uid)) {
throw new Error('[[error:invalid-data]]');
}
@@ -80,6 +83,9 @@ utils.tokens.add = async ({ token, uid, description = '', timestamp = Date.now()
};
utils.tokens.update = async (token, { uid, description }) => {
+ if (!srcUtils.isNumber(uid)) {
+ throw new Error('[[error:invalid-uid]]');
+ }
await Promise.all([
db.setObject(`token:${token}`, { uid, description }),
db.sortedSetAdd(`tokens:uid`, uid, token),
diff --git a/src/categories/delete.js b/src/categories/delete.js
index a03d96ee37..6581098c10 100644
--- a/src/categories/delete.js
+++ b/src/categories/delete.js
@@ -31,6 +31,9 @@ module.exports = function (Categories) {
if (categoryData && categoryData.name) {
bulkRemove.push(['categories:name', `${categoryData.name.slice(0, 200).toLowerCase()}:${cid}`]);
}
+ if (categoryData && categoryData.handle) {
+ bulkRemove.push(['categoryhandle:cid', categoryData.handle]);
+ }
await db.sortedSetRemoveBulk(bulkRemove);
await removeFromParent(cid);
diff --git a/src/controllers/404.js b/src/controllers/404.js
index becc206e76..bed1a085e3 100644
--- a/src/controllers/404.js
+++ b/src/controllers/404.js
@@ -6,6 +6,7 @@ const validator = require('validator');
const meta = require('../meta');
const plugins = require('../plugins');
+const activitypub = require('../activitypub');
const middleware = require('../middleware');
const helpers = require('../middleware/helpers');
const { secureRandom } = require('../utils');
@@ -24,6 +25,12 @@ exports.handle404 = helpers.try(async (req, res) => {
if (isClientScript.test(req.url)) {
res.type('text/javascript').status(404).send('Not Found');
+ } else if (
+ activitypub.helpers.assertAccept(req.headers.accept) ||
+ (req.headers['Content-Type'] && activitypub._constants.acceptableTypes.includes(req.headers['Content-Type']))
+ ) {
+ // todo: separate logging of AP 404s
+ res.sendStatus(404);
} else if (
!res.locals.isAPI && (
req.path.startsWith(`${relativePath}/assets/uploads`) ||
diff --git a/src/controllers/globalmods.js b/src/controllers/globalmods.js
index 2ad0d54b4f..5b6b1ee607 100644
--- a/src/controllers/globalmods.js
+++ b/src/controllers/globalmods.js
@@ -1,5 +1,7 @@
'use strict';
+const validator = require('validator');
+
const user = require('../user');
const meta = require('../meta');
const analytics = require('../analytics');
@@ -20,7 +22,7 @@ globalModsController.ipBlacklist = async function (req, res, next) {
]);
res.render('ip-blacklist', {
title: '[[pages:ip-blacklist]]',
- rules: rules,
+ rules: validator.escape(String(rules)),
analytics: analyticsData,
breadcrumbs: helpers.buildBreadcrumbs([{ text: '[[pages:ip-blacklist]]' }]),
});
diff --git a/src/middleware/activitypub.js b/src/middleware/activitypub.js
index f9b8dcd009..05f67d3338 100644
--- a/src/middleware/activitypub.js
+++ b/src/middleware/activitypub.js
@@ -16,10 +16,8 @@ middleware.assertS2S = async function (req, res, next) {
return next('route');
}
- const pass = (accept && accept.split(',').some((value) => {
- const parts = value.split(';').map(v => v.trim());
- return activitypub._constants.acceptableTypes.includes(value || parts[0]);
- })) || (contentType && activitypub._constants.acceptableTypes.includes(contentType));
+ const pass = activitypub.helpers.assertAccept(accept) ||
+ (contentType && activitypub._constants.acceptableTypes.includes(contentType));
if (!pass) {
return next('route');
diff --git a/src/pagination.js b/src/pagination.js
index bed225560a..c9f082a9a4 100644
--- a/src/pagination.js
+++ b/src/pagination.js
@@ -29,8 +29,7 @@ pagination.create = function (currentPage, pageCount, queryObj) {
if (startPage > pageCount - 5) {
startPage -= 2 - (pageCount - currentPage);
}
- let i;
- for (i = 0; i < 5; i += 1) {
+ for (let i = 0; i < 5; i += 1) {
pagesToShow.push(startPage + i);
}
@@ -45,10 +44,11 @@ pagination.create = function (currentPage, pageCount, queryObj) {
return { page: page, active: page === currentPage, qs: qs.stringify(queryObj) };
});
- for (i = pages.length - 1; i > 0; i -= 1) {
+ for (let i = pages.length - 1; i > 0; i -= 1) {
+ const prevPage = pages[i].page - 1;
if (pages[i].page - 2 === pages[i - 1].page) {
- pages.splice(i, 0, { page: pages[i].page - 1, active: false, qs: qs.stringify(queryObj) });
- } else if (pages[i].page - 1 !== pages[i - 1].page) {
+ pages.splice(i, 0, { page: prevPage, active: false, qs: qs.stringify({ ...queryObj, page: prevPage }) });
+ } else if (prevPage !== pages[i - 1].page) {
pages.splice(i, 0, { separator: true });
}
}
diff --git a/src/posts/create.js b/src/posts/create.js
index 869fdf3a05..656ae68ab0 100644
--- a/src/posts/create.js
+++ b/src/posts/create.js
@@ -11,8 +11,6 @@ const privileges = require('../privileges');
const activitypub = require('../activitypub');
const utils = require('../utils');
-const isEmojiShortcode = /^:[\w]+:$/;
-
module.exports = function (Posts) {
Posts.create = async function (data) {
// This is an internal method, consider using Topics.reply instead
@@ -54,9 +52,15 @@ module.exports = function (Posts) {
if (_activitypub && _activitypub.tag && Array.isArray(_activitypub.tag)) {
_activitypub.tag
.filter(tag => tag.type === 'Emoji' &&
- isEmojiShortcode.test(tag.name) &&
- tag.icon && tag.icon.mediaType && tag.icon.mediaType.startsWith('image/'))
+ tag.icon && tag.icon.type === 'Image')
.forEach((tag) => {
+ if (!tag.name.startsWith(':')) {
+ tag.name = `:${tag.name}`;
+ }
+ if (!tag.name.endsWith(':')) {
+ tag.name = `${tag.name}:`;
+ }
+
postData.content = postData.content.replace(new RegExp(tag.name, 'g'), ``);
});
}
diff --git a/src/topics/posts.js b/src/topics/posts.js
index bbbeb9c636..e32c18e727 100644
--- a/src/topics/posts.js
+++ b/src/topics/posts.js
@@ -192,7 +192,7 @@ module.exports = function (Topics) {
const pidToPrivs = _.zipObject(parentPids, postPrivileges);
parentPids = parentPids.filter(p => pidToPrivs[p]['topics:read']);
- const parentPosts = await posts.getPostsFields(parentPids, ['uid', 'pid', 'timestamp', 'content', 'deleted']);
+ const parentPosts = await posts.getPostsFields(parentPids, ['uid', 'pid', 'timestamp', 'content', 'sourceContent', 'deleted']);
const parentUids = _.uniq(parentPosts.map(postObj => postObj && postObj.uid));
const userData = await user.getUsersFields(parentUids, ['username', 'userslug', 'picture']);
diff --git a/src/views/admin/partials/edit-token-modal.tpl b/src/views/admin/partials/edit-token-modal.tpl
index 87644f6dcd..22c100ccab 100644
--- a/src/views/admin/partials/edit-token-modal.tpl
+++ b/src/views/admin/partials/edit-token-modal.tpl
@@ -1,13 +1,13 @@