diff --git a/CHANGELOG.md b/CHANGELOG.md index ec4faa3658..9010b97b06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,49 @@ +#### v4.4.4 (2025-06-18) + +##### Chores + +* incrementing version number - v4.4.3 (d354c2eb) +* update changelog for v4.4.3 (0c9297f8) +* incrementing version number - v4.4.2 (55c510ae) +* incrementing version number - v4.4.1 (5ae79b4e) +* incrementing version number - v4.4.0 (0a75eee3) +* 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 + +* link to post in preview timestamp (8c69c6a0) +* Add live reload functionality with Grunt watch and Socket.IO (#13489) (84d99a0f) +* closes #13484, post preview changes (14e30c4b) + +##### Bug Fixes + +* sanitize svg when uploading site-logo, default avatar and og:image (da2597f8) +* Revise package hash check in Docker entrypoint.sh (#13483) (6c5b2268) +* more edge cases (32faaba0) +* #13484, clear tooltip if cursor leaves link (0ebb31fe) + +##### Other Changes + +* fix lint (8ab034d8) + +##### Refactors + +* send single message (dc37789b) + #### v4.4.3 (2025-06-09) ##### Chores diff --git a/README.md b/README.md index 6c5d80e0fd..fb8b8702fe 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Our minimalist "Harmony" theme gets you going right away, no coding experience r NodeBB requires the following software to be installed: * A version of Node.js at least 20 or greater ([installation/upgrade instructions](https://github.com/nodesource/distributions)) -* MongoDB, version 3.6 or greater **or** Redis, version 2.8.9 or greater +* MongoDB, version 5 or greater **or** Redis, version 7.2 or greater * If you are using [clustering](https://docs.nodebb.org/configuring/scaling/) you need Redis installed and configured. * nginx, version 1.3.13 or greater (**only if** intending to use nginx to proxy requests to a NodeBB) diff --git a/install/package.json b/install/package.json index 4033fe1a27..dbb07c0c20 100644 --- a/install/package.json +++ b/install/package.json @@ -35,7 +35,6 @@ "@isaacs/ttlcache": "1.4.1", "@nodebb/spider-detector": "2.0.3", "@popperjs/core": "2.11.8", - "@resvg/resvg-js": "2.6.2", "@textcomplete/contenteditable": "0.1.13", "@textcomplete/core": "0.1.13", "@textcomplete/textarea": "0.1.13", diff --git a/public/openapi/write/posts/pid/diffs.yaml b/public/openapi/write/posts/pid/diffs.yaml index ea76f7ea66..ca8dd91ac7 100644 --- a/public/openapi/write/posts/pid/diffs.yaml +++ b/public/openapi/write/posts/pid/diffs.yaml @@ -24,6 +24,10 @@ get: response: type: object properties: + uid: + type: number + pid: + type: number timestamps: type: array items: @@ -37,6 +41,8 @@ get: type: string username: type: string + uid: + type: number editable: type: boolean deletable: diff --git a/renovate.json b/renovate.json index 7cbecd9e95..16656afbcc 100644 --- a/renovate.json +++ b/renovate.json @@ -2,7 +2,7 @@ "extends": [ "config:recommended" ], - "baseBranches": [ + "baseBranchPatterns": [ "develop" ], "labels": [ @@ -14,8 +14,7 @@ "dependencies" ], "rangeStrategy": "pin", - "matchPackageNames": [ - ] + "matchPackageNames": [] }, { "matchDepTypes": [ diff --git a/src/activitypub/index.js b/src/activitypub/index.js index 4067405080..926f8d0754 100644 --- a/src/activitypub/index.js +++ b/src/activitypub/index.js @@ -346,7 +346,16 @@ ActivityPub.get = async (type, id, uri, options) => { } }; -ActivityPub.retryQueue = lru({ name: 'activitypub-retry-queue', max: 4000, ttl: 1000 * 60 * 60 * 24 * 60 }); +ActivityPub.retryQueue = lru({ + name: 'activitypub-retry-queue', + max: 4000, + ttl: 1000 * 60 * 60 * 24 * 60, + dispose: (value) => { + if (value) { + clearTimeout(value); + } + }, +}); // handle clearing retry queue from another member of the cluster pubsub.on(`activitypub-retry-queue:lruCache:del`, (keys) => { diff --git a/src/api/posts.js b/src/api/posts.js index b39c173eb6..7946a1d311 100644 --- a/src/api/posts.js +++ b/src/api/posts.js @@ -491,10 +491,12 @@ async function diffsPrivilegeCheck(pid, uid) { postsAPI.getDiffs = async (caller, data) => { await diffsPrivilegeCheck(data.pid, caller.uid); - const timestamps = await posts.diffs.list(data.pid); - const post = await posts.getPostFields(data.pid, ['timestamp', 'uid']); + const [timestamps, post, diffs] = await Promise.all([ + posts.diffs.list(data.pid), + posts.getPostFields(data.pid, ['timestamp', 'uid']), + posts.diffs.get(data.pid), + ]); - const diffs = await posts.diffs.get(data.pid); const uids = diffs.map(diff => diff.uid || null); uids.push(post.uid); let usernames = await user.getUsersFields(uids, ['username']); @@ -508,18 +510,21 @@ postsAPI.getDiffs = async (caller, data) => { // timestamps returned by posts.diffs.list are strings timestamps.push(String(post.timestamp)); - - return { + const result = await plugins.hooks.fire('filter:post.getDiffs', { + uid: caller.uid, + pid: data.pid, timestamps: timestamps, revisions: timestamps.map((timestamp, idx) => ({ timestamp: timestamp, username: usernames[idx], + uid: uids[idx], })), // Only admins, global mods and moderator of that cid can delete a diff deletable: isAdmin || isModerator, // These and post owners can restore to a different post version editable: isAdmin || isModerator || parseInt(caller.uid, 10) === parseInt(post.uid, 10), - }; + }); + return result; }; postsAPI.loadDiff = async (caller, data) => { diff --git a/src/categories/icon.js b/src/categories/icon.js index 93f5614630..7aa94ab802 100644 --- a/src/categories/icon.js +++ b/src/categories/icon.js @@ -5,7 +5,7 @@ const fs = require('fs/promises'); const nconf = require('nconf'); const winston = require('winston'); const { default: satori } = require('satori'); -const { Resvg } = require('@resvg/resvg-js'); +const sharp = require('sharp'); const utils = require('../utils'); @@ -96,9 +96,9 @@ Icons.regenerate = async (cid) => { await fs.writeFile(path.resolve(nconf.get('upload_path'), 'category', `category-${cid}-icon.svg`), svg); // Generate and save PNG - const resvg = new Resvg(Buffer.from(svg)); - const pngData = resvg.render(); - const pngBuffer = pngData.asPng(); + const pngBuffer = await sharp(Buffer.from(svg)) + .png() + .toBuffer(); await fs.writeFile(path.resolve(nconf.get('upload_path'), 'category', `category-${cid}-icon.png`), pngBuffer); diff --git a/src/controllers/admin/uploads.js b/src/controllers/admin/uploads.js index ccd4261b36..0f70380695 100644 --- a/src/controllers/admin/uploads.js +++ b/src/controllers/admin/uploads.js @@ -4,7 +4,6 @@ const path = require('path'); const nconf = require('nconf'); const fs = require('fs'); const winston = require('winston'); -const sanitizeHtml = require('sanitize-html'); const meta = require('../../meta'); const posts = require('../../posts'); @@ -157,50 +156,11 @@ uploadsController.uploadCategoryPicture = async function (req, res, next) { return next(new Error('[[error:invalid-json]]')); } - if (uploadedFile.path.endsWith('.svg')) { - await sanitizeSvg(uploadedFile.path); - } - await validateUpload(uploadedFile, allowedImageTypes); const filename = `category-${params.cid}${path.extname(uploadedFile.name)}`; await uploadImage(filename, 'category', uploadedFile, req, res, next); }; -async function sanitizeSvg(filePath) { - const dirty = await fs.promises.readFile(filePath, 'utf8'); - const clean = sanitizeHtml(dirty, { - allowedTags: [ - 'svg', 'g', 'defs', 'linearGradient', 'radialGradient', 'stop', - 'circle', 'ellipse', 'polygon', 'polyline', 'path', 'rect', - 'line', 'text', 'tspan', 'use', 'symbol', 'clipPath', 'mask', 'pattern', - 'filter', 'feGaussianBlur', 'feOffset', 'feBlend', 'feColorMatrix', 'feMerge', 'feMergeNode', - ], - allowedAttributes: { - '*': [ - // Geometry - 'x', 'y', 'x1', 'x2', 'y1', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', - 'width', 'height', 'd', 'points', 'viewBox', 'transform', - - // Presentation - 'fill', 'stroke', 'stroke-width', 'opacity', - 'stop-color', 'stop-opacity', 'offset', 'style', 'class', - - // Text - 'text-anchor', 'font-size', 'font-family', - - // Misc - 'id', 'clip-path', 'mask', 'filter', 'gradientUnits', 'gradientTransform', - 'xmlns', 'preserveAspectRatio', - ], - }, - parser: { - lowerCaseTags: false, - lowerCaseAttributeNames: false, - }, - }); - await fs.promises.writeFile(filePath, clean); -} - uploadsController.uploadFavicon = async function (req, res, next) { const uploadedFile = req.files.files[0]; const allowedTypes = ['image/x-icon', 'image/vnd.microsoft.icon']; @@ -296,10 +256,6 @@ uploadsController.uploadOgImage = async function (req, res, next) { async function upload(name, req, res, next) { const uploadedFile = req.files.files[0]; - if (uploadedFile.path.endsWith('.svg')) { - await sanitizeSvg(uploadedFile.path); - } - await validateUpload(uploadedFile, allowedImageTypes); const filename = name + path.extname(uploadedFile.name); await uploadImage(filename, 'system', uploadedFile, req, res, next); diff --git a/src/file.js b/src/file.js index 639cc9f58c..7bfc957a94 100644 --- a/src/file.js +++ b/src/file.js @@ -7,6 +7,7 @@ const winston = require('winston'); const { mkdirp } = require('mkdirp'); const mime = require('mime'); const graceful = require('graceful-fs'); +const sanitizeHtml = require('sanitize-html'); const slugify = require('./slugify'); @@ -27,6 +28,10 @@ file.saveFileToLocal = async function (filename, folder, tempPath) { winston.verbose(`Saving file ${filename} to : ${uploadPath}`); await mkdirp(path.dirname(uploadPath)); + if (filename.endsWith('.svg')) { + await sanitizeSvg(tempPath); + } + await fs.promises.copyFile(tempPath, uploadPath); return { url: `/assets/uploads/${folder ? `${folder}/` : ''}${filename}`, @@ -155,4 +160,39 @@ file.walk = async function (dir) { return files.reduce((a, f) => a.concat(f), []); }; +async function sanitizeSvg(filePath) { + const dirty = await fs.promises.readFile(filePath, 'utf8'); + const clean = sanitizeHtml(dirty, { + allowedTags: [ + 'svg', 'g', 'defs', 'linearGradient', 'radialGradient', 'stop', + 'circle', 'ellipse', 'polygon', 'polyline', 'path', 'rect', + 'line', 'text', 'tspan', 'use', 'symbol', 'clipPath', 'mask', 'pattern', + 'filter', 'feGaussianBlur', 'feOffset', 'feBlend', 'feColorMatrix', 'feMerge', 'feMergeNode', + ], + allowedAttributes: { + '*': [ + // Geometry + 'x', 'y', 'x1', 'x2', 'y1', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', + 'width', 'height', 'd', 'points', 'viewBox', 'transform', + + // Presentation + 'fill', 'stroke', 'stroke-width', 'opacity', + 'stop-color', 'stop-opacity', 'offset', 'style', 'class', + + // Text + 'text-anchor', 'font-size', 'font-family', + + // Misc + 'id', 'clip-path', 'mask', 'filter', 'gradientUnits', 'gradientTransform', + 'xmlns', 'preserveAspectRatio', + ], + }, + parser: { + lowerCaseTags: false, + lowerCaseAttributeNames: false, + }, + }); + await fs.promises.writeFile(filePath, clean); +} + require('./promisify')(file); diff --git a/test/activitypub/notes.js b/test/activitypub/notes.js index fbbf0c59ec..e8672eb283 100644 --- a/test/activitypub/notes.js +++ b/test/activitypub/notes.js @@ -466,7 +466,8 @@ describe('Notes', () => { assert.strictEqual(cid, remoteCid); }); - it('should create a new topic in cid -1 if a non-same origin remote category is addressed', async () => { + it('should create a new topic in cid -1 if a non-same origin remote category is addressed', async function () { + this.timeout(60000); const { id: remoteCid } = helpers.mocks.group({ id: `https://example.com/${utils.generateUUID()}`, }); diff --git a/test/files/dirty.svg b/test/files/dirty.svg new file mode 100644 index 0000000000..a948be59b2 --- /dev/null +++ b/test/files/dirty.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/test/uploads.js b/test/uploads.js index 3b361e36d3..4fc0e41a64 100644 --- a/test/uploads.js +++ b/test/uploads.js @@ -338,6 +338,15 @@ describe('Upload Controllers', () => { assert.equal(body[0].url, `${nconf.get('relative_path')}/assets/uploads/category/category-1.png`); }); + it('should upload svg as category image after cleaning it up', async () => { + const { response, body } = await helpers.uploadFile(`${nconf.get('url')}/api/admin/category/uploadpicture`, path.join(__dirname, '../test/files/dirty.svg'), { params: JSON.stringify({ cid: cid }) }, jar, csrf_token); + assert.equal(response.statusCode, 200); + assert(Array.isArray(body)); + assert.equal(body[0].url, `${nconf.get('relative_path')}/assets/uploads/category/category-1.svg`); + const svgContents = await fs.readFile(path.join(__dirname, '../test/uploads/category/category-1.svg'), 'utf-8'); + assert.strictEqual(svgContents.includes('