fedipod 1.48.1 → 1.49.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/accounts.mjs +18 -4
- package/lib/client/masto/index.mjs +2 -0
- package/lib/client/masto/oauth.mjs +103 -25
- package/lib/client/oidc-auth.mjs +20 -2
- package/lib/client/webpush.mjs +14 -4
- package/lib/connections/tagfeed.mjs +28 -8
- package/lib/core/agent.mjs +16 -4
- package/lib/core/deliver.mjs +8 -2
- package/lib/core/intake/index.mjs +10 -5
- package/lib/core/publisher/index.mjs +3 -0
- package/lib/core/publisher/notes.mjs +5 -1
- package/lib/core/scheduled.mjs +12 -0
- package/lib/core/store.mjs +14 -0
- package/lib/gateway/front-core.mjs +33 -35
- package/lib/gateway/gateway-core.mjs +6 -1
- package/lib/gateway/masto-gateway.mjs +4 -2
- package/lib/gateway/notices.mjs +1 -1
- package/lib/gateway/outbox-door.mjs +3 -3
- package/lib/gateway/private-read.mjs +7 -1
- package/lib/gateway/quiet.mjs +3 -3
- package/lib/gateway/state-api.mjs +7 -3
- package/lib/gateway/token-claims.mjs +66 -4
- package/lib/server/embed.mjs +13 -5
- package/lib/shared/safefetch.mjs +11 -0
- package/lib/surface/surface.mjs +2 -1
- package/package.json +2 -2
- package/vendor/gate.cjs +10 -2
- package/web/admin/oauth-signin.mjs +26 -3
- package/web/front/run.js +1 -1
|
@@ -8,6 +8,16 @@ import * as podMedia from '../../pod/media.mjs';
|
|
|
8
8
|
import * as social from '../../core/social.mjs';
|
|
9
9
|
import { readBody, readMultipart } from './body.mjs';
|
|
10
10
|
|
|
11
|
+
// What a profile picture or header may be: an image of a kind every client
|
|
12
|
+
// shows, stored as that type and named by it, never by the client's filename.
|
|
13
|
+
// Anything else, a web page above all, would be served from the pod's own
|
|
14
|
+
// address and run there when opened.
|
|
15
|
+
const PROFILE_IMAGE_EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/avif': 'avif' };
|
|
16
|
+
const profileImageType = (f) => {
|
|
17
|
+
const t = String(f?.contentType || '').split(';')[0].trim().toLowerCase();
|
|
18
|
+
return PROFILE_IMAGE_EXT[t] ? t : null;
|
|
19
|
+
};
|
|
20
|
+
|
|
11
21
|
export async function handle(api, ctx) {
|
|
12
22
|
const { req, res, pathname, url, send } = ctx; // eslint-disable-line no-unused-vars
|
|
13
23
|
|
|
@@ -38,13 +48,17 @@ export async function handle(api, ctx) {
|
|
|
38
48
|
else form = await readBody(req);
|
|
39
49
|
|
|
40
50
|
const cfg = { ...api.store.getConfig() };
|
|
51
|
+
for (const f of [ files.avatar, files.header ]) {
|
|
52
|
+
if (f?.data?.length && !profileImageType(f)) {
|
|
53
|
+
return send(422, { error: 'a profile picture or header must be a PNG, JPEG, GIF, WebP or AVIF image' });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
41
56
|
const putImage = async (f) => {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
const slug = new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex') + '.' + ext;
|
|
57
|
+
const type = profileImageType(f);
|
|
58
|
+
const slug = new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex') + '.' + PROFILE_IMAGE_EXT[type];
|
|
45
59
|
const url = api.urls.media + slug;
|
|
46
60
|
await api.agent.publisher.ensureMediaContainer();
|
|
47
|
-
await podMedia.write(api.agent.remote, url, f.data,
|
|
61
|
+
await podMedia.write(api.agent.remote, url, f.data, type);
|
|
48
62
|
return url;
|
|
49
63
|
};
|
|
50
64
|
|
|
@@ -84,6 +84,7 @@ export class MastoApi {
|
|
|
84
84
|
store: this.store,
|
|
85
85
|
subject: () => this.urls?.actor || 'https://localhost/',
|
|
86
86
|
log: this.log,
|
|
87
|
+
liveKeys: () => new Set(this.tokens().map((t) => this._push.keyOf(t))),
|
|
87
88
|
});
|
|
88
89
|
return this._push;
|
|
89
90
|
}
|
|
@@ -177,6 +178,7 @@ export class MastoApi {
|
|
|
177
178
|
registerApp(...a) { return oauth.registerApp(this, ...a); }
|
|
178
179
|
mintCode(...a) { return oauth.mintCode(this, ...a); }
|
|
179
180
|
consumeCode(...a) { return oauth.consumeCode(this, ...a); }
|
|
181
|
+
peekCode(...a) { return oauth.peekCode(this, ...a); }
|
|
180
182
|
tokenOf(...a) { return oauth.tokenOf(this, ...a); }
|
|
181
183
|
authed(...a) { return oauth.authed(this, ...a); }
|
|
182
184
|
redirectAllowed(...a) { return oauth.redirectAllowed(this, ...a); }
|
|
@@ -12,6 +12,10 @@ const AUTHZ_WINDOW_MS = 60_000;
|
|
|
12
12
|
const AUTHZ_MAX_ATTEMPTS = 5;
|
|
13
13
|
const CODE_TTL_MS = 5 * 60_000; // an authorization code is short-lived
|
|
14
14
|
const MAX_APPS = 200; // registered third-party clients, capped
|
|
15
|
+
const MAX_REDIRECTS = 10; // return addresses one client may register
|
|
16
|
+
const MAX_REDIRECT_CHARS = 2048;
|
|
17
|
+
const REGISTRATION_WINDOW_MS = 60_000;
|
|
18
|
+
const MAX_REGISTRATIONS = 10; // registrations per window, from anyone
|
|
15
19
|
const CLIENT_DOC_TTL_MS = 10 * 60_000; // how long a fetched client document is trusted
|
|
16
20
|
const CLIENT_DOC_MAX_CACHED = 200; // ids remembered at once; oldest out
|
|
17
21
|
const CLIENT_DOC_WINDOW_MS = 60_000;
|
|
@@ -42,35 +46,38 @@ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c =>
|
|
|
42
46
|
const parseRedirects = (v) => (Array.isArray(v) ? v : String(v || '').split(/\s+/))
|
|
43
47
|
.map(s => s.trim()).filter(Boolean);
|
|
44
48
|
|
|
49
|
+
// The account's Fediverse address, @handle@host, or null before it has one.
|
|
50
|
+
const ownAddress = (api) => {
|
|
51
|
+
const handle = api.store.getConfig()?.handle;
|
|
52
|
+
return handle && api.host ? `@${handle}@${api.host}` : null;
|
|
53
|
+
};
|
|
54
|
+
|
|
45
55
|
// The sign-in page for an account whose owner signs in at their pod: no
|
|
46
56
|
// password of ours exists, and none is asked for. The page carries the
|
|
47
57
|
// client's request in its address, signs the owner in at the pod with the
|
|
48
58
|
// fediverse-session library, and proves that sign-in back to /oauth/authorize.
|
|
49
|
-
function sendPodSigninPage(res, client, webId, mount = '') {
|
|
50
|
-
let
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 you will be sent back to <code>${escapeHtml(where)}</code>` : ''}.</p>`;
|
|
57
|
-
}
|
|
59
|
+
function sendPodSigninPage(res, client, webId, mount = '', address = null) {
|
|
60
|
+
let where = '';
|
|
61
|
+
try { where = client?.redirect ? new URL(client.redirect).host : ''; } catch { /* oob or blank */ }
|
|
62
|
+
const app = escapeHtml(client?.name || where || 'this app');
|
|
63
|
+
const question = `Allow ${app} to access your ${address ? `${escapeHtml(address)} ` : ''}account?`;
|
|
58
64
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' });
|
|
59
65
|
res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
60
|
-
<title
|
|
66
|
+
<title>${question}</title>
|
|
61
67
|
<style>:root{color-scheme:light dark;--heading:#1a4f8a;--btn:#3d6b35;--line:#9a9a9a}
|
|
62
68
|
body{font:20px Arial,Helvetica,sans-serif;max-width:28rem;margin:12vh auto;padding:0 1rem}
|
|
63
69
|
h1{color:var(--heading);font-size:1.5rem}
|
|
64
|
-
|
|
70
|
+
h1,p{overflow-wrap:anywhere}
|
|
65
71
|
button{font:inherit;padding:.6rem 1.1rem;background:var(--btn);color:#fff;border:none;border-radius:9px;cursor:pointer}
|
|
66
72
|
button[hidden]{display:none}
|
|
73
|
+
#cancel{background:transparent;color:inherit;border:2px solid currentColor;margin-left:.6rem}
|
|
67
74
|
#signin-status{min-height:1.5em;padding:.6rem .8rem;border:1px solid var(--line);border-radius:8px;margin-top:1rem}
|
|
68
|
-
|
|
75
|
+
#signin-status:empty{min-height:0;padding:0;border:0;margin:0}
|
|
76
|
+
@media (prefers-color-scheme:dark){:root{--heading:#7fb3e8;--btn:#4a7a40}}</style></head><body>
|
|
69
77
|
<main>
|
|
70
|
-
<h1
|
|
71
|
-
${
|
|
72
|
-
<p
|
|
73
|
-
<p><button type="button" id="signin" hidden>Sign in with your pod</button></p>
|
|
78
|
+
<h1>${question}</h1>
|
|
79
|
+
<a id="webid" href="${escapeHtml(webId)}" hidden></a>
|
|
80
|
+
<p><button type="button" id="signin" hidden>Allow</button><button type="button" id="cancel" hidden>Cancel</button></p>
|
|
74
81
|
<p id="signin-status" role="status" aria-live="polite"></p>
|
|
75
82
|
</main>
|
|
76
83
|
<script type="module" src="${escapeHtml(mount)}/oauth/session/signin.mjs"></script>
|
|
@@ -324,12 +331,53 @@ export function registerApp(api, { name, website, redirectUris, scopes }) {
|
|
|
324
331
|
clientSecret: crypto.randomBytes(32).toString('base64url'),
|
|
325
332
|
name: String(name || 'client').slice(0, 200),
|
|
326
333
|
website: String(website || '').slice(0, 500),
|
|
327
|
-
redirectUris, scopes: String(scopes || 'read'), createdAt: Date.now(),
|
|
334
|
+
redirectUris, scopes: String(scopes || 'read').slice(0, 500), createdAt: Date.now(),
|
|
335
|
+
// Set when the owner first lets it sign in (touchApp): a client nobody
|
|
336
|
+
// has used is the first to go when the list is full.
|
|
337
|
+
usedAt: null,
|
|
328
338
|
};
|
|
329
|
-
|
|
339
|
+
const kept = api.apps();
|
|
340
|
+
while (kept.length >= MAX_APPS) kept.splice(kept.indexOf(leastUsed(kept)), 1);
|
|
341
|
+
api.store.write('oauth-apps.json', [...kept, app]);
|
|
330
342
|
return app;
|
|
331
343
|
}
|
|
332
344
|
|
|
345
|
+
// The client to push out of a full list: one nobody has signed in with,
|
|
346
|
+
// oldest first; failing that, the one used longest ago. A client registered
|
|
347
|
+
// before `usedAt` existed counts as used when it was made, so a flood of new
|
|
348
|
+
// registrations cannot push out the apps the owner already has.
|
|
349
|
+
function leastUsed(list) {
|
|
350
|
+
const never = list.filter((a) => a.usedAt === null);
|
|
351
|
+
const when = (a) => a.usedAt ?? a.createdAt ?? 0;
|
|
352
|
+
return (never.length ? never : list).reduce((a, b) => (when(b) < when(a) ? b : a));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function touchApp(api, clientId) {
|
|
356
|
+
const all = api.apps();
|
|
357
|
+
const app = all.find((a) => a.clientId === clientId);
|
|
358
|
+
if (!app) return;
|
|
359
|
+
app.usedAt = Date.now();
|
|
360
|
+
api.store.write('oauth-apps.json', all);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Why a registration's return addresses cannot be kept, or null. Registering
|
|
364
|
+
// needs no sign-in, so its size is the part a stranger controls.
|
|
365
|
+
export function redirectsProblem(raw) {
|
|
366
|
+
if (Array.isArray(raw) && raw.some((v) => typeof v !== 'string')) return 'redirect_uris must be text';
|
|
367
|
+
const list = parseRedirects(raw);
|
|
368
|
+
if (list.length > MAX_REDIRECTS) return `at most ${MAX_REDIRECTS} redirect_uris`;
|
|
369
|
+
if (list.some((u) => u.length > MAX_REDIRECT_CHARS)) return `a redirect_uri is at most ${MAX_REDIRECT_CHARS} characters`;
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function registrationLimited(api) {
|
|
374
|
+
const now = Date.now();
|
|
375
|
+
api.appRegistrations = (api.appRegistrations || []).filter((t) => now - t < REGISTRATION_WINDOW_MS);
|
|
376
|
+
if (api.appRegistrations.length >= MAX_REGISTRATIONS) return true;
|
|
377
|
+
api.appRegistrations.push(now);
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
|
|
333
381
|
// A short-lived, single-use authorization code for a registered client, kept
|
|
334
382
|
// apart from masto-tokens.json so the code is NOT a bearer until it is
|
|
335
383
|
// exchanged with the client secret.
|
|
@@ -348,6 +396,13 @@ export function mintCode(api, { clientId, redirectUri, scope, challenge = null,
|
|
|
348
396
|
return code;
|
|
349
397
|
}
|
|
350
398
|
|
|
399
|
+
// The live record for a code, left in place: the exchange checks it first,
|
|
400
|
+
// so a caller who cannot prove it does not use it up for the one who can.
|
|
401
|
+
export function peekCode(api, code) {
|
|
402
|
+
const now = Date.now();
|
|
403
|
+
return api.store.read('oauth-codes.json', []).find(c => c.code === code && now - c.createdAt < CODE_TTL_MS) || null;
|
|
404
|
+
}
|
|
405
|
+
|
|
351
406
|
export function consumeCode(api, code) {
|
|
352
407
|
const now = Date.now();
|
|
353
408
|
const all = api.store.read('oauth-codes.json', []);
|
|
@@ -376,7 +431,11 @@ export function redirectAllowed(api, redirect) {
|
|
|
376
431
|
try {
|
|
377
432
|
const u = new URL(redirect);
|
|
378
433
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
|
379
|
-
|
|
434
|
+
if (!api.allowed.has(u.host.toLowerCase())) return false;
|
|
435
|
+
// A suffixed pod shares its host with other pods: its own addresses are
|
|
436
|
+
// those under its path, and the rest of the host is somebody else's.
|
|
437
|
+
const mount = api.mount || '';
|
|
438
|
+
return !mount || u.pathname === mount || u.pathname.startsWith(`${mount}/`);
|
|
380
439
|
} catch { return false; }
|
|
381
440
|
}
|
|
382
441
|
|
|
@@ -398,6 +457,13 @@ export async function handle(api, ctx) {
|
|
|
398
457
|
// authorize step becomes a real login form.
|
|
399
458
|
if (pathname === '/api/v1/apps' && req.method === 'POST') {
|
|
400
459
|
const body = await readBody(req);
|
|
460
|
+
const problem = redirectsProblem(body.redirect_uris);
|
|
461
|
+
if (problem) return send(422, { error: problem });
|
|
462
|
+
if (registrationLimited(api)) {
|
|
463
|
+
api.log('app registration refused: too many this minute');
|
|
464
|
+
return send(429, { error: 'too many apps registered just now — try again in a minute' },
|
|
465
|
+
{ 'retry-after': String(REGISTRATION_WINDOW_MS / 1000) });
|
|
466
|
+
}
|
|
401
467
|
const redirectUris = parseRedirects(body.redirect_uris);
|
|
402
468
|
const app = api.registerApp({ name: body.client_name, website: body.website,
|
|
403
469
|
redirectUris, scopes: body.scopes });
|
|
@@ -427,7 +493,7 @@ export async function handle(api, ctx) {
|
|
|
427
493
|
// The way back from the pod's login: no client named, only the pod's
|
|
428
494
|
// code. The page finishes the sign-in and returns to the client's request.
|
|
429
495
|
if (req.method === 'GET' && !params.get('client_id') && params.get('code') && params.get('state') && ownerWebId) {
|
|
430
|
-
return sendPodSigninPage(res, null, ownerWebId, api.mount || '');
|
|
496
|
+
return sendPodSigninPage(res, null, ownerWebId, api.mount || '', ownAddress(api));
|
|
431
497
|
}
|
|
432
498
|
const app = api.findApp(params.get('client_id') || '');
|
|
433
499
|
// A client that published its own metadata document needs no
|
|
@@ -471,9 +537,15 @@ export async function handle(api, ctx) {
|
|
|
471
537
|
// about a web page. Anything pointing elsewhere has to be approved by
|
|
472
538
|
// somebody who knows the password.
|
|
473
539
|
const client = { name: app?.name || doc?.name || null, redirect, scope: params.get('scope') || 'read' };
|
|
540
|
+
// A proof method named and not known is refused rather than read as the
|
|
541
|
+
// weakest one, whose proof is the challenge itself.
|
|
542
|
+
const challengeMethod = params.get('code_challenge_method');
|
|
543
|
+
if (params.get('code_challenge') && challengeMethod && challengeMethod !== 'S256' && challengeMethod !== 'plain') {
|
|
544
|
+
return send(400, { error: 'code_challenge_method must be S256 or plain' });
|
|
545
|
+
}
|
|
474
546
|
if (external && !proved && !api.store.getConfig()?.uiPassword && !api.redirectAllowed(redirect)) {
|
|
475
547
|
// 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 || '');
|
|
548
|
+
if (req.method === 'GET' && ownerWebId) return sendPodSigninPage(res, client, ownerWebId, api.mount || '', ownAddress(api));
|
|
477
549
|
api.log(`authorize refused: no UI password, and "${redirect}" is not an address of this agent`);
|
|
478
550
|
return send(403, {
|
|
479
551
|
error: 'this client asks to be sent somewhere other than this agent, and no password is '
|
|
@@ -519,7 +591,7 @@ export async function handle(api, ctx) {
|
|
|
519
591
|
return sendLoginForm(res, params, '', client);
|
|
520
592
|
} else if (!proved && api.allowed && !api.allowed.isLocalRequest(req) && !api.throughDoor?.(req)) {
|
|
521
593
|
// 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 || '');
|
|
594
|
+
if (req.method === 'GET' && ownerWebId) return sendPodSigninPage(res, client, ownerWebId, api.mount || '', ownAddress(api));
|
|
523
595
|
// No password set, and this request did not come from this machine.
|
|
524
596
|
//
|
|
525
597
|
// The instant-authorize path is honest theatre on loopback: whoever can
|
|
@@ -543,6 +615,7 @@ export async function handle(api, ctx) {
|
|
|
543
615
|
challenge: params.get('code_challenge') || null,
|
|
544
616
|
challengeMethod: params.get('code_challenge_method') || null })
|
|
545
617
|
: api.mintToken(client.scope);
|
|
618
|
+
if (app) touchApp(api, app.clientId);
|
|
546
619
|
if (!redirect || redirect === 'urn:ietf:wg:oauth:2.0:oob') return send(200, { code });
|
|
547
620
|
const target = new URL(redirect);
|
|
548
621
|
target.searchParams.set('code', code);
|
|
@@ -563,7 +636,7 @@ export async function handle(api, ctx) {
|
|
|
563
636
|
// challenge is the whole of its proof. The code carries the document's
|
|
564
637
|
// URL as the client it was bound to.
|
|
565
638
|
if (!app && body.code_verifier && /^https:\/\//iu.test(String(body.client_id || ''))) {
|
|
566
|
-
const rec = api.
|
|
639
|
+
const rec = api.peekCode(body.code || '');
|
|
567
640
|
if (!rec || rec.clientId !== body.client_id
|
|
568
641
|
|| (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
|
|
569
642
|
api.log('token refused: code is not a live authorization for that client document');
|
|
@@ -573,6 +646,7 @@ export async function handle(api, ctx) {
|
|
|
573
646
|
api.log('token refused: the verifier does not answer the challenge this code was made with');
|
|
574
647
|
return send(400, { error: 'invalid_grant' });
|
|
575
648
|
}
|
|
649
|
+
if (!api.consumeCode(body.code || '')) return send(400, { error: 'invalid_grant' });
|
|
576
650
|
return send(200, { access_token: api.mintToken(rec.scope || 'read'), token_type: 'Bearer',
|
|
577
651
|
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
|
|
578
652
|
...(api.urls?.actor ? { activitypub_actor_id: api.urls.actor } : {}) });
|
|
@@ -582,7 +656,7 @@ export async function handle(api, ctx) {
|
|
|
582
656
|
// challenge it presented at authorize (RFC 7636). Sending a verifier is
|
|
583
657
|
// what says which of the two flows this is.
|
|
584
658
|
if (app && body.code_verifier) {
|
|
585
|
-
const rec = api.
|
|
659
|
+
const rec = api.peekCode(body.code || '');
|
|
586
660
|
if (!rec || rec.clientId !== app.clientId || (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
|
|
587
661
|
api.log('token refused: code is not a live authorization for this client');
|
|
588
662
|
return send(400, { error: 'invalid_grant' });
|
|
@@ -593,6 +667,7 @@ export async function handle(api, ctx) {
|
|
|
593
667
|
api.log('token refused: the verifier does not answer the challenge this code was made with');
|
|
594
668
|
return send(400, { error: 'invalid_grant' });
|
|
595
669
|
}
|
|
670
|
+
if (!api.consumeCode(body.code || '')) return send(400, { error: 'invalid_grant' });
|
|
596
671
|
return send(200, { access_token: api.mintToken(rec.scope || 'read'), token_type: 'Bearer',
|
|
597
672
|
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
|
|
598
673
|
...(api.urls?.actor ? { activitypub_actor_id: api.urls.actor } : {}) });
|
|
@@ -602,7 +677,7 @@ export async function handle(api, ctx) {
|
|
|
602
677
|
const known = Buffer.from(app.clientSecret);
|
|
603
678
|
const okSecret = given.length === known.length && crypto.timingSafeEqual(given, known);
|
|
604
679
|
if (!okSecret) { api.log('token refused: client secret mismatch'); return send(401, { error: 'invalid_client' }); }
|
|
605
|
-
const rec = api.
|
|
680
|
+
const rec = api.peekCode(body.code || '');
|
|
606
681
|
if (!rec || rec.clientId !== app.clientId || (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
|
|
607
682
|
api.log('token refused: code is not a live authorization for this client');
|
|
608
683
|
return send(400, { error: 'invalid_grant' });
|
|
@@ -613,6 +688,7 @@ export async function handle(api, ctx) {
|
|
|
613
688
|
api.log('token refused: this code was made with a challenge and the verifier does not answer it');
|
|
614
689
|
return send(400, { error: 'invalid_grant' });
|
|
615
690
|
}
|
|
691
|
+
if (!api.consumeCode(body.code || '')) return send(400, { error: 'invalid_grant' });
|
|
616
692
|
return send(200, { access_token: api.mintToken(rec.scope || 'read'), token_type: 'Bearer',
|
|
617
693
|
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
|
|
618
694
|
// Which actor the token acts for. A Mastodon client ignores it; an
|
|
@@ -647,6 +723,8 @@ export async function handle(api, ctx) {
|
|
|
647
723
|
if (gone) {
|
|
648
724
|
const kept = api.tokenRecords().filter(r => r.token !== gone);
|
|
649
725
|
api.store.write('masto-tokens.json', kept);
|
|
726
|
+
// Its notifications stop with it.
|
|
727
|
+
if (api.push.get(gone)) api.push.drop(gone);
|
|
650
728
|
api.log('client token revoked');
|
|
651
729
|
}
|
|
652
730
|
return send(200, {});
|
package/lib/client/oidc-auth.mjs
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
// The verifier is injected so offline tests stub it, and wrapped so the
|
|
9
9
|
// library (CJS, older jose) can be replaced without touching any caller.
|
|
10
10
|
|
|
11
|
+
import { scopeAllows } from './masto/oauth.mjs';
|
|
12
|
+
import { claimedWebId } from '../gateway/token-claims.mjs';
|
|
13
|
+
|
|
11
14
|
export function makeC2sAuth({ agent, masto = null, verifier = null, log = () => {}, scheme = null, mount = '' }) {
|
|
12
15
|
let verify = verifier;
|
|
13
16
|
const loadVerifier = async () => {
|
|
@@ -19,7 +22,14 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
|
|
|
19
22
|
};
|
|
20
23
|
|
|
21
24
|
return async function authenticate(req, pathname) {
|
|
22
|
-
|
|
25
|
+
const bearer = masto?.tokenOf(req);
|
|
26
|
+
if (bearer) {
|
|
27
|
+
// The same permission the Mastodon routes ask of this token: a client
|
|
28
|
+
// allowed only to read does not post here either.
|
|
29
|
+
const need = req.method === 'GET' || req.method === 'HEAD' ? 'read' : 'write';
|
|
30
|
+
if (!scopeAllows(bearer.scope, need)) {
|
|
31
|
+
return { ok: false, status: 403, error: `this app was not allowed to ${need === 'read' ? 'read' : 'post'} for the account` };
|
|
32
|
+
}
|
|
23
33
|
return { ok: true, webid: agent.remote?.webId || null, via: 'bearer' };
|
|
24
34
|
}
|
|
25
35
|
if (!req.headers.authorization) {
|
|
@@ -32,6 +42,12 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
|
|
|
32
42
|
return { ok: false, status: 401, error: 'a Solid-OIDC token must come with its DPoP proof',
|
|
33
43
|
headers: { 'www-authenticate': 'DPoP' } };
|
|
34
44
|
}
|
|
45
|
+
// A token naming anyone but the owner is refused before it is checked:
|
|
46
|
+
// checking fetches the documents it names, and a stranger names them.
|
|
47
|
+
const claimed = claimedWebId({ headers: { get: (n) => req.headers[n] ?? null } });
|
|
48
|
+
if (claimed && claimed !== agent.remote?.webId) {
|
|
49
|
+
return { ok: false, status: 403, error: 'this account belongs to its owner alone' };
|
|
50
|
+
}
|
|
35
51
|
let webid;
|
|
36
52
|
try {
|
|
37
53
|
const v = await loadVerifier();
|
|
@@ -48,8 +64,10 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
|
|
|
48
64
|
{ header: req.headers.dpop, method: req.method, url: htu },
|
|
49
65
|
));
|
|
50
66
|
} catch (e) {
|
|
67
|
+
// The reason stays here: it can carry what was read from an address
|
|
68
|
+
// the token named.
|
|
51
69
|
log(`c2s auth: token rejected — ${e.message}`);
|
|
52
|
-
return { ok: false, status: 401, error:
|
|
70
|
+
return { ok: false, status: 401, error: 'token rejected' };
|
|
53
71
|
}
|
|
54
72
|
const owner = agent.remote?.webId;
|
|
55
73
|
if (!owner || webid !== owner) {
|
package/lib/client/webpush.mjs
CHANGED
|
@@ -7,10 +7,13 @@ import crypto from 'node:crypto';
|
|
|
7
7
|
import webpush from 'web-push';
|
|
8
8
|
|
|
9
9
|
export class Push {
|
|
10
|
-
constructor({ store, subject, log = () => {} }) {
|
|
10
|
+
constructor({ store, subject, log = () => {}, liveKeys = null }) {
|
|
11
11
|
this.store = store;
|
|
12
12
|
this.subject = subject; // () => the actor URL, VAPID's contact
|
|
13
13
|
this.log = log;
|
|
14
|
+
// () => the keys of the sign-ins still in force, where they are kept beside
|
|
15
|
+
// the subscriptions; without it no subscription is forgotten for its sign-in.
|
|
16
|
+
this.liveKeys = liveKeys;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
state() { return this.store.read('webpush.json', { subs: {} }); }
|
|
@@ -68,14 +71,21 @@ export class Push {
|
|
|
68
71
|
|
|
69
72
|
// Push one notification to every subscription whose alerts allow the type.
|
|
70
73
|
// A push service answering 404/410 means the browser dropped the
|
|
71
|
-
// subscription — it is removed rather than retried forever.
|
|
74
|
+
// subscription — it is removed rather than retried forever. With
|
|
75
|
+
// `liveKeys`, every subscription whose sign-in is no longer in force is
|
|
76
|
+
// forgotten first: an app signed out, expired or pushed off the list hears
|
|
77
|
+
// nothing more.
|
|
72
78
|
async notify(n, payload) {
|
|
73
79
|
const st = this.state();
|
|
80
|
+
let dropped = false;
|
|
81
|
+
const live = this.liveKeys?.() || null;
|
|
82
|
+
if (live) {
|
|
83
|
+
for (const key of Object.keys(st.subs)) if (!live.has(key)) { delete st.subs[key]; dropped = true; }
|
|
84
|
+
}
|
|
74
85
|
const entries = Object.entries(st.subs);
|
|
75
|
-
if (!entries.length) return;
|
|
86
|
+
if (!entries.length) { if (dropped) this.save(st); return; }
|
|
76
87
|
const { publicKey, privateKey } = this.vapid();
|
|
77
88
|
const details = { subject: this.subject(), publicKey, privateKey };
|
|
78
|
-
let dropped = false;
|
|
79
89
|
for (const [key, sub] of entries) {
|
|
80
90
|
if (sub.alerts && sub.alerts[n.type] === false) continue;
|
|
81
91
|
try {
|
|
@@ -29,6 +29,20 @@ const DEFAULTS = {
|
|
|
29
29
|
intervalMin: 15,
|
|
30
30
|
};
|
|
31
31
|
const PER_TAG = 20; // statuses requested per tag per sweep
|
|
32
|
+
// One copy of each tag's timeline per process, for every account sweeping it:
|
|
33
|
+
// a server running many accounts asks the instance once per tag in this
|
|
34
|
+
// window, not once per account. Just under the shortest default interval.
|
|
35
|
+
const SHARED_TTL_MS = 12 * 60_000;
|
|
36
|
+
const sharedTimelines = new Map(); // `${instance}|${tag}` → { at, answer }
|
|
37
|
+
export function timelineOnce(key, load) {
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
const held = sharedTimelines.get(key);
|
|
40
|
+
if (held && now - held.at < SHARED_TTL_MS) return held.answer;
|
|
41
|
+
const answer = load().catch((e) => { sharedTimelines.delete(key); throw e; });
|
|
42
|
+
sharedTimelines.set(key, { at: now, answer });
|
|
43
|
+
if (sharedTimelines.size > 500) sharedTimelines.delete(sharedTimelines.keys().next().value);
|
|
44
|
+
return answer;
|
|
45
|
+
}
|
|
32
46
|
const MAX_NEW_PER_SWEEP = 8; // dereference budget per sweep — stay light
|
|
33
47
|
// …and no one tag may take more than this much of it. Without a per-tag share
|
|
34
48
|
// the budget was spent in list order: the first two or three busy tags used it
|
|
@@ -141,21 +155,27 @@ export class TagFeed {
|
|
|
141
155
|
try {
|
|
142
156
|
const { safeFetch, retryAfterMs, readCapped } = await import('../shared/safefetch.mjs');
|
|
143
157
|
const url = `${instance}/api/v1/timelines/tag/${encodeURIComponent(tag)}?limit=${PER_TAG}`;
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
158
|
+
const load = async () => {
|
|
159
|
+
const res = this.fetcher === globalThis.fetch
|
|
160
|
+
? await safeFetch(url, { headers: { accept: 'application/json' } })
|
|
161
|
+
: await this.fetcher(url, { headers: { accept: 'application/json' } });
|
|
162
|
+
if (res.status >= 400) return { status: res.status, retryAfter: retryAfterMs(res), list: null };
|
|
163
|
+
// Somebody else's server, polled on our schedule: bounded, because
|
|
164
|
+
// res.json() would buffer a timeline of whatever size it sent.
|
|
165
|
+
return { status: res.status, list: JSON.parse(await readCapped(res, MAX_TIMELINE_BYTES)) };
|
|
166
|
+
};
|
|
167
|
+
// Shared only with the real network: a fetcher handed in is a test's own.
|
|
168
|
+
const got = this.fetcher === globalThis.fetch ? await timelineOnce(`${instance}|${tag}`, load) : await load();
|
|
169
|
+
if (got.status >= 400) {
|
|
148
170
|
// This is somebody else's server, polled on OUR schedule, and it had
|
|
149
171
|
// no way to ask us to stop: a 429 or a 503 logged a line and the next
|
|
150
172
|
// sweep arrived on the same cadence regardless. One refusal now ends
|
|
151
173
|
// the sweep — the remaining tags are on the same instance, so there
|
|
152
174
|
// is nothing to be gained by asking them too.
|
|
153
|
-
this._backOff(
|
|
175
|
+
this._backOff(got.status, got.retryAfter);
|
|
154
176
|
return;
|
|
155
177
|
}
|
|
156
|
-
|
|
157
|
-
// res.json() would buffer a timeline of whatever size it sent.
|
|
158
|
-
list = JSON.parse(await readCapped(res, MAX_TIMELINE_BYTES));
|
|
178
|
+
list = got.list;
|
|
159
179
|
} catch (e) {
|
|
160
180
|
// A network failure is the same signal, minus the courtesy.
|
|
161
181
|
this.log(`tagfeed #${tag}: ${e.message}`);
|
package/lib/core/agent.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { AcctFeed } from '../connections/acctfeed.mjs';
|
|
|
25
25
|
import { BskyFeed } from '../connections/bskyfeed.mjs';
|
|
26
26
|
import { BskyGroup } from '../connections/bskygroup.mjs';
|
|
27
27
|
import { Lease } from './lease.mjs';
|
|
28
|
-
import { publishDue } from './scheduled.mjs';
|
|
28
|
+
import { publishDue, nextToPublish } from './scheduled.mjs';
|
|
29
29
|
import { apUrls, assertionKeyId, publicHandle } from './wire.mjs';
|
|
30
30
|
import { followActor, unfollowActor, resolveHandle } from './social.mjs';
|
|
31
31
|
import * as podInbox from '../pod/inbox.mjs';
|
|
@@ -306,6 +306,9 @@ export class Agent {
|
|
|
306
306
|
proofKeyId: assertionKeyId(this.urls),
|
|
307
307
|
log: this.log, passive: this.viewer,
|
|
308
308
|
onGone: () => this.publisher.publishCollections({ followers: true }),
|
|
309
|
+
// Inside a pod server, a long-lived process: several followers' servers
|
|
310
|
+
// at once rather than one after another.
|
|
311
|
+
parallel: this.embedded ? 6 : 1,
|
|
309
312
|
});
|
|
310
313
|
this.publisher = new Publisher({
|
|
311
314
|
config, remote: this.remote, store: this.store,
|
|
@@ -317,6 +320,9 @@ export class Agent {
|
|
|
317
320
|
// origin, so it can be advertised. Standalone it is on loopback, and
|
|
318
321
|
// naming it in a world-readable actor would send clients nowhere.
|
|
319
322
|
clientOrigin: this.embedded ? this.urls.base : null,
|
|
323
|
+
// There, a post is answered once it is on the pod and delivery carries
|
|
324
|
+
// on behind; a process that may be stopped any moment waits for it.
|
|
325
|
+
background: this.embedded,
|
|
320
326
|
});
|
|
321
327
|
// Intake is constructed even for viewers — its signed fetchAP powers
|
|
322
328
|
// search/deref; start() (draining) is active-only.
|
|
@@ -553,11 +559,17 @@ export class Agent {
|
|
|
553
559
|
this.tagfeed.start();
|
|
554
560
|
this.startBsky();
|
|
555
561
|
this.startAccts();
|
|
556
|
-
// Scheduled posts and polls whose time is up:
|
|
557
|
-
//
|
|
562
|
+
// Scheduled posts and polls whose time is up: every 30s the clock is
|
|
563
|
+
// compared with the next one due, and only then is anything done
|
|
564
|
+
// (lib/core/scheduled.mjs).
|
|
558
565
|
clearInterval(this.schedTimer);
|
|
566
|
+
let publishing = false;
|
|
559
567
|
this.schedTimer = setInterval(() => {
|
|
560
|
-
|
|
568
|
+
const due = nextToPublish(this.store);
|
|
569
|
+
if (publishing || due == null || due > Date.now()) return;
|
|
570
|
+
publishing = true;
|
|
571
|
+
publishDue(this.store, this.publisher, this.log).catch(err => this.log(`scheduled: ${err.message}`))
|
|
572
|
+
.finally(() => { publishing = false; });
|
|
561
573
|
}, 30_000);
|
|
562
574
|
this.schedTimer.unref();
|
|
563
575
|
// A CSV import interrupted by a restart or a handoff picks back up here.
|
package/lib/core/deliver.mjs
CHANGED
|
@@ -66,7 +66,7 @@ export class Deliverer {
|
|
|
66
66
|
// placing a post as it lands — which records what to send and leaves the
|
|
67
67
|
// sending to the run it starts.
|
|
68
68
|
constructor({ store, keyId, rsaPrivate, actorId = null, edPrivate = null, proofKeyId = null,
|
|
69
|
-
log = console.log, passive = false, defer = false, onGone = null }) {
|
|
69
|
+
log = console.log, passive = false, defer = false, onGone = null, parallel = 1 }) {
|
|
70
70
|
this.store = store;
|
|
71
71
|
this.defer = defer;
|
|
72
72
|
// Called after followers at a gone inbox are dropped: the agent republishes
|
|
@@ -81,7 +81,9 @@ export class Deliverer {
|
|
|
81
81
|
// How many fresh deliveries one attempt may carry. The Node deliverer
|
|
82
82
|
// opens a socket per recipient; the browser's relay deliverer sends a
|
|
83
83
|
// list, and sets this to what the relay accepts.
|
|
84
|
-
this.batchSize =
|
|
84
|
+
this.batchSize = parallel;
|
|
85
|
+
// Sent side by side, up to that many at once, rather than one after another.
|
|
86
|
+
this.parallel = parallel;
|
|
85
87
|
if (!passive) this.startQueue();
|
|
86
88
|
}
|
|
87
89
|
|
|
@@ -184,6 +186,10 @@ export class Deliverer {
|
|
|
184
186
|
// with the error deliverNow would have thrown. Here one at a time; the
|
|
185
187
|
// relay deliverer sends the whole list in one call.
|
|
186
188
|
async deliverManyNow(targets) {
|
|
189
|
+
if (this.parallel > 1) {
|
|
190
|
+
return Promise.all(targets.map((t) => this.deliverNow(t.inbox, t.activity)
|
|
191
|
+
.then(() => ({ ok: true }), (error) => ({ error }))));
|
|
192
|
+
}
|
|
187
193
|
const out = [];
|
|
188
194
|
for (const t of targets) {
|
|
189
195
|
try { await this.deliverNow(t.inbox, t.activity); out.push({ ok: true }); }
|
|
@@ -40,6 +40,9 @@ const POLL_MS = 2 * 60_000; // fallback cadence when there is no push
|
|
|
40
40
|
// With a live socket the poll is pure redundancy: it exists for the case where
|
|
41
41
|
// push is down, so it slows right down while push is up.
|
|
42
42
|
const POLL_PUSH_OK_MS = 10 * 60_000;
|
|
43
|
+
// Inside the pod server the store's own events wake the drain, and they are
|
|
44
|
+
// not lost the way a socket's are: the poll is only a backstop.
|
|
45
|
+
const POLL_IN_PROCESS_MS = 60 * 60_000;
|
|
43
46
|
// A container that times out will time out again in two minutes, and each
|
|
44
47
|
// attempt holds one of the pod's workers for the full timeout. Sweeping stops
|
|
45
48
|
// for a while instead, doubling up to half an hour.
|
|
@@ -113,7 +116,8 @@ export class Intake {
|
|
|
113
116
|
// what makes the fallback poll a slow one.
|
|
114
117
|
_pollMs() {
|
|
115
118
|
if (this.pollSeconds) return this.pollSeconds * 1000;
|
|
116
|
-
|
|
119
|
+
if (this.wsState === 'in-process') return POLL_IN_PROCESS_MS;
|
|
120
|
+
return this.wsState === 'open' ? POLL_PUSH_OK_MS : POLL_MS;
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
// `drainNow: false` and `subscribe: false` are for a browser worker the
|
|
@@ -121,6 +125,10 @@ export class Intake {
|
|
|
121
125
|
async start({ drainNow = true, subscribe = true } = {}) {
|
|
122
126
|
this.stopped = false; // restartable across demote/takeover cycles
|
|
123
127
|
if (drainNow) await this.drain().catch(e => this.log(`drain: ${e.message}`));
|
|
128
|
+
// Embedded in the pod server, a notification socket back to that same
|
|
129
|
+
// server buys nothing — the store's own change events wake the drain.
|
|
130
|
+
// Settled before the first poll is timed, which reads it.
|
|
131
|
+
if (!this.push) this.wsState = 'in-process';
|
|
124
132
|
const tick = () => {
|
|
125
133
|
this.pollTimer = setTimeout(() => {
|
|
126
134
|
this.drain().catch(e => this.log(`drain: ${e.message}`)).finally(() => { if (!this.stopped) tick(); });
|
|
@@ -128,10 +136,7 @@ export class Intake {
|
|
|
128
136
|
this.pollTimer.unref?.();
|
|
129
137
|
};
|
|
130
138
|
tick();
|
|
131
|
-
|
|
132
|
-
// server buys nothing — the store's own change events wake the drain.
|
|
133
|
-
if (!this.push) this.wsState = 'in-process';
|
|
134
|
-
else if (subscribe) this.subscribe().catch(e => this.log(`subscribe: ${e.message}`));
|
|
139
|
+
if (this.push && subscribe) this.subscribe().catch(e => this.log(`subscribe: ${e.message}`));
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
stop() { this.stopped = true; clearTimeout(this.pollTimer); clearTimeout(this.resubTimer); this.ws?.close(); }
|
|
@@ -35,6 +35,7 @@ import { recordPlace, forgetPlace } from '../place.mjs';
|
|
|
35
35
|
export class Publisher {
|
|
36
36
|
constructor({ config, remote, store, deliverer, publicKeyPem, assertionKey = null, log = console.log,
|
|
37
37
|
probeFetch = null, resolveMention = null, resolveActor = null, clientOrigin = null,
|
|
38
|
+
background = false,
|
|
38
39
|
}) {
|
|
39
40
|
this.config = config;
|
|
40
41
|
this.remote = remote;
|
|
@@ -48,6 +49,8 @@ export class Publisher {
|
|
|
48
49
|
// stranger can reach. Null on a laptop, where the surface is on loopback
|
|
49
50
|
// and advertising it to the world would name somewhere nobody can go.
|
|
50
51
|
this.clientOrigin = clientOrigin;
|
|
52
|
+
// Whether a new post's delivery may carry on after it is answered.
|
|
53
|
+
this.background = background;
|
|
51
54
|
// A fronted identity (config.gateway.frontActor) advertises its ids on a
|
|
52
55
|
// shared domain; the map tells RemotePod where each writes on the pod.
|
|
53
56
|
const publicBase = config.gateway?.frontActor
|
|
@@ -321,7 +321,11 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
|
|
|
321
321
|
reply?.inbox,
|
|
322
322
|
...addressedInboxes,
|
|
323
323
|
].filter(Boolean))];
|
|
324
|
-
|
|
324
|
+
// The post is on the pod; where the process lives on, the app is answered
|
|
325
|
+
// now and delivery carries on behind.
|
|
326
|
+
const fanout = publisher.deliverer.deliverToAll(inboxes, create);
|
|
327
|
+
if (publisher.background) fanout.catch((e) => publisher.log(`delivering ${note.id}: ${e.message}`));
|
|
328
|
+
else await fanout;
|
|
325
329
|
publisher.log(`note published: ${note.id} → ${inboxes.length} inbox(es)`);
|
|
326
330
|
// The quoted author is asked after the note is out: their Accept names the
|
|
327
331
|
// note, so it has to exist where they will look for it.
|
package/lib/core/scheduled.mjs
CHANGED
|
@@ -24,6 +24,18 @@ export async function publishDue(store, publisher, log = () => {}, now = Date.no
|
|
|
24
24
|
return due.length;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* When the next scheduled post or poll end falls due, in milliseconds, or null.
|
|
29
|
+
* Cheap enough to ask every 30 seconds: the timeline is read, not copied.
|
|
30
|
+
*/
|
|
31
|
+
export function nextToPublish(store) {
|
|
32
|
+
const at = [
|
|
33
|
+
...store.getScheduled().map((e) => Date.parse(e.scheduledAt)),
|
|
34
|
+
...(store.openPollEnds?.() || []),
|
|
35
|
+
].filter(Number.isFinite);
|
|
36
|
+
return at.length ? Math.min(...at) : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
/**
|
|
28
40
|
* When the next thing falls due, or null when nothing is waiting: a scheduled
|
|
29
41
|
* post, a poll's end, or another try at a delivery that failed.
|