UNPKG

15 kBJavaScriptView Raw
1'use strict';
2
3var async = require('async');
4var winston = module.parent.require('winston');
5var XRegExp = require('xregexp');
6var validator = require('validator');
7var nconf = module.parent.require('nconf');
8
9var db = require.main.require('./src/database');
10var api = require.main.require('./src/api');
11var Topics = require.main.require('./src/topics');
12var posts = require.main.require('./src/posts');
13var User = require.main.require('./src/user');
14var Groups = require.main.require('./src/groups');
15var Notifications = require.main.require('./src/notifications');
16var Privileges = require.main.require('./src/privileges');
17var plugins = require.main.require('./src/plugins');
18var Meta = require.main.require('./src/meta');
19var slugify = require.main.require('./src/slugify');
20var batch = require.main.require('./src/batch');
21const utils = require.main.require('./public/src/utils');
22
23var SocketPlugins = require.main.require('./src/socket.io/plugins');
24
25const utility = require('./lib/utility');
26
27var regex = XRegExp('(?:^|\\s|\\>|;)(@[\\p{L}\\d\\-_.]+)', 'g');
28var isLatinMention = /@[\w\d\-_.]+$/;
29var removePunctuationSuffix = function(string) {
30 return string.replace(/[!?.]*$/, '');
31};
32var Entities = require('html-entities').XmlEntities;
33var entities = new Entities();
34
35var Mentions = {
36 _settings: {},
37 _defaults: {
38 disableFollowedTopics: 'off',
39 autofillGroups: 'off',
40 disableGroupMentions: '[]',
41 overrideIgnores: 'off',
42 display: '',
43 }
44};
45SocketPlugins.mentions = {};
46
47Mentions.init = async (data) => {
48 var hostMiddleware = require.main.require('./src/middleware');
49 var controllers = require('./controllers');
50
51 data.router.get('/admin/plugins/mentions', hostMiddleware.admin.buildHeader, controllers.renderAdminPage);
52 data.router.get('/api/admin/plugins/mentions', controllers.renderAdminPage);
53
54 // Retrieve settings
55 Object.assign(Mentions._settings, Mentions._defaults, await Meta.settings.get('mentions'));
56};
57
58Mentions.addAdminNavigation = async (header) => {
59 header.plugins.push({
60 route: '/plugins/mentions',
61 name: 'Mentions'
62 });
63
64 return header;
65};
66
67function getNoMentionGroups() {
68 var noMentionGroups = ['registered-users', 'verified-users', 'unverified-users', 'guests'];
69 try {
70 noMentionGroups = noMentionGroups.concat(JSON.parse(Mentions._settings.disableGroupMentions));
71 } catch (err) {
72 winston.error(err);
73 }
74 return noMentionGroups;
75}
76
77Mentions.notify = function(data) {
78 var postData = data.post;
79 var cleanedContent = Mentions.clean(postData.content, true, true, true);
80 var matches = cleanedContent.match(regex);
81
82 if (!matches) {
83 return;
84 }
85
86 var noMentionGroups = getNoMentionGroups();
87
88 matches = matches.map(function(match) {
89 return slugify(match);
90 }).filter(function(match, index, array) {
91 return match && array.indexOf(match) === index && noMentionGroups.indexOf(match) === -1;
92 });
93
94 if (!matches.length) {
95 return;
96 }
97
98 async.parallel({
99 userRecipients: function(next) {
100 async.filter(matches, User.existsBySlug, next);
101 },
102 groupRecipients: function(next) {
103 async.filter(matches, Groups.existsBySlug, next);
104 }
105 }, function(err, results) {
106 if (err) {
107 return;
108 }
109
110 if (!results.userRecipients.length && !results.groupRecipients.length) {
111 return;
112 }
113
114 async.parallel({
115 topic: function(next) {
116 Topics.getTopicFields(postData.tid, ['title', 'cid'], next);
117 },
118 author: function(next) {
119 User.getUserField(postData.uid, 'username', next);
120 },
121 uids: function(next) {
122 async.map(results.userRecipients, function(slug, next) {
123 User.getUidByUserslug(slug, next);
124 }, next);
125 },
126 groupData: async function() {
127 return await getGroupMemberUids(results.groupRecipients);
128 },
129 topicFollowers: function(next) {
130 if (Mentions._settings.disableFollowedTopics === 'on') {
131 Topics.getFollowers(postData.tid, next);
132 } else {
133 next(null, []);
134 }
135 }
136 }, async (err, results) => {
137 if (err) {
138 return;
139 }
140
141 var title = entities.decode(results.topic.title);
142 var titleEscaped = title.replace(/%/g, '%').replace(/,/g, ',');
143
144 var uids = results.uids.map(String).filter(function(uid, index, array) {
145 return array.indexOf(uid) === index && parseInt(uid, 10) !== parseInt(postData.uid, 10) && !results.topicFollowers.includes(uid);
146 });
147
148 if (Mentions._settings.privilegedDirectReplies === 'on') {
149 const toPid = await posts.getPostField(data.post.pid, 'toPid');
150 uids = await filterPrivilegedUids(uids, data.post.cid, toPid);
151 }
152
153 var groupMemberUids = {};
154 results.groupData.groupNames.forEach(function(groupName, index) {
155 results.groupData.groupMembers[index] = results.groupData.groupMembers[index].filter(function(uid) {
156 if (!uid || groupMemberUids[uid]) {
157 return false;
158 }
159 groupMemberUids[uid] = 1;
160 return !uids.includes(uid) &&
161 parseInt(uid, 10) !== parseInt(postData.uid, 10) &&
162 !results.topicFollowers.includes(uid);
163 });
164 });
165
166 const filteredUids = await filterUidsAlreadyMentioned(uids, postData.pid);
167
168 if (filteredUids.length) {
169 sendNotificationToUids(postData, filteredUids, 'user', '[[notifications:user_mentioned_you_in, ' + results.author + ', ' + titleEscaped + ']]');
170 await db.setAdd(`mentions:pid:${postData.pid}:uids`, filteredUids);
171 }
172
173 for (let i = 0; i < results.groupData.groupNames.length; ++i) {
174 const memberUids = results.groupData.groupMembers[i];
175 const groupName = results.groupData.groupNames[i];
176 const groupMentionSent = await db.isSetMember(`mentions:pid:${postData.pid}:groups`, groupName);
177 if (!groupMentionSent && memberUids.length) {
178 sendNotificationToUids(postData, memberUids, groupName, '[[notifications:user_mentioned_group_in, ' + results.author + ', ' + groupName + ', ' + titleEscaped + ']]');
179 await db.setAdd(`mentions:pid:${postData.pid}:groups`, groupName);
180 }
181 };
182 });
183 });
184};
185
186async function filterUidsAlreadyMentioned(uids, pid) {
187 const isMember = await db.isSetMembers(`mentions:pid:${pid}:uids`, uids);
188 return uids.filter((uid, index) => !isMember[index]);
189}
190
191Mentions.addFilters = async (data) => {
192 data.regularFilters.push({ name: '[[notifications:mentions]]', filter: 'mention' });
193 return data;
194};
195
196Mentions.notificationTypes = async (data) => {
197 data.types.push('notificationType_mention');
198 return data;
199};
200
201Mentions.addFields = async (data) => {
202 if (!Meta.config.hideFullname) {
203 data.fields.push('fullname');
204 }
205 return data;
206};
207
208function sendNotificationToUids(postData, uids, nidType, notificationText) {
209 if (!uids.length) {
210 return;
211 }
212
213 var filteredUids = [];
214 var notification;
215 async.waterfall([
216 function (next) {
217 createNotification(postData, nidType, notificationText, next);
218 },
219 function (_notification, next) {
220 notification = _notification;
221 if (!notification) {
222 return next();
223 }
224
225 batch.processArray(uids, function (uids, next) {
226 async.waterfall([
227 function(next) {
228 Privileges.topics.filterUids('read', postData.tid, uids, next);
229 },
230 function(_uids, next) {
231 if (Mentions._settings.overrideIgnores === 'on') {
232 return setImmediate(next, null, _uids);
233 }
234
235 Topics.filterIgnoringUids(postData.tid, _uids, next);
236 },
237 function(_uids, next) {
238 if (!_uids.length) {
239 return next();
240 }
241
242 filteredUids = filteredUids.concat(_uids);
243
244 next();
245 }
246 ], next);
247 }, {
248 interval: 1000,
249 batch: 500,
250 }, next);
251 },
252 ], function (err) {
253 if (err) {
254 return winston.error(err);
255 }
256
257 if (notification && filteredUids.length) {
258 plugins.hooks.fire('action:mentions.notify', { notification, uids: filteredUids });
259 Notifications.push(notification, filteredUids);
260 }
261 });
262}
263
264function createNotification(postData, nidType, notificationText, callback) {
265 Topics.getTopicField(postData.tid, 'title', function (err, title) {
266 if (err) {
267 return callback(err);
268 }
269 if (title) {
270 title = utils.decodeHTMLEntities(title);
271 }
272 Notifications.create({
273 type: 'mention',
274 bodyShort: notificationText,
275 bodyLong: postData.content,
276 nid: 'tid:' + postData.tid + ':pid:' + postData.pid + ':uid:' + postData.uid + ':' + nidType,
277 pid: postData.pid,
278 tid: postData.tid,
279 from: postData.uid,
280 path: '/post/' + postData.pid,
281 topicTitle: title,
282 importance: 6,
283 }, callback);
284 });
285}
286
287async function getGroupMemberUids(groupRecipients) {
288 if (!groupRecipients.length) {
289 return { groupNames: [], groupMembers: [] };
290 }
291 const groupNames = Object.values(await db.getObjectFields('groupslug:groupname', groupRecipients));
292 const groupMembers = await Promise.all(groupNames.map(async (groupName) => {
293 if (!groupName) {
294 return [];
295 }
296 return db.getSortedSetRange(`group:${groupName}:members`, 0, 999);
297 }));
298 return { groupNames, groupMembers };
299}
300
301Mentions.parsePost = async (data) => {
302 if (!data || !data.postData || !data.postData.content) {
303 return data;
304 }
305
306 const parsed = await Mentions.parseRaw(data.postData.content);
307 data.postData.content = parsed;
308 return data;
309};
310
311Mentions.parseRaw = async (content) => {
312 let splitContent = utility.split(content, false, false, true);
313 var matches = [];
314 splitContent.forEach(function(cleanedContent, i) {
315 if ((i & 1) === 0) {
316 matches = matches.concat(cleanedContent.match(regex) || []);
317 }
318 });
319
320 if (!matches.length) {
321 return content;
322 }
323
324 matches = matches.filter(function(cur, idx) {
325 // Eliminate duplicates
326 return idx === matches.indexOf(cur);
327 }).map(function(match) {
328 /**
329 * Javascript-favour of regex does not support lookaround,
330 * so need to clean up the cruft by discarding everthing
331 * before the @
332 */
333 var atIndex = match.indexOf('@');
334 return atIndex !== 0 ? match.slice(atIndex) : match;
335 });
336
337 await Promise.all(matches.map(async (match) => {
338 var slug = slugify(match.slice(1));
339 match = removePunctuationSuffix(match);
340
341 const uid = await User.getUidByUserslug(slug);
342 const results = await utils.promiseParallel({
343 groupExists: Groups.existsBySlug(slug),
344 user: User.getUserFields(uid, ['uid', 'username', 'fullname']),
345 });
346
347 if (results.user.uid || results.groupExists) {
348 var regex = isLatinMention.test(match)
349 ? new RegExp('(?:^|\\s|\>|;)' + match + '\\b', 'g')
350 : new RegExp('(?:^|\\s|\>|;)' + match, 'g');
351
352 let skip = false;
353
354 splitContent = splitContent.map(function(c, i) {
355 // *Might* not be needed anymore? Check pls...
356 if (skip || (i & 1) === 1) {
357 skip = c === '<code>'; // if code block detected, skip the content inside of it
358 return c;
359 }
360 return c.replace(regex, function(match) {
361 // Again, cleaning up lookaround leftover bits
362 var atIndex = match.indexOf('@');
363 var plain = match.slice(0, atIndex);
364 match = match.slice(atIndex);
365 if (results.user.uid) {
366 switch (Mentions._settings.display) {
367 case 'fullname':
368 match = results.user.fullname || match;
369 break;
370 case 'username':
371 match = results.user.username;
372 break;
373 }
374 }
375
376 var str = results.user.uid
377 ? '<a class="plugin-mentions-user plugin-mentions-a" href="' + nconf.get('url') + '/uid/' + results.user.uid + '">' + match + '</a>'
378 : '<a class="plugin-mentions-group plugin-mentions-a" href="' + nconf.get('url') + '/groups/' + slug + '">' + match + '</a>';
379
380 return plain + str;
381 });
382 });
383 }
384 }));
385
386 return splitContent.join('');
387};
388
389Mentions.clean = function(input, isMarkdown, stripBlockquote, stripCode) {
390 var split = utility.split(input, isMarkdown, stripBlockquote, stripCode);
391 split = split.filter(function(e, i) {
392 // only keep non-code/non-blockquote
393 return (i & 1) === 0;
394 });
395 return split.join('');
396};
397
398/*
399 Local utility methods
400*/
401async function filterPrivilegedUids (uids, cid, toPid) {
402 let toPidUid;
403 if (toPid) {
404 toPidUid = await posts.getPostField(toPid, 'uid');
405 }
406
407 // Remove administrators, global mods, and moderators of the post's cid
408 uids = await Promise.all(uids.map(async (uid) => {
409 // Direct replies are a-ok.
410 if (uid === toPidUid) {
411 return uid;
412 }
413
414 const [isAdmin, isMod] = await Promise.all([
415 User.isAdministrator(uid),
416 User.isModerator(uid, cid), // covers gmod as well
417 ]);
418
419 return isAdmin || isMod ? false : uid;
420 }));
421
422 return uids.filter(Boolean);
423}
424
425async function filterDisallowedFullnames (users) {
426 const userSettings = await User.getMultipleUserSettings(users.map(user => user.uid));
427 return users.filter((user, index) => userSettings[index].showfullname);
428}
429
430async function stripDisallowedFullnames (users) {
431 const userSettings = await User.getMultipleUserSettings(users.map(user => user.uid));
432 return users.map((user, index) => {
433 if (!userSettings[index].showfullname) {
434 user.fullname = null;
435 }
436 return user;
437 });
438}
439
440/*
441 WebSocket methods
442*/
443
444SocketPlugins.mentions.getTopicUsers = async (socket, data) => {
445 const uids = await Topics.getUids(data.tid);
446 const users = await User.getUsers(uids);
447 if (Meta.config.hideFullname) {
448 return users;
449 }
450 return stripDisallowedFullnames(users);
451};
452
453SocketPlugins.mentions.listGroups = function(socket, data, callback) {
454 if (Mentions._settings.autofillGroups === 'off') {
455 return callback(null, []);
456 }
457
458 Groups.getGroups('groups:visible:createtime', 0, -1, function(err, groups) {
459 if (err) {
460 return callback(err);
461 }
462 var noMentionGroups = getNoMentionGroups();
463 groups = groups.filter(function(groupName) {
464 return groupName && !noMentionGroups.includes(groupName);
465 }).map(function(groupName) {
466 return validator.escape(groupName);
467 });
468 callback(null, groups);
469 });
470};
471
472SocketPlugins.mentions.userSearch = async (socket, data) => {
473 // Transparently pass request through to socket user.search handler
474 const socketUser = require.main.require('./src/socket.io/user');
475
476 // Search by username
477 let { users } = await api.users.search(socket, data);
478
479 if (!Meta.config.hideFullname) {
480 // Strip fullnames of users that do not allow their full name to be visible
481 users = await stripDisallowedFullnames(users);
482
483 // Search by fullname
484 let { users: fullnameUsers } = await api.users.search(socket, {query: data.query, searchBy: 'fullname'});
485 // Hide results of users that do not allow their full name to be visible (prevents "enumeration attack")
486 fullnameUsers = await filterDisallowedFullnames(fullnameUsers);
487
488 // Merge results, filter duplicates (from username search, leave fullname results)
489 users = users.filter(userObj =>
490 fullnameUsers.filter(userObj2 => userObj.uid === userObj2.uid).length === 0
491 ).concat(fullnameUsers);
492 }
493
494 if (Mentions._settings.privilegedDirectReplies !== 'on') {
495 return users;
496 }
497
498 if (data.composerObj) {
499 const cid = Topics.getTopicField(data.composerObj.tid, 'cid');
500 const filteredUids = await filterPrivilegedUids(users.map(userObj => userObj.uid), cid, data.composerObj.toPid);
501
502 users = users.filter((userObj) => filteredUids.includes(userObj.uid));
503 }
504
505 return users;
506};
507
508module.exports = Mentions;