Compare commits

...

19 Commits

Author SHA1 Message Date
Misty Release Bot
f875a83423 chore: incrementing version number - v4.1.1 2025-03-12 23:10:21 +00:00
Barış Soner Uşaklı
fc9cc8d6d7 fix: lang typo 2025-03-11 11:50:16 -04:00
Barış Soner Uşaklı
de502cd2ee feat: allow self-signed certs, closes #13238 2025-03-11 11:49:40 -04:00
Barış Soner Uşaklı
1ca7b7ecce test: fix inf loop if dirname results in same dir, ie \ 2025-03-10 18:25:53 -04:00
Barış Soner Uşaklı
c4e3139599 lint: missing semi 2025-03-10 18:13:16 -04:00
Barış Soner Uşaklı
e775564fc1 refactor: prevent following symlinks 2025-03-10 17:59:31 -04:00
Barış Soner Uşaklı
76896859fa fix: check if folder exists when uploading files in acp 2025-03-10 16:49:40 -04:00
Barış Soner Uşaklı
6d74ee2f59 refactor: show simple error if path doesn't exist 2025-03-10 16:20:51 -04:00
Barış Soner Uşaklı
810e8dbbbf fix: sanitize category svg image files 2025-03-10 15:51:43 -04:00
Barış Soner Uşaklı
1e6c6f4e44 fix: #13094, update unread chats on reconnect
unread topics and notifications were updated on reconnections, added chats as well
convert function to async added awaits
2025-03-09 12:03:09 -04:00
Barış Soner Uşaklı
6b9f166cb8 fix: don't update topic lastposttime by announce
this was causing topics to show up as unread eventhough there are no new posts and out of order on /recent
2025-03-09 11:14:16 -04:00
Barış Soner Uşaklı
b517f05e90 refactor: use navAdmin 2025-03-08 00:39:46 -05:00
Barış Soner Uşaklı
0427971879 test: #13078, add nav test 2025-03-08 00:39:05 -05:00
Barış Soner Uşaklı
bef1792086 fix: closes #13078, toggle /world navItem when AP is toggled 2025-03-08 00:09:41 -05:00
Barış Soner Uşaklı
c83f91bd12 refactor: dont generate UUID if no email 2025-03-07 10:23:55 -05:00
Barış Soner Uşaklı
84d3fe7969 refactor: show warning if there is no email for validation 2025-03-07 10:21:59 -05:00
Barış Soner Uşaklı
bb13ea3013 fix: closes #13231, add some text-truncate, match width 2025-03-05 17:19:47 -05:00
Barış Soner Uşaklı
48f0f47a2e fix: #13228, use timestamp from mainpost/lastpost
when forking a topic
2025-03-05 11:30:46 -05:00
Barış Soner Uşaklı
34414f168a chore: up themes 2025-03-03 17:55:44 -05:00
18 changed files with 261 additions and 115 deletions

View File

@@ -147,6 +147,7 @@
"username:disableEdit": 0,
"email:disableEdit": 0,
"email:smtpTransport:pool": 0,
"email:smtpTransport:allow-self-signed": 0,
"hideFullname": 0,
"hideEmail": 0,
"showFullnameAsDisplayName": 0,

View File

