fedipod 1.49.0 → 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/index.mjs +1 -0
- package/lib/client/masto/oauth.mjs +44 -24
- 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/server/embed.mjs +11 -3
- package/package.json +2 -2
- package/vendor/gate.cjs +10 -2
|
@@ -178,6 +178,7 @@ export class MastoApi {
|
|
|
178
178
|
registerApp(...a) { return oauth.registerApp(this, ...a); }
|
|
179
179
|
mintCode(...a) { return oauth.mintCode(this, ...a); }
|
|
180
180
|
consumeCode(...a) { return oauth.consumeCode(this, ...a); }
|
|
181
|
+
peekCode(...a) { return oauth.peekCode(this, ...a); }
|
|
181
182
|
tokenOf(...a) { return oauth.tokenOf(this, ...a); }
|
|
182
183
|
authed(...a) { return oauth.authed(this, ...a); }
|
|
183
184
|
redirectAllowed(...a) { return oauth.redirectAllowed(this, ...a); }
|
|
@@ -46,38 +46,38 @@ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c =>
|
|
|
46
46
|
const parseRedirects = (v) => (Array.isArray(v) ? v : String(v || '').split(/\s+/))
|
|
47
47
|
.map(s => s.trim()).filter(Boolean);
|
|
48
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
|
+
|
|
49
55
|
// The sign-in page for an account whose owner signs in at their pod: no
|
|
50
56
|
// password of ours exists, and none is asked for. The page carries the
|
|
51
57
|
// client's request in its address, signs the owner in at the pod with the
|
|
52
58
|
// fediverse-session library, and proves that sign-in back to /oauth/authorize.
|
|
53
|
-
function sendPodSigninPage(res, client, webId, mount = '') {
|
|
54
|
-
let
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
try { where = client.redirect ? new URL(client.redirect).host : ''; } catch { /* oob or blank */ }
|
|
59
|
-
const who = client.name ? escapeHtml(client.name) : (where ? escapeHtml(where) : 'A client');
|
|
60
|
-
asking = `<p><strong>${who}</strong> is asking to use your account`
|
|
61
|
-
+ `${where ? `, and you will be sent back to <code>${escapeHtml(where)}</code>` : ''}.</p>`;
|
|
62
|
-
allow = `Allow ${who}`;
|
|
63
|
-
}
|
|
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?`;
|
|
64
64
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' });
|
|
65
65
|
res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
66
|
-
<title
|
|
66
|
+
<title>${question}</title>
|
|
67
67
|
<style>:root{color-scheme:light dark;--heading:#1a4f8a;--btn:#3d6b35;--line:#9a9a9a}
|
|
68
68
|
body{font:20px Arial,Helvetica,sans-serif;max-width:28rem;margin:12vh auto;padding:0 1rem}
|
|
69
69
|
h1{color:var(--heading);font-size:1.5rem}
|
|
70
|
-
|
|
70
|
+
h1,p{overflow-wrap:anywhere}
|
|
71
71
|
button{font:inherit;padding:.6rem 1.1rem;background:var(--btn);color:#fff;border:none;border-radius:9px;cursor:pointer}
|
|
72
72
|
button[hidden]{display:none}
|
|
73
73
|
#cancel{background:transparent;color:inherit;border:2px solid currentColor;margin-left:.6rem}
|
|
74
74
|
#signin-status{min-height:1.5em;padding:.6rem .8rem;border:1px solid var(--line);border-radius:8px;margin-top:1rem}
|
|
75
|
+
#signin-status:empty{min-height:0;padding:0;border:0;margin:0}
|
|
75
76
|
@media (prefers-color-scheme:dark){:root{--heading:#7fb3e8;--btn:#4a7a40}}</style></head><body>
|
|
76
77
|
<main>
|
|
77
|
-
<h1
|
|
78
|
-
${
|
|
79
|
-
<p
|
|
80
|
-
<p><button type="button" id="signin" hidden>${allow}</button><button type="button" id="cancel" hidden>Cancel</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>
|
|
81
81
|
<p id="signin-status" role="status" aria-live="polite"></p>
|
|
82
82
|
</main>
|
|
83
83
|
<script type="module" src="${escapeHtml(mount)}/oauth/session/signin.mjs"></script>
|
|
@@ -396,6 +396,13 @@ export function mintCode(api, { clientId, redirectUri, scope, challenge = null,
|
|
|
396
396
|
return code;
|
|
397
397
|
}
|
|
398
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
|
+
|
|
399
406
|
export function consumeCode(api, code) {
|
|
400
407
|
const now = Date.now();
|
|
401
408
|
const all = api.store.read('oauth-codes.json', []);
|
|
@@ -424,7 +431,11 @@ export function redirectAllowed(api, redirect) {
|
|
|
424
431
|
try {
|
|
425
432
|
const u = new URL(redirect);
|
|
426
433
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
|
427
|
-
|
|
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}/`);
|
|
428
439
|
} catch { return false; }
|
|
429
440
|
}
|
|
430
441
|
|
|
@@ -482,7 +493,7 @@ export async function handle(api, ctx) {
|
|
|
482
493
|
// The way back from the pod's login: no client named, only the pod's
|
|
483
494
|
// code. The page finishes the sign-in and returns to the client's request.
|
|
484
495
|
if (req.method === 'GET' && !params.get('client_id') && params.get('code') && params.get('state') && ownerWebId) {
|
|
485
|
-
return sendPodSigninPage(res, null, ownerWebId, api.mount || '');
|
|
496
|
+
return sendPodSigninPage(res, null, ownerWebId, api.mount || '', ownAddress(api));
|
|
486
497
|
}
|
|
487
498
|
const app = api.findApp(params.get('client_id') || '');
|
|
488
499
|
// A client that published its own metadata document needs no
|
|
@@ -526,9 +537,15 @@ export async function handle(api, ctx) {
|
|
|
526
537
|
// about a web page. Anything pointing elsewhere has to be approved by
|
|
527
538
|
// somebody who knows the password.
|
|
528
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
|
+
}
|
|
529
546
|
if (external && !proved && !api.store.getConfig()?.uiPassword && !api.redirectAllowed(redirect)) {
|
|
530
547
|
// With a pod to sign in at, the owner approves the client there.
|
|
531
|
-
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));
|
|
532
549
|
api.log(`authorize refused: no UI password, and "${redirect}" is not an address of this agent`);
|
|
533
550
|
return send(403, {
|
|
534
551
|
error: 'this client asks to be sent somewhere other than this agent, and no password is '
|
|
@@ -574,7 +591,7 @@ export async function handle(api, ctx) {
|
|
|
574
591
|
return sendLoginForm(res, params, '', client);
|
|
575
592
|
} else if (!proved && api.allowed && !api.allowed.isLocalRequest(req) && !api.throughDoor?.(req)) {
|
|
576
593
|
// With a pod to sign in at, the owner does that instead of a password.
|
|
577
|
-
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));
|
|
578
595
|
// No password set, and this request did not come from this machine.
|
|
579
596
|
//
|
|
580
597
|
// The instant-authorize path is honest theatre on loopback: whoever can
|
|
@@ -619,7 +636,7 @@ export async function handle(api, ctx) {
|
|
|
619
636
|
// challenge is the whole of its proof. The code carries the document's
|
|
620
637
|
// URL as the client it was bound to.
|
|
621
638
|
if (!app && body.code_verifier && /^https:\/\//iu.test(String(body.client_id || ''))) {
|
|
622
|
-
const rec = api.
|
|
639
|
+
const rec = api.peekCode(body.code || '');
|
|
623
640
|
if (!rec || rec.clientId !== body.client_id
|
|
624
641
|
|| (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
|
|
625
642
|
api.log('token refused: code is not a live authorization for that client document');
|
|
@@ -629,6 +646,7 @@ export async function handle(api, ctx) {
|
|
|
629
646
|
api.log('token refused: the verifier does not answer the challenge this code was made with');
|
|
630
647
|
return send(400, { error: 'invalid_grant' });
|
|
631
648
|
}
|
|
649
|
+
if (!api.consumeCode(body.code || '')) return send(400, { error: 'invalid_grant' });
|
|
632
650
|
return send(200, { access_token: api.mintToken(rec.scope || 'read'), token_type: 'Bearer',
|
|
633
651
|
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
|
|
634
652
|
...(api.urls?.actor ? { activitypub_actor_id: api.urls.actor } : {}) });
|
|
@@ -638,7 +656,7 @@ export async function handle(api, ctx) {
|
|
|
638
656
|
// challenge it presented at authorize (RFC 7636). Sending a verifier is
|
|
639
657
|
// what says which of the two flows this is.
|
|
640
658
|
if (app && body.code_verifier) {
|
|
641
|
-
const rec = api.
|
|
659
|
+
const rec = api.peekCode(body.code || '');
|
|
642
660
|
if (!rec || rec.clientId !== app.clientId || (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
|
|
643
661
|
api.log('token refused: code is not a live authorization for this client');
|
|
644
662
|
return send(400, { error: 'invalid_grant' });
|
|
@@ -649,6 +667,7 @@ export async function handle(api, ctx) {
|
|
|
649
667
|
api.log('token refused: the verifier does not answer the challenge this code was made with');
|
|
650
668
|
return send(400, { error: 'invalid_grant' });
|
|
651
669
|
}
|
|
670
|
+
if (!api.consumeCode(body.code || '')) return send(400, { error: 'invalid_grant' });
|
|
652
671
|
return send(200, { access_token: api.mintToken(rec.scope || 'read'), token_type: 'Bearer',
|
|
653
672
|
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
|
|
654
673
|
...(api.urls?.actor ? { activitypub_actor_id: api.urls.actor } : {}) });
|
|
@@ -658,7 +677,7 @@ export async function handle(api, ctx) {
|
|
|
658
677
|
const known = Buffer.from(app.clientSecret);
|
|
659
678
|
const okSecret = given.length === known.length && crypto.timingSafeEqual(given, known);
|
|
660
679
|
if (!okSecret) { api.log('token refused: client secret mismatch'); return send(401, { error: 'invalid_client' }); }
|
|
661
|
-
const rec = api.
|
|
680
|
+
const rec = api.peekCode(body.code || '');
|
|
662
681
|
if (!rec || rec.clientId !== app.clientId || (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
|
|
663
682
|
api.log('token refused: code is not a live authorization for this client');
|
|
664
683
|
return send(400, { error: 'invalid_grant' });
|
|
@@ -669,6 +688,7 @@ export async function handle(api, ctx) {
|
|
|
669
688
|
api.log('token refused: this code was made with a challenge and the verifier does not answer it');
|
|
670
689
|
return send(400, { error: 'invalid_grant' });
|
|
671
690
|
}
|
|
691
|
+
if (!api.consumeCode(body.code || '')) return send(400, { error: 'invalid_grant' });
|
|
672
692
|
return send(200, { access_token: api.mintToken(rec.scope || 'read'), token_type: 'Bearer',
|
|
673
693
|
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
|
|
674
694
|
// Which actor the token acts for. A Mastodon client ignores it; an
|
|
@@ -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.
|
package/lib/core/store.mjs
CHANGED
|
@@ -435,6 +435,15 @@ export class PodStore {
|
|
|
435
435
|
// arrival order — the Mastodon-API facade serves timelines from this.
|
|
436
436
|
// [{ noteId, actor, content, published, inReplyTo, kind: 'timeline'|'post'|'tag' }]
|
|
437
437
|
getStatuses() { return this.read('statuses.json', []); }
|
|
438
|
+
// When each open poll closes, read in place: asked every 30 seconds, where
|
|
439
|
+
// a copy of the whole timeline each time was the whole cost of asking.
|
|
440
|
+
openPollEnds() {
|
|
441
|
+
const out = [];
|
|
442
|
+
for (const s of this.cache.get('statuses.json') || []) {
|
|
443
|
+
if (s?.kind === 'post' && s.poll && !s.poll.closed && s.poll.expiresAt) out.push(Date.parse(s.poll.expiresAt));
|
|
444
|
+
}
|
|
445
|
+
return out;
|
|
446
|
+
}
|
|
438
447
|
// A count must not pay for a clone: nodeinfo and the instance document are
|
|
439
448
|
// public and answer strangers, so they read the cache directly.
|
|
440
449
|
countStatuses(kind = null) {
|
|
@@ -482,6 +491,11 @@ export class PodStore {
|
|
|
482
491
|
const all = this.getStatuses();
|
|
483
492
|
const i = all.findIndex(x => x.noteId === noteId);
|
|
484
493
|
if (i < 0) return null;
|
|
494
|
+
// An edit is held to the size a new post is: a remote Update could
|
|
495
|
+
// otherwise put a post of megabytes into the document rewritten on every change.
|
|
496
|
+
if (typeof patch?.content === 'string' && patch.content.length > MAX_CONTENT) {
|
|
497
|
+
patch = { ...patch, content: clamp(patch.content, MAX_CONTENT), truncated: true };
|
|
498
|
+
}
|
|
485
499
|
all[i] = { ...all[i], ...patch };
|
|
486
500
|
this.write('statuses.json', all);
|
|
487
501
|
return all[i];
|
package/lib/server/embed.mjs
CHANGED
|
@@ -304,6 +304,7 @@ export async function startEmbeddedAgent({
|
|
|
304
304
|
autoAcceptFollows = true,
|
|
305
305
|
gateToken = null,
|
|
306
306
|
uiPath = '/fp/',
|
|
307
|
+
watch = null,
|
|
307
308
|
}) {
|
|
308
309
|
const base = podBase.endsWith('/') ? podBase : podBase + '/';
|
|
309
310
|
const handle = handleFor(base);
|
|
@@ -390,6 +391,7 @@ export async function startEmbeddedAgent({
|
|
|
390
391
|
gate: makeGate(gateToken, {
|
|
391
392
|
secureCookie: authorities.secure,
|
|
392
393
|
cookiePath: mount ? mount + uiPath : '/',
|
|
394
|
+
failClosed: true,
|
|
393
395
|
}),
|
|
394
396
|
allowed: authorities,
|
|
395
397
|
embedded: true,
|
|
@@ -402,9 +404,12 @@ export async function startEmbeddedAgent({
|
|
|
402
404
|
// A delivery landing in the inbox — through the door, or from any other
|
|
403
405
|
// writer — wakes the drain at once. Only additions, and only in the inbox:
|
|
404
406
|
// the drain's own DELETEs must not call it back.
|
|
407
|
+
// `watch(inbox, handler)`, from a server running many identities, is one
|
|
408
|
+
// listener for all of them; without it each listens to the store itself.
|
|
405
409
|
let coalesce = null;
|
|
406
410
|
let onChanged = null;
|
|
407
|
-
|
|
411
|
+
let unwatch = null;
|
|
412
|
+
if (typeof watch === 'function' || typeof resourceStore?.on === 'function') {
|
|
408
413
|
const inbox = urls.inbox;
|
|
409
414
|
onChanged = (identifier, activity) => {
|
|
410
415
|
const kind = String(activity?.value ?? activity ?? '');
|
|
@@ -417,16 +422,19 @@ export async function startEmbeddedAgent({
|
|
|
417
422
|
}, DRAIN_COALESCE_MS);
|
|
418
423
|
coalesce.unref?.();
|
|
419
424
|
};
|
|
420
|
-
|
|
425
|
+
if (typeof watch === 'function') unwatch = watch(inbox, onChanged);
|
|
426
|
+
else resourceStore.on('changed', onChanged);
|
|
421
427
|
}
|
|
422
428
|
|
|
423
429
|
const stop = async () => {
|
|
424
|
-
if (
|
|
430
|
+
if (unwatch) unwatch();
|
|
431
|
+
else if (onChanged) resourceStore.off('changed', onChanged);
|
|
425
432
|
surface.streaming?.stop?.();
|
|
426
433
|
clearTimeout(coalesce);
|
|
427
434
|
agent.intake?.stop();
|
|
428
435
|
agent.tagfeed?.stop();
|
|
429
436
|
agent.bskyfeed?.stop();
|
|
437
|
+
agent.acctfeed?.stop();
|
|
430
438
|
agent.deliverer?.stop();
|
|
431
439
|
agent.importer?.stop();
|
|
432
440
|
clearInterval(agent.schedTimer);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "1.49.
|
|
3
|
+
"version": "1.49.1",
|
|
4
4
|
"description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"install-service": "node bin/fedipod.mjs install-service",
|
|
56
56
|
"uninstall-service": "node bin/fedipod.mjs uninstall-service",
|
|
57
57
|
"prepublishOnly": "npm test",
|
|
58
|
-
"test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/hold-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs claude/smoke-tests/oauth-signin.test.mjs claude/smoke-tests/app-registration.test.mjs claude/smoke-tests/push-signout.test.mjs claude/smoke-tests/token-fetches.test.mjs && node scripts/check-sizes.mjs && node scripts/test-dependents.mjs && node claude/validation/browser-agent/all.mjs",
|
|
58
|
+
"test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/hold-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs claude/smoke-tests/oauth-signin.test.mjs claude/smoke-tests/app-registration.test.mjs claude/smoke-tests/push-signout.test.mjs claude/smoke-tests/token-fetches.test.mjs claude/smoke-tests/signin-hardening.test.mjs claude/smoke-tests/load-fixes.test.mjs && node scripts/check-sizes.mjs && node scripts/test-dependents.mjs && node claude/validation/browser-agent/all.mjs",
|
|
59
59
|
"verify-ui": "node scripts/verify-ui.mjs",
|
|
60
60
|
"build:sw": "node scripts/build-app.mjs --entry web/app/sw-src.mjs --out web/app/dist/sw.js",
|
|
61
61
|
"build:boot": "node scripts/build-app.mjs --entry web/app/boot.mjs --out web/app/dist/boot.js",
|
package/vendor/gate.cjs
CHANGED
|
@@ -88,11 +88,18 @@ function cookieValue(header, name) {
|
|
|
88
88
|
// AP_ALLOWED_HOSTS has nothing but this token, so the gate has to be total.
|
|
89
89
|
// `token` may be a function, resolved per request: an identity's secret can
|
|
90
90
|
// rotate while the server runs, and the very next request sees the new one.
|
|
91
|
-
|
|
91
|
+
// `failClosed`: with no token, refuse rather than open — for a gate whose
|
|
92
|
+
// token is only ever missing by accident (an account on a pod server).
|
|
93
|
+
function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCookie = false, cookiePath = '/', failClosed = false } = {}) {
|
|
92
94
|
const tokenNow = () => (typeof token === 'function' ? token() : token);
|
|
93
95
|
// gate(req, res) → true when the gate handled the response (caller stops).
|
|
94
96
|
function gate(req, res) {
|
|
95
97
|
const t = tokenNow();
|
|
98
|
+
if (!t && failClosed) {
|
|
99
|
+
res.writeHead(401, { 'content-type': 'text/plain' });
|
|
100
|
+
res.end('fedipod: this account has no key to open its pages with\n');
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
96
103
|
if (!t) return false;
|
|
97
104
|
if (tokenOk(req.headers[HEADER], t)) return false;
|
|
98
105
|
if (tokenOk(cookieValue(req.headers.cookie, COOKIE), t)) return false;
|
|
@@ -129,7 +136,8 @@ function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCoo
|
|
|
129
136
|
// Websocket upgrades have no res to answer with — just allowed or not.
|
|
130
137
|
gate.upgradeOk = (req) => {
|
|
131
138
|
const t = tokenNow();
|
|
132
|
-
|
|
139
|
+
if (!t) return !failClosed;
|
|
140
|
+
return tokenOk(req.headers[HEADER], t) || tokenOk(cookieValue(req.headers.cookie, COOKIE), t);
|
|
133
141
|
};
|
|
134
142
|
|
|
135
143
|
return gate;
|