diff --git a/CHANGELOG.md b/CHANGELOG.md
index d8de9449cc..34402b38c9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,48 @@
+#### v4.0.5 (2025-02-20)
+
+##### Chores
+
+* bump composer to 10.2.46 for #13132 (7520e4f6)
+* up harmony (f82f00e5)
+* up widgets (e23a14c1)
+* up harmony (c0996a80)
+* up dbsearch (d0a9ddea)
+* up dbsearch (310fab65)
+* add test helper to activitypub file (4bc0031f)
+* incrementing version number - v4.0.4 (b1125cce)
+* update changelog for v4.0.4 (d3b69a39)
+* 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
+
+* add upload button to quickreply (f67a0a12)
+* remove activities older than a week (9997189a)
+
+##### Bug Fixes
+
+* typo (e63f1234)
+* #13136, do not log 404s for AP requests (93f48409)
+* #13129, serve category backgroundImage as actor `icon`, not `image` (b8200095)
+* escape ip blacklist rules (625f4751)
+* closes #13180, don't execute cron jobs if ap disabled (a410587c)
+* #13172, Topics.addParentPosts not sending sourceContent in calling parsePosts (bb9687bd)
+* #13179, fix context resolution failure bug with frequency (6245e33d)
+* add back chronological sorting of asserted notes (de6e63bb)
+* #13170, remove mime-type and regex test for "Emoji" attachment, wrap tag name in colons if not provided (92708d2f)
+* closes #13176, check if uid is number when creating tokens (80cc1d34)
+* notes.assertPrivate sanity checks (5e71d597)
+* page index for single page, closes #13173 (b0e8058f)
+* remove handle on category purge (4134a075)
+
+##### Tests
+
+* dont clear local when testing (669755d1)
+* show objects on fail (f2824073)
+* wait after post request (64318242)
+
#### v4.0.4 (2025-02-17)
##### Chores
diff --git a/install/package.json b/install/package.json
index 23cdac6288..74801adb49 100644
--- a/install/package.json
+++ b/install/package.json
@@ -39,24 +39,24 @@
"@textcomplete/contenteditable": "0.1.13",
"@textcomplete/core": "0.1.13",
"@textcomplete/textarea": "0.1.13",
- "ace-builds": "1.37.5",
+ "ace-builds": "1.38.0",
"archiver": "7.0.1",
"async": "3.2.6",
"autoprefixer": "10.4.20",
"bcryptjs": "2.4.3",
- "benchpressjs": "2.5.1",
+ "benchpressjs": "2.5.3",
"body-parser": "1.20.3",
"bootbox": "6.0.0",
"bootstrap": "5.3.3",
"bootswatch": "5.3.3",
"chalk": "4.1.2",
- "chart.js": "4.4.7",
+ "chart.js": "4.4.8",
"cli-graph": "3.2.2",
"clipboard": "2.0.11",
"colors": "1.4.0",
"commander": "12.1.0",
"compare-versions": "6.1.1",
- "compression": "1.7.5",
+ "compression": "1.8.0",
"connect-flash": "0.1.1",
"connect-mongo": "5.1.0",
"connect-multiparty": "2.2.0",
@@ -68,7 +68,7 @@
"csrf-sync": "4.0.3",
"daemon": "1.1.0",
"diff": "7.0.0",
- "esbuild": "0.24.2",
+ "esbuild": "0.25.0",
"express": "4.21.2",
"express-session": "1.18.1",
"express-useragent": "1.0.15",
@@ -93,45 +93,45 @@
"lru-cache": "10.4.3",
"mime": "3.0.0",
"mkdirp": "3.0.1",
- "mongodb": "6.12.0",
+ "mongodb": "6.13.1",
"morgan": "1.10.0",
"mousetrap": "1.6.5",
"multiparty": "4.2.3",
"nconf": "0.12.1",
"nodebb-plugin-2factor": "7.5.9",
- "nodebb-plugin-composer-default": "10.2.46",
- "nodebb-plugin-dbsearch": "6.2.12",
+ "nodebb-plugin-composer-default": "10.2.47",
+ "nodebb-plugin-dbsearch": "6.2.13",
"nodebb-plugin-emoji": "6.0.2",
"nodebb-plugin-emoji-android": "4.1.1",
"nodebb-plugin-markdown": "13.1.0",
- "nodebb-plugin-mentions": "4.6.10",
+ "nodebb-plugin-mentions": "4.7.0",
"nodebb-plugin-spam-be-gone": "2.3.1",
- "nodebb-plugin-web-push": "0.7.2",
+ "nodebb-plugin-web-push": "0.7.3",
"nodebb-rewards-essentials": "1.0.1",
- "nodebb-theme-harmony": "2.0.28",
+ "nodebb-theme-harmony": "2.0.37",
"nodebb-theme-lavender": "7.1.17",
"nodebb-theme-peace": "2.2.39",
"nodebb-theme-persona": "14.0.15",
- "nodebb-widget-essentials": "7.0.34",
- "nodemailer": "6.9.16",
+ "nodebb-widget-essentials": "7.0.35",
+ "nodemailer": "6.10.0",
"nprogress": "0.2.0",
"passport": "0.7.0",
"passport-http-bearer": "1.0.1",
"passport-local": "1.0.0",
- "pg": "8.13.1",
- "pg-cursor": "2.12.1",
- "postcss": "8.5.1",
+ "pg": "8.13.3",
+ "pg-cursor": "2.12.3",
+ "postcss": "8.5.3",
"postcss-clean": "1.2.0",
"progress-webpack-plugin": "1.0.16",
"prompt": "1.3.0",
- "ioredis": "5.4.2",
+ "ioredis": "5.5.0",
"rimraf": "5.0.10",
"rss": "1.2.2",
"rtlcss": "4.3.0",
"sanitize-html": "2.14.0",
- "sass": "1.83.4",
+ "sass": "1.85.0",
"satori": "0.12.1",
- "semver": "7.6.3",
+ "semver": "7.7.1",
"serve-favicon": "2.5.0",
"sharp": "0.32.6",
"sitemap": "8.0.0",
@@ -146,9 +146,9 @@
"timeago": "1.6.7",
"tinycon": "0.6.8",
"toobusy-js": "0.5.1",
- "tough-cookie": "5.1.0",
+ "tough-cookie": "5.1.1",
"validator": "13.12.0",
- "webpack": "5.97.1",
+ "webpack": "5.98.0",
"webpack-merge": "6.0.1",
"winston": "3.17.0",
"workerpool": "9.2.0",
diff --git a/public/language/en-GB/category.json b/public/language/en-GB/category.json
index 7e3c6630c5..f6cea780cb 100644
--- a/public/language/en-GB/category.json
+++ b/public/language/en-GB/category.json
@@ -1,7 +1,8 @@
{
"category": "Category",
"subcategories": "Subcategories",
-
+ "uncategorized": "Uncategorized",
+ "uncategorized.description": "Topics that do not strictly fit in with any existing categories",
"new-topic-button": "New Topic",
"guest-login-post": "Log in to post",
"no-topics": "There are no topics in this category.
Why don't you try posting one?",
diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json
index caa23964b4..d511ef8300 100644
--- a/public/language/en-GB/error.json
+++ b/public/language/en-GB/error.json
@@ -268,6 +268,7 @@
"invalid-plugin-id": "Invalid plugin ID",
"plugin-not-whitelisted": "Unable to install plugin – only plugins whitelisted by the NodeBB Package Manager can be installed via the ACP",
+ "plugin-installation-via-acp-disabled": "Plugin installation via ACP is disabled",
"plugins-set-in-configuration": "You are not allowed to change plugin state as they are defined at runtime (config.json, environmental variables or terminal arguments), please modify the configuration instead.",
"theme-not-set-in-configuration": "When defining active plugins in configuration, changing themes requires adding the new theme to the list of active plugins before updating it in the ACP",
diff --git a/public/openapi/components/schemas/PostObject.yaml b/public/openapi/components/schemas/PostObject.yaml
index 00f840127a..502ea8044d 100644
--- a/public/openapi/components/schemas/PostObject.yaml
+++ b/public/openapi/components/schemas/PostObject.yaml
@@ -18,6 +18,9 @@ PostObject:
For posts received via ActivityPub, it is the url of the original piece of content.
content:
type: string
+ sourceContent:
+ type: string
+ nullable: true
uid:
type: number
description: A user identifier
diff --git a/public/openapi/read/admin/development/info.yaml b/public/openapi/read/admin/development/info.yaml
index 81b9e3f49e..1eeb77c1b3 100644
--- a/public/openapi/read/admin/development/info.yaml
+++ b/public/openapi/read/admin/development/info.yaml
@@ -98,6 +98,8 @@ get:
type: boolean
jobsDisabled:
type: boolean
+ acpPluginInstallDisabled:
+ type: boolean
git:
type: object
properties:
diff --git a/public/src/client/topic/threadTools.js b/public/src/client/topic/threadTools.js
index 38db844cdd..a83a9d86b5 100644
--- a/public/src/client/topic/threadTools.js
+++ b/public/src/client/topic/threadTools.js
@@ -85,12 +85,24 @@ define('forum/topic/threadTools', [
topicContainer.on('click', '[component="topic/event/delete"]', function () {
const eventId = $(this).attr('data-topic-event-id');
- const eventEl = $(this).parents('[component="topic/event"]');
+ const eventEl = $(this).parents('[data-topic-event-id]');
bootbox.confirm('[[topic:delete-event-confirm]]', (ok) => {
if (ok) {
api.del(`/topics/${tid}/events/${eventId}`, {})
.then(function () {
+ const itemsParent = eventEl.parents('[component="topic/event/items"]');
eventEl.remove();
+ if (itemsParent.length) {
+ const childrenCount = itemsParent.children().length;
+ const eventParent = itemsParent.parents('[component="topic/event"]');
+ if (!childrenCount) {
+ eventParent.remove();
+ } else {
+ eventParent
+ .find('[data-bs-toggle]')
+ .translateText(`[[topic:announcers-x, ${childrenCount}]]`);
+ }
+ }
})
.catch(alerts.error);
}
diff --git a/public/src/modules/logout.js b/public/src/modules/logout.js
index 400d5c25e1..a44fb56cf0 100644
--- a/public/src/modules/logout.js
+++ b/public/src/modules/logout.js
@@ -1,6 +1,6 @@
'use strict';
-define('logout', ['hooks'], function (hooks) {
+define('logout', ['hooks', 'alerts'], function (hooks, alerts) {
return function logout(redirect) {
redirect = redirect === undefined ? true : redirect;
hooks.fire('action:app.logout');
@@ -23,6 +23,9 @@ define('logout', ['hooks'], function (hooks) {
}
}
},
+ error: function (jqXHR) {
+ alerts.error(String(jqXHR.responseText || '[[error:logout-error]]'));
+ },
});
};
});
diff --git a/src/activitypub/actors.js b/src/activitypub/actors.js
index bda513661c..0fa91f49c2 100644
--- a/src/activitypub/actors.js
+++ b/src/activitypub/actors.js
@@ -100,8 +100,16 @@ Actors.assert = async (ids, options = {}) => {
try {
activitypub.helpers.log(`[activitypub/actors] Processing ${id}`);
const actor = (typeof id === 'object' && id.hasOwnProperty('id')) ? id : await activitypub.get('uid', 0, id, { cache: process.env.CI === 'true' });
+
+ let typeOk = false;
+ if (Array.isArray(actor.type)) {
+ typeOk = actor.type.some(type => activitypub._constants.acceptableActorTypes.has(type));
+ } else {
+ typeOk = activitypub._constants.acceptableActorTypes.has(actor.type);
+ }
+
if (
- !activitypub._constants.acceptableActorTypes.has(actor.type) ||
+ !typeOk ||
!activitypub._constants.requiredActorProps.every(prop => actor.hasOwnProperty(prop))
) {
return null;
diff --git a/src/activitypub/inbox.js b/src/activitypub/inbox.js
index 2887caac04..980709fb58 100644
--- a/src/activitypub/inbox.js
+++ b/src/activitypub/inbox.js
@@ -64,7 +64,7 @@ inbox.create = async (req) => {
const { object } = req.body;
// Alternative logic for non-public objects
- const isPublic = [...object.to, ...object.cc].includes(activitypub._constants.publicAddress);
+ const isPublic = [...(object.to || []), ...(object.cc || [])].includes(activitypub._constants.publicAddress);
if (!isPublic) {
return await activitypub.notes.assertPrivate(object);
}
diff --git a/src/activitypub/mocks.js b/src/activitypub/mocks.js
index 223d1a8cbd..4a2fa1e9b2 100644
--- a/src/activitypub/mocks.js
+++ b/src/activitypub/mocks.js
@@ -29,12 +29,14 @@ const Mocks = module.exports;
* Done so the output HTML is stripped of all non-essential items; mainly classes from plugins..
*/
const sanitizeConfig = {
- allowedTags: sanitize.defaults.allowedTags.concat(['img']),
+ allowedTags: sanitize.defaults.allowedTags.concat(['img', 'picture', 'source']),
allowedClasses: {
'*': [],
},
allowedAttributes: {
a: ['href', 'rel'],
+ source: ['type', 'src', 'srcset', 'sizes', 'media', 'height', 'width'],
+ img: ['alt', 'height', 'ismap', 'src', 'usemap', 'width', 'srcset'],
},
};
diff --git a/src/activitypub/notes.js b/src/activitypub/notes.js
index 2b6a0fceaa..ed0b1f6791 100644
--- a/src/activitypub/notes.js
+++ b/src/activitypub/notes.js
@@ -234,7 +234,7 @@ Notes.assertPrivate = async (object) => {
}
const localUids = [];
- const recipients = new Set([...object.to, ...object.cc]);
+ const recipients = new Set([...(object.to || []), ...(object.cc || [])]);
await Promise.all(Array.from(recipients).map(async (value) => {
const { type, id } = await activitypub.helpers.resolveLocalId(value);
if (type === 'user') {
diff --git a/src/categories/data.js b/src/categories/data.js
index 2b4e029caf..8890abf670 100644
--- a/src/categories/data.js
+++ b/src/categories/data.js
@@ -15,8 +15,8 @@ const intFields = [
const worldCategory = {
cid: -1,
- name: 'Uncategorized',
- description: 'Topics that do not strictly fit in with any existing categories',
+ name: '[[category:uncategorized]]',
+ description: '[[category:uncategorized.description]]',
icon: 'fa-globe',
imageClass: 'cover',
bgColor: '#eee',
diff --git a/src/controllers/activitypub/topics.js b/src/controllers/activitypub/topics.js
index 7a871a6876..fac130eb97 100644
--- a/src/controllers/activitypub/topics.js
+++ b/src/controllers/activitypub/topics.js
@@ -66,7 +66,6 @@ controller.list = async function (req, res) {
targetUid: targetUid,
};
const data = await categories.getCategoryById(cidQuery);
- data.name = '[[world:name]]';
delete data.children;
const tids = await getTids(cidQuery);
diff --git a/src/controllers/admin/info.js b/src/controllers/admin/info.js
index 45ffe078b7..6f63faf8a9 100644
--- a/src/controllers/admin/info.js
+++ b/src/controllers/admin/info.js
@@ -88,6 +88,7 @@ async function getNodeInfo() {
isPrimary: nconf.get('isPrimary'),
runJobs: nconf.get('runJobs'),
jobsDisabled: nconf.get('jobsDisabled'),
+ acpPluginInstallDisabled: nconf.get('acpPluginInstallDisabled'),
},
};
diff --git a/src/controllers/admin/users.js b/src/controllers/admin/users.js
index 4ea3ebb9e5..14e50bf9eb 100644
--- a/src/controllers/admin/users.js
+++ b/src/controllers/admin/users.js
@@ -199,7 +199,7 @@ async function loadUserInfo(callerUid, uids) {
const confirmObj = confirmObjs[index];
user['email:expired'] = !confirmObj.expires || Date.now() >= confirmObj.expires;
user['email:pending'] = confirmObj.expires && Date.now() < confirmObj.expires;
- user.emailToConfirm = confirmObj.email;
+ user.emailToConfirm = validator.escape(String(confirmObj.email));
}
}
});
diff --git a/src/controllers/authentication.js b/src/controllers/authentication.js
index 007d3ba6ac..c946ca8292 100644
--- a/src/controllers/authentication.js
+++ b/src/controllers/authentication.js
@@ -459,7 +459,7 @@ authenticationController.localLogin = async function (req, username, password, n
}
};
-authenticationController.logout = async function (req, res, next) {
+authenticationController.logout = async function (req, res) {
if (!req.loggedIn || !req.sessionID) {
res.clearCookie(nconf.get('sessionKey'), meta.configs.cookie.get());
return res.status(200).send('not-logged-in');
@@ -475,21 +475,22 @@ authenticationController.logout = async function (req, res, next) {
await user.setUserField(uid, 'lastonline', Date.now() - (meta.config.onlineCutoff * 60000));
await db.sortedSetAdd('users:online', Date.now() - (meta.config.onlineCutoff * 60000), uid);
- await plugins.hooks.fire('static:user.loggedOut', { req: req, res: res, uid: uid, sessionID: sessionID });
+ await plugins.hooks.fire('static:user.loggedOut', { req, res, uid, sessionID });
// Force session check for all connected socket.io clients with the same session id
sockets.in(`sess_${sessionID}`).emit('checkSession', 0);
const payload = {
next: `${nconf.get('relative_path')}/`,
};
- plugins.hooks.fire('filter:user.logout', payload);
+ await plugins.hooks.fire('filter:user.logout', payload);
if (req.body.noscript === 'true') {
return res.redirect(payload.next);
}
res.status(200).send(payload);
} catch (err) {
- next(err);
+ winston.error(`${req.method} ${req.originalUrl}\n${err.stack}`);
+ res.status(500).send(err.message);
}
};
diff --git a/src/emailer.js b/src/emailer.js
index 1b545b1a35..6c57d6b44a 100644
--- a/src/emailer.js
+++ b/src/emailer.js
@@ -12,6 +12,7 @@ const fs = require('fs');
const _ = require('lodash');
const jwt = require('jsonwebtoken');
+const db = require('./database');
const User = require('./user');
const Plugins = require('./plugins');
const meta = require('./meta');
@@ -223,6 +224,13 @@ Emailer.send = async (template, uid, params) => {
// 'welcome' and 'verify-email' explicitly used passed-in email address
if (['welcome', 'verify-email'].includes(template)) {
userData.email = params.email;
+ } else if (meta.config.includeUnverifiedEmails && !userData.email) {
+ // get unconfirmed email to use
+ const code = await db.get(`confirm:byUid:${uid}`);
+ const confirmObj = code ? await db.getObject(`confirm:${code}`) : null;
+ if (confirmObj && confirmObj.email) {
+ userData.email = String(confirmObj.email);
+ }
}
({ template, userData, params } = await Plugins.hooks.fire('filter:email.prepare', { template, uid, userData, params }));
diff --git a/src/posts/summary.js b/src/posts/summary.js
index 89e6087036..5995514eb6 100644
--- a/src/posts/summary.js
+++ b/src/posts/summary.js
@@ -22,7 +22,7 @@ module.exports = function (Posts) {
options.escape = options.hasOwnProperty('escape') ? options.escape : false;
options.extraFields = options.hasOwnProperty('extraFields') ? options.extraFields : [];
- const fields = ['pid', 'tid', 'toPid', 'url', 'content', 'uid', 'timestamp', 'deleted', 'upvotes', 'downvotes', 'replies', 'handle'].concat(options.extraFields);
+ const fields = ['pid', 'tid', 'toPid', 'url', 'content', 'sourceContent', 'uid', 'timestamp', 'deleted', 'upvotes', 'downvotes', 'replies', 'handle'].concat(options.extraFields);
let posts = await Posts.getPostsFields(pids, fields);
posts = posts.filter(Boolean);
@@ -74,7 +74,7 @@ module.exports = function (Posts) {
async function parsePosts(posts, options) {
return await Promise.all(posts.map(async (post) => {
- if (!post.content) {
+ if (!post.content && !post.sourceContent) {
return post;
}
if (options.parse) {
diff --git a/src/prestart.js b/src/prestart.js
index b09ef5d9bc..57b5f0590e 100644
--- a/src/prestart.js
+++ b/src/prestart.js
@@ -58,6 +58,7 @@ function loadConfig(configFile) {
isCluster: false,
isPrimary: true,
jobsDisabled: false,
+ acpPluginInstallDisabled: false,
fontawesome: {
pro: false,
styles: '*',
@@ -65,7 +66,7 @@ function loadConfig(configFile) {
});
// Explicitly cast as Bool, loader.js passes in isCluster as string 'true'/'false'
- const castAsBool = ['isCluster', 'isPrimary', 'jobsDisabled'];
+ const castAsBool = ['isCluster', 'isPrimary', 'jobsDisabled', 'acpPluginInstallDisabled'];
nconf.stores.env.readOnly = false;
castAsBool.forEach((prop) => {
const value = nconf.get(prop);
diff --git a/src/routes/well-known.js b/src/routes/well-known.js
index becd1370f8..399668b679 100644
--- a/src/routes/well-known.js
+++ b/src/routes/well-known.js
@@ -39,8 +39,8 @@ module.exports = function (app, middleware, controllers) {
const oneMonthAgo = addMonths(new Date(), -1);
const sixMonthsAgo = addMonths(new Date(), -6);
- const [{ postCount, userCount }, activeMonth, activeHalfyear] = await Promise.all([
- db.getObjectFields('global', ['postCount', 'userCount']),
+ const [{ postCount, topicCount, userCount }, activeMonth, activeHalfyear] = await Promise.all([
+ db.getObjectFields('global', ['postCount', 'topicCount', 'userCount']),
db.sortedSetCount('users:online', oneMonthAgo.getTime(), '+inf'),
db.sortedSetCount('users:online', sixMonthsAgo.getTime(), '+inf'),
]);
@@ -64,7 +64,8 @@ module.exports = function (app, middleware, controllers) {
activeMonth: activeMonth,
activeHalfyear: activeHalfyear,
},
- localPosts: postCount,
+ localPosts: topicCount,
+ localComments: postCount - topicCount,
},
openRegistrations: meta.config.registrationType === 'normal',
metadata: {
diff --git a/src/socket.io/admin/plugins.js b/src/socket.io/admin/plugins.js
index 2d6f705be9..d926dfa0cf 100644
--- a/src/socket.io/admin/plugins.js
+++ b/src/socket.io/admin/plugins.js
@@ -22,6 +22,10 @@ Plugins.toggleActive = async function (socket, plugin_id) {
};
Plugins.toggleInstall = async function (socket, data) {
+ const isInstalled = await plugins.isInstalled(data.id);
+ if (nconf.get('acpPluginInstallDisabled') && !isInstalled) {
+ throw new Error('[[error:plugin-installation-via-acp-disabled]]');
+ }
postsCache.reset();
await plugins.checkWhitelist(data.id, data.version);
const pluginData = await plugins.toggleInstall(data.id, data.version);
diff --git a/src/socket.io/helpers.js b/src/socket.io/helpers.js
index 66e568d1b2..d80638458e 100644
--- a/src/socket.io/helpers.js
+++ b/src/socket.io/helpers.js
@@ -17,6 +17,10 @@ const batch = require('../batch');
const SocketHelpers = module.exports;
SocketHelpers.notifyNew = async function (uid, type, result) {
+ const post = result.posts[0];
+ if (post && post.topic && parseInt(post.topic.cid, 10) === -1) {
+ return;
+ }
let uids = await user.getUidsFromSet('users:online', 0, -1);
uids = uids.filter(toUid => parseInt(toUid, 10) !== uid);
await batch.processArray(uids, async (uids) => {
diff --git a/src/topics/events.js b/src/topics/events.js
index 04dfe9b4d5..10ab9b8936 100644
--- a/src/topics/events.js
+++ b/src/topics/events.js
@@ -107,7 +107,7 @@ function renderUser(event) {
if (!event.user || event.user.system) {
return '[[global:system-user]]';
}
- return `${helpers.buildAvatar(event.user, '16px', true)} ${event.user.username}`;
+ return `${helpers.buildAvatar(event.user, '16px', true)} ${event.user.displayname}`;
}
function renderTimeago(event) {
diff --git a/src/topics/index.js b/src/topics/index.js
index 07600e6311..5717f26126 100644
--- a/src/topics/index.js
+++ b/src/topics/index.js
@@ -198,6 +198,7 @@ Topics.getTopicWithPosts = async function (topicData, set, uid, start, stop, rev
);
p.eventStart = undefined;
p.eventEnd = undefined;
+ p.events = mergeConsecutiveShareEvents(p.events);
});
topicData.category = category;
@@ -230,6 +231,23 @@ Topics.getTopicWithPosts = async function (topicData, set, uid, start, stop, rev
return result.topic;
};
+function mergeConsecutiveShareEvents(arr) {
+ return arr.reduce((acc, curr) => {
+ const last = acc[acc.length - 1];
+ if (last && last.type === curr.type && last.type === 'share') {
+ if (!last.items) {
+ last.items = [{ ...last }];
+ ['user', 'text', 'timestamp', 'timestampISO'].forEach(field => delete last[field]);
+ }
+ last.items.push(curr);
+ } else {
+ acc.push(curr);
+ }
+ return acc;
+ }, []);
+}
+
+
async function getDeleter(topicData) {
if (!parseInt(topicData.deleterUid, 10)) {
return null;
diff --git a/src/upgrades/3.6.0/category_tracking.js b/src/upgrades/3.6.0/category_tracking.js
index a30be983b6..d57717cd54 100644
--- a/src/upgrades/3.6.0/category_tracking.js
+++ b/src/upgrades/3.6.0/category_tracking.js
@@ -3,7 +3,6 @@
'use strict';
const db = require('../../database');
-const user = require('../../user');
const batch = require('../../batch');
module.exports = {
@@ -18,7 +17,7 @@ module.exports = {
}
await batch.processSortedSet(`users:joindate`, async (uids) => {
- const userSettings = await user.getMultipleUserSettings(uids);
+ const userSettings = await db.getObjects(uids.map(uid => `user:${uid}:settings`));
const change = userSettings.filter(s => s && s.categoryWatchState === 'watching');
await db.setObjectBulk(
change.map(s => [`user:${s.uid}:settings`, { categoryWatchState: 'tracking' }])
diff --git a/src/user/email.js b/src/user/email.js
index aec9379f41..c14c9c93fc 100644
--- a/src/user/email.js
+++ b/src/user/email.js
@@ -36,8 +36,10 @@ UserEmail.remove = async function (uid, sessionId) {
email: '',
'email:confirmed': 0,
}),
- db.sortedSetRemove('email:uid', email.toLowerCase()),
- db.sortedSetRemove('email:sorted', `${email.toLowerCase()}:${uid}`),
+ db.sortedSetRemoveBulk([
+ ['email:uid', email.toLowerCase()],
+ ['email:sorted', `${email.toLowerCase()}:${uid}`],
+ ]),
user.email.expireValidation(uid),
sessionId ? user.auth.revokeAllSessions(uid, sessionId) : Promise.resolve(),
events.log({
@@ -53,7 +55,7 @@ UserEmail.getEmailForValidation = async (uid) => {
let email = '';
// check email from confirmObj
const code = await db.get(`confirm:byUid:${uid}`);
- const confirmObj = await db.getObject(`confirm:${code}`);
+ const confirmObj = code ? await db.getObject(`confirm:${code}`) : null;
if (confirmObj && confirmObj.email && parseInt(uid, 10) === parseInt(confirmObj.uid, 10)) {
email = confirmObj.email;
}
diff --git a/src/user/profile.js b/src/user/profile.js
index c150675d6a..3009d0a3d5 100644
--- a/src/user/profile.js
+++ b/src/user/profile.js
@@ -282,6 +282,9 @@ module.exports = function (User) {
if (oldEmail === newEmail) {
return;
}
+ if (await User.email.isValidationPending(uid, newEmail)) {
+ return;
+ }
// 👉 Looking for email change logic? src/user/email.js (UserEmail.confirmByUid)
if (newEmail) {
diff --git a/src/views/partials/chats/parent.tpl b/src/views/partials/chats/parent.tpl
index 2d2e66bf3b..68f91414c5 100644
--- a/src/views/partials/chats/parent.tpl
+++ b/src/views/partials/chats/parent.tpl
@@ -4,7 +4,7 @@
${utils.generateUUID()}
`, + published: new Date().toISOString(), + ...override, + }; + + // If any values contain the hardcoded string "remove", remove that prop + Object.entries(note).forEach(([key, value]) => { + if (value === 'remove') { + delete note[key]; + } + }); + activitypub._cache.set(`0;${id}`, note); + + return { id, note }; +}; + +Helpers.mocks.create = (object) => { + // object is optional, will generate a public note if undefined + const baseUrl = 'https://example.org'; + const uuid = utils.generateUUID(); + const id = `${baseUrl}/activity/${uuid}`; + + object = object || Helpers.mocks.note().note; + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + id, + type: 'Create', + to: ['https://www.w3.org/ns/activitystreams#Public'], + cc: ['https://example.org/user/foobar/followers'], + actor: 'https://example.org/user/foobar', + object, + }; + + activitypub._cache.set(`0;${id}`, activity); + + return { id, activity }; +}; diff --git a/test/activitypub/notes.js b/test/activitypub/notes.js index 482b706d14..6a27c5904b 100644 --- a/test/activitypub/notes.js +++ b/test/activitypub/notes.js @@ -1,15 +1,81 @@ 'use strict'; const assert = require('assert'); +const nconf = require('nconf'); const db = require('../../src/database'); +const meta = require('../../src/meta'); +const install = require('../../src/install'); const user = require('../../src/user'); const categories = require('../../src/categories'); +const posts = require('../../src/posts'); const topics = require('../../src/topics'); const activitypub = require('../../src/activitypub'); const utils = require('../../src/utils'); +const helpers = require('./helpers'); + describe('Notes', () => { + describe('Assertion', () => { + before(async () => { + meta.config.activitypubEnabled = 1; + await install.giveWorldPrivileges(); + }); + + describe('Public objects', () => { + it('should pull a remote root-level object by its id and create a new topic', async () => { + const { id } = helpers.mocks.note(); + const { tid, count } = await activitypub.notes.assert(0, id, { skipChecks: true }); + assert.strictEqual(count, 1); + + const exists = await topics.exists(tid); + assert(exists); + }); + + it('should assert if the cc property is missing', async () => { + const { id } = helpers.mocks.note({ cc: 'remove' }); + const { tid, count } = await activitypub.notes.assert(0, id, { skipChecks: true }); + assert.strictEqual(count, 1); + + const exists = await topics.exists(tid); + assert(exists); + }); + }); + + describe('Private objects', () => { + let recipientUid; + + before(async () => { + recipientUid = await user.create({ username: utils.generateUUID().slice(0, 8) }); + }); + + it('should NOT create a new topic or post when asserting a private note', async () => { + const { id, note } = helpers.mocks.note({ + to: [`${nconf.get('url')}/uid/${recipientUid}`], + cc: [], + }); + const { activity } = helpers.mocks.create(note); + const { roomId } = await activitypub.inbox.create({ body: activity }); + assert(roomId); + assert(utils.isNumber(roomId)); + + const exists = await posts.exists(id); + assert(!exists); + }); + + it('should still assert if the cc property is missing', async () => { + const { id, note } = helpers.mocks.note({ + to: [`${nconf.get('url')}/uid/${recipientUid}`], + cc: 'remove', + }); + const { activity } = helpers.mocks.create(note); + const { roomId } = await activitypub.inbox.create({ body: activity }); + assert(roomId); + assert(utils.isNumber(roomId)); + }); + }); + }); + describe('Inbox Synchronization', () => { let cid; let uid; diff --git a/test/mocks/databasemock.js b/test/mocks/databasemock.js index 587dd65dcc..6a568109b7 100644 --- a/test/mocks/databasemock.js +++ b/test/mocks/databasemock.js @@ -144,6 +144,7 @@ before(async function () { nconf.set('version', packageInfo.version); nconf.set('runJobs', false); nconf.set('jobsDisabled', false); + nconf.set('acpPluginInstallDisabled', false); await db.init();