fedipod-server 0.13.0 → 0.13.2

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.
@@ -145,8 +145,13 @@ export async function handle(api, ctx) {
145
145
  if (acct === cfg?.handle || acct === `${cfg?.handle}@${api.host}`) {
146
146
  return send(200, api.selfAccount());
147
147
  }
148
+ // By host with its port, as the account is shown; and by hostname alone,
149
+ // for a client that drops the port from an address it was given.
148
150
  const hit = Object.entries(api.store.getActors()).find(([u, a]) => {
149
- try { return `${a.preferredUsername}@${new URL(u).host}` === acct; } catch { return false; }
151
+ try {
152
+ const at = new URL(u);
153
+ return `${a.preferredUsername}@${at.host}` === acct || `${a.preferredUsername}@${at.hostname}` === acct;
154
+ } catch { return false; }
150
155
  });
151
156
  return hit ? send(200, api.account(hit[0])) : send(404, { error: 'Record not found' });
152
157
  }
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
@@ -130,14 +130,31 @@ function indexQuads(quads) {
130
130
  }
131
131
 
132
132
  /**
133
- * The document's own node: a subject nothing else points at. A document that
134
- * points back at itself leaves no such subject, so the first typed subject is
135
- * the fallback rather than nothing at all.
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
- for (const s of bySubject.keys()) if (!objects.has(s)) return s;
139
- for (const [s, preds] of bySubject) if (preds.has(RDF_TYPE)) return s;
140
- return bySubject.keys().next().value ?? null;
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
- const subject = root ?? findRoot(ctx);
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;
@@ -502,5 +502,34 @@ export class PodStore {
502
502
  this.cache.set('ids.json', ids);
503
503
  return id;
504
504
  }
505
- urlFor(id) { return this.getIds()[id] || null; }
505
+ // The map first, for ids minted under an older scheme. Then, because the
506
+ // id IS the hash of the url, whatever this store knows is scanned for the
507
+ // url that hashes to it — actors, posts, contacts, requests, media. The
508
+ // browser build's worker is stopped whenever it idles, and the in-memory
509
+ // map went with it: a client clicking an account it had just been shown
510
+ // reached a fresh worker that held the actor and could not name it.
511
+ urlFor(id) {
512
+ const ids = this.getIds();
513
+ if (ids[id]) return ids[id];
514
+ if (!/^[a-f0-9]{16}$/u.test(String(id))) return null;
515
+ const hash = (u) => crypto.createHash('sha256').update(u).digest('hex').slice(0, 16);
516
+ const seen = new Set();
517
+ const candidates = function* (store) {
518
+ for (const u of Object.keys(store.getActors())) yield u;
519
+ for (const st of store.getStatuses()) {
520
+ yield st.noteId; yield st.actor;
521
+ for (const a of st.attachments || []) if (a?.url) yield a.url;
522
+ }
523
+ const c = store.getContacts();
524
+ for (const f of [...c.followers, ...c.following]) if (f?.actor) yield f.actor;
525
+ for (const r of store.getRequests()) if (r?.actor) yield r.actor;
526
+ for (const u of Object.keys(store.getMedia())) yield u;
527
+ };
528
+ for (const u of candidates(this)) {
529
+ if (typeof u !== 'string' || seen.has(u)) continue;
530
+ seen.add(u);
531
+ if (hash(u) === id) { ids[id] = u; this.cache.set('ids.json', ids); return u; }
532
+ }
533
+ return null;
534
+ }
506
535
  }
@@ -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 { return null; }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod-server",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "The FediPod Server: a full ActivityPub server as a Community Solid Server component.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
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`, handle: config.handle, sessionFetch: session.fetch,
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
  };
@@ -34555,8 +34555,40 @@ var PodStore = class {
34555
34555
  this.cache.set("ids.json", ids);
34556
34556
  return id;
34557
34557
  }
34558
+ // The map first, for ids minted under an older scheme. Then, because the
34559
+ // id IS the hash of the url, whatever this store knows is scanned for the
34560
+ // url that hashes to it — actors, posts, contacts, requests, media. The
34561
+ // browser build's worker is stopped whenever it idles, and the in-memory
34562
+ // map went with it: a client clicking an account it had just been shown
34563
+ // reached a fresh worker that held the actor and could not name it.
34558
34564
  urlFor(id) {
34559
- return this.getIds()[id] || null;
34565
+ const ids = this.getIds();
34566
+ if (ids[id]) return ids[id];
34567
+ if (!/^[a-f0-9]{16}$/u.test(String(id))) return null;
34568
+ const hash = (u) => node_crypto_default.createHash("sha256").update(u).digest("hex").slice(0, 16);
34569
+ const seen = /* @__PURE__ */ new Set();
34570
+ const candidates = function* (store) {
34571
+ for (const u of Object.keys(store.getActors())) yield u;
34572
+ for (const st2 of store.getStatuses()) {
34573
+ yield st2.noteId;
34574
+ yield st2.actor;
34575
+ for (const a of st2.attachments || []) if (a?.url) yield a.url;
34576
+ }
34577
+ const c = store.getContacts();
34578
+ for (const f of [...c.followers, ...c.following]) if (f?.actor) yield f.actor;
34579
+ for (const r of store.getRequests()) if (r?.actor) yield r.actor;
34580
+ for (const u of Object.keys(store.getMedia())) yield u;
34581
+ };
34582
+ for (const u of candidates(this)) {
34583
+ if (typeof u !== "string" || seen.has(u)) continue;
34584
+ seen.add(u);
34585
+ if (hash(u) === id) {
34586
+ ids[id] = u;
34587
+ this.cache.set("ids.json", ids);
34588
+ return u;
34589
+ }
34590
+ }
34591
+ return null;
34560
34592
  }
