diff --git a/install/data/defaults.json b/install/data/defaults.json index 8d0112ba74..b9f5f039f9 100644 --- a/install/data/defaults.json +++ b/install/data/defaults.json @@ -70,6 +70,8 @@ "reputation:disabled": 0, "downvote:disabled": 0, "disableSignatures": 0, + "downvotesPerDay": 10, + "downvotesPerUserPerDay": 3, "min:rep:downvote": 0, "min:rep:flag": 0, "min:rep:profile-picture": 0, diff --git a/install/package.json b/install/package.json index 8209666cab..f9cbf0f7e9 100644 --- a/install/package.json +++ b/install/package.json @@ -2,7 +2,7 @@ "name": "nodebb", "license": "GPL-3.0", "description": "NodeBB Forum", - "version": "1.14.1", + "version": "1.14.2-beta.0", "homepage": "http://www.nodebb.org", "repository": { "type": "git", @@ -80,7 +80,7 @@ "@nodebb/mubsub": "^1.6.0", "@nodebb/socket.io-adapter-mongo": "3.0.1", "nconf": "^0.10.0", - "nodebb-plugin-composer-default": "6.3.48", + "nodebb-plugin-composer-default": "6.3.50", "nodebb-plugin-dbsearch": "4.1.1", "nodebb-plugin-emoji": "^3.3.0", "nodebb-plugin-emoji-android": "2.0.0", @@ -90,7 +90,7 @@ "nodebb-plugin-spam-be-gone": "0.7.2", "nodebb-rewards-essentials": "0.1.3", "nodebb-theme-lavender": "5.0.11", - "nodebb-theme-persona": "10.1.60", + "nodebb-theme-persona": "10.1.62", "nodebb-theme-slick": "1.2.29", "nodebb-theme-vanilla": "11.1.32", "nodebb-widget-essentials": "4.1.1", @@ -172,4 +172,4 @@ "url": "https://github.com/barisusakli" } ] -} \ No newline at end of file +} diff --git a/public/language/en-GB/admin/manage/categories.json b/public/language/en-GB/admin/manage/categories.json index a8949c654b..70b282ca02 100644 --- a/public/language/en-GB/admin/manage/categories.json +++ b/public/language/en-GB/admin/manage/categories.json @@ -76,6 +76,7 @@ "alert.user-search": "Search for a user here...", "alert.find-group": "Find a Group", "alert.group-search": "Search for a group here...", + "alert.not-enough-whitelisted-tags": "Whitelisted tags are less than minimum tags, you need to create more whitelisted tags!", "collapse-all": "Collapse All", "expand-all": "Expand All", "disable-on-create": "Disable on create" diff --git a/public/language/en-GB/admin/manage/users.json b/public/language/en-GB/admin/manage/users.json index 93add0d7a4..2712dece9d 100644 --- a/public/language/en-GB/admin/manage/users.json +++ b/public/language/en-GB/admin/manage/users.json @@ -108,5 +108,5 @@ "alerts.prompt-email": "Emails: ", "alerts.email-sent-to": "An invitation email has been sent to %1", - "alerts.x-users-found": "%1 user(s) found! Search took %2 ms." + "alerts.x-users-found": "%1 user(s) found, (%2 seconds)" } \ No newline at end of file diff --git a/public/language/en-GB/admin/settings/reputation.json b/public/language/en-GB/admin/settings/reputation.json index 910909ff65..77fe61ac6a 100644 --- a/public/language/en-GB/admin/settings/reputation.json +++ b/public/language/en-GB/admin/settings/reputation.json @@ -5,6 +5,8 @@ "votes-are-public": "All Votes Are Public", "thresholds": "Activity Thresholds", "min-rep-downvote": "Minimum reputation to downvote posts", + "downvotes-per-day": "Downvotes per day (set to 0 for unlimited downvotes)", + "downvotes-per-user-per-day": "Downvotes per user per day (set to 0 for unlimited downvotes)", "min-rep-flag": "Minimum reputation to flag posts", "min-rep-website": "Minimum reputation to add \"Website\" to user profile", "min-rep-aboutme": "Minimum reputation to add \"About me\" to user profile", diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json index 521ea9a7e7..1158b27847 100644 --- a/public/language/en-GB/error.json +++ b/public/language/en-GB/error.json @@ -165,6 +165,8 @@ "not-enough-reputation-min-rep-cover-picture": "You do not have enough reputation to add a cover picture", "already-flagged": "You have already flagged this post", "self-vote": "You cannot vote on your own post", + "too-many-downvotes-today": "You can only downvote %1 times a day", + "too-many-downvotes-today-user": "You can only downvote a user %1 times a day", "reload-failed": "NodeBB encountered a problem while reloading: \"%1\". NodeBB will continue to serve the existing client-side assets, although you should undo what you did just prior to reloading.", diff --git a/public/language/en-GB/modules.json b/public/language/en-GB/modules.json index a0f3dfe641..47d62c74d7 100644 --- a/public/language/en-GB/modules.json +++ b/public/language/en-GB/modules.json @@ -60,6 +60,8 @@ "composer.upload-file": "Upload File", "composer.zen_mode": "Zen Mode", "composer.select_category": "Select a category", + "composer.textarea.placeholder": "Enter your post content here, drag and drop images", + "bootbox.ok": "OK", "bootbox.cancel": "Cancel", diff --git a/public/openapi/read.yaml b/public/openapi/read.yaml index 29ac388079..4603316f9f 100644 --- a/public/openapi/read.yaml +++ b/public/openapi/read.yaml @@ -954,11 +954,30 @@ paths: properties: search_display: type: string + matchCount: + type: number + query: + type: string + uidQuery: + type: string + usernameQuery: + type: string + emailQuery: + type: string + ipQuery: + type: string + pageCount: + type: number + resultsPerPage: + type: number + timing: + type: number users: type: array items: $ref: components/schemas/UserObject.yaml#/UserObjectACP - $ref: components/schemas/CommonProps.yaml#/CommonProps + - $ref: components/schemas/Pagination.yaml#/Pagination /api/admin/manage/users/latest: get: tags: diff --git a/public/src/admin/manage/category.js b/public/src/admin/manage/category.js index f4c75c38e6..497b99ca4a 100644 --- a/public/src/admin/manage/category.js +++ b/public/src/admin/manage/category.js @@ -48,6 +48,11 @@ define('admin/manage/category', [ $('[data-name="bgColor"], [data-name="color"]').each(enableColorPicker); $('#save').on('click', function () { + var tags = $('#tag-whitelist').val() ? $('#tag-whitelist').val().split(',') : []; + if (tags.length && tags.length < parseInt($('#cid-min-tags').val(), 10)) { + return app.alertError('[[admin/manage/categories:alert.not-enough-whitelisted-tags]]'); + } + if (Object.keys(modified_categories).length) { socket.emit('admin.categories.update', modified_categories, function (err, result) { if (err) { diff --git a/public/src/admin/manage/users.js b/public/src/admin/manage/users.js index 033613ee88..cef83479be 100644 --- a/public/src/admin/manage/users.js +++ b/public/src/admin/manage/users.js @@ -376,33 +376,10 @@ define('admin/manage/users', ['translator', 'benchpress', 'autocomplete'], funct timeoutId = setTimeout(function () { $('.fa-spinner').removeClass('hidden'); - - socket.emit('admin.user.search', { searchBy: type, query: $this.val() }, function (err, data) { - if (err) { - return app.alertError(err.message); - } - - Benchpress.parse('admin/manage/users', 'users', data, function (html) { - translator.translate(html, function (html) { - html = $(html); - $('.users-table tbody tr').remove(); - $('.users-table tbody').append(html); - html.find('.timeago').timeago(); - $('.fa-spinner').addClass('hidden'); - - if (data && data.users.length === 0) { - $('#user-notfound-notify').translateHtml('[[admin/manage/users:search.not-found]]') - .removeClass('hide') - .addClass('label-danger') - .removeClass('label-success'); - } else { - $('#user-notfound-notify').translateHtml(translator.compile('admin/manage/users:alerts.x-users-found', data.users.length, data.timing)) - .removeClass('hide') - .addClass('label-success') - .removeClass('label-danger'); - } - }); - }); + loadSearchPage({ + searchBy: type, + query: $this.val(), + page: 1, }); }, 250); }); @@ -412,6 +389,38 @@ define('admin/manage/users', ['translator', 'benchpress', 'autocomplete'], funct handleInvite(); }; + function loadSearchPage(query) { + var qs = decodeURIComponent($.param(query)); + $.get(config.relative_path + '/api/admin/manage/users/search?' + qs, renderSearchResults).fail(function (xhrErr) { + if (xhrErr && xhrErr.responseJSON && xhrErr.responseJSON.error) { + app.alertError(xhrErr.responseJSON.error); + } + }); + } + + function renderSearchResults(data) { + Benchpress.parse('partials/paginator', { pagination: data.pagination }, function (html) { + $('.pagination-container').replaceWith(html); + }); + + app.parseAndTranslate('admin/manage/users', 'users', data, function (html) { + $('.users-table tbody tr').remove(); + $('.users-table tbody').append(html); + html.find('.timeago').timeago(); + $('.fa-spinner').addClass('hidden'); + + if (data && data.users.length === 0) { + $('#user-notfound-notify').translateHtml('[[admin/manage/users:search.not-found]]') + .removeClass('hidden'); + $('#user-found-notify').addClass('hidden'); + } else { + $('#user-found-notify').translateHtml(translator.compile('admin/manage/users:alerts.x-users-found', data.matchCount, data.timing)) + .removeClass('hidden'); + $('#user-notfound-notify').addClass('hidden'); + } + }); + } + function handleInvite() { $('[component="user/invite"]').on('click', function () { bootbox.prompt('[[admin/manage/users:alerts.prompt-email]]', function (email) { diff --git a/public/src/modules/categorySearch.js b/public/src/modules/categorySearch.js index 1774fd2d16..5e8f2fcd26 100644 --- a/public/src/modules/categorySearch.js +++ b/public/src/modules/categorySearch.js @@ -11,7 +11,9 @@ define('categorySearch', function () { if (!searchEl.length) { return; } - var toggleVisibility = searchEl.parent('[component="category/dropdown"]').length > 0; + var toggleVisibility = searchEl.parent('[component="category/dropdown"]').length > 0 || + searchEl.parent('[component="category-selector"]').length > 0; + var categoryEls = el.find('[component="category/list"] [data-cid]'); el.on('show.bs.dropdown', function () { function revealParents(cid) { diff --git a/src/controllers/admin/users.js b/src/controllers/admin/users.js index c6c6673fc0..d58bc9077e 100644 --- a/src/controllers/admin/users.js +++ b/src/controllers/admin/users.js @@ -1,6 +1,7 @@ 'use strict'; const nconf = require('nconf'); +const validator = require('validator'); const user = require('../../user'); const meta = require('../../meta'); @@ -15,11 +16,56 @@ const usersController = module.exports; const userFields = ['uid', 'username', 'userslug', 'email', 'postcount', 'joindate', 'banned', 'reputation', 'picture', 'flags', 'lastonline', 'email:confirmed']; -usersController.search = function (req, res) { - res.render('admin/manage/users', { - search_display: '', - users: [], +usersController.search = async function (req, res) { + const page = parseInt(req.query.page, 10) || 1; + let resultsPerPage = parseInt(req.query.resultsPerPage, 10) || 50; + if (![50, 100, 250, 500].includes(resultsPerPage)) { + resultsPerPage = 50; + } + const searchData = await user.search({ + uid: req.uid, + query: req.query.query, + searchBy: req.query.searchBy, + page: page, + resultsPerPage: resultsPerPage, + findUids: async function (query, searchBy, hardCap) { + if (!query || query.length < 2) { + return []; + } + hardCap = hardCap || resultsPerPage * 10; + if (!query.endsWith('*')) { + query += '*'; + } + + const data = await db.getSortedSetScan({ + key: searchBy + ':sorted', + match: query, + limit: hardCap, + }); + return data.map(data => data.split(':')[1]); + }, + }); + + const uids = searchData.users.map(user => user && user.uid); + const userInfo = await user.getUsersFields(uids, ['email', 'flags', 'lastonline', 'joindate']); + + searchData.users.forEach(function (user, index) { + if (user && userInfo[index]) { + user.email = userInfo[index].email; + user.flags = userInfo[index].flags || 0; + user.lastonlineISO = userInfo[index].lastonlineISO; + user.joindateISO = userInfo[index].joindateISO; + } }); + searchData.query = validator.escape(String(req.query.query || '')); + searchData.uidQuery = req.query.searchBy === 'uid' ? searchData.query : ''; + searchData.usernameQuery = req.query.searchBy === 'username' ? searchData.query : ''; + searchData.emailQuery = req.query.searchBy === 'email' ? searchData.query : ''; + searchData.ipQuery = req.query.searchBy === 'uid' ? searchData.query : ''; + searchData.resultsPerPage = resultsPerPage; + searchData.pagination = pagination.create(page, searchData.pageCount, req.query); + searchData.search_display = ''; + res.render('admin/manage/users', searchData); }; usersController.sortByJoinDate = async function (req, res) { diff --git a/src/database/mongo.js b/src/database/mongo.js index d6ec3ccf8a..1339872839 100644 --- a/src/database/mongo.js +++ b/src/database/mongo.js @@ -168,7 +168,7 @@ mongoModule.close = function (callback) { mongoModule.socketAdapter = function () { const mongoAdapter = require('@nodebb/socket.io-adapter-mongo'); - return mongoAdapter(connection.getConnectionString()); + return mongoAdapter(connection.getConnectionString(), connection.getConnectionOptions()); }; require('./mongo/main')(mongoModule); diff --git a/src/meta/build.js b/src/meta/build.js index 5fe5c953b2..c67960fdaa 100644 --- a/src/meta/build.js +++ b/src/meta/build.js @@ -218,7 +218,7 @@ exports.build = function (targets, options, callback) { }, ], function (err) { if (err) { - winston.error('[build] Encountered error during build step\n' + err.stack); + winston.error('[build] Encountered error during build step\n' + err.stack ? err.stack : err); return callback(err); } diff --git a/src/posts/votes.js b/src/posts/votes.js index 92ed268d4c..fd224695d4 100644 --- a/src/posts/votes.js +++ b/src/posts/votes.js @@ -119,18 +119,17 @@ module.exports = function (Posts) { } async function unvote(pid, uid, command) { - const [owner, voteStatus, reputation] = await Promise.all([ + const [owner, voteStatus] = await Promise.all([ Posts.getPostField(pid, 'uid'), Posts.hasVoted(pid, uid), - user.getUserField(uid, 'reputation'), ]); if (parseInt(uid, 10) === parseInt(owner, 10)) { throw new Error('[[error:self-vote]]'); } - if (command === 'downvote' && reputation < meta.config['min:rep:downvote']) { - throw new Error('[[error:not-enough-reputation-to-downvote]]'); + if (command === 'downvote') { + await checkDownvoteLimitation(pid, uid); } let hook; @@ -159,6 +158,33 @@ module.exports = function (Posts) { return await vote(voteStatus.upvoted ? 'downvote' : 'upvote', true, pid, uid); } + async function checkDownvoteLimitation(pid, uid) { + const oneDay = 86400000; + const [reputation, targetUid, downvotedPids] = await Promise.all([ + user.getUserField(uid, 'reputation'), + Posts.getPostField(pid, 'uid'), + db.getSortedSetRevRangeByScore( + 'uid:' + uid + ':downvote', 0, -1, '+inf', Date.now() - oneDay + ), + ]); + + if (reputation < meta.config['min:rep:downvote']) { + throw new Error('[[error:not-enough-reputation-to-downvote]]'); + } + + if (meta.config.downvotesPerDay && downvotedPids.length >= meta.config.downvotesPerDay) { + throw new Error('[[error:too-many-downvotes-today, ' + meta.config.downvotesPerDay + ']]'); + } + + if (meta.config.downvotesPerUserPerDay) { + const postData = await Posts.getPostsFields(downvotedPids, ['uid']); + const targetDownvotes = postData.filter(p => p.uid === targetUid).length; + if (targetDownvotes >= meta.config.downvotesPerUserPerDay) { + throw new Error('[[error:too-many-downvotes-today-user, ' + meta.config.downvotesPerUserPerDay + ']]'); + } + } + } + async function vote(type, unvote, pid, uid) { uid = parseInt(uid, 10); if (uid <= 0) { diff --git a/src/socket.io/admin/user.js b/src/socket.io/admin/user.js index fde65426cd..f9686d2a21 100644 --- a/src/socket.io/admin/user.js +++ b/src/socket.io/admin/user.js @@ -178,6 +178,7 @@ async function deleteUsers(socket, uids, method) { } User.search = async function (socket, data) { + // TODO: deprecate const searchData = await user.search({ query: data.query, searchBy: data.searchBy, diff --git a/src/socket.io/groups.js b/src/socket.io/groups.js index d0b78f2880..3282e76b09 100644 --- a/src/socket.io/groups.js +++ b/src/socket.io/groups.js @@ -368,8 +368,15 @@ SocketGroups.cover.update = async (socket, data) => { if (!socket.uid) { throw new Error('[[error:no-privileges]]'); } + if (data.file || (!data.imageData && !data.position)) { + throw new Error('[[error:invalid-data]]'); + } await canModifyGroup(socket.uid, data.groupName); - return await groups.updateCover(socket.uid, data); + return await groups.updateCover(socket.uid, { + groupName: data.groupName, + imageData: data.imageData, + position: data.position, + }); }; SocketGroups.cover.remove = async (socket, data) => { @@ -378,7 +385,9 @@ SocketGroups.cover.remove = async (socket, data) => { } await canModifyGroup(socket.uid, data.groupName); - await groups.removeCover(data); + await groups.removeCover({ + groupName: data.groupName, + }); }; async function canModifyGroup(uid, groupName) { diff --git a/src/user/search.js b/src/user/search.js index 6c7d4724eb..969ed68126 100644 --- a/src/user/search.js +++ b/src/user/search.js @@ -37,9 +37,9 @@ module.exports = function (User) { }; if (paginate) { - var resultsPerPage = meta.config.userSearchResultsPerPage; - var start = Math.max(0, page - 1) * resultsPerPage; - var stop = start + resultsPerPage; + const resultsPerPage = data.resultsPerPage || meta.config.userSearchResultsPerPage; + const start = Math.max(0, page - 1) * resultsPerPage; + const stop = start + resultsPerPage; searchResult.pageCount = Math.ceil(uids.length / resultsPerPage); uids = uids.slice(start, stop); } diff --git a/src/views/admin/manage/users.tpl b/src/views/admin/manage/users.tpl index 41a4e96beb..f31382e671 100644 --- a/src/views/admin/manage/users.tpl +++ b/src/views/admin/manage/users.tpl @@ -57,23 +57,26 @@
-