nodebb-plugin-poll 4.0.14 → 4.1.0

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.
Files changed (41) hide show
  1. package/.editorconfig +7 -7
  2. package/LICENSE +20 -20
  3. package/eslint.config.mjs +7 -0
  4. package/languages/cs/poll.json +47 -47
  5. package/languages/de/poll.json +1 -1
  6. package/languages/en_GB/poll.json +1 -0
  7. package/languages/en_US/poll.json +52 -52
  8. package/languages/es/poll.json +39 -39
  9. package/languages/fr/poll.json +1 -1
  10. package/languages/gl/poll.json +38 -38
  11. package/languages/he/poll.json +1 -1
  12. package/languages/it/poll.json +1 -1
  13. package/languages/pl/poll.json +1 -1
  14. package/languages/pt-PT/poll.json +41 -41
  15. package/languages/ro/poll.json +39 -39
  16. package/languages/ru/poll.json +1 -1
  17. package/languages/tr/poll.json +47 -47
  18. package/lib/config.js +12 -1
  19. package/lib/hooks.js +231 -14
  20. package/lib/nodebb.js +0 -0
  21. package/lib/poll.js +147 -0
  22. package/lib/sockets.js +117 -12
  23. package/lib/vote.js +4 -0
  24. package/library.js +1 -1
  25. package/package-lock.json +1658 -0
  26. package/package.json +2 -2
  27. package/plugin.json +2 -0
  28. package/public/js/admin.js +0 -0
  29. package/public/js/poll/creator.js +26 -4
  30. package/public/js/poll/main.js +19 -8
  31. package/public/js/poll/view.js +31 -18
  32. package/templates/admin/plugins/poll.tpl +10 -11
  33. package/templates/poll/creator.tpl +14 -12
  34. package/templates/poll/manage.tpl +1 -1
  35. package/templates/poll/view/messages.tpl +2 -2
  36. package/templates/poll/view/results.tpl +9 -3
  37. package/templates/poll/view/voting.tpl +5 -5
  38. package/templates/poll/view.tpl +8 -8
  39. package/test/helpers.js +49 -0
  40. package/test/index.js +530 -0
  41. package/upgrades/fix_incorrect_poll_privilege.js +15 -15
package/lib/sockets.js CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const nconf = nodebb.require('nconf');
3
4
  const _ = nodebb.require('lodash');
4
5
  const db = nodebb.require('./src/database');
5
6
  const posts = nodebb.require('./src/posts');
@@ -15,7 +16,7 @@ const Sockets = exports;
15
16
 
