nodebb-plugin-mentions 4.8.10 → 4.8.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/library.js CHANGED
@@ -10,7 +10,6 @@ const nconf = require.main.require('nconf');
10
10
  const winston = require.main.require('winston');
11
11
 
12
12
  const db = require.main.require('./src/database');
13
- const api = require.main.require('./src/api');
14
13
  const meta = require.main.require('./src/meta');
15
14
  const categories = require.main.require('./src/categories');
16
15
  const Topics = require.main.require('./src/topics');
@@ -27,12 +26,13 @@ const batch = require.main.require('./src/batch');
27
26
  const utils = require.main.require('./src/utils');
28
27
  const SocketPlugins = require.main.require('./src/socket.io/plugins');
29
28
  const translator = require.main.require('./src/translator');
29
+ const privileges = require.main.require('./src/privileges');
30
30
 
31
31
  const utility = require('./lib/utility');
32
32
 
33
33
  const parts = {
34
34
  before: '(?<=(^|\\P{L}))', // a single unicode non-letter character or start of line
35
- main: '(@[\\p{L}\\d\\-_.@]+)', // unicode letters, numbers, dashes, underscores, or periods
35
+ main: '(@[\\p{L}\\d\\-_.@]+(?<![.-]))', // unicode letters, numbers, dashes, underscores, or periods, negative lookbehind to guard against periods/dashes at end
36
36
  after: '((?=\\b)(?=[^-])|(?=[^\\p{L}\\d\\-_.@])|$)', // used to figure out where latin mentions end
37
37
  };
38
38
  const regex = RegExp(`${parts.before}${parts.main}`, 'gu');
@@ -40,10 +40,9 @@ const isLatinMention = /@[\w\d\-_.@]+$/;
40
40
 
41
41
  const Mentions = module.exports;
42
42
 
