nodebb-plugin-poll 4.1.0 → 4.2.1
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/README.md +46 -0
- package/languages/cs/poll.json +0 -0
- package/languages/en_GB/poll.json +2 -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 +0 -0
- package/lib/nodebb.js +0 -0
- package/lib/poll.js +0 -0
- package/lib/sockets.js +46 -42
- package/lib/vote.js +0 -4
- package/library.js +6 -0
- package/package.json +1 -1
- package/plugin.json +0 -0
- package/public/js/admin.js +0 -0
- package/public/js/poll/creator.js +0 -0
- package/public/js/poll/main.js +0 -0
- package/public/js/poll/view.js +0 -4
- package/templates/admin/plugins/poll.tpl +0 -0
- package/templates/poll/creator.tpl +0 -0
- package/templates/poll/view/voting.tpl +6 -0
- package/upgrades/fix_incorrect_poll_privilege.js +15 -15
- package/package-lock.json +0 -1658
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/README.md
CHANGED
|
@@ -3,10 +3,56 @@
|
|
|
3
3
|
This NodeBB plugin will allow you to add polls to posts.
|
|
4
4
|
|
|
5
5
|
There's helpful modal available that will allow you to easily create a poll:
|
|
6
|
+
|
|
6
7
|

|
|
7
8
|
|
|
8
9
|
If you're willing to help, please make any improvements you want and submit a PR.
|
|
9
10
|
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
### Creating and editing polls
|
|
14
|
+
|
|
15
|
+
- Create polls from the composer (topic posts and replies) via the poll button in the formatting bar; multiple polls per post are supported
|
|
16
|
+
- Edit polls in the composer: change the title, add/remove options, adjust the end time and per-poll settings
|
|
17
|
+
- Poll creation is gated by the `poll:create` category privilege
|
|
18
|
+
- Polls follow their post's lifecycle — deleting a post soft-deletes its polls, restoring it restores them
|
|
19
|
+
|
|
20
|
+
### Per-poll options
|
|
21
|
+
|
|
22
|
+
- **Title** for the poll
|
|
23
|
+
- **Options** — up to the site-wide maximum (default 10)
|
|
24
|
+
- **Maximum votes per user** — `1` for single-choice (radio buttons), higher for multi-choice (checkboxes)
|
|
25
|
+
- **Optional end date/time** — the poll closes automatically when reached; no more voting after that
|
|
26
|
+
- **Allow anonymous voting** — voters can opt to hide their identity; anonymous voters are masked in the per-option voter lists for everyone except privileged users
|
|
27
|
+
- **Disallow vote updates** — lock the poll so votes can't be changed once cast
|
|
28
|
+
|
|
29
|
+
### Voting
|
|
30
|
+
|
|
31
|
+
- Logged-in users only (guests can view results if enabled in the ACP)
|
|
32
|
+
- Cast, change, and remove votes (subject to the per-poll settings above)
|
|
33
|
+
- Live results — vote changes are pushed to viewers in real time
|
|
34
|
+
- Per-option voter lists with percentages
|
|
35
|
+
|
|
36
|
+
### Site-wide settings (ACP)
|
|
37
|
+
|
|
38
|
+
- Default poll title
|
|
39
|
+
- Maximum number of options per poll
|
|
40
|
+
- Default maximum votes per user
|
|
41
|
+
- Allow guests to view results
|
|
42
|
+
|
|
43
|
+
## Federation
|
|
44
|
+
|
|
45
|
+
Polls federate across the fediverse as ActivityPub `Question` objects per [FEP-9967](https://codeberg.org/fediverse/fep/src/branch/main/fep/9967/fep-9967.md):
|
|
46
|
+
|
|
47
|
+
- **Inbound:** remote `Question` posts are ingested as polls and displayed with their current results. Local users can vote on remote polls, and each vote is federated back to the poll's author.
|
|
48
|
+
- **Outbound:** local polls are published as `Question` objects (single-choice as `oneOf`, multi-choice as `anyOf`), and votes received from remote actors are applied to the local poll's results.
|
|
49
|
+
|
|
50
|
+
Limitations:
|
|
51
|
+
|
|
52
|
+
- Only the **first** poll on a post is federated (FEP-9967 models one `Question` per poll); multiple polls per post are not supported.
|
|
53
|
+
- **Anonymous votes are not federated**, since FEP-9967 votes are attributed to the voter's actor.
|
|
54
|
+
- Votes on **remote** polls can be changed or removed (federated as `Create`/`Delete` per FEP-9967), but not all servers support this — e.g. some ignore `Delete` of a vote — so the change may not be reflected on the remote poll.
|
|
55
|
+
|
|
10
56
|
## Installation
|
|
11
57
|
|
|
12
58
|
Either through the NodeBB ACP or:
|
package/languages/cs/poll.json
CHANGED
|
File without changes
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"error.invalid-post": "Invalid post.",
|
|
39
39
|
"error.privilege.create": "You're not allowed to create a poll",
|
|
40
40
|
"error.anon-voting-not-allowed": "This poll does not allow voting anonymously",
|
|
41
|
+
"error.already-voting": "You are already voting for this poll",
|
|
41
42
|
|
|
42
43
|
"warning.redactor": "You're using Redactor. Do <strong>not</strong> edit the markup manually. Instead, re-open the poll creator.",
|
|
43
44
|
|
|
@@ -51,6 +52,7 @@
|
|
|
51
52
|
"voting_unavailable_message": "This poll has ended or has been marked as deleted. You can still view the results.",
|
|
52
53
|
"voting_update_disallowed_message": "You have already voted and changing your vote is not allowed for this poll",
|
|
53
54
|
"vote_is_final": "Changing your vote is not allowed for this poll so your vote is final",
|
|
55
|
+
"remote_vote_caveat": "This poll is from another server. Not all servers support changing or removing a vote, so your update may not be reflected there.",
|
|
54
56
|
|
|
55
57
|
"x-users-voted-for-this-option": "%1 user(s) voted for this option",
|
|
56
58
|
"votes": "votes",
|
|
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
|
File without changes
|
package/lib/nodebb.js
CHANGED
|
File without changes
|
package/lib/poll.js
CHANGED
|
File without changes
|
package/lib/sockets.js
CHANGED
|
@@ -53,42 +53,50 @@ Sockets.vote = async function (socket, data) {
|
|
|
53
53
|
|
|
54
54
|
data.uid = socket.uid;
|
|
55
55
|
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
const count = await db.incrObjectField(`poll:voteLocks`, `${data.pollId}:${socket.uid}`);
|
|
57
|
+
if (count > 1) {
|
|
58
|
+
throw new Error('[[poll:error.already-voting]]');
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const [pollData, canVote, optionsFilter] = await Promise.all([
|
|
62
|
+
db.getObject(`poll:${data.pollId}`),
|
|
63
|
+
Vote.canVote(socket.uid, data.pollId),
|
|
64
|
+
Poll.hasOptions(data.pollId, data.options),
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
// Filter the options on their existence
|
|
68
|
+
data.options = data.options.filter((el, index) => optionsFilter[index]);
|
|
69
|
+
|
|
70
|
+
// Give an error if there are too many votes
|
|
71
|
+
if (data.options.length > parseInt(pollData.maximumVotesPerUser, 10)) {
|
|
72
|
+
throw new Error(`You can only vote for ${pollData.maximumVotesPerUser} options on this poll.`);
|
|
73
|
+
}
|
|
64
74
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
75
|
+
if (data.voteAnon && parseInt(pollData.allowAnonVoting, 10) !== 1) {
|
|
76
|
+
throw new Error('[[poll:error.anon-voting-not-allowed]]');
|
|
77
|
+
}
|
|
69
78
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
if (!canVote || !data.options.length) {
|
|
80
|
+
throw new Error('Already voted or invalid option');
|
|
81
|
+
}
|
|
73
82
|
|
|
74
|
-
|
|
75
|
-
throw new Error('Already voted or invalid option');
|
|
76
|
-
}
|
|
83
|
+
await Vote.add(data);
|
|
77
84
|
|
|
78
|
-
|
|
85
|
+
// Federate the vote out (FEP-9967) if this is a remote poll
|
|
86
|
+
if (parseInt(pollData.remote, 10) === 1) {
|
|
87
|
+
await federateVoteChange({
|
|
88
|
+
pollData, socket, voteAnon: data.voteAnon, removedOptionIds: [], addedOptionIds: data.options,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
79
91
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
92
|
+
const returnPoll = await Poll.get(data.pollId, socket.uid, false);
|
|
93
|
+
NodeBB.SocketIndex.server.sockets.emit('event:poll.voteChange', {
|
|
94
|
+
pollData: returnPoll,
|
|
95
|
+
uid: socket.uid,
|
|
84
96
|
});
|
|
97
|
+
} finally {
|
|
98
|
+
await db.deleteObjectField(`poll:voteLocks`, `${data.pollId}:${socket.uid}`);
|
|
85
99
|
}
|
|
86
|
-
|
|
87
|
-
const returnPoll = await Poll.get(data.pollId, socket.uid, false);
|
|
88
|
-
NodeBB.SocketIndex.server.sockets.emit('event:poll.voteChange', {
|
|
89
|
-
pollData: returnPoll,
|
|
90
|
-
uid: socket.uid,
|
|
91
|
-
});
|
|
92
100
|
};
|
|
93
101
|
|
|
94
102
|
Sockets.updateVote = async function (socket, data) {
|
|
@@ -122,9 +130,10 @@ Sockets.updateVote = async function (socket, data) {
|
|
|
122
130
|
throw new Error('Invalid option');
|
|
123
131
|
}
|
|
124
132
|
|
|
125
|
-
// Capture the prior vote so we can federate the diff (remote polls only)
|
|
133
|
+
// Capture the prior vote + anon flag so we can federate the diff (remote polls only)
|
|
126
134
|
const isRemote = parseInt(pollData.remote, 10) === 1;
|
|
127
135
|
const oldOptions = isRemote ? (await Vote.get({ uid: socket.uid, pollId: data.pollId })).options : [];
|
|
136
|
+
const voteAnon = isRemote ? await db.isSortedSetMember(`poll:${data.pollId}:anon:voters`, socket.uid) : false;
|
|
128
137
|
|
|
129
138
|
await Vote.update(data);
|
|
130
139
|
|
|
@@ -133,7 +142,7 @@ Sockets.updateVote = async function (socket, data) {
|
|
|
133
142
|
const oldSet = oldOptions.map(String);
|
|
134
143
|
const removedOptionIds = oldOptions.filter(id => !data.options.map(String).includes(String(id)));
|
|
135
144
|
const addedOptionIds = data.options.filter(id => !oldSet.includes(String(id)));
|
|
136
|
-
await federateVoteChange({ pollData, socket, voteAnon
|
|
145
|
+
await federateVoteChange({ pollData, socket, voteAnon, removedOptionIds, addedOptionIds });
|
|
137
146
|
}
|
|
138
147
|
|
|
139
148
|
const returnPoll = await Poll.get(data.pollId, socket.uid, false);
|
|
@@ -254,8 +263,8 @@ async function checkPrivs(cid, socketUid) {
|
|
|
254
263
|
// FEP-9967 outbound vote federation
|
|
255
264
|
// ---------------------------------------------------------------------------
|
|
256
265
|
|
|
257
|
-
// Build a FEP-9967 vote Note. Stable, idempotent id so re-sends and
|
|
258
|
-
// dedupe remotely.
|
|
266
|
+
// Build a FEP-9967 vote Note. Stable, idempotent id so re-sends and
|
|
267
|
+
// Deletes dedupe remotely.
|
|
259
268
|
function buildVoteNote({ voterUrl, questionId, optionName, pollAuthorUrl }) {
|
|
260
269
|
const voteId = `${voterUrl}#activity/vote/${encodeURIComponent(questionId)}/${encodeURIComponent(optionName)}`;
|
|
261
270
|
return {
|
|
@@ -292,21 +301,16 @@ async function federateVoteChange({ pollData, socket, voteAnon, removedOptionIds
|
|
|
292
301
|
return opt ? opt.title : null;
|
|
293
302
|
};
|
|
294
303
|
|
|
295
|
-
//
|
|
304
|
+
// Delete removed options (the vote Note is an entity; retraction deletes it)
|
|
296
305
|
for (const id of removedOptionIds) {
|
|
297
306
|
const name = toName(id);
|
|
298
307
|
if (!name) continue;
|
|
299
308
|
const voteNote = buildVoteNote({ voterUrl, questionId, optionName: name, pollAuthorUrl });
|
|
300
309
|
// eslint-disable-next-line no-await-in-loop
|
|
301
310
|
await activitypub.send('uid', socket.uid, [pollAuthorUrl], {
|
|
302
|
-
id: `${voteNote.id}#
|
|
303
|
-
type: '
|
|
304
|
-
object:
|
|
305
|
-
id: voteNote.id,
|
|
306
|
-
type: 'Create',
|
|
307
|
-
actor: voterUrl,
|
|
308
|
-
object: voteNote,
|
|
309
|
-
},
|
|
311
|
+
id: `${voteNote.id}#delete`,
|
|
312
|
+
type: 'Delete',
|
|
313
|
+
object: voteNote,
|
|
310
314
|
});
|
|
311
315
|
}
|
|
312
316
|
|
package/lib/vote.js
CHANGED
|
@@ -73,10 +73,6 @@ 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
|
-
}
|
|
80
76
|
const [canRead, ended, deleted, updateDisabled] = await Promise.all([
|
|
81
77
|
privileges.posts.can('topics:read', pollInfo.pid, uid),
|
|
82
78
|
Poll.hasEnded(pollId),
|
package/library.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const nconf = nodebb.require('nconf');
|
|
4
|
+
|
|
3
5
|
const NodeBB = require('./lib/nodebb');
|
|
4
6
|
const Config = require('./lib/config');
|
|
5
7
|
const Sockets = require('./lib/sockets');
|
|
@@ -47,6 +49,10 @@ Plugin.load = async function (params) {
|
|
|
47
49
|
NodeBB.PluginSockets[Config.plugin.id] = Sockets;
|
|
48
50
|
|
|
49
51
|
NodeBB.app = params.app;
|
|
52
|
+
|
|
53
|
+
if (nconf.get('runJobs')) {
|
|
54
|
+
await db.delete(`poll:voteLocks`);
|
|
55
|
+
}
|
|
50
56
|
};
|
|
51
57
|
|
|
52
58
|
Plugin.addAdminNavigation = function (adminHeader) {
|
package/package.json
CHANGED
package/plugin.json
CHANGED
|
File without changes
|
package/public/js/admin.js
CHANGED
|
File without changes
|
|
File without changes
|
package/public/js/poll/main.js
CHANGED
|
File without changes
|
package/public/js/poll/view.js
CHANGED
|
@@ -204,10 +204,6 @@
|
|
|
204
204
|
};
|
|
205
205
|
|
|
206
206
|
View.prototype.voteUpdateAllowed = function () {
|
|
207
|
-
// Remote polls: vote changes/removals are not federated (no OSW support yet)
|
|
208
|
-
if (parseInt(this.pollData.info.remote, 10) === 1) {
|
|
209
|
-
return false;
|
|
210
|
-
}
|
|
211
207
|
return parseInt(this.pollData.info.disallowVoteUpdate, 10) !== 1;
|
|
212
208
|
};
|
|
213
209
|
|
|
File without changes
|
|
File without changes
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const db = nodebb.require('./src/database');
|
|
4
|
-
const privileges = nodebb.require('./src/privileges');
|
|
5
|
-
|
|
6
|
-
module.exports = {
|
|
7
|
-
name: 'Fix incorrectly given poll privilege',
|
|
8
|
-
timestamp: Date.UTC(2020, 9, 29),
|
|
9
|
-
method: async () => {
|
|
10
|
-
const cids = await db.getSortedSetRange('categories:cid', 0, -1);
|
|
11
|
-
await Promise.all(cids.map(async (cid) => {
|
|
12
|
-
await privileges.categories.rescind(['poll:create'], cid, ['registered-users']);
|
|
13
|
-
}));
|
|
14
|
-
},
|
|
15
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const db = nodebb.require('./src/database');
|
|
4
|
+
const privileges = nodebb.require('./src/privileges');
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
name: 'Fix incorrectly given poll privilege',
|
|
8
|
+
timestamp: Date.UTC(2020, 9, 29),
|
|
9
|
+
method: async () => {
|
|
10
|
+
const cids = await db.getSortedSetRange('categories:cid', 0, -1);
|
|
11
|
+
await Promise.all(cids.map(async (cid) => {
|
|
12
|
+
await privileges.categories.rescind(['poll:create'], cid, ['registered-users']);
|
|
13
|
+
}));
|
|
14
|
+
},
|
|
15
|
+
};
|