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}]]
-
-
-
+
+
+
+
+ | [[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]] |
+
+
+
+ {{{ each info }}}
- | [[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} |
-
-
- {{{ each info }}}
-
- | {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 }}}
-
-
+ {{{ 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 @@
-