Merge commit '524a1e8bfe403fa240e804f076943871264caf2f' into v4.x

This commit is contained in:
Misty Release Bot
2025-06-02 15:06:27 +00:00
17 changed files with 368 additions and 31 deletions

View File

@@ -1,3 +1,47 @@
#### v4.4.1 (2025-05-16)
##### Chores
* up themes (61a63851)
* incrementing version number - v4.4.0 (0a75eee3)
* update changelog for v4.4.0 (09cc91d5)
* 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
* save width and height values into post attachment (3674fa57)
* use local date string for digest subject (3d96afb2)
##### Bug Fixes
* openapi schema to handle additional `attachments` field in postsobject (ce5ef1ab)
* group edit url (0a574d72)
* add attachments to getpostsummaries call in search, #13324 (8f9f3771)
* bring back auto-categorization if group and object are same-origin, handle Peertube putting channel names in `attributedTo` (a460a550)
* #13419, handle remote content with mediaType text/markdown (45a11d45)
##### Refactors
* create date once per digest.send (6c3e2a8e)
##### Tests
* fix tests to account for a460a55064e1280f36a0021e0510c7c557251030 (948bfe46)
#### v4.4.0 (2025-05-14)
##### Breaking Changes

View File

@@ -99,7 +99,7 @@
"nconf": "0.13.0",
"nodebb-plugin-2factor": "7.5.10",
"nodebb-plugin-composer-default": "10.2.50",
"nodebb-plugin-dbsearch": "6.2.16",
"nodebb-plugin-dbsearch": "6.2.19",
"nodebb-plugin-emoji": "6.0.2",
"nodebb-plugin-emoji-android": "4.1.1",
"nodebb-plugin-markdown": "13.2.1",
@@ -107,7 +107,7 @@
"nodebb-plugin-spam-be-gone": "2.3.2",
"nodebb-plugin-web-push": "0.7.4",
"nodebb-rewards-essentials": "1.0.2",
"nodebb-theme-harmony": "2.1.13",
"nodebb-theme-harmony": "2.1.15",
"nodebb-theme-lavender": "7.1.19",
"nodebb-theme-peace": "2.2.43",
"nodebb-theme-persona": "14.1.12",
@@ -162,8 +162,8 @@
"@commitlint/config-angular": "19.8.1",
"coveralls": "3.1.1",
"@eslint/js": "9.26.0",
"@stylistic/eslint-plugin-js": "4.2.0",
"eslint-config-nodebb": "1.1.4",
"@stylistic/eslint-plugin-js": "4.4.0",
"eslint-config-nodebb": "1.1.5",
"eslint-plugin-import": "2.31.0",
"grunt": "1.6.1",
"grunt-contrib-watch": "1.1.0",

View File

@@ -271,6 +271,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",
"cannot-toggle-system-plugin": "You cannot toggle the state of a system plugin",
"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",

View File