43
- Mentions._settings = {};
44
43
  Mentions._defaults = {
45
44
  disableFollowedTopics: 'off',
46
- autofillGroups: 'off',
45
+ autofillGroups: 'on',
47
46
  disableGroupMentions: '[]',
48
47
  overrideIgnores: 'off',
49
48
  display: '',
@@ -57,11 +56,13 @@ Mentions.init = async (data) => {
57
56
  const controllers = require('./controllers');
58
57
 
59
58
  routeHelpers.setupAdminPageRoute(data.router, '/admin/plugins/mentions', controllers.renderAdminPage);
60
-
61
- // Retrieve settings
62
- Object.assign(Mentions._settings, Mentions._defaults, await Meta.settings.get('mentions'));
63
59
  };
64
60
 
61
+ async function getSettings() {
62
+ const settings = await Meta.settings.get('mentions');
63
+ return { ...Mentions._defaults, ...settings };
64
+ }
65
+
65
66
  Mentions.addAdminNavigation = async (header) => {
66
67
  header.plugins.push({
67
68
  route: '/plugins/mentions',
@@ -71,10 +72,11 @@ Mentions.addAdminNavigation = async (header) => {
71
72
  return header;
72
73
  };
73
74
 
74
- function getNoMentionGroups() {
75
- let noMentionGroups = ['registered-users', 'verified-users', 'unverified-users', 'guests'];
75
+ async function getNoMentionGroups() {
76
+ let noMentionGroups = ['registered-users', 'verified-users', 'unverified-users', 'guests', 'banned-users'];
76
77
  try {
77
- noMentionGroups = noMentionGroups.concat(JSON.parse(Mentions._settings.disableGroupMentions));
78
+ const settings = await getSettings();
79
+ noMentionGroups = noMentionGroups.concat(JSON.parse(settings.disableGroupMentions));
78
80
  } catch (err) {
79
81
  winston.error(err);
80
82
  }
@@ -93,7 +95,7 @@ Mentions.notify = async function ({ post }) {
93
95
  return;
94
96
  }
95
97
 
96
- const noMentionGroups = getNoMentionGroups();
98
+ const noMentionGroups = await getNoMentionGroups();
97
99
  matches = _.uniq(
98
100
  matches
99
101
  .map(match => slugify(match.slice(1)))
@@ -129,11 +131,11 @@ Mentions.notify = async function ({ post }) {
129
131
  if ((!uidsToNotify && !groupsToNotify) || (!uidsToNotify.length && !groupsToNotify.length)) {
130
132
  return;
131
133
  }
132
-
134
+ const settings = await getSettings();
133
135
  const [topic, userData, topicFollowers] = await Promise.all([
134
136
  Topics.getTopicFields(post.tid, ['title', 'cid']),
135
137
  User.getUserFields(post.uid, ['username']),
136
- Mentions._settings.disableFollowedTopics === 'on' ? Topics.getFollowers(post.tid) : [],
138
+ settings.disableFollowedTopics === 'on' ? Topics.getFollowers(post.tid) : [],
137
139
  ]);
138
140
  const { displayname } = userData;
139
141
  const title = entitiesDecode(topic.title);
@@ -142,7 +144,7 @@ Mentions.notify = async function ({ post }) {
142
144
  uid => uid !== postOwner && !topicFollowers.includes(uid)
143
145
  );
144
146
 
145
- if (Mentions._settings.privilegedDirectReplies === 'on') {
147
+ if (settings.privilegedDirectReplies === 'on') {
146
148
  const toPid = await posts.getPostField(post.pid, 'toPid');
147
149
  uids = await filterPrivilegedUids(uids, post.cid, toPid);
148
150
  }
@@ -298,10 +300,10 @@ async function sendNotificationToUids(postData, uids, nidType, notificationText)
298
300
  if (!notification) {
299
301
  return;
300
302
  }
301
-
303
+ const settings = await getSettings();
302
304
  await batch.processArray(uids, async (uids) => {
303
305
  uids = await Privileges.topics.filterUids('read', postData.tid, uids);
304
- if (Mentions._settings.overrideIgnores !== 'on') {
306
+ if (settings.overrideIgnores !== 'on') {
305
307
  uids = await Topics.filterIgnoringUids(postData.tid, uids);
306
308
  }
307
309
  filteredUids.push(...uids);
@@ -423,6 +425,8 @@ Mentions.parseRaw = async (content, type = 'default') => {
423
425
  return content;
424
426
  }
425
427
 
428
+ const settings = await getSettings();
429
+
426
430
  matches = _.uniq(matches).map((match) => {
427
431
  /**
428
432
  * Javascript-flavour of regex does not support lookaround,
@@ -434,6 +438,7 @@ Mentions.parseRaw = async (content, type = 'default') => {
434
438
  });
435
439
 
436
440
  // Convert matches to anchor html
441
+ let replacements = new Set();
437
442
  await Promise.all(matches.map(async (match) => {
438
443
  const slug = slugify(match.slice(1));
439
444
  match = removePunctuationSuffix(match);
@@ -475,6 +480,16 @@ Mentions.parseRaw = async (content, type = 'default') => {
475
480
  }
476
481
  }
477
482
 
483
+ replacements.add({ match, url, user, mentionType });
484
+ }
485
+ }));
486
+
487
+
488
+ replacements = Array.from(replacements)
489
+ .sort((a, b) => {
490
+ return b.user.userslug.length - a.user.userslug.length;
491
+ })
492
+ .forEach(({ match, url, user, mentionType }) => {
478
493
  const regex = isLatinMention.test(match) ?
479
494
  RegExp(`${parts.before}${match}${parts.after}`, 'gu') :
480
495
  RegExp(`${parts.before}${match}`, 'gu');
@@ -492,7 +507,7 @@ Mentions.parseRaw = async (content, type = 'default') => {
492
507
  const plain = match.slice(0, atIndex);
493
508
  match = match.slice(atIndex + 1);
494
509
  if (user && user.uid) {
495
- switch (Mentions._settings.display) {
510
+ switch (settings.display) {
496
511
  case 'fullname':
497
512
  match = user.displayname || match;
498
513
  break;
@@ -504,16 +519,15 @@ Mentions.parseRaw = async (content, type = 'default') => {
504
519
 
505
520
  let str;
506
521
  if (type === 'markdown') {
507
- str = `[${!Mentions._settings.display ? '@' : ''}${match}](${nconf.get('url')}${url})`;
522
+ str = `[${!settings.display ? '@' : ''}${match}](${nconf.get('url')}${url})`;
508
523
  } else {
509
- str = `<a class="plugin-mentions-${mentionType} plugin-mentions-a" href="${nconf.get('relative_path')}${url}" aria-label="Profile: ${match}">${!Mentions._settings.display ? '@' : ''}<bdi>${match}</bdi></a>`;
524
+ str = `<a class="plugin-mentions-${mentionType} plugin-mentions-a" href="${nconf.get('relative_path')}${url}" aria-label="Profile: ${match}">${!settings.display ? '@' : ''}<bdi>${match}</bdi></a>`;
510
525
  }
511
526
 
512
527
  return plain + str;
513
528
  });
514
529
  });
515
- }
516
- }));
530
+ });
517
531
 
518
532
  return splitContent.join('');
519
533
  };
@@ -553,17 +567,18 @@ async function filterPrivilegedUids(uids, cid, toPid) {
553
567
  }
554
568
 
555
569
  async function filterDisallowedFullnames(users) {
570
+ if (!users.length) return [];
556
571
  const userSettings = await User.getMultipleUserSettings(users.map(user => user.uid));
557
- return users.filter((user, index) => userSettings[index].showfullname);
572
+ return users.filter((user, index) => userSettings[index] && userSettings[index].showfullname);
558
573
  }
559
574
 
560
575
  async function stripDisallowedFullnames(users) {
576
+ if (!users.length) return [];
561
577
  const userSettings = await User.getMultipleUserSettings(users.map(user => user.uid));
562
- return users.map((user, index) => {
563
- if (!userSettings[index].showfullname) {
578
+ users.forEach((user, index) => {
579
+ if (user && userSettings[index] && !userSettings[index].showfullname) {
564
580
  user.fullname = null;
565
581
  }
566
- return user;
567
582
  });
568
583
  }
569
584
 
@@ -578,39 +593,62 @@ SocketPlugins.mentions.getTopicUsers = async (socket, data) => {
578
593
  if (Meta.config.hideFullname) {
579
594
  return users;
580
595
  }
581
- return stripDisallowedFullnames(users);
596
+ await stripDisallowedFullnames(users);
597
+ return users;
582
598
  };
583
599
 
584
600
  SocketPlugins.mentions.listGroups = async function () {
585
- if (Mentions._settings.autofillGroups === 'off') {
601
+ const settings = await getSettings();
602
+ if (settings.autofillGroups === 'off') {
586
603
  return [];
587
604
  }
588
605
 
589
606
  const groups = await Groups.getGroups('groups:visible:createtime', 0, -1);
590
- const noMentionGroups = getNoMentionGroups();
591
- return groups.filter(g => g && !noMentionGroups.includes(g)).map(g => validator.escape(String(g)));
607
+ const noMentionGroups = await getNoMentionGroups();
608
+ const filteredGroups = groups.filter(g => g && !noMentionGroups.includes(g))
609
+ .map(g => validator.escape(String(g)));
610
+
611
+ return filteredGroups;
592
612
  };
593
613
 
594
614
  SocketPlugins.mentions.userSearch = async (socket, data) => {
595
- // Search by username
596
- let { users } = await api.users.search(socket, data);
615
+ const allowed = await privileges.global.can('search:users', socket.uid);
616
+ if (!allowed) {
617
+ throw new Error('[[error:no-privileges]]');
618
+ }
597
619
 
598
- if (!Meta.config.hideFullname) {
599
- // Strip fullnames of users that do not allow their full name to be visible
600
- users = await stripDisallowedFullnames(users);
620
+ const searchOpts = {
621
+ uid: socket.uid,
622
+ query: data.query,
623
+ sortBy: 'postcount',
624
+ hardCap: 1000,
625
+ paginate: true,
626
+ resultsPerPage: 100,
627
+ };
628
+
629
+ const [byUsername, byFullname, settings] = await Promise.all([
630
+ User.search({ ...searchOpts, searchBy: 'username' }),
631
+ !Meta.config.hideFullname ?
632
+ User.search({ ...searchOpts, searchBy: 'fullname' }) :
633
+ Promise.resolve({ users : [] }),
634
+ getSettings(),
635
+ ]);
636
+
637
+ let { users } = byUsername;
601
638
 
602
- // Search by fullname
603
- let { users: fullnameUsers } = await api.users.search(socket, { query: data.query, searchBy: 'fullname' });
639
+ const [fullnameUsers] = await Promise.all([
604
640
  // Hide results of users that do not allow their full name to be visible (prevents "enumeration attack")
605
- fullnameUsers = await filterDisallowedFullnames(fullnameUsers);
641
+ filterDisallowedFullnames(byFullname.users),
642
+ // Strip fullnames of users that do not allow their full name to be visible
643
+ stripDisallowedFullnames(users),
644
+ ]);
606
645
 
607
- // Merge results, filter duplicates (from username search, leave fullname results)
608
- users = users.filter(
609
- userObj => fullnameUsers.filter(userObj2 => userObj.uid === userObj2.uid).length === 0
610
- ).concat(fullnameUsers);
611
- }
646
+ // Merge results, filter duplicates (from username search, leave fullname results)
647
+ const fullnameUidSet = new Set(fullnameUsers.map(u => u.uid));
648
+ users = users.filter(u => !fullnameUidSet.has(u.uid)).concat(fullnameUsers);
649
+ users.sort((a, b) => b.postcount - a.postcount);
612
650
 
613
- if (Mentions._settings.privilegedDirectReplies === 'on') {
651
+ if (settings.privilegedDirectReplies === 'on') {
614
652
  if (data.composerObj) {
615
653
  const cid = Topics.getTopicField(data.composerObj.tid, 'cid');
616
654
  const filteredUids = await filterPrivilegedUids(users.map(userObj => userObj.uid), cid, data.composerObj.toPid);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-mentions",
3
- "version": "4.8.10",
3
+ "version": "4.8.12",
4
4
  "description": "NodeBB Plugin that allows users to mention other users by prepending an '@' sign to their username",
5
5
  "main": "library.js",
6
6
  "repository": {
@@ -30,8 +30,8 @@
30
30
  "validator": "^13.0.0"
31
31
  },
32
32
  "devDependencies": {
33
- "eslint": "^9.25.1",
34
- "eslint-config-nodebb": "^1.1.4",
33
+ "eslint": "^10.0.0",
34
+ "eslint-config-nodebb": "^2.0.0",
35
35
  "eslint-plugin-import": "^2.31.0",
36
36
  "mocha": "11.7.5"
37
37
  }
package/static/admin.js CHANGED
@@ -6,30 +6,12 @@ define('admin/plugins/mentions', ['settings', 'alerts'], function (Settings, ale
6
6
  ACP.init = function () {
7
7
  Settings.load('mentions', $('.mentions-settings'));
8
8
 
9
- $(window).on('action:admin.settingsLoaded', applyDefaults);
10
-
11
9
  $('#save').on('click', function () {
12
10
  Settings.save('mentions', $('.mentions-settings'), function () {
13
- alerts.alert({
14
- type: 'success',
15
- alert_id: 'mentions-saved',
16
- title: 'Settings Saved',
17
- message: 'Please reload your NodeBB to apply these settings',
18
- timeout: 5000,
19
- clickfn: function () {
20
- socket.emit('admin.reload');
21
- },
22
- });
11
+ alerts.success('Settings Saved');
23
12
  });
24
13
  });
25
14
  };
26
15
 
27
- function applyDefaults() {
28
- if (!ajaxify.data.settings || !ajaxify.data.settings.hasOwnProperty('autofillGroups')) {
29
- $('input#autofillGroups').parents('.mdl-switch').toggleClass('is-checked', false);
30
- $('input#autofillGroups').prop('checked', false);
31
- }
32
- }
33
-
34
16
  return ACP;
35
17
  });
@@ -8,6 +8,12 @@ $(document).ready(function () {
8
8
  let localUserList = [];
9
9
  let helpers;
10
10
 
11
+ function showAlert(type, message) {
12
+ require(['alerts'], function (alerts) {
13
+ alerts[type](message);
14
+ });
15
+ }
16
+
11
17
  $(window).on('composer:autocomplete:init chat:autocomplete:init', function (ev, data) {
12
18
  loadTopicUsers(data.element);
13
19
 
@@ -36,37 +42,30 @@ $(document).ready(function () {
36
42
  composerObj: composer.posts[uuid],
37
43
  }, function (err, users) {
38
44
  if (err) {
39
- require(['alerts'], function (alerts) {
40
- alerts.alert({
41
- id: 'mention-error',
42
- type: 'danger',
43
- message: err.message,
44
- timeout: 5000,
45
- });
46
- });
45
+ showAlert('error', err.message);
47
46
  return callback([]);
48
47
  }
49
48
  const termLowerCase = term.toLocaleLowerCase();
50
49
  const localMatches = localUserList.filter(
51
50
  u => u.username.toLocaleLowerCase().startsWith(termLowerCase)
52
51
  );
53
- const categoryMatches = categoryList.filter(c => c && c.handle && c.handle.startsWith(termLowerCase));
52
+ const categoryMatches = categoryList.filter(
53
+ c => c && c.handle && c.handle.startsWith(termLowerCase)
54
+ );
54
55
 
55
56
  // remove local matches from search results, add category matches
56
57
  users = users.filter(u => !localMatches.find(lu => lu.uid === u.uid));
57
- users = sortEntries(localMatches).concat(sortEntries([...users, ...categoryMatches]));
58
- // mentions = entriesToMentions(users, helpers);
58
+
59
+ users = sortEntries(localMatches).concat(users).concat(sortEntries(categoryMatches));
59
60
 
60
61
  // Add groups that start with the search term
61
- const groupMentions = groupList.filter(function (groupName) {
62
- return groupName.toLocaleLowerCase().startsWith(termLowerCase);
63
- }).sort(function (a, b) {
64
- return a.toLocaleLowerCase() > b.toLocaleLowerCase() ? 1 : -1;
65
- });
62
+ const groupMentions = groupList.filter(
63
+ group => group.name.toLocaleLowerCase().startsWith(termLowerCase) ||
64
+ group.slug.startsWith(termLowerCase)
65
+ ).sort((a, b) =>a.name.toLocaleLowerCase() > b.name.toLocaleLowerCase() ? 1 : -1)
66
+ .map(group => group.name);
66
67
 
67
68
  // Add group mentions at the bottom of dropdown
68
- // mentions = mentions.concat(groupMentions);
69
-
70
69
  callback([...users, ...groupMentions]);
71
70
  });
72
71
  });
@@ -86,6 +85,12 @@ $(document).ready(function () {
86
85
  };
87
86
 
88
87
  data.strategies.push(strategy);
88
+ data.options = {
89
+ ...data.options,
90
+ ...{
91
+ maxCount: 100,
92
+ },
93
+ };
89
94
  });
90
95
 
91
96
  $(window).on('action:composer.loaded', function (ev, data) {
@@ -121,7 +126,7 @@ $(document).ready(function () {
121
126
  }
122
127
 
123
128
  function loadTopicUsers(element) {
124
- require(['composer', 'alerts'], function (composer, alerts) {
129
+ require(['composer'], function (composer) {
125
130
  function findTid() {
126
131
  const composerEl = element.parents('.composer').get(0);
127
132
  if (composerEl) {
@@ -146,7 +151,7 @@ $(document).ready(function () {
146
151
  tid: tid,
147
152
  }, function (err, users) {
148
153
  if (err) {
149
- return alerts.error(err);
154
+ return showAlert('error', err);
150
155
  }
151
156
  localUserList = users;
152
157
  });
@@ -154,14 +159,12 @@ $(document).ready(function () {
154
159
  }
155
160
 
156
161
  function loadGroupList() {
157
- socket.emit('plugins.mentions.listGroups', function (err, groupNames) {
162
+ socket.emit('plugins.mentions.listGroups', async function (err, groupNames) {
158
163
  if (err) {
159
- require(['alerts'], function (alerts) {
160
- alerts.error(err);
161
- });
162
- return;
164
+ return showAlert('error', err.message);
163
165
  }
164
- groupList = groupNames;
166
+ const s = await app.require('slugify');
167
+ groupList = groupNames.map(name => ({ name, slug: s(name) }));
165
168
  });
166
169
  }
167
170
 
package/test/index.js CHANGED
@@ -38,7 +38,7 @@ describe('regex', () => {
38
38
  it('should match a mention in all test strings', () => {
39
39
  const matches = string.match(matcher);
40
40
  assert(matches, `@testUser was not found in this string: ${string}`);
41
- assert.equal(slugify(matches[0]), 'testuser');
41
+ assert(['@testuser', '@testuser.'].includes(slugify(matches[0])));
42
42
  });
43
43
  });
44
44
 
@@ -163,7 +163,7 @@ describe('parser', () => {
163
163
  beforeEach(async () => {
164
164
  slug = utils.generateUUID().slice(0, 10);
165
165
  uid = await user.create({ username: slug });
166
- emailUid = await user.create({ username: `${slug}@test.nodebb.org` });
166
+ // emailUid = await user.create({ username: `${slug}@test.nodebb.org` });
167
167
  });
168
168
 
169
169
  it('should properly parse both users even if one user\'s username is a subset of the other', async () => {
@@ -172,7 +172,7 @@ describe('parser', () => {
172
172
 
173
173
  const html = await main.parseRaw(md);
174
174
 
175
- assert.strictEqual(html, `This sentence contains two mentions: <a class="plugin-mentions-user plugin-mentions-a" href="http://127.0.0.1:4567/uid/1">@${slug}</a> and <a class="plugin-mentions-user plugin-mentions-a" href="http://127.0.0.1:4567/uid/2">@${slug}-two</a>`);
175
+ assert.strictEqual(html, `This sentence contains two mentions: <a class="plugin-mentions-user plugin-mentions-a" href="/user/${slug}" aria-label="Profile: ${slug}">@<bdi>${slug}</bdi></a> and <a class="plugin-mentions-user plugin-mentions-a" href="/user/${slug}-two" aria-label="Profile: ${slug}-two">@<bdi>${slug}-two</bdi></a>`);
176
176
  });
177
177
 
178
178
  strings.forEach((string) => {
@@ -180,8 +180,9 @@ describe('parser', () => {
180
180
  const index = string.indexOf('@testUser');
181
181
  let check = string;
182
182
  if (!index || string[index - 1] !== '>') {
183
- check = string.replace(/@testUser/g, `<a class="plugin-mentions-user plugin-mentions-a" href="http://127.0.0.1:4567/uid/${uid}">@${slug}</a>`);
184
- string = string.replace(/testUser/g, slug);
183
+ check = string.replace(/@testUser\.?/g, `<a class="plugin-mentions-user plugin-mentions-a" href="/user/${slug}" aria-label="Profile: ${slug}">@<bdi>${slug}</bdi></a>`);
184
+ // check = string.replace(/@testUser/g, `<a class="plugin-mentions-user plugin-mentions-a" href="http://127.0.0.1:4567/uid/${uid}">@${slug}</a>`);
185
+ string = string.replace(/testUser\.?/g, slug);
185
186
  }
186
187
  const html = await main.parseRaw(string);
187
188
 
@@ -205,3 +206,15 @@ describe('parser', () => {
205
206
  });
206
207
  });
207
208
  });
209
+
210
+ describe('.getMatches() edge cases', () => {
211
+ before(async function () {
212
+ this.slug = slugify(utils.generateUUID().slice(0, 8));
213
+ this.uid = await user.create({ username: this.slug });
214
+ });
215
+
216
+ it('should match a mention at the end of a sentence', async function () {
217
+ const matches = await main.getMatches(`test sentence @${this.slug}.`);
218
+ console.log(matches);
219
+ });
220
+ });