Commit 8653495c authored by Eric Eastwood's avatar Eric Eastwood

Add reporting/flagging of users

See gitlab-org/gitter/webapp!1226
parent 3afb02a9
......@@ -93,6 +93,33 @@ You can quickly jump to editing your last message by using the up-arrow keyboard
![](https://i.imgur.com/28mHUvq.png)
## Delete messages
The **Delete** option is available in the message `...` dropdown in the top-right of every message.
You can delete any of your own messages. Room admins can also delete a message.
![](https://i.imgur.com/klpJ1IX.png)
## Report messages
The **Report** option is available in the message `...` dropdown in the top-right of every message.
![](https://i.imgur.com/mE0gbPM.png)
Messages should only be reported if they are spam, scams, or abuse. False-reports will be punished.
Some examples of reportable messages,
- Spam (especially messages cross-posted across many rooms)
- Random Ethereum addresses
- Crypto scams
- Skype address/number trolling for victims (scammers)
- Excessive name calling and retaliation
## Searching messages
Search is located in the left menu under the magnifying glass menu bar icon. You can press **Ctrl/Cmd + S** to jump straight to that view.
......@@ -106,7 +133,7 @@ You can use the `from:username` syntax to only find messages from the specified
## Message archive
You can access a rooms message archive via the **Room settings dropdown** -> **Arhives**.
You can access a rooms message archive via the **Room settings dropdown** -> **Archives**.
The archive heatmap currently only shows a year but you can manually navigate by changing the URL. You can [track this issue for increasing the heatmap size](https://gitlab.com/gitlab-org/gitter/webapp/issues/785)
......
{
"env": {
"commonjs": true,
"node": true,
"mocha": true
},
"plugins": [
"mocha"
],
"rules": {
"mocha/no-exclusive-tests": "error",
"max-nested-callbacks": [
"error",
10
],
"node/no-unpublished-require": "off"
}
}
'use strict';
const Promise = require('bluebird');
const env = require('gitter-web-env');
const stats = env.stats;
const logger = env.logger.get('chat-report-service');
const StatusError = require('statuserror');
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');
const troupeService = require('gitter-web-rooms/lib/troupe-service');
const calculateReportWeight = require('./lib/calculate-report-weight').calculateReportWeight;
const BAD_USER_THRESHOLD = 5;
const BAD_MESSAGE_THRESHOLD = 2;
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 getReportSumForUser(userInQuestionId) {
return ChatMessageReport.find({ messageUserId: userInQuestionId })
.lean()
.exec()
.then(function(reports) {
const resultantReportMap = reports.reduce(function(reportMap, report) {
const reportSent = report.sent ? report.sent.valueOf() : Date.now();
const reportWithinRange = (Date.now() - reportSent) <= SUM_PERIOD;
// Only count the biggest report from a given user against another
const isBiggerWeight = !reportMap[report.reporterUserId] || report.weight > reportMap[report.reporterUserId];
if(reportWithinRange && isBiggerWeight) {
reportMap[report.reporterUserId] = report.weight || 0;
}
return reportMap;
}, {});
return Object.keys(resultantReportMap).reduce(function(sum, reporterUserIdKey) {
return sum + resultantReportMap[reporterUserIdKey];
}, 0);
});
}
function getReportSumForMessage(messageId) {
return ChatMessageReport.find({ messageId: messageId })
.lean()
.exec()
.then(function(reports) {
return reports.reduce(function(sum, report) {
return sum + report.weight;
}, 0);
});
}
function newReport(fromUser, messageId) {
const reporterUserId = fromUser._id || fromUser.id;
return chatService.findById(messageId)
.bind({})
.then(function(chatMessage) {
this.chatMessage = chatMessage;
if (!chatMessage) {
throw new StatusError(404, `Chat message not found (${messageId})`);
}
else if(mongoUtils.objectIDsEqual(reporterUserId, this.chatMessage.fromUserId)) {
throw new StatusError(403, 'You can\'t report your own message');
}
return troupeService.findByIdLean(this.chatMessage.toTroupeId);
})
.then(function(room) {
this.room = room;
if (!room) {
throw new StatusError(404, `Room not found (${this.chatMessage.toTroupeId})`);
}
return calculateReportWeight(fromUser, this.room, this.chatMessage);
})
.then(function(weight) {
this.weight = weight;
return mongooseUtils.upsert(ChatMessageReport, { reporterUserId: reporterUserId, messageId: messageId }, {
$setOnInsert: {
weight: this.weight,
reporterUserId: reporterUserId,
messageId: messageId,
messageUserId: this.chatMessage.fromUserId,
text: this.chatMessage.text
}
})
})
.spread(function(report, updateExisting) {
let checkUserPromise = Promise.resolve();
let checkMessagePromise = Promise.resolve();
if(!updateExisting) {
const room = this.room;
const chatMessage = this.chatMessage;
// Send a stat for a new report
stats.event('new_chat_message_report', {
sent: report.sent,
weight: report.weight,
reporterUserId: report.reporterUserId,
messageId: report.messageId,
messageUserId: report.messageUserId,
text: report.text
});
checkUserPromise = getReportSumForUser(report.messageUserId)
.then(function(sum) {
logger.info(`Report from ${report.reporterUserId} with weight=${report.weight} made against user ${report.messageUserId}, sum=${sum}/${BAD_USER_THRESHOLD}`);
if(sum >= BAD_USER_THRESHOLD) {
stats.event('new_bad_user_from_reports', {
userId: report.messageUserId,
sum: sum
});
// Only clear messages for new users (spammers)
const userCreated = mongoUtils.getTimestampFromObjectId(chatMessage.fromUserId);
const shouldClearMessages = (Date.now() - userCreated) < NEW_USER_CLEAR_MESSAGE_PERIOD;
logger.info(`Bad user ${report.messageUserId} detected (hellban${shouldClearMessages ? ' and removing all messages' : ''}), sum=${sum}/${BAD_USER_THRESHOLD}`);
userService.hellbanUser(report.messageUserId);
if(shouldClearMessages) {
chatService.removeAllMessagesForUserId(report.messageUserId);
}
}
return null;
});
checkMessagePromise = getReportSumForMessage(report.messageId)
.then(function(sum) {
logger.info(`Report from ${report.reporterUserId} with weight=${report.weight} made against message ${report.messageId}, sum is now, sum=${sum}/${BAD_MESSAGE_THRESHOLD}`);
if(sum >= BAD_MESSAGE_THRESHOLD) {
stats.event('new_bad_message_from_reports', {
messageId: report.messageId,
sum: sum
});
logger.info(`Bad message ${report.messageId} detected (removing) sum=${sum}/${BAD_MESSAGE_THRESHOLD}`);
chatService.deleteMessageFromRoom(room, chatMessage);
}
return null;
});
}
return Promise.all([
checkUserPromise,
checkMessagePromise
])
.then(function() {
return report;
});
});
}
function findByIds(ids, callback) {
return mongooseUtils.findByIds(ChatMessageReport, ids, callback);
}
module.exports = {
BAD_USER_THRESHOLD: BAD_USER_THRESHOLD,
BAD_MESSAGE_THRESHOLD: BAD_MESSAGE_THRESHOLD,
getReportSumForUser: getReportSumForUser,
getReportSumForMessage: getReportSumForMessage,
newReport: newReport,
findByIds: findByIds
};
'use strict';
const debug = require('debug')('gitter:app:calculate-report-weight');
const mongoUtils = require('gitter-web-persistence-utils/lib/mongo-utils');
const policyFactory = require('gitter-web-permissions/lib/policy-factory');
const ONE_DAY_TIME = 24 * 60 * 60 * 1000; // One day
// Make it harder for new users to do as much damage
// 0 - 1
function calculateUserAgeWeight(fromUser) {
let userAgeWeight = 0;
const userCreated = mongoUtils.getTimestampFromObjectId(fromUser._id || fromUser.id);
if((Date.now() - userCreated) < (2 * ONE_DAY_TIME)) {
userAgeWeight = 0;
}
else if((Date.now() - userCreated) < (14 * ONE_DAY_TIME)) {
userAgeWeight = 0.15;
}
else if((Date.now() - userCreated) < (60 * ONE_DAY_TIME)) {
userAgeWeight = 0.3;
}
else if((Date.now() - userCreated) < (180 * ONE_DAY_TIME)) {
userAgeWeight = 0.6;
}
else if((Date.now() - userCreated) >= (180 * ONE_DAY_TIME)) {
userAgeWeight = 1;
}
return userAgeWeight;
}
// Make it harder to delete older messages
// 0 - 1
function calculateMessageAgeWeight(message) {
let messageAgeWeight = 1;
const messageCreated = message.sent.getTime();
if((Date.now() - messageCreated) < (2 * ONE_DAY_TIME)) {
messageAgeWeight = 1;
}
else if((Date.now() - messageCreated) < (21 * ONE_DAY_TIME)) {
messageAgeWeight = 0.5;
}
else if((Date.now() - messageCreated) >= (21 * ONE_DAY_TIME)) {
messageAgeWeight = 0;
}
return messageAgeWeight;
}
function calculateReportWeight(fromUser, room, message) {
let baseWeight = 1;
return policyFactory.createPolicyForRoom(fromUser, room)
.then(function(policy) {
return policy.canAdmin();
})
.then(function(canAdmin) {
if(canAdmin) {
baseWeight = 2.5;
}
const userAgeWeight = calculateUserAgeWeight(fromUser);
const messageAgeWeight = calculateMessageAgeWeight(message);
const resultantWeight = baseWeight * userAgeWeight * messageAgeWeight;
debug(`calculateReportWeight=${resultantWeight}, baseWeight=${baseWeight}, userAgeWeight=${userAgeWeight}, messageAgeWeight=${messageAgeWeight}`);
return resultantWeight;
});
}
module.exports = {
calculateUserAgeWeight: calculateUserAgeWeight,
calculateMessageAgeWeight: calculateMessageAgeWeight,
calculateReportWeight: calculateReportWeight
};
{
"name": "gitter-web-chat-reports",
"version": "1.0.0",
"main": "index.js",
"directories": {
"lib": "lib",
"test": "test"
},
"scripts": {
"test": "mocha test/"
},
"private": true,
"dependencies": {
"bluebird": "^3.5.1",
"statuserror": "^0.1.3",
"gitter-web-chats": "file:../chats",
"gitter-web-env": "file:../env",
"gitter-web-env": "file:../env",
"gitter-web-permissions": "file:../permissions",
"gitter-web-persistence-utils": "file:../persistence-utils",
"gitter-web-rooms": "file:../rooms"
},
"devDependencies": {
"gitter-web-test-utils": "file:../test-utils"
}
}
'use strict';
const assert = require('assert');
const fixtureLoader = require('gitter-web-test-utils/lib/test-fixtures');
const mongoUtils = require('gitter-web-persistence-utils/lib/mongo-utils');
const chatReportService = require('../');
const reportWeight = require('../lib/calculate-report-weight');
const ONE_DAY_TIME = 24 * 60 * 60 * 1000; // One day
describe('calculate-report-weight', function() {
const fixture = fixtureLoader.setup({
userNew: {},
userNewAdmin: {},
userOld: {
_id: mongoUtils.createTestIdForTimestampString(Date.now() - (365 * ONE_DAY_TIME))
},
userOldAdmin: {
_id: mongoUtils.createTestIdForTimestampString(Date.now() - (365 * ONE_DAY_TIME))
},
troupe1: {
users: [
'userNew',
'userNewAdmin',
'userOld',
'userOldAdmin'
],
securityDescriptor: {
extraAdmins: [
'userNewAdmin',
'userOldAdmin'
]
}
},
message1: {
user: 'userOld',
troupe: 'troupe1',
text: 'new_message',
sent: new Date()
},
messageOld1: {
user: 'userOld',
troupe: 'troupe1',
text: 'new_message',
sent: new Date(Date.now() - (30 * ONE_DAY_TIME))
},
});
describe('calculateUserAgeWeight', function() {
it('new users have 0 weight', function() {
assert.strictEqual(reportWeight.calculateUserAgeWeight({
_id: mongoUtils.createTestIdForTimestampString(Date.now())
}), 0);
});
it('3 month users have some weight', function() {
assert.strictEqual(reportWeight.calculateUserAgeWeight({
_id: mongoUtils.createTestIdForTimestampString(Date.now() - (90 * ONE_DAY_TIME))
}), 0.6);
});
it('old users have full weight', function() {
assert.strictEqual(reportWeight.calculateUserAgeWeight({
_id: mongoUtils.createTestIdForTimestampString(Date.now() - (365 * ONE_DAY_TIME))
}), 1);
});
});
describe('calculateReportWeight', function() {
it('new messages are easily removed', function() {
assert.strictEqual(reportWeight.calculateMessageAgeWeight({
sent: new Date()
}), 1);
});
it('week old messages have some weight', function() {
assert.strictEqual(reportWeight.calculateMessageAgeWeight({
sent: new Date(Date.now() - (7 * ONE_DAY_TIME))
}), 0.5);
});
it('old messages can\'t be removed', function() {
assert.strictEqual(reportWeight.calculateMessageAgeWeight({
sent: new Date(Date.now() - (30 * ONE_DAY_TIME))
}), 0);
});
});
describe('calculateReportWeight', function() {
describe('new users', function() {
it('have no power', function() {
return reportWeight.calculateReportWeight(fixture.userNew, fixture.troupe1, fixture.message1)
.then(function(weight) {
assert.strictEqual(weight, 0);
});
});
it('including admins have no power', function() {
return reportWeight.calculateReportWeight(fixture.userNewAdmin, fixture.troupe1, fixture.message1)
.then(function(weight) {
assert.strictEqual(weight, 0);
});
});
});
it('old messages can\'t be removed', function() {
return reportWeight.calculateReportWeight(fixture.userOldAdmin, fixture.troupe1, fixture.messageOld1)
.then(function(weight) {
assert.strictEqual(weight, 0);
});
});
it('admin will prompt bad message', function() {
return reportWeight.calculateReportWeight(fixture.userOldAdmin, fixture.troupe1, fixture.message1)
.then(function(weight) {
assert(weight >= chatReportService.BAD_MESSAGE_THRESHOLD);
});
});
it('user reports a message', function() {
return reportWeight.calculateReportWeight(fixture.userOld, fixture.troupe1, fixture.message1)
.then(function(weight) {
assert.strictEqual(weight, 1);
});
});
});
});
'use strict';
const assert = require('assert');
const sinon = require('sinon');
const fixtureLoader = require('gitter-web-test-utils/lib/test-fixtures');
const proxyquireNoCallThru = require('proxyquire').noCallThru();
const mongoUtils = require('gitter-web-persistence-utils/lib/mongo-utils');
const persistence = require('gitter-web-persistence');
const vanillaChatReportService = require('../');
const ONE_DAY_TIME = 24 * 60 * 60 * 1000; // One day
describe('chatReportService', function() {
let chatReportService;
let statsLog = [];
let removeAllMessagesForUserIdSpy;
let deleteMessageFromRoomSpy;
let hellbanUserSpy;
const fixture = fixtureLoader.setupEach({
userBad1: {},
user2: {},
user3: {},
troupe1: {
users: [
'userBad1',
'user2',
'user3',
'userMessageOverThreshold1',
'userOverThreshold1',
'userOldOverThreshold1'
]
},
message1: { user: 'userBad1', troupe: 'troupe1', text: 'new_message', sent: new Date() },
message2: { user: 'userBad1', troupe: 'troupe1', text: 'new_message2', sent: new Date() },
messageOld3: { user: 'userBad1', troupe: 'troupe1', text: 'old_message3', sent: new Date(Date.now() - (500 * ONE_DAY_TIME)) },
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 },
userMessageOverThreshold1: {},
messageToReportOverThreshold1: { user: 'userMessageOverThreshold1', troupe: 'troupe1', text: 'over_threshold_message (message)', sent: new Date() },
messageReportOverThreshold1: {
user: 'user2',
message: 'messageToReportOverThreshold1',
sent: Date.now(),
weight: vanillaChatReportService.BAD_MESSAGE_THRESHOLD
},
userOverThreshold1: {},
messageToReportUserOverThreshold1: { user: 'userOverThreshold1', troupe: 'troupe1', text: 'over_threshold_message (user)', sent: new Date() },
messageReportUserOverThreshold1: {
user: 'user2',
message: 'messageToReportUserOverThreshold1',
sent: Date.now(),
weight: vanillaChatReportService.BAD_USER_THRESHOLD
},
userOldOverThreshold1: {
_id: mongoUtils.createTestIdForTimestampString(Date.now() - (500 * ONE_DAY_TIME))
},
messageToReportUserOldOverThreshold1: { user: 'userOldOverThreshold1', troupe: 'troupe1', text: 'over_threshold_message (old user)', sent: new Date() },
messageReportUserOldOverThreshold1: {
user: 'user2',
message: 'messageToReportUserOldOverThreshold1',
sent: Date.now(),
weight: vanillaChatReportService.BAD_USER_THRESHOLD
},
});
beforeEach(function() {
statsLog = [];
removeAllMessagesForUserIdSpy = sinon.spy();
deleteMessageFromRoomSpy = sinon.spy();
hellbanUserSpy = sinon.spy();
chatReportService = proxyquireNoCallThru('../', {
'gitter-web-env': Object.assign(require('gitter-web-env'), {
stats: {
event: function(message) {
statsLog.push(message);
}
},
}),
'gitter-web-chats': Object.assign(require('gitter-web-chats'), {
removeAllMessagesForUserId: removeAllMessagesForUserIdSpy,
deleteMessageFromRoom: deleteMessageFromRoomSpy,
}),
'gitter-web-users': Object.assign(require('gitter-web-chats'), {
hellbanUser: hellbanUserSpy,
})
});
});
describe('getReportSumForUser', function() {
it('sum reports across all of their messages from many users', function() {
return chatReportService.getReportSumForUser(fixture.userBad1.id)
.then(function(sum) {
assert.strictEqual(sum, 3);
});
});
});
describe('getReportSumForMessage', function() {
it('sum reports from many users', function() {
return chatReportService.getReportSumForMessage(fixture.message1.id)
.then(function(sum) {
assert.strictEqual(sum, 2);
});
});
});
describe('newReport', function() {
it('report another users message', function() {
return chatReportService.newReport(fixture.user2, fixture.message1.id)
.then(function(report) {
assert.equal(report.sent.getTime(), fixture.messageReport1.sent.getTime());
assert.strictEqual(report.weight, fixture.messageReport1.weight);
assert(mongoUtils.objectIDsEqual(report.reporterUserId, fixture.user2._id));
assert(mongoUtils.objectIDsEqual(report.messageId, fixture.message1._id));
assert(mongoUtils.objectIDsEqual(report.messageUserId, fixture.message1.fromUserId));
assert.strictEqual(report.text, fixture.message1.text);
});
});
it('report own message', function() {
return chatReportService.newReport(fixture.userBad1, fixture.message1.id)
.catch(function(err) {
assert.strictEqual(err.status, 403);
});
});
it('detected bad message', function() {
return chatReportService.newReport(fixture.user3, fixture.messageToReportOverThreshold1.id)
.then(function() {
assert(statsLog.some(function(entry) {
return entry === 'new_bad_message_from_reports';
}));
assert.strictEqual(hellbanUserSpy.callCount, 0);
assert.strictEqual(deleteMessageFromRoomSpy.callCount, 1);
});
});
it('detected bad user that is new will clear messages', function() {
return chatReportService.newReport(fixture.user3, fixture.messageToReportUserOverThreshold1.id)
.then(function() {
assert(statsLog.some(function(entry) {
return entry === 'new_bad_user_from_reports';
}));
assert.strictEqual(hellbanUserSpy.callCount, 1);
assert.strictEqual(removeAllMessagesForUserIdSpy.callCount, 1);
});
});
it('detected bad user that is old', function() {
return chatReportService.newReport(fixture.user3, fixture.messageToReportUserOldOverThreshold1.id)
.then(function() {
assert(statsLog.some(function(entry) {
return entry === 'new_bad_user_from_reports';
}));
assert.strictEqual(hellbanUserSpy.callCount, 1);
assert.strictEqual(removeAllMessagesForUserIdSpy.callCount, 0);
});
});
});
});
......@@ -27,6 +27,7 @@ const userService = require('gitter-web-users');
const chatSearchService = require('./chat-search-service');
const unreadItemService = require('gitter-web-unread-items');
const recentRoomService = require('gitter-web-rooms/lib/recent-room-service');
const troupeService = require('gitter-web-rooms/lib/troupe-service');
const markdownMajorVersion = require('gitter-markdown-processor').version.split('.')[0];
var useHints = true;
......@@ -550,16 +551,36 @@ function deleteMessage(message) {
});
}
function removeAllMessagesForUserIdInRoomId(userId, roomId) {
return ChatMessage.find({ toTroupeId: roomId, fromUserId: userId })
function removeAllMessagesForUserId(userId) {
return ChatMessage.find({ fromUserId: userId })
.exec()
.then(function(messages) {
return Promise.map(messages, (message) => deleteMessage(message), { concurrency: 1 });
logger.info('removeAllMessagesForUserId(' + userId + '): Removing ' + messages.length + ' messages');
const troupeMap = {};
// Clear any unreads and delete the messages
return Promise.map(messages, (function(message) {
const toTroupeId = message.toTroupeId;
return Promise.resolve(troupeMap[toTroupeId] || troupeService.findById(message.toTroupeId))
.then(function(troupe) {
troupeMap[toTroupeId] = troupe;
return deleteMessageFromRoom(troupe, message);