Compare commits

..

32 Commits

Author SHA1 Message Date
Misty Release Bot
bb913c152b chore: incrementing version number - v4.4.6 2025-08-06 17:48:33 +00:00
Barış Soner Uşaklı
32de562e70 Revert "feat: add inspect argument"
This reverts commit 955b27debc.
2025-08-06 13:38:42 -04:00
Barış Soner Uşaklı
955b27debc feat: add inspect argument 2025-08-06 13:10:56 -04:00
Barış Soner Uşaklı
567ed8755b feat: add new brite skin from bootswatch 2025-08-01 16:18:10 -04:00
Barış Soner Uşaklı
d5f57af342 fix: pass max-memory expose-gc as process args 2025-08-01 12:21:17 -04:00
Misty Release Bot
de05dad251 chore: update changelog for v4.4.5 2025-07-31 13:57:00 +00:00
Misty Release Bot
af95cde187 chore: incrementing version number - v4.4.5 2025-07-31 13:57:00 +00:00
Barış Soner Uşaklı
5f5a697253 test: one more fix 2025-07-31 09:24:04 -04:00
Barış Soner Uşaklı
3b60931604 test: fix spec 2025-07-31 09:17:26 -04:00
Barış Soner Uşaklı
c7c83e0e4b test: fix openapi 2025-07-31 09:14:19 -04:00
Barış Soner Uşaklı
7c00e814b7 refactor: use promise.all 2025-07-31 09:02:58 -04:00
Barış Soner Uşaklı
bbb9a46019 feat: add filter:post.getDiffs 2025-07-31 09:02:49 -04:00
Barış Soner Uşaklı
5f696176b4 fix: clearTimeout if item is evicted from cache 2025-07-27 10:38:09 -04:00
Barış Soner Uşaklı
fe9b49e3d5 test: increase timeout of failing test 2025-07-25 10:57:30 -04:00
Barış Soner Uşaklı
b74c789849 fix: use sharp to convert svg to png, closes #13534 2025-07-25 10:49:52 -04:00
renovate[bot]
5a86415092 chore(config): migrate config renovate.json (#13565)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-25 10:28:40 -04:00
Barış Soner Uşaklı
5bcf078a75 fix: use filename to check for svg, tempPath doesn't always have extension 2025-07-24 12:07:26 -04:00
Barış Soner Uşaklı
a8f4c5e63a fix: apply sanitizeSvg to regular uploads and uploads from manage uploads acp page 2025-07-24 10:34:37 -04:00
Barış Uşaklı
3e961257ec Update README.md 2025-06-18 13:25:36 -04:00
Misty Release Bot
7b14e26775 chore: update changelog for v4.4.4 2025-06-18 14:20:41 +00:00
Misty Release Bot
2490c312c9 chore: incrementing version number - v4.4.4 2025-06-18 14:20:41 +00:00
Barış Soner Uşaklı
a3fed408e5 change default to perma ban 2025-06-17 09:21:00 -04:00
Barış Soner Uşaklı
8c69c6a0c4 feat: link to post in preview timestamp 2025-06-17 09:17:57 -04:00
Barış Soner Uşaklı
da2597f81c fix: sanitize svg when uploading site-logo, default avatar and og:image 2025-06-11 17:13:56 -04:00
Barış Soner Uşaklı
dc37789b5d refactor: send single message 2025-06-11 13:16:52 -04:00
Eli Sheinfeld
84d99a0fc7 feat: Add live reload functionality with Grunt watch and Socket.IO (#13489)
- Added livereload event to Grunt watch tasks for instant browser refresh
- Integrated Socket.IO WebSocket communication for real-time updates
- Enhanced development workflow with immediate file change detection
- Improved developer experience with automatic browser reload on file changes

Changes:
- Gruntfile.js: Send livereload message when files change
- src/start.js: Handle livereload events and broadcast via Socket.IO
2025-06-11 13:13:23 -04:00
cliffmccarthy
6c5b22684b fix: Revise package hash check in Docker entrypoint.sh (#13483)
- In the build_forum() function, the file install_hash.md5 is intended
  to track the content of package.json and detect changes that imply
  the need to run 'nodebb upgrade'.
- The check to compare the current checksum of package.json to the one
  saved in install_hash.md5 is reversed.  The "package.json was
  updated" branch is taken when the hashes are the same, not when they
  are different.
- When install_hash.md5 does not exist, the comparison value becomes
  the null string, which never matches the checksum of package.json.
  As a result, the code always takes the "No changes in package.json"
  branch and returns from the function without creating
  install_hash.md5.  As a result, install_hash.md5 never gets created
  on a new installation.
- Revised build_forum() to use "not equals" when comparing the two
  checksums.  This causes it to run 'nodebb upgrade' when the
  checksums are different, and also when install_hash.md5 does not yet
  exist.  If the checksum saved in install_hash.md5 matches the
  current package.json checksum, it proceeds to either the "Build
  before start" case or the "No changes" case.
2025-06-11 09:52:36 -04:00
Barış Soner Uşaklı
32faaba0e5 fix: more edge cases 2025-06-10 13:36:23 -04:00
Barış Soner Uşaklı
0ebb31fe87 fix: #13484, clear tooltip if cursor leaves link
and doesn't enter tooltip
2025-06-10 12:39:49 -04:00
Barış Soner Uşaklı
8ab034d8f0 lint: fix lint 2025-06-10 10:52:55 -04:00
Barış Soner Uşaklı
14e30c4bf8 feat: closes #13484, post preview changes
don't close preview when mouse leaves the anchor
close preview on click outside
close preview when mouseleaves preview
open the preview to the top if there isn't enough space
add scrollbar to post preview
2025-06-10 10:47:14 -04:00
Misty Release Bot
0c9297f81c chore: update changelog for v4.4.3 2025-06-09 15:26:59 +00:00
22 changed files with 337 additions and 127 deletions

View File

@@ -1,3 +1,128 @@
#### v4.4.5 (2025-07-31)
##### Chores
* **config:** migrate config renovate.json (#13565) (5a864150)
* incrementing version number - v4.4.4 (d323af44)
* update changelog for v4.4.4 (7b14e267)
* incrementing version number - v4.4.3 (d354c2eb)
* 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
* add filter:post.getDiffs (bbb9a460)
##### Bug Fixes
* clearTimeout if item is evicted from cache (5f696176)
* use sharp to convert svg to png, closes #13534 (b74c7898)
* use filename to check for svg, tempPath doesn't always have extension (5bcf078a)
* apply sanitizeSvg to regular uploads and uploads from manage uploads acp page (a8f4c5e6)
##### Refactors
* use promise.all (7c00e814)
##### Tests
* one more fix (5f5a6972)
* fix spec (3b609316)
* fix openapi (c7c83e0e)
* increase timeout of failing test (fe9b49e3)
#### 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
* up composer (5f51dfc4)
* incrementing version number - v4.4.2 (55c510ae)
* update changelog for v4.4.2 (6d40a211)
* 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)
##### Bug Fixes
* escape, query params (b02eb57d)
* closes #13475, don't store escaped username (806e54bf)
#### v4.4.2 (2025-06-02)
##### Chores

View File

@@ -173,7 +173,10 @@ module.exports = function (grunt) {
winston.error(err.stack);
}
if (worker) {
worker.send({ compiling: compiling });
worker.send({
compiling: compiling,
livereload: true, // Send livereload event via Socket.IO for instant browser refresh
});
}
});
});

