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/README.md +10 -0
- package/dist/claims.js +11 -1
- 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
|
@@ -124,6 +124,15 @@ with the password. The authorization code is bound to the client's registered
|
|
|
124
124
|
redirect, and only that client, holding its secret, can exchange it for a
|
|
125
125
|
token.
|
|
126
126
|
|
|
127
|
+
An ActivityPub client needs none of that arranged by hand. The actor says
|
|
128
|
+
where to sign in, and `/.well-known/oauth-authorization-server` says the same
|
|
129
|
+
thing at the address such clients look for first. An app that keeps no secret,
|
|
130
|
+
which is anything running in a browser, signs in by answering a challenge it
|
|
131
|
+
set at the start. An app that publishes a document about itself is known by
|
|
132
|
+
that document's address and registers nothing here. Once in, it posts to the
|
|
133
|
+
outbox the actor names, and reads what the identity received at `/ap/inbox`,
|
|
134
|
+
which nobody but the owner may read.
|
|
135
|
+
|
|
127
136
|
A client API is rooted at an origin, so each identity needs an origin of its
|
|
128
137
|
own: subdomain pods, one per identity. A second identity on a host is
|
|
129
138
|
refused at opt-in rather than half-working.
|
|
@@ -147,6 +156,7 @@ curl -X POST https://mei.example.org/fedipod/config \
|
|
|
147
156
|
**Some pod paths stop being served.** On an identity's origin the paths above
|
|
148
157
|
belong to the identity, so pod resources at those names — a container called
|
|
149
158
|
`api`, `oauth` or `fedipod`, or documents at `ap/actor`, `ap/outbox`,
|
|
159
|
+
`ap/inbox`, `.well-known/oauth-authorization-server`,
|
|
150
160
|
`.well-known/nodeinfo` and `nodeinfo/2.0` — are not served over HTTP there.
|
|
151
161
|
They stay in the pod and in its listings. Every other path is the pod, exactly
|
|
152
162
|
as before.
|
package/dist/claims.js
CHANGED
|
@@ -23,7 +23,17 @@ function claims(input, frontHost) {
|
|
|
23
23
|
// What an identity answers on its own pod's origin: the protocol routes other
|
|
24
24
|
// software addresses it by, and the one path its owner's pages live under.
|
|
25
25
|
// Everything else on that origin is the pod, and falls through to CSS.
|
|
26
|
-
const AGENT_PATHS = new Set([
|
|
26
|
+
const AGENT_PATHS = new Set([
|
|
27
|
+
'/ap/actor', '/ap/outbox',
|
|
28
|
+
// The owner reading their own mail. Deliveries still go to the inbox
|
|
29
|
+
// container on the pod, which the actor document names; nothing about
|
|
30
|
+
// receiving changes, and nothing at this address was ever served before.
|
|
31
|
+
'/ap/inbox',
|
|
32
|
+
'/.well-known/nodeinfo', '/nodeinfo/2.0',
|
|
33
|
+
// Where a client looks first to learn how to sign in. Nothing was served
|
|
34
|
+
// at this name before, so no pod resource is displaced.
|
|
35
|
+
'/.well-known/oauth-authorization-server',
|
|
36
|
+
]);
|
|
27
37
|
const AGENT_PREFIXES = ['/api/', '/oauth/'];
|
|
28
38
|
/**
|
|
29
39
|
* True when this request belongs to an identity's client surface.
|
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,
|