fedipod 0.16.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 +3 -0
- package/lib/admin.mjs +11 -1
- package/lib/c2s.mjs +131 -6
- package/lib/embed.mjs +1 -0
- package/lib/intake.mjs +23 -1
- package/lib/mastoapi.mjs +274 -14
- package/lib/polls.mjs +105 -0
- package/lib/publisher.mjs +266 -15
- package/lib/social.mjs +2 -0
- package/lib/storage.mjs +8 -2
- package/lib/wire.mjs +57 -3
- package/package.json +1 -1
- package/run-agent.mjs +10 -0
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/admin.mjs
CHANGED
|
@@ -485,9 +485,19 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
485
485
|
// C2S (ActivityPub §6) carries its own authentication — a Solid-OIDC
|
|
486
486
|
// DPoP proof or the facade's bearer — so the dk-token gate does not
|
|
487
487
|
// stand in front of it. The Host/Origin firewall above still does.
|
|
488
|
-
if (p === '/ap/outbox' || p === '/ap/actor') {
|
|
488
|
+
if (p === '/ap/outbox' || p === '/ap/actor' || p === '/ap/inbox') {
|
|
489
489
|
if (await c2s.handle(req, res, p, url)) return;
|
|
490
490
|
}
|
|
491
|
+
// Where a client looks first to find out how to sign in (RFC 8414), and
|
|
492
|
+
// in front of the door for the same reason C2S is: a client that has to
|
|
493
|
+
// be handed a secret before it can ask how to sign in cannot set itself
|
|
494
|
+
// up at all. It names endpoints and nothing else, the endpoints it names
|
|
495
|
+
// refuse without a password anyway, and the host and origin firewall
|
|
496
|
+
// above still decides who gets this far.
|
|
497
|
+
if (p === '/.well-known/oauth-authorization-server') {
|
|
498
|
+
const scheme = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
|
|
499
|
+
return json(res, 200, masto.authorizationServerMetadata(`${scheme}://${req.headers.host}`));
|
|
500
|
+
}
|
|
491
501
|
if (atDoor && gate(req, res)) return;
|
|
492
502
|
if (p === '/api/v1/streaming/health') {
|
|
493
503
|
res.writeHead(200, { 'content-type': 'text/plain' }); res.end('OK'); return;
|
package/lib/c2s.mjs
CHANGED
|
@@ -5,14 +5,26 @@
|
|
|
5
5
|
// SAME helper the facade and admin surfaces use — this module re-implements
|
|
6
6
|
// no persistence and no delivery, so one write path stays one.
|
|
7
7
|
//
|
|
8
|
-
// GETs are redirects: the pod's documents are the
|
|
9
|
-
// and a second renderer here would only drift from them.
|
|
8
|
+
// GETs on the actor and outbox are redirects: the pod's documents are the
|
|
9
|
+
// canonical ones, and a second renderer here would only drift from them.
|
|
10
|
+
//
|
|
11
|
+
// The inbox is the exception, and has to be. Deliveries land in a container on
|
|
12
|
+
// the pod which the drain empties as it handles each item, so reading that
|
|
13
|
+
// container tells the owner only what has not been dealt with yet. What was
|
|
14
|
+
// actually received is whole only in the archive, so §5.2's "the owner can
|
|
15
|
+
// read their own inbox" is served from there, by this agent, to the owner
|
|
16
|
+
// alone.
|
|
10
17
|
|
|
11
18
|
import * as social from './social.mjs';
|
|
12
19
|
import * as wire from './wire.mjs';
|
|
13
20
|
|
|
14
21
|
const MAX_BODY = 512 * 1024; // same ceiling the inbox drain enforces
|
|
15
22
|
|
|
23
|
+
// How many archived items one page of the inbox will read. A page is one
|
|
24
|
+
// month, and a month with more than this is served short rather than costing
|
|
25
|
+
// the pod an unbounded read; the log says when that happened.
|
|
26
|
+
const MAX_INBOX_PAGE = 500;
|
|
27
|
+
|
|
16
28
|
// §6 names activities; anything else with a type is an object to wrap.
|
|
17
29
|
const ACTIVITY_TYPES = new Set([
|
|
18
30
|
'Create', 'Update', 'Delete', 'Follow', 'Like', 'Announce', 'Undo',
|
|
@@ -60,14 +72,102 @@ export class C2S {
|
|
|
60
72
|
return iri ? this.store.getStatuses().find((s) => s.noteId === iri) : null;
|
|
61
73
|
}
|
|
62
74
|
|
|
75
|
+
/** The months the archive holds, newest first. One container listing. */
|
|
76
|
+
async archiveMonths() {
|
|
77
|
+
const archive = this.agent.intake?.archive;
|
|
78
|
+
if (!archive) return [];
|
|
79
|
+
const { names } = await archive.list('');
|
|
80
|
+
return (names || [])
|
|
81
|
+
.map((n) => n.replace(/\/$/u, ''))
|
|
82
|
+
.filter((n) => /^\d{4}-\d{2}$/u.test(n))
|
|
83
|
+
.sort()
|
|
84
|
+
.reverse();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The owner's own inbox, §5.2. Paged by month because that is how the
|
|
89
|
+
* archive is stored, so a page costs one listing and a read per item and no
|
|
90
|
+
* page is dearer for another month being large.
|
|
91
|
+
*/
|
|
92
|
+
async sendInbox(res, url) {
|
|
93
|
+
const id = `${this.urls.base}ap/inbox`;
|
|
94
|
+
const page = url?.searchParams?.get('page') || null;
|
|
95
|
+
let months;
|
|
96
|
+
try {
|
|
97
|
+
months = await this.archiveMonths();
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return this.send(res, 502, { error: `the archive could not be read: ${e.message}` });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!page) {
|
|
103
|
+
if (!months.length && this.store.getConfig()?.archiveInbox === false) {
|
|
104
|
+
this.log('inbox read: nothing to show — this identity does not keep what it receives');
|
|
105
|
+
}
|
|
106
|
+
return this.send(res, 200, {
|
|
107
|
+
'@context': wire.AS_CTX, id, type: 'OrderedCollection',
|
|
108
|
+
...(months.length ? { first: `${id}?page=${months[0]}` } : { orderedItems: [] }),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (!/^\d{4}-\d{2}$/u.test(page)) {
|
|
112
|
+
return this.send(res, 400, { error: 'page names a month, written 2026-09' });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const archive = this.agent.intake?.archive;
|
|
116
|
+
let names = [];
|
|
117
|
+
try {
|
|
118
|
+
// The trailing slash matters: without it this names a document, not the
|
|
119
|
+
// container, and a pod answers about the wrong thing.
|
|
120
|
+
({ names } = await archive.list(`${page}/`));
|
|
121
|
+
} catch (e) {
|
|
122
|
+
return this.send(res, 502, { error: `the archive could not be read: ${e.message}` });
|
|
123
|
+
}
|
|
124
|
+
const files = (names || []).filter((n) => n.endsWith('.json')).sort();
|
|
125
|
+
if (files.length > MAX_INBOX_PAGE) {
|
|
126
|
+
this.log(`inbox read: ${page} holds ${files.length} items; serving the first ${MAX_INBOX_PAGE}`);
|
|
127
|
+
}
|
|
128
|
+
const kept = [];
|
|
129
|
+
for (const file of files.slice(0, MAX_INBOX_PAGE)) {
|
|
130
|
+
// Read as written: these records are JSON-LD, and the default read asks
|
|
131
|
+
// turtle-first, which a server is free to answer with turtle.
|
|
132
|
+
const read = await archive.read(`${page}/${file}`, { accept: '*/*' });
|
|
133
|
+
if (!read?.ok || !read.body) continue;
|
|
134
|
+
try {
|
|
135
|
+
const record = JSON.parse(read.body);
|
|
136
|
+
// The record wraps the bytes as they arrived; the activity is those
|
|
137
|
+
// bytes, not a retelling of them.
|
|
138
|
+
kept.push({ at: record.receivedAt || '', activity: JSON.parse(record.raw) });
|
|
139
|
+
} catch { /* a record that will not parse is not one that can be served */ }
|
|
140
|
+
}
|
|
141
|
+
kept.sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
142
|
+
const older = months.filter((m) => m < page)[0] || null;
|
|
143
|
+
return this.send(res, 200, {
|
|
144
|
+
'@context': wire.AS_CTX,
|
|
145
|
+
id: `${id}?page=${page}`,
|
|
146
|
+
type: 'OrderedCollectionPage',
|
|
147
|
+
partOf: id,
|
|
148
|
+
...(older ? { next: `${id}?page=${older}` } : {}),
|
|
149
|
+
orderedItems: kept.map((k) => k.activity),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
63
153
|
async handle(req, res, pathname, url) { // eslint-disable-line no-unused-vars
|
|
64
|
-
if (pathname !== '/ap/outbox' && pathname !== '/ap/actor') return false;
|
|
154
|
+
if (pathname !== '/ap/outbox' && pathname !== '/ap/actor' && pathname !== '/ap/inbox') return false;
|
|
65
155
|
if (req.method === 'OPTIONS') {
|
|
66
|
-
res.writeHead(204, { allow: 'GET, POST, OPTIONS' });
|
|
156
|
+
res.writeHead(204, { allow: pathname === '/ap/inbox' ? 'GET, OPTIONS' : 'GET, POST, OPTIONS' });
|
|
157
|
+
res.end(); return true;
|
|
67
158
|
}
|
|
68
159
|
if (!this.agent.configured() || !this.urls) {
|
|
69
160
|
return this.send(res, 409, { error: 'agent not configured' });
|
|
70
161
|
}
|
|
162
|
+
if (pathname === '/ap/inbox') {
|
|
163
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
164
|
+
return this.send(res, 405, { error: "deliveries go to this actor's inbox on the pod, which the "
|
|
165
|
+
+ 'actor document names; this address is the owner reading their own' });
|
|
166
|
+
}
|
|
167
|
+
const reader = await this.auth(req, pathname);
|
|
168
|
+
if (!reader.ok) return this.send(res, reader.status, { error: reader.error });
|
|
169
|
+
return this.sendInbox(res, url);
|
|
170
|
+
}
|
|
71
171
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
72
172
|
// The pod's copy is the document; send the reader there.
|
|
73
173
|
const target = pathname === '/ap/actor' ? this.urls.actor : this.urls.outbox;
|
|
@@ -134,8 +234,9 @@ export class C2S {
|
|
|
134
234
|
|
|
135
235
|
switch (activity.type) {
|
|
136
236
|
case 'Create': {
|
|
137
|
-
|
|
138
|
-
|
|
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' });
|
|
139
240
|
}
|
|
140
241
|
const visibility = this.visibilityOf(activity, object);
|
|
141
242
|
if (!visibility) {
|
|
@@ -146,6 +247,30 @@ export class C2S {
|
|
|
146
247
|
// visible characters rather than as markup. Documented v1 limit.
|
|
147
248
|
const text = String(object.source?.content ?? object.content ?? '');
|
|
148
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
|
+
|
|
149
274
|
const attachments = arr(object.attachment).map((a) => ({
|
|
150
275
|
url: a?.url, mediaType: a?.mediaType,
|
|
151
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
|
|
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
|
@@ -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, {
|
|
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 (
|
|
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
|
-
|
|
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',
|
|
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: {
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|
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(
|
|
@@ -23,7 +29,7 @@ const AGENT_VERSION = JSON.parse(fs.readFileSync(
|
|
|
23
29
|
|
|
24
30
|
export class Publisher {
|
|
25
31
|
constructor({ config, remote, local, store, deliverer, publicKeyPem, assertionKey = null, log = console.log,
|
|
26
|
-
probeFetch = null, resolveMention = null, privateOnPod = true,
|
|
32
|
+
probeFetch = null, resolveMention = null, privateOnPod = true, clientOrigin = null,
|
|
27
33
|
}) {
|
|
28
34
|
this.config = config;
|
|
29
35
|
this.remote = remote;
|
|
@@ -32,6 +38,10 @@ export class Publisher {
|
|
|
32
38
|
this.deliverer = deliverer;
|
|
33
39
|
this.publicKeyPem = publicKeyPem;
|
|
34
40
|
this.assertionKey = assertionKey; // Ed25519 public half, multibase; null = no proofs
|
|
41
|
+
// Where this identity's client surface answers, when that is an address a
|
|
42
|
+
// stranger can reach. Null on a laptop, where the surface is on loopback
|
|
43
|
+
// and advertising it to the world would name somewhere nobody can go.
|
|
44
|
+
this.clientOrigin = clientOrigin;
|
|
35
45
|
// A fronted identity (config.gateway.frontActor) advertises its ids on a
|
|
36
46
|
// shared domain; the map tells RemotePod where each writes on the pod.
|
|
37
47
|
const publicBase = config.gateway?.frontActor
|
|
@@ -44,6 +54,8 @@ export class Publisher {
|
|
|
44
54
|
this.probeFetch = probeFetch || ((u, i) => this.remote.probe(u, i));
|
|
45
55
|
this.resolveMention = resolveMention;
|
|
46
56
|
this.privateOnPod = privateOnPod;
|
|
57
|
+
// Per-poll rewrite windows, keyed by question id. See POLL_REWRITE_MS.
|
|
58
|
+
this.pollTimers = new Map();
|
|
47
59
|
this.log = log;
|
|
48
60
|
}
|
|
49
61
|
|
|
@@ -88,6 +100,13 @@ export class Publisher {
|
|
|
88
100
|
pendingFollowing: priv ? urls.pendingFollowing : null,
|
|
89
101
|
blocked: priv ? urls.blocked : null,
|
|
90
102
|
inbox: gwActive ? gw.url : null,
|
|
103
|
+
// The agent's own outbox endpoint, where it is reachable: a client
|
|
104
|
+
// following the actor must arrive somewhere that will take a write.
|
|
105
|
+
outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : null,
|
|
106
|
+
// How a client-to-server client finds the way in with nothing configured
|
|
107
|
+
// by hand. Advertised only where the surface is publicly reachable.
|
|
108
|
+
oauthAuthorize: this.clientOrigin ? `${this.clientOrigin}oauth/authorize` : null,
|
|
109
|
+
oauthToken: this.clientOrigin ? `${this.clientOrigin}oauth/token` : null,
|
|
91
110
|
});
|
|
92
111
|
const surface = crypto.createHash('sha256').update(JSON.stringify({
|
|
93
112
|
actor: actorDoc, handle: this.config.handle, host,
|
|
@@ -872,20 +891,13 @@ export class Publisher {
|
|
|
872
891
|
}
|
|
873
892
|
|
|
874
893
|
// Compose → wire note on remote pod + RDF truth locally + deliver Create.
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
const published = new Date().toISOString();
|
|
883
|
-
const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
|
|
884
|
-
// A mention nobody can resolve stays plain text rather than failing the post.
|
|
885
|
-
// The reply's own text decides who is mentioned: trim a handle out and that
|
|
886
|
-
// person is not notified, which is what every fediverse client leads people
|
|
887
|
-
// to expect. A Group named in the parent is the one thing carried forward
|
|
888
|
-
// 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) {
|
|
889
901
|
const inText = new Set(wire.mentionsIn(content));
|
|
890
902
|
const carried = inReplyTo
|
|
891
903
|
? (this.store.getStatuses().find(s => s.noteId === inReplyTo)?.mentions || [])
|
|
@@ -899,6 +911,19 @@ export class Publisher {
|
|
|
899
911
|
if (!inText.has(handle) && doc.type !== 'Group') continue; // author trimmed them out
|
|
900
912
|
mentions.push({ handle, actor: doc.id, page: doc.url || null, inbox: doc.endpoints?.sharedInbox || doc.inbox });
|
|
901
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);
|
|
902
927
|
const note = wire.noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions,
|
|
903
928
|
visibility, summary: spoilerText, container: priv ? urls.privateNotes : urls.notes });
|
|
904
929
|
|
|
@@ -951,6 +976,232 @@ export class Publisher {
|
|
|
951
976
|
return note;
|
|
952
977
|
}
|
|
953
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
|
+
|
|
954
1205
|
// The pinned posts, as the actor's featured collection — the one document a
|
|
955
1206
|
// remote server reads when it shows this profile's pins.
|
|
956
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/storage.mjs
CHANGED
|
@@ -61,9 +61,15 @@ export class HttpStorage {
|
|
|
61
61
|
return { notModified: false, names, etag: res.headers.get('etag') };
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
// `accept` is for callers reading something that is RDF but is wanted as it
|
|
65
|
+
// was written: asking turtle-first for a JSON-LD document gets turtle back,
|
|
66
|
+
// because the server is entitled to convert between two RDF syntaxes.
|
|
67
|
+
async read(p, { etag, accept } = {}) {
|
|
65
68
|
const res = await this.fetchImpl(this._url(p), {
|
|
66
|
-
headers: {
|
|
69
|
+
headers: {
|
|
70
|
+
accept: accept || 'text/turtle, application/json;q=0.9, */*;q=0.8',
|
|
71
|
+
...(etag ? { 'if-none-match': etag } : {}),
|
|
72
|
+
},
|
|
67
73
|
});
|
|
68
74
|
if (res.status === 304) return { ok: true, notModified: true, status: 304, body: null, etag };
|
|
69
75
|
if (res.status >= 400) return { ok: false, notModified: false, status: res.status, body: null, etag: null };
|
package/lib/wire.mjs
CHANGED
|
@@ -108,7 +108,7 @@ export const assertionKeyId = (urls) => urls.actor + '#ed25519-key';
|
|
|
108
108
|
export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null, movedTo = null, kind = 'person',
|
|
109
109
|
approveJoins = false, summary = null, icon = null, image = null, fields = [],
|
|
110
110
|
webId = null, aliases = [], moderators = null, pendingFollowers = null, pendingFollowing = null,
|
|
111
|
-
blocked = null, inbox = null }) {
|
|
111
|
+
blocked = null, inbox = null, outbox = null, oauthAuthorize = null, oauthToken = null }) {
|
|
112
112
|
// manuallyApprovesFollowers is NOT in the base AS2 context, so it is declared
|
|
113
113
|
// inline exactly as Mastodon declares it — and only when we actually use it.
|
|
114
114
|
// It is what makes a client show "Request to follow" rather than "Follow" and
|
|
@@ -188,8 +188,23 @@ export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null
|
|
|
188
188
|
inbox: inbox || urls.inbox,
|
|
189
189
|
// Every Mastodon actor publishes one. With a single actor per pod ours is
|
|
190
190
|
// just the inbox, but its absence is the non-standard thing.
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
//
|
|
192
|
+
// The two OAuth entries are how a client-to-server client finds its way in
|
|
193
|
+
// without being told anything by hand (ActivityPub 4.1). They appear only
|
|
194
|
+
// where this identity's client surface answers on an address a stranger
|
|
195
|
+
// can reach, which is why the caller supplies them rather than this
|
|
196
|
+
// building them from the pod URL.
|
|
197
|
+
endpoints: {
|
|
198
|
+
sharedInbox: inbox || urls.inbox,
|
|
199
|
+
...(oauthAuthorize ? { oauthAuthorizationEndpoint: oauthAuthorize } : {}),
|
|
200
|
+
...(oauthToken ? { oauthTokenEndpoint: oauthToken } : {}),
|
|
201
|
+
},
|
|
202
|
+
// Where a client sends what this actor writes, which the protocol says is
|
|
203
|
+
// this same address. The pod holds the collection and answers reads of it;
|
|
204
|
+
// a client-to-server write has to reach the agent, and the pod cannot take
|
|
205
|
+
// one. So where the agent is reachable it is named here, and a read of it
|
|
206
|
+
// is sent straight on to the pod's own document.
|
|
207
|
+
outbox: outbox || urls.outbox,
|
|
193
208
|
// The pinned posts, as the collection other servers read when they show
|
|
194
209
|
// this profile. Mastodon's term, declared the way Mastodon declares it.
|
|
195
210
|
featured: urls.featured,
|
|
@@ -645,6 +660,45 @@ export function noteDoc({ urls, slug, content, published, inReplyTo, attachments
|
|
|
645
660
|
return note;
|
|
646
661
|
}
|
|
647
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
|
+
|
|
648
702
|
// The id is a document of its own, not `note.id + '#create'`. A group wraps this
|
|
649
703
|
// whole activity in its Announce (FEP-1b12), and the receiver resolves it by
|
|
650
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.
|
|
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,
|
|
@@ -338,6 +339,10 @@ export class Agent {
|
|
|
338
339
|
// Whether the fediverse tree is on the pod at all, so the ACL check does
|
|
339
340
|
// not probe for something the default layout keeps on local disk.
|
|
340
341
|
privateOnPod: !cred.privateRoot,
|
|
342
|
+
// Inside a pod server the client surface answers on the pod's own
|
|
343
|
+
// origin, so it can be advertised. Standalone it is on loopback, and
|
|
344
|
+
// naming it in a world-readable actor would send clients nowhere.
|
|
345
|
+
clientOrigin: this.embedded ? this.urls.base : null,
|
|
341
346
|
});
|
|
342
347
|
// Intake is constructed even for viewers — its signed fetchAP powers
|
|
343
348
|
// search/deref; start() (draining) is active-only.
|
|
@@ -543,6 +548,10 @@ export class Agent {
|
|
|
543
548
|
}).then(() => this.log(`scheduled post published (${e.id})`))
|
|
544
549
|
.catch(err => this.log(`scheduled post ${e.id} failed: ${err.message} — dropped`));
|
|
545
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}`));
|
|
546
555
|
}, 30_000);
|
|
547
556
|
this.schedTimer.unref();
|
|
548
557
|
// A CSV import interrupted by a restart or a handoff picks back up here.
|
|
@@ -647,6 +656,7 @@ export class Agent {
|
|
|
647
656
|
this.deliverer?.stop();
|
|
648
657
|
this.importer?.stop();
|
|
649
658
|
clearInterval(this.schedTimer);
|
|
659
|
+
this.publisher?.stopPolls();
|
|
650
660
|
// The lease too: standing down means standing down. Left renewing, a viewer
|
|
651
661
|
// keeps writing to the pod on the active agent's behalf and can win the
|
|
652
662
|
// lease back on a conditional PUT it had no business making.
|