fedipod 1.30.0 → 1.36.6
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/README.md +1 -1
- package/gateway.md +37 -0
- package/gui.md +2 -2
- package/lib/connections/bskyfeed.mjs +6 -0
- package/lib/core/deliver.mjs +52 -22
- package/lib/core/intake/index.mjs +17 -4
- package/lib/core/lease.mjs +7 -7
- package/lib/core/store.mjs +25 -5
- package/lib/gateway/caches.mjs +36 -0
- package/lib/gateway/front-core.mjs +59 -66
- package/lib/gateway/gateway-core.mjs +20 -8
- package/lib/gateway/headers.mjs +49 -0
- package/lib/gateway/notices.mjs +84 -0
- package/lib/gateway/quiet.mjs +189 -0
- package/lib/pod/containers.mjs +5 -1
- package/lib/pod/inbox.mjs +22 -3
- package/lib/pod/transport.mjs +36 -3
- package/lib/session/README.md +14 -0
- package/lib/session/fedi-account.mjs +62 -0
- package/lib/session/package.json +10 -2
- package/package.json +1 -1
- package/run-agent.mjs +16 -0
- package/scripts/stage-site.mjs +6 -3
- package/web/admin/bar.css +44 -10
- package/web/admin/client/index.html +29 -2
- package/web/admin/gateway.js +19 -0
- package/web/admin/index.html +46 -3
- package/web/admin/notices-bar.js +90 -0
- package/web/admin/record.js +1 -0
- package/web/admin/setup/index.html +29 -2
- package/web/admin/upkeep.js +5 -1
- package/web/app/admin-facade.mjs +24 -0
- package/web/app/agent.mjs +59 -6
- package/web/app/boot.mjs +2 -0
- package/web/app/deliver-relay.mjs +47 -7
- package/web/app/dist/boot.js +39 -4
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +273 -38
- package/web/app/dist/sw.js.map +3 -3
- package/web/app/update.js +4 -0
- package/web/front/admin.html +3 -1
- package/web/front/notices.html +69 -0
- package/web/front/notices.js +107 -0
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@ client-to-server, dokieli for one, can post as you. It sends to the outbox
|
|
|
72
72
|
address in your actor document, which your WebID profile also names, signed in
|
|
73
73
|
at your pod. The post goes out the next time you open fedipod.net.
|
|
74
74
|
|
|
75
|
-
**The manage page.** `manage
|
|
75
|
+
**The manage page.** `manage`, in the bar's account group, opens it: your profile,
|
|
76
76
|
aliases, the gateway, key rotation, recovering posts, parking, moving to
|
|
77
77
|
another server, retiring, and clearing a backlog. It is the same interface
|
|
78
78
|
the DeviceAgent has, described in [the admin interface](gui.md).
|
package/gateway.md
CHANGED
|
@@ -153,6 +153,43 @@ on the setup page). Setup reads the account on the pod, keeps its state and
|
|
|
153
153
|
key there, attaches at the new gateway, and completes the move when the
|
|
154
154
|
agent first acts. See [the DeviceAgent](device-agent.md).
|
|
155
155
|
|
|
156
|
+
## Accounts that go quiet
|
|
157
|
+
|
|
158
|
+
A gateway holds no mail. Every delivery it accepts is written into your pod
|
|
159
|
+
inbox, and your agent reads it from there. A BrowserAgent reads only while
|
|
160
|
+
its page is open, so an account nobody opens grows on its pod without limit
|
|
161
|
+
and comes back to a drain of everything at once. fedipod.net keeps two facts
|
|
162
|
+
about each browser account — when its owner last signed in or posted, and
|
|
163
|
+
how much content has arrived since — and acts on them.
|
|
164
|
+
|
|
165
|
+
**Paused.** After about 5,000 posts, replies, likes, boosts and edits since
|
|
166
|
+
you were last here, or when you say so on the manage page, the door accepts
|
|
167
|
+
content and discards it. Follows, unfollows, account moves, deletions and
|
|
168
|
+
blocks still reach your pod. Signing in ends an automatic pause by itself; a
|
|
169
|
+
pause you set lasts until you lift it.
|
|
170
|
+
|
|
171
|
+
**Closed.** After six months without a sign-in, or when you say so on the
|
|
172
|
+
manage page, the address is closed for good: its handle, its actor and its
|
|
173
|
+
door answer 410 Gone, other servers drop the account the next time they look,
|
|
174
|
+
and nobody can take the name. Nothing on your pod is touched. An address that
|
|
175
|
+
had already moved to another gateway keeps answering as moved.
|
|
176
|
+
|
|
177
|
+
Only accounts opened from a browser are counted. A DeviceAgent behind the
|
|
178
|
+
gateway drains its own inbox as it runs, and is never paused or closed by
|
|
179
|
+
time. Accounts from before this was built are counted from their next
|
|
180
|
+
sign-in. A gateway operator sets the cap and the window with
|
|
181
|
+
`FEDIPOD_PAUSE_ITEMS` and `FEDIPOD_CLOSE_DAYS`.
|
|
182
|
+
|
|
183
|
+
## Notices
|
|
184
|
+
|
|
185
|
+
Whoever runs the gateway can write notices to everyone with an account
|
|
186
|
+
there. A bell at the right end of the bar, on the record page and in the
|
|
187
|
+
client, shows how many this browser has not opened yet; the list gives the
|
|
188
|
+
titles and each one opens on its own. The operator writes, changes and
|
|
189
|
+
removes them at `/notices`, signed in as the admin the way the roster is.
|
|
190
|
+
A notice is a title and plain text: a blank line starts a paragraph, and a
|
|
191
|
+
web address becomes a link.
|
|
192
|
+
|
|
156
193
|
## What the gateway can see
|
|
157
194
|
|
|
158
195
|
It reads only public data to decide what concerns you: your published
|
package/gui.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# The Admin interface
|
|
2
2
|
|
|
3
|
-
In the browser version at fedipod.net, `manage
|
|
3
|
+
In the browser version at fedipod.net, `manage`, in the bar's account group, opens this
|
|
4
4
|
same page for your account. The rest of this page describes it as the
|
|
5
5
|
DeviceAgent serves it; the controls are the same, minus the manual inbox
|
|
6
6
|
drain and the local log, which a browser does not have.
|
|
7
7
|
|
|
8
|
-
Open `https://localhost:8030/` while any agent is running — it forwards you to the agent — then choose `manage account
|
|
8
|
+
Open `https://localhost:8030/` while any agent is running — it forwards you to the agent — then choose `manage` in the bar's account group and select the actor you want from the local actors dropdown.
|
|
9
9
|
Picking an actor marked "(stopped)" starts its agent, then opens its page.
|
|
10
10
|
|
|
11
11
|
The software row names the version the agent is running. When the copy on the machine is further ahead — after an update, or after pulling a checkout — it says so and asks for a restart, because an agent goes on serving the code it started with until it is restarted.
|
|
@@ -166,6 +166,10 @@ export class BskyFeed {
|
|
|
166
166
|
this.lastSweep = new Date().toISOString();
|
|
167
167
|
const self = this.atproto.read()?.did;
|
|
168
168
|
let added = 0;
|
|
169
|
+
// One commit boundary for the sweep, as acctfeed and tagfeed: every item
|
|
170
|
+
// awaits Bluesky before the next, so the debounce never coalesced them and
|
|
171
|
+
// the timeline index went to the pod once per notification.
|
|
172
|
+
this.store.hold?.();
|
|
169
173
|
try {
|
|
170
174
|
// Our own CROSS-POSTS must not echo back into the feed — but a post or
|
|
171
175
|
// reply written natively on Bluesky is ours to see here too. The mirrors
|
|
@@ -224,6 +228,8 @@ export class BskyFeed {
|
|
|
224
228
|
} catch (e) {
|
|
225
229
|
this._backOff(e.status || 0, null);
|
|
226
230
|
return;
|
|
231
|
+
} finally {
|
|
232
|
+
this.store.release?.();
|
|
227
233
|
}
|
|
228
234
|
const all = this.store.getStatuses();
|
|
229
235
|
const mirrored = all.filter(s => s.kind === 'bsky');
|
package/lib/core/deliver.mjs
CHANGED
|
@@ -73,6 +73,10 @@ export class Deliverer {
|
|
|
73
73
|
this.edPrivate = edPrivate;
|
|
74
74
|
this.proofKeyId = proofKeyId;
|
|
75
75
|
this.log = log;
|
|
76
|
+
// How many fresh deliveries one attempt may carry. The Node deliverer
|
|
77
|
+
// opens a socket per recipient; the browser's relay deliverer sends a
|
|
78
|
+
// list, and sets this to what the relay accepts.
|
|
79
|
+
this.batchSize = 1;
|
|
76
80
|
if (!passive) this.startQueue();
|
|
77
81
|
}
|
|
78
82
|
|
|
@@ -171,33 +175,51 @@ export class Deliverer {
|
|
|
171
175
|
}
|
|
172
176
|
}
|
|
173
177
|
|
|
178
|
+
// One attempt per target, answered in order: `{ ok: true }` or `{ error }`
|
|
179
|
+
// with the error deliverNow would have thrown. Here one at a time; the
|
|
180
|
+
// relay deliverer sends the whole list in one call.
|
|
181
|
+
async deliverManyNow(targets) {
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const t of targets) {
|
|
184
|
+
try { await this.deliverNow(t.inbox, t.activity); out.push({ ok: true }); }
|
|
185
|
+
catch (error) { out.push({ error }); }
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
174
190
|
async deliver(inbox, activity) {
|
|
175
191
|
// Proved before anything else, so the copy that goes on the queue is the
|
|
176
192
|
// copy that was signed — a retry days later must not post a bare activity.
|
|
177
193
|
const signed = await this.proofed(activity);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
194
|
+
if (this._queueIfCooling(inbox, signed)) return;
|
|
195
|
+
const [result] = await this.deliverManyNow([{ inbox, activity: signed }]);
|
|
196
|
+
await this._settle(inbox, signed, result);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// A host we already know is refusing: queue without asking again. This is
|
|
200
|
+
// the path a FRESH activity takes, so without it a fan-out to a struggling
|
|
201
|
+
// server opened one socket per follower before any of this applied.
|
|
202
|
+
_queueIfCooling(inbox, signed) {
|
|
181
203
|
const host = hostOf(inbox);
|
|
182
204
|
const until = this._cooling?.get(host);
|
|
183
|
-
if (until
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
this._enqueue({ inbox, activity: signed, attempts: 1, nextAt: Date.now() + wait });
|
|
205
|
+
if (!until || until <= Date.now()) return false;
|
|
206
|
+
this.log(`${host} is cooling — queueing ${signed.type} rather than asking again`);
|
|
207
|
+
this._enqueue({ inbox, activity: signed, attempts: 1, nextAt: until });
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// What one attempt's outcome means for the queue.
|
|
212
|
+
async _settle(inbox, signed, result) {
|
|
213
|
+
if (result.ok) { this.log(`delivered ${signed.type} → ${inbox}`); return; }
|
|
214
|
+
const e = result.error;
|
|
215
|
+
if (unsalvageable(e)) { await this._unsalvageable(inbox, e); return; }
|
|
216
|
+
this.log(`delivery failed (${e.message}) — queued`);
|
|
217
|
+
const wait = e.retryAfterMs || 60_000;
|
|
218
|
+
if (aboutTheHost(e)) {
|
|
219
|
+
this._cooling ||= new Map();
|
|
220
|
+
this._cooling.set(hostOf(inbox), Date.now() + wait);
|
|
200
221
|
}
|
|
222
|
+
this._enqueue({ inbox, activity: signed, attempts: 1, nextAt: Date.now() + wait });
|
|
201
223
|
}
|
|
202
224
|
|
|
203
225
|
// Not retried, and on a 410 the followers who received there are dropped,
|
|
@@ -231,8 +253,16 @@ export class Deliverer {
|
|
|
231
253
|
// Proved once for the whole fan-out: every recipient gets the same bytes,
|
|
232
254
|
// and one signature is computed rather than one per follower.
|
|
233
255
|
const signed = await this.proofed(activity);
|
|
234
|
-
// Shared inboxes deduplicate fan-out to the same server.
|
|
235
|
-
|
|
256
|
+
// Shared inboxes deduplicate fan-out to the same server. Sent in batches
|
|
257
|
+
// of batchSize, and a host found cooling by one batch is not asked again
|
|
258
|
+
// by the next.
|
|
259
|
+
const targets = [...new Set(inboxes)].map((inbox) => ({ inbox, activity: signed }));
|
|
260
|
+
for (let i = 0; i < targets.length; i += this.batchSize) {
|
|
261
|
+
const chunk = targets.slice(i, i + this.batchSize).filter((t) => !this._queueIfCooling(t.inbox, signed));
|
|
262
|
+
if (!chunk.length) continue;
|
|
263
|
+
const results = await this.deliverManyNow(chunk);
|
|
264
|
+
for (let k = 0; k < chunk.length; k++) await this._settle(chunk[k].inbox, signed, results[k]);
|
|
265
|
+
}
|
|
236
266
|
}
|
|
237
267
|
|
|
238
268
|
// Serialized, for the same reason Intake.drain is: the tick is 60s and a
|
|
@@ -51,6 +51,7 @@ const CHAIN_GAP_MS = 5_000; // pause between chained backlog sweeps
|
|
|
51
51
|
// enough that a crash re-does little, large enough that a flood of fast
|
|
52
52
|
// rejections does not become a pod write per item.
|
|
53
53
|
const DELETE_BATCH = 10;
|
|
54
|
+
const ORPHAN_RECEIPTS_PER_SWEEP = 20; // strays from before receipts were deleted with their items
|
|
54
55
|
// Attempt counts live in pod state, not in memory: a restart used to hand every
|
|
55
56
|
// poison item five fresh tries, and under a crash loop that is unbounded.
|
|
56
57
|
const ATTEMPTS_DOC = 'intake-attempts.json';
|
|
@@ -359,6 +360,7 @@ export class Intake {
|
|
|
359
360
|
// deadletter.json where one would do, and a flood is exactly when the pod
|
|
360
361
|
// should be asked for less rather than more.
|
|
361
362
|
const pending = [];
|
|
363
|
+
const withReceipt = new Set(); // items the listing showed a receipt beside
|
|
362
364
|
const flush = async () => {
|
|
363
365
|
if (!pending.length) return true;
|
|
364
366
|
// Written down before any of them leaves the mailbox. A failure here
|
|
@@ -379,13 +381,17 @@ export class Intake {
|
|
|
379
381
|
}
|
|
380
382
|
this._clearAttempt(url);
|
|
381
383
|
handled++;
|
|
384
|
+
// Its receipt goes with it. Left beside a deleted item, a receipt
|
|
385
|
+
// was one stray document per delivery, forever.
|
|
386
|
+
if (withReceipt.has(url)) await podInbox.dropReceiptBeside(this.remote, url);
|
|
382
387
|
await new Promise(r => setTimeout(r, DELETE_GAP_MS));
|
|
383
388
|
}
|
|
384
389
|
return true;
|
|
385
390
|
};
|
|
386
391
|
|
|
387
|
-
for (const { url, size } of items) {
|
|
392
|
+
for (const { url, size, receipt: hasReceipt } of items) {
|
|
388
393
|
if (url.endsWith('.keep')) continue;
|
|
394
|
+
if (hasReceipt) withReceipt.add(url);
|
|
389
395
|
// The listing already carries every child's size, so this costs nothing
|
|
390
396
|
// to ask. An activity is a few kB; anything of this order is not one, and
|
|
391
397
|
// reading it with an unbounded res.text() buffers whatever a stranger
|
|
@@ -430,9 +436,11 @@ export class Intake {
|
|
|
430
436
|
}
|
|
431
437
|
// A gateway that verified this delivery left a receipt beside it. Read
|
|
432
438
|
// it only when a gateway is configured (no config → no fetch, so an
|
|
433
|
-
// install with no gateway pays nothing)
|
|
434
|
-
//
|
|
435
|
-
|
|
439
|
+
// install with no gateway pays nothing) and the listing showed one
|
|
440
|
+
// beside this item (`false` is a definite no; a listing that does not
|
|
441
|
+
// say leaves it to the read); a missing or HMAC-invalid receipt reads
|
|
442
|
+
// as null, which is exactly today's unverified behavior.
|
|
443
|
+
const receipt = activity && hasReceipt !== false ? await this._readReceipt(url) : null;
|
|
436
444
|
if (activity && this.gatewaySecret()) this._bumpGatewayStat(!!receipt?.verified);
|
|
437
445
|
// The owner's own post, taken at the outbox door: not mail to read
|
|
438
446
|
// but a write to make. Never archived or forwarded as if received.
|
|
@@ -464,6 +472,11 @@ export class Intake {
|
|
|
464
472
|
if (pending.length >= DELETE_BATCH && !await flush()) return;
|
|
465
473
|
}
|
|
466
474
|
if (!await this._finishSweep(flush)) return;
|
|
475
|
+
// Receipts earlier drains left behind, a few per sweep until none remain.
|
|
476
|
+
for (const stray of podInbox.orphanReceipts(this.remote, this.urls).slice(0, ORPHAN_RECEIPTS_PER_SWEEP)) {
|
|
477
|
+
if (!await podInbox.dropStrayReceipt(this.remote, stray)) break;
|
|
478
|
+
await new Promise(r => setTimeout(r, DELETE_GAP_MS));
|
|
479
|
+
}
|
|
467
480
|
// Made progress and there is more waiting: go straight round rather than
|
|
468
481
|
// sleeping. Gated on progress so a sweep that achieved nothing — a
|
|
469
482
|
// cooldown, an unwritable store, poison at the head — cannot spin.
|
package/lib/core/lease.mjs
CHANGED
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
import crypto from 'node:crypto';
|
|
10
10
|
|
|
11
11
|
// Renewal is the one thing that writes even when nothing is happening: at 30s
|
|
12
|
-
// it was 120 PUTs an hour per agent, each taking a write lock on the pod
|
|
13
|
-
// against a
|
|
14
|
-
//
|
|
15
|
-
// to the TTL — a user acting on a second device does not wait,
|
|
16
|
-
// claims the lease outright.
|
|
17
|
-
const TTL_MS =
|
|
18
|
-
const RENEW_MS =
|
|
12
|
+
// it was 120 PUTs an hour per agent, each taking a write lock on the pod; at
|
|
13
|
+
// 90s, 40. Five minutes against a 15-minute TTL leaves two missed renewals of
|
|
14
|
+
// headroom and makes it 12. The cost is automatic promotion after a crash
|
|
15
|
+
// waiting up to the TTL — a user acting on a second device does not wait,
|
|
16
|
+
// since takeover() claims the lease outright.
|
|
17
|
+
const TTL_MS = 900_000;
|
|
18
|
+
const RENEW_MS = 300_000;
|
|
19
19
|
const JITTER = () => 0.85 + Math.random() * 0.3;
|
|
20
20
|
// Distinct from null: null is "nobody holds it", this is "we could not ask".
|
|
21
21
|
const UNREADABLE = Symbol('lease-unreadable');
|
package/lib/core/store.mjs
CHANGED
|
@@ -72,6 +72,11 @@ export function dropFollower(contacts, actor, why) {
|
|
|
72
72
|
return contacts;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// What a state document looks like on the pod; write() and _put() agree on it.
|
|
76
|
+
const serialise = (obj) => JSON.stringify(obj, null, 2) + '\n';
|
|
77
|
+
// The same record apart from when it was fetched.
|
|
78
|
+
const sameButWhen = (a, b) => JSON.stringify({ ...a, fetchedAt: null }) === JSON.stringify({ ...b, fetchedAt: null });
|
|
79
|
+
|
|
75
80
|
export class PodStore {
|
|
76
81
|
constructor({ storage = null, log = console.log } = {}) {
|
|
77
82
|
this.lastSkipped = []; // what the last load could not read, as `name (HTTP n)`
|
|
@@ -79,6 +84,7 @@ export class PodStore {
|
|
|
79
84
|
this.log = log;
|
|
80
85
|
this.cache = new Map(); // name → parsed value
|
|
81
86
|
this.etags = new Map(); // name (or '') → last ETag, for revalidation
|
|
87
|
+
this.lastText = new Map(); // name → the bytes last loaded from or sent to the pod
|
|
82
88
|
this.timers = new Map(); // name → debounce timer
|
|
83
89
|
this.dirty = new Set(); // written while held; flushed by commit/release
|
|
84
90
|
this._held = 0;
|
|
@@ -91,7 +97,7 @@ export class PodStore {
|
|
|
91
97
|
attach(storage) {
|
|
92
98
|
// A different tree is different state: carrying the old cache and its
|
|
93
99
|
// ETags across would serve one container's documents as another's.
|
|
94
|
-
if (this.storage && this.storage.base !== storage.base) { this.cache.clear(); this.etags.clear(); }
|
|
100
|
+
if (this.storage && this.storage.base !== storage.base) { this.cache.clear(); this.etags.clear(); this.lastText.clear(); }
|
|
95
101
|
this.storage = storage;
|
|
96
102
|
}
|
|
97
103
|
|
|
@@ -144,7 +150,7 @@ export class PodStore {
|
|
|
144
150
|
}
|
|
145
151
|
fetched++;
|
|
146
152
|
this.etags.set(name, r.etag);
|
|
147
|
-
try { this.cache.set(name, JSON.parse(r.body)); }
|
|
153
|
+
try { this.cache.set(name, JSON.parse(r.body)); this.lastText.set(name, r.body); }
|
|
148
154
|
catch (e) { this.log(`state load ${name}: unparsable (${e.message})`); }
|
|
149
155
|
}
|
|
150
156
|
// Kept for /status: a document skipped here is a timeline or a contact
|
|
@@ -167,6 +173,15 @@ export class PodStore {
|
|
|
167
173
|
write(name, obj) {
|
|
168
174
|
this.cache.set(name, structuredClone(obj));
|
|
169
175
|
if (!this.storage) return;
|
|
176
|
+
// The same bytes the pod already holds are not sent again. A like, a
|
|
177
|
+
// bookmark, a sweep that found nothing new each used to upload the whole
|
|
178
|
+
// timeline index and the whole people cache, unchanged — the largest
|
|
179
|
+
// write on the pod, on every small action. A write that had been armed
|
|
180
|
+
// and is now undone is unarmed with it.
|
|
181
|
+
if (this.lastText.get(name) === serialise(obj)) {
|
|
182
|
+
clearTimeout(this.timers.get(name)); this.timers.delete(name); this.dirty.delete(name);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
170
185
|
// Held: record it and leave the writing to the commit boundary the caller
|
|
171
186
|
// already has. See hold().
|
|
172
187
|
if (this._held) { this.dirty.add(name); return; }
|
|
@@ -205,13 +220,13 @@ export class PodStore {
|
|
|
205
220
|
// the returned promise so one failure cannot poison the queue.
|
|
206
221
|
_put(name) {
|
|
207
222
|
const done = this.chain.then(async () => {
|
|
208
|
-
const body =
|
|
223
|
+
const body = serialise(this.cache.get(name));
|
|
209
224
|
for (let attempt = 1; attempt <= PUT_RETRIES; attempt++) {
|
|
210
225
|
// A storage that throws is a bug, not a hiccup — but it must not
|
|
211
226
|
// escape into the write queue, where it would look like success.
|
|
212
227
|
const r = await this.storage.write(name, body, 'application/json')
|
|
213
228
|
.catch(e => ({ ok: false, retry: false, why: e.message }));
|
|
214
|
-
if (r.ok) return true;
|
|
229
|
+
if (r.ok) { this.lastText.set(name, body); return true; }
|
|
215
230
|
// The storage says whether trying again could possibly help: a pod's
|
|
216
231
|
// 4xx is an answer rather than a hiccup, and a directory's EACCES will
|
|
217
232
|
// still be an EACCES in two seconds.
|
|
@@ -236,6 +251,7 @@ export class PodStore {
|
|
|
236
251
|
// migrates to the local machine — leaving the copy behind would defeat it).
|
|
237
252
|
async remove(name) {
|
|
238
253
|
this.cache.delete(name);
|
|
254
|
+
this.lastText.delete(name);
|
|
239
255
|
clearTimeout(this.timers.get(name));
|
|
240
256
|
this.timers.delete(name);
|
|
241
257
|
if (!this.storage) return true;
|
|
@@ -462,6 +478,7 @@ export class PodStore {
|
|
|
462
478
|
getActors() { return this.read('actors.json', {}); }
|
|
463
479
|
cacheActor(url, doc) {
|
|
464
480
|
const a = this.getActors();
|
|
481
|
+
const known = a[url];
|
|
465
482
|
a[url] = {
|
|
466
483
|
name: clamp(plainText(doc.name || doc.preferredUsername || ''), MAX_NAME),
|
|
467
484
|
preferredUsername: clamp(plainText(doc.preferredUsername || ''), MAX_NAME),
|
|
@@ -474,9 +491,12 @@ export class PodStore {
|
|
|
474
491
|
type: doc.type || 'Person',
|
|
475
492
|
followers: doc.followers || null,
|
|
476
493
|
following: doc.following || null,
|
|
477
|
-
...(
|
|
494
|
+
...(known?.counts ? { counts: known.counts } : {}),
|
|
478
495
|
fetchedAt: new Date().toISOString(),
|
|
479
496
|
};
|
|
497
|
+
// Fetched again and found the same: nothing to write. Only the time of
|
|
498
|
+
// asking differs, and that is not worth the whole cache going to the pod.
|
|
499
|
+
if (known && sameButWhen(known, a[url])) { a[url] = known; return; }
|
|
480
500
|
this.write('actors.json', prune(a, ACTOR_CACHE_MAX, this.getContacts()));
|
|
481
501
|
}
|
|
482
502
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// caches.mjs — what one warm gateway process keeps between requests: the
|
|
2
|
+
// pod-token verifier, and the public keys of the servers that deliver here.
|
|
3
|
+
// Built per request, each costs round trips to somebody else's server for
|
|
4
|
+
// every call; built once, a process pays them once.
|
|
5
|
+
|
|
6
|
+
// The Solid-OIDC verifier remembers the issuers' key sets it has seen. A
|
|
7
|
+
// fresh one per call re-read the caller's WebID document, the issuer's
|
|
8
|
+
// discovery document and its keys on every signed-in call.
|
|
9
|
+
let verifier = null;
|
|
10
|
+
export async function podTokenVerifier() {
|
|
11
|
+
if (!verifier) verifier = (await import('@solid/access-token-verifier')).createSolidTokenVerifier();
|
|
12
|
+
return verifier;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The keys deliveries are signed with, by key id, in the shape Fedify's
|
|
16
|
+
// verifier asks for. A key is held an hour; a key that could not be fetched
|
|
17
|
+
// is held five minutes, so a server that was down is asked again soon.
|
|
18
|
+
// Fedify re-fetches on its own when a held key no longer verifies, so a
|
|
19
|
+
// rotated key costs one failed check, not an hour of refused mail.
|
|
20
|
+
const KEY_TTL_MS = 60 * 60_000;
|
|
21
|
+
const MISS_TTL_MS = 5 * 60_000;
|
|
22
|
+
const KEY_CACHE_MAX = 500;
|
|
23
|
+
const keys = new Map(); // key id → { key, until }
|
|
24
|
+
export const senderKeys = {
|
|
25
|
+
async get(keyId) {
|
|
26
|
+
const hit = keys.get(keyId.href);
|
|
27
|
+
if (!hit) return undefined;
|
|
28
|
+
if (hit.until < Date.now()) { keys.delete(keyId.href); return undefined; }
|
|
29
|
+
return hit.key;
|
|
30
|
+
},
|
|
31
|
+
async set(keyId, key) {
|
|
32
|
+
if (keys.size >= KEY_CACHE_MAX) keys.delete(keys.keys().next().value);
|
|
33
|
+
keys.set(keyId.href, { key, until: Date.now() + (key ? KEY_TTL_MS : MISS_TTL_MS) });
|
|
34
|
+
},
|
|
35
|
+
size: () => keys.size,
|
|
36
|
+
};
|