Merge commit '61e5293a76aafe9e09f3c665ac9f514a94b4769d' into v3.x

This commit is contained in:
Misty Release Bot
2024-06-27 15:09:30 +00:00
100 changed files with 1277 additions and 771 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -3,7 +3,7 @@
"ip": "IP <strong>%1</strong>",
"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"

View File

@@ -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"
}

View File

@@ -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.

View File

@@ -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:

View File

@@ -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

View File

@@ -116,6 +116,8 @@ get:
type: number
socketCount:
type: number
connectionCount:
type: number
users:
type: object
properties:

View File

@@ -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:

View File

@@ -382,6 +382,8 @@ get:
type: number
downvote:disabled:
type: number
voteVisibility:
type: string
feeds:disableRSS:
type: number
signatures:hideDuplicates:

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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; }
}
}

View File

@@ -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) {

View File

@@ -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 () {

View File

@@ -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(`<li><a href="${href}">${href}</a></li>`);
});
linkContainer.toggleClass('hidden', !linksInPost.length);
});
}
function confirmReject(msg) {
return new Promise((resolve) => {
bootbox.confirm(msg, resolve);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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();
});

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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 += '<img ' + attr2String(attributes) + ' src="' + userObj.picture + '" style="' + styles.join(' ') + '" onError="this.remove();" itemprop="image" />';
output += `<img${attr2String(attributes)} alt="${userObj.username}" loading="lazy" component="${component || 'avatar/picture'}" src="${userObj.picture}" style="${styles.join(' ')}" onError="this.remove()" itemprop="image" />`;
}
attributes.set('component', component || 'avatar/icon');
styles.push('background-color: ' + userObj['icon:bgColor'] + ';');
output += '<span ' + attr2String(attributes) + ' style="' + styles.join(' ') + '">' + userObj['icon:text'] + '</span>';
output += `<span${attr2String(attributes)} component="${component || 'avatar/icon'}" style="${styles.join(' ')} background-color: ${userObj['icon:bgColor']}">${userObj['icon:text']}</span>`;
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 '<li class="dropdown-divider"></li>';
}
return `
<li class="dropdown-item placeholder-wave">
<div class="placeholder" style="width: 20px;"></div>
<div class="placeholder col-${i}"></div>
</li>`;
});
return html;
}
function register() {
Object.keys(helpers).forEach(function (helperName) {
Benchpress.registerHelper(helperName, helpers[helperName]);

View File

@@ -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);
};

View File

@@ -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('');

View File

@@ -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;
}

View File

@@ -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);
};

View File

@@ -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];
}

View File

@@ -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,

View File

@@ -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([

View File

@@ -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',

View File

@@ -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) {

View File

@@ -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';

View File

@@ -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];

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -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);
};

View File

@@ -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();
}

View File

@@ -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([

View File

@@ -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),

View File

@@ -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;

View File

@@ -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'),

View File

@@ -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,

View File

@@ -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'),

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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 () {

View File

@@ -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;
});

View File

@@ -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) {

View File

@@ -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;
};

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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')) {

View File

@@ -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,
});
}
}

View File

@@ -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);

View File

@@ -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();
});

View File

@@ -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 = {

View File

@@ -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]]']));

View File

@@ -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/<lowercaseslug>
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();
};

View File

@@ -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;
}

View File

@@ -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();
}
};

View File

@@ -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) {

View File

@@ -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),

View File

@@ -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);

View File

@@ -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)));

View File

@@ -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));

View File

@@ -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);

View File

@@ -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,

View File

@@ -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);

View File

@@ -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'),

View File

@@ -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({

View File

@@ -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,

View File

@@ -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');
}

View File

@@ -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];
}
};

View File

@@ -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],
});

View File

@@ -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));
}
};

View File

@@ -0,0 +1,21 @@
'use strict';
const db = require('../../database');
const batch = require('../../batch');
module.exports = {
name: 'Remove uid:<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,
});
},
};

View File

@@ -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,
});
},
};

View File

@@ -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 });

View File

@@ -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,
}));

View File

@@ -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;
};

View File

@@ -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'),
]);

View File

@@ -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);

View File