@@ -23,11 +23,13 @@
display: block;
}
}
.dropdown-left {
.dropdown-menu { --bs-position: start; }
html[data-dir="ltr"] {
.dropdown-left .dropdown-menu { --bs-position: start; }
.dropdown-right .dropdown-menu { --bs-position: end; }
}
.dropdown-right {
.dropdown-menu { --bs-position: end; }
html[data-dir="rtl"] {
.dropdown-left .dropdown-menu { --bs-position: end; }
.dropdown-right .dropdown-menu { --bs-position: start; }
}
.category-dropdown-menu {

View File

@@ -225,7 +225,7 @@ define('forum/topic/threadTools', [
return;
}
dropdownMenu.html(helpers.generatePlaceholderWave([8, 8, 8]));
const data = await socket.emit('topics.loadTopicTools', { tid: ajaxify.data.tid, cid: ajaxify.data.cid });
const data = await socket.emit('topics.loadTopicTools', { tid: ajaxify.data.tid, cid: ajaxify.data.cid }).catch(alerts.error);
const html = await app.parseAndTranslate('partials/topic/topic-menu-list', data);
$(dropdownMenu).attr('data-loaded', 'true').html(html);
hooks.fire('action:topic.tools.load', {

View File

@@ -95,6 +95,12 @@ inbox.update = async (req) => {
try {
switch (true) {
case isNote: {
const cid = await posts.getCidByPid(object.id);
const allowed = await privileges.categories.can('posts:edit', cid, activitypub._constants.uid);
if (!allowed) {
throw new Error('[[error:no-privileges]]');
}
const postData = await activitypub.mocks.post(object);
postData.tags = await activitypub.notes._normalizeTags(postData._activitypub.tag, postData.cid);
await posts.edit(postData);
@@ -200,7 +206,7 @@ inbox.delete = async (req) => {
const objectHostname = new URL(pid).hostname;
if (actorHostname !== objectHostname) {
throw new Error('[[error:activitypub.origin-mismatch]]');
return reject('Delete', object, actor);
}
const [isNote/* , isActor */] = await Promise.all([
@@ -210,6 +216,12 @@ inbox.delete = async (req) => {
switch (true) {
case isNote: {
const cid = await posts.getCidByPid(pid);
const allowed = await privileges.categories.can('posts:edit', cid, activitypub._constants.uid);
if (!allowed) {
return reject('Delete', object, actor);
}
const uid = await posts.getPostField(pid, 'uid');
await activitypub.feps.announce(pid, req.body);
await api.posts[method]({ uid }, { pid });
@@ -282,9 +294,13 @@ inbox.announce = async (req) => {
const { id: localId } = await activitypub.helpers.resolveLocalId(id);
const exists = await posts.exists(localId || id);
if (exists) {
const result = await posts.upvote(localId || id, object.actor);
if (localId) {
socketHelpers.upvote(result, 'notifications:upvoted-your-post-in');
try {
const result = await posts.upvote(localId || id, object.actor);
if (localId) {
socketHelpers.upvote(result, 'notifications:upvoted-your-post-in');
}
} catch (e) {
// vote denied due to local limitations (frequency, privilege, etc.); noop.
}
}
@@ -530,7 +546,8 @@ inbox.undo = async (req) => {
case 'Like': {
const exists = await posts.exists(id);
if (localType !== 'post' || !exists) {
throw new Error('[[error:invalid-pid]]');
reject('Like', object, actor);
break;
}
const allowed = await privileges.posts.can('posts:upvote', id, activitypub._constants.uid);

View File

@@ -27,6 +27,9 @@ const probeCache = ttl({
max: 500,
ttl: 1000 * 60 * 60, // 1 hour
});
const probeRateLimit = ttl({
ttl: 1000 * 3, // 3 seconds
});
const ActivityPub = module.exports;
@@ -506,6 +509,13 @@ ActivityPub.probe = async ({ uid, url }) => {
* - Returns a relative path if already available, true if not, and false otherwise.
*/
// Disable on config setting; restrict lookups to HTTPS-enabled URLs only
const { activitypubProbe } = meta.config;
const { protocol } = new URL(url);
if (!activitypubProbe || protocol !== 'https:') {
return false;
}
// Known resources
const [isNote, isMessage, isActor, isActorUrl] = await Promise.all([
posts.exists(url),
@@ -541,6 +551,17 @@ ActivityPub.probe = async ({ uid, url }) => {
}
}
// Guests not allowed to use expensive logic path
if (!uid) {
return false;
}
// One request allowed every 3 seconds (configured at top)
const limited = probeRateLimit.get(uid);
if (limited) {
return false;
}
// Cached result
if (probeCache.has(url)) {
return probeCache.get(url);
@@ -572,6 +593,7 @@ ActivityPub.probe = async ({ uid, url }) => {
return false;
}
try {
probeRateLimit.set(uid, true);
return await checkHeader(meta.config.activitypubProbeTimeout || 2000);
} catch (e) {
if (e.name === 'TimeoutError') {

View File

@@ -196,8 +196,8 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => {
const { to, cc, attachment } = mainPost._activitypub;
const tags = await Notes._normalizeTags(mainPost._activitypub.tag || []);
await Promise.all([
topics.post({
try {
await topics.post({
tid,
uid: authorId,
cid: options.cid || cid,
@@ -208,13 +208,16 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => {
content: mainPost.content,
sourceContent: mainPost.sourceContent,
_activitypub: mainPost._activitypub,
}),
Notes.updateLocalRecipients(mainPid, { to, cc }),
]);
unprocessed.shift();
});
unprocessed.shift();
} catch (e) {
activitypub.helpers.log(`[activitypub/notes.assert] Could not post topic (${mainPost.pid}): ${e.message}`);
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,

View File

@@ -119,6 +119,7 @@ activitypubApi.unfollow = enabledCheck(async (caller, { type, id, actor }) => {
await activitypub.send(type, id, [actor], {
id: `${nconf.get('url')}/${type}/${id}#activity/undo:follow/${encodeURIComponent(actor)}/${timestamp}`,
type: 'Undo',
actor: object.actor,
object,
});
@@ -126,6 +127,7 @@ activitypubApi.unfollow = enabledCheck(async (caller, { type, id, actor }) => {
await Promise.all([
db.sortedSetRemove(`followingRemote:${id}`, actor),
db.sortedSetRemove(`followRequests:uid.${id}`, actor),
db.sortedSetRemove(`followersRemote:${actor}`, id),
db.decrObjectField(`user:${id}`, 'followingRemoteCount'),
]);
} else if (type === 'cid') {

View File

@@ -31,7 +31,7 @@ Controller.fetch = async (req, res, next) => {
if (typeof result === 'string') {
return helpers.redirect(res, result);
} else if (result) {
const { id, type } = await activitypub.get('uid', req.uid || 0, url.href);
const { id, type } = await activitypub.get('uid', req.uid, url.href);
switch (true) {
case activitypub._constants.acceptedPostTypes.includes(type): {
return helpers.redirect(res, `/post/${encodeURIComponent(id)}`);
@@ -145,7 +145,7 @@ Controller.postInbox = async (req, res) => {
const method = String(req.body.type).toLowerCase();
if (!activitypub.inbox.hasOwnProperty(method)) {
winston.warn(`[activitypub/inbox] Received Activity of type ${method} but unable to handle. Ignoring.`);
return res.sendStatus(501);
return res.sendStatus(200);
}
try {

View File

@@ -150,6 +150,7 @@ module.exports = function (middleware) {
async function loadClientHeaderFooterData(req, res, options) {
const registrationType = meta.config.registrationType || 'normal';
res.locals.config = res.locals.config || {};
const userLang = res.locals.config.userLang || meta.config.userLang || 'en-GB';
const templateValues = {
title: meta.config.title || '',
'title:url': meta.config['title:url'] || '',
@@ -180,9 +181,9 @@ module.exports = function (middleware) {
blocks: user.blocks.list(req.uid),
user: user.getUserData(req.uid),
isEmailConfirmSent: req.uid <= 0 ? false : await user.email.isValidationPending(req.uid),
languageDirection: translator.translate('[[language:dir]]', res.locals.config.userLang),
timeagoCode: languages.userTimeagoCode(res.locals.config.userLang),
browserTitle: translator.translate(controllersHelpers.buildTitle(title)),
languageDirection: translator.translate('[[language:dir]]', userLang),
timeagoCode: languages.userTimeagoCode(userLang),
browserTitle: translator.translate(controllersHelpers.buildTitle(title), userLang),
navigation: navigation.get(req.uid),
roomIds: req.uid > 0 ? db.getSortedSetRevRange(`uid:${req.uid}:chat:rooms`, 0, 0) : [],
});

View File

@@ -156,6 +156,16 @@ module.exports = function (Plugins) {
}
};
Plugins.isSystemPlugin = async function (id) {
const pluginDir = path.join(paths.nodeModules, id, 'plugin.json');
try {
const pluginData = JSON.parse(await fs.readFile(pluginDir, 'utf8'));
return pluginData && pluginData.system === true;
} catch (err) {
return false;
}
};
Plugins.isActive = async function (id) {
if (nconf.get('plugins:active')) {
return nconf.get('plugins:active').includes(id);

View File

@@ -188,13 +188,16 @@ module.exports = function (Posts) {
data: data,
};
payload = await plugins.hooks.fire('filter:post-queue.save', payload);
payload.data = JSON.stringify(data);
await db.sortedSetAdd('post:queue', now, id);
await db.setObject(`post:queue:${id}`, payload);
await db.setObject(`post:queue:${id}`, {
...payload,
data: JSON.stringify(payload.data),
});
await user.setUserField(data.uid, 'lastqueuetime', now);
cache.del('post-queue');
await plugins.hooks.fire('action:post-queue.save', payload);
const cid = await getCid(type, data);
const uids = await getNotificationUids(cid);
const bodyLong = await parseBodyLong(cid, type, data);

View File

@@ -11,6 +11,9 @@ const { pluginNamePattern } = require('../../constants');
const Plugins = module.exports;
Plugins.toggleActive = async function (socket, plugin_id) {
if (await plugins.isSystemPlugin(plugin_id)) {
throw new Error('[[error:cannot-toggle-system-plugin]]');
}
postsCache.reset();
const data = await plugins.toggleActive(plugin_id);
await events.log({
@@ -22,6 +25,9 @@ Plugins.toggleActive = async function (socket, plugin_id) {
};
Plugins.toggleInstall = async function (socket, data) {
if (await plugins.isSystemPlugin(data.id)) {
throw new Error('[[error:cannot-toggle-system-plugin]]');
}
const isInstalled = await plugins.isInstalled(data.id);
const isStarterPlan = nconf.get('saas_plan') === 'starter';
if ((isStarterPlan || nconf.get('acpPluginInstallDisabled')) && !isInstalled) {

View File

@@ -6,9 +6,6 @@ const plugins = require('../../plugins');
module.exports = function (SocketTopics) {
SocketTopics.loadTopicTools = async function (socket, data) {
if (!socket.uid) {
throw new Error('[[error:no-privileges]]');
}
if (!data) {
throw new Error('[[error:invalid-data]]');
}
@@ -21,7 +18,7 @@ module.exports = function (SocketTopics) {
if (!topicData) {
throw new Error('[[error:no-topic]]');
}
if (!userPrivileges['topics:read']) {
if (!userPrivileges['topics:read'] || !userPrivileges.view_thread_tools) {
throw new Error('[[error:no-privileges]]');
}
topicData.privileges = userPrivileges;

View File

@@ -202,3 +202,26 @@ Helpers.mocks.update = (override = {}) => {
return { activity };
};
Helpers.mocks.delete = (override = {}) => {
let actor = override.actor;
let object = override.object;
if (!actor) {
({ id: actor } = Helpers.mocks.person());
}
if (!object) {
({ id: object } = Helpers.mocks.note());
}
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `${Helpers.mocks._baseUrl}/delete/${encodeURIComponent(object.id || object)}`,
type: 'Delete',
to: [activitypub._constants.publicAddress],
cc: [`${actor}/followers`],
actor,
object,
};
return { activity };
};

View File

@@ -0,0 +1,206 @@
'use strict';
const assert = require('assert');
const nconf = require('nconf');
const db = require('../mocks/databasemock');
const request = require('../../src/request');
const user = require('../../src/user');
const topics = require('../../src/topics');
const posts = require('../../src/posts');
const categories = require('../../src/categories');
const privileges = require('../../src/privileges');
const meta = require('../../src/meta');
const install = require('../../src/install');
const utils = require('../../src/utils');
const activitypub = require('../../src/activitypub');
const helpers = require('./helpers');
describe('Privilege logic for remote users/content (ActivityPub)', () => {
before(async () => {
meta.config.activitypubEnabled = 1;
// await install.giveWorldPrivileges();
});
describe('"fediverse" pseudo-user', () => {
describe('no privileges given', () => {
let uid;
let cid;
let topicData;
let postData;
let mainPid;
let handle;
before(async () => {
uid = await user.create({ username: utils.generateUUID() });
({ cid } = await categories.create({ name: utils.generateUUID() }));
({ topicData, postData } = await topics.post({
cid,
uid,
title: utils.generateUUID(),
content: utils.generateUUID(),
}));
handle = await categories.getCategoryField(cid, 'handle');
const privsToRemove = await privileges.categories.getGroupPrivilegeList();
await privileges.categories.rescind(privsToRemove, cid, ['fediverse']);
});
describe('incoming requests', () => {
it('should not respond to a webfinger request to a category\'s handle', async () => {
const response = await activitypub.helpers.query(`${handle}@${nconf.get('url_parsed').hostname}`);
assert.strictEqual(response, false);
});
it('should not respond to a request for the category actor', async () => {
await assert.rejects(
activitypub.get('uid', uid, `${nconf.get('url')}/category/${cid}`),
{ message: '[[error:activitypub.get-failed]]' }
);
});
it('should not respond to a request for a topic collection', async () => {
await assert.rejects(
activitypub.get('uid', uid, `${nconf.get('url')}/topic/${topicData.tid}`),
{ message: '[[error:activitypub.get-failed]]' }
);
});
it('should not respond to a request for a post', async () => {
await assert.rejects(
activitypub.get('uid', uid, `${nconf.get('url')}/post/${topicData.mainPid}`),
{ message: '[[error:activitypub.get-failed]]' }
);
});
});
describe('incoming activities', () => {
describe('Create(Note)', () => {
let note;
let activity;
before(async () => {
({ note } = helpers.mocks.note({
cc: [`${nconf.get('url')}/category/${cid}`],
}));
({ activity } = helpers.mocks.create(note));
await activitypub.inbox.create({ body: activity });
});
it('should not assert the note', async () => {
const exists = await posts.exists(note.id);
assert.strictEqual(exists, false);
});
});
describe('Update(Note)', () => {
let note;
let activity;
before(async () => {
({ note } = helpers.mocks.note({
cc: [`${nconf.get('url')}/category/${cid}`],
}));
({ activity } = helpers.mocks.create(note));
await privileges.categories.give(['groups:topics:create'], cid, ['fediverse']);
await activitypub.inbox.create({ body: activity });
});
after(async () => {
await privileges.categories.rescind(['groups:topics:create'], cid, ['fediverse']);
});
it('should assert the note', async () => {
const exists = await posts.exists(note.id);
assert.strictEqual(exists, true);
});
it('should not allow edits to the note', async () => {
const oldContent = note.content;
note.content = 'new content';
({ activity } = helpers.mocks.update({
object: note,
}));
await activitypub.inbox.update({ body: activity });
const postData = await posts.getPostData(note.id);
assert.strictEqual(postData.content, oldContent);
assert.strictEqual(postData.edited, 0);
});
});
describe('Delete(Note)', () => {
let note;
let activity;
before(async () => {
({ note } = helpers.mocks.note({
cc: [`${nconf.get('url')}/category/${cid}`],
}));
({ activity } = helpers.mocks.create(note));
await privileges.categories.give(['groups:topics:create'], cid, ['fediverse']);
await activitypub.inbox.create({ body: activity });
});
after(async () => {
await privileges.categories.rescind(['groups:topics:create'], cid, ['fediverse']);
});
it('should assert the note', async () => {
const exists = await posts.exists(note.id);
assert.strictEqual(exists, true);
});
it('should ignore remote deletion of said note', async () => {
({ activity } = helpers.mocks.delete({ object: note }));
await activitypub.inbox.delete({ body: activity });
const exists = await posts.exists(note.id);
assert.strictEqual(exists, true);
});
});
});
describe('outgoing requests', () => {
it('should not federate out a new post', async () => {
});
it('should not federate out a post edit', async () => {
});
it('should not federate out a post deletion', async () => {
});
it('should not federate out a post announce', async () => {
});
});
});
describe('regular privilege set', () => {
let cid;
let handle;
before(async () => {
({ cid } = await categories.create({ name: utils.generateUUID() }));
handle = await categories.getCategoryField(cid, 'handle');
const privsToRemove = await privileges.categories.getGroupPrivilegeList();
});
describe('groups:find', () => {
it('should return webfinger response to a category\'s handle', async () => {
const { response, body } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=acct:${handle}@${nconf.get('url_parsed').host}`);
assert(response);
assert.strictEqual(response.statusCode, 200);
assert(body.links && body.links.length);
assert.strictEqual(body.subject, `acct:${handle}@${nconf.get('url_parsed').host}`);
});
});
});
});
});