fedipod 1.26.1 → 1.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client/masto/index.mjs +4 -1
- package/lib/client/masto/oauth.mjs +66 -9
- package/lib/device/admin/surface.mjs +24 -1
- package/lib/device/certs.mjs +18 -2
- package/lib/gateway/front-core.mjs +41 -6
- package/package.json +1 -1
- package/vendor/gate.cjs +6 -3
- package/web/admin/oauth-signin.mjs +37 -0
- package/web/app/site/admin/client/index.html +2 -2
- package/web/app/site/admin/client-phanpy/index.html +2 -2
- package/web/app/site/admin/index.html +2 -2
- package/web/app/site/admin/oauth-signin.mjs +37 -0
- package/web/app/site/admin/setup/index.html +2 -2
- package/web/app/site/app/compose/index.html +2 -2
- package/web/app/site/app/index.html +2 -2
- package/web/app/site/authorize_interaction.html +2 -2
- package/web/app/site/bb/index.html +2 -2
- package/web/app/site/build.json +1 -1
- package/web/app/site/index.html +2 -2
- package/web/front/admin.html +0 -1
- package/web/front/new-account.html +0 -1
- package/web/front/run.html +13 -7
- package/web/front/run.js +73 -17
|
@@ -31,7 +31,7 @@ export { pollParams } from './body.mjs';
|
|
|
31
31
|
export { attachmentType, extensionFor } from './media.mjs';
|
|
32
32
|
|
|
33
33
|
export class MastoApi {
|
|
34
|
-
constructor({ agent, log = console.log, allowed = null, scheme = null, embedded = false,
|
|
34
|
+
constructor({ agent, log = console.log, allowed = null, scheme = null, embedded = false, throughDoor = null,
|
|
35
35
|
mount = '', streaming = true, webPush = true, scheduling = true }) {
|
|
36
36
|
this.agent = agent;
|
|
37
37
|
// The path this identity's surface answers under, when it shares its origin
|
|
@@ -45,6 +45,9 @@ export class MastoApi {
|
|
|
45
45
|
this.embedded = embedded;
|
|
46
46
|
this.log = log;
|
|
47
47
|
this.allowed = allowed; // authorities a redirect_uri may name
|
|
48
|
+
// Whether a request already passed the owner's door (its key or cookie):
|
|
49
|
+
// an owner who opened the management page is the owner at the sign-in too.
|
|
50
|
+
this.throughDoor = throughDoor;
|
|
48
51
|
// The scheme this identity is reached on, when the socket cannot say —
|
|
49
52
|
// a server behind a TLS proxy terminates cleartext and still is https.
|
|
50
53
|
this.scheme = scheme;
|
|
@@ -42,6 +42,42 @@ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c =>
|
|
|
42
42
|
const parseRedirects = (v) => (Array.isArray(v) ? v : String(v || '').split(/\s+/))
|
|
43
43
|
.map(s => s.trim()).filter(Boolean);
|
|
44
44
|
|
|
45
|
+
// The sign-in page for an account whose owner signs in at their pod: no
|
|
46
|
+
// password of ours exists, and none is asked for. The page carries the
|
|
47
|
+
// client's request in its address, signs the owner in at the pod with the
|
|
48
|
+
// fediverse-account library, and proves that sign-in back to /oauth/authorize.
|
|
49
|
+
function sendPodSigninPage(res, client, webId, mount = '') {
|
|
50
|
+
let asking = '';
|
|
51
|
+
if (client && (client.name || client.redirect)) {
|
|
52
|
+
let where = '';
|
|
53
|
+
try { where = client.redirect ? new URL(client.redirect).host : ''; } catch { /* oob or blank */ }
|
|
54
|
+
const who = client.name ? escapeHtml(client.name) : (where ? escapeHtml(where) : 'A client');
|
|
55
|
+
asking = `<p><strong>${who}</strong> is asking to use your account`
|
|
56
|
+
+ `${where ? `, and will be sent back to <code>${escapeHtml(where)}</code>` : ''}.</p>`;
|
|
57
|
+
}
|
|
58
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' });
|
|
59
|
+
res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
60
|
+
<title>Sign in with your pod</title>
|
|
61
|
+
<style>:root{color-scheme:light dark;--heading:#1a4f8a;--btn:#3d6b35;--line:#9a9a9a}
|
|
62
|
+
body{font:20px Arial,Helvetica,sans-serif;max-width:28rem;margin:12vh auto;padding:0 1rem}
|
|
63
|
+
h1{color:var(--heading);font-size:1.5rem}
|
|
64
|
+
code{word-break:break-all;background:rgba(120,120,120,.12);padding:.05em .35em;border-radius:4px}
|
|
65
|
+
button{font:inherit;padding:.6rem 1.1rem;background:var(--btn);color:#fff;border:none;border-radius:9px;cursor:pointer}
|
|
66
|
+
button[hidden]{display:none}
|
|
67
|
+
#signin-status{min-height:1.5em;padding:.6rem .8rem;border:1px solid var(--line);border-radius:8px;margin-top:1rem}
|
|
68
|
+
@media (prefers-color-scheme:dark){:root{--heading:#7fb3e8;--btn:#5a8a4f}}</style></head><body>
|
|
69
|
+
<main>
|
|
70
|
+
<h1>Sign in with your pod</h1>
|
|
71
|
+
${asking}
|
|
72
|
+
<p>This account belongs to <a id="webid" href="${escapeHtml(webId)}">${escapeHtml(webId)}</a>. Sign in at that pod to allow it.</p>
|
|
73
|
+
<p><button type="button" id="signin" hidden>Sign in with your pod</button></p>
|
|
74
|
+
<p id="signin-status" role="status" aria-live="polite"></p>
|
|
75
|
+
</main>
|
|
76
|
+
<script type="module" src="${escapeHtml(mount)}/oauth/session/signin.mjs"></script>
|
|
77
|
+
</body></html>`);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
45
81
|
function sendLoginForm(res, params, error = '', client = null, status = null, headers = {}) {
|
|
46
82
|
const hidden = [...params.entries()].filter(([k]) => k !== 'password')
|
|
47
83
|
.map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`).join('\n');
|
|
@@ -378,6 +414,21 @@ export async function handle(api, ctx) {
|
|
|
378
414
|
let body = null;
|
|
379
415
|
if (req.method === 'POST') { body = await readBody(req); params = new URLSearchParams(body); }
|
|
380
416
|
const redirect = params.get('redirect_uri') || '';
|
|
417
|
+
// The owner's pod sign-in, proved: a Solid-OIDC token with its DPoP proof,
|
|
418
|
+
// from the WebID this account belongs to. It stands in for the password
|
|
419
|
+
// everywhere below.
|
|
420
|
+
let proved = false;
|
|
421
|
+
if (req.method === 'POST' && req.headers.authorization && req.headers.dpop && api.podAuth) {
|
|
422
|
+
const proof = await api.podAuth(req, pathname);
|
|
423
|
+
if (!proof.ok) return send(proof.status || 401, { error: proof.error });
|
|
424
|
+
proved = true;
|
|
425
|
+
}
|
|
426
|
+
const ownerWebId = api.ownerWebId?.() || null;
|
|
427
|
+
// The way back from the pod's login: no client named, only the pod's
|
|
428
|
+
// code. The page finishes the sign-in and returns to the client's request.
|
|
429
|
+
if (req.method === 'GET' && !params.get('client_id') && params.get('code') && params.get('state') && ownerWebId) {
|
|
430
|
+
return sendPodSigninPage(res, null, ownerWebId, api.mount || '');
|
|
431
|
+
}
|
|
381
432
|
const app = api.findApp(params.get('client_id') || '');
|
|
382
433
|
// A client that published its own metadata document needs no
|
|
383
434
|
// registration here: the document is its name and says where it may be
|
|
@@ -419,14 +470,16 @@ export async function handle(api, ctx) {
|
|
|
419
470
|
// port is the owner" is a statement about the machine rather than
|
|
420
471
|
// about a web page. Anything pointing elsewhere has to be approved by
|
|
421
472
|
// somebody who knows the password.
|
|
422
|
-
|
|
473
|
+
const client = { name: app?.name || doc?.name || null, redirect, scope: params.get('scope') || 'read' };
|
|
474
|
+
if (external && !proved && !api.store.getConfig()?.uiPassword && !api.redirectAllowed(redirect)) {
|
|
475
|
+
// With a pod to sign in at, the owner approves the client there.
|
|
476
|
+
if (req.method === 'GET' && ownerWebId) return sendPodSigninPage(res, client, ownerWebId, api.mount || '');
|
|
423
477
|
api.log(`authorize refused: no UI password, and "${redirect}" is not an address of this agent`);
|
|
424
478
|
return send(403, {
|
|
425
479
|
error: 'this client asks to be sent somewhere other than this agent, and no password is '
|
|
426
480
|
+ 'set to approve that with. Run `fedipod passwd` and try again.',
|
|
427
481
|
});
|
|
428
482
|
}
|
|
429
|
-
const client = { name: app?.name || doc?.name || null, redirect, scope: params.get('scope') || 'read' };
|
|
430
483
|
if (app) {
|
|
431
484
|
if (!app.redirectUris.includes(redirect)) {
|
|
432
485
|
api.log(`authorize refused: redirect_uri "${redirect}" not registered for ${app.clientId}`);
|
|
@@ -448,7 +501,7 @@ export async function handle(api, ctx) {
|
|
|
448
501
|
api.log(`authorize refused: redirect_uri "${redirect}" is not this agent`);
|
|
449
502
|
return send(400, { error: 'redirect_uri must be an address of this agent' });
|
|
450
503
|
}
|
|
451
|
-
if (req.method === 'POST') {
|
|
504
|
+
if (req.method === 'POST' && !proved) {
|
|
452
505
|
if (api.rateLimited()) {
|
|
453
506
|
api.log('authorize rate limited');
|
|
454
507
|
// 429, not 401: a client that reads this as a wrong password will
|
|
@@ -460,11 +513,13 @@ export async function handle(api, ctx) {
|
|
|
460
513
|
if (!pw || !checkPassword(pw, body.password || '')) {
|
|
461
514
|
return sendLoginForm(res, params, 'wrong password — try again', client);
|
|
462
515
|
}
|
|
463
|
-
} else if (pw) {
|
|
516
|
+
} else if (pw && !proved) {
|
|
464
517
|
// The login/approve screen names what is asking before the owner types
|
|
465
518
|
// the password — a cross-site client cannot forge past that.
|
|
466
519
|
return sendLoginForm(res, params, '', client);
|
|
467
|
-
} else if (api.allowed && !api.allowed.isLocalRequest(req)) {
|
|
520
|
+
} else if (!proved && api.allowed && !api.allowed.isLocalRequest(req) && !api.throughDoor?.(req)) {
|
|
521
|
+
// With a pod to sign in at, the owner does that instead of a password.
|
|
522
|
+
if (req.method === 'GET' && ownerWebId) return sendPodSigninPage(res, client, ownerWebId, api.mount || '');
|
|
468
523
|
// No password set, and this request did not come from this machine.
|
|
469
524
|
//
|
|
470
525
|
// The instant-authorize path is honest theatre on loopback: whoever can
|
|
@@ -477,10 +532,9 @@ export async function handle(api, ctx) {
|
|
|
477
532
|
// dispatched before that check ever runs, so it needs its own.
|
|
478
533
|
api.log(`authorize refused: no UI password, and "${req.headers.host}" is not this machine`);
|
|
479
534
|
return send(403, {
|
|
480
|
-
error:
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
: 'run `fedipod passwd` before logging in over that address'),
|
|
535
|
+
error: api.embedded
|
|
536
|
+
? 'Open your account\'s management page first, then sign in from there.'
|
|
537
|
+
: 'Signing in from another machine needs a password. On the machine that runs your account, run: fedipod passwd',
|
|
484
538
|
});
|
|
485
539
|
}
|
|
486
540
|
// External clients get a bound code; the local flow keeps code==token.
|
|
@@ -493,6 +547,9 @@ export async function handle(api, ctx) {
|
|
|
493
547
|
const target = new URL(redirect);
|
|
494
548
|
target.searchParams.set('code', code);
|
|
495
549
|
if (params.get('state')) target.searchParams.set('state', params.get('state'));
|
|
550
|
+
// A proved sign-in came from the page's own request, which cannot follow
|
|
551
|
+
// a redirect to a client's address; the page is told where to go.
|
|
552
|
+
if (proved) return send(200, { redirect: target.href });
|
|
496
553
|
res.writeHead(302, { location: target.href });
|
|
497
554
|
res.end();
|
|
498
555
|
return true;
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// served off disk, and the dispatch to the routes modules beside it. Each of
|
|
7
7
|
// those exports get() and post() over the same ctx.
|
|
8
8
|
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
9
11
|
import { MastoApi } from '../../client/masto/index.mjs';
|
|
10
12
|
import { C2S } from '../../client/c2s.mjs';
|
|
11
13
|
import { makeC2sAuth } from '../../client/oidc-auth.mjs';
|
|
@@ -75,12 +77,23 @@ const ROUTES = [owner, setup, lifecycle, gateway, social, connections];
|
|
|
75
77
|
// meaning inside a server that is not one — and moves the gate: a pod that is
|
|
76
78
|
// a fediverse instance must let strangers reach /api and /oauth, so the gate
|
|
77
79
|
// guards the operator's door (basePath) instead of the whole surface.
|
|
80
|
+
const SESSION_FILES = {
|
|
81
|
+
'/oauth/session/signin.mjs': 'web/admin/oauth-signin.mjs',
|
|
82
|
+
'/oauth/session/fedi-login.mjs': 'lib/session/fedi-login.mjs',
|
|
83
|
+
'/oauth/session/oidc-session.mjs': 'lib/session/oidc-session.mjs',
|
|
84
|
+
};
|
|
85
|
+
|
|
78
86
|
export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
79
87
|
port = null, handle = null, embedded = false, basePath = '/', mount = '',
|
|
80
88
|
publicOrigin = null, scheme = null,
|
|
81
89
|
versionOnDisk = () => localVersion(projectRoot) }) {
|
|
82
90
|
const json = (res, status, obj) => sendJson(res, status, obj, allowed);
|
|
83
|
-
const masto = new MastoApi({ agent, log, allowed, scheme, embedded, mount
|
|
91
|
+
const masto = new MastoApi({ agent, log, allowed, scheme, embedded, mount,
|
|
92
|
+
throughDoor: (req) => Boolean(gate?.upgradeOk?.(req)) });
|
|
93
|
+
// A client's sign-in can be proved with the owner's pod sign-in instead of a
|
|
94
|
+
// password: the same verification the post door uses, and the owner it names.
|
|
95
|
+
masto.podAuth = makeC2sAuth({ agent, log, scheme, mount });
|
|
96
|
+
masto.ownerWebId = () => agent.remote?.webId || null;
|
|
84
97
|
// The spec's own write API (§6), beside the facade. Its bearer fallback is
|
|
85
98
|
// the facade's token, so the two surfaces share one notion of the operator.
|
|
86
99
|
const c2s = new C2S({ agent, log, scheme, mount,
|
|
@@ -172,6 +185,16 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
172
185
|
if (p === '/ap/outbox' || p === '/ap/actor' || p === '/ap/inbox') {
|
|
173
186
|
if (await c2s.handle(req, res, p, url)) return;
|
|
174
187
|
}
|
|
188
|
+
// The sign-in page's script and the library it signs in with, outside
|
|
189
|
+
// the door like the sign-in itself. A fixed list; nothing in the request
|
|
190
|
+
// names a file.
|
|
191
|
+
const sessionFile = SESSION_FILES[p];
|
|
192
|
+
if (sessionFile) {
|
|
193
|
+
let body;
|
|
194
|
+
try { body = fs.readFileSync(path.join(projectRoot, sessionFile)); } catch { return json(res, 404, { error: 'not here' }); }
|
|
195
|
+
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' });
|
|
196
|
+
res.end(body); return;
|
|
197
|
+
}
|
|
175
198
|
// Where a client looks first to find out how to sign in (RFC 8414), and
|
|
176
199
|
// in front of the door for the same reason C2S is: a client that has to
|
|
177
200
|
// be handed a secret before it can ask how to sign in cannot set itself
|
package/lib/device/certs.mjs
CHANGED
|
@@ -71,7 +71,16 @@ function nameConstraintsExtension() {
|
|
|
71
71
|
return { id: '2.5.29.30', critical: true, value: der.getBytes() };
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
// The issuer's own key identifier, for the certificate it signs to name. A
|
|
75
|
+
// verifier that finds several CAs under one name — this machine's browser
|
|
76
|
+
// store has held dozens of "FediPod Local CA" from scratch agents — picks by
|
|
77
|
+
// this, and without it picks by name, tries the wrong key and fails.
|
|
78
|
+
const issuerKeyId = (issuerCertPem) => {
|
|
79
|
+
const ext = forge.pki.certificateFromPem(issuerCertPem).getExtension('subjectKeyIdentifier');
|
|
80
|
+
return ext?.subjectKeyIdentifier ? forge.util.hexToBytes(ext.subjectKeyIdentifier) : null;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
function makeCert({ subjectCN, issuer = null, issuerKeyPem = null, issuerCertPem = null, isCA = false, days, extraDns = [] }) {
|
|
75
84
|
const keys = forge.pki.rsa.generateKeyPair({ bits: 2048 });
|
|
76
85
|
const cert = forge.pki.createCertificate();
|
|
77
86
|
cert.publicKey = keys.publicKey;
|
|
@@ -89,6 +98,9 @@ function makeCert({ subjectCN, issuer = null, issuerKeyPem = null, isCA = false,
|
|
|
89
98
|
: [{ name: 'basicConstraints', cA: false },
|
|
90
99
|
{ name: 'keyUsage', digitalSignature: true, keyEncipherment: true },
|
|
91
100
|
{ name: 'extKeyUsage', serverAuth: true },
|
|
101
|
+
{ name: 'subjectKeyIdentifier' },
|
|
102
|
+
...(issuerCertPem && issuerKeyId(issuerCertPem)
|
|
103
|
+
? [{ name: 'authorityKeyIdentifier', keyIdentifier: issuerKeyId(issuerCertPem) }] : []),
|
|
92
104
|
{ name: 'subjectAltName',
|
|
93
105
|
altNames: [...SANS, ...extraDns.map(v => ({ type: 2, value: v }))] }]);
|
|
94
106
|
const signingKey = issuerKeyPem ? forge.pki.privateKeyFromPem(issuerKeyPem) : keys.privateKey;
|
|
@@ -131,6 +143,7 @@ export function ensureLocalTls(dir, { log = () => {}, names = [] } = {}) {
|
|
|
131
143
|
const trust = !!(caKey && caCert);
|
|
132
144
|
const isStale = (k, c) => !k || !c || daysLeft(c) < REMINT_DAYS || !coversLocalhost(c)
|
|
133
145
|
|| (trust && !issuedBy(c, caCert))
|
|
146
|
+
|| (trust && !namesIssuerKey(c))
|
|
134
147
|
|| names.some(n => !certDnsNames(c).includes(n));
|
|
135
148
|
let key = readPem(dir, FILES.key);
|
|
136
149
|
let cert = readPem(dir, FILES.cert);
|
|
@@ -165,7 +178,7 @@ export function ensureLocalTls(dir, { log = () => {}, names = [] } = {}) {
|
|
|
165
178
|
.filter(n => n !== 'localhost' && n !== '*.localhost');
|
|
166
179
|
const minted = makeCert({
|
|
167
180
|
subjectCN: 'localhost',
|
|
168
|
-
...(trust ? { issuer: caSubject(caCert), issuerKeyPem: caKey } : {}),
|
|
181
|
+
...(trust ? { issuer: caSubject(caCert), issuerKeyPem: caKey, issuerCertPem: caCert } : {}),
|
|
169
182
|
days: LEAF_DAYS,
|
|
170
183
|
extraDns: carried,
|
|
171
184
|
});
|
|
@@ -181,6 +194,9 @@ export function ensureLocalTls(dir, { log = () => {}, names = [] } = {}) {
|
|
|
181
194
|
return { key, cert, trust, dir, expiresInDays: Math.floor(daysLeft(cert)) };
|
|
182
195
|
}
|
|
183
196
|
|
|
197
|
+
const namesIssuerKey = (certPem) => {
|
|
198
|
+
try { return !!forge.pki.certificateFromPem(certPem).getExtension('authorityKeyIdentifier'); } catch { return false; }
|
|
199
|
+
};
|
|
184
200
|
const caSubject = (caCertPem) => forge.pki.certificateFromPem(caCertPem).subject.attributes
|
|
185
201
|
.map(a => ({ name: a.name, value: a.value }));
|
|
186
202
|
const issuedBy = (certPem, caCertPem) => {
|
|
@@ -221,11 +221,22 @@ function parseUserPath(pathname) {
|
|
|
221
221
|
// `netlify-cdn-cache-control` is the edge's own instruction; `cache-control`
|
|
222
222
|
// is what the reader's browser is told. The edge may serve a stale copy while
|
|
223
223
|
// it fetches a fresh one, so nobody waits on the pod for a document that is
|
|
224
|
-
// merely a
|
|
224
|
+
// merely minutes old. The edge holds a document far longer than a browser
|
|
225
|
+
// is asked to: the edge is what turns a thousand readers into one function
|
|
226
|
+
// call, while a browser that held an actor for ten minutes would show one
|
|
227
|
+
// person a stale profile they had just edited.
|
|
228
|
+
const BROWSER_MAX_AGE = 60;
|
|
225
229
|
const publicFor = (seconds, stale = seconds * 4) => ({
|
|
226
|
-
'cache-control': `public, max-age=${seconds}`,
|
|
230
|
+
'cache-control': `public, max-age=${Math.min(seconds, BROWSER_MAX_AGE)}`,
|
|
227
231
|
'netlify-cdn-cache-control': `public, s-maxage=${seconds}, stale-while-revalidate=${stale}`,
|
|
228
232
|
});
|
|
233
|
+
// How long the edge holds a public document, and a handle's WebFinger
|
|
234
|
+
// answer. Every server that has heard of an account asks for its actor and
|
|
235
|
+
// its handle again and again; a post or a collection changes when its owner
|
|
236
|
+
// acts, and ten minutes of edge is what the plan's budget of function calls
|
|
237
|
+
// can afford.
|
|
238
|
+
const PUBLIC_EDGE_SECONDS = 600;
|
|
239
|
+
const WEBFINGER_EDGE_SECONDS = 3600;
|
|
229
240
|
|
|
230
241
|
// Where a pod owner opts their identity in. An operator may name it, because
|
|
231
242
|
// the path it takes is one their pod server can no longer serve; the dot says
|
|
@@ -382,7 +393,7 @@ async function route(request, ctx) {
|
|
|
382
393
|
if (pathname === runPath(ctx)) {
|
|
383
394
|
if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: {}, body: '' };
|
|
384
395
|
if (!ctx.runPage) return notFound();
|
|
385
|
-
return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: ctx.runPage };
|
|
396
|
+
return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' }, body: ctx.runPage };
|
|
386
397
|
}
|
|
387
398
|
|
|
388
399
|
// The roster page: the host reading who has accounts here. The page signs in
|
|
@@ -609,6 +620,28 @@ async function route(request, ctx) {
|
|
|
609
620
|
return j(200, { results });
|
|
610
621
|
}
|
|
611
622
|
|
|
623
|
+
// Who is here, and which pod of theirs could opt in. The opt-in page asks
|
|
624
|
+
// this first: on a pod server that issued the reader's session, the answer
|
|
625
|
+
// names their pod and its address, and the page shows one button instead of
|
|
626
|
+
// a form. A Gateway holds no such session and says so; the page then asks
|
|
627
|
+
// for the pod by hand and proves it with a sign-in, as before.
|
|
628
|
+
if (pathname === '/api/agent' && request.method === 'GET') {
|
|
629
|
+
if (!ctx.agentControl) return j(501, { error: 'this server does not run identities' });
|
|
630
|
+
if (!ctx.webIdsFromSession) return j(200, { offered: true, session: 'unknown' });
|
|
631
|
+
if (!sameOriginRequest(request, ctx.frontOrigin)) return j(200, { offered: true, session: 'none' });
|
|
632
|
+
const webIds = await ctx.webIdsFromSession(request).catch(() => []);
|
|
633
|
+
if (!webIds.length) return j(200, { offered: true, session: 'none', loginUrl: '/.account/login/password/' });
|
|
634
|
+
const pods = [];
|
|
635
|
+
for (const webId of webIds) {
|
|
636
|
+
let podBase;
|
|
637
|
+
try { podBase = podBaseOfWebId(webId); } catch { continue; }
|
|
638
|
+
let about = null;
|
|
639
|
+
try { about = ctx.agentControl.describe ? await ctx.agentControl.describe({ podBase }) : null; } catch { about = null; }
|
|
640
|
+
pods.push({ webId, podBase, ...(about || {}) });
|
|
641
|
+
}
|
|
642
|
+
return j(200, { offered: true, session: 'ok', pods });
|
|
643
|
+
}
|
|
644
|
+
|
|
612
645
|
// Runtime opt-in: a pod owner asks the server this front sits on to RUN
|
|
613
646
|
// their identity. Same proof as attach — a Solid-OIDC token — but the
|
|
614
647
|
// ownership check is stricter: the WebID must live UNDER the pod base, not
|
|
@@ -691,7 +724,9 @@ async function route(request, ctx) {
|
|
|
691
724
|
if (mPageScript) {
|
|
692
725
|
const body = ctx.pageScripts?.[`${mPageScript[1]}.js`];
|
|
693
726
|
if (!body) return notFound();
|
|
694
|
-
|
|
727
|
+
// Revalidated on every load: a page served fresh with a script the
|
|
728
|
+
// browser kept from last time is the mismatch nobody can see.
|
|
729
|
+
return { status: 200, headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' }, body };
|
|
695
730
|
}
|
|
696
731
|
|
|
697
732
|
// The vendored Solid-OIDC browser library the opt-in and admin pages load —
|
|
@@ -727,7 +762,7 @@ async function route(request, ctx) {
|
|
|
727
762
|
// A handle answers the same for everybody, and changes only when a row
|
|
728
763
|
// does. Every server that has heard of this account asks for it; the edge
|
|
729
764
|
// can answer most of them.
|
|
730
|
-
Object.assign(out.headers, publicFor(
|
|
765
|
+
Object.assign(out.headers, publicFor(WEBFINGER_EDGE_SECONDS));
|
|
731
766
|
return out;
|
|
732
767
|
}
|
|
733
768
|
|
|
@@ -856,7 +891,7 @@ async function route(request, ctx) {
|
|
|
856
891
|
// `readPublicDocument` asked for it with no credential, so a document that
|
|
857
892
|
// came back is one anybody can read — which is what makes it cacheable.
|
|
858
893
|
return { status: 200,
|
|
859
|
-
headers: { ...open, 'content-type': got.type || (container ? 'text/turtle' : page ? 'text/html' : AP_CT), ...publicFor(
|
|
894
|
+
headers: { ...open, 'content-type': got.type || (container ? 'text/turtle' : page ? 'text/html' : AP_CT), ...publicFor(PUBLIC_EDGE_SECONDS) },
|
|
860
895
|
body: text };
|
|
861
896
|
}
|
|
862
897
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.1",
|
|
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/vendor/gate.cjs
CHANGED
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
// authorized when it carries the secret:
|
|
8
8
|
// 1. header `x-dk-token` — the Electron shell injects this on app traffic, or
|
|
9
9
|
// 2. cookie `dk-token` — a browser "blessed" via the token URL, or
|
|
10
|
-
// 3. query `?dk-token=<secret>` — answered with a SameSite=
|
|
11
|
-
// a redirect to the same URL minus the param (the blessing flow),
|
|
10
|
+
// 3. query `?dk-token=<secret>` — answered with a SameSite=Lax cookie and
|
|
11
|
+
// a redirect to the same URL minus the param (the blessing flow). Lax,
|
|
12
|
+
// not Strict: a link from another site — the pod server's own opt-in
|
|
13
|
+
// page among them — must arrive with the cookie it was just handed, and
|
|
14
|
+
// Lax still withholds it from cross-site POSTs and subresources; or
|
|
12
15
|
// 4. (opt-in, proxy only) an Origin/Referer on the allowOrigins list — app
|
|
13
16
|
// pages in a blessed browser call the proxy with plain fetch(), which
|
|
14
17
|
// doesn't attach cookies cross-port; a hostile page can't forge Origin.
|
|
@@ -105,7 +108,7 @@ function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCoo
|
|
|
105
108
|
// Path scopes the cookie to this identity's own door: on a shared
|
|
106
109
|
// origin (suffix pods) two co-tenants must not clobber each other's,
|
|
107
110
|
// and a whole-origin cookie would. Defaults to '/'.
|
|
108
|
-
'set-cookie': `${COOKIE}=${t}; Path=${cookiePath}; HttpOnly; SameSite=
|
|
111
|
+
'set-cookie': `${COOKIE}=${t}; Path=${cookiePath}; HttpOnly; SameSite=Lax; Max-Age=31536000`
|
|
109
112
|
+ (secureCookie ? '; Secure' : ''),
|
|
110
113
|
'location': url.pathname + url.search,
|
|
111
114
|
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// oauth-signin.mjs — a client asked this account to sign its owner in. The
|
|
2
|
+
// owner signs in at their own pod, with the fediverse-account library, and the
|
|
3
|
+
// page proves that sign-in to the account with one signed request; the account
|
|
4
|
+
// then hands the client its code. No password of ours anywhere.
|
|
5
|
+
import { fediLogin } from './fedi-login.mjs';
|
|
6
|
+
|
|
7
|
+
const $ = (id) => document.getElementById(id);
|
|
8
|
+
const say = (text, error = false) => { const s = $('signin-status'); s.textContent = text; s.setAttribute('role', error ? 'alert' : 'status'); };
|
|
9
|
+
const webId = $('webid').href;
|
|
10
|
+
const here = location.origin + location.pathname;
|
|
11
|
+
const login = fediLogin({ dbName: 'fedipod-signin', clientName: 'FediPod sign-in', redirectUri: here });
|
|
12
|
+
|
|
13
|
+
// Back from the pod: this finishes the sign-in and returns to the client's
|
|
14
|
+
// request, which carries the client's own parameters.
|
|
15
|
+
try { await login.resume(); } catch (e) { say(e.message, true); }
|
|
16
|
+
|
|
17
|
+
async function prove() {
|
|
18
|
+
const s = await login.getSession();
|
|
19
|
+
if (!s) return false;
|
|
20
|
+
if (s.webId !== webId) { say(`You signed in as ${s.webId}, but this account belongs to ${webId}. Sign out there and sign in as the owner.`, true); return true; }
|
|
21
|
+
say('Signing you in…');
|
|
22
|
+
const res = await s.fetch(here, {
|
|
23
|
+
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
24
|
+
body: location.search.replace(/^\?/u, ''),
|
|
25
|
+
});
|
|
26
|
+
const d = await res.json().catch(() => ({}));
|
|
27
|
+
if (res.ok && d.redirect) { location.href = d.redirect; return true; }
|
|
28
|
+
if (res.ok && d.code) { say(`Your authorization code: ${d.code}`); return true; }
|
|
29
|
+
say(d.error || `The account refused the sign-in (HTTP ${res.status}).`, true);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (location.search.includes('client_id=') && !(await prove())) $('signin').hidden = false;
|
|
34
|
+
$('signin').addEventListener('click', async () => {
|
|
35
|
+
say('Taking you to your pod…');
|
|
36
|
+
try { await login.login(webId, { returnTo: location.href, redirectUri: here }); } catch (e) { say(e.message, true); }
|
|
37
|
+
});
|
|
@@ -19,8 +19,8 @@ main { flex: 1 1 auto; min-height: 0; display: flex; }
|
|
|
19
19
|
</style>
|
|
20
20
|
<link rel="stylesheet" href="../bar.css">
|
|
21
21
|
<style>#actor-pick{display:none!important}</style>
|
|
22
|
-
<meta name="fedipod-version" content="1.
|
|
23
|
-
<meta name="fedipod-build" content="
|
|
22
|
+
<meta name="fedipod-version" content="1.27.1">
|
|
23
|
+
<meta name="fedipod-build" content="709aafeeaee1">
|
|
24
24
|
<script src="/update.js" defer></script>
|
|
25
25
|
<script src="/admin/client-pick.js"></script>
|
|
26
26
|
</head>
|
|
@@ -19,8 +19,8 @@ main { flex: 1 1 auto; min-height: 0; display: flex; }
|
|
|
19
19
|
</style>
|
|
20
20
|
<link rel="stylesheet" href="../bar.css">
|
|
21
21
|
<style>#actor-pick{display:none!important}</style>
|
|
22
|
-
<meta name="fedipod-version" content="1.
|
|
23
|
-
<meta name="fedipod-build" content="
|
|
22
|
+
<meta name="fedipod-version" content="1.27.1">
|
|
23
|
+
<meta name="fedipod-build" content="709aafeeaee1">
|
|
24
24
|
<script src="/update.js" defer></script>
|
|
25
25
|
<script src="/admin/client-pick.js"></script>
|
|
26
26
|
</head>
|
|
@@ -168,8 +168,8 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
|
|
|
168
168
|
<link rel="stylesheet" href="bar.css?v=3">
|
|
169
169
|
<link rel="stylesheet" href="window.css">
|
|
170
170
|
<style>#gw-shape-front,#do-drain,#do-log,#actor-pick{display:none!important}</style>
|
|
171
|
-
<meta name="fedipod-version" content="1.
|
|
172
|
-
<meta name="fedipod-build" content="
|
|
171
|
+
<meta name="fedipod-version" content="1.27.1">
|
|
172
|
+
<meta name="fedipod-build" content="709aafeeaee1">
|
|
173
173
|
<script src="/update.js" defer></script>
|
|
174
174
|
<script src="/admin/client-pick.js"></script>
|
|
175
175
|
</head>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// oauth-signin.mjs — a client asked this account to sign its owner in. The
|
|
2
|
+
// owner signs in at their own pod, with the fediverse-account library, and the
|
|
3
|
+
// page proves that sign-in to the account with one signed request; the account
|
|
4
|
+
// then hands the client its code. No password of ours anywhere.
|
|
5
|
+
import { fediLogin } from './fedi-login.mjs';
|
|
6
|
+
|
|
7
|
+
const $ = (id) => document.getElementById(id);
|
|
8
|
+
const say = (text, error = false) => { const s = $('signin-status'); s.textContent = text; s.setAttribute('role', error ? 'alert' : 'status'); };
|
|
9
|
+
const webId = $('webid').href;
|
|
10
|
+
const here = location.origin + location.pathname;
|
|
11
|
+
const login = fediLogin({ dbName: 'fedipod-signin', clientName: 'FediPod sign-in', redirectUri: here });
|
|
12
|
+
|
|
13
|
+
// Back from the pod: this finishes the sign-in and returns to the client's
|
|
14
|
+
// request, which carries the client's own parameters.
|
|
15
|
+
try { await login.resume(); } catch (e) { say(e.message, true); }
|
|
16
|
+
|
|
17
|
+
async function prove() {
|
|
18
|
+
const s = await login.getSession();
|
|
19
|
+
if (!s) return false;
|
|
20
|
+
if (s.webId !== webId) { say(`You signed in as ${s.webId}, but this account belongs to ${webId}. Sign out there and sign in as the owner.`, true); return true; }
|
|
21
|
+
say('Signing you in…');
|
|
22
|
+
const res = await s.fetch(here, {
|
|
23
|
+
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
24
|
+
body: location.search.replace(/^\?/u, ''),
|
|
25
|
+
});
|
|
26
|
+
const d = await res.json().catch(() => ({}));
|
|
27
|
+
if (res.ok && d.redirect) { location.href = d.redirect; return true; }
|
|
28
|
+
if (res.ok && d.code) { say(`Your authorization code: ${d.code}`); return true; }
|
|
29
|
+
say(d.error || `The account refused the sign-in (HTTP ${res.status}).`, true);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (location.search.includes('client_id=') && !(await prove())) $('signin').hidden = false;
|
|
34
|
+
$('signin').addEventListener('click', async () => {
|
|
35
|
+
say('Taking you to your pod…');
|
|
36
|
+
try { await login.login(webId, { returnTo: location.href, redirectUri: here }); } catch (e) { say(e.message, true); }
|
|
37
|
+
});
|
|
@@ -54,8 +54,8 @@ button:disabled { opacity: .5; cursor: default; }
|
|
|
54
54
|
</style>
|
|
55
55
|
<link rel="stylesheet" href="../bar.css">
|
|
56
56
|
<style>#actor-pick{display:none!important}</style>
|
|
57
|
-
<meta name="fedipod-version" content="1.
|
|
58
|
-
<meta name="fedipod-build" content="
|
|
57
|
+
<meta name="fedipod-version" content="1.27.1">
|
|
58
|
+
<meta name="fedipod-build" content="709aafeeaee1">
|
|
59
59
|
<script src="/update.js" defer></script>
|
|
60
60
|
</head>
|
|
61
61
|
<body>
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
<link rel="modulepreload" crossorigin href="../assets/chunk-aKtaBQYM.js">
|
|
13
13
|
<link rel="modulepreload" crossorigin href="../assets/pwa-viewport-ogKbF51K.js">
|
|
14
14
|
|
|
15
|
-
<link rel="manifest" href="./manifest.webmanifest"><meta name="fedipod-version" content="1.
|
|
16
|
-
<meta name="fedipod-build" content="
|
|
15
|
+
<link rel="manifest" href="./manifest.webmanifest"><meta name="fedipod-version" content="1.27.1">
|
|
16
|
+
<meta name="fedipod-build" content="709aafeeaee1">
|
|
17
17
|
<script src="/update.js" defer></script>
|
|
18
18
|
</head>
|
|
19
19
|
<body>
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
<link rel="modulepreload" crossorigin href="./assets/ICONS-DIPJeU0C.js">
|
|
66
66
|
<link rel="modulepreload" crossorigin href="./assets/ScheduledAtField-D-Abfo4J.js">
|
|
67
67
|
|
|
68
|
-
<link rel="manifest" href="./manifest.webmanifest"><meta name="fedipod-version" content="1.
|
|
69
|
-
<meta name="fedipod-build" content="
|
|
68
|
+
<link rel="manifest" href="./manifest.webmanifest"><meta name="fedipod-version" content="1.27.1">
|
|
69
|
+
<meta name="fedipod-build" content="709aafeeaee1">
|
|
70
70
|
<script src="/update.js" defer></script>
|
|
71
71
|
</head>
|
|
72
72
|
<body>
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
.hint { color: var(--sub); }
|
|
36
36
|
</style>
|
|
37
37
|
<script src="/authorize_interaction.js" defer></script>
|
|
38
|
-
<meta name="fedipod-version" content="1.
|
|
39
|
-
<meta name="fedipod-build" content="
|
|
38
|
+
<meta name="fedipod-version" content="1.27.1">
|
|
39
|
+
<meta name="fedipod-build" content="709aafeeaee1">
|
|
40
40
|
<script src="/update.js" defer></script>
|
|
41
41
|
</head>
|
|
42
42
|
<body>
|
|
@@ -211,8 +211,8 @@
|
|
|
211
211
|
/* At the foot of the page, centred, clear of whatever is above it. */
|
|
212
212
|
footer.foot { margin: 1rem auto 0; color: var(--sub); font-size: 16px; text-align: center; }
|
|
213
213
|
</style>
|
|
214
|
-
<meta name="fedipod-version" content="1.
|
|
215
|
-
<meta name="fedipod-build" content="
|
|
214
|
+
<meta name="fedipod-version" content="1.27.1">
|
|
215
|
+
<meta name="fedipod-build" content="709aafeeaee1">
|
|
216
216
|
<script src="/update.js" defer></script>
|
|
217
217
|
</head>
|
|
218
218
|
<body>
|
package/web/app/site/build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"build":"
|
|
1
|
+
{"build":"709aafeeaee1","version":"1.27.1","at":"2026-09-21T23:13:33.708Z"}
|
package/web/app/site/index.html
CHANGED
|
@@ -72,8 +72,8 @@ button:disabled { opacity: .55; cursor: default; }
|
|
|
72
72
|
a { color: var(--accent); }
|
|
73
73
|
[hidden] { display: none !important; }
|
|
74
74
|
</style>
|
|
75
|
-
<meta name="fedipod-version" content="1.
|
|
76
|
-
<meta name="fedipod-build" content="
|
|
75
|
+
<meta name="fedipod-version" content="1.27.1">
|
|
76
|
+
<meta name="fedipod-build" content="709aafeeaee1">
|
|
77
77
|
<script src="/update.js" defer></script>
|
|
78
78
|
</head>
|
|
79
79
|
<body>
|
package/web/front/admin.html
CHANGED
package/web/front/run.html
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
-
<title>FediPod —
|
|
6
|
+
<title>FediPod — turn your pod into a Fediverse account</title>
|
|
7
7
|
<link rel="icon" href="/icons/gateway.svg" type="image/svg+xml">
|
|
8
8
|
<style>
|
|
9
9
|
:root { color-scheme: light dark; --line:#d9d4c7; --dim:#45443d; --accent:#6a8a5a; }
|
|
@@ -15,20 +15,27 @@
|
|
|
15
15
|
input[type=text], input[type=url] { width: 100%; padding: .5rem .6rem; font: inherit;
|
|
16
16
|
border: 1px solid var(--line); border-radius: 8px; box-sizing: border-box; }
|
|
17
17
|
.hint { color: var(--dim); font-size: .95rem; }
|
|
18
|
+
.pod { border: 1px solid var(--line); border-radius: 9px; padding: .9rem 1.1rem; margin: .8rem 0; }
|
|
19
|
+
.pod p { margin: .3rem 0; }
|
|
20
|
+
.pod .actions { margin-top: .8rem; }
|
|
18
21
|
.primary { background: var(--accent); color: #fff; border: none; border-radius: 9px;
|
|
19
22
|
padding: .6rem 1.1rem; font: inherit; cursor: pointer; }
|
|
20
23
|
button { font: inherit; }
|
|
21
24
|
.primary[disabled], button[disabled] { opacity: .5; cursor: not-allowed; }
|
|
22
25
|
[hidden] { display: none !important; }
|
|
23
26
|
code { background: rgba(120,120,120,.12); padding: .05em .35em; border-radius: 4px; }
|
|
27
|
+
.pod code { background: #fff3c4; color: #1a1a1a; font-size: 16px; }
|
|
28
|
+
@media (prefers-color-scheme: dark) { .pod code { background: #5a4a00; color: #fff; } }
|
|
24
29
|
pre { background: rgba(120,120,120,.12); padding: .7rem .9rem; border-radius: 8px; overflow-x: auto; }
|
|
25
30
|
</style>
|
|
26
31
|
</head>
|
|
27
32
|
<body>
|
|
28
|
-
<h1>
|
|
29
|
-
<p class="lede">
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
<h1>Turn your pod into a Fediverse account</h1>
|
|
34
|
+
<p class="lede">Users with pods on this server may opt-in below to create a Fediverse account
|
|
35
|
+
that stores your data on your pod. You'll be able to interact with Mastodon and users of other
|
|
36
|
+
Fediverse services straight from your pod.</p>
|
|
37
|
+
|
|
38
|
+
<section id="run-known" hidden aria-live="polite"></section>
|
|
32
39
|
|
|
33
40
|
<form id="run-form">
|
|
34
41
|
<p class="field">
|
|
@@ -43,10 +50,9 @@ you to keep running. Sign in with your pod to prove it is yours.</p>
|
|
|
43
50
|
<button type="button" id="run-continue" class="primary" disabled>Sign in with your pod & opt in</button>
|
|
44
51
|
<button type="button" id="run-leave">Stop running it here</button>
|
|
45
52
|
</p>
|
|
46
|
-
<p class="hint" id="run-note" hidden></p>
|
|
47
53
|
</form>
|
|
54
|
+
<p class="hint" id="run-note" hidden></p>
|
|
48
55
|
|
|
49
56
|
<script type="module" src="/run.js"></script>
|
|
50
|
-
<i>(cc) 4.0 By, Jeff Zucker, 2026</i>
|
|
51
57
|
</body>
|
|
52
58
|
</html>
|
package/web/front/run.js
CHANGED
|
@@ -23,22 +23,80 @@ try {
|
|
|
23
23
|
session = new SessionCore({ redirect_uris: [HERE], client_name: 'FediPod gateway' });
|
|
24
24
|
} catch { /* leave null — the "did not load" notes below fire */ }
|
|
25
25
|
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
26
|
+
// What this server knows before anything is typed. 501: it runs no
|
|
27
|
+
// identities. A pod server that issued the reader's session names their pod
|
|
28
|
+
// and its address, and the page shows one button per pod. Not signed in
|
|
29
|
+
// there: one button, to its login. A Gateway knows nothing: the form below.
|
|
29
30
|
let offered = true;
|
|
31
|
+
const say = (tag, text) => { const e = document.createElement(tag); e.textContent = text; return e; };
|
|
30
32
|
(async () => {
|
|
31
|
-
const res = await fetch('/api/agent', {
|
|
32
|
-
|
|
33
|
+
const res = await fetch('/api/agent', { credentials: 'same-origin', headers: { accept: 'application/json' } }).catch(() => null);
|
|
34
|
+
const d = res && res.ok ? await res.json().catch(() => ({})) : {};
|
|
35
|
+
const n = $('run-note');
|
|
33
36
|
if (!res || res.status === 501) {
|
|
34
37
|
offered = false;
|
|
35
|
-
const n = $('run-note');
|
|
36
38
|
n.hidden = false;
|
|
37
39
|
n.textContent = 'This server does not run identities. Your agent stays where it is.';
|
|
40
|
+
} else if (d.session === 'none') {
|
|
41
|
+
$('run-form').hidden = true;
|
|
42
|
+
showSignIn(d.loginUrl);
|
|
43
|
+
} else if (d.session === 'ok' && Array.isArray(d.pods) && d.pods.length) {
|
|
44
|
+
$('run-form').hidden = true;
|
|
45
|
+
showPods(d.pods);
|
|
38
46
|
}
|
|
39
47
|
runFormCheck();
|
|
40
48
|
})();
|
|
41
49
|
|
|
50
|
+
async function refreshKnown() {
|
|
51
|
+
const res = await fetch('/api/agent', { credentials: 'same-origin', headers: { accept: 'application/json' } }).catch(() => null);
|
|
52
|
+
const d = res && res.ok ? await res.json().catch(() => ({})) : {};
|
|
53
|
+
if (d.session === 'ok' && Array.isArray(d.pods) && d.pods.length) showPods(d.pods);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function showSignIn(loginUrl) {
|
|
57
|
+
const k = $('run-known');
|
|
58
|
+
k.hidden = false;
|
|
59
|
+
k.replaceChildren();
|
|
60
|
+
k.append(say('p', 'You are not signed in at this server.'));
|
|
61
|
+
if (loginUrl) {
|
|
62
|
+
const go = say('button', 'Sign in');
|
|
63
|
+
go.type = 'button'; go.className = 'primary';
|
|
64
|
+
go.onclick = () => { location.href = loginUrl; };
|
|
65
|
+
const p = document.createElement('p'); p.className = 'actions'; p.append(go);
|
|
66
|
+
k.append(p, say('p', 'Sign in, then come back to this page.'));
|
|
67
|
+
k.lastChild.className = 'hint';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function showPods(pods) {
|
|
72
|
+
const k = $('run-known');
|
|
73
|
+
k.hidden = false;
|
|
74
|
+
k.replaceChildren();
|
|
75
|
+
for (const pod of pods) {
|
|
76
|
+
const box = document.createElement('div'); box.className = 'pod';
|
|
77
|
+
// The names stand out from the sentences around them.
|
|
78
|
+
const line = (before, id, after = '.') => { const p = say('p', before); p.append(say('code', id), after); return p; };
|
|
79
|
+
box.append(line('Solid Identity (WebID): ', pod.webId, ''));
|
|
80
|
+
const actions = document.createElement('p'); actions.className = 'actions';
|
|
81
|
+
box.append(line('Fediverse Identity: ', pod.address, ''));
|
|
82
|
+
if (pod.running) {
|
|
83
|
+
const links = say('p', 'Manage your account: ');
|
|
84
|
+
const m = say('a', pod.manage.split('?')[0]); m.href = pod.manage;
|
|
85
|
+
links.append(m);
|
|
86
|
+
box.append(links);
|
|
87
|
+
const stop = say('button', 'Close this Fediverse account'); stop.type = 'button'; stop.className = 'primary';
|
|
88
|
+
stop.onclick = () => runStart('opt-out', pod.podBase);
|
|
89
|
+
actions.append(stop);
|
|
90
|
+
} else {
|
|
91
|
+
const go = say('button', 'Create a Fediverse account'); go.type = 'button'; go.className = 'primary';
|
|
92
|
+
go.onclick = () => runStart('opt-in', pod.podBase);
|
|
93
|
+
actions.append(go);
|
|
94
|
+
}
|
|
95
|
+
box.append(actions);
|
|
96
|
+
k.append(box);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
42
100
|
function runFormCheck() {
|
|
43
101
|
let ok = false;
|
|
44
102
|
try { ok = /^https?:$/.test(new URL($('run-pod-url').value.trim()).protocol); } catch { /* not yet */ }
|
|
@@ -63,9 +121,9 @@ $('run-issuer').addEventListener('input', runFormCheck);
|
|
|
63
121
|
// a filled-in form, with nothing to say why.
|
|
64
122
|
runFormCheck();
|
|
65
123
|
|
|
66
|
-
const runStart = async (action) => {
|
|
124
|
+
const runStart = async (action, podBase = null) => {
|
|
67
125
|
const n = $('run-note');
|
|
68
|
-
const u = new URL($('run-pod-url').value.trim());
|
|
126
|
+
const u = new URL(podBase || $('run-pod-url').value.trim());
|
|
69
127
|
if (u.pathname === '') u.pathname = '/';
|
|
70
128
|
if (!u.pathname.endsWith('/')) u.pathname += '/';
|
|
71
129
|
u.search = ''; u.hash = '';
|
|
@@ -103,16 +161,14 @@ async function showReply(res, p) {
|
|
|
103
161
|
// back from the server and one comes from sessionStorage, and pasting any
|
|
104
162
|
// of them into innerHTML means whatever markup they contain is rendered.
|
|
105
163
|
// Only the fixed wording is markup here; everything variable is text.
|
|
106
|
-
const
|
|
107
|
-
n.textContent = 'Your
|
|
108
|
-
|
|
109
|
-
n.append(
|
|
110
|
-
|
|
111
|
-
n.append(':', say('pre', d.doorSecret));
|
|
112
|
-
n.append('To check it works:', say('pre', d.command));
|
|
113
|
-
n.append('Lost it? Opt in again — that mints a fresh one and retires this one.');
|
|
164
|
+
const manage = `${p.podBase.replace(/\/$/, '')}${d.doorPath}?dk-token=${encodeURIComponent(d.doorSecret)}`;
|
|
165
|
+
n.textContent = 'Your Fediverse account is ready. Manage it at ';
|
|
166
|
+
const a = say('a', p.podBase.replace(/\/$/, '') + d.doorPath); a.href = manage;
|
|
167
|
+
n.append(a, '.');
|
|
168
|
+
refreshKnown();
|
|
114
169
|
} else if (res && res.status === 200) {
|
|
115
|
-
n.textContent = '
|
|
170
|
+
n.textContent = 'Your Fediverse account is closed. Your pod and its data are untouched.';
|
|
171
|
+
refreshKnown();
|
|
116
172
|
} else {
|
|
117
173
|
n.textContent = (p.action === 'opt-in' ? 'opt-in' : 'opt-out') + ' failed: '
|
|
118
174
|
+ (d.error || (res ? 'HTTP ' + res.status : 'no response'));
|