nodebb-plugin-poll 4.0.15 → 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.
package/.editorconfig CHANGED
@@ -1,8 +1,8 @@
1
- root = true
2
-
3
- [{*.js, *.css, *.tpl, *.json}]
4
- indent_style = tab
5
- end_of_line = lf
6
- charset = utf-8
7
- trim_trailing_whitespace = true
1
+ root = true
2
+
3
+ [{*.js, *.css, *.tpl, *.json}]
4
+ indent_style = tab
5
+ end_of_line = lf
6
+ charset = utf-8
7
+ trim_trailing_whitespace = true
8
8
  insert_final_newline = false
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- The MIT License (MIT)
2
-
3
- Copyright (c) 2015 Erik Schamper
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 Erik Schamper
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
package/eslint.config.mjs CHANGED
@@ -6,5 +6,12 @@ import publicConfig from 'eslint-config-nodebb/public';
6
6
  export default [
7
7
  ...publicConfig,
8
8
  ...serverConfig,
9
+ {
10
+ languageOptions: {
11
+ globals: {
12
+ nodebb: 'readonly',
13
+ },
14
+ },
15
+ },
9
16
  ];
10
17
 
File without changes
@@ -10,6 +10,7 @@
10
10
  "max_votes": "Maximum number of votes per user",
11
11
  "disallow_vote_update": "Don't allow users to update their vote",
12
12
  "allow_anon_voting": "Allow anonymous voting",
13
+ "anon_voting_not_federated": "Anonymous votes are not federated to other servers.",
13
14
  "info_choices": "A number greater than 1 will enable multiple choice polls.",
14
15
  "settings": "Settings",
15
16
  "save": "Save",
File without changes
File without changes
File without changes
package/lib/config.js CHANGED
File without changes
package/lib/hooks.js CHANGED
@@ -49,6 +49,14 @@ Hooks.filter.postCreate = async function (hookData) {
49
49
  post.pollIds = JSON.stringify(savedPolls.map(p => String(p.pollId)));
50
50
  }
51
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
+ }
52
60
  return hookData;
53
61
  };
54
62
 
@@ -67,6 +75,33 @@ Hooks.filter.postGetFields = async function (hookData) {
67
75
  };
68
76
 
69
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
+
70
105
  const currentPollIds = await Poll.getPollIdsByPid(hookData.data.pid);
71
106
  if (!currentPollIds.length && (!Array.isArray(hookData.data.polls) || !hookData.data.polls.length)) {
72
107
  // no polls before or after, nothing to do
@@ -89,6 +124,71 @@ Hooks.filter.postEdit = async function (hookData) {
89
124
  return hookData;
90
125
  };
91
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
+
92
192
  Hooks.filter.topicPost = async function (data) {
93
193
  if (Array.isArray(data.polls) && data.polls.length) {
94
194
  await canCreate(data.cid, data.uid);
@@ -104,6 +204,137 @@ Hooks.filter.topicReply = async function (data) {
104
204
  return data;
105
205
  };
106
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
+
107
338
  Hooks.action.postDelete = async function (data) {
108
339
  const pollIds = await Poll.getPollIdsByPid(data.post.pid);
109
340
  if (pollIds.length) {
package/lib/nodebb.js CHANGED
File without changes
package/lib/poll.js CHANGED
@@ -1,5 +1,7 @@
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
 
@@ -46,6 +48,92 @@ Poll.add = async function (postData, polls) {
46
48
  return savePolls;
47
49
  };
48
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
+
49
137
  Poll.deletePolls = async function (pollIds) {
50
138
  if (!pollIds.length) return;
51
139
  const polls = await db.getObjects(pollIds.map(pollId => `poll:${pollId}`));
@@ -211,6 +299,20 @@ Poll.getInfo = async function (pollId, withVotes = false) {
211
299
  const end = parseInt(poll.end, 10);
212
300
  poll.ended = end > 0 && Date.now() > end;
213
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
+ }
214
316
  return poll;
215
317
  };
216
318
 
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');
@@ -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,10 +157,22 @@ 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
  };
@@ -216,3 +249,77 @@ async function checkPrivs(cid, socketUid) {
216
249
  }
217
250
  return true;
218
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
File without changes