mirror of
https://github.com/NodeBB/NodeBB.git
synced 2026-05-19 00:46:13 +02:00
Merge commit '03b7374c69b77dd9c20814c32485402be0cf3a54' into v4.x
This commit is contained in:
210
CHANGELOG.md
210
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
116
public/src/client/chats/events.js
Normal file
116
public/src/client/chats/events.js
Normal 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;
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(/^[.-]*(.+?)[.-]*$/);
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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',
|
||||
}, {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
48
src/upgrades/4.8.2/clean_ap_tids_from_topic_zsets.js
Normal file
48
src/upgrades/4.8.2/clean_ap_tids_from_topic_zsets.js
Normal 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');
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
14
src/utils.js
14
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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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]]' },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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]]' }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 [[global:home]] here and [[global:search]] 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 [[global:home]] here'),
|
||||
@@ -344,6 +358,14 @@ describe('Translator static methods', () => {
|
||||
);
|
||||
done();
|
||||
});
|
||||
|
||||
it('should not unescape markdown links', (done) => {
|
||||
assert.strictEqual(
|
||||
Translator.unescape('&lsqblink text &lsqbtest]](https://example.org)'),
|
||||
'&lsqblink text &lsqbtest]](https://example.org)'
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
describe('.compile', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user