fedipod 1.2.0 → 1.3.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/client/c2s.mjs +6 -4
- package/lib/core/as2.mjs +64 -18
- package/lib/core/graphview.mjs +269 -0
- package/lib/core/intake/index.mjs +4 -4
- package/lib/core/intake/verify.mjs +4 -3
- package/lib/core/publisher/restore.mjs +7 -4
- package/lib/server/embed.mjs +87 -11
- package/package.json +1 -1
- package/web/app/agent.mjs +2 -4
- package/web/app/boot.mjs +2 -3
- package/web/app/dist/boot.js +22 -3
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +240 -42
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/keys-browser.mjs +27 -4
- package/web/app/signup.mjs +2 -3
- package/web/app/site/boot.js +22 -3
- package/web/app/site/sw.js +240 -42
package/lib/client/c2s.mjs
CHANGED
|
@@ -192,12 +192,14 @@ export class C2S {
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
// Read as JSON-LD, so a client may send its activity with whatever context
|
|
195
|
-
// it likes and still be understood.
|
|
196
|
-
//
|
|
197
|
-
//
|
|
195
|
+
// it likes and still be understood. What is read is the GRAPH: `dispatch`
|
|
196
|
+
// takes decisions from it and the publisher builds the document that is
|
|
197
|
+
// actually posted, so nothing a client sent is republished verbatim and a
|
|
198
|
+
// term it aliased still means what it says.
|
|
198
199
|
let activity;
|
|
199
200
|
try {
|
|
200
|
-
|
|
201
|
+
const read = await readLenient(await readBody(req));
|
|
202
|
+
activity = read.view ?? read.doc;
|
|
201
203
|
} catch (e) {
|
|
202
204
|
return this.send(res, 400, { error: `unreadable body: ${e.message}` });
|
|
203
205
|
}
|
package/lib/core/as2.mjs
CHANGED
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
// read it as one. Expanding it to a graph is what makes the document mean the
|
|
5
5
|
// same thing however its sender chose to write it — `"type": "Create"` and
|
|
6
6
|
// `"@type": "as:Create"` are the same statement, and only a JSON-LD reader
|
|
7
|
-
// knows that.
|
|
8
|
-
//
|
|
7
|
+
// knows that.
|
|
8
|
+
//
|
|
9
|
+
// The graph is what the handlers read, through lib/core/graphview.mjs. `doc`,
|
|
10
|
+
// the copy compacted against the standard context, is kept for the callers
|
|
11
|
+
// that republish what they read rather than act on it — compaction alone drops
|
|
12
|
+
// any term the sender defined in its own context, so it is the weaker read.
|
|
9
13
|
//
|
|
10
14
|
// What we STORE and SEND is untouched by any of this. Bytes go into the pod as
|
|
11
15
|
// they arrived, and lib/core/wire.mjs builds what we send as compacted AS2
|
|
@@ -15,16 +19,18 @@
|
|
|
15
19
|
// common shape: one that skipped it would not be read as JSON-LD, and would
|
|
16
20
|
// produce no graph, so nothing could be validated against it.
|
|
17
21
|
//
|
|
18
|
-
// CONTEXTS ARE NEVER FETCHED.
|
|
22
|
+
// CONTEXTS ARE NEVER FETCHED. The loader serves the fourteen the fediverse
|
|
19
23
|
// actually uses and refuses every other URL. That refusal is the point: this
|
|
20
24
|
// code runs on documents a stranger wrote, in the same path the SSRF guards
|
|
21
25
|
// protect, and expanding one would otherwise mean dereferencing a URL of their
|
|
22
|
-
// choosing. A document naming a context we do not hold is
|
|
23
|
-
//
|
|
26
|
+
// choosing. A document naming a context we do not hold is not lost for it —
|
|
27
|
+
// `groundContext` reads it against the contexts we hold and says so in
|
|
28
|
+
// `degraded` — but nothing is ever fetched to make that happen.
|
|
24
29
|
|
|
25
30
|
import jsonld from 'jsonld';
|
|
26
31
|
import { AS_CTX } from './wire.mjs';
|
|
27
32
|
import { CONTEXTS } from './contexts/index.mjs';
|
|
33
|
+
import { graphView } from './graphview.mjs';
|
|
28
34
|
|
|
29
35
|
export { CONTEXTS };
|
|
30
36
|
|
|
@@ -99,26 +105,66 @@ export async function parseAS2(raw) {
|
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
108
|
+
* Rewrite a document's `@context` to one we can read without fetching anything.
|
|
109
|
+
*
|
|
110
|
+
* A document carrying no `@context` is not a puzzle: AS2 Core §2.1 says a
|
|
111
|
+
* consumer meeting `application/activity+json` without one MUST assume the
|
|
112
|
+
* normative context still applies. That is what this does. A document naming a
|
|
113
|
+
* context we do not hold keeps every entry we DO hold — inline objects
|
|
114
|
+
* included, since those need no fetch — and loses only the unfetchable ones,
|
|
115
|
+
* so a Mastodon post with an unknown extension still reads as a post.
|
|
116
|
+
*
|
|
117
|
+
* Nothing here dereferences a URL. The result names only contexts already on
|
|
118
|
+
* disk, so the guarantee that a stranger's document cannot make us reach out
|
|
119
|
+
* survives the fallback.
|
|
120
|
+
*/
|
|
121
|
+
export function groundContext(input) {
|
|
122
|
+
const ctx = input['@context'];
|
|
123
|
+
const held = (c) => typeof c === 'string' ? Object.hasOwn(CONTEXTS, c) : (c && typeof c === 'object');
|
|
124
|
+
let kept;
|
|
125
|
+
if (ctx === undefined || ctx === null) kept = [];
|
|
126
|
+
else if (Array.isArray(ctx)) kept = ctx.filter(held);
|
|
127
|
+
else kept = held(ctx) ? [ctx] : [];
|
|
128
|
+
if (!kept.some(c => c === AS_CTX)) kept.unshift(AS_CTX);
|
|
129
|
+
return { ...input, '@context': kept.length === 1 ? kept[0] : kept };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Read AS2 bytes, and when they cannot be read as JSON-LD as written, read
|
|
134
|
+
* them against the contexts we do hold.
|
|
104
135
|
*
|
|
105
136
|
* A delivery naming a context we do not hold, or carrying none at all, is
|
|
106
|
-
* still a delivery somebody sent.
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* letting it pass unnoticed.
|
|
137
|
+
* still a delivery somebody sent. It is now grounded rather than refused, so
|
|
138
|
+
* `graph` is present for anything that is JSON at all — which is what lets the
|
|
139
|
+
* handlers read the graph instead of the document. `degraded` still carries
|
|
140
|
+
* the reason the document could not be read exactly as written, so a caller
|
|
141
|
+
* records what happened instead of letting it pass unnoticed.
|
|
111
142
|
*
|
|
112
|
-
* `doc`
|
|
113
|
-
* plain read would have concluded too.
|
|
143
|
+
* `doc` and `graph` are null only when the bytes are not JSON at all, which is
|
|
144
|
+
* what the plain read would have concluded too.
|
|
114
145
|
*/
|
|
115
146
|
export async function readLenient(raw) {
|
|
116
147
|
try {
|
|
117
148
|
const { doc, graph } = await parseAS2(raw);
|
|
118
|
-
return { doc, graph, degraded: null };
|
|
149
|
+
return { doc, graph, view: graphView(graph), degraded: null };
|
|
119
150
|
} catch (e) {
|
|
120
|
-
let
|
|
121
|
-
try {
|
|
122
|
-
|
|
151
|
+
let input = null;
|
|
152
|
+
try { input = typeof raw === 'string' ? JSON.parse(raw) : (raw ?? null); } catch { /* not JSON either */ }
|
|
153
|
+
if (!input || typeof input !== 'object') return { doc: null, graph: null, view: null, degraded: e.message };
|
|
154
|
+
// Grounded, so a graph exists for every document that is JSON at all.
|
|
155
|
+
//
|
|
156
|
+
// `doc` stays the bytes as they were parsed, NOT the grounded compaction.
|
|
157
|
+
// Compacting shortens an IRI to whatever term names it — `#Public` comes
|
|
158
|
+
// back as `as:Public` — and the callers that still read `doc` republish
|
|
159
|
+
// what they read, so rewriting it here silently changed what they posted.
|
|
160
|
+
try {
|
|
161
|
+
const { graph } = await parseAS2(groundContext(input));
|
|
162
|
+
return { doc: input, graph, view: graphView(graph), degraded: e.message };
|
|
163
|
+
} catch (inner) {
|
|
164
|
+
// Grounding names only held contexts, so reaching here means the document
|
|
165
|
+
// is malformed JSON-LD rather than unfetchable. Hand back what a plain
|
|
166
|
+
// read would have seen, with no graph, as this always did.
|
|
167
|
+
return { doc: input, graph: null, view: null, degraded: `${e.message}; grounded read also failed: ${inner.message}` };
|
|
168
|
+
}
|
|
123
169
|
}
|
|
124
170
|
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// graphview.mjs — what the handlers read.
|
|
2
|
+
//
|
|
3
|
+
// `parseAS2` produces quads. This turns them back into something a handler can
|
|
4
|
+
// read by name, so the thing being read is the GRAPH: every property access
|
|
5
|
+
// here is a lookup over statements, not a key on the JSON a stranger sent.
|
|
6
|
+
//
|
|
7
|
+
// That distinction is the whole point. A document can say the same thing in
|
|
8
|
+
// many ways — `"type": "Create"`, `"@type": "as:Create"`, a term aliased by the
|
|
9
|
+
// sender's own context — and all of them leave the same statement in the
|
|
10
|
+
// graph. Reading the graph means a handler sees one answer without knowing any
|
|
11
|
+
// of that happened.
|
|
12
|
+
//
|
|
13
|
+
// Names are resolved through the contexts we already hold, AS2 first. They are
|
|
14
|
+
// not hardcoded: `movedTo` is not in the AS2 context at all (it lives in
|
|
15
|
+
// miscellany), and `inbox` is `ldp:inbox` rather than an `as:` term, so a map
|
|
16
|
+
// written by hand would get both wrong.
|
|
17
|
+
|
|
18
|
+
import { CONTEXTS } from './contexts/index.mjs';
|
|
19
|
+
import { AS_CTX } from './wire.mjs';
|
|
20
|
+
|
|
21
|
+
const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
|
|
22
|
+
const RDF_FIRST = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#first';
|
|
23
|
+
const RDF_REST = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#rest';
|
|
24
|
+
const RDF_NIL = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#nil';
|
|
25
|
+
|
|
26
|
+
// Properties a handler treats as a collection. JSON-LD compaction would hand
|
|
27
|
+
// back a bare value when there is only one, and `Array.isArray` checks in the
|
|
28
|
+
// handlers then read it as empty. These always answer with an array.
|
|
29
|
+
// A stranger's document decides the shape of this graph, so the walk is capped
|
|
30
|
+
// rather than trusted to terminate on its own.
|
|
31
|
+
const MAX_DEPTH = 12;
|
|
32
|
+
|
|
33
|
+
const ALWAYS_LIST = new Set([
|
|
34
|
+
'items', 'orderedItems', 'to', 'cc', 'bto', 'bcc', 'tag', 'attachment',
|
|
35
|
+
'anyOf', 'oneOf', 'audience', 'attributedTo', 'alsoKnownAs',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/** Expand `as:object` against a context's own prefix declarations. */
|
|
39
|
+
function expandIri(value, prefixes) {
|
|
40
|
+
if (typeof value !== 'string') return null;
|
|
41
|
+
if (/^https?:\/\//.test(value)) return value;
|
|
42
|
+
const colon = value.indexOf(':');
|
|
43
|
+
if (colon < 1) return null;
|
|
44
|
+
const base = prefixes[value.slice(0, colon)];
|
|
45
|
+
return base ? base + value.slice(colon + 1) : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* term -> { iri, list } over every context we hold, AS2 winning ties.
|
|
50
|
+
*
|
|
51
|
+
* Built once. A term already claimed by an earlier context is not overwritten,
|
|
52
|
+
* and AS2 is walked first, so an extension cannot quietly redefine `object`.
|
|
53
|
+
*/
|
|
54
|
+
function buildTerms() {
|
|
55
|
+
const terms = new Map();
|
|
56
|
+
const order = [AS_CTX, ...Object.keys(CONTEXTS).filter(u => u !== AS_CTX)];
|
|
57
|
+
for (const url of order) {
|
|
58
|
+
const ctx = CONTEXTS[url]?.['@context'];
|
|
59
|
+
if (!ctx || typeof ctx !== 'object') continue;
|
|
60
|
+
const prefixes = {};
|
|
61
|
+
for (const [k, v] of Object.entries(ctx)) {
|
|
62
|
+
if (typeof v === 'string' && /^https?:\/\//.test(v)) prefixes[k] = v;
|
|
63
|
+
}
|
|
64
|
+
for (const [name, def] of Object.entries(ctx)) {
|
|
65
|
+
if (name.startsWith('@') || terms.has(name)) continue;
|
|
66
|
+
const raw = typeof def === 'string' ? def : def?.['@id'];
|
|
67
|
+
const iri = expandIri(raw, prefixes);
|
|
68
|
+
if (!iri) continue;
|
|
69
|
+
terms.set(name, { iri, list: def?.['@container'] === '@list', container: def?.['@container'] ?? null });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return terms;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const TERMS = buildTerms();
|
|
76
|
+
|
|
77
|
+
/** iri -> shortest term naming it, so a type comes back as `Create`. */
|
|
78
|
+
const BY_IRI = (() => {
|
|
79
|
+
const m = new Map();
|
|
80
|
+
for (const [name, { iri }] of TERMS) {
|
|
81
|
+
const held = m.get(iri);
|
|
82
|
+
if (!held || name.length < held.length) m.set(iri, name);
|
|
83
|
+
}
|
|
84
|
+
return m;
|
|
85
|
+
})();
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* iri -> every term naming it.
|
|
89
|
+
*
|
|
90
|
+
* More than one term can mean the same predicate: `items` and `orderedItems`
|
|
91
|
+
* are both `as:items`, and only the list tells them apart. Emitting just the
|
|
92
|
+
* shorter name would lose `orderedItems` — and with it the ordering, which is
|
|
93
|
+
* the one thing an OrderedCollection is for.
|
|
94
|
+
*/
|
|
95
|
+
const NAMES_BY_IRI = (() => {
|
|
96
|
+
const m = new Map();
|
|
97
|
+
for (const [name, { iri, container }] of TERMS) {
|
|
98
|
+
// `contentMap` and `content` are the same predicate, but a language map is
|
|
99
|
+
// not another spelling of the plain term — emitting one as a bare string
|
|
100
|
+
// would invent a value the document never carried.
|
|
101
|
+
if (container === '@language' || container === '@index') continue;
|
|
102
|
+
const held = m.get(iri);
|
|
103
|
+
if (held) held.push(name); else m.set(iri, [name]);
|
|
104
|
+
}
|
|
105
|
+
return m;
|
|
106
|
+
})();
|
|
107
|
+
|
|
108
|
+
/** Index quads by subject, then by predicate. */
|
|
109
|
+
function indexQuads(quads) {
|
|
110
|
+
const bySubject = new Map();
|
|
111
|
+
const objects = new Set();
|
|
112
|
+
// A BlankNode's `.value` has no `_:` on it, so blankness is recorded here
|
|
113
|
+
// rather than guessed from the string. A blank node has no id, and handing a
|
|
114
|
+
// handler `id: "b0"` would be handing it an identifier that means nothing.
|
|
115
|
+
const blanks = new Set();
|
|
116
|
+
for (const q of quads) {
|
|
117
|
+
if (q.subject.termType === 'BlankNode') blanks.add(q.subject.value);
|
|
118
|
+
if (q.object.termType === 'BlankNode') blanks.add(q.object.value);
|
|
119
|
+
const s = q.subject.value;
|
|
120
|
+
let preds = bySubject.get(s);
|
|
121
|
+
if (!preds) bySubject.set(s, preds = new Map());
|
|
122
|
+
let vals = preds.get(q.predicate.value);
|
|
123
|
+
if (!vals) preds.set(q.predicate.value, vals = []);
|
|
124
|
+
vals.push(q.object);
|
|
125
|
+
if (q.object.termType === 'NamedNode' || q.object.termType === 'BlankNode') {
|
|
126
|
+
objects.add(q.object.value);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { bySubject, objects, blanks };
|
|
130
|
+
}
|
|
131
|
+
|
|
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.
|
|
136
|
+
*/
|
|
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;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Walk an rdf:List into an ordered array. `orderedItems` depends on this. */
|
|
144
|
+
function readList(head, ctx, depth, path) {
|
|
145
|
+
const out = [];
|
|
146
|
+
const walked = new Set();
|
|
147
|
+
let node = head;
|
|
148
|
+
while (node && node !== RDF_NIL && !walked.has(node)) {
|
|
149
|
+
walked.add(node);
|
|
150
|
+
const preds = ctx.bySubject.get(node);
|
|
151
|
+
if (!preds) break;
|
|
152
|
+
const first = preds.get(RDF_FIRST)?.[0];
|
|
153
|
+
if (first) {
|
|
154
|
+
const value = toValue(first, ctx, depth, path);
|
|
155
|
+
if (value !== null) out.push(value);
|
|
156
|
+
}
|
|
157
|
+
node = preds.get(RDF_REST)?.[0]?.value ?? null;
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isListHead(term, ctx) {
|
|
163
|
+
if (term.termType !== 'BlankNode' && term.termType !== 'NamedNode') return false;
|
|
164
|
+
return !!ctx.bySubject.get(term.value)?.has(RDF_FIRST);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A term becomes what a handler expects to find there.
|
|
169
|
+
*
|
|
170
|
+
* A node we hold statements about becomes a nested view, which is what an
|
|
171
|
+
* embedded object was in the JSON. A node we hold nothing about becomes its
|
|
172
|
+
* IRI, which is what a bare reference was. Handlers already test which they
|
|
173
|
+
* got (`typeof activity.object === 'object'`), so both stay readable.
|
|
174
|
+
*/
|
|
175
|
+
function toValue(term, ctx, depth, path) {
|
|
176
|
+
if (term.termType === 'Literal') return term.value;
|
|
177
|
+
if (isListHead(term, ctx)) return readList(term.value, ctx, depth, path);
|
|
178
|
+
const preds = ctx.bySubject.get(term.value);
|
|
179
|
+
if (preds && preds.size) return makeView(term.value, ctx, depth + 1, path);
|
|
180
|
+
return term.termType === 'BlankNode' ? null : term.value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function readProperty(subject, name, ctx, depth = 0, path = new Set()) {
|
|
184
|
+
const preds = ctx.bySubject.get(subject);
|
|
185
|
+
if (!preds) return undefined;
|
|
186
|
+
|
|
187
|
+
if (name === 'id') return ctx.blanks.has(subject) ? undefined : subject;
|
|
188
|
+
if (name === 'type') {
|
|
189
|
+
const types = (preds.get(RDF_TYPE) ?? []).map(t => BY_IRI.get(t.value) ?? t.value);
|
|
190
|
+
if (!types.length) return undefined;
|
|
191
|
+
return types.length === 1 ? types[0] : types;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const term = TERMS.get(name);
|
|
195
|
+
if (!term) return undefined;
|
|
196
|
+
const values = preds.get(term.iri);
|
|
197
|
+
if (!values || !values.length) return undefined;
|
|
198
|
+
|
|
199
|
+
// `items` and `orderedItems` are the same predicate; the list is what tells
|
|
200
|
+
// them apart, so asking for the ordered one only answers when it is ordered.
|
|
201
|
+
const listed = values.filter(v => isListHead(v, ctx));
|
|
202
|
+
if (term.list) {
|
|
203
|
+
if (!listed.length) return undefined;
|
|
204
|
+
return readList(listed[0].value, ctx, depth, path);
|
|
205
|
+
}
|
|
206
|
+
if (listed.length === values.length && listed.length === 1) return readList(listed[0].value, ctx, depth, path);
|
|
207
|
+
|
|
208
|
+
const out = values.map(v => toValue(v, ctx, depth, path)).filter(v => v !== null);
|
|
209
|
+
if (ALWAYS_LIST.has(name)) return out.flat();
|
|
210
|
+
return out.length === 1 ? out[0] : out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Build the plain object for one node.
|
|
215
|
+
*
|
|
216
|
+
* Materialised rather than proxied on purpose. A lazy reader would still be
|
|
217
|
+
* reading the graph, but it would be an exotic object, and the handlers pass
|
|
218
|
+
* what they read on to things that spread it, serialise it and structuredClone
|
|
219
|
+
* it into the store — all of which a Proxy either breaks or quietly truncates.
|
|
220
|
+
* What comes out here is an ordinary object whose every value came from a
|
|
221
|
+
* statement.
|
|
222
|
+
*
|
|
223
|
+
* `depth` and `seen` are the guards. The graph is built from a stranger's
|
|
224
|
+
* document, so it can be cyclic or enormous; a node already on the path
|
|
225
|
+
* becomes its IRI rather than being walked again.
|
|
226
|
+
*/
|
|
227
|
+
function makeView(subject, ctx, depth = 0, seen = new Set()) {
|
|
228
|
+
if (depth > MAX_DEPTH || seen.has(subject)) {
|
|
229
|
+
return ctx.blanks.has(subject) ? null : subject;
|
|
230
|
+
}
|
|
231
|
+
const preds = ctx.bySubject.get(subject);
|
|
232
|
+
if (!preds) return ctx.blanks.has(subject) ? null : subject;
|
|
233
|
+
|
|
234
|
+
const path = new Set(seen).add(subject);
|
|
235
|
+
const out = {};
|
|
236
|
+
const id = readProperty(subject, 'id', ctx, depth, path);
|
|
237
|
+
if (id !== undefined) out.id = id;
|
|
238
|
+
const type = readProperty(subject, 'type', ctx, depth, path);
|
|
239
|
+
if (type !== undefined) out.type = type;
|
|
240
|
+
|
|
241
|
+
for (const predicate of preds.keys()) {
|
|
242
|
+
if (predicate === RDF_TYPE || predicate === RDF_FIRST || predicate === RDF_REST) continue;
|
|
243
|
+
// A predicate no context we hold names cannot be asked for by name, so it
|
|
244
|
+
// is left out rather than carried under an IRI no handler would look up.
|
|
245
|
+
for (const name of NAMES_BY_IRI.get(predicate) ?? []) {
|
|
246
|
+
if (name === 'id' || name === 'type') continue;
|
|
247
|
+
const value = readProperty(subject, name, ctx, depth, path);
|
|
248
|
+
if (value !== undefined) out[name] = value;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* A reader over `parseAS2`'s quads.
|
|
256
|
+
*
|
|
257
|
+
* Returns null when there is nothing to read, which is the same answer the
|
|
258
|
+
* document-shaped read gave for bytes that were not a document.
|
|
259
|
+
*/
|
|
260
|
+
export function graphView(quads, { root = null } = {}) {
|
|
261
|
+
if (!quads || !quads.length) return null;
|
|
262
|
+
const ctx = indexQuads(quads);
|
|
263
|
+
const subject = root ?? findRoot(ctx);
|
|
264
|
+
if (!subject) return null;
|
|
265
|
+
const view = makeView(subject, ctx);
|
|
266
|
+
return (view && typeof view === 'object') ? view : null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export { TERMS, BY_IRI };
|
|
@@ -223,8 +223,8 @@ export class Intake {
|
|
|
223
223
|
// failed and stays for the next pass.
|
|
224
224
|
const got = await podInbox.readItem(this.remote, item.url, { maxBytes: MAX_ITEM_BYTES, readCapped });
|
|
225
225
|
const read = got.raw === null ? null : await readLenient(got.raw);
|
|
226
|
-
if (read?.degraded) this.log(`inbox item ${item.url}
|
|
227
|
-
const activity = read?.doc ?? null;
|
|
226
|
+
if (read?.degraded) this.log(`inbox item ${item.url} grounded to read: ${read.degraded}`);
|
|
227
|
+
const activity = read?.view ?? read?.doc ?? null;
|
|
228
228
|
// A Create is the content the owner just asked to be rid of. Anything
|
|
229
229
|
// else changes state and is applied exactly as a drain would.
|
|
230
230
|
if (keepConcerning) {
|
|
@@ -407,8 +407,8 @@ export class Intake {
|
|
|
407
407
|
// says. A document we cannot read that way is read the way it always
|
|
408
408
|
// was rather than lost, and the reason is logged.
|
|
409
409
|
const read = raw ? await readLenient(raw) : null;
|
|
410
|
-
if (read?.degraded) this.log(`inbox item ${url}
|
|
411
|
-
activity = read?.doc ?? null;
|
|
410
|
+
if (read?.degraded) this.log(`inbox item ${url} grounded to read: ${read.degraded}`);
|
|
411
|
+
activity = read?.view ?? read?.doc ?? null;
|
|
412
412
|
// What the shapes make of it. This decides NOTHING — the activity is
|
|
413
413
|
// handled either way. It is written down so that which shapes fire on
|
|
414
414
|
// real traffic is a question with an answer, rather than a guess made
|
|
@@ -25,8 +25,8 @@ export async function fetchAP(intake, url) {
|
|
|
25
25
|
let doc = null;
|
|
26
26
|
try {
|
|
27
27
|
const read = await readLenient(await readCapped(res));
|
|
28
|
-
if (read.degraded) intake.log(`fetch ${url}
|
|
29
|
-
doc = read.doc;
|
|
28
|
+
if (read.degraded) intake.log(`fetch ${url} grounded to read: ${read.degraded}`);
|
|
29
|
+
doc = read.view ?? read.doc;
|
|
30
30
|
} catch (e) { intake.log(`fetch ${url}: unreadable — ${e.message}`); return null; }
|
|
31
31
|
if (!doc) { intake.log(`fetch ${url}: unreadable as JSON`); return null; }
|
|
32
32
|
// A document is only evidence about its OWN origin.
|
|
@@ -134,7 +134,8 @@ export async function isGone(intake, url) {
|
|
|
134
134
|
// A Tombstone answers 200 and still means deleted.
|
|
135
135
|
try {
|
|
136
136
|
const { readCapped } = await import('../../shared/safefetch.mjs');
|
|
137
|
-
|
|
137
|
+
const read = await readLenient(await readCapped(res));
|
|
138
|
+
return (read.view ?? read.doc)?.type === 'Tombstone';
|
|
138
139
|
} catch { return false; }
|
|
139
140
|
}
|
|
140
141
|
return null; // 401/403/5xx — no answer, not a denial
|
|
@@ -37,11 +37,14 @@ export async function reconcileFollowers(publisher, contacts) {
|
|
|
37
37
|
try {
|
|
38
38
|
const res = await publisher.deliverer.signedFetch(actor, { headers: { accept: ACCEPT_AP } });
|
|
39
39
|
if (!res.ok) continue;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
// The graph, not the compacted copy: `inbox` is `ldp:inbox` rather than
|
|
41
|
+
// an `as:` term, and what is wanted here is the statement, not a key.
|
|
42
|
+
const { doc, view, degraded } = await readLenient(await res.json());
|
|
43
|
+
const actorDoc = view ?? doc;
|
|
44
|
+
if (degraded) publisher.log?.(`actor ${actor} grounded to read: ${degraded}`);
|
|
45
|
+
if (!actorDoc?.inbox) continue;
|
|
43
46
|
contacts.followers.push({
|
|
44
|
-
actor, inbox:
|
|
47
|
+
actor, inbox: actorDoc.inbox, sharedInbox: actorDoc.endpoints?.sharedInbox || null, recovered: true,
|
|
45
48
|
// Said explicitly, because onUndo reads it: the pod publishes WHO
|
|
46
49
|
// follows, never the id of the Follow that did it, so a recovered
|
|
47
50
|
// record has nothing an Undo can be matched against and must not be
|
package/lib/server/embed.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { createRequire } from 'node:module';
|
|
|
19
19
|
import { Agent } from '../../run-agent.mjs';
|
|
20
20
|
import { RemotePod } from '../device/remote.mjs';
|
|
21
21
|
import { apUrls, DEFAULT_ROOT } from '../core/wire.mjs';
|
|
22
|
+
import { handleDelivery } from '../gateway/gateway-core.mjs';
|
|
22
23
|
import { writeJsonAtomic } from '../device/home.mjs';
|
|
23
24
|
import { buildAdminSurface } from '../device/admin/index.mjs';
|
|
24
25
|
import { FixedAuthorities } from '../shared/guard.mjs';
|
|
@@ -50,7 +51,8 @@ export async function ensureDoorSecret(session, podBase, { rotate = false, dataD
|
|
|
50
51
|
const onHost = dataDir && handle ? path.join(dataDir, handle, 'door-secret.json') : null;
|
|
51
52
|
|
|
52
53
|
if (!rotate) {
|
|
53
|
-
const res = await session.fetch(url, { headers: { accept: 'application/json' } })
|
|
54
|
+
const res = await session.fetch(url, { headers: { accept: 'application/json' } })
|
|
55
|
+
.catch(() => ({ ok: false }));
|
|
54
56
|
if (res.ok) {
|
|
55
57
|
try {
|
|
56
58
|
const rec = JSON.parse(await res.text());
|
|
@@ -74,18 +76,35 @@ export async function ensureDoorSecret(session, podBase, { rotate = false, dataD
|
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
const rec = { secret: mintSecret(), mintedAt: new Date().toISOString() };
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
if (await putSecret(session, url, rec)) {
|
|
80
|
+
if (onHost) fs.rmSync(onHost, { force: true });
|
|
81
|
+
return { secret: rec.secret, url, rotated: rotate };
|
|
82
|
+
}
|
|
83
|
+
// The pod would not take it. Opting in must not fail for that: a pod that
|
|
84
|
+
// cannot be written to right now is a pod whose owner still asked for an
|
|
85
|
+
// identity, and the secret has somewhere else to live — where it lived
|
|
86
|
+
// before. The next start moves it onto the pod.
|
|
87
|
+
if (onHost) {
|
|
88
|
+
writeJsonAtomic(onHost, rec, { mode: 0o600 });
|
|
89
|
+
log(`the pod would not take the door secret; it is on this host at ${onHost} for now`);
|
|
90
|
+
return { secret: rec.secret, url: onHost, rotated: rotate };
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`could not write the door secret to ${url}, and there is no directory to keep it in`);
|
|
80
93
|
}
|
|
81
94
|
|
|
82
95
|
async function putSecret(session, url, rec) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
96
|
+
try {
|
|
97
|
+
const res = await session.fetch(url, {
|
|
98
|
+
method: 'PUT',
|
|
99
|
+
headers: { 'content-type': 'application/json' },
|
|
100
|
+
body: JSON.stringify(rec, null, 2) + '\n',
|
|
101
|
+
});
|
|
102
|
+
return res.ok;
|
|
103
|
+
} catch {
|
|
104
|
+
// A store that throws rather than answering is a store that did not take
|
|
105
|
+
// it, which is the same answer.
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
89
108
|
}
|
|
90
109
|
|
|
91
110
|
/** The identity's name: a subdomain pod is its label, a path pod its last segment. */
|
|
@@ -183,6 +202,62 @@ async function connectionsIntoPod(agent, home, log) {
|
|
|
183
202
|
}
|
|
184
203
|
}
|
|
185
204
|
|
|
205
|
+
/**
|
|
206
|
+
* The pod's own inbox is a verifying door here.
|
|
207
|
+
*
|
|
208
|
+
* The server that stores the inbox is the server the delivery arrives at, so
|
|
209
|
+
* the signature is checked while the headers still exist and the receipt is
|
|
210
|
+
* written beside the activity — the same door code a standalone gateway runs,
|
|
211
|
+
* with nothing renamed and nothing advertised differently. The identity's
|
|
212
|
+
* config names its own inbox as the door and carries the receipt secret, which
|
|
213
|
+
* is what makes the drain read receipts at all; `trust` because a verified
|
|
214
|
+
* sender is one the identity may act for.
|
|
215
|
+
*
|
|
216
|
+
* An identity attached to an outside door keeps that door: only the secret
|
|
217
|
+
* is ensured, so receipts from either door verify against the one value.
|
|
218
|
+
*/
|
|
219
|
+
export async function ensureInboxDoor(agent, urls, log = () => {}) {
|
|
220
|
+
const cfg = agent.store.getConfig();
|
|
221
|
+
if (!cfg) return;
|
|
222
|
+
const g = { ...(cfg.gateway || {}) };
|
|
223
|
+
const fresh = !g.url || !g.mode || g.mode === 'off';
|
|
224
|
+
if (fresh) Object.assign(g, { url: urls.inbox, mode: 'trust' });
|
|
225
|
+
if (!g.hmacSecret) g.hmacSecret = crypto.randomBytes(32).toString('base64');
|
|
226
|
+
if (fresh || !cfg.gateway?.hmacSecret) {
|
|
227
|
+
agent.store.setConfig({ ...cfg, gateway: g });
|
|
228
|
+
await agent.store.flush();
|
|
229
|
+
log(fresh ? 'the pod inbox verifies deliveries at the door' : 'receipt secret added for the inbox door');
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* One delivery to a running identity's inbox, verified at the door.
|
|
235
|
+
*
|
|
236
|
+
* `request` is the WHATWG form of the POST. `podPut` writes through the
|
|
237
|
+
* server's store. Returns { status, reason } for the caller to answer with.
|
|
238
|
+
* The policy is read from the identity's live state rather than its
|
|
239
|
+
* published policy document, because both are in this process.
|
|
240
|
+
*/
|
|
241
|
+
export async function deliverToInbox(agent, request, { podPut, gatewayWebId = null, fetchImpl = fetch } = {}) {
|
|
242
|
+
const cfg = agent.store.getConfig() || {};
|
|
243
|
+
const contacts = agent.store.getContacts();
|
|
244
|
+
const bl = agent.store.getBlocklist();
|
|
245
|
+
const u = agent.urls;
|
|
246
|
+
const toPod = (x) => (u.toPod ? u.toPod(x) : x);
|
|
247
|
+
const ident = {
|
|
248
|
+
inboxUrl: toPod(u.inbox),
|
|
249
|
+
actorUrl: u.actor,
|
|
250
|
+
followersUrl: u.followers,
|
|
251
|
+
notesPrefix: u.notes,
|
|
252
|
+
following: contacts.following.filter((f) => f.accepted && !f.bsky).map((f) => f.actor),
|
|
253
|
+
blocklist: { domains: bl.domains || [], actors: bl.actors || [] },
|
|
254
|
+
kind: cfg.kind || 'person',
|
|
255
|
+
gatewayWebId,
|
|
256
|
+
hmacSecret: cfg.gateway?.hmacSecret || null,
|
|
257
|
+
};
|
|
258
|
+
return handleDelivery(request, ident, { podPut, fetchImpl });
|
|
259
|
+
}
|
|
260
|
+
|
|
186
261
|
/**
|
|
187
262
|
* Bring one identity up inside the server.
|
|
188
263
|
*
|
|
@@ -259,6 +334,7 @@ export async function startEmbeddedAgent({
|
|
|
259
334
|
// Before connect(), which is what looks the key and the connections up.
|
|
260
335
|
await keyIntoPod(agent, home, log);
|
|
261
336
|
await connectionsIntoPod(agent, home, log);
|
|
337
|
+
await ensureInboxDoor(agent, urls, log);
|
|
262
338
|
|
|
263
339
|
await agent.connect();
|
|
264
340
|
|
|
@@ -324,6 +400,6 @@ export async function startEmbeddedAgent({
|
|
|
324
400
|
// returned rather than rebuilt by the caller so the root name lives here.
|
|
325
401
|
return {
|
|
326
402
|
agent, handle, home, surface, host: authorities.host,
|
|
327
|
-
podHome: urls.home, actorUrl: urls.actor, stop,
|
|
403
|
+
podHome: urls.home, actorUrl: urls.actor, inboxUrl: urls.inbox, stop,
|
|
328
404
|
};
|
|
329
405
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/web/app/agent.mjs
CHANGED
|
@@ -17,9 +17,8 @@ import { MastoApi } from '../../lib/client/masto/index.mjs';
|
|
|
17
17
|
import { TagFeed } from '../../lib/connections/tagfeed.mjs';
|
|
18
18
|
import { makeDpopSession } from './pod-auth.mjs';
|
|
19
19
|
import { BrowserRemotePod } from './pod-remote.mjs';
|
|
20
|
-
import { importSigningKey, loadKeysFromPod,
|
|
20
|
+
import { importSigningKey, loadKeysFromPod, cacheOpenedKeys } from './keys-browser.mjs';
|
|
21
21
|
import { generateKeys, wrapKeys } from './keystore.mjs';
|
|
22
|
-
import { kvPut } from './idb-kv.mjs';
|
|
23
22
|
import { RelayDeliverer } from './deliver-relay.mjs';
|
|
24
23
|
import { AdminFacade } from './admin-facade.mjs';
|
|
25
24
|
import { BrowserAtproto } from './atproto-browser.mjs';
|
|
@@ -364,8 +363,7 @@ export class BrowserAgent {
|
|
|
364
363
|
const rec = await generateKeys();
|
|
365
364
|
rec.mintedFor = this.urls.actor; // one key, one actor (lib/keys.mjs)
|
|
366
365
|
await podState.writeWrappedKeys(this.remote, this.urls, await wrapKeys(rec, password));
|
|
367
|
-
await
|
|
368
|
-
const keys = await importSigningKey(rec);
|
|
366
|
+
const keys = await cacheOpenedKeys(this.urls.actor, rec);
|
|
369
367
|
this.publisher.publicKeyPem = keys.rsaPublicPem;
|
|
370
368
|
this.deliverer.rsaPrivate = keys.rsaPrivate;
|
|
371
369
|
await this.publisher.publishProfile();
|
package/web/app/boot.mjs
CHANGED
|
@@ -15,8 +15,7 @@ import * as podState from '../../lib/pod/state.mjs';
|
|
|
15
15
|
import { BrowserRemotePod } from './pod-remote.mjs';
|
|
16
16
|
import { beginLogin, completeLogin, getSession, signOut } from './oidc-session.mjs';
|
|
17
17
|
import { unwrapKeys, isKeyEnvelope } from './keystore.mjs';
|
|
18
|
-
import {
|
|
19
|
-
import { keyCacheKey } from './keys-browser.mjs';
|
|
18
|
+
import { cacheOpenedKeys } from './keys-browser.mjs';
|
|
20
19
|
|
|
21
20
|
const REDIRECT = `${location.origin}/`; // the app root doubles as the OIDC callback
|
|
22
21
|
|
|
@@ -83,7 +82,7 @@ window.fedipodUnlock = async (password) => {
|
|
|
83
82
|
if (!isKeyEnvelope(doc)) throw new Error('this account\'s key is not locked — nothing to unlock');
|
|
84
83
|
const rec = await unwrapKeys(doc, password); // throws 'wrong password'
|
|
85
84
|
const actorUrl = `${cfg.remotePod}${cfg.root || AP_ROOT}ap/actor`;
|
|
86
|
-
await
|
|
85
|
+
await cacheOpenedKeys(actorUrl, rec);
|
|
87
86
|
await bootWorker();
|
|
88
87
|
};
|
|
89
88
|
|