1 | 'use strict';
|
2 |
|
3 | var async = require('async');
|
4 | var winston = module.parent.require('winston');
|
5 | var XRegExp = require('xregexp');
|
6 | var validator = require('validator');
|
7 | var nconf = module.parent.require('nconf');
|
8 |
|
9 | var db = require.main.require('./src/database');
|
10 | var api = require.main.require('./src/api');
|
11 | var Topics = require.main.require('./src/topics');
|
12 | var posts = require.main.require('./src/posts');
|
13 | var User = require.main.require('./src/user');
|
14 | var Groups = require.main.require('./src/groups');
|
15 | var Notifications = require.main.require('./src/notifications');
|
16 | var Privileges = require.main.require('./src/privileges');
|
17 | var plugins = require.main.require('./src/plugins');
|
18 | var Meta = require.main.require('./src/meta');
|
19 | var slugify = require.main.require('./src/slugify');
|
20 | var batch = require.main.require('./src/batch');
|
21 | const utils = require.main.require('./public/src/utils');
|
22 |
|
23 | var SocketPlugins = require.main.require('./src/socket.io/plugins');
|
24 |
|
25 | const utility = require('./lib/utility');
|
26 |
|
27 | var regex = XRegExp('(?:^|\\s|\\>|;)(@[\\p{L}\\d\\-_.]+)', 'g');
|
28 | var isLatinMention = /@[\w\d\-_.]+$/;
|
29 | var removePunctuationSuffix = function(string) {
|
30 | return string.replace(/[!?.]*$/, '');
|
31 | };
|
32 | var Entities = require('html-entities').XmlEntities;
|
33 | var entities = new Entities();
|
34 |
|
35 | var Mentions = {
|
36 | _settings: {},
|
37 | _defaults: {
|
38 | disableFollowedTopics: 'off',
|
39 | autofillGroups: 'off',
|
40 | disableGroupMentions: '[]',
|
41 | overrideIgnores: 'off',
|
42 | display: '',
|
43 | }
|
44 | };
|
45 | SocketPlugins.mentions = {};
|
46 |
|
47 | Mentions.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 |
|
55 | Object.assign(Mentions._settings, Mentions._defaults, await Meta.settings.get('mentions'));
|
56 | };
|
57 |
|
58 | Mentions.addAdminNavigation = async (header) => {
|
59 | header.plugins.push({
|
60 | route: '/plugins/mentions',
|
61 | name: 'Mentions'
|
62 | });
|
63 |
|
64 | return header;
|
65 | };
|
66 |
|
67 | function 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 |
|
77 | Mentions.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 |
|
186 | async 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 |
|
191 | Mentions.addFilters = async (data) => {
|
192 | data.regularFilters.push({ name: '[[notifications:mentions]]', filter: 'mention' });
|
193 | return data;
|
194 | };
|
195 |
|
196 | Mentions.notificationTypes = async (data) => {
|
197 | data.types.push('notificationType_mention');
|
198 | return data;
|
199 | };
|
200 |
|
201 | Mentions.addFields = async (data) => {
|
202 | if (!Meta.config.hideFullname) {
|
203 | data.fields.push('fullname');
|
204 | }
|
205 | return data;
|
206 | };
|
207 |
|
208 | function 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 |
|
264 | function 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 |
|
287 | async 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 |
|
301 | Mentions.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 |
|
311 | Mentions.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 |
|
326 | return idx === matches.indexOf(cur);
|
327 | }).map(function(match) {
|
328 | |
329 |
|
330 |
|
331 |
|
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 |
|
356 | if (skip || (i & 1) === 1) {
|
357 | skip = c === '<code>';
|
358 | return c;
|
359 | }
|
360 | return c.replace(regex, function(match) {
|
361 |
|
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 |
|
389 | Mentions.clean = function(input, isMarkdown, stripBlockquote, stripCode) {
|
390 | var split = utility.split(input, isMarkdown, stripBlockquote, stripCode);
|
391 | split = split.filter(function(e, i) {
|
392 |
|
393 | return (i & 1) === 0;
|
394 | });
|
395 | return split.join('');
|
396 | };
|
397 |
|
398 |
|
399 |
|
400 |
|
401 | async function filterPrivilegedUids (uids, cid, toPid) {
|
402 | let toPidUid;
|
403 | if (toPid) {
|
404 | toPidUid = await posts.getPostField(toPid, 'uid');
|
405 | }
|
406 |
|
407 |
|
408 | uids = await Promise.all(uids.map(async (uid) => {
|
409 |
|
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),
|
417 | ]);
|
418 |
|
419 | return isAdmin || isMod ? false : uid;
|
420 | }));
|
421 |
|
422 | return uids.filter(Boolean);
|
423 | }
|
424 |
|
425 | async 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 |
|
430 | async 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 |
|
442 |
|
443 |
|
444 | SocketPlugins.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 |
|
453 | SocketPlugins.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 |
|
472 | SocketPlugins.mentions.userSearch = async (socket, data) => {
|
473 |
|
474 | const socketUser = require.main.require('./src/socket.io/user');
|
475 |
|
476 |
|
477 | let { users } = await api.users.search(socket, data);
|
478 |
|
479 | if (!Meta.config.hideFullname) {
|
480 |
|
481 | users = await stripDisallowedFullnames(users);
|
482 |
|
483 |
|
484 | let { users: fullnameUsers } = await api.users.search(socket, {query: data.query, searchBy: 'fullname'});
|
485 |
|
486 | fullnameUsers = await filterDisallowedFullnames(fullnameUsers);
|
487 |
|
488 |
|
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 |
|
508 | module.exports = Mentions;
|