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('