| 1 | /**
|
|---|
| 2 | * ap-polls.js — de peilingen (stap 8 van shaer-drc).
|
|---|
| 3 | *
|
|---|
| 4 | * Beide kanten van een fediverse-poll:
|
|---|
| 5 | * - VREEMDE polls: parsePoll (het AS2 Question-formaat naar onze compacte
|
|---|
| 6 | * vorm) en de twee stemhandelingen (voteOnPoll uit de tijdlijncache,
|
|---|
| 7 | * voteOnRemotePoll op URL).
|
|---|
| 8 | * - EIGEN polls: de definitie op posts.poll_json, de telling uit de
|
|---|
| 9 | * stembiljetten (poll_votes), de Question-vorm op een note, het innemen
|
|---|
| 10 | * van een biljet en de gebundelde Update(Question) naar de volgers.
|
|---|
| 11 | *
|
|---|
| 12 | * Vier werktuigen uit de dienstlaag komen via wirePolls binnen; de rest wijst
|
|---|
| 13 | * omlaag (db, ap-core, ap-transport).
|
|---|
| 14 | */
|
|---|
| 15 | import db from '../config/database.js';
|
|---|
| 16 | import { actorId, AP_CONTEXT } from './ap-core.js';
|
|---|
| 17 | import { fetchActor, getOrCreateKeys, deliverWithRetry } from './ap-transport.js';
|
|---|
| 18 |
|
|---|
| 19 | // De werktuigen uit de dienstlaag; ActivityPubService vult ze onderaan.
|
|---|
| 20 | let deliverUpdate, rid, movedRefusal, actorUriOf;
|
|---|
| 21 | export function wirePolls(deps) {
|
|---|
| 22 | ({ deliverUpdate, rid, movedRefusal, actorUriOf } = deps);
|
|---|
| 23 | }
|
|---|
| 24 |
|
|---|
| 25 | // Parse a fediverse poll (an ActivityStreams `Question` — the Mastodon-standard poll form)
|
|---|
| 26 | // into our compact shape. `oneOf` = single choice, `anyOf` = multiple; each option is a Note
|
|---|
| 27 | // with a `name` and a `replies` collection whose `totalItems` is that option's vote count.
|
|---|
| 28 | export function parsePoll(o) {
|
|---|
| 29 | if (!o || o.type !== 'Question') return null;
|
|---|
| 30 | const raw = Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null);
|
|---|
| 31 | if (!raw || !raw.length) return null;
|
|---|
| 32 | const options = raw.slice(0, 12).map((opt) => ({
|
|---|
| 33 | name: String((opt && opt.name) || '').slice(0, 300),
|
|---|
| 34 | count: Math.max(0, Number(opt && opt.replies && opt.replies.totalItems) || 0),
|
|---|
| 35 | })).filter((x) => x.name);
|
|---|
| 36 | if (!options.length) return null;
|
|---|
| 37 | const endTime = o.endTime || (typeof o.closed === 'string' ? o.closed : null);
|
|---|
| 38 | const closed = !!o.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
|
|---|
| 39 | return { multiple: Array.isArray(o.anyOf), options, endTime, closed, voters: Number(o.votersCount) || null, voted: null };
|
|---|
| 40 | }
|
|---|
| 41 |
|
|---|
| 42 | // ── Polls WE host (a local post with a poll) ──────────────────────
|
|---|
| 43 | // Parse the poll definition stored on our own post (posts.poll_json). Counts are
|
|---|
| 44 | // NOT stored here — they're derived from the poll_votes ballots so a re-render always
|
|---|
| 45 | // reflects the authoritative tally.
|
|---|
| 46 | export function parseOwnPoll(pollJson) {
|
|---|
| 47 | if (!pollJson) return null;
|
|---|
| 48 | let d; try { d = typeof pollJson === 'string' ? JSON.parse(pollJson) : pollJson; } catch { return null; }
|
|---|
| 49 | if (!d || !Array.isArray(d.options)) return null;
|
|---|
| 50 | const options = d.options.map((o) => ({ name: String((o && o.name != null ? o.name : o) || '').slice(0, 300) })).filter((o) => o.name);
|
|---|
| 51 | if (options.length < 2) return null;
|
|---|
| 52 | const endTime = d.endTime || null;
|
|---|
| 53 | const closed = !!d.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
|
|---|
| 54 | return { multiple: !!d.multiple, options, endTime, closed };
|
|---|
| 55 | }
|
|---|
| 56 |
|
|---|
| 57 | // Live tally of a hosted poll from its ballots: per-option counts + unique voters.
|
|---|
| 58 | export function pollTally(postId) {
|
|---|
| 59 | const counts = {}; let voters = 0;
|
|---|
| 60 | try {
|
|---|
| 61 | for (const r of db.prepare('SELECT choice, COUNT(*) AS n FROM poll_votes WHERE post_id = ? GROUP BY choice').all(postId)) counts[r.choice] = r.n;
|
|---|
| 62 | voters = db.prepare('SELECT COUNT(DISTINCT actor_uri) AS n FROM poll_votes WHERE post_id = ?').get(postId).n || 0;
|
|---|
| 63 | } catch { /* table may not exist yet */ }
|
|---|
| 64 | return { counts, voters };
|
|---|
| 65 | }
|
|---|
| 66 |
|
|---|
| 67 | // Render-ready view of a hosted poll (options with counts + percentages, totals, state).
|
|---|
| 68 | // Voting is fediverse-only, so this is display-only on the site.
|
|---|
| 69 | export function ownPollView(post) {
|
|---|
| 70 | const poll = parseOwnPoll(post && post.poll_json);
|
|---|
| 71 | if (!poll) return null;
|
|---|
| 72 | const { counts, voters } = pollTally(post.id);
|
|---|
| 73 | const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
|---|
| 74 | const denom = poll.multiple ? voters : total; // multiple-choice %: share of voters (can sum >100%)
|
|---|
| 75 | const options = poll.options.map((o) => {
|
|---|
| 76 | const count = counts[o.name] || 0;
|
|---|
| 77 | return { name: o.name, count, pct: denom ? Math.round((count / denom) * 100) : 0 };
|
|---|
| 78 | });
|
|---|
| 79 | return { multiple: poll.multiple, options, total, voters, endTime: poll.endTime, closed: poll.closed };
|
|---|
| 80 | }
|
|---|
| 81 |
|
|---|
| 82 | // Attach the AS2 Question shape to a note built for a hosted poll. Mastodon renders a
|
|---|
| 83 | // status with either media OR a poll (never both), so a poll federates as content +
|
|---|
| 84 | // options with no media attachment. oneOf = single choice, anyOf = multiple.
|
|---|
| 85 | export function applyPollToNote(note, postId, poll) {
|
|---|
| 86 | const { counts, voters } = pollTally(postId);
|
|---|
| 87 | const opts = poll.options.map((o) => ({
|
|---|
| 88 | type: 'Note',
|
|---|
| 89 | name: o.name,
|
|---|
| 90 | replies: { type: 'Collection', totalItems: counts[o.name] || 0 },
|
|---|
| 91 | }));
|
|---|
| 92 | note.type = 'Question';
|
|---|
| 93 | note[poll.multiple ? 'anyOf' : 'oneOf'] = opts;
|
|---|
| 94 | if (poll.endTime) note.endTime = new Date(poll.endTime).toISOString();
|
|---|
| 95 | // Once closed, Mastodon expects a `closed` timestamp (the effective end).
|
|---|
| 96 | if (poll.closed) note.closed = poll.endTime ? new Date(poll.endTime).toISOString() : new Date().toISOString();
|
|---|
| 97 | note.votersCount = voters;
|
|---|
| 98 | delete note.attachment; // media ATTACHMENTS + a poll are mutually exclusive on Mastodon
|
|---|
| 99 | // Keep note.image: it's the cover, which Mastodon ignores on a Question anyway
|
|---|
| 100 | // (same as on any Note) but Klonkt reads to show the cover in feeds/the Cirkel.
|
|---|
| 101 | // Deleting it stripped the cover off every boosted poll.
|
|---|
| 102 | return note;
|
|---|
| 103 | }
|
|---|
| 104 |
|
|---|
| 105 | // Record an inbound ballot on one of OUR polls. A vote arrives as a Create(Note) whose
|
|---|
| 106 | // `name` is the chosen option and `inReplyTo` is our poll note — the Mastodon-standard
|
|---|
| 107 | // vote form. Returns { handled } — handled=true means it was addressed to a poll (so the
|
|---|
| 108 | // caller must NOT also store it as a reply), false means "not a poll, fall through".
|
|---|
| 109 | export function recordPollBallot(postId, actorUri, rawChoice) {
|
|---|
| 110 | const choice = String(rawChoice == null ? '' : rawChoice).slice(0, 300);
|
|---|
| 111 | if (!choice) return { handled: false };
|
|---|
| 112 | let post; try { post = db.prepare('SELECT poll_json FROM posts WHERE id = ?').get(postId); } catch { return { handled: false }; }
|
|---|
| 113 | const poll = post && parseOwnPoll(post.poll_json);
|
|---|
| 114 | if (!poll) return { handled: false }; // not a poll → let the reply logic handle it
|
|---|
| 115 | if (poll.closed) return { handled: true }; // voting closed → drop
|
|---|
| 116 | if (!poll.options.some((o) => o.name === choice)) return { handled: true }; // unknown option → drop
|
|---|
| 117 | try {
|
|---|
| 118 | // Single choice = one ballot per actor: ignore a later/different vote. Multiple choice
|
|---|
| 119 | // allows one ballot per distinct option (the UNIQUE(post,actor,choice) dedupes repeats).
|
|---|
| 120 | if (!poll.multiple && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? AND actor_uri = ? LIMIT 1').get(postId, actorUri)) return { handled: true };
|
|---|
| 121 | db.prepare('INSERT OR IGNORE INTO poll_votes (post_id, actor_uri, choice) VALUES (?, ?, ?)').run(postId, actorUri, choice);
|
|---|
| 122 | } catch { return { handled: true }; }
|
|---|
| 123 | schedulePollUpdate(postId);
|
|---|
| 124 | return { handled: true };
|
|---|
| 125 | }
|
|---|
| 126 |
|
|---|
| 127 | // Coalesce a burst of votes into ONE Update(Question) per poll: the first vote schedules a
|
|---|
| 128 | // refresh ~15s out; further votes in that window ride the same pending update (which carries
|
|---|
| 129 | // the accumulated tally). Non-follower voters re-fetch the Question (live tally) themselves.
|
|---|
| 130 | const _pollUpdTimers = new Map();
|
|---|
| 131 | function schedulePollUpdate(postId) {
|
|---|
| 132 | if (_pollUpdTimers.has(postId)) return;
|
|---|
| 133 | const t = setTimeout(() => { _pollUpdTimers.delete(postId); deliverPollUpdate(postId).catch(() => { /* best-effort */ }); }, 15000);
|
|---|
| 134 | if (t.unref) t.unref();
|
|---|
| 135 | _pollUpdTimers.set(postId, t);
|
|---|
| 136 | }
|
|---|
| 137 |
|
|---|
| 138 | // Push the fresh poll tally (or closed state) to followers as Update(Question).
|
|---|
| 139 | export async function deliverPollUpdate(postId) {
|
|---|
| 140 | const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
|
|---|
| 141 | if (!base || !postId) return;
|
|---|
| 142 | let post, site;
|
|---|
| 143 | try {
|
|---|
| 144 | post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
|---|
| 145 | if (!post || !post.poll_json) return;
|
|---|
| 146 | site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
|
|---|
| 147 | } catch { return; }
|
|---|
| 148 | if (site) await deliverUpdate(site, post);
|
|---|
| 149 | }
|
|---|
| 150 |
|
|---|
| 151 | // Vote on a remote fediverse poll (a cached Question). A ballot = a Create(Note) carrying only a
|
|---|
| 152 | // `name` (the chosen option) + inReplyTo the Question, addressed to the poll's author — the
|
|---|
| 153 | // Mastodon-standard vote. Records our choice locally + optimistically bumps the counts; the
|
|---|
| 154 | // author's Update(Question) refreshes the authoritative totals when it arrives.
|
|---|
| 155 | export async function voteOnPoll(site, questionId, choices) {
|
|---|
| 156 | const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
|
|---|
| 157 | if (!base || !site || !site.slug || !questionId) return { error: 'config' };
|
|---|
| 158 | let row; try { row = db.prepare('SELECT author_uri, poll_json FROM ap_timeline WHERE id = ? AND slug = ? LIMIT 1').get(questionId, site.slug); } catch { /* ignore */ }
|
|---|
| 159 | if (!row || !row.poll_json) return { error: 'not_found' };
|
|---|
| 160 | let poll; try { poll = JSON.parse(row.poll_json); } catch { return { error: 'not_found' }; }
|
|---|
| 161 | if (poll.closed) return { error: 'closed' };
|
|---|
| 162 | if (poll.voted) return { error: 'already' };
|
|---|
| 163 | const valid = new Set(poll.options.map((o) => o.name));
|
|---|
| 164 | const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
|
|---|
| 165 | if (!picks.length) return { error: 'invalid' };
|
|---|
| 166 | const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
|
|---|
| 167 | const me = actorId(base, site.slug);
|
|---|
| 168 | const keys = getOrCreateKeys(site.slug);
|
|---|
| 169 | const authorUri = row.author_uri || null;
|
|---|
| 170 | const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
|
|---|
| 171 | const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
|
|---|
| 172 | if (!inbox) return { error: 'unreachable' };
|
|---|
| 173 | for (const name of chosen) {
|
|---|
| 174 | const nid = `${me}/votes/${Date.now()}-${rid()}`;
|
|---|
| 175 | const note = { id: nid, type: 'Note', attributedTo: me, to: authorUri ? [authorUri] : [], name, inReplyTo: questionId, published: new Date().toISOString() };
|
|---|
| 176 | const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
|
|---|
| 177 | deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
|
|---|
| 178 | }
|
|---|
| 179 | // Local optimistic update (authoritative counts arrive via the author's Update(Question)).
|
|---|
| 180 | poll.voted = poll.multiple ? chosen : chosen[0];
|
|---|
| 181 | for (const o of poll.options) if (chosen.includes(o.name)) o.count = (o.count || 0) + 1;
|
|---|
| 182 | if (poll.voters != null) poll.voters += 1;
|
|---|
| 183 | try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), questionId, site.slug); } catch { /* ignore */ }
|
|---|
| 184 | return { ok: true };
|
|---|
| 185 | }
|
|---|
| 186 |
|
|---|
| 187 | // Vote on ANY fediverse poll by URL (the interact page) — no timeline cache needed. Fetches
|
|---|
| 188 | // the Question fresh, validates the choice(s), and casts the Mastodon-standard ballot (a
|
|---|
| 189 | // Create(Note) with `name` + inReplyTo) straight to the poll's author. Used for polls you find
|
|---|
| 190 | // by URL, not just ones from accounts you follow (which go through voteOnPoll via /news).
|
|---|
| 191 | export async function voteOnRemotePoll(site, questionUrl, choices) {
|
|---|
| 192 | const _mv = movedRefusal(site, 'poll-vote'); if (_mv) return _mv;
|
|---|
| 193 | const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
|
|---|
| 194 | if (!base || !site || !site.slug || !/^https?:\/\//i.test(String(questionUrl || ''))) return { error: 'config' };
|
|---|
| 195 | const q = await fetchActor(questionUrl).catch(() => null); // AP GET (SSRF-guarded)
|
|---|
| 196 | if (!q || q.type !== 'Question' || !q.id) return { error: 'not_found' };
|
|---|
| 197 | const poll = parsePoll(q);
|
|---|
| 198 | if (!poll) return { error: 'not_found' };
|
|---|
| 199 | if (poll.closed) return { error: 'closed' };
|
|---|
| 200 | const valid = new Set(poll.options.map((o) => o.name));
|
|---|
| 201 | const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
|
|---|
| 202 | if (!picks.length) return { error: 'invalid' };
|
|---|
| 203 | const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
|
|---|
| 204 | const authorUri = actorUriOf(q.attributedTo);
|
|---|
| 205 | const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
|
|---|
| 206 | const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
|
|---|
| 207 | if (!inbox) return { error: 'unreachable' };
|
|---|
| 208 | const me = actorId(base, site.slug);
|
|---|
| 209 | const keys = getOrCreateKeys(site.slug);
|
|---|
| 210 | for (const name of chosen) {
|
|---|
| 211 | const nid = `${me}/votes/${Date.now()}-${rid()}`;
|
|---|
| 212 | const note = { id: nid, type: 'Note', attributedTo: me, to: [authorUri], name, inReplyTo: q.id, published: new Date().toISOString() };
|
|---|
| 213 | const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
|
|---|
| 214 | deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
|
|---|
| 215 | }
|
|---|
| 216 | return { ok: true };
|
|---|
| 217 | }
|
|---|