fedipod-server 0.13.0 → 0.13.1
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/core/as2.mjs +9 -4
- package/lib/core/graphview.mjs +28 -8
- package/lib/gateway/front-core.mjs +14 -6
- package/package.json +1 -1
- package/web/app/agent.mjs +5 -2
- package/web/app/deliver-relay.mjs +18 -1
- package/web/app/dist/sw.js +32 -13
- package/web/app/dist/sw.js.map +3 -3
- package/web/app/site/sw.js +112 -27
package/lib/core/as2.mjs
CHANGED
|
@@ -144,12 +144,17 @@ export function groundContext(input) {
|
|
|
144
144
|
* what the plain read would have concluded too.
|
|
145
145
|
*/
|
|
146
146
|
export async function readLenient(raw) {
|
|
147
|
+
// The document's own node is the one its `id` names. Handed to the view
|
|
148
|
+
// as the root, so a document that points at itself — every actor does,
|
|
149
|
+
// through its key's `owner` — is still read as itself.
|
|
150
|
+
let input = null;
|
|
151
|
+
try { input = typeof raw === 'string' ? JSON.parse(raw) : (raw ?? null); } catch { /* not JSON either */ }
|
|
152
|
+
const own = input && typeof input === 'object' ? (input.id ?? input['@id']) : null;
|
|
153
|
+
const root = typeof own === 'string' && /^https?:\/\//u.test(own) ? own : null;
|
|
147
154
|
try {
|
|
148
155
|
const { doc, graph } = await parseAS2(raw);
|
|
149
|
-
return { doc, graph, view: graphView(graph), degraded: null };
|
|
156
|
+
return { doc, graph, view: graphView(graph, { root }), degraded: null };
|
|
150
157
|
} catch (e) {
|
|
151
|
-
let input = null;
|
|
152
|
-
try { input = typeof raw === 'string' ? JSON.parse(raw) : (raw ?? null); } catch { /* not JSON either */ }
|
|
153
158
|
if (!input || typeof input !== 'object') return { doc: null, graph: null, view: null, degraded: e.message };
|
|
154
159
|
// Grounded, so a graph exists for every document that is JSON at all.
|
|
155
160
|
//
|
|
@@ -159,7 +164,7 @@ export async function readLenient(raw) {
|
|
|
159
164
|
// what they read, so rewriting it here silently changed what they posted.
|
|
160
165
|
try {
|
|
161
166
|
const { graph } = await parseAS2(groundContext(input));
|
|
162
|
-
return { doc: input, graph, view: graphView(graph), degraded: e.message };
|
|
167
|
+
return { doc: input, graph, view: graphView(graph, { root }), degraded: e.message };
|
|
163
168
|
} catch (inner) {
|
|
164
169
|
// Grounding names only held contexts, so reaching here means the document
|
|
165
170
|
// is malformed JSON-LD rather than unfetchable. Hand back what a plain
|
package/lib/core/graphview.mjs
CHANGED
|
@@ -130,14 +130,31 @@ function indexQuads(quads) {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
/**
|
|
133
|
-
* The document's own node
|
|
134
|
-
*
|
|
135
|
-
*
|
|
133
|
+
* The document's own node, when the caller could not name it.
|
|
134
|
+
*
|
|
135
|
+
* A named subject nothing points at is the plain case. But an actor is
|
|
136
|
+
* pointed at by its own key (`owner`), so it is never that; and taking "the
|
|
137
|
+
* first typed subject" instead handed back a profile field — a blank
|
|
138
|
+
* PropertyValue that happens to come first in quad order — for every
|
|
139
|
+
* Mastodon actor with fields on it. Named subjects therefore come before
|
|
140
|
+
* blank ones at every step, and a blank node is the root only when nothing
|
|
141
|
+
* named exists at all.
|
|
136
142
|
*/
|
|
137
|
-
function findRoot({ bySubject, objects }) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
143
|
+
function findRoot({ bySubject, objects, blanks }) {
|
|
144
|
+
const all = [...bySubject.keys()];
|
|
145
|
+
const named = all.filter((s) => !blanks.has(s));
|
|
146
|
+
// A subject nothing points at is the document's own node — named if there
|
|
147
|
+
// is one, else blank: a client's Update carries no id of its own and is a
|
|
148
|
+
// blank root above the named note it edits.
|
|
149
|
+
const free = all.filter((s) => !objects.has(s));
|
|
150
|
+
const freeNamed = free.find((s) => !blanks.has(s));
|
|
151
|
+
if (freeNamed) return freeNamed;
|
|
152
|
+
if (free.length) return free[0];
|
|
153
|
+
// Everything is pointed at (an actor, through its key's owner): a typed
|
|
154
|
+
// named subject, before any typed blank one.
|
|
155
|
+
for (const s of named) if (bySubject.get(s).has(RDF_TYPE)) return s;
|
|
156
|
+
for (const s of all) if (bySubject.get(s).has(RDF_TYPE)) return s;
|
|
157
|
+
return named[0] ?? all[0] ?? null;
|
|
141
158
|
}
|
|
142
159
|
|
|
143
160
|
/** Walk an rdf:List into an ordered array. `orderedItems` depends on this. */
|
|
@@ -260,7 +277,10 @@ function makeView(subject, ctx, depth = 0, seen = new Set()) {
|
|
|
260
277
|
export function graphView(quads, { root = null } = {}) {
|
|
261
278
|
if (!quads || !quads.length) return null;
|
|
262
279
|
const ctx = indexQuads(quads);
|
|
263
|
-
|
|
280
|
+
// The root the caller names is used only when the graph has it as a
|
|
281
|
+
// subject: a document whose `id` names something it says nothing about
|
|
282
|
+
// still reads as whatever it does describe.
|
|
283
|
+
const subject = (root && ctx.bySubject.has(root)) ? root : findRoot(ctx);
|
|
264
284
|
if (!subject) return null;
|
|
265
285
|
const view = makeView(subject, ctx);
|
|
266
286
|
return (view && typeof view === 'object') ? view : null;
|
|
@@ -44,7 +44,12 @@ async function verifyPodToken(request, pathname, verifier) {
|
|
|
44
44
|
const url = request.url;
|
|
45
45
|
const { webid } = await v(authz, dpop ? { header: dpop, method: request.method, url } : undefined);
|
|
46
46
|
return webid || null;
|
|
47
|
-
} catch {
|
|
47
|
+
} catch (e) {
|
|
48
|
+
// Said aloud: a token the front will not take is otherwise a bare 401 to
|
|
49
|
+
// the caller and nothing at all here.
|
|
50
|
+
console.log(`front: pod token refused on ${pathname}: ${e?.message || e}`);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
// Is this WebID served by the claimed pod? A pod owner's WebID lives on the pod
|
|
@@ -149,7 +154,7 @@ async function relayOne(item, rec, fetchImpl) {
|
|
|
149
154
|
}
|
|
150
155
|
try {
|
|
151
156
|
const res = await safeFetch(url, { method, headers, body, signal: AbortSignal.timeout(RELAY_TIMEOUT_MS) }, fetchImpl);
|
|
152
|
-
const out = { url, status: res.status };
|
|
157
|
+
const out = { url, method, status: res.status };
|
|
153
158
|
// The far server asking to be left alone has to reach the agent that will
|
|
154
159
|
// do the asking again. Without this the browser build could not honour a
|
|
155
160
|
// Retry-After at all — every delivery it makes goes through here — and fell
|
|
@@ -161,7 +166,7 @@ async function relayOne(item, rec, fetchImpl) {
|
|
|
161
166
|
out.body = await readCapped(res, RELAY_MAX_BODY);
|
|
162
167
|
}
|
|
163
168
|
return out;
|
|
164
|
-
} catch (e) { return { url, status: 0, error: e.message }; }
|
|
169
|
+
} catch (e) { return { url, method, status: 0, error: e.message }; }
|
|
165
170
|
}
|
|
166
171
|
|
|
167
172
|
const j = (status, obj, ct = 'application/json') =>
|
|
@@ -470,15 +475,18 @@ async function route(request, ctx) {
|
|
|
470
475
|
try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
|
|
471
476
|
const handle = String(body.handle || '').toLowerCase();
|
|
472
477
|
const rec = await ctx.lookup(handle);
|
|
473
|
-
if (!rec) return j(404, { error: 'no such account' });
|
|
478
|
+
if (!rec) { console.log(`relay: no account named ${handle}`); return j(404, { error: 'no such account' }); }
|
|
474
479
|
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
475
|
-
if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
|
|
480
|
+
if (!webid) { console.log(`relay @${handle}: no usable pod token`); return j(401, { error: 'a Solid-OIDC token proving the pod is required' }); }
|
|
476
481
|
const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
|
|
477
|
-
if (!owner) return j(403, { error: "the token proves a different pod than this account's" });
|
|
482
|
+
if (!owner) { console.log(`relay @${handle}: token is for ${webid}, not this account's pod`); return j(403, { error: "the token proves a different pod than this account's" }); }
|
|
478
483
|
const items = Array.isArray(body.requests) ? body.requests : [];
|
|
479
484
|
if (!items.length) return j(400, { error: 'requests must be a non-empty list' });
|
|
480
485
|
if (items.length > RELAY_MAX_REQUESTS) return j(400, { error: `at most ${RELAY_MAX_REQUESTS} requests per call` });
|
|
481
486
|
const results = await Promise.all(items.map((it) => relayOne(it, rec, ctx.fetchImpl || fetch)));
|
|
487
|
+
// One line per relayed request, so a lookup that fails on the far side is
|
|
488
|
+
// visible here and not only as an empty result in someone's browser.
|
|
489
|
+
for (const r of results) console.log(`relay @${handle}: ${r.method || ''} ${r.url} → ${r.status}${r.error ? ` (${r.error})` : ''}`);
|
|
482
490
|
return j(200, { results });
|
|
483
491
|
}
|
|
484
492
|
|
package/package.json
CHANGED
package/web/app/agent.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { makeDpopSession } from './pod-auth.mjs';
|
|
|
19
19
|
import { BrowserRemotePod } from './pod-remote.mjs';
|
|
20
20
|
import { importSigningKey, loadKeysFromPod, cacheOpenedKeys } from './keys-browser.mjs';
|
|
21
21
|
import { generateKeys, wrapKeys } from './keystore.mjs';
|
|
22
|
-
import { RelayDeliverer } from './deliver-relay.mjs';
|
|
22
|
+
import { RelayDeliverer, doorKeyOf } from './deliver-relay.mjs';
|
|
23
23
|
import { AdminFacade } from './admin-facade.mjs';
|
|
24
24
|
import { BrowserAtproto } from './atproto-browser.mjs';
|
|
25
25
|
import { BskyFeed } from '../../lib/connections/bskyfeed.mjs';
|
|
@@ -203,7 +203,10 @@ export class BrowserAgent {
|
|
|
203
203
|
passive: true,
|
|
204
204
|
store: this.store, rsaPrivate: keys.rsaPrivate, keyId: this.urls.actor + '#main-key',
|
|
205
205
|
actorId: this.urls.actor, log: this.log,
|
|
206
|
-
relayUrl: `${frontOrigin.replace(/\/$/, '')}/api/relay`,
|
|
206
|
+
relayUrl: `${frontOrigin.replace(/\/$/, '')}/api/relay`,
|
|
207
|
+
// The relay finds the account by the front's own key for it, which for a
|
|
208
|
+
// mail-door account is the full address, not the bare handle.
|
|
209
|
+
handle: doorKeyOf(config.gateway?.url) || config.handle, sessionFetch: session.fetch,
|
|
207
210
|
});
|
|
208
211
|
|
|
209
212
|
this.publisher = new Publisher({
|
|
@@ -10,6 +10,20 @@
|
|
|
10
10
|
import { Deliverer } from '../../lib/core/deliver.mjs';
|
|
11
11
|
import { sign } from './shims/fedify-sig.mjs';
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The name the front keys this account's row by, read off its door inbox:
|
|
15
|
+
* `<front>/u/<key>/ap/inbox/`. A mail-door account is keyed by its full
|
|
16
|
+
* address (`you@your.pod`), not the bare handle, because "you" alone is not
|
|
17
|
+
* unique across pods — and the relay looks the account up by that key.
|
|
18
|
+
* Null when the URL is not a door of that shape.
|
|
19
|
+
*/
|
|
20
|
+
export function doorKeyOf(doorInboxUrl) {
|
|
21
|
+
try {
|
|
22
|
+
const seg = new URL(doorInboxUrl).pathname.split('/');
|
|
23
|
+
return seg[1] === 'u' && seg[2] ? decodeURIComponent(seg[2]) : null;
|
|
24
|
+
} catch { return null; }
|
|
25
|
+
}
|
|
26
|
+
|
|
13
27
|
export class RelayDeliverer extends Deliverer {
|
|
14
28
|
constructor(opts) {
|
|
15
29
|
super(opts);
|
|
@@ -28,10 +42,13 @@ export class RelayDeliverer extends Deliverer {
|
|
|
28
42
|
async signedFetch(url, init = {}) {
|
|
29
43
|
const body = typeof init.body === 'string' ? init.body : (init.body ? new TextDecoder().decode(init.body) : '');
|
|
30
44
|
const s = await sign({ url, method: init.method || 'GET', headers: init.headers || {}, body }, this.rsaPrivate, this.keyId);
|
|
45
|
+
// Every signed header goes to the relay, `accept` included: the signature
|
|
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.
|
|
31
48
|
const relayReq = {
|
|
32
49
|
url: s.url, method: s.method, body,
|
|
33
50
|
headers: {
|
|
34
|
-
date: s.headers.date, digest: s.headers.digest,
|
|
51
|
+
date: s.headers.date, digest: s.headers.digest, accept: s.headers.accept,
|
|
35
52
|
'content-type': s.headers['content-type'], signature: s.headers.signature,
|
|
36
53
|
},
|
|
37
54
|
};
|
package/web/app/dist/sw.js
CHANGED
|
@@ -55555,10 +55555,16 @@ function indexQuads(quads) {
|
|
|
55555
55555
|
}
|
|
55556
55556
|
return { bySubject, objects, blanks };
|
|
55557
55557
|
}
|
|
55558
|
-
function findRoot({ bySubject, objects }) {
|
|
55559
|
-
|
|
55560
|
-
|
|
55561
|
-
|
|
55558
|
+
function findRoot({ bySubject, objects, blanks }) {
|
|
55559
|
+
const all = [...bySubject.keys()];
|
|
55560
|
+
const named = all.filter((s) => !blanks.has(s));
|
|
55561
|
+
const free = all.filter((s) => !objects.has(s));
|
|
55562
|
+
const freeNamed = free.find((s) => !blanks.has(s));
|
|
55563
|
+
if (freeNamed) return freeNamed;
|
|
55564
|
+
if (free.length) return free[0];
|
|
55565
|
+
for (const s of named) if (bySubject.get(s).has(RDF_TYPE)) return s;
|
|
55566
|
+
for (const s of all) if (bySubject.get(s).has(RDF_TYPE)) return s;
|
|
55567
|
+
return named[0] ?? all[0] ?? null;
|
|
55562
55568
|
}
|
|
55563
55569
|
function readList(head, ctx, depth, path) {
|
|
55564
55570
|
const out = [];
|
|
@@ -55636,7 +55642,7 @@ function makeView(subject, ctx, depth = 0, seen = /* @__PURE__ */ new Set()) {
|
|
|
55636
55642
|
function graphView(quads, { root = null } = {}) {
|
|
55637
55643
|
if (!quads || !quads.length) return null;
|
|
55638
55644
|
const ctx = indexQuads(quads);
|
|
55639
|
-
const subject = root
|
|
55645
|
+
const subject = root && ctx.bySubject.has(root) ? root : findRoot(ctx);
|
|
55640
55646
|
if (!subject) return null;
|
|
55641
55647
|
const view = makeView(subject, ctx);
|
|
55642
55648
|
return view && typeof view === "object" ? view : null;
|
|
@@ -55695,19 +55701,21 @@ function groundContext(input) {
|
|
|
55695
55701
|
return { ...input, "@context": kept.length === 1 ? kept[0] : kept };
|
|
55696
55702
|
}
|
|
55697
55703
|
async function readLenient(raw) {
|
|
55704
|
+
let input = null;
|
|
55705
|
+
try {
|
|
55706
|
+
input = typeof raw === "string" ? JSON.parse(raw) : raw ?? null;
|
|
55707
|
+
} catch {
|
|
55708
|
+
}
|
|
55709
|
+
const own = input && typeof input === "object" ? input.id ?? input["@id"] : null;
|
|
55710
|
+
const root = typeof own === "string" && /^https?:\/\//u.test(own) ? own : null;
|
|
55698
55711
|
try {
|
|
55699
55712
|
const { doc, graph: graph2 } = await parseAS2(raw);
|
|
55700
|
-
return { doc, graph: graph2, view: graphView(graph2), degraded: null };
|
|
55713
|
+
return { doc, graph: graph2, view: graphView(graph2, { root }), degraded: null };
|
|
55701
55714
|
} catch (e) {
|
|
55702
|
-
let input = null;
|
|
55703
|
-
try {
|
|
55704
|
-
input = typeof raw === "string" ? JSON.parse(raw) : raw ?? null;
|
|
55705
|
-
} catch {
|
|
55706
|
-
}
|
|
55707
55715
|
if (!input || typeof input !== "object") return { doc: null, graph: null, view: null, degraded: e.message };
|
|
55708
55716
|
try {
|
|
55709
55717
|
const { graph: graph2 } = await parseAS2(groundContext(input));
|
|
55710
|
-
return { doc: input, graph: graph2, view: graphView(graph2), degraded: e.message };
|
|
55718
|
+
return { doc: input, graph: graph2, view: graphView(graph2, { root }), degraded: e.message };
|
|
55711
55719
|
} catch (inner) {
|
|
55712
55720
|
return { doc: input, graph: null, view: null, degraded: `${e.message}; grounded read also failed: ${inner.message}` };
|
|
55713
55721
|
}
|
|
@@ -67364,6 +67372,14 @@ var Deliverer = class {
|
|
|
67364
67372
|
|
|
67365
67373
|
// web/app/deliver-relay.mjs
|
|
67366
67374
|
init_fedify_sig();
|
|
67375
|
+
function doorKeyOf(doorInboxUrl) {
|
|
67376
|
+
try {
|
|
67377
|
+
const seg2 = new URL(doorInboxUrl).pathname.split("/");
|
|
67378
|
+
return seg2[1] === "u" && seg2[2] ? decodeURIComponent(seg2[2]) : null;
|
|
67379
|
+
} catch {
|
|
67380
|
+
return null;
|
|
67381
|
+
}
|
|
67382
|
+
}
|
|
67367
67383
|
var RelayDeliverer = class extends Deliverer {
|
|
67368
67384
|
constructor(opts) {
|
|
67369
67385
|
super(opts);
|
|
@@ -67388,6 +67404,7 @@ var RelayDeliverer = class extends Deliverer {
|
|
|
67388
67404
|
headers: {
|
|
67389
67405
|
date: s.headers.date,
|
|
67390
67406
|
digest: s.headers.digest,
|
|
67407
|
+
accept: s.headers.accept,
|
|
67391
67408
|
"content-type": s.headers["content-type"],
|
|
67392
67409
|
signature: s.headers.signature
|
|
67393
67410
|
}
|
|
@@ -69615,7 +69632,9 @@ var BrowserAgent = class _BrowserAgent {
|
|
|
69615
69632
|
actorId: this.urls.actor,
|
|
69616
69633
|
log: this.log,
|
|
69617
69634
|
relayUrl: `${frontOrigin.replace(/\/$/, "")}/api/relay`,
|
|
69618
|
-
|
|
69635
|
+
// The relay finds the account by the front's own key for it, which for a
|
|
69636
|
+
// mail-door account is the full address, not the bare handle.
|
|
69637
|
+
handle: doorKeyOf(config.gateway?.url) || config.handle,
|
|
69619
69638
|
sessionFetch: session.fetch
|
|
69620
69639
|
});
|
|
69621
69640
|
this.publisher = new Publisher({
|