fedipod 0.14.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/admin.mjs +43 -5
- package/lib/c2s.mjs +104 -4
- package/lib/embed.mjs +6 -1
- package/lib/front-core.mjs +27 -4
- package/lib/intake.mjs +21 -2
- package/lib/links.mjs +35 -0
- package/lib/mastoapi.mjs +197 -11
- package/lib/publisher.mjs +12 -1
- package/lib/remote.mjs +119 -3
- package/lib/setup.mjs +56 -0
- package/lib/storage.mjs +8 -2
- package/lib/wire.mjs +18 -3
- package/package.json +1 -1
- package/run-agent.mjs +4 -0
- package/scripts/pin-solid-oidc.mjs +68 -0
- package/web/admin/setup/index.html +6 -1
- package/web/admin/setup/setup.js +34 -8
- package/web/front/admin.html +21 -17
- package/web/front/run.html +18 -15
- package/web/front/solid-oidc-client.js +6 -0
- package/web/front/solid-client-authn.bundle.js +0 -2
package/lib/admin.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { identityHomes, rootOf, tildify, defaultProfile, writeJsonAtomic } from
|
|
|
27
27
|
import { copyPrivateHalf, isCurrent, CURRENT_LAYOUT } from './migrate.mjs';
|
|
28
28
|
import { normalizeImport, IMPORT_KINDS } from './import.mjs';
|
|
29
29
|
import { insecureUrlReason } from './safefetch.mjs';
|
|
30
|
-
import { newRun, preflight, runSetup, setupInputError, hasCredential } from './setup.mjs';
|
|
30
|
+
import { newRun, preflight, runSetup, setupInputError, hasCredential, credentialPath } from './setup.mjs';
|
|
31
31
|
import { portFree, freePortFrom } from './ports.mjs';
|
|
32
32
|
import { claimDirectory, yieldDirectory } from './directory.mjs';
|
|
33
33
|
import { localFetch } from './localapi.mjs';
|
|
@@ -112,7 +112,7 @@ const AGENT_VERSION = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package
|
|
|
112
112
|
// /shutdown is here because stopping an agent that was never set up is exactly
|
|
113
113
|
// the case it exists for; it is in LOCAL_ONLY_POSTS below, so it still answers
|
|
114
114
|
// only to this machine.
|
|
115
|
-
const OPEN_POSTS = new Set(['/block', '/unblock', '/setup', '/setup/check', '/shutdown']);
|
|
115
|
+
const OPEN_POSTS = new Set(['/block', '/unblock', '/setup', '/setup/check', '/setup/reset', '/shutdown']);
|
|
116
116
|
|
|
117
117
|
// The page the other server's redirect lands on. Self-contained on purpose:
|
|
118
118
|
// the browser arrives here from somewhere else, and nothing may load from
|
|
@@ -147,11 +147,11 @@ function callbackPage(ok, msg) {
|
|
|
147
147
|
// server there is no such process and no such machine: identities come from
|
|
148
148
|
// the server's own configuration, so these are not there to be found.
|
|
149
149
|
const EMBEDDED_CUT = new Set(['/profiles', '/shutdown', '/new-actor', '/start-actor',
|
|
150
|
-
'/state-move', '/setup', '/setup/check']);
|
|
150
|
+
'/state-move', '/setup', '/setup/check', '/setup/reset']);
|
|
151
151
|
// AP_ALLOWED_HOSTS may name a tailnet host or a reverse-proxy domain. The
|
|
152
152
|
// fediverse is welcome there; creating accounts and editing the record is for
|
|
153
153
|
// whoever is sitting at the machine.
|
|
154
|
-
const LOCAL_ONLY_POSTS = new Set(['/setup', '/setup/check', '/config', '/new-actor', '/start-actor', '/shutdown', '/state-move', '/atproto/connect', '/fediacct/connect', '/fediacct/disconnect', '/fediacct', '/gateway', '/alias', '/import', '/update']);
|
|
154
|
+
const LOCAL_ONLY_POSTS = new Set(['/setup', '/setup/check', '/setup/reset', '/config', '/new-actor', '/start-actor', '/shutdown', '/state-move', '/atproto/connect', '/fediacct/connect', '/fediacct/disconnect', '/fediacct', '/gateway', '/alias', '/import', '/update']);
|
|
155
155
|
// The identity itself. Changing any of these means a different actor at a
|
|
156
156
|
// different address, which is a new setup, not an edit.
|
|
157
157
|
const PERMANENT_CONFIG = ['handle', 'remotePod', 'issuer', 'root', 'kind'];
|
|
@@ -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;
|
|
@@ -848,6 +858,34 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
848
858
|
}
|
|
849
859
|
// ---- setup, driven by the page at /admin/setup/ ----
|
|
850
860
|
case '/setup/check': return json(res, 200, preflight(body));
|
|
861
|
+
// Discard a credential that never finished setup, so the account and
|
|
862
|
+
// pod can be entered again. The credential a CSS server mints is shown
|
|
863
|
+
// once, so a setup that stops after the mint (a wrong pod answers 401
|
|
864
|
+
// to the first write) leaves the form in "finish" mode with no way to
|
|
865
|
+
// re-enter what was wrong. This removes it locally and reopens the full
|
|
866
|
+
// form. It does NOT revoke server-side — that needs the account
|
|
867
|
+
// password (`fedipod revoke-credential`); the old credential is left on
|
|
868
|
+
// the account, revocable from its dashboard.
|
|
869
|
+
case '/setup/reset': {
|
|
870
|
+
if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
|
|
871
|
+
// A working identity is never swapped out this way — that is a
|
|
872
|
+
// teardown (`fedipod retire`), not a half-finished setup.
|
|
873
|
+
if (agent.configured()) {
|
|
874
|
+
return json(res, 409, { error: 'this home holds a working identity — retire it, do not reset' });
|
|
875
|
+
}
|
|
876
|
+
if (setupRun?.phase === 'running') {
|
|
877
|
+
return json(res, 409, { error: 'setup is running — let it finish or stop it first', phase: 'running' });
|
|
878
|
+
}
|
|
879
|
+
const home = agent.home;
|
|
880
|
+
if (!home) return json(res, 500, { error: 'this agent has no AP_HOME to reset' });
|
|
881
|
+
const removed = hasCredential(home);
|
|
882
|
+
if (removed) fs.rmSync(credentialPath(home), { force: true });
|
|
883
|
+
// Drop the pod handle too, so configured() cannot flicker true off a
|
|
884
|
+
// stale in-memory session while the fresh form is filled in.
|
|
885
|
+
agent.remote = null;
|
|
886
|
+
setupRun = null;
|
|
887
|
+
return json(res, 200, { ok: true, removed });
|
|
888
|
+
}
|
|
851
889
|
case '/setup': {
|
|
852
890
|
// A visited page must not be able to navigate this into existence.
|
|
853
891
|
if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
|
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;
|
package/lib/embed.mjs
CHANGED
|
@@ -210,5 +210,10 @@ export async function startEmbeddedAgent({
|
|
|
210
210
|
]);
|
|
211
211
|
};
|
|
212
212
|
|
|
213
|
-
|
|
213
|
+
// podHome and actorUrl are the identity's own locations on the pod. They are
|
|
214
|
+
// returned rather than rebuilt by the caller so the root name lives here.
|
|
215
|
+
return {
|
|
216
|
+
agent, handle, home, surface, host: authorities.host,
|
|
217
|
+
podHome: urls.home, actorUrl: urls.actor, stop,
|
|
218
|
+
};
|
|
214
219
|
}
|
package/lib/front-core.mjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import crypto from 'node:crypto';
|
|
19
19
|
import { handleDelivery } from './gateway-core.mjs';
|
|
20
20
|
import { readCapped } from './safefetch.mjs';
|
|
21
|
+
import { linkTargets, REL } from './links.mjs';
|
|
21
22
|
|
|
22
23
|
// The one WebFinger document, spelled out here rather than imported from
|
|
23
24
|
// wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
|
|
@@ -48,6 +49,24 @@ async function verifyPodToken(request, pathname, verifier) {
|
|
|
48
49
|
// Is this WebID served by the claimed pod? A pod owner's WebID lives on the pod
|
|
49
50
|
// origin — that is the whole proof: a token for a WebID under podHome could
|
|
50
51
|
// only be minted by someone who controls that pod's identity provider.
|
|
52
|
+
// A pod that will not answer must not hold up the person opting in; without
|
|
53
|
+
// an answer the older check stands on its own.
|
|
54
|
+
const OWNER_LOOKUP_MS = 5_000;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Who the pod server says owns the pod. The server that hosts it is the
|
|
58
|
+
* authority on that, so when it answers, its answer decides. A server that
|
|
59
|
+
* says nothing leaves where the WebID lives as the only evidence there is.
|
|
60
|
+
*/
|
|
61
|
+
async function podOwners(podBase, fetchImpl = fetch) {
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetchImpl(podBase, {
|
|
64
|
+
method: 'HEAD', signal: AbortSignal.timeout(OWNER_LOOKUP_MS),
|
|
65
|
+
});
|
|
66
|
+
return linkTargets(res?.headers?.get?.('link'), REL.owner, podBase);
|
|
67
|
+
} catch { return []; }
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
function webidUnderPod(webid, podHome) {
|
|
52
71
|
try { return new URL(webid).origin === new URL(podHome).origin; } catch { return false; }
|
|
53
72
|
}
|
|
@@ -285,7 +304,11 @@ export async function routeFront(request, ctx) {
|
|
|
285
304
|
} catch { return j(400, { error: 'podBase is not a URL' }); }
|
|
286
305
|
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
287
306
|
if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
|
|
288
|
-
|
|
307
|
+
// The pod's own server names its owner when it can. Where it does, that is
|
|
308
|
+
// the proof; where it does not, the WebID must at least live under the pod.
|
|
309
|
+
const owners = await podOwners(podBase, ctx.fetchImpl || fetch);
|
|
310
|
+
const proven = owners.length ? owners.includes(webid) : webid.startsWith(podBase);
|
|
311
|
+
if (!proven) {
|
|
289
312
|
return j(403, { error: 'the token proves a different pod than the one you listed' });
|
|
290
313
|
}
|
|
291
314
|
if (action === 'opt-in') {
|
|
@@ -304,9 +327,9 @@ export async function routeFront(request, ctx) {
|
|
|
304
327
|
return j(400, { error: 'action must be opt-in or opt-out' });
|
|
305
328
|
}
|
|
306
329
|
|
|
307
|
-
// The vendored Solid-OIDC browser library the
|
|
308
|
-
// here because this function owns every path on the domain.
|
|
309
|
-
if (pathname === '/solid-client
|
|
330
|
+
// The vendored Solid-OIDC browser library the /run and /admin pages load —
|
|
331
|
+
// served here because this function owns every path on the domain.
|
|
332
|
+
if (pathname === '/solid-oidc-client.js') {
|
|
310
333
|
if (!ctx.authBundle) return notFound();
|
|
311
334
|
return { status: 200, headers: { 'content-type': 'text/javascript' }, body: ctx.authBundle };
|
|
312
335
|
}
|
package/lib/intake.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import * as $rdf from 'rdflib';
|
|
|
18
18
|
import { USER_AGENT } from './ua.mjs';
|
|
19
19
|
import { PUBLIC } from './wire.mjs';
|
|
20
20
|
import { HTTP_TIMEOUT_MS, readCapped } from './safefetch.mjs';
|
|
21
|
+
import { linkTargets, REL } from './links.mjs';
|
|
21
22
|
import { dropFollower } from './store.mjs';
|
|
22
23
|
|
|
23
24
|
const RDF = $rdf.Namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#');
|
|
@@ -307,6 +308,24 @@ export class Intake {
|
|
|
307
308
|
}
|
|
308
309
|
}
|
|
309
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Where this pod describes the services it offers. The pod says so on any
|
|
313
|
+
* response about one of its resources; the well-known path is only what a
|
|
314
|
+
* pod that says nothing has always used.
|
|
315
|
+
*/
|
|
316
|
+
async _storageDescriptionUrl() {
|
|
317
|
+
try {
|
|
318
|
+
const head = await fetch(this.urls.base, {
|
|
319
|
+
method: 'HEAD',
|
|
320
|
+
headers: { 'user-agent': USER_AGENT },
|
|
321
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
322
|
+
});
|
|
323
|
+
const [found] = linkTargets(head.headers.get('link'), REL.storageDescription, this.urls.base);
|
|
324
|
+
if (found) return found;
|
|
325
|
+
} catch { /* the well-known path below */ }
|
|
326
|
+
return this.urls.base + '.well-known/solid';
|
|
327
|
+
}
|
|
328
|
+
|
|
310
329
|
async _subscribeOnce() {
|
|
311
330
|
// Reuse a channel we already have rather than asking for another one.
|
|
312
331
|
const saved = this.store.read(CHANNEL_DOC, null);
|
|
@@ -314,13 +333,13 @@ export class Intake {
|
|
|
314
333
|
this._openSocket(saved.receiveFrom, true);
|
|
315
334
|
return;
|
|
316
335
|
}
|
|
317
|
-
const
|
|
336
|
+
const descUrl = await this._storageDescriptionUrl();
|
|
337
|
+
const descRes = await fetch(descUrl, {
|
|
318
338
|
headers: { accept: 'text/turtle', 'user-agent': USER_AGENT },
|
|
319
339
|
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
320
340
|
});
|
|
321
341
|
// The service description is RDF; ask rdflib which subject is the
|
|
322
342
|
// WebSocketChannel2023 service rather than pattern-matching the document.
|
|
323
|
-
const descUrl = this.urls.base + '.well-known/solid';
|
|
324
343
|
const g = $rdf.graph();
|
|
325
344
|
try { $rdf.parse(await readCapped(descRes), g, descUrl, 'text/turtle'); }
|
|
326
345
|
catch (e) { this.wsState = 'unavailable'; this.log(`service description unparsable (${e.message}) — polling only`); return; }
|
package/lib/links.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// links.mjs — reading RFC 8288 Link headers.
|
|
2
|
+
//
|
|
3
|
+
// Solid says where a resource's access control lives, where a storage
|
|
4
|
+
// describes itself, and who owns a storage, by putting a link on the response.
|
|
5
|
+
// Working any of those out from the resource's own URL instead is exactly what
|
|
6
|
+
// the specs tell clients not to do, so this is the one place that reads them.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Every target a Link header gives for one relation, resolved against the URL
|
|
10
|
+
* the header came from. A header may carry several links, and one link may
|
|
11
|
+
* carry several relation names.
|
|
12
|
+
*/
|
|
13
|
+
export function linkTargets(headerValue, rel, baseUrl) {
|
|
14
|
+
if (!headerValue) return [];
|
|
15
|
+
const wanted = String(rel).toLowerCase();
|
|
16
|
+
const out = [];
|
|
17
|
+
// Split on the commas BETWEEN links: one inside a URI has its closing angle
|
|
18
|
+
// bracket still ahead of it, and is left alone.
|
|
19
|
+
for (const part of String(headerValue).split(/,(?![^<]*>)/u)) {
|
|
20
|
+
const link = /^\s*<([^>]*)>\s*(.*)$/u.exec(part);
|
|
21
|
+
if (!link) continue;
|
|
22
|
+
const relParam = /(?:^|;)\s*rel\s*=\s*(?:"([^"]*)"|([^;"\s]+))/iu.exec(link[2]);
|
|
23
|
+
const names = (relParam?.[1] ?? relParam?.[2] ?? '').toLowerCase().split(/\s+/u);
|
|
24
|
+
if (!names.includes(wanted)) continue;
|
|
25
|
+
try { out.push(new URL(link[1], baseUrl).href); } catch { /* not a URL we can follow */ }
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The relations this project follows. */
|
|
31
|
+
export const REL = {
|
|
32
|
+
acl: 'acl',
|
|
33
|
+
storageDescription: 'http://www.w3.org/ns/solid/terms#storageDescription',
|
|
34
|
+
owner: 'http://www.w3.org/ns/solid/terms#owner',
|
|
35
|
+
};
|