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
@@ -1,39 +1,39 @@
1
- {
2
- "poll": "Sondaj",
3
- "toggles": "Comutare",
4
- "allow_guests": "Oaspeți au acces la rezultate",
5
- "limits": "Limite",
6
- "max_options": "Opțiuni maxime",
7
- "defaults": "Valori implicite",
8
- "default_title": "Titlu ",
9
- "max_votes": "Voturi maximale per utilizator",
10
- "info_choices": "Un număr mai mare de 1 permite selecții multiple.",
11
- "settings": "Setări",
12
- "save": "Salvează",
13
- "reset": "Resetare",
14
-
15
- "creator_title": "Crează sondajul",
16
- "poll_title": "Titlul sondajului",
17
- "poll_title_placeholder": "Introduceț titlul sondajului",
18
- "options_title": "Opțiuni",
19
- "options_add": "Adaugă opțiuni",
20
- "max_votes_placeholder": "Introduceți numerele",
21
- "auto_end_title": "Sfârșitul sondajului",
22
- "auto_end_placeholder": "Introduceți data si ora",
23
- "auto_end_help": "Lăsați acest câmp liber dacă sondajul nu are termen.",
24
-
25
- "error.max_options": "Puteți introduce un maxim de %d opțiuni.",
26
- "error.no_options": "Introduceți ce-l puțin o opțiune.",
27
- "error.valid_date": "Vă rog introduceți o dată valabilă.",
28
-
29
- "error.privilege.create": "You're not allowed to create a poll",
30
-
31
- "vote": "Votează",
32
- "to_voting": "Sondajul",
33
- "to_results": "Rezultate",
34
- "voting_unavailable_title": "Sondajul nu este disponibil",
35
- "voting_unavailable_message": "Sondajul a fost încheiat sau anulat. Puteți sa va uitați la rezultate.",
36
-
37
- "vote_count": "Utilizatorii au votat pentru aceste opțiuni",
38
- "votes": "Voturi"
39
- }
1
+ {
2
+ "poll": "Sondaj",
3
+ "toggles": "Comutare",
4
+ "allow_guests": "Oaspeți au acces la rezultate",
5
+ "limits": "Limite",
6
+ "max_options": "Opțiuni maxime",
7
+ "defaults": "Valori implicite",
8
+ "default_title": "Titlu ",
9
+ "max_votes": "Voturi maximale per utilizator",
10
+ "info_choices": "Un număr mai mare de 1 permite selecții multiple.",
11
+ "settings": "Setări",
12
+ "save": "Salvează",
13
+ "reset": "Resetare",
14
+
15
+ "creator_title": "Crează sondajul",
16
+ "poll_title": "Titlul sondajului",
17
+ "poll_title_placeholder": "Introduceț titlul sondajului",
18
+ "options_title": "Opțiuni",
19
+ "options_add": "Adaugă opțiuni",
20
+ "max_votes_placeholder": "Introduceți numerele",
21
+ "auto_end_title": "Sfârșitul sondajului",
22
+ "auto_end_placeholder": "Introduceți data si ora",
23
+ "auto_end_help": "Lăsați acest câmp liber dacă sondajul nu are termen.",
24
+
25
+ "error.max_options": "Puteți introduce un maxim de %1 opțiuni.",
26
+ "error.no_options": "Introduceți ce-l puțin o opțiune.",
27
+ "error.valid_date": "Vă rog introduceți o dată valabilă.",
28
+
29
+ "error.privilege.create": "You're not allowed to create a poll",
30
+
31
+ "vote": "Votează",
32
+ "to_voting": "Sondajul",
33
+ "to_results": "Rezultate",
34
+ "voting_unavailable_title": "Sondajul nu este disponibil",
35
+ "voting_unavailable_message": "Sondajul a fost încheiat sau anulat. Puteți sa va uitați la rezultate.",
36
+
37
+ "vote_count": "Utilizatorii au votat pentru aceste opțiuni",
38
+ "votes": "Voturi"
39
+ }
@@ -23,7 +23,7 @@
23
23
  "auto_end_placeholder": "Нажмите, чтобы ввести дату и время",
24
24
  "auto_end_help": "Если оставить данное поле пустым, то опрос будет всегда открыт.",
