Commit 9dfb2405 authored by Eric Eastwood's avatar Eric Eastwood
Browse files

Add basic dashboard to review chat message reports

See https://gitlab.com/gitlab-org/gitter/webapp/merge_requests/1226
parent 8653495c
Pipeline #28083706 passed with stages
in 11 minutes and 54 seconds
......@@ -60,6 +60,7 @@ var cssWebStyleBuilder = styleBuilder([
'public/less/router-archive-home.less',
'public/less/router-archive-links.less',
'public/less/router-archive-chat.less',
'public/less/router-admin-dashboard.less',
'public/less/homepage.less',
'public/less/userhome.less',
'public/less/org-page.less',
......
......@@ -113,8 +113,9 @@ Messages should only be reported if they are spam, scams, or abuse. False-report
Some examples of reportable messages,
- Spam (especially messages cross-posted across many rooms)
- Doxing and personal information
- Random Ethereum addresses
- Crypto scams
- Crypto scams and viruses
- Skype address/number trolling for victims (scammers)
- Excessive name calling and retaliation
......
......@@ -5,9 +5,9 @@ const env = require('gitter-web-env');
const stats = env.stats;
const logger = env.logger.get('chat-report-service');
const StatusError = require('statuserror');
const ObjectID = require('mongodb').ObjectID;
const mongooseUtils = require('gitter-web-persistence-utils/lib/mongoose-utils');
const mongoUtils = require('gitter-web-persistence-utils/lib/mongo-utils');
const User = require('gitter-web-persistence').User;
const ChatMessageReport = require('gitter-web-persistence').ChatMessageReport;
const chatService = require('gitter-web-chats');
const userService = require('gitter-web-users');
......@@ -20,6 +20,14 @@ const ONE_DAY_TIME = 24 * 60 * 60 * 1000; // One day
const SUM_PERIOD = 5 * ONE_DAY_TIME;
const NEW_USER_CLEAR_MESSAGE_PERIOD = 3 * ONE_DAY_TIME;
function sentBefore(objectId) {
return new Date(objectId.getTimestamp().valueOf() + 1000);
}
function sentAfter(objectId) {
return new Date(objectId.getTimestamp().valueOf() - 1000);
}
function getReportSumForUser(userInQuestionId) {
return ChatMessageReport.find({ messageUserId: userInQuestionId })
.lean()
......@@ -50,7 +58,10 @@ function getReportSumForMessage(messageId) {
.exec()
.then(function(reports) {
return reports.reduce(function(sum, report) {
return sum + report.weight;
const reportSent = report.sent ? report.sent.valueOf() : Date.now();
const reportWithinRange = (Date.now() - reportSent) <= SUM_PERIOD;
return sum + (reportWithinRange ? report.weight : 0);
}, 0);
});
}
......@@ -85,6 +96,7 @@ function newReport(fromUser, messageId) {
return mongooseUtils.upsert(ChatMessageReport, { reporterUserId: reporterUserId, messageId: messageId }, {
$setOnInsert: {
sent: new Date(),
weight: this.weight,
reporterUserId: reporterUserId,
messageId: messageId,
......@@ -166,11 +178,40 @@ function findByIds(ids, callback) {
return mongooseUtils.findByIds(ChatMessageReport, ids, callback);
}
function findChatMessageReports(options) {
const limit = Math.min(options.limit || 50, 100);
let query = ChatMessageReport
.find();
if(options.beforeId) {
const beforeId = new ObjectID(options.beforeId);
query = query.where('sent').lte(sentBefore(beforeId));
query = query.where('_id').lt(beforeId);
}
if(options.afterId) {
const afterId = new ObjectID(options.afterId);
query = query.where('sent').gte(sentAfter(afterId));
query = query.where('_id').gt(afterId);
}
if(options.lean) {
query = query.lean();
}
return query
.sort({ sent: 'desc' })
.limit(limit)
.exec();
}
module.exports = {
BAD_USER_THRESHOLD: BAD_USER_THRESHOLD,
BAD_MESSAGE_THRESHOLD: BAD_MESSAGE_THRESHOLD,
getReportSumForUser: getReportSumForUser,
getReportSumForMessage: getReportSumForMessage,
newReport: newReport,
findByIds: findByIds
findByIds: findByIds,
findChatMessageReports: findChatMessageReports
};
......@@ -21,11 +21,13 @@ describe('chatReportService', function() {
userBad1: {},
user2: {},
user3: {},
user4: {},
troupe1: {
users: [
'userBad1',
'user2',
'user3',
'user4',
'userMessageOverThreshold1',
'userOverThreshold1',
'userOldOverThreshold1'
......@@ -38,10 +40,12 @@ describe('chatReportService', function() {
messageReport1: { user: 'user2', message: 'message1', sent: Date.now(), weight: 1 },
// Ignored because report is too old
messageReport2: { user: 'user2', message: 'message2', sent: new Date(Date.now() - (500 * ONE_DAY_TIME)), weight: 7 },
// Ignored because there is another report from this user with higher weight
messageReport3: { user: 'user3', message: 'message1', sent: Date.now(), weight: 1 },
messageReport4: { user: 'user3', message: 'message2', sent: Date.now(), weight: 2 },
messageReport2: { user: 'user4', message: 'message1', sent: new Date(Date.now() - (500 * ONE_DAY_TIME)), weight: 7 },
// Ignored because report is too old
messageReport3: { user: 'user2', message: 'message2', sent: new Date(Date.now() - (500 * ONE_DAY_TIME)), weight: 7 },
// Ignored because there is another report from this user with higher weight below
messageReport4: { user: 'user3', message: 'message1', sent: Date.now(), weight: 1 },
messageReport5: { user: 'user3', message: 'message2', sent: Date.now(), weight: 2 },
userMessageOverThreshold1: {},
messageToReportOverThreshold1: { user: 'userMessageOverThreshold1', troupe: 'troupe1', text: 'over_threshold_message (message)', sent: new Date() },
......@@ -159,7 +163,7 @@ describe('chatReportService', function() {
});
});
it('detected bad user that is old', function() {
it('detected bad user but is old enough to not automatically clear messages', function() {
return chatReportService.newReport(fixture.user3, fixture.messageToReportUserOldOverThreshold1.id)
.then(function() {
assert(statsLog.some(function(entry) {
......
......@@ -547,7 +547,7 @@ function deleteMessage(message) {
// `_.omit` because of `Cannot update '__v' and '__v' at the same time` error
return mongooseUtils.upsert(ChatMessageBackup, { _id: message._id }, _.omit(message.toObject(), '__v'))
.then(() => {
message.remove();
return message.remove();
});
}
......
......@@ -5,7 +5,7 @@ const Schema = mongoose.Schema;
const ObjectId = Schema.ObjectId;
const ChatMessageReportSchema = new Schema({
sent: { type: Date, "default": Date.now },
sent: { type: Date },
weight: Number,
reporterUserId: { type: ObjectId, required: true },
messageId: { type: ObjectId, required: true },
......
'use strict';
const $ = require('jquery');
const Backbone = require('backbone');
const Marionette = require('backbone.marionette');
require('./views/behaviors/isomorphic');
const debug = require('debug-proxy')('app:chat-messsage-reports');
const context = require('./utils/context');
function getAccountAgeString(user) {
if(user) {
const createdDate = new Date(user.accountCreatedDate);
return `
${Math.floor((Date.now() - createdDate.getTime()) / (1000 * 60 * 60 * 24))} days, ${createdDate.getFullYear()}-${createdDate.getMonth()}-${createdDate.getDay()}
`;
}
return '';
}
const ReportView = Marionette.ItemView.extend({
tagName: 'tr',
template: (data) => {
return `
<td class="admin-chat-report-table-cell admin-chat-report-table-reporter-cell model-id-${data.reporterUser && data.reporterUser.id}">
<div class="admin-chat-report-table-reporter-cell-username">
${data.reporterUser ? data.reporterUser.username : 'Unknown'}
</div>
<div class="admin-chat-report-table-reporter-cell-id">
${data.reporterUserId}
</div>
<div title="Account age">
${getAccountAgeString(data.reporterUser)}
</div>
</td>
<td class="admin-chat-report-table-cell admin-chat-report-table-message-author-cell model-id-${data.messageUser && data.messageUser.id}">
<div class="admin-chat-report-table-message-author-cell-username">
${data.messageUser ? data.messageUser.username : 'Unknown'}
</div>
<div class="admin-chat-report-table-message-author-cell-id">
${data.messageUserId}
</div>
<div title="Account age">
${getAccountAgeString(data.messageUser)}
</div>
</td>
<td class="admin-chat-report-table-cell admin-chat-report-item-message-text">
<div>
Weight: <strong>${data.weight}</strong>&nbsp;&nbsp;&nbsp;--&nbsp;${data.sent}
</div>
<div class="admin-chat-report-table-message-cell-id">
${data.messageId}
</div>
<div>
${data.messageText}
</div>
</td>
`
},
});
const ReportCollectionView = Marionette.CompositeView.extend({
childView: ReportView,
childViewContainer: '.js-report-list',
childViewOptions: function(item) {
return item;
},
template: function() {
return `
<table>
<thead>
<tr>
<td class="admin-chat-report-table-header-cell admin-chat-report-table-reporter-cell">
Reporter
</td>
<td class="admin-chat-report-table-header-cell admin-chat-report-table-message-author-cell">
Message Author
</td>
<td class="admin-chat-report-table-header-cell">
Message text
</td>
</tr>
</thead>
<tbody class="js-report-list"></tbody>
</table>
`;
}
});
const DashboardView = Marionette.LayoutView.extend({
behaviors: {
Isomorphic: {
reportTable: { el: '.js-report-table', init: 'initReportCollectionView' },
},
},
initReportCollectionView: function(optionsForRegion) {
return new ReportCollectionView(optionsForRegion({
collection: new Backbone.Collection(this.model.get('reports'))
}));
},
template: function(data) {
const reports = data.reports;
const lastReport = reports && reports[reports.length - 1];
let paginationLink = '';
if(lastReport) {
paginationLink = `
<hr />
<a href="?beforeId=${lastReport.id}">
Next page
</a>
`;
}
return `
<div class="dashboard">
<div class="js-report-table"></div>
${paginationLink}
<br />
<br />
<br />
<br />
</div>
`
},
});
const snapshot = context.getSnapshot('adminChatMessageReportDashboard');
debug('snapshot', snapshot);
new DashboardView({
el: $('.js-chat-message-report-dashboard-root'),
model: new Backbone.Model(snapshot),
}).render();
......@@ -31,6 +31,7 @@ var webpackConfig = {
"apps": path.resolve(path.join(__dirname, "./apps.js")),
"router-org-page": path.resolve(path.join(__dirname, './router-org-page.js')),
"router-userhome": path.resolve(path.join(__dirname, './router-userhome.js')),
"chat-message-reports": path.resolve(path.join(__dirname, './chat-message-reports.js')),
"mobile-native-userhome": path.resolve(path.join(__dirname, "./mobile-native-userhome")),
"router-home-learn": path.resolve(path.join(__dirname, './router-home-learn')),
......
@import "base-web.less";
@import "typography.less";
body {
overflow: auto;
}
.admin-chat-report-table-header-cell {
border-bottom: 1px solid #000000;
font-size: 1.125em;
font-weight: bold;
}
.admin-chat-report-table-cell,
.admin-chat-report-table-header-cell {
vertical-align: top;
padding-top: 10px;
padding-bottom: 20px;
}
.admin-chat-report-table-reporter-cell,
.admin-chat-report-table-message-author-cell {
padding-right: 40px;
}
.admin-chat-report-table-reporter-cell-username,
.admin-chat-report-table-message-author-cell-username {
font-weight: bold;
}
.admin-chat-report-table-reporter-cell-id,
.admin-chat-report-table-message-author-cell-id,
.admin-chat-report-table-message-cell-id {
font-size: .75em;
}
.admin-chat-report-item-message-text {
}
<!doctype html>
<html class="no-js {{#if hasCachedFonts}}fonts-loaded{{/if}}" lang="{{ lang }}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{{ __ "Gitter &mdash; Where developers come to talk." }}}</title>
<meta name="description" content="Where developers come to talk.">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href='{{cdn cssFileName }}'>
<link id="favicon" rel="shortcut icon" href="{{ cdn 'images/favicon-normal.ico' }}">
{{> fonts}}
</head>
<body>
<h1>Admin dashboard: Chat message reports</h1>
<div class="js-chat-message-report-dashboard-root"></div>
{{{ generateTroupeContext troupeContext }}}
{{{ generateEnv }}}
{{{ bootScript bootScriptName jsRoot=jsRoot }}}
<script type="text/javascript" src="//use.typekit.net/ohh4mcl.js"></script>
<script type="text/javascript">try{Typekit.load();}catch(e){}</script>
</body>
</html>
......@@ -9,7 +9,7 @@ module.exports = {
create: function(req) {
return chatReportService.newReport(req.user, req.params.chatMessageId)
.then(function(report) {
const strategy = new restSerializer.ChatReportStrategy();
const strategy = new restSerializer.ChatMessageReportStrategy();
return restSerializer.serializeObject(report, strategy);
});
},
......
"use strict";
var env = require('gitter-web-env');
var nconf = env.config;
var stats = env.stats;
var express = require('express');
var identifyRoute = env.middlewares.identifyRoute;
var featureToggles = require('../web/middlewares/feature-toggles');
var ensureLoggedIn = require('../web/middlewares/ensure-logged-in');
var langs = require('langs');
var loginUtils = require('../web/login-utils');
var social = require('./social-metadata');
var fonts = require('../web/fonts');
var survivalMode = !!process.env.SURVIVAL_MODE || false;
const Promise = require('bluebird');
const env = require('gitter-web-env');
const nconf = env.config;
const stats = env.stats;
const express = require('express');
const StatusError = require('statuserror');
const identifyRoute = env.middlewares.identifyRoute;
const featureToggles = require('../web/middlewares/feature-toggles');
const ensureLoggedIn = require('../web/middlewares/ensure-logged-in');
const langs = require('langs');
const loginUtils = require('../web/login-utils');
const social = require('./social-metadata');
const fonts = require('../web/fonts');
const contextGenerator = require('../web/context-generator');
const generateAdminChatMessageReportSnapshot = require('./snapshots/admin-chat-message-report-snapshot');
const survivalMode = !!process.env.SURVIVAL_MODE || false;
/**
* When Gitter hits a big news site, this setting disables
......@@ -157,4 +161,27 @@ router.get('/_s/cdn/*',
res.redirect(req.path.replace('/_s/cdn', ''));
});
router.get('/-/admin/chat-message-reports',
identifyRoute('admin-chat-message-reports'),
function(req, res) {
if(!req.user || !req.user.staff) {
throw new StatusError(403, 'Only staff can view this area');
}
Promise.props({
troupeContext: contextGenerator.generateBasicContext(req),
snapshots: generateAdminChatMessageReportSnapshot(req)
})
.then(function({ troupeContext, snapshots }) {
troupeContext.snapshots = snapshots;
res.render('admin/chat-message-reports', {
bootScriptName: 'chat-message-reports',
cssFileName: 'styles/router-admin-dashboard.css',
troupeContext: troupeContext,
});
});
});
module.exports = router;
'use strict';
const mongoUtils = require('gitter-web-persistence-utils/lib/mongo-utils');
const restSerializer = require('../../serializers/rest-serializer');
const chatReportService = require('gitter-web-chat-reports');
function postprocessUser(user) {
if(user) {
return Object.assign({}, user, {
accountCreatedDate: mongoUtils.getTimestampFromObjectId(user.id)
});
}
}
function getSnapshotsForPageContext(req) {
return chatReportService.findChatMessageReports({
beforeId: req.query.beforeId,
afterId: req.query.afterId,
limit: req.query.limit
})
.then(function(reports) {
const strategy = new restSerializer.ChatMessageReportStrategy();
return restSerializer.serialize(reports, strategy)
})
.then((serializedReports) => {
return {
adminChatMessageReportDashboard: {
reports: serializedReports.map((report) => {
return Object.assign({}, report, {
reporterUser: postprocessUser(report.reporterUser),
messageUser: postprocessUser(report.messageUser)
});
})
}
};
});
}
module.exports = getSnapshotsForPageContext;
'use strict';
const Promise = require('bluebird');
const UserIdStrategy = require('./user-id-strategy');
const ChatIdStrategy = require('./chat-id-strategy');
function ChatMessageReportStrategy(options) {
const userIdStategy = new UserIdStrategy(options);
const chatIdStategy = new ChatIdStrategy(options);
this.preload = function(chatMessageReports) {
// We can't use a `Array.reduce` because there is some magic `Sequence` methods expected that would get stripped :shrug:
const reporterUserIds = chatMessageReports.map((report) => report.reporterUserId);
const messageUserIds = chatMessageReports.map((report) => report.messageUserId);
const chatIds = chatMessageReports.map((report) => report.messageId);
return Promise.all([
userIdStategy.preload(reporterUserIds.concat(messageUserIds)),
chatIdStategy.preload(chatIds)
]);
};
this.map = function(report) {
const reporterUser = userIdStategy.map(report.reporterUserId);
const messageUser = userIdStategy.map(report.messageUserId);
const message = chatIdStategy.map(report.messageId);
return {
id: report._id,
sent: report.sent,
weight: report.weight,
// Included because the `messageUser` may have been deleted
reporterUserId: report.reporterUserId,
reporterUser: reporterUser,
messageId: report.messageId,
// Included because the `messageUser` may have been deleted
messageUserId: report.messageUserId,
messageUser: messageUser,
// messageText contains the text when the report was made
// which may differ from the current message
messageText: report.text,
message
};
};
}
ChatMessageReportStrategy.prototype = {
name: 'ChatMessageReportStrategy'
};
module.exports = ChatMessageReportStrategy;
"use strict";
const UserIdStrategy = require('./user-id-strategy');
function ChatReportStrategy(/*options*/) {
const userIdStategy = UserIdStrategy.slim();
this.preload = function(reports) {
const userIds = reports.map(function(report) { return report.reporterUserId; });
return userIdStategy.preload(userIds);