fedipod-server 0.8.0 → 0.10.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/lib/mastoapi.mjs CHANGED
@@ -16,6 +16,11 @@ import { sanitizeHtml, followsNeedApproval, publicHandle } from './wire.mjs';
16
16
  import { authorOf } from './intake.mjs';
17
17
  import { profileUrl, postUrl } from './bskyfeed.mjs';
18
18
  import { Push } from './webpush.mjs';
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';
19
24
 
20
25
  // What an attachment is allowed to BE. Anything else is stored as bytes, which
21
26
  // a browser downloads rather than runs.
@@ -26,6 +31,26 @@ const ATTACHMENT_KINDS = new Set(['image', 'video', 'audio']);
26
31
  const NEVER = new Set(['image/svg+xml', 'image/svg']);
27
32
  const OPAQUE = 'application/octet-stream';
28
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
+
29
54
  export function attachmentType(claimed) {
30
55
  const t = String(claimed || '').split(';')[0].trim().toLowerCase();
31
56
  if (!/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(t) || NEVER.has(t)) return OPAQUE;
@@ -62,6 +87,8 @@ const AUTHZ_WINDOW_MS = 60_000;
62
87
  const AUTHZ_MAX_ATTEMPTS = 5;
63
88
  const CODE_TTL_MS = 5 * 60_000; // an authorization code is short-lived
64
89
  const MAX_APPS = 200; // registered third-party clients, capped
90
+ const CLIENT_DOC_TTL_MS = 10 * 60_000; // how long a fetched client document is trusted
91
+ const CLIENT_DOC_MAX = 64 * 1024; // it names a client; it is not a payload
65
92
 
66
93
  export class MastoApi {
67
94
  constructor({ agent, log = console.log, allowed = null, scheme = null, embedded = false }) {
@@ -127,7 +154,91 @@ export class MastoApi {
127
154
  // said, and only that client, presenting its secret, can exchange it for a
128
155
  // bearer. A redirect back to this agent's own origin keeps the local flow.
129
156
  apps() { return this.store.read('oauth-apps.json', []); }
157
+ /**
158
+ * What a client needs to know before it can sign in, at the address RFC 8414
159
+ * puts it. The actor carries the same two endpoints; a client that looks
160
+ * here first finds everything rather than the minimum.
161
+ *
162
+ * `none` among the authentication methods is what says a client keeping no
163
+ * secret is welcome, which is the whole of what a browser app needs to hear.
164
+ */
165
+ authorizationServerMetadata(origin) {
166
+ const at = (p) => `${origin.replace(/\/$/u, '')}${p}`;
167
+ return {
168
+ issuer: origin.replace(/\/$/u, ''),
169
+ authorization_endpoint: at('/oauth/authorize'),
170
+ token_endpoint: at('/oauth/token'),
171
+ revocation_endpoint: at('/oauth/revoke'),
172
+ registration_endpoint: at('/api/v1/apps'),
173
+ response_types_supported: [ 'code' ],
174
+ grant_types_supported: [ 'authorization_code' ],
175
+ code_challenge_methods_supported: [ 'S256', 'plain' ],
176
+ token_endpoint_auth_methods_supported: [ 'client_secret_post', 'none' ],
177
+ scopes_supported: [ 'read', 'write', 'follow', 'push' ],
178
+ };
179
+ }
180
+
130
181
  findApp(clientId) { return clientId ? this.apps().find(a => a.clientId === clientId) || null : null; }
182
+
183
+ /**
184
+ * A client that publishes its own metadata document is named by that
185
+ * document's URL and registers nothing here: the document says who it is
186
+ * and where it may be sent back to. Such a client keeps no secret, so it
187
+ * always proves itself with a challenge instead.
188
+ *
189
+ * The fetch is the guarded one — a client id is a URL a stranger chose, and
190
+ * an unguarded fetch of it would ask this machine to reach wherever they
191
+ * pointed.
192
+ */
193
+ async resolveClientDocument(clientId) {
194
+ if (!/^https:\/\//iu.test(String(clientId || ''))) return null; // cleartext is refused
195
+ this.clientDocs = this.clientDocs || new Map();
196
+ const seen = this.clientDocs.get(clientId);
197
+ if (seen && Date.now() - seen.at < CLIENT_DOC_TTL_MS) return seen.client;
198
+ let doc;
199
+ try {
200
+ const res = await safeFetch(clientId, { headers: { accept: 'application/json' } });
201
+ if (res.status >= 400) { this.log(`client document ${clientId} → ${res.status}`); return null; }
202
+ doc = JSON.parse(await readCapped(res, CLIENT_DOC_MAX));
203
+ } catch (e) {
204
+ this.log(`client document ${clientId} could not be read: ${e.message}`);
205
+ return null;
206
+ }
207
+ // It must claim to be itself: a document naming some other id would let
208
+ // one client borrow another's name.
209
+ if (doc?.client_id !== clientId) {
210
+ this.log(`client document ${clientId} names ${doc?.client_id ?? 'nothing'} — refused`);
211
+ return null;
212
+ }
213
+ const redirectUris = [].concat(doc.redirect_uris || []).filter((u) => typeof u === 'string');
214
+ if (!redirectUris.length) { this.log(`client document ${clientId} names no redirect — refused`); return null; }
215
+ const client = {
216
+ clientId, redirectUris,
217
+ name: String(doc.client_name || clientId).slice(0, 200),
218
+ scopes: 'read write follow',
219
+ };
220
+ this.clientDocs.set(clientId, { at: Date.now(), client });
221
+ return client;
222
+ }
223
+
224
+ /**
225
+ * Whether a redirect the client asked for is one it published.
226
+ *
227
+ * A native client listens on whatever port the machine gave it, so it can
228
+ * only publish the loopback address without one (RFC 8252). The port is
229
+ * therefore not part of the match there, and nowhere else.
230
+ */
231
+ static redirectMatches(published, asked) {
232
+ if (published === asked) return true;
233
+ try {
234
+ const a = new URL(published);
235
+ const b = new URL(asked);
236
+ const loopback = (h) => h === '127.0.0.1' || h === '[::1]' || h === 'localhost';
237
+ if (!loopback(a.hostname) || a.hostname !== b.hostname) return false;
238
+ return a.protocol === b.protocol
239
+ && a.pathname.replace(/\/$/u, '') === b.pathname.replace(/\/$/u, '');
240
+ } catch { return false; }
241
+ }
131
242
  registerApp({ name, website, redirectUris, scopes }) {
132
243
  const app = {
133
244
  clientId: crypto.randomBytes(16).toString('hex'),
@@ -143,13 +254,39 @@ export class MastoApi {
143
254
  // A short-lived, single-use authorization code for a registered client, kept
144
255
  // apart from masto-tokens.json so the code is NOT a bearer until it is
145
256
  // exchanged with the client secret.
146
- mintCode({ clientId, redirectUri, scope }) {
257
+ mintCode({ clientId, redirectUri, scope, challenge = null, challengeMethod = null }) {
147
258
  const code = crypto.randomBytes(24).toString('hex');
148
259
  const now = Date.now();
149
260
  const kept = this.store.read('oauth-codes.json', []).filter(c => now - c.createdAt < CODE_TTL_MS);
150
- this.store.write('oauth-codes.json', [...kept, { code, clientId, redirectUri, scope, createdAt: now }].slice(-50));
261
+ this.store.write('oauth-codes.json', [ ...kept, {
262
+ code, clientId, redirectUri, scope, createdAt: now,
263
+ // What the client promised to prove when it comes back for the token.
264
+ // A client that cannot keep a secret — anything running in a browser —
265
+ // has this instead, and it is the only thing standing between a stolen
266
+ // code and a token.
267
+ ...(challenge ? { challenge, challengeMethod: challengeMethod || 'plain' } : {}),
268
+ } ].slice(-50));
151
269
  return code;
152
270
  }
271
+ /**
272
+ * Whether this verifier is the one the challenge was made from (RFC 7636).
273
+ * Length is checked because a short verifier is guessable, which is the
274
+ * whole thing this is here to prevent.
275
+ */
276
+ static provesCode(rec, verifier) {
277
+ const v = String(verifier || '');
278
+ if (v.length < 43 || v.length > 128) return false;
279
+ if ((rec.challengeMethod || 'plain') === 'S256') {
280
+ const made = crypto.createHash('sha256').update(v).digest('base64url');
281
+ const given = Buffer.from(made);
282
+ const known = Buffer.from(String(rec.challenge));
283
+ return given.length === known.length && crypto.timingSafeEqual(given, known);
284
+ }
285
+ const given = Buffer.from(v);
286
+ const known = Buffer.from(String(rec.challenge));
287
+ return given.length === known.length && crypto.timingSafeEqual(given, known);
288
+ }
289
+
153
290
  consumeCode(code) {
154
291
  const now = Date.now();
155
292
  const all = this.store.read('oauth-codes.json', []);
@@ -515,7 +652,7 @@ export class MastoApi {
515
652
  expires_at: s.poll.expiresAt || null,
516
653
  expired: !!s.poll.closed || (!!s.poll.expiresAt && Date.parse(s.poll.expiresAt) < Date.now()),
517
654
  multiple: !!s.poll.multiple,
518
- votes_count: votes, voters_count: null,
655
+ votes_count: votes, voters_count: s.poll.votersCount ?? null,
519
656
  options: opts.map(o => ({ title: o.title, votes_count: o.votes || 0 })),
520
657
  voted: !!s.poll.voted, own_votes: s.poll.ownVotes || [],
521
658
  emojis: [],
@@ -620,19 +757,35 @@ export class MastoApi {
620
757
  if (req.method === 'POST') { body = await readBody(req); params = new URLSearchParams(body); }
621
758
  const redirect = params.get('redirect_uri') || '';
622
759
  const app = this.findApp(params.get('client_id') || '');
760
+ // A client that published its own metadata document needs no
761
+ // registration here: the document is its name and says where it may be
762
+ // sent back to.
763
+ const doc = app ? null : await this.resolveClientDocument(params.get('client_id') || '');
623
764
  // A REGISTERED client is always the third-party flow — its code is
624
765
  // bound to it and exchanged with its secret — even when its redirect
625
766
  // points back at this very agent (a web client served from our own
626
767
  // origin registers itself exactly like a phone app does). The local
627
768
  // code-is-the-token flow is only for the built-in client, which never
628
769
  // registers.
629
- const external = !!app;
630
- const client = { name: app?.name || null, redirect, scope: params.get('scope') || 'read' };
631
- if (external) {
770
+ const external = !!app || !!doc;
771
+ const client = { name: app?.name || doc?.name || null, redirect, scope: params.get('scope') || 'read' };
772
+ if (app) {
632
773
  if (!app.redirectUris.includes(redirect)) {
633
774
  this.log(`authorize refused: redirect_uri "${redirect}" not registered for ${app.clientId}`);
634
775
  return send(400, { error: 'redirect_uri was not registered by this client' });
635
776
  }
777
+ } else if (doc) {
778
+ if (!doc.redirectUris.some((u) => MastoApi.redirectMatches(u, redirect))) {
779
+ this.log(`authorize refused: redirect_uri "${redirect}" is not one ${doc.clientId} published`);
780
+ return send(400, { error: 'redirect_uri is not one this client published' });
781
+ }
782
+ // It keeps no secret, so the challenge is the only thing that will
783
+ // stand between its code and a token. Refuse now rather than mint a
784
+ // code nothing can prove.
785
+ if (!params.get('code_challenge')) {
786
+ this.log(`authorize refused: ${doc.clientId} keeps no secret and offered no challenge`);
787
+ return send(400, { error: 'a client identified by its own document must send a code_challenge' });
788
+ }
636
789
  } else if (!this.redirectAllowed(redirect)) {
637
790
  this.log(`authorize refused: redirect_uri "${redirect}" is not this agent`);
638
791
  return send(400, { error: 'redirect_uri must be an address of this agent' });
@@ -640,7 +793,11 @@ export class MastoApi {
640
793
  if (req.method === 'POST') {
641
794
  if (this.rateLimited()) {
642
795
  this.log('authorize rate limited');
643
- return sendLoginForm(res, params, 'too many attempts wait a minute', client);
796
+ // 429, not 401: a client that reads this as a wrong password will
797
+ // ask the person to type it again, which is the one thing that
798
+ // cannot help. Retry-After says how long the wait actually is.
799
+ return sendLoginForm(res, params, 'too many attempts — wait a minute', client,
800
+ 429, { 'retry-after': String(Math.ceil(AUTHZ_WINDOW_MS / 1000)) });
644
801
  }
645
802
  if (!pw || !checkPassword(pw, body.password || '')) {
646
803
  return sendLoginForm(res, params, 'wrong password — try again', client);
@@ -670,7 +827,9 @@ export class MastoApi {
670
827
  }
671
828
  // External clients get a bound code; the local flow keeps code==token.
672
829
  const code = external
673
- ? this.mintCode({ clientId: app.clientId, redirectUri: redirect, scope: client.scope })
830
+ ? this.mintCode({ clientId: (app || doc).clientId, redirectUri: redirect, scope: client.scope,
831
+ challenge: params.get('code_challenge') || null,
832
+ challengeMethod: params.get('code_challenge_method') || null })
674
833
  : this.mintToken();
675
834
  if (!redirect || redirect === 'urn:ietf:wg:oauth:2.0:oob') return send(200, { code });
676
835
  const target = new URL(redirect);
@@ -685,6 +844,44 @@ export class MastoApi {
685
844
  // A registered third-party client exchanges its bound code, proving its
686
845
  // secret, for a real bearer — the code alone is not a token.
687
846
  const app = this.findApp(body.client_id || '');
847
+ // A client named by its own document keeps no secret at all, so the
848
+ // challenge is the whole of its proof. The code carries the document's
849
+ // URL as the client it was bound to.
850
+ if (!app && body.code_verifier && /^https:\/\//iu.test(String(body.client_id || ''))) {
851
+ const rec = this.consumeCode(body.code || '');
852
+ if (!rec || rec.clientId !== body.client_id
853
+ || (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
854
+ this.log('token refused: code is not a live authorization for that client document');
855
+ return send(400, { error: 'invalid_grant' });
856
+ }
857
+ if (!rec.challenge || !MastoApi.provesCode(rec, body.code_verifier)) {
858
+ this.log('token refused: the verifier does not answer the challenge this code was made with');
859
+ return send(400, { error: 'invalid_grant' });
860
+ }
861
+ return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
862
+ scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
863
+ ...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
864
+ }
865
+ // A client that runs in a browser cannot keep a secret, so it proves it
866
+ // is the same caller that asked instead: it sends the verifier for the
867
+ // challenge it presented at authorize (RFC 7636). Sending a verifier is
868
+ // what says which of the two flows this is.
869
+ if (app && body.code_verifier) {
870
+ const rec = this.consumeCode(body.code || '');
871
+ if (!rec || rec.clientId !== app.clientId || (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
872
+ this.log('token refused: code is not a live authorization for this client');
873
+ return send(400, { error: 'invalid_grant' });
874
+ }
875
+ // A code minted without a challenge cannot be redeemed with one: that
876
+ // would let anyone holding a stolen code invent the proof for it.
877
+ if (!rec.challenge || !MastoApi.provesCode(rec, body.code_verifier)) {
878
+ this.log('token refused: the verifier does not answer the challenge this code was made with');
879
+ return send(400, { error: 'invalid_grant' });
880
+ }
881
+ return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
882
+ scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
883
+ ...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
884
+ }
688
885
  if (app && body.client_secret) {
689
886
  const given = Buffer.from(String(body.client_secret));
690
887
  const known = Buffer.from(app.clientSecret);
@@ -695,8 +892,18 @@ export class MastoApi {
695
892
  this.log('token refused: code is not a live authorization for this client');
696
893
  return send(400, { error: 'invalid_grant' });
697
894
  }
895
+ // A challenge, once made, is not optional: without this a client could
896
+ // present one and then skip past it with the secret alone.
897
+ if (rec.challenge && !MastoApi.provesCode(rec, body.code_verifier)) {
898
+ this.log('token refused: this code was made with a challenge and the verifier does not answer it');
899
+ return send(400, { error: 'invalid_grant' });
900
+ }
698
901
  return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
699
- scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000) });
902
+ scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
903
+ // Which actor the token acts for. A Mastodon client ignores it; an
904
+ // ActivityPub API client needs it, and asking for it separately
905
+ // would mean a second round trip before it knows who it is.
906
+ ...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
700
907
  }
701
908
  // Local flow: the code IS the token, minted by /oauth/authorize after the
702
909
  // password gate. Minting one here for an unrecognised code handed a
@@ -707,7 +914,9 @@ export class MastoApi {
707
914
  this.log('token refused: code is not a live authorization');
708
915
  return send(400, { error: 'invalid_grant' });
709
916
  }
710
- return send(200, { access_token: body.code, token_type: 'Bearer', scope: body.scope || 'read write follow push', created_at: Math.floor(Date.now() / 1000) });
917
+ return send(200, { access_token: body.code, token_type: 'Bearer',
918
+ scope: body.scope || 'read write follow push', created_at: Math.floor(Date.now() / 1000),
919
+ ...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
711
920
  }
712
921
  if (pathname === '/oauth/revoke' && req.method === 'POST') {
713
922
  // It used to answer 200 and keep the token, so logging out of a client
@@ -895,6 +1104,41 @@ export class MastoApi {
895
1104
  const mediaIds = [].concat(body.media_ids || body['media_ids[]'] || []).filter(Boolean);
896
1105
  const media = this.store.getMedia();
897
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
+
898
1142
  if (body.scheduled_at) {
899
1143
  const at = Date.parse(body.scheduled_at);
900
1144
  if (!Number.isFinite(at) || at < Date.now() + 60_000) {
@@ -1522,7 +1766,12 @@ function instanceConfig() {
1522
1766
  image_size_limit: 10 * 1024 * 1024, video_size_limit: 40 * 1024 * 1024,
1523
1767
  image_matrix_limit: 16777216, video_matrix_limit: 2304000,
1524
1768
  },
1525
- 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
+ },
1526
1775
  accounts: { max_featured_tags: 0 },
1527
1776
  };
1528
1777
  }
@@ -1554,7 +1803,7 @@ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c =>
1554
1803
  const parseRedirects = (v) => (Array.isArray(v) ? v : String(v || '').split(/\s+/))
1555
1804
  .map(s => s.trim()).filter(Boolean);
1556
1805
 
1557
- function sendLoginForm(res, params, error = '', client = null) {
1806
+ function sendLoginForm(res, params, error = '', client = null, status = null, headers = {}) {
1558
1807
  const hidden = [...params.entries()].filter(([k]) => k !== 'password')
1559
1808
  .map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`).join('\n');
1560
1809
  // Name what is asking, so the owner approves a client they can see rather
@@ -1569,7 +1818,8 @@ function sendLoginForm(res, params, error = '', client = null) {
1569
1818
  + `${where ? `, sending the authorization to <code>${escapeHtml(where)}</code>` : ''}.</p>`
1570
1819
  + `<p>Scope: <code>${escapeHtml(client.scope || 'read')}</code>. Enter the agent password to allow it.</p>`;
1571
1820
  }
1572
- res.writeHead(error ? 401 : 200, { 'content-type': 'text/html; charset=utf-8' });
1821
+ res.writeHead(status || (error ? 401 : 200),
1822
+ { 'content-type': 'text/html; charset=utf-8', ...headers });
1573
1823
  res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
1574
1824
  <title>FediPod — authorize</title>
1575
1825
  <style>:root{color-scheme:light dark;font-size:125%;--heading:#1a4f8a}
@@ -1679,7 +1929,17 @@ function readBody(req) {
1679
1929
  const ct = String(req.headers['content-type'] || '');
1680
1930
  try {
1681
1931
  if (ct.includes('application/json')) return resolve(data ? JSON.parse(data) : {});
1682
- 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);
1683
1943
  } catch (e) { reject(e); }
1684
1944
  });
1685
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
+ }