34561
34593
  };
34562
34594
 
@@ -55555,10 +55587,16 @@ function indexQuads(quads) {
55555
55587
  }
55556
55588
  return { bySubject, objects, blanks };
55557
55589
  }
55558
- function findRoot({ bySubject, objects }) {
55559
- for (const s of bySubject.keys()) if (!objects.has(s)) return s;
55560
- for (const [s, preds] of bySubject) if (preds.has(RDF_TYPE)) return s;
55561
- return bySubject.keys().next().value ?? null;
55590
+ function findRoot({ bySubject, objects, blanks }) {
55591
+ const all = [...bySubject.keys()];
55592
+ const named = all.filter((s) => !blanks.has(s));
55593
+ const free = all.filter((s) => !objects.has(s));
55594
+ const freeNamed = free.find((s) => !blanks.has(s));
55595
+ if (freeNamed) return freeNamed;
55596
+ if (free.length) return free[0];
55597
+ for (const s of named) if (bySubject.get(s).has(RDF_TYPE)) return s;
55598
+ for (const s of all) if (bySubject.get(s).has(RDF_TYPE)) return s;
55599
+ return named[0] ?? all[0] ?? null;
55562
55600
  }
55563
55601
  function readList(head, ctx, depth, path) {
55564
55602
  const out = [];
@@ -55636,7 +55674,7 @@ function makeView(subject, ctx, depth = 0, seen = /* @__PURE__ */ new Set()) {
55636
55674
  function graphView(quads, { root = null } = {}) {
55637
55675
  if (!quads || !quads.length) return null;
55638
55676
  const ctx = indexQuads(quads);
55639
- const subject = root ?? findRoot(ctx);
55677
+ const subject = root && ctx.bySubject.has(root) ? root : findRoot(ctx);
55640
55678
  if (!subject) return null;
55641
55679
  const view = makeView(subject, ctx);
55642
55680
  return view && typeof view === "object" ? view : null;
@@ -55695,19 +55733,21 @@ function groundContext(input) {
55695
55733
  return { ...input, "@context": kept.length === 1 ? kept[0] : kept };
55696
55734
  }
55697
55735
  async function readLenient(raw) {
55736
+ let input = null;
55737
+ try {
55738
+ input = typeof raw === "string" ? JSON.parse(raw) : raw ?? null;
55739
+ } catch {
55740
+ }
55741
+ const own = input && typeof input === "object" ? input.id ?? input["@id"] : null;
55742
+ const root = typeof own === "string" && /^https?:\/\//u.test(own) ? own : null;
55698
55743
  try {
55699
55744
  const { doc, graph: graph2 } = await parseAS2(raw);
55700
- return { doc, graph: graph2, view: graphView(graph2), degraded: null };
55745
+ return { doc, graph: graph2, view: graphView(graph2, { root }), degraded: null };
55701
55746
  } catch (e) {
55702
- let input = null;
55703
- try {
55704
- input = typeof raw === "string" ? JSON.parse(raw) : raw ?? null;
55705
- } catch {
55706
- }
55707
55747
  if (!input || typeof input !== "object") return { doc: null, graph: null, view: null, degraded: e.message };
55708
55748
  try {
55709
55749
  const { graph: graph2 } = await parseAS2(groundContext(input));
55710
- return { doc: input, graph: graph2, view: graphView(graph2), degraded: e.message };
55750
+ return { doc: input, graph: graph2, view: graphView(graph2, { root }), degraded: e.message };
55711
55751
  } catch (inner) {
55712
55752
  return { doc: input, graph: null, view: null, degraded: `${e.message}; grounded read also failed: ${inner.message}` };
55713
55753
  }
@@ -65423,7 +65463,8 @@ async function handle4(api, ctx) {
65423
65463
  }
65424
65464
  const hit = Object.entries(api.store.getActors()).find(([u, a]) => {
65425
65465
  try {
65426
- return `${a.preferredUsername}@${new URL(u).host}` === acct;
65466
+ const at = new URL(u);
65467
+ return `${a.preferredUsername}@${at.host}` === acct || `${a.preferredUsername}@${at.hostname}` === acct;
65427
65468
  } catch {
65428
65469
  return false;
65429
65470
  }
@@ -67364,6 +67405,14 @@ var Deliverer = class {
67364
67405
 
67365
67406
  // web/app/deliver-relay.mjs
67366
67407
  init_fedify_sig();
67408
+ function doorKeyOf(doorInboxUrl) {
67409
+ try {
67410
+ const seg2 = new URL(doorInboxUrl).pathname.split("/");
67411
+ return seg2[1] === "u" && seg2[2] ? decodeURIComponent(seg2[2]) : null;
67412
+ } catch {
67413
+ return null;
67414
+ }
67415
+ }
67367
67416
  var RelayDeliverer = class extends Deliverer {
67368
67417
  constructor(opts) {
67369
67418
  super(opts);
@@ -67388,6 +67437,7 @@ var RelayDeliverer = class extends Deliverer {
67388
67437
  headers: {
67389
67438
  date: s.headers.date,
67390
67439
  digest: s.headers.digest,
67440
+ accept: s.headers.accept,
67391
67441
  "content-type": s.headers["content-type"],
67392
67442
  signature: s.headers.signature
67393
67443
  }
@@ -69615,7 +69665,9 @@ var BrowserAgent = class _BrowserAgent {
69615
69665
  actorId: this.urls.actor,
69616
69666
  log: this.log,
69617
69667
  relayUrl: `${frontOrigin.replace(/\/$/, "")}/api/relay`,
69618
- handle: config.handle,
69668
+ // The relay finds the account by the front's own key for it, which for a
69669
+ // mail-door account is the full address, not the bare handle.
69670
+ handle: doorKeyOf(config.gateway?.url) || config.handle,
69619
69671
  sessionFetch: session.fetch
69620
69672
  });
69621
69673
  this.publisher = new Publisher({