From 1aa70c57eb60daa51bf1181335f37e5a46ffbb72 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Wed, 23 Nov 2016 19:41:35 -0500 Subject: [PATCH 01/48] WIP --- src/controllers/admin/flags.js | 3 +- src/flags.js | 484 +++++++++++++++++++++++++++++++++ src/posts.js | 1 - src/posts/delete.js | 3 +- src/posts/flags.js | 417 ---------------------------- src/socket.io/posts/flag.js | 9 +- src/upgrade.js | 7 +- src/user/admin.js | 3 +- 8 files changed, 499 insertions(+), 428 deletions(-) create mode 100644 src/flags.js delete mode 100644 src/posts/flags.js diff --git a/src/controllers/admin/flags.js b/src/controllers/admin/flags.js index 1b31a95ff4..80c31ba60a 100644 --- a/src/controllers/admin/flags.js +++ b/src/controllers/admin/flags.js @@ -5,6 +5,7 @@ var validator = require('validator'); var posts = require('../../posts'); var user = require('../../user'); +var flags = require('../../flags'); var categories = require('../../categories'); var analytics = require('../../analytics'); var pagination = require('../../pagination'); @@ -94,7 +95,7 @@ function getFlagData(req, res, callback) { sets.push('uid:' + uid + ':flag:pids'); } - posts.getFlags(sets, cid, req.uid, start, stop, next); + flags.get(sets, cid, req.uid, start, stop, next); } ], callback); } diff --git a/src/flags.js b/src/flags.js new file mode 100644 index 0000000000..1e6294aecb --- /dev/null +++ b/src/flags.js @@ -0,0 +1,484 @@ + + +'use strict'; + +var async = require('async'); +var winston = require('winston'); +var db = require('./database'); +var user = require('./user'); +var analytics = require('./analytics'); +var topics = require('./topics'); +var posts = require('./posts'); +var utils = require('../public/src/utils'); + +var Flags = { + _defaults: { + state: 'open' + } +}; + +Flags.create = function (type, id, uid, reason, callback) { + async.waterfall([ + function (next) { + // Sanity checks + async.parallel([ + async.apply(Flags.exists, type, id, uid), + async.apply(Flags.targetExists, type, id) + ], function (err, checks) { + if (checks[0]) { + return next(new Error('[[error:already-flagged]]')); + } else if (!checks[1]) { + return next(new Error('[[error:invalid-data]]')); + } else { + next(); + } + }); + }, + function (next) { + var flagId = utils.generateUUID(); + + async.parallel([ + async.apply(db.setObject.bind(db), 'flag:' + flagId, _.defaults({ + description: reason + }), Flags._defaults), + async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', now, flagId) + ], function (err) { + if (err) { + return next(err); + } + }); + } + ], function (err) { + if (err) { + return callback(err); + } + + console.log('done', arguments); + process.exit(); + }); + // if (!parseInt(uid, 10) || !reason) { + // return callback(); + // } + + // async.waterfall([ + // function (next) { + // async.parallel({ + // hasFlagged: async.apply(Flags.isFlaggedByUser, post.pid, uid), + // exists: async.apply(Posts.exists, post.pid) + // }, next); + // }, + // function (results, next) { + // if (!results.exists) { + // return next(new Error('[[error:no-post]]')); + // } + + // 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 (data, next) { + // openNewFlag(post.pid, uid, next); + // } + // ], function (err) { + // if (err) { + // return callback(err); + // } + // analytics.increment('flags'); + // callback(); + // }); +}; + +function openNewFlag(pid, uid, callback) { + db.sortedSetScore('posts:flags:count', pid, function (err, count) { + if (err) { + return callback(err); + } + if (count === 1) { // Only update state on new flag + Flags.update(uid, pid, { + state: 'open' + }, callback); + } else { + callback(); + } + }); +} + +Flags.exists = function (type, id, uid, callback) { + db.isObjectField('flagHash:flagId', [type, id, uid].join(':'), callback); +}; + +Flags.targetExists = function (type, id, callback) { + switch (type) { + case 'topic': + topics.exists(id, callback); + break; + + case 'post': + posts.exists(id, callback); + break; + } +}; + +/* new signature (type, id, uid, callback) */ +Flags.isFlaggedByUser = function (pid, uid, callback) { + db.isSortedSetMember('pid:' + pid + ':flag:uids', uid, callback); +}; + +Flags.dismiss = function (pid, callback) { + async.waterfall([ + function (next) { + db.getObjectFields('post:' + pid, ['pid', 'uid', 'flags'], next); + }, + function (postData, next) { + if (!postData.pid) { + return callback(); + } + async.parallel([ + function (next) { + if (parseInt(postData.uid, 10)) { + if (parseInt(postData.flags, 10) > 0) { + async.parallel([ + async.apply(db.sortedSetIncrBy, 'users:flags', -postData.flags, postData.uid), + async.apply(db.incrObjectFieldBy, 'user:' + postData.uid, 'flags', -postData.flags) + ], next); + } else { + next(); + } + } else { + next(); + } + }, + 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) { + if (err) { + return next(err); + } + + 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'), + async.apply(db.deleteObjectFields, 'post:' + pid, ['flag:state', 'flag:assignee', 'flag:notes', 'flag:history']) + ], next); + }, + function (results, next) { + db.sortedSetsRemoveRangeByScore(['users:flags'], '-inf', 0, next); + } + ], callback); +}; + +// Pretty sure we don't need this method... +Flags.dismissAll = function (callback) { + db.getSortedSetRange('posts:flagged', 0, -1, function (err, pids) { + if (err) { + return callback(err); + } + async.eachSeries(pids, Flags.dismiss, callback); + }); +}; + +Flags.dismissByUid = function (uid, callback) { + db.getSortedSetRange('uid:' + uid + ':flag:pids', 0, -1, function (err, pids) { + if (err) { + return callback(err); + } + async.eachSeries(pids, Flags.dismiss, callback); + }); +}; + +Flags.get = function (set, cid, uid, start, stop, callback) { + async.waterfall([ + function (next) { + if (Array.isArray(set)) { + db.getSortedSetRevIntersect({sets: set, start: start, stop: -1, aggregate: 'MAX'}, next); + } else { + db.getSortedSetRevRange(set, start, -1, next); + } + }, + function (pids, next) { + if (cid) { + Posts.filterPidsByCid(pids, cid, next); + } else { + process.nextTick(next, null, pids); + } + }, + function (pids, next) { + getFlaggedPostsWithReasons(pids, uid, next); + }, + function (posts, next) { + var count = posts.length; + var end = stop - start + 1; + next(null, {posts: posts.slice(0, stop === -1 ? undefined : end), count: count}); + } + ], callback); +}; + +function getFlaggedPostsWithReasons(pids, uid, callback) { + async.waterfall([ + function (next) { + async.parallel({ + uidsReasons: function (next) { + async.map(pids, function (pid, next) { + db.getSortedSetRange('pid:' + pid + ':flag:uid:reason', 0, -1, next); + }, next); + }, + posts: function (next) { + Posts.getPostSummaryByPids(pids, uid, {stripTags: false, extraFields: ['flags', 'flag:assignee', 'flag:state', 'flag:notes', 'flag:history']}, next); + } + }, next); + }, + function (results, next) { + async.map(results.uidsReasons, function (uidReasons, next) { + async.map(uidReasons, function (uidReason, next) { + var uid = uidReason.split(':')[0]; + var reason = uidReason.substr(uidReason.indexOf(':') + 1); + user.getUserFields(uid, ['username', 'userslug', 'picture'], function (err, userData) { + next(err, {user: userData, reason: reason}); + }); + }, next); + }, function (err, reasons) { + if (err) { + return callback(err); + } + + results.posts.forEach(function (post, index) { + if (post) { + post.flagReasons = reasons[index]; + } + }); + + next(null, results.posts); + }); + }, + async.apply(Posts.expandFlagHistory), + function (posts, next) { + // Parse out flag data into its own object inside each post hash + async.map(posts, function (postObj, next) { + for(var prop in postObj) { + postObj.flagData = postObj.flagData || {}; + + if (postObj.hasOwnProperty(prop) && prop.startsWith('flag:')) { + postObj.flagData[prop.slice(5)] = postObj[prop]; + + if (prop === 'flag:state') { + switch(postObj[prop]) { + case 'open': + postObj.flagData.labelClass = 'info'; + break; + case 'wip': + postObj.flagData.labelClass = 'warning'; + break; + case 'resolved': + postObj.flagData.labelClass = 'success'; + break; + case 'rejected': + postObj.flagData.labelClass = 'danger'; + break; + } + } + + delete postObj[prop]; + } + } + + if (postObj.flagData.assignee) { + user.getUserFields(parseInt(postObj.flagData.assignee, 10), ['username', 'picture'], function (err, userData) { + if (err) { + return next(err); + } + + postObj.flagData.assigneeUser = userData; + next(null, postObj); + }); + } else { + setImmediate(next.bind(null, null, postObj)); + } + }, next); + } + ], callback); +} + +// New method signature (type, id, flagObj, callback) and name (.update()) +// uid used in history string, which should be rewritten too. +Flags.update = function (uid, pid, flagObj, callback) { + // Retrieve existing flag data to compare for history-saving purposes + var changes = []; + var changeset = {}; + var prop; + + Posts.getPostData(pid, function (err, postData) { + if (err) { + return callback(err); + } + + // Track new additions + for(prop in flagObj) { + if (flagObj.hasOwnProperty(prop) && !postData.hasOwnProperty('flag:' + prop) && flagObj[prop].length) { + changes.push(prop); + } + } + + // Track changed items + for(prop in postData) { + if ( + postData.hasOwnProperty(prop) && prop.startsWith('flag:') && + flagObj.hasOwnProperty(prop.slice(5)) && + postData[prop] !== flagObj[prop.slice(5)] + ) { + changes.push(prop.slice(5)); + } + } + + changeset = changes.reduce(function (memo, prop) { + memo['flag:' + prop] = flagObj[prop]; + return memo; + }, {}); + + // Append changes to history string + if (changes.length) { + try { + var history = JSON.parse(postData['flag:history'] || '[]'); + + changes.forEach(function (property) { + switch(property) { + case 'assignee': // intentional fall-through + case 'state': + history.unshift({ + uid: uid, + type: property, + value: flagObj[property], + timestamp: Date.now() + }); + break; + + case 'notes': + history.unshift({ + uid: uid, + type: property, + timestamp: Date.now() + }); + } + }); + + changeset['flag:history'] = JSON.stringify(history); + } catch (e) { + winston.warn('[flags/update] Unable to deserialise post flag history, likely malformed data'); + } + } + + // Save flag data into post hash + if (changes.length) { + Posts.setPostFields(pid, changeset, callback); + } else { + setImmediate(callback); + } + }); +}; + +// To be rewritten and deprecated +Flags.expandFlagHistory = function (posts, callback) { + // Expand flag history + async.map(posts, function (post, next) { + var history; + try { + history = JSON.parse(post['flag:history'] || '[]'); + } catch (e) { + winston.warn('[flags/get] Unable to deserialise post flag history, likely malformed data'); + return callback(e); + } + + async.map(history, function (event, next) { + event.timestampISO = new Date(event.timestamp).toISOString(); + + async.parallel([ + function (next) { + user.getUserFields(event.uid, ['username', 'picture'], function (err, userData) { + if (err) { + return next(err); + } + + event.user = userData; + next(); + }); + }, + function (next) { + if (event.type === 'assignee') { + user.getUserField(parseInt(event.value, 10), 'username', function (err, username) { + if (err) { + return next(err); + } + + event.label = username || 'Unknown user'; + next(null); + }); + } else if (event.type === 'state') { + event.label = '[[topic:flag_manage_state_' + event.value + ']]'; + setImmediate(next); + } else { + setImmediate(next); + } + } + ], function (err) { + next(err, event); + }); + }, function (err, history) { + if (err) { + return next(err); + } + + post['flag:history'] = history; + next(null, post); + }); + }, callback); +}; + +module.exports = Flags; \ No newline at end of file diff --git a/src/posts.js b/src/posts.js index 047917cb5f..b476b84414 100644 --- a/src/posts.js +++ b/src/posts.js @@ -21,7 +21,6 @@ var plugins = require('./plugins'); require('./posts/category')(Posts); require('./posts/summary')(Posts); require('./posts/recent')(Posts); - require('./posts/flags')(Posts); require('./posts/tools')(Posts); require('./posts/votes')(Posts); require('./posts/bookmarks')(Posts); diff --git a/src/posts/delete.js b/src/posts/delete.js index 7a1d3d0cc8..ebf902aef2 100644 --- a/src/posts/delete.js +++ b/src/posts/delete.js @@ -8,6 +8,7 @@ var topics = require('../topics'); var user = require('../user'); var notifications = require('../notifications'); var plugins = require('../plugins'); +var flags = require('../flags'); module.exports = function (Posts) { @@ -145,7 +146,7 @@ module.exports = function (Posts) { db.sortedSetsRemove(['posts:pid', 'posts:flagged'], pid, next); }, function (next) { - Posts.dismissFlag(pid, next); + flags.dismiss(pid, next); } ], function (err) { if (err) { diff --git a/src/posts/flags.js b/src/posts/flags.js deleted file mode 100644 index e81da20f95..0000000000 --- a/src/posts/flags.js +++ /dev/null @@ -1,417 +0,0 @@ - - -'use strict'; - -var async = require('async'); -var winston = require('winston'); -var db = require('../database'); -var user = require('../user'); -var analytics = require('../analytics'); - -module.exports = function (Posts) { - - Posts.flag = function (post, uid, reason, callback) { - if (!parseInt(uid, 10) || !reason) { - return callback(); - } - - async.waterfall([ - function (next) { - async.parallel({ - hasFlagged: async.apply(Posts.isFlaggedByUser, post.pid, uid), - exists: async.apply(Posts.exists, post.pid) - }, next); - }, - function (results, next) { - if (!results.exists) { - return next(new Error('[[error:no-post]]')); - } - - 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 (data, next) { - openNewFlag(post.pid, uid, next); - } - ], function (err) { - if (err) { - return callback(err); - } - analytics.increment('flags'); - callback(); - }); - }; - - function openNewFlag(pid, uid, callback) { - db.sortedSetScore('posts:flags:count', pid, function (err, count) { - if (err) { - return callback(err); - } - if (count === 1) { // Only update state on new flag - Posts.updateFlagData(uid, pid, { - state: 'open' - }, callback); - } else { - callback(); - } - }); - } - - Posts.isFlaggedByUser = function (pid, uid, callback) { - db.isSortedSetMember('pid:' + pid + ':flag:uids', uid, callback); - }; - - Posts.dismissFlag = function (pid, callback) { - async.waterfall([ - function (next) { - db.getObjectFields('post:' + pid, ['pid', 'uid', 'flags'], next); - }, - function (postData, next) { - if (!postData.pid) { - return callback(); - } - async.parallel([ - function (next) { - if (parseInt(postData.uid, 10)) { - if (parseInt(postData.flags, 10) > 0) { - async.parallel([ - async.apply(db.sortedSetIncrBy, 'users:flags', -postData.flags, postData.uid), - async.apply(db.incrObjectFieldBy, 'user:' + postData.uid, 'flags', -postData.flags) - ], next); - } else { - next(); - } - } else { - next(); - } - }, - 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) { - if (err) { - return next(err); - } - - 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'), - async.apply(db.deleteObjectFields, 'post:' + pid, ['flag:state', 'flag:assignee', 'flag:notes', 'flag:history']) - ], next); - }, - function (results, next) { - db.sortedSetsRemoveRangeByScore(['users:flags'], '-inf', 0, next); - } - ], callback); - }; - - Posts.dismissAllFlags = function (callback) { - db.getSortedSetRange('posts:flagged', 0, -1, function (err, pids) { - if (err) { - return callback(err); - } - 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); - }); - }; - - Posts.getFlags = function (set, cid, uid, start, stop, callback) { - async.waterfall([ - function (next) { - if (Array.isArray(set)) { - db.getSortedSetRevIntersect({sets: set, start: start, stop: -1, aggregate: 'MAX'}, next); - } else { - db.getSortedSetRevRange(set, start, -1, next); - } - }, - function (pids, next) { - if (cid) { - Posts.filterPidsByCid(pids, cid, next); - } else { - process.nextTick(next, null, pids); - } - }, - function (pids, next) { - getFlaggedPostsWithReasons(pids, uid, next); - }, - function (posts, next) { - var count = posts.length; - var end = stop - start + 1; - next(null, {posts: posts.slice(0, stop === -1 ? undefined : end), count: count}); - } - ], callback); - }; - - function getFlaggedPostsWithReasons(pids, uid, callback) { - async.waterfall([ - function (next) { - async.parallel({ - uidsReasons: function (next) { - async.map(pids, function (pid, next) { - db.getSortedSetRange('pid:' + pid + ':flag:uid:reason', 0, -1, next); - }, next); - }, - posts: function (next) { - Posts.getPostSummaryByPids(pids, uid, {stripTags: false, extraFields: ['flags', 'flag:assignee', 'flag:state', 'flag:notes', 'flag:history']}, next); - } - }, next); - }, - function (results, next) { - async.map(results.uidsReasons, function (uidReasons, next) { - async.map(uidReasons, function (uidReason, next) { - var uid = uidReason.split(':')[0]; - var reason = uidReason.substr(uidReason.indexOf(':') + 1); - user.getUserFields(uid, ['username', 'userslug', 'picture'], function (err, userData) { - next(err, {user: userData, reason: reason}); - }); - }, next); - }, function (err, reasons) { - if (err) { - return callback(err); - } - - results.posts.forEach(function (post, index) { - if (post) { - post.flagReasons = reasons[index]; - } - }); - - next(null, results.posts); - }); - }, - async.apply(Posts.expandFlagHistory), - function (posts, next) { - // Parse out flag data into its own object inside each post hash - async.map(posts, function (postObj, next) { - for(var prop in postObj) { - postObj.flagData = postObj.flagData || {}; - - if (postObj.hasOwnProperty(prop) && prop.startsWith('flag:')) { - postObj.flagData[prop.slice(5)] = postObj[prop]; - - if (prop === 'flag:state') { - switch(postObj[prop]) { - case 'open': - postObj.flagData.labelClass = 'info'; - break; - case 'wip': - postObj.flagData.labelClass = 'warning'; - break; - case 'resolved': - postObj.flagData.labelClass = 'success'; - break; - case 'rejected': - postObj.flagData.labelClass = 'danger'; - break; - } - } - - delete postObj[prop]; - } - } - - if (postObj.flagData.assignee) { - user.getUserFields(parseInt(postObj.flagData.assignee, 10), ['username', 'picture'], function (err, userData) { - if (err) { - return next(err); - } - - postObj.flagData.assigneeUser = userData; - next(null, postObj); - }); - } else { - setImmediate(next.bind(null, null, postObj)); - } - }, next); - } - ], callback); - } - - Posts.updateFlagData = function (uid, pid, flagObj, callback) { - // Retrieve existing flag data to compare for history-saving purposes - var changes = []; - var changeset = {}; - var prop; - - Posts.getPostData(pid, function (err, postData) { - if (err) { - return callback(err); - } - - // Track new additions - for(prop in flagObj) { - if (flagObj.hasOwnProperty(prop) && !postData.hasOwnProperty('flag:' + prop) && flagObj[prop].length) { - changes.push(prop); - } - } - - // Track changed items - for(prop in postData) { - if ( - postData.hasOwnProperty(prop) && prop.startsWith('flag:') && - flagObj.hasOwnProperty(prop.slice(5)) && - postData[prop] !== flagObj[prop.slice(5)] - ) { - changes.push(prop.slice(5)); - } - } - - changeset = changes.reduce(function (memo, prop) { - memo['flag:' + prop] = flagObj[prop]; - return memo; - }, {}); - - // Append changes to history string - if (changes.length) { - try { - var history = JSON.parse(postData['flag:history'] || '[]'); - - changes.forEach(function (property) { - switch(property) { - case 'assignee': // intentional fall-through - case 'state': - history.unshift({ - uid: uid, - type: property, - value: flagObj[property], - timestamp: Date.now() - }); - break; - - case 'notes': - history.unshift({ - uid: uid, - type: property, - timestamp: Date.now() - }); - } - }); - - changeset['flag:history'] = JSON.stringify(history); - } catch (e) { - winston.warn('[posts/updateFlagData] Unable to deserialise post flag history, likely malformed data'); - } - } - - // Save flag data into post hash - if (changes.length) { - Posts.setPostFields(pid, changeset, callback); - } else { - setImmediate(callback); - } - }); - }; - - Posts.expandFlagHistory = function (posts, callback) { - // Expand flag history - async.map(posts, function (post, next) { - var history; - try { - history = JSON.parse(post['flag:history'] || '[]'); - } catch (e) { - winston.warn('[posts/getFlags] Unable to deserialise post flag history, likely malformed data'); - return callback(e); - } - - async.map(history, function (event, next) { - event.timestampISO = new Date(event.timestamp).toISOString(); - - async.parallel([ - function (next) { - user.getUserFields(event.uid, ['username', 'picture'], function (err, userData) { - if (err) { - return next(err); - } - - event.user = userData; - next(); - }); - }, - function (next) { - if (event.type === 'assignee') { - user.getUserField(parseInt(event.value, 10), 'username', function (err, username) { - if (err) { - return next(err); - } - - event.label = username || 'Unknown user'; - next(null); - }); - } else if (event.type === 'state') { - event.label = '[[topic:flag_manage_state_' + event.value + ']]'; - setImmediate(next); - } else { - setImmediate(next); - } - } - ], function (err) { - next(err, event); - }); - }, function (err, history) { - if (err) { - return next(err); - } - - post['flag:history'] = history; - next(null, post); - }); - }, callback); - }; -}; diff --git a/src/socket.io/posts/flag.js b/src/socket.io/posts/flag.js index 077b88bfc9..88b47058d1 100644 --- a/src/socket.io/posts/flag.js +++ b/src/socket.io/posts/flag.js @@ -12,6 +12,7 @@ var notifications = require('../../notifications'); var plugins = require('../../plugins'); var meta = require('../../meta'); var utils = require('../../../public/src/utils'); +var flags = require('../../flags'); module.exports = function (SocketPosts) { @@ -64,7 +65,7 @@ module.exports = function (SocketPosts) { flaggingUser = user.userData; flaggingUser.uid = socket.uid; - posts.flag(post, socket.uid, data.reason, next); + flags.create('post', post.pid, socket.uid, data.reason, next); }, function (next) { async.parallel({ @@ -119,7 +120,7 @@ module.exports = function (SocketPosts) { if (!isAdminOrGlobalModerator) { return next(new Error('[[no-privileges]]')); } - posts.dismissFlag(pid, next); + flags.dismiss(pid, next); } ], callback); }; @@ -133,7 +134,7 @@ module.exports = function (SocketPosts) { if (!isAdminOrGlobalModerator) { return next(new Error('[[no-privileges]]')); } - posts.dismissAllFlags(next); + flags.dismissAll(next); } ], callback); }; @@ -165,7 +166,7 @@ module.exports = function (SocketPosts) { return memo; }, payload); - posts.updateFlagData(socket.uid, data.pid, payload, next); + flags.update(socket.uid, data.pid, payload, next); } ], callback); }; diff --git a/src/upgrade.js b/src/upgrade.js index 79ffa6b5ee..2a605e84f9 100644 --- a/src/upgrade.js +++ b/src/upgrade.js @@ -462,8 +462,9 @@ Upgrade.upgrade = function (callback) { updatesMade = true; winston.info('[2016/04/29] Dismiss flags from deleted topics'); - var posts = require('./posts'), - topics = require('./topics'); + var posts = require('./posts'); + var topics = require('./topics'); + var flags = require('./flags'); var pids, tids; @@ -486,7 +487,7 @@ Upgrade.upgrade = function (callback) { }).filter(Boolean); winston.info('[2016/04/29] ' + toDismiss.length + ' dismissable flags found'); - async.each(toDismiss, posts.dismissFlag, next); + async.each(toDismiss, flags.dismiss, next); } ], function (err) { if (err) { diff --git a/src/user/admin.js b/src/user/admin.js index 8b5a6ebef4..5d2215980c 100644 --- a/src/user/admin.js +++ b/src/user/admin.js @@ -6,6 +6,7 @@ var db = require('../database'); var posts = require('../posts'); var plugins = require('../plugins'); var winston = require('winston'); +var flags = require('../flags'); module.exports = function (User) { @@ -62,7 +63,7 @@ module.exports = function (User) { } async.eachSeries(uids, function (uid, next) { - posts.dismissUserFlags(uid, next); + flags.dismissByUid(uid, next); }, callback); }; }; From 106502952a79ce18a7f0d1ce54ff036527ee955f Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Thu, 24 Nov 2016 11:56:57 -0500 Subject: [PATCH 02/48] fixed crash in flags page for now --- src/flags.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/flags.js b/src/flags.js index 1e6294aecb..89f42337d4 100644 --- a/src/flags.js +++ b/src/flags.js @@ -246,7 +246,7 @@ Flags.get = function (set, cid, uid, start, stop, callback) { }, function (pids, next) { if (cid) { - Posts.filterPidsByCid(pids, cid, next); + posts.filterPidsByCid(pids, cid, next); } else { process.nextTick(next, null, pids); } @@ -272,7 +272,7 @@ function getFlaggedPostsWithReasons(pids, uid, callback) { }, next); }, posts: function (next) { - Posts.getPostSummaryByPids(pids, uid, {stripTags: false, extraFields: ['flags', 'flag:assignee', 'flag:state', 'flag:notes', 'flag:history']}, next); + posts.getPostSummaryByPids(pids, uid, {stripTags: false, extraFields: ['flags', 'flag:assignee', 'flag:state', 'flag:notes', 'flag:history']}, next); } }, next); }, @@ -299,7 +299,7 @@ function getFlaggedPostsWithReasons(pids, uid, callback) { next(null, results.posts); }); }, - async.apply(Posts.expandFlagHistory), + async.apply(Flags.expandFlagHistory), function (posts, next) { // Parse out flag data into its own object inside each post hash async.map(posts, function (postObj, next) { @@ -355,7 +355,7 @@ Flags.update = function (uid, pid, flagObj, callback) { var changeset = {}; var prop; - Posts.getPostData(pid, function (err, postData) { + posts.getPostData(pid, function (err, postData) { if (err) { return callback(err); } @@ -417,7 +417,7 @@ Flags.update = function (uid, pid, flagObj, callback) { // Save flag data into post hash if (changes.length) { - Posts.setPostFields(pid, changeset, callback); + posts.setPostFields(pid, changeset, callback); } else { setImmediate(callback); } From 640df0379e3e3b818c9398da5a26b2585de550fd Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Fri, 25 Nov 2016 12:43:10 -0500 Subject: [PATCH 03/48] flag list page (#5232) --- public/language/en-GB/flags.json | 6 ++ src/controllers/mods.js | 9 ++- src/flags.js | 101 ++++++++++++++++++++++--------- src/routes/index.js | 2 +- 4 files changed, 87 insertions(+), 31 deletions(-) create mode 100644 public/language/en-GB/flags.json diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json new file mode 100644 index 0000000000..de083196e7 --- /dev/null +++ b/public/language/en-GB/flags.json @@ -0,0 +1,6 @@ +{ + "quick-filters": "Quick Filters", + "reporter": "Reporter", + "reported-at": "Reported At", + "no-flags": "Hooray! No flags found." +} \ No newline at end of file diff --git a/src/controllers/mods.js b/src/controllers/mods.js index 0079412f87..2a1aad09dc 100644 --- a/src/controllers/mods.js +++ b/src/controllers/mods.js @@ -3,7 +3,8 @@ var async = require('async'); var user = require('../user'); -var adminFlagsController = require('./admin/flags'); +var flags = require('../flags'); +// var adminFlagsController = require('./admin/flags'); var modsController = {}; @@ -20,7 +21,11 @@ modsController.flagged = function (req, res, next) { res.locals.cids = results.moderatedCids; } - adminFlagsController.get(req, res, next); + flags.list({}, function(err, flags) { + res.render('flags', { + flags: flags + }); + }); }); }; diff --git a/src/flags.js b/src/flags.js index 89f42337d4..bdf1ac37bb 100644 --- a/src/flags.js +++ b/src/flags.js @@ -13,10 +13,71 @@ var utils = require('../public/src/utils'); var Flags = { _defaults: { - state: 'open' + state: 'open', + assignee: null } }; +Flags.list = function (filters, callback) { + if (typeof filters === 'function' && !callback) { + callback = filters; + filters = {}; + } + + async.waterfall([ + async.apply(db.getSortedSetRevRange.bind(db), 'flags:datetime', 0, 19), + function (flagIds, next) { + async.map(flagIds, function (flagId, next) { + async.waterfall([ + async.apply(db.getObject, 'flag:' + flagId), + function (flagObj, next) { + user.getUserFields(flagObj.uid, ['username', 'picture'], function (err, userObj) { + next(err, Object.assign(flagObj, { + user: { + username: userObj.username, + picture: userObj.picture, + 'icon:bgColor': userObj['icon:bgColor'], + 'icon:text': userObj['icon:text'] + } + })); + }) + } + ], function (err, flagObj) { + if (err) { + return next(err); + } + + switch(flagObj.state) { + case 'open': + flagObj.labelClass = 'info'; + break; + case 'wip': + flagObj.labelClass = 'warning'; + break; + case 'resolved': + flagObj.labelClass = 'success'; + break; + case 'rejected': + flagObj.labelClass = 'danger'; + break; + } + + next(null, Object.assign(flagObj, { + target_readable: flagObj.type.charAt(0).toUpperCase() + flagObj.type.slice(1) + ' ' + flagObj.id, + datetimeISO: new Date(parseInt(flagObj.datetime, 10)).toISOString() + })); + }); + }, next); + } + ], function (err, flags) { + if (err) { + return callback(err); + } + + return callback(null, flags); + }); +}; + Flags.create = function (type, id, uid, reason, callback) { async.waterfall([ function (next) { @@ -38,23 +99,22 @@ Flags.create = function (type, id, uid, reason, callback) { var flagId = utils.generateUUID(); async.parallel([ - async.apply(db.setObject.bind(db), 'flag:' + flagId, _.defaults({ - description: reason - }), Flags._defaults), - async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', now, flagId) - ], function (err) { - if (err) { - return next(err); - } - }); + async.apply(db.setObject.bind(db), 'flag:' + flagId, Object.assign({}, Flags._defaults, { + type: type, + id: id, + description: reason, + uid: uid, + datetime: Date.now() + })), + async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', Date.now(), flagId) + ], next); } ], function (err) { if (err) { return callback(err); } - console.log('done', arguments); - process.exit(); + callback(); }); // if (!parseInt(uid, 10) || !reason) { // return callback(); @@ -107,7 +167,7 @@ Flags.create = function (type, id, uid, reason, callback) { // ], next); // }, // function (data, next) { - // openNewFlag(post.pid, uid, next); + // openNewFlag(post.pid, uid, next); // removed, used to just update flag to open state if new flag // } // ], function (err) { // if (err) { @@ -118,21 +178,6 @@ Flags.create = function (type, id, uid, reason, callback) { // }); }; -function openNewFlag(pid, uid, callback) { - db.sortedSetScore('posts:flags:count', pid, function (err, count) { - if (err) { - return callback(err); - } - if (count === 1) { // Only update state on new flag - Flags.update(uid, pid, { - state: 'open' - }, callback); - } else { - callback(); - } - }); -} - Flags.exists = function (type, id, uid, callback) { db.isObjectField('flagHash:flagId', [type, id, uid].join(':'), callback); }; diff --git a/src/routes/index.js b/src/routes/index.js index f36ad1468a..0273160745 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -40,7 +40,7 @@ function mainRoutes(app, middleware, controllers) { } function modRoutes(app, middleware, controllers) { - setupPageRoute(app, '/posts/flags', middleware, [], controllers.mods.flagged); + setupPageRoute(app, '/flags', middleware, [], controllers.mods.flagged); } function globalModRoutes(app, middleware, controllers) { From 98a104564b511155cbf24a78406905b07f96ae74 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Fri, 25 Nov 2016 14:17:51 -0500 Subject: [PATCH 04/48] some light refactoring, details API (#5232) --- public/language/en-GB/flags.json | 1 + src/controllers/mods.js | 28 ++++++++-- src/flags.js | 92 ++++++++++++++++++++------------ src/routes/index.js | 3 +- 4 files changed, 86 insertions(+), 38 deletions(-) diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json index de083196e7..ad8456ebf6 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -1,5 +1,6 @@ { "quick-filters": "Quick Filters", + "state": "State", "reporter": "Reporter", "reported-at": "Reported At", "no-flags": "Hooray! No flags found." diff --git a/src/controllers/mods.js b/src/controllers/mods.js index 2a1aad09dc..f5a6e9b06d 100644 --- a/src/controllers/mods.js +++ b/src/controllers/mods.js @@ -6,15 +6,19 @@ var user = require('../user'); var flags = require('../flags'); // var adminFlagsController = require('./admin/flags'); -var modsController = {}; +var modsController = { + flags: {} +}; -modsController.flagged = function (req, res, next) { +modsController.flags.list = function (req, res, next) { async.parallel({ isAdminOrGlobalMod: async.apply(user.isAdminOrGlobalMod, req.uid), moderatedCids: async.apply(user.getModeratedCids, req.uid) }, function (err, results) { - if (err || !(results.isAdminOrGlobalMod || !!results.moderatedCids.length)) { + if (err) { return next(err); + } else if (!(results.isAdminOrGlobalMod || !!results.moderatedCids.length)) { + return next(new Error('[[error:no-privileges]]')); } if (!results.isAdminOrGlobalMod && results.moderatedCids.length) { @@ -22,11 +26,27 @@ modsController.flagged = function (req, res, next) { } flags.list({}, function(err, flags) { - res.render('flags', { + res.render('flags/list', { flags: flags }); }); }); }; +modsController.flags.detail = function (req, res, next) { + async.parallel({ + isAdminOrGlobalMod: async.apply(user.isAdminOrGlobalMod, req.uid), + moderatedCids: async.apply(user.getModeratedCids, req.uid), + flagData: async.apply(flags.get, req.params.flagId) + }, function (err, results) { + if (err || !results.flagData) { + return next(err || new Error('[[error:invalid-data]]')); + } else if (!(results.isAdminOrGlobalMod || !!results.moderatedCids.length)) { + return next(new Error('[[error:no-privileges]]')); + } + + res.render('flags/detail', results.flagData); + }); +}; + module.exports = modsController; diff --git a/src/flags.js b/src/flags.js index bdf1ac37bb..8734f811a8 100644 --- a/src/flags.js +++ b/src/flags.js @@ -18,6 +18,30 @@ var Flags = { } }; +Flags.get = function (flagId, callback) { + async.waterfall([ + async.apply(async.parallel, { + base: async.apply(db.getObject.bind(db), 'flag:' + flagId), + history: async.apply(db.getSortedSetRevRange.bind(db), 'flag:' + flagId + ':history', 0, -1), + notes: async.apply(db.getSortedSetRevRange.bind(db), 'flag:' + flagId + ':notes', 0, -1) + }), + function (data, next) { + user.getUserFields(data.base.uid, ['username', 'picture'], function (err, userObj) { + next(err, Object.assign(data.base, { + history: data.history, + notes: data.notes, + reporter: { + username: userObj.username, + picture: userObj.picture, + 'icon:bgColor': userObj['icon:bgColor'], + 'icon:text': userObj['icon:text'] + } + })); + }); + } + ], callback); +}; + Flags.list = function (filters, callback) { if (typeof filters === 'function' && !callback) { callback = filters; @@ -33,7 +57,7 @@ Flags.list = function (filters, callback) { function (flagObj, next) { user.getUserFields(flagObj.uid, ['username', 'picture'], function (err, userObj) { next(err, Object.assign(flagObj, { - user: { + reporter: { username: userObj.username, picture: userObj.picture, 'icon:bgColor': userObj['icon:bgColor'], @@ -63,7 +87,7 @@ Flags.list = function (filters, callback) { } next(null, Object.assign(flagObj, { - target_readable: flagObj.type.charAt(0).toUpperCase() + flagObj.type.slice(1) + ' ' + flagObj.id, + target_readable: flagObj.type.charAt(0).toUpperCase() + flagObj.type.slice(1) + ' ' + flagObj.targetId, datetimeISO: new Date(parseInt(flagObj.datetime, 10)).toISOString() })); }); @@ -95,18 +119,19 @@ Flags.create = function (type, id, uid, reason, callback) { } }); }, - function (next) { - var flagId = utils.generateUUID(); - + async.apply(db.incrObjectField, 'global', 'nextFlagId'), + function (flagId, next) { async.parallel([ async.apply(db.setObject.bind(db), 'flag:' + flagId, Object.assign({}, Flags._defaults, { + flagId: flagId, type: type, - id: id, + targetId: id, description: reason, uid: uid, datetime: Date.now() })), - async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', Date.now(), flagId) + async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', Date.now(), flagId), + async.apply(db.setObjectField.bind(db), 'flagHash:flagId', [type, id, uid].join(':'), flagId) ], next); } ], function (err) { @@ -280,32 +305,33 @@ Flags.dismissByUid = function (uid, callback) { }); }; -Flags.get = function (set, cid, uid, start, stop, callback) { - async.waterfall([ - function (next) { - if (Array.isArray(set)) { - db.getSortedSetRevIntersect({sets: set, start: start, stop: -1, aggregate: 'MAX'}, next); - } else { - db.getSortedSetRevRange(set, start, -1, next); - } - }, - function (pids, next) { - if (cid) { - posts.filterPidsByCid(pids, cid, next); - } else { - process.nextTick(next, null, pids); - } - }, - function (pids, next) { - getFlaggedPostsWithReasons(pids, uid, next); - }, - function (posts, next) { - var count = posts.length; - var end = stop - start + 1; - next(null, {posts: posts.slice(0, stop === -1 ? undefined : end), count: count}); - } - ], callback); -}; +// This is the old method to get list of flags, supercede by Flags.list(); +// Flags.get = function (set, cid, uid, start, stop, callback) { +// async.waterfall([ +// function (next) { +// if (Array.isArray(set)) { +// db.getSortedSetRevIntersect({sets: set, start: start, stop: -1, aggregate: 'MAX'}, next); +// } else { +// db.getSortedSetRevRange(set, start, -1, next); +// } +// }, +// function (pids, next) { +// if (cid) { +// posts.filterPidsByCid(pids, cid, next); +// } else { +// process.nextTick(next, null, pids); +// } +// }, +// function (pids, next) { +// getFlaggedPostsWithReasons(pids, uid, next); +// }, +// function (posts, next) { +// var count = posts.length; +// var end = stop - start + 1; +// next(null, {posts: posts.slice(0, stop === -1 ? undefined : end), count: count}); +// } +// ], callback); +// }; function getFlaggedPostsWithReasons(pids, uid, callback) { async.waterfall([ diff --git a/src/routes/index.js b/src/routes/index.js index 0273160745..f71eac2c62 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -40,7 +40,8 @@ function mainRoutes(app, middleware, controllers) { } function modRoutes(app, middleware, controllers) { - setupPageRoute(app, '/flags', middleware, [], controllers.mods.flagged); + setupPageRoute(app, '/flags', middleware, [], controllers.mods.flags.list); + setupPageRoute(app, '/flags/:flagId', middleware, [], controllers.mods.flags.detail); } function globalModRoutes(app, middleware, controllers) { From 9f7c4befea24d4af92bfb60fecc065125f6b5ffe Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Fri, 25 Nov 2016 15:09:52 -0500 Subject: [PATCH 05/48] omg tests (#5232), and .create returns flag data now --- src/controllers/mods.js | 6 ++- src/flags.js | 23 +++++---- test/flags.js | 107 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 test/flags.js diff --git a/src/controllers/mods.js b/src/controllers/mods.js index f5a6e9b06d..7ebb70d888 100644 --- a/src/controllers/mods.js +++ b/src/controllers/mods.js @@ -25,7 +25,11 @@ modsController.flags.list = function (req, res, next) { res.locals.cids = results.moderatedCids; } - flags.list({}, function(err, flags) { + flags.list({}, function (err, flags) { + if (err) { + return next(err); + } + res.render('flags/list', { flags: flags }); diff --git a/src/flags.js b/src/flags.js index 8734f811a8..d9f6aa136f 100644 --- a/src/flags.js +++ b/src/flags.js @@ -64,7 +64,7 @@ Flags.list = function (filters, callback) { 'icon:text': userObj['icon:text'] } })); - }) + }); } ], function (err, flagObj) { if (err) { @@ -110,6 +110,10 @@ Flags.create = function (type, id, uid, reason, callback) { async.apply(Flags.exists, type, id, uid), async.apply(Flags.targetExists, type, id) ], function (err, checks) { + if (err) { + return next(err); + } + if (checks[0]) { return next(new Error('[[error:already-flagged]]')); } else if (!checks[1]) { @@ -132,15 +136,16 @@ Flags.create = function (type, id, uid, reason, callback) { })), async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', Date.now(), flagId), async.apply(db.setObjectField.bind(db), 'flagHash:flagId', [type, id, uid].join(':'), flagId) - ], next); - } - ], function (err) { - if (err) { - return callback(err); - } + ], function (err, data) { + if (err) { + return next(err); + } - callback(); - }); + next(null, flagId); + }); + }, + async.apply(Flags.get) + ], callback); // if (!parseInt(uid, 10) || !reason) { // return callback(); // } diff --git a/test/flags.js b/test/flags.js new file mode 100644 index 0000000000..ed879fba49 --- /dev/null +++ b/test/flags.js @@ -0,0 +1,107 @@ +'use strict'; +/*globals require, before, after, describe, it*/ + +var assert = require('assert'); +var async = require('async'); + +var db = require('./mocks/databasemock'); +var Flags = require('../src/flags'); +var Categories = require('../src/categories'); +var Topics = require('../src/topics'); +var User = require('../src/user'); + +describe('Flags', function () { + before(function (done) { + // Create some stuff to flag + async.waterfall([ + async.apply(User.create, {username: 'testUser', password: 'abcdef', email: 'b@c.com'}), + function (uid, next) { + Categories.create({ + name: 'test category' + }, function (err, category) { + if (err) { + return done(err); + } + + Topics.post({ + cid: category.cid, + uid: uid, + title: 'Topic to flag', + content: 'This is flaggable content' + }, next); + }); + } + ], done); + }); + + describe('.create()', function () { + it('should create a flag and return its data', function (done) { + Flags.create('post', 1, 1, 'Test flag', function (err, flagData) { + assert.ifError(err); + var compare = { + flagId: 1, + uid: 1, + targetId: 1, + type: 'post', + description: 'Test flag', + state: 'open' + }; + + for(var key in compare) { + if (compare.hasOwnProperty(key)) { + assert.ok(flagData[key]); + assert.strictEqual(flagData[key], compare[key]); + } + } + + done(); + }); + }); + }); + + describe('.get()', function () { + it('should retrieve and display a flag\'s data', function (done) { + Flags.get(1, function (err, flagData) { + assert.ifError(err); + var compare = { + flagId: 1, + uid: 1, + targetId: 1, + type: 'post', + description: 'Test flag', + state: 'open' + }; + + for(var key in compare) { + if (compare.hasOwnProperty(key)) { + assert.ok(flagData[key]); + assert.strictEqual(flagData[key], compare[key]); + } + } + + done(); + }); + }); + }); + + describe('.list()', function () { + it('should show a list of flags (with one item)', function (done) { + Flags.list({}, function (err, flags) { + assert.ifError(err); + assert.ok(Array.isArray(flags)); + assert.equal(flags.length, 1); + + Flags.get(flags[0].flagId, function (err, flagData) { + assert.ifError(err); + assert.equal(flags[0].flagId, flagData.flagId); + assert.equal(flags[0].description, flagData.description); + done(); + }); + }); + }); + }); + + after(function (done) { + db.emptydb(done); + }); +}); From fceb5cc86b600087bff8b2adc983e70c3fbb725e Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Tue, 29 Nov 2016 22:10:51 -0500 Subject: [PATCH 06/48] more work on flags detail pages (#5232) --- public/language/en-GB/flags.json | 16 +++++++++----- public/language/en-GB/topic.json | 5 ----- src/flags.js | 38 +++++++++++++++++++++++++++----- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json index ad8456ebf6..bf22e68df6 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -1,7 +1,13 @@ { - "quick-filters": "Quick Filters", - "state": "State", - "reporter": "Reporter", - "reported-at": "Reported At", - "no-flags": "Hooray! No flags found." + "quick-filters": "Quick Filters", + "state": "State", + "reporter": "Reporter", + "reported-at": "Reported At", + "no-flags": "Hooray! No flags found.", + + "state": "State", + "state-open": "New/Open", + "state-wip": "Work in Progress", + "state-resolved": "Resolved", + "state-rejected": "Rejected" } \ No newline at end of file diff --git a/public/language/en-GB/topic.json b/public/language/en-GB/topic.json index 29a85c15cc..885afe5d62 100644 --- a/public/language/en-GB/topic.json +++ b/public/language/en-GB/topic.json @@ -41,11 +41,6 @@ "flag_manage_history": "Action History", "flag_manage_no_history": "No event history to report", "flag_manage_assignee": "Assignee", - "flag_manage_state": "State", - "flag_manage_state_open": "New/Open", - "flag_manage_state_wip": "Work in Progress", - "flag_manage_state_resolved": "Resolved", - "flag_manage_state_rejected": "Rejected", "flag_manage_notes": "Shared Notes", "flag_manage_update": "Update Flag Status", "flag_manage_history_assignee": "Assigned to %1", diff --git a/src/flags.js b/src/flags.js index d9f6aa136f..65ab029e2a 100644 --- a/src/flags.js +++ b/src/flags.js @@ -20,21 +20,30 @@ var Flags = { Flags.get = function (flagId, callback) { async.waterfall([ + // First stage async.apply(async.parallel, { base: async.apply(db.getObject.bind(db), 'flag:' + flagId), history: async.apply(db.getSortedSetRevRange.bind(db), 'flag:' + flagId + ':history', 0, -1), notes: async.apply(db.getSortedSetRevRange.bind(db), 'flag:' + flagId + ':notes', 0, -1) }), function (data, next) { - user.getUserFields(data.base.uid, ['username', 'picture'], function (err, userObj) { + // Second stage + async.parallel({ + userObj: async.apply(user.getUserFields, data.base.uid, ['username', 'picture']), + targetObj: async.apply(Flags.getTarget, data.base.type, data.base.targetId, data.base.uid) + }, function (err, payload) { + // Final object return construction next(err, Object.assign(data.base, { + datetimeISO: new Date(data.base.datetime).toISOString(), + target_readable: data.base.type.charAt(0).toUpperCase() + data.base.type.slice(1) + ' ' + data.base.targetId, + target: payload.targetObj, history: data.history, notes: data.notes, reporter: { - username: userObj.username, - picture: userObj.picture, - 'icon:bgColor': userObj['icon:bgColor'], - 'icon:text': userObj['icon:text'] + username: payload.userObj.username, + picture: payload.userObj.picture, + 'icon:bgColor': payload.userObj['icon:bgColor'], + 'icon:text': payload.userObj['icon:text'] } })); }); @@ -102,6 +111,25 @@ Flags.list = function (filters, callback) { }); }; +Flags.getTarget = function (type, id, uid, callback) { + switch (type) { + case 'post': + async.waterfall([ + async.apply(posts.getPostsByPids, [id], uid), + function (posts, next) { + topics.addPostData(posts, uid, next); + } + ], function (err, posts) { + callback(err, posts[0]); + }); + break; + + case 'user': + user.getUsersData(id, callback); + break; + } +}; + Flags.create = function (type, id, uid, reason, callback) { async.waterfall([ function (next) { From 77809b2b52002d62babde09b10067e93c845a1fe Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Wed, 30 Nov 2016 20:26:45 -0500 Subject: [PATCH 07/48] added avatar-xl size --- public/less/generics.less | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/public/less/generics.less b/public/less/generics.less index 8533dd33b9..5b3c8eff4e 100644 --- a/public/less/generics.less +++ b/public/less/generics.less @@ -107,6 +107,12 @@ } &.avatar-lg { + width: 64px; + height: 64px; + .user-icon-style(64px, 4rem); + } + + &.avatar-xl { width: 128px; height: 128px; .user-icon-style(128px, 7.5rem); From c5c2d27180a5ab39edd87d3fd83a0b86a4ff85a3 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Wed, 30 Nov 2016 20:34:06 -0500 Subject: [PATCH 08/48] flag assignees, state, notes WIP, #5232 --- public/language/en-GB/flags.json | 7 +++++- public/language/en-GB/topic.json | 4 ---- src/controllers/mods.js | 7 ++++-- src/flags.js | 40 +++++++++++++++++++++++++++++++- src/user/data.js | 13 +++++++++++ 5 files changed, 63 insertions(+), 8 deletions(-) diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json index bf22e68df6..d266e2c4db 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -4,10 +4,15 @@ "reporter": "Reporter", "reported-at": "Reported At", "no-flags": "Hooray! No flags found.", + "assignee": "Assignee", + "update": "Update", + "notes": "Flag Notes", + "add-note": "Add Note", "state": "State", "state-open": "New/Open", "state-wip": "Work in Progress", "state-resolved": "Resolved", - "state-rejected": "Rejected" + "state-rejected": "Rejected", + "no-assignee": "Not Assigned" } \ No newline at end of file diff --git a/public/language/en-GB/topic.json b/public/language/en-GB/topic.json index 885afe5d62..ce7f35f816 100644 --- a/public/language/en-GB/topic.json +++ b/public/language/en-GB/topic.json @@ -37,12 +37,8 @@ "flag_title": "Flag this post for moderation", "flag_success": "This post has been flagged for moderation.", - "flag_manage_title": "Flagged post in %1", "flag_manage_history": "Action History", "flag_manage_no_history": "No event history to report", - "flag_manage_assignee": "Assignee", - "flag_manage_notes": "Shared Notes", - "flag_manage_update": "Update Flag Status", "flag_manage_history_assignee": "Assigned to %1", "flag_manage_history_state": "Updated state to %1", "flag_manage_history_notes": "Updated flag notes", diff --git a/src/controllers/mods.js b/src/controllers/mods.js index 7ebb70d888..012ffde3c6 100644 --- a/src/controllers/mods.js +++ b/src/controllers/mods.js @@ -41,7 +41,8 @@ modsController.flags.detail = function (req, res, next) { async.parallel({ isAdminOrGlobalMod: async.apply(user.isAdminOrGlobalMod, req.uid), moderatedCids: async.apply(user.getModeratedCids, req.uid), - flagData: async.apply(flags.get, req.params.flagId) + flagData: async.apply(flags.get, req.params.flagId), + assignees: async.apply(user.getAdminsandGlobalModsandModerators) }, function (err, results) { if (err || !results.flagData) { return next(err || new Error('[[error:invalid-data]]')); @@ -49,7 +50,9 @@ modsController.flags.detail = function (req, res, next) { return next(new Error('[[error:no-privileges]]')); } - res.render('flags/detail', results.flagData); + res.render('flags/detail', Object.assign(results.flagData, { + assignees: results.assignees + })); }); }; diff --git a/src/flags.js b/src/flags.js index 65ab029e2a..b88dac6b2f 100644 --- a/src/flags.js +++ b/src/flags.js @@ -10,6 +10,7 @@ var analytics = require('./analytics'); var topics = require('./topics'); var posts = require('./posts'); var utils = require('../public/src/utils'); +var _ = require('underscore'); var Flags = { _defaults: { @@ -24,7 +25,7 @@ Flags.get = function (flagId, callback) { async.apply(async.parallel, { base: async.apply(db.getObject.bind(db), 'flag:' + flagId), history: async.apply(db.getSortedSetRevRange.bind(db), 'flag:' + flagId + ':history', 0, -1), - notes: async.apply(db.getSortedSetRevRange.bind(db), 'flag:' + flagId + ':notes', 0, -1) + notes: async.apply(Flags.getNotes, flagId) }), function (data, next) { // Second stage @@ -130,6 +131,43 @@ Flags.getTarget = function (type, id, uid, callback) { } }; +Flags.getNotes = function (flagId, callback) { + async.waterfall([ + async.apply(db.getSortedSetRevRangeWithScores.bind(db), 'flag:' + flagId + ':notes', 0, -1), + function (notes, next) { + var uids = []; + var noteObj; + notes = notes.map(function (note) { + try { + noteObj = JSON.parse(note.value); + uids.push(noteObj[0]); + return { + uid: noteObj[0], + content: noteObj[1], + datetime: note.score, + datetimeISO: new Date(note.score).toISOString() + }; + } catch (e) { + return next(e); + } + }); + next(null, notes, uids); + }, + function (notes, uids, next) { + user.getUsersData(uids, function (err, users) { + if (err) { + return next(err); + } + + next(null, notes.map(function (note, idx) { + note.user = users[idx]; + return note; + })); + }); + } + ], callback); +}; + Flags.create = function (type, id, uid, reason, callback) { async.waterfall([ function (next) { diff --git a/src/user/data.js b/src/user/data.js index cbaf066ded..1e10704c73 100644 --- a/src/user/data.js +++ b/src/user/data.js @@ -80,6 +80,15 @@ module.exports = function (User) { return callback(null, []); } + // Eliminate duplicates and build ref table + uids = uids.filter(function (uid, index) { + return index === uids.indexOf(uid); + }); + var ref = uids.reduce(function (memo, cur, idx) { + memo[cur] = idx; + return memo; + }, {}); + var keys = uids.map(function (uid) { return 'user:' + uid; }); @@ -89,6 +98,10 @@ module.exports = function (User) { return callback(err); } + users = uids.map(function (uid) { + return users[ref[uid]]; + }); + modifyUserData(users, [], callback); }); }; From e9ff605a2024006bd7c8c451b057981cea58bb2f Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Thu, 1 Dec 2016 09:24:49 -0500 Subject: [PATCH 09/48] some more tests for #5232 --- src/flags.js | 4 +++- test/flags.js | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/flags.js b/src/flags.js index b88dac6b2f..8be774b467 100644 --- a/src/flags.js +++ b/src/flags.js @@ -126,7 +126,9 @@ Flags.getTarget = function (type, id, uid, callback) { break; case 'user': - user.getUsersData(id, callback); + user.getUsersData([id], function (err, users) { + callback(err, users ? users[0] : undefined); + }); break; } }; diff --git a/test/flags.js b/test/flags.js index ed879fba49..c5e69aa973 100644 --- a/test/flags.js +++ b/test/flags.js @@ -101,6 +101,48 @@ describe('Flags', function () { }); }); + describe('.getTarget()', function() { + it('should return a post\'s data if queried with type "post"', function (done) { + Flags.getTarget('post', 1, 1, function (err, data) { + assert.ifError(err); + var compare = { + uid: 1, + pid: 1, + content: 'This is flaggable content' + }; + + for(var key in compare) { + if (compare.hasOwnProperty(key)) { + assert.ok(data[key]); + assert.strictEqual(data[key], compare[key]); + } + } + + done(); + }); + }); + + it('should return a user\'s data if queried with type "user"', function (done) { + Flags.getTarget('user', 1, 1, function (err, data) { + assert.ifError(err); + var compare = { + uid: 1, + username: 'testUser', + email: 'b@c.com' + }; + + for(var key in compare) { + if (compare.hasOwnProperty(key)) { + assert.ok(data[key]); + assert.strictEqual(data[key], compare[key]); + } + } + + done(); + }); + }); + });; + after(function (done) { db.emptydb(done); }); From 888c120e08e7c725c820a302aac5d0e766254114 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Thu, 1 Dec 2016 09:42:24 -0500 Subject: [PATCH 10/48] removed some unneeded methods for #5232 --- src/flags.js | 118 --------------------------------------------------- 1 file changed, 118 deletions(-) diff --git a/src/flags.js b/src/flags.js index 8be774b467..99039aa175 100644 --- a/src/flags.js +++ b/src/flags.js @@ -292,11 +292,6 @@ Flags.targetExists = function (type, id, callback) { } }; -/* new signature (type, id, uid, callback) */ -Flags.isFlaggedByUser = function (pid, uid, callback) { - db.isSortedSetMember('pid:' + pid + ':flag:uids', uid, callback); -}; - Flags.dismiss = function (pid, callback) { async.waterfall([ function (next) { @@ -378,119 +373,6 @@ Flags.dismissByUid = function (uid, callback) { }); }; -// This is the old method to get list of flags, supercede by Flags.list(); -// Flags.get = function (set, cid, uid, start, stop, callback) { -// async.waterfall([ -// function (next) { -// if (Array.isArray(set)) { -// db.getSortedSetRevIntersect({sets: set, start: start, stop: -1, aggregate: 'MAX'}, next); -// } else { -// db.getSortedSetRevRange(set, start, -1, next); -// } -// }, -// function (pids, next) { -// if (cid) { -// posts.filterPidsByCid(pids, cid, next); -// } else { -// process.nextTick(next, null, pids); -// } -// }, -// function (pids, next) { -// getFlaggedPostsWithReasons(pids, uid, next); -// }, -// function (posts, next) { -// var count = posts.length; -// var end = stop - start + 1; -// next(null, {posts: posts.slice(0, stop === -1 ? undefined : end), count: count}); -// } -// ], callback); -// }; - -function getFlaggedPostsWithReasons(pids, uid, callback) { - async.waterfall([ - function (next) { - async.parallel({ - uidsReasons: function (next) { - async.map(pids, function (pid, next) { - db.getSortedSetRange('pid:' + pid + ':flag:uid:reason', 0, -1, next); - }, next); - }, - posts: function (next) { - posts.getPostSummaryByPids(pids, uid, {stripTags: false, extraFields: ['flags', 'flag:assignee', 'flag:state', 'flag:notes', 'flag:history']}, next); - } - }, next); - }, - function (results, next) { - async.map(results.uidsReasons, function (uidReasons, next) { - async.map(uidReasons, function (uidReason, next) { - var uid = uidReason.split(':')[0]; - var reason = uidReason.substr(uidReason.indexOf(':') + 1); - user.getUserFields(uid, ['username', 'userslug', 'picture'], function (err, userData) { - next(err, {user: userData, reason: reason}); - }); - }, next); - }, function (err, reasons) { - if (err) { - return callback(err); - } - - results.posts.forEach(function (post, index) { - if (post) { - post.flagReasons = reasons[index]; - } - }); - - next(null, results.posts); - }); - }, - async.apply(Flags.expandFlagHistory), - function (posts, next) { - // Parse out flag data into its own object inside each post hash - async.map(posts, function (postObj, next) { - for(var prop in postObj) { - postObj.flagData = postObj.flagData || {}; - - if (postObj.hasOwnProperty(prop) && prop.startsWith('flag:')) { - postObj.flagData[prop.slice(5)] = postObj[prop]; - - if (prop === 'flag:state') { - switch(postObj[prop]) { - case 'open': - postObj.flagData.labelClass = 'info'; - break; - case 'wip': - postObj.flagData.labelClass = 'warning'; - break; - case 'resolved': - postObj.flagData.labelClass = 'success'; - break; - case 'rejected': - postObj.flagData.labelClass = 'danger'; - break; - } - } - - delete postObj[prop]; - } - } - - if (postObj.flagData.assignee) { - user.getUserFields(parseInt(postObj.flagData.assignee, 10), ['username', 'picture'], function (err, userData) { - if (err) { - return next(err); - } - - postObj.flagData.assigneeUser = userData; - next(null, postObj); - }); - } else { - setImmediate(next.bind(null, null, postObj)); - } - }, next); - } - ], callback); -} - // New method signature (type, id, flagObj, callback) and name (.update()) // uid used in history string, which should be rewritten too. Flags.update = function (uid, pid, flagObj, callback) { From 709a7ff7f00df8fd70c5308337f9bd774d05f759 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Thu, 1 Dec 2016 11:04:37 -0500 Subject: [PATCH 11/48] fixed issue with getUsersData not actually returning the same number of elements as was passed-in --- src/user/data.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/user/data.js b/src/user/data.js index 1e10704c73..2e5bfb2218 100644 --- a/src/user/data.js +++ b/src/user/data.js @@ -81,15 +81,15 @@ module.exports = function (User) { } // Eliminate duplicates and build ref table - uids = uids.filter(function (uid, index) { + var uniqueUids = uids.filter(function (uid, index) { return index === uids.indexOf(uid); }); - var ref = uids.reduce(function (memo, cur, idx) { + var ref = uniqueUids.reduce(function (memo, cur, idx) { memo[cur] = idx; return memo; }, {}); - var keys = uids.map(function (uid) { + var keys = uniqueUids.map(function (uid) { return 'user:' + uid; }); From d9d60c20bd743abd0a027efef474916278e3d793 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Thu, 1 Dec 2016 11:42:06 -0500 Subject: [PATCH 12/48] flag updating and note appending, #5232 --- public/language/en-GB/flags.json | 4 +- public/src/client/flags/detail.js | 60 +++++++++++ public/src/client/topic/flag.js | 2 +- src/flags.js | 156 ++++++--------------------- src/socket.io/flags.js | 167 ++++++++++++++++++++++++++++ src/socket.io/index.js | 6 +- src/socket.io/posts.js | 1 - src/socket.io/posts/flag.js | 173 ------------------------------ 8 files changed, 270 insertions(+), 299 deletions(-) create mode 100644 public/src/client/flags/detail.js create mode 100644 src/socket.io/flags.js delete mode 100644 src/socket.io/posts/flag.js diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json index d266e2c4db..0c1f32b88e 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -14,5 +14,7 @@ "state-wip": "Work in Progress", "state-resolved": "Resolved", "state-rejected": "Rejected", - "no-assignee": "Not Assigned" + "no-assignee": "Not Assigned", + "updated": "Flag Details Updated", + "note-added": "Note Added" } \ No newline at end of file diff --git a/public/src/client/flags/detail.js b/public/src/client/flags/detail.js new file mode 100644 index 0000000000..d23a1941b5 --- /dev/null +++ b/public/src/client/flags/detail.js @@ -0,0 +1,60 @@ +'use strict'; + +/* globals define */ + +define('forum/flags/detail', ['components'], function (components) { + var Flags = {}; + + Flags.init = function () { + // Update attributes + $('#state').val(ajaxify.data.state).removeAttr('disabled'); + $('#assignee').val(ajaxify.data.assignee).removeAttr('disabled'); + + $('[data-action]').on('click', function () { + var action = this.getAttribute('data-action'); + + switch (action) { + case 'update': + socket.emit('flags.update', { + flagId: ajaxify.data.flagId, + data: $('#attributes').serializeArray() + }, function (err) { + if (err) { + return app.alertError(err.message); + } else { + app.alertSuccess('[[flags:updated]]'); + } + }); + break; + + case 'appendNote': + socket.emit('flags.appendNote', { + flagId: ajaxify.data.flagId, + note: document.getElementById('note').value + }, function (err, notes) { + if (err) { + return app.alertError(err.message); + } else { + app.alertSuccess('[[flags:note-added]]'); + Flags.reloadNotes(notes); + } + }); + break; + } + }); + }; + + Flags.reloadNotes = function (notes) { + templates.parse('flags/detail', 'notes', { + notes: notes + }, function (html) { + var wrapperEl = components.get('flag/notes'); + wrapperEl.empty(); + wrapperEl.html(html); + wrapperEl.find('span.timeago').timeago(); + document.getElementById('note').value = ''; + }); + }; + + return Flags; +}); diff --git a/public/src/client/topic/flag.js b/public/src/client/topic/flag.js index 78b1dd5d2a..046585ae68 100644 --- a/public/src/client/topic/flag.js +++ b/public/src/client/topic/flag.js @@ -48,7 +48,7 @@ define('forum/topic/flag', [], function () { if (!pid || !reason) { return; } - socket.emit('posts.flag', {pid: pid, reason: reason}, function (err) { + socket.emit('flags.create', {pid: pid, reason: reason}, function (err) { if (err) { return app.alertError(err.message); } diff --git a/src/flags.js b/src/flags.js index 99039aa175..323a2a1e07 100644 --- a/src/flags.js +++ b/src/flags.js @@ -373,138 +373,52 @@ Flags.dismissByUid = function (uid, callback) { }); }; -// New method signature (type, id, flagObj, callback) and name (.update()) -// uid used in history string, which should be rewritten too. -Flags.update = function (uid, pid, flagObj, callback) { +Flags.update = function (flagId, uid, changeset, callback) { // Retrieve existing flag data to compare for history-saving purposes - var changes = []; - var changeset = {}; - var prop; + var fields = ['state', 'assignee']; - posts.getPostData(pid, function (err, postData) { - if (err) { - return callback(err); - } - - // Track new additions - for(prop in flagObj) { - if (flagObj.hasOwnProperty(prop) && !postData.hasOwnProperty('flag:' + prop) && flagObj[prop].length) { - changes.push(prop); - } - } - - // Track changed items - for(prop in postData) { - if ( - postData.hasOwnProperty(prop) && prop.startsWith('flag:') && - flagObj.hasOwnProperty(prop.slice(5)) && - postData[prop] !== flagObj[prop.slice(5)] - ) { - changes.push(prop.slice(5)); - } - } - - changeset = changes.reduce(function (memo, prop) { - memo['flag:' + prop] = flagObj[prop]; - return memo; - }, {}); - - // Append changes to history string - if (changes.length) { - try { - var history = JSON.parse(postData['flag:history'] || '[]'); - - changes.forEach(function (property) { - switch(property) { - case 'assignee': // intentional fall-through - case 'state': - history.unshift({ - uid: uid, - type: property, - value: flagObj[property], - timestamp: Date.now() - }); - break; - - case 'notes': - history.unshift({ - uid: uid, - type: property, - timestamp: Date.now() - }); + async.waterfall([ + async.apply(db.getObjectFields.bind(db), 'flag:' + flagId, fields), + function (current, next) { + for(var prop in changeset) { + if (changeset.hasOwnProperty(prop)) { + if (current[prop] === changeset[prop]) { + delete changeset[prop]; } - }); + } + } - changeset['flag:history'] = JSON.stringify(history); - } catch (e) { - winston.warn('[flags/update] Unable to deserialise post flag history, likely malformed data'); + if (!Object.keys(changeset).length) { + // No changes + return next(); } - } - // Save flag data into post hash - if (changes.length) { - posts.setPostFields(pid, changeset, callback); - } else { - setImmediate(callback); + async.parallel([ + // Save new object to db (upsert) + async.apply(db.setObject, 'flag:' + flagId, changeset), + // Append history + async.apply(Flags.appendHistory, flagId, uid, Object.keys(changeset)) + ], next); } - }); + ], callback); }; -// To be rewritten and deprecated -Flags.expandFlagHistory = function (posts, callback) { - // Expand flag history - async.map(posts, function (post, next) { - var history; - try { - history = JSON.parse(post['flag:history'] || '[]'); - } catch (e) { - winston.warn('[flags/get] Unable to deserialise post flag history, likely malformed data'); - return callback(e); - } - - async.map(history, function (event, next) { - event.timestampISO = new Date(event.timestamp).toISOString(); - - async.parallel([ - function (next) { - user.getUserFields(event.uid, ['username', 'picture'], function (err, userData) { - if (err) { - return next(err); - } +Flags.appendHistory = function (flagId, uid, changeset, callback) { + return callback(); +}; - event.user = userData; - next(); - }); - }, - function (next) { - if (event.type === 'assignee') { - user.getUserField(parseInt(event.value, 10), 'username', function (err, username) { - if (err) { - return next(err); - } - - event.label = username || 'Unknown user'; - next(null); - }); - } else if (event.type === 'state') { - event.label = '[[topic:flag_manage_state_' + event.value + ']]'; - setImmediate(next); - } else { - setImmediate(next); - } - } - ], function (err) { - next(err, event); - }); - }, function (err, history) { - if (err) { - return next(err); - } +Flags.appendNote = function (flagId, uid, note, callback) { + var payload; + try { + payload = JSON.stringify([uid, note]); + } catch (e) { + return callback(e); + } - post['flag:history'] = history; - next(null, post); - }); - }, callback); + async.waterfall([ + async.apply(db.sortedSetAdd, 'flag:' + flagId + ':notes', Date.now(), payload), + async.apply(Flags.getNotes, flagId) + ], callback); }; module.exports = Flags; \ No newline at end of file diff --git a/src/socket.io/flags.js b/src/socket.io/flags.js new file mode 100644 index 0000000000..28db7551a6 --- /dev/null +++ b/src/socket.io/flags.js @@ -0,0 +1,167 @@ +'use strict'; + +var async = require('async'); +var S = require('string'); + +var user = require('../user'); +var groups = require('../groups'); +var posts = require('../posts'); +var topics = require('../topics'); +var privileges = require('../privileges'); +var notifications = require('../notifications'); +var plugins = require('../plugins'); +var meta = require('../meta'); +var utils = require('../../public/src/utils'); +var flags = require('../flags'); + +var SocketFlags = {}; + +SocketFlags.create = function (socket, data, callback) { + if (!socket.uid) { + return callback(new Error('[[error:not-logged-in]]')); + } + + if (!data || !data.pid || !data.reason) { + return callback(new Error('[[error:invalid-data]]')); + } + + var flaggingUser = {}; + var post; + + async.waterfall([ + function (next) { + posts.getPostFields(data.pid, ['pid', 'tid', 'uid', 'content', 'deleted'], next); + }, + function (postData, next) { + if (parseInt(postData.deleted, 10) === 1) { + return next(new Error('[[error:post-deleted]]')); + } + + post = postData; + topics.getTopicFields(post.tid, ['title', 'cid'], next); + }, + function (topicData, next) { + post.topic = topicData; + + async.parallel({ + isAdminOrMod: function (next) { + privileges.categories.isAdminOrMod(post.topic.cid, socket.uid, next); + }, + userData: function (next) { + user.getUserFields(socket.uid, ['username', 'reputation', 'banned'], next); + } + }, next); + }, + function (user, next) { + var minimumReputation = utils.isNumber(meta.config['privileges:flag']) ? parseInt(meta.config['privileges:flag'], 10) : 1; + if (!user.isAdminOrMod && parseInt(user.userData.reputation, 10) < minimumReputation) { + return next(new Error('[[error:not-enough-reputation-to-flag]]')); + } + + if (parseInt(user.banned, 10) === 1) { + return next(new Error('[[error:user-banned]]')); + } + + flaggingUser = user.userData; + flaggingUser.uid = socket.uid; + + flags.create('post', post.pid, socket.uid, data.reason, next); + }, + function (next) { + async.parallel({ + post: function (next) { + posts.parsePost(post, next); + }, + admins: function (next) { + groups.getMembers('administrators', 0, -1, next); + }, + globalMods: function (next) { + groups.getMembers('Global Moderators', 0, -1, next); + }, + moderators: function (next) { + groups.getMembers('cid:' + post.topic.cid + ':privileges:mods', 0, -1, next); + } + }, next); + }, + function (results, next) { + var title = S(post.topic.title).decodeHTMLEntities().s; + var titleEscaped = title.replace(/%/g, '%').replace(/,/g, ','); + + notifications.create({ + bodyShort: '[[notifications:user_flagged_post_in, ' + flaggingUser.username + ', ' + titleEscaped + ']]', + bodyLong: post.content, + pid: data.pid, + path: '/post/' + data.pid, + nid: 'post_flag:' + data.pid + ':uid:' + socket.uid, + from: socket.uid, + mergeId: 'notifications:user_flagged_post_in|' + data.pid, + topicTitle: post.topic.title + }, function (err, notification) { + if (err || !notification) { + return next(err); + } + + plugins.fireHook('action:post.flag', {post: post, reason: data.reason, flaggingUser: flaggingUser}); + notifications.push(notification, results.admins.concat(results.moderators).concat(results.globalMods), next); + }); + } + ], callback); +}; + +SocketFlags.update = function (socket, data, callback) { + if (!data || !(data.flagId && data.data)) { + return callback(new Error('[[error:invalid-data]]')); + } + + var payload = {}; + + async.waterfall([ + function (next) { + async.parallel([ + async.apply(user.isAdminOrGlobalMod, socket.uid), + async.apply(user.isModeratorOfAnyCategory, socket.uid) + ], function (err, results) { + next(err, results[0] || results[1]); + }); + }, + function (allowed, next) { + if (!allowed) { + return next(new Error('[[no-privileges]]')); + } + + // Translate form data into object + payload = data.data.reduce(function (memo, cur) { + memo[cur.name] = cur.value; + return memo; + }, payload); + + flags.update(data.flagId, socket.uid, payload, next); + } + ], callback); +}; + +SocketFlags.appendNote = function (socket, data, callback) { + if (!data || !(data.flagId && data.note)) { + return callback(new Error('[[error:invalid-data]]')); + } + + async.waterfall([ + function (next) { + async.parallel([ + async.apply(user.isAdminOrGlobalMod, socket.uid), + async.apply(user.isModeratorOfAnyCategory, socket.uid) + ], function (err, results) { + next(err, results[0] || results[1]); + }); + }, + function (allowed, next) { + if (!allowed) { + return next(new Error('[[no-privileges]]')); + } + + flags.appendNote(data.flagId, socket.uid, data.note, next); + } + ], callback); +}; + +module.exports = SocketFlags; diff --git a/src/socket.io/index.js b/src/socket.io/index.js index 38e73c1cc1..43157f19ad 100644 --- a/src/socket.io/index.js +++ b/src/socket.io/index.js @@ -123,8 +123,10 @@ var ratelimit = require('../middleware/ratelimit'); } function requireModules() { - var modules = ['admin', 'categories', 'groups', 'meta', 'modules', - 'notifications', 'plugins', 'posts', 'topics', 'user', 'blacklist' + var modules = [ + 'admin', 'categories', 'groups', 'meta', 'modules', + 'notifications', 'plugins', 'posts', 'topics', 'user', + 'blacklist', 'flags' ]; modules.forEach(function (module) { diff --git a/src/socket.io/posts.js b/src/socket.io/posts.js index fe729a5c11..b5309dd3cf 100644 --- a/src/socket.io/posts.js +++ b/src/socket.io/posts.js @@ -20,7 +20,6 @@ require('./posts/move')(SocketPosts); require('./posts/votes')(SocketPosts); require('./posts/bookmarks')(SocketPosts); require('./posts/tools')(SocketPosts); -require('./posts/flag')(SocketPosts); SocketPosts.reply = function (socket, data, callback) { if (!data || !data.tid || !data.content) { diff --git a/src/socket.io/posts/flag.js b/src/socket.io/posts/flag.js deleted file mode 100644 index 88b47058d1..0000000000 --- a/src/socket.io/posts/flag.js +++ /dev/null @@ -1,173 +0,0 @@ -'use strict'; - -var async = require('async'); -var S = require('string'); - -var user = require('../../user'); -var groups = require('../../groups'); -var posts = require('../../posts'); -var topics = require('../../topics'); -var privileges = require('../../privileges'); -var notifications = require('../../notifications'); -var plugins = require('../../plugins'); -var meta = require('../../meta'); -var utils = require('../../../public/src/utils'); -var flags = require('../../flags'); - -module.exports = function (SocketPosts) { - - SocketPosts.flag = function (socket, data, callback) { - if (!socket.uid) { - return callback(new Error('[[error:not-logged-in]]')); - } - - if (!data || !data.pid || !data.reason) { - return callback(new Error('[[error:invalid-data]]')); - } - - var flaggingUser = {}; - var post; - - async.waterfall([ - function (next) { - posts.getPostFields(data.pid, ['pid', 'tid', 'uid', 'content', 'deleted'], next); - }, - function (postData, next) { - if (parseInt(postData.deleted, 10) === 1) { - return next(new Error('[[error:post-deleted]]')); - } - - post = postData; - topics.getTopicFields(post.tid, ['title', 'cid'], next); - }, - function (topicData, next) { - post.topic = topicData; - - async.parallel({ - isAdminOrMod: function (next) { - privileges.categories.isAdminOrMod(post.topic.cid, socket.uid, next); - }, - userData: function (next) { - user.getUserFields(socket.uid, ['username', 'reputation', 'banned'], next); - } - }, next); - }, - function (user, next) { - var minimumReputation = utils.isNumber(meta.config['privileges:flag']) ? parseInt(meta.config['privileges:flag'], 10) : 1; - if (!user.isAdminOrMod && parseInt(user.userData.reputation, 10) < minimumReputation) { - return next(new Error('[[error:not-enough-reputation-to-flag]]')); - } - - if (parseInt(user.banned, 10) === 1) { - return next(new Error('[[error:user-banned]]')); - } - - flaggingUser = user.userData; - flaggingUser.uid = socket.uid; - - flags.create('post', post.pid, socket.uid, data.reason, next); - }, - function (next) { - async.parallel({ - post: function (next) { - posts.parsePost(post, next); - }, - admins: function (next) { - groups.getMembers('administrators', 0, -1, next); - }, - globalMods: function (next) { - groups.getMembers('Global Moderators', 0, -1, next); - }, - moderators: function (next) { - groups.getMembers('cid:' + post.topic.cid + ':privileges:mods', 0, -1, next); - } - }, next); - }, - function (results, next) { - var title = S(post.topic.title).decodeHTMLEntities().s; - var titleEscaped = title.replace(/%/g, '%').replace(/,/g, ','); - - notifications.create({ - bodyShort: '[[notifications:user_flagged_post_in, ' + flaggingUser.username + ', ' + titleEscaped + ']]', - bodyLong: post.content, - pid: data.pid, - path: '/post/' + data.pid, - nid: 'post_flag:' + data.pid + ':uid:' + socket.uid, - from: socket.uid, - mergeId: 'notifications:user_flagged_post_in|' + data.pid, - topicTitle: post.topic.title - }, function (err, notification) { - if (err || !notification) { - return next(err); - } - - plugins.fireHook('action:post.flag', {post: post, reason: data.reason, flaggingUser: flaggingUser}); - notifications.push(notification, results.admins.concat(results.moderators).concat(results.globalMods), next); - }); - } - ], callback); - }; - - SocketPosts.dismissFlag = function (socket, pid, callback) { - if (!pid || !socket.uid) { - return callback(new Error('[[error:invalid-data]]')); - } - async.waterfall([ - function (next) { - user.isAdminOrGlobalMod(socket.uid, next); - }, - function (isAdminOrGlobalModerator, next) { - if (!isAdminOrGlobalModerator) { - return next(new Error('[[no-privileges]]')); - } - flags.dismiss(pid, next); - } - ], callback); - }; - - SocketPosts.dismissAllFlags = function (socket, data, callback) { - async.waterfall([ - function (next) { - user.isAdminOrGlobalMod(socket.uid, next); - }, - function (isAdminOrGlobalModerator, next) { - if (!isAdminOrGlobalModerator) { - return next(new Error('[[no-privileges]]')); - } - flags.dismissAll(next); - } - ], callback); - }; - - SocketPosts.updateFlag = function (socket, data, callback) { - if (!data || !(data.pid && data.data)) { - return callback(new Error('[[error:invalid-data]]')); - } - - var payload = {}; - - async.waterfall([ - function (next) { - async.parallel([ - async.apply(user.isAdminOrGlobalMod, socket.uid), - async.apply(user.isModeratorOfAnyCategory, socket.uid) - ], function (err, results) { - next(err, results[0] || results[1]); - }); - }, - function (allowed, next) { - if (!allowed) { - return next(new Error('[[no-privileges]]')); - } - - // Translate form data into object - payload = data.data.reduce(function (memo, cur) { - memo[cur.name] = cur.value; - return memo; - }, payload); - - flags.update(socket.uid, data.pid, payload, next); - } - ], callback); - }; -}; From f1d144f15e5461769e266cd69a116148df2eb021 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Thu, 1 Dec 2016 16:22:10 -0500 Subject: [PATCH 13/48] history appending, finished up notes, #5232 --- public/language/en-GB/flags.json | 3 ++ public/src/client/flags/detail.js | 23 +++++++++--- src/flags.js | 58 ++++++++++++++++++++++++++++--- src/socket.io/flags.js | 9 ++++- src/user/data.js | 17 +++++++-- 5 files changed, 98 insertions(+), 12 deletions(-) diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json index 0c1f32b88e..7b9193680f 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -6,8 +6,11 @@ "no-flags": "Hooray! No flags found.", "assignee": "Assignee", "update": "Update", + "updated": "Updated", "notes": "Flag Notes", "add-note": "Add Note", + "history": "Flag History", + "back": "Back to Flags List", "state": "State", "state-open": "New/Open", diff --git a/public/src/client/flags/detail.js b/public/src/client/flags/detail.js index d23a1941b5..aef21ec925 100644 --- a/public/src/client/flags/detail.js +++ b/public/src/client/flags/detail.js @@ -2,7 +2,7 @@ /* globals define */ -define('forum/flags/detail', ['components'], function (components) { +define('forum/flags/detail', ['components', 'translator'], function (components, translator) { var Flags = {}; Flags.init = function () { @@ -18,11 +18,12 @@ define('forum/flags/detail', ['components'], function (components) { socket.emit('flags.update', { flagId: ajaxify.data.flagId, data: $('#attributes').serializeArray() - }, function (err) { + }, function (err, history) { if (err) { return app.alertError(err.message); } else { app.alertSuccess('[[flags:updated]]'); + Flags.reloadHistory(history); } }); break; @@ -31,12 +32,13 @@ define('forum/flags/detail', ['components'], function (components) { socket.emit('flags.appendNote', { flagId: ajaxify.data.flagId, note: document.getElementById('note').value - }, function (err, notes) { + }, function (err, payload) { if (err) { return app.alertError(err.message); } else { app.alertSuccess('[[flags:note-added]]'); - Flags.reloadNotes(notes); + Flags.reloadNotes(payload.notes); + Flags.reloadHistory(payload.history); } }); break; @@ -56,5 +58,18 @@ define('forum/flags/detail', ['components'], function (components) { }); }; + Flags.reloadHistory = function (history) { + templates.parse('flags/detail', 'history', { + history: history + }, function (html) { + translator.translate(html, function (translated) { + var wrapperEl = components.get('flag/history'); + wrapperEl.empty(); + wrapperEl.html(translated); + wrapperEl.find('span.timeago').timeago(); + }); + }); + }; + return Flags; }); diff --git a/src/flags.js b/src/flags.js index 323a2a1e07..3dc1e51f32 100644 --- a/src/flags.js +++ b/src/flags.js @@ -24,7 +24,7 @@ Flags.get = function (flagId, callback) { // First stage async.apply(async.parallel, { base: async.apply(db.getObject.bind(db), 'flag:' + flagId), - history: async.apply(db.getSortedSetRevRange.bind(db), 'flag:' + flagId + ':history', 0, -1), + history: async.apply(Flags.getHistory, flagId), notes: async.apply(Flags.getNotes, flagId) }), function (data, next) { @@ -156,7 +156,7 @@ Flags.getNotes = function (flagId, callback) { next(null, notes, uids); }, function (notes, uids, next) { - user.getUsersData(uids, function (err, users) { + user.getUsersFields(uids, ['username', 'userslug', 'picture'], function (err, users) { if (err) { return next(err); } @@ -398,13 +398,61 @@ Flags.update = function (flagId, uid, changeset, callback) { async.apply(db.setObject, 'flag:' + flagId, changeset), // Append history async.apply(Flags.appendHistory, flagId, uid, Object.keys(changeset)) - ], next); + ], function (err, data) { + return next(err); + }); } ], callback); }; +Flags.getHistory = function (flagId, callback) { + var history; + var uids = []; + async.waterfall([ + async.apply(db.getSortedSetRevRangeWithScores.bind(db), 'flag:' + flagId + ':history', 0, -1), + function (_history, next) { + history = _history.map(function (entry) { + try { + entry.value = JSON.parse(entry.value); + } catch (e) { + return callback(e); + } + + uids.push(entry.value[0]); + + return { + uid: entry.value[0], + fields: entry.value[1], + datetime: entry.score, + datetimeISO: new Date(entry.score).toISOString() + }; + }); + + user.getUsersFields(uids, ['username', 'userslug', 'picture'], next); + } + ], function (err, users) { + if (err) { + return callback(err); + } + + history = history.map(function (event, idx) { + event.user = users[idx]; + return event; + }); + + callback(null, history); + }); +}; + Flags.appendHistory = function (flagId, uid, changeset, callback) { - return callback(); + var payload; + try { + payload = JSON.stringify([uid, changeset, Date.now()]); + } catch (e) { + return callback(e); + } + + db.sortedSetAdd('flag:' + flagId + ':history', Date.now(), payload, callback); }; Flags.appendNote = function (flagId, uid, note, callback) { @@ -417,7 +465,7 @@ Flags.appendNote = function (flagId, uid, note, callback) { async.waterfall([ async.apply(db.sortedSetAdd, 'flag:' + flagId + ':notes', Date.now(), payload), - async.apply(Flags.getNotes, flagId) + async.apply(Flags.appendHistory, flagId, uid, ['notes']) ], callback); }; diff --git a/src/socket.io/flags.js b/src/socket.io/flags.js index 28db7551a6..8b66dd094c 100644 --- a/src/socket.io/flags.js +++ b/src/socket.io/flags.js @@ -136,7 +136,8 @@ SocketFlags.update = function (socket, data, callback) { }, payload); flags.update(data.flagId, socket.uid, payload, next); - } + }, + async.apply(flags.getHistory, data.flagId) ], callback); }; @@ -160,6 +161,12 @@ SocketFlags.appendNote = function (socket, data, callback) { } flags.appendNote(data.flagId, socket.uid, data.note, next); + }, + function (next) { + async.parallel({ + "notes": async.apply(flags.getNotes, data.flagId), + "history": async.apply(flags.getHistory, data.flagId) + }, next); } ], callback); }; diff --git a/src/user/data.js b/src/user/data.js index 2e5bfb2218..5716208ae5 100644 --- a/src/user/data.js +++ b/src/user/data.js @@ -34,11 +34,20 @@ module.exports = function (User) { } } - if (!Array.isArray(uids) || !uids.length) { + // Eliminate duplicates and build ref table + var uniqueUids = uids.filter(function (uid, index) { + return index === uids.indexOf(uid); + }); + var ref = uniqueUids.reduce(function (memo, cur, idx) { + memo[cur] = idx; + return memo; + }, {}); + + if (!Array.isArray(uniqueUids) || !uniqueUids.length) { return callback(null, []); } - var keys = uids.map(function (uid) { + var keys = uniqueUids.map(function (uid) { return 'user:' + uid; }); @@ -60,6 +69,10 @@ module.exports = function (User) { return callback(err); } + users = uids.map(function (uid) { + return users[ref[uid]]; + }); + modifyUserData(users, fieldsToRemove, callback); }); }; From 22eeabc5c73050385eb45254eb2b2099a9781d98 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Thu, 1 Dec 2016 16:36:30 -0500 Subject: [PATCH 14/48] new strings for empty notes or history, #5232 --- public/language/en-GB/flags.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json index 7b9193680f..cb1f513deb 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -7,10 +7,14 @@ "assignee": "Assignee", "update": "Update", "updated": "Updated", + "notes": "Flag Notes", "add-note": "Add Note", + "no-notes": "No shared notes.", + "history": "Flag History", "back": "Back to Flags List", + "no-history": "No flag history.", "state": "State", "state-open": "New/Open", From 839a0efc0a056905e7a6b0a94a21af24e33315c2 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Fri, 2 Dec 2016 09:49:25 -0500 Subject: [PATCH 15/48] one more language string for #5232 --- public/language/en-GB/flags.json | 1 + 1 file changed, 1 insertion(+) diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json index cb1f513deb..5f989cd4d0 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -3,6 +3,7 @@ "state": "State", "reporter": "Reporter", "reported-at": "Reported At", + "description": "Description", "no-flags": "Hooray! No flags found.", "assignee": "Assignee", "update": "Update", From 20fa8ebf76bb702fc8277a577f0e239185dfca20 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Fri, 2 Dec 2016 11:24:12 -0500 Subject: [PATCH 16/48] simplified flags.get a tad --- src/flags.js | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/flags.js b/src/flags.js index 3dc1e51f32..6022750f73 100644 --- a/src/flags.js +++ b/src/flags.js @@ -30,7 +30,7 @@ Flags.get = function (flagId, callback) { function (data, next) { // Second stage async.parallel({ - userObj: async.apply(user.getUserFields, data.base.uid, ['username', 'picture']), + userObj: async.apply(user.getUserFields, data.base.uid, ['username', 'userslug', 'picture']), targetObj: async.apply(Flags.getTarget, data.base.type, data.base.targetId, data.base.uid) }, function (err, payload) { // Final object return construction @@ -40,12 +40,7 @@ Flags.get = function (flagId, callback) { target: payload.targetObj, history: data.history, notes: data.notes, - reporter: { - username: payload.userObj.username, - picture: payload.userObj.picture, - 'icon:bgColor': payload.userObj['icon:bgColor'], - 'icon:text': payload.userObj['icon:text'] - } + reporter: payload.userObj })); }); } From 0724bee6c65b5e2e0dcb71628f0ce573da62aaf7 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Fri, 2 Dec 2016 12:10:19 -0500 Subject: [PATCH 17/48] removed deprecated dismiss methods --- src/flags.js | 81 ---------------------------------------------------- 1 file changed, 81 deletions(-) diff --git a/src/flags.js b/src/flags.js index 6022750f73..54f6f0bd30 100644 --- a/src/flags.js +++ b/src/flags.js @@ -287,87 +287,6 @@ Flags.targetExists = function (type, id, callback) { } }; -Flags.dismiss = function (pid, callback) { - async.waterfall([ - function (next) { - db.getObjectFields('post:' + pid, ['pid', 'uid', 'flags'], next); - }, - function (postData, next) { - if (!postData.pid) { - return callback(); - } - async.parallel([ - function (next) { - if (parseInt(postData.uid, 10)) { - if (parseInt(postData.flags, 10) > 0) { - async.parallel([ - async.apply(db.sortedSetIncrBy, 'users:flags', -postData.flags, postData.uid), - async.apply(db.incrObjectFieldBy, 'user:' + postData.uid, 'flags', -postData.flags) - ], next); - } else { - next(); - } - } else { - next(); - } - }, - 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) { - if (err) { - return next(err); - } - - 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'), - async.apply(db.deleteObjectFields, 'post:' + pid, ['flag:state', 'flag:assignee', 'flag:notes', 'flag:history']) - ], next); - }, - function (results, next) { - db.sortedSetsRemoveRangeByScore(['users:flags'], '-inf', 0, next); - } - ], callback); -}; - -// Pretty sure we don't need this method... -Flags.dismissAll = function (callback) { - db.getSortedSetRange('posts:flagged', 0, -1, function (err, pids) { - if (err) { - return callback(err); - } - async.eachSeries(pids, Flags.dismiss, callback); - }); -}; - -Flags.dismissByUid = function (uid, callback) { - db.getSortedSetRange('uid:' + uid + ':flag:pids', 0, -1, function (err, pids) { - if (err) { - return callback(err); - } - async.eachSeries(pids, Flags.dismiss, callback); - }); -}; - Flags.update = function (flagId, uid, changeset, callback) { // Retrieve existing flag data to compare for history-saving purposes var fields = ['state', 'assignee']; From 169defd19476648c769a56fe2bbc6b062597f99b Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Fri, 2 Dec 2016 12:34:58 -0500 Subject: [PATCH 18/48] #5232, update flag history to save new value --- src/flags.js | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/flags.js b/src/flags.js index 54f6f0bd30..893bba4442 100644 --- a/src/flags.js +++ b/src/flags.js @@ -290,14 +290,18 @@ Flags.targetExists = function (type, id, callback) { Flags.update = function (flagId, uid, changeset, callback) { // Retrieve existing flag data to compare for history-saving purposes var fields = ['state', 'assignee']; + var history = []; async.waterfall([ async.apply(db.getObjectFields.bind(db), 'flag:' + flagId, fields), function (current, next) { - for(var prop in changeset) { + for (var prop in changeset) { if (changeset.hasOwnProperty(prop)) { if (current[prop] === changeset[prop]) { delete changeset[prop]; + } else { + // Append to history payload + history.push(prop + ':' + changeset[prop]); } } } @@ -311,7 +315,7 @@ Flags.update = function (flagId, uid, changeset, callback) { // Save new object to db (upsert) async.apply(db.setObject, 'flag:' + flagId, changeset), // Append history - async.apply(Flags.appendHistory, flagId, uid, Object.keys(changeset)) + async.apply(Flags.appendHistory, flagId, uid, history) ], function (err, data) { return next(err); }); @@ -334,9 +338,18 @@ Flags.getHistory = function (flagId, callback) { uids.push(entry.value[0]); + // Deserialise field object + var fields = entry.value[1].map(function (field) { + field = field.toString().split(':'); + return { + "attribute": field[0], + "value": field[1] === undefined ? null : field[1] + }; + }); + return { uid: entry.value[0], - fields: entry.value[1], + fields: fields, datetime: entry.score, datetimeISO: new Date(entry.score).toISOString() }; @@ -349,6 +362,7 @@ Flags.getHistory = function (flagId, callback) { return callback(err); } + // Append user data to each history event history = history.map(function (event, idx) { event.user = users[idx]; return event; From 9129597811ab10f2d5d8775ed1f8bc9bd1e7dae8 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Fri, 2 Dec 2016 12:51:39 -0500 Subject: [PATCH 19/48] #5232 some tweaks to flag history saving --- public/language/en-GB/flags.json | 1 - src/flags.js | 12 +++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json index 5f989cd4d0..0cdc3fbfb9 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -23,6 +23,5 @@ "state-resolved": "Resolved", "state-rejected": "Rejected", "no-assignee": "Not Assigned", - "updated": "Flag Details Updated", "note-added": "Note Added" } \ No newline at end of file diff --git a/src/flags.js b/src/flags.js index 893bba4442..58b230af06 100644 --- a/src/flags.js +++ b/src/flags.js @@ -341,9 +341,19 @@ Flags.getHistory = function (flagId, callback) { // Deserialise field object var fields = entry.value[1].map(function (field) { field = field.toString().split(':'); + + switch (field[0]) { + case 'state': + field[1] = field[1] === undefined ? null : '[[flags:state-' + field[1] + ']]'; + break; + + default: + field[1] = field[1] === undefined ? null : field[1]; + break; + } return { "attribute": field[0], - "value": field[1] === undefined ? null : field[1] + "value": field[1] }; }); From 753d4b0275e3927560d9f7c2c8217f6273d4715e Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Fri, 2 Dec 2016 15:28:28 -0500 Subject: [PATCH 20/48] wrapped up basic functionality of list and detail for flags, filter support. #5232 --- public/language/en-GB/flags.json | 7 ++ public/language/en-GB/topic.json | 6 -- public/src/admin/manage/flags.js | 172 ------------------------------ public/src/client/flags/detail.js | 4 +- public/src/client/flags/list.js | 28 +++++ src/controllers/admin.js | 1 - src/controllers/admin/flags.js | 104 ------------------ src/controllers/mods.js | 12 ++- src/flags.js | 31 +++++- src/routes/admin.js | 1 - src/socket.io/flags.js | 2 +- 11 files changed, 78 insertions(+), 290 deletions(-) delete mode 100644 public/src/admin/manage/flags.js create mode 100644 public/src/client/flags/list.js delete mode 100644 src/controllers/admin/flags.js diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json index 0cdc3fbfb9..cdc3c4069e 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -9,6 +9,13 @@ "update": "Update", "updated": "Updated", + "filters": "Filter Options", + "filter-reporterId": "Reporter UID", + "filter-type": "Flag Type", + "filter-type-all": "All Content", + "filter-type-post": "Post", + "apply-filters": "Apply Filters", + "notes": "Flag Notes", "add-note": "Add Note", "no-notes": "No shared notes.", diff --git a/public/language/en-GB/topic.json b/public/language/en-GB/topic.json index ce7f35f816..4ae208076e 100644 --- a/public/language/en-GB/topic.json +++ b/public/language/en-GB/topic.json @@ -37,12 +37,6 @@ "flag_title": "Flag this post for moderation", "flag_success": "This post has been flagged for moderation.", - "flag_manage_history": "Action History", - "flag_manage_no_history": "No event history to report", - "flag_manage_history_assignee": "Assigned to %1", - "flag_manage_history_state": "Updated state to %1", - "flag_manage_history_notes": "Updated flag notes", - "flag_manage_saved": "Flag Details Updated", "deleted_message": "This topic has been deleted. Only users with topic management privileges can see it.", diff --git a/public/src/admin/manage/flags.js b/public/src/admin/manage/flags.js deleted file mode 100644 index fb8b32d602..0000000000 --- a/public/src/admin/manage/flags.js +++ /dev/null @@ -1,172 +0,0 @@ -"use strict"; -/*global define, socket, app, utils, bootbox, ajaxify*/ - -define('admin/manage/flags', [ - 'autocomplete', - 'Chart', - 'components' -], function (autocomplete, Chart, components) { - - var Flags = {}; - - Flags.init = function () { - $('.post-container .content img:not(.not-responsive)').addClass('img-responsive'); - - autocomplete.user($('#byUsername')); - - handleDismiss(); - handleDismissAll(); - handleDelete(); - handleGraphs(); - - updateFlagDetails(ajaxify.data.posts); - - components.get('posts/flags').on('click', '[component="posts/flag/update"]', updateFlag); - - // Open flag as indicated in location bar - if (window.location.hash.startsWith('#flag-pid-')) { - $(window.location.hash).collapse('toggle'); - } - }; - - function handleDismiss() { - $('.flags').on('click', '.dismiss', function () { - var btn = $(this); - var pid = btn.parents('[data-pid]').attr('data-pid'); - - socket.emit('posts.dismissFlag', pid, function (err) { - done(err, btn); - }); - }); - } - - function handleDismissAll() { - $('#dismissAll').on('click', function () { - socket.emit('posts.dismissAllFlags', function (err) { - if (err) { - return app.alertError(err.message); - } - - ajaxify.refresh(); - }); - return false; - }); - } - - function handleDelete() { - $('.flags').on('click', '.delete', function () { - var btn = $(this); - bootbox.confirm('Do you really want to delete this post?', function (confirm) { - if (!confirm) { - return; - } - var pid = btn.parents('[data-pid]').attr('data-pid'); - var tid = btn.parents('[data-pid]').attr('data-tid'); - socket.emit('posts.delete', {pid: pid, tid: tid}, function (err) { - done(err, btn); - }); - }); - }); - } - - function done(err, btn) { - if (err) { - return app.alertError(err.messaage); - } - btn.parents('[data-pid]').fadeOut(function () { - $(this).remove(); - if (!$('.flags [data-pid]').length) { - $('.post-container').text('No flagged posts!'); - } - }); - } - - 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.tooltips.enabled = false; - } - var data = { - 'flags:daily': { - labels: dailyLabels, - datasets: [ - { - label: "", - backgroundColor: "rgba(151,187,205,0.2)", - borderColor: "rgba(151,187,205,1)", - pointBackgroundColor: "rgba(151,187,205,1)", - pointHoverBackgroundColor: "#fff", - pointBorderColor: "#fff", - pointHoverBorderColor: "rgba(151,187,205,1)", - data: ajaxify.data.analytics - } - ] - } - }; - - dailyCanvas.width = $(dailyCanvas).parent().width(); - new Chart(dailyCanvas.getContext('2d'), { - type: 'line', - data: data['flags:daily'], - options: { - responsive: true, - animation: false, - legend: { - display: false - }, - scales: { - yAxes: [{ - ticks: { - beginAtZero: true - } - }] - } - } - }); - } - - function updateFlagDetails(source) { - // As the flag details are returned in the API, update the form controls to show the correct data - - // Create reference hash for use in this method - source = source.reduce(function (memo, cur) { - memo[cur.pid] = cur.flagData; - return memo; - }, {}); - - components.get('posts/flag').each(function (idx, el) { - var pid = el.getAttribute('data-pid'); - var el = $(el); - - if (source[pid]) { - for(var prop in source[pid]) { - if (source[pid].hasOwnProperty(prop)) { - el.find('[name="' + prop + '"]').val(source[pid][prop]); - } - } - } - }); - } - - function updateFlag() { - var pid = $(this).parents('[component="posts/flag"]').attr('data-pid'); - var formData = $($(this).parents('form').get(0)).serializeArray(); - - socket.emit('posts.updateFlag', { - pid: pid, - data: formData - }, function (err) { - if (err) { - return app.alertError(err.message); - } else { - app.alertSuccess('[[topic:flag_manage_saved]]'); - } - }); - } - - return Flags; -}); \ No newline at end of file diff --git a/public/src/client/flags/detail.js b/public/src/client/flags/detail.js index aef21ec925..e20f05dba9 100644 --- a/public/src/client/flags/detail.js +++ b/public/src/client/flags/detail.js @@ -2,7 +2,7 @@ /* globals define */ -define('forum/flags/detail', ['components', 'translator'], function (components, translator) { +define('forum/flags/detail', ['forum/flags/list', 'components', 'translator'], function (FlagsList, components, translator) { var Flags = {}; Flags.init = function () { @@ -44,6 +44,8 @@ define('forum/flags/detail', ['components', 'translator'], function (components, break; } }); + + FlagsList.enableFilterForm(); }; Flags.reloadNotes = function (notes) { diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js new file mode 100644 index 0000000000..6111372150 --- /dev/null +++ b/public/src/client/flags/list.js @@ -0,0 +1,28 @@ +'use strict'; + +/* globals define */ + +define('forum/flags/list', ['components'], function (components) { + var Flags = {}; + + Flags.init = function () { + Flags.enableFilterForm(); + }; + + Flags.enableFilterForm = function () { + var filtersEl = components.get('flags/filters'); + + filtersEl.find('button').on('click', function () { + var payload = filtersEl.serializeArray(); + var qs = payload.map(function (filter) { + if (filter.value) { + return filter.name + '=' + filter.value; + } + }).filter(Boolean).join('&'); + + ajaxify.go('flags?' + qs); + }) + }; + + return Flags; +}); diff --git a/src/controllers/admin.js b/src/controllers/admin.js index 7f622466cd..c58def03cf 100644 --- a/src/controllers/admin.js +++ b/src/controllers/admin.js @@ -4,7 +4,6 @@ var adminController = { dashboard: require('./admin/dashboard'), categories: require('./admin/categories'), tags: require('./admin/tags'), - flags: require('./admin/flags'), blacklist: require('./admin/blacklist'), groups: require('./admin/groups'), appearance: require('./admin/appearance'), diff --git a/src/controllers/admin/flags.js b/src/controllers/admin/flags.js deleted file mode 100644 index 80c31ba60a..0000000000 --- a/src/controllers/admin/flags.js +++ /dev/null @@ -1,104 +0,0 @@ -"use strict"; - -var async = require('async'); -var validator = require('validator'); - -var posts = require('../../posts'); -var user = require('../../user'); -var flags = require('../../flags'); -var categories = require('../../categories'); -var analytics = require('../../analytics'); -var pagination = require('../../pagination'); - -var flagsController = {}; - -var itemsPerPage = 20; - -flagsController.get = function (req, res, next) { - var byUsername = req.query.byUsername || ''; - var cid = req.query.cid || 0; - var sortBy = req.query.sortBy || 'count'; - var page = parseInt(req.query.page, 10) || 1; - - async.parallel({ - categories: function (next) { - categories.buildForSelect(req.uid, next); - }, - flagData: function (next) { - getFlagData(req, res, next); - }, - analytics: function (next) { - analytics.getDailyStatsForSet('analytics:flags', Date.now(), 30, next); - }, - assignees: async.apply(user.getAdminsandGlobalModsandModerators) - }, function (err, results) { - if (err) { - return next(err); - } - - // Minimise data set for assignees so tjs does less work - results.assignees = results.assignees.map(function (userObj) { - return { - uid: userObj.uid, - username: userObj.username - }; - }); - - // If res.locals.cids is populated, then slim down the categories list - if (res.locals.cids) { - results.categories = results.categories.filter(function (category) { - return res.locals.cids.indexOf(String(category.cid)) !== -1; - }); - } - - var pageCount = Math.max(1, Math.ceil(results.flagData.count / itemsPerPage)); - - results.categories.forEach(function (category) { - category.selected = parseInt(category.cid, 10) === parseInt(cid, 10); - }); - - var data = { - posts: results.flagData.posts, - assignees: results.assignees, - analytics: results.analytics, - categories: results.categories, - byUsername: validator.escape(String(byUsername)), - sortByCount: sortBy === 'count', - sortByTime: sortBy === 'time', - pagination: pagination.create(page, pageCount, req.query), - title: '[[pages:flagged-posts]]' - }; - res.render('admin/manage/flags', data); - }); -}; - -function getFlagData(req, res, callback) { - var sortBy = req.query.sortBy || 'count'; - var byUsername = req.query.byUsername || ''; - var cid = req.query.cid || res.locals.cids || 0; - var page = parseInt(req.query.page, 10) || 1; - var start = (page - 1) * itemsPerPage; - var stop = start + itemsPerPage - 1; - - var sets = [sortBy === 'count' ? 'posts:flags:count' : 'posts:flagged']; - - async.waterfall([ - function (next) { - if (byUsername) { - user.getUidByUsername(byUsername, next); - } else { - process.nextTick(next, null, 0); - } - }, - function (uid, next) { - if (uid) { - sets.push('uid:' + uid + ':flag:pids'); - } - - flags.get(sets, cid, req.uid, start, stop, next); - } - ], callback); -} - - -module.exports = flagsController; diff --git a/src/controllers/mods.js b/src/controllers/mods.js index 012ffde3c6..759de90ef1 100644 --- a/src/controllers/mods.js +++ b/src/controllers/mods.js @@ -25,7 +25,17 @@ modsController.flags.list = function (req, res, next) { res.locals.cids = results.moderatedCids; } - flags.list({}, function (err, flags) { + // Parse query string params for filters + var valid = ['reporterId', 'type']; + var filters = valid.reduce(function (memo, cur) { + if (req.query.hasOwnProperty(cur)) { + memo[cur] = req.query[cur]; + } + + return memo; + }, {}); + + flags.list(filters, function (err, flags) { if (err) { return next(err); } diff --git a/src/flags.js b/src/flags.js index 58b230af06..6744d8ad59 100644 --- a/src/flags.js +++ b/src/flags.js @@ -53,8 +53,31 @@ Flags.list = function (filters, callback) { filters = {}; } + var sets = []; + if (Object.keys(filters).length > 0) { + for (var type in filters) { + switch (type) { + case 'type': + sets.push('flags:byType:' + filters[type]); + break; + + case 'reporterId': + sets.push('flags:byReporter:' + filters[type]); + break; + } + } + + } + sets = sets.length ? sets : ['flags:datetime']; // No filter default + async.waterfall([ - async.apply(db.getSortedSetRevRange.bind(db), 'flags:datetime', 0, 19), + function (next) { + if (sets.length === 1) { + db.getSortedSetRevRange(sets[0], 0, 19, next); + } else { + db.getSortedSetRevIntersect({sets: sets, start: 0, stop: -1, aggregate: 'MAX'}, next); + } + }, function (flagIds, next) { async.map(flagIds, function (flagId, next) { async.waterfall([ @@ -197,8 +220,10 @@ Flags.create = function (type, id, uid, reason, callback) { uid: uid, datetime: Date.now() })), - async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', Date.now(), flagId), - async.apply(db.setObjectField.bind(db), 'flagHash:flagId', [type, id, uid].join(':'), flagId) + async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', Date.now(), flagId), // by time, the default + async.apply(db.sortedSetAdd.bind(db), 'flags:byReporter:' + uid, Date.now(), flagId), // by reporter + async.apply(db.sortedSetAdd.bind(db), 'flags:byType:' + type, Date.now(), flagId), // by flag type + async.apply(db.setObjectField.bind(db), 'flagHash:flagId', [type, id, uid].join(':'), flagId) // save hash for existence checking ], function (err, data) { if (err) { return next(err); diff --git a/src/routes/admin.js b/src/routes/admin.js index 0611eede70..53b12fc4b8 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -56,7 +56,6 @@ function addRoutes(router, middleware, controllers) { router.get('/manage/categories/:category_id/analytics', middlewares, controllers.admin.categories.getAnalytics); router.get('/manage/tags', middlewares, controllers.admin.tags.get); - router.get('/manage/flags', middlewares, controllers.admin.flags.get); router.get('/manage/ip-blacklist', middlewares, controllers.admin.blacklist.get); router.get('/manage/users', middlewares, controllers.admin.users.sortByJoinDate); diff --git a/src/socket.io/flags.js b/src/socket.io/flags.js index 8b66dd094c..e6fb0be116 100644 --- a/src/socket.io/flags.js +++ b/src/socket.io/flags.js @@ -67,7 +67,7 @@ SocketFlags.create = function (socket, data, callback) { flags.create('post', post.pid, socket.uid, data.reason, next); }, - function (next) { + function (flagObj, next) { async.parallel({ post: function (next) { posts.parsePost(post, next); From 8e1d441e20d70e61d12a13ec66c48c8c029f3470 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Mon, 5 Dec 2016 12:40:25 -0500 Subject: [PATCH 21/48] Added some quick filters, #5232 --- public/language/en-GB/flags.json | 5 +++- public/src/client/flags/list.js | 5 ++++ src/controllers/mods.js | 8 ++++--- src/flags.js | 39 ++++++++++++++++++++++++-------- 4 files changed, 44 insertions(+), 13 deletions(-) diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json index cdc3c4069e..fbe32a812e 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -1,5 +1,4 @@ { - "quick-filters": "Quick Filters", "state": "State", "reporter": "Reporter", "reported-at": "Reported At", @@ -9,11 +8,15 @@ "update": "Update", "updated": "Updated", + "quick-filters": "Quick Filters", + "filter-active": "There are one or more filters active in this list of flags", + "filter-reset": "Remove Filters", "filters": "Filter Options", "filter-reporterId": "Reporter UID", "filter-type": "Flag Type", "filter-type-all": "All Content", "filter-type-post": "Post", + "filter-quick-mine": "Assigned to me", "apply-filters": "Apply Filters", "notes": "Flag Notes", diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js index 6111372150..2994e7fcf9 100644 --- a/public/src/client/flags/list.js +++ b/public/src/client/flags/list.js @@ -12,6 +12,11 @@ define('forum/flags/list', ['components'], function (components) { Flags.enableFilterForm = function () { var filtersEl = components.get('flags/filters'); + // Parse ajaxify data to set form values to reflect current filters + for(var filter in ajaxify.data.filters) { + filtersEl.find('[name="' + filter + '"]').val(ajaxify.data.filters[filter]); + } + filtersEl.find('button').on('click', function () { var payload = filtersEl.serializeArray(); var qs = payload.map(function (filter) { diff --git a/src/controllers/mods.js b/src/controllers/mods.js index 759de90ef1..f15b1a6b1a 100644 --- a/src/controllers/mods.js +++ b/src/controllers/mods.js @@ -26,7 +26,7 @@ modsController.flags.list = function (req, res, next) { } // Parse query string params for filters - var valid = ['reporterId', 'type']; + var valid = ['reporterId', 'type', 'quick']; var filters = valid.reduce(function (memo, cur) { if (req.query.hasOwnProperty(cur)) { memo[cur] = req.query[cur]; @@ -35,13 +35,15 @@ modsController.flags.list = function (req, res, next) { return memo; }, {}); - flags.list(filters, function (err, flags) { + flags.list(filters, req.uid, function (err, flags) { if (err) { return next(err); } res.render('flags/list', { - flags: flags + flags: flags, + hasFilter: !!Object.keys(filters).length, + filters: filters }); }); }); diff --git a/src/flags.js b/src/flags.js index 6744d8ad59..492e1faa43 100644 --- a/src/flags.js +++ b/src/flags.js @@ -47,7 +47,7 @@ Flags.get = function (flagId, callback) { ], callback); }; -Flags.list = function (filters, callback) { +Flags.list = function (filters, uid, callback) { if (typeof filters === 'function' && !callback) { callback = filters; filters = {}; @@ -64,16 +64,23 @@ Flags.list = function (filters, callback) { case 'reporterId': sets.push('flags:byReporter:' + filters[type]); break; + + case 'quick': + switch (filters.quick) { + case 'mine': + sets.push('flags:byAssignee:' + uid); + break; + } + break; } } - } sets = sets.length ? sets : ['flags:datetime']; // No filter default async.waterfall([ function (next) { if (sets.length === 1) { - db.getSortedSetRevRange(sets[0], 0, 19, next); + db.getSortedSetRevRange(sets[0], 0, -1, next); } else { db.getSortedSetRevIntersect({sets: sets, start: 0, stop: -1, aggregate: 'MAX'}, next); } @@ -316,6 +323,8 @@ Flags.update = function (flagId, uid, changeset, callback) { // Retrieve existing flag data to compare for history-saving purposes var fields = ['state', 'assignee']; var history = []; + var tasks = []; + var now = Date.now(); async.waterfall([ async.apply(db.getObjectFields.bind(db), 'flag:' + flagId, fields), @@ -325,6 +334,18 @@ Flags.update = function (flagId, uid, changeset, callback) { if (current[prop] === changeset[prop]) { delete changeset[prop]; } else { + // Add tasks as necessary + switch (prop) { + case 'state': + tasks.push(async.apply(db.sortedSetAdd.bind(db), 'flags:byState:' + changeset[prop], now, flagId)); + tasks.push(async.apply(db.sortedSetRemove.bind(db), 'flags:byState:' + current[prop], flagId)); + break; + + case 'assignee': + tasks.push(async.apply(db.sortedSetAdd.bind(db), 'flags:byAssignee:' + changeset[prop], now, flagId)); + break; + } + // Append to history payload history.push(prop + ':' + changeset[prop]); } @@ -336,12 +357,12 @@ Flags.update = function (flagId, uid, changeset, callback) { return next(); } - async.parallel([ - // Save new object to db (upsert) - async.apply(db.setObject, 'flag:' + flagId, changeset), - // Append history - async.apply(Flags.appendHistory, flagId, uid, history) - ], function (err, data) { + // Save new object to db (upsert) + tasks.push(async.apply(db.setObject, 'flag:' + flagId, changeset)); + // Append history + tasks.push(async.apply(Flags.appendHistory, flagId, uid, history)) + + async.parallel(tasks, function (err, data) { return next(err); }); } From 88958049ebce7abc17a40ba5223d5edcbeabea92 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Mon, 5 Dec 2016 15:32:58 -0500 Subject: [PATCH 22/48] added some more filters, and appending event to history on flag creation issue #5232 --- public/language/en-GB/flags.json | 2 ++ public/src/client/flags/list.js | 4 ++-- src/controllers/mods.js | 2 +- src/flags.js | 9 +++++++++ 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json index fbe32a812e..5d9799de50 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -16,6 +16,8 @@ "filter-type": "Flag Type", "filter-type-all": "All Content", "filter-type-post": "Post", + "filter-state": "State", + "filter-assignee": "Assignee UID", "filter-quick-mine": "Assigned to me", "apply-filters": "Apply Filters", diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js index 2994e7fcf9..a5277f98df 100644 --- a/public/src/client/flags/list.js +++ b/public/src/client/flags/list.js @@ -13,7 +13,7 @@ define('forum/flags/list', ['components'], function (components) { var filtersEl = components.get('flags/filters'); // Parse ajaxify data to set form values to reflect current filters - for(var filter in ajaxify.data.filters) { + for (var filter in ajaxify.data.filters) { filtersEl.find('[name="' + filter + '"]').val(ajaxify.data.filters[filter]); } @@ -26,7 +26,7 @@ define('forum/flags/list', ['components'], function (components) { }).filter(Boolean).join('&'); ajaxify.go('flags?' + qs); - }) + }); }; return Flags; diff --git a/src/controllers/mods.js b/src/controllers/mods.js index f15b1a6b1a..512ad32222 100644 --- a/src/controllers/mods.js +++ b/src/controllers/mods.js @@ -26,7 +26,7 @@ modsController.flags.list = function (req, res, next) { } // Parse query string params for filters - var valid = ['reporterId', 'type', 'quick']; + var valid = ['assignee', 'state', 'reporterId', 'type', 'quick']; var filters = valid.reduce(function (memo, cur) { if (req.query.hasOwnProperty(cur)) { memo[cur] = req.query[cur]; diff --git a/src/flags.js b/src/flags.js index 492e1faa43..a8bca052d1 100644 --- a/src/flags.js +++ b/src/flags.js @@ -60,11 +60,19 @@ Flags.list = function (filters, uid, callback) { case 'type': sets.push('flags:byType:' + filters[type]); break; + + case 'state': + sets.push('flags:byState:' + filters[type]); + break; case 'reporterId': sets.push('flags:byReporter:' + filters[type]); break; + case 'assignee': + sets.push('flags:byAssignee:' + filters[type]); + break; + case 'quick': switch (filters.quick) { case 'mine': @@ -236,6 +244,7 @@ Flags.create = function (type, id, uid, reason, callback) { return next(err); } + Flags.appendHistory(flagId, uid, ['created']); next(null, flagId); }); }, From cd3002e812a1c41aea190d72b5941f3d6634505f Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Mon, 5 Dec 2016 15:52:01 -0500 Subject: [PATCH 23/48] removed user flag reset method and associated socket call from ACP --- public/src/admin/manage/users.js | 9 --------- src/socket.io/admin/user.js | 8 -------- src/views/admin/manage/users.tpl | 1 - 3 files changed, 18 deletions(-) diff --git a/public/src/admin/manage/users.js b/public/src/admin/manage/users.js index b8fb8d5de6..2f344e37c7 100644 --- a/public/src/admin/manage/users.js +++ b/public/src/admin/manage/users.js @@ -128,15 +128,6 @@ define('admin/manage/users', ['translator'], function (translator) { socket.emit('admin.user.resetLockouts', uids, done('Lockout(s) reset!')); }); - $('.reset-flags').on('click', function () { - var uids = getSelectedUids(); - if (!uids.length) { - return; - } - - socket.emit('admin.user.resetFlags', uids, done('Flags(s) reset!')); - }); - $('.admin-user').on('click', function () { var uids = getSelectedUids(); if (!uids.length) { diff --git a/src/socket.io/admin/user.js b/src/socket.io/admin/user.js index 4a77c224a7..272a13f7f7 100644 --- a/src/socket.io/admin/user.js +++ b/src/socket.io/admin/user.js @@ -68,14 +68,6 @@ User.resetLockouts = function (socket, uids, callback) { async.each(uids, user.auth.resetLockout, callback); }; -User.resetFlags = function (socket, uids, callback) { - if (!Array.isArray(uids)) { - return callback(new Error('[[error:invalid-data]]')); - } - - user.resetFlags(uids, callback); -}; - User.validateEmail = function (socket, uids, callback) { if (!Array.isArray(uids)) { return callback(new Error('[[error:invalid-data]]')); diff --git a/src/views/admin/manage/users.tpl b/src/views/admin/manage/users.tpl index de0cd15e62..b43d66af8a 100644 --- a/src/views/admin/manage/users.tpl +++ b/src/views/admin/manage/users.tpl @@ -19,7 +19,6 @@ <li><a href="#" class="ban-user-temporary"><i class="fa fa-fw fa-clock-o"></i> Ban User(s) Temporarily</a></li> <li><a href="#" class="unban-user"><i class="fa fa-fw fa-comment-o"></i> Unban User(s)</a></li> <li><a href="#" class="reset-lockout"><i class="fa fa-fw fa-unlock"></i> Reset Lockout</a></li> - <li><a href="#" class="reset-flags"><i class="fa fa-fw fa-flag"></i> Reset Flags</a></li> <li class="divider"></li> <li><a href="#" class="delete-user"><i class="fa fa-fw fa-trash-o"></i> Delete User(s)</a></li> <li><a href="#" class="delete-user-and-content"><i class="fa fa-fw fa-trash-o"></i> Delete User(s) and Content</a></li> From 9f9051026b89b41d6028e8c0b59f6fa9a6e7336e Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Tue, 6 Dec 2016 16:11:56 -0500 Subject: [PATCH 24/48] more work on #5232 --- public/language/en-GB/flags.json | 8 ++ public/src/client/flags/list.js | 7 ++ public/src/client/topic/flag.js | 2 +- src/controllers/mods.js | 2 +- src/flags.js | 143 +++++++++++++++++++++++++++---- src/socket.io/flags.js | 85 ++---------------- 6 files changed, 153 insertions(+), 94 deletions(-) diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json index 5d9799de50..6b4e96f4fb 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -13,6 +13,7 @@ "filter-reset": "Remove Filters", "filters": "Filter Options", "filter-reporterId": "Reporter UID", + "filter-targetUid": "Flagged UID", "filter-type": "Flag Type", "filter-type-all": "All Content", "filter-type-post": "Post", @@ -21,6 +22,13 @@ "filter-quick-mine": "Assigned to me", "apply-filters": "Apply Filters", + "quick-links": "Quick Links", + "flagged-user": "Flagged User", + "reporter": "Reporting User", + "view-profile": "View Profile", + "start-new-chat": "Start New Chat", + "go-to-target": "View Flag Target", + "notes": "Flag Notes", "add-note": "Add Note", "no-notes": "No shared notes.", diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js index a5277f98df..cbdaf94f24 100644 --- a/public/src/client/flags/list.js +++ b/public/src/client/flags/list.js @@ -7,6 +7,7 @@ define('forum/flags/list', ['components'], function (components) { Flags.init = function () { Flags.enableFilterForm(); + Flags.enableChatButtons(); }; Flags.enableFilterForm = function () { @@ -29,5 +30,11 @@ define('forum/flags/list', ['components'], function (components) { }); }; + Flags.enableChatButtons = function () { + $('[data-chat]').on('click', function () { + app.newChat(this.getAttribute('data-chat')); + }); + }; + return Flags; }); diff --git a/public/src/client/topic/flag.js b/public/src/client/topic/flag.js index 046585ae68..6b3440da54 100644 --- a/public/src/client/topic/flag.js +++ b/public/src/client/topic/flag.js @@ -48,7 +48,7 @@ define('forum/topic/flag', [], function () { if (!pid || !reason) { return; } - socket.emit('flags.create', {pid: pid, reason: reason}, function (err) { + socket.emit('flags.create', {type: 'post', id: pid, reason: reason}, function (err) { if (err) { return app.alertError(err.message); } diff --git a/src/controllers/mods.js b/src/controllers/mods.js index 512ad32222..788294a01e 100644 --- a/src/controllers/mods.js +++ b/src/controllers/mods.js @@ -26,7 +26,7 @@ modsController.flags.list = function (req, res, next) { } // Parse query string params for filters - var valid = ['assignee', 'state', 'reporterId', 'type', 'quick']; + var valid = ['assignee', 'state', 'reporterId', 'type', 'targetUid', 'quick']; var filters = valid.reduce(function (memo, cur) { if (req.query.hasOwnProperty(cur)) { memo[cur] = req.query[cur]; diff --git a/src/flags.js b/src/flags.js index a8bca052d1..45aae2502e 100644 --- a/src/flags.js +++ b/src/flags.js @@ -1,23 +1,22 @@ - - 'use strict'; var async = require('async'); var winston = require('winston'); var db = require('./database'); var user = require('./user'); +var groups = require('./groups'); +var meta = require('./meta'); +var notifications = require('./notifications'); var analytics = require('./analytics'); var topics = require('./topics'); var posts = require('./posts'); +var privileges = require('./privileges'); +var plugins = require('./plugins'); var utils = require('../public/src/utils'); var _ = require('underscore'); +var S = require('string'); -var Flags = { - _defaults: { - state: 'open', - assignee: null - } -}; +var Flags = {}; Flags.get = function (flagId, callback) { async.waterfall([ @@ -72,6 +71,10 @@ Flags.list = function (filters, uid, callback) { case 'assignee': sets.push('flags:byAssignee:' + filters[type]); break; + + case 'targetUid': + sets.push('flags:byTargetUid:' + filters[type]); + break; case 'quick': switch (filters.quick) { @@ -145,6 +148,43 @@ Flags.list = function (filters, uid, callback) { }); }; +Flags.validate = function (payload, callback) { + async.parallel({ + targetExists: async.apply(Flags.targetExists, payload.type, payload.id), + target: async.apply(Flags.getTarget, payload.type, payload.id, payload.uid), + reporter: async.apply(user.getUserData, payload.uid) + }, function (err, data) { + if (err) { + return callback(err); + } + + if (data.target.deleted) { + return callback(new Error('[[error:post-deleted]]')); + } else if (data.reporter.banned) { + return callback(new Error('[[error:user-banned]]')); + } + + switch (payload.type) { + case 'post': + async.parallel({ + privileges: async.apply(privileges.posts.get, [payload.id], payload.uid) + }, function (err, subdata) { + if (err) { + return callback(err); + } + + var minimumReputation = utils.isNumber(meta.config['privileges:flag']) ? parseInt(meta.config['privileges:flag'], 10) : 1; + if (!subdata.privileges[0].isAdminOrMod && parseInt(data.reporter.reputation, 10) < minimumReputation) { + return callback(new Error('[[error:not-enough-reputation-to-flag]]')); + } + + callback(); + }); + break; + } + }); +}; + Flags.getTarget = function (type, id, uid, callback) { switch (type) { case 'post': @@ -204,17 +244,22 @@ Flags.getNotes = function (flagId, callback) { }; Flags.create = function (type, id, uid, reason, callback) { + var targetUid; + async.waterfall([ function (next) { // Sanity checks async.parallel([ async.apply(Flags.exists, type, id, uid), - async.apply(Flags.targetExists, type, id) + async.apply(Flags.targetExists, type, id), + async.apply(Flags.getTargetUid, type, id) ], function (err, checks) { if (err) { return next(err); } + targetUid = checks[2] || null; + if (checks[0]) { return next(new Error('[[error:already-flagged]]')); } else if (!checks[1]) { @@ -226,25 +271,31 @@ Flags.create = function (type, id, uid, reason, callback) { }, async.apply(db.incrObjectField, 'global', 'nextFlagId'), function (flagId, next) { - async.parallel([ - async.apply(db.setObject.bind(db), 'flag:' + flagId, Object.assign({}, Flags._defaults, { + var tasks = [ + async.apply(db.setObject.bind(db), 'flag:' + flagId, { flagId: flagId, type: type, targetId: id, description: reason, uid: uid, datetime: Date.now() - })), + }), async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', Date.now(), flagId), // by time, the default async.apply(db.sortedSetAdd.bind(db), 'flags:byReporter:' + uid, Date.now(), flagId), // by reporter async.apply(db.sortedSetAdd.bind(db), 'flags:byType:' + type, Date.now(), flagId), // by flag type async.apply(db.setObjectField.bind(db), 'flagHash:flagId', [type, id, uid].join(':'), flagId) // save hash for existence checking - ], function (err, data) { + ]; + + if (targetUid) { + tasks.push(async.apply(db.sortedSetAdd.bind(db), 'flags:byTargetUid:' + targetUid, Date.now(), flagId)); // by target uid + } + + async.parallel(tasks, function (err, data) { if (err) { return next(err); } - Flags.appendHistory(flagId, uid, ['created']); + Flags.update(flagId, uid, { "state": "open" }); next(null, flagId); }); }, @@ -318,7 +369,7 @@ Flags.exists = function (type, id, uid, callback) { Flags.targetExists = function (type, id, callback) { switch (type) { - case 'topic': + case 'topic': // just an example... topics.exists(id, callback); break; @@ -328,6 +379,14 @@ Flags.targetExists = function (type, id, callback) { } }; +Flags.getTargetUid = function (type, id, callback) { + switch (type) { + case 'post': + posts.getPostField(id, 'uid', callback); + break; + } +}; + Flags.update = function (flagId, uid, changeset, callback) { // Retrieve existing flag data to compare for history-saving purposes var fields = ['state', 'assignee']; @@ -369,7 +428,7 @@ Flags.update = function (flagId, uid, changeset, callback) { // Save new object to db (upsert) tasks.push(async.apply(db.setObject, 'flag:' + flagId, changeset)); // Append history - tasks.push(async.apply(Flags.appendHistory, flagId, uid, history)) + tasks.push(async.apply(Flags.appendHistory, flagId, uid, history)); async.parallel(tasks, function (err, data) { return next(err); @@ -462,4 +521,56 @@ Flags.appendNote = function (flagId, uid, note, callback) { ], callback); }; +Flags.notify = function (flagObj, uid, callback) { + // Notify administrators, mods, and other associated people + switch (flagObj.type) { + case 'post': + async.parallel({ + post: function (next) { + async.waterfall([ + async.apply(posts.getPostData, flagObj.targetId), + async.apply(posts.parsePost) + ], next); + }, + title: async.apply(topics.getTitleByPid, flagObj.targetId), + admins: async.apply(groups.getMembers, 'administrators', 0, -1), + globalMods: async.apply(groups.getMembers, 'Global Moderators', 0, -1), + moderators: function (next) { + async.waterfall([ + async.apply(posts.getCidByPid, flagObj.targetId), + function (cid, next) { + groups.getMembers('cid:' + cid + ':privileges:mods', 0, -1, next); + } + ], next); + } + }, function (err, results) { + if (err) { + return callback(err); + } + + var title = S(results.title).decodeHTMLEntities().s; + var titleEscaped = title.replace(/%/g, '%').replace(/,/g, ','); + + notifications.create({ + bodyShort: '[[notifications:user_flagged_post_in, ' + flagObj.reporter.username + ', ' + titleEscaped + ']]', + bodyLong: results.post.content, + pid: flagObj.targetId, + path: '/post/' + flagObj.targetId, + nid: 'flag:post:' + flagObj.targetId + ':uid:' + uid, + from: uid, + mergeId: 'notifications:user_flagged_post_in|' + flagObj.targetId, + topicTitle: results.title + }, function (err, notification) { + if (err || !notification) { + return callback(err); + } + + plugins.fireHook('action:post.flag', {post: results.post, reason: flagObj.description, flaggingUser: flagObj.reporter}); + notifications.push(notification, results.admins.concat(results.moderators).concat(results.globalMods), callback); + }); + }); + break; + } +}; + module.exports = Flags; \ No newline at end of file diff --git a/src/socket.io/flags.js b/src/socket.io/flags.js index e6fb0be116..ce148faa87 100644 --- a/src/socket.io/flags.js +++ b/src/socket.io/flags.js @@ -21,89 +21,22 @@ SocketFlags.create = function (socket, data, callback) { return callback(new Error('[[error:not-logged-in]]')); } - if (!data || !data.pid || !data.reason) { + if (!data || !data.type || !data.id || !data.reason) { return callback(new Error('[[error:invalid-data]]')); } - var flaggingUser = {}; - var post; - async.waterfall([ + async.apply(flags.validate, { + uid: socket.uid, + type: data.type, + id: data.id + }), function (next) { - posts.getPostFields(data.pid, ['pid', 'tid', 'uid', 'content', 'deleted'], next); - }, - function (postData, next) { - if (parseInt(postData.deleted, 10) === 1) { - return next(new Error('[[error:post-deleted]]')); - } - - post = postData; - topics.getTopicFields(post.tid, ['title', 'cid'], next); - }, - function (topicData, next) { - post.topic = topicData; - - async.parallel({ - isAdminOrMod: function (next) { - privileges.categories.isAdminOrMod(post.topic.cid, socket.uid, next); - }, - userData: function (next) { - user.getUserFields(socket.uid, ['username', 'reputation', 'banned'], next); - } - }, next); - }, - function (user, next) { - var minimumReputation = utils.isNumber(meta.config['privileges:flag']) ? parseInt(meta.config['privileges:flag'], 10) : 1; - if (!user.isAdminOrMod && parseInt(user.userData.reputation, 10) < minimumReputation) { - return next(new Error('[[error:not-enough-reputation-to-flag]]')); - } - - if (parseInt(user.banned, 10) === 1) { - return next(new Error('[[error:user-banned]]')); - } - - flaggingUser = user.userData; - flaggingUser.uid = socket.uid; - - flags.create('post', post.pid, socket.uid, data.reason, next); + // If we got here, then no errors occurred + flags.create(data.type, data.id, socket.uid, data.reason, next); }, function (flagObj, next) { - async.parallel({ - post: function (next) { - posts.parsePost(post, next); - }, - admins: function (next) { - groups.getMembers('administrators', 0, -1, next); - }, - globalMods: function (next) { - groups.getMembers('Global Moderators', 0, -1, next); - }, - moderators: function (next) { - groups.getMembers('cid:' + post.topic.cid + ':privileges:mods', 0, -1, next); - } - }, next); - }, - function (results, next) { - var title = S(post.topic.title).decodeHTMLEntities().s; - var titleEscaped = title.replace(/%/g, '%').replace(/,/g, ','); - - notifications.create({ - bodyShort: '[[notifications:user_flagged_post_in, ' + flaggingUser.username + ', ' + titleEscaped + ']]', - bodyLong: post.content, - pid: data.pid, - path: '/post/' + data.pid, - nid: 'post_flag:' + data.pid + ':uid:' + socket.uid, - from: socket.uid, - mergeId: 'notifications:user_flagged_post_in|' + data.pid, - topicTitle: post.topic.title - }, function (err, notification) { - if (err || !notification) { - return next(err); - } - - plugins.fireHook('action:post.flag', {post: post, reason: data.reason, flaggingUser: flaggingUser}); - notifications.push(notification, results.admins.concat(results.moderators).concat(results.globalMods), next); - }); + flags.notify(flagObj, socket.uid, next); } ], callback); }; From 57fcb92bbcc14f265e238348e82d18e5ea357431 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Tue, 6 Dec 2016 20:28:54 -0500 Subject: [PATCH 25/48] added a smattering of tests for #5232 --- test/flags.js | 188 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 183 insertions(+), 5 deletions(-) diff --git a/test/flags.js b/test/flags.js index c5e69aa973..ee735afd60 100644 --- a/test/flags.js +++ b/test/flags.js @@ -8,7 +8,9 @@ var db = require('./mocks/databasemock'); var Flags = require('../src/flags'); var Categories = require('../src/categories'); var Topics = require('../src/topics'); +var Posts = require('../src/posts'); var User = require('../src/user'); +var Meta = require('../src/meta'); describe('Flags', function () { before(function (done) { @@ -43,8 +45,7 @@ describe('Flags', function () { uid: 1, targetId: 1, type: 'post', - description: 'Test flag', - state: 'open' + description: 'Test flag' }; for(var key in compare) { @@ -86,7 +87,7 @@ describe('Flags', function () { describe('.list()', function () { it('should show a list of flags (with one item)', function (done) { - Flags.list({}, function (err, flags) { + Flags.list({}, 1, function (err, flags) { assert.ifError(err); assert.ok(Array.isArray(flags)); assert.equal(flags.length, 1); @@ -101,7 +102,49 @@ describe('Flags', function () { }); }); - describe('.getTarget()', function() { + describe('.update()', function () { + it('should alter a flag\'s various attributes and persist them to the database', function (done) { + Flags.update(1, 1, { + "state": "wip", + "assignee": 1 + }, function (err) { + assert.ifError(err); + db.getObjectFields('flag:1', ['state', 'assignee'], function (err, data) { + if (err) { + throw err; + } + + assert.strictEqual('wip', data.state); + assert.strictEqual(1, data.assignee); + done(); + }); + }); + }); + + it('should persist to the flag\'s history', function (done) { + Flags.getHistory(1, function (err, history) { + if (err) { + throw err; + } + + history.forEach(function (change) { + switch (change.attribute) { + case 'state': + assert.strictEqual('[[flags:state-wip]]', change.value); + break; + + case 'assignee': + assert.strictEqual(1, change.value); + break; + } + }); + + done(); + }); + }); + }); + + describe('.getTarget()', function () { it('should return a post\'s data if queried with type "post"', function (done) { Flags.getTarget('post', 1, 1, function (err, data) { assert.ifError(err); @@ -141,7 +184,142 @@ describe('Flags', function () { done(); }); }); - });; + }); + + describe('.validate()', function () { + it('should error out if type is post and post is deleted', function (done) { + Posts.delete(1, 1, function (err) { + if (err) { + throw err; + } + + Flags.validate({ + type: 'post', + id: 1, + uid: 1 + }, function (err) { + assert.ok(err); + assert.strictEqual('[[error:post-deleted]]', err.message); + Posts.restore(1, 1, done); + }); + }); + }); + + it('should not pass validation if flag threshold is set and user rep does not meet it', function (done) { + Meta.configs.set('privileges:flag', '50', function (err) { + assert.ifError(err); + + Flags.validate({ + type: 'post', + id: 1, + uid: 1 + }, function (err) { + assert.ok(err); + assert.strictEqual('[[error:not-enough-reputation-to-flag]]', err.message); + Meta.configs.set('privileges:flag', 0, done); + }); + }); + }); + }); + + describe('.appendNote()', function () { + it('should add a note to a flag', function (done) { + Flags.appendNote(1, 1, 'this is my note', function (err) { + assert.ifError(err); + + db.getSortedSetRange('flag:1:notes', 0, -1, function (err, notes) { + if (err) { + throw err; + } + + assert.strictEqual('[1,"this is my note"]', notes[0]); + done(); + }); + }); + }); + + it('should be a JSON string', function (done) { + db.getSortedSetRange('flag:1:notes', 0, -1, function (err, notes) { + if (err) { + throw err; + } + + try { + JSON.parse(notes[0]); + } catch (e) { + assert.ifError(e); + } + + done(); + }); + }); + }); + + describe('.getNotes()', function () { + before(function (done) { + // Add a second note + Flags.appendNote(1, 1, 'this is the second note', done); + }); + + it('return should match a predefined spec', function (done) { + Flags.getNotes(1, function (err, notes) { + assert.ifError(err); + var compare = { + uid: 1, + content: 'this is my note' + }; + + var data = notes[1]; + for(var key in compare) { + if (compare.hasOwnProperty(key)) { + assert.ok(data[key]); + assert.strictEqual(data[key], compare[key]); + } + } + + done(); + }); + }); + + it('should retrieve a list of notes, from newest to oldest', function (done) { + Flags.getNotes(1, function (err, notes) { + assert.ifError(err); + assert(notes[0].datetime > notes[1].datetime); + assert.strictEqual('this is the second note', notes[0].content); + done(); + }); + }); + }); + + describe('.appendHistory()', function () { + var entries; + before(function (done) { + db.sortedSetCard('flag:1:history', function (err, count) { + entries = count; + done(err); + }); + }); + + it('should add a new entry into a flag\'s history', function (done) { + Flags.appendHistory(1, 1, ['state:rejected'], function (err) { + assert.ifError(err); + + Flags.getHistory(1, function (err, history) { + assert.strictEqual(entries + 1, history.length); + done(); + }); + }); + }); + }); + + describe('.getHistory()', function () { + it('should retrieve a flag\'s history', function (done) { + Flags.getHistory(1, function (err, history) { + assert.strictEqual(history[0].fields[0].value, '[[flags:state-rejected]]'); + done(); + }); + }); + }); after(function (done) { db.emptydb(done); From 5dd892bd010e126b2c8f13a47460144c3a77bdcf Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Wed, 7 Dec 2016 12:07:22 -0500 Subject: [PATCH 26/48] a bunch of changes here... allowing user profiles to be flagged, #5232 --- public/language/en-GB/flags.json | 13 +++- public/language/en-GB/notifications.json | 3 + public/language/en-GB/topic.json | 9 +-- public/language/en-GB/user.json | 1 + public/src/client/account/header.js | 10 +++ public/src/client/flags/list.js | 2 + public/src/client/topic/postTools.js | 9 ++- .../topic/flag.js => modules/flags.js} | 25 +++--- src/controllers/mods.js | 6 +- src/flags.js | 76 +++++++++++++++++-- src/meta/js.js | 4 +- src/notifications.js | 2 + test/flags.js | 12 ++- 13 files changed, 133 insertions(+), 39 deletions(-) rename public/src/{client/topic/flag.js => modules/flags.js} (62%) diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json index 6b4e96f4fb..a9272897ca 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -29,6 +29,9 @@ "start-new-chat": "Start New Chat", "go-to-target": "View Flag Target", + "user-view": "View Profile", + "user-edit": "Edit Profile", + "notes": "Flag Notes", "add-note": "Add Note", "no-notes": "No shared notes.", @@ -43,5 +46,13 @@ "state-resolved": "Resolved", "state-rejected": "Rejected", "no-assignee": "Not Assigned", - "note-added": "Note Added" + "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.", + "modal-reason-spam": "Spam", + "modal-reason-offensive": "Offensive", + "modal-reason-custom": "Reason for reporting this content...", + "modal-submit": "Submit Report", + "modal-submit-success": "Content has been flagged for moderation." } \ No newline at end of file diff --git a/public/language/en-GB/notifications.json b/public/language/en-GB/notifications.json index 5a2ed58908..0838ca17eb 100644 --- a/public/language/en-GB/notifications.json +++ b/public/language/en-GB/notifications.json @@ -21,6 +21,9 @@ "user_flagged_post_in": "<strong>%1</strong> flagged a post in <strong>%2</strong>", "user_flagged_post_in_dual": "<strong>%1</strong> and <strong>%2</strong> flagged a post in <strong>%3</strong>", "user_flagged_post_in_multiple": "<strong>%1</strong> and %2 others flagged a post in <strong>%3</strong>", + "user_flagged_user": "<strong>%1</strong> flagged a user profile (%2)", + "user_flagged_user_dual": "<strong>%1</strong> and <strong>%2</strong> flagged a user profile (%3)", + "user_flagged_user_multiple": "<strong>%1</strong> and %2 others flagged a user profile (%3)", "user_posted_to" : "<strong>%1</strong> has posted a reply to: <strong>%2</strong>", "user_posted_to_dual" : "<strong>%1</strong> and <strong>%2</strong> have posted replies to: <strong>%3</strong>", "user_posted_to_multiple" : "<strong>%1</strong> and %2 others have posted replies to: <strong>%3</strong>", diff --git a/public/language/en-GB/topic.json b/public/language/en-GB/topic.json index 4ae208076e..5571292dda 100644 --- a/public/language/en-GB/topic.json +++ b/public/language/en-GB/topic.json @@ -28,7 +28,6 @@ "link": "Link", "share": "Share", "tools": "Tools", - "flag": "Flag", "locked": "Locked", "pinned": "Pinned", "moved": "Moved", @@ -36,7 +35,6 @@ "bookmark_instructions" : "Click here to return to the last read post in this thread.", "flag_title": "Flag this post for moderation", - "flag_success": "This post has been flagged for moderation.", "deleted_message": "This topic has been deleted. Only users with topic management privileges can see it.", @@ -138,10 +136,5 @@ "stale.create": "Create a new topic", "stale.reply_anyway": "Reply to this topic anyway", - "link_back": "Re: [%1](%2)\n\n", - - "spam": "Spam", - "offensive": "Offensive", - "custom-flag-reason": "Enter a flagging reason" - + "link_back": "Re: [%1](%2)\n\n" } diff --git a/public/language/en-GB/user.json b/public/language/en-GB/user.json index f0cb35f615..0725f208a3 100644 --- a/public/language/en-GB/user.json +++ b/public/language/en-GB/user.json @@ -35,6 +35,7 @@ "chat": "Chat", "chat_with": "Continue chat with %1", "new_chat_with": "Start new chat with %1", + "flag-profile": "Flag Profile", "follow": "Follow", "unfollow": "Unfollow", "more": "More", diff --git a/public/src/client/account/header.js b/public/src/client/account/header.js index d225e2cae1..b53cbb2be4 100644 --- a/public/src/client/account/header.js +++ b/public/src/client/account/header.js @@ -49,6 +49,7 @@ define('forum/account/header', [ components.get('account/ban').on('click', banAccount); components.get('account/unban').on('click', unbanAccount); components.get('account/delete').on('click', deleteAccount); + components.get('account/flag').on('click', flagAccount); }; function hidePrivateLinks() { @@ -167,6 +168,15 @@ define('forum/account/header', [ }); } + function flagAccount() { + require(['flags'], function (flags) { + flags.showFlagModal({ + type: 'user', + id: ajaxify.data.uid + }); + }); + } + function removeCover() { socket.emit('user.removeCover', { uid: ajaxify.data.uid diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js index cbdaf94f24..bf8b4bc1a8 100644 --- a/public/src/client/flags/list.js +++ b/public/src/client/flags/list.js @@ -23,6 +23,8 @@ define('forum/flags/list', ['components'], function (components) { var qs = payload.map(function (filter) { if (filter.value) { return filter.name + '=' + filter.value; + } else { + return; } }).filter(Boolean).join('&'); diff --git a/public/src/client/topic/postTools.js b/public/src/client/topic/postTools.js index faa0567878..5618e692db 100644 --- a/public/src/client/topic/postTools.js +++ b/public/src/client/topic/postTools.js @@ -167,10 +167,11 @@ define('forum/topic/postTools', ['share', 'navigator', 'components', 'translator postContainer.on('click', '[component="post/flag"]', function () { var pid = getData($(this), 'data-pid'); - var username = getData($(this), 'data-username'); - var userslug = getData($(this), 'data-userslug'); - require(['forum/topic/flag'], function (flag) { - flag.showFlagModal(pid, username, userslug); + require(['flags'], function (flags) { + flags.showFlagModal({ + type: 'post', + id: pid + }); }); }); diff --git a/public/src/client/topic/flag.js b/public/src/modules/flags.js similarity index 62% rename from public/src/client/topic/flag.js rename to public/src/modules/flags.js index 6b3440da54..cc9fd5103a 100644 --- a/public/src/client/topic/flag.js +++ b/public/src/modules/flags.js @@ -2,18 +2,13 @@ /* globals define, app, socket, templates */ -define('forum/topic/flag', [], function () { - +define('flags', [], function () { var Flag = {}, flagModal, flagCommit; - Flag.showFlagModal = function (pid, username, userslug) { - parseModal({ - pid: pid, - username: username, - userslug: userslug - }, function (html) { + Flag.showFlagModal = function (data) { + parseModal(data, function (html) { flagModal = $(html); flagModal.on('hidden.bs.modal', function () { @@ -23,11 +18,11 @@ define('forum/topic/flag', [], function () { flagCommit = flagModal.find('#flag-post-commit'); flagModal.on('click', '.flag-reason', function () { - flagPost(pid, $(this).text()); + createFlag(data.type, data.id, $(this).text()); }); flagCommit.on('click', function () { - flagPost(pid, flagModal.find('#flag-reason-custom').val()); + createFlag(data.type, data.id, flagModal.find('#flag-reason-custom').val()); }); flagModal.modal('show'); @@ -37,24 +32,24 @@ define('forum/topic/flag', [], function () { }; function parseModal(tplData, callback) { - templates.parse('partials/modals/flag_post_modal', tplData, function (html) { + templates.parse('partials/modals/flag_modal', tplData, function (html) { require(['translator'], function (translator) { translator.translate(html, callback); }); }); } - function flagPost(pid, reason) { - if (!pid || !reason) { + function createFlag(type, id, reason) { + if (!type || !id || !reason) { return; } - socket.emit('flags.create', {type: 'post', id: pid, reason: reason}, function (err) { + socket.emit('flags.create', {type: type, id: id, reason: reason}, function (err) { if (err) { return app.alertError(err.message); } flagModal.modal('hide'); - app.alertSuccess('[[topic:flag_success]]'); + app.alertSuccess('[[flags:modal-submit-success]]'); }); } diff --git a/src/controllers/mods.js b/src/controllers/mods.js index 788294a01e..656605f277 100644 --- a/src/controllers/mods.js +++ b/src/controllers/mods.js @@ -63,7 +63,11 @@ modsController.flags.detail = function (req, res, next) { } res.render('flags/detail', Object.assign(results.flagData, { - assignees: results.assignees + assignees: results.assignees, + type_bool: ['post', 'user'].reduce(function (memo, cur) { + memo[cur] = results.flagData.type === cur; + return memo; + }, {}) })); }); }; diff --git a/src/flags.js b/src/flags.js index 45aae2502e..9f121f81bf 100644 --- a/src/flags.js +++ b/src/flags.js @@ -167,20 +167,44 @@ Flags.validate = function (payload, callback) { switch (payload.type) { case 'post': async.parallel({ - privileges: async.apply(privileges.posts.get, [payload.id], payload.uid) + editable: async.apply(privileges.posts.canEdit, payload.id, payload.uid) }, function (err, subdata) { if (err) { return callback(err); } var minimumReputation = utils.isNumber(meta.config['privileges:flag']) ? parseInt(meta.config['privileges:flag'], 10) : 1; - if (!subdata.privileges[0].isAdminOrMod && parseInt(data.reporter.reputation, 10) < minimumReputation) { + // Check if reporter meets rep threshold (or can edit the target post, in which case threshold does not apply) + if (!subdata.editable.flag && parseInt(data.reporter.reputation, 10) < minimumReputation) { return callback(new Error('[[error:not-enough-reputation-to-flag]]')); } callback(); }); break; + + case 'user': + async.parallel({ + editable: async.apply(privileges.users.canEdit, payload.uid, payload.id) + }, function (err, subdata) { + if (err) { + return callback(err); + } + + + var minimumReputation = utils.isNumber(meta.config['privileges:flag']) ? parseInt(meta.config['privileges:flag'], 10) : 1; + // Check if reporter meets rep threshold (or can edit the target user, in which case threshold does not apply) + if (!subdata.editable && parseInt(data.reporter.reputation, 10) < minimumReputation) { + return callback(new Error('[[error:not-enough-reputation-to-flag]]')); + } + + callback(); + }); + break; + + default: + callback(new Error('[[error:invalid-data]]')); + break; } }); }; @@ -369,13 +393,17 @@ Flags.exists = function (type, id, uid, callback) { Flags.targetExists = function (type, id, callback) { switch (type) { - case 'topic': // just an example... - topics.exists(id, callback); - break; - case 'post': posts.exists(id, callback); break; + + case 'user': + user.exists(id, callback); + break; + + default: + callback(new Error('[[error:invalid-data]]')); + break; } }; @@ -384,6 +412,10 @@ Flags.getTargetUid = function (type, id, callback) { case 'post': posts.getPostField(id, 'uid', callback); break; + + case 'user': + setImmediate(callback, null, id); + break; } }; @@ -553,7 +585,7 @@ Flags.notify = function (flagObj, uid, callback) { notifications.create({ bodyShort: '[[notifications:user_flagged_post_in, ' + flagObj.reporter.username + ', ' + titleEscaped + ']]', - bodyLong: results.post.content, + bodyLong: flagObj.description, pid: flagObj.targetId, path: '/post/' + flagObj.targetId, nid: 'flag:post:' + flagObj.targetId + ':uid:' + uid, @@ -570,6 +602,36 @@ Flags.notify = function (flagObj, uid, callback) { }); }); break; + + case 'user': + async.parallel({ + admins: async.apply(groups.getMembers, 'administrators', 0, -1), + globalMods: async.apply(groups.getMembers, 'Global Moderators', 0, -1), + }, function (err, results) { + if (err) { + return callback(err); + } + + notifications.create({ + bodyShort: '[[notifications:user_flagged_user, ' + flagObj.reporter.username + ', ' + flagObj.target.username + ']]', + bodyLong: flagObj.description, + path: '/uid/' + flagObj.targetId, + nid: 'flag:user:' + flagObj.targetId + ':uid:' + uid, + from: uid, + mergeId: 'notifications:user_flagged_user|' + flagObj.targetId + }, function (err, notification) { + if (err || !notification) { + return callback(err); + } + + notifications.push(notification, results.admins.concat(results.globalMods), callback); + }); + }); + break; + + default: + callback(new Error('[[error:invalid-data]]')); + break; } }; diff --git a/src/meta/js.js b/src/meta/js.js index 626fa0ecd8..947550f37e 100644 --- a/src/meta/js.js +++ b/src/meta/js.js @@ -49,7 +49,6 @@ module.exports = function (Meta) { 'public/src/client/unread.js', 'public/src/client/topic.js', 'public/src/client/topic/events.js', - 'public/src/client/topic/flag.js', 'public/src/client/topic/fork.js', 'public/src/client/topic/move.js', 'public/src/client/topic/posts.js', @@ -72,7 +71,8 @@ module.exports = function (Meta) { 'public/src/modules/taskbar.js', 'public/src/modules/helpers.js', 'public/src/modules/sounds.js', - 'public/src/modules/string.js' + 'public/src/modules/string.js', + 'public/src/modules/flags.js' ], // modules listed below are routed through express (/src/modules) so they can be defined anonymously diff --git a/src/notifications.js b/src/notifications.js index b99700be01..0fb1e1ace3 100644 --- a/src/notifications.js +++ b/src/notifications.js @@ -415,6 +415,7 @@ var utils = require('../public/src/utils'); 'notifications:user_started_following_you', 'notifications:user_posted_to', 'notifications:user_flagged_post_in', + 'notifications:user_flagged_user', 'new_register' ], isolated, differentiators, differentiator, modifyIndex, set; @@ -462,6 +463,7 @@ var utils = require('../public/src/utils'); case 'notifications:user_started_following_you': case 'notifications:user_posted_to': case 'notifications:user_flagged_post_in': + case 'notifications:user_flagged_user': var usernames = set.map(function (notifObj) { return notifObj && notifObj.user && notifObj.user.username; }).filter(function (username, idx, array) { diff --git a/test/flags.js b/test/flags.js index ee735afd60..14bb1e65c6 100644 --- a/test/flags.js +++ b/test/flags.js @@ -32,6 +32,11 @@ describe('Flags', function () { content: 'This is flaggable content' }, next); }); + }, + function (topicData, next) { + User.create({ + username: 'testUser2', password: 'abcdef', email: 'c@d.com' + }, next); } ], done); }); @@ -212,7 +217,7 @@ describe('Flags', function () { Flags.validate({ type: 'post', id: 1, - uid: 1 + uid: 2 }, function (err) { assert.ok(err); assert.strictEqual('[[error:not-enough-reputation-to-flag]]', err.message); @@ -305,6 +310,10 @@ describe('Flags', function () { assert.ifError(err); Flags.getHistory(1, function (err, history) { + if (err) { + throw err; + } + assert.strictEqual(entries + 1, history.length); done(); }); @@ -315,6 +324,7 @@ describe('Flags', function () { describe('.getHistory()', function () { it('should retrieve a flag\'s history', function (done) { Flags.getHistory(1, function (err, history) { + assert.ifError(err); assert.strictEqual(history[0].fields[0].value, '[[flags:state-rejected]]'); done(); }); From a5fb4825b4c92a481e7da04246cb307b56f40bd0 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Wed, 7 Dec 2016 13:06:55 -0500 Subject: [PATCH 27/48] deprecating old hook for #5232 --- public/language/en-GB/pages.json | 2 +- src/flags.js | 7 ++++++- src/plugins/hooks.js | 3 ++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/public/language/en-GB/pages.json b/public/language/en-GB/pages.json index 801b28edea..104e2249f2 100644 --- a/public/language/en-GB/pages.json +++ b/public/language/en-GB/pages.json @@ -6,7 +6,7 @@ "popular-month": "Popular topics this month", "popular-alltime": "All time popular topics", "recent": "Recent Topics", - "flagged-posts": "Flagged Posts", + "flagged-content": "Flagged Content", "ip-blacklist": "IP Blacklist", "users/online": "Online Users", diff --git a/src/flags.js b/src/flags.js index 9f121f81bf..bfbe80992a 100644 --- a/src/flags.js +++ b/src/flags.js @@ -597,7 +597,9 @@ Flags.notify = function (flagObj, uid, callback) { return callback(err); } - plugins.fireHook('action:post.flag', {post: results.post, reason: flagObj.description, flaggingUser: flagObj.reporter}); + plugins.fireHook('action:flag.create', { + flag: flagObj + }); notifications.push(notification, results.admins.concat(results.moderators).concat(results.globalMods), callback); }); }); @@ -624,6 +626,9 @@ Flags.notify = function (flagObj, uid, callback) { return callback(err); } + plugins.fireHook('action:flag.create', { + flag: flagObj + }); notifications.push(notification, results.admins.concat(results.globalMods), callback); }); }); diff --git a/src/plugins/hooks.js b/src/plugins/hooks.js index b8e5af0e46..b5a32ba1b7 100644 --- a/src/plugins/hooks.js +++ b/src/plugins/hooks.js @@ -7,7 +7,8 @@ module.exports = function (Plugins) { Plugins.deprecatedHooks = { 'filter:user.custom_fields': null, // remove in v1.1.0 'filter:post.save': 'filter:post.create', - 'filter:user.profileLinks': 'filter:user.profileMenu' + 'filter:user.profileLinks': 'filter:user.profileMenu', + 'action:post.flag': 'action:flag.create' }; /* `data` is an object consisting of (* is required): From e40eb75f8f66a04811d64943426e1ff18c3f453c Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Wed, 7 Dec 2016 15:13:40 -0500 Subject: [PATCH 28/48] change history saving to append an object not a serialised array, #5232 --- src/flags.js | 41 ++++++++++++++++------------------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/src/flags.js b/src/flags.js index bfbe80992a..1655361b0e 100644 --- a/src/flags.js +++ b/src/flags.js @@ -422,7 +422,6 @@ Flags.getTargetUid = function (type, id, callback) { Flags.update = function (flagId, uid, changeset, callback) { // Retrieve existing flag data to compare for history-saving purposes var fields = ['state', 'assignee']; - var history = []; var tasks = []; var now = Date.now(); @@ -445,9 +444,6 @@ Flags.update = function (flagId, uid, changeset, callback) { tasks.push(async.apply(db.sortedSetAdd.bind(db), 'flags:byAssignee:' + changeset[prop], now, flagId)); break; } - - // Append to history payload - history.push(prop + ':' + changeset[prop]); } } } @@ -460,7 +456,7 @@ Flags.update = function (flagId, uid, changeset, callback) { // Save new object to db (upsert) tasks.push(async.apply(db.setObject, 'flag:' + flagId, changeset)); // Append history - tasks.push(async.apply(Flags.appendHistory, flagId, uid, history)); + tasks.push(async.apply(Flags.appendHistory, flagId, uid, changeset)); async.parallel(tasks, function (err, data) { return next(err); @@ -484,28 +480,21 @@ Flags.getHistory = function (flagId, callback) { uids.push(entry.value[0]); - // Deserialise field object - var fields = entry.value[1].map(function (field) { - field = field.toString().split(':'); - - switch (field[0]) { - case 'state': - field[1] = field[1] === undefined ? null : '[[flags:state-' + field[1] + ']]'; - break; - - default: - field[1] = field[1] === undefined ? null : field[1]; - break; - } - return { - "attribute": field[0], - "value": field[1] - }; - }); + // Deserialise changeset + var changeset = entry.value[1]; + if (changeset.hasOwnProperty('state')) { + changeset.state = changeset.state === undefined ? '' : '[[flags:state-' + changeset.state + ']]'; + } + if (changeset.hasOwnProperty('assignee')) { + changeset.assignee = changeset.assignee || ''; + } + if (changeset.hasOwnProperty('notes')) { + changeset.notes = changeset.notes || ''; + } return { uid: entry.value[0], - fields: fields, + fields: changeset, datetime: entry.score, datetimeISO: new Date(entry.score).toISOString() }; @@ -549,7 +538,9 @@ Flags.appendNote = function (flagId, uid, note, callback) { async.waterfall([ async.apply(db.sortedSetAdd, 'flag:' + flagId + ':notes', Date.now(), payload), - async.apply(Flags.appendHistory, flagId, uid, ['notes']) + async.apply(Flags.appendHistory, flagId, uid, { + notes: null + }) ], callback); }; From 6533fa066d219c05ad295a884d526e8fabf586ce Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Wed, 7 Dec 2016 15:42:47 -0500 Subject: [PATCH 29/48] removed unneeded fixes #5232 --- src/flags.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/flags.js b/src/flags.js index 1655361b0e..aafae593e0 100644 --- a/src/flags.js +++ b/src/flags.js @@ -485,12 +485,6 @@ Flags.getHistory = function (flagId, callback) { if (changeset.hasOwnProperty('state')) { changeset.state = changeset.state === undefined ? '' : '[[flags:state-' + changeset.state + ']]'; } - if (changeset.hasOwnProperty('assignee')) { - changeset.assignee = changeset.assignee || ''; - } - if (changeset.hasOwnProperty('notes')) { - changeset.notes = changeset.notes || ''; - } return { uid: entry.value[0], From 31996f9377a8142ec9be92164905f182ab0bcc45 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Wed, 7 Dec 2016 15:51:05 -0500 Subject: [PATCH 30/48] added page titles --- public/language/en-GB/pages.json | 3 +++ src/controllers/mods.js | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/public/language/en-GB/pages.json b/public/language/en-GB/pages.json index 104e2249f2..5efa686fc3 100644 --- a/public/language/en-GB/pages.json +++ b/public/language/en-GB/pages.json @@ -32,6 +32,9 @@ "chats": "Chats", "chat": "Chatting with %1", + "flags": "Flags", + "flag-details": "Flag %1 Details", + "account/edit": "Editing \"%1\"", "account/edit/password": "Editing password of \"%1\"", "account/edit/username": "Editing username of \"%1\"", diff --git a/src/controllers/mods.js b/src/controllers/mods.js index 656605f277..62f7b6dd07 100644 --- a/src/controllers/mods.js +++ b/src/controllers/mods.js @@ -43,7 +43,8 @@ modsController.flags.list = function (req, res, next) { res.render('flags/list', { flags: flags, hasFilter: !!Object.keys(filters).length, - filters: filters + filters: filters, + title: '[[pages:flags]]' }); }); }); @@ -67,7 +68,8 @@ modsController.flags.detail = function (req, res, next) { type_bool: ['post', 'user'].reduce(function (memo, cur) { memo[cur] = results.flagData.type === cur; return memo; - }, {}) + }, {}), + title: '[[pages:flag-details, ' + req.params.flagId + ']]' })); }); }; From ebc9abd7730bac3366dd4599b5a365c58a7c5697 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Fri, 9 Dec 2016 14:33:59 -0500 Subject: [PATCH 31/48] upgrade script and graphs for #5232 --- public/src/client/flags/list.js | 51 +++++++- src/controllers/mods.js | 9 +- src/flags.js | 49 +++++--- src/upgrade.js | 87 +++++++++++++- src/views/admin/manage/flags.tpl | 196 ------------------------------- 5 files changed, 177 insertions(+), 215 deletions(-) delete mode 100644 src/views/admin/manage/flags.tpl diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js index bf8b4bc1a8..7647e1db6d 100644 --- a/public/src/client/flags/list.js +++ b/public/src/client/flags/list.js @@ -2,12 +2,13 @@ /* globals define */ -define('forum/flags/list', ['components'], function (components) { +define('forum/flags/list', ['components', 'Chart'], function (components, Chart) { var Flags = {}; Flags.init = function () { Flags.enableFilterForm(); Flags.enableChatButtons(); + Flags.handleGraphs(); }; Flags.enableFilterForm = function () { @@ -38,5 +39,53 @@ define('forum/flags/list', ['components'], function (components) { }); }; + Flags.handleGraphs = function () { + var dailyCanvas = document.getElementById('flags:daily'); + var dailyLabels = utils.getDaysArray().map(function (text, idx) { + return idx % 3 ? '' : text; + }); + + if (utils.isMobile()) { + Chart.defaults.global.tooltips.enabled = false; + } + var data = { + 'flags:daily': { + labels: dailyLabels, + datasets: [ + { + label: "", + backgroundColor: "rgba(151,187,205,0.2)", + borderColor: "rgba(151,187,205,1)", + pointBackgroundColor: "rgba(151,187,205,1)", + pointHoverBackgroundColor: "#fff", + pointBorderColor: "#fff", + pointHoverBorderColor: "rgba(151,187,205,1)", + data: ajaxify.data.analytics + } + ] + } + }; + + dailyCanvas.width = $(dailyCanvas).parent().width(); + new Chart(dailyCanvas.getContext('2d'), { + type: 'line', + data: data['flags:daily'], + options: { + responsive: true, + animation: false, + legend: { + display: false + }, + scales: { + yAxes: [{ + ticks: { + beginAtZero: true + } + }] + } + } + }); + }; + return Flags; }); diff --git a/src/controllers/mods.js b/src/controllers/mods.js index 62f7b6dd07..39820e60a6 100644 --- a/src/controllers/mods.js +++ b/src/controllers/mods.js @@ -4,6 +4,7 @@ var async = require('async'); var user = require('../user'); var flags = require('../flags'); +var analytics = require('../analytics'); // var adminFlagsController = require('./admin/flags'); var modsController = { @@ -35,13 +36,17 @@ modsController.flags.list = function (req, res, next) { return memo; }, {}); - flags.list(filters, req.uid, function (err, flags) { + async.parallel({ + flags: async.apply(flags.list, filters, req.uid), + analytics: async.apply(analytics.getDailyStatsForSet, 'analytics:flags', Date.now(), 30) + }, function (err, data) { if (err) { return next(err); } res.render('flags/list', { - flags: flags, + flags: data.flags, + analytics: data.analytics, hasFilter: !!Object.keys(filters).length, filters: filters, title: '[[pages:flags]]' diff --git a/src/flags.js b/src/flags.js index aafae593e0..7c56ba4fb5 100644 --- a/src/flags.js +++ b/src/flags.js @@ -267,8 +267,16 @@ Flags.getNotes = function (flagId, callback) { ], callback); }; -Flags.create = function (type, id, uid, reason, callback) { +Flags.create = function (type, id, uid, reason, timestamp, callback) { var targetUid; + var doHistoryAppend = false; + + // timestamp is optional + if (typeof timestamp === 'function' && !callback) { + callback = timestamp; + timestamp = Date.now(); + doHistoryAppend = true; + } async.waterfall([ function (next) { @@ -302,16 +310,17 @@ Flags.create = function (type, id, uid, reason, callback) { targetId: id, description: reason, uid: uid, - datetime: Date.now() + datetime: timestamp }), - async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', Date.now(), flagId), // by time, the default - async.apply(db.sortedSetAdd.bind(db), 'flags:byReporter:' + uid, Date.now(), flagId), // by reporter - async.apply(db.sortedSetAdd.bind(db), 'flags:byType:' + type, Date.now(), flagId), // by flag type - async.apply(db.setObjectField.bind(db), 'flagHash:flagId', [type, id, uid].join(':'), flagId) // save hash for existence checking + async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', timestamp, flagId), // by time, the default + async.apply(db.sortedSetAdd.bind(db), 'flags:byReporter:' + uid, timestamp, flagId), // by reporter + async.apply(db.sortedSetAdd.bind(db), 'flags:byType:' + type, timestamp, flagId), // by flag type + async.apply(db.setObjectField.bind(db), 'flagHash:flagId', [type, id, uid].join(':'), flagId), // save hash for existence checking + async.apply(analytics.increment, 'flags') // some fancy analytics ]; if (targetUid) { - tasks.push(async.apply(db.sortedSetAdd.bind(db), 'flags:byTargetUid:' + targetUid, Date.now(), flagId)); // by target uid + tasks.push(async.apply(db.sortedSetAdd.bind(db), 'flags:byTargetUid:' + targetUid, timestamp, flagId)); // by target uid } async.parallel(tasks, function (err, data) { @@ -319,7 +328,10 @@ Flags.create = function (type, id, uid, reason, callback) { return next(err); } - Flags.update(flagId, uid, { "state": "open" }); + if (doHistoryAppend) { + Flags.update(flagId, uid, { "state": "open" }); + } + next(null, flagId); }); }, @@ -423,7 +435,7 @@ Flags.update = function (flagId, uid, changeset, callback) { // Retrieve existing flag data to compare for history-saving purposes var fields = ['state', 'assignee']; var tasks = []; - var now = Date.now(); + var now = changeset.datetime || Date.now(); async.waterfall([ async.apply(db.getObjectFields.bind(db), 'flag:' + flagId, fields), @@ -513,16 +525,24 @@ Flags.getHistory = function (flagId, callback) { Flags.appendHistory = function (flagId, uid, changeset, callback) { var payload; + var datetime = changeset.datetime || Date.now(); + delete changeset.datetime; + try { - payload = JSON.stringify([uid, changeset, Date.now()]); + payload = JSON.stringify([uid, changeset, datetime]); } catch (e) { return callback(e); } - db.sortedSetAdd('flag:' + flagId + ':history', Date.now(), payload, callback); + db.sortedSetAdd('flag:' + flagId + ':history', datetime, payload, callback); }; -Flags.appendNote = function (flagId, uid, note, callback) { +Flags.appendNote = function (flagId, uid, note, datetime, callback) { + if (typeof datetime === 'function' && !callback) { + callback = datetime; + datetime = Date.now(); + } + var payload; try { payload = JSON.stringify([uid, note]); @@ -531,9 +551,10 @@ Flags.appendNote = function (flagId, uid, note, callback) { } async.waterfall([ - async.apply(db.sortedSetAdd, 'flag:' + flagId + ':notes', Date.now(), payload), + async.apply(db.sortedSetAdd, 'flag:' + flagId + ':notes', datetime, payload), async.apply(Flags.appendHistory, flagId, uid, { - notes: null + notes: null, + datetime: datetime }) ], callback); }; diff --git a/src/upgrade.js b/src/upgrade.js index 2a605e84f9..29b806d4a7 100644 --- a/src/upgrade.js +++ b/src/upgrade.js @@ -12,7 +12,7 @@ var db = require('./database'), schemaDate, thisSchemaDate, // IMPORTANT: REMEMBER TO UPDATE VALUE OF latestSchema - latestSchema = Date.UTC(2016, 10, 22); + latestSchema = Date.UTC(2016, 11, 7); Upgrade.check = function (callback) { db.get('schemaDate', function (err, value) { @@ -1023,7 +1023,7 @@ Upgrade.upgrade = function (callback) { if (schemaDate < thisSchemaDate) { updatesMade = true; - winston.info('[2016/11/25] Creating sorted sets for pinned topcis'); + winston.info('[2016/11/25] Creating sorted sets for pinned topics'); var topics = require('./topics'); var batch = require('./batch'); @@ -1060,6 +1060,89 @@ Upgrade.upgrade = function (callback) { next(); } }, + function (next) { + thisSchemaDate = Date.UTC(2016, 11, 7); + + if (schemaDate < thisSchemaDate) { + updatesMade = true; + winston.info('[2016/12/07] Migrating flags to new schema (#5232)'); + + var batch = require('./batch'); + var posts = require('./posts'); + var flags = require('./flags'); + var migrated = 0; + + batch.processSortedSet('posts:pid', function (ids, next) { + posts.getPostsByPids(ids, 1, function (err, posts) { + if (err) { + return next(err); + } + + posts = posts.filter(function (post) { + return post.hasOwnProperty('flags'); + }); + + async.each(posts, function (post, next) { + async.parallel({ + uids: async.apply(db.getSortedSetRangeWithScores, 'pid:' + post.pid + ':flag:uids', 0, -1), + reasons: async.apply(db.getSortedSetRange, 'pid:' + post.pid + ':flag:uid:reason', 0, -1) + }, function (err, data) { + if (err) { + return next(err); + } + + // Just take the first entry + var datetime = data.uids[0].score; + var reason = data.reasons[0].split(':')[1]; + var flagObj; + + async.waterfall([ + async.apply(flags.create, 'post', post.pid, data.uids[0].value, reason, datetime), + function (_flagObj, next) { + flagObj = _flagObj; + if (post['flag:state'] || post['flag:assignee']) { + flags.update(flagObj.flagId, 1, { + state: post['flag:state'], + assignee: post['flag:assignee'], + datetime: datetime + }, next); + } else { + setImmediate(next); + } + }, + function (next) { + if (post.hasOwnProperty('flag:notes') && post['flag:notes'].length) { + try { + var history = JSON.parse(post['flag:history']); + history = history.filter(function (event) { + return event.type === 'notes'; + })[0]; + + flags.appendNote(flagObj.flagId, history.uid, post['flag:notes'], history.timestamp, next); + } catch (e) { + next(e); + } + } else { + setImmediate(next); + } + } + ], next); + }); + }, next); + }); + }, function (err) { + if (err) { + return next(err); + } + + winston.info('[2016/12/07] Migrating flags to new schema (#5232) - done'); + Upgrade.update(thisSchemaDate, next); + }); + } else { + winston.info('[2016/12/07] Migrating flags to new schema (#5232) - skipped!'); + next(); + } + } // Add new schema updates here // IMPORTANT: REMEMBER TO UPDATE VALUE OF latestSchema IN LINE 24!!! ], function (err) { diff --git a/src/views/admin/manage/flags.tpl b/src/views/admin/manage/flags.tpl deleted file mode 100644 index a14348ba42..0000000000 --- a/src/views/admin/manage/flags.tpl +++ /dev/null @@ -1,196 +0,0 @@ -<div class="flags"> - - <div class="col-lg-12"> - - <div class="text-center"> - <div class="panel panel-default"> - <div class="panel-body"> - <div><canvas id="flags:daily" height="250"></canvas></div> - <p> - - </p> - </div> - <div class="panel-footer"><small>Daily flags</small></div> - </div> - </div> - - <form id="flag-search" method="GET" action="flags"> - <div class="form-group"> - <div> - <div> - <label>Flags by user</label> - <input type="text" class="form-control" id="byUsername" placeholder="Search flagged posts by username" name="byUsername" value="{byUsername}"> - </div> - </div> - </div> - - <div class="form-group"> - <div> - <div> - <label>Category</label> - <select class="form-control" id="category-selector" name="cid"> - <option value="">[[unread:all_categories]]</option> - <!-- BEGIN categories --> - <option value="{categories.cid}" <!-- IF categories.selected -->selected<!-- ENDIF categories.selected -->>{categories.text}</option> - <!-- END categories --> - </select> - </div> - </div> - </div> - - <div class="form-group"> - <label>Sort By</label> - <div> - <div> - <select id="flag-sort-by" class="form-control" name="sortBy"> - <option value="count" <!-- IF sortByCount -->selected<!-- ENDIF sortByCount -->>Most Flags</option> - <option value="time" <!-- IF sortByTime -->selected<!-- ENDIF sortByTime -->>Most Recent</option> - </select> - </div> - </div> - </div> - - <button type="submit" class="btn btn-primary">Search</button> - <button class="btn btn-primary" id="dismissAll">Dismiss All</button> - </form> - - <hr/> - - <div data-next="{next}"> - - <div component="posts/flags" class="panel-group post-container" id="accordion" role="tablist" aria-multiselectable="true" data-next="{next}"> - <!-- IF !posts.length --> - <div class="alert alert-success"> - No flagged posts! - </div> - <!-- ENDIF !posts.length --> - - <!-- BEGIN posts --> - <div class="panel panel-default" component="posts/flag" data-pid="{../pid}"> - <div class="panel-heading" role="tab"> - <h4 class="panel-title"> - <a role="button" data-toggle="collapse" data-parent="#accordion" href="#flag-pid-{posts.pid}" aria-expanded="true" aria-controls="flag-pid-{posts.pid}"> - <!-- IF ../flagData.assignee --> - <div class="pull-right"> - <!-- IF ../flagData.assigneeUser.picture --> - <img class="avatar avatar-xs" title="{../flagData.assigneeUser.username}" src="{../flagData.assigneeUser.picture}"> - <!-- ELSE --> - <div class="avatar avatar-xs" title="{../flagData.assigneeUser.username}" style="background-color: {../flagData.assigneeUser.icon:bgColor};">{../flagData.assigneeUser.icon:text}</div> - <!-- ENDIF ../flagData.assigneeUser.picture --> - </div> - <!-- ENDIF ../flagData.assignee --> - <span class="label <!-- IF ../flagData.labelClass -->label-{../flagData.labelClass}<!-- ELSE -->label-info<!-- ENDIF ../flagData.labelClass -->">[[topic:flag_manage_state_<!-- IF ../flagData.state -->{../flagData.state}<!-- ELSE -->open<!-- ENDIF ../flagData.state -->]]</span> - [[topic:flag_manage_title, {posts.category.name}]] - <small><span class="timeago" title="{posts.timestampISO}"></span></small> - </a> - </h4> - </div> - <div id="flag-pid-{posts.pid}" class="panel-collapse collapse" role="tabpanel"> - <div class="panel-body"> - <div class="row" data-pid="{posts.pid}" data-tid="{posts.topic.tid}"> - <div class="col-sm-8"> - <div class="well flag-post-body"> - <a href="{config.relative_path}/user/{../user.userslug}"> - <!-- IF ../user.picture --> - <img title="{posts.user.username}" src="{../user.picture}"> - <!-- ELSE --> - <div title="{posts.user.username}" class="user-icon" style="background-color: {../user.icon:bgColor};">{../user.icon:text}</div> - <!-- ENDIF ../user.picture --> - </a> - - <a href="{config.relative_path}/user/{../user.userslug}"> - <strong><span>{../user.username}</span></strong> - </a> - <div class="content"> - <p>{posts.content}</p> - </div> - <small> - <span class="pull-right"> - Posted in <a href="{config.relative_path}/category/{posts.category.slug}" target="_blank"><i class="fa {posts.category.icon}"></i> {posts.category.name}</a>, <span class="timeago" title="{posts.timestampISO}"></span> • - <a href="{config.relative_path}/post/{posts.pid}" target="_blank">Read More</a> - </span> - </small> - </div> - </div> - <div class="col-sm-4"> - <i class="fa fa-flag"></i> This post has been flagged {posts.flags} time(s): - <blockquote class="flag-reporters"> - <ul> - <!-- BEGIN posts.flagReasons --> - <li> - <a target="_blank" href="{config.relative_path}/user/{../user.userslug}"> - <!-- IF ../user.picture --> - <img src="{../user.picture}" /> - <!-- ELSE --> - <div class="user-icon" style="background-color: {../user.icon:bgColor};">{../user.icon:text}</div> - <!-- ENDIF ../user.picture --> - {../user.username} - </a>: "{posts.flagReasons.reason}" - </li> - <!-- END posts.flagReasons --> - </ul> - </blockquote> - <div class="btn-group"> - <button class="btn btn-sm btn-success dismiss">Dismiss this Flag</button> - <button class="btn btn-sm btn-danger delete">Delete the Post</button> - </div> - </div> - </div> - <hr /> - <div class="row"> - <div class="col-sm-6"> - <form role="form"> - <div class="form-group"> - <label for="{posts.pid}-assignee">[[topic:flag_manage_assignee]]</label> - <select class="form-control" id="{posts.pid}-assignee" name="assignee"> - <!-- BEGIN assignees --> - <option value="{assignees.uid}">{assignees.username}</option> - <!-- END assignees --> - </select> - </div> - <div class="form-group"> - <label for="{posts.pid}-state">[[topic:flag_manage_state]]</label> - <select class="form-control" id="{posts.pid}-state" name="state"> - <option value="open">[[topic:flag_manage_state_open]]</option> - <option value="wip">[[topic:flag_manage_state_wip]]</option> - <option value="resolved">[[topic:flag_manage_state_resolved]]</option> - <option value="rejected">[[topic:flag_manage_state_rejected]]</option> - </select> - </div> - <div class="form-group"> - <label for="{posts.pid}-notes">[[topic:flag_manage_notes]]</label> - <textarea class="form-control" id="{posts.pid}-notes" name="notes"></textarea> - </div> - <button type="button" component="posts/flag/update" class="btn btn-sm btn-primary btn-block">[[topic:flag_manage_update]]</button> - </form> - </div> - <div class="col-sm-6"> - <h5>[[topic:flag_manage_history]]</h5> - <!-- IF !posts.flagData.history.length --> - <div class="alert alert-info">[[topic:flag_manage_no_history]]</div> - <!-- ELSE --> - <ul class="list-group" component="posts/flag/history"> - <!-- BEGIN posts.flagData.history --> - <li class="list-group-item"> - <div class="pull-right"><small><span class="timeago" title="{posts.flagData.history.timestampISO}"></span></small></div> - <!-- IF ../user.picture --> - <img class="avatar avatar-sm avatar-rounded" src="{../user.picture}" title="{../user.username}" /> - <!-- ELSE --> - <div class="avatar avatar-sm avatar-rounded" style="background-color: {../user.icon:bgColor};" title="{../user.username}">{../user.icon:text}</div> - <!-- ENDIF ../user.picture --> - [[topic:flag_manage_history_{posts.flagData.history.type}, {posts.flagData.history.label}]] - </li> - <!-- END posts.flagData.history --> - </ul> - <!-- ENDIF !posts.flagData.history.length --> - </div> - </div> - </div> - </div> - </div> - <!-- END posts --> - <!-- IMPORT partials/paginator.tpl --> - </div> - </div> - </div> -</div> From 9ada35cfb982ca332e9c097e06588bb0e986046f Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Fri, 9 Dec 2016 14:39:31 -0500 Subject: [PATCH 32/48] allowing Analytics.increment to have a callback --- src/analytics.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/analytics.js b/src/analytics.js index 6b248057da..9151bd6ce7 100644 --- a/src/analytics.js +++ b/src/analytics.js @@ -20,13 +20,17 @@ new cronJob('*/10 * * * *', function () { Analytics.writeData(); }, null, true); -Analytics.increment = function (keys) { +Analytics.increment = function (keys, callback) { keys = Array.isArray(keys) ? keys : [keys]; keys.forEach(function (key) { counters[key] = counters[key] || 0; ++counters[key]; }); + + if (typeof callback === 'function') { + callback(); + } }; Analytics.pageView = function (payload) { From 47530423066c778983d7d00caa0073a1fb72d5c9 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Fri, 9 Dec 2016 14:53:49 -0500 Subject: [PATCH 33/48] lowered analytics disk writes to every ten seconds, because why every 10 minutes? :shipit: --- src/analytics.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/analytics.js b/src/analytics.js index 9151bd6ce7..c6cfbeba7e 100644 --- a/src/analytics.js +++ b/src/analytics.js @@ -16,7 +16,7 @@ var uniquevisitors = 0; var isCategory = /^(?:\/api)?\/category\/(\d+)/; -new cronJob('*/10 * * * *', function () { +new cronJob('*/10 * * * * *', function () { Analytics.writeData(); }, null, true); From aaec71bc0ca1d3613a07f6906b8d951ae4e60fab Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Fri, 9 Dec 2016 14:59:23 -0500 Subject: [PATCH 34/48] added stepSize to flags chart, #5232 --- public/src/client/flags/list.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js index 7647e1db6d..255c7176f1 100644 --- a/public/src/client/flags/list.js +++ b/public/src/client/flags/list.js @@ -79,7 +79,8 @@ define('forum/flags/list', ['components', 'Chart'], function (components, Chart) scales: { yAxes: [{ ticks: { - beginAtZero: true + beginAtZero: true, + stepSize: 1 } }] } From 01969970995810602d26ff25ebc9ea8e38760a3e Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Tue, 13 Dec 2016 09:36:46 -0500 Subject: [PATCH 35/48] fixing issues as found by @barisusakli re: #5232 and #5282 --- src/flags.js | 89 +++++++--------------------------------------------- 1 file changed, 11 insertions(+), 78 deletions(-) diff --git a/src/flags.js b/src/flags.js index 7c56ba4fb5..1fb2986d2b 100644 --- a/src/flags.js +++ b/src/flags.js @@ -139,13 +139,7 @@ Flags.list = function (filters, uid, callback) { }); }, next); } - ], function (err, flags) { - if (err) { - return callback(err); - } - - return callback(null, flags); - }); + ], callback); }; Flags.validate = function (payload, callback) { @@ -166,16 +160,14 @@ Flags.validate = function (payload, callback) { switch (payload.type) { case 'post': - async.parallel({ - editable: async.apply(privileges.posts.canEdit, payload.id, payload.uid) - }, function (err, subdata) { + privileges.posts.canEdit(payload.id, payload.uid, function (err, editable) { if (err) { return callback(err); } var minimumReputation = utils.isNumber(meta.config['privileges:flag']) ? parseInt(meta.config['privileges:flag'], 10) : 1; // Check if reporter meets rep threshold (or can edit the target post, in which case threshold does not apply) - if (!subdata.editable.flag && parseInt(data.reporter.reputation, 10) < minimumReputation) { + if (!editable.flag && parseInt(data.reporter.reputation, 10) < minimumReputation) { return callback(new Error('[[error:not-enough-reputation-to-flag]]')); } @@ -184,17 +176,14 @@ Flags.validate = function (payload, callback) { break; case 'user': - async.parallel({ - editable: async.apply(privileges.users.canEdit, payload.uid, payload.id) - }, function (err, subdata) { + privileges.users.canEdit(payload.uid, payload.id, function (err, editable) { if (err) { return callback(err); } - var minimumReputation = utils.isNumber(meta.config['privileges:flag']) ? parseInt(meta.config['privileges:flag'], 10) : 1; // Check if reporter meets rep threshold (or can edit the target user, in which case threshold does not apply) - if (!subdata.editable && parseInt(data.reporter.reputation, 10) < minimumReputation) { + if (!editable && parseInt(data.reporter.reputation, 10) < minimumReputation) { return callback(new Error('[[error:not-enough-reputation-to-flag]]')); } @@ -227,6 +216,10 @@ Flags.getTarget = function (type, id, uid, callback) { callback(err, users ? users[0] : undefined); }); break; + + default: + callback(new Error('[[error:invalid-data]]')); + break; } }; @@ -315,7 +308,7 @@ Flags.create = function (type, id, uid, reason, timestamp, callback) { async.apply(db.sortedSetAdd.bind(db), 'flags:datetime', timestamp, flagId), // by time, the default async.apply(db.sortedSetAdd.bind(db), 'flags:byReporter:' + uid, timestamp, flagId), // by reporter async.apply(db.sortedSetAdd.bind(db), 'flags:byType:' + type, timestamp, flagId), // by flag type - async.apply(db.setObjectField.bind(db), 'flagHash:flagId', [type, id, uid].join(':'), flagId), // save hash for existence checking + async.apply(db.sortedSetAdd.bind(db), 'flags:hash', flagId, [type, id, uid].join(':')), // save zset for duplicate checking async.apply(analytics.increment, 'flags') // some fancy analytics ]; @@ -337,70 +330,10 @@ Flags.create = function (type, id, uid, reason, timestamp, callback) { }, async.apply(Flags.get) ], callback); - // if (!parseInt(uid, 10) || !reason) { - // return callback(); - // } - - // async.waterfall([ - // function (next) { - // async.parallel({ - // hasFlagged: async.apply(Flags.isFlaggedByUser, post.pid, uid), - // exists: async.apply(Posts.exists, post.pid) - // }, next); - // }, - // function (results, next) { - // if (!results.exists) { - // return next(new Error('[[error:no-post]]')); - // } - - // 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 (data, next) { - // openNewFlag(post.pid, uid, next); // removed, used to just update flag to open state if new flag - // } - // ], function (err) { - // if (err) { - // return callback(err); - // } - // analytics.increment('flags'); - // callback(); - // }); }; Flags.exists = function (type, id, uid, callback) { - db.isObjectField('flagHash:flagId', [type, id, uid].join(':'), callback); + db.isSortedSetMember('flags:hash', [type, id, uid].join(':'), callback); }; Flags.targetExists = function (type, id, callback) { From 831c2064a0435eb07c5bb3d010753dc0416e6166 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Tue, 13 Dec 2016 12:11:51 -0500 Subject: [PATCH 36/48] For #5232, added tests and returning flag data on socket flag creation --- src/flags.js | 4 + src/socket.io/flags.js | 12 +- test/flags.js | 612 +++++++++++++++++++++++------------------ 3 files changed, 358 insertions(+), 270 deletions(-) diff --git a/src/flags.js b/src/flags.js index 1fb2986d2b..1ae5a84ed8 100644 --- a/src/flags.js +++ b/src/flags.js @@ -494,6 +494,10 @@ Flags.appendNote = function (flagId, uid, note, datetime, callback) { Flags.notify = function (flagObj, uid, callback) { // Notify administrators, mods, and other associated people + if (!callback) { + callback = function () {}; + } + switch (flagObj.type) { case 'post': async.parallel({ diff --git a/src/socket.io/flags.js b/src/socket.io/flags.js index ce148faa87..c25bd662fa 100644 --- a/src/socket.io/flags.js +++ b/src/socket.io/flags.js @@ -34,11 +34,15 @@ SocketFlags.create = function (socket, data, callback) { function (next) { // If we got here, then no errors occurred flags.create(data.type, data.id, socket.uid, data.reason, next); - }, - function (flagObj, next) { - flags.notify(flagObj, socket.uid, next); } - ], callback); + ], function (err, flagObj) { + if (err) { + return callback(err); + } + + flags.notify(flagObj, socket.uid); + callback(null, flagObj); + }); }; SocketFlags.update = function (socket, data, callback) { diff --git a/test/flags.js b/test/flags.js index 14bb1e65c6..8d359c6930 100644 --- a/test/flags.js +++ b/test/flags.js @@ -10,6 +10,7 @@ var Categories = require('../src/categories'); var Topics = require('../src/topics'); var Posts = require('../src/posts'); var User = require('../src/user'); +var Groups = require('../src/groups'); var Meta = require('../src/meta'); describe('Flags', function () { @@ -37,300 +38,379 @@ describe('Flags', function () { User.create({ username: 'testUser2', password: 'abcdef', email: 'c@d.com' }, next); + }, + function (uid, next) { + Groups.join('administrators', uid, next); } ], done); }); - describe('.create()', function () { - it('should create a flag and return its data', function (done) { - Flags.create('post', 1, 1, 'Test flag', function (err, flagData) { - assert.ifError(err); - var compare = { - flagId: 1, - uid: 1, - targetId: 1, - type: 'post', - description: 'Test flag' - }; - - for(var key in compare) { - if (compare.hasOwnProperty(key)) { - assert.ok(flagData[key]); - assert.strictEqual(flagData[key], compare[key]); - } - } - - done(); - }); - }); - }); - - describe('.get()', function () { - it('should retrieve and display a flag\'s data', function (done) { - Flags.get(1, function (err, flagData) { - assert.ifError(err); - var compare = { - flagId: 1, - uid: 1, - targetId: 1, - type: 'post', - description: 'Test flag', - state: 'open' - }; - - for(var key in compare) { - if (compare.hasOwnProperty(key)) { - assert.ok(flagData[key]); - assert.strictEqual(flagData[key], compare[key]); - } - } - - done(); - }); - }); - }); - - describe('.list()', function () { - it('should show a list of flags (with one item)', function (done) { - Flags.list({}, 1, function (err, flags) { - assert.ifError(err); - assert.ok(Array.isArray(flags)); - assert.equal(flags.length, 1); + // describe('.create()', function () { + // it('should create a flag and return its data', function (done) { + // Flags.create('post', 1, 1, 'Test flag', function (err, flagData) { + // assert.ifError(err); + // var compare = { + // flagId: 1, + // uid: 1, + // targetId: 1, + // type: 'post', + // description: 'Test flag' + // }; + + // for(var key in compare) { + // if (compare.hasOwnProperty(key)) { + // assert.ok(flagData[key]); + // assert.strictEqual(flagData[key], compare[key]); + // } + // } + + // done(); + // }); + // }); + // }); + + // describe('.get()', function () { + // it('should retrieve and display a flag\'s data', function (done) { + // Flags.get(1, function (err, flagData) { + // assert.ifError(err); + // var compare = { + // flagId: 1, + // uid: 1, + // targetId: 1, + // type: 'post', + // description: 'Test flag', + // state: 'open' + // }; + + // for(var key in compare) { + // if (compare.hasOwnProperty(key)) { + // assert.ok(flagData[key]); + // assert.strictEqual(flagData[key], compare[key]); + // } + // } + + // done(); + // }); + // }); + // }); + + // describe('.list()', function () { + // it('should show a list of flags (with one item)', function (done) { + // Flags.list({}, 1, function (err, flags) { + // assert.ifError(err); + // assert.ok(Array.isArray(flags)); + // assert.equal(flags.length, 1); - Flags.get(flags[0].flagId, function (err, flagData) { - assert.ifError(err); - assert.equal(flags[0].flagId, flagData.flagId); - assert.equal(flags[0].description, flagData.description); - done(); - }); - }); - }); - }); - - describe('.update()', function () { - it('should alter a flag\'s various attributes and persist them to the database', function (done) { - Flags.update(1, 1, { - "state": "wip", - "assignee": 1 - }, function (err) { - assert.ifError(err); - db.getObjectFields('flag:1', ['state', 'assignee'], function (err, data) { - if (err) { - throw err; - } - - assert.strictEqual('wip', data.state); - assert.strictEqual(1, data.assignee); - done(); - }); - }); - }); - - it('should persist to the flag\'s history', function (done) { - Flags.getHistory(1, function (err, history) { - if (err) { - throw err; - } - - history.forEach(function (change) { - switch (change.attribute) { - case 'state': - assert.strictEqual('[[flags:state-wip]]', change.value); - break; + // Flags.get(flags[0].flagId, function (err, flagData) { + // assert.ifError(err); + // assert.equal(flags[0].flagId, flagData.flagId); + // assert.equal(flags[0].description, flagData.description); + // done(); + // }); + // }); + // }); + // }); + + // describe('.update()', function () { + // it('should alter a flag\'s various attributes and persist them to the database', function (done) { + // Flags.update(1, 1, { + // "state": "wip", + // "assignee": 1 + // }, function (err) { + // assert.ifError(err); + // db.getObjectFields('flag:1', ['state', 'assignee'], function (err, data) { + // if (err) { + // throw err; + // } + + // assert.strictEqual('wip', data.state); + // assert.strictEqual(1, data.assignee); + // done(); + // }); + // }); + // }); + + // it('should persist to the flag\'s history', function (done) { + // Flags.getHistory(1, function (err, history) { + // if (err) { + // throw err; + // } + + // history.forEach(function (change) { + // switch (change.attribute) { + // case 'state': + // assert.strictEqual('[[flags:state-wip]]', change.value); + // break; - case 'assignee': - assert.strictEqual(1, change.value); - break; - } - }); - - done(); - }); - }); - }); - - describe('.getTarget()', function () { - it('should return a post\'s data if queried with type "post"', function (done) { - Flags.getTarget('post', 1, 1, function (err, data) { - assert.ifError(err); - var compare = { - uid: 1, - pid: 1, - content: 'This is flaggable content' - }; - - for(var key in compare) { - if (compare.hasOwnProperty(key)) { - assert.ok(data[key]); - assert.strictEqual(data[key], compare[key]); - } - } - - done(); - }); - }); + // case 'assignee': + // assert.strictEqual(1, change.value); + // break; + // } + // }); + + // done(); + // }); + // }); + // }); + + // describe('.getTarget()', function () { + // it('should return a post\'s data if queried with type "post"', function (done) { + // Flags.getTarget('post', 1, 1, function (err, data) { + // assert.ifError(err); + // var compare = { + // uid: 1, + // pid: 1, + // content: 'This is flaggable content' + // }; + + // for(var key in compare) { + // if (compare.hasOwnProperty(key)) { + // assert.ok(data[key]); + // assert.strictEqual(data[key], compare[key]); + // } + // } + + // done(); + // }); + // }); + + // it('should return a user\'s data if queried with type "user"', function (done) { + // Flags.getTarget('user', 1, 1, function (err, data) { + // assert.ifError(err); + // var compare = { + // uid: 1, + // username: 'testUser', + // email: 'b@c.com' + // }; + + // for(var key in compare) { + // if (compare.hasOwnProperty(key)) { + // assert.ok(data[key]); + // assert.strictEqual(data[key], compare[key]); + // } + // } + + // done(); + // }); + // }); + // }); + + // describe('.validate()', function () { + // it('should error out if type is post and post is deleted', function (done) { + // Posts.delete(1, 1, function (err) { + // if (err) { + // throw err; + // } + + // Flags.validate({ + // type: 'post', + // id: 1, + // uid: 1 + // }, function (err) { + // assert.ok(err); + // assert.strictEqual('[[error:post-deleted]]', err.message); + // Posts.restore(1, 1, done); + // }); + // }); + // }); + + // it('should not pass validation if flag threshold is set and user rep does not meet it', function (done) { + // Meta.configs.set('privileges:flag', '50', function (err) { + // assert.ifError(err); + + // Flags.validate({ + // type: 'post', + // id: 1, + // uid: 2 + // }, function (err) { + // assert.ok(err); + // assert.strictEqual('[[error:not-enough-reputation-to-flag]]', err.message); + // Meta.configs.set('privileges:flag', 0, done); + // }); + // }); + // }); + // }); + + // describe('.appendNote()', function () { + // it('should add a note to a flag', function (done) { + // Flags.appendNote(1, 1, 'this is my note', function (err) { + // assert.ifError(err); + + // db.getSortedSetRange('flag:1:notes', 0, -1, function (err, notes) { + // if (err) { + // throw err; + // } + + // assert.strictEqual('[1,"this is my note"]', notes[0]); + // done(); + // }); + // }); + // }); + + // it('should be a JSON string', function (done) { + // db.getSortedSetRange('flag:1:notes', 0, -1, function (err, notes) { + // if (err) { + // throw err; + // } + + // try { + // JSON.parse(notes[0]); + // } catch (e) { + // assert.ifError(e); + // } + + // done(); + // }); + // }); + // }); + + // describe('.getNotes()', function () { + // before(function (done) { + // // Add a second note + // Flags.appendNote(1, 1, 'this is the second note', done); + // }); + + // it('return should match a predefined spec', function (done) { + // Flags.getNotes(1, function (err, notes) { + // assert.ifError(err); + // var compare = { + // uid: 1, + // content: 'this is my note' + // }; + + // var data = notes[1]; + // for(var key in compare) { + // if (compare.hasOwnProperty(key)) { + // assert.ok(data[key]); + // assert.strictEqual(data[key], compare[key]); + // } + // } + + // done(); + // }); + // }); + + // it('should retrieve a list of notes, from newest to oldest', function (done) { + // Flags.getNotes(1, function (err, notes) { + // assert.ifError(err); + // assert(notes[0].datetime > notes[1].datetime); + // assert.strictEqual('this is the second note', notes[0].content); + // done(); + // }); + // }); + // }); + + // describe('.appendHistory()', function () { + // var entries; + // before(function (done) { + // db.sortedSetCard('flag:1:history', function (err, count) { + // entries = count; + // done(err); + // }); + // }); + + // it('should add a new entry into a flag\'s history', function (done) { + // Flags.appendHistory(1, 1, ['state:rejected'], function (err) { + // assert.ifError(err); + + // Flags.getHistory(1, function (err, history) { + // if (err) { + // throw err; + // } + + // assert.strictEqual(entries + 1, history.length); + // done(); + // }); + // }); + // }); + // }); + + // describe('.getHistory()', function () { + // it('should retrieve a flag\'s history', function (done) { + // Flags.getHistory(1, function (err, history) { + // assert.ifError(err); + // assert.strictEqual(history[0].fields[0].value, '[[flags:state-rejected]]'); + // done(); + // }); + // }); + // }); + + describe('(websockets)', function () { + var SocketFlags = require('../src/socket.io/flags.js'); + var tid, pid, flag; - it('should return a user\'s data if queried with type "user"', function (done) { - Flags.getTarget('user', 1, 1, function (err, data) { - assert.ifError(err); - var compare = { - uid: 1, - username: 'testUser', - email: 'b@c.com' - }; - - for(var key in compare) { - if (compare.hasOwnProperty(key)) { - assert.ok(data[key]); - assert.strictEqual(data[key], compare[key]); - } - } + before(function (done) { + Topics.post({ + cid: 1, + uid: 1, + title: 'Another topic', + content: 'This is flaggable content' + }, function (err, topic) { + tid = topic.postData.tid; + pid = topic.postData.pid; - done(); + done(err); }); }); - }); - - describe('.validate()', function () { - it('should error out if type is post and post is deleted', function (done) { - Posts.delete(1, 1, function (err) { - if (err) { - throw err; - } - Flags.validate({ + describe('.create()', function () { + it('should create a flag with no errors', function (done) { + SocketFlags.create({ uid: 2 }, { type: 'post', - id: 1, - uid: 1 - }, function (err) { - assert.ok(err); - assert.strictEqual('[[error:post-deleted]]', err.message); - Posts.restore(1, 1, done); - }); - }); - }); - - it('should not pass validation if flag threshold is set and user rep does not meet it', function (done) { - Meta.configs.set('privileges:flag', '50', function (err) { - assert.ifError(err); + id: pid, + reason: 'foobar' + }, function (err, flagObj) { + flag = flagObj; + assert.ifError(err); - Flags.validate({ - type: 'post', - id: 1, - uid: 2 - }, function (err) { - assert.ok(err); - assert.strictEqual('[[error:not-enough-reputation-to-flag]]', err.message); - Meta.configs.set('privileges:flag', 0, done); + Flags.exists('post', pid, 1, function (err, exists) { + assert.ifError(err); + assert(true); + done(); + }); }); }); }); - }); - - describe('.appendNote()', function () { - it('should add a note to a flag', function (done) { - Flags.appendNote(1, 1, 'this is my note', function (err) { - assert.ifError(err); - - db.getSortedSetRange('flag:1:notes', 0, -1, function (err, notes) { - if (err) { - throw err; - } - assert.strictEqual('[1,"this is my note"]', notes[0]); + describe('.update()', function () { + it('should update a flag\'s properties', function (done) { + SocketFlags.update({ uid: 2 }, { + flagId: flag.flagId, + data: [{ + name: 'state', + value: 'wip' + }] + }, function (err, history) { + assert.ifError(err); + assert(Array.isArray(history)); + assert(history[0].fields.hasOwnProperty('state')); + assert.strictEqual('[[flags:state-wip]]', history[0].fields.state); done(); }); }); }); - it('should be a JSON string', function (done) { - db.getSortedSetRange('flag:1:notes', 0, -1, function (err, notes) { - if (err) { - throw err; - } - - try { - JSON.parse(notes[0]); - } catch (e) { - assert.ifError(e); - } - - done(); - }); - }); - }); - - describe('.getNotes()', function () { - before(function (done) { - // Add a second note - Flags.appendNote(1, 1, 'this is the second note', done); - }); - - it('return should match a predefined spec', function (done) { - Flags.getNotes(1, function (err, notes) { - assert.ifError(err); - var compare = { - uid: 1, - content: 'this is my note' - }; - - var data = notes[1]; - for(var key in compare) { - if (compare.hasOwnProperty(key)) { - assert.ok(data[key]); - assert.strictEqual(data[key], compare[key]); - } - } - - done(); - }); - }); - - it('should retrieve a list of notes, from newest to oldest', function (done) { - Flags.getNotes(1, function (err, notes) { - assert.ifError(err); - assert(notes[0].datetime > notes[1].datetime); - assert.strictEqual('this is the second note', notes[0].content); - done(); - }); - }); - }); - - describe('.appendHistory()', function () { - var entries; - before(function (done) { - db.sortedSetCard('flag:1:history', function (err, count) { - entries = count; - done(err); - }); - }); - - it('should add a new entry into a flag\'s history', function (done) { - Flags.appendHistory(1, 1, ['state:rejected'], function (err) { - assert.ifError(err); - - Flags.getHistory(1, function (err, history) { - if (err) { - throw err; - } - - assert.strictEqual(entries + 1, history.length); + describe('.appendNote()', function () { + it('should append a note to the flag', function (done) { + SocketFlags.appendNote({ uid: 2 }, { + flagId: flag.flagId, + note: 'lorem ipsum dolor sit amet' + }, function (err, data) { + assert(data.hasOwnProperty('notes')); + assert(Array.isArray(data.notes)); + assert.strictEqual('lorem ipsum dolor sit amet', data.notes[0].content); + assert.strictEqual(2, data.notes[0].uid); + + assert(data.hasOwnProperty('history')); + assert(Array.isArray(data.history)); + assert.strictEqual(1, Object.keys(data.history[0].fields).length); + assert(data.history[0].fields.hasOwnProperty('notes')); done(); }); }); }); }); - describe('.getHistory()', function () { - it('should retrieve a flag\'s history', function (done) { - Flags.getHistory(1, function (err, history) { - assert.ifError(err); - assert.strictEqual(history[0].fields[0].value, '[[flags:state-rejected]]'); - done(); - }); - }); - }); - after(function (done) { db.emptydb(done); }); From 380ebf67ee84f34907d1f59d8d8985bdaa66ea6b Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Tue, 13 Dec 2016 14:24:09 -0500 Subject: [PATCH 37/48] oops, uncommenting all the other tests for flags --- test/flags.js | 570 +++++++++++++++++++++++++------------------------- 1 file changed, 285 insertions(+), 285 deletions(-) diff --git a/test/flags.js b/test/flags.js index 8d359c6930..7b0252086a 100644 --- a/test/flags.js +++ b/test/flags.js @@ -45,295 +45,295 @@ describe('Flags', function () { ], done); }); - // describe('.create()', function () { - // it('should create a flag and return its data', function (done) { - // Flags.create('post', 1, 1, 'Test flag', function (err, flagData) { - // assert.ifError(err); - // var compare = { - // flagId: 1, - // uid: 1, - // targetId: 1, - // type: 'post', - // description: 'Test flag' - // }; - - // for(var key in compare) { - // if (compare.hasOwnProperty(key)) { - // assert.ok(flagData[key]); - // assert.strictEqual(flagData[key], compare[key]); - // } - // } - - // done(); - // }); - // }); - // }); - - // describe('.get()', function () { - // it('should retrieve and display a flag\'s data', function (done) { - // Flags.get(1, function (err, flagData) { - // assert.ifError(err); - // var compare = { - // flagId: 1, - // uid: 1, - // targetId: 1, - // type: 'post', - // description: 'Test flag', - // state: 'open' - // }; - - // for(var key in compare) { - // if (compare.hasOwnProperty(key)) { - // assert.ok(flagData[key]); - // assert.strictEqual(flagData[key], compare[key]); - // } - // } - - // done(); - // }); - // }); - // }); - - // describe('.list()', function () { - // it('should show a list of flags (with one item)', function (done) { - // Flags.list({}, 1, function (err, flags) { - // assert.ifError(err); - // assert.ok(Array.isArray(flags)); - // assert.equal(flags.length, 1); + describe('.create()', function () { + it('should create a flag and return its data', function (done) { + Flags.create('post', 1, 1, 'Test flag', function (err, flagData) { + assert.ifError(err); + var compare = { + flagId: 1, + uid: 1, + targetId: 1, + type: 'post', + description: 'Test flag' + }; + + for(var key in compare) { + if (compare.hasOwnProperty(key)) { + assert.ok(flagData[key]); + assert.strictEqual(flagData[key], compare[key]); + } + } + + done(); + }); + }); + }); + + describe('.get()', function () { + it('should retrieve and display a flag\'s data', function (done) { + Flags.get(1, function (err, flagData) { + assert.ifError(err); + var compare = { + flagId: 1, + uid: 1, + targetId: 1, + type: 'post', + description: 'Test flag', + state: 'open' + }; + + for(var key in compare) { + if (compare.hasOwnProperty(key)) { + assert.ok(flagData[key]); + assert.strictEqual(flagData[key], compare[key]); + } + } + + done(); + }); + }); + }); + + describe('.list()', function () { + it('should show a list of flags (with one item)', function (done) { + Flags.list({}, 1, function (err, flags) { + assert.ifError(err); + assert.ok(Array.isArray(flags)); + assert.equal(flags.length, 1); - // Flags.get(flags[0].flagId, function (err, flagData) { - // assert.ifError(err); - // assert.equal(flags[0].flagId, flagData.flagId); - // assert.equal(flags[0].description, flagData.description); - // done(); - // }); - // }); - // }); - // }); - - // describe('.update()', function () { - // it('should alter a flag\'s various attributes and persist them to the database', function (done) { - // Flags.update(1, 1, { - // "state": "wip", - // "assignee": 1 - // }, function (err) { - // assert.ifError(err); - // db.getObjectFields('flag:1', ['state', 'assignee'], function (err, data) { - // if (err) { - // throw err; - // } - - // assert.strictEqual('wip', data.state); - // assert.strictEqual(1, data.assignee); - // done(); - // }); - // }); - // }); - - // it('should persist to the flag\'s history', function (done) { - // Flags.getHistory(1, function (err, history) { - // if (err) { - // throw err; - // } - - // history.forEach(function (change) { - // switch (change.attribute) { - // case 'state': - // assert.strictEqual('[[flags:state-wip]]', change.value); - // break; + Flags.get(flags[0].flagId, function (err, flagData) { + assert.ifError(err); + assert.equal(flags[0].flagId, flagData.flagId); + assert.equal(flags[0].description, flagData.description); + done(); + }); + }); + }); + }); + + describe('.update()', function () { + it('should alter a flag\'s various attributes and persist them to the database', function (done) { + Flags.update(1, 1, { + "state": "wip", + "assignee": 1 + }, function (err) { + assert.ifError(err); + db.getObjectFields('flag:1', ['state', 'assignee'], function (err, data) { + if (err) { + throw err; + } + + assert.strictEqual('wip', data.state); + assert.strictEqual(1, data.assignee); + done(); + }); + }); + }); + + it('should persist to the flag\'s history', function (done) { + Flags.getHistory(1, function (err, history) { + if (err) { + throw err; + } + + history.forEach(function (change) { + switch (change.attribute) { + case 'state': + assert.strictEqual('[[flags:state-wip]]', change.value); + break; - // case 'assignee': - // assert.strictEqual(1, change.value); - // break; - // } - // }); - - // done(); - // }); - // }); - // }); - - // describe('.getTarget()', function () { - // it('should return a post\'s data if queried with type "post"', function (done) { - // Flags.getTarget('post', 1, 1, function (err, data) { - // assert.ifError(err); - // var compare = { - // uid: 1, - // pid: 1, - // content: 'This is flaggable content' - // }; - - // for(var key in compare) { - // if (compare.hasOwnProperty(key)) { - // assert.ok(data[key]); - // assert.strictEqual(data[key], compare[key]); - // } - // } - - // done(); - // }); - // }); - - // it('should return a user\'s data if queried with type "user"', function (done) { - // Flags.getTarget('user', 1, 1, function (err, data) { - // assert.ifError(err); - // var compare = { - // uid: 1, - // username: 'testUser', - // email: 'b@c.com' - // }; - - // for(var key in compare) { - // if (compare.hasOwnProperty(key)) { - // assert.ok(data[key]); - // assert.strictEqual(data[key], compare[key]); - // } - // } - - // done(); - // }); - // }); - // }); - - // describe('.validate()', function () { - // it('should error out if type is post and post is deleted', function (done) { - // Posts.delete(1, 1, function (err) { - // if (err) { - // throw err; - // } - - // Flags.validate({ - // type: 'post', - // id: 1, - // uid: 1 - // }, function (err) { - // assert.ok(err); - // assert.strictEqual('[[error:post-deleted]]', err.message); - // Posts.restore(1, 1, done); - // }); - // }); - // }); - - // it('should not pass validation if flag threshold is set and user rep does not meet it', function (done) { - // Meta.configs.set('privileges:flag', '50', function (err) { - // assert.ifError(err); - - // Flags.validate({ - // type: 'post', - // id: 1, - // uid: 2 - // }, function (err) { - // assert.ok(err); - // assert.strictEqual('[[error:not-enough-reputation-to-flag]]', err.message); - // Meta.configs.set('privileges:flag', 0, done); - // }); - // }); - // }); - // }); - - // describe('.appendNote()', function () { - // it('should add a note to a flag', function (done) { - // Flags.appendNote(1, 1, 'this is my note', function (err) { - // assert.ifError(err); + case 'assignee': + assert.strictEqual(1, change.value); + break; + } + }); + + done(); + }); + }); + }); + + describe('.getTarget()', function () { + it('should return a post\'s data if queried with type "post"', function (done) { + Flags.getTarget('post', 1, 1, function (err, data) { + assert.ifError(err); + var compare = { + uid: 1, + pid: 1, + content: 'This is flaggable content' + }; + + for(var key in compare) { + if (compare.hasOwnProperty(key)) { + assert.ok(data[key]); + assert.strictEqual(data[key], compare[key]); + } + } + + done(); + }); + }); + + it('should return a user\'s data if queried with type "user"', function (done) { + Flags.getTarget('user', 1, 1, function (err, data) { + assert.ifError(err); + var compare = { + uid: 1, + username: 'testUser', + email: 'b@c.com' + }; + + for(var key in compare) { + if (compare.hasOwnProperty(key)) { + assert.ok(data[key]); + assert.strictEqual(data[key], compare[key]); + } + } + + done(); + }); + }); + }); + + describe('.validate()', function () { + it('should error out if type is post and post is deleted', function (done) { + Posts.delete(1, 1, function (err) { + if (err) { + throw err; + } + + Flags.validate({ + type: 'post', + id: 1, + uid: 1 + }, function (err) { + assert.ok(err); + assert.strictEqual('[[error:post-deleted]]', err.message); + Posts.restore(1, 1, done); + }); + }); + }); + + it('should not pass validation if flag threshold is set and user rep does not meet it', function (done) { + Meta.configs.set('privileges:flag', '50', function (err) { + assert.ifError(err); + + Flags.validate({ + type: 'post', + id: 1, + uid: 2 + }, function (err) { + assert.ok(err); + assert.strictEqual('[[error:not-enough-reputation-to-flag]]', err.message); + Meta.configs.set('privileges:flag', 0, done); + }); + }); + }); + }); + + describe('.appendNote()', function () { + it('should add a note to a flag', function (done) { + Flags.appendNote(1, 1, 'this is my note', function (err) { + assert.ifError(err); - // db.getSortedSetRange('flag:1:notes', 0, -1, function (err, notes) { - // if (err) { - // throw err; - // } - - // assert.strictEqual('[1,"this is my note"]', notes[0]); - // done(); - // }); - // }); - // }); - - // it('should be a JSON string', function (done) { - // db.getSortedSetRange('flag:1:notes', 0, -1, function (err, notes) { - // if (err) { - // throw err; - // } - - // try { - // JSON.parse(notes[0]); - // } catch (e) { - // assert.ifError(e); - // } - - // done(); - // }); - // }); - // }); - - // describe('.getNotes()', function () { - // before(function (done) { - // // Add a second note - // Flags.appendNote(1, 1, 'this is the second note', done); - // }); - - // it('return should match a predefined spec', function (done) { - // Flags.getNotes(1, function (err, notes) { - // assert.ifError(err); - // var compare = { - // uid: 1, - // content: 'this is my note' - // }; + db.getSortedSetRange('flag:1:notes', 0, -1, function (err, notes) { + if (err) { + throw err; + } + + assert.strictEqual('[1,"this is my note"]', notes[0]); + done(); + }); + }); + }); + + it('should be a JSON string', function (done) { + db.getSortedSetRange('flag:1:notes', 0, -1, function (err, notes) { + if (err) { + throw err; + } + + try { + JSON.parse(notes[0]); + } catch (e) { + assert.ifError(e); + } + + done(); + }); + }); + }); + + describe('.getNotes()', function () { + before(function (done) { + // Add a second note + Flags.appendNote(1, 1, 'this is the second note', done); + }); + + it('return should match a predefined spec', function (done) { + Flags.getNotes(1, function (err, notes) { + assert.ifError(err); + var compare = { + uid: 1, + content: 'this is my note' + }; - // var data = notes[1]; - // for(var key in compare) { - // if (compare.hasOwnProperty(key)) { - // assert.ok(data[key]); - // assert.strictEqual(data[key], compare[key]); - // } - // } - - // done(); - // }); - // }); - - // it('should retrieve a list of notes, from newest to oldest', function (done) { - // Flags.getNotes(1, function (err, notes) { - // assert.ifError(err); - // assert(notes[0].datetime > notes[1].datetime); - // assert.strictEqual('this is the second note', notes[0].content); - // done(); - // }); - // }); - // }); - - // describe('.appendHistory()', function () { - // var entries; - // before(function (done) { - // db.sortedSetCard('flag:1:history', function (err, count) { - // entries = count; - // done(err); - // }); - // }); - - // it('should add a new entry into a flag\'s history', function (done) { - // Flags.appendHistory(1, 1, ['state:rejected'], function (err) { - // assert.ifError(err); - - // Flags.getHistory(1, function (err, history) { - // if (err) { - // throw err; - // } - - // assert.strictEqual(entries + 1, history.length); - // done(); - // }); - // }); - // }); - // }); - - // describe('.getHistory()', function () { - // it('should retrieve a flag\'s history', function (done) { - // Flags.getHistory(1, function (err, history) { - // assert.ifError(err); - // assert.strictEqual(history[0].fields[0].value, '[[flags:state-rejected]]'); - // done(); - // }); - // }); - // }); + var data = notes[1]; + for(var key in compare) { + if (compare.hasOwnProperty(key)) { + assert.ok(data[key]); + assert.strictEqual(data[key], compare[key]); + } + } + + done(); + }); + }); + + it('should retrieve a list of notes, from newest to oldest', function (done) { + Flags.getNotes(1, function (err, notes) { + assert.ifError(err); + assert(notes[0].datetime > notes[1].datetime); + assert.strictEqual('this is the second note', notes[0].content); + done(); + }); + }); + }); + + describe('.appendHistory()', function () { + var entries; + before(function (done) { + db.sortedSetCard('flag:1:history', function (err, count) { + entries = count; + done(err); + }); + }); + + it('should add a new entry into a flag\'s history', function (done) { + Flags.appendHistory(1, 1, ['state:rejected'], function (err) { + assert.ifError(err); + + Flags.getHistory(1, function (err, history) { + if (err) { + throw err; + } + + assert.strictEqual(entries + 1, history.length); + done(); + }); + }); + }); + }); + + describe('.getHistory()', function () { + it('should retrieve a flag\'s history', function (done) { + Flags.getHistory(1, function (err, history) { + assert.ifError(err); + assert.strictEqual(history[0].fields[0].value, '[[flags:state-rejected]]'); + done(); + }); + }); + }); describe('(websockets)', function () { var SocketFlags = require('../src/socket.io/flags.js'); From e6768ab57286749821eec15cb69e39641139577a Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Wed, 14 Dec 2016 10:03:39 -0500 Subject: [PATCH 38/48] some more fixes to flags, simplifying qs manipulation in flags search re: #5232 --- public/language/en-GB/flags.json | 1 + public/src/client/flags/list.js | 14 ++++---------- src/controllers/mods.js | 1 - 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json index a9272897ca..f1fd71bf27 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -41,6 +41,7 @@ "no-history": "No flag history.", "state": "State", + "state-all": "All states", "state-open": "New/Open", "state-wip": "Work in Progress", "state-resolved": "Resolved", diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js index 255c7176f1..12cc19093f 100644 --- a/public/src/client/flags/list.js +++ b/public/src/client/flags/list.js @@ -20,16 +20,10 @@ define('forum/flags/list', ['components', 'Chart'], function (components, Chart) } filtersEl.find('button').on('click', function () { - var payload = filtersEl.serializeArray(); - var qs = payload.map(function (filter) { - if (filter.value) { - return filter.name + '=' + filter.value; - } else { - return; - } - }).filter(Boolean).join('&'); - - ajaxify.go('flags?' + qs); + var payload = filtersEl.serializeArray().filter(function (item) { + return !!item.value; + }); + ajaxify.go('flags?' + $.param(payload)); }); }; diff --git a/src/controllers/mods.js b/src/controllers/mods.js index 39820e60a6..666f449316 100644 --- a/src/controllers/mods.js +++ b/src/controllers/mods.js @@ -5,7 +5,6 @@ var async = require('async'); var user = require('../user'); var flags = require('../flags'); var analytics = require('../analytics'); -// var adminFlagsController = require('./admin/flags'); var modsController = { flags: {} From ebffc4460011178f2745a27f78d2aeaca6559d9a Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Wed, 14 Dec 2016 15:00:41 -0500 Subject: [PATCH 39/48] fix tests, #5232 --- src/flags.js | 2 +- test/flags.js | 22 +++++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/flags.js b/src/flags.js index 1ae5a84ed8..995e880801 100644 --- a/src/flags.js +++ b/src/flags.js @@ -47,7 +47,7 @@ Flags.get = function (flagId, callback) { }; Flags.list = function (filters, uid, callback) { - if (typeof filters === 'function' && !callback) { + if (typeof filters === 'function' && !uid && !callback) { callback = filters; filters = {}; } diff --git a/test/flags.js b/test/flags.js index 7b0252086a..607b807db3 100644 --- a/test/flags.js +++ b/test/flags.js @@ -41,6 +41,11 @@ describe('Flags', function () { }, function (uid, next) { Groups.join('administrators', uid, next); + }, + function (next) { + User.create({ + username: 'unprivileged', password: 'abcdef', email: 'd@e.com' + }, next); } ], done); }); @@ -109,6 +114,15 @@ describe('Flags', function () { }); }); }); + + it('should return a filtered list of flags if said filters are passed in', function (done) { + Flags.list({ + state: 'open' + }, 1, function (err, flags) { + assert.ifError(err); + done(); + }); + }); }); describe('.update()', function () { @@ -221,7 +235,7 @@ describe('Flags', function () { Flags.validate({ type: 'post', id: 1, - uid: 2 + uid: 3 }, function (err) { assert.ok(err); assert.strictEqual('[[error:not-enough-reputation-to-flag]]', err.message); @@ -310,7 +324,9 @@ describe('Flags', function () { }); it('should add a new entry into a flag\'s history', function (done) { - Flags.appendHistory(1, 1, ['state:rejected'], function (err) { + Flags.appendHistory(1, 1, { + state: 'rejected' + }, function (err) { assert.ifError(err); Flags.getHistory(1, function (err, history) { @@ -329,7 +345,7 @@ describe('Flags', function () { it('should retrieve a flag\'s history', function (done) { Flags.getHistory(1, function (err, history) { assert.ifError(err); - assert.strictEqual(history[0].fields[0].value, '[[flags:state-rejected]]'); + assert.strictEqual(history[0].fields.state, '[[flags:state-rejected]]'); done(); }); }); From 0927d54c9877f8b024abd167121602b0e4aff2a5 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Wed, 14 Dec 2016 15:53:57 -0500 Subject: [PATCH 40/48] ability to filter flags by cid, #5232, more tests --- public/language/en-GB/flags.json | 2 ++ src/controllers/mods.js | 13 ++++++-- src/flags.js | 32 +++++++++++++++--- test/flags.js | 57 ++++++++++++++++++++++++++++---- 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json index f1fd71bf27..2a1bf919f8 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -19,7 +19,9 @@ "filter-type-post": "Post", "filter-state": "State", "filter-assignee": "Assignee UID", + "filter-cid": "Category", "filter-quick-mine": "Assigned to me", + "filter-cid-all": "All categories", "apply-filters": "Apply Filters", "quick-links": "Quick Links", diff --git a/src/controllers/mods.js b/src/controllers/mods.js index 666f449316..cae9ade1ed 100644 --- a/src/controllers/mods.js +++ b/src/controllers/mods.js @@ -3,6 +3,7 @@ var async = require('async'); var user = require('../user'); +var categories = require('../categories'); var flags = require('../flags'); var analytics = require('../analytics'); @@ -26,7 +27,7 @@ modsController.flags.list = function (req, res, next) { } // Parse query string params for filters - var valid = ['assignee', 'state', 'reporterId', 'type', 'targetUid', 'quick']; + var valid = ['assignee', 'state', 'reporterId', 'type', 'targetUid', 'cid', 'quick']; var filters = valid.reduce(function (memo, cur) { if (req.query.hasOwnProperty(cur)) { memo[cur] = req.query[cur]; @@ -37,15 +38,23 @@ modsController.flags.list = function (req, res, next) { async.parallel({ flags: async.apply(flags.list, filters, req.uid), - analytics: async.apply(analytics.getDailyStatsForSet, 'analytics:flags', Date.now(), 30) + analytics: async.apply(analytics.getDailyStatsForSet, 'analytics:flags', Date.now(), 30), + categories: async.apply(categories.buildForSelect, req.uid) }, function (err, data) { if (err) { return next(err); } + // Minimal returned set for templates.js + data.categories = data.categories.reduce(function (memo, cur) { + memo[cur.cid] = cur.name; + return memo; + }, {}); + res.render('flags/list', { flags: data.flags, analytics: data.analytics, + categories: data.categories, hasFilter: !!Object.keys(filters).length, filters: filters, title: '[[pages:flags]]' diff --git a/src/flags.js b/src/flags.js index 995e880801..81b97ee408 100644 --- a/src/flags.js +++ b/src/flags.js @@ -71,11 +71,15 @@ Flags.list = function (filters, uid, callback) { case 'assignee': sets.push('flags:byAssignee:' + filters[type]); break; - + case 'targetUid': sets.push('flags:byTargetUid:' + filters[type]); break; + case 'cid': + sets.push('flags:byCid:' + filters[type]); + break; + case 'quick': switch (filters.quick) { case 'mine': @@ -262,6 +266,7 @@ Flags.getNotes = function (flagId, callback) { Flags.create = function (type, id, uid, reason, timestamp, callback) { var targetUid; + var targetCid; var doHistoryAppend = false; // timestamp is optional @@ -273,17 +278,21 @@ Flags.create = function (type, id, uid, reason, timestamp, callback) { async.waterfall([ function (next) { - // Sanity checks async.parallel([ + // Sanity checks async.apply(Flags.exists, type, id, uid), async.apply(Flags.targetExists, type, id), - async.apply(Flags.getTargetUid, type, id) + + // Extra data for zset insertion + async.apply(Flags.getTargetUid, type, id), + async.apply(Flags.getTargetCid, type, id) ], function (err, checks) { if (err) { return next(err); } targetUid = checks[2] || null; + targetCid = checks[3] || null; if (checks[0]) { return next(new Error('[[error:already-flagged]]')); @@ -315,6 +324,9 @@ Flags.create = function (type, id, uid, reason, timestamp, callback) { if (targetUid) { tasks.push(async.apply(db.sortedSetAdd.bind(db), 'flags:byTargetUid:' + targetUid, timestamp, flagId)); // by target uid } + if (targetCid) { + tasks.push(async.apply(db.sortedSetAdd.bind(db), 'flags:byCid:' + targetCid, timestamp, flagId)); // by target uid + } async.parallel(tasks, function (err, data) { if (err) { @@ -358,12 +370,24 @@ Flags.getTargetUid = function (type, id, callback) { posts.getPostField(id, 'uid', callback); break; - case 'user': + default: setImmediate(callback, null, id); break; } }; +Flags.getTargetCid = function (type, id, callback) { + switch (type) { + case 'post': + posts.getCidByPid(id, callback); + break; + + default: + setImmediate(callback, null, id); + break; + } +}; + Flags.update = function (flagId, uid, changeset, callback) { // Retrieve existing flag data to compare for history-saving purposes var fields = ['state', 'assignee']; diff --git a/test/flags.js b/test/flags.js index 607b807db3..c46ac0bcef 100644 --- a/test/flags.js +++ b/test/flags.js @@ -72,6 +72,14 @@ describe('Flags', function () { done(); }); }); + + it('should add the flag to the byCid zset for category 1 if it is of type post', function (done) { + db.isSortedSetMember('flags:byCid:' + 1, 1, function (err, isMember) { + assert.ifError(err); + assert.ok(isMember); + done(); + }); + }); }); describe('.get()', function () { @@ -115,12 +123,49 @@ describe('Flags', function () { }); }); - it('should return a filtered list of flags if said filters are passed in', function (done) { - Flags.list({ - state: 'open' - }, 1, function (err, flags) { - assert.ifError(err); - done(); + describe('(with filters)', function () { + it('should return a filtered list of flags if said filters are passed in', function (done) { + Flags.list({ + state: 'open' + }, 1, function (err, flags) { + assert.ifError(err); + assert.ok(Array.isArray(flags)); + assert.strictEqual(1, flags[0].flagId); + done(); + }); + }); + + it('should return no flags if a filter with no matching flags is used', function (done) { + Flags.list({ + state: 'rejected' + }, 1, function (err, flags) { + assert.ifError(err); + assert.ok(Array.isArray(flags)); + assert.strictEqual(0, flags.length); + done(); + }); + }); + + it('should return a flag when filtered by cid 1', function (done) { + Flags.list({ + cid: 1 + }, 1, function (err, flags) { + assert.ifError(err); + assert.ok(Array.isArray(flags)); + assert.strictEqual(1, flags.length); + done(); + }); + }); + + it('shouldn\'t return a flag when filtered by cid 2', function (done) { + Flags.list({ + cid: 2 + }, 1, function (err, flags) { + assert.ifError(err); + assert.ok(Array.isArray(flags)); + assert.strictEqual(0, flags.length); + done(); + }); }); }); }); From 7b471b76db69c3fc5eb0ea5fca17b177e07f8c93 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Mon, 19 Dec 2016 09:50:46 -0500 Subject: [PATCH 41/48] fixing tests for #5232 --- test/flags.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/flags.js b/test/flags.js index c46ac0bcef..cc8a0425e3 100644 --- a/test/flags.js +++ b/test/flags.js @@ -457,6 +457,7 @@ describe('Flags', function () { flagId: flag.flagId, note: 'lorem ipsum dolor sit amet' }, function (err, data) { + assert.ifError(err); assert(data.hasOwnProperty('notes')); assert(Array.isArray(data.notes)); assert.strictEqual('lorem ipsum dolor sit amet', data.notes[0].content); From ad633aad4583546bad9302ca1cbef9ad052c131e Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Mon, 19 Dec 2016 11:16:03 -0500 Subject: [PATCH 42/48] additional tests and proper handling for purged flag targets, #5232 --- public/language/en-GB/flags.json | 1 + src/controllers/mods.js | 9 +++-- src/flags.js | 60 +++++++++++++++++++------------- src/posts/delete.js | 3 -- src/upgrade.js | 47 ------------------------- src/user/admin.js | 10 ------ test/flags.js | 44 +++++++++++++++++++++++ 7 files changed, 87 insertions(+), 87 deletions(-) diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json index 2a1bf919f8..66b9acc92a 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -7,6 +7,7 @@ "assignee": "Assignee", "update": "Update", "updated": "Updated", + "target-purged": "The content this flag referred to has been purged and is no longer available.", "quick-filters": "Quick Filters", "filter-active": "There are one or more filters active in this list of flags", diff --git a/src/controllers/mods.js b/src/controllers/mods.js index cae9ade1ed..242d68d708 100644 --- a/src/controllers/mods.js +++ b/src/controllers/mods.js @@ -78,8 +78,13 @@ modsController.flags.detail = function (req, res, next) { res.render('flags/detail', Object.assign(results.flagData, { assignees: results.assignees, - type_bool: ['post', 'user'].reduce(function (memo, cur) { - memo[cur] = results.flagData.type === cur; + type_bool: ['post', 'user', 'empty'].reduce(function (memo, cur) { + if (cur !== 'empty') { + memo[cur] = results.flagData.type === cur && !!Object.keys(results.flagData.target).length; + } else { + memo[cur] = !Object.keys(results.flagData.target).length; + } + return memo; }, {}), title: '[[pages:flag-details, ' + req.params.flagId + ']]' diff --git a/src/flags.js b/src/flags.js index 81b97ee408..61b7a1746d 100644 --- a/src/flags.js +++ b/src/flags.js @@ -202,31 +202,6 @@ Flags.validate = function (payload, callback) { }); }; -Flags.getTarget = function (type, id, uid, callback) { - switch (type) { - case 'post': - async.waterfall([ - async.apply(posts.getPostsByPids, [id], uid), - function (posts, next) { - topics.addPostData(posts, uid, next); - } - ], function (err, posts) { - callback(err, posts[0]); - }); - break; - - case 'user': - user.getUsersData([id], function (err, users) { - callback(err, users ? users[0] : undefined); - }); - break; - - default: - callback(new Error('[[error:invalid-data]]')); - break; - } -}; - Flags.getNotes = function (flagId, callback) { async.waterfall([ async.apply(db.getSortedSetRevRangeWithScores.bind(db), 'flag:' + flagId + ':notes', 0, -1), @@ -348,6 +323,41 @@ Flags.exists = function (type, id, uid, callback) { db.isSortedSetMember('flags:hash', [type, id, uid].join(':'), callback); }; +Flags.getTarget = function (type, id, uid, callback) { + async.waterfall([ + async.apply(Flags.targetExists, type, id), + function (exists, next) { + if (exists) { + switch (type) { + case 'post': + async.waterfall([ + async.apply(posts.getPostsByPids, [id], uid), + function (posts, next) { + topics.addPostData(posts, uid, next); + } + ], function (err, posts) { + next(err, posts[0]); + }); + break; + + case 'user': + user.getUsersData([id], function (err, users) { + next(err, users ? users[0] : undefined); + }); + break; + + default: + next(new Error('[[error:invalid-data]]')); + break; + } + } else { + // Target used to exist (otherwise flag creation'd fail), but no longer + next(null, {}); + } + } + ], callback); +}; + Flags.targetExists = function (type, id, callback) { switch (type) { case 'post': diff --git a/src/posts/delete.js b/src/posts/delete.js index ebf902aef2..32ee6b6f41 100644 --- a/src/posts/delete.js +++ b/src/posts/delete.js @@ -144,9 +144,6 @@ module.exports = function (Posts) { }, function (next) { db.sortedSetsRemove(['posts:pid', 'posts:flagged'], pid, next); - }, - function (next) { - flags.dismiss(pid, next); } ], function (err) { if (err) { diff --git a/src/upgrade.js b/src/upgrade.js index 29b806d4a7..1815d1e560 100644 --- a/src/upgrade.js +++ b/src/upgrade.js @@ -455,53 +455,6 @@ Upgrade.upgrade = function (callback) { next(); } }, - function (next) { - thisSchemaDate = Date.UTC(2016, 3, 29); - - if (schemaDate < thisSchemaDate) { - updatesMade = true; - winston.info('[2016/04/29] Dismiss flags from deleted topics'); - - var posts = require('./posts'); - var topics = require('./topics'); - var flags = require('./flags'); - - var pids, tids; - - async.waterfall([ - async.apply(db.getSortedSetRange, 'posts:flagged', 0, -1), - function (_pids, next) { - pids = _pids; - posts.getPostsFields(pids, ['tid'], next); - }, - function (_tids, next) { - tids = _tids.map(function (a) { - return a.tid; - }); - - topics.getTopicsFields(tids, ['deleted'], next); - }, - function (state, next) { - var toDismiss = state.map(function (a, idx) { - return parseInt(a.deleted, 10) === 1 ? pids[idx] : null; - }).filter(Boolean); - - winston.info('[2016/04/29] ' + toDismiss.length + ' dismissable flags found'); - async.each(toDismiss, flags.dismiss, next); - } - ], function (err) { - if (err) { - return next(err); - } - - winston.info('[2016/04/29] Dismiss flags from deleted topics done'); - Upgrade.update(thisSchemaDate, next); - }); - } else { - winston.info('[2016/04/29] Dismiss flags from deleted topics skipped!'); - next(); - } - }, function (next) { thisSchemaDate = Date.UTC(2016, 4, 28); diff --git a/src/user/admin.js b/src/user/admin.js index 5d2215980c..4f7ecf66fb 100644 --- a/src/user/admin.js +++ b/src/user/admin.js @@ -56,14 +56,4 @@ module.exports = function (User) { } ], callback); }; - - User.resetFlags = function (uids, callback) { - if (!Array.isArray(uids) || !uids.length) { - return callback(); - } - - async.eachSeries(uids, function (uid, next) { - flags.dismissByUid(uid, next); - }, callback); - }; }; diff --git a/test/flags.js b/test/flags.js index cc8a0425e3..58250fb27f 100644 --- a/test/flags.js +++ b/test/flags.js @@ -82,6 +82,42 @@ describe('Flags', function () { }); }); + describe('.exists()', function () { + it('should return Boolean True if a flag matching the flag hash already exists', function (done) { + Flags.exists('post', 1, 1, function (err, exists) { + assert.ifError(err); + assert.strictEqual(true, exists); + done(); + }); + }); + + it('should return Boolean False if a flag matching the flag hash does not already exists', function (done) { + Flags.exists('post', 1, 2, function (err, exists) { + assert.ifError(err); + assert.strictEqual(false, exists); + done(); + }); + }); + }); + + describe('.targetExists()', function () { + it('should return Boolean True if the targeted element exists', function (done) { + Flags.targetExists('post', 1, function (err, exists) { + assert.ifError(err); + assert.strictEqual(true, exists); + done(); + }); + }); + + it('should return Boolean False if the targeted element does not exist', function (done) { + Flags.targetExists('post', 15, function (err, exists) { + assert.ifError(err); + assert.strictEqual(false, exists); + done(); + }); + }); + }); + describe('.get()', function () { it('should retrieve and display a flag\'s data', function (done) { Flags.get(1, function (err, flagData) { @@ -252,6 +288,14 @@ describe('Flags', function () { done(); }); }); + + it('should return a plain object with no properties if the target no longer exists', function (done) { + Flags.getTarget('user', 15, 1, function (err, data) { + assert.ifError(err); + assert.strictEqual(0, Object.keys(data).length); + done(); + }); + }); }); describe('.validate()', function () { From 47c9c936936f50cc0fa4494d5ae2164f5f219fee Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Mon, 19 Dec 2016 11:54:52 -0500 Subject: [PATCH 43/48] removed old flagging tests --- test/posts.js | 215 -------------------------------------------------- 1 file changed, 215 deletions(-) diff --git a/test/posts.js b/test/posts.js index 1ddbd83399..090edd3ca1 100644 --- a/test/posts.js +++ b/test/posts.js @@ -418,221 +418,6 @@ describe('Post\'s', function () { }); }); - describe('flagging a post', function () { - var meta = require('../src/meta'); - var socketPosts = require('../src/socket.io/posts'); - it('should fail to flag a post due to low reputation', function (done) { - meta.config['privileges:flag'] = 10; - flagPost(function (err) { - assert.equal(err.message, '[[error:not-enough-reputation-to-flag]]'); - done(); - }); - }); - - it('should flag a post', function (done) { - meta.config['privileges:flag'] = -1; - flagPost(function (err) { - assert.ifError(err); - done(); - }); - }); - - it('should return nothing without a uid or a reason', function (done) { - socketPosts.flag({uid: 0}, {pid: postData.pid, reason: 'reason'}, function (err) { - assert.equal(err.message, '[[error:not-logged-in]]'); - socketPosts.flag({uid: voteeUid}, {}, function (err) { - assert.equal(err.message, '[[error:invalid-data]]'); - done(); - }); - }); - }); - - it('should return an error without an existing post', function (done) { - socketPosts.flag({uid: voteeUid}, {pid: 12312312, reason: 'reason'}, function (err) { - assert.equal(err.message, '[[error:no-post]]'); - done(); - }); - }); - - it('should return an error if the flag already exists', function (done) { - socketPosts.flag({uid: voteeUid}, {pid: postData.pid, reason: 'reason'}, function (err) { - assert.equal(err.message, '[[error:already-flagged]]'); - done(); - }); - }); - }); - - function flagPost(next) { - var socketPosts = require('../src/socket.io/posts'); - socketPosts.flag({uid: voteeUid}, {pid: postData.pid, reason: 'reason'}, next); - } - - describe('get flag data', function () { - it('should see the flagged post', function (done) { - posts.isFlaggedByUser(postData.pid, voteeUid, function (err, hasFlagged) { - assert.ifError(err); - assert(hasFlagged); - done(); - }); - }); - - it('should return the flagged post data', function (done) { - posts.getFlags('posts:flagged', cid, voteeUid, 0, -1, function (err, flagData) { - assert.ifError(err); - assert(flagData.posts); - assert(flagData.count); - assert.equal(flagData.count, 1); - assert.equal(flagData.posts.length, 1); - assert(flagData.posts[0].flagReasons); - assert.equal(flagData.posts[0].flagReasons.length, 1); - assert.strictEqual(flagData.posts[0].flagReasons[0].reason, 'reason'); - assert(flagData.posts[0].flagData); - assert.strictEqual(flagData.posts[0].flagData.state, 'open'); - done(); - }); - }); - }); - - describe('updating a flag', function () { - var socketPosts = require('../src/socket.io/posts'); - - it('should update a flag', function (done) { - async.waterfall([ - function (next) { - socketPosts.updateFlag({uid: globalModUid}, { - pid: postData.pid, - data: [ - {name: 'assignee', value: `${globalModUid}`}, - {name: 'notes', value: 'notes'} - ] - }, function (err) { - assert.ifError(err); - posts.getFlags('posts:flagged', cid, globalModUid, 0, -1, function (err, flagData) { - assert.ifError(err); - assert(flagData.posts); - assert.equal(flagData.posts.length, 1); - assert.deepEqual({ - assignee: flagData.posts[0].flagData.assignee, - notes: flagData.posts[0].flagData.notes, - state: flagData.posts[0].flagData.state, - labelClass: flagData.posts[0].flagData.labelClass - }, { - assignee: `${globalModUid}`, - notes: 'notes', - state: 'open', - labelClass: 'info' - }); - next(); - }); - }); - }, function (next) { - posts.updateFlagData(globalModUid, postData.pid, { - state: 'rejected' - }, function (err) { - assert.ifError(err); - posts.getFlags('posts:flagged', cid, globalModUid, 0, -1, function (err, flagData) { - assert.ifError(err); - assert(flagData.posts); - assert.equal(flagData.posts.length, 1); - assert.deepEqual({ - state: flagData.posts[0].flagData.state, - labelClass: flagData.posts[0].flagData.labelClass - }, { - state: 'rejected', - labelClass: 'danger' - }); - next(); - }); - }); - }, function (next) { - posts.updateFlagData(globalModUid, postData.pid, { - state: 'wip' - }, function (err) { - assert.ifError(err); - posts.getFlags('posts:flagged', cid, globalModUid, 0, -1, function (err, flagData) { - assert.ifError(err); - assert(flagData.posts); - assert.equal(flagData.posts.length, 1); - assert.deepEqual({ - state: flagData.posts[0].flagData.state, - labelClass: flagData.posts[0].flagData.labelClass - }, { - state: 'wip', - labelClass: 'warning' - }); - next(); - }); - }); - }, function (next) { - posts.updateFlagData(globalModUid, postData.pid, { - state: 'resolved' - }, function (err) { - assert.ifError(err); - posts.getFlags('posts:flagged', cid, globalModUid, 0, -1, function (err, flagData) { - assert.ifError(err); - assert(flagData.posts); - assert.equal(flagData.posts.length, 1); - assert.deepEqual({ - state: flagData.posts[0].flagData.state, - labelClass: flagData.posts[0].flagData.labelClass - }, { - state: 'resolved', - labelClass: 'success' - }); - next(); - }); - }); - } - ], done); - }); - }); - - describe('dismissing a flag', function () { - var socketPosts = require('../src/socket.io/posts'); - - it('should dismiss a flag', function (done) { - socketPosts.dismissFlag({uid: globalModUid}, postData.pid, function (err) { - assert.ifError(err); - posts.isFlaggedByUser(postData.pid, voteeUid, function (err, hasFlagged) { - assert.ifError(err); - assert(!hasFlagged); - flagPost(function (err) { - assert.ifError(err); - done(); - }); - }); - }); - }); - - it('should dismiss all of a user\'s flags', function (done) { - posts.dismissUserFlags(voteeUid, function (err) { - assert.ifError(err); - posts.isFlaggedByUser(postData.pid, voteeUid, function (err, hasFlagged) { - assert.ifError(err); - assert(!hasFlagged); - flagPost(function (err) { - assert.ifError(err); - done(); - }); - }); - }); - }); - - it('should dismiss all flags', function (done) { - socketPosts.dismissAllFlags({uid: globalModUid}, {}, function (err) { - assert.ifError(err); - posts.isFlaggedByUser(postData.pid, voteeUid, function (err, hasFlagged) { - assert.ifError(err); - assert(!hasFlagged); - flagPost(function (err) { - assert.ifError(err); - done(); - }); - }); - }); - }); - }); - describe('getPostSummaryByPids', function () { it('should return empty array for empty pids', function (done) { posts.getPostSummaryByPids([], 0, {}, function (err, data) { From 5e52cfdf86ed709d122fbaf73bd6379c7745f308 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Mon, 19 Dec 2016 12:01:00 -0500 Subject: [PATCH 44/48] removed one more old flag test for #5232 --- test/socket.io.js | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/test/socket.io.js b/test/socket.io.js index ddb136d3b0..3928dc64c4 100644 --- a/test/socket.io.js +++ b/test/socket.io.js @@ -245,16 +245,6 @@ describe('socket.io', function () { }); }); - it('should reset flags', function (done) { - var socketAdmin = require('../src/socket.io/admin'); - socketAdmin.user.resetFlags({uid: adminUid}, [regularUid], function (err) { - assert.ifError(err); - done(); - }); - }); - - - describe('validation emails', function () { var socketAdmin = require('../src/socket.io/admin'); var meta = require('../src/meta'); From 2ea63f3d4259407045d9d586a4bbf8b4343a23fb Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Mon, 19 Dec 2016 12:13:36 -0500 Subject: [PATCH 45/48] how much fun is it to fix tests when stack traces don't work? lots. :shipit: --- src/flags.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/flags.js b/src/flags.js index 61b7a1746d..d3735671b2 100644 --- a/src/flags.js +++ b/src/flags.js @@ -34,7 +34,7 @@ Flags.get = function (flagId, callback) { }, function (err, payload) { // Final object return construction next(err, Object.assign(data.base, { - datetimeISO: new Date(data.base.datetime).toISOString(), + datetimeISO: new Date(parseInt(data.base.datetime, 10)).toISOString(), target_readable: data.base.type.charAt(0).toUpperCase() + data.base.type.slice(1) + ' ' + data.base.targetId, target: payload.targetObj, history: data.history, @@ -216,7 +216,7 @@ Flags.getNotes = function (flagId, callback) { uid: noteObj[0], content: noteObj[1], datetime: note.score, - datetimeISO: new Date(note.score).toISOString() + datetimeISO: new Date(parseInt(note.score, 10)).toISOString() }; } catch (e) { return next(e); @@ -469,7 +469,7 @@ Flags.getHistory = function (flagId, callback) { uid: entry.value[0], fields: changeset, datetime: entry.score, - datetimeISO: new Date(entry.score).toISOString() + datetimeISO: new Date(parseInt(entry.score, 10)).toISOString() }; }); From 07ac7dce841d2d9ed96229dd7df02bf21fa20145 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Mon, 19 Dec 2016 12:31:55 -0500 Subject: [PATCH 46/48] minor tweaks to test to be redis-compatible --- test/flags.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/test/flags.js b/test/flags.js index 58250fb27f..dfa6f1a773 100644 --- a/test/flags.js +++ b/test/flags.js @@ -65,7 +65,7 @@ describe('Flags', function () { for(var key in compare) { if (compare.hasOwnProperty(key)) { assert.ok(flagData[key]); - assert.strictEqual(flagData[key], compare[key]); + assert.equal(flagData[key], compare[key]); } } @@ -134,7 +134,7 @@ describe('Flags', function () { for(var key in compare) { if (compare.hasOwnProperty(key)) { assert.ok(flagData[key]); - assert.strictEqual(flagData[key], compare[key]); + assert.equal(flagData[key], compare[key]); } } @@ -166,7 +166,7 @@ describe('Flags', function () { }, 1, function (err, flags) { assert.ifError(err); assert.ok(Array.isArray(flags)); - assert.strictEqual(1, flags[0].flagId); + assert.strictEqual(1, parseInt(flags[0].flagId, 10)); done(); }); }); @@ -219,7 +219,8 @@ describe('Flags', function () { } assert.strictEqual('wip', data.state); - assert.strictEqual(1, data.assignee); + assert.ok(!isNaN(parseInt(data.assignee, 10))); + assert.strictEqual(1, parseInt(data.assignee, 10)); done(); }); }); @@ -261,7 +262,7 @@ describe('Flags', function () { for(var key in compare) { if (compare.hasOwnProperty(key)) { assert.ok(data[key]); - assert.strictEqual(data[key], compare[key]); + assert.equal(data[key], compare[key]); } } @@ -281,7 +282,7 @@ describe('Flags', function () { for(var key in compare) { if (compare.hasOwnProperty(key)) { assert.ok(data[key]); - assert.strictEqual(data[key], compare[key]); + assert.equal(data[key], compare[key]); } } @@ -480,7 +481,7 @@ describe('Flags', function () { describe('.update()', function () { it('should update a flag\'s properties', function (done) { SocketFlags.update({ uid: 2 }, { - flagId: flag.flagId, + flagId: 2, data: [{ name: 'state', value: 'wip' @@ -498,7 +499,7 @@ describe('Flags', function () { describe('.appendNote()', function () { it('should append a note to the flag', function (done) { SocketFlags.appendNote({ uid: 2 }, { - flagId: flag.flagId, + flagId: 2, note: 'lorem ipsum dolor sit amet' }, function (err, data) { assert.ifError(err); From babafde7268c2d334457a12d23022af24ef5d6ab Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Mon, 19 Dec 2016 12:40:33 -0500 Subject: [PATCH 47/48] once more for good measure! --- src/flags.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flags.js b/src/flags.js index d3735671b2..7825f5603d 100644 --- a/src/flags.js +++ b/src/flags.js @@ -156,9 +156,9 @@ Flags.validate = function (payload, callback) { return callback(err); } - if (data.target.deleted) { + if (parseInt(data.target.deleted, 10)) { return callback(new Error('[[error:post-deleted]]')); - } else if (data.reporter.banned) { + } else if (parseInt(data.reporter.banned, 10)) { return callback(new Error('[[error:user-banned]]')); } From 283ae564f2771459592e8a36a46e09e4bcafc1d1 Mon Sep 17 00:00:00 2001 From: Julian Lam <julian@nodebb.org> Date: Mon, 19 Dec 2016 12:52:47 -0500 Subject: [PATCH 48/48] removing incorrect parseInt --- src/flags.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flags.js b/src/flags.js index 7825f5603d..ecc5a84e3e 100644 --- a/src/flags.js +++ b/src/flags.js @@ -156,7 +156,7 @@ Flags.validate = function (payload, callback) { return callback(err); } - if (parseInt(data.target.deleted, 10)) { + if (data.target.deleted) { return callback(new Error('[[error:post-deleted]]')); } else if (parseInt(data.reporter.banned, 10)) { return callback(new Error('[[error:user-banned]]'));