fedipod 1.32.0 → 1.36.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/connections/bskyfeed.mjs +6 -0
- package/lib/core/deliver.mjs +52 -22
- package/lib/core/intake/index.mjs +17 -4
- package/lib/core/lease.mjs +7 -7
- package/lib/core/store.mjs +25 -5
- package/lib/gateway/caches.mjs +36 -0
- package/lib/gateway/front-core.mjs +29 -56
- package/lib/gateway/gateway-core.mjs +5 -2
- package/lib/gateway/headers.mjs +49 -0
- package/lib/gateway/quiet.mjs +7 -2
- package/lib/pod/containers.mjs +5 -1
- package/lib/pod/inbox.mjs +22 -3
- package/lib/pod/transport.mjs +36 -3
- package/lib/session/README.md +14 -0
- package/lib/session/fedi-account.mjs +62 -0
- package/lib/session/package.json +10 -2
- package/package.json +1 -1
- package/run-agent.mjs +16 -0
- package/web/app/agent.mjs +13 -8
- package/web/app/deliver-relay.mjs +47 -7
- package/web/app/dist/boot.js +36 -3
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +193 -43
- package/web/app/dist/sw.js.map +3 -3
- package/web/app/update.js +4 -0
package/lib/pod/inbox.mjs
CHANGED
|
@@ -85,13 +85,15 @@ export async function writeKeep(pod, urls) {
|
|
|
85
85
|
* those and not the others is an inbox that is open when it should be shut.
|
|
86
86
|
*/
|
|
87
87
|
export async function setPosture(pod, urls, posture) {
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
// Restated at every start, and most starts find it already so: the rule is
|
|
89
|
+
// read first and written only when it differs (transport.setAcl ifChanged).
|
|
90
|
+
if (posture === 'open') return pod.setAcl(urls.inbox, ['Append'], { ifChanged: true });
|
|
91
|
+
if (posture === 'closed') return pod.setAcl(urls.inbox, [], { ifChanged: true });
|
|
90
92
|
const webId = posture?.gatewayWebId;
|
|
91
93
|
if (!webId) throw new Error(`inbox.setPosture: unknown posture ${JSON.stringify(posture)}`);
|
|
92
94
|
// Public loses Append; the door keeps it. Both halves in one write, because
|
|
93
95
|
// between two writes the inbox is either open to everyone or shut to the door.
|
|
94
|
-
return pod.setAcl(urls.inbox, [], { appendAgents: [webId] });
|
|
96
|
+
return pod.setAcl(urls.inbox, [], { appendAgents: [webId], ifChanged: true });
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
/**
|
|
@@ -145,3 +147,20 @@ export async function readDeliveryReceipt(pod, itemUrl, { maxBytes, readCapped }
|
|
|
145
147
|
* @returns {Promise<boolean>} whether the pod removed it
|
|
146
148
|
*/
|
|
147
149
|
export const dropHandledItem = (pod, url) => pod.delete(url);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Remove the receipt a gateway wrote beside an item, once the item is gone.
|
|
153
|
+
* Best-effort: a receipt that stays is a stray document, never a lost
|
|
154
|
+
* delivery. Left behind, they were one stray per delivery, forever.
|
|
155
|
+
*/
|
|
156
|
+
export const dropReceiptBeside = (pod, url) => pod.delete(url + '.receipt.json').catch(() => false);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Receipts whose item is no longer in the inbox, from the last listing: what
|
|
160
|
+
* earlier drains left behind. The drain removes a few of these each sweep
|
|
161
|
+
* until none are left.
|
|
162
|
+
*/
|
|
163
|
+
export const orphanReceipts = (pod, urls) => pod.orphanReceipts?.(urls.inbox) ?? [];
|
|
164
|
+
|
|
165
|
+
/** Remove one such stray. @returns {Promise<boolean>} whether the pod removed it */
|
|
166
|
+
export const dropStrayReceipt = (pod, url) => pod.delete(url).catch(() => false);
|
package/lib/pod/transport.mjs
CHANGED
|
@@ -345,7 +345,31 @@ export class PodTransport {
|
|
|
345
345
|
const podTarget = this.toPod ? this.toPod(targetUrl) : targetUrl;
|
|
346
346
|
const url = await this.aclUrlFor(podTarget);
|
|
347
347
|
if (!await this.aclWritable(url)) return null;
|
|
348
|
-
|
|
348
|
+
const doc = this.aclDoc(podTarget, publicModes, { ...opts, aclUrl: url });
|
|
349
|
+
// `ifChanged`: read the rule the pod holds and write only when it differs.
|
|
350
|
+
// For a rule restated at every start — the inbox door — a read is the
|
|
351
|
+
// whole cost, where a write took the pod's lock to say the same thing.
|
|
352
|
+
if (opts.ifChanged && await this.aclSame(url, doc)) return { status: 304, unchanged: true };
|
|
353
|
+
return this.put(url, doc, 'text/turtle');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Whether the pod's rule at `aclUrl` states exactly what `doc` states.
|
|
357
|
+
// Compared as graphs, not bytes: the pod serialises what it holds its own
|
|
358
|
+
// way. Every rule this file writes names its subjects, so triple sets are
|
|
359
|
+
// enough; anything unreadable or with blank nodes reads as different.
|
|
360
|
+
async aclSame(aclUrl, doc) {
|
|
361
|
+
try {
|
|
362
|
+
const res = await this.fetch(aclUrl, { headers: { accept: 'text/turtle' } });
|
|
363
|
+
if (res.status !== 200) return false;
|
|
364
|
+
const triples = (text) => {
|
|
365
|
+
const g = $rdf.graph();
|
|
366
|
+
$rdf.parse(text, g, aclUrl, 'text/turtle');
|
|
367
|
+
if (g.statements.some((st) => st.subject.termType === 'BlankNode' || st.object.termType === 'BlankNode')) return null;
|
|
368
|
+
return g.statements.map((st) => `${st.subject.value} ${st.predicate.value} ${st.object.value}`).sort().join('\n');
|
|
369
|
+
};
|
|
370
|
+
const theirs = triples(await res.text());
|
|
371
|
+
return theirs !== null && theirs === triples(doc);
|
|
372
|
+
} catch { return false; }
|
|
349
373
|
}
|
|
350
374
|
|
|
351
375
|
// Child documents of an LDP container (URLs under it, excluding aux docs).
|
|
@@ -382,12 +406,14 @@ export class PodTransport {
|
|
|
382
406
|
$rdf.parse(body, g, url, 'text/turtle');
|
|
383
407
|
const here = $rdf.sym(url);
|
|
384
408
|
const seen = new Set();
|
|
409
|
+
const receipts = new Set();
|
|
385
410
|
const list = [];
|
|
386
411
|
for (const child of g.each(here, LDP('contains'), null, here)) {
|
|
387
412
|
const u = child.value;
|
|
388
413
|
// `.receipt.json` is a verification receipt a gateway wrote beside an
|
|
389
414
|
// inbox item — read with the item, never enumerated as an item itself.
|
|
390
|
-
if (
|
|
415
|
+
if (u.endsWith('.receipt.json')) { receipts.add(u); continue; }
|
|
416
|
+
if (!u.startsWith(url) || u === url || /\.(acl|meta)$/.test(u) || seen.has(u)) continue;
|
|
391
417
|
seen.add(u);
|
|
392
418
|
list.push({
|
|
393
419
|
url: u,
|
|
@@ -395,14 +421,21 @@ export class PodTransport {
|
|
|
395
421
|
modified: g.any(child, DC('modified'), null, here)?.value || null,
|
|
396
422
|
});
|
|
397
423
|
}
|
|
424
|
+
// Whether a receipt sits beside each item, so the drain asks for one only
|
|
425
|
+
// where there is one; and the receipts nothing sits beside any more.
|
|
426
|
+
for (const item of list) item.receipt = receipts.has(item.url + '.receipt.json');
|
|
427
|
+
const orphans = [...receipts].filter((r) => !seen.has(r.slice(0, -'.receipt.json'.length)));
|
|
398
428
|
// Oldest first. An LDP listing is a set, so without this a drain works in
|
|
399
429
|
// whatever order the graph happened to parse — a mention from last week
|
|
400
430
|
// after one from today, and no way to make progress predictable.
|
|
401
431
|
list.sort((a, b) => String(a.modified || '').localeCompare(String(b.modified || '')));
|
|
402
|
-
this._listCache.set(url, { etag: res.headers.get('etag'), children: list });
|
|
432
|
+
this._listCache.set(url, { etag: res.headers.get('etag'), children: list, orphans });
|
|
403
433
|
return list;
|
|
404
434
|
}
|
|
405
435
|
|
|
436
|
+
/** Receipts in the last listing of `url` whose item is gone. */
|
|
437
|
+
orphanReceipts(url) { return this._listCache?.get(url)?.orphans ?? []; }
|
|
438
|
+
|
|
406
439
|
/**
|
|
407
440
|
* The WebID profile advertises the actor as an account:
|
|
408
441
|
* <webId> foaf:account <actor> .
|
package/lib/session/README.md
CHANGED
|
@@ -18,6 +18,9 @@ Once signed in, the account gives you:
|
|
|
18
18
|
- **`post`** — publishes a post as them, public, unlisted or followers-only.
|
|
19
19
|
- **`reply`** — answers a post.
|
|
20
20
|
- **`timeline`** — their home timeline, newest first.
|
|
21
|
+
- **`outbox`** — their own posts, newest first. Pass `rdf: true` to also get
|
|
22
|
+
the real thing as RDF, for an app that wants to work with it as linked
|
|
23
|
+
data rather than as this library's own shape.
|
|
21
24
|
- **`follow`** — follows someone by handle.
|
|
22
25
|
- **`favourite`**, **`boost`** — as they say.
|
|
23
26
|
- **`profile`** — who they are as their server or pod shows them: name,
|
|
@@ -37,6 +40,8 @@ DeviceAgent or Server account, and when fedipod.net is next open for a
|
|
|
37
40
|
browser-based one.
|
|
38
41
|
|
|
39
42
|
Three files, no dependencies. It runs in a page and in a service worker.
|
|
43
|
+
The one exception: `outbox({ rdf: true })` needs the `jsonld` package, and
|
|
44
|
+
only loads it when that flag is actually used.
|
|
40
45
|
|
|
41
46
|
[The demo](https://jeff-zucker.github.io/FediPod/) shows the sign-in and
|
|
42
47
|
the profile that comes back.
|
|
@@ -73,6 +78,7 @@ const me = await accounts.current(); // null when nobody i
|
|
|
73
78
|
if (me?.notice) show(me.notice);
|
|
74
79
|
await me.post({ text: 'Hello from my app' });
|
|
75
80
|
for (const p of await me.timeline({ limit: 20 })) render(p);
|
|
81
|
+
for (const p of await me.outbox({ limit: 20 })) render(p);
|
|
76
82
|
await me.follow('@aisha@her.server');
|
|
77
83
|
await me.reply(p.url, 'Well said');
|
|
78
84
|
await me.signOut();
|
|
@@ -93,6 +99,14 @@ await me.signOut();
|
|
|
93
99
|
- **`reply(post, text)`** — the post is named by its address or its id.
|
|
94
100
|
- **`timeline({ limit })`** — each post as `{ id, url, author: { id, handle,
|
|
95
101
|
name }, html, published, inReplyTo }`.
|
|
102
|
+
- **`outbox({ limit, rdf })`** — the same shape as `timeline`, but only this
|
|
103
|
+
account's own posts. With `rdf: true`, the returned array also carries
|
|
104
|
+
`.rdf`: the real outbox parsed into an RDF/JS quad array — for a Mastodon
|
|
105
|
+
account too, since a Mastodon server is itself an ActivityPub server with
|
|
106
|
+
a real actor and outbox, same as a pod's. It comes back empty on a server
|
|
107
|
+
that requires a signed request just to read that document, which some
|
|
108
|
+
do. Reading it needs the `jsonld` package available to your app; without
|
|
109
|
+
`rdf: true` nothing changes and nothing extra loads.
|
|
96
110
|
- **`fetch`** — the raw authenticated fetch, for anything the above does not
|
|
97
111
|
cover.
|
|
98
112
|
- **`actor`** — the account's ActivityPub id.
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
// await me.post({ text }); await me.timeline({ limit: 20 })
|
|
16
16
|
// await me.reply(postUrlOrId, text); await me.follow('@aisha@her.server')
|
|
17
17
|
// await me.favourite(postUrlOrId); await me.boost(postUrlOrId)
|
|
18
|
+
// await me.outbox({ limit: 20 }); // this account's own posts, newest first
|
|
19
|
+
// await me.outbox({ rdf: true }); // the same, plus the real RDF graph on .rdf
|
|
18
20
|
// await me.signOut()
|
|
19
21
|
//
|
|
20
22
|
// A Mastodon account is used through its server's API with a token the
|
|
@@ -58,6 +60,35 @@ export function fediAccount({
|
|
|
58
60
|
return body;
|
|
59
61
|
};
|
|
60
62
|
|
|
63
|
+
// Only loaded when `outbox({ rdf: true })` is actually called, so nobody
|
|
64
|
+
// pays for a JSON-LD parser just by importing this file. Not in
|
|
65
|
+
// `dependencies` — the app supplies "jsonld" if it wants this flag to work.
|
|
66
|
+
const loadJsonLd = async () => {
|
|
67
|
+
try { return (await import('jsonld')).default; }
|
|
68
|
+
catch { throw new Error('outbox({ rdf: true }) needs the "jsonld" package available to the app — it is not bundled with this library'); }
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// The account's real outbox, read from the pod: the head collection names
|
|
72
|
+
// `first`, each page names `orderedItems` and (while there is more) `next`.
|
|
73
|
+
// Walked only far enough to cover `limit`, and run through jsonld.toRDF so
|
|
74
|
+
// the result is a genuine RDF/JS quad array, not this library's own shape.
|
|
75
|
+
const fetchOutboxRdf = async (actorUrl, limit) => {
|
|
76
|
+
if (!actorUrl) return null;
|
|
77
|
+
const jsonld = await loadJsonLd();
|
|
78
|
+
const actorDoc = await json(actorUrl, { headers: { accept: 'application/activity+json' } });
|
|
79
|
+
let pageUrl = actorDoc?.outbox ? (await json(actorDoc.outbox, { headers: { accept: 'application/activity+json' } }))?.first : null;
|
|
80
|
+
const quads = []; const seen = new Set(); let collected = 0;
|
|
81
|
+
while (pageUrl && collected < limit && !seen.has(pageUrl)) {
|
|
82
|
+
seen.add(pageUrl);
|
|
83
|
+
const page = await json(pageUrl, { headers: { accept: 'application/activity+json' } });
|
|
84
|
+
if (!page) break;
|
|
85
|
+
quads.push(...(await jsonld.toRDF(page)));
|
|
86
|
+
collected += (page.orderedItems || []).length;
|
|
87
|
+
pageUrl = page.next || null;
|
|
88
|
+
}
|
|
89
|
+
return quads;
|
|
90
|
+
};
|
|
91
|
+
|
|
61
92
|
// ---- finding out what an address is ----
|
|
62
93
|
|
|
63
94
|
// Does this host speak the Mastodon API? Any server of that family says so
|
|
@@ -255,6 +286,20 @@ export function fediAccount({
|
|
|
255
286
|
const list = await call(`/api/v1/timelines/home?limit=${Math.min(40, limit)}`).then((r) => said(r, a.host));
|
|
256
287
|
return (Array.isArray(list) ? list : []).map(item);
|
|
257
288
|
},
|
|
289
|
+
// This account's own posts, newest first — Mastodon's equivalent of an
|
|
290
|
+
// outbox. A Mastodon server IS an ActivityPub server, so its actor and
|
|
291
|
+
// outbox are real AS2/JSON-LD too, same as a pod's; `rdf: true` reads
|
|
292
|
+
// that, not the REST API above. It comes back empty on an instance that
|
|
293
|
+
// requires a signed request just to read the public actor document
|
|
294
|
+
// (some do — mastodon.social among them); `fetchOutboxRdf`'s plain GET
|
|
295
|
+
// then gets 401 and the loop below finds nothing to walk.
|
|
296
|
+
async outbox({ limit = 20, rdf = false } = {}) {
|
|
297
|
+
const me = await call('/api/v1/accounts/verify_credentials').then((r) => said(r, a.host));
|
|
298
|
+
const list = await call(`/api/v1/accounts/${me.id}/statuses?limit=${Math.min(40, limit)}`).then((r) => said(r, a.host));
|
|
299
|
+
const items = (Array.isArray(list) ? list : []).map(item);
|
|
300
|
+
if (rdf) items.rdf = await fetchOutboxRdf(a.actor, limit);
|
|
301
|
+
return items;
|
|
302
|
+
},
|
|
258
303
|
async follow(handle) {
|
|
259
304
|
const who = parseAddress(handle);
|
|
260
305
|
if (!who?.at) throw new Error('a handle looks like @you@your.server');
|
|
@@ -314,6 +359,23 @@ export function fediAccount({
|
|
|
314
359
|
.slice(0, limit)
|
|
315
360
|
.map((s) => ({ id: s.noteId, url: s.noteId, published: s.published || null, author: who(s.actor), html: s.content || '', inReplyTo: s.inReplyTo || null }));
|
|
316
361
|
},
|
|
362
|
+
// This account's own posts, newest first, read from the same local
|
|
363
|
+
// record `timeline` uses (fast, no network call). With `rdf: true`,
|
|
364
|
+
// also fetches the real outbox from the pod and parses it into RDF/JS
|
|
365
|
+
// quads on the returned array's `.rdf` — a second, live document, not
|
|
366
|
+
// derived from the local record, so it needs `jsonld` (see loadJsonLd).
|
|
367
|
+
async outbox({ limit = 20, rdf = false } = {}) {
|
|
368
|
+
const rows = await state('statuses.json');
|
|
369
|
+
const actors = (await state('actors.json')) || {};
|
|
370
|
+
const who = (id) => { const d = actors[id]?.doc || actors[id] || {}; return { id, handle: d.preferredUsername ? `@${d.preferredUsername}@${new URL(id).host}` : null, name: d.name || null }; };
|
|
371
|
+
const items = (Array.isArray(rows) ? rows : [])
|
|
372
|
+
.filter((s) => s.kind === 'post')
|
|
373
|
+
.sort((x, y) => String(y.published || '').localeCompare(String(x.published || '')))
|
|
374
|
+
.slice(0, limit)
|
|
375
|
+
.map((s) => ({ id: s.noteId, url: s.noteId, published: s.published || null, author: who(s.actor), html: s.content || '', inReplyTo: s.inReplyTo || null }));
|
|
376
|
+
if (rdf) items.rdf = await fetchOutboxRdf(facts.actor, limit);
|
|
377
|
+
return items;
|
|
378
|
+
},
|
|
317
379
|
async follow(handle) {
|
|
318
380
|
const who = parseAddress(handle);
|
|
319
381
|
if (!who?.at) throw new Error('a handle looks like @you@your.server');
|
package/lib/session/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fediverse-account",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Type a Fediverse handle or a WebID and get back an account you can act with: post, read its timeline, follow, reply, favourite, boost. Works out whether the account lives on a Mastodon-family server or on a Solid pod through FediPod, sends the person to sign in there, brings them back to where they were, and speaks to whichever it is.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,5 +37,13 @@
|
|
|
37
37
|
"scripts": {
|
|
38
38
|
"prepublishOnly": "node ../../scripts/check-pod-calls.mjs"
|
|
39
39
|
},
|
|
40
|
-
"dependencies": {}
|
|
40
|
+
"dependencies": {},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"jsonld": "^9.0.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"jsonld": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
}
|
|
41
49
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.36.6",
|
|
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
|
@@ -53,6 +53,7 @@ import { exposureProblem, hostLabel } from './lib/shared/guard.mjs';
|
|
|
53
53
|
import { pendingSteps } from './lib/device/migrate.mjs';
|
|
54
54
|
import { apUrls, assertionKeyId , publicHandle } from './lib/core/wire.mjs';
|
|
55
55
|
import { followActor, unfollowActor, resolveHandle } from './lib/core/social.mjs';
|
|
56
|
+
import * as podInbox from './lib/pod/inbox.mjs';
|
|
56
57
|
|
|
57
58
|
export class Agent {
|
|
58
59
|
constructor({ home, log }) {
|
|
@@ -510,6 +511,19 @@ export class Agent {
|
|
|
510
511
|
return true;
|
|
511
512
|
}
|
|
512
513
|
|
|
514
|
+
// The inbox's door must be open to the world (or to the gateway alone, in
|
|
515
|
+
// locked mode) or nothing anyone sends ever lands. The permission is written
|
|
516
|
+
// at setup, and a container made again later — a root move, a pod rebuilt —
|
|
517
|
+
// came back without it: the group's inbox on fp2 refused every delivery for
|
|
518
|
+
// nine days while its agent ran on, healthy-looking. One write per start,
|
|
519
|
+
// and it is idempotent.
|
|
520
|
+
async ensureInboxOpen() {
|
|
521
|
+
const g = this.store.getConfig()?.gateway;
|
|
522
|
+
const posture = g?.mode === 'locked' && g.webId ? { gatewayWebId: g.webId } : 'open';
|
|
523
|
+
await podInbox.setPosture(this.remote, this.urls, posture);
|
|
524
|
+
return posture;
|
|
525
|
+
}
|
|
526
|
+
|
|
513
527
|
// Read-only mode: refresh the state cache periodically, and take over the
|
|
514
528
|
// moment the active agent's lease frees.
|
|
515
529
|
startViewer() {
|
|
@@ -607,6 +621,8 @@ export class Agent {
|
|
|
607
621
|
.catch(e => this.log(`actor check failed: ${e.message}`));
|
|
608
622
|
this.ensureFeaturedPublished()
|
|
609
623
|
.catch(e => this.log(`featured check failed: ${e.message}`));
|
|
624
|
+
this.ensureInboxOpen()
|
|
625
|
+
.catch(e => this.log(`inbox door check failed: ${e.message}`));
|
|
610
626
|
// The human page, rewritten only when what it shows has changed: one
|
|
611
627
|
// local digest compare, and a write the first time after an upgrade.
|
|
612
628
|
this.publisher.publishProfilePage()
|
package/web/app/agent.mjs
CHANGED
|
@@ -169,12 +169,14 @@ export class BrowserAgent {
|
|
|
169
169
|
this.lease.onLost = () => this.demote();
|
|
170
170
|
this.lease.startRenewal();
|
|
171
171
|
try {
|
|
172
|
-
// Forced, not revalidated
|
|
173
|
-
// writing
|
|
174
|
-
// the store is write-through — so the first write from here
|
|
175
|
-
// whole document back over newer state.
|
|
176
|
-
//
|
|
177
|
-
|
|
172
|
+
// Forced, not revalidated, when this device WATCHED first: the active
|
|
173
|
+
// one was writing, our cache is however stale the last viewer poll left
|
|
174
|
+
// it, and the store is write-through — so the first write from here
|
|
175
|
+
// would push a whole document back over newer state. A device that
|
|
176
|
+
// booted straight into acting read everything a moment ago; asking
|
|
177
|
+
// again is one revalidation, not a second download of every document.
|
|
178
|
+
await this.store.load({ force: !!this._watched }).catch((e) => this.log(`re-reading state: ${e.message}`));
|
|
179
|
+
this._watched = false;
|
|
178
180
|
// Own posts the outbox names and the timeline index lacks come back
|
|
179
181
|
// here, before anything acts on the index.
|
|
180
182
|
await this.publisher.healStatuses().catch((e) => this.log(`healing the timeline index: ${e.message}`));
|
|
@@ -202,6 +204,7 @@ export class BrowserAgent {
|
|
|
202
204
|
demote() {
|
|
203
205
|
if (this.viewer) return;
|
|
204
206
|
this.viewer = true;
|
|
207
|
+
this._watched = true;
|
|
205
208
|
this.log('another device took over — read-only here');
|
|
206
209
|
this.lease.stopRenewal();
|
|
207
210
|
clearInterval(this._openTimer); this._openTimer = null;
|
|
@@ -355,8 +358,9 @@ export class BrowserAgent {
|
|
|
355
358
|
// somebody reads from counting as a quiet one. Only a closed address
|
|
356
359
|
// stops the boot; a gateway that cannot be reached is no reason to
|
|
357
360
|
// refuse a sign-in.
|
|
358
|
-
|
|
359
|
-
|
|
361
|
+
// At the page's own origin, as the relay is: a page on the test alias
|
|
362
|
+
// asked the real site instead and could not get past its preflight.
|
|
363
|
+
this.gatewayApi = config.gateway?.url ? `${frontOrigin.replace(/\/$/, '')}/api` : null;
|
|
360
364
|
this.doorKey = doorKeyOf(config.gateway?.url) || config.handle;
|
|
361
365
|
this.gatewayStanding = null;
|
|
362
366
|
const standing = await this.openAtGateway();
|
|
@@ -476,6 +480,7 @@ export class BrowserAgent {
|
|
|
476
480
|
// The lease decides: this device ACTS on the pod, or reads it read-only.
|
|
477
481
|
this.viewer = !(await this.lease.acquire());
|
|
478
482
|
if (this.viewer) {
|
|
483
|
+
this._watched = true;
|
|
479
484
|
this.log(`read-only viewer: another device is active on @${config.handle}`);
|
|
480
485
|
this.startViewerPoll(); // reload the feed, and promote if the lease frees
|
|
481
486
|
return;
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
import { Deliverer } from '../../lib/core/deliver.mjs';
|
|
11
11
|
import { sign } from './shims/fedify-sig.mjs';
|
|
12
12
|
|
|
13
|
+
// What one relay call may carry (lib/gateway/front-core.mjs RELAY_MAX_REQUESTS).
|
|
14
|
+
const RELAY_MAX_REQUESTS = 20;
|
|
15
|
+
|
|
13
16
|
/**
|
|
14
17
|
* The name the front keys this account's row by, read off its door inbox:
|
|
15
18
|
* `<front>/u/<key>/ap/inbox/`. A mail-door account is keyed by its full
|
|
@@ -30,6 +33,7 @@ export class RelayDeliverer extends Deliverer {
|
|
|
30
33
|
this.relayUrl = opts.relayUrl; // <front>/api/relay
|
|
31
34
|
this.handle = opts.handle;
|
|
32
35
|
this.sessionFetch = opts.sessionFetch; // the DPoP session's fetch, to authenticate to the relay
|
|
36
|
+
this.batchSize = RELAY_MAX_REQUESTS;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
// Same contract as Deliverer.signedFetch, DEFAULT INCLUDED: an init with no
|
|
@@ -40,32 +44,68 @@ export class RelayDeliverer extends Deliverer {
|
|
|
40
44
|
// never saw the document it asked for: a Follow from anyone new was rejected
|
|
41
45
|
// with "actor fetch failed", and nothing needing a lookup could be ingested.
|
|
42
46
|
async signedFetch(url, init = {}) {
|
|
47
|
+
const req = await this._signedRequest(url, init);
|
|
48
|
+
const [r0] = await this._relay([req]);
|
|
49
|
+
return this._outcome(r0, url, init.method || 'GET');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A fan-out in one call: the relay takes a list, so a post to twenty
|
|
53
|
+
// followers is one call, not twenty (Deliverer.deliverToAll, batchSize).
|
|
54
|
+
async deliverManyNow(targets) {
|
|
55
|
+
const reqs = await Promise.all(targets.map((t) => this._signedRequest(t.inbox, {
|
|
56
|
+
method: 'POST', headers: { 'content-type': 'application/activity+json' }, body: JSON.stringify(t.activity),
|
|
57
|
+
})));
|
|
58
|
+
let results;
|
|
59
|
+
try { results = await this._relay(reqs); } catch (error) { return targets.map(() => ({ error })); }
|
|
60
|
+
return targets.map((t, i) => {
|
|
61
|
+
try { this._outcome(results[i] || {}, t.inbox, 'POST'); return { ok: true }; }
|
|
62
|
+
catch (error) { return { error }; }
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Signed here, sent verbatim by the relay. Every signed header goes along,
|
|
67
|
+
// `accept` included: the signature covers it, so a relay request missing it
|
|
68
|
+
// carries an invalid signature — and a read without it gets the HTML page
|
|
69
|
+
// instead of the document.
|
|
70
|
+
async _signedRequest(url, init = {}) {
|
|
43
71
|
const body = typeof init.body === 'string' ? init.body : (init.body ? new TextDecoder().decode(init.body) : '');
|
|
44
72
|
const s = await sign({ url, method: init.method || 'GET', headers: init.headers || {}, body }, this.rsaPrivate, this.keyId);
|
|
45
|
-
|
|
46
|
-
// covers it, so a relay request missing it carries an invalid signature —
|
|
47
|
-
// and a read without it gets the HTML page instead of the document.
|
|
48
|
-
const relayReq = {
|
|
73
|
+
return {
|
|
49
74
|
url: s.url, method: s.method, body,
|
|
50
75
|
headers: {
|
|
51
76
|
date: s.headers.date, digest: s.headers.digest, accept: s.headers.accept,
|
|
52
77
|
'content-type': s.headers['content-type'], signature: s.headers.signature,
|
|
53
78
|
},
|
|
54
79
|
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// One relay call for a list of requests; the results in the same order.
|
|
83
|
+
// The relay's OWN answer, apart from the recipients': unreachable and a
|
|
84
|
+
// refusal are hiccups the queue retries. Its 404 is not — it says this
|
|
85
|
+
// account has no row here, and no retry changes that. It used to be read as
|
|
86
|
+
// a hiccup too, and a tab whose account the site did not know retried its
|
|
87
|
+
// deliveries every minute for three days.
|
|
88
|
+
async _relay(requests) {
|
|
55
89
|
let res;
|
|
56
90
|
try {
|
|
57
91
|
res = await this.sessionFetch(this.relayUrl, {
|
|
58
92
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
59
|
-
body: JSON.stringify({ handle: this.handle, requests
|
|
93
|
+
body: JSON.stringify({ handle: this.handle, requests }),
|
|
60
94
|
});
|
|
61
95
|
} catch (e) { const err = new Error(`relay unreachable: ${e.message}`); err.status = 0; throw err; }
|
|
96
|
+
if (res.status === 404) { const err = new Error('relay: no such account here'); err.status = 404; throw err; }
|
|
62
97
|
if (res.status >= 400) { const err = new Error(`relay ${res.status}`); err.status = 502; throw err; }
|
|
63
98
|
const out = await res.json().catch(() => ({}));
|
|
64
|
-
|
|
99
|
+
return Array.isArray(out.results) ? out.results : [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// What the far server answered, as the Node deliverer would have seen it:
|
|
103
|
+
// a Response for a read, a thrown error carrying the status for a refusal.
|
|
104
|
+
_outcome(r0, url, method) {
|
|
65
105
|
const status = r0.status || 0;
|
|
66
106
|
if (status === 0) { const err = new Error(r0.error || 'relay could not send'); err.status = 502; throw err; }
|
|
67
107
|
if (status >= 400) {
|
|
68
|
-
const err = new Error(`${
|
|
108
|
+
const err = new Error(`${method} ${url} → ${status}`); err.status = status;
|
|
69
109
|
// The receiving server's own answer to "when should I try again", carried
|
|
70
110
|
// through the relay (lib/front-core.mjs). Spelled `retryAfterMs`, which is
|
|
71
111
|
// what the delivery queue reads (lib/deliver.mjs) — the queue's ladder is
|
package/web/app/dist/boot.js
CHANGED
|
@@ -32822,7 +32822,29 @@ var PodTransport = class {
|
|
|
32822
32822
|
const podTarget = this.toPod ? this.toPod(targetUrl) : targetUrl;
|
|
32823
32823
|
const url = await this.aclUrlFor(podTarget);
|
|
32824
32824
|
if (!await this.aclWritable(url)) return null;
|
|
32825
|
-
|
|
32825
|
+
const doc = this.aclDoc(podTarget, publicModes, { ...opts, aclUrl: url });
|
|
32826
|
+
if (opts.ifChanged && await this.aclSame(url, doc)) return { status: 304, unchanged: true };
|
|
32827
|
+
return this.put(url, doc, "text/turtle");
|
|
32828
|
+
}
|
|
32829
|
+
// Whether the pod's rule at `aclUrl` states exactly what `doc` states.
|
|
32830
|
+
// Compared as graphs, not bytes: the pod serialises what it holds its own
|
|
32831
|
+
// way. Every rule this file writes names its subjects, so triple sets are
|
|
32832
|
+
// enough; anything unreadable or with blank nodes reads as different.
|
|
32833
|
+
async aclSame(aclUrl, doc) {
|
|
32834
|
+
try {
|
|
32835
|
+
const res = await this.fetch(aclUrl, { headers: { accept: "text/turtle" } });
|
|
32836
|
+
if (res.status !== 200) return false;
|
|
32837
|
+
const triples = (text) => {
|
|
32838
|
+
const g = graph();
|
|
32839
|
+
parse2(text, g, aclUrl, "text/turtle");
|
|
32840
|
+
if (g.statements.some((st2) => st2.subject.termType === "BlankNode" || st2.object.termType === "BlankNode")) return null;
|
|
32841
|
+
return g.statements.map((st2) => `${st2.subject.value} ${st2.predicate.value} ${st2.object.value}`).sort().join("\n");
|
|
32842
|
+
};
|
|
32843
|
+
const theirs = triples(await res.text());
|
|
32844
|
+
return theirs !== null && theirs === triples(doc);
|
|
32845
|
+
} catch {
|
|
32846
|
+
return false;
|
|
32847
|
+
}
|
|
32826
32848
|
}
|
|
32827
32849
|
// Child documents of an LDP container (URLs under it, excluding aux docs).
|
|
32828
32850
|
// Revalidated: the inbox is polled every couple of minutes and is usually
|
|
@@ -32846,10 +32868,15 @@ var PodTransport = class {
|
|
|
32846
32868
|
parse2(body, g, url, "text/turtle");
|
|
32847
32869
|
const here = namedNode2(url);
|
|
32848
32870
|
const seen = /* @__PURE__ */ new Set();
|
|
32871
|
+
const receipts = /* @__PURE__ */ new Set();
|
|
32849
32872
|
const list = [];
|
|
32850
32873
|
for (const child of g.each(here, LDP("contains"), null, here)) {
|
|
32851
32874
|
const u = child.value;
|
|
32852
|
-
if (
|
|
32875
|
+
if (u.endsWith(".receipt.json")) {
|
|
32876
|
+
receipts.add(u);
|
|
32877
|
+
continue;
|
|
32878
|
+
}
|
|
32879
|
+
if (!u.startsWith(url) || u === url || /\.(acl|meta)$/.test(u) || seen.has(u)) continue;
|
|
32853
32880
|
seen.add(u);
|
|
32854
32881
|
list.push({
|
|
32855
32882
|
url: u,
|
|
@@ -32857,10 +32884,16 @@ var PodTransport = class {
|
|
|
32857
32884
|
modified: g.any(child, DC("modified"), null, here)?.value || null
|
|
32858
32885
|
});
|
|
32859
32886
|
}
|
|
32887
|
+
for (const item of list) item.receipt = receipts.has(item.url + ".receipt.json");
|
|
32888
|
+
const orphans = [...receipts].filter((r) => !seen.has(r.slice(0, -".receipt.json".length)));
|
|
32860
32889
|
list.sort((a, b) => String(a.modified || "").localeCompare(String(b.modified || "")));
|
|
32861
|
-
this._listCache.set(url, { etag: res.headers.get("etag"), children: list });
|
|
32890
|
+
this._listCache.set(url, { etag: res.headers.get("etag"), children: list, orphans });
|
|
32862
32891
|
return list;
|
|
32863
32892
|
}
|
|
32893
|
+
/** Receipts in the last listing of `url` whose item is gone. */
|
|
32894
|
+
orphanReceipts(url) {
|
|
32895
|
+
return this._listCache?.get(url)?.orphans ?? [];
|
|
32896
|
+
}
|
|
32864
32897
|
/**
|
|
32865
32898
|
* The WebID profile advertises the actor as an account:
|
|
32866
32899
|
* <webId> foaf:account <actor> .
|