@@ -2,7 +2,7 @@
"name": "nodebb",
"license": "GPL-3.0",
"description": "NodeBB Forum",
"version": "4.1.0",
"version": "4.1.1",
"homepage": "https://www.nodebb.org",
"repository": {
"type": "git",
@@ -108,10 +108,10 @@
"nodebb-plugin-spam-be-gone": "2.3.1",
"nodebb-plugin-web-push": "0.7.3",
"nodebb-rewards-essentials": "1.0.1",
"nodebb-theme-harmony": "2.0.37",
"nodebb-theme-lavender": "7.1.17",
"nodebb-theme-harmony": "2.0.39",
"nodebb-theme-lavender": "7.1.18",
"nodebb-theme-peace": "2.2.39",
"nodebb-theme-persona": "14.0.15",
"nodebb-theme-persona": "14.0.16",
"nodebb-widget-essentials": "7.0.35",
"nodemailer": "6.10.0",
"nprogress": "0.2.0",

View File

@@ -94,6 +94,7 @@
"federation.followers-handle": "Handle",
"federation.followers-id": "ID",
"federation.followers-none": "No followers.",
"federation.followers-autofill": "Autofill",
"alert.created": "Created",
"alert.create-success": "Category successfully created!",

View File

@@ -28,6 +28,8 @@
"smtp-transport.password": "Password",
"smtp-transport.pool": "Enable pooled connections",
"smtp-transport.pool-help": "Pooling connections prevents NodeBB from creating a new connection for every email. This option only applies if SMTP Transport is enabled.",
"smtp-transport.allow-self-signed": "Allow self-signed certificates",
"smtp-transport.allow-self-signed-help": "Enabling this setting will allow you to use self-signed or invalid TLS certificates.",
"template": "Edit Email Template",
"template.select": "Select Email Template",

View File

@@ -297,7 +297,6 @@ inbox.announce = async (req) => {
}
({ tid } = assertion);
await topics.updateLastPostTime(tid, timestamp);
await activitypub.notes.updateLocalRecipients(pid, { to, cc });
await activitypub.notes.syncUserInboxes(tid);
}

View File