25
25
 
26
- "error.max_options": "Вы можете создать только %d опций.",
26
+ "error.max_options": "Вы можете создать только %1 опций.",
27
27
  "error.no_options": "Создайте хотя бы один вариант ответа.",
28
28
  "error.valid_date": "Введите корректную дату.",
29
29
 
@@ -1,47 +1,47 @@
1
- {
2
- "poll": "Anket",
3
- "toggles": "Değişiklikler",
4
- "allow_guests": "Ziyaretçilerin anket sonucunu görmelerine izin ver",
5
- "limits": "Sınırlamalar",
6
- "max_options": "Anketlerdeki maksimum seçenek sayısı",
7
- "defaults": "Varsayılanlar",
8
- "default_title": "Varsayılan Anket Başlığı",
9
- "max_votes": "Kişi başı maksimum oy sayısı",
10
- "disallow_vote_update": "Kişilerin oylarını değiştirmelerine izin verme",
11
- "info_choices": "1'den büyük bir sayı çoktan seçmeli anket oluşturacak.",
12
- "settings": "Ayarlar",
13
- "save": "Kaydet",
14
- "reset": "Sıfırla",
15
-
16
- "creator_title": "Anket Oluştur",
17
- "poll_title": "Anket Başlığı",
18
- "poll_title_placeholder": "Anket başlığını buraya girin",
19
- "options_title": "Seçenekler",
20
- "options_add": "Seçenek ekle",
21
- "max_votes_placeholder": "Bir sayı girin",
22
- "auto_end_title": "Anketi otomatik olarak bitir",
23
- "auto_end_placeholder": "Tarih ve saat bilgisini buraya girin",
24
- "auto_end_help": "Burayı boş bırakırsanız anket hiç sonlanmayacak.",
25
-
26
- "error.max_options": "Sadece %d seçenek oluşturabilirsiniz.",
27
- "error.no_options": "En az bir seçenek oluşturmalısınız",
28
- "error.valid_date": "Lütfen geçerli bir tarih girin",
29
-
30
- "error.not_main": "Anketi sadece ilk iletiye ekleyebilirsiniz.",
31
- "error.privilege.create": "Anket oluşturmaya yetkiniz yok",
32
-
33
- "warning.redactor": "Redactor kullanıyorsunuz. Markup'ı manuel olarak <strong>değiştirmeyin</strong>. Bunun yerine anket oluşturucuyu yeniden açın.",
34
-
35
- "vote": "Oy ver",
36
- "update_vote": "Oyu güncelle",
37
- "remove_vote": "Oyu kaldır",
38
- "to_voting": "Seçeneklere geri dön",
39
- "to_results": "Sonuçlar",
40
- "voting_unavailable_title": "Oy vermek artık mümkün değil",
41
- "voting_unavailable_message": "Bu anket bitti veya silindi. Sonuçları hala görmeniz mümkün",
42
- "voting_update_disallowed_message": "Daha önce oy verdiniz. Bu anket için oyunuzu değiştiremezsiniz",
43
- "vote_is_final": "Bu ankette oy değiştirmeniz mümkün değildir, o nedenle seçiminiz son karardır",
44
-
45
- "vote_count": "kişi bu seçenek için oy kullandı",
46
- "votes": "oy"
47
- }
1
+ {
2
+ "poll": "Anket",
3
+ "toggles": "Değişiklikler",
4
+ "allow_guests": "Ziyaretçilerin anket sonucunu görmelerine izin ver",
5
+ "limits": "Sınırlamalar",
6
+ "max_options": "Anketlerdeki maksimum seçenek sayısı",
7
+ "defaults": "Varsayılanlar",
8
+ "default_title": "Varsayılan Anket Başlığı",
9
+ "max_votes": "Kişi başı maksimum oy sayısı",
10
+ "disallow_vote_update": "Kişilerin oylarını değiştirmelerine izin verme",
11
+ "info_choices": "1'den büyük bir sayı çoktan seçmeli anket oluşturacak.",
12
+ "settings": "Ayarlar",
13
+ "save": "Kaydet",
14
+ "reset": "Sıfırla",
15
+
16
+ "creator_title": "Anket Oluştur",
17
+ "poll_title": "Anket Başlığı",
18
+ "poll_title_placeholder": "Anket başlığını buraya girin",
19
+ "options_title": "Seçenekler",
20
+ "options_add": "Seçenek ekle",
21
+ "max_votes_placeholder": "Bir sayı girin",
22
+ "auto_end_title": "Anketi otomatik olarak bitir",
23
+ "auto_end_placeholder": "Tarih ve saat bilgisini buraya girin",
24
+ "auto_end_help": "Burayı boş bırakırsanız anket hiç sonlanmayacak.",
25
+
26
+ "error.max_options": "Sadece %1 seçenek oluşturabilirsiniz.",
27
+ "error.no_options": "En az bir seçenek oluşturmalısınız",
28
+ "error.valid_date": "Lütfen geçerli bir tarih girin",
29
+
30
+ "error.not_main": "Anketi sadece ilk iletiye ekleyebilirsiniz.",
31
+ "error.privilege.create": "Anket oluşturmaya yetkiniz yok",
32
+
33
+ "warning.redactor": "Redactor kullanıyorsunuz. Markup'ı manuel olarak <strong>değiştirmeyin</strong>. Bunun yerine anket oluşturucuyu yeniden açın.",
34
+
35
+ "vote": "Oy ver",
36
+ "update_vote": "Oyu güncelle",
37
+ "remove_vote": "Oyu kaldır",
38
+ "to_voting": "Seçeneklere geri dön",
39
+ "to_results": "Sonuçlar",
40
+ "voting_unavailable_title": "Oy vermek artık mümkün değil",
41
+ "voting_unavailable_message": "Bu anket bitti veya silindi. Sonuçları hala görmeniz mümkün",
42
+ "voting_update_disallowed_message": "Daha önce oy verdiniz. Bu anket için oyunuzu değiştiremezsiniz",
43
+ "vote_is_final": "Bu ankette oy değiştirmeniz mümkün değildir, o nedenle seçiminiz son karardır",
44
+
45
+ "vote_count": "kişi bu seçenek için oy kullandı",
46
+ "votes": "oy"
47
+ }
package/lib/config.js CHANGED
@@ -28,9 +28,20 @@ Config.defaults = {
28
28
 
29
29
  Config.getSettings = async function () {
30
30
  const settings = await meta.settings.get(Config.plugin.id);
31
- return {
31
+ const merged = {
32
32
  ...Config.defaults,
33
33
  ...settings,
34
34
  };
35
+ // NodeBB's settings helper stores checkboxes as 'on'/'off'; normalize to a boolean
36
+ // so parseInt(...) === 1 checks don't silently fail.
37
+ merged.allowGuestsToViewResults = ['1', 1, 'on', true].includes(merged.allowGuestsToViewResults);
38
+ // NodeBB's settings helper stores blank number fields as ''; fall back to defaults
39
+ // so an empty ACP field doesn't override the configured default.
40
+ merged.maxOptions = parseInt(merged.maxOptions, 10) || Config.defaults.maxOptions;
41
+ merged.maximumVotesPerUser = parseInt(merged.maximumVotesPerUser, 10) || Config.defaults.maximumVotesPerUser;
42
+ // NodeBB's settings helper stores blank text fields as ''; fall back to the
43
+ // configured default so an empty ACP field doesn't override the built-in default.
44
+ merged.defaultTitle = merged.defaultTitle || Config.defaults.defaultTitle;
45
+ return merged;
35
46
  };
36
47
 
package/lib/hooks.js CHANGED
@@ -44,12 +44,19 @@ Hooks.filter.postCreate = async function (hookData) {
44
44
  // post is going to be saved to db, data is what is submitted by user
45
45
  const { post, data } = hookData;
46
46
  if (Array.isArray(data?.polls) && data.polls.length) {
47
- await checkMaxOptions(data.polls);
48
47
  const savedPolls = await Poll.add(post, data.polls);
49
48
  if (savedPolls.length) {
50
49
  post.pollIds = JSON.stringify(savedPolls.map(p => String(p.pollId)));
51
50
  }
52
51
  }
52
+
53
+ // Federated Question (FEP-9967): build a remote poll from the AP data
54
+ const ap = data?._activitypub;
55
+ if (ap && (Array.isArray(ap.oneOf) || Array.isArray(ap.anyOf))) {
56
+ const saved = await Poll.createFromQuestion(post, ap);
57
+ const existing = post.pollIds ? JSON.parse(post.pollIds) : [];
58
+ post.pollIds = JSON.stringify([...existing, String(saved.pollId)]);
59
+ }
53
60
  return hookData;
54
61
  };
55
62
 
@@ -68,13 +75,39 @@ Hooks.filter.postGetFields = async function (hookData) {
68
75
  };
69
76
 
70
77
  Hooks.filter.postEdit = async function (hookData) {
78
+ // Federated Question update (FEP-9967): refresh the existing remote poll
79
+ const ap = hookData.data?._activitypub;
80
+ if (ap && (Array.isArray(ap.oneOf) || Array.isArray(ap.anyOf))) {
81
+ const currentPollIds = await Poll.getPollIdsByPid(hookData.data.pid);
82
+ let remotePollId = null;
83
+ for (const pid of currentPollIds) {
84
+ // eslint-disable-next-line no-await-in-loop
85
+ if (await Poll.isRemote(pid)) {
86
+ remotePollId = pid;
87
+ break;
88
+ }
89
+ }
90
+ if (remotePollId) {
91
+ await Poll.updateFromQuestion(remotePollId, ap);
92
+ } else {
93
+ // No existing poll (e.g. the initial Create was missed); create one
94
+ const saved = await Poll.createFromQuestion({
95
+ uid: hookData.data.uid,
96
+ pid: hookData.data.pid,
97
+ timestamp: hookData.data.timestamp,
98
+ edited: hookData.data.edited,
99
+ }, ap);
100
+ hookData.post.pollIds = JSON.stringify([String(saved.pollId)]);
101
+ }
102
+ return hookData;
103
+ }
104
+
71
105
  const currentPollIds = await Poll.getPollIdsByPid(hookData.data.pid);
72
106
  if (!currentPollIds.length && (!Array.isArray(hookData.data.polls) || !hookData.data.polls.length)) {
73
107
  // no polls before or after, nothing to do
74
108
  return hookData;
75
109
  }
76
110
  const pollData = hookData.data.polls || [];
77
- await checkMaxOptions(pollData);
78
111
  const toAdd = pollData.filter(p => !currentPollIds.includes(String(p.pollId)));
79
112
 
80
113
  if (toAdd.length) {
@@ -91,6 +124,71 @@ Hooks.filter.postEdit = async function (hookData) {
91
124
  return hookData;
92
125
  };
93
126
 
127
+ // FEP-9967 outbound: transform a local post with a poll into a `Question`
128
+ // object. Fired at the end of `Mocks.notes.public` (core), which has already
129
+ // built a `Note`/`Article`. Only the first poll on a post is federated.
130
+ Hooks.filter.activitypubMocksNote = async function (hookData) {
131
+ const { object, post } = hookData;
132
+ if (!object || !post || !post.pid) {
133
+ return hookData;
134
+ }
135
+
136
+ const pollIds = await Poll.getPollIdsByPid(post.pid);
137
+ if (!pollIds.length) {
138
+ return hookData;
139
+ }
140
+
141
+ const pollId = pollIds[0];
142
+ const [info, votersCount] = await Promise.all([
143
+ Poll.getInfo(pollId),
144
+ Poll.getVotersCount(pollId),
145
+ ]);
146
+ if (!info || !Array.isArray(info.options) || !info.options.length) {
147
+ return hookData;
148
+ }
149
+
150
+ const options = info.options
151
+ .filter(opt => opt && typeof opt.title === 'string')
152
+ .map(opt => ({
153
+ type: 'Note',
154
+ name: opt.title,
155
+ replies: { type: 'Collection', totalItems: opt.voteCount || 0 },
156
+ }));
157
+
158
+ const isMulti = parseInt(info.maximumVotesPerUser, 10) > 1;
159
+ const end = parseInt(info.end, 10);
160
+ const ended = end > 0 && Date.now() > end;
161
+
162
+ object.type = 'Question';
163
+ // Embed the poll title in the content so it renders as the question text
164
+ // on Mastodon (which displays `content` above the options).
165
+ if (info.title) {
166
+ object.content = `<p>${info.title}</p>${object.content || ''}`;
167
+ }
168
+ if (isMulti) {
169
+ object.anyOf = options;
170
+ } else {
171
+ object.oneOf = options;
172
+ }
173
+ if (end > 0) {
174
+ object.endTime = new Date(end).toISOString();
175
+ }
176
+ if (ended) {
177
+ object.closed = true;
178
+ }
179
+ if (votersCount > 0) {
180
+ object.votersCount = votersCount;
181
+ }
182
+
183
+ // A poll post is a Note, not an Article — drop the Article-only fields.
184
+ // `name` (topic title) is kept: harmless and non-conflicting.
185
+ delete object.preview;
186
+ delete object.summary;
187
+ delete object.sensitive;
188
+
189
+ return hookData;
190
+ };
191
+
94
192
  Hooks.filter.topicPost = async function (data) {
95
193
  if (Array.isArray(data.polls) && data.polls.length) {
96
194
  await canCreate(data.cid, data.uid);
@@ -106,6 +204,137 @@ Hooks.filter.topicReply = async function (data) {
106
204
  return data;
107
205
  };
108
206
 
207
+ // FEP-9967 inbound: detect a remote vote on a local poll and apply it.
208
+ // A vote is a Create(Note) where the Note has `name` + `inReplyTo` and no
209
+ // `content`, and `inReplyTo` resolves to a local post that has a poll.
210
+ Hooks.filter.activitypubCreate = async function (hookData) {
211
+ const { activity } = hookData;
212
+ if (!activity || activity.type !== 'Create' || !activity.object) {
213
+ return hookData;
214
+ }
215
+
216
+ const { object } = activity;
217
+ // FEP-9967 vote shape: Note with name + inReplyTo, no content
218
+ if (object.type !== 'Note' || !object.name || !object.inReplyTo || object.content) {
219
+ return hookData;
220
+ }
221
+
222
+ // Resolve inReplyTo to a local post (e.g. https://example.org/post/123)
223
+ const match = object.inReplyTo.match(/\/post\/(\d+)/);
224
+ if (!match) return hookData;
225
+ const pid = parseInt(match[1], 10);
226
+ if (!(await posts.exists(pid))) return hookData;
227
+
228
+ const pollIds = await Poll.getPollIdsByPid(pid);
229
+ if (!pollIds.length) return hookData;
230
+
231
+ const pollId = pollIds[0];
232
+ const info = await Poll.getInfo(pollId);
233
+ if (!info) return hookData;
234
+
235
+ // Poll must be active
236
+ if (await Poll.hasEnded(pollId) || await Poll.isDeleted(pollId)) {
237
+ return hookData;
238
+ }
239
+
240
+ // Match name to a poll option
241
+ const option = info.options.find(opt => opt && opt.title === object.name);
242
+ if (!option) return hookData;
243
+
244
+ const remoteActorUrl = activity.actor;
245
+ if (!remoteActorUrl) return hookData;
246
+ const isMulti = parseInt(info.maximumVotesPerUser, 10) > 1;
247
+
248
+ // Idempotency: check if the actor has already voted
249
+ if (isMulti) {
250
+ const alreadyVoted = await db.sortedSetScore(`poll:${pollId}:options:${option.id}:votes`, remoteActorUrl);
251
+ if (alreadyVoted) return hookData;
252
+ } else {
253
+ const alreadyVoted = await db.sortedSetScore(`poll:${pollId}:voters`, remoteActorUrl);
254
+ if (alreadyVoted) return hookData;
255
+ }
256
+
257
+ // Apply the vote (remote actor URL as the uid — sorted sets handle strings)
258
+ const Vote = require('./vote');
259
+ await Vote.add({
260
+ pollId,
261
+ options: [option.id],
262
+ uid: remoteActorUrl,
263
+ voteAnon: false,
264
+ });
265
+
266
+ // Claim the activity so it is not processed as a regular post
267
+ hookData.claimed = true;
268
+
269
+ // Publish Update(Question) with refreshed results back to the voter
270
+ const activitypub = nodebb.require('./src/activitypub');
271
+ const [votersCount, refreshedOptions] = await Promise.all([
272
+ Poll.getVotersCount(pollId),
273
+ Poll.getOptions(pollId, false),
274
+ ]);
275
+ const questionObject = {
276
+ id: object.inReplyTo,
277
+ type: 'Question',
278
+ attributedTo: await posts.getPostField(pid, 'uid'),
279
+ to: ['https://www.w3.org/ns/activitystreams#Public'],
280
+ content: await posts.getPostField(pid, 'content'),
281
+ };
282
+ const isMultiChoice = parseInt(info.maximumVotesPerUser, 10) > 1;
283
+ const optionsPayload = refreshedOptions
284
+ .filter(opt => opt && typeof opt.title === 'string')
285
+ .map(opt => ({
286
+ type: 'Note',
287
+ name: opt.title,
288
+ replies: { type: 'Collection', totalItems: opt.voteCount || 0 },
289
+ }));
290
+ if (isMultiChoice) {
291
+ questionObject.anyOf = optionsPayload;
292
+ } else {
293
+ questionObject.oneOf = optionsPayload;
294
+ }
295
+ if (votersCount > 0) {
296
+ questionObject.votersCount = votersCount;
297
+ }
298
+ const end = parseInt(info.end, 10);
299
+ if (end > 0) {
300
+ questionObject.endTime = new Date(end).toISOString();
301
+ }
302
+ if (end > 0 && Date.now() > end) {
303
+ questionObject.closed = true;
304
+ }
305
+
306
+ // Publish Update(Question) to all recipients (public, followers, voters)
307
+ setImmediate(async () => {
308
+ try {
309
+ const utils = nodebb.require('./src/utils');
310
+ const authorUid = parseInt(await posts.getPostField(pid, 'uid'), 10);
311
+ const remoteVoters = (await db.getSortedSetMembers(`poll:${pollId}:voters`))
312
+ .filter(v => !utils.isNumber(v));
313
+
314
+ const { to, cc, targets } = await activitypub.buildRecipients(
315
+ { to: ['https://www.w3.org/ns/activitystreams#Public'], cc: remoteVoters },
316
+ { pid, uid: authorUid }
317
+ );
318
+
319
+ questionObject.to = to;
320
+ questionObject.cc = cc;
321
+
322
+ await activitypub.send('uid', authorUid, Array.from(targets), {
323
+ id: `${object.id || object.inReplyTo}#update/${Date.now()}`,
324
+ type: 'Update',
325
+ actor: questionObject.attributedTo,
326
+ to,
327
+ cc,
328
+ object: questionObject,
329
+ });
330
+ } catch (e) {
331
+ // Non-fatal: log and move on
332
+ }
333
+ });
334
+
335
+ return hookData;
336
+ };
337
+
109
338
  Hooks.action.postDelete = async function (data) {
110
339
  const pollIds = await Poll.getPollIdsByPid(data.post.pid);
111
340
  if (pollIds.length) {
@@ -136,15 +365,3 @@ async function canCreate(cid, uid) {
136
365
  }
137
366
  }
138
367
 
139
- async function checkMaxOptions(polls) {
140
- if (!Array.isArray(polls) || !polls.length) {
141
- return;
142
- }
143
- const { maxOptions } = await Config.getSettings();
144
- polls.forEach((poll) => {
145
- if (poll && poll.options.length > maxOptions) {
146
- throw new Error(`[[poll:error.max_options, ${maxOptions}]]`);
147
- }
148
- });
149
- }
150
-
package/lib/nodebb.js CHANGED
File without changes
package/lib/poll.js CHANGED
@@ -1,8 +1,11 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('crypto');
4
+
3
5
  const db = nodebb.require('./src/database');
4
6
  const privileges = nodebb.require('./src/privileges');
5
7
 
8
+ const Config = require('./config');
6
9
  const Vote = require('./vote');
7
10
 
8
11
  const Poll = exports;
@@ -10,6 +13,7 @@ const Poll = exports;
10
13
  Poll.add = async function (postData, polls) {
11
14
  // postData is what is being saved to db
12
15
  // polls is submited by user
16
+ await validateMaxOptions(polls);
13
17
  const savePolls = [];
14
18
  for (const pollData of polls) {
15
19
  // eslint-disable-next-line no-await-in-loop
@@ -44,6 +48,92 @@ Poll.add = async function (postData, polls) {
44
48
  return savePolls;
45
49
  };
46
50
 
51
+ // ---------------------------------------------------------------------------
52
+ // Federated (remote) polls — FEP-9967 `Question` objects
53
+ // ---------------------------------------------------------------------------
54
+
55
+ // Stable option id derived from the option name (the FEP guarantees name
56
+ // uniqueness within a poll), so ids survive `Update`s even if order changes.
57
+ function hashOptionId(name) {
58
+ return crypto.createHash('sha256').update(String(name)).digest('hex').slice(0, 12);
59
+ }
60
+
61
+ // Parse a Question's `_activitypub` poll data into the plugin's option shape.
62
+ function parseQuestion(ap) {
63
+ const rawOptions = Array.isArray(ap.anyOf) ? ap.anyOf : (Array.isArray(ap.oneOf) ? ap.oneOf : []);
64
+ const isMulti = Array.isArray(ap.anyOf);
65
+ const options = [];
66
+ const remoteVotes = {};
67
+ rawOptions.forEach((opt) => {
68
+ if (!opt || typeof opt.name !== 'string') {
69
+ return;
70
+ }
71
+ const id = hashOptionId(opt.name);
72
+ options.push({ id, title: opt.name });
73
+ remoteVotes[id] = (opt.replies && opt.replies.totalItems) || 0;
74
+ });
75
+ return {
76
+ options,
77
+ remoteVotes,
78
+ maximumVotesPerUser: isMulti ? Math.max(options.length, 1) : 1,
79
+ };
80
+ }
81
+
82
+ function tryParseRemoteVotes(json) {
83
+ if (!json) {
84
+ return {};
85
+ }
86
+ try {
87
+ const obj = JSON.parse(json);
88
+ return obj && typeof obj === 'object' && !Array.isArray(obj) ? obj : {};
89
+ } catch (err) {
90
+ return {};
91
+ }
92
+ }
93
+
94
+ // Create a poll from a federated Question. Bypasses the local `maxOptions`
95
+ // limit (that cap applies to locally-authored polls, not ingested ones).
96
+ Poll.createFromQuestion = async function (postData, ap) {
97
+ const { options, remoteVotes, maximumVotesPerUser } = parseQuestion(ap);
98
+ const pollId = await db.incrObjectField('global', 'nextPollId');
99
+ const poll = {
100
+ pollId,
101
+ title: 'Poll',
102
+ uid: postData.editor || postData.uid,
103
+ pid: postData.pid,
104
+ deleted: 0,
105
+ end: ap.endTime ? new Date(ap.endTime).getTime() : 0,
106
+ maximumVotesPerUser,
107
+ timestamp: postData.edited || postData.timestamp,
108
+ remote: 1,
109
+ remoteVotes: JSON.stringify(remoteVotes),
110
+ options: JSON.stringify(options),
111
+ ...(ap.votersCount !== undefined && { remoteVotersCount: ap.votersCount }),
112
+ };
113
+ await Promise.all([
114
+ db.setObject(`poll:${pollId}`, poll),
115
+ db.sortedSetAdd('polls:createtime', poll.timestamp, pollId),
116
+ ]);
117
+ return poll;
118
+ };
119
+
120
+ // Refresh an existing remote poll from an updated Question (FEP-9967 `Update`).
121
+ Poll.updateFromQuestion = async function (pollId, ap) {
122
+ const { options, remoteVotes, maximumVotesPerUser } = parseQuestion(ap);
123
+ await db.setObject(`poll:${pollId}`, {
124
+ options: JSON.stringify(options),
125
+ remoteVotes: JSON.stringify(remoteVotes),
126
+ end: ap.endTime ? new Date(ap.endTime).getTime() : 0,
127
+ maximumVotesPerUser,
128
+ ...(ap.votersCount !== undefined && { remoteVotersCount: ap.votersCount }),
129
+ });
130
+ };
131
+
132
+ Poll.isRemote = async function (pollId) {
133
+ const result = await Poll.getField(pollId, 'remote');
134
+ return parseInt(result, 10) === 1;
135
+ };
136
+
47
137
  Poll.deletePolls = async function (pollIds) {
48
138
  if (!pollIds.length) return;
49
139
  const polls = await db.getObjects(pollIds.map(pollId => `poll:${pollId}`));
@@ -75,6 +165,9 @@ Poll.edit = async function (postData, newPolls) {
75
165
  const toDelete = currentPollIds.filter(pollId => !newPollIds.includes(String(pollId)));
76
166
  const currentPolls = await db.getObjects(currentPollIds.map(pollId => `poll:${pollId}`));
77
167
 
168
+ await validateMaxOptions(toAdd);
169
+ await validateMaxOptionsForEdit(toUpdate, currentPolls);
170
+
78
171
  await Poll.deletePolls(toDelete);
79
172
 
80
173
  const savedPolls = await Poll.add(postData, toAdd);
@@ -206,6 +299,20 @@ Poll.getInfo = async function (pollId, withVotes = false) {
206
299
  const end = parseInt(poll.end, 10);
207
300
  poll.ended = end > 0 && Date.now() > end;
208
301
  poll.options = await loadOptions(pollId, poll.options, withVotes);
302
+
303
+ // Federated (remote) polls: displayed count = ingested baseline + local votes.
304
+ // (v1 reconciliation: a local voter sees their vote reflect immediately; a small
305
+ // over-count is accepted until the next remote Update refreshes the baseline.)
306
+ if (parseInt(poll.remote, 10) === 1) {
307
+ const remoteVotes = tryParseRemoteVotes(poll.remoteVotes);
308
+ poll.options.forEach((option) => {
309
+ if (option) {
310
+ const baseline = remoteVotes[option.id] || 0;
311
+ option.voteCount = baseline + (option.voteCount || 0);
312
+ }
313
+ });
314
+ poll.voteCount = poll.options.reduce((sum, o) => sum + (o ? (o.voteCount || 0) : 0), 0);
315
+ }
209
316
  return poll;
210
317
  };
211
318
 
@@ -245,6 +352,46 @@ function tryParseOptions(optionsJson) {
245
352
  }
246
353
  }
247
354
 
355
+ async function validateMaxOptions(polls) {
356
+ if (!Array.isArray(polls) || !polls.length) {
357
+ return;
358
+ }
359
+ const settings = await Config.getSettings();
360
+ const { maxOptions } = settings;
361
+ polls.forEach((poll) => {
362
+ if (!poll) {
363
+ return;
364
+ }
365
+ const optionCount = Array.isArray(poll.options) ? poll.options.length : 0;
366
+ if (optionCount > maxOptions) {
367
+ throw new Error(`[[poll:error.max_options, ${maxOptions}]]`);
368
+ }
369
+ });
370
+ }
371
+
372
+ async function validateMaxOptionsForEdit(updatedPolls, currentPolls) {
373
+ if (!Array.isArray(updatedPolls) || !updatedPolls.length) {
374
+ return;
375
+ }
376
+ const settings = await Config.getSettings();
377
+ const { maxOptions } = settings;
378
+ updatedPolls.forEach((poll) => {
379
+ if (!poll) {
380
+ return;
381
+ }
382
+ const newOptionCount = Array.isArray(poll.options) ? poll.options.length : 0;
383
+ if (newOptionCount <= maxOptions) {
384
+ return;
385
+ }
386
+ const currentPoll = currentPolls.find(p => p && String(p.pollId) === String(poll.pollId));
387
+ const currentOptions = currentPoll ? tryParseOptions(currentPoll.options) : [];
388
+ const currentOptionCount = Array.isArray(currentOptions) ? currentOptions.length : 0;
389
+ if (newOptionCount > currentOptionCount) {
390
+ throw new Error(`[[poll:error.max_options, ${maxOptions}]]`);
391
+ }
392
+ });
393
+ }
394
+
248
395
  Poll.getOption = async function (pollId, option, withVotes = false) {
249
396
  const [optionsJson, votes, voteCount] = await Promise.all([
250
397
  db.getObjectField(`poll:${pollId}`, 'options'),