Merge commit '00668bdc342f22b1ef263ca2d6003b12bbb194af' into v4.x

This commit is contained in:
Misty Release Bot
2025-05-12 14:53:33 +00:00
16 changed files with 212 additions and 95 deletions

View File

@@ -1,3 +1,36 @@
#### v4.3.1 (2025-05-07)
##### Chores
* node 18 eol (800426d6)
* up widgets (ee2f91ad)
* up themes (18867fb1)
* update bundled plugins to use eslint9 (343f13e1)
* incrementing version number - v4.3.0 (bff291db)
* update changelog for v4.3.0 (76c03019)
* 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)
##### Other Changes
* //github.com/NodeBB/NodeBB/issues/13367 (d35aad31)
##### Tests
* fix android test (31af05c7)
* fix android test (25979294)
* fix a test (7ef79981)
#### v4.3.0 (2025-05-01)
##### Chores

View File

@@ -58,8 +58,8 @@ RUN corepack enable \
&& mkdir -p /usr/src/app/logs/ /opt/config/ \
&& chown -R ${USER}:${USER} /usr/src/app/ /opt/config/
COPY --from=build --chown=${USER}:${USER} /usr/src/app/ /usr/src/app/install/docker/setup.json /usr/src/app/
COPY --from=build --chown=${USER}:${USER} /usr/bin/tini /usr/src/app/install/docker/entrypoint.sh /usr/local/bin/
COPY --from=git --chown=${USER}:${USER} /usr/src/app/ /usr/src/app/install/docker/setup.json /usr/src/app/
COPY --from=git --chown=${USER}:${USER} /usr/bin/tini /usr/src/app/install/docker/entrypoint.sh /usr/local/bin/
COPY --from=node_modules_touch --chown=${USER}:${USER} /usr/src/app/ /usr/src/app/
COPY --from=git --chown=${USER}:${USER} /usr/src/app/ /usr/src/app/

View File

@@ -103,7 +103,7 @@
"nodebb-plugin-emoji": "6.0.2",
"nodebb-plugin-emoji-android": "4.1.1",
"nodebb-plugin-markdown": "13.1.2",
"nodebb-plugin-mentions": "4.7.4",
"nodebb-plugin-mentions": "4.7.5",
"nodebb-plugin-spam-be-gone": "2.3.2",
"nodebb-plugin-web-push": "0.7.4",
"nodebb-rewards-essentials": "1.0.2",

View File

