diff --git a/CHANGELOG.md b/CHANGELOG.md index 401006c4cd..2ca34e9da0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,70 @@ +#### 1.14.1 (2020-07-08) + +##### Chores + +* incrementing version number - v1.14.1 (31203b16) +* update changelog for v1.14.1 (d4c16086) +* incrementing version number - v1.14.1-beta.3 (e8ecef6b) +* incrementing version number - v1.14.1-beta.2 (b8d9b6b1) +* incrementing version number - v1.14.1-beta.1 (be85123a) +* incrementing version number - v1.14.1-beta.0 (c279875a) +* incrementing version number - v1.14.0 (bb73d6a4) +* update changelog for v1.14.0 (cffae0f1) + +##### New Features + +* add tools to recent/unread (#8477) (658dd03b) +* fire new hooks on chat message editing (4f51838d) +* add back redis tests (bdc4d9e7) +* remove redis test (8461a179) +* use covered query (057b783d) +* add js-enabled.css to list of preloaded css files (da29b947) +* zscan (#8457) (723fe8e8) +* fix blocksCount not being returned on user profile (bd228d5e) + +##### Bug Fixes + +* **deps:** + * update dependency nodebb-theme-persona to v10.1.60 (#8478) (14eafcb6) + * bump nodebb-plugin-composer-default to 6.3.48 (943a344a) + * update dependency nodebb-plugin-dbsearch to v4.1.1 (#8476) (9f06f12c) + * update dependency nodebb-plugin-composer-default to v6.3.47 (#8473) (857900f1) + * update dependency nodebb-plugin-dbsearch to v4.1.0 (#8471) (eb51cfd4) + * update dependency nodebb-theme-persona to v10.1.59 (#8468) (ee38e05d) + * update dependency nodebb-widget-essentials to v4.1.1 (#8466) (519e035d) + * update dependency @nodebb/socket.io-adapter-mongo to v3.0.1 (#8464) (412ca4ae) +* #8474 (c2ca02df) +* show stack properly (7b04d897) +* editing chat messages does not go through content sanity checks (9a6b87d2) +* don't show blocked users under nested replies (d6c619cf) +* tests (87dd6c83) +* handle scan/zscan returning duplicate elements on redis (746222d6) +* #8467, fix url to merged topic in subfolder installs (9eb748b9) +* openapi (5f1865c0) +* openapi (65c0adc7) +* dont allow searching by email/ip if not privileged (ac6b571e) +* missing backgroundImage #8386 (fef04fcf) +* dont allow searching by ip/banned/flagged for regular users (02ac44cc) +* admin privileges client-side regression (f3441fce) +* only add blocksCount for self and admins (59a2ace6) +* tests (fd20e5c6) +* better changelog (f992af05) +* **tests:** + * another shot in the dark (8853cd1a) + * shot in the dark (9458d90b) +* **openapi:** tests (c468942f) + +##### Other Changes + +* update changelog for v1.14.1" (26c74409) +* //github.com/NodeBB/NodeBB (0d9461b1) +* //github.com/NodeBB/NodeBB (ace312e0) +* post.changeOwner (b60e1cbf) + +##### Reverts + +* bad changelog (a761e31f) + #### 1.14.0 (2020-07-02) ##### Chores 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..91e726f1ea 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.1", "homepage": "http://www.nodebb.org", "repository": { "type": "git", @@ -80,19 +80,19 @@ "@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", "nodebb-plugin-markdown": "8.11.2", - "nodebb-plugin-mentions": "2.8.3", + "nodebb-plugin-mentions": "2.9.1", "nodebb-plugin-soundpack-default": "1.0.0", "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.65", "nodebb-theme-slick": "1.2.29", - "nodebb-theme-vanilla": "11.1.32", + "nodebb-theme-vanilla": "11.1.33", "nodebb-widget-essentials": "4.1.1", "nodemailer": "^6.4.6", "passport": "^0.4.1", @@ -133,8 +133,8 @@ }, "devDependencies": { "@apidevtools/swagger-parser": "9.0.1", - "@commitlint/cli": "9.0.1", - "@commitlint/config-angular": "9.0.1", + "@commitlint/cli": "9.1.1", + "@commitlint/config-angular": "9.1.1", "coveralls": "3.1.0", "eslint": "7.3.1", "eslint-config-airbnb-base": "14.1.0", @@ -172,4 +172,4 @@ "url": "https://github.com/barisusakli" } ] -} \ No newline at end of file +} diff --git a/public/.eslintrc b/public/.eslintrc index 865785241c..322f86a92d 100644 --- a/public/.eslintrc +++ b/public/.eslintrc @@ -21,6 +21,7 @@ "es6": false }, "rules": { + "block-scoped-var": "off", "no-dupe-class-members": "off", "no-var": "off", "object-shorthand": "off", 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/groups.json b/public/language/en-GB/admin/manage/groups.json index c3d60d4eed..c61a539a7d 100644 --- a/public/language/en-GB/admin/manage/groups.json +++ b/public/language/en-GB/admin/manage/groups.json @@ -8,6 +8,8 @@ "hidden": "Hidden", "private": "Private", "edit": "Edit", + "delete": "Delete", + "download-csv": "Download CSV", "search-placeholder": "Search", "create": "Create Group", "description-placeholder": "A short description about your group", 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/flags.json b/public/language/en-GB/flags.json index fb8708fc16..a8bd07ad4f 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -43,6 +43,9 @@ "notes": "Flag Notes", "add-note": "Add Note", "no-notes": "No shared notes.", + "delete-note-confirm": "Are you sure you want to delete this flag note?", + "note-added": "Note Added", + "note-deleted": "Note Deleted", "history": "Account & Flag History", "no-history": "No flag history.", @@ -53,7 +56,6 @@ "state-resolved": "Resolved", "state-rejected": "Rejected", "no-assignee": "Not Assigned", - "note-added": "Note Added", "modal-title": "Report Inappropriate Content", "modal-body": "Please specify your reason for flagging %1 %2 for review. Alternatively, use one of the quick report buttons if applicable.", 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..28a502a571 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: @@ -2257,6 +2276,26 @@ paths: schema: type: string format: binary + /api/admin/groups/{groupname}/csv: + get: + tags: + - admin + summary: Get members of a group (.csv) + parameters: + - in: header + name: referer + schema: + type: string + required: true + example: /admin/manage/groups + responses: + "200": + description: "A CSV file containing all users in the group" + content: + text/csv: + schema: + type: string + format: binary /api/admin/analytics: 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/groups.js b/public/src/admin/manage/groups.js index 5a70d96d99..1d52341df5 100644 --- a/public/src/admin/manage/groups.js +++ b/public/src/admin/manage/groups.js @@ -52,7 +52,7 @@ define('admin/manage/groups', ['translator', 'benchpress'], function (translator }); }); - $('.groups-list').on('click', 'button[data-action]', function () { + $('.groups-list').on('click', '[data-action]', function () { var el = $(this); var action = el.attr('data-action'); var groupName = el.parents('tr[data-groupname]').attr('data-groupname'); 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/app.js b/public/src/app.js index a302e36873..fb7f310a94 100644 --- a/public/src/app.js +++ b/public/src/app.js @@ -6,6 +6,7 @@ app = window.app || {}; app.isFocused = true; app.currentRoom = null; app.widgets = {}; +app.flags = {}; app.cacheBuster = null; (function () { @@ -117,6 +118,9 @@ app.cacheBuster = null; headers: { 'x-csrf-token': config.csrf_token, }, + beforeSend: function () { + app.flags._logout = true; + }, success: function (data) { $(window).trigger('action:app.loggedOut', data); if (redirect) { @@ -169,6 +173,10 @@ app.cacheBuster = null; }; app.handleInvalidSession = function () { + if (app.flags._logout) { + return; + } + socket.disconnect(); bootbox.alert({ title: '[[error:invalid-session]]', diff --git a/public/src/client/flags/detail.js b/public/src/client/flags/detail.js index b847b4391f..31d9e9d156 100644 --- a/public/src/client/flags/detail.js +++ b/public/src/client/flags/detail.js @@ -8,9 +8,10 @@ define('forum/flags/detail', ['forum/flags/list', 'components', 'translator', 'b $('#state').val(ajaxify.data.state).removeAttr('disabled'); $('#assignee').val(ajaxify.data.assignee).removeAttr('disabled'); - $('[data-action]').on('click', function () { + $('#content > div').on('click', '[data-action]', function () { var action = this.getAttribute('data-action'); var uid = $(this).parents('[data-uid]').attr('data-uid'); + var noteEl = document.getElementById('note'); switch (action) { case 'assign': @@ -33,7 +34,8 @@ define('forum/flags/detail', ['forum/flags/list', 'components', 'translator', 'b case 'appendNote': socket.emit('flags.appendNote', { flagId: ajaxify.data.flagId, - note: document.getElementById('note').value, + note: noteEl.value, + datetime: noteEl.getAttribute('data-datetime'), }, function (err, payload) { if (err) { return app.alertError(err.message); @@ -41,6 +43,9 @@ define('forum/flags/detail', ['forum/flags/list', 'components', 'translator', 'b app.alertSuccess('[[flags:note-added]]'); Detail.reloadNotes(payload.notes); Detail.reloadHistory(payload.history); + + noteEl.setAttribute('data-action', 'appendNote'); + noteEl.removeAttribute('data-datetime'); }); break; @@ -75,6 +80,43 @@ define('forum/flags/detail', ['forum/flags/list', 'components', 'translator', 'b case 'restore-post': postAction('restore', ajaxify.data.target.pid, ajaxify.data.target.tid); break; + + case 'delete-note': + var datetime = this.closest('[data-datetime]').getAttribute('data-datetime'); + bootbox.confirm('[[flags:delete-note-confirm]]', function (ok) { + if (ok) { + socket.emit('flags.deleteNote', { + flagId: ajaxify.data.flagId, + datetime: datetime, + }, function (err, payload) { + if (err) { + return app.alertError(err.message); + } + + app.alertSuccess('[[flags:note-deleted]]'); + Detail.reloadNotes(payload.notes); + Detail.reloadHistory(payload.history); + }); + } + }); + break; + + case 'prepare-edit': + var selectedNoteEl = this.closest('[data-index]'); + var index = selectedNoteEl.getAttribute('data-index'); + var textareaEl = document.getElementById('note'); + textareaEl.value = ajaxify.data.notes[index].content; + textareaEl.setAttribute('data-datetime', ajaxify.data.notes[index].datetime); + + var siblings = selectedNoteEl.parentElement.children; + for (var el in siblings) { + if (siblings.hasOwnProperty(el)) { + siblings[el].classList.remove('editing'); + } + } + selectedNoteEl.classList.add('editing'); + textareaEl.focus(); + break; } }); @@ -103,6 +145,7 @@ define('forum/flags/detail', ['forum/flags/list', 'components', 'translator', 'b } Detail.reloadNotes = function (notes) { + ajaxify.data.notes = notes; Benchpress.parse('flags/detail', 'notes', { notes: notes, }, function (html) { diff --git a/public/src/client/unread.js b/public/src/client/unread.js index 504f72e613..3d0dfa5fe1 100644 --- a/public/src/client/unread.js +++ b/public/src/client/unread.js @@ -14,7 +14,6 @@ define('forum/unread', ['topicSelect', 'components', 'topicList'], function (top app.enterRoom('unread_topics'); topicList.init('unread'); - topicSelect.init(); updateUnreadTopicCount('/' + ajaxify.data.selectedFilter.url, ajaxify.data.topicCount); 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/public/src/modules/flags.js b/public/src/modules/flags.js index 8c02ee9b0a..9ddf27dd9a 100644 --- a/public/src/modules/flags.js +++ b/public/src/modules/flags.js @@ -49,6 +49,11 @@ define('flags', function () { }); flagModal.modal('show'); + $(window).trigger('action:flag.showModal', { + modalEl: flagModal, + type: data.type, + id: data.id, + }); flagModal.find('#flag-reason-custom').on('keyup blur change', checkFlagButtonEnable); }); diff --git a/src/controllers/admin/groups.js b/src/controllers/admin/groups.js index 33e3971e8c..b17c4c23d5 100644 --- a/src/controllers/admin/groups.js +++ b/src/controllers/admin/groups.js @@ -1,11 +1,14 @@ 'use strict'; +const nconf = require('nconf'); const validator = require('validator'); const db = require('../../database'); +const user = require('../../user'); const groups = require('../../groups'); const meta = require('../../meta'); const pagination = require('../../pagination'); +const events = require('../../events'); const groupsController = module.exports; @@ -60,3 +63,29 @@ async function getGroupNames() { const groupNames = await db.getSortedSetRange('groups:createtime', 0, -1); return groupNames.filter(name => name !== 'registered-users' && !groups.isPrivilegeGroup(name)); } + +groupsController.getCSV = async function (req, res) { + const referer = req.headers.referer; + + if (!referer || !referer.replace(nconf.get('url'), '').startsWith('/admin/manage/groups')) { + return res.status(403).send('[[error:invalid-origin]]'); + } + await events.log({ + type: 'getGroupCSV', + uid: req.uid, + ip: req.ip, + }); + const groupName = req.params.groupname; + const members = (await groups.getMembersOfGroups([groupName]))[0]; + const fields = ['email', 'username', 'uid']; + const userData = await user.getUsersFields(members, fields); + let csvContent = fields.join(',') + '\n'; + csvContent += userData.reduce((memo, user) => { + memo += user.email + ',' + user.username + ',' + user.uid + '\n'; + return memo; + }, ''); + + res.attachment(validator.escape(groupName) + '_members.csv'); + res.setHeader('Content-Type', 'text/csv'); + res.end(csvContent); +}; 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/flags.js b/src/flags.js index 6668895098..2a8927902a 100644 --- a/src/flags.js +++ b/src/flags.js @@ -228,6 +228,21 @@ Flags.validate = async function (payload) { Flags.getNotes = async function (flagId) { let notes = await db.getSortedSetRevRangeWithScores('flag:' + flagId + ':notes', 0, -1); + notes = await modifyNotes(notes); + return notes; +}; + +Flags.getNote = async function (flagId, datetime) { + let notes = await db.getSortedSetRangeByScoreWithScores('flag:' + flagId + ':notes', 0, 1, datetime, datetime); + if (!notes.length) { + throw new Error('[[error:invalid-data]]'); + } + + notes = await modifyNotes(notes); + return notes[0]; +}; + +async function modifyNotes(notes) { const uids = []; notes = notes.map(function (note) { const noteObj = JSON.parse(note.value); @@ -245,6 +260,15 @@ Flags.getNotes = async function (flagId) { note.content = validator.escape(note.content); return note; }); +} + +Flags.deleteNote = async function (flagId, datetime) { + const note = await db.getSortedSetRangeByScore('flag:' + flagId + ':notes', 0, 1, datetime, datetime); + if (!note.length) { + throw new Error('[[error:invalid-data]]'); + } + + await db.sortedSetRemove('flag:' + flagId + ':notes', note[0]); }; Flags.create = async function (type, id, uid, reason, timestamp) { @@ -475,7 +499,11 @@ Flags.appendHistory = async function (flagId, uid, changeset) { }; Flags.appendNote = async function (flagId, uid, note, datetime) { + if (datetime) { + await Flags.deleteNote(flagId, datetime); + } datetime = datetime || Date.now(); + const payload = JSON.stringify([uid, note]); await db.sortedSetAdd('flag:' + flagId + ':notes', datetime, payload); await Flags.appendHistory(flagId, uid, { 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/routes/admin.js b/src/routes/admin.js index 708a66a485..5fa9ecb698 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -69,6 +69,7 @@ module.exports = function (app, middleware, controllers) { function apiRoutes(router, middleware, controllers) { router.get('/api/admin/users/csv', middleware.authenticate, helpers.tryRoute(controllers.admin.users.getCSV)); + router.get('/api/admin/groups/:groupname/csv', middleware.authenticate, helpers.tryRoute(controllers.admin.groups.getCSV)); router.get('/api/admin/analytics', middleware.authenticate, helpers.tryRoute(controllers.admin.dashboard.getAnalytics)); const multipart = require('connect-multiparty'); 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/flags.js b/src/socket.io/flags.js index e2c9a56215..7039903f8d 100644 --- a/src/socket.io/flags.js +++ b/src/socket.io/flags.js @@ -53,7 +53,26 @@ SocketFlags.appendNote = async function (socket, data) { if (!allowed) { throw new Error('[[no-privileges]]'); } - await flags.appendNote(data.flagId, socket.uid, data.note); + await flags.appendNote(data.flagId, socket.uid, data.note, data.datetime); + + const [notes, history] = await Promise.all([ + flags.getNotes(data.flagId), + flags.getHistory(data.flagId), + ]); + return { notes: notes, history: history }; +}; + +SocketFlags.deleteNote = async function (socket, data) { + if (!data || !(data.flagId && data.datetime)) { + throw new Error('[[error:invalid-data]]'); + } + + const note = await flags.getNote(data.flagId, data.datetime); + if (note.uid !== socket.uid) { + throw new Error('[[error:no-privileges]]'); + } + + await flags.deleteNote(data.flagId, data.datetime); const [notes, history] = await Promise.all([ flags.getNotes(data.flagId), 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/groups.tpl b/src/views/admin/manage/groups.tpl index 54ab2914be..bdfde889bb 100644 --- a/src/views/admin/manage/groups.tpl +++ b/src/views/admin/manage/groups.tpl @@ -19,7 +19,7 @@ - {groups.displayName} + {groups.displayName} {groups.userTitle} @@ -42,13 +42,15 @@ {groups.memberCount} -
- - [[admin/manage/groups:edit]] - - - - +
+ +
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 @@
-
+
-
+
-
+
-
+
- [[admin/manage/users:search.not-found]]
+ +
[[admin/manage/users:alerts.x-users-found, {matchCount}, {timing}]]
+ +
[[admin/manage/users:search.not-found]]
diff --git a/src/views/admin/partials/widget-settings.tpl b/src/views/admin/partials/widget-settings.tpl index 8ce3b8485f..87d60ea2f1 100644 --- a/src/views/admin/partials/widget-settings.tpl +++ b/src/views/admin/partials/widget-settings.tpl @@ -17,7 +17,7 @@
- diff --git a/src/views/admin/settings/reputation.tpl b/src/views/admin/settings/reputation.tpl index b05e7e8238..4d802383b9 100644 --- a/src/views/admin/settings/reputation.tpl +++ b/src/views/admin/settings/reputation.tpl @@ -32,7 +32,10 @@
[[admin/settings/reputation:thresholds]]
- [[admin/settings/reputation:min-rep-downvote]]

