fedipod 1.49.2 → 1.50.2
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/bin/fedipod.mjs +2 -5
- package/cli.md +5 -10
- package/lib/client/masto/index.mjs +0 -3
- package/lib/client/masto/oauth.mjs +23 -112
- package/lib/client/masto/render.mjs +11 -4
- package/lib/core/agent.mjs +3 -12
- package/lib/core/graphview.mjs +9 -1
- package/lib/core/publisher/restore.mjs +1 -0
- package/lib/core/store.mjs +27 -6
- package/lib/device/cli/commands/setup.mjs +1 -24
- package/lib/device/setup.mjs +1 -5
- package/lib/shared/guard.mjs +2 -2
- package/lib/surface/routes/owner.mjs +2 -12
- package/lib/surface/surface.mjs +1 -2
- package/package.json +2 -2
- package/web/admin/index.html +1 -1
- package/web/admin/record.js +0 -3
- package/web/app/admin-facade.mjs +0 -6
- package/web/app/agent.mjs +3 -1
- package/web/app/dist/sw.js +220 -151
- package/web/app/dist/sw.js.map +3 -3
package/bin/fedipod.mjs
CHANGED
|
@@ -70,9 +70,6 @@
|
|
|
70
70
|
// into profiles/<its handle>/. Installs made before the
|
|
71
71
|
// 2026-07-30 rename keep ~/.activitypod until they run
|
|
72
72
|
// `--to`; new ones get ~/.fedipod.
|
|
73
|
-
// fedipod passwd set/change the UI password (REQUIRED before any
|
|
74
|
-
// non-loopback exposure — it turns the instant
|
|
75
|
-
// OAuth redirect into a real login form)
|
|
76
73
|
// fedipod tokens list client tokens; --revoke <prefix> / --revoke-all
|
|
77
74
|
// fedipod revoke-credential --email you@example.org
|
|
78
75
|
// kill this machine's pod credential server-side and
|
|
@@ -93,7 +90,7 @@ const COMMANDS = new Map([
|
|
|
93
90
|
['up', run.up], ['start', run.start], ['run', run.start], ['stop', run.stop], ['status', run.status],
|
|
94
91
|
['https', run.https],
|
|
95
92
|
['setup', setup.setup], ['rotate-key', setup.rotateKey], ['revoke-credential', setup.revokeCredential],
|
|
96
|
-
['tokens', setup.tokens], ['
|
|
93
|
+
['tokens', setup.tokens], ['keys', setup.keys],
|
|
97
94
|
['state', state.state], ['upgrade', state.upgrade], ['profiles', state.profiles], ['home', state.home],
|
|
98
95
|
['export', state.exportCollectionsCmd],
|
|
99
96
|
['update', service.update], ['install-service', service.service], ['uninstall-service', service.service],
|
|
@@ -109,7 +106,7 @@ const command = COMMANDS.get(cmd);
|
|
|
109
106
|
if (command) {
|
|
110
107
|
await command();
|
|
111
108
|
} else {
|
|
112
|
-
console.log('usage: fedipod <setup|start|stop|status|state|upgrade|rebuild|home
|
|
109
|
+
console.log('usage: fedipod <setup|start|stop|status|state|upgrade|rebuild|home'
|
|
113
110
|
+ '|tokens|revoke-credential|install-service|archive|alias|import|keys|front> [--flags]');
|
|
114
111
|
console.log(' keys: where the signing key lives; --to pod|local moves it (pod = multi-device signing)');
|
|
115
112
|
console.log(' https: the local certificate agents serve TLS with; --trust mints a local CA for strict clients');
|
package/cli.md
CHANGED
|
@@ -77,20 +77,15 @@ nothing local is overwritten. Agent must be running.
|
|
|
77
77
|
## Security
|
|
78
78
|
|
|
79
79
|
```
|
|
80
|
-
fedipod passwd # require a password when a client logs in
|
|
81
80
|
fedipod tokens # list client logins; --revoke <prefix> or --revoke-all
|
|
82
81
|
fedipod revoke-credential --email EMAIL # cut this machine off from the pod account
|
|
83
82
|
```
|
|
84
|
-
A new password takes effect when a running agent restarts.
|
|
85
83
|
|
|
86
|
-
**A client hosted somewhere else
|
|
87
|
-
site (Elk, Phanpy on someone's own domain) asks the agent to send
|
|
88
|
-
result back to that site
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
So: no password, no sending credentials off this machine. Clients that live on
|
|
92
|
-
the agent itself (the bundled one, a dist in `ui/`, anything on
|
|
93
|
-
`https://localhost:<port>`) are unaffected and need no password.
|
|
84
|
+
**A client hosted somewhere else is approved at your pod.** A client running
|
|
85
|
+
on its own site (Elk, Phanpy on someone's own domain) asks the agent to send
|
|
86
|
+
the sign-in result back to that site; you approve it by signing in at your pod
|
|
87
|
+
and pressing Allow. Clients that live on the agent itself (the bundled one, a
|
|
88
|
+
dist in `ui/`, anything on `https://localhost:<port>`) need nothing.
|
|
94
89
|
|
|
95
90
|
## Bluesky
|
|
96
91
|
|
|
@@ -26,7 +26,6 @@ import * as accounts from './accounts.mjs';
|
|
|
26
26
|
import * as timelines from './timelines.mjs';
|
|
27
27
|
import * as statuses from './statuses.mjs';
|
|
28
28
|
import * as media from './media.mjs';
|
|
29
|
-
export { hashPassword } from './oauth.mjs';
|
|
30
29
|
export { pollParams } from './body.mjs';
|
|
31
30
|
export { attachmentType, extensionFor } from './media.mjs';
|
|
32
31
|
|
|
@@ -74,7 +73,6 @@ export class MastoApi {
|
|
|
74
73
|
// loss — the client says "scheduled" and the post never appears. Refusing
|
|
75
74
|
// it is worse UX and better behaviour.
|
|
76
75
|
this.scheduling = scheduling;
|
|
77
|
-
this.authzAttempts = []; // password-attempt timestamps
|
|
78
76
|
}
|
|
79
77
|
|
|
80
78
|
get store() { return this.agent.store; }
|
|
@@ -182,7 +180,6 @@ export class MastoApi {
|
|
|
182
180
|
tokenOf(...a) { return oauth.tokenOf(this, ...a); }
|
|
183
181
|
authed(...a) { return oauth.authed(this, ...a); }
|
|
184
182
|
redirectAllowed(...a) { return oauth.redirectAllowed(this, ...a); }
|
|
185
|
-
rateLimited(...a) { return oauth.rateLimited(this, ...a); }
|
|
186
183
|
|
|
187
184
|
// render.mjs
|
|
188
185
|
selfAccount(...a) { return render.selfAccount(this, ...a); }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// oauth.mjs — how a client gets in: registered apps and client documents,
|
|
2
2
|
// authorization codes and PKCE, the bearer tokens and their scopes, the
|
|
3
|
-
//
|
|
3
|
+
// owner's approval at their pod, and the three /oauth endpoints plus app registration.
|
|
4
4
|
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
import { isCrossSiteNavigation } from '../../shared/guard.mjs';
|
|
@@ -8,8 +8,6 @@ import { safeFetch, readCapped } from '../../shared/safefetch.mjs';
|
|
|
8
8
|
import { readBody } from './body.mjs';
|
|
9
9
|
|
|
10
10
|
const TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000; // tokens age out after 90 days
|
|
11
|
-
const AUTHZ_WINDOW_MS = 60_000;
|
|
12
|
-
const AUTHZ_MAX_ATTEMPTS = 5;
|
|
13
11
|
const CODE_TTL_MS = 5 * 60_000; // an authorization code is short-lived
|
|
14
12
|
const MAX_APPS = 200; // registered third-party clients, capped
|
|
15
13
|
const MAX_REDIRECTS = 10; // return addresses one client may register
|
|
@@ -22,20 +20,6 @@ const CLIENT_DOC_WINDOW_MS = 60_000;
|
|
|
22
20
|
const CLIENT_DOC_MAX_FETCHES = 20; // outbound lookups per window
|
|
23
21
|
const CLIENT_DOC_MAX = 64 * 1024; // it names a client; it is not a payload
|
|
24
22
|
|
|
25
|
-
// scrypt check for the optional UI password ({ saltHex, hashHex } record —
|
|
26
|
-
// see hashPassword, used by the passwd CLI).
|
|
27
|
-
export function hashPassword(password) {
|
|
28
|
-
const salt = crypto.randomBytes(16);
|
|
29
|
-
const hash = crypto.scryptSync(String(password), salt, 32);
|
|
30
|
-
return { saltHex: salt.toString('hex'), hashHex: hash.toString('hex') };
|
|
31
|
-
}
|
|
32
|
-
export function checkPassword(rec, password) {
|
|
33
|
-
try {
|
|
34
|
-
const hash = crypto.scryptSync(String(password), Buffer.from(rec.saltHex, 'hex'), 32);
|
|
35
|
-
return crypto.timingSafeEqual(hash, Buffer.from(rec.hashHex, 'hex'));
|
|
36
|
-
} catch { return false; }
|
|
37
|
-
}
|
|
38
|
-
|
|
39
23
|
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c =>
|
|
40
24
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
41
25
|
|
|
@@ -85,48 +69,6 @@ button[hidden]{display:none}
|
|
|
85
69
|
return true;
|
|
86
70
|
}
|
|
87
71
|
|
|
88
|
-
function sendLoginForm(res, params, error = '', client = null, status = null, headers = {}, mount = '') {
|
|
89
|
-
const hidden = [...params.entries()].filter(([k]) => k !== 'password')
|
|
90
|
-
.map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`).join('\n');
|
|
91
|
-
// Name what is asking, so the owner approves a client they can see rather
|
|
92
|
-
// than an opaque one. The destination the code will be sent to is the fact
|
|
93
|
-
// that matters for a cross-site client.
|
|
94
|
-
let asking = '<p>Enter the agent password to authorize this client.</p>';
|
|
95
|
-
if (client && (client.name || client.redirect)) {
|
|
96
|
-
let where = '';
|
|
97
|
-
try { where = client.redirect ? new URL(client.redirect).host : ''; } catch { /* oob or blank */ }
|
|
98
|
-
const who = client.name ? escapeHtml(client.name) : (where ? escapeHtml(where) : 'A client');
|
|
99
|
-
asking = `<p><strong>${who}</strong> is asking to access your account`
|
|
100
|
-
+ `${where ? `, sending the authorization to <code>${escapeHtml(where)}</code>` : ''}.</p>`
|
|
101
|
-
+ `<p>Scope: <code>${escapeHtml(client.scope || 'read')}</code>. Enter the agent password to allow it.</p>`;
|
|
102
|
-
}
|
|
103
|
-
res.writeHead(status || (error ? 401 : 200),
|
|
104
|
-
{ 'content-type': 'text/html; charset=utf-8', ...headers });
|
|
105
|
-
res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
106
|
-
<title>FediPod — authorize</title>
|
|
107
|
-
<style>:root{color-scheme:light dark;font-size:125%;--heading:#1a4f8a}
|
|
108
|
-
body{font:1rem system-ui,sans-serif;max-width:22rem;margin:15vh auto;padding:0 1rem}
|
|
109
|
-
h1{color:var(--heading)}
|
|
110
|
-
code{word-break:break-all}
|
|
111
|
-
@media (prefers-color-scheme:dark){:root{--heading:#7fb3e8}}
|
|
112
|
-
input,button{font:inherit;width:100%;padding:.5rem;margin:.3rem 0;box-sizing:border-box}
|
|
113
|
-
.err{color:#b00020}
|
|
114
|
-
@media (prefers-color-scheme:dark){.err{color:#ff8a8a}}</style></head><body>
|
|
115
|
-
<main>
|
|
116
|
-
<h1>FediPod</h1>
|
|
117
|
-
${asking}
|
|
118
|
-
${error ? `<p class="err" id="login-err" role="alert">${escapeHtml(error)}</p>` : ''}
|
|
119
|
-
<form method="POST" action="${escapeHtml(mount)}/oauth/authorize">
|
|
120
|
-
${hidden}
|
|
121
|
-
<label for="password">Agent password</label>
|
|
122
|
-
<input type="password" id="password" name="password" autofocus autocomplete="current-password"
|
|
123
|
-
${error ? 'aria-invalid="true" aria-describedby="login-err"' : ''}>
|
|
124
|
-
<button type="submit">Authorize</button>
|
|
125
|
-
</form>
|
|
126
|
-
</main></body></html>`);
|
|
127
|
-
return true;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
72
|
// What one request needs. Mastodon's four coarse scopes; a client that was
|
|
131
73
|
// granted a granular `write:statuses` satisfies `write` here, which is the
|
|
132
74
|
// direction that cannot let anything through that `write` would not.
|
|
@@ -439,22 +381,13 @@ export function redirectAllowed(api, redirect) {
|
|
|
439
381
|
} catch { return false; }
|
|
440
382
|
}
|
|
441
383
|
|
|
442
|
-
export function rateLimited(api) {
|
|
443
|
-
const now = Date.now();
|
|
444
|
-
api.authzAttempts = api.authzAttempts.filter(t => now - t < AUTHZ_WINDOW_MS);
|
|
445
|
-
if (api.authzAttempts.length >= AUTHZ_MAX_ATTEMPTS) return true;
|
|
446
|
-
api.authzAttempts.push(now);
|
|
447
|
-
return false;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
384
|
export async function handle(api, ctx) {
|
|
451
385
|
const { req, res, pathname, url, send } = ctx; // eslint-disable-line no-unused-vars
|
|
452
386
|
|
|
453
387
|
// --- oauth ---
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
// authorize step becomes a real login form.
|
|
388
|
+
// On loopback this is "theater": whoever can reach the surface IS the
|
|
389
|
+
// trusted user, and authorize answers at once. Anywhere else the owner
|
|
390
|
+
// approves the client by signing in at their pod.
|
|
458
391
|
if (pathname === '/api/v1/apps' && req.method === 'POST') {
|
|
459
392
|
const body = await readBody(req);
|
|
460
393
|
const problem = redirectsProblem(body.redirect_uris);
|
|
@@ -475,14 +408,12 @@ export async function handle(api, ctx) {
|
|
|
475
408
|
});
|
|
476
409
|
}
|
|
477
410
|
if (pathname === '/oauth/authorize' && (req.method === 'GET' || req.method === 'POST')) {
|
|
478
|
-
const pw = api.store.getConfig()?.uiPassword; // { saltHex, hashHex } scrypt record
|
|
479
411
|
let params = url.searchParams;
|
|
480
412
|
let body = null;
|
|
481
413
|
if (req.method === 'POST') { body = await readBody(req); params = new URLSearchParams(body); }
|
|
482
414
|
const redirect = params.get('redirect_uri') || '';
|
|
483
415
|
// The owner's pod sign-in, proved: a Solid-OIDC token with its DPoP proof,
|
|
484
|
-
// from the WebID this account belongs to. It
|
|
485
|
-
// everywhere below.
|
|
416
|
+
// from the WebID this account belongs to. It is how a client is approved.
|
|
486
417
|
let proved = false;
|
|
487
418
|
if (req.method === 'POST' && req.headers.authorization && req.headers.dpop && api.podAuth) {
|
|
488
419
|
const proof = await api.podAuth(req, pathname);
|
|
@@ -513,7 +444,7 @@ export async function handle(api, ctx) {
|
|
|
513
444
|
//
|
|
514
445
|
// /api/v1/apps takes any redirect_uri from anyone — as it must, that is
|
|
515
446
|
// how a fediverse client registers — and the instant-authorize path below
|
|
516
|
-
// mints
|
|
447
|
+
// mints at once whenever the request "is this machine". A page
|
|
517
448
|
// in another tab is this machine. So: register a client whose redirect is
|
|
518
449
|
// your own server, navigate the owner's browser to /oauth/authorize, and
|
|
519
450
|
// the code arrives at your address. The agent's own CA is in the browser's
|
|
@@ -530,12 +461,10 @@ export async function handle(api, ctx) {
|
|
|
530
461
|
api.log(`authorize refused: cross-site navigation to the mint from ${req.headers.referer || 'nowhere'}`);
|
|
531
462
|
return send(403, { error: 'a cross-site navigation may not authorize a client' });
|
|
532
463
|
}
|
|
533
|
-
// 2.
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
// about a web page. Anything pointing elsewhere has to be approved by
|
|
538
|
-
// somebody who knows the password.
|
|
464
|
+
// 2. The unapproved path is honest only for a client on an address this
|
|
465
|
+
// agent itself answers on — where "whoever reaches the port is the
|
|
466
|
+
// owner" is a statement about the machine rather than about a web
|
|
467
|
+
// page. Anything pointing elsewhere is approved by the owner at their pod.
|
|
539
468
|
const client = { name: app?.name || doc?.name || null, redirect, scope: params.get('scope') || 'read' };
|
|
540
469
|
// A proof method named and not known is refused rather than read as the
|
|
541
470
|
// weakest one, whose proof is the challenge itself.
|
|
@@ -543,13 +472,12 @@ export async function handle(api, ctx) {
|
|
|
543
472
|
if (params.get('code_challenge') && challengeMethod && challengeMethod !== 'S256' && challengeMethod !== 'plain') {
|
|
544
473
|
return send(400, { error: 'code_challenge_method must be S256 or plain' });
|
|
545
474
|
}
|
|
546
|
-
if (external && !proved && !api.
|
|
547
|
-
//
|
|
475
|
+
if (external && !proved && !api.redirectAllowed(redirect)) {
|
|
476
|
+
// The owner approves the client at their pod.
|
|
548
477
|
if (req.method === 'GET' && ownerWebId) return sendPodSigninPage(res, client, ownerWebId, api.mount || '', ownAddress(api));
|
|
549
|
-
api.log(`authorize refused:
|
|
478
|
+
api.log(`authorize refused: "${redirect}" is not an address of this agent, and nothing approved it`);
|
|
550
479
|
return send(403, {
|
|
551
|
-
error: 'this client asks to be sent somewhere other than this
|
|
552
|
-
+ 'set to approve that with. Run `fedipod passwd` and try again.',
|
|
480
|
+
error: 'this client asks to be sent somewhere other than this account; approve it by signing in at your pod',
|
|
553
481
|
});
|
|
554
482
|
}
|
|
555
483
|
if (app) {
|
|
@@ -573,26 +501,10 @@ export async function handle(api, ctx) {
|
|
|
573
501
|
api.log(`authorize refused: redirect_uri "${redirect}" is not this agent`);
|
|
574
502
|
return send(400, { error: 'redirect_uri must be an address of this agent' });
|
|
575
503
|
}
|
|
576
|
-
if (
|
|
577
|
-
|
|
578
|
-
api.log('authorize rate limited');
|
|
579
|
-
// 429, not 401: a client that reads this as a wrong password will
|
|
580
|
-
// ask the person to type it again, which is the one thing that
|
|
581
|
-
// cannot help. Retry-After says how long the wait actually is.
|
|
582
|
-
return sendLoginForm(res, params, 'too many attempts — wait a minute', client,
|
|
583
|
-
429, { 'retry-after': String(Math.ceil(AUTHZ_WINDOW_MS / 1000)) }, api.mount || '');
|
|
584
|
-
}
|
|
585
|
-
if (!pw || !checkPassword(pw, body.password || '')) {
|
|
586
|
-
return sendLoginForm(res, params, 'wrong password — try again', client, null, {}, api.mount || '');
|
|
587
|
-
}
|
|
588
|
-
} else if (pw && !proved) {
|
|
589
|
-
// The login/approve screen names what is asking before the owner types
|
|
590
|
-
// the password — a cross-site client cannot forge past that.
|
|
591
|
-
return sendLoginForm(res, params, '', client, null, {}, api.mount || '');
|
|
592
|
-
} else if (!proved && api.allowed && !api.allowed.isLocalRequest(req) && !api.throughDoor?.(req)) {
|
|
593
|
-
// With a pod to sign in at, the owner does that instead of a password.
|
|
504
|
+
if (!proved && api.allowed && !api.allowed.isLocalRequest(req) && !api.throughDoor?.(req)) {
|
|
505
|
+
// The owner signs in at their pod and approves the client there.
|
|
594
506
|
if (req.method === 'GET' && ownerWebId) return sendPodSigninPage(res, client, ownerWebId, api.mount || '', ownAddress(api));
|
|
595
|
-
//
|
|
507
|
+
// Not proved, and this request did not come from this machine.
|
|
596
508
|
//
|
|
597
509
|
// The instant-authorize path is honest theatre on loopback: whoever can
|
|
598
510
|
// reach it IS the trusted user. It stops being theatre the moment an
|
|
@@ -602,11 +514,11 @@ export async function handle(api, ctx) {
|
|
|
602
514
|
// — update_credentials included, which is precisely the authority the
|
|
603
515
|
// isLocal check on /setup and /config exists to withhold. /oauth is
|
|
604
516
|
// dispatched before that check ever runs, so it needs its own.
|
|
605
|
-
api.log(`authorize refused:
|
|
517
|
+
api.log(`authorize refused: not approved at the pod, and "${req.headers.host}" is not this machine`);
|
|
606
518
|
return send(403, {
|
|
607
519
|
error: api.embedded
|
|
608
520
|
? 'Open your account\'s management page first, then sign in from there.'
|
|
609
|
-
: 'Signing in from another machine
|
|
521
|
+
: 'Signing in from another machine goes through your pod: sign in there to approve this app.',
|
|
610
522
|
});
|
|
611
523
|
}
|
|
612
524
|
// External clients get a bound code; the local flow keeps code==token.
|
|
@@ -696,11 +608,10 @@ export async function handle(api, ctx) {
|
|
|
696
608
|
// would mean a second round trip before it knows who it is.
|
|
697
609
|
...(api.urls?.actor ? { activitypub_actor_id: api.urls.actor } : {}) });
|
|
698
610
|
}
|
|
699
|
-
// Local flow: the code IS the token, minted by /oauth/authorize
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
// on any agent deliberately exposed through AP_ALLOWED_HOSTS.
|
|
611
|
+
// Local flow: the code IS the token, minted by /oauth/authorize. Minting
|
|
612
|
+
// one here for an unrecognised code handed a bearer to anyone who could
|
|
613
|
+
// reach the port — and a non-browser client sends no Origin, so the
|
|
614
|
+
// firewall never saw it.
|
|
704
615
|
if (!body.code || !api.tokens().includes(body.code)) {
|
|
705
616
|
api.log('token refused: code is not a live authorization');
|
|
706
617
|
return send(400, { error: 'invalid_grant' });
|
|
@@ -10,6 +10,13 @@ import { sanitizeHtml, followsNeedApproval, publicHandle } from '../../core/wire
|
|
|
10
10
|
import { profileUrl, postUrl } from '../../connections/bskyfeed.mjs';
|
|
11
11
|
import { htmlToText } from './body.mjs';
|
|
12
12
|
|
|
13
|
+
// Posts stored from 2026-09-13 to 2026-10-10 hold a Mastodon post's text twice,
|
|
14
|
+
// joined by a comma; shown once.
|
|
15
|
+
export const once = (html) => {
|
|
16
|
+
const m = (html.length - 1) / 2;
|
|
17
|
+
return Number.isInteger(m) && html[m] === ',' && html.slice(0, m) === html.slice(m + 1) ? html.slice(0, m) : html;
|
|
18
|
+
};
|
|
19
|
+
|
|
13
20
|
// 1x1 transparent PNG — placeholder header, and the instance thumbnail. A
|
|
14
21
|
// banner nobody set should take up no room and show nothing; an avatar is a
|
|
15
22
|
// different matter (below).
|
|
@@ -273,7 +280,7 @@ export function status(api, s, { all, depth = 0 } = {}) {
|
|
|
273
280
|
created_at: s.published || new Date().toISOString(),
|
|
274
281
|
in_reply_to_id: s.inReplyTo ? api.store.idFor(s.inReplyTo) : null,
|
|
275
282
|
in_reply_to_account_id: null,
|
|
276
|
-
sensitive: !!(s.spoiler || s.sensitive), spoiler_text: s.spoiler || '',
|
|
283
|
+
sensitive: !!(s.spoiler || s.sensitive), spoiler_text: once(s.spoiler || ''),
|
|
277
284
|
visibility: s.visibility || 'public', language: null,
|
|
278
285
|
edited_at: s.editedAt || null,
|
|
279
286
|
uri: s.noteId, url: s.link || s.noteId,
|
|
@@ -284,7 +291,7 @@ export function status(api, s, { all, depth = 0 } = {}) {
|
|
|
284
291
|
favourited: !!s.favourited || (s.sourceAccts || []).some(v => v.favourited),
|
|
285
292
|
reblogged: !!s.reblogged || (s.sourceAccts || []).some(v => v.reblogged),
|
|
286
293
|
muted: false, bookmarked: !!s.bookmarked, pinned: !!s.pinned,
|
|
287
|
-
content: s.content || '',
|
|
294
|
+
content: once(s.content || ''),
|
|
288
295
|
reblog: null, application: null,
|
|
289
296
|
account: api.account(s.actor),
|
|
290
297
|
media_attachments: (s.attachments || []).map(a => api.mediaJson(a)),
|
|
@@ -323,7 +330,7 @@ export function status(api, s, { all, depth = 0 } = {}) {
|
|
|
323
330
|
// information it uses to decide anyway.
|
|
324
331
|
export function filtersFor(api, s) {
|
|
325
332
|
const now = Date.now();
|
|
326
|
-
const hay = `${s.content || ''} ${s.spoiler || ''}`
|
|
333
|
+
const hay = `${once(s.content || '')} ${once(s.spoiler || '')}`
|
|
327
334
|
.replace(/<[^>]*>/gu, ' ') // the text, not the markup around it
|
|
328
335
|
.toLowerCase();
|
|
329
336
|
if (!hay.trim()) return [];
|
|
@@ -403,7 +410,7 @@ export function pushNotify(api, n) {
|
|
|
403
410
|
return api.push.notify(n, {
|
|
404
411
|
notification_id: n.id, notification_type: n.type, preferred_locale: 'en',
|
|
405
412
|
title: `${acct.display_name || acct.acct} ${verbs[n.type] || n.type}`,
|
|
406
|
-
body: s ? htmlToText(s.content || '').slice(0, 140) : '',
|
|
413
|
+
body: s ? htmlToText(once(s.content || '')).slice(0, 140) : '',
|
|
407
414
|
icon: acct.avatar || '',
|
|
408
415
|
});
|
|
409
416
|
}
|
package/lib/core/agent.mjs
CHANGED
|
@@ -210,8 +210,8 @@ export class Agent {
|
|
|
210
210
|
this.log(`this actor was retired on ${config.retiredAt} — run setup for a new identity`);
|
|
211
211
|
return false;
|
|
212
212
|
}
|
|
213
|
-
// A rename is a merge into the existing config, never a rewrite —
|
|
214
|
-
//
|
|
213
|
+
// A rename is a merge into the existing config, never a rewrite —
|
|
214
|
+
// anything set later must survive it.
|
|
215
215
|
if (name && name !== config.name) {
|
|
216
216
|
this.store.setConfig({ ...config, name });
|
|
217
217
|
config = this.store.getConfig();
|
|
@@ -235,11 +235,7 @@ export class Agent {
|
|
|
235
235
|
// came out bare. Pod state is the authority; this is the cache of it.
|
|
236
236
|
this.recordHandle(config.handle);
|
|
237
237
|
|
|
238
|
-
// Said once, where the config is finally known.
|
|
239
|
-
// token is refused before the socket opens (startAgent), so reaching here
|
|
240
|
-
// means the token is set and the whole surface is behind it. A UI password
|
|
241
|
-
// is the second, per-person half: without one, anyone who has the token
|
|
242
|
-
// gets a client bearer for the asking.
|
|
238
|
+
// Said once, where the config is finally known.
|
|
243
239
|
// An install made before the shape changed goes on working, which is why
|
|
244
240
|
// nothing here refuses — but it should not go on SILENTLY. The whole reason
|
|
245
241
|
// this is said out loud is that bbba587 changed a default and every install
|
|
@@ -253,11 +249,6 @@ export class Agent {
|
|
|
253
249
|
// read by nobody who lives in the GUI.
|
|
254
250
|
this.pendingUpgrade = pending.map(s => s.what);
|
|
255
251
|
|
|
256
|
-
if (process.env.AP_ALLOWED_HOSTS && !config.uiPassword) {
|
|
257
|
-
this.log('WARNING: AP_ALLOWED_HOSTS is set and no UI password is — anyone holding '
|
|
258
|
-
+ 'AP_GATE_TOKEN can mint a client token. Run `fedipod passwd`.');
|
|
259
|
-
}
|
|
260
|
-
|
|
261
252
|
if (!act) return true; // reading only — see the note above
|
|
262
253
|
|
|
263
254
|
// Exactly one agent may act on a pod (inbox drains are destructive
|
package/lib/core/graphview.mjs
CHANGED
|
@@ -227,6 +227,8 @@ function fromLiteral(term) {
|
|
|
227
227
|
return term.value;
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
const isTagged = (term) => term.termType === 'Literal' && !!term.language;
|
|
231
|
+
|
|
230
232
|
function toValue(term, ctx, depth, path) {
|
|
231
233
|
if (term.termType === 'Literal') return fromLiteral(term);
|
|
232
234
|
if (isListHead(term, ctx)) return readList(term.value, ctx, depth, path);
|
|
@@ -248,8 +250,14 @@ function readProperty(subject, name, ctx, depth = 0, path = new Set()) {
|
|
|
248
250
|
|
|
249
251
|
const term = TERMS.get(name);
|
|
250
252
|
if (!term) return undefined;
|
|
251
|
-
|
|
253
|
+
let values = preds.get(term.iri);
|
|
252
254
|
if (!values || !values.length) return undefined;
|
|
255
|
+
// `content` and `contentMap` are one predicate, and Mastodon sends a post's
|
|
256
|
+
// text both ways. The plain term is the untagged copy, else the first tagged.
|
|
257
|
+
if (values.some(isTagged)) {
|
|
258
|
+
const plain = values.filter(v => !isTagged(v));
|
|
259
|
+
values = plain.length ? plain : [values.find(isTagged)];
|
|
260
|
+
}
|
|
253
261
|
|
|
254
262
|
// `items` and `orderedItems` are the same predicate; the list is what tells
|
|
255
263
|
// them apart, so asking for the ordered one only answers when it is ordered.
|
|
@@ -210,6 +210,7 @@ export async function rebuildStatuses(publisher, { fromNotes = false } = {}) {
|
|
|
210
210
|
// merge-only rebuild when something is.
|
|
211
211
|
export async function healStatuses(publisher) {
|
|
212
212
|
const { urls, store } = publisher;
|
|
213
|
+
if (store.unread?.has('statuses.json')) return { missing: 0, recovered: 0 }; // nothing to compare with
|
|
213
214
|
const own = store.read('outbox.json', [])
|
|
214
215
|
.map(i => (typeof i === 'string' ? i : null))
|
|
215
216
|
.filter(id => id && id.startsWith(urls.notes));
|
package/lib/core/store.mjs
CHANGED
|
@@ -90,6 +90,10 @@ export class PodStore {
|
|
|
90
90
|
this._held = 0;
|
|
91
91
|
this.chain = Promise.resolve(); // serialized writes
|
|
92
92
|
this.verdicts = new Map(); // name → did its last write land? (read by commit)
|
|
93
|
+
// Documents the pod holds that could not be read, and that nothing here
|
|
94
|
+
// has a copy of. Never written to until they are read: what this store
|
|
95
|
+
// would send is not the document, and would replace it on the pod.
|
|
96
|
+
this.unread = new Set();
|
|
93
97
|
// Moves on each time queued writes are dropped (discardPending): work that
|
|
94
98
|
// began in an earlier generation has lost results, whatever a commit says.
|
|
95
99
|
this.generation = 0;
|
|
@@ -106,7 +110,7 @@ export class PodStore {
|
|
|
106
110
|
attach(storage) {
|
|
107
111
|
// A different tree is different state: carrying the old cache and its
|
|
108
112
|
// ETags across would serve one container's documents as another's.
|
|
109
|
-
if (this.storage && this.storage.base !== storage.base) { this.cache.clear(); this.etags.clear(); this.lastText.clear(); }
|
|
113
|
+
if (this.storage && this.storage.base !== storage.base) { this.cache.clear(); this.etags.clear(); this.lastText.clear(); this.unread.clear(); }
|
|
110
114
|
this.storage = storage;
|
|
111
115
|
}
|
|
112
116
|
|
|
@@ -137,8 +141,8 @@ export class PodStore {
|
|
|
137
141
|
if (listing.missing && this.storage.kind === 'pod') {
|
|
138
142
|
throw new Error(`state container missing (HTTP 404) at ${this.base} — pod unreachable or state gone`);
|
|
139
143
|
}
|
|
140
|
-
this.etags.set('', listing.etag);
|
|
141
144
|
const names = listing.names.filter(n => n.endsWith('.json'));
|
|
145
|
+
for (const name of this.unread) if (!names.includes(name)) this.unread.delete(name); // gone: nothing to write over
|
|
142
146
|
let fetched = 0;
|
|
143
147
|
const skipped = [];
|
|
144
148
|
for (const name of names) {
|
|
@@ -147,17 +151,20 @@ export class PodStore {
|
|
|
147
151
|
if (r.notModified) continue; // ours is current
|
|
148
152
|
if (!r.ok) {
|
|
149
153
|
// One unreadable document must not restart the whole sweep — skip it,
|
|
150
|
-
// keep whatever we already hold, and try again next load (
|
|
151
|
-
// recorded, so the
|
|
154
|
+
// keep whatever we already hold, and try again next load (neither its
|
|
155
|
+
// etag nor the container's is recorded, so the next load lists and
|
|
156
|
+
// reads it again). config.json is the
|
|
152
157
|
// exception: without it the caller cannot tell "never set up" from
|
|
153
158
|
// "could not read", and would tell the user to run setup.
|
|
154
159
|
if (name === 'config.json' && !this.cache.has(name)) {
|
|
155
160
|
throw new Error(`state doc ${name} unreadable (HTTP ${r.status})`);
|
|
156
161
|
}
|
|
162
|
+
if (!this.lastText.has(name)) this.unread.add(name);
|
|
157
163
|
skipped.push(`${name} (HTTP ${r.status})`);
|
|
158
164
|
continue;
|
|
159
165
|
}
|
|
160
166
|
fetched++;
|
|
167
|
+
this.unread.delete(name);
|
|
161
168
|
this.etags.set(name, r.etag);
|
|
162
169
|
try { this.cache.set(name, JSON.parse(r.body)); this.lastText.set(name, r.body); }
|
|
163
170
|
catch (e) { this.log(`state load ${name}: unparsable (${e.message})`); }
|
|
@@ -165,7 +172,12 @@ export class PodStore {
|
|
|
165
172
|
// Kept for /status: a document skipped here is a timeline or a contact
|
|
166
173
|
// list quietly missing, and the owner should be able to see that.
|
|
167
174
|
this.lastSkipped = skipped;
|
|
168
|
-
|
|
175
|
+
// The container's ETag vouches for every document in it, so it is kept
|
|
176
|
+
// only when every one was read; otherwise the next load would stop at
|
|
177
|
+
// "not modified" and never retry what was skipped.
|
|
178
|
+
if (skipped.length) this.etags.delete(''); else this.etags.set('', listing.etag);
|
|
179
|
+
if (skipped.length) this.log(`state load skipped ${skipped.length}: ${skipped.join(', ')}`
|
|
180
|
+
+ (this.unread.size ? ` — not written until read: ${[...this.unread].join(', ')}` : ''));
|
|
169
181
|
this.log(`state loaded: ${this.cache.size} doc(s) from ${this.base} (${fetched} re-fetched)`);
|
|
170
182
|
this.onLoaded?.();
|
|
171
183
|
}
|
|
@@ -177,6 +189,7 @@ export class PodStore {
|
|
|
177
189
|
for (const { name, text, etag } of docs) {
|
|
178
190
|
try { this.cache.set(name, JSON.parse(text)); } catch { continue; }
|
|
179
191
|
this.lastText.set(name, text);
|
|
192
|
+
this.unread.delete(name);
|
|
180
193
|
if (etag) this.etags.set(name, etag);
|
|
181
194
|
}
|
|
182
195
|
if (listingEtag) this.etags.set('', listingEtag);
|
|
@@ -193,11 +206,12 @@ export class PodStore {
|
|
|
193
206
|
const r = await this.storage.read(name, etag ? { etag } : undefined);
|
|
194
207
|
if (r.notModified) continue;
|
|
195
208
|
if (r.status === 404) {
|
|
196
|
-
this.cache.delete(name); this.lastText.delete(name); this.etags.delete(name);
|
|
209
|
+
this.cache.delete(name); this.lastText.delete(name); this.etags.delete(name); this.unread.delete(name);
|
|
197
210
|
} else if (!r.ok) {
|
|
198
211
|
throw new Error(`state doc ${name} unreadable (HTTP ${r.status})`);
|
|
199
212
|
} else {
|
|
200
213
|
this.cache.set(name, JSON.parse(r.body)); this.lastText.set(name, r.body); this.etags.set(name, r.etag);
|
|
214
|
+
this.unread.delete(name);
|
|
201
215
|
}
|
|
202
216
|
this.onSettled?.(name);
|
|
203
217
|
}
|
|
@@ -216,6 +230,8 @@ export class PodStore {
|
|
|
216
230
|
write(name, obj) {
|
|
217
231
|
this.cache.set(name, structuredClone(obj));
|
|
218
232
|
if (!this.storage) return;
|
|
233
|
+
// Not sent: the next commit says it did not land, and reads it first.
|
|
234
|
+
if (this.unread.has(name)) { this.verdicts.set(name, false); return; }
|
|
219
235
|
// The same bytes the pod already holds are not sent again. A like, a
|
|
220
236
|
// bookmark, a sweep that found nothing new each used to upload the whole
|
|
221
237
|
// timeline index and the whole people cache, unchanged — the largest
|
|
@@ -263,6 +279,7 @@ export class PodStore {
|
|
|
263
279
|
// the returned promise so one failure cannot poison the queue.
|
|
264
280
|
_put(name) {
|
|
265
281
|
const done = this.chain.then(async () => {
|
|
282
|
+
if (this.unread.has(name)) return false;
|
|
266
283
|
const body = serialise(this.cache.get(name));
|
|
267
284
|
await Promise.resolve(this.onWriting?.(name, body)).catch(() => {});
|
|
268
285
|
for (let attempt = 1; attempt <= PUT_RETRIES; attempt++) {
|
|
@@ -313,6 +330,10 @@ export class PodStore {
|
|
|
313
330
|
// `since`: the generation the caller's work began in. Anything dropped since
|
|
314
331
|
// makes the answer false.
|
|
315
332
|
async commit({ since } = {}) {
|
|
333
|
+
// A document that could not be read is read now, replacing whatever was
|
|
334
|
+
// written to it here; those writes already count as not landed, so the
|
|
335
|
+
// drain keeps its items and takes them again against the real document.
|
|
336
|
+
if (this.unread.size && this.storage) await this.refresh([...this.unread]).catch(() => {});
|
|
316
337
|
const pending = [];
|
|
317
338
|
// Held names too, or a sweep that suspended the debounce would delete the
|
|
318
339
|
// pod's copy of an item whose result had not been written down — the one
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// setup.mjs — making an identity and its secrets: setup, rotate-key,
|
|
2
|
-
// revoke-credential, tokens,
|
|
2
|
+
// revoke-credential, tokens, keys.
|
|
3
3
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
@@ -398,29 +398,6 @@ if (has('revoke-all')) {
|
|
|
398
398
|
}
|
|
399
399
|
}
|
|
400
400
|
|
|
401
|
-
export async function passwd() {
|
|
402
|
-
requireIdentity();
|
|
403
|
-
const { Agent } = await import(new URL('../../../../run-agent.mjs', import.meta.url));
|
|
404
|
-
const { hashPassword } = await import(new URL('../../../../lib/client/masto/index.mjs', import.meta.url));
|
|
405
|
-
const { RemotePod } = await import(new URL('../../../../lib/device/remote.mjs', import.meta.url));
|
|
406
|
-
const { apUrls } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
|
|
407
|
-
const agent = new Agent({ home: HOME, log: () => {} });
|
|
408
|
-
const cred = agent.readCredential();
|
|
409
|
-
if (!cred) { console.error('no credential — run setup first'); process.exit(2); }
|
|
410
|
-
const pw = await askHidden('new UI password: ');
|
|
411
|
-
if (!pw) { console.error('empty password — aborted'); process.exit(2); }
|
|
412
|
-
agent.remote = new RemotePod(cred);
|
|
413
|
-
await agent.remote.warmup();
|
|
414
|
-
agent.urls = apUrls(cred.remotePod, cred.root);
|
|
415
|
-
agent.store.attach(agent.privateStorage(cred, 'state')); // honours privateRoot
|
|
416
|
-
await agent.store.load();
|
|
417
|
-
const config = agent.store.getConfig();
|
|
418
|
-
if (!config) { console.error('pod state empty — run setup first'); process.exit(2); }
|
|
419
|
-
agent.store.setConfig({ ...config, uiPassword: hashPassword(pw) });
|
|
420
|
-
await agent.store.flush();
|
|
421
|
-
console.log('UI password set — /oauth/authorize now shows a login form (restart a running agent to pick it up)');
|
|
422
|
-
}
|
|
423
|
-
|
|
424
401
|
export async function keys() {
|
|
425
402
|
// Where the signing key lives; `--to pod` moves it into pod state — with
|
|
426
403
|
// the state store on a pod every device reaches, that is what lets them
|
package/lib/device/setup.mjs
CHANGED
|
@@ -15,7 +15,6 @@ import * as $rdf from 'rdflib';
|
|
|
15
15
|
|
|
16
16
|
import { createAccountWithPod as realCreateAccount } from './account.mjs';
|
|
17
17
|
import { mintCredential as realMint, RemotePod } from './remote.mjs';
|
|
18
|
-
import { hashPassword } from '../client/masto/index.mjs';
|
|
19
18
|
import { webfingerHost, apUrls, DEFAULT_ROOT } from '../core/wire.mjs';
|
|
20
19
|
import { rootOf, recordLastUsed, writeJsonAtomic } from './home.mjs';
|
|
21
20
|
import { insecureUrlReason } from '../shared/safefetch.mjs';
|
|
@@ -233,7 +232,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
233
232
|
|
|
234
233
|
const {
|
|
235
234
|
mode, issuer, email, password, handle, name, podName,
|
|
236
|
-
kind = 'person', approveJoins = false, summary, icon, keys,
|
|
235
|
+
kind = 'person', approveJoins = false, summary, icon, keys,
|
|
237
236
|
gateway = null, shape = 'pod', gatewayOrigin = 'https://fedipod.net',
|
|
238
237
|
// The container that holds `fedipod/` (blank: the pod's root), and the
|
|
239
238
|
// person's yes to a new public type index when their profile names none.
|
|
@@ -436,9 +435,6 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
436
435
|
done('connect');
|
|
437
436
|
|
|
438
437
|
begin('publish');
|
|
439
|
-
if (uiPassword) {
|
|
440
|
-
agent.store.setConfig({ ...agent.store.getConfig(), uiPassword: hashPassword(uiPassword) });
|
|
441
|
-
}
|
|
442
438
|
const published = await agent.publisher.publishProfile();
|
|
443
439
|
await agent.store.flush();
|
|
444
440
|
// Where the account lives, in the owner's public type index — made first
|
package/lib/shared/guard.mjs
CHANGED
|
@@ -72,10 +72,10 @@ export function isLoopbackSocket(req) {
|
|
|
72
72
|
// means something on this machine is relaying, and a stock
|
|
73
73
|
// `proxy_pass http://127.0.0.1:<port>;` rewrites Host to a loopback authority
|
|
74
74
|
// and connects from loopback. Every caller then looks local, which is what the
|
|
75
|
-
// local-only routes and the
|
|
75
|
+
// local-only routes and the unapproved authorize path were trusting.
|
|
76
76
|
//
|
|
77
77
|
// A secret is all that is left, and AP_GATE_TOKEN is the one every route
|
|
78
|
-
// checks
|
|
78
|
+
// checks; an app's sign-in is approved at the owner's pod. Returns a
|
|
79
79
|
// reason string when the agent must not start, or null.
|
|
80
80
|
export function exposureProblem({ allowedHosts, gateToken } = {}) {
|
|
81
81
|
const exposed = String(allowedHosts || '').split(',').map(s => s.trim()).filter(Boolean);
|