@@ -6,51 +6,52 @@
<div class="card-body">
<span>[[admin/development/info:nodes-responded, {nodeCount}, {timeout}]]</span>
<table class="table table-sm text-sm">
<thead>
<div class="table-responsive">
<table class="table table-sm text-sm">
<thead>
<tr>
<td class="fw-bold">[[admin/development/info:host]]</td>
<td class="fw-bold text-center">[[admin/development/info:primary]]</td>
<td class="fw-bold">[[admin/development/info:nodejs]]</td>
<td class="fw-bold">[[admin/development/info:online]]</td>
<td class="fw-bold">[[admin/development/info:git]]</td>
<td class="fw-bold">[[admin/development/info:cpu-usage]]</td>
<td class="fw-bold">[[admin/development/info:process-memory]]</td>
<td class="fw-bold">[[admin/development/info:system-memory]]</td>
<td class="fw-bold">[[admin/development/info:load]]</td>
<td class="fw-bold text-end">[[admin/development/info:uptime]]</td>
</tr>
</thead>
<tbody class="text-xs">
{{{ each info }}}
<tr>
<td class="fw-bold">[[admin/development/info:host]]</td>
<td class="fw-bold text-center">[[admin/development/info:primary]]</td>
<td class="fw-bold">[[admin/development/info:nodejs]]</td>
<td class="fw-bold">[[admin/development/info:online]]</td>
<td class="fw-bold">[[admin/development/info:git]]</td>
<td class="fw-bold">[[admin/development/info:cpu-usage]]</td>
<td class="fw-bold">[[admin/development/info:process-memory]]</td>
<td class="fw-bold">[[admin/development/info:system-memory]]</td>
<td class="fw-bold">[[admin/development/info:load]]</td>
<td class="fw-bold">[[admin/development/info:uptime]]</td>
<td>{info.os.hostname}:{info.process.port}</td>
<td class="text-center">
{{{if info.nodebb.isPrimary}}}<i class="fa fa-check"></i>{{{else}}}<i class="fa fa-times"></i>{{{end}}} /
{{{if info.nodebb.runJobs}}}<i class="fa fa-check"></i>{{{else}}}<i class="fa fa-times"></i>{{{end}}}
</td>
<td>{info.process.version}</td>
<td>
<span title="[[admin/development/info:registered]]">{info.stats.onlineRegisteredCount}</span> /
<span title="[[admin/development/info:guests]]">{info.stats.onlineGuestCount}</span> /
<span title="[[admin/development/info:sockets]]">{info.stats.socketCount}</span> /
<span title="[[admin/development/info:connection-count]]">{info.stats.connectionCount}</span>
</td>
<td>{info.git.branch}@<a href="https://github.com/NodeBB/NodeBB/commit/{info.git.hash}" target="_blank">{info.git.hashShort}</a></td>
<td>{info.process.cpuUsage}%</td>
<td>
<span title="[[admin/development/info:used-memory-process]]">{info.process.memoryUsage.humanReadable} gb</span>
</td>
<td>
<span title="[[admin/development/info:used-memory-os]]">{info.os.usedmem} gb</span> /
<span title="[[admin/development/info:total-memory-os]]">{info.os.totalmem} gb</span>
</td>
<td>{info.os.load}</td>
<td class="text-end">{info.process.uptimeHumanReadable}</td>
</tr>
</thead>
<tbody class="text-xs">
{{{ each info }}}
<tr>
<td>{info.os.hostname}:{info.process.port}</td>
<td class="text-center">
{{{if info.nodebb.isPrimary}}}<i class="fa fa-check"></i>{{{else}}}<i class="fa fa-times"></i>{{{end}}} /
{{{if info.nodebb.runJobs}}}<i class="fa fa-check"></i>{{{else}}}<i class="fa fa-times"></i>{{{end}}}
</td>
<td>{info.process.version}</td>
<td>
<span title="[[admin/development/info:registered]]">{info.stats.onlineRegisteredCount}</span> /
<span title="[[admin/development/info:guests]]">{info.stats.onlineGuestCount}</span> /
<span title="[[admin/development/info:sockets]]">{info.stats.socketCount}</span>
</td>
<td>{info.git.branch}@<a href="https://github.com/NodeBB/NodeBB/commit/{info.git.hash}" target="_blank">{info.git.hashShort}</a></td>
<td>{info.process.cpuUsage}%</td>
<td>
<span title="[[admin/development/info:used-memory-process]]">{info.process.memoryUsage.humanReadable} gb</span>
</td>
<td>
<span title="[[admin/development/info:used-memory-os]]">{info.os.usedmem} gb</span> /
<span title="[[admin/development/info:total-memory-os]]">{info.os.totalmem} gb</span>
</td>
<td>{info.os.load}</td>
<td>{info.process.uptimeHumanReadable}</td>
</tr>
{{{ end }}}
</tbody>
</table>
{{{ end }}}
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -121,15 +121,15 @@
</div>
</div>
<div class="card mb-3 pointer" data-container-html='<div class="card"><h5 class="card-header">\{{title}}</h5><div class="card-body">\{{body}}</div></div>'>
<div class="card-header d-flex justify-content-between">
<div class="card-header d-flex justify-content-between text-nowrap flex-wrap align-items-center">
[[admin/extend/widgets:container.card-header]]
<div class="d-flex gap-1 color-selector">
<button data-class="text-bg-primary" class="btn btn-sm btn-primary"</button>
<button data-class="" class="btn btn-sm btn-secondary"</button>
<button data-class="text-bg-success" class="btn btn-sm btn-success"</button>
<button data-class="text-bg-info" class="btn btn-sm btn-info"</button>
<button data-class="text-bg-warning" class="btn btn-sm btn-warning"</button>
<button data-class="text-bg-danger" class="btn btn-sm btn-danger"</button>
<div class="d-flex gap-1 color-selector" style="height: 18px;">
<button data-class="text-bg-primary" class="btn btn-sm btn-primary"></button>
<button data-class="" class="btn btn-sm btn-secondary"></button>
<button data-class="text-bg-success" class="btn btn-sm btn-success"></button>
<button data-class="text-bg-info" class="btn btn-sm btn-info"></button>
<button data-class="text-bg-warning" class="btn btn-sm btn-warning"></button>
<button data-class="text-bg-danger" class="btn btn-sm btn-danger"></button>
</div>
</div>
<div class="card-body">
@@ -138,9 +138,9 @@
</div>
<div class="alert alert-info pointer" data-container-html='<div class="alert alert-info">\{{body}}</div>'>
<div class="d-flex justify-content-between">
<div class="d-flex justify-content-between text-nowrap flex-wrap align-items-center">
[[admin/extend/widgets:container.alert]]
<div class="d-flex gap-1 color-selector">
<div class="d-flex gap-1 color-selector" style="height: 18px;">
<button data-class="alert-success" class="btn btn-sm btn-success"></button>
<button data-class="alert-info" class="btn btn-sm btn-info"></button>
<button data-class="alert-warning" class="btn btn-sm btn-warning"></button>