@@ -3,6 +3,8 @@
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');
@@ -22,9 +24,15 @@ uploadsController.get = async function (req, res, next) {
}
const itemsPerPage = 20;
const page = parseInt(req.query.page, 10) || 1;
let files = [];
try {
await checkSymLinks(req.query.dir);
files = await getFilesInFolder(currentFolder);
} catch (err) {
winston.error(err.stack);
return next(new Error('[[error:invalid-path]]'));
}
try {
let files = await fs.promises.readdir(currentFolder);
files = files.filter(filename => filename !== '.gitignore');
const itemCount = files.length;
const start = Math.max(0, (page - 1) * itemsPerPage);
const stop = start + itemsPerPage;
@@ -64,6 +72,34 @@ uploadsController.get = async function (req, res, next) {
}
};
async function checkSymLinks(folder) {
let dir = path.normalize(folder || '');
while (dir.length && dir !== '.') {
const nextPath = path.join(nconf.get('upload_path'), dir);
// eslint-disable-next-line no-await-in-loop
const stat = await fs.promises.lstat(nextPath);
if (stat.isSymbolicLink()) {
throw new Error('[[invalid-path]]');
}
const newDir = path.dirname(dir);
if (newDir === dir) {
break;
}
dir = newDir;
}
}
async function getFilesInFolder(folder) {
const dirents = await fs.promises.readdir(folder, { withFileTypes: true });
const files = [];
for await (const dirent of dirents) {
if (!dirent.isSymbolicLink() && dirent.name !== '.gitignore') {
files.push(dirent.name);
}
}
return files;
}
function buildBreadcrumbs(currentFolder) {
const crumbs = [];
const parts = currentFolder.replace(nconf.get('upload_path'), '').split(path.sep);
@@ -94,14 +130,14 @@ async function getFileData(currentDir, file) {
const stat = await fs.promises.stat(pathToFile);
let filesInDir = [];
if (stat.isDirectory()) {
filesInDir = await fs.promises.readdir(pathToFile);
filesInDir = await getFilesInFolder(pathToFile);
}
const url = `${nconf.get('upload_url') + currentDir.replace(nconf.get('upload_path'), '')}/${file}`;
return {
name: file,
path: pathToFile.replace(path.join(nconf.get('upload_path'), '/'), ''),
url: url,
fileCount: Math.max(0, filesInDir.length - 1), // ignore .gitignore
fileCount: filesInDir.length,
size: stat.size,
sizeHumanReadable: `${(stat.size / 1024).toFixed(1)}KiB`,
isDirectory: stat.isDirectory(),
@@ -121,11 +157,50 @@ 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'];
@@ -197,6 +272,9 @@ uploadsController.uploadFile = async function (req, res, next) {
return next(new Error('[[error:invalid-json]]'));
}
if (!await file.exists(path.join(nconf.get('upload_path'), params.folder))) {
return next(new Error('[[error:invalid-path]]'));
}
try {
const data = await file.saveFileToLocal(uploadedFile.name, params.folder, uploadedFile.path);
res.json([{ url: data.url }]);

View File

@@ -153,7 +153,11 @@ Emailer.setupFallbackTransport = (config) => {
} else {
smtpOptions.service = String(config['email:smtpTransport:service']);
}
if (config['email:smtpTransport:allow-self-signed']) {
smtpOptions.tls = {
rejectUnauthorized: false,
};
}
Emailer.transports.smtp = nodemailer.createTransport(smtpOptions);
Emailer.fallbackTransport = Emailer.transports.smtp;
} else {

View File

@@ -18,9 +18,6 @@ module.exports = function (Messaging) {
uids = [uids];
}
uids = uids.filter(uid => parseInt(uid, 10) > 0);
if (!uids.length) {
return;
}
uids.forEach((uid) => {
io.in(`uid_${uid}`).emit('event:unread.updateChatCount', data);
});

View File

@@ -132,6 +132,7 @@ Configs.setMultiple = async function (data) {
await processConfig(data);
data = serialize(data);
await db.setObject('config', data);
await updateNavItems(data);
updateConfig(deserialize(data));
};
@@ -228,6 +229,13 @@ async function getLogoSize(data) {
data['brand:emailLogo:width'] = size.width;
}
async function updateNavItems(data) {
if (data.hasOwnProperty('activitypubEnabled')) {
const navAdmin = require('../navigation/admin');
await navAdmin.update('/world', { enabled: data.activitypubEnabled ? 'on' : '' });
}
}
function updateConfig(config) {
updateLocalConfig(config);
pubsub.publish('config:update', config);

View File

@@ -85,6 +85,19 @@ admin.get = async function () {
return cache.map(item => ({ ...item }));
};
admin.update = async function (route, data) {
const ids = await db.getSortedSetRange('navigation:enabled', 0, -1);
const navItems = await db.getObjects(ids.map(id => `navigation:enabled:${id}`));
const matchedRoutes = navItems.filter(item => item && item.route === route);
if (matchedRoutes.length) {
await db.setObjectBulk(
matchedRoutes.map(item => [`navigation:enabled:${item.order}`, data])
);
cache = null;
pubsub.publish('admin:navigation:save');
}
};
async function getAvailable() {
const core = require('../../install/data/navigation.json').map((item) => {
item.core = true;

View File

@@ -6,20 +6,23 @@ const user = require('../user');
const meta = require('../meta');
const topics = require('../topics');
const privileges = require('../privileges');
const messaging = require('../messaging');
const SocketMeta = module.exports;
SocketMeta.rooms = {};
SocketMeta.reconnected = function (socket, data, callback) {
callback = callback || function () {};
if (socket.uid) {
topics.pushUnreadCount(socket.uid);
user.notifications.pushCount(socket.uid);
SocketMeta.reconnected = async function (socket) {
if (socket.uid > 0) {
await Promise.all([
topics.pushUnreadCount(socket.uid),
user.notifications.pushCount(socket.uid),
messaging.pushUnreadCount(socket.uid),
]);
}
callback(null, {
return {
'cache-buster': meta.config['cache-buster'],
hostname: os.hostname(),
});
};
};
/* Rooms */

View File

@@ -38,25 +38,29 @@ module.exports = function (Topics) {
cid = await posts.getCidByPid(mainPid);
}
const [postData, isAdminOrMod] = await Promise.all([
const [mainPost, isAdminOrMod] = await Promise.all([
posts.getPostData(mainPid),
privileges.categories.isAdminOrMod(cid, uid),
]);
let lastPost = mainPost;
if (pids.length > 1) {
lastPost = await posts.getPostData(pids[pids.length - 1]);
}
if (!isAdminOrMod) {
throw new Error('[[error:no-privileges]]');
}
const scheduled = postData.timestamp > Date.now();
const now = Date.now();
const scheduled = mainPost.timestamp > now;
const params = {
uid: postData.uid,
uid: mainPost.uid,
title: title,
cid: cid,
timestamp: scheduled && postData.timestamp,
timestamp: mainPost.timestamp,
};
const result = await plugins.hooks.fire('filter:topic.fork', {
params: params,
tid: postData.tid,
tid: mainPost.tid,
});
const tid = await Topics.create(result.params);
@@ -71,21 +75,21 @@ module.exports = function (Topics) {
await Topics.movePostToTopic(uid, pid, tid, scheduled);
}
await Topics.updateLastPostTime(tid, scheduled ? (postData.timestamp + 1) : Date.now());
await Topics.updateLastPostTime(tid, scheduled ? (mainPost.timestamp + 1) : lastPost.timestamp);
await Promise.all([
Topics.setTopicFields(tid, {
upvotes: postData.upvotes,
downvotes: postData.downvotes,
upvotes: mainPost.upvotes,
downvotes: mainPost.downvotes,
forkedFromTid: fromTid,
forkerUid: uid,
forkTimestamp: Date.now(),
forkTimestamp: now,
}),
db.sortedSetsAdd(['topics:votes', `cid:${cid}:tids:votes`], postData.votes, tid),
db.sortedSetsAdd(['topics:votes', `cid:${cid}:tids:votes`], mainPost.votes, tid),
Topics.events.log(fromTid, { type: 'fork', uid, href: `/topic/${tid}` }),
]);
plugins.hooks.fire('action:topic.fork', { tid: tid, fromTid: fromTid, uid: uid });
plugins.hooks.fire('action:topic.fork', { tid, fromTid, uid });
return await Topics.getTopicData(tid);
};

View File

@@ -124,23 +124,22 @@ UserEmail.sendValidationEmail = async function (uid, options) {
};
}
const confirm_code = utils.generateUUID();
const confirm_link = `${nconf.get('url')}/confirm/${confirm_code}`;
const { emailConfirmInterval, emailConfirmExpiry } = meta.config;
// If no email passed in (default), retrieve email from uid
if (!options.email || !options.email.length) {
options.email = await user.getUserField(uid, 'email');
}
if (!options.email) {
winston.warn(`[user/email] No email found for uid ${uid}`);
return;
}
const { emailConfirmInterval, emailConfirmExpiry } = meta.config;
if (!options.force && !await UserEmail.canSendValidation(uid, options.email)) {
throw new Error(`[[error:confirm-email-already-sent, ${emailConfirmInterval}]]`);
}
const confirm_code = utils.generateUUID();
const confirm_link = `${nconf.get('url')}/confirm/${confirm_code}`;
const username = await user.getUserField(uid, 'username');
const data = await plugins.hooks.fire('filter:user.verify', {
uid,

View File

@@ -14,85 +14,85 @@
<a class="btn btn-primary" href="{config.relative_path}/admin/settings/activitypub">[[admin/manage/categories:federation.disabled-cta]]</a>
</div>
{{{ else }}}
<div class="acp-page-container">
<div class="row settings m-0">
<div class="col-12 col-md-8 px-0 mb-4" tabindex="0">
<div id="site-settings" class="mb-4">
<form role="form">
<h5 class="fw-bold settings-header">[[admin/manage/categories:federation.syncing-header]]</h5>
<p>[[admin/manage/categories:federation.syncing-intro]]</p>
<p class="form-text">[[admin/manage/categories:federation.syncing-caveat]]</p>
<div class="row settings m-0">
<div class="col-12 px-0 mb-4" tabindex="0">
<div id="site-settings" class="mb-4">
<form role="form">
<h5 class="fw-bold settings-header">[[admin/manage/categories:federation.syncing-header]]</h5>
<p>[[admin/manage/categories:federation.syncing-intro]]</p>
<p class="form-text">[[admin/manage/categories:federation.syncing-caveat]]</p>
{{{ if !following.length }}}
<div class="alert alert-info">[[admin/manage/categories:federation.syncing-none]]</div>
{{{ else }}}
<table class="table">
<thead>
<tr>
<th>[[admin/manage/categories:federation.syncing-actorUri]]</th>
<th></th>
</tr>
</thead>
<tbody>
{{{ each following }}}
<tr>
<td>
<pre class="mb-0 mt-1">{./id}</pre>
{{{ if !./approved }}}
<span class="form-text text-warning">Pending</span>
{{{ end }}}
</td>
<td>
<button type="button" data-action="unfollow" data-actor="{./id}" class="btn btn-sm btn-danger">[[admin/manage/categories:federation.syncing-unfollow]]</button>
</td>
</tr>
{{{ end }}}
</tbody>
</table>
{{{ end }}}
{{{ if !following.length }}}
<div class="alert alert-info">[[admin/manage/categories:federation.syncing-none]]</div>
{{{ else }}}
<table class="table">
<thead>
<tr>
<th>[[admin/manage/categories:federation.syncing-actorUri]]</th>
<th></th>
</tr>
</thead>
<tbody>
{{{ each following }}}
<tr>
<td>
<pre class="mb-0 mt-1">{./id}</pre>
{{{ if !./approved }}}
<span class="form-text text-warning">Pending</span>
{{{ end }}}
</td>
<td>
<button type="button" data-action="unfollow" data-actor="{./id}" class="btn btn-sm btn-danger">[[admin/manage/categories:federation.syncing-unfollow]]</button>
</td>
</tr>
{{{ end }}}
</tbody>
</table>
{{{ end }}}
<div class="mb-3">
<label class="form-label" for="syncing-add">[[admin/manage/categories:federation.syncing-add]]</label>
<div class="input-group">
<input id="syncing-add" type="url" class="form-control" />
<button data-action="follow" type="button" class="btn btn-primary">[[admin/manage/categories:federation.syncing-follow]]</button>
</div>
<div class="mb-3">
<label class="form-label" for="syncing-add">[[admin/manage/categories:federation.syncing-add]]</label>
<div class="input-group">
<input id="syncing-add" type="url" class="form-control" />
<button data-action="follow" type="button" class="btn btn-primary">[[admin/manage/categories:federation.syncing-follow]]</button>
</div>
</div>
<hr />
<hr />
<div class="mb-3">
<p>[[admin/manage/categories:federation.followers]]</p>
<table class="table small">
<tr>
<th>[[admin/manage/categories:federation.followers-handle]]</th>
<th>[[admin/manage/categories:federation.followers-id]]</th>
</tr>
{{{ if !followers.length}}}
<tr>
<td class="text-center border-0" colspan="2">
<em>[[admin/manage/categories:federation.followers-none]]</em>
</td>
</tr>
{{{ end }}}
{{{ each followers }}}
<tr data-uid="{./uid}">
<td>
{buildAvatar(followers, "24px", true)}
{./userslug}
</td>
<td>
<code>{./uid}</code>
<button type="button" class="btn btn-link" data-action="autofill">
<i class="fa fa-exchange-alt"></i>
<div class="mb-3">
<p>[[admin/manage/categories:federation.followers]]</p>
<table class="table small">
<tr>
<th>[[admin/manage/categories:federation.followers-handle]]</th>
<th>[[admin/manage/categories:federation.followers-id]]</th>
</tr>
{{{ if !followers.length}}}
<tr>
<td class="text-center border-0" colspan="2">
<em>[[admin/manage/categories:federation.followers-none]]</em>
</td>
</tr>
{{{ end }}}
{{{ each followers }}}
<tr data-uid="{./uid}">
<td class="w-100 text-truncate" style="max-width: 1px;">
{buildAvatar(followers, "24px", true)}
{./userslug}
</td>
<td class="w-0">
<div class="d-flex gap-2 flex-nowrap align-items-center">
<button type="button" class="btn btn-ghost btn-sm border" data-action="autofill" title="[[admin/manage/categories:federation.followers-autofill]]">
<i class="fa fa-exchange-alt text-primary"></i>
</button>
</td>
</tr>
{{{ end }}}
</table>
</div>
</form>
</div>
<code>{./uid}</code>
</div>
</td>
</tr>
{{{ end }}}
</table>
</div>
</form>
</div>
</div>
</div>

View File

@@ -116,6 +116,11 @@
<label for="email:smtpTransport:pool" class="form-check-label">[[admin/settings/email:smtp-transport.pool]]</label>
<p class="form-text">[[admin/settings/email:smtp-transport.pool-help]]</p>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="email:smtpTransport:allow-self-signed" data-field="email:smtpTransport:allow-self-signed" name="email:smtpTransport:allow-self-signed" />
<label for="email:smtpTransport:allow-self-signed" class="form-check-label">[[admin/settings/email:smtp-transport.allow-self-signed]]</label>
<p class="form-text">[[admin/settings/email:smtp-transport.allow-self-signed-help]]</p>
</div>
<div class="mb-3">
<label class="form-label" for="email:smtpTransport:service">[[admin/settings/email:smtp-transport.service]]</label>
<select class="form-select" id="email:smtpTransport:service" data-field="email:smtpTransport:service">

24
test/navigation.js Normal file
View File

@@ -0,0 +1,24 @@
'use strict';
const assert = require('assert');
const db = require('./mocks/databasemock');
const meta = require('../src/meta');
const navAdmin = require('../src/navigation/admin');
describe('Navigation', () => {
before(async () => {
const data = require('../install/data/navigation.json');
await navAdmin.save(data);
});
it('should toggle /world route when ap is toggled', async () => {
let nav = await navAdmin.get();
let world = nav.find(item => item.route === '&#x2F;world');
assert.strictEqual(!!world.enabled, true);
await meta.configs.setMultiple({ activitypubEnabled: 0 });
nav = await navAdmin.get();
world = nav.find(item => item.route === '&#x2F;world');
assert.strictEqual(!!world.enabled, false);
});
});

View File

@@ -269,12 +269,9 @@ describe('socket.io', () => {
});
});
it('should push unread notifications on reconnect', (done) => {
it('should push unread notifications/chats on reconnect', async () => {
const socketMeta = require('../src/socket.io/meta');
socketMeta.reconnected({ uid: 1 }, {}, (err) => {
assert.ifError(err);
done();
});
await socketMeta.reconnected({ uid: 1 }, {});
});

View File

@@ -400,6 +400,17 @@ describe('Upload Controllers', () => {
assert.strictEqual(body.error, '[[error:invalid-path]]');
});
it('should fail to upload regular file if directory does not exist', async () => {
const { response, body } = await helpers.uploadFile(`${nconf.get('url')}/api/admin/upload/file`, path.join(__dirname, '../test/files/test.png'), {
params: JSON.stringify({
folder: 'does-not-exist',
}),
}, jar, csrf_token);
assert.equal(response.statusCode, 500);
assert.strictEqual(body.error, '[[error:invalid-path]]');
});
describe('ACP uploads screen', () => {
it('should create a folder', async () => {
const { response } = await helpers.createFolder('', 'myfolder', jar, csrf_token);