16
17
  Sockets.get = async function (socket, data) {
17
18
  const settings = await Config.getSettings();
18
- const allowAnon = settings.allowGuestsToViewResults === 'on';
19
+ const allowAnon = !!settings.allowGuestsToViewResults;
19
20
 
20
21
  if (!data) {
21
22
  throw new Error('Invalid request, request data is not defined');
@@ -27,13 +28,13 @@ Sockets.get = async function (socket, data) {
27
28
  return [];
28
29
  }
29
30
  return await Promise.all(data.pollIds.map(async (pollId) => {
30
- const withVotes = allowAnon || socket.uid > 0;
31
+ const withVotes = !!socket.uid;
31
32
  const pollData = await Poll.get(pollId, socket.uid, withVotes);
32
33
  if (!pollData) {
33
34
  return null;
34
35
  }
35
36
 
36
- if (parseInt(pollData.info.allowAnonVoting, 10) === 1) {
37
+ if (withVotes && parseInt(pollData.info.allowAnonVoting, 10) === 1) {
37
38
  await anonymizeVoters(socket.uid, pollId, pollData.options);
38
39
  }
39
40
 
@@ -76,6 +77,13 @@ Sockets.vote = async function (socket, data) {
76
77
 
77
78
  await Vote.add(data);
78
79
 
80
+ // Federate the vote out (FEP-9967) if this is a remote poll
81
+ if (parseInt(pollData.remote, 10) === 1) {
82
+ await federateVoteChange({
83
+ pollData, socket, voteAnon: data.voteAnon, removedOptionIds: [], addedOptionIds: data.options,
84
+ });
85
+ }
86
+
79
87
  const returnPoll = await Poll.get(data.pollId, socket.uid, false);
80
88
  NodeBB.SocketIndex.server.sockets.emit('event:poll.voteChange', {
81
89
  pollData: returnPoll,
@@ -114,7 +122,20 @@ Sockets.updateVote = async function (socket, data) {
114
122
  throw new Error('Invalid option');
115
123
  }
116
124
 
125
+ // Capture the prior vote so we can federate the diff (remote polls only)
126
+ const isRemote = parseInt(pollData.remote, 10) === 1;
127
+ const oldOptions = isRemote ? (await Vote.get({ uid: socket.uid, pollId: data.pollId })).options : [];
128
+
117
129
  await Vote.update(data);
130
+
131
+ // Federate the vote change out (FEP-9967) if this is a remote poll
132
+ if (isRemote) {
133
+ const oldSet = oldOptions.map(String);
134
+ const removedOptionIds = oldOptions.filter(id => !data.options.map(String).includes(String(id)));
135
+ const addedOptionIds = data.options.filter(id => !oldSet.includes(String(id)));
136
+ await federateVoteChange({ pollData, socket, voteAnon: data.voteAnon, removedOptionIds, addedOptionIds });
137
+ }
138
+
118
139
  const returnPoll = await Poll.get(data.pollId, socket.uid, false);
119
140
  NodeBB.SocketIndex.server.sockets.emit('event:poll.voteChange', {
120
141
  pollData: returnPoll,
@@ -136,29 +157,39 @@ Sockets.removeVote = async function (socket, data) {
136
157
  throw new Error('Can\'t remove vote');
137
158
  }
138
159
 
160
+ const pollData = await db.getObject(`poll:${data.pollId}`);
161
+ const isRemote = parseInt(pollData.remote, 10) === 1;
162
+ // Capture the prior vote + anon flag before removal (remote polls only)
163
+ const oldOptions = isRemote ? (await Vote.get({ uid: socket.uid, pollId: data.pollId })).options : [];
164
+ const voteAnon = isRemote ? await db.isSortedSetMember(`poll:${data.pollId}:anon:voters`, socket.uid) : false;
165
+
139
166
  await Vote.remove(data);
140
- const pollData = await Poll.get(data.pollId, socket.uid, false);
167
+
168
+ // Federate the vote removal out (FEP-9967) if this is a remote poll
169
+ if (isRemote) {
170
+ await federateVoteChange({ pollData, socket, voteAnon, removedOptionIds: oldOptions, addedOptionIds: [] });
171
+ }
172
+
173
+ const returnPoll = await Poll.get(data.pollId, socket.uid, false);
141
174
  NodeBB.SocketIndex.server.sockets.emit('event:poll.voteChange', {
142
- pollData,
175
+ pollData: returnPoll,
143
176
  uid: socket.uid,
144
177
  });
145
178
  };
146
179
 
147
180
  Sockets.getOptionDetails = async function (socket, data) {
148
- if (!data || isNaN(parseInt(data.pollId, 10)) || isNaN(parseInt(data.optionId, 10))) {
181
+ if (!socket.uid || !data || isNaN(parseInt(data.pollId, 10)) || isNaN(parseInt(data.optionId, 10))) {
149
182
  throw new Error('Invalid request');
150
183
  }
151
- const [poll, option, settings] = await Promise.all([
184
+ const [poll, option] = await Promise.all([
152
185
  Poll.getInfo(data.pollId),
153
186
  Poll.getOption(data.pollId, data.optionId, true),
154
- Config.getSettings(),
155
187
  ]);
156
188
  if (!poll) {
157
189
  throw new Error('[[error:invalid-data]]');
158
190
  }
159
- const allowAnon = settings.allowGuestsToViewResults === 'on';
160
191
  const canRead = await privileges.posts.can('topics:read', poll.pid, socket.uid);
161
- if (!canRead || (!allowAnon && socket.uid <= 0)) {
192
+ if (!canRead) {
162
193
  throw new Error('[[error:no-privileges]]');
163
194
  }
164
195
  if (!option || !option.votes || !option.votes.length) {
@@ -170,7 +201,7 @@ Sockets.getOptionDetails = async function (socket, data) {
170
201
  }
171
202
 
172
203
  const userData = await NodeBB.User.getUsersFields(option.votes, [
173
- 'uid', 'username', 'userslug', 'picture',
204
+ 'uid', 'username', 'userslug', 'picture', 'displayname', 'icon:text', 'icon:bgColor',
174
205
  ]);
175
206
  option.votes = userData;
176
207
 
@@ -205,7 +236,7 @@ async function anonymizeVoters(callerUid, pollId, options) {
205
236
  }
206
237
  const uidToIsAnon = _.zipObject(uids, isAnon);
207
238
  options.forEach((opt) => {
208
- opt.votes = opt.votes.map(
239
+ opt.votes = (opt.votes || []).map(
209
240
  uid => (uidToIsAnon[uid] && String(callerUid) !== uid ? 0 : uid)
210
241
  );
211
242
  });
@@ -218,3 +249,77 @@ async function checkPrivs(cid, socketUid) {
218
249
  }
219
250
  return true;
220
251
  }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // FEP-9967 outbound vote federation
255
+ // ---------------------------------------------------------------------------
256
+
257
+ // Build a FEP-9967 vote Note. Stable, idempotent id so re-sends and Undos
258
+ // dedupe remotely.
259
+ function buildVoteNote({ voterUrl, questionId, optionName, pollAuthorUrl }) {
260
+ const voteId = `${voterUrl}#activity/vote/${encodeURIComponent(questionId)}/${encodeURIComponent(optionName)}`;
261
+ return {
262
+ id: voteId,
263
+ type: 'Note',
264
+ attributedTo: voterUrl,
265
+ inReplyTo: questionId,
266
+ name: optionName,
267
+ to: [pollAuthorUrl],
268
+ };
269
+ }
270
+
271
+ // Federate a vote change (additions and/or removals) for a remote poll.
272
+ // Anonymous votes are not federated (FEP votes are attributed to the voter
273
+ // actor, which anon votes lack).
274
+ async function federateVoteChange({ pollData, socket, voteAnon, removedOptionIds, addedOptionIds }) {
275
+ if (voteAnon) {
276
+ return;
277
+ }
278
+ if (!removedOptionIds.length && !addedOptionIds.length) {
279
+ return;
280
+ }
281
+
282
+ const activitypub = nodebb.require('./src/activitypub');
283
+ const options = JSON.parse(pollData.options || '[]');
284
+ const [questionId, pollAuthorUrl] = await Promise.all([
285
+ posts.getPostField(pollData.pid, 'url'), // remote Question id (a URL)
286
+ posts.getPostField(pollData.pid, 'uid'), // remote actor URL (poll author)
287
+ ]);
288
+ const voterUrl = `${nconf.get('url')}/uid/${socket.uid}`;
289
+
290
+ const toName = (id) => {
291
+ const opt = options.find(o => String(o.id) === String(id));
292
+ return opt ? opt.title : null;
293
+ };
294
+
295
+ // Undo removed options
296
+ for (const id of removedOptionIds) {
297
+ const name = toName(id);
298
+ if (!name) continue;
299
+ const voteNote = buildVoteNote({ voterUrl, questionId, optionName: name, pollAuthorUrl });
300
+ // eslint-disable-next-line no-await-in-loop
301
+ await activitypub.send('uid', socket.uid, [pollAuthorUrl], {
302
+ id: `${voteNote.id}#undo`,
303
+ type: 'Undo',
304
+ object: {
305
+ id: voteNote.id,
306
+ type: 'Create',
307
+ actor: voterUrl,
308
+ object: voteNote,
309
+ },
310
+ });
311
+ }
312
+
313
+ // Create added options
314
+ for (const id of addedOptionIds) {
315
+ const name = toName(id);
316
+ if (!name) continue;
317
+ const voteNote = buildVoteNote({ voterUrl, questionId, optionName: name, pollAuthorUrl });
318
+ // eslint-disable-next-line no-await-in-loop
319
+ await activitypub.send('uid', socket.uid, [pollAuthorUrl], {
320
+ id: voteNote.id,
321
+ type: 'Create',
322
+ object: voteNote,
323
+ });
324
+ }
325
+ }
package/lib/vote.js CHANGED
@@ -73,6 +73,10 @@ Vote.canUpdateVote = async function (uid, pollId) {
73
73
  if (!pollInfo) {
74
74
  throw new Error('[[error:invalid-data]]');
75
75
  }
76
+ // Remote polls: vote changes/removals are not federated (no OSW support yet)
77
+ if (parseInt(pollInfo.remote, 10) === 1) {
78
+ return false;
79
+ }
76
80
  const [canRead, ended, deleted, updateDisabled] = await Promise.all([
77
81
  privileges.posts.can('topics:read', pollInfo.pid, uid),
78
82
  Poll.hasEnded(pollId),
package/library.js CHANGED
@@ -92,7 +92,7 @@ Plugin.renderPollWidget = async function (widget) {
92
92
  const Poll = require('./lib/poll');
93
93
 
94
94
  const settings = await Config.getSettings();
95
- const allowAnon = settings.allowGuestsToViewResults === 'on';
95
+ const allowAnon = !!settings.allowGuestsToViewResults;
96
96
 
97
97
  if (!widget.uid && !allowAnon) {
98
98
  return null;