View File

@@ -18,7 +18,7 @@ const cookieParser = require('cookie-parser');
const session = require('express-session');
const useragent = require('express-useragent');
const favicon = require('serve-favicon');
const detector = require('spider-detector');
const detector = require('@nodebb/spider-detector');
const helmet = require('helmet');
const Benchpress = require('benchpressjs');
@@ -76,6 +76,10 @@ exports.destroy = function (callback) {
}
};
exports.getConnectionCount = function () {
return Object.keys(connections).length;
};
exports.listen = async function () {
emailer.registerApp(app);
setupExpressApp(app);
@@ -182,7 +186,6 @@ function setupExpressApp(app) {
req: apiHelpers.buildReqObject(req),
}, next);
});
app.use(middleware.autoLocale); // must be added after auth middlewares are added
const toobusy = require('toobusy-js');
toobusy.maxLag(meta.config.eventLoopLagThreshold);

View File

@@ -562,8 +562,10 @@ describe('API', async () => {
const reloginPaths = ['GET /api/user/{userslug}/edit/email', 'PUT /users/{uid}/password', 'DELETE /users/{uid}/sessions/{uuid}'];
if (reloginPaths.includes(`${method.toUpperCase()} ${path}`)) {
({ jar } = await helpers.loginUser('admin', '123456'));
const sessionUUIDs = await db.getObject('uid:1:sessionUUID:sessionId');
mocks.delete['/users/{uid}/sessions/{uuid}'][1].example = Object.keys(sessionUUIDs).pop();
const sessionIds = await db.getSortedSetRange('uid:1:sessions', 0, -1);
const sessObj = await db.sessionStoreGet(sessionIds[0]);
const { uuid } = sessObj.meta;
mocks.delete['/users/{uid}/sessions/{uuid}'][1].example = uuid;
// Retrieve CSRF token using cookie, to test Write API
csrfToken = await helpers.getCsrfToken(jar);

View File

@@ -195,7 +195,7 @@ describe('authentication', () => {
});
assert(body);
assert.equal(body.username, username);
const sessions = await db.getObject(`uid:${uid}:sessionUUID:sessionId`);
const sessions = await db.getSortedSetRange(`uid:${uid}:sessions`, 0, -1);
assert(sessions);
assert(Object.keys(sessions).length > 0);
});

View File

@@ -64,12 +64,15 @@ describe('Key methods', () => {
});
});
it('should work for an array of keys', (done) => {
db.exists(['testKey', 'doesnotexist'], (err, exists) => {
assert.ifError(err);
assert.deepStrictEqual(exists, [true, false]);
done();
});
it('should work for an array of keys', async () => {
assert.deepStrictEqual(
await db.exists(['testKey', 'doesnotexist']),
[true, false]
);
assert.deepStrictEqual(
await db.exists([]),
[]
);
});
describe('scan', () => {

View File

@@ -1,29 +1,17 @@
'use strict';
const async = require('async');
const assert = require('assert');
const db = require('../mocks/databasemock');
describe('Sorted Set methods', () => {
before((done) => {
async.parallel([
function (next) {
db.sortedSetAdd('sortedSetTest1', [1.1, 1.2, 1.3], ['value1', 'value2', 'value3'], next);
},
function (next) {
db.sortedSetAdd('sortedSetTest2', [1, 4], ['value1', 'value4'], next);
},
function (next) {
db.sortedSetAdd('sortedSetTest3', [2, 4], ['value2', 'value4'], next);
},
function (next) {
db.sortedSetAdd('sortedSetTest4', [1, 1, 2, 3, 5], ['b', 'a', 'd', 'e', 'c'], next);
},
function (next) {
db.sortedSetAdd('sortedSetLex', [0, 0, 0, 0], ['a', 'b', 'c', 'd'], next);
},
], done);
before(async () => {
await Promise.all([
db.sortedSetAdd('sortedSetTest1', [1.1, 1.2, 1.3], ['value1', 'value2', 'value3']),
db.sortedSetAdd('sortedSetTest2', [1, 4], ['value1', 'value4']),
db.sortedSetAdd('sortedSetTest3', [2, 4], ['value2', 'value4']),
db.sortedSetAdd('sortedSetTest4', [1, 1, 2, 3, 5], ['b', 'a', 'd', 'e', 'c']),
db.sortedSetAdd('sortedSetLex', [0, 0, 0, 0], ['a', 'b', 'c', 'd']),
]);
});
describe('sortedSetScan', () => {
@@ -617,6 +605,23 @@ describe('Sorted Set methods', () => {
done();
});
});
it('should work with min/max', async () => {
let count = await db.sortedSetsCardSum([
'sortedSetTest1', 'sortedSetTest2', 'sortedSetTest3',
], '-inf', 2);
assert.strictEqual(count, 5);
count = await db.sortedSetsCardSum([
'sortedSetTest1', 'sortedSetTest2', 'sortedSetTest3',
], 2, '+inf');
assert.strictEqual(count, 3);
count = await db.sortedSetsCardSum([
'sortedSetTest1', 'sortedSetTest2', 'sortedSetTest3',
], '-inf', '+inf');
assert.strictEqual(count, 7);
});
});
describe('sortedSetRank()', () => {
@@ -1225,11 +1230,11 @@ describe('Sorted Set methods', () => {
});
describe('sortedSetsRemove()', () => {
before((done) => {
async.parallel([
async.apply(db.sortedSetAdd, 'sorted4', [1, 2], ['value1', 'value2']),
async.apply(db.sortedSetAdd, 'sorted5', [1, 2], ['value1', 'value3']),
], done);
before(async () => {
await Promise.all([
db.sortedSetAdd('sorted4', [1, 2], ['value1', 'value2']),
db.sortedSetAdd('sorted5', [1, 2], ['value1', 'value3']),
]);
});
it('should remove element from multiple sorted sets', (done) => {
@@ -1278,15 +1283,11 @@ describe('Sorted Set methods', () => {
});
describe('getSortedSetIntersect', () => {
before((done) => {
async.parallel([
function (next) {
db.sortedSetAdd('interSet1', [1, 2, 3], ['value1', 'value2', 'value3'], next);
},
function (next) {
db.sortedSetAdd('interSet2', [4, 5, 6], ['value2', 'value3', 'value5'], next);
},
], done);
before(async () => {
await Promise.all([
db.sortedSetAdd('interSet1', [1, 2, 3], ['value1', 'value2', 'value3']),
db.sortedSetAdd('interSet2', [4, 5, 6], ['value2', 'value3', 'value5']),
]);
});
it('should return the intersection of two sets', (done) => {
@@ -1446,21 +1447,13 @@ describe('Sorted Set methods', () => {
});
describe('sortedSetIntersectCard', () => {
before((done) => {
async.parallel([
function (next) {
db.sortedSetAdd('interCard1', [0, 0, 0], ['value1', 'value2', 'value3'], next);
},
function (next) {
db.sortedSetAdd('interCard2', [0, 0, 0], ['value2', 'value3', 'value4'], next);
},
function (next) {
db.sortedSetAdd('interCard3', [0, 0, 0], ['value3', 'value4', 'value5'], next);
},
function (next) {
db.sortedSetAdd('interCard4', [0, 0, 0], ['value4', 'value5', 'value6'], next);
},
], done);
before(async () => {
await Promise.all([
db.sortedSetAdd('interCard1', [0, 0, 0], ['value1', 'value2', 'value3']),
db.sortedSetAdd('interCard2', [0, 0, 0], ['value2', 'value3', 'value4']),
db.sortedSetAdd('interCard3', [0, 0, 0], ['value3', 'value4', 'value5']),
db.sortedSetAdd('interCard4', [0, 0, 0], ['value4', 'value5', 'value6']),
]);
});
it('should return # of elements in intersection', (done) => {

View File

@@ -194,7 +194,7 @@ async function setupMockDefaults() {
meta.config.autoDetectLang = 0;
require('../../src/groups').cache.reset();
require('../../src/posts/cache').reset();
require('../../src/posts/cache').getOrCreate().reset();
require('../../src/cache').reset();
require('../../src/middleware/uploads').clearCache();
// privileges must be given after cache reset

View File

@@ -184,8 +184,8 @@ describe('Post\'s', () => {
it('should get upvoters', (done) => {
socketPosts.getUpvoters({ uid: globalModUid }, [postData.pid], (err, data) => {
assert.ifError(err);
assert.equal(data[0].otherCount, 0);
assert.equal(data[0].usernames, 'upvoter');
assert.equal(data.otherCount, 0);
assert.equal(data.usernames, 'upvoter');
done();
});
});

View File

@@ -740,7 +740,7 @@ describe('socket.io', () => {
it('should toggle caches', async () => {
const caches = {
post: require('../src/posts/cache'),
post: require('../src/posts/cache').getOrCreate(),
object: require('../src/database').objectCache,
group: require('../src/groups').cache,
local: require('../src/cache'),

View File

@@ -607,7 +607,7 @@ describe('User', () => {
it('should return an icon text and valid background if username and picture is explicitly requested', async () => {
const payload = await User.getUserFields(testUid, ['username', 'picture']);
const validBackgrounds = await User.getIconBackgrounds(testUid);
const validBackgrounds = await User.getIconBackgrounds();
assert.strictEqual(payload['icon:text'], userData.username.slice(0, 1).toUpperCase());
assert(payload['icon:bgColor']);
assert(validBackgrounds.includes(payload['icon:bgColor']));
@@ -616,7 +616,7 @@ describe('User', () => {
it('should return a valid background, even if an invalid background colour is set', async () => {
await User.setUserField(testUid, 'icon:bgColor', 'teal');
const payload = await User.getUserFields(testUid, ['username', 'picture']);
const validBackgrounds = await User.getIconBackgrounds(testUid);
const validBackgrounds = await User.getIconBackgrounds();
assert(payload['icon:bgColor']);
assert(validBackgrounds.includes(payload['icon:bgColor']));
@@ -1492,28 +1492,18 @@ describe('User', () => {
});
});
it('should return true if user/group exists', (done) => {
meta.userOrGroupExists('registered-users', (err, exists) => {
assert.ifError(err);
assert(exists);
done();
});
});
it('should return true/false if user/group exists or not', async () => {
assert.strictEqual(await meta.userOrGroupExists('registered-users'), true);
assert.strictEqual(await meta.userOrGroupExists('John Smith'), true);
assert.strictEqual(await meta.userOrGroupExists('doesnot exist'), false);
assert.deepStrictEqual(await meta.userOrGroupExists(['doesnot exist', 'nope not here']), [false, false]);
assert.deepStrictEqual(await meta.userOrGroupExists(['doesnot exist', 'John Smith']), [false, true]);
assert.deepStrictEqual(await meta.userOrGroupExists(['administrators', 'John Smith']), [true, true]);
it('should return true if user/group exists', (done) => {
meta.userOrGroupExists('John Smith', (err, exists) => {
assert.ifError(err);
assert(exists);
done();
});
});
it('should return false if user/group does not exists', (done) => {
meta.userOrGroupExists('doesnot exist', (err, exists) => {
assert.ifError(err);
assert(!exists);
done();
});
await assert.rejects(
meta.userOrGroupExists(['', undefined]),
{ message: '[[error:invalid-data]]' },
);
});
it('should delete user', async () => {