fedipod 0.17.0 → 0.18.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/README.md CHANGED
@@ -74,6 +74,9 @@ keyword filters, scheduled posts, pinned posts (visible from other servers),
74
74
  blocking and muting from the client, custom emojis, and web-push
75
75
  notifications that reach you while the client is closed.
76
76
 
77
+ Polls: up to four options, one answer or several, running from five minutes to
78
+ a month.
79
+
77
80
  ### Other clients
78
81
 
79
82
  - **Web clients**: drop any static Mastodon client dist into `ui/<name>/`
package/lib/c2s.mjs CHANGED
@@ -234,8 +234,9 @@ export class C2S {
234
234
 
235
235
  switch (activity.type) {
236
236
  case 'Create': {
237
- if (!object || (object.type && object.type !== 'Note')) {
238
- return this.send(res, 422, { error: 'only a Note (or a bare Note) can be created here' });
237
+ const makes = object?.type || 'Note';
238
+ if (!object || (makes !== 'Note' && makes !== 'Question')) {
239
+ return this.send(res, 422, { error: 'only a Note or a Question (or a bare Note) can be created here' });
239
240
  }
240
241
  const visibility = this.visibilityOf(activity, object);
241
242
  if (!visibility) {
@@ -246,6 +247,30 @@ export class C2S {
246
247
  // visible characters rather than as markup. Documented v1 limit.
247
248
  const text = String(object.source?.content ?? object.content ?? '');
248
249
  if (!text.trim()) return this.send(res, 422, { error: 'the note has no content' });
250
+
251
+ // A Question is a poll: the choices are in oneOf (pick one) or anyOf
252
+ // (pick several), each naming itself, and endTime is when it shuts.
253
+ if (makes === 'Question') {
254
+ const one = arr(object.oneOf);
255
+ const many = arr(object.anyOf);
256
+ const titles = (one.length ? one : many).map((c) => String(c?.name ?? '').trim()).filter(Boolean);
257
+ try {
258
+ const question = await agent.publisher.publishQuestion(text, {
259
+ options: titles,
260
+ multiple: !one.length && many.length > 0,
261
+ expiresAt: object.endTime || null,
262
+ inReplyTo: idOf(object.inReplyTo) || undefined,
263
+ visibility,
264
+ spoilerText: object.summary || null,
265
+ });
266
+ return this.send(res, 201,
267
+ { id: wire.createActivityId(question.id), object: question.id },
268
+ { location: wire.createActivityId(question.id) });
269
+ } catch (e) {
270
+ return this.send(res, 422, { error: e.message });
271
+ }
272
+ }
273
+
249
274
  const attachments = arr(object.attachment).map((a) => ({
250
275
  url: a?.url, mediaType: a?.mediaType,
251
276
  ...(a?.name ? { description: a.name } : {}),
package/lib/embed.mjs CHANGED
@@ -202,6 +202,7 @@ export async function startEmbeddedAgent({
202
202
  agent.importer?.stop();
203
203
  clearInterval(agent.schedTimer);
204
204
  clearInterval(agent.refreshTimer);
205
+ agent.publisher?.stopPolls();
205
206
  // Same order the standalone agent's shutdown uses: write what is pending,
206
207
  // then let go of the lease so the next agent need not wait out the TTL.
207
208
  await Promise.allSettled([
package/lib/intake.mjs CHANGED
@@ -19,6 +19,7 @@ import { USER_AGENT } from './ua.mjs';
19
19
  import { PUBLIC } from './wire.mjs';
20
20
  import { HTTP_TIMEOUT_MS, readCapped } from './safefetch.mjs';
21
21
  import { linkTargets, REL } from './links.mjs';
22
+ import * as polls from './polls.mjs';
22
23
  import { dropFollower } from './store.mjs';
23
24
 
24
25
  const RDF = $rdf.Namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#');
@@ -349,13 +350,18 @@ export class Intake {
349
350
  || g.each(null, NOTIFY('channelType'), NOTIFY('WebSocketChannel2023'), null)
350
351
  .map(n => n.value).find(Boolean);
351
352
  if (!channel) { this.wsState = 'unavailable'; this.log('no WebSocketChannel2023 service — polling only'); return; }
353
+ // The topic is a POD resource, and it travels in the BODY — so the url map
354
+ // RemotePod applies to the request line never reaches it. A fronted
355
+ // identity's inbox url names the front, which the pod cannot grant read on,
356
+ // and the subscription came back 403. A no-op when unfronted.
357
+ const topic = this.urls.toPod ? this.urls.toPod(this.urls.inbox) : this.urls.inbox;
352
358
  const sub = await this.remote.fetch(channel, {
353
359
  method: 'POST',
354
360
  headers: { 'content-type': 'application/ld+json' },
355
361
  body: JSON.stringify({
356
362
  '@context': ['https://www.w3.org/ns/solid/notification/v1'],
357
363
  type: 'http://www.w3.org/ns/solid/notifications#WebSocketChannel2023',
358
- topic: this.urls.inbox,
364
+ topic,
359
365
  }),
360
366
  });
361
367
  const body = await readCapped(sub).then(JSON.parse).catch(() => null);
@@ -1426,6 +1432,22 @@ export class Intake {
1426
1432
  // somebody else's boost, or through a hashtag feed.
1427
1433
  if (this.store.isBlocked(author)) return `blocked author (${author})`;
1428
1434
 
1435
+ // An answer to one of our polls is a number on a document, not a post. It
1436
+ // arrives as an ordinary reply naming an option and carrying nothing else,
1437
+ // so filing it as one would put a blank entry in the thread and ring the
1438
+ // owner once per voter. Counted or refused — a second answer, an option we
1439
+ // do not offer, a poll already shut — it stops here either way.
1440
+ const asked = note.inReplyTo && this.store.getStatuses()
1441
+ .find(x => x.noteId === String(note.inReplyTo) && x.kind === 'post' && x.poll);
1442
+ if (asked && polls.isVoteShape(note)) {
1443
+ const counted = await this.publisher.recordVote(asked.noteId, author, note.name)
1444
+ .catch(e => { this.log(`vote on ${asked.noteId}: ${e.message}`); return false; });
1445
+ this.log(counted
1446
+ ? `vote counted (${note.name}): ${asked.noteId}`
1447
+ : `vote not counted (${note.name}) from ${author}: ${asked.noteId}`);
1448
+ return;
1449
+ }
1450
+
1429
1451
  // Anyone can Append to a public inbox, so arriving is not the same as
1430
1452
  // belonging in the home timeline. Follow Mastodon's split: people you
1431
1453
  // follow (and their boosts) are HOME; anyone else is a MENTION — kept,
package/lib/mastoapi.mjs CHANGED
@@ -17,6 +17,10 @@ import { authorOf } from './intake.mjs';
17
17
  import { profileUrl, postUrl } from './bskyfeed.mjs';
18
18
  import { Push } from './webpush.mjs';
19
19
  import { safeFetch, readCapped } from './safefetch.mjs';
20
+ import {
21
+ MAX_OPTIONS as POLL_MAX_OPTIONS, MAX_OPTION_CHARS as POLL_MAX_OPTION_CHARS,
22
+ MIN_SECONDS as POLL_MIN_SECONDS, MAX_SECONDS as POLL_MAX_SECONDS,
23
+ } from './polls.mjs';
20
24
 
21
25
  // What an attachment is allowed to BE. Anything else is stored as bytes, which
22
26
  // a browser downloads rather than runs.
@@ -27,6 +31,26 @@ const ATTACHMENT_KINDS = new Set(['image', 'video', 'audio']);
27
31
  const NEVER = new Set(['image/svg+xml', 'image/svg']);
28
32
  const OPAQUE = 'application/octet-stream';
29
33
 
34
+ /**
35
+ * A poll out of a compose request, or null when there is none. A JSON client
36
+ * sends a `poll` object; a form-encoded one spells the same thing out in
37
+ * Rails's bracket notation, which is the shape the option list arrives in.
38
+ */
39
+ export function pollParams(body) {
40
+ const nested = body?.poll && typeof body.poll === 'object' ? body.poll : null;
41
+ const options = [].concat(nested?.options ?? body?.['poll[options][]'] ?? [])
42
+ .map(o => String(o ?? '').trim()).filter(Boolean);
43
+ const rawExpiry = nested?.expires_in ?? body?.['poll[expires_in]'];
44
+ const rawMultiple = nested?.multiple ?? body?.['poll[multiple]'];
45
+ if (!options.length && rawExpiry === undefined) return null;
46
+ return {
47
+ options,
48
+ expiresIn: rawExpiry === undefined || rawExpiry === null || rawExpiry === ''
49
+ ? null : Number(rawExpiry),
50
+ multiple: rawMultiple === true || rawMultiple === 'true' || rawMultiple === '1',
51
+ };
52
+ }
53
+
30
54
  export function attachmentType(claimed) {
31
55
  const t = String(claimed || '').split(';')[0].trim().toLowerCase();
32
56
  if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(t) || NEVER.has(t)) return OPAQUE;
@@ -628,7 +652,7 @@ export class MastoApi {
628
652
  expires_at: s.poll.expiresAt || null,
629
653
  expired: !!s.poll.closed || (!!s.poll.expiresAt && Date.parse(s.poll.expiresAt) < Date.now()),
630
654
  multiple: !!s.poll.multiple,
631
- votes_count: votes, voters_count: null,
655
+ votes_count: votes, voters_count: s.poll.votersCount ?? null,
632
656
  options: opts.map(o => ({ title: o.title, votes_count: o.votes || 0 })),
633
657
  voted: !!s.poll.voted, own_votes: s.poll.ownVotes || [],
634
658
  emojis: [],
@@ -1080,6 +1104,41 @@ export class MastoApi {
1080
1104
  const mediaIds = [].concat(body.media_ids || body['media_ids[]'] || []).filter(Boolean);
1081
1105
  const media = this.store.getMedia();
1082
1106
  const attachments = mediaIds.map(id => media[id] && { id, ...media[id] }).filter(Boolean);
1107
+
1108
+ const asking = pollParams(body);
1109
+ if (asking) {
1110
+ // Mastodon's own rules, stated here rather than discovered inside the
1111
+ // publisher, so a client gets the reason back on the request it made.
1112
+ if (attachments.length) return send(422, { error: 'a poll cannot carry media' });
1113
+ if (body.scheduled_at) return send(422, { error: 'a poll cannot be scheduled' });
1114
+ if (asking.options.length < 2) return send(422, { error: 'a poll needs at least two options' });
1115
+ if (asking.options.length > POLL_MAX_OPTIONS) {
1116
+ return send(422, { error: `a poll takes at most ${POLL_MAX_OPTIONS} options` });
1117
+ }
1118
+ if (asking.options.some(o => o.length > POLL_MAX_OPTION_CHARS)) {
1119
+ return send(422, { error: `a poll option is at most ${POLL_MAX_OPTION_CHARS} characters` });
1120
+ }
1121
+ if (new Set(asking.options).size !== asking.options.length) {
1122
+ return send(422, { error: 'a poll\u2019s options must differ from one another' });
1123
+ }
1124
+ const seconds = asking.expiresIn ?? POLL_MAX_SECONDS;
1125
+ if (!Number.isFinite(seconds) || seconds < POLL_MIN_SECONDS || seconds > POLL_MAX_SECONDS) {
1126
+ return send(422, {
1127
+ error: `a poll runs between ${POLL_MIN_SECONDS} and ${POLL_MAX_SECONDS} seconds`,
1128
+ });
1129
+ }
1130
+ try {
1131
+ const q = await this.agent.publisher.publishQuestion(body.status, {
1132
+ options: asking.options, multiple: asking.multiple,
1133
+ expiresAt: new Date(Date.now() + seconds * 1000).toISOString(),
1134
+ inReplyTo, visibility, spoilerText,
1135
+ });
1136
+ return send(200, this.status(this.store.getStatuses().find(x => x.noteId === q.id)));
1137
+ } catch (e) {
1138
+ return send(422, { error: e.message });
1139
+ }
1140
+ }
1141
+
1083
1142
  if (body.scheduled_at) {
1084
1143
  const at = Date.parse(body.scheduled_at);
1085
1144
  if (!Number.isFinite(at) || at < Date.now() + 60_000) {
@@ -1707,7 +1766,12 @@ function instanceConfig() {
1707
1766
  image_size_limit: 10 * 1024 * 1024, video_size_limit: 40 * 1024 * 1024,
1708
1767
  image_matrix_limit: 16777216, video_matrix_limit: 2304000,
1709
1768
  },
1710
- polls: { max_options: 0 },
1769
+ polls: {
1770
+ max_options: POLL_MAX_OPTIONS,
1771
+ max_characters_per_option: POLL_MAX_OPTION_CHARS,
1772
+ min_expiration: POLL_MIN_SECONDS,
1773
+ max_expiration: POLL_MAX_SECONDS,
1774
+ },
1711
1775
  accounts: { max_featured_tags: 0 },
1712
1776
  };
1713
1777
  }
@@ -1865,7 +1929,17 @@ function readBody(req) {
1865
1929
  const ct = String(req.headers['content-type'] || '');
1866
1930
  try {
1867
1931
  if (ct.includes('application/json')) return resolve(data ? JSON.parse(data) : {});
1868
- resolve(Object.fromEntries(new URLSearchParams(data)));
1932
+ // A form-encoded list is the same key repeated, spelled with a
1933
+ // trailing `[]`. Reading it as a plain object kept only the last one,
1934
+ // so a client sending its poll or its media that way lost all but the
1935
+ // final value. Only the `[]` keys become lists: everything else keeps
1936
+ // the single value the rest of this file reads.
1937
+ const form = new URLSearchParams(data);
1938
+ const out = {};
1939
+ for (const key of new Set(form.keys())) {
1940
+ out[key] = key.endsWith('[]') ? form.getAll(key) : form.get(key);
1941
+ }
1942
+ resolve(out);
1869
1943
  } catch (e) { reject(e); }
1870
1944
  });
1871
1945
  req.on('error', reject);
package/lib/polls.mjs ADDED
@@ -0,0 +1,105 @@
1
+ // polls.mjs — the count behind a Question.
2
+ //
3
+ // A poll travels as a Question whose options live in `oneOf` (pick one) or
4
+ // `anyOf` (pick several). A vote is not a special activity: it arrives as an
5
+ // ordinary reply carrying the option's `name` and no content. So the fediverse
6
+ // carries the votes and the author's server keeps the count — which means when
7
+ // the poll is ours, the roster of who chose what is ours to hold.
8
+ //
9
+ // Everything here is pure. The roster is a plain object the caller stores and
10
+ // hands back; the tallies are always DERIVED from it rather than kept beside
11
+ // it, so a count cannot drift away from the votes it is meant to summarize.
12
+
13
+ import crypto from 'node:crypto';
14
+
15
+ /** Where the roster lives in agent state. */
16
+ export const VOTES_DOC = 'poll-votes.json';
17
+
18
+ // What a poll may be. These are Mastodon's own limits, and they are the ones
19
+ // clients read out of the instance document to draw their poll composer, so a
20
+ // number we chose differently would be a number the composer then lets someone
21
+ // exceed. They live here rather than beside the client API because every way
22
+ // in has to honour them: an option is matched BY NAME when the vote comes
23
+ // back, so a long title is one something else in the network may truncate and
24
+ // hand back unrecognizable.
25
+ export const MAX_OPTIONS = 4;
26
+ export const MAX_OPTION_CHARS = 50;
27
+ export const MIN_SECONDS = 5 * 60;
28
+ export const MAX_SECONDS = 2629746; // a month, as Mastodon counts one
29
+
30
+ // A voter is recorded as a hash of their actor id rather than the id itself.
31
+ // The roster is rewritten on every vote inside a document we serialize whole,
32
+ // and a poll that travels would otherwise grow a list of everyone who took
33
+ // part. The hash is stable, which is all a duplicate check needs.
34
+ export function voterKey(actor) {
35
+ return crypto.createHash('sha256').update(String(actor)).digest('hex').slice(0, 16);
36
+ }
37
+
38
+ /**
39
+ * Does this note have the shape of a vote? A name, and no content: that is the
40
+ * whole convention. Anything carrying prose is a reply somebody wrote, and
41
+ * swallowing it into a tally would lose it.
42
+ */
43
+ export function isVoteShape(note) {
44
+ if (!note?.name || typeof note.name !== 'string') return false;
45
+ const text = String(note.content ?? '').replace(/<[^>]*>/gu, '').trim();
46
+ return text === '';
47
+ }
48
+
49
+ /** Which option is this the name of? -1 when it names none of them. */
50
+ export function optionIndex(poll, name) {
51
+ const opts = poll?.options || [];
52
+ return opts.findIndex(o => o.title === String(name));
53
+ }
54
+
55
+ /** Closed outright, or past its end time. */
56
+ export function pollClosed(poll, now = Date.now()) {
57
+ if (!poll) return false;
58
+ if (poll.closed) return true;
59
+ return !!poll.expiresAt && Date.parse(poll.expiresAt) <= now;
60
+ }
61
+
62
+ /**
63
+ * Record one choice. Returns the roster to store and whether anything changed
64
+ * — an unchanged roster must not cost a rewrite of the Question, which is the
65
+ * one expense a busy poll can run up on the pod.
66
+ *
67
+ * A single-choice poll takes the first answer and ignores the rest, which is
68
+ * how a voter changing their mind is refused rather than counted twice. A
69
+ * multiple-choice poll unions the answers, because each choice arrives as its
70
+ * own reply.
71
+ */
72
+ export function addVote(roster, key, index, { multiple = false } = {}) {
73
+ const had = roster[key] || [];
74
+ if (had.length && !multiple) return { roster, changed: false };
75
+ if (had.includes(index)) return { roster, changed: false };
76
+ return { roster: { ...roster, [key]: [...had, index] }, changed: true };
77
+ }
78
+
79
+ /**
80
+ * The counts, derived. `votes` is answers given, `voters` is people who gave
81
+ * them — the same number until a poll takes several answers each.
82
+ */
83
+ export function tallyOf(roster, optionCount) {
84
+ const counts = new Array(optionCount).fill(0);
85
+ let voters = 0;
86
+ for (const picks of Object.values(roster || {})) {
87
+ let counted = false;
88
+ for (const i of picks) {
89
+ if (i >= 0 && i < optionCount) { counts[i]++; counted = true; }
90
+ }
91
+ if (counted) voters++;
92
+ }
93
+ return { counts, voters, votes: counts.reduce((n, c) => n + c, 0) };
94
+ }
95
+
96
+ /** The poll record with its counts brought up to date from the roster. */
97
+ export function withTally(poll, roster) {
98
+ const opts = poll?.options || [];
99
+ const { counts, voters } = tallyOf(roster, opts.length);
100
+ return {
101
+ ...poll,
102
+ options: opts.map((o, i) => ({ ...o, votes: counts[i] })),
103
+ votersCount: voters,
104
+ };
105
+ }
package/lib/publisher.mjs CHANGED
@@ -7,6 +7,7 @@ import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import * as wire from './wire.mjs';
10
+ import * as polls from './polls.mjs';
10
11
  import { USER_AGENT } from './ua.mjs';
11
12
  import { HTTP_TIMEOUT_MS } from './safefetch.mjs';
12
13
 
@@ -16,6 +17,11 @@ const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="http
16
17
  // still gets everything, so a missed call site degrades to the old cost rather
17
18
  // than silently publishing nothing.
18
19
  const REBUILD_MAX_PER_RUN = 200;
20
+ // How long a poll gathers votes before its Question is rewritten. Every vote
21
+ // changes a number other servers re-read, and rewriting per vote would make a
22
+ // busy poll a steady write stream against the pod. A burst costs one rewrite
23
+ // and one Update instead.
24
+ const POLL_REWRITE_MS = 10_000;
19
25
  const ALL_COLLECTIONS = { followers: true, following: true, outbox: true, acls: true,
20
26
  pending: true, blocked: true };
21
27
  const AGENT_VERSION = JSON.parse(fs.readFileSync(
@@ -48,6 +54,8 @@ export class Publisher {
48
54
  this.probeFetch = probeFetch || ((u, i) => this.remote.probe(u, i));
49
55
  this.resolveMention = resolveMention;
50
56
  this.privateOnPod = privateOnPod;
57
+ // Per-poll rewrite windows, keyed by question id. See POLL_REWRITE_MS.
58
+ this.pollTimers = new Map();
51
59
  this.log = log;
52
60
  }
53
61
 
@@ -883,20 +891,13 @@ export class Publisher {
883
891
  }
884
892
 
885
893
  // Compose → wire note on remote pod + RDF truth locally + deliver Create.
886
- async publishNote(content, { inReplyTo, attachments, visibility = 'public', spoilerText = null } = {}) {
887
- const { urls } = this;
888
- const priv = visibility === 'private' || visibility === 'direct';
889
- if (priv) {
890
- const ready = await this.privateReady();
891
- if (ready !== true) throw new Error(ready);
892
- }
893
- const published = new Date().toISOString();
894
- const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
895
- // A mention nobody can resolve stays plain text rather than failing the post.
896
- // The reply's own text decides who is mentioned: trim a handle out and that
897
- // person is not notified, which is what every fediverse client leads people
898
- // to expect. A Group named in the parent is the one thing carried forward
899
- // regardless — drop it and the group stops carrying the thread.
894
+ // Who this text mentions, resolved. A mention nobody can resolve stays plain
895
+ // text rather than failing the post. The text itself decides: trim a handle
896
+ // out and that person is not notified, which is what every fediverse client
897
+ // leads people to expect. A Group named in the parent is the one thing
898
+ // carried forward regardless — drop it and the group stops carrying the
899
+ // thread.
900
+ async _mentionsFor(content, inReplyTo) {
900
901
  const inText = new Set(wire.mentionsIn(content));
901
902
  const carried = inReplyTo
902
903
  ? (this.store.getStatuses().find(s => s.noteId === inReplyTo)?.mentions || [])
@@ -910,6 +911,19 @@ export class Publisher {
910
911
  if (!inText.has(handle) && doc.type !== 'Group') continue; // author trimmed them out
911
912
  mentions.push({ handle, actor: doc.id, page: doc.url || null, inbox: doc.endpoints?.sharedInbox || doc.inbox });
912
913
  }
914
+ return mentions;
915
+ }
916
+
917
+ async publishNote(content, { inReplyTo, attachments, visibility = 'public', spoilerText = null } = {}) {
918
+ const { urls } = this;
919
+ const priv = visibility === 'private' || visibility === 'direct';
920
+ if (priv) {
921
+ const ready = await this.privateReady();
922
+ if (ready !== true) throw new Error(ready);
923
+ }
924
+ const published = new Date().toISOString();
925
+ const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
926
+ const mentions = await this._mentionsFor(content, inReplyTo);
913
927
  const note = wire.noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions,
914
928
  visibility, summary: spoilerText, container: priv ? urls.privateNotes : urls.notes });
915
929
 
@@ -962,6 +976,232 @@ export class Publisher {
962
976
  return note;
963
977
  }
964
978
 
979
+ // --- polls ---------------------------------------------------------------
980
+ //
981
+ // A poll is published as a Question and answered by ordinary replies naming
982
+ // an option, so the count is ours to keep and ours to republish. The roster
983
+ // of who chose what lives in agent state (polls.VOTES_DOC); the tallies on
984
+ // the status row and in the pod document are always derived from it.
985
+
986
+ /**
987
+ * Publish a poll. `options` is a list of choice titles, `multiple` lets a
988
+ * voter pick more than one, and `expiresAt` is when voting stops.
989
+ */
990
+ async publishQuestion(content, { options = [], multiple = false, expiresAt = null,
991
+ inReplyTo = undefined, visibility = 'public', spoilerText = null } = {}) {
992
+ const { urls } = this;
993
+ const priv = visibility === 'private' || visibility === 'direct';
994
+ if (priv) {
995
+ const ready = await this.privateReady();
996
+ if (ready !== true) throw new Error(ready);
997
+ }
998
+ const titles = [].concat(options).map(o => String(o ?? '').trim()).filter(Boolean);
999
+ if (titles.length < 2) throw new Error('a poll needs at least two options');
1000
+ // Options are matched BY NAME when a vote arrives — that is the whole of
1001
+ // the convention — so two options reading the same are one option that
1002
+ // cannot be told apart.
1003
+ if (new Set(titles).size !== titles.length) throw new Error('a poll’s options must differ from one another');
1004
+ // Bounded here as well as at the client API, because the outbox is a
1005
+ // second way in and an unbounded poll is a document a stranger sizes.
1006
+ if (titles.length > polls.MAX_OPTIONS) throw new Error(`a poll takes at most ${polls.MAX_OPTIONS} options`);
1007
+ if (titles.some(t => t.length > polls.MAX_OPTION_CHARS)) {
1008
+ throw new Error(`a poll option is at most ${polls.MAX_OPTION_CHARS} characters`);
1009
+ }
1010
+ if (expiresAt) {
1011
+ const ends = Date.parse(expiresAt);
1012
+ if (!Number.isFinite(ends)) throw new Error('the closing time is not a date');
1013
+ const seconds = (ends - Date.now()) / 1000;
1014
+ if (seconds < polls.MIN_SECONDS || seconds > polls.MAX_SECONDS) {
1015
+ throw new Error(`a poll runs between ${polls.MIN_SECONDS} and ${polls.MAX_SECONDS} seconds`);
1016
+ }
1017
+ }
1018
+
1019
+ const published = new Date().toISOString();
1020
+ const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
1021
+ const mentions = await this._mentionsFor(content, inReplyTo);
1022
+ const poll = {
1023
+ multiple: !!multiple,
1024
+ expiresAt: expiresAt || null,
1025
+ closed: null,
1026
+ options: titles.map(title => ({ title, votes: 0 })),
1027
+ votersCount: 0,
1028
+ // Resolved once, here: a tally rewrite must not cost a webfinger lookup
1029
+ // per vote for people the poll named.
1030
+ mentionInboxes: [...new Set(mentions.map(m => m.inbox).filter(Boolean))],
1031
+ };
1032
+ const question = wire.questionDoc({
1033
+ urls, slug, content, published, inReplyTo, attachments: [], mentions,
1034
+ visibility, summary: spoilerText,
1035
+ container: priv ? urls.privateNotes : urls.notes,
1036
+ options: poll.options, multiple: poll.multiple, endTime: poll.expiresAt, votersCount: 0,
1037
+ });
1038
+
1039
+ await this.remote.putJson(question.id, question);
1040
+ // Empty, but present: a dangling `replies` that 404s is worse than none.
1041
+ await this.remote.putJson(wire.repliesId(question.id), wire.collection(wire.repliesId(question.id), []));
1042
+ if (!priv) await this.recordOutbox(question.id);
1043
+ await this.local.writeNote('posts', slug, {
1044
+ noteId: question.id, actor: urls.actor, published, content: question.content,
1045
+ inReplyTo, attachments: [],
1046
+ });
1047
+ this.store.addStatus({
1048
+ noteId: question.id, actor: urls.actor, content: question.content, published,
1049
+ kind: 'post', slug, text: content, visibility, poll, inReplyTo,
1050
+ ...(spoilerText ? { spoiler: spoilerText } : {}),
1051
+ ...(question.tag?.length ? { mentions: question.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
1052
+ });
1053
+
1054
+ const create = this._pollActivity('Create', question, wire.createActivityId(question.id));
1055
+ await this.remote.putJson(create.id, create);
1056
+ const contacts = this.store.getContacts();
1057
+ const inboxes = [...new Set([
1058
+ ...(visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
1059
+ ...poll.mentionInboxes,
1060
+ ].filter(Boolean))];
1061
+ await this.deliverer.deliverToAll(inboxes, create);
1062
+ this.log(`poll published: ${question.id} (${titles.length} options) → ${inboxes.length} inbox(es)`);
1063
+ return question;
1064
+ }
1065
+
1066
+ // The Create or Update carrying a Question. The context is hoisted onto the
1067
+ // activity and the embedded object keeps none: a nested @context is legal
1068
+ // JSON-LD but not every server reads one, and votersCount is declared there.
1069
+ _pollActivity(type, question, id) {
1070
+ const { '@context': ctx, ...object } = question;
1071
+ return {
1072
+ '@context': ctx, id, type,
1073
+ actor: this.urls.actor,
1074
+ published: question.published,
1075
+ to: question.to, cc: question.cc,
1076
+ object,
1077
+ };
1078
+ }
1079
+
1080
+ /**
1081
+ * One vote on one of OUR polls, named by the option's title. Returns true
1082
+ * when it counted — a second answer to a single-choice poll, an option we do
1083
+ * not offer, or a poll that has closed all count for nothing.
1084
+ */
1085
+ async recordVote(questionId, actor, optionName) {
1086
+ const s = this.store.getStatuses().find(x => x.noteId === questionId);
1087
+ if (!s?.poll || s.kind !== 'post' || s.actor !== this.urls.actor) return false;
1088
+ if (polls.pollClosed(s.poll)) return false;
1089
+ const index = polls.optionIndex(s.poll, optionName);
1090
+ if (index < 0) return false;
1091
+ const all = this.store.read(polls.VOTES_DOC, {});
1092
+ const { roster, changed } = polls.addVote(all[questionId] || {}, polls.voterKey(actor), index,
1093
+ { multiple: !!s.poll.multiple });
1094
+ if (!changed) return false;
1095
+ this.store.write(polls.VOTES_DOC, { ...all, [questionId]: roster });
1096
+ this.store.updateStatus(questionId, { poll: polls.withTally(s.poll, roster) });
1097
+ this._pollDirty(questionId);
1098
+ return true;
1099
+ }
1100
+
1101
+ // Open the rewrite window for a poll whose count moved. Already open is
1102
+ // already enough: the whole point is that a burst costs one rewrite.
1103
+ _pollDirty(questionId) {
1104
+ if (this.pollTimers.has(questionId)) return;
1105
+ const t = setTimeout(() => {
1106
+ this.pollTimers.delete(questionId);
1107
+ this.republishPoll(questionId).catch(e => this.log(`poll rewrite: ${e.message}`));
1108
+ }, POLL_REWRITE_MS);
1109
+ t.unref?.();
1110
+ this.pollTimers.set(questionId, t);
1111
+ }
1112
+
1113
+ /**
1114
+ * Write the poll's current count back to the pod and tell everyone who has
1115
+ * it. `closing` stamps it shut, which is a one-way door.
1116
+ */
1117
+ async republishPoll(questionId, { closing = null } = {}) {
1118
+ const s = this.store.getStatuses().find(x => x.noteId === questionId);
1119
+ if (!s?.poll) return null;
1120
+ const { urls } = this;
1121
+ const roster = this.store.read(polls.VOTES_DOC, {})[questionId] || {};
1122
+ // A shut poll whose roster has been retired keeps the counts on its row:
1123
+ // deriving them from an empty roster would publish a poll nobody voted in.
1124
+ const shut = closing || s.poll.closed;
1125
+ const counted = shut && !Object.keys(roster).length ? s.poll : polls.withTally(s.poll, roster);
1126
+ const poll = { ...counted, ...(closing ? { closed: closing } : {}) };
1127
+ const container = String(s.noteId).startsWith(urls.privateNotes) ? urls.privateNotes : urls.notes;
1128
+ const slug = s.slug || String(s.noteId).slice(container.length);
1129
+ const mentions = (s.mentions || []).map(m => ({
1130
+ handle: String(m.name || '').replace(/^@/, ''), actor: m.href, page: null, inbox: null,
1131
+ }));
1132
+ const question = wire.questionDoc({
1133
+ urls, slug, content: s.text ?? '', published: s.published, inReplyTo: s.inReplyTo,
1134
+ attachments: [], mentions,
1135
+ visibility: s.visibility || 'public', summary: s.spoiler || null, container,
1136
+ options: poll.options, multiple: !!poll.multiple, endTime: poll.expiresAt,
1137
+ closed: poll.closed, votersCount: poll.votersCount || 0,
1138
+ });
1139
+ await this.remote.putJson(question.id, question);
1140
+ // The Create is overwritten too, so a group's Announce resolves to the
1141
+ // current count rather than to the one the poll opened with.
1142
+ await this.remote.putJson(wire.createActivityId(question.id),
1143
+ this._pollActivity('Create', question, wire.createActivityId(question.id)));
1144
+ this.store.updateStatus(questionId, { poll });
1145
+
1146
+ // Not `updated`: a changed count is not an edit, and stamping one would
1147
+ // have every client show the poll as edited each time somebody voted.
1148
+ //
1149
+ // The Update is named after the STATE it carries rather than the moment it
1150
+ // was sent. A clock only tells two rewrites apart when they fall in
1151
+ // different milliseconds, and a receiving server that has seen an activity
1152
+ // id drops the next one wearing it — which would quietly freeze the count.
1153
+ // Naming the state means an id changes exactly when there is something new
1154
+ // to say, and two sends of the same numbers are the duplicate they look
1155
+ // like.
1156
+ const stamp = crypto.createHash('sha256').update(JSON.stringify([
1157
+ poll.options.map(o => o.votes || 0), poll.votersCount || 0, poll.closed || '',
1158
+ ])).digest('hex').slice(0, 12);
1159
+ const update = this._pollActivity('Update', question, `${question.id}#poll-${stamp}`);
1160
+ const contacts = this.store.getContacts();
1161
+ const inboxes = [...new Set([
1162
+ ...(s.visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
1163
+ ...(s.poll.mentionInboxes || []),
1164
+ ].filter(Boolean))];
1165
+ await this.deliverer.deliverToAll(inboxes, update);
1166
+ this.log(`poll ${closing ? 'closed' : 'count published'}: ${question.id} → ${inboxes.length} inbox(es)`);
1167
+ return poll;
1168
+ }
1169
+
1170
+ /**
1171
+ * Shut any poll whose time is up. Called from the agent's sweep. Closing is
1172
+ * recorded BEFORE the republish, so a failed republish cannot leave a poll
1173
+ * open and collecting votes it has already refused.
1174
+ */
1175
+ async closeDuePolls(now = Date.now()) {
1176
+ const due = this.store.getStatuses().filter(s => s.kind === 'post' && s.poll
1177
+ && !s.poll.closed && s.poll.expiresAt && Date.parse(s.poll.expiresAt) <= now);
1178
+ for (const s of due) {
1179
+ const closed = new Date(Math.min(now, Date.parse(s.poll.expiresAt) || now)).toISOString();
1180
+ clearTimeout(this.pollTimers.get(s.noteId));
1181
+ this.pollTimers.delete(s.noteId);
1182
+ this.store.updateStatus(s.noteId, { poll: { ...s.poll, closed } });
1183
+ const done = await this.republishPoll(s.noteId, { closing: closed })
1184
+ .catch(e => { this.log(`poll close ${s.noteId}: ${e.message}`); return null; });
1185
+ // The roster only ever answered one question — has this person already
1186
+ // voted — and a shut poll has stopped asking it. Dropping it keeps a
1187
+ // document we serialize whole from carrying every poll's voters forever.
1188
+ if (done) {
1189
+ const all = this.store.read(polls.VOTES_DOC, {});
1190
+ if (all[s.noteId]) {
1191
+ delete all[s.noteId];
1192
+ this.store.write(polls.VOTES_DOC, all);
1193
+ }
1194
+ }
1195
+ }
1196
+ return due.length;
1197
+ }
1198
+
1199
+ /** Stop the pending rewrite windows. Called at shutdown. */
1200
+ stopPolls() {
1201
+ for (const t of this.pollTimers.values()) clearTimeout(t);
1202
+ this.pollTimers.clear();
1203
+ }
1204
+
965
1205
  // The pinned posts, as the actor's featured collection — the one document a
966
1206
  // remote server reads when it shows this profile's pins.
967
1207
  async publishFeatured() {
package/lib/social.mjs CHANGED
@@ -317,6 +317,8 @@ export async function votePoll(agent, s, choices) {
317
317
  return { ok: false, error: 'this poll has closed' };
318
318
  }
319
319
  if (poll.voted) return { ok: false, error: 'already voted' };
320
+ // Your own poll is the one you are counting, not one you are answering.
321
+ if (s.actor === urls.actor) return { ok: false, error: 'this is your own poll' };
320
322
  const picks = [...new Set(choices)].filter(i => i >= 0 && i < opts.length);
321
323
  if (!picks.length) return { ok: false, error: 'invalid choice' };
322
324
  if (!poll.multiple && picks.length > 1) return { ok: false, error: 'this poll takes one choice' };
package/lib/wire.mjs CHANGED
@@ -660,6 +660,45 @@ export function noteDoc({ urls, slug, content, published, inReplyTo, attachments
660
660
  return note;
661
661
  }
662
662
 
663
+ // A poll is a note that asks something: the same addressing, the same
664
+ // mentions, the same replies collection, with the choices in `oneOf` (pick
665
+ // one) or `anyOf` (pick several). Each choice carries the count as the
666
+ // totalItems of its own replies collection, which is where every server that
667
+ // shows a poll reads it from.
668
+ //
669
+ // `endTime` is when voting stops and `closed` is the stamp saying it has —
670
+ // both plain AS2. `votersCount` is Mastodon's: PEOPLE rather than answers,
671
+ // which differ only once a poll takes several answers each. It is not in the
672
+ // base context, so it is declared inline exactly as Mastodon declares it, and
673
+ // only when we actually carry it.
674
+ export function questionDoc({ options = [], multiple = false, endTime = null,
675
+ closed = null, votersCount = null, ...rest }) {
676
+ const note = noteDoc(rest);
677
+ const choices = options.map(o => ({
678
+ type: 'Note',
679
+ name: String(o.title),
680
+ replies: { type: 'Collection', totalItems: Number(o.votes) || 0 },
681
+ }));
682
+ const question = {
683
+ ...note,
684
+ type: 'Question',
685
+ [multiple ? 'anyOf' : 'oneOf']: choices,
686
+ };
687
+ if (endTime) question.endTime = endTime;
688
+ if (closed) question.closed = closed;
689
+ if (votersCount !== null && votersCount !== undefined) {
690
+ question['@context'] = [AS_CTX, {
691
+ toot: 'http://joinmastodon.org/ns#',
692
+ votersCount: {
693
+ '@id': 'toot:votersCount',
694
+ '@type': 'http://www.w3.org/2001/XMLSchema#nonNegativeInteger',
695
+ },
696
+ }];
697
+ question.votersCount = votersCount;
698
+ }
699
+ return question;
700
+ }
701
+
663
702
  // The id is a document of its own, not `note.id + '#create'`. A group wraps this
664
703
  // whole activity in its Announce (FEP-1b12), and the receiver resolves it by
665
704
  // dereferencing this id — a fragment would just serve the Note back under a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/run-agent.mjs CHANGED
@@ -324,6 +324,7 @@ export class Agent {
324
324
  this.deliverer?.stop();
325
325
  this.tagfeed?.stop();
326
326
  clearInterval(this.schedTimer);
327
+ this.publisher?.stopPolls();
327
328
  this.deliverer = new Deliverer({
328
329
  store: this.store, rsaPrivate: keys.rsaPrivate, keyId: this.urls.actor + '#main-key',
329
330
  actorId: this.urls.actor, edPrivate: keys.edPrivate,
@@ -547,6 +548,10 @@ export class Agent {
547
548
  }).then(() => this.log(`scheduled post published (${e.id})`))
548
549
  .catch(err => this.log(`scheduled post ${e.id} failed: ${err.message} — dropped`));
549
550
  }
551
+ // A poll whose time is up is shut on the same sweep: it stops taking
552
+ // answers here, and everyone holding it is told once.
553
+ this.publisher.closeDuePolls()
554
+ .catch(err => this.log(`closing polls: ${err.message}`));
550
555
  }, 30_000);
551
556
  this.schedTimer.unref();
552
557
  // A CSV import interrupted by a restart or a handoff picks back up here.
@@ -651,6 +656,7 @@ export class Agent {
651
656
  this.deliverer?.stop();
652
657
  this.importer?.stop();
653
658
  clearInterval(this.schedTimer);
659
+ this.publisher?.stopPolls();
654
660
  // The lease too: standing down means standing down. Left renewing, a viewer
655
661
  // keeps writing to the pod on the active agent's behalf and can win the
656
662
  // lease back on a conditional PUT it had no business making.