fedipod-server 0.7.0 → 0.9.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 +38 -8
- package/config/server.json +11 -0
- package/dist/claims.js +16 -5
- package/dist/components/context.jsonld +6 -0
- package/dist/directory.d.ts +11 -0
- package/dist/directory.js +19 -0
- package/dist/handler.d.ts +18 -2
- package/dist/handler.js +63 -27
- package/dist/handler.jsonld +27 -0
- package/lib/acctfeed.mjs +257 -0
- package/lib/admin.mjs +187 -7
- package/lib/bskygroup.mjs +1 -1
- package/lib/c2s.mjs +104 -4
- package/lib/embed.mjs +6 -1
- package/lib/fediacct.mjs +257 -0
- package/lib/front-core.mjs +74 -4
- package/lib/intake.mjs +21 -2
- package/lib/links.mjs +35 -0
- package/lib/mastoapi.mjs +298 -13
- package/lib/publisher.mjs +12 -1
- package/lib/remote.mjs +119 -3
- package/lib/setup.mjs +56 -0
- package/lib/storage.mjs +8 -2
- package/lib/store.mjs +26 -1
- package/lib/wire.mjs +20 -5
- package/package.json +1 -1
- package/phanpy/dist/assets/main-BdqNbG-a.js +1 -1
- package/phanpy/dist/compose/index.html +1 -1
- package/phanpy/dist/index.html +1 -1
- package/phanpy/dist/sw.js +2 -25
- package/phanpy/dist/sw.js.map +1 -0
- package/run-agent.mjs +35 -0
- package/web/admin/admin.js +81 -22
- package/web/admin/index.html +37 -8
- package/web/admin/setup/index.html +20 -4
- package/web/admin/setup/setup.js +50 -10
- package/web/front/admin.html +133 -0
- package/web/front/new-account.html +2 -2
- package/web/front/run.html +20 -16
- package/web/front/solid-oidc-client.js +6 -0
- package/web/front/solid-client-authn.bundle.js +0 -2
package/lib/setup.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import fs from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { pathToFileURL } from 'node:url';
|
|
14
|
+
import * as $rdf from 'rdflib';
|
|
14
15
|
|
|
15
16
|
import { createAccountWithPod as realCreateAccount } from './account.mjs';
|
|
16
17
|
import { mintCredential as realMint } from './remote.mjs';
|
|
@@ -20,8 +21,48 @@ import { rootOf, recordLastUsed, writeJsonAtomic } from './home.mjs';
|
|
|
20
21
|
import { insecureUrlReason } from './safefetch.mjs';
|
|
21
22
|
import { CURRENT_LAYOUT, isCurrent } from './migrate.mjs';
|
|
22
23
|
|
|
24
|
+
const SOLID = $rdf.Namespace('http://www.w3.org/ns/solid/terms#');
|
|
25
|
+
|
|
23
26
|
export const STEPS = ['account', 'credential', 'bootstrap', 'connect', 'publish', 'verify'];
|
|
24
27
|
|
|
28
|
+
// Read-only pre-check on the pod behind an existing-pod setup, before a
|
|
29
|
+
// credential is minted or the first write is attempted. Two failures it turns
|
|
30
|
+
// from a bare "PUT … → 401" into a plain sentence:
|
|
31
|
+
// 1. the pod host is unreachable — no account should be built on a pod that
|
|
32
|
+
// is not there;
|
|
33
|
+
// 2. the pod's WebID document declares no OIDC issuer. A pod verifies a token
|
|
34
|
+
// by dereferencing its WebID and looking for solid:oidcIssuer; an empty or
|
|
35
|
+
// issuer-less profile makes it reject every write with 401, so setup would
|
|
36
|
+
// stall on the first one. Only judged when the document is publicly
|
|
37
|
+
// readable — a protected one cannot be read here and is left to bootstrap.
|
|
38
|
+
export async function checkPodUsable(pod, { webId, fetch: doFetch = globalThis.fetch } = {}) {
|
|
39
|
+
const base = pod.endsWith('/') ? pod : pod + '/';
|
|
40
|
+
const wid = webId || new URL('profile/card#me', base).href;
|
|
41
|
+
const cardUrl = wid.split('#')[0];
|
|
42
|
+
|
|
43
|
+
let root;
|
|
44
|
+
try { root = await doFetch(base, { headers: { accept: 'text/turtle' } }); }
|
|
45
|
+
catch (e) { return { ok: false, error: `${base} could not be reached (${e.message}). Check the address and that the server is running.` }; }
|
|
46
|
+
if (root.status === 404) return { ok: false, error: `there is no pod at ${base} — the server answered 404.` };
|
|
47
|
+
if (root.status >= 500) return { ok: false, error: `the pod host at ${base} is not responding (HTTP ${root.status}).` };
|
|
48
|
+
|
|
49
|
+
let card;
|
|
50
|
+
try { card = await doFetch(cardUrl, { headers: { accept: 'text/turtle' } }); }
|
|
51
|
+
catch (e) { return { ok: false, error: `the pod's WebID document ${cardUrl} could not be read (${e.message}).` }; }
|
|
52
|
+
if (card.status === 404) return { ok: false, error: `the pod has no WebID document at ${cardUrl} — the identity it needs is missing.` };
|
|
53
|
+
if (card.status >= 500) return { ok: false, error: `the pod host is not serving ${cardUrl} (HTTP ${card.status}).` };
|
|
54
|
+
if (card.status === 401 || card.status === 403) return { ok: true }; // cannot read unauthenticated; leave it to bootstrap
|
|
55
|
+
|
|
56
|
+
const body = await card.text().catch(() => '');
|
|
57
|
+
const g = $rdf.graph();
|
|
58
|
+
try { $rdf.parse(body, g, cardUrl, (card.headers.get('content-type') || 'text/turtle').split(';')[0].trim()); }
|
|
59
|
+
catch { return { ok: false, error: `the pod's WebID document ${cardUrl} could not be parsed as RDF.` }; }
|
|
60
|
+
if (!g.each($rdf.sym(wid), SOLID('oidcIssuer'), null).length) {
|
|
61
|
+
return { ok: false, error: `the pod's WebID document ${cardUrl} declares no OIDC issuer, so the pod will reject every write. It looks empty or incomplete — restore it, or set up with a fresh pod.` };
|
|
62
|
+
}
|
|
63
|
+
return { ok: true };
|
|
64
|
+
}
|
|
65
|
+
|
|
25
66
|
export const credentialPath = (home) => path.join(home, 'credential.json');
|
|
26
67
|
export const hasCredential = (home) => fs.existsSync(credentialPath(home));
|
|
27
68
|
|
|
@@ -118,6 +159,7 @@ export function preflight({ mode, pod, issuer, podName, handle, kind }) {
|
|
|
118
159
|
export async function runSetup({ home, agent, answers, run, deps = {}, log = () => {} }) {
|
|
119
160
|
const createAccount = deps.createAccountWithPod || realCreateAccount;
|
|
120
161
|
const mint = deps.mintCredential || realMint;
|
|
162
|
+
const checkPod = deps.checkPodUsable || checkPodUsable;
|
|
121
163
|
|
|
122
164
|
const at = (key) => run.steps.find(s => s.key === key);
|
|
123
165
|
const begin = (key) => { at(key).state = 'running'; };
|
|
@@ -166,14 +208,21 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
166
208
|
}
|
|
167
209
|
done('account', pod);
|
|
168
210
|
} else {
|
|
211
|
+
// A pod you bring must be there and carry a usable WebID before a
|
|
212
|
+
// credential is minted against it — no account on a pod that is not
|
|
213
|
+
// reachable, and no silent 401 later on a pod whose profile is empty.
|
|
214
|
+
const usable = await checkPod(pod);
|
|
215
|
+
if (!usable.ok) throw new Error(usable.error);
|
|
169
216
|
skip('account', 'using the pod you already have');
|
|
170
217
|
}
|
|
171
218
|
|
|
172
219
|
// --- credential: the point of no return, and the durability boundary ---
|
|
220
|
+
let resumeWebId = null;
|
|
173
221
|
if (resuming) {
|
|
174
222
|
const rec = JSON.parse(fs.readFileSync(credPath, 'utf8'));
|
|
175
223
|
pod = rec.remotePod;
|
|
176
224
|
root = rec.root;
|
|
225
|
+
resumeWebId = rec.webId || null;
|
|
177
226
|
skip('credential', `already minted — ${credPath}`);
|
|
178
227
|
} else {
|
|
179
228
|
begin('credential');
|
|
@@ -212,6 +261,13 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
212
261
|
|
|
213
262
|
// --- provision the pod and bring federation up ---
|
|
214
263
|
begin('bootstrap');
|
|
264
|
+
// Resuming skipped the checks the fresh paths ran, and the credential it
|
|
265
|
+
// carries may be the one bound to a pod whose profile is empty — the write
|
|
266
|
+
// below would 401. Catch it here, as this step, with a sentence.
|
|
267
|
+
if (resuming) {
|
|
268
|
+
const ready = await checkPod(pod, { webId: resumeWebId || undefined });
|
|
269
|
+
if (!ready.ok) throw new Error(ready.error);
|
|
270
|
+
}
|
|
215
271
|
await agent.bootstrap({ handle, name: name || handle, root, kind, approveJoins, summary, icon, gateway });
|
|
216
272
|
done('bootstrap');
|
|
217
273
|
|
package/lib/storage.mjs
CHANGED
|
@@ -61,9 +61,15 @@ export class HttpStorage {
|
|
|
61
61
|
return { notModified: false, names, etag: res.headers.get('etag') };
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
// `accept` is for callers reading something that is RDF but is wanted as it
|
|
65
|
+
// was written: asking turtle-first for a JSON-LD document gets turtle back,
|
|
66
|
+
// because the server is entitled to convert between two RDF syntaxes.
|
|
67
|
+
async read(p, { etag, accept } = {}) {
|
|
65
68
|
const res = await this.fetchImpl(this._url(p), {
|
|
66
|
-
headers: {
|
|
69
|
+
headers: {
|
|
70
|
+
accept: accept || 'text/turtle, application/json;q=0.9, */*;q=0.8',
|
|
71
|
+
...(etag ? { 'if-none-match': etag } : {}),
|
|
72
|
+
},
|
|
67
73
|
});
|
|
68
74
|
if (res.status === 304) return { ok: true, notModified: true, status: 304, body: null, etag };
|
|
69
75
|
if (res.status >= 400) return { ok: false, notModified: false, status: res.status, body: null, etag: null };
|
package/lib/store.mjs
CHANGED
|
@@ -343,7 +343,8 @@ export class PodStore {
|
|
|
343
343
|
}
|
|
344
344
|
addStatus(s) {
|
|
345
345
|
const all = this.getStatuses();
|
|
346
|
-
|
|
346
|
+
const at = all.findIndex(x => x.noteId === s.noteId);
|
|
347
|
+
if (at >= 0) return this._mergeStatus(all, at, s);
|
|
347
348
|
// Same reason as cacheActor: statuses.json is serialized whole on every
|
|
348
349
|
// change, and remote content is only bounded by the 5 MB fetch ceiling.
|
|
349
350
|
if (typeof s.content === 'string' && s.content.length > MAX_CONTENT) {
|
|
@@ -352,6 +353,30 @@ export class PodStore {
|
|
|
352
353
|
all.unshift(s);
|
|
353
354
|
this.write('statuses.json', all.slice(0, 1000));
|
|
354
355
|
this.onEvent?.('status', s); // streaming subscribers
|
|
356
|
+
return { added: true, merged: false, status: s };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The same post arrives twice when two of the owner's accounts follow its
|
|
360
|
+
// author, and the second arrival is the only record that the other one saw
|
|
361
|
+
// it. Merged in place — the per-kind prune tails take the tail to be the
|
|
362
|
+
// oldest — and with no stream event, which would show the post twice.
|
|
363
|
+
_mergeStatus(all, at, s) {
|
|
364
|
+
const row = all[at];
|
|
365
|
+
const known = new Set((row.sourceAccts || []).map(v => v.acct));
|
|
366
|
+
const fresh = (s.sourceAccts || []).filter(v => v && !known.has(v.acct));
|
|
367
|
+
// Only our own verified intake may raise a row's kind. A general ladder
|
|
368
|
+
// would let a source that merely SAW a post promote a stranger's mention
|
|
369
|
+
// into the home timeline, which is the route tagfeed had to close.
|
|
370
|
+
const first = (k) => k === 'post' || k === 'timeline';
|
|
371
|
+
const raise = first(s.kind) && !first(row.kind);
|
|
372
|
+
if (!fresh.length && !raise) return { added: false, merged: false, status: row };
|
|
373
|
+
all[at] = {
|
|
374
|
+
...row,
|
|
375
|
+
...(fresh.length ? { sourceAccts: [...(row.sourceAccts || []), ...fresh] } : {}),
|
|
376
|
+
...(raise ? { kind: s.kind, ...(s.slug ? { slug: s.slug } : {}) } : {}),
|
|
377
|
+
};
|
|
378
|
+
this.write('statuses.json', all);
|
|
379
|
+
return { added: false, merged: true, status: all[at] };
|
|
355
380
|
}
|
|
356
381
|
updateStatus(noteId, patch) {
|
|
357
382
|
const all = this.getStatuses();
|
package/lib/wire.mjs
CHANGED
|
@@ -108,7 +108,7 @@ export const assertionKeyId = (urls) => urls.actor + '#ed25519-key';
|
|
|
108
108
|
export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null, movedTo = null, kind = 'person',
|
|
109
109
|
approveJoins = false, summary = null, icon = null, image = null, fields = [],
|
|
110
110
|
webId = null, aliases = [], moderators = null, pendingFollowers = null, pendingFollowing = null,
|
|
111
|
-
blocked = null, inbox = null }) {
|
|
111
|
+
blocked = null, inbox = null, outbox = null, oauthAuthorize = null, oauthToken = null }) {
|
|
112
112
|
// manuallyApprovesFollowers is NOT in the base AS2 context, so it is declared
|
|
113
113
|
// inline exactly as Mastodon declares it — and only when we actually use it.
|
|
114
114
|
// It is what makes a client show "Request to follow" rather than "Follow" and
|
|
@@ -188,8 +188,23 @@ export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null
|
|
|
188
188
|
inbox: inbox || urls.inbox,
|
|
189
189
|
// Every Mastodon actor publishes one. With a single actor per pod ours is
|
|
190
190
|
// just the inbox, but its absence is the non-standard thing.
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
//
|
|
192
|
+
// The two OAuth entries are how a client-to-server client finds its way in
|
|
193
|
+
// without being told anything by hand (ActivityPub 4.1). They appear only
|
|
194
|
+
// where this identity's client surface answers on an address a stranger
|
|
195
|
+
// can reach, which is why the caller supplies them rather than this
|
|
196
|
+
// building them from the pod URL.
|
|
197
|
+
endpoints: {
|
|
198
|
+
sharedInbox: inbox || urls.inbox,
|
|
199
|
+
...(oauthAuthorize ? { oauthAuthorizationEndpoint: oauthAuthorize } : {}),
|
|
200
|
+
...(oauthToken ? { oauthTokenEndpoint: oauthToken } : {}),
|
|
201
|
+
},
|
|
202
|
+
// Where a client sends what this actor writes, which the protocol says is
|
|
203
|
+
// this same address. The pod holds the collection and answers reads of it;
|
|
204
|
+
// a client-to-server write has to reach the agent, and the pod cannot take
|
|
205
|
+
// one. So where the agent is reachable it is named here, and a read of it
|
|
206
|
+
// is sent straight on to the pod's own document.
|
|
207
|
+
outbox: outbox || urls.outbox,
|
|
193
208
|
// The pinned posts, as the collection other servers read when they show
|
|
194
209
|
// this profile. Mastodon's term, declared the way Mastodon declares it.
|
|
195
210
|
featured: urls.featured,
|
|
@@ -243,8 +258,8 @@ ${icon ? `<img class="avatar" src="${esc(icon)}" alt="">` : ''}
|
|
|
243
258
|
<h1>${esc(name)}</h1>
|
|
244
259
|
<p class="address">${esc(address)}</p>
|
|
245
260
|
${summary ? `<div>${summary}</div>` : ''}
|
|
246
|
-
<p>This is ${what} on the
|
|
247
|
-
into the search box of Mastodon or any
|
|
261
|
+
<p>This is ${what} on the Fediverse. To follow it, paste the address above
|
|
262
|
+
into the search box of Mastodon or any Fediverse app — or use the form.</p>
|
|
248
263
|
<form id="follow">
|
|
249
264
|
<label for="server">your server</label>
|
|
250
265
|
<input id="server" type="text" placeholder="mastodon.social" autocomplete="off"
|