Merge commit '03b7374c69b77dd9c20814c32485402be0cf3a54' into v4.x

This commit is contained in:
Misty Release Bot
2026-01-28 14:18:48 +00:00
40 changed files with 690 additions and 282 deletions

View File

@@ -1,3 +1,213 @@
#### v4.8.0 (2026-01-14)
##### Chores
* **deps:**
* update dependency @stylistic/eslint-plugin to v5.7.0 (#13879) (be0d43cf)
* update commitlint monorepo to v20.3.1 (#13876) (c88ce519)
* update dependency sass-embedded to v1.97.2 (#13870) (27d511ff)
* update commitlint monorepo to v20.3.0 (#13865) (447cfd03)
* update dependency smtp-server to v3.18.0 (#13858) (f35c77dd)
* update dependency jsdom to v27.4.0 (#13860) (37c052f4)
* update dependency sass-embedded to v1.97.1 (#13850) (d28866ab)
* update dependency sass-embedded to v1.97.0 (#13837) (168b6e63)
* update dependency smtp-server to v3.17.1 (#13829) (ad895efb)
* update dependency @eslint/js to v9.39.2 (#13830) (22fe83f0)
* update github artifact actions (#13831) (b1696218)
* update actions/cache action to v5 (#13828) (0fcc8543)
* update dependency smtp-server to v3.17.0 (#13824) (3adcbe0f)
* update dependency sass-embedded to v1.96.0 (#13821) (b992511b)
* update dependency sass-embedded to v1.95.1 (#13817) (a2f2c8c7)
* update dependency jsdom to v27.3.0 (#13814) (a35c326a)
* update commitlint monorepo to v20.2.0 (#13810) (e50edd52)
* update dependency lint-staged to v16.2.7 (#13785) (76b6b3b2)
* update actions/checkout action to v6 (#13802) (7f21a171)
* bump profile max upload size default (bed6ed3c)
* up themes (b323b5d8)
* up markdown (eb77c9bf)
* up mentions (648d9c78)
* incrementing version number - v4.7.2 (cd419d8a)
* update changelog for v4.7.2 (2f0526b8)
* incrementing version number - v4.7.1 (afb88805)
* allow direct testing in test/categories.js (29687722)
* incrementing version number - v4.7.0 (e82d40f8)
* incrementing version number - v4.6.3 (9fc5b0f3)
* incrementing version number - v4.6.2 (f98747db)
* incrementing version number - v4.6.1 (f47aa678)
* incrementing version number - v4.6.0 (ee395bc5)
* incrementing version number - v4.5.2 (ad2da639)
* incrementing version number - v4.5.1 (69f4b61f)
* incrementing version number - v4.5.0 (f05c5d06)
* incrementing version number - v4.4.6 (074043ad)
* incrementing version number - v4.4.5 (6f106923)
* incrementing version number - v4.4.4 (d323af44)
* 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)
##### Documentation Changes
* update openapi schema for missing routes related to crossposting (d81b644d)
##### New Features
* user crossposts federate as:Announce (273bc68c)
* add missing files, minor changes to crossposts list modal (38fd1798)
* introduce new front-end UI button for cross-posting, hide move on topics in remote cids (0041cfe2)
* disallow moving topics to and from remote categories, + basic tests for topic moving (ea1e4c7d)
* API v3 calls to crosspost and uncrosspost a topic to and from a category (74172ecc)
* refactor out.announce.topic to allow user announces, refactor tests to accommodate (874ffd7b)
* stop extraneous vote and tids_read data from being saved for remote users (097d0802)
* support remote Dislike activity, federate out a Dislike on downvote, bwahahah (528cd258)
* expand postingRestrictedToMods mask testing, handle actor update for that prop (6a561050)
* setAddBulk (#13805) (7d5402fe)
* save privilege masking set when asserting group (f0a7a442)
* patch low-level privilege query calls to accept privilege masks at the cid level (4020e1be)
* federate out topic removal activities when topic is deleted and purged from a local category (3ab61615)
##### Bug Fixes
* i18n fallbacks (a73ab8ee)
* #13889, custom emoji from Piefed (0c75934a)
* #13888, decode html entities for AP category name and description (6eea4df5)
* derp (bcc204fa)
* bump themes (a4c470ff)
* guard against negative uids crossposting (2f96eed4)
* bump themes (943b53b0)
* calling sortedSetRemove to remove multiple values, instead of baking it into sortedSetRemoveBulk (82507c0f)
* unused values (b9b33f9f)
* typo, client-side handling of crossposts as pertains to uncategorized topics (7465762d)
* client-side handling of category selector when cross-posting so only local cids are sent to backend (ea417b06)
* update category sync logic to utilise crossposts instead (e5ee52e5)
* remove old remote user to remote category migration logic + tests (28249efb)
* update auto-categorization rules to also handle already-categorized topics via crosspost (148663c5)
* topic crosspost delete and purge handling (f6cc556d)
* bug where privileges users could not uncrosspost others' crossposts. Tests (0a0a7da9)
* allow non-mods to crosspost, move crosspost button out of topic tools, in-modal state updates (6daaad81)
* removed ajaxify refresh on crosspost commit, dynamically update post stats in template, logic fix (b981082d)
* nodeinfo route to publish federation.enabled in metadata section (14aa2bee)
* bump link-preview again (74e47820)
* bump link-preview (486e77c7)
* remove commented out require (ffc3d279)
* bump link-preview (cc1649e0)
* auto-enable post queue as default, adjust tests to compensate (9390ccb6)
* remove bidiControls from notification.bodyShort (b0679cad)
* author of boosted content was not targeted in the activity (b05199d8)
* closes #13872, use translator.compile for notification text (5a031d01)
* #13715, dont reduce hardcap if usersPerPage is < 50 (cb31e70e)
* dont use sass-embedded on freebsd, #13867 (b7de0cc7)
* wrong increment value (20918b52)
* increment progress on upgrade script (8abe0dfa)
* add join-lemmy context for outgoing category group actors context prop (f1d50c35)
* use setsAdd (d8e55d58)
* missing await (4a6dcf1a)
* admin privilege overrides only apply to local categories (7b194c69)
* have notes.assert call out.announce.topic only if uid is set (so, if note assertion is called via search; manual pull) (3b7bcba6)
* deep clone activity prop before execution; feps.announce (977a67f4)
* minor comment fix (411baa21)
* publish `postingRestrictedToMods` property in group actor (c365c1dc)
* **deps:**
* update dependency spdx-license-list to v6.11.0 (#13890) (9b1c32b1)
* update dependency diff to v8.0.3 (#13882) (974ab1f8)
* update dependency nodebb-theme-persona to v14.1.23 (#13878) (47074b3c)
* update dependency nodebb-theme-harmony to v2.1.31 (#13877) (125c8e58)
* update dependency body-parser to v2.2.2 (#13873) (e717f00e)
* update dependency sass to v1.97.2 (#13871) (5100cc4f)
* update dependency nodebb-plugin-markdown to v13.2.3 (#13869) (a8c18f8a)
* update dependency nodebb-theme-harmony to v2.1.30 (#13863) (49379e2e)
* update dependency nodebb-theme-persona to v14.1.22 (#13864) (e4435e52)
* update dependency @isaacs/ttlcache to v2.1.4 (#13861) (89abdca1)
* update socket.io packages to v4.8.3 (#13857) (6807f860)
* update dependency sass to v1.97.1 (#13856) (7325b995)
* update dependency nodebb-theme-persona to v14.1.20 (#13855) (b8f68fb4)
* update dependency nodebb-theme-harmony to v2.1.28 (#13854) (f98fd6dc)
* update dependency fs-extra to v11.3.3 (#13851) (160ce17f)
* update dependency nodemailer to v7.0.12 (#13853) (f6ef041c)
* update dependency nodebb-plugin-2factor to v7.6.1 (#13852) (abcb2382)
* update dependency validator to v13.15.26 (#13846) (2a10f904)
* update dependency nodebb-theme-persona to v14.1.19 (#13849) (b933d1a2)
* update dependency nodebb-theme-harmony to v2.1.27 (#13848) (61d8cba9)
* update dependency webpack to v5.104.1 (#13847) (bb5a90a3)
* update dependency esbuild to v0.27.2 (#13842) (5844e393)
* update dependency nodebb-plugin-mentions to v4.8.4 (#13845) (2ffa4383)
* update dependency webpack to v5.104.0 (#13839) (f16eec30)
* update dependency sass to v1.97.0 (#13838) (ab8dbb41)
* update dependency fetch-cookie to v3.2.0 (#13836) (0ef5cbbb)
* update dependency autoprefixer to v10.4.23 (#13835) (7c2e8330)
* update dependency terser-webpack-plugin to v5.3.16 (#13827) (da7c9b32)
* update dependency sass to v1.96.0 (#13822) (d4f53a62)
* update dependency winston to v3.19.0 (#13812) (81c232f1)
* update dependency cron to v4.4.0 (#13818) (f077c4ca)
* update dependency sass to v1.95.1 (#13816) (adedb7b6)
* update dependency sass to v1.95.0 (#13815) (eaa6e71a)
* update dependency terser-webpack-plugin to v5.3.15 (#13811) (10d2e929)
* update dependency esbuild to v0.27.1 (#13806) (6b1dcb4b)
* update dependency jsonwebtoken to v9.0.3 (#13807) (7b734cfd)
* update dependency ace-builds to v1.43.5 (#13797) (93057306)
* update dependency lru-cache to v11.2.4 (#13798) (731933a6)
* update dependency express to v4.22.1 (#13800) (38321220)
* update dependency ipaddr.js to v2.3.0 (#13801) (ad5cd27b)
* update dependency nodemailer to v7.0.11 (#13799) (ecec1f45)
* update dependency cron to v4.3.5 (#13796) (5ba6bea0)
* update dependency body-parser to v2.2.1 (#13795) (624ef616)
* update dependency @isaacs/ttlcache to v2.1.3 (#13791) (5f55ca85)
* update dependency sass to v1.94.2 (#13786) (1cb8b381)
* update dependency redis to v5.10.0 (#13787) (1bcfe3f0)
##### Other Changes
* fix... tests (d20906b5)
* still broken... more debug logs (a82e1f44)
* log mock results (8236b594)
##### Refactors
* check if tid is truthy (0e1ccfc9)
* crossposts.get to return limited category data (name, icon, etc.), fixed up crosspost modal to hide uncategorized and all categories options (349b0875)
* move crosspost methods into their own file in src/topics (1be88ca0)
* silence if-function deprecation on prod (403230cc)
* clear quick reply as soon as submitting (a331f8da)
##### Tests
* intify uid/cid if they are numbers (when getting crossposts) (47e37ed5)
* stop using partialDeepStrictEqual for now (0677689a)
* ensure auto-cat and cat sync logic properly integrates with crossposts (add163a4)
* crossposting behaviour and logic tests (947676ef)
* new test file for crossposts (3560b6a3)
* additional logic to allow multi-typing in schema type (4f1fa2d1)
* lowercase tags (81cac015)
* fix test to check for Secure in cookie string if test runner domain is https (5954015e)
* more out.announce tests (cfdbbb04)
* basic tests for activitypub.out (67912dc9)
* update activitypub._sent to save targets as well, updated tests to accommodate format change (41368ef8)
* test runs should not actually federate activities out (483ab083)
* check if tests pass without await (5414cf47)
* add back logs for failing test (301b5386)
* add a test for set db.exists (#13809) (69562704)
* fix failing test by adjusting the tests (c5292442)
* privilege masking tests (934e6be9)
* log label (22d3c523)
* log activities (e39c9149)
* on test fail show activities (841bd825)
* new mongodb deps (#13793) (287b2569)
#### v4.7.2 (2025-12-24)
##### Chores

View File

@@ -97,20 +97,20 @@
"multer": "2.0.2",
"nconf": "0.13.0",
"nodebb-plugin-2factor": "7.6.1",
"nodebb-plugin-composer-default": "10.3.1",
"nodebb-plugin-dbsearch": "6.3.4",
"nodebb-plugin-composer-default": "10.3.4",
"nodebb-plugin-dbsearch": "6.3.5",
"nodebb-plugin-emoji": "6.0.5",
"nodebb-plugin-emoji-android": "4.1.1",
"nodebb-plugin-link-preview": "2.2.1",
"nodebb-plugin-link-preview": "2.2.2",
"nodebb-plugin-markdown": "13.2.3",
"nodebb-plugin-mentions": "4.8.5",
"nodebb-plugin-spam-be-gone": "2.3.2",
"nodebb-plugin-web-push": "0.7.6",
"nodebb-rewards-essentials": "1.0.2",
"nodebb-theme-harmony": "2.1.33",
"nodebb-theme-harmony": "2.1.36",
"nodebb-theme-lavender": "7.1.19",
"nodebb-theme-peace": "2.2.49",
"nodebb-theme-persona": "14.1.25",
"nodebb-theme-persona": "14.1.26",
"nodebb-widget-essentials": "7.0.41",
"nodemailer": "7.0.12",
"nprogress": "0.2.0",
@@ -124,6 +124,7 @@
"pretty": "^2.0.0",
"progress-webpack-plugin": "1.0.16",
"prompt": "1.3.0",
"qs": "6.14.1",
"redis": "5.10.0",
"rimraf": "6.1.2",
"rss": "1.2.2",

View File

@@ -292,6 +292,7 @@
"api.401": "A valid login session was not found. Please log in and try again.",
"api.403": "You are not authorised to make this call",
"api.404": "Invalid API call",
"api.413": "The request payload is too large",
"api.426": "HTTPS is required for requests to the write api, please re-send your request via HTTPS",
"api.429": "You have made too many requests, please try again later",
"api.500": "An unexpected error was encountered while attempting to service your request.",

View File

@@ -88,7 +88,7 @@ define('admin/manage/group', [
bootbox.confirm('[[admin/manage/groups:alerts.confirm-delete]]', function (confirm) {
if (confirm) {
api.del(`/groups/${slugify(ajaxify.data.group.name)}`, {}).then(() => {
ajaxify.go('/admin/managegroups');
ajaxify.go('/admin/manage/groups');
}).catch(alerts.error);
}
});

View File

@@ -40,7 +40,6 @@ define('admin/manage/groups', [
const createModal = $('#create-modal');
const createGroupName = $('#create-group-name');
const createModalGo = $('#create-modal-go');
const createModalError = $('#create-modal-error');
createGroupName.trigger('focus');
createModal.on('keypress', function (e) {
@@ -61,18 +60,12 @@ define('admin/manage/groups', [
};
api.post('/groups', submitObj).then((response) => {
createModalError.addClass('hide');
createGroupName.val('');
createModal.on('hidden.bs.modal', function () {
ajaxify.go('admin/manage/groups/' + response.name);
});
createModal.modal('hide');
}).catch((err) => {
if (!utils.hasLanguageKey(err.status.message)) {
err.status.message = '[[admin/manage/groups:alerts.create-failure]]';
}
createModalError.translateHtml(err.status.message).removeClass('hide');
});
}).catch(alerts.error);
});
});
});

View File

@@ -25,10 +25,13 @@ define('admin/settings', [
});
const offset = mainHader.outerHeight(true);
// https://stackoverflow.com/a/11814275/583363
tocList.find('a').on('click', function (event) {
event.preventDefault();
tocList.find('a').on('click', function () {
const href = $(this).attr('href');
$(href)[0].scrollIntoView();
const $target = $(href);
if (!$target.length) {
return;
}
$target.get(0).scrollIntoView(true);
window.location.hash = href;
scrollBy(0, -offset);
setTimeout(() => {

View File

@@ -11,6 +11,7 @@ define('forum/chats', [
'forum/chats/user-list',
'forum/chats/message-search',
'forum/chats/pinned-messages',
'forum/chats/events',
'autocomplete',
'hooks',
'bootbox',
@@ -21,15 +22,14 @@ define('forum/chats', [
], function (
components, mousetrap, recentChats, create,
manage, messages, userList, messageSearch, pinnedMessages,
autocomplete, hooks, bootbox, alerts, chatModule, api,
uploadHelpers
events, autocomplete, hooks, bootbox, alerts, chatModule,
api, uploadHelpers
) {
const Chats = {
initialised: false,
activeAutocomplete: {},
newMessage: false,
};
let newMessage = false;
let chatNavWrapper = null;
$(window).on('action:ajaxify.start', function () {
@@ -54,10 +54,9 @@ define('forum/chats', [
socket.emit('modules.chats.enterPublic', ajaxify.data.publicRooms.map(r => r.roomId));
const env = utils.findBootstrapEnvironment();
chatNavWrapper = $('[component="chat/nav-wrapper"]');
if (!Chats.initialised) {
Chats.addSocketListeners();
Chats.addGlobalEventListeners();
}
Chats.addSocketListeners();
Chats.addGlobalEventListeners();
recentChats.init();
@@ -68,7 +67,6 @@ define('forum/chats', [
Chats.addHotkeys();
}
Chats.initialised = true;
const chatContentEl = $('[component="chat/message/content"]');
messages.wrapImagesInLinks(chatContentEl);
if (ajaxify.data.scrollToIndex) {
@@ -675,89 +673,22 @@ define('forum/chats', [
};
Chats.addGlobalEventListeners = function () {
$(window).on('mousemove keypress click', function () {
if (newMessage && ajaxify.data.roomId) {
api.del(`/chats/${ajaxify.data.roomId}/state`, {});
newMessage = false;
}
});
$(window).off('mousemove keypress click', onUserInteraction)
.on('mousemove keypress click', onUserInteraction);
};
function onUserInteraction() {
if (Chats.newMessage && ajaxify.data.roomId) {
// mark current room read on user interaction
api.del(`/chats/${ajaxify.data.roomId}/state`, {});
Chats.newMessage = false;
}
}
Chats.addSocketListeners = function () {
socket.on('event:chats.receive', function (data) {
if (chatModule.isFromBlockedUser(data.fromUid)) {
return;
}
if (parseInt(data.roomId, 10) === parseInt(ajaxify.data.roomId, 10)) {
data.self = parseInt(app.user.uid, 10) === parseInt(data.fromUid, 10) ? 1 : 0;
if (!newMessage) {
newMessage = data.self === 0;
}
data.message.self = data.self;
data.message.timestamp = Math.min(Date.now(), data.message.timestamp);
data.message.timestampISO = utils.toISOString(data.message.timestamp);
messages.appendChatMessage($('[component="chat/message/content"]'), data.message);
Chats.updateTeaser(data.roomId, {
content: utils.stripHTMLTags(utils.decodeHTMLEntities(data.message.content)),
user: data.message.fromUser,
timestampISO: data.message.timestampISO,
});
}
});
socket.on('event:chats.public.unread', function (data) {
if (
chatModule.isFromBlockedUser(data.fromUid) ||
chatModule.isLookingAtRoom(data.roomId) ||
app.user.uid === parseInt(data.fromUid, 10)
) {
return;
}
Chats.markChatPageElUnread(data);
Chats.increasePublicRoomUnreadCount(chatNavWrapper.find('[data-roomid=' + data.roomId + ']'));
});
socket.on('event:user_status_change', function (data) {
app.updateUserStatus($('.chats-list [data-uid="' + data.uid + '"] [component="user/status"]'), data.status);
});
events.init();
messages.addSocketListeners();
socket.on('event:chats.roomRename', function (data) {
const roomEl = components.get('chat/recent/room', data.roomId);
if (roomEl.length) {
const titleEl = roomEl.find('[component="chat/room/title"]');
ajaxify.data.roomName = data.newName;
titleEl.translateText(data.newName ? data.newName : ajaxify.data.usernames);
}
const titleEl = $(`[component="chat/main-wrapper"][data-roomid="${data.roomId}"] [component="chat/header/title"]`);
if (titleEl.length) {
titleEl.html(
data.newName ?
`<i class="fa ${ajaxify.data.icon} text-muted"></i> ${data.newName}` :
ajaxify.data.chatWithMessage
);
}
});
socket.on('event:chats.mark', ({ roomId, state }) => {
const roomEls = $(`[component="chat/recent"] [data-roomid="${roomId}"], [component="chat/list"] [data-roomid="${roomId}"], [component="chat/public"] [data-roomid="${roomId}"]`);
roomEls.each((idx, el) => {
const roomEl = $(el);
chatModule.markChatElUnread(roomEl, state === 1);
if (state === 0) {
Chats.updatePublicRoomUnreadCount(roomEl, 0);
}
});
});
socket.on('event:chats.typing', async (data) => {
if (data.uid === app.user.uid || chatModule.isFromBlockedUser(data.uid)) {
return;
}
chatModule.updateTypingUserList($(`[component="chat/main-wrapper"][data-roomid="${data.roomId}"]`), data);
});
};
Chats.updateTeaser = async function (roomId, teaser) {

View File

@@ -0,0 +1,116 @@
'use strict';
define('forum/chats/events', [
'forum/chats/messages',
'chat',
'components',
], function (messages, chatModule, components) {
const Events = {};
const events = {
'event:chats.receive': chatsReceive,
'event:chats.public.unread': publicChatUnread,
'event:user_status_change': onUserStatusChange,
'event:chats.roomRename': onRoomRename,
'event:chats.mark': markChatState,
'event:chats.typing': onChatTyping,
};
let chatNavWrapper = null;
let Chats = null;
Events.init = async function () {
Chats = await app.require('forum/chats');
chatNavWrapper = $('[component="chat/nav-wrapper"]');
Events.removeListeners();
for (const [eventName, handler] of Object.entries(events)) {
socket.on(eventName, handler);
}
};
Events.removeListeners = function () {
for (const [eventName, handler] of Object.entries(events)) {
socket.removeListener(eventName, handler);
}
};
function chatsReceive(data) {
if (chatModule.isFromBlockedUser(data.fromUid)) {
return;
}
if (parseInt(data.roomId, 10) === parseInt(ajaxify.data.roomId, 10)) {
data.self = parseInt(app.user.uid, 10) === parseInt(data.fromUid, 10) ? 1 : 0;
if (!Chats.newMessage) {
Chats.newMessage = data.self === 0;
}
data.message.self = data.self;
data.message.timestamp = Math.min(Date.now(), data.message.timestamp);
data.message.timestampISO = utils.toISOString(data.message.timestamp);
messages.appendChatMessage($('[component="chat/message/content"]'), data.message);
Chats.updateTeaser(data.roomId, {
content: utils.stripHTMLTags(utils.decodeHTMLEntities(data.message.content)),
user: data.message.fromUser,
timestampISO: data.message.timestampISO,
});
}
}
function publicChatUnread(data) {
if (
!ajaxify.data.template.chats ||
chatModule.isFromBlockedUser(data.fromUid) ||
chatModule.isLookingAtRoom(data.roomId) ||
app.user.uid === parseInt(data.fromUid, 10)
) {
return;
}
Chats.markChatPageElUnread(data);
Chats.increasePublicRoomUnreadCount(chatNavWrapper.find('[data-roomid=' + data.roomId + ']'));
}
function onUserStatusChange(data) {
app.updateUserStatus(
$(`.chats-list [data-uid="${data.uid}"] [component="user/status"]`), data.status
);
}
function onRoomRename(data) {
const roomEl = components.get('chat/recent/room', data.roomId);
if (roomEl.length) {
const titleEl = roomEl.find('[component="chat/room/title"]');
ajaxify.data.roomName = data.newName;
titleEl.translateText(data.newName ? data.newName : ajaxify.data.usernames);
}
const titleEl = $(`[component="chat/main-wrapper"][data-roomid="${data.roomId}"] [component="chat/header/title"]`);
if (titleEl.length) {
titleEl.html(
data.newName ?
`<i class="fa ${ajaxify.data.icon} text-muted"></i> ${data.newName}` :
ajaxify.data.chatWithMessage
);
}
}
function markChatState({ roomId, state }) {
const roomEls = $(`[component="chat/recent"] [data-roomid="${roomId}"], [component="chat/list"] [data-roomid="${roomId}"], [component="chat/public"] [data-roomid="${roomId}"]`);
roomEls.each((idx, el) => {
const roomEl = $(el);
chatModule.markChatElUnread(roomEl, state === 1);
if (state === 0) {
Chats.updatePublicRoomUnreadCount(roomEl, 0);
}
});
}
function onChatTyping(data) {
if (data.uid === app.user.uid || chatModule.isFromBlockedUser(data.uid)) {
return;
}
chatModule.updateTypingUserList($(`[component="chat/main-wrapper"][data-roomid="${data.roomId}"]`), data);
}
return Events;
});

View File

@@ -8,13 +8,16 @@ define('forum/groups/list', [
Groups.init = function () {
// Group creation
$('button[data-action="new"]').on('click', function () {
bootbox.prompt('[[groups:new-group.group-name]]', function (name) {
if (name && name.length) {
api.post('/groups', {
name: name,
}).then((res) => {
const modal = bootbox.prompt('[[groups:new-group.group-name]]', function (name) {
if (name === '') {
return false;
}
if (name && name.trim().length) {
api.post('/groups', { name }).then((res) => {
modal.modal('hide');
ajaxify.go('groups/' + res.slug);
}).catch(alerts.error);
return false;
}
});
});
@@ -42,19 +45,17 @@ define('forum/groups/list', [
return false;
};
function renderSearchResults(data) {
app.parseAndTranslate('partials/paginator', {
pagination: data.pagination,
}).then(function (html) {
$('.pagination-container').replaceWith(html);
});
const groupsEl = $('#groups-list');
app.parseAndTranslate('partials/groups/list', {
groups: data.groups,
}).then(function (html) {
groupsEl.empty().append(html);
});
async function renderSearchResults(data) {
const [paginationHtml, groupsHtml] = await Promise.all([
app.parseAndTranslate('partials/paginator', {
pagination: data.pagination,
}),
app.parseAndTranslate('partials/groups/list', {
groups: data.groups,
}),
]);
$('.pagination-container').replaceWith(paginationHtml);
$('#groups-list').empty().append(groupsHtml);
}
return Groups;

View File

@@ -464,7 +464,7 @@ module.exports = function (utils, load, warn) {
*/
Translator.escape = function escape(text) {
return typeof text === 'string' ?
text.replace(/\[\[/g, '&lsqb;&lsqb;').replace(/\]\]/g, '&rsqb;&rsqb;') :
text.replace(/\[\[([a-zA-Z0-9_.-]+:[a-zA-Z0-9_.-]+)\]\]/g, '&lsqb;&lsqb;$1&rsqb;&rsqb;') :
text;
};
@@ -475,7 +475,7 @@ module.exports = function (utils, load, warn) {
*/
Translator.unescape = function unescape(text) {
return typeof text === 'string' ?
text.replace(/&rsqb;&rsqb;/g, ']]').replace(/&lsqb;&lsqb;/g, '[[') :
text.replace(/&lsqb;&lsqb;([a-zA-Z0-9_.-]+:[a-zA-Z0-9_.-]+)&rsqb;&rsqb;/g, '[[$1]]') :
text;
};

View File

@@ -181,7 +181,7 @@ define('uploadHelpers', ['alerts'], function (alerts) {
'[[error:parse-error]]';
if (xhr && xhr.status === 413) {
errorMsg = xhr.statusText || 'Request Entity Too Large';
errorMsg = '[[error:api.413]]';
}
alerts.error(errorMsg);
alerts.remove(alert_id);

View File

@@ -301,7 +301,7 @@ const utils = {
return String(str).replace(new RegExp('<(\\/)?(' + (pattern || '[^\\s>]+') + ')(\\s+[^<>]*?)?\\s*(\\/)?>', 'gi'), '');
},
stripBidiControls: function (input) {
return input.replace(/[\u202A-\u202E\u2066-\u2069]/g, '');
return input.replace(/[\u202A-\u202E\u2066-\u2069]/gi, '');
},
cleanUpTag: function (tag, maxLength) {
if (typeof tag !== 'string' || !tag.length) {
@@ -310,7 +310,7 @@ const utils = {
tag = tag.trim().toLowerCase();
// see https://github.com/NodeBB/NodeBB/issues/4378
tag = tag.replace(/\u202E/gi, '');
tag = utils.stripBidiControls(tag);
tag = tag.replace(/[,/#!$^*;:{}=_`<>'"~()?|]/g, '');
tag = tag.slice(0, maxLength || 15).trim();
const matches = tag.match(/^[.-]*(.+?)[.-]*$/);

View File

@@ -65,10 +65,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.assertAccept = (accept) => {
if (!accept) return false;
const normalized = accept.split(',').map(s => s.trim().replace(/\s*;\s*/g, ';')).join(',');
return activitypub._constants.acceptableTypes.some(type => normalized.includes(type));
};
Helpers.isWebfinger = (value) => {
// N.B. returns normalized handle, so truthy check!

View File

@@ -41,7 +41,7 @@ ActivityPub._constants = Object.freeze({
acceptablePublicAddresses: ['https://www.w3.org/ns/activitystreams#Public', 'as:Public', 'Public'],
acceptableTypes: [
'application/activity+json',
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'application/ld+json;profile="https://www.w3.org/ns/activitystreams"',
],
acceptedPostTypes: [
'Note', 'Page', 'Article', 'Question', 'Video',

View File

@@ -310,7 +310,7 @@ Out.announce.topic = enabledCheck(async (tid, uid) => {
if (uid) {
const exists = await user.exists(uid);
if (!exists || !utils.isNumber(cid)) {
if (!exists) {
return;
}
} else {

View File

@@ -17,12 +17,14 @@ module.exports = function (Categories) {
await async.eachLimit(tids, 10, async (tid) => {
await topics.purgePostsAndTopic(tid, uid);
});
await db.sortedSetRemove(`cid:${cid}:tids`, tids);
}, { alwaysStartAt: 0 });
const pinnedTids = await db.getSortedSetRevRange(`cid:${cid}:tids:pinned`, 0, -1);
await async.eachLimit(pinnedTids, 10, async (tid) => {
await topics.purgePostsAndTopic(tid, uid);
});
await db.sortedSetRemove(`cid:${cid}:tids:pinned`, pinnedTids);
const categoryData = await Categories.getCategoryData(cid);
await purgeCategory(cid, categoryData);
plugins.hooks.fire('action:category.delete', { cid: cid, uid: uid, category: categoryData });

View File

@@ -96,10 +96,14 @@ module.exports = function (Categories) {
};
async function getTopics(tids, uid) {
const topicData = await topics.getTopicsFields(
tids,
['tid', 'mainPid', 'slug', 'title', 'teaserPid', 'cid', 'postcount']
);
const [topicData, crossposts] = await Promise.all([
topics.getTopicsFields(
tids,
['tid', 'mainPid', 'slug', 'title', 'teaserPid', 'cid', 'postcount']
),
topics.crossposts.get(tids),
]);
topicData.forEach((topic) => {
if (topic) {
topic.teaserPid = topic.teaserPid || topic.mainPid;
@@ -124,6 +128,7 @@ module.exports = function (Categories) {
slug: topicData[index].slug,
title: topicData[index].title,
};
teaser.crossposts = crossposts[index];
}
});
return teasers.filter(Boolean);
@@ -132,15 +137,20 @@ module.exports = function (Categories) {
function assignTopicsToCategories(categories, topics) {
categories.forEach((category) => {
if (category) {
category.posts = topics.filter(
t => t.cid &&
(t.cid === category.cid || (t.parentCids && t.parentCids.includes(category.cid)))
)
category.posts = topics.filter(t =>
t.cid &&
(t.cid === category.cid ||
(t.parentCids && t.parentCids.includes(category.cid)) ||
(t.crossposts.some(({ cid }) => parseInt(cid, 10) === category.cid))
))
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, parseInt(category.numRecentReplies, 10));
}
});
topics.forEach((t) => { t.parentCids = undefined; });
topics.forEach((t) => {
t.parentCids = undefined;
t.crossposts = undefined;
});
}
function bubbleUpChildrenPosts(categoryData) {

View File

@@ -42,7 +42,7 @@ async function registerAndLoginUser(req, res, userData) {
}
const queue = await user.shouldQueueUser(req.ip);
const result = await plugins.hooks.fire('filter:register.shouldQueue', { req: req, res: res, userData: userData, queue: queue });
const result = await plugins.hooks.fire('filter:register.shouldQueue', { req, res, userData, queue });
if (result.queue) {
return await addToApprovalQueue(req, userData);
}
@@ -102,10 +102,6 @@ authenticationController.register = async function (req, res) {
throw new Error('[[user:change-password-error-match]]');
}
if (userData.password.length > 512) {
throw new Error('[[error:password-too-long]]');
}
user.isPasswordValid(userData.password);
await plugins.hooks.fire('filter:password.check', { password: userData.password, uid: 0, userData: userData });

View File

@@ -40,6 +40,7 @@ helpers.noScriptErrors = async function (req, res, error, httpStatus) {
};
helpers.terms = {
alltime: 'alltime',
daily: 'day',
weekly: 'week',
monthly: 'month',
@@ -101,7 +102,7 @@ helpers.buildFilters = function (url, filter, query) {
helpers.buildTerms = function (url, term, query) {
return [{
name: '[[recent:alltime]]',
url: url + helpers.buildQueryString(query, 'term', ''),
url: url + helpers.buildQueryString(query, 'term', 'alltime'),
selected: term === 'alltime',
term: 'alltime',
}, {

View File

@@ -1,6 +1,7 @@
'use strict';
const nconf = require('nconf');
const path = require('path');
const qs = require('querystring');
const validator = require('validator');
@@ -311,17 +312,15 @@ async function addTags(topicData, req, res, currentPage, postAtIndex) {
async function addOGImageTags(res, topicData, postAtIndex) {
const uploads = postAtIndex ? await posts.uploads.listWithSizes(postAtIndex.pid) : [];
const images = uploads.map((upload) => {
upload.name = `${url + upload_url}/${upload.name}`;
return upload;
});
const images = uploads.filter(Boolean);
if (topicData.thumbs) {
const path = require('path');
const thumbs = topicData.thumbs.filter(
t => t && images.every(img => path.normalize(img.name) !== path.normalize(url + t.url))
t => t && images.every(img => path.normalize(img.name) !== path.normalize(t.path))
);
images.push(...thumbs.map(thumbObj => ({ name: url + thumbObj.url })));
images.push(...thumbs.map(t => t.path));
}
if (topicData.category.backgroundImage && (!postAtIndex || !postAtIndex.index)) {
images.push(topicData.category.backgroundImage);
}
@@ -332,13 +331,15 @@ async function addOGImageTags(res, topicData, postAtIndex) {
}
function addOGImageTag(res, image) {
let imageUrl;
if (typeof image === 'string' && !image.startsWith('http')) {
imageUrl = url + image.replace(new RegExp(`^${relative_path}`), '');
} else if (typeof image === 'object') {
imageUrl = image.name;
} else {
imageUrl = image;
const isObject = typeof image === 'object' && image.name;
let imageUrl = isObject ? image.name : image;
if (!(typeof imageUrl === 'string')) {
return;
}
if (!imageUrl.startsWith('http')) {
// (https://domain.com/forum) + (/assets/uploads) + (/files/imagePath)
imageUrl = url + path.posix.join(upload_url, imageUrl);
}
res.locals.metaTags.push({
@@ -351,7 +352,7 @@ function addOGImageTag(res, image) {
noEscape: true,
});
if (typeof image === 'object' && image.width && image.height) {
if (isObject && image.width && image.height) {
res.locals.metaTags.push({
property: 'og:image:width',
content: String(image.width),

View File

@@ -261,6 +261,7 @@ events.deleteEvents = async function (eids) {
events.deleteAll = async function () {
await batch.processSortedSet('events:time', async (eids) => {
await events.deleteEvents(eids);
await db.sortedSetRemove('events:time', eids);
}, { alwaysStartAt: 0, batch: 500 });
};

View File

@@ -271,27 +271,30 @@ module.exports = function (Posts) {
async function updateTopicVoteCount(postData) {
const topicData = await topics.getTopicFields(postData.tid, ['mainPid', 'cid', 'pinned']);
const { cid } = topicData;
if (postData.uid) {
if (postData.votes !== 0) {
await db.sortedSetAdd(`cid:${topicData.cid}:uid:${postData.uid}:pids:votes`, postData.votes, postData.pid);
await db.sortedSetAdd(`cid:${cid}:uid:${postData.uid}:pids:votes`, postData.votes, postData.pid);
} else {
await db.sortedSetRemove(`cid:${topicData.cid}:uid:${postData.uid}:pids:votes`, postData.pid);
await db.sortedSetRemove(`cid:${cid}:uid:${postData.uid}:pids:votes`, postData.pid);
}
}
if (String(topicData.mainPid) !== String(postData.pid)) {
return await db.sortedSetAdd(`tid:${postData.tid}:posts:votes`, postData.votes, postData.pid);
}
const isRemoteCid = !utils.isNumber(cid) || cid === -1;
const promises = [
topics.setTopicFields(postData.tid, {
upvotes: postData.upvotes,
downvotes: postData.downvotes,
}),
db.sortedSetAdd('topics:votes', postData.votes, postData.tid),
isRemoteCid ?
Promise.resolve() :
db.sortedSetAdd('topics:votes', postData.votes, postData.tid),
];
if (!topicData.pinned) {
promises.push(db.sortedSetAdd(`cid:${topicData.cid}:tids:votes`, postData.votes, postData.tid));
promises.push(db.sortedSetAdd(`cid:${cid}:tids:votes`, postData.votes, postData.tid));
}
await Promise.all(promises);
}

View File

@@ -61,6 +61,11 @@ async function validateTokenIfRequiresLogin(requiresLogin, cid, req, res) {
return true;
}
function stripUnicodeControlChars(str) {
// eslint-disable-next-line no-control-regex
return str.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '');
}
async function generateForTopic(req, res, next) {
if (meta.config['feeds:disableRSS']) {
return next();
@@ -80,20 +85,21 @@ async function generateForTopic(req, res, next) {
if (await validateTokenIfRequiresLogin(!userPrivileges['topics:read'], topic.cid, req, res)) {
const topicData = await topics.getTopicWithPosts(topic, `tid:${tid}:posts`, req.uid || req.query.uid || 0, 0, 24, true);
const mainPost = topicData.posts[0];
topics.modifyPostsByPrivilege(topicData, userPrivileges);
const title = stripUnicodeControlChars(topicData.title);
const feed = new rss({
title: utils.stripHTMLTags(topicData.title, utils.tags),
description: topicData.posts.length ? topicData.posts[0].content : '',
title: utils.stripHTMLTags(title, utils.tags),
description: topicData.posts.length ? stripUnicodeControlChars(mainPost.content) : '',
feed_url: `${nconf.get('url')}/topic/${tid}.rss`,
site_url: `${nconf.get('url')}/topic/${topicData.slug}`,
image_url: topicData.posts.length ? topicData.posts[0].picture : '',
author: topicData.posts.length ? topicData.posts[0].username : '',
image_url: topicData.posts.length ? mainPost.picture : '',
author: topicData.posts.length ? mainPost.username : '',
ttl: 60,
});
if (topicData.posts.length > 0) {
feed.pubDate = new Date(parseInt(topicData.posts[0].timestamp, 10)).toUTCString();
feed.pubDate = new Date(parseInt(mainPost.timestamp, 10)).toUTCString();
}
const replies = topicData.posts.slice(1);
replies.forEach((postData) => {
@@ -103,8 +109,8 @@ async function generateForTopic(req, res, next) {
).toUTCString();
feed.item({
title: `Reply to ${utils.stripHTMLTags(topicData.title, utils.tags)} on ${dateStamp}`,
description: postData.content,
title: `Reply to ${utils.stripHTMLTags(title, utils.tags)} on ${dateStamp}`,
description: stripUnicodeControlChars(postData.content),
url: `${nconf.get('url')}/post/${postData.pid}`,
author: postData.user ? postData.user.username : '',
date: dateStamp,
@@ -122,15 +128,20 @@ async function generateForCategory(req, res, next) {
return next();
}
const uid = req.uid || req.query.uid || 0;
async function getRecentlyCreatedTids() {
const [pinnedTids, tids] = await Promise.all([
db.getSortedSetRevRange(`cid:${cid}:tids:pinned`, 0, -1),
db.getSortedSetRevRange(`cid:${cid}:tids:create`, 0, 24),
]);
const allTids = Array.from(new Set([...pinnedTids, ...tids]));
const topicData = await topics.getTopicsFields(allTids, ['tid', 'timestamp']);
topicData.sort((a, b) => b.timestamp - a.timestamp);
return topicData.slice(0, 25).map(t => t.tid);
}
const [userPrivileges, category, tids] = await Promise.all([
privileges.categories.get(cid, req.uid),
categories.getCategoryData(cid),
db.getSortedSetRevIntersect({
sets: ['topics:tid', `cid:${cid}:tids:lastposttime`],
start: 0,
stop: 24,
weights: [1, 0],
}),
getRecentlyCreatedTids(),
]);
if (!category || !category.name) {
@@ -252,7 +263,7 @@ async function generateTopicsFeed(feedOptions, feedTopics, timestampField) {
feedOptions.feed_url = nconf.get('url') + feedOptions.feed_url;
feedOptions.site_url = nconf.get('url') + feedOptions.site_url;
feedTopics = feedTopics.filter(Boolean);
feedTopics = feedTopics.filter(t => t && !t.deleted);
const feed = new rss(feedOptions);
@@ -260,38 +271,39 @@ async function generateTopicsFeed(feedOptions, feedTopics, timestampField) {
feed.pubDate = new Date(feedTopics[0][timestampField]).toUTCString();
}
async function addFeedItem(topicData) {
if (feedOptions.useMainPost) {
const tids = feedTopics.map(topic => topic.tid);
const mainPosts = await topics.getMainPosts(tids, feedOptions.uid);
feedTopics.forEach((topicData, index) => {
topicData.mainPost = mainPosts[index];
});
}
function addFeedItem(topicData) {
const title = stripUnicodeControlChars(topicData.title);
const feedItem = {
title: utils.stripHTMLTags(topicData.title, utils.tags),
title: utils.stripHTMLTags(title, utils.tags),
url: `${nconf.get('url')}/topic/${topicData.slug}`,
date: new Date(topicData[timestampField]).toUTCString(),
};
if (topicData.deleted) {
return;
}
if (topicData.teaser && topicData.teaser.user && !feedOptions.useMainPost) {
feedItem.description = topicData.teaser.content;
feedItem.description = stripUnicodeControlChars(topicData.teaser.content);
feedItem.author = topicData.teaser.user.username;
feed.item(feedItem);
return;
}
const mainPost = await topics.getMainPost(topicData.tid, feedOptions.uid);
const { mainPost } = topicData;
if (!mainPost) {
feed.item(feedItem);
return;
}
feedItem.description = mainPost.content;
feedItem.description = stripUnicodeControlChars(mainPost.content);
feedItem.author = mainPost.user && mainPost.user.username;
feed.item(feedItem);
}
for (const topicData of feedTopics) {
/* eslint-disable no-await-in-loop */
await addFeedItem(topicData);
}
feedTopics.forEach(addFeedItem);
return feed;
}
@@ -357,9 +369,10 @@ function generateForPostsFeed(feedOptions, posts) {
}
posts.forEach((postData) => {
const title = stripUnicodeControlChars(postData.topic ? postData.topic.title : '');
feed.item({
title: postData.topic ? postData.topic.title : '',
description: postData.content,
title: title,
description: stripUnicodeControlChars(postData.content),
url: `${nconf.get('url')}/post/${postData.pid}`,
author: postData.user ? postData.user.username : '',
date: new Date(parseInt(postData.timestamp, 10)).toUTCString(),
@@ -394,7 +407,8 @@ async function generateForTag(req, res) {
return controllers404.handle404(req, res);
}
const uid = await getUidFromToken(req);
const tag = validator.escape(String(req.params.tag));
const set = `tag:${String(req.params.tag)}:topics`;
const tag = validator.escape(stripUnicodeControlChars(String(req.params.tag)));
const page = parseInt(req.query.page, 10) || 1;
const topicsPerPage = meta.config.topicsPerPage || 20;
const start = Math.max(0, (page - 1) * topicsPerPage);
@@ -407,7 +421,7 @@ async function generateForTag(req, res) {
site_url: `/tags/${tag}`,
start: start,
stop: stop,
}, `tag:${tag}:topics`, res);
}, set, res);
}
async function getUidFromToken(req) {

View File

@@ -376,9 +376,9 @@ function sortPosts(posts, data) {
} else {
posts.sort((p1, p2) => {
if (p1[fields[0]][fields[1]] > p2[fields[0]][fields[1]]) {
return direction;
} else if (p1[fields[0]][fields[1]] < p2[fields[0]][fields[1]]) {
return -direction;
} else if (p1[fields[0]][fields[1]] < p2[fields[0]][fields[1]]) {
return direction;
}
return 0;
});

View File

@@ -1,5 +1,6 @@
'use strict';
const _ = require('lodash');
const db = require('../database');
const topics = require('.');
const user = require('../user');
@@ -10,30 +11,39 @@ const utils = require('../utils');
const Crossposts = module.exports;
Crossposts.get = async function (tid) {
const crosspostIds = await db.getSortedSetMembers(`tid:${tid}:crossposts`);
let crossposts = await db.getObjects(crosspostIds.map(id => `crosspost:${id}`));
const cids = crossposts.reduce((cids, crossposts) => {
cids.add(crossposts.cid);
return cids;
}, new Set());
let categoriesData = await categories.getCategoriesFields(
Array.from(cids), ['cid', 'name', 'icon', 'bgColor', 'color', 'slug']
Crossposts.get = async function (tids) {
const isArray = Array.isArray(tids);
if (!isArray) {
tids = [tids];
}
const crosspostIds = await db.getSortedSetsMembers(tids.map(tid => `tid:${tid}:crossposts`));
const allCrosspostIds = crosspostIds.flat();
const allCrossposts = await db.getObjects(allCrosspostIds.map(id => `crosspost:${id}`));
const categoriesData = await categories.getCategoriesFields(
_.uniq(allCrossposts.map(c => c.cid)), ['cid', 'name', 'icon', 'bgColor', 'color', 'slug']
);
categoriesData = categoriesData.reduce((map, category) => {
const categoriesMap = categoriesData.reduce((map, category) => {
map.set(parseInt(category.cid, 10), category);
return map;
}, new Map());
crossposts = crossposts.map((crosspost, idx) => {
crosspost.id = crosspostIds[idx];
crosspost.category = categoriesData.get(parseInt(crosspost.cid, 10));
crosspost.uid = utils.isNumber(crosspost.uid) ? parseInt(crosspost.uid) : crosspost.uid;
crosspost.cid = utils.isNumber(crosspost.cid) ? parseInt(crosspost.cid) : crosspost.cid;
return crosspost;
});
const crosspostMap = allCrossposts.reduce((map, crosspost, index) => {
const id = allCrosspostIds[index];
if (id && crosspost) {
map.set(id, crosspost);
crosspost.id = id;
crosspost.category = categoriesMap.get(parseInt(crosspost.cid, 10));
crosspost.uid = utils.isNumber(crosspost.uid) ? parseInt(crosspost.uid, 10) : crosspost.uid;
crosspost.cid = utils.isNumber(crosspost.cid) ? parseInt(crosspost.cid, 10) : crosspost.cid;
}
return map;
}, new Map());
return crossposts;
const crossposts = crosspostIds.map(ids => ids.map(id => crosspostMap.get(id)));
return isArray ? crossposts : crossposts[0];
};
Crossposts.add = async function (tid, cid, uid) {

View File

@@ -73,14 +73,11 @@ module.exports = function (Topics) {
};
Topics.purge = async function (tid, uid) {
const [deletedTopic, tags] = await Promise.all([
Topics.getTopicData(tid),
Topics.getTopicTags(tid),
]);
const deletedTopic = await Topics.getTopicData(tid);
if (!deletedTopic) {
return;
}
deletedTopic.tags = tags;
deletedTopic.tags = deletedTopic.tags.map(tag => tag.value);
await deleteFromFollowersIgnorers(tid);
await Promise.all([

View File

@@ -312,13 +312,22 @@ module.exports = function (Topics) {
};
Topics.increasePostCount = async function (tid) {
incrementFieldAndUpdateSortedSet(tid, 'postcount', 1, 'topics:posts');
await incrementFieldAndUpdateSortedSet(tid, 'postcount', 1, 'topics:posts');
};
Topics.decreasePostCount = async function (tid) {
incrementFieldAndUpdateSortedSet(tid, 'postcount', -1, 'topics:posts');
await incrementFieldAndUpdateSortedSet(tid, 'postcount', -1, 'topics:posts');
};
async function incrementFieldAndUpdateSortedSet(tid, field, by, set) {
const cid = await Topics.getTopicField(tid, 'cid');
const value = await db.incrObjectFieldBy(`topic:${tid}`, field, by);
const isRemoteCid = !utils.isNumber(cid) || cid === -1;
if (!isRemoteCid) {
await db.sortedSetAdd(set, value, tid);
}
}
Topics.increaseViewCount = async function (req, tid) {
const allow = req.uid > 0 || (meta.config.guestsIncrementTopicViews && req.uid === 0);
if (allow) {
@@ -327,17 +336,16 @@ module.exports = function (Topics) {
const interval = meta.config.incrementTopicViewsInterval * 60000;
if (!req.session.tids_viewed[tid] || req.session.tids_viewed[tid] < now - interval) {
const cid = await Topics.getTopicField(tid, 'cid');
incrementFieldAndUpdateSortedSet(tid, 'viewcount', 1, ['topics:views', `cid:${cid}:tids:views`]);
const isRemoteCid = !utils.isNumber(cid) || cid === -1;
const value = await db.incrObjectFieldBy(`topic:${tid}`, 'viewcount', 1);
await db.sortedSetsAdd(
isRemoteCid ? [`cid:${cid}:tids:views`] : ['topics:views', `cid:${cid}:tids:views`], value, tid
);
req.session.tids_viewed[tid] = now;
}
}
};
async function incrementFieldAndUpdateSortedSet(tid, field, by, set) {
const value = await db.incrObjectFieldBy(`topic:${tid}`, field, by);
await db[Array.isArray(set) ? 'sortedSetsAdd' : 'sortedSetAdd'](set, value, tid);
}
Topics.getTitleByPid = async function (pid) {
return await Topics.getTopicFieldByPid('title', pid);
};

View File

@@ -141,8 +141,8 @@ module.exports = function (Topics) {
});
tids = await privileges.topics.filterTids('topics:read', tids, params.uid);
const topicData = (await Topics.getTopicsFields(tids, ['tid', 'cid', 'uid', 'postcount', 'deleted', 'scheduled', 'tags']))
.filter(t => t.scheduled || !t.deleted);
const topicData = (await Topics.getTopicsFields(tids, ['tid', 'cid', 'uid', 'postcount', 'deleted', 'tags']))
.filter(t => !t.deleted);
const topicCids = _.uniq(topicData.map(topic => topic.cid)).filter(Boolean);
const categoryWatchState = await categories.getWatchState(topicCids, params.uid);

View File

@@ -0,0 +1,48 @@
'use strict';
const db = require('../../database');
const batch = require('../../batch');
const utils = require('../../utils');
module.exports = {
name: 'Remove AP tids from topics:recent, topics:views, topics:posts, topics:votes zsets',
timestamp: Date.UTC(2026, 0, 25),
method: async function () {
const { progress } = this;
const [recent, views, posts, votes] = await db.sortedSetsCard([
'topics:recent', 'topics:views', 'topics:posts', 'topics:votes',
]);
progress.total = recent + views + posts + votes;
async function cleanupSet(setName) {
const tidsToRemove = [];
await batch.processSortedSet(setName, async (tids) => {
const topicData = await db.getObjectsFields(tids.map(tid => `topic:${tid}`), ['cid']);
topicData.forEach((t, index) => {
if (t) {
t.tid = tids[index];
}
});
const batchTids = topicData.filter(
t => t && (!t.cid || !utils.isNumber(t.cid) || t.cid === -1)
).map(t => t.tid);
tidsToRemove.push(...batchTids);
progress.incr(tids.length);
}, {
batch: 500,
});
await batch.processArray(tidsToRemove, async (batchTids) => {
await db.sortedSetRemove(setName, batchTids);
}, {
batch: 500,
});
}
await cleanupSet('topics:recent');
await cleanupSet('topics:views');
await cleanupSet('topics:posts');
await cleanupSet('topics:votes');
},
};

View File

@@ -55,7 +55,8 @@ module.exports = function (User) {
fields: fieldsToExport,
showIps: fieldsToExport.includes('ip'),
});
const customUserFields = await db.getSortedSetRange('user-custom-fields', 0, -1);
const fieldsToWrapInQuotes = ['fullname', 'signature', 'aboutme', ...customUserFields];
if (!showIps && fields.includes('ip')) {
fields.splice(fields.indexOf('ip'), 1);
}
@@ -63,7 +64,7 @@ module.exports = function (User) {
path.join(baseDir, 'build/export', 'users.csv'),
'w'
);
fs.promises.appendFile(fd, `${fields.map(f => `"${f}"`).join(',')}\n`);
await fs.promises.appendFile(fd, `${fields.map(f => `"${f}"`).join(',')}\n`);
await batch.processSortedSet('users:joindate', async (uids) => {
const userFieldsToLoad = fields.filter(field => field !== 'ip' && field !== 'password');
const usersData = await User.getUsersFields(uids, userFieldsToLoad);
@@ -76,6 +77,11 @@ module.exports = function (User) {
if (Array.isArray(userIps[index])) {
user.ip = userIps[index].join(',');
}
fieldsToWrapInQuotes.forEach((field) => {
if (user[field]) {
user[field] = `"${String(user[field])}"`;
}
});
});
const opts = { fields, header: false };

View File

@@ -333,7 +333,7 @@ module.exports = function (User) {
user.displayname = validator.escape(String(
meta.config.showFullnameAsDisplayName && showfullname && user.fullname ?
user.fullname :
utils.stripBidiControls(user.fullname) :
user.username
));
}

View File

@@ -43,15 +43,17 @@ module.exports = function (User) {
async function deletePosts(callerUid, uid) {
await batch.processSortedSet(`uid:${uid}:posts`, async (pids) => {
await posts.purge(pids, callerUid);
await db.sortedSetRemove(`uid:${uid}:posts`, pids);
}, { alwaysStartAt: 0, batch: 500 });
}
async function deleteTopics(callerUid, uid) {
await batch.processSortedSet(`uid:${uid}:topics`, async (ids) => {
await async.eachSeries(ids, async (tid) => {
await batch.processSortedSet(`uid:${uid}:topics`, async (tids) => {
await async.eachSeries(tids, async (tid) => {
await topics.purge(tid, callerUid);
});
}, { alwaysStartAt: 0 });
await db.sortedSetRemove(`uid:${uid}:topics`, tids);
}, { alwaysStartAt: 0, batch: 100 });
}
async function deleteUploads(callerUid, uid) {

View File

@@ -84,6 +84,7 @@ Digest.getSubscribers = async function (interval) {
Digest.send = async function (data) {
let emailsSent = 0;
let emailsFailed = 0;
if (!data || !data.subscribers || !data.subscribers.length) {
return emailsSent;
}
@@ -100,6 +101,7 @@ Digest.send = async function (data) {
return;
}
const userSettings = await user.getMultipleUserSettings(userData.map(u => u.uid));
const successfullUids = [];
await Promise.all(userData.map(async (userObj, index) => {
const userSetting = userSettings[index];
const [publicRooms, notifications, topics] = await Promise.all([
@@ -124,57 +126,62 @@ Digest.send = async function (data) {
}
});
emailsSent += 1;
await emailer.send('digest', userObj.uid, {
subject: `[[email:digest.subject, ${date.toLocaleDateString(userSetting.userLang)}]]`,
username: userObj.username,
userslug: userObj.userslug,
notifications: unreadNotifs,
publicRooms: publicRooms,
recent: topics.recent,
topTopics: topics.top,
popularTopics: topics.popular,
interval: data.interval,
showUnsubscribe: true,
}).catch((err) => {
try {
await emailer.send('digest', userObj.uid, {
subject: `[[email:digest.subject, ${date.toLocaleDateString(userSetting.userLang)}]]`,
username: userObj.username,
userslug: userObj.userslug,
notifications: unreadNotifs,
publicRooms: publicRooms,
recent: topics.recent,
topTopics: topics.top,
popularTopics: topics.popular,
interval: data.interval,
showUnsubscribe: true,
});
emailsSent += 1;
successfullUids.push(userObj.uid);
} catch (err) {
emailsFailed += 1;
if (!errorLogged) {
winston.error(`[user/jobs] Could not send digest email\n[emailer.send] ${err.stack}`);
errorLogged = true;
}
});
}
}));
if (data.interval !== 'alltime') {
if (data.interval !== 'alltime' && successfullUids.length) {
const now = Date.now();
await db.sortedSetAdd('digest:delivery', userData.map(() => now), userData.map(u => u.uid));
await db.sortedSetAdd('digest:delivery', successfullUids.map(() => now), successfullUids);
}
}, {
interval: 1000,
batch: 100,
});
winston.info(`[user/jobs] Digest (${data.interval}) sending completed. ${emailsSent} emails sent.`);
winston.info(`[user/jobs] Digest (${data.interval}) sending completed. ${emailsSent} emails sent. ${emailsFailed} failures.`);
return emailsSent;
};
Digest.getDeliveryTimes = async (start, stop) => {
const count = await db.sortedSetCard('users:joindate');
const uids = await user.getUidsFromSet('users:joindate', start, stop);
const [count, uids] = await Promise.all([
db.sortedSetCard('users:joindate'),
user.getUidsFromSet('users:joindate', start, stop),
]);
if (!uids.length) {
return [];
return { users: [], count };
}
const [scores, settings] = await Promise.all([
const [scores, settings, userData] = await Promise.all([
// Grab the last time a digest was successfully delivered to these uids
db.sortedSetScores('digest:delivery', uids),
// Get users' digest settings
Digest.getUsersInterval(uids),
user.getUsersFields(uids, ['username', 'picture']),
]);
// Populate user data
let userData = await user.getUsersFields(uids, ['username', 'picture']);
userData = userData.map((user, idx) => {
userData.forEach((user, idx) => {
user.lastDelivery = scores[idx] ? new Date(scores[idx]).toISOString() : '[[admin/manage/digest:null]]';
user.setting = settings[idx];
return user;
});
return {

View File

@@ -42,7 +42,19 @@ utils.secureRandom = function (low, high) {
};
utils.getSass = function () {
if (process.platform === 'freebsd') {
// https://github.com/NodeBB/NodeBB/issues/11606
function isMusl() {
if (process.platform !== 'linux') {
return false;
}
try {
return !process.report.getReport().header.glibcVersionRuntime;
} catch {
return true;
}
}
if (process.platform === 'freebsd' || isMusl()) {
return require('sass');
}
try {

View File

@@ -6,7 +6,6 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-hidden="true"></button>
</div>
<div class="modal-body">
<div class="alert alert-danger hide" id="create-modal-error"></div>
<form>
<div class="mb-3">
<label class="form-label" for="create-group-name">[[admin/manage/groups:name]]</label>

View File

@@ -137,6 +137,12 @@ function setupExpressApp(app) {
app.set('view engine', 'tpl');
app.set('views', viewsDir);
app.set('json spaces', global.env === 'development' ? 4 : 0);
// https://github.com/NodeBB/NodeBB/issues/13918
const qs = require('qs');
app.set('query parser', str => qs.parse(str, {
arrayLimit: Math.min(100, nconf.get('queryParser:arrayLimit') || 50),
}));
app.use(flash());
app.enable('view cache');

View File

@@ -159,9 +159,9 @@ describe('Crossposting (& related logic)', () => {
it('should not let another user uncrosspost', async () => {
const uid2 = await user.create({ username: utils.generateUUID().slice(0, 8) });
assert.rejects(
await assert.rejects(
topics.crossposts.remove(tid, cid2, uid2),
'[[error:invalid-data]]',
{ message: '[[error:invalid-data]]' },
);
});
@@ -184,9 +184,9 @@ describe('Crossposting (& related logic)', () => {
});
it('should throw on uncrossposting if already uncrossposted', async () => {
assert.rejects(
await assert.rejects(
topics.crossposts.remove(tid, cid2, uid),
'[[error:invalid-data]]',
{ message: '[[error:invalid-data]]' },
);
});
});
@@ -286,9 +286,9 @@ describe('Crossposting (& related logic)', () => {
it('should fail to uncrosspost if not mod of passed-in category', async () => {
await privileges.categories.give(['moderate'], cid1, [privUid]);
assert.rejects(
await assert.rejects(
topics.crossposts.remove(tid, cid2, privUid),
'[[error:invalid-data]]',
{ message: '[[error:invalid-data]]' },
);
});

View File

@@ -87,22 +87,22 @@ describe('Topic tools', () => {
});
it('should throw when attempting to move a topic from a remote category', async () => {
assert.rejects(
await assert.rejects(
topics.tools.move(tid1, {
cid: localCid,
uid: 'system',
}),
'[[error:cant-move-topic-to-from-remote-categories]]'
{ message: '[[error:no-topic]]' }
);
});
it('should throw when attempting to move a topic to a remote category', async () => {
assert.rejects(
await assert.rejects(
topics.tools.move(tid2, {
cid: remoteCid,
uid: 'system',
}),
'[[error:cant-move-topic-to-from-remote-categories]]'
{ message: '[[error:cant-move-topic-to-from-remote-categories]]' }
);
});
});

View File

@@ -326,7 +326,7 @@ describe('Translator static methods', () => {
done();
});
});
describe('.escape', () => {
describe('.escape/.unescape', () => {
it('should escape translation patterns within text', (done) => {
assert.strictEqual(
Translator.escape('some nice text [[global:home]] here'),
@@ -334,9 +334,23 @@ describe('Translator static methods', () => {
);
done();
});
});
describe('.unescape', () => {
it('should escape all translation patterns within text', (done) => {
assert.strictEqual(
Translator.escape('some nice text [[global:home]] here and [[global:search]] there'),
'some nice text &lsqb;&lsqb;global:home&rsqb;&rsqb; here and &lsqb;&lsqb;global:search&rsqb;&rsqb; there'
);
done();
});
it('should not escape markdown links', (done) => {
assert.strictEqual(
Translator.escape('[link text [test]](https://example.org)'),
'[link text [test]](https://example.org)'
);
done();
});
it('should unescape escaped translation patterns within text', (done) => {
assert.strictEqual(
Translator.unescape('some nice text &lsqb;&lsqb;global:home&rsqb;&rsqb; here'),
@@ -344,6 +358,14 @@ describe('Translator static methods', () => {
);
done();
});
it('should not unescape markdown links', (done) => {
assert.strictEqual(
Translator.unescape('&lsqblink text &lsqbtest&rsqb;&rsqb;(https://example.org)'),
'&lsqblink text &lsqbtest&rsqb;&rsqb;(https://example.org)'
);
done();
});
});
describe('.compile', () => {

View File

@@ -51,6 +51,12 @@ describe('Utility Methods', () => {
assert.strictEqual(out, 'Hello World Dwellers');
});
it('should remove common bidi embedding and override controls if they are lowercase', () => {
const input = '\u202aHello\u202c \u202bWorld\u202c \u202dDwellers\u202e';
const out = utils.stripBidiControls(input);
assert.strictEqual(out, 'Hello World Dwellers');
});
it('should remove bidirectional isolate formatting characters', () => {
const input = '\u2066abc\u2067def\u2068ghi\u2069';
const out = utils.stripBidiControls(input);