diff --git a/CHANGELOG.md b/CHANGELOG.md index bac75051f0..8f43cfc6d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/install/package.json b/install/package.json index 040fac1dee..1075aba0c1 100644 --- a/install/package.json +++ b/install/package.json @@ -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", diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json index b32ccfedfb..61231b11a8 100644 --- a/public/language/en-GB/error.json +++ b/public/language/en-GB/error.json @@ -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.", diff --git a/public/src/admin/manage/group.js b/public/src/admin/manage/group.js index 1e91b17c0b..c856f0b487 100644 --- a/public/src/admin/manage/group.js +++ b/public/src/admin/manage/group.js @@ -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); } }); diff --git a/public/src/admin/manage/groups.js b/public/src/admin/manage/groups.js index ba3d11f258..a12881af26 100644 --- a/public/src/admin/manage/groups.js +++ b/public/src/admin/manage/groups.js @@ -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); }); }); }); diff --git a/public/src/admin/settings.js b/public/src/admin/settings.js index f189718426..29ede2f502 100644 --- a/public/src/admin/settings.js +++ b/public/src/admin/settings.js @@ -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(() => { diff --git a/public/src/client/chats.js b/public/src/client/chats.js index 4181bd79cb..65f7cd6685 100644 --- a/public/src/client/chats.js +++ b/public/src/client/chats.js @@ -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 ? - ` ${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) { diff --git a/public/src/client/chats/events.js b/public/src/client/chats/events.js new file mode 100644 index 0000000000..4e14f941e1 --- /dev/null +++ b/public/src/client/chats/events.js @@ -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 ? + ` ${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; +}); diff --git a/public/src/client/groups/list.js b/public/src/client/groups/list.js index bd13996044..a68b87bf9a 100644 --- a/public/src/client/groups/list.js +++ b/public/src/client/groups/list.js @@ -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; diff --git a/public/src/modules/translator.common.js b/public/src/modules/translator.common.js index c69a9cc265..8f78c5c49e 100644 --- a/public/src/modules/translator.common.js +++ b/public/src/modules/translator.common.js @@ -464,7 +464,7 @@ module.exports = function (utils, load, warn) { */ Translator.escape = function escape(text) { return typeof text === 'string' ? - text.replace(/\[\[/g, '[[').replace(/\]\]/g, ']]') : + text.replace(/\[\[([a-zA-Z0-9_.-]+:[a-zA-Z0-9_.-]+)\]\]/g, '[[$1]]') : text; }; @@ -475,7 +475,7 @@ module.exports = function (utils, load, warn) { */ Translator.unescape = function unescape(text) { return typeof text === 'string' ? - text.replace(/]]/g, ']]').replace(/[[/g, '[[') : + text.replace(/[[([a-zA-Z0-9_.-]+:[a-zA-Z0-9_.-]+)]]/g, '[[$1]]') : text; }; diff --git a/public/src/modules/uploadHelpers.js b/public/src/modules/uploadHelpers.js index 49ed9fd9fb..a465a44054 100644 --- a/public/src/modules/uploadHelpers.js +++ b/public/src/modules/uploadHelpers.js @@ -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); diff --git a/public/src/utils.common.js b/public/src/utils.common.js index 873292d22e..fbebb7ba73 100644 --- a/public/src/utils.common.js +++ b/public/src/utils.common.js @@ -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(/^[.-]*(.+?)[.-]*$/); diff --git a/src/activitypub/helpers.js b/src/activitypub/helpers.js index 80135808ab..a966d26ac0 100644 --- a/src/activitypub/helpers.js +++ b/src/activitypub/helpers.js @@ -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! diff --git a/src/activitypub/index.js b/src/activitypub/index.js index 92dd10b544..4ab6159f2f 100644 --- a/src/activitypub/index.js +++ b/src/activitypub/index.js @@ -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', diff --git a/src/activitypub/out.js b/src/activitypub/out.js index 4e11c243ef..701464ba8c 100644 --- a/src/activitypub/out.js +++ b/src/activitypub/out.js @@ -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 { diff --git a/src/categories/delete.js b/src/categories/delete.js index c129cddbd2..243b310106 100644 --- a/src/categories/delete.js +++ b/src/categories/delete.js @@ -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 }); diff --git a/src/categories/recentreplies.js b/src/categories/recentreplies.js index c162d438b8..1021181e22 100644 --- a/src/categories/recentreplies.js +++ b/src/categories/recentreplies.js @@ -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) { diff --git a/src/controllers/authentication.js b/src/controllers/authentication.js index 162bea8ecd..6de6202047 100644 --- a/src/controllers/authentication.js +++ b/src/controllers/authentication.js @@ -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 }); diff --git a/src/controllers/helpers.js b/src/controllers/helpers.js index a6ade8c73b..97ff1a3129 100644 --- a/src/controllers/helpers.js +++ b/src/controllers/helpers.js @@ -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', }, { diff --git a/src/controllers/topics.js b/src/controllers/topics.js index 08958ce730..7147bedd54 100644 --- a/src/controllers/topics.js +++ b/src/controllers/topics.js @@ -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), diff --git a/src/events.js b/src/events.js index 6a293c1bdd..6bd65c1b21 100644 --- a/src/events.js +++ b/src/events.js @@ -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 }); }; diff --git a/src/posts/votes.js b/src/posts/votes.js index d9487b1dc2..64724778c1 100644 --- a/src/posts/votes.js +++ b/src/posts/votes.js @@ -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); } diff --git a/src/routes/feeds.js b/src/routes/feeds.js index b913ca56da..0e4c066673 100644 --- a/src/routes/feeds.js +++ b/src/routes/feeds.js @@ -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) { diff --git a/src/search.js b/src/search.js index baf4d3c340..b8909b1b41 100644 --- a/src/search.js +++ b/src/search.js @@ -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; }); diff --git a/src/topics/crossposts.js b/src/topics/crossposts.js index b4ae973ad2..e677e11481 100644 --- a/src/topics/crossposts.js +++ b/src/topics/crossposts.js @@ -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) { diff --git a/src/topics/delete.js b/src/topics/delete.js index 03e756e1fd..9a6110fffc 100644 --- a/src/topics/delete.js +++ b/src/topics/delete.js @@ -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([ diff --git a/src/topics/posts.js b/src/topics/posts.js index a8535939e9..67d9d4ed62 100644 --- a/src/topics/posts.js +++ b/src/topics/posts.js @@ -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); }; diff --git a/src/topics/unread.js b/src/topics/unread.js index ed93f19abf..afdb3fc155 100644 --- a/src/topics/unread.js +++ b/src/topics/unread.js @@ -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); diff --git a/src/upgrades/4.8.2/clean_ap_tids_from_topic_zsets.js b/src/upgrades/4.8.2/clean_ap_tids_from_topic_zsets.js new file mode 100644 index 0000000000..547dd14157 --- /dev/null +++ b/src/upgrades/4.8.2/clean_ap_tids_from_topic_zsets.js @@ -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'); + }, +}; diff --git a/src/user/admin.js b/src/user/admin.js index 35598bbbd9..b629e46628 100644 --- a/src/user/admin.js +++ b/src/user/admin.js @@ -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 }; diff --git a/src/user/data.js b/src/user/data.js index cd2326281d..0620e159a2 100644 --- a/src/user/data.js +++ b/src/user/data.js @@ -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 )); } diff --git a/src/user/delete.js b/src/user/delete.js index 8b084b184b..65d8ccea65 100644 --- a/src/user/delete.js +++ b/src/user/delete.js @@ -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) { diff --git a/src/user/digest.js b/src/user/digest.js index 77bc2e93e5..89301dde85 100644 --- a/src/user/digest.js +++ b/src/user/digest.js @@ -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 { diff --git a/src/utils.js b/src/utils.js index 55d3b5e793..abc7a92e2a 100644 --- a/src/utils.js +++ b/src/utils.js @@ -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 { diff --git a/src/views/admin/partials/create_group_modal.tpl b/src/views/admin/partials/create_group_modal.tpl index 49ee2678fd..51554b6064 100644 --- a/src/views/admin/partials/create_group_modal.tpl +++ b/src/views/admin/partials/create_group_modal.tpl @@ -6,7 +6,6 @@