fedipod-server 0.28.1 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -28
- package/config/server.json +22 -0
- package/dist/claims.js +3 -2
- package/dist/directory.d.ts +2 -0
- package/dist/handler.d.ts +4 -1
- package/dist/handler.js +32 -3
- package/dist/handler.jsonld +4 -0
- package/dist/store-pod.js +37 -0
- package/lib/client/c2s.mjs +97 -43
- package/lib/client/masto/accounts.mjs +1 -0
- package/lib/core/deliver.mjs +17 -1
- package/lib/core/intake/activities.mjs +18 -2
- package/lib/core/intake/group.mjs +3 -1
- package/lib/core/intake/index.mjs +33 -2
- package/lib/core/intake/notes.mjs +12 -5
- package/lib/core/place.mjs +82 -0
- package/lib/core/publisher/collections.mjs +37 -2
- package/lib/core/publisher/index.mjs +23 -1
- package/lib/core/publisher/notes.mjs +73 -7
- package/lib/core/publisher/own.mjs +143 -0
- package/lib/core/publisher/questions.mjs +5 -3
- package/lib/core/social.mjs +62 -32
- package/lib/core/store.mjs +11 -1
- package/lib/core/wire.mjs +51 -15
- package/lib/device/admin/routes/lifecycle.mjs +1 -1
- package/lib/device/admin/routes/setup.mjs +17 -1
- package/lib/device/cli/commands/setup.mjs +50 -8
- package/lib/device/cli/context.mjs +1 -1
- package/lib/device/migrate.mjs +1 -1
- package/lib/device/setup.mjs +45 -8
- package/lib/gateway/front-core.mjs +54 -58
- package/lib/gateway/gateway-core.mjs +87 -8
- package/lib/gateway/relay-extras.mjs +89 -0
- package/lib/gateway/token-claims.mjs +16 -0
- package/lib/pod/location.mjs +52 -0
- package/lib/pod/notes.mjs +2 -4
- package/lib/pod/transport.mjs +171 -22
- package/lib/pod/type-index.mjs +101 -0
- package/lib/pod/urls.mjs +6 -0
- package/lib/server/embed.mjs +7 -6
- package/lib/session/fedi-account.mjs +19 -10
- package/lib/session/package.json +1 -1
- package/package.json +1 -1
- package/run-agent.mjs +2 -0
- package/web/admin/actors.js +2 -0
- package/web/admin/index.html +9 -0
- package/web/admin/record.js +4 -1
- package/web/admin/setup/index.html +17 -1
- package/web/admin/setup/setup.js +18 -5
- package/web/app/README.md +1 -1
- package/web/app/admin-facade.mjs +1 -1
- package/web/app/agent.mjs +11 -2
- package/web/app/boot.mjs +72 -32
- package/web/app/dist/boot.js +430 -86
- package/web/app/dist/boot.js.map +4 -4
- package/web/app/dist/sw.js +3294 -2648
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/index.html +16 -0
- package/web/app/signup.mjs +65 -26
- package/web/front/run.html +7 -1
- package/web/front/run.js +30 -4
package/lib/pod/transport.mjs
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import * as $rdf from 'rdflib';
|
|
16
16
|
import { readCapped, retryAfterMs } from './http.mjs';
|
|
17
17
|
import { linkTargets, REL } from './links.mjs';
|
|
18
|
+
import { podBaseOfWebId } from './urls.mjs';
|
|
18
19
|
|
|
19
20
|
const LDP = $rdf.Namespace('http://www.w3.org/ns/ldp#');
|
|
20
21
|
const DC = $rdf.Namespace('http://purl.org/dc/terms/');
|
|
@@ -23,6 +24,17 @@ const RDF = $rdf.Namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#');
|
|
|
23
24
|
const ACL = $rdf.Namespace('http://www.w3.org/ns/auth/acl#');
|
|
24
25
|
const FOAF = $rdf.Namespace('http://xmlns.com/foaf/0.1/');
|
|
25
26
|
const AS = $rdf.Namespace('https://www.w3.org/ns/activitystreams#');
|
|
27
|
+
const RDFS = $rdf.Namespace('http://www.w3.org/2000/01/rdf-schema#');
|
|
28
|
+
|
|
29
|
+
// An inserted term must be something a reader can take for what it says it
|
|
30
|
+
// is: an absolute http(s) IRI, or a literal.
|
|
31
|
+
function termProblem(t) {
|
|
32
|
+
if (t?.termType === 'Literal') return null;
|
|
33
|
+
if (t?.termType !== 'NamedNode') return `${t?.value ?? t} is not an IRI or a literal`;
|
|
34
|
+
try {
|
|
35
|
+
return /^https?:$/.test(new URL(t.value).protocol) ? null : `${t.value} is not an http(s) IRI`;
|
|
36
|
+
} catch { return `${t.value} is not an absolute IRI`; }
|
|
37
|
+
}
|
|
26
38
|
|
|
27
39
|
// A pod whose access rules are ACP policies, not WAC authorizations. This
|
|
28
40
|
// library writes WAC; over an ACP resource that would be noise where the pod's
|
|
@@ -445,16 +457,11 @@ export class PodTransport {
|
|
|
445
457
|
* written back — an empty or foreign body must never become the new profile.
|
|
446
458
|
*/
|
|
447
459
|
async linkAccountInProfile({ actorUrl, accountName, kind = 'person', outbox = null }) {
|
|
448
|
-
const docUrl = this.webId.split('#')[0];
|
|
449
|
-
const res = await this.fetch(docUrl, { headers: { accept: 'text/turtle' } });
|
|
450
|
-
if (res.status >= 400) throw new Error(`[${this.label}] GET ${docUrl} → ${res.status}`);
|
|
451
|
-
const g = $rdf.graph();
|
|
452
|
-
$rdf.parse(await res.text(), g, docUrl, 'text/turtle');
|
|
453
|
-
const doc = $rdf.sym(docUrl);
|
|
454
460
|
const me = $rdf.sym(this.webId);
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
461
|
+
const podBase = podBaseOfWebId(this.webId);
|
|
462
|
+
// The profile and what its seeAlso names: what either says is said.
|
|
463
|
+
const docs = await this.profileDocs(podBase);
|
|
464
|
+
const says = (s, p, o) => docs.some(({ g }) => g?.holds(s, p, o));
|
|
458
465
|
const actor = $rdf.sym(actorUrl);
|
|
459
466
|
const wanted = [
|
|
460
467
|
[me, FOAF('account'), actor],
|
|
@@ -465,22 +472,24 @@ export class PodTransport {
|
|
|
465
472
|
// WebID is what dokieli reads); only where a door exists to take it.
|
|
466
473
|
...(outbox ? [[me, AS('outbox'), $rdf.sym(outbox)]] : []),
|
|
467
474
|
];
|
|
468
|
-
const missing = wanted.filter(([s, p, o]) => !
|
|
475
|
+
const missing = wanted.filter(([s, p, o]) => !says(s, p, o));
|
|
469
476
|
// A handle change leaves the old accountName behind; ours is replaced.
|
|
470
477
|
// Likewise an outbox that moved.
|
|
471
|
-
const stale = [
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
478
|
+
const stale = [];
|
|
479
|
+
for (const { g } of docs) {
|
|
480
|
+
for (const st of g?.statementsMatching(actor, FOAF('accountName'), null) || []) {
|
|
481
|
+
if (st.object.value !== accountName) stale.push([st.subject, st.predicate, st.object]);
|
|
482
|
+
}
|
|
483
|
+
if (outbox) {
|
|
484
|
+
for (const st of g?.statementsMatching(me, AS('outbox'), null) || []) {
|
|
485
|
+
if (st.object.value !== outbox) stale.push([st.subject, st.predicate, st.object]);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
475
489
|
if (!missing.length && !stale.length) return false;
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
const deletes = stale.map(st => [st.subject, st.predicate, st.object]);
|
|
480
|
-
if (await this.patchDocument(docUrl, missing, deletes)) return true;
|
|
481
|
-
for (const st of stale) g.remove(st);
|
|
482
|
-
for (const [s, p, o] of missing) g.add(s, p, o, doc);
|
|
483
|
-
await this.put(docUrl, $rdf.serialize(doc, g, docUrl, 'text/turtle'), 'text/turtle');
|
|
490
|
+
// Checked before it is written, to the profile or else to a document its
|
|
491
|
+
// seeAlso names (writeAboutWebId); a patch carries only these statements.
|
|
492
|
+
await this.writeAboutWebId(podBase, { inserts: missing, deletes: stale });
|
|
484
493
|
return true;
|
|
485
494
|
}
|
|
486
495
|
|
|
@@ -522,4 +531,144 @@ export class PodTransport {
|
|
|
522
531
|
if (res.status === 405 || res.status === 415 || res.status === 501) return false;
|
|
523
532
|
throw new Error(`[${this.label}] PATCH ${docUrl} → ${res.status}`);
|
|
524
533
|
}
|
|
534
|
+
|
|
535
|
+
// ---- writing a profile or a type index: valid RDF, or nothing ----
|
|
536
|
+
//
|
|
537
|
+
// Two rules. A change is written only if the document it leaves behind is
|
|
538
|
+
// valid RDF: the graph as it would be afterwards is serialised and parsed
|
|
539
|
+
// back, and the subject that must stay described still is. And a profile
|
|
540
|
+
// that will not take a write is not the end: the statements go to the first
|
|
541
|
+
// document its rdfs:seeAlso names, inside the same pod, that takes them —
|
|
542
|
+
// under the same check. A profile is what every Solid app signs in through;
|
|
543
|
+
// one left broken breaks them all. Reading follows the same links.
|
|
544
|
+
|
|
545
|
+
/** An IRI or a literal as a term, for the operations that pass plain values. */
|
|
546
|
+
sym(iri) {
|
|
547
|
+
try { return $rdf.sym(iri); } catch { throw new Error(`not written: ${iri} is not an absolute IRI`); }
|
|
548
|
+
}
|
|
549
|
+
literal(value) { return $rdf.literal(value); }
|
|
550
|
+
|
|
551
|
+
/** A document as a graph, or null when it is not there. */
|
|
552
|
+
async readRdf(docUrl) {
|
|
553
|
+
const res = await this.fetch(docUrl, { headers: { accept: 'text/turtle' } });
|
|
554
|
+
if (res.status === 404 || res.status === 410) return null;
|
|
555
|
+
if (res.status >= 400) throw new Error(`[${this.label}] GET ${docUrl} → ${res.status}`);
|
|
556
|
+
const g = $rdf.graph();
|
|
557
|
+
$rdf.parse(await res.text(), g, docUrl, 'text/turtle');
|
|
558
|
+
return g;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* The document as it would be after the change, checked. Returns the Turtle
|
|
563
|
+
* to write, or throws saying why nothing may be written.
|
|
564
|
+
*/
|
|
565
|
+
checkedRdf(g, docUrl, { inserts = [], deletes = [], mustDescribe = null }) {
|
|
566
|
+
const doc = $rdf.sym(docUrl);
|
|
567
|
+
for (const [s, p, o] of inserts) {
|
|
568
|
+
const bad = termProblem(s) || termProblem(p) || termProblem(o)
|
|
569
|
+
|| (s?.termType === 'Literal' || p?.termType !== 'NamedNode' ? 'a literal subject or predicate' : null);
|
|
570
|
+
if (bad) throw new Error(`not written: ${bad}`);
|
|
571
|
+
}
|
|
572
|
+
const after = $rdf.graph();
|
|
573
|
+
for (const st of g.statementsMatching(null, null, null, doc)) after.add(st.subject, st.predicate, st.object, doc);
|
|
574
|
+
for (const [s, p, o] of deletes) for (const st of after.statementsMatching(s, p, o, doc)) after.remove(st);
|
|
575
|
+
for (const [s, p, o] of inserts) if (!after.holds(s, p, o, doc)) after.add(s, p, o, doc);
|
|
576
|
+
let text;
|
|
577
|
+
try { text = $rdf.serialize(doc, after, docUrl, 'text/turtle'); } catch (e) {
|
|
578
|
+
throw new Error(`not written: ${docUrl} would not serialise (${e.message})`);
|
|
579
|
+
}
|
|
580
|
+
const back = $rdf.graph();
|
|
581
|
+
try { $rdf.parse(text, back, docUrl, 'text/turtle'); } catch (e) {
|
|
582
|
+
throw new Error(`not written: ${docUrl} would not parse back (${e.message})`);
|
|
583
|
+
}
|
|
584
|
+
if (back.statementsMatching(null, null, null, doc).length !== after.statementsMatching(null, null, null, doc).length) {
|
|
585
|
+
throw new Error(`not written: ${docUrl} would not read back as written`);
|
|
586
|
+
}
|
|
587
|
+
if (mustDescribe && !back.statementsMatching($rdf.sym(mustDescribe), null, null, doc).length) {
|
|
588
|
+
throw new Error(`not written: ${docUrl} would no longer describe ${mustDescribe}`);
|
|
589
|
+
}
|
|
590
|
+
return text;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Change one document, or refuse. `g` is the document as read (null for a
|
|
595
|
+
* new one). A PATCH carries only these statements; where the pod cannot
|
|
596
|
+
* patch, the whole checked document is written. Returns the write's status.
|
|
597
|
+
*/
|
|
598
|
+
async writeRdfChecked(docUrl, g, { inserts = [], deletes = [], mustDescribe = null }) {
|
|
599
|
+
const current = g || $rdf.graph();
|
|
600
|
+
const doc = $rdf.sym(docUrl);
|
|
601
|
+
const todo = inserts.filter(([s, p, o]) => !current.holds(s, p, o, doc));
|
|
602
|
+
const gone = deletes.filter(([s, p, o]) => current.holds(s, p, o, doc));
|
|
603
|
+
if (!todo.length && !gone.length) return 200;
|
|
604
|
+
const text = this.checkedRdf(current, docUrl, { inserts: todo, deletes: gone, mustDescribe });
|
|
605
|
+
if (g) {
|
|
606
|
+
let res = null;
|
|
607
|
+
try {
|
|
608
|
+
res = await this.fetch(docUrl, { method: 'PATCH', headers: { 'content-type': 'text/n3' },
|
|
609
|
+
body: this.n3Patch(docUrl, todo, gone) });
|
|
610
|
+
} catch { res = null; }
|
|
611
|
+
if (res && res.status < 300) return res.status;
|
|
612
|
+
// A pod that cannot patch gets the whole checked document; any other
|
|
613
|
+
// answer — a refusal, a conflict — is the answer.
|
|
614
|
+
if (res && ![405, 415, 501].includes(res.status)) return res.status;
|
|
615
|
+
}
|
|
616
|
+
const put = await this.fetch(docUrl, { method: 'PUT', headers: { 'content-type': 'text/turtle' }, body: text });
|
|
617
|
+
return put.status;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* The profile and the documents its rdfs:seeAlso names inside this pod,
|
|
622
|
+
* each with its graph, the profile first. What any of them says about the
|
|
623
|
+
* WebID is what the profile says.
|
|
624
|
+
*/
|
|
625
|
+
async profileDocs(podBase) {
|
|
626
|
+
const profileUrl = this.webId.split('#')[0];
|
|
627
|
+
const g = await this.readRdf(profileUrl);
|
|
628
|
+
if (!g) throw new Error(`no profile at ${profileUrl}`);
|
|
629
|
+
const out = [{ url: profileUrl, g, profile: true }];
|
|
630
|
+
const seen = new Set([profileUrl]);
|
|
631
|
+
for (const st of g.statementsMatching(null, RDFS('seeAlso'), null)) {
|
|
632
|
+
const url = st.object.value.split('#')[0];
|
|
633
|
+
if (seen.has(url) || !url.startsWith(podBase)) continue; // inside this pod only
|
|
634
|
+
seen.add(url);
|
|
635
|
+
out.push({ url, g: await this.readRdf(url).catch(() => null), profile: false });
|
|
636
|
+
}
|
|
637
|
+
return out;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Every value the WebID has for `predicate`, across the profile and its seeAlso documents. */
|
|
641
|
+
webIdValues(docs, predicate) {
|
|
642
|
+
const me = $rdf.sym(this.webId);
|
|
643
|
+
const p = $rdf.sym(predicate);
|
|
644
|
+
const out = [];
|
|
645
|
+
for (const { g } of docs) for (const st of g?.statementsMatching(me, p, null) || []) out.push(st.object.value);
|
|
646
|
+
return [...new Set(out)];
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Write statements about the WebID: to the profile, or — if the profile will
|
|
651
|
+
* not take them — to the first of its seeAlso documents that will. Every
|
|
652
|
+
* write checked. Returns the document written to, or null when nothing
|
|
653
|
+
* needed writing; throws when none would take it.
|
|
654
|
+
*/
|
|
655
|
+
async writeAboutWebId(podBase, { inserts = [], deletes = [] }) {
|
|
656
|
+
const docs = await this.profileDocs(podBase);
|
|
657
|
+
const holds = ([s, p, o]) => docs.some(({ g }) => g?.holds(s, p, o));
|
|
658
|
+
const todo = inserts.filter(t => !holds(t));
|
|
659
|
+
const gone = deletes.filter(holds);
|
|
660
|
+
if (!todo.length && !gone.length) return null;
|
|
661
|
+
const refusals = [];
|
|
662
|
+
for (const d of docs) {
|
|
663
|
+
const here = gone.filter(([s, p, o]) => d.g?.holds(s, p, o));
|
|
664
|
+
const status = await this.writeRdfChecked(d.url, d.g, {
|
|
665
|
+
inserts: todo, deletes: here, mustDescribe: d.profile ? this.webId : null,
|
|
666
|
+
});
|
|
667
|
+
if (status < 300) return d.url;
|
|
668
|
+
refusals.push(`${d.url} → ${status}`);
|
|
669
|
+
if (status !== 401 && status !== 403) break; // a real answer, not "not yours to write"
|
|
670
|
+
}
|
|
671
|
+
throw new Error(`not written: neither the profile nor a document it names would take it (${refusals.join('; ')})`);
|
|
672
|
+
}
|
|
673
|
+
|
|
525
674
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// type-index.mjs — an account's place, recorded in the person's public type
|
|
2
|
+
// index: the list Solid apps read to find what kind of thing lives where.
|
|
3
|
+
//
|
|
4
|
+
// The record is a registration saying there is an instance of `as:Actor` at the
|
|
5
|
+
// account's actor on the pod:
|
|
6
|
+
//
|
|
7
|
+
// <#actor-…> a solid:TypeRegistration;
|
|
8
|
+
// solid:forClass as:Actor;
|
|
9
|
+
// solid:instance <…/ap/actor>.
|
|
10
|
+
//
|
|
11
|
+
// The instance is the actor at the pod, not a gateway's address for it, so the
|
|
12
|
+
// place can be read off it. It is also how the account is found again.
|
|
13
|
+
//
|
|
14
|
+
// Every write goes through the transport's checked writers: valid RDF, to the
|
|
15
|
+
// profile or a document its seeAlso names, or nothing.
|
|
16
|
+
|
|
17
|
+
const SOLID = 'http://www.w3.org/ns/solid/terms#';
|
|
18
|
+
const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
|
|
19
|
+
const AS_ACTOR = 'https://www.w3.org/ns/activitystreams#Actor';
|
|
20
|
+
const PUBLIC_READ = ['Read'];
|
|
21
|
+
|
|
22
|
+
/** Where a new public type index goes when a person has none. */
|
|
23
|
+
export const newIndexUrl = (podBase) => `${podBase}settings/publicTypeIndex.ttl`;
|
|
24
|
+
|
|
25
|
+
/** The person's public type index, or null when their profile names none. */
|
|
26
|
+
export async function findPublicIndex(pod, podBase) {
|
|
27
|
+
const docs = await pod.profileDocs(podBase);
|
|
28
|
+
return pod.webIdValues(docs, SOLID + 'publicTypeIndex')[0] || null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Every actor the index registers, as the instances it names. */
|
|
32
|
+
export async function actorsIn(pod, indexUrl) {
|
|
33
|
+
const g = await pod.readRdf(indexUrl).catch(() => null);
|
|
34
|
+
if (!g) return [];
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const reg of g.each(null, pod.sym(SOLID + 'forClass'), pod.sym(AS_ACTOR))) {
|
|
37
|
+
for (const inst of g.each(reg, pod.sym(SOLID + 'instance'), null)) out.push(inst.value);
|
|
38
|
+
}
|
|
39
|
+
return [...new Set(out)];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// One registration per actor, named by the actor, so registering again finds
|
|
43
|
+
// the same one.
|
|
44
|
+
function regFor(indexUrl, actorUrl) {
|
|
45
|
+
let h = 0;
|
|
46
|
+
for (const c of actorUrl) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
|
47
|
+
return `${indexUrl}#actor-${h.toString(36)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Add the account's registration, unless the index already has one for it. */
|
|
51
|
+
export async function register(pod, indexUrl, actorUrl) {
|
|
52
|
+
const g = await pod.readRdf(indexUrl);
|
|
53
|
+
if (!g) throw new Error(`no type index at ${indexUrl}`);
|
|
54
|
+
if ((await actorsIn(pod, indexUrl)).includes(actorUrl)) return false;
|
|
55
|
+
const reg = pod.sym(regFor(indexUrl, actorUrl));
|
|
56
|
+
const status = await pod.writeRdfChecked(indexUrl, g, {
|
|
57
|
+
inserts: [
|
|
58
|
+
[reg, pod.sym(RDF_TYPE), pod.sym(SOLID + 'TypeRegistration')],
|
|
59
|
+
[reg, pod.sym(SOLID + 'forClass'), pod.sym(AS_ACTOR)],
|
|
60
|
+
[reg, pod.sym(SOLID + 'instance'), pod.sym(actorUrl)],
|
|
61
|
+
],
|
|
62
|
+
mustDescribe: indexUrl,
|
|
63
|
+
});
|
|
64
|
+
if (status >= 300) throw new Error(`the type index at ${indexUrl} did not take the registration (${status})`);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Make a public type index and name it from the profile. Only ever called
|
|
70
|
+
* after the person has said yes. An index already at the usual place is kept,
|
|
71
|
+
* not overwritten; it is only named.
|
|
72
|
+
*/
|
|
73
|
+
export async function createPublicIndex(pod, podBase) {
|
|
74
|
+
const url = newIndexUrl(podBase);
|
|
75
|
+
const doc = pod.sym(url);
|
|
76
|
+
const existing = await pod.readRdf(url).catch(() => null);
|
|
77
|
+
if (!existing) {
|
|
78
|
+
const status = await pod.writeRdfChecked(url, null, {
|
|
79
|
+
inserts: [[doc, pod.sym(RDF_TYPE), pod.sym(SOLID + 'TypeIndex')],
|
|
80
|
+
[doc, pod.sym(RDF_TYPE), pod.sym(SOLID + 'ListedDocument')]],
|
|
81
|
+
mustDescribe: url,
|
|
82
|
+
});
|
|
83
|
+
if (status >= 300) throw new Error(`could not make a type index at ${url} (${status})`);
|
|
84
|
+
}
|
|
85
|
+
// Public, as the profile is: an app finds your things by reading it.
|
|
86
|
+
await pod.setAcl(url, PUBLIC_READ);
|
|
87
|
+
await pod.writeAboutWebId(podBase, {
|
|
88
|
+
inserts: [[pod.sym(pod.webId), pod.sym(SOLID + 'publicTypeIndex'), doc]],
|
|
89
|
+
});
|
|
90
|
+
return url;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The actors this person's index says live in this pod. Empty when there is no
|
|
95
|
+
* index or nothing registered.
|
|
96
|
+
*/
|
|
97
|
+
export async function registeredActors(pod, podBase) {
|
|
98
|
+
const index = await findPublicIndex(pod, podBase).catch(() => null);
|
|
99
|
+
if (!index) return [];
|
|
100
|
+
return (await actorsIn(pod, index)).filter(a => a.startsWith(podBase) && a.endsWith('ap/actor'));
|
|
101
|
+
}
|
package/lib/pod/urls.mjs
CHANGED
|
@@ -46,6 +46,12 @@ export function apUrls(remotePod, root, { publicBase = null } = {}) {
|
|
|
46
46
|
pendingFollowers: face + 'ap/private/pending-followers',
|
|
47
47
|
pendingFollowing: face + 'ap/private/pending-following',
|
|
48
48
|
blocked: face + 'ap/private/blocked',
|
|
49
|
+
// The outbox as its owner reads it (every message, §5.1) and what the
|
|
50
|
+
// owner has liked (§5.5): the owner's alone, so they sit here too — and
|
|
51
|
+
// at the pod's own address even for a fronted identity, because the owner
|
|
52
|
+
// reads them there, with a credential the pod checks.
|
|
53
|
+
ownOutbox: home + 'ap/private/outbox',
|
|
54
|
+
liked: home + 'ap/private/liked',
|
|
49
55
|
profileHtml: face + 'ap/profile.html',
|
|
50
56
|
// Media stays on the pod even when fronted: attachment urls are not
|
|
51
57
|
// identity-checked by remotes, and proxying blobs would be pure cost.
|
package/lib/server/embed.mjs
CHANGED
|
@@ -45,10 +45,10 @@ const mintSecret = () => crypto.randomBytes(32).toString('base64');
|
|
|
45
45
|
* secret is moved into the pod and the host's copy removed, so the owner's
|
|
46
46
|
* existing door link goes on working.
|
|
47
47
|
*/
|
|
48
|
-
export async function ensureDoorSecret(session, podBase, { rotate = false, dataDir = null, handle = null, log = () => {} } = {}) {
|
|
48
|
+
export async function ensureDoorSecret(session, podBase, { rotate = false, dataDir = null, handle = null, root = 'fedipod/', log = () => {} } = {}) {
|
|
49
49
|
const base = podBase.endsWith('/') ? podBase : podBase + '/';
|
|
50
|
-
// Under the identity's own tree
|
|
51
|
-
const url = apUrls(base,
|
|
50
|
+
// Under the identity's own tree, where the gate reads it back.
|
|
51
|
+
const url = apUrls(base, root).state + 'door-secret.json';
|
|
52
52
|
const onHost = dataDir && handle ? path.join(dataDir, handle, 'door-secret.json') : null;
|
|
53
53
|
|
|
54
54
|
if (!rotate) {
|
|
@@ -125,7 +125,7 @@ export function handleFor(podBase) {
|
|
|
125
125
|
* pod does not travel to, so handing the pod over hands over an identity that
|
|
126
126
|
* cannot sign. An identity set up before this gets the mode added.
|
|
127
127
|
*/
|
|
128
|
-
function ensureCredential(home, { podBase, webId }) {
|
|
128
|
+
function ensureCredential(home, { podBase, webId, root = 'fedipod/' }) {
|
|
129
129
|
const file = path.join(home, 'credential.json');
|
|
130
130
|
try {
|
|
131
131
|
const rec = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
@@ -138,7 +138,7 @@ function ensureCredential(home, { podBase, webId }) {
|
|
|
138
138
|
webId,
|
|
139
139
|
remotePod: podBase.endsWith('/') ? podBase : podBase + '/',
|
|
140
140
|
createdAt: new Date().toISOString(),
|
|
141
|
-
root
|
|
141
|
+
root,
|
|
142
142
|
keysMode: 'pod',
|
|
143
143
|
};
|
|
144
144
|
writeJsonAtomic(file, rec, { mode: 0o600 });
|
|
@@ -269,6 +269,7 @@ export async function deliverToInbox(agent, request, { podPut, gatewayWebId = nu
|
|
|
269
269
|
*/
|
|
270
270
|
export async function startEmbeddedAgent({
|
|
271
271
|
podBase,
|
|
272
|
+
root = 'fedipod/',
|
|
272
273
|
dataDir,
|
|
273
274
|
session,
|
|
274
275
|
resourceStore = null,
|
|
@@ -285,7 +286,7 @@ export async function startEmbeddedAgent({
|
|
|
285
286
|
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
286
287
|
|
|
287
288
|
const webId = base + webIdSuffix.replace(/^\//u, '');
|
|
288
|
-
const cred = ensureCredential(home, { podBase: base, webId });
|
|
289
|
+
const cred = ensureCredential(home, { podBase: base, webId, root });
|
|
289
290
|
|
|
290
291
|
const agent = new Agent({ home, log });
|
|
291
292
|
agent.log = log;
|
|
@@ -60,6 +60,14 @@ export function fediAccount({
|
|
|
60
60
|
return body;
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
+
// A collection, or a page of one, may be named by its address or given
|
|
64
|
+
// whole in place (ActivityPub allows either); the whole one is used as it
|
|
65
|
+
// is, an address is fetched.
|
|
66
|
+
const idOf = (v) => (typeof v === 'string' ? v : v?.id || null);
|
|
67
|
+
const asDoc = async (v) => (v && typeof v === 'object'
|
|
68
|
+
? (v.type ? v : await asDoc(v.id))
|
|
69
|
+
: (typeof v === 'string' ? await json(v, { headers: { accept: 'application/activity+json' } }) : null));
|
|
70
|
+
|
|
63
71
|
// Only loaded when `outbox({ rdf: true })` is actually called, so nobody
|
|
64
72
|
// pays for a JSON-LD parser just by importing this file. Not in
|
|
65
73
|
// `dependencies` — the app supplies "jsonld" if it wants this flag to work.
|
|
@@ -76,15 +84,16 @@ export function fediAccount({
|
|
|
76
84
|
if (!actorUrl) return null;
|
|
77
85
|
const jsonld = await loadJsonLd();
|
|
78
86
|
const actorDoc = await json(actorUrl, { headers: { accept: 'application/activity+json' } });
|
|
79
|
-
|
|
87
|
+
const head = await asDoc(actorDoc?.outbox);
|
|
88
|
+
let page = head?.first ?? null;
|
|
80
89
|
const quads = []; const seen = new Set(); let collected = 0;
|
|
81
|
-
while (
|
|
82
|
-
seen.add(
|
|
83
|
-
const
|
|
84
|
-
if (!
|
|
85
|
-
quads.push(...(await jsonld.toRDF(
|
|
86
|
-
collected += (
|
|
87
|
-
|
|
90
|
+
while (page && collected < limit && !seen.has(idOf(page) || page)) {
|
|
91
|
+
seen.add(idOf(page) || page);
|
|
92
|
+
const doc = await asDoc(page);
|
|
93
|
+
if (!doc) break;
|
|
94
|
+
quads.push(...(await jsonld.toRDF(doc)));
|
|
95
|
+
collected += (doc.orderedItems || []).length;
|
|
96
|
+
page = doc.next ?? null;
|
|
88
97
|
}
|
|
89
98
|
return quads;
|
|
90
99
|
};
|
|
@@ -226,7 +235,7 @@ export function fediAccount({
|
|
|
226
235
|
let actor = p.actor || pick(me, FOAF_ACCOUNT);
|
|
227
236
|
let door = pick(me, AS_OUTBOX);
|
|
228
237
|
const doc = actor ? await json(actor, { headers: { accept: 'application/activity+json' } }) : null;
|
|
229
|
-
if (!door && doc?.outbox) door = doc.outbox;
|
|
238
|
+
if (!door && doc?.outbox) door = idOf(doc.outbox);
|
|
230
239
|
let root = p.root || null;
|
|
231
240
|
if (!root && doc?.preferredUsername && actor) {
|
|
232
241
|
const { aliases } = await webfinger(doc.preferredUsername, new URL(actor).host).catch(() => ({ aliases: [] }));
|
|
@@ -340,7 +349,7 @@ export function fediAccount({
|
|
|
340
349
|
fetch: session.fetch,
|
|
341
350
|
async profile() {
|
|
342
351
|
const doc = facts.actor ? await json(facts.actor, { headers: { accept: 'application/activity+json' } }) : null;
|
|
343
|
-
const count = async (
|
|
352
|
+
const count = async (v) => (await asDoc(v))?.totalItems ?? null;
|
|
344
353
|
return { handle: facts.handle, name: doc?.name || null, url: doc?.url || null, avatar: doc?.icon?.url || (typeof doc?.icon === 'string' ? doc.icon : null), bio: doc?.summary || '',
|
|
345
354
|
followers: await count(doc?.followers), following: await count(doc?.following), posts: await count(doc?.outbox), actor: facts.actor, webId: facts.webId };
|
|
346
355
|
},
|
package/lib/session/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fediverse-account",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
package/package.json
CHANGED
package/run-agent.mjs
CHANGED
|
@@ -552,6 +552,8 @@ export class Agent {
|
|
|
552
552
|
// Own posts the outbox names and the timeline index lacks come back here,
|
|
553
553
|
// before anything acts on the index.
|
|
554
554
|
await this.publisher.healStatuses().catch(e => this.log(`healing the timeline index: ${e.message}`));
|
|
555
|
+
// Likes made before the liked list existed become its first entries.
|
|
556
|
+
try { this.publisher.backfillLiked(); } catch (e) { this.log(`liked list: ${e.message}`); }
|
|
555
557
|
this.lease.onLost = () => this.demote();
|
|
556
558
|
this.lease.startRenewal();
|
|
557
559
|
this.deliverer.startQueue();
|
package/web/admin/actors.js
CHANGED
|
@@ -136,6 +136,8 @@ $('new-actor-form').addEventListener('submit', async (ev) => {
|
|
|
136
136
|
};
|
|
137
137
|
if (answers.mode === 'new') answers.podName = $('new-podname').value.trim() || handle;
|
|
138
138
|
else answers.pod = $('new-pod').value.trim();
|
|
139
|
+
answers.container = $('new-container').value.trim();
|
|
140
|
+
answers.createIndex = $('new-createIndex').checked;
|
|
139
141
|
$('new-actor-go').disabled = true;
|
|
140
142
|
say(`setting up ${handle || 'the new actor'} — this takes a while`);
|
|
141
143
|
const r = await write('/new-actor', answers, `setting up ${handle}`);
|
package/web/admin/index.html
CHANGED
|
@@ -272,6 +272,15 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
|
|
|
272
272
|
<label for="new-pod">Pod address</label>
|
|
273
273
|
<input type="url" id="new-pod" autocomplete="url" placeholder="https://you.solidcommunity.net/">
|
|
274
274
|
</div>
|
|
275
|
+
<label for="new-container">Store your data in a container named fedipod, inside this container</label>
|
|
276
|
+
<input type="text" id="new-container" autocomplete="off" spellcheck="false" placeholder="/"
|
|
277
|
+
aria-describedby="new-container-hint">
|
|
278
|
+
<p class="hint" id="new-container-hint">Leave it empty for your pod's root. This is permanent.</p>
|
|
279
|
+
<p id="new-typeindex-text">FediPod records where the account lives in your pod's public type index,
|
|
280
|
+
the list Solid apps use to find your things. A new pod has none; setup stops if yours has none
|
|
281
|
+
and you have not said yes here. It will be readable by anyone, like your profile.</p>
|
|
282
|
+
<label class="choice"><input type="checkbox" id="new-createIndex" aria-describedby="new-typeindex-text">
|
|
283
|
+
If my pod has no public type index, create one</label>
|
|
275
284
|
</fieldset>
|
|
276
285
|
|
|
277
286
|
<p>
|
package/web/admin/record.js
CHANGED
|
@@ -269,6 +269,9 @@ function render() {
|
|
|
269
269
|
['Fediverse identity', config.address || `@${config.handle} — no resolvable address`,
|
|
270
270
|
config.accountId && config.address ? { href: `/admin/client/#/a/${config.accountId}` } : null],
|
|
271
271
|
['Solid identity', config.webId, config.remotePod ? { href: config.remotePod, blank: true } : null],
|
|
272
|
+
// Where on the pod the account's data lives: the container its owner chose.
|
|
273
|
+
['stored at', config.remotePod && config.root ? config.remotePod + config.root : null,
|
|
274
|
+
config.remotePod && config.root ? { href: config.remotePod + config.root, blank: true } : null],
|
|
272
275
|
['Other identities', 'ctl'],
|
|
273
276
|
['local store', config.home],
|
|
274
277
|
// The address you actually open, not the bare number — the named origin when
|
|
@@ -362,7 +365,7 @@ function render() {
|
|
|
362
365
|
if (!config.address) {
|
|
363
366
|
const p = document.createElement('p');
|
|
364
367
|
p.className = 'warn';
|
|
365
|
-
p.textContent = `${config.remotePod} is
|
|
368
|
+
p.textContent = `${config.remotePod} is a suffixed pod, so this actor cannot be `
|
|
366
369
|
+ 'discovered as a handle by other servers. Posting and reading still work.';
|
|
367
370
|
$('pane-identity').appendChild(p);
|
|
368
371
|
}
|
|
@@ -151,7 +151,7 @@ button:disabled { opacity: .5; cursor: default; }
|
|
|
151
151
|
<input type="url" id="issuerOther" name="issuerOther" autocomplete="url" placeholder="https://pods.example.org">
|
|
152
152
|
</div>
|
|
153
153
|
<p class="hint" id="issuer-new-hint">The Community Solid Server providers from
|
|
154
|
-
solidproject.org. A provider that
|
|
154
|
+
solidproject.org. A provider that makes suffixed pods gives your address at a
|
|
155
155
|
gateway instead.</p>
|
|
156
156
|
</div>
|
|
157
157
|
|
|
@@ -184,6 +184,22 @@ button:disabled { opacity: .5; cursor: default; }
|
|
|
184
184
|
<label for="pod">Pod address</label>
|
|
185
185
|
<input type="url" id="pod" name="pod" autocomplete="url" placeholder="https://you.solidcommunity.net/">
|
|
186
186
|
</div>
|
|
187
|
+
|
|
188
|
+
<div id="row-container">
|
|
189
|
+
<label for="container">Store your data in a container named fedipod, inside this container</label>
|
|
190
|
+
<input type="text" id="container" name="container" autocomplete="off" spellcheck="false"
|
|
191
|
+
placeholder="/" aria-describedby="container-hint">
|
|
192
|
+
<p class="hint" id="container-hint">Leave it empty for your pod's root. This is permanent.
|
|
193
|
+
<span id="place"></span></p>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<div id="row-typeindex" hidden>
|
|
197
|
+
<p id="typeindex-text">Your pod has no public type index, the list Solid apps use to find your
|
|
198
|
+
things. FediPod needs one to record where your account lives. It will be readable by anyone,
|
|
199
|
+
like your profile.</p>
|
|
200
|
+
<label class="choice"><input type="checkbox" id="createIndex" name="createIndex"
|
|
201
|
+
aria-describedby="typeindex-text"> Create a public type index on my pod</label>
|
|
202
|
+
</div>
|
|
187
203
|
</fieldset>
|
|
188
204
|
|
|
189
205
|
<p id="row-reenter" class="warn" hidden>Finishing a setup that already has an account credential.
|
package/web/admin/setup/setup.js
CHANGED
|
@@ -108,6 +108,9 @@ function answers() {
|
|
|
108
108
|
};
|
|
109
109
|
if (mode === 'new') a.podName = f.podName.value.trim() || a.handle;
|
|
110
110
|
else a.pod = f.pod.value.trim();
|
|
111
|
+
// The container that holds fedipod/, and the yes to a new public type index.
|
|
112
|
+
a.container = f.container.value.trim();
|
|
113
|
+
a.createIndex = f.createIndex.checked;
|
|
111
114
|
if (!state.resumable && !state.passwordSupplied) a.password = f.password.value;
|
|
112
115
|
return a;
|
|
113
116
|
}
|
|
@@ -116,13 +119,13 @@ function answers() {
|
|
|
116
119
|
// account?", in the same words, as you type.
|
|
117
120
|
const NOTES = {
|
|
118
121
|
'pod-is-a-path': (a) =>
|
|
119
|
-
`This pod is
|
|
120
|
-
+ 'answered only
|
|
122
|
+
`This pod is suffixed (on ${host(a.pod)}), not subdomained. WebFinger is `
|
|
123
|
+
+ 'answered only for a subdomained pod, so other servers '
|
|
121
124
|
+ 'will not find this address. Posting and reading still work; being discovered does not.',
|
|
122
125
|
};
|
|
123
126
|
const REFUSALS = {
|
|
124
127
|
'group-needs-host-root': () =>
|
|
125
|
-
'A group needs a pod
|
|
128
|
+
'A group needs a subdomained pod, or nobody could ever find it. '
|
|
126
129
|
+ 'Give the group a pod of its own, or set this up as a person.',
|
|
127
130
|
};
|
|
128
131
|
const host = (u) => { try { return new URL(u).host; } catch { return u; } };
|
|
@@ -149,6 +152,11 @@ async function preview() {
|
|
|
149
152
|
const { json } = await postJson('/setup/check', a);
|
|
150
153
|
if (!json) return;
|
|
151
154
|
$('preview').textContent = json.address || '…';
|
|
155
|
+
// Where the data will go, and the type index question when the pod has
|
|
156
|
+
// none (a new pod never does).
|
|
157
|
+
$('place').textContent = json.placeProblem ? json.placeProblem : (json.place ? `Your data will be at ${json.place}.` : '');
|
|
158
|
+
$('row-typeindex').hidden = json.typeIndex === true;
|
|
159
|
+
try { $('container').placeholder = a.mode === 'existing' && a.pod ? new URL(a.pod).pathname : '/'; } catch { /* not a URL yet */ }
|
|
152
160
|
// The address-shape choice: hidden for a group (a group cannot front yet),
|
|
153
161
|
// locked to the gateway for a pod on a suffix-based host, an open choice
|
|
154
162
|
// for a pod at its own host.
|
|
@@ -165,7 +173,7 @@ async function preview() {
|
|
|
165
173
|
if (note) {
|
|
166
174
|
note.hidden = !forced;
|
|
167
175
|
note.textContent = forced
|
|
168
|
-
? 'Your pod is
|
|
176
|
+
? 'Your pod is suffixed, so its address lives at the gateway. Your posts, key and data stay on your pod.'
|
|
169
177
|
: '';
|
|
170
178
|
}
|
|
171
179
|
}
|
|
@@ -188,6 +196,11 @@ async function preview() {
|
|
|
188
196
|
async function onSubmit(ev) {
|
|
189
197
|
ev.preventDefault();
|
|
190
198
|
$('form-error').textContent = '';
|
|
199
|
+
// No type index and no yes: stop here, before anything is sent.
|
|
200
|
+
if (!$('row-typeindex').hidden && !$('createIndex').checked) {
|
|
201
|
+
$('form-error').textContent = 'Setup stopped. Nothing was written to your pod. FediPod needs a public type index to record where your account lives.';
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
191
204
|
$('submit').disabled = true;
|
|
192
205
|
const { status, json } = await postJson('/setup', answers());
|
|
193
206
|
if (status !== 202) {
|
|
@@ -300,7 +313,7 @@ function paneDone(result) {
|
|
|
300
313
|
$('done-address').textContent = result?.address || `@${result?.handle} — no resolvable address`;
|
|
301
314
|
const notes = [];
|
|
302
315
|
if (!result?.resolvable) {
|
|
303
|
-
notes.push(`${result?.pod} is
|
|
316
|
+
notes.push(`${result?.pod} is a suffixed pod, so this address cannot be `
|
|
304
317
|
+ 'discovered by other servers. Posting and reading still work.');
|
|
305
318
|
}
|
|
306
319
|
if (result?.unreachable?.length) {
|
package/web/app/README.md
CHANGED
|
@@ -77,5 +77,5 @@ See the `MastoApi` options in `agent.mjs`.
|
|
|
77
77
|
| **Web push** | `shims/web-push.mjs` is a no-op. `vapid` is omitted, and a client that subscribes anyway gets a 422 rather than a subscription nothing will push to. |
|
|
78
78
|
| **Scheduled posts** | nothing runs between now and the scheduled time. A `scheduled_at` is refused with a 422 that says so — accepting one was silent loss. |
|
|
79
79
|
| **Groups** | sign-up makes personal identities only (`signup.mjs`), and the moderation surface is not here. Joining a group works; hosting one needs the DeviceAgent. See `groups.md`. |
|
|
80
|
-
| **Changing where the address lives** | the shape is chosen at sign-up (`signup.mjs`): on the pod, `@you@yourpod` with the gateway as a mail door, or at the gateway, `@you@front` — and a pod
|
|
80
|
+
| **Changing where the address lives** | the shape is chosen at sign-up (`signup.mjs`): on the pod, `@you@yourpod` with the gateway as a mail door, or at the gateway, `@you@front` — and a suffixed pod is always fronted. `admin-facade.mjs` refuses changing it afterwards, because a rename needs a restart a browser does not have. |
|
|
81
81
|
| **Moving the private half** | `/state-move` is about filesystem paths and `credential.json`. A browser has neither; its private half is always on the pod. |
|
package/web/app/admin-facade.mjs
CHANGED
|
@@ -356,7 +356,7 @@ export class AdminFacade {
|
|
|
356
356
|
const aliases = [...(cfg.aliases || [])];
|
|
357
357
|
if (body.add) {
|
|
358
358
|
if (!webfingerHost(urls.base) && !cfg.gateway?.frontActor) {
|
|
359
|
-
return json(400, { error: 'this pod is
|
|
359
|
+
return json(400, { error: 'this pod is suffixed, so other servers could never resolve it as a Move target' });
|
|
360
360
|
}
|
|
361
361
|
const id = await this.resolveActor(body.add);
|
|
362
362
|
if (!id) return json(400, { error: `could not fetch the old account (${body.add}) — enter its URL or @user@host, and it must answer` });
|