Merge branch 'flagging-refactor' into develop

v1.18.x
Julian Lam 8 years ago
commit 76bcc1c327

@ -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."
}

@ -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>",

@ -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\"",

@ -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"
}

@ -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",

@ -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);

@ -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;
});

@ -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) {

@ -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

@ -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;
});

@ -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;
});

@ -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
});
});
});

@ -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]]');
});
}

@ -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) {

@ -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'),

@ -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;

@ -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 + ']]'
}));
});
};

@ -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, '&#37;').replace(/,/g, '&#44;');
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;

@ -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

@ -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) {

@ -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):

@ -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);

@ -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) {

@ -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);
};
};

@ -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);

@ -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) {

@ -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]]'));

@ -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;

@ -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) {

@ -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) {

@ -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, '&#37;').replace(/,/g, '&#44;');
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);
};
};

@ -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) {

@ -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);
};
};

@ -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);
});
};

@ -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>
&nbsp;[[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> &bull;
<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>

@ -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>

@ -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);
});
});

@ -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) {

@ -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');

Loading…
Cancel
Save