fedipod 1.7.0 → 1.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 +14 -3
- package/gateway.md +16 -0
- package/groups.md +1 -0
- package/lib/client/c2s.mjs +112 -64
- package/lib/client/masto/index.mjs +9 -1
- package/lib/client/masto/render.mjs +5 -3
- package/lib/client/masto/statuses.mjs +7 -4
- package/lib/client/oidc-auth.mjs +10 -3
- package/lib/core/as2.mjs +1 -1
- package/lib/core/contexts/anno.json +126 -0
- package/lib/core/contexts/index.mjs +4 -0
- package/lib/core/contexts/map.json +2 -1
- package/lib/core/deliver.mjs +24 -1
- package/lib/core/intake/activities.mjs +54 -0
- package/lib/core/intake/index.mjs +34 -5
- package/lib/core/intake/notes.mjs +7 -0
- package/lib/core/publisher/collections.mjs +7 -4
- package/lib/core/publisher/index.mjs +49 -27
- package/lib/core/publisher/notes.mjs +154 -7
- package/lib/core/publisher/questions.mjs +11 -7
- package/lib/core/publisher/restore.mjs +27 -2
- package/lib/core/social.mjs +1 -0
- package/lib/core/store.mjs +13 -0
- package/lib/core/wire.mjs +71 -20
- package/lib/device/admin/static.mjs +2 -2
- package/lib/device/admin/surface.mjs +11 -2
- package/lib/gateway/front-core.mjs +76 -6
- package/lib/gateway/gateway-core.mjs +40 -0
- package/lib/gateway/httpsig.mjs +7 -3
- package/lib/pod/actor.mjs +2 -2
- package/lib/pod/transport.mjs +9 -3
- package/package.json +1 -1
- package/run-agent.mjs +11 -0
- package/scripts/refresh-contexts.mjs +5 -0
- package/scripts/stage-site.mjs +1 -0
- package/web/admin/index.html +2 -0
- package/web/admin/upkeep.js +9 -1
- package/web/app/README.md +1 -1
- package/web/app/agent.mjs +12 -0
- package/web/app/boot.mjs +49 -10
- package/web/app/dist/boot.js +47 -7
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +1604 -722
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/index.html +16 -6
- package/web/app/site/_redirects +1 -0
- package/web/app/site/admin/index.html +2 -0
- package/web/app/site/admin/upkeep.js +9 -1
- package/web/app/site/boot.js +89 -37
- package/web/app/site/index.html +16 -6
- package/web/app/site/sw.js +1605 -723
- package/web/front/#new-account.html# +0 -43
- package/web/front/new-account.html~ +0 -50
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
{
|
|
2
|
+
"@context": {
|
|
3
|
+
"oa": "http://www.w3.org/ns/oa#",
|
|
4
|
+
"dc": "http://purl.org/dc/elements/1.1/",
|
|
5
|
+
"dcterms": "http://purl.org/dc/terms/",
|
|
6
|
+
"dctypes": "http://purl.org/dc/dcmitype/",
|
|
7
|
+
"foaf": "http://xmlns.com/foaf/0.1/",
|
|
8
|
+
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
|
9
|
+
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
|
|
10
|
+
"skos": "http://www.w3.org/2004/02/skos/core#",
|
|
11
|
+
"xsd": "http://www.w3.org/2001/XMLSchema#",
|
|
12
|
+
"iana": "http://www.iana.org/assignments/relation/",
|
|
13
|
+
"owl": "http://www.w3.org/2002/07/owl#",
|
|
14
|
+
"as": "http://www.w3.org/ns/activitystreams#",
|
|
15
|
+
"schema": "http://schema.org/",
|
|
16
|
+
|
|
17
|
+
"id": {"@type": "@id", "@id": "@id"},
|
|
18
|
+
"type": {"@type": "@id", "@id": "@type"},
|
|
19
|
+
|
|
20
|
+
"Annotation": "oa:Annotation",
|
|
21
|
+
"Dataset": "dctypes:Dataset",
|
|
22
|
+
"Image": "dctypes:StillImage",
|
|
23
|
+
"Video": "dctypes:MovingImage",
|
|
24
|
+
"Audio": "dctypes:Sound",
|
|
25
|
+
"Text": "dctypes:Text",
|
|
26
|
+
"TextualBody": "oa:TextualBody",
|
|
27
|
+
"ResourceSelection": "oa:ResourceSelection",
|
|
28
|
+
"SpecificResource": "oa:SpecificResource",
|
|
29
|
+
"FragmentSelector": "oa:FragmentSelector",
|
|
30
|
+
"CssSelector": "oa:CssSelector",
|
|
31
|
+
"XPathSelector": "oa:XPathSelector",
|
|
32
|
+
"TextQuoteSelector": "oa:TextQuoteSelector",
|
|
33
|
+
"TextPositionSelector": "oa:TextPositionSelector",
|
|
34
|
+
"DataPositionSelector": "oa:DataPositionSelector",
|
|
35
|
+
"SvgSelector": "oa:SvgSelector",
|
|
36
|
+
"RangeSelector": "oa:RangeSelector",
|
|
37
|
+
"TimeState": "oa:TimeState",
|
|
38
|
+
"HttpRequestState": "oa:HttpRequestState",
|
|
39
|
+
"CssStylesheet": "oa:CssStyle",
|
|
40
|
+
"Choice": "oa:Choice",
|
|
41
|
+
"Person": "foaf:Person",
|
|
42
|
+
"Software": "as:Application",
|
|
43
|
+
"Organization": "foaf:Organization",
|
|
44
|
+
"AnnotationCollection": "as:OrderedCollection",
|
|
45
|
+
"AnnotationPage": "as:OrderedCollectionPage",
|
|
46
|
+
"Audience": "schema:Audience",
|
|
47
|
+
|
|
48
|
+
"Motivation": "oa:Motivation",
|
|
49
|
+
"bookmarking": "oa:bookmarking",
|
|
50
|
+
"classifying": "oa:classifying",
|
|
51
|
+
"commenting": "oa:commenting",
|
|
52
|
+
"describing": "oa:describing",
|
|
53
|
+
"editing": "oa:editing",
|
|
54
|
+
"highlighting": "oa:highlighting",
|
|
55
|
+
"identifying": "oa:identifying",
|
|
56
|
+
"linking": "oa:linking",
|
|
57
|
+
"moderating": "oa:moderating",
|
|
58
|
+
"questioning": "oa:questioning",
|
|
59
|
+
"replying": "oa:replying",
|
|
60
|
+
"reviewing": "oa:reviewing",
|
|
61
|
+
"assessing": "oa:assessing",
|
|
62
|
+
"tagging": "oa:tagging",
|
|
63
|
+
|
|
64
|
+
"auto": "oa:autoDirection",
|
|
65
|
+
"ltr": "oa:ltrDirection",
|
|
66
|
+
"rtl": "oa:rtlDirection",
|
|
67
|
+
|
|
68
|
+
"body": {"@type": "@id", "@id": "oa:hasBody"},
|
|
69
|
+
"target": {"@type": "@id", "@id": "oa:hasTarget"},
|
|
70
|
+
"source": {"@type": "@id", "@id": "oa:hasSource"},
|
|
71
|
+
"selector": {"@type": "@id", "@id": "oa:hasSelector"},
|
|
72
|
+
"state": {"@type": "@id", "@id": "oa:hasState"},
|
|
73
|
+
"scope": {"@type": "@id", "@id": "oa:hasScope"},
|
|
74
|
+
"refinedBy": {"@type": "@id", "@id": "oa:refinedBy"},
|
|
75
|
+
"startSelector": {"@type": "@id", "@id": "oa:hasStartSelector"},
|
|
76
|
+
"endSelector": {"@type": "@id", "@id": "oa:hasEndSelector"},
|
|
77
|
+
"renderedVia": {"@type": "@id", "@id": "oa:renderedVia"},
|
|
78
|
+
"creator": {"@type": "@id", "@id": "dcterms:creator"},
|
|
79
|
+
"generator": {"@type": "@id", "@id": "as:generator"},
|
|
80
|
+
"rights": {"@type": "@id", "@id": "dcterms:rights"},
|
|
81
|
+
"homepage": {"@type": "@id", "@id": "foaf:homepage"},
|
|
82
|
+
"via": {"@type": "@id", "@id": "oa:via"},
|
|
83
|
+
"canonical": {"@type": "@id", "@id": "oa:canonical"},
|
|
84
|
+
"stylesheet": {"@type": "@id", "@id": "oa:styledBy"},
|
|
85
|
+
"cached": {"@type": "@id", "@id": "oa:cachedSource"},
|
|
86
|
+
"conformsTo": {"@type": "@id", "@id": "dcterms:conformsTo"},
|
|
87
|
+
"items": {"@type": "@id", "@id": "as:items", "@container": "@list"},
|
|
88
|
+
"partOf": {"@type": "@id", "@id": "as:partOf"},
|
|
89
|
+
"first": {"@type": "@id", "@id": "as:first"},
|
|
90
|
+
"last": {"@type": "@id", "@id": "as:last"},
|
|
91
|
+
"next": {"@type": "@id", "@id": "as:next"},
|
|
92
|
+
"prev": {"@type": "@id", "@id": "as:prev"},
|
|
93
|
+
"audience": {"@type": "@id", "@id": "schema:audience"},
|
|
94
|
+
"motivation": {"@type": "@vocab", "@id": "oa:motivatedBy"},
|
|
95
|
+
"purpose": {"@type": "@vocab", "@id": "oa:hasPurpose"},
|
|
96
|
+
"textDirection": {"@type": "@vocab", "@id": "oa:textDirection"},
|
|
97
|
+
|
|
98
|
+
"accessibility": "schema:accessibilityFeature",
|
|
99
|
+
"bodyValue": "oa:bodyValue",
|
|
100
|
+
"format": "dc:format",
|
|
101
|
+
"language": "dc:language",
|
|
102
|
+
"processingLanguage": "oa:processingLanguage",
|
|
103
|
+
"value": "rdf:value",
|
|
104
|
+
"exact": "oa:exact",
|
|
105
|
+
"prefix": "oa:prefix",
|
|
106
|
+
"suffix": "oa:suffix",
|
|
107
|
+
"styleClass": "oa:styleClass",
|
|
108
|
+
"name": "foaf:name",
|
|
109
|
+
"email": "foaf:mbox",
|
|
110
|
+
"email_sha1": "foaf:mbox_sha1sum",
|
|
111
|
+
"nickname": "foaf:nick",
|
|
112
|
+
"label": "rdfs:label",
|
|
113
|
+
|
|
114
|
+
"created": {"@id": "dcterms:created", "@type": "xsd:dateTime"},
|
|
115
|
+
"modified": {"@id": "dcterms:modified", "@type": "xsd:dateTime"},
|
|
116
|
+
"generated": {"@id": "dcterms:issued", "@type": "xsd:dateTime"},
|
|
117
|
+
"sourceDate": {"@id": "oa:sourceDate", "@type": "xsd:dateTime"},
|
|
118
|
+
"sourceDateStart": {"@id": "oa:sourceDateStart", "@type": "xsd:dateTime"},
|
|
119
|
+
"sourceDateEnd": {"@id": "oa:sourceDateEnd", "@type": "xsd:dateTime"},
|
|
120
|
+
|
|
121
|
+
"start": {"@id": "oa:start", "@type": "xsd:nonNegativeInteger"},
|
|
122
|
+
"end": {"@id": "oa:end", "@type": "xsd:nonNegativeInteger"},
|
|
123
|
+
"total": {"@id": "as:totalItems", "@type": "xsd:nonNegativeInteger"},
|
|
124
|
+
"startIndex": {"@id": "as:startIndex", "@type": "xsd:nonNegativeInteger"}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -25,6 +25,9 @@ import ctx10 from './fep-5711.json' with { type: 'json' };
|
|
|
25
25
|
import ctx11 from './join-lemmy.json' with { type: 'json' };
|
|
26
26
|
import ctx12 from './joinmastodon.json' with { type: 'json' };
|
|
27
27
|
import ctx13 from './miscellany.json' with { type: 'json' };
|
|
28
|
+
// Not from fedify: the W3C Web Annotation context, which a client such as
|
|
29
|
+
// dokieli names on the annotations it posts to the outbox.
|
|
30
|
+
import ctx14 from './anno.json' with { type: 'json' };
|
|
28
31
|
|
|
29
32
|
/** URL → the context document itself. Nothing outside this map is ever resolved. */
|
|
30
33
|
export const CONTEXTS = {
|
|
@@ -42,4 +45,5 @@ export const CONTEXTS = {
|
|
|
42
45
|
'https://join-lemmy.org/context.json': ctx11,
|
|
43
46
|
'http://joinmastodon.org/ns': ctx12,
|
|
44
47
|
'https://purl.archive.org/miscellany': ctx13,
|
|
48
|
+
'http://www.w3.org/ns/anno.jsonld': ctx14,
|
|
45
49
|
};
|
|
@@ -12,5 +12,6 @@
|
|
|
12
12
|
"https://w3id.org/fep/5711": "fep-5711.json",
|
|
13
13
|
"https://join-lemmy.org/context.json": "join-lemmy.json",
|
|
14
14
|
"http://joinmastodon.org/ns": "joinmastodon.json",
|
|
15
|
-
"https://purl.archive.org/miscellany": "miscellany.json"
|
|
15
|
+
"https://purl.archive.org/miscellany": "miscellany.json",
|
|
16
|
+
"http://www.w3.org/ns/anno.jsonld": "anno.json"
|
|
16
17
|
}
|
package/lib/core/deliver.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { webcrypto } from 'node:crypto';
|
|
|
8
8
|
import { attachProof } from './proof.mjs';
|
|
9
9
|
import { pinnedFor, retryAfterMs, HTTP_TIMEOUT_MS } from '../shared/safefetch.mjs';
|
|
10
10
|
import { USER_AGENT } from '../shared/ua.mjs';
|
|
11
|
+
import { dropFollower } from './store.mjs';
|
|
11
12
|
|
|
12
13
|
const MAX_REDIRECTS = 3;
|
|
13
14
|
|
|
@@ -52,14 +53,20 @@ const MAX_QUEUE = 2000; // beyond it, overflow dead-lette
|
|
|
52
53
|
// the server, or no answer at all, is grounds to leave it alone.
|
|
53
54
|
const hostOf = (inbox) => { try { return new URL(inbox).host; } catch { return inbox; } };
|
|
54
55
|
const aboutTheHost = (e) => !e.status || e.status >= 500 || e.status === 429;
|
|
56
|
+
// A 410 says the inbox is gone for good and a 404 that nothing answers there:
|
|
57
|
+
// neither earns a second POST. Gone also means whoever read there has left.
|
|
58
|
+
const unsalvageable = (e) => e.status === 404 || e.status === 410;
|
|
55
59
|
|
|
56
60
|
export class Deliverer {
|
|
57
61
|
// passive: signing-only (viewer-mode agents) — no queue drain timer, so a
|
|
58
62
|
// read-only agent never mutates shared delivery state. startQueue() flips
|
|
59
63
|
// it live when a viewer is promoted to active.
|
|
60
64
|
constructor({ store, keyId, rsaPrivate, actorId = null, edPrivate = null, proofKeyId = null,
|
|
61
|
-
log = console.log, passive = false }) {
|
|
65
|
+
log = console.log, passive = false, onGone = null }) {
|
|
62
66
|
this.store = store;
|
|
67
|
+
// Called after followers at a gone inbox are dropped: the agent republishes
|
|
68
|
+
// its followers collection.
|
|
69
|
+
this.onGone = onGone;
|
|
63
70
|
this.keyId = keyId;
|
|
64
71
|
this.rsaPrivate = rsaPrivate;
|
|
65
72
|
this.actorId = actorId;
|
|
@@ -182,6 +189,7 @@ export class Deliverer {
|
|
|
182
189
|
await this.deliverNow(inbox, signed);
|
|
183
190
|
this.log(`delivered ${signed.type} → ${inbox}`);
|
|
184
191
|
} catch (e) {
|
|
192
|
+
if (unsalvageable(e)) { await this._unsalvageable(inbox, e); return; }
|
|
185
193
|
this.log(`delivery failed (${e.message}) — queued`);
|
|
186
194
|
const wait = e.retryAfterMs || 60_000;
|
|
187
195
|
if (aboutTheHost(e)) {
|
|
@@ -192,6 +200,20 @@ export class Deliverer {
|
|
|
192
200
|
}
|
|
193
201
|
}
|
|
194
202
|
|
|
203
|
+
// Not retried, and on a 410 the followers who received there are dropped,
|
|
204
|
+
// so the next fan-out stops asking; the followers collection is republished
|
|
205
|
+
// through onGone. A 404 drops only this delivery.
|
|
206
|
+
async _unsalvageable(inbox, e) {
|
|
207
|
+
if (e.status !== 410) { this.log(`delivery dropped (${e.message}) — nothing answers there`); return; }
|
|
208
|
+
const contacts = this.store.getContacts?.();
|
|
209
|
+
const left = (contacts?.followers || []).filter(f => f.inbox === inbox || f.sharedInbox === inbox);
|
|
210
|
+
this.log(`${inbox} is gone (410) — delivery dropped${left.length ? `, ${left.length} follower(s) there forgotten` : ''}`);
|
|
211
|
+
if (!left.length) return;
|
|
212
|
+
for (const f of left) dropFollower(contacts, f.actor, 'inbox-gone');
|
|
213
|
+
this.store.setContacts(contacts);
|
|
214
|
+
try { await this.onGone?.(); } catch (err) { this.log(`followers republish after 410: ${err.message}`); }
|
|
215
|
+
}
|
|
216
|
+
|
|
195
217
|
// The queue has a ceiling: a fan-out into a sea of down servers must not grow
|
|
196
218
|
// queue.json without bound. Overflow keeps the evidence as a dead letter.
|
|
197
219
|
_enqueue(item) {
|
|
@@ -292,6 +314,7 @@ export class Deliverer {
|
|
|
292
314
|
changed = true;
|
|
293
315
|
this.log(`retry ok: ${item.activity.type} → ${item.inbox}`);
|
|
294
316
|
} catch (e) {
|
|
317
|
+
if (unsalvageable(e)) { changed = true; await this._unsalvageable(item.inbox, e); continue; }
|
|
295
318
|
item.attempts += 1;
|
|
296
319
|
changed = true;
|
|
297
320
|
const wait = e.retryAfterMs || ladderFor(item);
|
|
@@ -103,6 +103,8 @@ export async function onFollow(intake, activity, actor, { trusted = false } = {}
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
export async function onUndo(intake, activity, actor, { trusted = false } = {}) {
|
|
106
|
+
const inner = activity.object && typeof activity.object === 'object' ? activity.object : null;
|
|
107
|
+
if (inner?.type === 'Like' || inner?.type === 'Announce') return onUndoReaction(intake, inner, actor, { trusted });
|
|
106
108
|
// AS2 allows `object` to be a bare IRI, and that IRI is exactly the Follow
|
|
107
109
|
// id we stored. Reading `.type` off a string gives undefined, so the whole
|
|
108
110
|
// Undo was dropped — silently, since handle() reads that as handled, so no
|
|
@@ -274,6 +276,58 @@ export async function onAnnounce(intake, activity, actor, objectId) {
|
|
|
274
276
|
return intake.ingestNote(objectId, actor, { via: actor });
|
|
275
277
|
}
|
|
276
278
|
|
|
279
|
+
// Undo{Like} and Undo{Announce}: the sender unsaying a favourite or a boost.
|
|
280
|
+
// The Like it unsays was recorded on the sender's word alone, so its undoing
|
|
281
|
+
// is taken the same way: the notification it left goes. A boost unsaid by
|
|
282
|
+
// someone we follow takes the post they carried off the timeline — the carry
|
|
283
|
+
// was accepted on that follow, and the retraction is honoured on it.
|
|
284
|
+
export async function onUndoReaction(intake, inner, actor, { trusted = false } = {}) {
|
|
285
|
+
const innerActor = typeof inner.actor === 'string' ? inner.actor : inner.actor?.id;
|
|
286
|
+
if (innerActor && innerActor !== actor) return; // one cannot unsay another's
|
|
287
|
+
const wrapped = inner.object;
|
|
288
|
+
const target = (wrapped && typeof wrapped === 'object'
|
|
289
|
+
&& (wrapped.type === 'Create' || wrapped.type === 'Update')) ? wrapped.object : wrapped;
|
|
290
|
+
const objectId = typeof target === 'string' ? target : target?.id;
|
|
291
|
+
if (!objectId) return;
|
|
292
|
+
if (objectId.startsWith(intake.urls.notes)) {
|
|
293
|
+
const type = inner.type === 'Like' ? 'favourite' : 'reblog';
|
|
294
|
+
const gone = intake.store.removeNotifications(n => n.type === type && n.actor === actor && n.noteId === objectId);
|
|
295
|
+
if (gone) intake.log(`${inner.type} withdrawn by ${actor} on ${objectId}`);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (inner.type !== 'Announce') return;
|
|
299
|
+
const followed = trusted || intake.store.getContacts().following.some(f => f.actor === actor && f.accepted);
|
|
300
|
+
if (!followed) return;
|
|
301
|
+
const s = intake.store.getStatuses().find(x => x.noteId === objectId);
|
|
302
|
+
if (!s || s.kind === 'post' || s.via !== actor) return;
|
|
303
|
+
await intake.forget(s);
|
|
304
|
+
intake.log(`boost withdrawn by ${actor}: ${objectId} left the timeline`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Block: the sender wants nothing more to do with this account. Mastodon
|
|
308
|
+
// drops the follow both ways on receipt, and so does this — on a delivery
|
|
309
|
+
// the door verified. A Block carries nothing to check and names nothing we
|
|
310
|
+
// hold, so one taken from the public-append inbox unverified would let
|
|
311
|
+
// anybody evict any follower, the hole the Undo handler closes with the
|
|
312
|
+
// follow id. Unverified: logged, nothing else.
|
|
313
|
+
export async function onBlock(intake, activity, actor, { trusted = false } = {}) {
|
|
314
|
+
const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
|
|
315
|
+
if (objectId !== intake.urls.actor) return;
|
|
316
|
+
if (!trusted) { intake.log(`Block from ${actor} arrived unverified — ignored`); return; }
|
|
317
|
+
const contacts = intake.store.getContacts();
|
|
318
|
+
const wasFollower = contacts.followers.some(f => f.actor === actor);
|
|
319
|
+
const wasFollowing = contacts.following.some(f => f.actor === actor);
|
|
320
|
+
if (wasFollower) dropFollower(contacts, actor, 'blocked-us');
|
|
321
|
+
contacts.following = contacts.following.filter(f => f.actor !== actor);
|
|
322
|
+
intake.store.setContacts(contacts);
|
|
323
|
+
const reqs = intake.store.getRequests();
|
|
324
|
+
const left = reqs.filter(r => r.actor !== actor);
|
|
325
|
+
if (left.length !== reqs.length) intake.store.setRequests(left);
|
|
326
|
+
await intake.republish({ followers: wasFollower, following: wasFollowing, pending: true });
|
|
327
|
+
const what = [wasFollower && 'their follow of us', wasFollowing && 'our follow of them'].filter(Boolean).join(' and ');
|
|
328
|
+
intake.log(`blocked by ${actor}: ${what || 'no relationship'} dropped`);
|
|
329
|
+
}
|
|
330
|
+
|
|
277
331
|
// Mastodon sends these constantly; ignoring them left deleted posts standing
|
|
278
332
|
// for good. Two guards, because a forged Delete would otherwise erase anyone's
|
|
279
333
|
// content: it must come from the object's own origin, and the object must
|
|
@@ -59,8 +59,10 @@ const MAX_ITEM_ATTEMPTS = 5;
|
|
|
59
59
|
const MAX_ITEMS_PER_DRAIN = 50;
|
|
60
60
|
|
|
61
61
|
export class Intake {
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
// `ownerPost(activity, { raw, slug })` is the client-to-server dispatcher,
|
|
63
|
+
// for an item the Gateway's outbox door took on the owner's behalf.
|
|
64
|
+
constructor({ config, urls, remote, store, deliverer, publisher, log = console.log, lease = null, archive = null, push = true, pollSeconds = null, ownerPost = null }) {
|
|
65
|
+
Object.assign(this, { config, urls, remote, store, deliverer, publisher, log, lease, archive, push, pollSeconds, ownerPost });
|
|
64
66
|
this.serial = Date.now();
|
|
65
67
|
this.stopped = false;
|
|
66
68
|
// (attempt counts are kept in pod state — see _bumpAttempt)
|
|
@@ -429,9 +431,14 @@ export class Intake {
|
|
|
429
431
|
// receipt reads as null, which is exactly today's unverified behavior.
|
|
430
432
|
const receipt = activity ? await this._readReceipt(url) : null;
|
|
431
433
|
if (activity && this.gatewaySecret()) this._bumpGatewayStat(!!receipt?.verified);
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
434
|
+
// The owner's own post, taken at the outbox door: not mail to read
|
|
435
|
+
// but a write to make. Never archived or forwarded as if received.
|
|
436
|
+
const owned = activity && this.isOwnerPost(receipt);
|
|
437
|
+
const rejection = !activity ? 'unparsable JSON'
|
|
438
|
+
: owned ? await this.ownerPostFrom(activity, raw, receipt)
|
|
439
|
+
: await this.handle(activity, receipt);
|
|
440
|
+
if (!rejection && raw && !owned) await this._archive(url, raw, activity);
|
|
441
|
+
if (!rejection && !owned) await this._maybeForward(activity); // §7.1.2, only what we accepted
|
|
435
442
|
if (rejection) {
|
|
436
443
|
this.store.addDeadLetter({
|
|
437
444
|
inboxUrl: url, reason: rejection, activity: trimActivity(activity),
|
|
@@ -577,6 +584,7 @@ export class Intake {
|
|
|
577
584
|
case 'Reject': return this.onReject(activity, actor, { trusted });
|
|
578
585
|
case 'Move': return this.onMove(activity, actor);
|
|
579
586
|
case 'Add': case 'Remove': return this.onAddRemove(activity, actor);
|
|
587
|
+
case 'Block': return this.onBlock(activity, actor, { trusted });
|
|
580
588
|
default: this.log(`ignored ${activity.type} from ${actor}`);
|
|
581
589
|
}
|
|
582
590
|
}
|
|
@@ -594,6 +602,26 @@ export class Intake {
|
|
|
594
602
|
gatewaySecret(...a) { return verify.gatewaySecret(this, ...a); }
|
|
595
603
|
_bumpGatewayStat(...a) { return verify.bumpGatewayStat(this, ...a); }
|
|
596
604
|
_readReceipt(...a) { return verify.readReceipt(this, ...a); }
|
|
605
|
+
|
|
606
|
+
// A receipt the door stamped `c2s` for THIS actor, and only a receipt whose
|
|
607
|
+
// HMAC verified (readReceipt returns nothing else). A stranger appending an
|
|
608
|
+
// item that claims to be ours has no such receipt and is read as mail.
|
|
609
|
+
isOwnerPost(receipt) {
|
|
610
|
+
return !!(this.ownerPost && receipt?.verified && receipt.method === 'c2s' && receipt.actor === this.urls.actor);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Publish what the owner posted at the door. The bytes as the client wrote
|
|
614
|
+
// them go to the dispatcher (an object stored as sent must be the client's
|
|
615
|
+
// document, not our reading of it); the graph is what it decides from.
|
|
616
|
+
// Returns null when published, else the reason it was not — a dead letter.
|
|
617
|
+
async ownerPostFrom(activity, raw, receipt) {
|
|
618
|
+
let asSent = null;
|
|
619
|
+
try { asSent = raw ? JSON.parse(raw) : null; } catch { asSent = null; }
|
|
620
|
+
const r = await this.ownerPost(activity, { raw: asSent, slug: receipt.slug || null });
|
|
621
|
+
if (!r || r.status >= 300) return `owner post refused (${r?.status || '?'}): ${r?.body?.error || ''}`;
|
|
622
|
+
this.log(`owner post from the outbox door published: ${r.body?.object || r.body?.id || activity.type}`);
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
597
625
|
receiptVouchesFor(...a) { return verify.receiptVouchesFor(this, ...a); }
|
|
598
626
|
isGone(...a) { return verify.isGone(this, ...a); }
|
|
599
627
|
|
|
@@ -609,6 +637,7 @@ export class Intake {
|
|
|
609
637
|
onAddRemove(...a) { return activities.onAddRemove(this, ...a); }
|
|
610
638
|
onFollow(...a) { return activities.onFollow(this, ...a); }
|
|
611
639
|
onUndo(...a) { return activities.onUndo(this, ...a); }
|
|
640
|
+
onBlock(...a) { return activities.onBlock(this, ...a); }
|
|
612
641
|
onCreate(...a) { return activities.onCreate(this, ...a); }
|
|
613
642
|
onAnnouncedDelete(...a) { return activities.onAnnouncedDelete(this, ...a); }
|
|
614
643
|
onAnnounce(...a) { return activities.onAnnounce(this, ...a); }
|
|
@@ -190,6 +190,11 @@ export async function ingestNote(intake, objectId, actor, { via, inline = null }
|
|
|
190
190
|
.slice(0, MAX_MENTIONS)
|
|
191
191
|
.map(t => ({ href: httpOnly(String(t.href).slice(0, MAX_URL_CHARS)), name: String(t.name).slice(0, 256) }))
|
|
192
192
|
.filter(m => m.href);
|
|
193
|
+
const hashtags = [].concat(note.tag || [])
|
|
194
|
+
.filter(t => t?.type === 'Hashtag' && t.name)
|
|
195
|
+
.slice(0, MAX_MENTIONS)
|
|
196
|
+
.map(t => ({ name: String(t.name).replace(/^#/, '').slice(0, 128), url: httpOnly(String(t.href || '').slice(0, MAX_URL_CHARS)) }))
|
|
197
|
+
.filter(t => t.name);
|
|
193
198
|
const emojis = emojisOf(note);
|
|
194
199
|
const poll = pollOf(note);
|
|
195
200
|
// Explicitly addressed, but to nobody public and to no followers
|
|
@@ -209,6 +214,8 @@ export async function ingestNote(intake, objectId, actor, { via, inline = null }
|
|
|
209
214
|
...(nonPublic ? { nonPublic: true } : {}),
|
|
210
215
|
// The author's content warning, shown as one: plain text only.
|
|
211
216
|
...(note.summary ? { spoiler: String(note.summary).replace(/<[^>]*>/g, '') } : {}),
|
|
217
|
+
...(note.sensitive === true ? { sensitive: true } : {}),
|
|
218
|
+
...(hashtags.length ? { tags: hashtags } : {}),
|
|
212
219
|
...(poll ? { poll } : {}),
|
|
213
220
|
...(emojis.length ? { emojis } : {}),
|
|
214
221
|
...(mentions.length ? { mentions } : {}),
|
|
@@ -86,7 +86,10 @@ export async function publishOutbox(publisher, outbox, { acls = false, force = f
|
|
|
86
86
|
// the flat collection this used to write, so an actor published before paging
|
|
87
87
|
// is still readable — which matters because rebuild reads this to recover
|
|
88
88
|
// posts a lost machine no longer has.
|
|
89
|
-
export function readPublishedOutbox(publisher) {
|
|
89
|
+
export async function readPublishedOutbox(publisher) {
|
|
90
|
+
const items = await podOutbox.readPublished(publisher.remote, publisher.urls);
|
|
91
|
+
return Array.isArray(items) ? items.map(wire.outboxLocalItem) : items;
|
|
92
|
+
}
|
|
90
93
|
|
|
91
94
|
// The followers collection, paged like the outbox: a head that carries only
|
|
92
95
|
// the count and the page bounds, and page documents holding the actor IRIs —
|
|
@@ -191,9 +194,9 @@ export async function publishBlocked(publisher) {
|
|
|
191
194
|
}
|
|
192
195
|
|
|
193
196
|
// The outbox is the public record of everything this actor has said, boosts
|
|
194
|
-
// included. A
|
|
195
|
-
// has only a fragment id, so the activity
|
|
196
|
-
//
|
|
197
|
+
// included. A post is recorded by its note id and published as its Create
|
|
198
|
+
// (wire.outboxWireItem); an Announce has only a fragment id, so the activity
|
|
199
|
+
// itself goes in.
|
|
197
200
|
export async function recordOutbox(publisher, item) {
|
|
198
201
|
const outbox = publisher.store.read('outbox.json', []);
|
|
199
202
|
outbox.unshift(item);
|
|
@@ -34,7 +34,7 @@ const AGENT_VERSION = JSON.parse(fs.readFileSync(
|
|
|
34
34
|
|
|
35
35
|
export class Publisher {
|
|
36
36
|
constructor({ config, remote, store, deliverer, publicKeyPem, assertionKey = null, log = console.log,
|
|
37
|
-
probeFetch = null, resolveMention = null, clientOrigin = null,
|
|
37
|
+
probeFetch = null, resolveMention = null, resolveActor = null, clientOrigin = null,
|
|
38
38
|
}) {
|
|
39
39
|
this.config = config;
|
|
40
40
|
this.remote = remote;
|
|
@@ -57,6 +57,9 @@ export class Publisher {
|
|
|
57
57
|
// socket opened to that pod. Tests inject their own.
|
|
58
58
|
this.probeFetch = probeFetch || ((u, i) => this.remote.probe(u, i));
|
|
59
59
|
this.resolveMention = resolveMention;
|
|
60
|
+
// An actor or object by its address, verified at its origin — how a reply
|
|
61
|
+
// finds the inbox of the person it answers.
|
|
62
|
+
this.resolveActor = resolveActor;
|
|
60
63
|
// Per-poll rewrite windows, keyed by question id. See POLL_REWRITE_MS.
|
|
61
64
|
this.pollTimers = new Map();
|
|
62
65
|
this.log = log;
|
|
@@ -76,22 +79,23 @@ export class Publisher {
|
|
|
76
79
|
// republish button. Without it, an actor lost from the pod would match the
|
|
77
80
|
// digest, be skipped, and leave the agent reporting success while nobody can
|
|
78
81
|
// resolve it.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// The owner-only collections (FEP-4ccd, FEP-c648) are advertised only
|
|
84
|
-
// where "owner-only" is real — the pod must provably enforce the private
|
|
85
|
-
// container's ACL, the same bar private posts clear.
|
|
86
|
-
const priv = await this.privateReady() === true;
|
|
87
|
-
const moderators = (this.config.moderators || []).length ? urls.moderators : null;
|
|
88
|
-
// An advertised inbox gateway (config.gateway.mode past 'off') becomes the
|
|
89
|
-
// actor's inbox; deliveries reach the pod inbox through it. Absent → today.
|
|
82
|
+
// The Gateway's outbox door for this account, when a Gateway is attached:
|
|
83
|
+
// beside its inbox door, or under the fronted actor.
|
|
84
|
+
gatewayOutbox() {
|
|
90
85
|
const gw = this.config.gateway;
|
|
91
|
-
|
|
92
|
-
|
|
86
|
+
if (!(gw && gw.url && gw.mode && gw.mode !== 'off')) return null;
|
|
87
|
+
if (gw.frontActor) return String(gw.frontActor).replace(/ap\/actor\/?$/u, 'ap/outbox');
|
|
88
|
+
return String(gw.url).replace(/ap\/inbox\/?$/u, 'ap/outbox');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Every actor document this identity publishes, from one place: the
|
|
92
|
+
// profile publish and a Move both write it, and a Move that rebuilt it
|
|
93
|
+
// by hand dropped the gateway inbox, the outbox door and the collections.
|
|
94
|
+
actorDocFor({ priv = false, moderators = null, inbox = null, movedTo = this.config.movedTo || null } = {}) {
|
|
95
|
+
const { urls } = this;
|
|
96
|
+
return wire.actorDoc({
|
|
93
97
|
urls, handle: wire.publicHandle(this.config), name: this.config.name, publicKeyPem: this.publicKeyPem,
|
|
94
|
-
movedTo
|
|
98
|
+
movedTo, kind: this.config.kind,
|
|
95
99
|
approveJoins: wire.followsNeedApproval(this.config),
|
|
96
100
|
assertionKey: this.assertionKey,
|
|
97
101
|
summary: this.config.summary || null, icon: this.config.icon || null,
|
|
@@ -102,15 +106,32 @@ export class Publisher {
|
|
|
102
106
|
pendingFollowers: priv ? urls.pendingFollowers : null,
|
|
103
107
|
pendingFollowing: priv ? urls.pendingFollowing : null,
|
|
104
108
|
blocked: priv ? urls.blocked : null,
|
|
105
|
-
inbox
|
|
109
|
+
inbox,
|
|
106
110
|
// The agent's own outbox endpoint, where it is reachable: a client
|
|
107
111
|
// following the actor must arrive somewhere that will take a write.
|
|
108
|
-
|
|
112
|
+
// Otherwise the Gateway's outbox door, when one is attached.
|
|
113
|
+
outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : this.gatewayOutbox(),
|
|
109
114
|
// How a client-to-server client finds the way in with nothing configured
|
|
110
115
|
// by hand. Advertised only where the surface is publicly reachable.
|
|
111
116
|
oauthAuthorize: this.clientOrigin ? `${this.clientOrigin}oauth/authorize` : null,
|
|
112
117
|
oauthToken: this.clientOrigin ? `${this.clientOrigin}oauth/token` : null,
|
|
113
118
|
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async publishProfile({ force = false } = {}) {
|
|
122
|
+
const { urls } = this;
|
|
123
|
+
const host = new URL(urls.base).host;
|
|
124
|
+
|
|
125
|
+
// The owner-only collections (FEP-4ccd, FEP-c648) are advertised only
|
|
126
|
+
// where "owner-only" is real — the pod must provably enforce the private
|
|
127
|
+
// container's ACL, the same bar private posts clear.
|
|
128
|
+
const priv = await this.privateReady() === true;
|
|
129
|
+
const moderators = (this.config.moderators || []).length ? urls.moderators : null;
|
|
130
|
+
// An advertised inbox gateway (config.gateway.mode past 'off') becomes the
|
|
131
|
+
// actor's inbox; deliveries reach the pod inbox through it. Absent → today.
|
|
132
|
+
const gw = this.config.gateway;
|
|
133
|
+
const gwActive = gw && gw.url && gw.mode && gw.mode !== 'off';
|
|
134
|
+
const actorDoc = this.actorDocFor({ priv, moderators, inbox: gwActive ? gw.url : null });
|
|
114
135
|
const surface = crypto.createHash('sha256').update(JSON.stringify({
|
|
115
136
|
actor: actorDoc, handle: this.config.handle, host,
|
|
116
137
|
quiesced: !!this.config.quiescedAt, version: AGENT_VERSION,
|
|
@@ -122,7 +143,7 @@ export class Publisher {
|
|
|
122
143
|
}
|
|
123
144
|
|
|
124
145
|
await discovery.writeWebfinger(this.remote, urls,
|
|
125
|
-
wire.jrd({ handle: this.config.handle, host, actor: urls.actor }));
|
|
146
|
+
wire.jrd({ handle: this.config.handle, host, actor: urls.actor, page: urls.profileHtml }));
|
|
126
147
|
await discovery.writeHostMeta(this.remote, urls, wire.hostMeta(urls.base));
|
|
127
148
|
|
|
128
149
|
const nodeinfoDocUrl = urls.home + 'ap/nodeinfo-2.0';
|
|
@@ -163,6 +184,8 @@ export class Publisher {
|
|
|
163
184
|
actorUrl: urls.actor,
|
|
164
185
|
accountName: `@${this.config.handle}@${host}`,
|
|
165
186
|
kind: this.config.kind,
|
|
187
|
+
// Where a Solid client posts: dokieli reads `as:outbox` off the WebID.
|
|
188
|
+
outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : this.gatewayOutbox(),
|
|
166
189
|
});
|
|
167
190
|
if (wrote) this.log('WebID profile now lists the actor as a foaf:account');
|
|
168
191
|
} catch (e) {
|
|
@@ -346,15 +369,12 @@ export class Publisher {
|
|
|
346
369
|
await this.deliverer.deliverToAll(inboxes, wire.moveActivity(urls, target, Date.parse(at)));
|
|
347
370
|
this.config.movedTo = target; // so the republish below carries it
|
|
348
371
|
this.store.setConfig({ ...this.store.getConfig(), movedTo: target, movedAt: at });
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
image: this.config.image || null, fields: this.config.fields || [],
|
|
356
|
-
webId: this.remote.webId || null,
|
|
357
|
-
aliases: this.config.aliases || [],
|
|
372
|
+
const gw = this.config.gateway;
|
|
373
|
+
const gwActive = gw && gw.url && gw.mode && gw.mode !== 'off';
|
|
374
|
+
const priv = await this.privateReady() === true;
|
|
375
|
+
const moderators = (this.config.moderators || []).length ? urls.moderators : null;
|
|
376
|
+
await podActor.writeMoved(this.remote, urls, this.actorDocFor({
|
|
377
|
+
priv, moderators, inbox: gwActive ? gw.url : null, movedTo: target,
|
|
358
378
|
}));
|
|
359
379
|
await this.store.flush();
|
|
360
380
|
this.log(`moved to ${target}: Move sent to ${inboxes.length} inbox(es), actor now advertises movedTo`);
|
|
@@ -400,6 +420,7 @@ export class Publisher {
|
|
|
400
420
|
reconcileFollowers(...a) { return restore.reconcileFollowers(this, ...a); }
|
|
401
421
|
reconcileOutbox(...a) { return restore.reconcileOutbox(this, ...a); }
|
|
402
422
|
rebuildStatuses(...a) { return restore.rebuildStatuses(this, ...a); }
|
|
423
|
+
healStatuses(...a) { return restore.healStatuses(this, ...a); }
|
|
403
424
|
|
|
404
425
|
// notes.mjs
|
|
405
426
|
ensureMediaContainer(...a) { return notes.ensureMediaContainer(this, ...a); }
|
|
@@ -408,6 +429,7 @@ export class Publisher {
|
|
|
408
429
|
privateReady(...a) { return notes.privateReady(this, ...a); }
|
|
409
430
|
_mentionsFor(...a) { return notes.mentionsFor(this, ...a); }
|
|
410
431
|
publishNote(...a) { return notes.publishNote(this, ...a); }
|
|
432
|
+
publishObject(...a) { return notes.publishObject(this, ...a); }
|
|
411
433
|
updateNote(...a) { return notes.updateNote(this, ...a); }
|
|
412
434
|
|
|
413
435
|
// questions.mjs
|