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 021d7eeff5..455c4475e2 100644 --- a/install/package.json +++ b/install/package.json @@ -2,7 +2,7 @@ "name": "nodebb", "license": "GPL-3.0", "description": "NodeBB Forum", - "version": "4.0.4", + "version": "4.0.5", "homepage": "https://www.nodebb.org", "repository": { "type": "git", @@ -99,7 +99,7 @@ "multiparty": "4.2.3", "nconf": "0.12.1", "nodebb-plugin-2factor": "7.5.9", - "nodebb-plugin-composer-default": "10.2.45", + "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", diff --git a/src/activitypub/helpers.js b/src/activitypub/helpers.js index 0c649a3945..2311db66b5 100644 --- a/src/activitypub/helpers.js +++ b/src/activitypub/helpers.js @@ -64,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/mocks.js b/src/activitypub/mocks.js index 3bbcd5c3e9..d45e585f81 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'], }, }; @@ -342,23 +344,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': [ @@ -376,7 +379,7 @@ Mocks.actors.category = async (cid) => { name, preferredUsername, summary, - image, + // image, // todo once categories have cover photos icon, publicKey: { 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/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/test/activitypub.js b/test/activitypub.js index aee20d8037..d2c2334140 100644 --- a/test/activitypub.js +++ b/test/activitypub.js @@ -350,6 +350,79 @@ describe('ActivityPub integration', () => { }); }); + describe.only('Category Actor endpoint', () => { + let cid; + let slug; + let description; + + beforeEach(async () => { + slug = slugify(utils.generateUUID().slice(0, 8)); + description = utils.generateUUID(); + ({ cid } = await categories.create({ + name: slug, + description, + })); + }); + + it('should return a valid ActivityPub Actor JSON-LD payload', async () => { + const { response, body } = await request.get(`${nconf.get('url')}/category/${cid}`, { + headers: { + Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + }, + }); + + assert(response); + assert.strictEqual(response.statusCode, 200); + assert(body.hasOwnProperty('@context')); + assert(body['@context'].includes('https://www.w3.org/ns/activitystreams')); + + ['id', 'url', /* 'followers', 'following', */ 'inbox', 'outbox'].forEach((prop) => { + assert(body.hasOwnProperty(prop)); + assert(body[prop]); + }); + + assert.strictEqual(body.id, `${nconf.get('url')}/category/${cid}`); + assert.strictEqual(body.type, 'Group'); + assert.strictEqual(body.summary, description); + assert.deepStrictEqual(body.icon, { + type: 'Image', + mediaType: 'image/png', + url: `${nconf.get('url')}/assets/uploads/category/category-${cid}-icon.png`, + }); + }); + + it('should contain a `publicKey` property with a public key', async () => { + const { body } = await request.get(`${nconf.get('url')}/category/${cid}`, { + headers: { + Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + }, + }); + + assert(body.hasOwnProperty('publicKey')); + assert(['id', 'owner', 'publicKeyPem'].every(prop => body.publicKey.hasOwnProperty(prop))); + }); + + it('should serve the the backgroundImage in `icon` if set', async () => { + const payload = {}; + payload[cid] = { + backgroundImage: `/assets/uploads/files/test.png`, + }; + await categories.update(payload); + + const { body } = await request.get(`${nconf.get('url')}/category/${cid}`, { + headers: { + Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + }, + }); + + assert.deepStrictEqual(body.icon, { + type: 'Image', + mediaType: 'image/png', + url: `${nconf.get('url')}/assets/uploads/files/test.png`, + }); + }); + }); + describe('Instance Actor endpoint', () => { let response; let body;