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 +7 -7
- package/LICENSE +20 -20
- package/eslint.config.mjs +7 -0
- package/languages/cs/poll.json +0 -0
- package/languages/en_GB/poll.json +1 -0
- package/languages/en_US/poll.json +0 -0
- package/languages/fr/poll.json +0 -0
- package/languages/ru/poll.json +0 -0
- package/lib/config.js +0 -0
- package/lib/hooks.js +231 -0
- package/lib/nodebb.js +0 -0
- package/lib/poll.js +102 -0
- package/lib/sockets.js +109 -2
- package/lib/vote.js +4 -0
- package/library.js +0 -0
- package/package-lock.json +1658 -0
- package/package.json +2 -2
- package/plugin.json +2 -0
- package/public/js/admin.js +0 -0
- package/public/js/poll/creator.js +5 -0
- package/public/js/poll/main.js +0 -0
- package/public/js/poll/view.js +6 -2
- package/templates/admin/plugins/poll.tpl +0 -0
- package/templates/poll/creator.tpl +1 -0
- package/test/helpers.js +49 -0
- package/test/index.js +530 -0
- package/upgrades/fix_incorrect_poll_privilege.js +15 -15
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
package/languages/cs/poll.json
CHANGED
|
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
|
package/languages/fr/poll.json
CHANGED
|
File without changes
|
package/languages/ru/poll.json
CHANGED
|
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
|
-
|
|
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
|