diff --git a/CHANGELOG.md b/CHANGELOG.md index 30c9a42afd..63f0509b35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,73 @@ +#### v3.8.2 (2024-05-29) + +##### Chores + +* up composer (83facb7d) +* up harmony (17ea61a0) +* incrementing version number - v3.8.1 (527326f7) +* update changelog for v3.8.1 (5ef3e0f3) +* incrementing version number - v3.8.0 (e228a6eb) +* incrementing version number - v3.7.5 (6882894d) +* incrementing version number - v3.7.4 (6678744c) +* incrementing version number - v3.7.3 (2d62b6f6) +* incrementing version number - v3.7.2 (cc257e7e) +* incrementing version number - v3.7.1 (712365a5) +* incrementing version number - v3.7.0 (9a6153d7) +* incrementing version number - v3.6.7 (86a17e38) +* incrementing version number - v3.6.6 (6604bf37) +* incrementing version number - v3.6.5 (6c653625) +* incrementing version number - v3.6.4 (83d131b4) +* incrementing version number - v3.6.3 (fc7d2bfd) +* incrementing version number - v3.6.2 (0f577a57) +* incrementing version number - v3.6.1 (f1a69468) +* incrementing version number - v3.6.0 (4cdf85f8) +* incrementing version number - v3.5.3 (ed0e8783) +* incrementing version number - v3.5.2 (52fbb2da) +* incrementing version number - v3.5.1 (4c543488) +* incrementing version number - v3.5.0 (d06fb4f0) +* incrementing version number - v3.4.3 (5c984250) +* incrementing version number - v3.4.2 (3f0dac38) +* incrementing version number - v3.4.1 (01e69574) +* incrementing version number - v3.4.0 (fd9247c5) +* incrementing version number - v3.3.9 (5805e770) +* incrementing version number - v3.3.8 (a5603565) +* incrementing version number - v3.3.7 (b26f1744) +* incrementing version number - v3.3.6 (7fb38792) +* incrementing version number - v3.3.4 (a67f84ea) +* incrementing version number - v3.3.3 (f94d239b) +* incrementing version number - v3.3.2 (ec9dac97) +* incrementing version number - v3.3.1 (151cc68f) +* incrementing version number - v3.3.0 (fc1ad70f) +* incrementing version number - v3.2.3 (b06d3e63) +* incrementing version number - v3.2.2 (758ecfcd) +* incrementing version number - v3.2.1 (20145074) +* incrementing version number - v3.2.0 (9ecac38e) +* incrementing version number - v3.1.7 (0b4e81ab) +* incrementing version number - v3.1.6 (b3a3b130) +* incrementing version number - v3.1.5 (ec19343a) +* incrementing version number - v3.1.4 (2452783c) +* incrementing version number - v3.1.3 (3b4e9d3f) +* incrementing version number - v3.1.2 (40fa3489) +* incrementing version number - v3.1.1 (40250733) +* incrementing version number - v3.1.0 (0cb386bd) +* incrementing version number - v3.0.1 (26f6ea49) +* incrementing version number - v3.0.0 (224e08cd) + +##### New Features + +* show ignored/watched topics in topic list, closes #10974 (29dbe92d) +* convert "All Votes Are Public" toggle to vote visibility (e0515080) + +##### Bug Fixes + +* wrong var for ignored (7969e62d) +* reduce docker image size again and speed up build (56ef2bdd) +* update thumb count when removing thumbs (6214336c) + +##### Refactors + +* render (2c0f8c91) + #### v3.8.1 (2024-05-15) ##### Chores diff --git a/install/package.json b/install/package.json index 07d4ad5867..262a7474d6 100644 --- a/install/package.json +++ b/install/package.json @@ -33,6 +33,7 @@ "@fontsource/poppins": "5.0.14", "@fortawesome/fontawesome-free": "6.5.2", "@isaacs/ttlcache": "1.4.1", + "@nodebb/spider-detector": "2.0.3", "@popperjs/core": "2.11.8", "ace-builds": "1.33.2", "archiver": "7.0.1", @@ -93,9 +94,9 @@ "mousetrap": "1.6.5", "multiparty": "4.2.3", "nconf": "0.12.1", - "nodebb-plugin-2factor": "7.5.1", + "nodebb-plugin-2factor": "7.5.3", "nodebb-plugin-composer-default": "10.2.36", - "nodebb-plugin-dbsearch": "6.2.3", + "nodebb-plugin-dbsearch": "6.2.5", "nodebb-plugin-emoji": "5.1.15", "nodebb-plugin-emoji-android": "4.0.0", "nodebb-plugin-markdown": "12.2.6", @@ -103,10 +104,10 @@ "nodebb-plugin-ntfy": "1.7.4", "nodebb-plugin-spam-be-gone": "2.2.2", "nodebb-rewards-essentials": "1.0.0", - "nodebb-theme-harmony": "1.2.57", + "nodebb-theme-harmony": "1.2.63", "nodebb-theme-lavender": "7.1.8", - "nodebb-theme-peace": "2.2.5", - "nodebb-theme-persona": "13.3.20", + "nodebb-theme-peace": "2.2.6", + "nodebb-theme-persona": "13.3.24", "nodebb-widget-essentials": "7.0.16", "nodemailer": "6.9.13", "nprogress": "0.2.0", @@ -134,7 +135,6 @@ "@socket.io/redis-adapter": "8.3.0", "sortablejs": "1.15.2", "spdx-license-list": "6.9.0", - "spider-detector": "2.0.1", "terser-webpack-plugin": "5.3.10", "textcomplete": "0.18.2", "textcomplete.contenteditable": "0.1.1", diff --git a/public/language/en-GB/admin/development/info.json b/public/language/en-GB/admin/development/info.json index 11202d9c3a..9834719daf 100644 --- a/public/language/en-GB/admin/development/info.json +++ b/public/language/en-GB/admin/development/info.json @@ -3,7 +3,7 @@ "ip": "IP %1", "nodes-responded": "%1 nodes responded within %2ms!", "host": "host", - "primary": "primary / run jobs", + "primary": "primary / jobs", "pid": "pid", "nodejs": "nodejs", "online": "online", @@ -19,6 +19,7 @@ "registered": "Registered", "sockets": "Sockets", + "connection-count": "Connection Count", "guests": "Guests", "info": "Info" diff --git a/public/language/en-GB/post-queue.json b/public/language/en-GB/post-queue.json index 021ed0d83f..24b33da2e6 100644 --- a/public/language/en-GB/post-queue.json +++ b/public/language/en-GB/post-queue.json @@ -38,5 +38,6 @@ "remove-selected": "Remove Selected", "remove-selected-confirm": "Do you want to remove %1 selected posts?", "bulk-accept-success": "%1 posts accepted", - "bulk-reject-success": "%1 posts rejected" + "bulk-reject-success": "%1 posts rejected", + "links-in-this-post": "Links in this post" } \ No newline at end of file diff --git a/public/openapi/components/schemas/UserObject.yaml b/public/openapi/components/schemas/UserObject.yaml index 9b217cee8b..b258ea123d 100644 --- a/public/openapi/components/schemas/UserObject.yaml +++ b/public/openapi/components/schemas/UserObject.yaml @@ -331,6 +331,12 @@ UserObjectFull: example: - administrators - Staff + iconBackgrounds: + type: array + items: + type: string + description: A valid CSS colour code + example: '#fff' muted: type: boolean description: Whether or not the user has been muted. diff --git a/public/openapi/read.yaml b/public/openapi/read.yaml index 1e1b721d6f..7c4e1f9e97 100644 --- a/public/openapi/read.yaml +++ b/public/openapi/read.yaml @@ -168,6 +168,8 @@ paths: $ref: 'read/admin/upload/file.yaml' /api/admin/uploadDefaultAvatar: $ref: 'read/admin/uploadDefaultAvatar.yaml' + /api/admin/config: + $ref: 'read/admin/config.yaml' /api/config: $ref: 'read/config.yaml' /api/users: diff --git a/public/openapi/read/admin/config.yaml b/public/openapi/read/admin/config.yaml new file mode 100644 index 0000000000..73105169dc --- /dev/null +++ b/public/openapi/read/admin/config.yaml @@ -0,0 +1,174 @@ +get: + tags: + - admin + summary: Get forum settings and admin only settings + description: This route retrieves forum settings and user-specific settings for client-side and admin-side options on the forum. + responses: + "200": + description: "" + content: + application/json: + schema: + type: object + properties: + relative_path: + type: string + upload_url: + type: string + assetBaseUrl: + type: string + asset_base_url: + type: string + siteTitle: + type: string + browserTitle: + type: string + titleLayout: + type: string + showSiteTitle: + type: boolean + maintenanceMode: + type: boolean + postQueue: + type: number + minimumTitleLength: + type: number + maximumTitleLength: + type: number + minimumPostLength: + type: number + maximumPostLength: + type: number + minimumTagsPerTopic: + type: number + maximumTagsPerTopic: + type: number + minimumTagLength: + type: number + undoTimeout: + type: number + maximumTagLength: + type: number + useOutgoingLinksPage: + type: boolean + allowGuestHandles: + type: boolean + allowTopicsThumbnail: + type: boolean + usePagination: + type: boolean + disableChat: + type: boolean + disableChatMessageEditing: + type: boolean + maximumChatMessageLength: + type: number + socketioTransports: + type: array + items: + type: string + socketioOrigins: + type: string + websocketAddress: + type: string + maxReconnectionAttempts: + type: number + reconnectionDelay: + type: number + topicsPerPage: + type: number + postsPerPage: + type: number + maximumFileSize: + type: number + theme:id: + type: string + theme:src: + type: string + defaultLang: + type: string + userLang: + type: string + loggedIn: + type: boolean + uid: + type: number + description: A user identifier + cache-buster: + type: string + topicPostSort: + type: string + categoryTopicSort: + type: string + csrf_token: + type: string + searchEnabled: + type: boolean + searchDefaultInQuick: + type: string + disableCustomUserSkins: + type: boolean + bootswatchSkin: + type: string + defaultBootswatchSkin: + type: string + composer:showHelpTab: + type: boolean + enablePostHistory: + type: boolean + timeagoCutoff: + type: number + timeagoCodes: + type: array + items: + type: string + cookies: + type: object + properties: + enabled: + type: boolean + message: + type: string + dismiss: + type: string + link: + type: string + link_url: + type: string + thumbs: + type: object + properties: + size: + type: number + acpLang: + type: string + openOutgoingLinksInNewTab: + type: boolean + topicSearchEnabled: + type: boolean + hideSubCategories: + type: boolean + hideCategoryLastPost: + type: boolean + enableQuickReply: + type: boolean + emailPrompt: + type: number + useragent: + type: object + properties: + isSafari: + type: boolean + composer-default: + type: object + fontawesome: + type: object + properties: + pro: + type: boolean + styles: + type: array + items: + type: string + version: + type: string diff --git a/public/openapi/read/admin/development/info.yaml b/public/openapi/read/admin/development/info.yaml index 493958b564..81b9e3f49e 100644 --- a/public/openapi/read/admin/development/info.yaml +++ b/public/openapi/read/admin/development/info.yaml @@ -116,6 +116,8 @@ get: type: number socketCount: type: number + connectionCount: + type: number users: type: object properties: diff --git a/public/openapi/read/config.yaml b/public/openapi/read/config.yaml index ceb99c0d58..5e6f05d91c 100644 --- a/public/openapi/read/config.yaml +++ b/public/openapi/read/config.yaml @@ -152,129 +152,13 @@ get: type: boolean enableQuickReply: type: boolean - iconBackgrounds: - type: array - items: - type: string - description: A valid CSS colour code - example: '#fff' emailPrompt: type: number useragent: type: object properties: - isYaBrowser: - type: boolean - isAuthoritative: - type: boolean - isMobile: - type: boolean - isMobileNative: - type: boolean - isTablet: - type: boolean - isiPad: - type: boolean - isiPod: - type: boolean - isiPhone: - type: boolean - isiPhoneNative: - type: boolean - isAndroid: - type: boolean - isAndroidNative: - type: boolean - isBlackberry: - type: boolean - isOpera: - type: boolean - isIE: - type: boolean - isEdge: - type: boolean - isIECompatibilityMode: - type: boolean isSafari: type: boolean - isFirefox: - type: boolean - isWebkit: - type: boolean - isChrome: - type: boolean - isKonqueror: - type: boolean - isOmniWeb: - type: boolean - isSeaMonkey: - type: boolean - isFlock: - type: boolean - isAmaya: - type: boolean - isPhantomJS: - type: boolean - isEpiphany: - type: boolean - isDesktop: - type: boolean - isWindows: - type: boolean - isLinux: - type: boolean - isLinux64: - type: boolean - isMac: - type: boolean - isChromeOS: - type: boolean - isBada: - type: boolean - isSamsung: - type: boolean - isRaspberry: - type: boolean - isBot: - type: boolean - isCurl: - type: boolean - isAndroidTablet: - type: boolean - isWinJs: - type: boolean - isKindleFire: - type: boolean - isSilk: - type: boolean - isCaptive: - type: boolean - isSmartTV: - type: boolean - isUC: - type: boolean - isFacebook: - type: boolean - isAlamoFire: - type: boolean - isElectron: - type: boolean - silkAccelerated: - type: boolean - browser: - type: string - version: - type: string - os: - type: string - platform: - type: string - geoIp: - type: object - source: - type: string - isWechat: - type: boolean composer-default: type: object fontawesome: diff --git a/public/openapi/read/topic/topic_id.yaml b/public/openapi/read/topic/topic_id.yaml index 20ef21a031..366ff32675 100644 --- a/public/openapi/read/topic/topic_id.yaml +++ b/public/openapi/read/topic/topic_id.yaml @@ -382,6 +382,8 @@ get: type: number downvote:disabled: type: number + voteVisibility: + type: string feeds:disableRSS: type: number signatures:hideDuplicates: diff --git a/public/openapi/write.yaml b/public/openapi/write.yaml index 79657b519e..c59b9bce29 100644 --- a/public/openapi/write.yaml +++ b/public/openapi/write.yaml @@ -180,6 +180,10 @@ paths: $ref: 'write/posts/pid/move.yaml' /posts/{pid}/vote: $ref: 'write/posts/pid/vote.yaml' + /posts/{pid}/voters: + $ref: 'write/posts/pid/voters.yaml' + /posts/{pid}/upvoters: + $ref: 'write/posts/pid/upvoters.yaml' /posts/{pid}/bookmark: $ref: 'write/posts/pid/bookmark.yaml' /posts/{pid}/diffs: diff --git a/public/openapi/write/posts/pid/upvoters.yaml b/public/openapi/write/posts/pid/upvoters.yaml new file mode 100644 index 0000000000..d005e33529 --- /dev/null +++ b/public/openapi/write/posts/pid/upvoters.yaml @@ -0,0 +1,33 @@ +get: + tags: + - posts + summary: get upvoter usernames of a post + description: This is used for getting a list of upvoter usernames for the vote tooltip + parameters: + - in: path + name: pid + schema: + type: string + required: true + description: a valid post id + example: 2 + responses: + '200': + description: Usernames of upvoters of post + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: + otherCount: + type: number + usernames: + type: array + cutoff: + type: number + diff --git a/public/openapi/write/posts/pid/voters.yaml b/public/openapi/write/posts/pid/voters.yaml new file mode 100644 index 0000000000..868b587c36 --- /dev/null +++ b/public/openapi/write/posts/pid/voters.yaml @@ -0,0 +1,37 @@ +get: + tags: + - posts + summary: get voters of a post + description: This returns the upvoters and downvoters of a post if the user has permission to view them + parameters: + - in: path + name: pid + schema: + type: string + required: true + description: a valid post id + example: 2 + responses: + '200': + description: Data about upvoters and downvoters of the post + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: + upvoteCount: + type: number + downvoteCount: + type: number + showDownvotes: + type: boolean + upvoters: + type: array + downvoters: + type: array + diff --git a/public/scss/global.scss b/public/scss/global.scss index 394eec646e..ab7d569242 100644 --- a/public/scss/global.scss +++ b/public/scss/global.scss @@ -20,3 +20,20 @@ html[data-dir="rtl"] { } } /*rtl:end:ignore*/ + +[component="post/content"], [component="chat/message/body"], [component="composer"] .preview { + h1 { font-size: calc(1.15rem + 1vw); } + h2 { font-size: calc(1.1rem + 0.8vw); } + h3 { font-size: calc(1.075rem + 0.6vw); } + h4 { font-size: calc(1.05rem + 0.3vw); } + h5 { font-size: 1.125rem; } + h6 { font-size: 1rem; } + @include media-breakpoint-up(xl) { + h1 { font-size: 1.75rem; } + h2 { font-size: 1.5rem; } + h3 { font-size: 1.375rem; } + h4 { font-size: 1.250rem; } + h5 { font-size: 1.125rem; } + h6 { font-size: 1rem; } + } +} \ No newline at end of file diff --git a/public/src/ajaxify.js b/public/src/ajaxify.js index d6748dca1b..e6c4648dad 100644 --- a/public/src/ajaxify.js +++ b/public/src/ajaxify.js @@ -25,7 +25,7 @@ ajaxify.widgets = { render: render }; } ajaxify.go = function (url, callback, quiet) { // Automatically reconnect to socket and re-ajaxify on success - if (!socket.connected) { + if (!socket.connected && parseInt(app.user.uid, 10) >= 0) { app.reconnect(); if (ajaxify.reconnectAction) { diff --git a/public/src/client/chats/recent.js b/public/src/client/chats/recent.js index b9b7e1b678..24fa82f951 100644 --- a/public/src/client/chats/recent.js +++ b/public/src/client/chats/recent.js @@ -37,7 +37,7 @@ define('forum/chats/recent', ['alerts', 'api', 'chat'], function (alerts, api, c recentChats.attr('loading', 1); api.get(`/chats`, { uid: ajaxify.data.uid, - after: recentChats.attr('data-nextstart'), + start: recentChats.attr('data-nextstart'), }).then(({ rooms, nextStart }) => { if (rooms.length) { onRecentChatsLoaded({ rooms, nextStart }, function () { diff --git a/public/src/client/post-queue.js b/public/src/client/post-queue.js index d7cfb9f230..ff5fa931d7 100644 --- a/public/src/client/post-queue.js +++ b/public/src/client/post-queue.js @@ -29,8 +29,23 @@ define('forum/post-queue', [ }); $('[component="post/content"] img:not(.not-responsive)').addClass('img-fluid'); + showLinksInPosts(); }; + function showLinksInPosts() { + $('.posts-list [data-id]').each((idx, el) => { + const $el = $(el); + const linkContainer = $el.find('[component="post-queue/link-container"]'); + const linkList = linkContainer.find('[component="post-queue/link-container/list"]'); + const linksInPost = $el.find('.post-content a'); + linksInPost.each((idx, link) => { + const href = $(link).attr('href'); + linkList.append(`
  • ${href}
  • `); + }); + linkContainer.toggleClass('hidden', !linksInPost.length); + }); + } + function confirmReject(msg) { return new Promise((resolve) => { bootbox.confirm(msg, resolve); diff --git a/public/src/client/topic.js b/public/src/client/topic.js index b4dd497dc3..3e7ffa7ee9 100644 --- a/public/src/client/topic.js +++ b/public/src/client/topic.js @@ -297,7 +297,7 @@ define('forum/topic', [ destroyed = true; } $(window).one('action:ajaxify.start', destroyTooltip); - $('[component="topic"]').on('mouseenter', '[component="post"] a, [component="topic/event"] a', async function () { + $('[component="topic"]').on('mouseenter', '[component="post/parent"] a, [component="post/content"] a, [component="topic/event"] a', async function () { const link = $(this); destroyed = false; diff --git a/public/src/client/topic/postTools.js b/public/src/client/topic/postTools.js index 571361734f..f8d2ca8933 100644 --- a/public/src/client/topic/postTools.js +++ b/public/src/client/topic/postTools.js @@ -36,7 +36,7 @@ define('forum/topic/postTools', [ if (!container) { return; } - $('[component="topic"]').on('show.bs.dropdown', '.moderator-tools', function () { + $('[component="topic"]').on('show.bs.dropdown', '[component="post/tools"]', function () { const $this = $(this); const dropdownMenu = $this.find('.dropdown-menu'); const { top } = this.getBoundingClientRect(); @@ -45,6 +45,10 @@ define('forum/topic/postTools', [ if (dropdownMenu.attr('data-loaded')) { return; } + dropdownMenu.html(helpers.generatePlaceholderWave([ + 3, 5, 9, 7, 10, 'divider', 10, + ])); + const postEl = $this.parents('[data-pid]'); const pid = postEl.attr('data-pid'); const index = parseInt(postEl.attr('data-index'), 10); diff --git a/public/src/client/topic/replies.js b/public/src/client/topic/replies.js index a70862c119..0fb2da8a38 100644 --- a/public/src/client/topic/replies.js +++ b/public/src/client/topic/replies.js @@ -8,21 +8,22 @@ define('forum/topic/replies', ['forum/topic/posts', 'hooks', 'alerts', 'api'], f const post = button.closest('[data-pid]'); const pid = post.data('pid'); const open = button.find('[component="post/replies/open"]'); - const loading = button.find('[component="post/replies/loading"]'); - const close = button.find('[component="post/replies/close"]'); - if (open.is(':not(.hidden)') && loading.is('.hidden')) { - open.addClass('hidden'); - loading.removeClass('hidden'); + if (open.attr('loading') !== '1' && open.attr('loaded') !== '1') { + open.attr('loading', '1') + .removeClass('fa-chevron-down') + .addClass('fa-spin fa-spinner'); + api.get(`/posts/${pid}/replies`, {}, function (err, { replies }) { const postData = replies; - loading.addClass('hidden'); + open.removeAttr('loading') + .attr('loaded', '1') + .removeClass('fa-spin fa-spinner') + .addClass('fa-chevron-up'); if (err) { - open.removeClass('hidden'); return alerts.error(err); } - close.removeClass('hidden'); postData.forEach((post, index) => { if (post) { post.index = index; @@ -50,10 +51,11 @@ define('forum/topic/replies', ['forum/topic/posts', 'hooks', 'alerts', 'api'], f hooks.fire('action:posts.loaded', { posts: postData }); }); }); - } else if (close.is(':not(.hidden)')) { - close.addClass('hidden'); - open.removeClass('hidden'); - loading.addClass('hidden'); + } else if (open.attr('loaded') === '1') { + open.removeAttr('loaded') + .removeAttr('loading') + .removeClass('fa-spin fa-spinner fa-chevron-up') + .addClass('fa-chevron-down'); post.find('[component="post/replies"]').slideUp('fast', function () { $(this).remove(); }); diff --git a/public/src/client/topic/threadTools.js b/public/src/client/topic/threadTools.js index 60dfd8b766..a66b293a73 100644 --- a/public/src/client/topic/threadTools.js +++ b/public/src/client/topic/threadTools.js @@ -11,7 +11,8 @@ define('forum/topic/threadTools', [ 'bootbox', 'alerts', 'bootstrap', -], function (components, translator, handleBack, posts, api, hooks, bootbox, alerts, bootstrap) { + 'helpers', +], function (components, translator, handleBack, posts, api, hooks, bootbox, alerts, bootstrap, helpers) { const ThreadTools = {}; ThreadTools.init = function (tid, topicContainer) { @@ -211,6 +212,7 @@ define('forum/topic/threadTools', [ if (dropdownMenu.attr('data-loaded')) { return; } + dropdownMenu.html(helpers.generatePlaceholderWave([8, 8, 8])); const data = await socket.emit('topics.loadTopicTools', { tid: ajaxify.data.tid, cid: ajaxify.data.cid }); const html = await app.parseAndTranslate('partials/topic/topic-menu-list', data); $(dropdownMenu).attr('data-loaded', 'true').html(html); diff --git a/public/src/client/topic/votes.js b/public/src/client/topic/votes.js index b4365697ed..d78d197f27 100644 --- a/public/src/client/topic/votes.js +++ b/public/src/client/topic/votes.js @@ -9,10 +9,19 @@ define('forum/topic/votes', [ Votes.addVoteHandler = function () { _showTooltip = {}; - components.get('topic').on('mouseenter', '[data-pid] [component="post/vote-count"]', loadDataAndCreateTooltip); - components.get('topic').on('mouseleave', '[data-pid] [component="post/vote-count"]', destroyTooltip); + if (canSeeVotes()) { + components.get('topic').on('mouseenter', '[data-pid] [component="post/vote-count"]', loadDataAndCreateTooltip); + components.get('topic').on('mouseleave', '[data-pid] [component="post/vote-count"]', destroyTooltip); + } }; + function canSeeVotes() { + const { voteVisibility, privileges } = ajaxify.data; + return privileges.isAdminOrMod || + voteVisibility === 'all' || + (voteVisibility === 'loggedin' && config.loggedIn); + } + function destroyTooltip() { const $this = $(this); const pid = $this.parents('[data-pid]').attr('data-pid'); @@ -35,15 +44,12 @@ define('forum/topic/votes', [ $this.attr('title', ''); } - socket.emit('posts.getUpvoters', [pid], function (err, data) { + api.get(`/posts/${pid}/upvoters`, {}, function (err, data) { if (err) { - if (err.message === '[[error:no-privileges]]') { - return; - } return alerts.error(err); } - if (_showTooltip[pid] && data.length) { - createTooltip($this, data[0]); + if (_showTooltip[pid] && data) { + createTooltip($this, data); } }); } @@ -101,13 +107,11 @@ define('forum/topic/votes', [ }; Votes.showVotes = function (pid) { - socket.emit('posts.getVoters', { pid: pid }, function (err, data) { + if (!canSeeVotes()) { + return; + } + api.get(`/posts/${pid}/voters`, {}, function (err, data) { if (err) { - if (err.message === '[[error:no-privileges]]') { - return; - } - - // Only show error if it's an unexpected error. return alerts.error(err); } diff --git a/public/src/modules/accounts/picture.js b/public/src/modules/accounts/picture.js index d8ab277f24..ddd3005453 100644 --- a/public/src/modules/accounts/picture.js +++ b/public/src/modules/accounts/picture.js @@ -27,7 +27,7 @@ define('accounts/picture', [ icon: { text: ajaxify.data['icon:text'], bgColor: ajaxify.data['icon:bgColor'] }, defaultAvatar: ajaxify.data.defaultAvatar, allowProfileImageUploads: ajaxify.data.allowProfileImageUploads, - iconBackgrounds: config.iconBackgrounds, + iconBackgrounds: ajaxify.data.iconBackgrounds, user: { uid: ajaxify.data.uid, username: ajaxify.data.username, diff --git a/public/src/modules/helpers.common.js b/public/src/modules/helpers.common.js index 6c17927918..c5533cf56b 100644 --- a/public/src/modules/helpers.common.js +++ b/public/src/modules/helpers.common.js @@ -25,12 +25,14 @@ module.exports = function (utils, Benchpress, relative_path) { userAgentIcons, buildAvatar, increment, + generateWroteReplied, generateRepliedTo, generateWrote, isoTimeToLocaleString, shouldHideReplyContainer, humanReadableNumber, formattedNumber, + generatePlaceholderWave, register, __escape: identity, }; @@ -295,34 +297,24 @@ module.exports = function (utils, Benchpress, relative_path) { if (!userObj) { userObj = this; } - + classNames = classNames || ''; const attributes = new Map([ - ['alt', userObj.username], ['title', userObj.username], ['data-uid', userObj.uid], - ['loading', 'lazy'], - ['aria-label', `[[aria:user-avatar-for, ${userObj.username}]]`], + ['class', `avatar ${classNames}${rounded ? ' avatar-rounded' : ''}`], ]); const styles = [`--avatar-size: ${size};`]; const attr2String = attributes => Array.from(attributes).reduce((output, [prop, value]) => { output += ` ${prop}="${value}"`; return output; }, ''); - classNames = classNames || ''; - - attributes.set('class', `avatar ${classNames}${rounded ? ' avatar-rounded' : ''}`); let output = ''; if (userObj.picture) { - attributes.set('component', component || 'avatar/picture'); - output += ''; + output += ``; } - - attributes.set('component', component || 'avatar/icon'); - styles.push('background-color: ' + userObj['icon:bgColor'] + ';'); - output += '' + userObj['icon:text'] + ''; - + output += `${userObj['icon:text']}`; return output; } @@ -330,6 +322,13 @@ module.exports = function (utils, Benchpress, relative_path) { return String(value + parseInt(inc, 10)); } + function generateWroteReplied(post, timeagoCutoff) { + if (post.toPid) { + return generateRepliedTo(post, timeagoCutoff); + } + return generateWrote(post, timeagoCutoff); + } + function generateRepliedTo(post, timeagoCutoff) { const displayname = post.parent && post.parent.displayname ? post.parent.displayname : '[[global:guest]]'; @@ -367,6 +366,21 @@ module.exports = function (utils, Benchpress, relative_path) { return utils.addCommas(number); } + function generatePlaceholderWave(items) { + const html = items.map((i) => { + if (i === 'divider') { + return ''; + } + return ` + `; + }); + + return html; + } + function register() { Object.keys(helpers).forEach(function (helperName) { Benchpress.registerHelper(helperName, helpers[helperName]); diff --git a/public/src/modules/navigator.js b/public/src/modules/navigator.js index 5111b47e95..8c557985c3 100644 --- a/public/src/modules/navigator.js +++ b/public/src/modules/navigator.js @@ -183,7 +183,10 @@ define('navigator', [ async function updateThumbTimestampToIndex(thumb, index) { const el = thumb.find('.thumb-timestamp'); if (el.length) { - const timestamp = await getPostTimestampByIndex(index); + const postAtIndex = ajaxify.data.posts.find( + p => parseInt(p.index, 10) === Math.max(0, parseInt(index, 10) - 1) + ); + const timestamp = postAtIndex ? postAtIndex.timestamp : await getPostTimestampByIndex(index); el.attr('title', utils.toISOString(timestamp)).timeago(); } } @@ -450,7 +453,6 @@ define('navigator', [ } count = value; navigator.updateTextAndProgressBar(); - setThumbToIndex(index); toggle(count > 0); }; diff --git a/public/src/modules/quickreply.js b/public/src/modules/quickreply.js index 99be979319..55aea0b769 100644 --- a/public/src/modules/quickreply.js +++ b/public/src/modules/quickreply.js @@ -115,7 +115,7 @@ define('quickreply', [ const textEl = components.get('topic/quickreply/text'); composer.newReply({ tid: ajaxify.data.tid, - title: ajaxify.data.title, + title: ajaxify.data.titleRaw, body: textEl.val(), }); textEl.val(''); diff --git a/public/src/sockets.js b/public/src/sockets.js index e4ef8273e1..799dc038cd 100644 --- a/public/src/sockets.js +++ b/public/src/sockets.js @@ -16,6 +16,7 @@ app = window.app || {}; reconnectionAttempts: config.maxReconnectionAttempts, reconnectionDelay: config.reconnectionDelay, transports: config.socketioTransports, + autoConnect: false, path: config.relative_path + '/socket.io', query: { _csrf: config.csrf_token, @@ -48,11 +49,12 @@ app = window.app || {}; hooks = _hooks; if (parseInt(app.user.uid, 10) >= 0) { addHandlers(); + socket.connect(); } }); window.app.reconnect = () => { - if (socket.connected) { + if (socket.connected || parseInt(app.user.uid, 10) < 0) { return; } diff --git a/src/api/posts.js b/src/api/posts.js index 603e3bf2aa..2bf4a7c3d2 100644 --- a/src/api/posts.js +++ b/src/api/posts.js @@ -3,9 +3,11 @@ const validator = require('validator'); const _ = require('lodash'); +const db = require('../database'); const utils = require('../utils'); const user = require('../user'); const posts = require('../posts'); +const postsCache = require('../posts/cache'); const topics = require('../topics'); const groups = require('../groups'); const plugins = require('../plugins'); @@ -224,7 +226,7 @@ postsAPI.purge = async function (caller, data) { if (!canPurge) { throw new Error('[[error:no-privileges]]'); } - require('../posts/cache').del(data.pid); + postsCache.del(data.pid); await posts.purge(data.pid, caller.uid); websockets.in(`topic_${postData.tid}`).emit('event:post_purged', postData); @@ -306,6 +308,95 @@ postsAPI.unvote = async function (caller, data) { return await apiHelpers.postCommand(caller, 'unvote', 'voted', '', data); }; +postsAPI.getVoters = async function (caller, data) { + if (!data || !data.pid) { + throw new Error('[[error:invalid-data]]'); + } + const { pid } = data; + const cid = await posts.getCidByPid(pid); + if (!await canSeeVotes(caller.uid, cid)) { + throw new Error('[[error:no-privileges]]'); + } + const showDownvotes = !meta.config['downvote:disabled']; + const [upvoteUids, downvoteUids] = await Promise.all([ + db.getSetMembers(`pid:${data.pid}:upvote`), + showDownvotes ? db.getSetMembers(`pid:${data.pid}:downvote`) : [], + ]); + + const [upvoters, downvoters] = await Promise.all([ + user.getUsersFields(upvoteUids, ['username', 'userslug', 'picture']), + user.getUsersFields(downvoteUids, ['username', 'userslug', 'picture']), + ]); + + return { + upvoteCount: upvoters.length, + downvoteCount: downvoters.length, + showDownvotes: showDownvotes, + upvoters: upvoters, + downvoters: downvoters, + }; +}; + +postsAPI.getUpvoters = async function (caller, data) { + if (!data.pid) { + throw new Error('[[error:invalid-data]]'); + } + const { pid } = data; + const cid = await posts.getCidByPid(pid); + if (!await canSeeVotes(caller.uid, cid)) { + throw new Error('[[error:no-privileges]]'); + } + + let upvotedUids = (await posts.getUpvotedUidsByPids([pid]))[0]; + const cutoff = 6; + if (!upvotedUids.length) { + return { + otherCount: 0, + usernames: [], + cutoff, + }; + } + let otherCount = 0; + if (upvotedUids.length > cutoff) { + otherCount = upvotedUids.length - (cutoff - 1); + upvotedUids = upvotedUids.slice(0, cutoff - 1); + } + + const usernames = await user.getUsernamesByUids(upvotedUids); + return { + otherCount, + usernames, + cutoff, + }; +}; + +async function canSeeVotes(uid, cids) { + const isArray = Array.isArray(cids); + if (!isArray) { + cids = [cids]; + } + const uniqCids = _.uniq(cids); + const [canRead, isAdmin, isMod] = await Promise.all([ + privileges.categories.isUserAllowedTo( + 'topics:read', uniqCids, uid + ), + privileges.users.isAdministrator(uid), + privileges.users.isModerator(uid, cids), + ]); + const cidToAllowed = _.zipObject(uniqCids, canRead); + const checks = cids.map( + (cid, index) => isAdmin || isMod[index] || + ( + cidToAllowed[cid] && + ( + meta.config.voteVisibility === 'all' || + (meta.config.voteVisibility === 'loggedin' && parseInt(uid, 10) > 0) + ) + ) + ); + return isArray ? checks : checks[0]; +} + postsAPI.bookmark = async function (caller, data) { return await apiHelpers.postCommand(caller, 'bookmark', 'bookmarked', '', data); }; diff --git a/src/api/users.js b/src/api/users.js index 931e75b36b..c4f4add772 100644 --- a/src/api/users.js +++ b/src/api/users.js @@ -656,7 +656,7 @@ usersAPI.changePicture = async (caller, data) => { picture = returnData && returnData.picture; } - const validBackgrounds = await user.getIconBackgrounds(caller.uid); + const validBackgrounds = await user.getIconBackgrounds(); if (!validBackgrounds.includes(data.bgColor)) { data.bgColor = validBackgrounds[0]; } diff --git a/src/controllers/accounts/blocks.js b/src/controllers/accounts/blocks.js index a3f6cba3ff..185d922970 100644 --- a/src/controllers/accounts/blocks.js +++ b/src/controllers/accounts/blocks.js @@ -12,9 +12,9 @@ blocksController.getBlocks = async function (req, res) { const resultsPerPage = 50; const start = Math.max(0, page - 1) * resultsPerPage; const stop = start + resultsPerPage - 1; + const payload = res.locals.userData; + const { uid, username, userslug, blocksCount } = payload; - const { uid, username, userslug, blocksCount } = await user.getUserFields(res.locals.uid, ['uid', 'username', 'userslug', 'blocksCount']); - const payload = {}; const uids = await user.blocks.list(uid); const data = await plugins.hooks.fire('filter:user.getBlocks', { uids: uids, diff --git a/src/controllers/accounts/categories.js b/src/controllers/accounts/categories.js index 4dfaf95f31..04222c1468 100644 --- a/src/controllers/accounts/categories.js +++ b/src/controllers/accounts/categories.js @@ -9,7 +9,8 @@ const meta = require('../../meta'); const categoriesController = module.exports; categoriesController.get = async function (req, res) { - const { username, userslug } = await user.getUserFields(res.locals.uid, ['username', 'userslug']); + const payload = res.locals.userData; + const { username, userslug } = payload; const [states, allCategoriesData] = await Promise.all([ user.getCategoryWatchState(res.locals.uid), categories.buildForSelect(res.locals.uid, 'find', ['descriptionParsed', 'depth', 'slug']), @@ -31,7 +32,6 @@ categoriesController.get = async function (req, res) { } }); - const payload = {}; payload.categories = categoriesData; payload.title = `[[pages:account/watched-categories, ${username}]]`; payload.breadcrumbs = helpers.buildBreadcrumbs([ diff --git a/src/controllers/accounts/consent.js b/src/controllers/accounts/consent.js index c4ec132a11..ecd8915bd7 100644 --- a/src/controllers/accounts/consent.js +++ b/src/controllers/accounts/consent.js @@ -2,7 +2,6 @@ const db = require('../../database'); const meta = require('../../meta'); -const user = require('../../user'); const helpers = require('../helpers'); const consentController = module.exports; @@ -11,11 +10,10 @@ consentController.get = async function (req, res, next) { if (!meta.config.gdpr_enabled) { return next(); } - - const { username, userslug } = await user.getUserFields(res.locals.uid, ['username', 'userslug']); + const payload = res.locals.userData; + const { username, userslug } = payload; const consented = await db.getObjectField(`user:${res.locals.uid}`, 'gdpr_consent'); - const payload = {}; payload.gdpr_consent = parseInt(consented, 10) === 1; payload.digest = { frequency: meta.config.dailyDigestFreq || 'off', diff --git a/src/controllers/accounts/edit.js b/src/controllers/accounts/edit.js index f3b8ce58ec..599f898c24 100644 --- a/src/controllers/accounts/edit.js +++ b/src/controllers/accounts/edit.js @@ -6,13 +6,16 @@ const helpers = require('../helpers'); const groups = require('../../groups'); const privileges = require('../../privileges'); const plugins = require('../../plugins'); -const accountHelpers = require('./helpers'); const file = require('../../file'); const editController = module.exports; -editController.get = async function (req, res) { - const [{ +editController.get = async function (req, res, next) { + const { userData } = res.locals; + if (!userData) { + return next(); + } + const { username, userslug, isSelf, @@ -20,36 +23,36 @@ editController.get = async function (req, res) { groups: _groups, groupTitleArray, allowMultipleBadges, - }, canUseSignature, canManageUsers] = await Promise.all([ - accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, req.query), + } = userData; + + const [canUseSignature, canManageUsers] = await Promise.all([ privileges.global.can('signature', req.uid), privileges.admin.can('admin:users', req.uid), ]); - const payload = {}; - payload.maximumSignatureLength = meta.config.maximumSignatureLength; - payload.maximumAboutMeLength = meta.config.maximumAboutMeLength; - payload.maximumProfileImageSize = meta.config.maximumProfileImageSize; - payload.allowMultipleBadges = meta.config.allowMultipleBadges === 1; - payload.allowAccountDelete = meta.config.allowAccountDelete === 1; - payload.allowWebsite = !isSelf || !!meta.config['reputation:disabled'] || reputation >= meta.config['min:rep:website']; - payload.allowAboutMe = !isSelf || !!meta.config['reputation:disabled'] || reputation >= meta.config['min:rep:aboutme']; - payload.allowSignature = canUseSignature && (!isSelf || !!meta.config['reputation:disabled'] || reputation >= meta.config['min:rep:signature']); - payload.profileImageDimension = meta.config.profileImageDimension; - payload.defaultAvatar = user.getDefaultAvatar(); + userData.maximumSignatureLength = meta.config.maximumSignatureLength; + userData.maximumAboutMeLength = meta.config.maximumAboutMeLength; + userData.maximumProfileImageSize = meta.config.maximumProfileImageSize; + userData.allowMultipleBadges = meta.config.allowMultipleBadges === 1; + userData.allowAccountDelete = meta.config.allowAccountDelete === 1; + userData.allowWebsite = !isSelf || !!meta.config['reputation:disabled'] || reputation >= meta.config['min:rep:website']; + userData.allowAboutMe = !isSelf || !!meta.config['reputation:disabled'] || reputation >= meta.config['min:rep:aboutme']; + userData.allowSignature = canUseSignature && (!isSelf || !!meta.config['reputation:disabled'] || reputation >= meta.config['min:rep:signature']); + userData.profileImageDimension = meta.config.profileImageDimension; + userData.defaultAvatar = user.getDefaultAvatar(); - payload.groups = _groups.filter(g => g && g.userTitleEnabled && !groups.isPrivilegeGroup(g.name) && g.name !== 'registered-users'); + userData.groups = _groups.filter(g => g && g.userTitleEnabled && !groups.isPrivilegeGroup(g.name) && g.name !== 'registered-users'); if (req.uid === res.locals.uid || canManageUsers) { const { associations } = await plugins.hooks.fire('filter:auth.list', { uid: res.locals.uid, associations: [] }); - payload.sso = associations; + userData.sso = associations; } if (!allowMultipleBadges) { - payload.groupTitle = groupTitleArray[0]; + userData.groupTitle = groupTitleArray[0]; } - payload.groups.sort((a, b) => { + userData.groups.sort((a, b) => { const i1 = groupTitleArray.indexOf(a.name); const i2 = groupTitleArray.indexOf(b.name); if (i1 === -1) { @@ -59,14 +62,14 @@ editController.get = async function (req, res) { } return i1 - i2; }); - payload.groups.forEach((group) => { + userData.groups.forEach((group) => { group.userTitle = group.userTitle || group.displayName; group.selected = groupTitleArray.includes(group.name); }); - payload.groupSelectSize = Math.min(10, Math.max(5, payload.groups.length + 1)); + userData.groupSelectSize = Math.min(10, Math.max(5, userData.groups.length + 1)); - payload.title = `[[pages:account/edit, ${username}]]`; - payload.breadcrumbs = helpers.buildBreadcrumbs([ + userData.title = `[[pages:account/edit, ${username}]]`; + userData.breadcrumbs = helpers.buildBreadcrumbs([ { text: username, url: `/user/${userslug}`, @@ -75,9 +78,9 @@ editController.get = async function (req, res) { text: '[[user:edit]]', }, ]); - payload.editButtons = []; + userData.editButtons = []; - res.render('account/edit', payload); + res.render('account/edit', userData); }; editController.password = async function (req, res, next) { @@ -102,6 +105,7 @@ editController.email = async function (req, res, next) { }; async function renderRoute(name, req, res) { + const { userData } = res.locals; const [isAdmin, { username, userslug }, hasPassword] = await Promise.all([ privileges.admin.can('admin:users', req.uid), user.getUserFields(res.locals.uid, ['username', 'userslug']), @@ -112,14 +116,14 @@ async function renderRoute(name, req, res) { return helpers.notAllowed(req, res); } - const payload = { hasPassword }; + userData.hasPassword = hasPassword; if (name === 'password') { - payload.minimumPasswordLength = meta.config.minimumPasswordLength; - payload.minimumPasswordStrength = meta.config.minimumPasswordStrength; + userData.minimumPasswordLength = meta.config.minimumPasswordLength; + userData.minimumPasswordStrength = meta.config.minimumPasswordStrength; } - payload.title = `[[pages:account/edit/${name}, ${username}]]`; - payload.breadcrumbs = helpers.buildBreadcrumbs([ + userData.title = `[[pages:account/edit/${name}, ${username}]]`; + userData.breadcrumbs = helpers.buildBreadcrumbs([ { text: username, url: `/user/${userslug}`, @@ -133,7 +137,7 @@ async function renderRoute(name, req, res) { }, ]); - res.render(`account/edit/${name}`, payload); + res.render(`account/edit/${name}`, userData); } editController.uploadPicture = async function (req, res, next) { diff --git a/src/controllers/accounts/follow.js b/src/controllers/accounts/follow.js index 44ad00d3de..7a28374582 100644 --- a/src/controllers/accounts/follow.js +++ b/src/controllers/accounts/follow.js @@ -14,19 +14,20 @@ followController.getFollowers = async function (req, res, next) { await getFollow('account/followers', 'followers', req, res, next); }; -async function getFollow(tpl, name, req, res) { +async function getFollow(tpl, name, req, res, next) { + const { userData: payload } = res.locals; + if (!payload) { + return next(); + } const { username, userslug, followerCount, followingCount, - } = await user.getUserFields(res.locals.uid, [ - 'username', 'userslug', 'followerCount', 'followingCount', - ]); + } = payload; const page = parseInt(req.query.page, 10) || 1; const resultsPerPage = 50; const start = Math.max(0, page - 1) * resultsPerPage; const stop = start + resultsPerPage - 1; - const payload = {}; payload.title = `[[pages:${tpl}, ${username}]]`; const method = name === 'following' ? 'getFollowing' : 'getFollowers'; diff --git a/src/controllers/accounts/groups.js b/src/controllers/accounts/groups.js index 27c0797afe..3a9d66243e 100644 --- a/src/controllers/accounts/groups.js +++ b/src/controllers/accounts/groups.js @@ -9,7 +9,7 @@ const groupsController = module.exports; groupsController.get = async function (req, res) { const { username, userslug } = await user.getUserFields(res.locals.uid, ['username', 'userslug']); - const payload = {}; + const payload = res.locals.userData; let groupsData = await groups.getUserGroups([res.locals.uid]); groupsData = groupsData[0]; diff --git a/src/controllers/accounts/helpers.js b/src/controllers/accounts/helpers.js index 4d58f18273..a504adb43e 100644 --- a/src/controllers/accounts/helpers.js +++ b/src/controllers/accounts/helpers.js @@ -32,11 +32,7 @@ helpers.getUserDataByUserSlug = async function (userslug, callerUID, query = {}) await parseAboutMe(results.userData); let { userData } = results; - const { userSettings } = results; - const { isAdmin } = results; - const { isGlobalModerator } = results; - const { isModerator } = results; - const { canViewInfo } = results; + const { userSettings, isAdmin, isGlobalModerator, isModerator, canViewInfo } = results; const isSelf = parseInt(callerUID, 10) === parseInt(userData.uid, 10); if (meta.config['reputation:disabled']) { @@ -84,6 +80,7 @@ helpers.getUserDataByUserSlug = async function (userslug, callerUID, query = {}) userData.isFollowing = results.isFollowing; userData.canChat = results.canChat; userData.hasPrivateChat = results.hasPrivateChat; + userData.iconBackgrounds = results.iconBackgrounds; userData.showHidden = results.canEdit; // remove in v1.19.0 userData.allowProfilePicture = !userData.isSelf || !!meta.config['reputation:disabled'] || userData.reputation >= meta.config['min:rep:profile-picture']; userData.allowCoverPicture = !userData.isSelf || !!meta.config['reputation:disabled'] || userData.reputation >= meta.config['min:rep:cover-picture']; @@ -142,12 +139,18 @@ function escape(value) { } async function getAllData(uid, callerUID) { + // loading these before caches them, so the big promiseParallel doesn't make extra db calls + const [[isTargetAdmin, isCallerAdmin], isGlobalModerator] = await Promise.all([ + user.isAdministrator([uid, callerUID]), + user.isGlobalModerator(callerUID), + ]); + return await utils.promiseParallel({ userData: user.getUserData(uid), - isTargetAdmin: user.isAdministrator(uid), + isTargetAdmin: isTargetAdmin, userSettings: user.getSettings(uid), - isAdmin: user.isAdministrator(callerUID), - isGlobalModerator: user.isGlobalModerator(callerUID), + isAdmin: isCallerAdmin, + isGlobalModerator: isGlobalModerator, isModerator: user.isModeratorOfAnyCategory(callerUID), isFollowing: user.isFollowing(callerUID, uid), ips: user.getIPs(uid, 4), @@ -160,6 +163,7 @@ async function getAllData(uid, callerUID) { canViewInfo: privileges.global.can('view:users:info', callerUID), canChat: canChat(callerUID, uid), hasPrivateChat: messaging.hasPrivateChat(callerUID, uid), + iconBackgrounds: user.getIconBackgrounds(), }); } @@ -180,8 +184,8 @@ async function getCounts(userData, callerUID) { const cids = await categories.getCidsByPrivilege('categories:cid', callerUID, 'topics:read'); const promises = { posts: db.sortedSetsCardSum(cids.map(c => `cid:${c}:uid:${uid}:pids`)), - best: Promise.all(cids.map(async c => db.sortedSetCount(`cid:${c}:uid:${uid}:pids:votes`, 1, '+inf'))), - controversial: Promise.all(cids.map(async c => db.sortedSetCount(`cid:${c}:uid:${uid}:pids:votes`, '-inf', -1))), + best: db.sortedSetsCardSum(cids.map(c => `cid:${c}:uid:${uid}:pids:votes`), 1, '+inf'), + controversial: db.sortedSetsCardSum(cids.map(c => `cid:${c}:uid:${uid}:pids:votes`), '-inf', -1), topics: db.sortedSetsCardSum(cids.map(c => `cid:${c}:uid:${uid}:tids`)), }; if (userData.isAdmin || userData.isSelf) { @@ -196,8 +200,6 @@ async function getCounts(userData, callerUID) { promises.blocks = user.getUserField(userData.uid, 'blocksCount'); } const counts = await utils.promiseParallel(promises); - counts.best = counts.best.reduce((sum, count) => sum + count, 0); - counts.controversial = counts.controversial.reduce((sum, count) => sum + count, 0); counts.categoriesWatched = counts.categoriesWatched && counts.categoriesWatched.length; counts.groups = userData.groups.length; counts.following = userData.followingCount; diff --git a/src/controllers/accounts/info.js b/src/controllers/accounts/info.js index b62de6b486..7081acc7df 100644 --- a/src/controllers/accounts/info.js +++ b/src/controllers/accounts/info.js @@ -13,19 +13,17 @@ infoController.get = async function (req, res) { const start = (page - 1) * itemsPerPage; const stop = start + itemsPerPage - 1; - const [{ username, userslug }, isPrivileged] = await Promise.all([ - user.getUserFields(res.locals.uid, ['username', 'userslug']), + const payload = res.locals.userData; + const { username, userslug } = payload; + const [isPrivileged, history, sessions, usernames, emails] = await Promise.all([ user.isPrivileged(req.uid), - ]); - const [history, sessions, usernames, emails, notes] = await Promise.all([ user.getModerationHistory(res.locals.uid), user.auth.getSessions(res.locals.uid, req.sessionID), user.getHistory(`user:${res.locals.uid}:usernames`), user.getHistory(`user:${res.locals.uid}:emails`), - getNotes({ uid: res.locals.uid, isPrivileged }, start, stop), ]); - const payload = {}; + const notes = await getNotes({ uid: res.locals.uid, isPrivileged }, start, stop); payload.history = history; payload.sessions = sessions; diff --git a/src/controllers/accounts/posts.js b/src/controllers/accounts/posts.js index d1881454cb..53ca842cb8 100644 --- a/src/controllers/accounts/posts.js +++ b/src/controllers/accounts/posts.js @@ -177,10 +177,9 @@ async function getPostsFromUserSet(template, req, res) { const data = templateToData[template]; const page = Math.max(1, parseInt(req.query.page, 10) || 1); - const [{ username, userslug }, settings] = await Promise.all([ - user.getUserFields(res.locals.uid, ['username', 'userslug']), - user.getSettings(req.uid), - ]); + const payload = res.locals.userData; + const { username, userslug } = payload; + const settings = await user.getSettings(req.uid); const itemsPerPage = data.type === 'topics' ? settings.topicsPerPage : settings.postsPerPage; const start = (page - 1) * itemsPerPage; @@ -207,7 +206,6 @@ async function getPostsFromUserSet(template, req, res) { } const { itemCount, itemData } = result; - const payload = {}; payload[data.type] = itemData[data.type]; payload.nextStart = itemData.nextStart; diff --git a/src/controllers/accounts/profile.js b/src/controllers/accounts/profile.js index 1ef9756784..9a2c349916 100644 --- a/src/controllers/accounts/profile.js +++ b/src/controllers/accounts/profile.js @@ -1,6 +1,5 @@ 'use strict'; -const nconf = require('nconf'); const _ = require('lodash'); const db = require('../../database'); @@ -9,24 +8,13 @@ const posts = require('../../posts'); const categories = require('../../categories'); const plugins = require('../../plugins'); const privileges = require('../../privileges'); -const accountHelpers = require('./helpers'); const helpers = require('../helpers'); const utils = require('../../utils'); const profileController = module.exports; profileController.get = async function (req, res, next) { - const lowercaseSlug = req.params.userslug.toLowerCase(); - - if (req.params.userslug !== lowercaseSlug) { - if (res.locals.isAPI) { - req.params.userslug = lowercaseSlug; - } else { - return res.redirect(`${nconf.get('relative_path')}/user/${lowercaseSlug}`); - } - } - - const userData = await accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, req.query); + const { userData } = res.locals; if (!userData) { return next(); } diff --git a/src/controllers/accounts/sessions.js b/src/controllers/accounts/sessions.js index 74896346d7..520f466f5b 100644 --- a/src/controllers/accounts/sessions.js +++ b/src/controllers/accounts/sessions.js @@ -6,13 +6,15 @@ const helpers = require('../helpers'); const sessionController = module.exports; sessionController.get = async function (req, res) { - const { username, userslug } = await user.getUserFields(res.locals.uid, ['username', 'userslug']); + const payload = res.locals.userData; + const { username, userslug } = payload; - const payload = { - sessions: await user.auth.getSessions(res.locals.uid, req.sessionID), - title: '[[pages:account/sessions]]', - breadcrumbs: helpers.buildBreadcrumbs([{ text: username, url: `/user/${userslug}` }, { text: '[[pages:account/sessions]]' }]), - }; + payload.sessions = await user.auth.getSessions(res.locals.uid, req.sessionID); + payload.title = '[[pages:account/sessions]]'; + payload.breadcrumbs = helpers.buildBreadcrumbs([ + { text: username, url: `/user/${userslug}` }, + { text: '[[pages:account/sessions]]' }, + ]); res.render('account/sessions', payload); }; diff --git a/src/controllers/accounts/settings.js b/src/controllers/accounts/settings.js index 2f5259822a..6248bc5ddd 100644 --- a/src/controllers/accounts/settings.js +++ b/src/controllers/accounts/settings.js @@ -13,13 +13,12 @@ const plugins = require('../../plugins'); const notifications = require('../../notifications'); const db = require('../../database'); const helpers = require('../helpers'); -const accountHelpers = require('./helpers'); const slugify = require('../../slugify'); const settingsController = module.exports; settingsController.get = async function (req, res, next) { - const userData = await accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, req.query); + const { userData } = res.locals; if (!userData) { return next(); } diff --git a/src/controllers/accounts/tags.js b/src/controllers/accounts/tags.js index 736b67aae0..a4a30404c3 100644 --- a/src/controllers/accounts/tags.js +++ b/src/controllers/accounts/tags.js @@ -1,7 +1,6 @@ 'use strict'; const db = require('../../database'); -const user = require('../../user'); const helpers = require('../helpers'); const tagsController = module.exports; @@ -10,10 +9,10 @@ tagsController.get = async function (req, res) { if (req.uid !== res.locals.uid) { return helpers.notAllowed(req, res); } - const { username, userslug } = await user.getUserFields(res.locals.uid, ['username', 'userslug']); + const payload = res.locals.userData; + const { username, userslug } = payload; const tagData = await db.getSortedSetRange(`uid:${res.locals.uid}:followed_tags`, 0, -1); - const payload = {}; payload.tags = tagData; payload.title = `[[pages:account/watched-tags, ${username}]]`; payload.breadcrumbs = helpers.buildBreadcrumbs([ diff --git a/src/controllers/accounts/uploads.js b/src/controllers/accounts/uploads.js index edfbe23740..b438b472f2 100644 --- a/src/controllers/accounts/uploads.js +++ b/src/controllers/accounts/uploads.js @@ -6,14 +6,14 @@ const nconf = require('nconf'); const db = require('../../database'); const helpers = require('../helpers'); -const user = require('../../user'); const meta = require('../../meta'); const pagination = require('../../pagination'); const uploadsController = module.exports; uploadsController.get = async function (req, res) { - const { username, userslug } = await user.getUserFields(res.locals.uid, ['username', 'userslug']); + const payload = res.locals.userData; + const { username, userslug } = payload; const page = Math.max(1, parseInt(req.query.page, 10) || 1); const itemsPerPage = 25; const start = (page - 1) * itemsPerPage; @@ -23,7 +23,6 @@ uploadsController.get = async function (req, res) { db.getSortedSetRevRange(`uid:${res.locals.uid}:uploads`, start, stop), ]); - const payload = {}; payload.uploads = uploadNames.map(uploadName => ({ name: uploadName, url: path.resolve(nconf.get('upload_url'), uploadName), diff --git a/src/controllers/admin.js b/src/controllers/admin.js index c56c8f2ed8..b167f606e6 100644 --- a/src/controllers/admin.js +++ b/src/controllers/admin.js @@ -1,7 +1,9 @@ 'use strict'; const privileges = require('../privileges'); +const plugins = require('../plugins'); const helpers = require('./helpers'); +const apiController = require('./api'); const adminController = { dashboard: require('./admin/dashboard'), @@ -55,4 +57,15 @@ adminController.routeIndex = async (req, res) => { return helpers.notAllowed(req, res); }; +adminController.loadConfig = async function (req) { + const config = await apiController.loadConfig(req); + await plugins.hooks.fire('filter:config.get.admin', config); + return config; +}; + +adminController.getConfig = async (req, res) => { + const config = await adminController.loadConfig(req); + res.json(config); +}; + module.exports = adminController; diff --git a/src/controllers/admin/cache.js b/src/controllers/admin/cache.js index 43d6f4ddca..2faad03fc2 100644 --- a/src/controllers/admin/cache.js +++ b/src/controllers/admin/cache.js @@ -6,7 +6,7 @@ const utils = require('../../utils'); const plugins = require('../../plugins'); cacheController.get = async function (req, res) { - const postCache = require('../../posts/cache'); + const postCache = require('../../posts/cache').getOrCreate(); const groupCache = require('../../groups').cache; const { objectCache } = require('../../database'); const localCache = require('../../cache'); @@ -46,7 +46,7 @@ cacheController.get = async function (req, res) { cacheController.dump = async function (req, res, next) { let caches = { - post: require('../../posts/cache'), + post: require('../../posts/cache').getOrCreate(), object: require('../../database').objectCache, group: require('../../groups').cache, local: require('../../cache'), diff --git a/src/controllers/api.js b/src/controllers/api.js index 22574a9ce6..a4d1f34291 100644 --- a/src/controllers/api.js +++ b/src/controllers/api.js @@ -88,9 +88,10 @@ apiController.loadConfig = async function (req) { thumbs: { size: meta.config.topicThumbSize, }, - iconBackgrounds: await user.getIconBackgrounds(req.uid), emailPrompt: meta.config.emailPrompt, - useragent: req.useragent, + useragent: { + isSafari: req.useragent.isSafari, + }, fontawesome: { pro: fontawesome_pro, styles: fontawesome_styles, diff --git a/src/controllers/authentication.js b/src/controllers/authentication.js index b3949905d4..6591459cf2 100644 --- a/src/controllers/authentication.js +++ b/src/controllers/authentication.js @@ -342,7 +342,7 @@ authenticationController.doLogin = async function (req, uid) { await authenticationController.onSuccessfulLogin(req, uid); }; -authenticationController.onSuccessfulLogin = async function (req, uid) { +authenticationController.onSuccessfulLogin = async function (req, uid, trackSession = true) { /* * Older code required that this method be called from within the SSO plugin. * That behaviour is no longer required, onSuccessfulLogin is now automatically @@ -380,7 +380,7 @@ authenticationController.onSuccessfulLogin = async function (req, uid) { new Promise((resolve) => { req.session.save(resolve); }), - user.auth.addSession(uid, req.sessionID, uuid), + trackSession ? user.auth.addSession(uid, req.sessionID) : undefined, user.updateLastOnlineTime(uid), user.onUserOnline(uid, Date.now()), analytics.increment('logins'), diff --git a/src/controllers/topics.js b/src/controllers/topics.js index 9ea025e038..6a3eba85c5 100644 --- a/src/controllers/topics.js +++ b/src/controllers/topics.js @@ -31,15 +31,17 @@ topicsController.get = async function getTopic(req, res, next) { return next(); } let postIndex = parseInt(req.params.post_index, 10) || 1; + const topicData = await topics.getTopicData(tid); + if (!topicData) { + return next(); + } const [ userPrivileges, settings, - topicData, rssToken, ] = await Promise.all([ privileges.topics.get(tid, req.uid), user.getSettings(req.uid), - topics.getTopicData(tid), user.auth.getFeedToken(req.uid), ]); @@ -47,7 +49,6 @@ topicsController.get = async function getTopic(req, res, next) { const pageCount = Math.max(1, Math.ceil((topicData && topicData.postcount) / settings.postsPerPage)); const invalidPagination = (settings.usePagination && (currentPage < 1 || currentPage > pageCount)); if ( - !topicData || userPrivileges.disabled || invalidPagination || (topicData.scheduled && !userPrivileges.view_scheduled) @@ -96,6 +97,7 @@ topicsController.get = async function getTopic(req, res, next) { topicData.topicStaleDays = meta.config.topicStaleDays; topicData['reputation:disabled'] = meta.config['reputation:disabled']; topicData['downvote:disabled'] = meta.config['downvote:disabled']; + topicData.voteVisibility = meta.config.voteVisibility; topicData['feeds:disableRSS'] = meta.config['feeds:disableRSS'] || 0; topicData['signatures:hideDuplicates'] = meta.config['signatures:hideDuplicates']; topicData.bookmarkThreshold = meta.config.bookmarkThreshold; @@ -379,16 +381,14 @@ topicsController.pagination = async function (req, res, next) { if (!utils.isNumber(tid)) { return next(); } - - const [userPrivileges, settings, topic] = await Promise.all([ - privileges.topics.get(tid, req.uid), - user.getSettings(req.uid), - topics.getTopicData(tid), - ]); - + const topic = await topics.getTopicData(tid); if (!topic) { return next(); } + const [userPrivileges, settings] = await Promise.all([ + privileges.topics.get(tid, req.uid), + user.getSettings(req.uid), + ]); if (!userPrivileges.read || !privileges.topics.canViewDeletedScheduled(topic, userPrivileges)) { return helpers.notAllowed(req, res); diff --git a/src/controllers/write/chats.js b/src/controllers/write/chats.js index ae21235f88..81f4fb27e8 100644 --- a/src/controllers/write/chats.js +++ b/src/controllers/write/chats.js @@ -10,7 +10,7 @@ Chats.list = async (req, res) => { let { page, perPage, start, uid } = req.query; ([page, perPage, start, uid] = [page, perPage, start, uid].map(value => isFinite(value) && parseInt(value, 10))); page = page || 1; - perPage = perPage || 20; + perPage = Math.min(100, perPage || 20); // start supercedes page if (start) { diff --git a/src/controllers/write/posts.js b/src/controllers/write/posts.js index 529eabfe44..1dc8cf6800 100644 --- a/src/controllers/write/posts.js +++ b/src/controllers/write/posts.js @@ -131,6 +131,16 @@ Posts.unvote = async (req, res) => { helpers.formatApiResponse(200, res); }; +Posts.getVoters = async (req, res) => { + const data = await api.posts.getVoters(req, { pid: req.params.pid }); + helpers.formatApiResponse(200, res, data); +}; + +Posts.getUpvoters = async (req, res) => { + const data = await api.posts.getUpvoters(req, { pid: req.params.pid }); + helpers.formatApiResponse(200, res, data); +}; + Posts.bookmark = async (req, res) => { const data = await mock(req); await api.posts.bookmark(req, data); diff --git a/src/database/mongo.js b/src/database/mongo.js index 131916cb6f..3a9be4e3a7 100644 --- a/src/database/mongo.js +++ b/src/database/mongo.js @@ -137,11 +137,11 @@ mongoModule.info = async function (db) { listCollections = listCollections.map(collectionInfo => ({ name: collectionInfo.ns, count: collectionInfo.count, - size: collectionInfo.size, - avgObjSize: collectionInfo.avgObjSize, - storageSize: collectionInfo.storageSize, - totalIndexSize: collectionInfo.totalIndexSize, - indexSizes: collectionInfo.indexSizes, + size: collectionInfo.storageStats && collectionInfo.storageStats.size, + avgObjSize: collectionInfo.storageStats && collectionInfo.storageStats.avgObjSize, + storageSize: collectionInfo.storageStats && collectionInfo.storageStats.storageSize, + totalIndexSize: collectionInfo.storageStats && collectionInfo.storageStats.totalIndexSize, + indexSizes: collectionInfo.storageStats && collectionInfo.storageStats.indexSizes, })); stats.mem = serverStatus.mem || { resident: 0, virtual: 0 }; @@ -169,11 +169,14 @@ mongoModule.info = async function (db) { async function getCollectionStats(db) { const items = await db.listCollections().toArray(); - return await Promise.all( - items.map(collection => db.collection(collection.name).aggregate([ - { $collStats: { latencyStats: {}, storageStats: {}, count: {} } }, - ])) + const cols = await Promise.all( + items.map( + collection => db.collection(collection.name).aggregate([ + { $collStats: { latencyStats: {}, storageStats: {}, count: {} } }, + ]).toArray() + ) ); + return cols.map(col => col[0]); } mongoModule.close = async function () { diff --git a/src/database/mongo/main.js b/src/database/mongo/main.js index 5a7b7a75c2..884be9d2f7 100644 --- a/src/database/mongo/main.js +++ b/src/database/mongo/main.js @@ -17,11 +17,14 @@ module.exports = function (module) { } if (Array.isArray(key)) { + if (!key.length) { + return []; + } const data = await module.client.collection('objects').find({ _key: { $in: key }, }, { _id: 0, _key: 1 }).toArray(); - const map = {}; + const map = Object.create(null); data.forEach((item) => { map[item._key] = true; }); diff --git a/src/database/mongo/sorted.js b/src/database/mongo/sorted.js index 0b5036b064..08869d5b5f 100644 --- a/src/database/mongo/sorted.js +++ b/src/database/mongo/sorted.js @@ -157,33 +157,39 @@ module.exports = function (module) { query.score.$lte = max; } - const count = await module.client.collection('objects').countDocuments(query); - return count || 0; + return await module.client.collection('objects').countDocuments(query); }; module.sortedSetCard = async function (key) { if (!key) { return 0; } - const count = await module.client.collection('objects').countDocuments({ _key: key }); - return parseInt(count, 10) || 0; + return await module.client.collection('objects').countDocuments({ _key: key }); }; module.sortedSetsCard = async function (keys) { if (!Array.isArray(keys) || !keys.length) { return []; } - const promises = keys.map(k => module.sortedSetCard(k)); - return await Promise.all(promises); + return await Promise.all(keys.map(module.sortedSetCard)); }; - module.sortedSetsCardSum = async function (keys) { - if (!keys || (Array.isArray(keys) && !keys.length)) { + module.sortedSetsCardSum = async function (keys, min = '-inf', max = '+inf') { + const isArray = Array.isArray(keys); + if (!keys || (isArray && !keys.length)) { return 0; } - const count = await module.client.collection('objects').countDocuments({ _key: Array.isArray(keys) ? { $in: keys } : keys }); - return parseInt(count, 10) || 0; + const query = { _key: isArray ? { $in: keys } : keys }; + if (min !== '-inf') { + query.score = { $gte: min }; + } + if (max !== '+inf') { + query.score = query.score || {}; + query.score.$lte = max; + } + + return await module.client.collection('objects').countDocuments(query); }; module.sortedSetRank = async function (key, value) { diff --git a/src/database/postgres/main.js b/src/database/postgres/main.js index 444af9e5be..c0838b45a0 100644 --- a/src/database/postgres/main.js +++ b/src/database/postgres/main.js @@ -16,38 +16,59 @@ module.exports = function (module) { if (!key) { return; } - - // Redis/Mongo consider empty zsets as non-existent, match that behaviour - const type = await module.type(key); - if (type === 'zset') { - if (Array.isArray(key)) { - const members = await Promise.all(key.map(key => module.getSortedSetRange(key, 0, 0))); - return members.map(member => member.length > 0); - } - const members = await module.getSortedSetRange(key, 0, 0); - return members.length > 0; + const isArray = Array.isArray(key); + if (isArray && !key.length) { + return []; } - if (Array.isArray(key)) { + async function checkIfzSetsExist(keys) { + const members = await Promise.all( + keys.map(key => module.getSortedSetRange(key, 0, 0)) + ); + return members.map(member => member.length > 0); + } + + async function checkIfKeysExist(keys) { const res = await module.pool.query({ name: 'existsArray', text: ` SELECT o."_key" k FROM "legacy_object_live" o WHERE o."_key" = ANY($1::TEXT[])`, - values: [key], + values: [keys], }); - return key.map(k => res.rows.some(r => r.k === k)); + return keys.map(k => res.rows.some(r => r.k === k)); + } + + // Redis/Mongo consider empty zsets as non-existent, match that behaviour + if (isArray) { + const types = await Promise.all(key.map(module.type)); + const zsetKeys = key.filter((_key, i) => types[i] === 'zset'); + const otherKeys = key.filter((_key, i) => types[i] !== 'zset'); + const [zsetExits, otherExists] = await Promise.all([ + checkIfzSetsExist(zsetKeys), + checkIfKeysExist(otherKeys), + ]); + const existsMap = Object.create(null); + zsetKeys.forEach((k, i) => { existsMap[k] = zsetExits[i]; }); + otherKeys.forEach((k, i) => { existsMap[k] = otherExists[i]; }); + return key.map(k => existsMap[k]); + } + const type = await module.type(key); + if (type === 'zset') { + const members = await module.getSortedSetRange(key, 0, 0); + return members.length > 0; } const res = await module.pool.query({ name: 'exists', text: ` SELECT EXISTS(SELECT * FROM "legacy_object_live" - WHERE "_key" = $1::TEXT - LIMIT 1) e`, + WHERE "_key" = $1::TEXT + LIMIT 1) e`, values: [key], }); + return res.rows[0].e; }; diff --git a/src/database/postgres/sorted.js b/src/database/postgres/sorted.js index 5e3b6a65aa..27168493a7 100644 --- a/src/database/postgres/sorted.js +++ b/src/database/postgres/sorted.js @@ -221,16 +221,42 @@ SELECT o."_key" k, return keys.map(k => parseInt((res.rows.find(r => r.k === k) || { c: 0 }).c, 10)); }; - module.sortedSetsCardSum = async function (keys) { + module.sortedSetsCardSum = async function (keys, min = '-inf', max = '+inf') { if (!keys || (Array.isArray(keys) && !keys.length)) { return 0; } if (!Array.isArray(keys)) { keys = [keys]; } - const counts = await module.sortedSetsCard(keys); - const sum = counts.reduce((acc, val) => acc + val, 0); - return sum; + let counts = []; + if (min !== '-inf' || max !== '+inf') { + if (min === '-inf') { + min = null; + } + if (max === '+inf') { + max = null; + } + + const res = await module.pool.query({ + name: 'sortedSetsCardSum', + text: ` + SELECT o."_key" k, + COUNT(*) c + FROM "legacy_object_live" o + INNER JOIN "legacy_zset" z + ON o."_key" = z."_key" + AND o."type" = z."type" + WHERE o."_key" = ANY($1::TEXT[]) + AND (z."score" >= $2::NUMERIC OR $2::NUMERIC IS NULL) + AND (z."score" <= $3::NUMERIC OR $3::NUMERIC IS NULL) + GROUP BY o."_key"`, + values: [keys, min, max], + }); + counts = keys.map(k => parseInt((res.rows.find(r => r.k === k) || { c: 0 }).c, 10)); + } else { + counts = await module.sortedSetsCard(keys); + } + return counts.reduce((acc, val) => acc + val, 0); }; module.sortedSetRank = async function (key, value) { diff --git a/src/database/redis/main.js b/src/database/redis/main.js index 8b79afb07c..b849361a8e 100644 --- a/src/database/redis/main.js +++ b/src/database/redis/main.js @@ -14,6 +14,9 @@ module.exports = function (module) { module.exists = async function (key) { if (Array.isArray(key)) { + if (!key.length) { + return []; + } const batch = module.client.batch(); key.forEach(key => batch.exists(key)); const data = await helpers.execBatch(batch); diff --git a/src/database/redis/sorted.js b/src/database/redis/sorted.js index 07a30bab05..013477da5a 100644 --- a/src/database/redis/sorted.js +++ b/src/database/redis/sorted.js @@ -116,16 +116,21 @@ module.exports = function (module) { return await helpers.execBatch(batch); }; - module.sortedSetsCardSum = async function (keys) { + module.sortedSetsCardSum = async function (keys, min = '-inf', max = '+inf') { if (!keys || (Array.isArray(keys) && !keys.length)) { return 0; } if (!Array.isArray(keys)) { keys = [keys]; } - const counts = await module.sortedSetsCard(keys); - const sum = counts.reduce((acc, val) => acc + val, 0); - return sum; + const batch = module.client.batch(); + if (min !== '-inf' || max !== '+inf') { + keys.forEach(k => batch.zcount(String(k), min, max)); + } else { + keys.forEach(k => batch.zcard(String(k))); + } + const counts = await helpers.execBatch(batch); + return counts.reduce((acc, val) => acc + val, 0); }; module.sortedSetRank = async function (key, value) { diff --git a/src/messaging/index.js b/src/messaging/index.js index ccedd47e81..7a2cd617a6 100644 --- a/src/messaging/index.js +++ b/src/messaging/index.js @@ -417,7 +417,8 @@ async function checkReputation(uid) { } Messaging.hasPrivateChat = async (uid, withUid) => { - if (parseInt(uid, 10) === parseInt(withUid, 10)) { + if (parseInt(uid, 10) === parseInt(withUid, 10) || + parseInt(uid, 10) <= 0 || parseInt(withUid, 10) <= 0) { return 0; } diff --git a/src/meta/index.js b/src/meta/index.js index 487c53df60..cb4f8bfdcd 100644 --- a/src/meta/index.js +++ b/src/meta/index.js @@ -24,20 +24,26 @@ Meta.templates = require('./templates'); Meta.blacklist = require('./blacklist'); Meta.languages = require('./languages'); +const user = require('../user'); +const groups = require('../groups'); /* Assorted */ Meta.userOrGroupExists = async function (slug) { - if (!slug) { + const isArray = Array.isArray(slug); + if ((isArray && slug.some(slug => !slug)) || (!isArray && !slug)) { throw new Error('[[error:invalid-data]]'); } - const user = require('../user'); - const groups = require('../groups'); - slug = slugify(slug); + + slug = isArray ? slug.map(s => slugify(s, false)) : slugify(slug); + const [userExists, groupExists] = await Promise.all([ user.existsBySlug(slug), groups.existsBySlug(slug), ]); - return userExists || groupExists; + + return isArray ? + slug.map((s, i) => userExists[i] || groupExists[i]) : + (userExists || groupExists); }; if (nconf.get('isPrimary')) { diff --git a/src/meta/tags.js b/src/meta/tags.js index 121c1c74c2..b59760b167 100644 --- a/src/meta/tags.js +++ b/src/meta/tags.js @@ -14,8 +14,10 @@ const relative_path = nconf.get('relative_path'); const upload_url = nconf.get('upload_url'); Tags.parse = async (req, data, meta, link) => { + const isAPI = req.res && req.res.locals && req.res.locals.isAPI; + // Meta tags - const defaultTags = [{ + const defaultTags = isAPI ? [] : [{ name: 'viewport', content: 'width=device-width, initial-scale=1.0', }, { @@ -40,14 +42,14 @@ Tags.parse = async (req, data, meta, link) => { content: Meta.config.themeColor || '#ffffff', }]; - if (Meta.config.keywords) { + if (Meta.config.keywords && !isAPI) { defaultTags.push({ name: 'keywords', content: Meta.config.keywords, }); } - if (Meta.config['brand:logo']) { + if (Meta.config['brand:logo'] && !isAPI) { defaultTags.push({ name: 'msapplication-square150x150logo', content: Meta.config['brand:logo'], @@ -59,7 +61,7 @@ Tags.parse = async (req, data, meta, link) => { const cacheBuster = `${Meta.config['cache-buster'] ? `?${Meta.config['cache-buster']}` : ''}`; // Link Tags - const defaultLinks = [{ + const defaultLinks = isAPI ? [] : [{ rel: 'icon', type: 'image/x-icon', href: `${faviconPath}${cacheBuster}`, @@ -69,7 +71,7 @@ Tags.parse = async (req, data, meta, link) => { crossorigin: `use-credentials`, }]; - if (plugins.hooks.hasListeners('filter:search.query')) { + if (plugins.hooks.hasListeners('filter:search.query') && !isAPI) { defaultLinks.push({ rel: 'search', type: 'application/opensearchdescription+xml', @@ -78,7 +80,59 @@ Tags.parse = async (req, data, meta, link) => { }); } - // Touch icons for mobile-devices + if (!isAPI) { + addTouchIcons(defaultLinks); + } + + const results = await utils.promiseParallel({ + tags: plugins.hooks.fire('filter:meta.getMetaTags', { req: req, data: data, tags: defaultTags }), + links: plugins.hooks.fire('filter:meta.getLinkTags', { req: req, data: data, links: defaultLinks }), + }); + + meta = results.tags.tags.concat(meta || []).map((tag) => { + if (!tag || typeof tag.content !== 'string') { + winston.warn('Invalid meta tag. ', tag); + return tag; + } + + if (!tag.noEscape) { + const attributes = Object.keys(tag); + attributes.forEach((attr) => { + tag[attr] = utils.escapeHTML(String(tag[attr])); + }); + } + + return tag; + }); + + await addSiteOGImage(meta); + + addIfNotExists(meta, 'property', 'og:title', Meta.config.title || 'NodeBB'); + const ogUrl = url + (req.originalUrl !== '/' ? stripRelativePath(req.originalUrl) : ''); + addIfNotExists(meta, 'property', 'og:url', ogUrl); + addIfNotExists(meta, 'name', 'description', Meta.config.description); + addIfNotExists(meta, 'property', 'og:description', Meta.config.description); + + link = results.links.links.concat(link || []); + if (isAPI) { + const whitelist = ['canonical', 'alternate', 'up']; + link = link.filter(link => whitelist.some(val => val === link.rel)); + } + link = link.map((tag) => { + if (!tag.noEscape) { + const attributes = Object.keys(tag); + attributes.forEach((attr) => { + tag[attr] = utils.escapeHTML(String(tag[attr])); + }); + } + + return tag; + }); + + return { meta, link }; +}; + +function addTouchIcons(defaultLinks) { if (Meta.config['brand:touchIcon']) { defaultLinks.push({ rel: 'apple-touch-icon', @@ -142,64 +196,16 @@ Tags.parse = async (req, data, meta, link) => { href: `${relative_path}/assets/images/touch/512.png`, }); } - - const results = await utils.promiseParallel({ - tags: plugins.hooks.fire('filter:meta.getMetaTags', { req: req, data: data, tags: defaultTags }), - links: plugins.hooks.fire('filter:meta.getLinkTags', { req: req, data: data, links: defaultLinks }), - }); - - meta = results.tags.tags.concat(meta || []).map((tag) => { - if (!tag || typeof tag.content !== 'string') { - winston.warn('Invalid meta tag. ', tag); - return tag; - } - - if (!tag.noEscape) { - const attributes = Object.keys(tag); - attributes.forEach((attr) => { - tag[attr] = utils.escapeHTML(String(tag[attr])); - }); - } - - return tag; - }); - - await addSiteOGImage(meta); - - addIfNotExists(meta, 'property', 'og:title', Meta.config.title || 'NodeBB'); - const ogUrl = url + (req.originalUrl !== '/' ? stripRelativePath(req.originalUrl) : ''); - addIfNotExists(meta, 'property', 'og:url', ogUrl); - addIfNotExists(meta, 'name', 'description', Meta.config.description); - addIfNotExists(meta, 'property', 'og:description', Meta.config.description); - - link = results.links.links.concat(link || []).map((tag) => { - if (!tag.noEscape) { - const attributes = Object.keys(tag); - attributes.forEach((attr) => { - tag[attr] = utils.escapeHTML(String(tag[attr])); - }); - } - - return tag; - }); - - return { meta, link }; -}; +} function addIfNotExists(meta, keyName, tagName, value) { - let exists = false; - meta.forEach((tag) => { - if (tag[keyName] === tagName) { - exists = true; - } - }); + const exists = meta.some(tag => tag[keyName] === tagName); if (!exists && value) { - const data = { + meta.push({ content: utils.escapeHTML(String(value)), - }; - data[keyName] = tagName; - meta.push(data); + [keyName]: tagName, + }); } } diff --git a/src/meta/templates.js b/src/meta/templates.js index ec3c54882d..7661db48e8 100644 --- a/src/meta/templates.js +++ b/src/meta/templates.js @@ -114,7 +114,7 @@ async function compile() { let files = await plugins.getActive(); files = await getTemplateDirs(files); files = await getTemplateFiles(files); - + const minify = process.env.NODE_ENV !== 'development'; await Promise.all(Object.keys(files).map(async (name) => { const filePath = files[name]; let imported = await fs.promises.readFile(filePath, 'utf8'); @@ -122,6 +122,11 @@ async function compile() { await mkdirp(path.join(viewsPath, path.dirname(name))); + // remove empty lines and whitespace + if (minify) { + imported = imported.split('\n').map(line => line.trim()).filter(Boolean).join('\n'); + } + await fs.promises.writeFile(path.join(viewsPath, name), imported); const compiled = await Benchpress.precompile(imported, { filename: name }); await fs.promises.writeFile(path.join(viewsPath, name.replace(/\.tpl$/, '.js')), compiled); diff --git a/src/middleware/admin.js b/src/middleware/admin.js index b514fb5cca..bf89079103 100644 --- a/src/middleware/admin.js +++ b/src/middleware/admin.js @@ -10,7 +10,7 @@ const privileges = require('../privileges'); const helpers = require('./helpers'); const controllers = { - api: require('../controllers/api'), + admin: require('../controllers/admin'), helpers: require('../controllers/helpers'), }; @@ -22,7 +22,7 @@ middleware.buildHeader = helpers.try(async (req, res, next) => { await require('./index').applyCSRFasync(req, res); } - res.locals.config = await controllers.api.loadConfig(req); + res.locals.config = await controllers.admin.loadConfig(req); next(); }); diff --git a/src/middleware/render.js b/src/middleware/render.js index 78d3577418..e01110936f 100644 --- a/src/middleware/render.js +++ b/src/middleware/render.js @@ -116,7 +116,7 @@ module.exports = function (middleware) { } try { - await renderMethod(template, { ...res.locals.templateValues, ...options }, fn); + await renderMethod(template, options, fn); } catch (err) { next(err); } @@ -130,7 +130,7 @@ module.exports = function (middleware) { return await user.getUserData(req.uid); } return { - uid: 0, + uid: req.uid === -1 ? -1 : 0, username: '[[global:guest]]', picture: user.getDefaultAvatar(), 'icon:text': '?', @@ -184,7 +184,7 @@ module.exports = function (middleware) { timeagoCode: languages.userTimeagoCode(res.locals.config.userLang), browserTitle: translator.translate(controllersHelpers.buildTitle(title)), navigation: navigation.get(req.uid), - roomIds: db.getSortedSetRevRange(`uid:${req.uid}:chat:rooms`, 0, 0), + roomIds: req.uid > 0 ? db.getSortedSetRevRange(`uid:${req.uid}:chat:rooms`, 0, 0) : [], }); const unreadData = { diff --git a/src/middleware/uploads.js b/src/middleware/uploads.js index 5b1e9c392f..d1ce5b09b2 100644 --- a/src/middleware/uploads.js +++ b/src/middleware/uploads.js @@ -5,12 +5,12 @@ const meta = require('../meta'); const helpers = require('./helpers'); const user = require('../user'); -const cache = cacheCreate({ - ttl: meta.config.uploadRateLimitCooldown * 1000, -}); +let cache; exports.clearCache = function () { - cache.clear(); + if (cache) { + cache.clear(); + } }; exports.ratelimit = helpers.try(async (req, res, next) => { @@ -18,7 +18,11 @@ exports.ratelimit = helpers.try(async (req, res, next) => { if (!meta.config.uploadRateLimitThreshold || (uid && await user.isAdminOrGlobalMod(uid))) { return next(); } - + if (!cache) { + cache = cacheCreate({ + ttl: meta.config.uploadRateLimitCooldown * 1000, + }); + } const count = (cache.get(`${req.ip}:uploaded_file_count`) || 0) + req.files.files.length; if (count > meta.config.uploadRateLimitThreshold) { return next(new Error(['[[error:upload-ratelimit-reached]]'])); diff --git a/src/middleware/user.js b/src/middleware/user.js index 342730c507..ca6afcaf9b 100644 --- a/src/middleware/user.js +++ b/src/middleware/user.js @@ -41,7 +41,7 @@ module.exports = function (middleware) { async function finishLogin(req, user) { const loginAsync = util.promisify(req.login).bind(req); await loginAsync(user, { keepSessionInfo: true }); - await controllers.authentication.onSuccessfulLogin(req, user.uid); + await controllers.authentication.onSuccessfulLogin(req, user.uid, false); req.uid = parseInt(user.uid, 10); req.loggedIn = req.uid > 0; return true; @@ -248,7 +248,21 @@ module.exports = function (middleware) { }; middleware.buildAccountData = async (req, res, next) => { - res.locals.templateValues = await accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, req.query); + // use lowercase slug on api routes, or direct to the user/ + const lowercaseSlug = req.params.userslug.toLowerCase(); + if (req.params.userslug !== lowercaseSlug) { + if (res.locals.isAPI) { + req.params.userslug = lowercaseSlug; + } else { + const newPath = req.path.replace(new RegExp(`/${req.params.userslug}`), () => `/${lowercaseSlug}`); + return res.redirect(`${nconf.get('relative_path')}${newPath}`); + } + } + + res.locals.userData = await accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, req.query); + if (!res.locals.userData) { + return next('route'); + } next(); }; diff --git a/src/plugins/hooks.js b/src/plugins/hooks.js index aa471af6a8..8a5d1a885d 100644 --- a/src/plugins/hooks.js +++ b/src/plugins/hooks.js @@ -3,6 +3,7 @@ const winston = require('winston'); const plugins = require('.'); const utils = require('../utils'); +const als = require('../als'); const Hooks = module.exports; @@ -185,7 +186,6 @@ Hooks.fire = async function (hook, params) { } let deleteCaller = false; if (params && typeof params === 'object' && !Array.isArray(params) && !params.hasOwnProperty('caller')) { - const als = require('../als'); params.caller = als.getStore(); deleteCaller = true; } diff --git a/src/posts/cache.js b/src/posts/cache.js index 7f4711d0cd..bb65026ae4 100644 --- a/src/posts/cache.js +++ b/src/posts/cache.js @@ -1,12 +1,31 @@ 'use strict'; -const cacheCreate = require('../cache/lru'); -const meta = require('../meta'); +let cache = null; -module.exports = cacheCreate({ - name: 'post', - maxSize: meta.config.postCacheSize, - sizeCalculation: function (n) { return n.length || 1; }, - ttl: 0, - enabled: global.env === 'production', -}); +exports.getOrCreate = function () { + if (!cache) { + const cacheCreate = require('../cache/lru'); + const meta = require('../meta'); + cache = cacheCreate({ + name: 'post', + maxSize: meta.config.postCacheSize, + sizeCalculation: function (n) { return n.length || 1; }, + ttl: 0, + enabled: global.env === 'production', + }); + } + + return cache; +}; + +exports.del = function (pid) { + if (cache) { + cache.del(pid); + } +}; + +exports.reset = function () { + if (cache) { + cache.reset(); + } +}; diff --git a/src/posts/parse.js b/src/posts/parse.js index f36013dd77..4e16a111ad 100644 --- a/src/posts/parse.js +++ b/src/posts/parse.js @@ -10,6 +10,7 @@ const meta = require('../meta'); const plugins = require('../plugins'); const translator = require('../translator'); const utils = require('../utils'); +const postCache = require('./cache'); let sanitizeConfig = { allowedTags: sanitize.defaults.allowedTags.concat([ @@ -52,7 +53,7 @@ module.exports = function (Posts) { return postData; } postData.content = String(postData.content || ''); - const cache = require('./cache'); + const cache = postCache.getOrCreate(); const pid = String(postData.pid); const cachedContent = cache.get(pid); if (postData.pid && cachedContent !== undefined) { diff --git a/src/posts/uploads.js b/src/posts/uploads.js index 3ab2a6aeb4..12564d0f17 100644 --- a/src/posts/uploads.js +++ b/src/posts/uploads.js @@ -163,7 +163,7 @@ module.exports = function (Posts) { filePaths = await _filterValidPaths(filePaths); // Only process files that exist and are within uploads directory const now = Date.now(); - const scores = filePaths.map(() => now); + const scores = filePaths.map((p, i) => now + i); const bulkAdd = filePaths.map(path => [`upload:${md5(path)}:pids`, now, pid]); await Promise.all([ db.sortedSetAdd(`post:${pid}:uploads`, scores, filePaths), diff --git a/src/posts/user.js b/src/posts/user.js index 850ed4c613..1fd8fa7e2c 100644 --- a/src/posts/user.js +++ b/src/posts/user.js @@ -17,7 +17,7 @@ module.exports = function (Posts) { const [userData, userSettings, signatureUids] = await Promise.all([ getUserData(uids, uid), user.getMultipleUserSettings(uids), - privileges.global.filterUids('signature', uids), + meta.config.disableSignatures ? [] : privileges.categories.filterUids('signature', 0, uids), ]); const uidsSignatureSet = new Set(signatureUids.map(uid => parseInt(uid, 10))); const groupsMap = await getGroupsMap(userData); diff --git a/src/promisify.js b/src/promisify.js index da6aad1fd2..47b2f3a9f4 100644 --- a/src/promisify.js +++ b/src/promisify.js @@ -37,7 +37,7 @@ module.exports = function (theModule, ignoreKeys) { } function wrapCallback(origFn, callbackFn) { - return async function wrapperCallback(...args) { + return function wrapperCallback(...args) { if (args.length && typeof args[args.length - 1] === 'function') { const cb = args.pop(); args.push((err, res) => (res !== undefined ? cb(err, res) : cb(err))); diff --git a/src/routes/admin.js b/src/routes/admin.js index 01e228dabe..6e6721c13e 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -61,6 +61,7 @@ module.exports = function (app, name, middleware, controllers) { function apiRoutes(router, name, middleware, controllers) { + router.get(`/api/${name}/config`, middleware.ensureLoggedIn, helpers.tryRoute(controllers.admin.getConfig)); router.get(`/api/${name}/users/csv`, middleware.ensureLoggedIn, helpers.tryRoute(controllers.admin.users.getCSV)); router.get(`/api/${name}/groups/:groupname/csv`, middleware.ensureLoggedIn, helpers.tryRoute(controllers.admin.groups.getCSV)); router.get(`/api/${name}/analytics`, middleware.ensureLoggedIn, helpers.tryRoute(controllers.admin.dashboard.getAnalytics)); diff --git a/src/routes/api.js b/src/routes/api.js index 7f4afdc801..0fe575a326 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -6,7 +6,7 @@ const uploadsController = require('../controllers/uploads'); const helpers = require('./helpers'); module.exports = function (app, middleware, controllers) { - const middlewares = [middleware.authenticateRequest]; + const middlewares = [middleware.autoLocale, middleware.authenticateRequest]; const router = express.Router(); app.use('/api', router); diff --git a/src/routes/helpers.js b/src/routes/helpers.js index b43f53fd3e..34a455076e 100644 --- a/src/routes/helpers.js +++ b/src/routes/helpers.js @@ -16,6 +16,7 @@ helpers.setupPageRoute = function (...args) { } middlewares = [ + middleware.autoLocale, middleware.applyBlacklist, middleware.authenticateRequest, middleware.redirectToHomeIfBanned, @@ -44,7 +45,7 @@ helpers.setupAdminPageRoute = function (...args) { if (args.length === 5) { winston.warn(`[helpers.setupAdminPageRoute(${name})] passing \`middleware\` as the third param is deprecated, it can now be safely removed`); } - router.get(name, middleware.admin.buildHeader, middlewares, helpers.tryRoute(controller)); + router.get(name, middleware.autoLocale, middleware.admin.buildHeader, middlewares, helpers.tryRoute(controller)); router.get(`/api${name}`, middlewares, helpers.tryRoute(controller)); }; @@ -55,6 +56,7 @@ helpers.setupApiRoute = function (...args) { const controller = args[args.length - 1]; middlewares = [ + middleware.autoLocale, middleware.applyBlacklist, middleware.authenticateRequest, middleware.maintenanceMode, diff --git a/src/routes/write/posts.js b/src/routes/write/posts.js index a834d26088..e573bbb9b0 100644 --- a/src/routes/write/posts.js +++ b/src/routes/write/posts.js @@ -26,6 +26,8 @@ module.exports = function () { setupApiRoute(router, 'put', '/:pid/vote', [...middlewares, middleware.checkRequired.bind(null, ['delta'])], controllers.write.posts.vote); setupApiRoute(router, 'delete', '/:pid/vote', middlewares, controllers.write.posts.unvote); + setupApiRoute(router, 'get', '/:pid/voters', [middleware.assert.post], controllers.write.posts.getVoters); + setupApiRoute(router, 'get', '/:pid/upvoters', [middleware.assert.post], controllers.write.posts.getUpvoters); setupApiRoute(router, 'put', '/:pid/bookmark', middlewares, controllers.write.posts.bookmark); setupApiRoute(router, 'delete', '/:pid/bookmark', middlewares, controllers.write.posts.unbookmark); diff --git a/src/socket.io/admin/cache.js b/src/socket.io/admin/cache.js index 1d382720f5..65ddfbefe1 100644 --- a/src/socket.io/admin/cache.js +++ b/src/socket.io/admin/cache.js @@ -7,7 +7,7 @@ const plugins = require('../../plugins'); SocketCache.clear = async function (socket, data) { let caches = { - post: require('../../posts/cache'), + post: require('../../posts/cache').getOrCreate(), object: db.objectCache, group: require('../../groups').cache, local: require('../../cache'), @@ -21,7 +21,7 @@ SocketCache.clear = async function (socket, data) { SocketCache.toggle = async function (socket, data) { let caches = { - post: require('../../posts/cache'), + post: require('../../posts/cache').getOrCreate(), object: db.objectCache, group: require('../../groups').cache, local: require('../../cache'), diff --git a/src/socket.io/admin/plugins.js b/src/socket.io/admin/plugins.js index b8890f9e61..2d6f705be9 100644 --- a/src/socket.io/admin/plugins.js +++ b/src/socket.io/admin/plugins.js @@ -5,12 +5,13 @@ const nconf = require('nconf'); const plugins = require('../../plugins'); const events = require('../../events'); const db = require('../../database'); +const postsCache = require('../../posts/cache'); const { pluginNamePattern } = require('../../constants'); const Plugins = module.exports; Plugins.toggleActive = async function (socket, plugin_id) { - require('../../posts/cache').reset(); + postsCache.reset(); const data = await plugins.toggleActive(plugin_id); await events.log({ type: `plugin-${data.active ? 'activate' : 'deactivate'}`, @@ -21,7 +22,7 @@ Plugins.toggleActive = async function (socket, plugin_id) { }; Plugins.toggleInstall = async function (socket, data) { - require('../../posts/cache').reset(); + postsCache.reset(); await plugins.checkWhitelist(data.id, data.version); const pluginData = await plugins.toggleInstall(data.id, data.version); await events.log({ diff --git a/src/socket.io/admin/rooms.js b/src/socket.io/admin/rooms.js index c426d0c7d6..a8107edaa7 100644 --- a/src/socket.io/admin/rooms.js +++ b/src/socket.io/admin/rooms.js @@ -2,6 +2,7 @@ const topics = require('../../topics'); const io = require('..'); +const webserver = require('../../webserver'); const totals = {}; @@ -94,6 +95,7 @@ SocketRooms.getLocalStats = function () { onlineGuestCount: 0, onlineRegisteredCount: 0, socketCount: 0, + connectionCount: webserver.getConnectionCount(), users: { categories: 0, recent: 0, diff --git a/src/socket.io/index.js b/src/socket.io/index.js index d0ba0b4b19..43804c22d3 100644 --- a/src/socket.io/index.js +++ b/src/socket.io/index.js @@ -14,6 +14,8 @@ const logger = require('../logger'); const plugins = require('../plugins'); const ratelimit = require('../middleware/ratelimit'); const blacklist = require('../meta/blacklist'); +const als = require('../als'); +const apiHelpers = require('../api/helpers'); const Namespaces = Object.create(null); @@ -88,8 +90,7 @@ function onConnection(socket) { onConnect(socket); socket.onAny((event, ...args) => { const payload = { event: event, ...deserializePayload(args) }; - const als = require('../als'); - const apiHelpers = require('../api/helpers'); + als.run({ uid: socket.uid, req: apiHelpers.buildReqObject(socket, payload), @@ -131,10 +132,10 @@ async function onConnect(socket) { return; } - if (socket.uid) { + if (socket.uid > 0) { socket.join(`uid_${socket.uid}`); socket.join('online_users'); - } else { + } else if (socket.uid === 0) { socket.join('online_guests'); } diff --git a/src/socket.io/posts/votes.js b/src/socket.io/posts/votes.js index cd0b6f3b9b..3a92360535 100644 --- a/src/socket.io/posts/votes.js +++ b/src/socket.io/posts/votes.js @@ -1,105 +1,22 @@ 'use strict'; -const _ = require('lodash'); - -const db = require('../../database'); -const user = require('../../user'); -const posts = require('../../posts'); -const privileges = require('../../privileges'); -const meta = require('../../meta'); +const api = require('../../api'); +const sockets = require('../index'); module.exports = function (SocketPosts) { SocketPosts.getVoters = async function (socket, data) { if (!data || !data.pid) { throw new Error('[[error:invalid-data]]'); } - const cid = await posts.getCidByPid(data.pid); - if (!await canSeeVotes(socket.uid, cid)) { - throw new Error('[[error:no-privileges]]'); - } - const showDownvotes = !meta.config['downvote:disabled']; - const [upvoteUids, downvoteUids] = await Promise.all([ - db.getSetMembers(`pid:${data.pid}:upvote`), - showDownvotes ? db.getSetMembers(`pid:${data.pid}:downvote`) : [], - ]); - - const [upvoters, downvoters] = await Promise.all([ - user.getUsersFields(upvoteUids, ['username', 'userslug', 'picture']), - user.getUsersFields(downvoteUids, ['username', 'userslug', 'picture']), - ]); - - return { - upvoteCount: upvoters.length, - downvoteCount: downvoters.length, - showDownvotes: showDownvotes, - upvoters: upvoters, - downvoters: downvoters, - }; + sockets.warnDeprecated(socket, 'GET /api/v3/posts/:pid/voters'); + return await api.posts.getVoters(socket, { pid: data.pid }); }; SocketPosts.getUpvoters = async function (socket, pids) { if (!Array.isArray(pids)) { throw new Error('[[error:invalid-data]]'); } - - const cids = await posts.getCidsByPids(pids); - if ((await canSeeVotes(socket.uid, cids)).includes(false)) { - throw new Error('[[error:no-privileges]]'); - } - - const data = await posts.getUpvotedUidsByPids(pids); - if (!data.length) { - return []; - } - const cutoff = 6; - const sliced = data.map((uids) => { - let otherCount = 0; - if (uids.length > cutoff) { - otherCount = uids.length - (cutoff - 1); - uids = uids.slice(0, cutoff - 1); - } - return { - otherCount, - uids, - }; - }); - - const uniqUids = _.uniq(_.flatten(sliced.map(d => d.uids))); - const usernameMap = _.zipObject(uniqUids, await user.getUsernamesByUids(uniqUids)); - const result = sliced.map( - data => ({ - otherCount: data.otherCount, - cutoff: cutoff, - usernames: data.uids.map(uid => usernameMap[uid]), - }) - ); - return result; + sockets.warnDeprecated(socket, 'GET /api/v3/posts/:pid/upvoters'); + return await api.posts.getUpvoters(socket, { pid: pids[0] }); }; - - async function canSeeVotes(uid, cids) { - const isArray = Array.isArray(cids); - if (!isArray) { - cids = [cids]; - } - const uniqCids = _.uniq(cids); - const [canRead, isAdmin, isMod] = await Promise.all([ - privileges.categories.isUserAllowedTo( - 'topics:read', uniqCids, uid - ), - privileges.users.isAdministrator(uid), - privileges.users.isModerator(uid, cids), - ]); - const cidToAllowed = _.zipObject(uniqCids, canRead); - const checks = cids.map( - (cid, index) => isAdmin || isMod[index] || - ( - cidToAllowed[cid] && - ( - meta.config.voteVisibility === 'all' || - (meta.config.voteVisibility === 'loggedin' && parseInt(uid, 10) > 0) - ) - ) - ); - return isArray ? checks : checks[0]; - } }; diff --git a/src/topics/events.js b/src/topics/events.js index 13ab489c63..f63f4b32a8 100644 --- a/src/topics/events.js +++ b/src/topics/events.js @@ -111,10 +111,8 @@ function renderTimeago(event) { } Events.get = async (tid, uid, reverse = false) => { - const topics = require('.'); - - if (!await topics.exists(tid)) { - throw new Error('[[error:no-topic]]'); + if (!tid) { + return []; } let eventIds = await db.getSortedSetRangeWithScores(`topic:${tid}:events`, 0, -1); @@ -122,7 +120,11 @@ Events.get = async (tid, uid, reverse = false) => { const timestamps = eventIds.map(obj => obj.score); eventIds = eventIds.map(obj => obj.value); let events = await db.getObjects(keys); - events = await modifyEvent({ tid, uid, eventIds, timestamps, events }); + events.forEach((e, idx) => { + e.timestamp = timestamps[idx]; + }); + await addEventsFromPostQueue(tid, uid, events); + events = await modifyEvent({ uid, events }); if (reverse) { events.reverse(); } @@ -146,8 +148,7 @@ async function getCategoryInfo(cids) { return _.zipObject(uniqCids, catData); } -async function modifyEvent({ tid, uid, eventIds, timestamps, events }) { - // Add posts from post queue +async function addEventsFromPostQueue(tid, uid, events) { const isPrivileged = await user.isPrivileged(uid); if (isPrivileged) { const queuedPosts = await posts.getQueuedPosts({ tid }, { metadata: false }); @@ -157,11 +158,10 @@ async function modifyEvent({ tid, uid, eventIds, timestamps, events }) { timestamp: item.data.timestamp || Date.now(), uid: item.data.uid, }))); - queuedPosts.forEach((item) => { - timestamps.push(item.data.timestamp || Date.now()); - }); } +} +async function modifyEvent({ uid, events }) { const [users, fromCategories, userSettings] = await Promise.all([ getUserInfo(events.map(event => event.uid).filter(Boolean)), getCategoryInfo(events.map(event => event.fromCid).filter(Boolean)), @@ -185,10 +185,8 @@ async function modifyEvent({ tid, uid, eventIds, timestamps, events }) { events = events.filter(event => Events._types.hasOwnProperty(event.type)); // Add user & metadata - events.forEach((event, idx) => { - event.id = parseInt(eventIds[idx], 10); - event.timestamp = timestamps[idx]; - event.timestampISO = new Date(timestamps[idx]).toISOString(); + events.forEach((event) => { + event.timestampISO = utils.toISOString(event.timestamp); if (event.hasOwnProperty('uid')) { event.user = users.get(event.uid === 'system' ? 'system' : parseInt(event.uid, 10)); } @@ -223,16 +221,15 @@ Events.log = async (tid, payload) => { } const eventId = await db.incrObjectField('global', 'nextTopicEventId'); + payload.id = eventId; await Promise.all([ db.setObject(`topicEvent:${eventId}`, payload), db.sortedSetAdd(`topic:${tid}:events`, timestamp, eventId), ]); - + payload.timestamp = timestamp; let events = await modifyEvent({ uid: payload.uid, - eventIds: [eventId], - timestamps: [timestamp], events: [payload], }); diff --git a/src/topics/suggested.js b/src/topics/suggested.js index 2d6f7db99c..bc8bbd2102 100644 --- a/src/topics/suggested.js +++ b/src/topics/suggested.js @@ -6,29 +6,36 @@ const _ = require('lodash'); const db = require('../database'); const user = require('../user'); const privileges = require('../privileges'); -const search = require('../search'); +const plugins = require('../plugins'); module.exports = function (Topics) { Topics.getSuggestedTopics = async function (tid, uid, start, stop, cutoff = 0) { let tids; - tid = parseInt(tid, 10); + if (!tid) { + return []; + } + tid = String(tid); cutoff = cutoff === 0 ? cutoff : (cutoff * 2592000000); + const { cid, title, tags } = await Topics.getTopicFields(tid, [ + 'cid', 'title', 'tags', + ]); + const [tagTids, searchTids] = await Promise.all([ - getTidsWithSameTags(tid, cutoff), - getSearchTids(tid, uid, cutoff), + getTidsWithSameTags(tid, tags.map(t => t.value), cutoff), + getSearchTids(tid, title, cid, cutoff), ]); tids = _.uniq(tagTids.concat(searchTids)); let categoryTids = []; if (stop !== -1 && tids.length < stop - start + 1) { - categoryTids = await getCategoryTids(tid, cutoff); + categoryTids = await getCategoryTids(tid, cid, cutoff); } tids = _.shuffle(_.uniq(tids.concat(categoryTids))); tids = await privileges.topics.filterTids('topics:read', tids, uid); let topicData = await Topics.getTopicsByTids(tids, uid); - topicData = topicData.filter(topic => topic && topic.tid !== tid); + topicData = topicData.filter(topic => topic && String(topic.tid) !== tid); topicData = await user.blocks.filter(uid, topicData); topicData = topicData.slice(start, stop !== -1 ? stop + 1 : undefined) .sort((t1, t2) => t2.timestamp - t1.timestamp); @@ -36,36 +43,37 @@ module.exports = function (Topics) { return topicData; }; - async function getTidsWithSameTags(tid, cutoff) { - const tags = await Topics.getTopicTags(tid); + async function getTidsWithSameTags(tid, tags, cutoff) { let tids = cutoff === 0 ? await db.getSortedSetRevRange(tags.map(tag => `tag:${tag}:topics`), 0, -1) : await db.getSortedSetRevRangeByScore(tags.map(tag => `tag:${tag}:topics`), 0, -1, '+inf', Date.now() - cutoff); tids = tids.filter(_tid => _tid !== tid); // remove self - return _.shuffle(_.uniq(tids)).slice(0, 10).map(Number); + return _.shuffle(_.uniq(tids)).slice(0, 10); } - async function getSearchTids(tid, uid, cutoff) { - const topicData = await Topics.getTopicFields(tid, ['title', 'cid']); - const data = await search.search({ - query: topicData.title, - searchIn: 'titles', + async function getSearchTids(tid, title, cid, cutoff) { + let { ids: tids } = await plugins.hooks.fire('filter:search.query', { + index: 'topic', + content: title, matchWords: 'any', - categories: [topicData.cid], - uid: uid, - returnIds: true, - timeRange: cutoff !== 0 ? cutoff / 1000 : 0, - timeFilter: 'newer', + cid: [cid], + limit: 20, + ids: [], }); - data.tids = data.tids.filter(_tid => _tid !== tid); // remove self - return _.shuffle(data.tids).slice(0, 10).map(Number); + tids = tids.filter(_tid => String(_tid) !== tid); // remove self + if (cutoff) { + const topicData = await Topics.getTopicsFields(tids, ['tid', 'timestamp']); + const now = Date.now(); + tids = topicData.filter(t => t && t.timestamp > now - cutoff).map(t => t.tid); + } + + return _.shuffle(tids).slice(0, 10).map(String); } - async function getCategoryTids(tid, cutoff) { - const cid = await Topics.getTopicField(tid, 'cid'); + async function getCategoryTids(tid, cid, cutoff) { const tids = cutoff === 0 ? await db.getSortedSetRevRange(`cid:${cid}:tids:lastposttime`, 0, 9) : await db.getSortedSetRevRangeByScore(`cid:${cid}:tids:lastposttime`, 0, 10, '+inf', Date.now() - cutoff); - return _.shuffle(tids.map(Number).filter(_tid => _tid !== tid)); + return _.shuffle(tids.filter(_tid => _tid !== tid)); } }; diff --git a/src/upgrades/3.8.3/remove-session-uuid.js b/src/upgrades/3.8.3/remove-session-uuid.js new file mode 100644 index 0000000000..59a975fce2 --- /dev/null +++ b/src/upgrades/3.8.3/remove-session-uuid.js @@ -0,0 +1,21 @@ +'use strict'; + + +const db = require('../../database'); +const batch = require('../../batch'); + +module.exports = { + name: 'Remove uid::sessionUUID:sessionId object', + timestamp: Date.UTC(2024, 5, 26), + method: async function () { + const { progress } = this; + + await batch.processSortedSet('users:joindate', async (uids) => { + progress.incr(uids.length); + await db.deleteAll(uids.map(uid => `uid:${uid}:sessionUUID:sessionId`)); + }, { + batch: 500, + progress: progress, + }); + }, +}; diff --git a/src/upgrades/3.8.3/topic-event-ids.js b/src/upgrades/3.8.3/topic-event-ids.js new file mode 100644 index 0000000000..b85963db6e --- /dev/null +++ b/src/upgrades/3.8.3/topic-event-ids.js @@ -0,0 +1,38 @@ +/* eslint-disable no-await-in-loop */ + +'use strict'; + +const db = require('../../database'); +const batch = require('../../batch'); + +module.exports = { + name: 'Add id field to all topic events', + timestamp: Date.UTC(2024, 5, 24), + method: async function () { + const { progress } = this; + + let nextId = await db.getObjectField('global', 'nextTopicEventId'); + nextId = parseInt(nextId, 10) || 0; + const ids = []; + for (let i = 1; i < nextId; i++) { + ids.push(i); + } + await batch.processArray(ids, async (eids) => { + const eventData = await db.getObjects(eids.map(eid => `topicEvent:${eid}`)); + const bulkSet = []; + eventData.forEach((event, idx) => { + if (event && event.type) { + const id = eids[idx]; + bulkSet.push( + [`topicEvent:${id}`, { id: id }] + ); + } + }); + await db.setObjectBulk(bulkSet); + progress.incr(eids.length); + }, { + batch: 500, + progress, + }); + }, +}; diff --git a/src/user/auth.js b/src/user/auth.js index 954d00a0c5..0adf589967 100644 --- a/src/user/auth.js +++ b/src/user/auth.js @@ -1,6 +1,5 @@ 'use strict'; -const winston = require('winston'); const validator = require('validator'); const _ = require('lodash'); const db = require('../database'); @@ -77,56 +76,53 @@ module.exports = function (User) { }; async function cleanExpiredSessions(uid) { - const uuidMapping = await db.getObject(`uid:${uid}:sessionUUID:sessionId`); - if (!uuidMapping) { - return; + const sids = await db.getSortedSetRange(`uid:${uid}:sessions`, 0, -1); + if (!sids.length) { + return []; } - const expiredUUIDs = []; + const expiredSids = []; - await Promise.all(Object.keys(uuidMapping).map(async (uuid) => { - const sid = uuidMapping[uuid]; + const activeSids = []; + await Promise.all(sids.map(async (sid) => { const sessionObj = await db.sessionStoreGet(sid); const expired = !sessionObj || !sessionObj.hasOwnProperty('passport') || !sessionObj.passport.hasOwnProperty('user') || parseInt(sessionObj.passport.user, 10) !== parseInt(uid, 10); if (expired) { - expiredUUIDs.push(uuid); expiredSids.push(sid); + } else { + activeSids.push(sid); } })); - await db.deleteObjectFields(`uid:${uid}:sessionUUID:sessionId`, expiredUUIDs); + await db.sortedSetRemove(`uid:${uid}:sessions`, expiredSids); + return activeSids; } - User.auth.addSession = async function (uid, sessionId, uuid) { + User.auth.addSession = async function (uid, sessionId) { if (!(parseInt(uid, 10) > 0)) { return; } - await cleanExpiredSessions(uid); - await Promise.all([ - db.sortedSetAdd(`uid:${uid}:sessions`, Date.now(), sessionId), - db.setObjectField(`uid:${uid}:sessionUUID:sessionId`, uuid, sessionId), - ]); - await revokeSessionsAboveThreshold(uid, meta.config.maxUserSessions); + + const activeSids = await cleanExpiredSessions(uid); + await db.sortedSetAdd(`uid:${uid}:sessions`, Date.now(), sessionId); + await revokeSessionsAboveThreshold(activeSids.push(sessionId), uid); }; - async function revokeSessionsAboveThreshold(uid, maxUserSessions) { - const activeSessions = await db.getSortedSetRange(`uid:${uid}:sessions`, 0, -1); - if (activeSessions.length > maxUserSessions) { - const sessionsToRevoke = activeSessions.slice(0, activeSessions.length - maxUserSessions); - await Promise.all(sessionsToRevoke.map(sessionId => User.auth.revokeSession(sessionId, uid))); + async function revokeSessionsAboveThreshold(activeSids, uid) { + if (meta.config.maxUserSessions > 0 && activeSids.length > meta.config.maxUserSessions) { + const sessionsToRevoke = activeSids.slice(0, activeSids.length - meta.config.maxUserSessions); + await User.auth.revokeSession(sessionsToRevoke, uid); } } - User.auth.revokeSession = async function (sessionId, uid) { - winston.verbose(`[user.auth] Revoking session ${sessionId} for user ${uid}`); - const sessionObj = await db.sessionStoreGet(sessionId); - if (sessionObj && sessionObj.meta && sessionObj.meta.uuid) { - await db.deleteObjectField(`uid:${uid}:sessionUUID:sessionId`, sessionObj.meta.uuid); - } + User.auth.revokeSession = async function (sessionIds, uid) { + sessionIds = Array.isArray(sessionIds) ? sessionIds : [sessionIds]; + const destroySids = sids => Promise.all(sids.map(db.sessionStoreDestroy)); + await Promise.all([ - db.sortedSetRemove(`uid:${uid}:sessions`, sessionId), - db.sessionStoreDestroy(sessionId), + db.sortedSetRemove(`uid:${uid}:sessions`, sessionIds), + destroySids(sessionIds), ]); }; @@ -137,7 +133,7 @@ module.exports = function (User) { uids.forEach((uid, index) => { const ids = sids[index].filter(id => id !== except); if (ids.length) { - promises.push(ids.map(s => User.auth.revokeSession(s, uid))); + promises.push(User.auth.revokeSession(ids, uid)); } }); await Promise.all(promises); @@ -146,11 +142,10 @@ module.exports = function (User) { User.auth.deleteAllSessions = async function () { await batch.processSortedSet('users:joindate', async (uids) => { const sessionKeys = uids.map(uid => `uid:${uid}:sessions`); - const sessionUUIDKeys = uids.map(uid => `uid:${uid}:sessionUUID:sessionId`); const sids = _.flatten(await db.getSortedSetRange(sessionKeys, 0, -1)); await Promise.all([ - db.deleteAll(sessionKeys.concat(sessionUUIDKeys)), + db.deleteAll(sessionKeys), ...sids.map(sid => db.sessionStoreDestroy(sid)), ]); }, { batch: 1000 }); diff --git a/src/user/bans.js b/src/user/bans.js index 465f6300e6..c52a24db6b 100644 --- a/src/user/bans.js +++ b/src/user/bans.js @@ -42,7 +42,7 @@ module.exports = function (User) { await db.sortedSetAdd('users:banned', now, uid); await db.sortedSetAdd(`uid:${uid}:bans:timestamp`, now, banKey); await db.setObject(banKey, banData); - await User.setUserField(uid, 'banned:expire', banData.expire); + await User.setUserFields(uid, { banned: 1, 'banned:expire': banData.expire }); if (until > now) { await db.sortedSetAdd('users:banned:expire', until, uid); } else { @@ -69,7 +69,7 @@ module.exports = function (User) { uids = isArray ? uids : [uids]; const userData = await User.getUsersFields(uids, ['email:confirmed']); - await db.setObject(uids.map(uid => `user:${uid}`), { 'banned:expire': 0 }); + await db.setObject(uids.map(uid => `user:${uid}`), { banned: 0, 'banned:expire': 0 }); const now = Date.now(); const unbanDataArray = []; /* eslint-disable no-await-in-loop */ @@ -124,16 +124,15 @@ module.exports = function (User) { User.bans.unbanIfExpired = async function (uids) { // loading user data will unban if it has expired -barisu - const userData = await User.getUsersFields(uids, ['banned:expire']); + const userData = await User.getUsersFields(uids, ['banned', 'banned:expire']); return User.bans.calcExpiredFromUserData(userData); }; - User.bans.calcExpiredFromUserData = async function (userData) { + User.bans.calcExpiredFromUserData = function (userData) { const isArray = Array.isArray(userData); userData = isArray ? userData : [userData]; - const banned = await groups.isMembers(userData.map(u => u.uid), groups.BANNED_USERS); - userData = userData.map((userData, index) => ({ - banned: banned[index], + userData = userData.map(userData => ({ + banned: !!(userData && userData.banned), 'banned:expire': userData && userData['banned:expire'], banExpired: userData && userData['banned:expire'] <= Date.now() && userData['banned:expire'] !== 0, })); diff --git a/src/user/data.js b/src/user/data.js index c7e2d8b828..d0940ff98e 100644 --- a/src/user/data.js +++ b/src/user/data.js @@ -44,6 +44,8 @@ module.exports = function (User) { 'email:confirmed': 0, }; + let iconBackgrounds; + User.getUsersFields = async function (uids, fields) { if (!Array.isArray(uids) || !uids.length) { return []; @@ -120,7 +122,9 @@ module.exports = function (User) { user.username = (user.hasOwnProperty('oldUid') && parseInt(user.oldUid, 10)) ? '[[global:former-user]]' : '[[global:guest]]'; user.displayname = user.username; } - + if (uid === -1) { // if loading spider set uid to -1 otherwise spiders have uid = 0 like guests + user.uid = -1; + } return user; }); return users; @@ -185,8 +189,12 @@ module.exports = function (User) { ['showfullname'] )); } + if (!iconBackgrounds) { + iconBackgrounds = await User.getIconBackgrounds(); + } - await Promise.all(users.map(async (user) => { + const unbanUids = []; + users.forEach((user) => { if (!user) { return; } @@ -202,7 +210,7 @@ module.exports = function (User) { user.email = validator.escape(user.email ? user.email.toString() : ''); } - if (!parseInt(user.uid, 10)) { + if (!user.uid) { for (const [key, value] of Object.entries(User.guestData)) { user[key] = value; } @@ -232,15 +240,12 @@ module.exports = function (User) { } // User Icons - if (requestedFields.includes('picture') && user.username && parseInt(user.uid, 10) && !meta.config.defaultAvatar) { - const iconBackgrounds = await User.getIconBackgrounds(user.uid); - let bgColor = await User.getUserField(user.uid, 'icon:bgColor'); - if (!iconBackgrounds.includes(bgColor)) { - bgColor = Array.prototype.reduce.call(user.username, (cur, next) => cur + next.charCodeAt(), 0); - bgColor = iconBackgrounds[bgColor % iconBackgrounds.length]; + if (requestedFields.includes('picture') && user.username && user.uid && !meta.config.defaultAvatar) { + if (!iconBackgrounds.includes(user['icon:bgColor'])) { + const nameAsIndex = Array.from(user.username).reduce((cur, next) => cur + next.charCodeAt(), 0); + user['icon:bgColor'] = iconBackgrounds[nameAsIndex % iconBackgrounds.length]; } user['icon:text'] = (user.username[0] || '').toUpperCase(); - user['icon:bgColor'] = bgColor; } if (user.hasOwnProperty('joindate')) { @@ -251,22 +256,25 @@ module.exports = function (User) { user.lastonlineISO = utils.toISOString(user.lastonline) || user.joindateISO; } + if (user.hasOwnProperty('mutedUntil')) { + user.muted = user.mutedUntil > Date.now(); + } + if (user.hasOwnProperty('banned') || user.hasOwnProperty('banned:expire')) { - const result = await User.bans.calcExpiredFromUserData(user); + const result = User.bans.calcExpiredFromUserData(user); user.banned = result.banned; const unban = result.banned && result.banExpired; user.banned_until = unban ? 0 : user['banned:expire']; user.banned_until_readable = user.banned_until && !unban ? utils.toISOString(user.banned_until) : 'Not Banned'; if (unban) { - await User.bans.unban(user.uid, '[[user:info.ban-expired]]'); + unbanUids.push(user.uid); user.banned = false; } } - - if (user.hasOwnProperty('mutedUntil')) { - user.muted = user.mutedUntil > Date.now(); - } - })); + }); + if (unbanUids.length) { + await User.bans.unban(unbanUids, '[[user:info.ban-expired]]'); + } return await plugins.hooks.fire('filter:users.get', users); } @@ -311,14 +319,20 @@ module.exports = function (User) { } } - User.getIconBackgrounds = async (uid = 0) => { - let iconBackgrounds = [ + + User.getIconBackgrounds = async () => { + if (iconBackgrounds) { + return iconBackgrounds; + } + + const _iconBackgrounds = [ '#f44336', '#e91e63', '#9c27b0', '#673ab7', '#3f51b5', '#2196f3', '#009688', '#1b5e20', '#33691e', '#827717', '#e65100', '#ff5722', '#795548', '#607d8b', ]; - ({ iconBackgrounds } = await plugins.hooks.fire('filter:user.iconBackgrounds', { uid, iconBackgrounds })); + const data = await plugins.hooks.fire('filter:user.iconBackgrounds', { iconBackgrounds: _iconBackgrounds }); + iconBackgrounds = data.iconBackgrounds; return iconBackgrounds; }; diff --git a/src/user/delete.js b/src/user/delete.js index 9efd8802ae..8f99117c59 100644 --- a/src/user/delete.js +++ b/src/user/delete.js @@ -119,7 +119,7 @@ module.exports = function (User) { `uid:${uid}:chat:rooms:read`, `uid:${uid}:upvote`, `uid:${uid}:downvote`, `uid:${uid}:flag:pids`, - `uid:${uid}:sessions`, `uid:${uid}:sessionUUID:sessionId`, + `uid:${uid}:sessions`, `invitation:uid:${uid}`, ]; @@ -209,18 +209,22 @@ module.exports = function (User) { ]); async function updateCount(uids, name, fieldName) { - await async.each(uids, async (uid) => { - let count = await db.sortedSetCard(name + uid); - count = parseInt(count, 10) || 0; - await db.setObjectField(`user:${uid}`, fieldName, count); + await batch.processArray(uids, async (uids) => { + const counts = await db.sortedSetsCard(uids.map(uid => name + uid)); + const bulkSet = counts.map( + (count, index) => ([`user:${uids[index]}`, { [fieldName]: count || 0 }]) + ); + await db.setObjectBulk(bulkSet); + }, { + batch: 500, }); } const followingSets = followers.map(uid => `following:${uid}`); const followerSets = following.map(uid => `followers:${uid}`); + await db.sortedSetsRemove(followerSets.concat(followingSets), uid); await Promise.all([ - db.sortedSetsRemove(followerSets.concat(followingSets), uid), updateCount(following, 'followers:', 'followerCount'), updateCount(followers, 'following:', 'followingCount'), ]); diff --git a/src/user/index.js b/src/user/index.js index 25f90c906b..5922fea7b7 100644 --- a/src/user/index.js +++ b/src/user/index.js @@ -50,8 +50,12 @@ User.exists = async function (uids) { }; User.existsBySlug = async function (userslug) { - const exists = await User.getUidByUserslug(userslug); - return !!exists; + if (Array.isArray(userslug)) { + const uids = await User.getUidsByUserslugs(userslug); + return uids.map(uid => !!uid); + } + const uid = await User.getUidByUserslug(userslug); + return !!uid; }; User.getUidsFromSet = async function (set, start, stop) { @@ -112,6 +116,10 @@ User.getUidByUserslug = async function (userslug) { return await db.sortedSetScore('userslug:uid', userslug); }; +User.getUidsByUserslugs = async function (userslugs) { + return await db.sortedSetScores('userslug:uid', userslugs); +}; + User.getUsernamesByUids = async function (uids) { const users = await User.getUsersFields(uids, ['username']); return users.map(user => user.username); diff --git a/src/views/admin/development/info.tpl b/src/views/admin/development/info.tpl index 7440729fab..493e16ac93 100644 --- a/src/views/admin/development/info.tpl +++ b/src/views/admin/development/info.tpl @@ -6,51 +6,52 @@
    [[admin/development/info:nodes-responded, {nodeCount}, {timeout}]] - - - +
    +
    + + + + + + + + + + + + + + + + {{{ each info }}} - - - - - - - - - - + + + + + + + + + + - - - {{{ each info }}} - - - - - - - - - - - - - {{{ end }}} - -
    [[admin/development/info:host]][[admin/development/info:primary]][[admin/development/info:nodejs]][[admin/development/info:online]][[admin/development/info:git]][[admin/development/info:cpu-usage]][[admin/development/info:process-memory]][[admin/development/info:system-memory]][[admin/development/info:load]][[admin/development/info:uptime]]
    [[admin/development/info:host]][[admin/development/info:primary]][[admin/development/info:nodejs]][[admin/development/info:online]][[admin/development/info:git]][[admin/development/info:cpu-usage]][[admin/development/info:process-memory]][[admin/development/info:system-memory]][[admin/development/info:load]][[admin/development/info:uptime]]{info.os.hostname}:{info.process.port} + {{{if info.nodebb.isPrimary}}}{{{else}}}{{{end}}} / + {{{if info.nodebb.runJobs}}}{{{else}}}{{{end}}} + {info.process.version} + {info.stats.onlineRegisteredCount} / + {info.stats.onlineGuestCount} / + {info.stats.socketCount} / + {info.stats.connectionCount} + {info.git.branch}@{info.git.hashShort}{info.process.cpuUsage}% + {info.process.memoryUsage.humanReadable} gb + + {info.os.usedmem} gb / + {info.os.totalmem} gb + {info.os.load}{info.process.uptimeHumanReadable}
    {info.os.hostname}:{info.process.port} - {{{if info.nodebb.isPrimary}}}{{{else}}}{{{end}}} / - {{{if info.nodebb.runJobs}}}{{{else}}}{{{end}}} - {info.process.version} - {info.stats.onlineRegisteredCount} / - {info.stats.onlineGuestCount} / - {info.stats.socketCount} - {info.git.branch}@{info.git.hashShort}{info.process.cpuUsage}% - {info.process.memoryUsage.humanReadable} gb - - {info.os.usedmem} gb / - {info.os.totalmem} gb - {info.os.load}{info.process.uptimeHumanReadable}
    + {{{ end }}} + +
    diff --git a/src/views/admin/extend/widgets.tpl b/src/views/admin/extend/widgets.tpl index ae1effdc37..5a7f86d86d 100644 --- a/src/views/admin/extend/widgets.tpl +++ b/src/views/admin/extend/widgets.tpl @@ -121,15 +121,15 @@
    -
    +
    [[admin/extend/widgets:container.card-header]] -
    - + + + + +
    @@ -138,9 +138,9 @@
    -
    +
    [[admin/extend/widgets:container.alert]] -
    +
    diff --git a/src/webserver.js b/src/webserver.js index ff5031ff41..f492a0da02 100644 --- a/src/webserver.js +++ b/src/webserver.js @@ -18,7 +18,7 @@ const cookieParser = require('cookie-parser'); const session = require('express-session'); const useragent = require('express-useragent'); const favicon = require('serve-favicon'); -const detector = require('spider-detector'); +const detector = require('@nodebb/spider-detector'); const helmet = require('helmet'); const Benchpress = require('benchpressjs'); @@ -76,6 +76,10 @@ exports.destroy = function (callback) { } }; +exports.getConnectionCount = function () { + return Object.keys(connections).length; +}; + exports.listen = async function () { emailer.registerApp(app); setupExpressApp(app); @@ -182,7 +186,6 @@ function setupExpressApp(app) { req: apiHelpers.buildReqObject(req), }, next); }); - app.use(middleware.autoLocale); // must be added after auth middlewares are added const toobusy = require('toobusy-js'); toobusy.maxLag(meta.config.eventLoopLagThreshold); diff --git a/test/api.js b/test/api.js index 47961742ff..0ea9918953 100644 --- a/test/api.js +++ b/test/api.js @@ -562,8 +562,10 @@ describe('API', async () => { const reloginPaths = ['GET /api/user/{userslug}/edit/email', 'PUT /users/{uid}/password', 'DELETE /users/{uid}/sessions/{uuid}']; if (reloginPaths.includes(`${method.toUpperCase()} ${path}`)) { ({ jar } = await helpers.loginUser('admin', '123456')); - const sessionUUIDs = await db.getObject('uid:1:sessionUUID:sessionId'); - mocks.delete['/users/{uid}/sessions/{uuid}'][1].example = Object.keys(sessionUUIDs).pop(); + const sessionIds = await db.getSortedSetRange('uid:1:sessions', 0, -1); + const sessObj = await db.sessionStoreGet(sessionIds[0]); + const { uuid } = sessObj.meta; + mocks.delete['/users/{uid}/sessions/{uuid}'][1].example = uuid; // Retrieve CSRF token using cookie, to test Write API csrfToken = await helpers.getCsrfToken(jar); diff --git a/test/authentication.js b/test/authentication.js index 1dcbe176a8..193d617435 100644 --- a/test/authentication.js +++ b/test/authentication.js @@ -195,7 +195,7 @@ describe('authentication', () => { }); assert(body); assert.equal(body.username, username); - const sessions = await db.getObject(`uid:${uid}:sessionUUID:sessionId`); + const sessions = await db.getSortedSetRange(`uid:${uid}:sessions`, 0, -1); assert(sessions); assert(Object.keys(sessions).length > 0); }); diff --git a/test/database/keys.js b/test/database/keys.js index fde4bbc442..984a5e7a66 100644 --- a/test/database/keys.js +++ b/test/database/keys.js @@ -64,12 +64,15 @@ describe('Key methods', () => { }); }); - it('should work for an array of keys', (done) => { - db.exists(['testKey', 'doesnotexist'], (err, exists) => { - assert.ifError(err); - assert.deepStrictEqual(exists, [true, false]); - done(); - }); + it('should work for an array of keys', async () => { + assert.deepStrictEqual( + await db.exists(['testKey', 'doesnotexist']), + [true, false] + ); + assert.deepStrictEqual( + await db.exists([]), + [] + ); }); describe('scan', () => { diff --git a/test/database/sorted.js b/test/database/sorted.js index 36d4534a91..33d3e4c4b5 100644 --- a/test/database/sorted.js +++ b/test/database/sorted.js @@ -1,29 +1,17 @@ 'use strict'; - -const async = require('async'); const assert = require('assert'); const db = require('../mocks/databasemock'); describe('Sorted Set methods', () => { - before((done) => { - async.parallel([ - function (next) { - db.sortedSetAdd('sortedSetTest1', [1.1, 1.2, 1.3], ['value1', 'value2', 'value3'], next); - }, - function (next) { - db.sortedSetAdd('sortedSetTest2', [1, 4], ['value1', 'value4'], next); - }, - function (next) { - db.sortedSetAdd('sortedSetTest3', [2, 4], ['value2', 'value4'], next); - }, - function (next) { - db.sortedSetAdd('sortedSetTest4', [1, 1, 2, 3, 5], ['b', 'a', 'd', 'e', 'c'], next); - }, - function (next) { - db.sortedSetAdd('sortedSetLex', [0, 0, 0, 0], ['a', 'b', 'c', 'd'], next); - }, - ], done); + before(async () => { + await Promise.all([ + db.sortedSetAdd('sortedSetTest1', [1.1, 1.2, 1.3], ['value1', 'value2', 'value3']), + db.sortedSetAdd('sortedSetTest2', [1, 4], ['value1', 'value4']), + db.sortedSetAdd('sortedSetTest3', [2, 4], ['value2', 'value4']), + db.sortedSetAdd('sortedSetTest4', [1, 1, 2, 3, 5], ['b', 'a', 'd', 'e', 'c']), + db.sortedSetAdd('sortedSetLex', [0, 0, 0, 0], ['a', 'b', 'c', 'd']), + ]); }); describe('sortedSetScan', () => { @@ -617,6 +605,23 @@ describe('Sorted Set methods', () => { done(); }); }); + + it('should work with min/max', async () => { + let count = await db.sortedSetsCardSum([ + 'sortedSetTest1', 'sortedSetTest2', 'sortedSetTest3', + ], '-inf', 2); + assert.strictEqual(count, 5); + + count = await db.sortedSetsCardSum([ + 'sortedSetTest1', 'sortedSetTest2', 'sortedSetTest3', + ], 2, '+inf'); + assert.strictEqual(count, 3); + + count = await db.sortedSetsCardSum([ + 'sortedSetTest1', 'sortedSetTest2', 'sortedSetTest3', + ], '-inf', '+inf'); + assert.strictEqual(count, 7); + }); }); describe('sortedSetRank()', () => { @@ -1225,11 +1230,11 @@ describe('Sorted Set methods', () => { }); describe('sortedSetsRemove()', () => { - before((done) => { - async.parallel([ - async.apply(db.sortedSetAdd, 'sorted4', [1, 2], ['value1', 'value2']), - async.apply(db.sortedSetAdd, 'sorted5', [1, 2], ['value1', 'value3']), - ], done); + before(async () => { + await Promise.all([ + db.sortedSetAdd('sorted4', [1, 2], ['value1', 'value2']), + db.sortedSetAdd('sorted5', [1, 2], ['value1', 'value3']), + ]); }); it('should remove element from multiple sorted sets', (done) => { @@ -1278,15 +1283,11 @@ describe('Sorted Set methods', () => { }); describe('getSortedSetIntersect', () => { - before((done) => { - async.parallel([ - function (next) { - db.sortedSetAdd('interSet1', [1, 2, 3], ['value1', 'value2', 'value3'], next); - }, - function (next) { - db.sortedSetAdd('interSet2', [4, 5, 6], ['value2', 'value3', 'value5'], next); - }, - ], done); + before(async () => { + await Promise.all([ + db.sortedSetAdd('interSet1', [1, 2, 3], ['value1', 'value2', 'value3']), + db.sortedSetAdd('interSet2', [4, 5, 6], ['value2', 'value3', 'value5']), + ]); }); it('should return the intersection of two sets', (done) => { @@ -1446,21 +1447,13 @@ describe('Sorted Set methods', () => { }); describe('sortedSetIntersectCard', () => { - before((done) => { - async.parallel([ - function (next) { - db.sortedSetAdd('interCard1', [0, 0, 0], ['value1', 'value2', 'value3'], next); - }, - function (next) { - db.sortedSetAdd('interCard2', [0, 0, 0], ['value2', 'value3', 'value4'], next); - }, - function (next) { - db.sortedSetAdd('interCard3', [0, 0, 0], ['value3', 'value4', 'value5'], next); - }, - function (next) { - db.sortedSetAdd('interCard4', [0, 0, 0], ['value4', 'value5', 'value6'], next); - }, - ], done); + before(async () => { + await Promise.all([ + db.sortedSetAdd('interCard1', [0, 0, 0], ['value1', 'value2', 'value3']), + db.sortedSetAdd('interCard2', [0, 0, 0], ['value2', 'value3', 'value4']), + db.sortedSetAdd('interCard3', [0, 0, 0], ['value3', 'value4', 'value5']), + db.sortedSetAdd('interCard4', [0, 0, 0], ['value4', 'value5', 'value6']), + ]); }); it('should return # of elements in intersection', (done) => { diff --git a/test/mocks/databasemock.js b/test/mocks/databasemock.js index 5cb8af1d34..507f29d6fc 100644 --- a/test/mocks/databasemock.js +++ b/test/mocks/databasemock.js @@ -194,7 +194,7 @@ async function setupMockDefaults() { meta.config.autoDetectLang = 0; require('../../src/groups').cache.reset(); - require('../../src/posts/cache').reset(); + require('../../src/posts/cache').getOrCreate().reset(); require('../../src/cache').reset(); require('../../src/middleware/uploads').clearCache(); // privileges must be given after cache reset diff --git a/test/posts.js b/test/posts.js index e52b5cdf23..20403e24cf 100644 --- a/test/posts.js +++ b/test/posts.js @@ -184,8 +184,8 @@ describe('Post\'s', () => { it('should get upvoters', (done) => { socketPosts.getUpvoters({ uid: globalModUid }, [postData.pid], (err, data) => { assert.ifError(err); - assert.equal(data[0].otherCount, 0); - assert.equal(data[0].usernames, 'upvoter'); + assert.equal(data.otherCount, 0); + assert.equal(data.usernames, 'upvoter'); done(); }); }); diff --git a/test/socket.io.js b/test/socket.io.js index eacab90ac5..6c0a5a2367 100644 --- a/test/socket.io.js +++ b/test/socket.io.js @@ -740,7 +740,7 @@ describe('socket.io', () => { it('should toggle caches', async () => { const caches = { - post: require('../src/posts/cache'), + post: require('../src/posts/cache').getOrCreate(), object: require('../src/database').objectCache, group: require('../src/groups').cache, local: require('../src/cache'), diff --git a/test/user.js b/test/user.js index 668ec4ec8b..25c0ddc6f0 100644 --- a/test/user.js +++ b/test/user.js @@ -607,7 +607,7 @@ describe('User', () => { it('should return an icon text and valid background if username and picture is explicitly requested', async () => { const payload = await User.getUserFields(testUid, ['username', 'picture']); - const validBackgrounds = await User.getIconBackgrounds(testUid); + const validBackgrounds = await User.getIconBackgrounds(); assert.strictEqual(payload['icon:text'], userData.username.slice(0, 1).toUpperCase()); assert(payload['icon:bgColor']); assert(validBackgrounds.includes(payload['icon:bgColor'])); @@ -616,7 +616,7 @@ describe('User', () => { it('should return a valid background, even if an invalid background colour is set', async () => { await User.setUserField(testUid, 'icon:bgColor', 'teal'); const payload = await User.getUserFields(testUid, ['username', 'picture']); - const validBackgrounds = await User.getIconBackgrounds(testUid); + const validBackgrounds = await User.getIconBackgrounds(); assert(payload['icon:bgColor']); assert(validBackgrounds.includes(payload['icon:bgColor'])); @@ -1492,28 +1492,18 @@ describe('User', () => { }); }); - it('should return true if user/group exists', (done) => { - meta.userOrGroupExists('registered-users', (err, exists) => { - assert.ifError(err); - assert(exists); - done(); - }); - }); + it('should return true/false if user/group exists or not', async () => { + assert.strictEqual(await meta.userOrGroupExists('registered-users'), true); + assert.strictEqual(await meta.userOrGroupExists('John Smith'), true); + assert.strictEqual(await meta.userOrGroupExists('doesnot exist'), false); + assert.deepStrictEqual(await meta.userOrGroupExists(['doesnot exist', 'nope not here']), [false, false]); + assert.deepStrictEqual(await meta.userOrGroupExists(['doesnot exist', 'John Smith']), [false, true]); + assert.deepStrictEqual(await meta.userOrGroupExists(['administrators', 'John Smith']), [true, true]); - it('should return true if user/group exists', (done) => { - meta.userOrGroupExists('John Smith', (err, exists) => { - assert.ifError(err); - assert(exists); - done(); - }); - }); - - it('should return false if user/group does not exists', (done) => { - meta.userOrGroupExists('doesnot exist', (err, exists) => { - assert.ifError(err); - assert(!exists); - done(); - }); + await assert.rejects( + meta.userOrGroupExists(['', undefined]), + { message: '[[error:invalid-data]]' }, + ); }); it('should delete user', async () => {