diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json new file mode 100644 index 0000000000..66b9acc92a --- /dev/null +++ b/public/language/en-GB/flags.json @@ -0,0 +1,62 @@ +{ + "state": "State", + "reporter": "Reporter", + "reported-at": "Reported At", + "description": "Description", + "no-flags": "Hooray! No flags found.", + "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", + "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", + "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", + "flagged-user": "Flagged User", + "reporter": "Reporting User", + "view-profile": "View Profile", + "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.", + + "history": "Flag History", + "back": "Back to Flags List", + "no-history": "No flag history.", + + "state": "State", + "state-all": "All states", + "state-open": "New/Open", + "state-wip": "Work in Progress", + "state-resolved": "Resolved", + "state-rejected": "Rejected", + "no-assignee": "Not Assigned", + "note-added": "Note Added", + + "modal-title": "Report Inappropriate Content", + "modal-body": "Please specify your reason for flagging %1 %2 for review. Alternatively, use one of the quick report buttons if applicable.", + "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/pages.json b/public/language/en-GB/pages.json index 801b28edea..5efa686fc3 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", @@ -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/public/language/en-GB/topic.json b/public/language/en-GB/topic.json index 29a85c15cc..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,22 +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.", - "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_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", - "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.", @@ -153,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/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); 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/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/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/detail.js b/public/src/client/flags/detail.js new file mode 100644 index 0000000000..e20f05dba9 --- /dev/null +++ b/public/src/client/flags/detail.js @@ -0,0 +1,77 @@ +'use strict'; + +/* globals define */ + +define('forum/flags/detail', ['forum/flags/list', 'components', 'translator'], function (FlagsList, components, translator) { + 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, history) { + if (err) { + return app.alertError(err.message); + } else { + app.alertSuccess('[[flags:updated]]'); + Flags.reloadHistory(history); + } + }); + break; + + case 'appendNote': + socket.emit('flags.appendNote', { + flagId: ajaxify.data.flagId, + note: document.getElementById('note').value + }, function (err, payload) { + if (err) { + return app.alertError(err.message); + } else { + app.alertSuccess('[[flags:note-added]]'); + Flags.reloadNotes(payload.notes); + Flags.reloadHistory(payload.history); + } + }); + break; + } + }); + + FlagsList.enableFilterForm(); + }; + + 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 = ''; + }); + }; + + 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/public/src/client/flags/list.js b/public/src/client/flags/list.js new file mode 100644 index 0000000000..12cc19093f --- /dev/null +++ b/public/src/client/flags/list.js @@ -0,0 +1,86 @@ +'use strict'; + +/* globals define */ + +define('forum/flags/list', ['components', 'Chart'], function (components, Chart) { + var Flags = {}; + + Flags.init = function () { + Flags.enableFilterForm(); + Flags.enableChatButtons(); + Flags.handleGraphs(); + }; + + 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().filter(function (item) { + return !!item.value; + }); + ajaxify.go('flags?' + $.param(payload)); + }); + }; + + Flags.enableChatButtons = function () { + $('[data-chat]').on('click', function () { + app.newChat(this.getAttribute('data-chat')); + }); + }; + + 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, + stepSize: 1 + } + }] + } + } + }); + }; + + return Flags; +}); 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 63% rename from public/src/client/topic/flag.js rename to public/src/modules/flags.js index 78b1dd5d2a..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('posts.flag', {pid: 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/analytics.js b/src/analytics.js index 6b248057da..c6cfbeba7e 100644 --- a/src/analytics.js +++ b/src/analytics.js @@ -16,17 +16,21 @@ var uniquevisitors = 0; var isCategory = /^(?:\/api)?\/category\/(\d+)/; -new cronJob('*/10 * * * *', function () { +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) { 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 1b31a95ff4..0000000000 --- a/src/controllers/admin/flags.js +++ /dev/null @@ -1,103 +0,0 @@ -"use strict"; - -var async = require('async'); -var validator = require('validator'); - -var posts = require('../../posts'); -var user = require('../../user'); -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'); - } - - posts.getFlags(sets, cid, req.uid, start, stop, next); - } - ], callback); -} - - -module.exports = flagsController; diff --git a/src/controllers/mods.js b/src/controllers/mods.js index 0079412f87..242d68d708 100644 --- a/src/controllers/mods.js +++ b/src/controllers/mods.js @@ -3,24 +3,92 @@ var async = require('async'); var user = require('../user'); -var adminFlagsController = require('./admin/flags'); +var categories = require('../categories'); +var flags = require('../flags'); +var analytics = require('../analytics'); -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) { res.locals.cids = results.moderatedCids; } - adminFlagsController.get(req, res, next); + // Parse query string params for filters + 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]; + } + + return memo; + }, {}); + + async.parallel({ + flags: async.apply(flags.list, filters, req.uid), + 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]]' + }); + }); + }); +}; + +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), + assignees: async.apply(user.getAdminsandGlobalModsandModerators) + }, 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', Object.assign(results.flagData, { + assignees: results.assignees, + 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 new file mode 100644 index 0000000000..ecc5a84e3e --- /dev/null +++ b/src/flags.js @@ -0,0 +1,620 @@ +'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 = {}; + +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(Flags.getHistory, flagId), + notes: async.apply(Flags.getNotes, flagId) + }), + function (data, next) { + // Second stage + async.parallel({ + 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 + next(err, Object.assign(data.base, { + 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, + notes: data.notes, + reporter: payload.userObj + })); + }); + } + ], callback); +}; + +Flags.list = function (filters, uid, callback) { + if (typeof filters === 'function' && !uid && !callback) { + callback = filters; + 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 '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 'targetUid': + sets.push('flags:byTargetUid:' + filters[type]); + break; + + case 'cid': + sets.push('flags:byCid:' + 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, -1, next); + } else { + db.getSortedSetRevIntersect({sets: sets, start: 0, stop: -1, aggregate: 'MAX'}, next); + } + }, + 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, { + reporter: { + 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.targetId, + datetimeISO: new Date(parseInt(flagObj.datetime, 10)).toISOString() + })); + }); + }, next); + } + ], 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 (parseInt(data.reporter.banned, 10)) { + return callback(new Error('[[error:user-banned]]')); + } + + switch (payload.type) { + case 'post': + 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 (!editable.flag && parseInt(data.reporter.reputation, 10) < minimumReputation) { + return callback(new Error('[[error:not-enough-reputation-to-flag]]')); + } + + callback(); + }); + break; + + case 'user': + 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 (!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; + } + }); +}; + +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(parseInt(note.score, 10)).toISOString() + }; + } catch (e) { + return next(e); + } + }); + next(null, notes, uids); + }, + function (notes, uids, next) { + user.getUsersFields(uids, ['username', 'userslug', 'picture'], 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, timestamp, callback) { + var targetUid; + var targetCid; + var doHistoryAppend = false; + + // timestamp is optional + if (typeof timestamp === 'function' && !callback) { + callback = timestamp; + timestamp = Date.now(); + doHistoryAppend = true; + } + + async.waterfall([ + function (next) { + async.parallel([ + // Sanity checks + async.apply(Flags.exists, type, id, uid), + async.apply(Flags.targetExists, 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]]')); + } else if (!checks[1]) { + return next(new Error('[[error:invalid-data]]')); + } else { + next(); + } + }); + }, + async.apply(db.incrObjectField, 'global', 'nextFlagId'), + function (flagId, next) { + var tasks = [ + async.apply(db.setObject.bind(db), 'flag:' + flagId, { + flagId: flagId, + type: type, + targetId: id, + description: reason, + uid: uid, + datetime: timestamp + }), + 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.sortedSetAdd.bind(db), 'flags:hash', flagId, [type, id, uid].join(':')), // save zset for duplicate checking + async.apply(analytics.increment, 'flags') // some fancy analytics + ]; + + 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) { + return next(err); + } + + if (doHistoryAppend) { + Flags.update(flagId, uid, { "state": "open" }); + } + + next(null, flagId); + }); + }, + async.apply(Flags.get) + ], callback); +}; + +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': + posts.exists(id, callback); + break; + + case 'user': + user.exists(id, callback); + break; + + default: + callback(new Error('[[error:invalid-data]]')); + break; + } +}; + +Flags.getTargetUid = function (type, id, callback) { + switch (type) { + case 'post': + posts.getPostField(id, 'uid', callback); + break; + + 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']; + var tasks = []; + var now = changeset.datetime || 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]; + } 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; + } + } + } + } + + if (!Object.keys(changeset).length) { + // No changes + return next(); + } + + // 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, changeset)); + + async.parallel(tasks, 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]); + + // Deserialise changeset + var changeset = entry.value[1]; + if (changeset.hasOwnProperty('state')) { + changeset.state = changeset.state === undefined ? '' : '[[flags:state-' + changeset.state + ']]'; + } + + return { + uid: entry.value[0], + fields: changeset, + datetime: entry.score, + datetimeISO: new Date(parseInt(entry.score, 10)).toISOString() + }; + }); + + user.getUsersFields(uids, ['username', 'userslug', 'picture'], next); + } + ], function (err, users) { + if (err) { + return callback(err); + } + + // Append user data to each history event + history = history.map(function (event, idx) { + event.user = users[idx]; + return event; + }); + + callback(null, history); + }); +}; + +Flags.appendHistory = function (flagId, uid, changeset, callback) { + var payload; + var datetime = changeset.datetime || Date.now(); + delete changeset.datetime; + + try { + payload = JSON.stringify([uid, changeset, datetime]); + } catch (e) { + return callback(e); + } + + db.sortedSetAdd('flag:' + flagId + ':history', datetime, payload, 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]); + } catch (e) { + return callback(e); + } + + async.waterfall([ + async.apply(db.sortedSetAdd, 'flag:' + flagId + ':notes', datetime, payload), + async.apply(Flags.appendHistory, flagId, uid, { + notes: null, + datetime: 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({ + 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: flagObj.description, + 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:flag.create', { + flag: flagObj + }); + notifications.push(notification, results.admins.concat(results.moderators).concat(results.globalMods), 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); + } + + plugins.fireHook('action:flag.create', { + flag: flagObj + }); + notifications.push(notification, results.admins.concat(results.globalMods), callback); + }); + }); + break; + + default: + callback(new Error('[[error:invalid-data]]')); + break; + } +}; + +module.exports = Flags; \ No newline at end of file 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/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): diff --git a/src/posts.js b/src/posts.js index 3a7d2d7e19..ed22e51ed1 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..32ee6b6f41 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) { @@ -143,9 +144,6 @@ module.exports = function (Posts) { }, function (next) { db.sortedSetsRemove(['posts:pid', 'posts:flagged'], pid, next); - }, - function (next) { - Posts.dismissFlag(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/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/routes/index.js b/src/routes/index.js index e9943e0dae..b3bc508c7d 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, '/posts/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) { diff --git a/src/socket.io/admin/user.js b/src/socket.io/admin/user.js index 87a4c2d2ae..480ad6a4d5 100644 --- a/src/socket.io/admin/user.js +++ b/src/socket.io/admin/user.js @@ -69,14 +69,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/socket.io/flags.js b/src/socket.io/flags.js new file mode 100644 index 0000000000..c25bd662fa --- /dev/null +++ b/src/socket.io/flags.js @@ -0,0 +1,111 @@ +'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.type || !data.id || !data.reason) { + return callback(new Error('[[error:invalid-data]]')); + } + + async.waterfall([ + async.apply(flags.validate, { + uid: socket.uid, + type: data.type, + id: data.id + }), + function (next) { + // If we got here, then no errors occurred + flags.create(data.type, data.id, socket.uid, data.reason, next); + } + ], function (err, flagObj) { + if (err) { + return callback(err); + } + + flags.notify(flagObj, socket.uid); + callback(null, flagObj); + }); +}; + +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); + }, + async.apply(flags.getHistory, data.flagId) + ], 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); + }, + function (next) { + async.parallel({ + "notes": async.apply(flags.getNotes, data.flagId), + "history": async.apply(flags.getHistory, data.flagId) + }, 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 077b88bfc9..0000000000 --- a/src/socket.io/posts/flag.js +++ /dev/null @@ -1,172 +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'); - -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; - - posts.flag(post, 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]]')); - } - posts.dismissFlag(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]]')); - } - posts.dismissAllFlags(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); - - posts.updateFlagData(socket.uid, data.pid, payload, next); - } - ], callback); - }; -}; diff --git a/src/upgrade.js b/src/upgrade.js index 79ffa6b5ee..1815d1e560 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) { @@ -455,52 +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'), - topics = require('./topics'); - - 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, posts.dismissFlag, 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); @@ -1022,7 +976,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'); @@ -1059,6 +1013,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/user/admin.js b/src/user/admin.js index 8b5a6ebef4..4f7ecf66fb 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) { @@ -55,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) { - posts.dismissUserFlags(uid, next); - }, callback); - }; }; diff --git a/src/user/data.js b/src/user/data.js index cbaf066ded..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); }); }; @@ -80,7 +93,16 @@ module.exports = function (User) { return callback(null, []); } - var keys = uids.map(function (uid) { + // 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; + }, {}); + + var keys = uniqueUids.map(function (uid) { return 'user:' + uid; }); @@ -89,6 +111,10 @@ module.exports = function (User) { return callback(err); } + users = uids.map(function (uid) { + return users[ref[uid]]; + }); + modifyUserData(users, [], callback); }); }; 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> 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> diff --git a/test/flags.js b/test/flags.js new file mode 100644 index 0000000000..dfa6f1a773 --- /dev/null +++ b/test/flags.js @@ -0,0 +1,524 @@ +'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 Posts = require('../src/posts'); +var User = require('../src/user'); +var Groups = require('../src/groups'); +var Meta = require('../src/meta'); + +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); + }); + }, + function (topicData, next) { + User.create({ + username: 'testUser2', password: 'abcdef', email: 'c@d.com' + }, next); + }, + function (uid, next) { + Groups.join('administrators', uid, next); + }, + function (next) { + User.create({ + username: 'unprivileged', password: 'abcdef', email: 'd@e.com' + }, 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.equal(flagData[key], compare[key]); + } + } + + 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('.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) { + 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.equal(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('(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, parseInt(flags[0].flagId, 10)); + 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(); + }); + }); + }); + }); + + 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.ok(!isNaN(parseInt(data.assignee, 10))); + assert.strictEqual(1, parseInt(data.assignee, 10)); + 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.equal(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.equal(data[key], compare[key]); + } + } + + 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 () { + 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: 3 + }, 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.state, '[[flags:state-rejected]]'); + done(); + }); + }); + }); + + describe('(websockets)', function () { + var SocketFlags = require('../src/socket.io/flags.js'); + var tid, pid, flag; + + 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(err); + }); + }); + + describe('.create()', function () { + it('should create a flag with no errors', function (done) { + SocketFlags.create({ uid: 2 }, { + type: 'post', + id: pid, + reason: 'foobar' + }, function (err, flagObj) { + flag = flagObj; + assert.ifError(err); + + Flags.exists('post', pid, 1, function (err, exists) { + assert.ifError(err); + assert(true); + done(); + }); + }); + }); + }); + + describe('.update()', function () { + it('should update a flag\'s properties', function (done) { + SocketFlags.update({ uid: 2 }, { + flagId: 2, + 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(); + }); + }); + }); + + describe('.appendNote()', function () { + it('should append a note to the flag', function (done) { + SocketFlags.appendNote({ uid: 2 }, { + flagId: 2, + 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); + 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(); + }); + }); + }); + }); + + after(function (done) { + db.emptydb(done); + }); +}); 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) { diff --git a/test/socket.io.js b/test/socket.io.js index d4eea47df4..61d7b86f7c 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');