+ [[admin/settings/reputation:min-rep-downvote]]

+ [[admin/settings/reputation:downvotes-per-day]]

+ [[admin/settings/reputation:downvotes-per-user-per-day]]

[[admin/settings/reputation:min-rep-flag]]

[[admin/settings/reputation:min-rep-website]]

[[admin/settings/reputation:min-rep-aboutme]]

diff --git a/test/controllers-admin.js b/test/controllers-admin.js index 283c77de8f..52ab458986 100644 --- a/test/controllers-admin.js +++ b/test/controllers-admin.js @@ -364,6 +364,43 @@ describe('Admin Controllers', function () { }); }); + it('should return 403 if no referer', function (done) { + request(nconf.get('url') + '/api/admin/groups/administrators/csv', { jar: jar }, function (err, res, body) { + assert.ifError(err); + assert.equal(res.statusCode, 403); + assert.equal(body, '[[error:invalid-origin]]'); + done(); + }); + }); + + it('should return 403 if referer is not /api/admin/groups/administrators/csv', function (done) { + request(nconf.get('url') + '/api/admin/groups/administrators/csv', { + jar: jar, + headers: { + referer: '/topic/1/test', + }, + }, function (err, res, body) { + assert.ifError(err); + assert.equal(res.statusCode, 403); + assert.equal(body, '[[error:invalid-origin]]'); + done(); + }); + }); + + it('should load /api/admin/groups/administrators/csv', function (done) { + request(nconf.get('url') + '/api/admin/groups/administrators/csv', { + jar: jar, + headers: { + referer: nconf.get('url') + '/admin/manage/groups', + }, + }, function (err, res, body) { + assert.ifError(err); + assert.equal(res.statusCode, 200); + assert(body); + done(); + }); + }); + it('should load /admin/advanced/hooks', function (done) { request(nconf.get('url') + '/api/admin/advanced/hooks', { jar: jar, json: true }, function (err, res, body) { assert.ifError(err); diff --git a/test/groups.js b/test/groups.js index 1e1e794335..a3b6470021 100644 --- a/test/groups.js +++ b/test/groups.js @@ -1387,9 +1387,9 @@ describe('Groups', function () { }); it('should fail if user is not logged in or not owner', function (done) { - socketGroups.cover.update({ uid: 0 }, {}, function (err) { + socketGroups.cover.update({ uid: 0 }, { imageData: 'asd' }, function (err) { assert.equal(err.message, '[[error:no-privileges]]'); - socketGroups.cover.update({ uid: regularUid }, { groupName: 'Test' }, function (err) { + socketGroups.cover.update({ uid: regularUid }, { groupName: 'Test', imageData: 'asd' }, function (err) { assert.equal(err.message, '[[error:no-privileges]]'); done(); }); @@ -1404,7 +1404,7 @@ describe('Groups', function () { type: 'image/png', }, }; - socketGroups.cover.update({ uid: adminUid }, data, function (err, data) { + Groups.updateCover({ uid: adminUid }, data, function (err, data) { assert.ifError(err); Groups.getGroupFields('Test', ['cover:url'], function (err, groupData) { assert.ifError(err); @@ -1434,6 +1434,20 @@ describe('Groups', function () { }); }); + it('should fail to upload group cover with invalid image', function (done) { + var data = { + groupName: 'Test', + file: { + path: imagePath, + type: 'image/png', + }, + }; + socketGroups.cover.update({ uid: adminUid }, data, function (err) { + assert.equal(err.message, '[[error:invalid-data]]'); + done(); + }); + }); + it('should fail to upload group cover with invalid image', function (done) { var data = { groupName: 'Test', diff --git a/test/posts.js b/test/posts.js index 9bc9fa09ef..df8b113f89 100644 --- a/test/posts.js +++ b/test/posts.js @@ -242,6 +242,42 @@ describe('Post\'s', function () { }); }); }); + + it('should prevent downvoting more than total daily limit', async () => { + const oldValue = meta.config.downvotesPerDay; + meta.config.downvotesPerDay = 1; + let err; + const p1 = await topics.reply({ + uid: voteeUid, + tid: topicData.tid, + content: 'raw content', + }); + try { + await socketPosts.downvote({ uid: voterUid }, { pid: p1.pid, room_id: 'topic_1' }); + } catch (_err) { + err = _err; + } + assert.equal(err.message, '[[error:too-many-downvotes-today, 1]]'); + meta.config.downvotesPerDay = oldValue; + }); + + it('should prevent downvoting target user more than total daily limit', async () => { + const oldValue = meta.config.downvotesPerUserPerDay; + meta.config.downvotesPerUserPerDay = 1; + let err; + const p1 = await topics.reply({ + uid: voteeUid, + tid: topicData.tid, + content: 'raw content', + }); + try { + await socketPosts.downvote({ uid: voterUid }, { pid: p1.pid, room_id: 'topic_1' }); + } catch (_err) { + err = _err; + } + assert.equal(err.message, '[[error:too-many-downvotes-today-user, 1]]'); + meta.config.downvotesPerUserPerDay = oldValue; + }); }); describe('bookmarking', function () { @@ -910,7 +946,7 @@ describe('Post\'s', function () { it('should get pid index', function (done) { socketPosts.getPidIndex({ uid: voterUid }, { pid: pid, tid: topicData.tid, topicPostSort: 'oldest_to_newest' }, function (err, index) { assert.ifError(err); - assert.equal(index, 2); + assert.equal(index, 4); done(); }); });