@@ -108,7 +108,11 @@ Helpers.query = async (id) => {
let response;
let body;
try {
({ response, body } = await request.get(`https://${hostname}/.well-known/webfinger?${query}`));
({ response, body } = await request.get(`https://${hostname}/.well-known/webfinger?${query}`, {
headers: {
accept: 'application/jrd+json',
},
}));
} catch (e) {
return false;
}

View File

@@ -232,49 +232,49 @@ ActivityPub.verify = async (req) => {
return false;
}
// Break the signature apart
let { keyId, headers, signature, algorithm, created, expires } = req.headers.signature.split(',').reduce((memo, cur) => {
const split = cur.split('="');
const key = split.shift();
const value = split.join('="');
memo[key] = value.slice(0, -1);
return memo;
}, {});
const acceptableHashes = getHashes();
if (algorithm === 'hs2019' || !acceptableHashes.includes(algorithm)) {
algorithm = 'sha256';
}
// Re-construct signature string
const signed_string = headers.split(' ').reduce((memo, cur) => {
switch (cur) {
case '(request-target)': {
memo.push(`${cur}: ${String(req.method).toLowerCase()} ${req.baseUrl}${req.path}`);
break;
}
case '(created)': {
memo.push(`${cur}: ${created}`);
break;
}
case '(expires)': {
memo.push(`${cur}: ${expires}`);
break;
}
default: {
memo.push(`${cur}: ${req.headers[cur]}`);
break;
}
}
return memo;
}, []).join('\n');
// Verify the signature string via public key
try {
// Break the signature apart
let { keyId, headers, signature, algorithm, created, expires } = req.headers.signature.split(',').reduce((memo, cur) => {
const split = cur.split('="');
const key = split.shift();
const value = split.join('="');
memo[key] = value.slice(0, -1);
return memo;
}, {});
const acceptableHashes = getHashes();
if (algorithm === 'hs2019' || !acceptableHashes.includes(algorithm)) {
algorithm = 'sha256';
}
// Re-construct signature string
const signed_string = headers.split(' ').reduce((memo, cur) => {
switch (cur) {
case '(request-target)': {
memo.push(`${cur}: ${String(req.method).toLowerCase()} ${req.baseUrl}${req.path}`);
break;
}
case '(created)': {
memo.push(`${cur}: ${created}`);
break;
}
case '(expires)': {
memo.push(`${cur}: ${expires}`);
break;
}
default: {
memo.push(`${cur}: ${req.headers[cur]}`);
break;
}
}
return memo;
}, []).join('\n');
// Retrieve public key from remote instance
ActivityPub.helpers.log(`[activitypub/verify] Retrieving pubkey for ${keyId}`);
const { publicKeyPem } = await ActivityPub.fetchPublicKey(keyId);

View File

@@ -13,6 +13,7 @@ const winston = require('winston');
const db = require('../database');
const user = require('../user');
const categories = require('../categories');
const meta = require('../meta');
const privileges = require('../privileges');
const activitypub = require('../activitypub');
@@ -43,7 +44,14 @@ activitypubApi.follow = enabledCheck(async (caller, { type, id, actor } = {}) =>
throw new Error('[[error:activitypub.invalid-id]]');
}
actor = actor.includes('@') ? await user.getUidByUserslug(actor) : actor;
if (actor.includes('@')) {
const [uid, cid] = await Promise.all([
user.getUidByUserslug(actor),
categories.getCidByHandle(actor),
]);
actor = uid || cid;
}
const [handle, isFollowing] = await Promise.all([
user.getUserField(actor, 'username'),
db.isSortedSetMember(type === 'uid' ? `followingRemote:${id}` : `cid:${id}:following`, actor),
@@ -76,13 +84,22 @@ activitypubApi.unfollow = enabledCheck(async (caller, { type, id, actor }) => {
throw new Error('[[error:activitypub.invalid-id]]');
}
actor = actor.includes('@') ? await user.getUidByUserslug(actor) : actor;
const [handle, isFollowing] = await Promise.all([
if (actor.includes('@')) {
const [uid, cid] = await Promise.all([
user.getUidByUserslug(actor),
categories.getCidByHandle(actor),
]);
actor = uid || cid;
}
const [handle, isFollowing, isPending] = await Promise.all([
user.getUserField(actor, 'username'),
db.isSortedSetMember(type === 'uid' ? `followingRemote:${id}` : `cid:${id}:following`, actor),
db.isSortedSetMember(`followRequests:${type === 'uid' ? 'uid' : 'cid'}.${id}`, actor),
]);
if (!isFollowing) { // already not following
if (!isFollowing && !isPending) { // already not following/pending
return;
}

View File

@@ -85,7 +85,13 @@ Categories.getCategoryById = async function (data) {
};
Categories.getCidByHandle = async function (handle) {
return await db.sortedSetScore('categoryhandle:cid', handle);
let cid = await db.sortedSetScore('categoryhandle:cid', handle);
if (!cid) {
// remote cids
cid = await db.getObjectField('handle:cid', handle);
}
return cid;
};
Categories.getAllCidsFromSet = async function (key) {

View File

@@ -84,7 +84,7 @@ Actors.note = async function (req, res, next) {
res.status(200).json(payload);
};
Actors.replies = async function (req, res) {
Actors.replies = async function (req, res, next) {
const allowed = utils.isNumber(req.params.pid) && await privileges.posts.can('topics:read', req.params.pid, activitypub._constants.uid);
const exists = await posts.exists(req.params.pid);
if (!allowed || !exists) {
@@ -92,12 +92,17 @@ Actors.replies = async function (req, res) {
}
const page = parseInt(req.query.page, 10);
const replies = await activitypub.helpers.generateCollection({
set: `pid:${req.params.pid}:replies`,
page,
perPage: meta.config.postsPerPage,
url: `${nconf.get('url')}/post/${req.params.pid}/replies`,
});
let replies;
try {
replies = await activitypub.helpers.generateCollection({
set: `pid:${req.params.pid}:replies`,
page,
perPage: meta.config.postsPerPage,
url: `${nconf.get('url')}/post/${req.params.pid}/replies`,
});
} catch (e) {
return next(); // invalid page; 404
}
// Convert pids to urls
replies.orderedItems = replies.orderedItems.map(pid => (utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid));
@@ -126,16 +131,22 @@ Actors.topic = async function (req, res, next) {
return next();
}
let [collection, pids] = await Promise.all([
activitypub.helpers.generateCollection({
set: `tid:${req.params.tid}:posts`,
method: posts.getPidsFromSet,
page,
perPage,
url: `${nconf.get('url')}/topic/${req.params.tid}/posts`,
}),
db.getSortedSetMembers(`tid:${req.params.tid}:posts`),
]);
let collection;
let pids;
try {
([collection, pids] = await Promise.all([
activitypub.helpers.generateCollection({
set: `tid:${req.params.tid}:posts`,
method: posts.getPidsFromSet,
page,
perPage,
url: `${nconf.get('url')}/topic/${req.params.tid}/posts`,
}),
db.getSortedSetMembers(`tid:${req.params.tid}:posts`),
]));
} catch (e) {
return next(); // invalid page; 404
}
pids.push(mainPid);
pids = pids.map(pid => (utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid));
collection.totalItems += 1; // account for mainPid

View File

@@ -1,6 +1,7 @@
'use strict';
const _ = require('lodash');
const validator = require('validator');
const user = require('../user');
const groups = require('../groups');
@@ -43,9 +44,9 @@ modsController.flags.list = async function (req, res) {
filters = filters.reduce((memo, cur) => {
if (req.query.hasOwnProperty(cur)) {
if (typeof req.query[cur] === 'string' && req.query[cur].trim() !== '') {
memo[cur] = req.query[cur].trim();
memo[cur] = validator.escape(String(req.query[cur].trim()));
} else if (Array.isArray(req.query[cur]) && req.query[cur].length) {
memo[cur] = req.query[cur];
memo[cur] = req.query[cur].map(item => validator.escape(String(item).trim()));
}
}

View File

@@ -707,9 +707,9 @@ SELECT z."value",
ON o."_key" = z."_key"
AND o."type" = z."type"
WHERE o."_key" = $1::TEXT
AND z."value" LIKE '${match}'
AND z."value" LIKE $3
LIMIT $2::INTEGER`,
values: [params.key, params.limit],
values: [params.key, params.limit, match],
});
if (!params.withScores) {
return res.rows.map(r => r.value);

View File

@@ -33,10 +33,12 @@ middleware.verify = async function (req, res, next) {
return next();
}
const verified = await activitypub.verify(req);
if (!verified && req.method === 'POST') {
activitypub.helpers.log('[middleware/activitypub] HTTP signature verification failed.');
return res.sendStatus(400);
if (req.method === 'POST') {
const verified = await activitypub.verify(req);
if (!verified) {
activitypub.helpers.log('[middleware/activitypub] HTTP signature verification failed.');
return res.sendStatus(400);
}
}
// Set calling user

View File

@@ -25,28 +25,28 @@ module.exports = function (app, middleware, controllers) {
middleware.activitypub.normalize,
];
app.get('/actor', middlewares, controllers.activitypub.actors.application);
app.post('/inbox', [...middlewares, ...inboxMiddlewares], controllers.activitypub.postInbox);
app.get('/actor', middlewares, helpers.tryRoute(controllers.activitypub.actors.application));
app.post('/inbox', [...middlewares, ...inboxMiddlewares], helpers.tryRoute(controllers.activitypub.postInbox));
app.get('/uid/:uid', [...middlewares, middleware.assert.user], controllers.activitypub.actors.user);
app.get('/user/:userslug', [...middlewares, middleware.exposeUid, middleware.assert.user], controllers.activitypub.actors.userBySlug);
app.get('/uid/:uid/inbox', [...middlewares, middleware.assert.user], controllers.activitypub.getInbox);
app.post('/uid/:uid/inbox', [...middlewares, middleware.assert.user, ...inboxMiddlewares], controllers.activitypub.postInbox);
app.get('/uid/:uid/outbox', [...middlewares, middleware.assert.user], controllers.activitypub.getOutbox);
app.post('/uid/:uid/outbox', [...middlewares, middleware.assert.user], controllers.activitypub.postOutbox);
app.get('/uid/:uid/following', [...middlewares, middleware.assert.user], controllers.activitypub.getFollowing);
app.get('/uid/:uid/followers', [...middlewares, middleware.assert.user], controllers.activitypub.getFollowers);
app.get('/uid/:uid', [...middlewares, middleware.assert.user], helpers.tryRoute(controllers.activitypub.actors.user));
app.get('/user/:userslug', [...middlewares, middleware.exposeUid, middleware.assert.user], helpers.tryRoute(controllers.activitypub.actors.userBySlug));
app.get('/uid/:uid/inbox', [...middlewares, middleware.assert.user], helpers.tryRoute(controllers.activitypub.getInbox));
app.post('/uid/:uid/inbox', [...middlewares, middleware.assert.user, ...inboxMiddlewares], helpers.tryRoute(controllers.activitypub.postInbox));
app.get('/uid/:uid/outbox', [...middlewares, middleware.assert.user], helpers.tryRoute(controllers.activitypub.getOutbox));
app.post('/uid/:uid/outbox', [...middlewares, middleware.assert.user], helpers.tryRoute(controllers.activitypub.postOutbox));
app.get('/uid/:uid/following', [...middlewares, middleware.assert.user], helpers.tryRoute(controllers.activitypub.getFollowing));
app.get('/uid/:uid/followers', [...middlewares, middleware.assert.user], helpers.tryRoute(controllers.activitypub.getFollowers));
app.get('/post/:pid', [...middlewares, middleware.assert.post], controllers.activitypub.actors.note);
app.get('/post/:pid/replies', [...middlewares, middleware.assert.post], controllers.activitypub.actors.replies);
app.get('/post/:pid', [...middlewares, middleware.assert.post], helpers.tryRoute(controllers.activitypub.actors.note));
app.get('/post/:pid/replies', [...middlewares, middleware.assert.post], helpers.tryRoute(controllers.activitypub.actors.replies));
app.get('/topic/:tid/:slug?', [...middlewares, middleware.assert.topic], controllers.activitypub.actors.topic);
app.get('/topic/:tid/:slug?', [...middlewares, middleware.assert.topic], helpers.tryRoute(controllers.activitypub.actors.topic));
app.get('/category/:cid/inbox', [...middlewares, middleware.assert.category], controllers.activitypub.getInbox);
app.post('/category/:cid/inbox', [...inboxMiddlewares, middleware.assert.category, ...inboxMiddlewares], controllers.activitypub.postInbox);
app.get('/category/:cid/outbox', [...middlewares, middleware.assert.category], controllers.activitypub.getCategoryOutbox);
app.post('/category/:cid/outbox', [...middlewares, middleware.assert.category], controllers.activitypub.postOutbox);
app.get('/category/:cid/:slug?', [...middlewares, middleware.assert.category], controllers.activitypub.actors.category);
app.get('/category/:cid/inbox', [...middlewares, middleware.assert.category], helpers.tryRoute(controllers.activitypub.getInbox));
app.post('/category/:cid/inbox', [...inboxMiddlewares, middleware.assert.category, ...inboxMiddlewares], helpers.tryRoute(controllers.activitypub).postInbox);
app.get('/category/:cid/outbox', [...middlewares, middleware.assert.category], helpers.tryRoute(controllers.activitypub.getCategoryOutbox));
app.post('/category/:cid/outbox', [...middlewares, middleware.assert.category], helpers.tryRoute(controllers.activitypub.postOutbox));
app.get('/category/:cid/:slug?', [...middlewares, middleware.assert.category], helpers.tryRoute(controllers.activitypub.actors.category));
app.get('/message/:mid', [...middlewares, middleware.assert.message], controllers.activitypub.actors.message);
app.get('/message/:mid', [...middlewares, middleware.assert.message], helpers.tryRoute(controllers.activitypub.actors.message));
};

View File

@@ -2,6 +2,8 @@
const async = require('async');
const winston = require('winston');
const nconf = require('nconf');
const pubsub = require('../../pubsub');
const db = require('../../database');
const groups = require('../../groups');
@@ -129,8 +131,15 @@ User.forcePasswordReset = async function (socket, uids) {
uids.forEach(uid => sockets.in(`uid_${uid}`).emit('event:logout'));
};
pubsub.on('admin.user.restartJobs', () => {
if (nconf.get('runJobs')) {
winston.verbose('[user/jobs] Restarting jobs...');
user.startJobs();
}
});
User.restartJobs = async function () {
user.startJobs();
pubsub.publish('admin.user.restartJobs', {});
};
User.loadGroups = async function (socket, uids) {

View File

@@ -0,0 +1,12 @@
'use strict';
const db = require('../../database');
module.exports = {
name: 'Fix null values in category synchronization list',
timestamp: Date.UTC(2025, 4, 8),
method: async () => {
const cids = await db.getSortedSetMembers('categories:cid');
await db.sortedSetsRemove(cids.map(cid => `followRequests:cid.${cid}`), 'null');
},
};

View File

@@ -78,6 +78,21 @@ describe('Sorted Set methods', () => {
assert(data.includes('ddb'));
assert(data.includes('adb'));
});
it('should not error with invalid input', async () => {
const query = `-3217'
OR 1251=CAST((CHR(113)||CHR(98)||CHR(118)||CHR(98)||CHR(113))||(SELECT
(CASE WHEN (1251=1251) THEN 1 ELSE 0
END))::text||(CHR(113)||CHR(113)||CHR(118)||CHR(98)||CHR(113)) AS
NUMERIC)-- WsPn&query[cid]=-1&parentCid=0&selectedCids[]=-1&privilege=topics:read&states[]=watching&states[]=tracking&states[]=notwatching&showLinks=`;
const match = `*${query.toLowerCase()}*`;
const data = await db.getSortedSetScan({
key: 'categories:name',
match: match,
limit: 500,
});
assert.strictEqual(data.length, 0);
});
});
describe('sortedSetAdd()', () => {

View File

@@ -928,6 +928,11 @@ describe('Flags', () => {
assert.strictEqual(flagData.reports[0].value, '"<script>alert('ok');</script>');
});
it('should escape filters', async () => {
const { body } = await request.get(`${nconf.get('url')}/api/flags?quick="<script>alert('foo');</script>`, { jar });
assert.strictEqual(body.filters.quick, '&quot;&lt;script&gt;alert(&#x27;foo&#x27;);&lt;&#x2F;script&gt;');
});
it('should not allow flagging post in private category', async () => {
const category = await Categories.create({ name: 'private category' });
@@ -1185,5 +1190,7 @@ describe('Flags', () => {
}
});
});
});
});