fedipod 1.26.1 → 1.27.3
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/core/publisher/collections.mjs +6 -1
- package/lib/core/publisher/index.mjs +1 -0
- 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/run-agent.mjs +14 -0
- package/vendor/gate.cjs +6 -3
- package/web/admin/oauth-signin.mjs +37 -0
- package/web/app/dist/sw.js +58 -7
- package/web/app/dist/sw.js.map +2 -2
- 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/app/site/sw.js +58 -7
- 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;
|
|
@@ -15,7 +15,7 @@ import * as podPrivate from '../../pod/private.mjs';
|
|
|
15
15
|
// still gets everything, so a missed call site degrades to the old cost rather
|
|
16
16
|
// than silently publishing nothing.
|
|
17
17
|
export const ALL_COLLECTIONS = { followers: true, following: true, outbox: true, acls: true,
|
|
18
|
-
pending: true, blocked: true };
|
|
18
|
+
pending: true, blocked: true, featured: true };
|
|
19
19
|
|
|
20
20
|
// Publish the collections a change actually TOUCHED.
|
|
21
21
|
//
|
|
@@ -152,6 +152,11 @@ export async function publishCollections(publisher, which = ALL_COLLECTIONS) {
|
|
|
152
152
|
}
|
|
153
153
|
if (which.pending) await publisher.publishPending();
|
|
154
154
|
if (which.blocked) await publisher.publishBlocked();
|
|
155
|
+
// The pinned posts, written even when there are none. The actor names this
|
|
156
|
+
// collection, and a server showing the profile reads it whether or not
|
|
157
|
+
// anything was ever pinned; with no document there the pod refused every
|
|
158
|
+
// reader, a refusal nothing caches, and every server asked again.
|
|
159
|
+
if (which.featured) await publisher.publishFeatured();
|
|
155
160
|
if (which.outbox) {
|
|
156
161
|
const outbox = publisher.store.read('outbox.json', []);
|
|
157
162
|
// Reconcile reads every published page, which is the expensive part of a
|
|
@@ -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.3",
|
|
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/run-agent.mjs
CHANGED
|
@@ -497,6 +497,18 @@ export class Agent {
|
|
|
497
497
|
return true;
|
|
498
498
|
}
|
|
499
499
|
|
|
500
|
+
// The featured collection was once written only on the first pin, so an
|
|
501
|
+
// account from before then has none, and every server that shows the
|
|
502
|
+
// profile is refused and asks again. One authenticated GET; the empty
|
|
503
|
+
// collection goes up when the pod lacks it. True when it had to.
|
|
504
|
+
async ensureFeaturedPublished() {
|
|
505
|
+
const doc = await this.remote.getJson(this.urls.featured);
|
|
506
|
+
if (doc?.id) return false;
|
|
507
|
+
this.log('featured collection missing from the pod — publishing it');
|
|
508
|
+
await this.publisher.publishFeatured();
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
511
|
+
|
|
500
512
|
// Read-only mode: refresh the state cache periodically, and take over the
|
|
501
513
|
// moment the active agent's lease frees.
|
|
502
514
|
startViewer() {
|
|
@@ -588,6 +600,8 @@ export class Agent {
|
|
|
588
600
|
if (repair) {
|
|
589
601
|
this.ensureActorPublished()
|
|
590
602
|
.catch(e => this.log(`actor check failed: ${e.message}`));
|
|
603
|
+
this.ensureFeaturedPublished()
|
|
604
|
+
.catch(e => this.log(`featured check failed: ${e.message}`));
|
|
591
605
|
// The human page, rewritten only when what it shows has changed: one
|
|
592
606
|
// local digest compare, and a write the first time after an upgrade.
|
|
593
607
|
this.publisher.publishProfilePage()
|
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
|
+
});
|
package/web/app/dist/sw.js
CHANGED
|
@@ -45643,7 +45643,8 @@ var ALL_COLLECTIONS = {
|
|
|
45643
45643
|
outbox: true,
|
|
45644
45644
|
acls: true,
|
|
45645
45645
|
pending: true,
|
|
45646
|
-
blocked: true
|
|
45646
|
+
blocked: true,
|
|
45647
|
+
featured: true
|
|
45647
45648
|
};
|
|
45648
45649
|
async function publishOutbox(publisher, outbox, { acls = false, force = false } = {}) {
|
|
45649
45650
|
const { urls } = publisher;
|
|
@@ -45742,6 +45743,7 @@ async function publishCollections(publisher, which = ALL_COLLECTIONS) {
|
|
|
45742
45743
|
}
|
|
45743
45744
|
if (which.pending) await publisher.publishPending();
|
|
45744
45745
|
if (which.blocked) await publisher.publishBlocked();
|
|
45746
|
+
if (which.featured) await publisher.publishFeatured();
|
|
45745
45747
|
if (which.outbox) {
|
|
45746
45748
|
const outbox = publisher.store.read("outbox.json", []);
|
|
45747
45749
|
const known2 = publisher.store.read("published.json", {}).outboxIndex;
|
|
@@ -57345,6 +57347,7 @@ var Publisher = class {
|
|
|
57345
57347
|
["notes", urls.notes],
|
|
57346
57348
|
["followers", urls.followers],
|
|
57347
57349
|
["following", urls.following],
|
|
57350
|
+
["featured", urls.featured],
|
|
57348
57351
|
["outbox", urls.outbox]
|
|
57349
57352
|
];
|
|
57350
57353
|
const unreachable = [];
|
|
@@ -65852,6 +65855,39 @@ function checkPassword(rec, password) {
|
|
|
65852
65855
|
}
|
|
65853
65856
|
var escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
65854
65857
|
var parseRedirects = (v) => (Array.isArray(v) ? v : String(v || "").split(/\s+/)).map((s) => s.trim()).filter(Boolean);
|
|
65858
|
+
function sendPodSigninPage(res, client, webId, mount = "") {
|
|
65859
|
+
let asking = "";
|
|
65860
|
+
if (client && (client.name || client.redirect)) {
|
|
65861
|
+
let where = "";
|
|
65862
|
+
try {
|
|
65863
|
+
where = client.redirect ? new URL(client.redirect).host : "";
|
|
65864
|
+
} catch {
|
|
65865
|
+
}
|
|
65866
|
+
const who = client.name ? escapeHtml(client.name) : where ? escapeHtml(where) : "A client";
|
|
65867
|
+
asking = `<p><strong>${who}</strong> is asking to use your account${where ? `, and will be sent back to <code>${escapeHtml(where)}</code>` : ""}.</p>`;
|
|
65868
|
+
}
|
|
65869
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" });
|
|
65870
|
+
res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
65871
|
+
<title>Sign in with your pod</title>
|
|
65872
|
+
<style>:root{color-scheme:light dark;--heading:#1a4f8a;--btn:#3d6b35;--line:#9a9a9a}
|
|
65873
|
+
body{font:20px Arial,Helvetica,sans-serif;max-width:28rem;margin:12vh auto;padding:0 1rem}
|
|
65874
|
+
h1{color:var(--heading);font-size:1.5rem}
|
|
65875
|
+
code{word-break:break-all;background:rgba(120,120,120,.12);padding:.05em .35em;border-radius:4px}
|
|
65876
|
+
button{font:inherit;padding:.6rem 1.1rem;background:var(--btn);color:#fff;border:none;border-radius:9px;cursor:pointer}
|
|
65877
|
+
button[hidden]{display:none}
|
|
65878
|
+
#signin-status{min-height:1.5em;padding:.6rem .8rem;border:1px solid var(--line);border-radius:8px;margin-top:1rem}
|
|
65879
|
+
@media (prefers-color-scheme:dark){:root{--heading:#7fb3e8;--btn:#5a8a4f}}</style></head><body>
|
|
65880
|
+
<main>
|
|
65881
|
+
<h1>Sign in with your pod</h1>
|
|
65882
|
+
${asking}
|
|
65883
|
+
<p>This account belongs to <a id="webid" href="${escapeHtml(webId)}">${escapeHtml(webId)}</a>. Sign in at that pod to allow it.</p>
|
|
65884
|
+
<p><button type="button" id="signin" hidden>Sign in with your pod</button></p>
|
|
65885
|
+
<p id="signin-status" role="status" aria-live="polite"></p>
|
|
65886
|
+
</main>
|
|
65887
|
+
<script type="module" src="${escapeHtml(mount)}/oauth/session/signin.mjs"><\/script>
|
|
65888
|
+
</body></html>`);
|
|
65889
|
+
return true;
|
|
65890
|
+
}
|
|
65855
65891
|
function sendLoginForm(res, params, error2 = "", client = null, status2 = null, headers = {}) {
|
|
65856
65892
|
const hidden = [...params.entries()].filter(([k]) => k !== "password").map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`).join("\n");
|
|
65857
65893
|
let asking = "<p>Enter the agent password to authorize this client.</p>";
|
|
@@ -66120,6 +66156,16 @@ async function handle(api, ctx) {
|
|
|
66120
66156
|
params = new URLSearchParams(body);
|
|
66121
66157
|
}
|
|
66122
66158
|
const redirect = params.get("redirect_uri") || "";
|
|
66159
|
+
let proved = false;
|
|
66160
|
+
if (req.method === "POST" && req.headers.authorization && req.headers.dpop && api.podAuth) {
|
|
66161
|
+
const proof = await api.podAuth(req, pathname);
|
|
66162
|
+
if (!proof.ok) return send(proof.status || 401, { error: proof.error });
|
|
66163
|
+
proved = true;
|
|
66164
|
+
}
|
|
66165
|
+
const ownerWebId = api.ownerWebId?.() || null;
|
|
66166
|
+
if (req.method === "GET" && !params.get("client_id") && params.get("code") && params.get("state") && ownerWebId) {
|
|
66167
|
+
return sendPodSigninPage(res, null, ownerWebId, api.mount || "");
|
|
66168
|
+
}
|
|
66123
66169
|
const app = api.findApp(params.get("client_id") || "");
|
|
66124
66170
|
const doc = app ? null : await api.resolveClientDocument(params.get("client_id") || "");
|
|
66125
66171
|
const external = !!app || !!doc;
|
|
@@ -66127,13 +66173,14 @@ async function handle(api, ctx) {
|
|
|
66127
66173
|
api.log(`authorize refused: cross-site navigation to the mint from ${req.headers.referer || "nowhere"}`);
|
|
66128
66174
|
return send(403, { error: "a cross-site navigation may not authorize a client" });
|
|
66129
66175
|
}
|
|
66130
|
-
|
|
66176
|
+
const client = { name: app?.name || doc?.name || null, redirect, scope: params.get("scope") || "read" };
|
|
66177
|
+
if (external && !proved && !api.store.getConfig()?.uiPassword && !api.redirectAllowed(redirect)) {
|
|
66178
|
+
if (req.method === "GET" && ownerWebId) return sendPodSigninPage(res, client, ownerWebId, api.mount || "");
|
|
66131
66179
|
api.log(`authorize refused: no UI password, and "${redirect}" is not an address of this agent`);
|
|
66132
66180
|
return send(403, {
|
|
66133
66181
|
error: "this client asks to be sent somewhere other than this agent, and no password is set to approve that with. Run `fedipod passwd` and try again."
|
|
66134
66182
|
});
|
|
66135
66183
|
}
|
|
66136
|
-
const client = { name: app?.name || doc?.name || null, redirect, scope: params.get("scope") || "read" };
|
|
66137
66184
|
if (app) {
|
|
66138
66185
|
if (!app.redirectUris.includes(redirect)) {
|
|
66139
66186
|
api.log(`authorize refused: redirect_uri "${redirect}" not registered for ${app.clientId}`);
|
|
@@ -66152,7 +66199,7 @@ async function handle(api, ctx) {
|
|
|
66152
66199
|
api.log(`authorize refused: redirect_uri "${redirect}" is not this agent`);
|
|
66153
66200
|
return send(400, { error: "redirect_uri must be an address of this agent" });
|
|
66154
66201
|
}
|
|
66155
|
-
if (req.method === "POST") {
|
|
66202
|
+
if (req.method === "POST" && !proved) {
|
|
66156
66203
|
if (api.rateLimited()) {
|
|
66157
66204
|
api.log("authorize rate limited");
|
|
66158
66205
|
return sendLoginForm(
|
|
@@ -66167,12 +66214,13 @@ async function handle(api, ctx) {
|
|
|
66167
66214
|
if (!pw || !checkPassword(pw, body.password || "")) {
|
|
66168
66215
|
return sendLoginForm(res, params, "wrong password \u2014 try again", client);
|
|
66169
66216
|
}
|
|
66170
|
-
} else if (pw) {
|
|
66217
|
+
} else if (pw && !proved) {
|
|
66171
66218
|
return sendLoginForm(res, params, "", client);
|
|
66172
|
-
} else if (api.allowed && !api.allowed.isLocalRequest(req)) {
|
|
66219
|
+
} else if (!proved && api.allowed && !api.allowed.isLocalRequest(req) && !api.throughDoor?.(req)) {
|
|
66220
|
+
if (req.method === "GET" && ownerWebId) return sendPodSigninPage(res, client, ownerWebId, api.mount || "");
|
|
66173
66221
|
api.log(`authorize refused: no UI password, and "${req.headers.host}" is not this machine`);
|
|
66174
66222
|
return send(403, {
|
|
66175
|
-
error: "
|
|
66223
|
+
error: api.embedded ? "Open your account's management page first, then sign in from there." : "Signing in from another machine needs a password. On the machine that runs your account, run: fedipod passwd"
|
|
66176
66224
|
});
|
|
66177
66225
|
}
|
|
66178
66226
|
const code = external ? api.mintCode({
|
|
@@ -66186,6 +66234,7 @@ async function handle(api, ctx) {
|
|
|
66186
66234
|
const target = new URL(redirect);
|
|
66187
66235
|
target.searchParams.set("code", code);
|
|
66188
66236
|
if (params.get("state")) target.searchParams.set("state", params.get("state"));
|
|
66237
|
+
if (proved) return send(200, { redirect: target.href });
|
|
66189
66238
|
res.writeHead(302, { location: target.href });
|
|
66190
66239
|
res.end();
|
|
66191
66240
|
return true;
|
|
@@ -68091,6 +68140,7 @@ var MastoApi = class _MastoApi {
|
|
|
68091
68140
|
allowed = null,
|
|
68092
68141
|
scheme = null,
|
|
68093
68142
|
embedded = false,
|
|
68143
|
+
throughDoor = null,
|
|
68094
68144
|
mount = "",
|
|
68095
68145
|
streaming = true,
|
|
68096
68146
|
webPush = true,
|
|
@@ -68101,6 +68151,7 @@ var MastoApi = class _MastoApi {
|
|
|
68101
68151
|
this.embedded = embedded;
|
|
68102
68152
|
this.log = log2;
|
|
68103
68153
|
this.allowed = allowed;
|
|
68154
|
+
this.throughDoor = throughDoor;
|
|
68104
68155
|
this.scheme = scheme;
|
|
68105
68156
|
this.streaming = streaming;
|
|
68106
68157
|
this.webPush = webPush;
|