diff --git a/public/language/en_GB/pages.json b/public/language/en_GB/pages.json index bbdf3cdc47..2fe5b5dbac 100644 --- a/public/language/en_GB/pages.json +++ b/public/language/en_GB/pages.json @@ -13,6 +13,7 @@ "users/sort-posts": "Users with the most posts", "users/sort-reputation": "Users with the most reputation", "users/banned": "Banned Users", + "users/most-flags": "Most flagged users", "users/search": "User Search", "notifications": "Notifications", diff --git a/public/language/en_GB/users.json b/public/language/en_GB/users.json index e693bf6333..5c9d8b93a4 100644 --- a/public/language/en_GB/users.json +++ b/public/language/en_GB/users.json @@ -2,6 +2,7 @@ "latest_users": "Latest Users", "top_posters": "Top Posters", "most_reputation": "Most Reputation", + "most_flags": "Most Flags", "search": "Search", "enter_username": "Enter a username to search", "load_more": "Load More", diff --git a/public/src/admin/manage/flags.js b/public/src/admin/manage/flags.js index eaa839522a..e1ccde9c00 100644 --- a/public/src/admin/manage/flags.js +++ b/public/src/admin/manage/flags.js @@ -1,11 +1,12 @@ "use strict"; -/*global define, socket, app, admin, utils, bootbox, RELATIVE_PATH*/ +/*global define, socket, app, utils, bootbox, ajaxify*/ define('admin/manage/flags', [ 'forum/infinitescroll', 'admin/modules/selectable', - 'autocomplete' -], function(infinitescroll, selectable, autocomplete) { + 'autocomplete', + 'Chart' +], function(infinitescroll, selectable, autocomplete, Chart) { var Flags = {}; @@ -20,6 +21,7 @@ define('admin/manage/flags', [ handleDismissAll(); handleDelete(); handleInfiniteScroll(); + handleGraphs(); }; function handleDismiss() { @@ -101,5 +103,42 @@ define('admin/manage/flags', [ }); } + function handleGraphs() { + var dailyCanvas = document.getElementById('flags:daily'); + var dailyLabels = utils.getDaysArray().map(function(text, idx) { + return idx % 3 ? '' : text; + }); + + if (utils.isMobile()) { + Chart.defaults.global.showTooltips = false; + } + var data = { + 'flags:daily': { + labels: dailyLabels, + datasets: [ + { + label: "", + fillColor: "rgba(151,187,205,0.2)", + strokeColor: "rgba(151,187,205,1)", + pointColor: "rgba(151,187,205,1)", + pointStrokeColor: "#fff", + pointHighlightFill: "#fff", + pointHighlightStroke: "rgba(151,187,205,1)", + data: ajaxify.data.analytics + } + ] + } + }; + + + + dailyCanvas.width = $(dailyCanvas).parent().width(); + new Chart(dailyCanvas.getContext('2d')).Line(data['flags:daily'], { + responsive: true, + animation: false + }); + + } + return Flags; }); \ No newline at end of file diff --git a/public/src/client/users.js b/public/src/client/users.js index d90d3ca6ac..fe083a13e2 100644 --- a/public/src/client/users.js +++ b/public/src/client/users.js @@ -102,14 +102,15 @@ define('forum/users', ['translator'], function(translator) { if (!username) { return loadPage(page); } - + var activeSection = getActiveSection(); socket.emit('user.search', { query: username, page: page, searchBy: 'username', sortBy: $('.search select').val() || getSortBy(), - onlineOnly: $('.search .online-only').is(':checked') || (getActiveSection() === 'online'), - bannedOnly: getActiveSection() === 'banned' + onlineOnly: $('.search .online-only').is(':checked') || (activeSection === 'online'), + bannedOnly: activeSection === 'banned', + flaggedOnly: activeSection === 'flagged' }, function(err, data) { if (err) { return app.alertError(err.message); @@ -168,8 +169,8 @@ define('forum/users', ['translator'], function(translator) { } function getActiveSection() { - var url = window.location.href, - parts = url.split('/'); + var url = window.location.href; + var parts = url.split('/'); return parts[parts.length - 1]; } diff --git a/src/controllers/admin/flags.js b/src/controllers/admin/flags.js index cb2f830365..340eda0a9d 100644 --- a/src/controllers/admin/flags.js +++ b/src/controllers/admin/flags.js @@ -2,6 +2,7 @@ var async = require('async'); var posts = require('../../posts'); +var analytics = require('../../analytics'); var flagsController = {}; @@ -13,20 +14,28 @@ flagsController.get = function(req, res, next) { async.waterfall([ function (next) { - if (byUsername) { - posts.getUserFlags(byUsername, sortBy, req.uid, start, stop, next); - } else { - var set = sortBy === 'count' ? 'posts:flags:count' : 'posts:flagged'; - posts.getFlags(set, req.uid, start, stop, next); - } + async.parallel({ + posts: function(next) { + if (byUsername) { + posts.getUserFlags(byUsername, sortBy, req.uid, start, stop, next); + } else { + var set = sortBy === 'count' ? 'posts:flags:count' : 'posts:flagged'; + posts.getFlags(set, req.uid, start, stop, next); + } + }, + analytics: function(next) { + analytics.getDailyStatsForSet('analytics:flags', Date.now(), 30, next); + } + }, next); } - ], function (err, posts) { + ], function (err, results) { if (err) { return next(err); } var data = { - posts: posts, - next: stop + 1, + posts: results.posts, + analytics: results.analytics, + next: stop + 1, byUsername: byUsername, title: '[[pages:flagged-posts]]' }; diff --git a/src/controllers/admin/users.js b/src/controllers/admin/users.js index 1b833c2709..e682045fce 100644 --- a/src/controllers/admin/users.js +++ b/src/controllers/admin/users.js @@ -28,6 +28,10 @@ usersController.noPosts = function(req, res, next) { getUsersByScore('users:postcount', 'noposts', 0, 0, req, res, next); }; +usersController.flagged = function(req, res, next) { + getUsersByScore('users:flags', 'mostflags', 1, '+inf', req, res, next); +}; + usersController.inactive = function(req, res, next) { var timeRange = 1000 * 60 * 60 * 24 * 30 * (parseInt(req.query.months, 10) || 3); var cutoff = Date.now() - timeRange; diff --git a/src/controllers/users.js b/src/controllers/users.js index c84de75b5b..4ee7e5a9ec 100644 --- a/src/controllers/users.js +++ b/src/controllers/users.js @@ -69,6 +69,20 @@ usersController.getBannedUsers = function(req, res, next) { }); }; +usersController.getFlaggedUsers = function(req, res, next) { + usersController.getUsers('users:flags', req.uid, req.query.page, function(err, userData) { + if (err) { + return next(err); + } + + if (!userData.isAdminOrGlobalMod) { + return next(); + } + + render(req, res, userData, next); + }); +}; + usersController.renderUsersPage = function(set, req, res, next) { usersController.getUsers(set, req.uid, req.query.page, function(err, userData) { if (err) { @@ -79,23 +93,16 @@ usersController.renderUsersPage = function(set, req, res, next) { }; usersController.getUsers = function(set, uid, page, callback) { - var setToTitles = { - 'users:postcount': '[[pages:users/sort-posts]]', - 'users:reputation': '[[pages:users/sort-reputation]]', - 'users:joindate': '[[pages:users/latest]]', - 'users:online': '[[pages:users/online]]', - 'users:banned': '[[pages:users/banned]]' - }; - - var setToCrumbs = { - 'users:postcount': '[[users:top_posters]]', - 'users:reputation': '[[users:most_reputation]]', - 'users:joindate': '[[global:users]]', - 'users:online': '[[global:online]]', - 'users:banned': '[[user:banned]]' + var setToData = { + 'users:postcount': {title: '[[pages:users/sort-posts]]', crumb: '[[users:top_posters]]'}, + 'users:reputation': {title: '[[pages:users/sort-reputation]]', crumb: '[[users:most_reputation]]'}, + 'users:joindate': {title: '[[pages:users/latest]]', crumb: '[[global:users]]'}, + 'users:online': {title: '[[pages:users/online]]', crumb: '[[global:online]]'}, + 'users:banned': {title: '[[pages:users/banned]]', crumb: '[[user:banned]]'}, + 'users:flags': {title: '[[pages:users/most-flags]]', crumb: '[[users:most_flags]]'}, }; - var breadcrumbs = [{text: setToCrumbs[set]}]; + var breadcrumbs = [{text: setToData[set].crumb}]; if (set !== 'users:joindate') { breadcrumbs.unshift({text: '[[global:users]]', url: '/users'}); @@ -127,7 +134,7 @@ usersController.getUsers = function(set, uid, page, callback) { users: results.usersData.users, pagination: pagination.create(page, pageCount), userCount: results.usersData.count, - title: setToTitles[set] || '[[pages:users/latest]]', + title: setToData[set].title || '[[pages:users/latest]]', breadcrumbs: helpers.buildBreadcrumbs(breadcrumbs), setName: set, isAdminOrGlobalMod: results.isAdministrator || results.isGlobalMod @@ -148,6 +155,8 @@ usersController.getUsersAndCount = function(set, uid, start, stop, callback) { db.sortedSetCount('users:online', now - 300000, '+inf', next); } else if (set === 'users:banned') { db.sortedSetCard('users:banned', next); + } else if (set === 'users:flags') { + db.sortedSetCard('users:flags', next); } else { db.getObjectField('global', 'userCount', next); } diff --git a/src/posts/flags.js b/src/posts/flags.js index cf2b37bf0c..9eae966cda 100644 --- a/src/posts/flags.js +++ b/src/posts/flags.js @@ -5,7 +5,7 @@ var async = require('async'); var db = require('../database'); var user = require('../user'); - +var analytics = require('../analytics'); module.exports = function(Posts) { @@ -13,52 +13,59 @@ module.exports = function(Posts) { if (!parseInt(uid, 10) || !reason) { return callback(); } - async.parallel({ - hasFlagged: async.apply(hasFlagged, post.pid, uid), - exists: async.apply(Posts.exists, post.pid) - }, function(err, results) { - if (err || !results.exists) { - return callback(err || new Error('[[error:no-post]]')); - } - if (results.hasFlagged) { - return callback(new Error('[[error:already-flagged]]')); - } - var now = Date.now(); - - async.parallel([ - function(next) { - db.sortedSetAdd('posts:flagged', now, post.pid, next); - }, - function(next) { - db.sortedSetIncrBy('posts:flags:count', 1, post.pid, next); - }, - function(next) { - db.incrObjectField('post:' + post.pid, 'flags', next); - }, - function(next) { - db.sortedSetAdd('pid:' + post.pid + ':flag:uids', now, uid, next); - }, - function(next) { - db.sortedSetAdd('pid:' + post.pid + ':flag:uid:reason', 0, uid + ':' + reason, next); - }, - function(next) { - if (parseInt(post.uid, 10)) { - db.sortedSetAdd('uid:' + post.uid + ':flag:pids', now, post.pid, next); - } else { - next(); - } - }, - function(next) { - if (parseInt(post.uid, 10)) { - db.setAdd('uid:' + post.uid + ':flagged_by', uid, next); - } else { - next(); - } + async.waterfall([ + function(next) { + async.parallel({ + hasFlagged: async.apply(hasFlagged, post.pid, uid), + exists: async.apply(Posts.exists, post.pid) + }, next); + }, + function(results, next) { + if (!results.exists) { + return next(new Error('[[error:no-post]]')); } - ], function(err) { - callback(err); - }); + + if (results.hasFlagged) { + return next(new Error('[[error:already-flagged]]')); + } + + var now = Date.now(); + async.parallel([ + function(next) { + db.sortedSetAdd('posts:flagged', now, post.pid, next); + }, + function(next) { + db.sortedSetIncrBy('posts:flags:count', 1, post.pid, next); + }, + function(next) { + db.incrObjectField('post:' + post.pid, 'flags', next); + }, + function(next) { + db.sortedSetAdd('pid:' + post.pid + ':flag:uids', now, uid, next); + }, + function(next) { + db.sortedSetAdd('pid:' + post.pid + ':flag:uid:reason', 0, uid + ':' + reason, next); + }, + function(next) { + if (parseInt(post.uid, 10)) { + async.parallel([ + async.apply(db.sortedSetIncrBy, 'users:flags', 1, post.uid), + async.apply(db.incrObjectField, 'user:' + post.uid, 'flags'), + async.apply(db.sortedSetAdd, 'uid:' + post.uid + ':flag:pids', now, post.pid) + ], next); + } else { + next(); + } + } + ], next); + } + ], function(err) { + if (err) { + return callback(err); + } + analytics.increment('flags'); + callback(); }); }; @@ -67,41 +74,58 @@ module.exports = function(Posts) { } Posts.dismissFlag = function(pid, callback) { - async.parallel([ + async.waterfall([ function(next) { - db.getObjectField('post:' + pid, 'uid', function(err, uid) { - if (err) { - return next(err); - } - - db.sortedSetsRemove([ - 'posts:flagged', - 'posts:flags:count', - 'uid:' + uid + ':flag:pids' - ], pid, next); - }); + db.getObjectFields('post:' + pid, ['pid', 'uid', 'flags'], next); }, - function(next) { - async.series([ + function(postData, next) { + if (!postData.pid) { + return callback(); + } + async.parallel([ function(next) { - db.getSortedSetRange('pid:' + pid + ':flag:uids', 0, -1, function(err, uids) { - async.each(uids, function(uid, next) { - var nid = 'post_flag:' + pid + ':uid:' + uid; + if (parseInt(postData.uid, 10)) { + if (parseInt(postData.flags, 10) > 0) { async.parallel([ - async.apply(db.delete, 'notifications:' + nid), - async.apply(db.sortedSetRemove, 'notifications', 'post_flag:' + pid + ':uid:' + uid) + async.apply(db.sortedSetIncrBy, 'users:flags', -postData.flags, postData.uid), + async.apply(db.incrObjectFieldBy, 'user:' + postData.uid, 'flags', -postData.flags) ], next); - }, next); - }); + } else { + next(); + } + } }, - async.apply(db.delete, 'pid:' + pid + ':flag:uids') + function(next) { + db.sortedSetsRemove([ + 'posts:flagged', + 'posts:flags:count', + 'uid:' + postData.uid + ':flag:pids' + ], pid, next); + }, + function(next) { + async.series([ + function(next) { + db.getSortedSetRange('pid:' + pid + ':flag:uids', 0, -1, function(err, uids) { + async.each(uids, function(uid, next) { + var nid = 'post_flag:' + pid + ':uid:' + uid; + async.parallel([ + async.apply(db.delete, 'notifications:' + nid), + async.apply(db.sortedSetRemove, 'notifications', 'post_flag:' + pid + ':uid:' + uid) + ], next); + }, next); + }); + }, + async.apply(db.delete, 'pid:' + pid + ':flag:uids') + ], next); + }, + async.apply(db.deleteObjectField, 'post:' + pid, 'flags'), + async.apply(db.delete, 'pid:' + pid + ':flag:uid:reason') ], next); }, - async.apply(db.deleteObjectField, 'post:' + pid, 'flags'), - async.apply(db.delete, 'pid:' + pid + ':flag:uid:reason') - ], function(err) { - callback(err); - }); + function(results, next) { + db.sortedSetsRemoveRangeByScore(['users:flags'], '-inf', 0, next); + } + ], callback); }; Posts.dismissAllFlags = function(callback) { @@ -109,7 +133,16 @@ module.exports = function(Posts) { if (err) { return callback(err); } - async.eachLimit(pids, 50, Posts.dismissFlag, callback); + async.eachSeries(pids, Posts.dismissFlag, callback); + }); + }; + + Posts.dismissUserFlags = function(uid, callback) { + db.getSortedSetRange('uid:' + uid + ':flag:pids', 0, -1, function(err, pids) { + if (err) { + return callback(err); + } + async.eachSeries(pids, Posts.dismissFlag, callback); }); }; diff --git a/src/routes/admin.js b/src/routes/admin.js index 521f0194bf..543b9e8489 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -65,6 +65,7 @@ function addRoutes(router, middleware, controllers) { router.get('/manage/users/not-validated', middlewares, controllers.admin.users.notValidated); router.get('/manage/users/no-posts', middlewares, controllers.admin.users.noPosts); router.get('/manage/users/inactive', middlewares, controllers.admin.users.inactive); + router.get('/manage/users/flagged', middlewares, controllers.admin.users.flagged); router.get('/manage/users/banned', middlewares, controllers.admin.users.banned); router.get('/manage/registration', middlewares, controllers.admin.users.registrationQueue); diff --git a/src/routes/index.js b/src/routes/index.js index 06c9706856..1e51f5d59e 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -75,6 +75,7 @@ function userRoutes(app, middleware, controllers) { setupPageRoute(app, '/users/sort-posts', middleware, middlewares, controllers.users.getUsersSortedByPosts); setupPageRoute(app, '/users/sort-reputation', middleware, middlewares, controllers.users.getUsersSortedByReputation); setupPageRoute(app, '/users/banned', middleware, middlewares, controllers.users.getBannedUsers); + setupPageRoute(app, '/users/flagged', middleware, middlewares, controllers.users.getFlaggedUsers); } function groupRoutes(app, middleware, controllers) { diff --git a/src/socket.io/admin/user.js b/src/socket.io/admin/user.js index 2633425dc4..ed7b4b378d 100644 --- a/src/socket.io/admin/user.js +++ b/src/socket.io/admin/user.js @@ -185,25 +185,15 @@ User.search = function(socket, data, callback) { return user && user.uid; }); - async.parallel({ - users: function(next) { - user.getUsersFields(uids, ['email'], next); - }, - flagCounts: function(next) { - var sets = uids.map(function(uid) { - return 'uid:' + uid + ':flagged_by'; - }); - db.setsCount(sets, next); - } - }, function(err, results) { + user.getUsersFields(uids, ['email', 'flags'], function(err, userInfo) { if (err) { return callback(err); } userData.forEach(function(user, index) { - if (user) { - user.email = (results.users[index] && results.users[index].email) || ''; - user.flags = results.flagCounts[index] || 0; + if (user && userInfo[index]) { + user.email = userInfo[index].email || ''; + user.flags = userInfo[index].flags || 0; } }); diff --git a/src/socket.io/user/search.js b/src/socket.io/user/search.js index ecef1127c2..76b1daa3a7 100644 --- a/src/socket.io/user/search.js +++ b/src/socket.io/user/search.js @@ -20,6 +20,7 @@ module.exports = function(SocketUser) { sortBy: data.sortBy, onlineOnly: data.onlineOnly, bannedOnly: data.bannedOnly, + flaggedOnly: data.flaggedOnly, uid: socket.uid }, function(err, result) { if (err) { diff --git a/src/user.js b/src/user.js index 3303d04e79..b4557abfb0 100644 --- a/src/user.js +++ b/src/user.js @@ -89,7 +89,8 @@ var utils = require('../public/src/utils'); }; User.getUsers = function(uids, uid, callback) { - var fields = ['uid', 'username', 'userslug', 'picture', 'status', 'banned', 'joindate', 'postcount', 'reputation', 'email:confirmed', 'lastonline']; + var fields = ['uid', 'username', 'userslug', 'picture', 'status', 'flags', + 'banned', 'joindate', 'postcount', 'reputation', 'email:confirmed', 'lastonline']; async.waterfall([ function (next) { diff --git a/src/user/admin.js b/src/user/admin.js index c402eb7e7a..59ec0e0979 100644 --- a/src/user/admin.js +++ b/src/user/admin.js @@ -3,6 +3,7 @@ var async = require('async'); var db = require('../database'); +var posts = require('../posts'); var plugins = require('../plugins'); module.exports = function(User) { @@ -89,7 +90,6 @@ module.exports = function(User) { }; User.unban = function(uid, callback) { - db.delete('uid:' + uid + ':flagged_by'); async.waterfall([ function (next) { User.setUserField(uid, 'banned', 0, next); @@ -108,9 +108,9 @@ module.exports = function(User) { if (!Array.isArray(uids) || !uids.length) { return callback(); } - var keys = uids.map(function(uid) { - return 'uid:' + uid + ':flagged_by'; - }); - db.deleteAll(keys, callback); + + async.eachSeries(uids, function(uid, next) { + posts.dismissUserFlags(uid, next); + }, callback); }; }; diff --git a/src/user/search.js b/src/user/search.js index b2a1e83a80..6bb7084b1f 100644 --- a/src/user/search.js +++ b/src/user/search.js @@ -1,10 +1,10 @@ 'use strict'; -var async = require('async'), - meta = require('../meta'), - plugins = require('../plugins'), - db = require('../database'); +var async = require('async'); +var meta = require('../meta'); +var plugins = require('../plugins'); +var db = require('../database'); module.exports = function(User) { @@ -84,7 +84,7 @@ module.exports = function(User) { function filterAndSortUids(uids, data, callback) { var sortBy = data.sortBy || 'joindate'; - var fields = ['uid', 'status', 'lastonline', 'banned', sortBy]; + var fields = ['uid', 'status', 'lastonline', 'banned', 'flags', sortBy]; User.getUsersFields(uids, fields, function(err, userData) { if (err) { @@ -96,13 +96,19 @@ module.exports = function(User) { return user && user.status !== 'offline' && (Date.now() - parseInt(user.lastonline, 10) < 300000); }); } - - if(data.bannedOnly) { + + if (data.bannedOnly) { userData = userData.filter(function(user) { return user && user.banned; }); } + if (data.flaggedOnly) { + userData = userData.filter(function(user) { + return user && parseInt(user.flags, 10) > 0; + }); + } + sortUsers(userData, sortBy); uids = userData.map(function(user) { diff --git a/src/views/admin/manage/flags.tpl b/src/views/admin/manage/flags.tpl index a7a9253163..1bd1726b97 100644 --- a/src/views/admin/manage/flags.tpl +++ b/src/views/admin/manage/flags.tpl @@ -1,32 +1,48 @@
-
-
-