View File

@@ -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)

View File

@@ -103,7 +103,7 @@ build_forum() {
local config="$1"
local start_build="$2"
local package_hash=$(md5sum install/package.json | head -c 32)
if [ "$package_hash" = "$(cat $CONFIG_DIR/install_hash.md5 || true)" ]; then
if [ "$package_hash" != "$(cat $CONFIG_DIR/install_hash.md5 || true)" ]; then
echo "package.json was updated. Upgrading..."
/usr/src/app/nodebb upgrade --config="$config" || {
echo "Failed to build NodeBB. Exiting..."

View File

@@ -2,7 +2,7 @@
"name": "nodebb",
"license": "GPL-3.0",
"description": "NodeBB Forum",
"version": "4.4.3",
"version": "4.4.6",
"homepage": "https://www.nodebb.org",
"repository": {
"type": "git",
@@ -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",

View File

@@ -99,8 +99,12 @@ Loader.start = function () {
function forkWorker(index, isPrimary) {
const ports = getPorts();
const args = [];
const execArgv = [];
if (nconf.get('max-memory')) {
args.push(`--max-old-space-size=${nconf.get('max-memory')}`);
execArgv.push(`--max-old-space-size=${nconf.get('max-memory')}`);
}
if (nconf.get('expose-gc')) {
execArgv.push('--expose-gc');
}
if (!ports[index]) {
return console.log(`[cluster] invalid port for worker : ${index} ports: ${ports.length}`);
@@ -109,10 +113,10 @@ function forkWorker(index, isPrimary) {
process.env.isPrimary = isPrimary;
process.env.isCluster = nconf.get('isCluster') || ports.length > 1;
process.env.port = ports[index];
const worker = fork(appPath, args, {
silent: silent,
env: process.env,
execArgv: execArgv,
});
worker.index = index;

View File

@@ -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:

View File

@@ -1,6 +1,11 @@
// fixes for global skin issues
// brite text-secondary is white :/
.skin-brite .text-secondary {
color: var(--bs-secondary-color) !important;
}
// fix minty buttons
.skin-minty .btn{
.skin-minty .btn {
color: initial!important;
}

View File

@@ -307,66 +307,93 @@ define('forum/topic', [
if (!ajaxify.data.showPostPreviewsOnHover || utils.isMobile()) {
return;
}
let timeoutId = 0;
let renderTimeout = 0;
let destroyed = false;
let link = null;
const postCache = {};
function destroyTooltip() {
clearTimeout(timeoutId);
clearTimeout(renderTimeout);
renderTimeout = 0;
$('#post-tooltip').remove();
destroyed = true;
}
function onClickOutside(ev) {
// If the click is outside the tooltip, destroy it
if (!$(ev.target).closest('#post-tooltip').length) {
destroyTooltip();
}
}
$(window).one('action:ajaxify.start', destroyTooltip);
$('[component="topic"]').on('mouseenter', 'a[component="post/parent"], [component="post/content"] a, [component="topic/event"] a', async function () {
const link = $(this);
$('[component="topic"]').on('mouseenter', 'a[component="post/parent"], [component="post/parent/content"] a,[component="post/content"] a, [component="topic/event"] a', async function () {
link = $(this);
link.removeAttr('over-tooltip');
link.one('mouseleave', function () {
clearTimeout(renderTimeout);
renderTimeout = 0;
setTimeout(() => {
if (!link.attr('over-tooltip') && !renderTimeout) {
destroyTooltip();
}
}, 100);
});
clearTimeout(renderTimeout);
destroyed = false;
async function renderPost(pid) {
const postData = postCache[pid] || await api.get(`/posts/${encodeURIComponent(pid)}/summary`);
$('#post-tooltip').remove();
if (postData && ajaxify.data.template.topic) {
postCache[pid] = postData;
const tooltip = await app.parseAndTranslate('partials/topic/post-preview', { post: postData });
if (destroyed) {
return;
renderTimeout = setTimeout(async () => {
async function renderPost(pid) {
const postData = postCache[pid] || await api.get(`/posts/${encodeURIComponent(pid)}/summary`);
$('#post-tooltip').remove();
if (postData && ajaxify.data.template.topic) {
postCache[pid] = postData;
const tooltip = await app.parseAndTranslate('partials/topic/post-preview', { post: postData });
if (destroyed) {
return;
}
tooltip.hide().find('.timeago').timeago();
tooltip.appendTo($('body')).fadeIn(300);
const postContent = link.parents('[component="topic"]').find('[component="post/content"]').first();
const postRect = postContent.offset();
const postWidth = postContent.width();
const linkRect = link.offset();
const { top } = link.get(0).getBoundingClientRect();
const dropup = top > window.innerHeight / 2;
tooltip.on('mouseenter', function () {
link.attr('over-tooltip', 1);
});
tooltip.one('mouseleave', destroyTooltip);
$(window).off('click', onClickOutside).one('click', onClickOutside);
tooltip.css({
top: dropup ? linkRect.top - tooltip.outerHeight() : linkRect.top + 30,
left: postRect.left,
width: postWidth,
});
}
tooltip.hide().find('.timeago').timeago();
tooltip.appendTo($('body')).fadeIn(300);
const postContent = link.parents('[component="topic"]').find('[component="post/content"]').first();
const postRect = postContent.offset();
const postWidth = postContent.width();
const linkRect = link.offset();
tooltip.css({
top: linkRect.top + 30,
left: postRect.left,
width: postWidth,
});
}
}
const href = link.attr('href');
const location = utils.urlToLocation(href);
const pathname = location.pathname;
const validHref = href && href !== '#' && window.location.hostname === location.hostname;
$('#post-tooltip').remove();
const postMatch = validHref && pathname && pathname.match(/\/post\/([\d]+|(?:[\w_.~!$&'()*+,;=:@-]|%[\dA-F]{2})+)/);
const topicMatch = validHref && pathname && pathname.match(/\/topic\/([\da-z-]+)/);
if (postMatch) {
const pid = postMatch[1];
if (encodeURIComponent(link.parents('[component="post"]').attr('data-pid')) === encodeURIComponent(pid)) {
return; // dont render self post
}
timeoutId = setTimeout(async () => {
const href = link.attr('href');
const location = utils.urlToLocation(href);
const pathname = location.pathname;
const validHref = href && href !== '#' && window.location.hostname === location.hostname;
$('#post-tooltip').remove();
const postMatch = validHref && pathname && pathname.match(/\/post\/([\d]+|(?:[\w_.~!$&'()*+,;=:@-]|%[\dA-F]{2})+)/);
const topicMatch = validHref && pathname && pathname.match(/\/topic\/([\da-z-]+)/);
if (postMatch) {
const pid = postMatch[1];
if (encodeURIComponent(link.parents('[component="post"]').attr('data-pid')) === encodeURIComponent(pid)) {
return; // dont render self post
}
renderPost(pid);
}, 300);
} else if (topicMatch) {
timeoutId = setTimeout(async () => {
} else if (topicMatch) {
const tid = topicMatch[1];
const topicData = await api.get('/topics/' + tid, {});
renderPost(topicData.mainPid);
}, 300);
}
}).on('mouseleave', '[component="post"] a, [component="topic/event"] a', destroyTooltip);
}
}, 300);
});
}
function setupQuickReply() {

View File

@@ -2,7 +2,7 @@
"extends": [
"config:recommended"
],
"baseBranches": [
"baseBranchPatterns": [
"develop"
],
"labels": [
@@ -14,8 +14,7 @@
"dependencies"
],
"rangeStrategy": "pin",
"matchPackageNames": [
]
"matchPackageNames": []
},
{
"matchDepTypes": [

View File

@@ -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) => {

View File

@@ -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) => {

View File

@@ -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);

View File

@@ -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'];
@@ -258,10 +218,6 @@ uploadsController.uploadMaskableIcon = async function (req, res, next) {
}
};
uploadsController.uploadLogo = async function (req, res, next) {
await upload('site-logo', req, res, next);
};
uploadsController.uploadFile = async function (req, res, next) {
const uploadedFile = req.files.files[0];
let params;
@@ -285,6 +241,10 @@ uploadsController.uploadFile = async function (req, res, next) {
}
};
uploadsController.uploadLogo = async function (req, res, next) {
await upload('site-logo', req, res, next);
};
uploadsController.uploadDefaultAvatar = async function (req, res, next) {
await upload('avatar-default', req, res, next);
};

View File

@@ -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);

View File

@@ -16,7 +16,7 @@ const utils = require('../utils');
const CSS = module.exports;
CSS.supportedSkins = [
'cerulean', 'cosmo', 'cyborg', 'darkly', 'flatly', 'journal', 'litera',
'brite', 'cerulean', 'cosmo', 'cyborg', 'darkly', 'flatly', 'journal', 'litera',
'lumen', 'lux', 'materia', 'minty', 'morph', 'pulse', 'quartz', 'sandstone',
'simplex', 'sketchy', 'slate', 'solar', 'spacelab', 'superhero', 'united',
'vapor', 'yeti', 'zephyr',
@@ -270,7 +270,7 @@ CSS.getSkinSwitcherOptions = async function (uid) {
{ name: '[[user:no-skin]]', value: 'noskin', selected: userSettings.bootswatchSkin === 'noskin' },
];
const lightSkins = [
'cerulean', 'cosmo', 'flatly', 'journal', 'litera',
'brite', 'cerulean', 'cosmo', 'flatly', 'journal', 'litera',
'lumen', 'lux', 'materia', 'minty', 'morph', 'pulse', 'sandstone',
'simplex', 'sketchy', 'spacelab', 'united', 'yeti', 'zephyr',
];

View File

@@ -107,13 +107,24 @@ function addProcessHandlers() {
shutdown(1);
});
process.on('message', (msg) => {
if (msg && Array.isArray(msg.compiling)) {
if (msg.compiling.includes('tpl')) {
const benchpressjs = require('benchpressjs');
benchpressjs.flush();
} else if (msg.compiling.includes('lang')) {
const translator = require('./translator');
translator.flush();
if (msg) {
if (Array.isArray(msg.compiling)) {
if (msg.compiling.includes('tpl')) {
const benchpressjs = require('benchpressjs');
benchpressjs.flush();
} else if (msg.compiling.includes('lang')) {
const translator = require('./translator');
translator.flush();
}
}
if (msg.livereload) {
// Send livereload event to all connected clients via Socket.IO
const websockets = require('./socket.io');
if (websockets.server) {
websockets.server.emit('event:livereload');
winston.info('[livereload] Sent reload event to all clients');
}
}
}
});

View File

@@ -12,7 +12,7 @@
<div class="col-5">
<div class="mb-3">
<label class="form-label" for="length">[[admin/manage/users:temp-ban.length]]</label>
<input class="form-control" id="length" name="length" type="number" min="0" value="1" />
<input class="form-control" id="length" name="length" type="number" min="0" value="0" />
</div>
<div class="form-check form-check-inline">
<label class="form-check-label" for="unit-hours">[[admin/manage/users:temp-ban.hours]]</label>

View File

@@ -1,11 +1,14 @@
<div id="post-tooltip" class="card card-body shadow bg-body text-body z-1 position-absolute">
<div class="d-flex flex-column gap-2">
<div class="d-flex gap-1 align-items-center">
<a href="{{{ if post.user.userslug }}}{config.relative_path}/user/{post.user.userslug}{{{ else }}}#{{{ end }}}">
{buildAvatar(post.user, "24px", true, "", "user/picture")} {post.user.username}
</a>
<span class="timeago text-xs" title="{post.timestampISO}"></span>
<div class="d-flex gap-2 align-items-center">
<div>
<a href="{{{ if post.user.userslug }}}{config.relative_path}/user/{post.user.userslug}{{{ else }}}#{{{ end }}}">{buildAvatar(post.user, "20px", true, "", "user/picture")}</a>
<a href="{{{ if post.user.userslug }}}{config.relative_path}/user/{post.user.userslug}{{{ else }}}#{{{ end }}}">{post.user.username}</a>
</div>
<div>
<a href="{config.relative_path}/post/{post.pid}" class="timeago text-xs text-secondary lh-1" style="vertical-align: middle;" title="{post.timestampISO}"></a>
</div>
</div>
<div class="content">{post.content}</div>
<div class="content ghost-scrollbar" style="max-height: 300px; overflow-y:auto;">{post.content}</div>
</div>
</div>

View File

@@ -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()}`,
});

4
test/files/dirty.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="10" width="80" height="80" fill="red" stroke="black" stroke-width="4"/>
</svg>
<script>alert('foo');</script>

After

Width:  |  Height:  |  Size: 192 B

View File

@@ -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('<script>'), false);
});
it('should upload default avatar', async () => {
const { response, body } = await helpers.uploadFile(`${nconf.get('url')}/api/admin/uploadDefaultAvatar`, path.join(__dirname, '../test/files/test.png'), { }, jar, csrf_token);
assert.equal(response.statusCode, 200);