fedipod 1.45.0 → 1.48.0
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 +11 -16
- package/architecture.md +6 -3
- package/gateway.md +51 -23
- package/gui.md +3 -2
- package/lib/client/c2s.mjs +2 -1
- package/lib/client/masto/index.mjs +5 -4
- package/lib/client/masto/render.mjs +4 -1
- package/lib/core/intake/activities.mjs +8 -9
- package/lib/core/intake/counts.mjs +143 -0
- package/lib/core/intake/index.mjs +4 -0
- package/lib/core/publisher/notes.mjs +4 -1
- package/lib/core/social.mjs +2 -0
- package/lib/core/storage.mjs +54 -5
- package/lib/core/wire.mjs +16 -5
- package/lib/gateway/account-agent.mjs +20 -4
- package/lib/gateway/copy.mjs +95 -5
- package/lib/gateway/front-core.mjs +4 -4
- package/lib/gateway/held-mail.mjs +11 -4
- package/lib/gateway/keeper.mjs +9 -6
- package/lib/gateway/masto-gateway.mjs +140 -40
- package/lib/gateway/private-read.mjs +10 -3
- package/lib/gateway/quiet.mjs +4 -3
- package/lib/gateway/state-api.mjs +73 -18
- package/lib/pod/notes.mjs +25 -0
- package/lib/server/embed.mjs +1 -1
- package/lib/session/README.md +2 -135
- package/lib/surface/routes/owner.mjs +2 -2
- package/lib/surface/surface.mjs +4 -3
- package/package.json +5 -3
- package/scripts/check-netlify-bundles.mjs +0 -4
- package/scripts/check-pod-calls.mjs +5 -108
- package/scripts/check-sizes.mjs +6 -3
- package/scripts/dependents.mjs +76 -0
- package/scripts/stage-site.mjs +10 -8
- package/scripts/start.mjs +2 -2
- package/scripts/test-dependents.mjs +37 -0
- package/web/admin/gateway.js +8 -24
- package/web/admin/index.html +0 -16
- package/web/admin/record.js +1 -2
- package/web/admin/upkeep.js +1 -5
- package/web/app/admin-facade.mjs +1 -1
- package/web/app/agent.mjs +27 -10
- package/web/app/copy-mode.mjs +98 -13
- package/web/app/dist/boot.js +68 -57
- package/web/app/dist/boot.js.map +4 -4
- package/web/app/dist/sw.js +583 -283
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/forum-support.mjs +2 -2
- package/web/app/oidc-session.mjs +2 -2
- package/lib/session/demo.html +0 -94
- package/lib/session/fedi-account.mjs +0 -417
- package/lib/session/fedi-login.mjs +0 -123
- package/lib/session/oidc-session.mjs +0 -233
- package/lib/session/package.json +0 -49
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// masto-gateway.mjs — Mastodon apps at the gateway's own address: elk.zone,
|
|
2
2
|
// Ivory, Tusky or any other app, signed in to an account the gateway keeps
|
|
3
|
-
// running, reading and writing it from its copy (copy.mjs)
|
|
3
|
+
// running, reading and writing it from its copy (copy.mjs), made from the pod
|
|
4
|
+
// when there is none while the hold is on; with the hold off, from the pod.
|
|
4
5
|
//
|
|
5
6
|
// POST /api/v1/apps an app registers here, for every account
|
|
6
7
|
// GET /oauth/authorize sends the person to the sign-in page
|
|
@@ -20,7 +21,8 @@
|
|
|
20
21
|
// browser sitting open, as one device does another — and reads the signing
|
|
21
22
|
// key from the pod for that one request. Mail that arrived while nobody was
|
|
22
23
|
// reading is read into the copy after an app's timeline or notification check
|
|
23
|
-
// has been answered, at most every thirty seconds
|
|
24
|
+
// has been answered, at most every thirty seconds; with the hold off, the pod
|
|
25
|
+
// inbox is drained then instead, unless FediPod is open and drains it itself.
|
|
24
26
|
//
|
|
25
27
|
// Phone notifications: an app signs up for them through the account's own
|
|
26
28
|
// facade (/api/v1/push/subscription), with the one push key pair this gateway
|
|
@@ -37,10 +39,13 @@ import { PodStore } from '../core/store.mjs';
|
|
|
37
39
|
import { apUrls } from '../core/wire.mjs';
|
|
38
40
|
import { Publisher } from '../core/publisher/index.mjs';
|
|
39
41
|
import { nextDue } from '../core/scheduled.mjs';
|
|
40
|
-
import { reachAccount, stateAndLease, actingAgent } from './account-agent.mjs';
|
|
42
|
+
import { reachAccount, settle, stateAndLease, actingAgent } from './account-agent.mjs';
|
|
41
43
|
import { ensureCopy } from './state-api.mjs';
|
|
42
|
-
import { CopyStorage, lockCopy, keptNow, keptBefore, GATEWAY_HOLDER } from './copy.mjs';
|
|
44
|
+
import { CopyStorage, lockCopy, keptNow, keptBefore, holdOn, waitOutClosing, GATEWAY_HOLDER } from './copy.mjs';
|
|
45
|
+
import { HttpStorage } from '../core/storage.mjs';
|
|
46
|
+
import * as podInbox from '../pod/inbox.mjs';
|
|
43
47
|
import { heldEntries } from './held-mail.mjs';
|
|
48
|
+
import { noteOpened } from './quiet.mjs';
|
|
44
49
|
import { Push } from '../client/webpush.mjs';
|
|
45
50
|
import webpush from 'web-push';
|
|
46
51
|
|
|
@@ -196,19 +201,65 @@ class GatewayPush extends Push {
|
|
|
196
201
|
// ---- the account behind a token ----
|
|
197
202
|
|
|
198
203
|
// Loaded copies, kept while this running copy lives: a check that finds the
|
|
199
|
-
// copy unchanged costs one listing, not every document.
|
|
200
|
-
|
|
204
|
+
// copy unchanged costs one listing, not every document. With the hold off,
|
|
205
|
+
// the pod's state, read the same way.
|
|
206
|
+
const reading = new Map(); // handle (or pod:handle) -> PodStore
|
|
201
207
|
const noAuthorities = { has: () => false, isLocalRequest: () => false, isLocal: () => false, wsAuthorities: () => [] };
|
|
202
208
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
209
|
+
// What an app reads: the copy, or with the hold off, the pod.
|
|
210
|
+
const stateOf = (ctx, handle, at) => (at.inCopy
|
|
211
|
+
? new CopyStorage(ctx.copyKv, handle, { holder: GATEWAY_HOLDER })
|
|
212
|
+
: new HttpStorage(at.urls.state, at.podFetch));
|
|
213
|
+
|
|
214
|
+
async function readingStore(ctx, handle, at, log) {
|
|
215
|
+
const key = at.inCopy ? handle : `pod:${handle}`;
|
|
216
|
+
let held = reading.get(key);
|
|
217
|
+
if (!held) {
|
|
218
|
+
held = { store: new PodStore({ storage: stateOf(ctx, handle, at), log }) };
|
|
207
219
|
if (reading.size > 200) reading.clear();
|
|
208
|
-
reading.set(
|
|
220
|
+
reading.set(key, held);
|
|
209
221
|
}
|
|
210
|
-
|
|
211
|
-
|
|
222
|
+
// This request's own sign-in at the pod; the same place, so what is held stays.
|
|
223
|
+
else held.store.attach(stateOf(ctx, handle, at));
|
|
224
|
+
held.at = Date.now();
|
|
225
|
+
await held.store.load();
|
|
226
|
+
return held.store;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// What this running copy remembers of an account, a loaded copy or an answer,
|
|
230
|
+
// goes once nobody has asked for fifteen minutes, as the copy itself does
|
|
231
|
+
// (copy.mjs: the hold).
|
|
232
|
+
const MEMORY_MS = 15 * 60_000;
|
|
233
|
+
function forgetOld(now = Date.now()) {
|
|
234
|
+
for (const [k, v] of reading) if (now - v.at > MEMORY_MS) reading.delete(k);
|
|
235
|
+
for (const [k, v] of answered) if (now - v.at > MEMORY_MS) { answeredBytes -= v.body.length; answered.delete(k); }
|
|
236
|
+
}
|
|
237
|
+
const forget = (handle) => { reading.delete(handle); reading.delete(`pod:${handle}`); };
|
|
238
|
+
|
|
239
|
+
// An app's repeated check: while the copy's list of documents is as it was
|
|
240
|
+
// and no mail waits, the same question gets the same answer, without opening
|
|
241
|
+
// the copy. Ten minutes at most, for what changes with the clock alone (a
|
|
242
|
+
// poll closing). Kept per token, while this running copy lives.
|
|
243
|
+
const REPEAT_MS = 10 * 60_000;
|
|
244
|
+
const REPEAT_MAX_BYTES = 32 * 1024 * 1024;
|
|
245
|
+
const REPEATABLE = /^\/api\/v[12]\/(timelines\/[^/]+(\/[^/]+)?|notifications(\/unread_count)?|markers|conversations|follow_requests|announcements|accounts\/verify_credentials|lists|filters|preferences)$/u;
|
|
246
|
+
const answered = new Map(); // handle, token hash and the question -> { tag, at, status, headers, body }
|
|
247
|
+
let answeredBytes = 0;
|
|
248
|
+
|
|
249
|
+
async function repeatAnswer(ctx, handle, key, at) {
|
|
250
|
+
const had = answered.get(key);
|
|
251
|
+
if (!had || Date.now() - had.at > REPEAT_MS) return null;
|
|
252
|
+
const listing = await stateOf(ctx, handle, at).list('', { etag: had.tag }).catch(() => ({ notModified: false }));
|
|
253
|
+
if (!listing.notModified) return null;
|
|
254
|
+
if (ctx.listHeld && (await ctx.listHeld(handle)).length) return null;
|
|
255
|
+
return { status: had.status, headers: { ...had.headers }, body: had.body };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function rememberAnswer(key, tag, status, headers, body) {
|
|
259
|
+
if (body.length > REPEAT_MAX_BYTES / 64) return;
|
|
260
|
+
if (answeredBytes + body.length > REPEAT_MAX_BYTES) { answered.clear(); answeredBytes = 0; }
|
|
261
|
+
answeredBytes += body.length - (answered.get(key)?.body.length || 0);
|
|
262
|
+
answered.set(key, { tag, at: Date.now(), status, headers, body });
|
|
212
263
|
}
|
|
213
264
|
|
|
214
265
|
// What an app reading needs of an agent: the store, the account's addresses
|
|
@@ -246,19 +297,31 @@ async function notePushSubs(ctx, handle, store) {
|
|
|
246
297
|
}
|
|
247
298
|
export async function pushWanted(ctx, handle) { return Number((await ctx.mastoKv?.get(`push/${handle}`))?.text || 0) > 0; }
|
|
248
299
|
|
|
300
|
+
// With the hold off, mail is in the pod inbox, not held here: whether any is
|
|
301
|
+
// waiting, from one listing.
|
|
302
|
+
async function inboxWaiting(at) {
|
|
303
|
+
const items = await podInbox.list(at.remote, at.urls).catch(() => []);
|
|
304
|
+
return items.some((e) => !e.url.endsWith('.keep') && !e.url.endsWith('.receipt.json'));
|
|
305
|
+
}
|
|
306
|
+
|
|
249
307
|
/**
|
|
250
308
|
* Read what was held for an account into its copy, and push each notification
|
|
251
|
-
* that makes to the owner's phones and browsers.
|
|
252
|
-
*
|
|
253
|
-
*
|
|
309
|
+
* that makes to the owner's phones and browsers. With the hold off, what waits
|
|
310
|
+
* in the pod inbox is drained instead. `takeover`: act even when a browser
|
|
311
|
+
* that went quiet still holds the lease (a push run); otherwise leave the mail
|
|
312
|
+
* to whoever holds it (an app's check).
|
|
254
313
|
*/
|
|
255
314
|
async function readHeld(ctx, handle, rec, { log = console.log, takeover = false, waitMs = 1000 } = {}) {
|
|
256
|
-
|
|
315
|
+
const held = async () => !!(ctx.listHeld && (await ctx.listHeld(handle)).length);
|
|
316
|
+
if (holdOn(ctx) && !await held()) return 0;
|
|
257
317
|
const unlock = await lockCopy(ctx.copyKv, handle, { waitMs });
|
|
258
318
|
if (!unlock) return 0;
|
|
259
319
|
try {
|
|
260
320
|
const at = await reachAccount(ctx, handle, rec, { log });
|
|
261
|
-
if (at.skipped
|
|
321
|
+
if (at.skipped) return 0;
|
|
322
|
+
if (!(await settle(ctx, handle, rec, at, { log })).ok) return 0;
|
|
323
|
+
const drains = !at.inCopy;
|
|
324
|
+
if (drains && !await inboxWaiting(at) && !await held()) return 0;
|
|
262
325
|
const { store, lease } = stateAndLease(ctx, handle, at, { log });
|
|
263
326
|
if (!await lease.acquire() && !(takeover && await lease.takeover())) return 0;
|
|
264
327
|
await store.load();
|
|
@@ -277,22 +340,23 @@ async function readHeld(ctx, handle, rec, { log = console.log, takeover = false,
|
|
|
277
340
|
})().catch((e) => log(`push @${handle}: ${e.message}`)));
|
|
278
341
|
};
|
|
279
342
|
}
|
|
280
|
-
const entries = await heldEntries(ctx, handle);
|
|
343
|
+
const entries = ctx.listHeld ? await heldEntries(ctx, handle) : [];
|
|
281
344
|
const done = new Set(await agent.intake.takeHeld(entries));
|
|
282
345
|
for (const e of entries) if (done.has(e.name)) for (const n of e.held) await ctx.dropHeld(handle, n);
|
|
346
|
+
if (drains) await agent.intake.drain();
|
|
283
347
|
await Promise.all(pushes);
|
|
284
348
|
await store.commit();
|
|
285
349
|
await lease.release().catch(() => {});
|
|
286
350
|
await ctx.noteNext?.(handle, nextDue(store)).catch(() => {});
|
|
287
|
-
|
|
288
|
-
if (done.size) log(`@${handle}: ${done.size} held deliveries read into the copy, ${pushes.length} pushed`);
|
|
289
|
-
return done.size;
|
|
351
|
+
forget(handle);
|
|
352
|
+
if (done.size || drains) log(`@${handle}: ${drains ? 'the pod inbox drained' : `${done.size} held deliveries read into the copy`}, ${pushes.length} pushed`);
|
|
353
|
+
return done.size || (drains ? 1 : 0);
|
|
290
354
|
} finally { await unlock(); }
|
|
291
355
|
}
|
|
292
356
|
|
|
293
357
|
/** For an app checking in: at most every thirty seconds, never over a browser. */
|
|
294
358
|
export async function readHeldForApp(ctx, handle, rec, { log = console.log } = {}) {
|
|
295
|
-
if (!ctx.listHeld) return 0;
|
|
359
|
+
if (!ctx.listHeld && holdOn(ctx)) return 0;
|
|
296
360
|
const key = `${handle}/mail-read-at`;
|
|
297
361
|
const last = Number((await ctx.copyKv.get(key))?.text || 0);
|
|
298
362
|
if (Date.now() - last < MAIL_EVERY_MS) return 0;
|
|
@@ -307,7 +371,7 @@ export async function readHeldForApp(ctx, handle, rec, { log = console.log } = {
|
|
|
307
371
|
* burst of likes is a run or two, not one each.
|
|
308
372
|
*/
|
|
309
373
|
export async function pushHeld(ctx, handle, rec, { log = console.log } = {}) {
|
|
310
|
-
if (!ctx.listHeld || !await pushWanted(ctx, handle)) return 0;
|
|
374
|
+
if ((!ctx.listHeld && holdOn(ctx)) || !await pushWanted(ctx, handle)) return 0;
|
|
311
375
|
const kv = ctx.mastoKv;
|
|
312
376
|
const wanted = async () => (await kv.get(`pushwant/${handle}`))?.text || null;
|
|
313
377
|
let n = 0;
|
|
@@ -326,21 +390,44 @@ export async function pushHeld(ctx, handle, rec, { log = console.log } = {}) {
|
|
|
326
390
|
return n;
|
|
327
391
|
}
|
|
328
392
|
|
|
329
|
-
// A request from an app, answered by the account's facade over its copy
|
|
330
|
-
|
|
393
|
+
// A request from an app, answered by the account's facade over its copy, or
|
|
394
|
+
// with the hold off, over the pod.
|
|
395
|
+
async function answerForAccount(request, url, ctx, tok, rec, log, tokenHash) {
|
|
331
396
|
const handle = tok.handle;
|
|
397
|
+
forgetOld();
|
|
398
|
+
const repeatKey = request.method === 'GET' && REPEATABLE.test(url.pathname) ? `${handle}\n${tokenHash}\n${url.pathname}${url.search}` : null;
|
|
399
|
+
// The copy's listing as it was: the same answer, without opening the copy.
|
|
400
|
+
// A copy written to the pod and deleted since lists differently.
|
|
401
|
+
if (repeatKey && holdOn(ctx)) {
|
|
402
|
+
const again = await repeatAnswer(ctx, handle, repeatKey, { inCopy: true });
|
|
403
|
+
if (again) return again;
|
|
404
|
+
}
|
|
405
|
+
// A copy the round is writing to the pod and deleting is waited out; it is
|
|
406
|
+
// made again below.
|
|
407
|
+
await waitOutClosing(ctx.copyKv, handle);
|
|
332
408
|
const at = await reachAccount(ctx, handle, rec, { log });
|
|
333
409
|
if (at.skipped) return json(503, { error: `this account cannot be reached now: ${at.skipped}` });
|
|
334
|
-
|
|
410
|
+
const token = { token: 'gateway', scope: tok.scope };
|
|
411
|
+
const writes = request.method !== 'GET' && request.method !== 'HEAD';
|
|
412
|
+
if (!writes && !at.inCopy && holdOn(ctx)) {
|
|
335
413
|
const made = await ensureCopy(ctx, handle, rec, log);
|
|
336
414
|
if (!made.ok) return json(made.status === 409 ? 503 : made.status, { error: made.why });
|
|
337
415
|
at.inCopy = true;
|
|
338
416
|
}
|
|
339
|
-
|
|
340
|
-
|
|
417
|
+
// New mail read in beside this answer, for the app's next check.
|
|
418
|
+
if (!writes && /^\/api\/v[12]\/(timelines\/home|notifications)$/u.test(url.pathname)) {
|
|
419
|
+
ctx.waitUntil?.(readHeldForApp(ctx, handle, rec, { log }).catch((e) => log(`app @${handle}: held mail: ${e.message}`)));
|
|
420
|
+
}
|
|
421
|
+
// With the hold off, the pod's listing, after the inbox has been looked at.
|
|
422
|
+
if (repeatKey && !holdOn(ctx)) {
|
|
423
|
+
const again = await repeatAnswer(ctx, handle, repeatKey, at);
|
|
424
|
+
if (again) return again;
|
|
425
|
+
}
|
|
341
426
|
const b = await bridge(request, url);
|
|
342
427
|
if (!writes) {
|
|
343
|
-
const store = await readingStore(ctx, handle, log);
|
|
428
|
+
const store = await readingStore(ctx, handle, at, log);
|
|
429
|
+
// The copy as this answer reads it: a later check finding the same list repeats it.
|
|
430
|
+
const tag = store.etags.get('');
|
|
344
431
|
let actingP = null;
|
|
345
432
|
const acting = () => (actingP ||= (async () => {
|
|
346
433
|
const s = stateAndLease(ctx, handle, at, { log });
|
|
@@ -353,17 +440,23 @@ async function answerForAccount(request, url, ctx, tok, rec, log) {
|
|
|
353
440
|
if (!agent) return json(503, { error: 'this account\'s copy holds no account' });
|
|
354
441
|
const api = facade(agent, token, log, await vapidKeys(ctx.mastoKv), pushContact(ctx));
|
|
355
442
|
const handled = await api.handle(b.req, b.res, url.pathname, url);
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
443
|
+
if (!handled) return new Response(JSON.stringify({ error: 'not found' }), { status: 404 });
|
|
444
|
+
const res = b.response();
|
|
445
|
+
// An answer that had to act (a fetch from another server) is not repeated.
|
|
446
|
+
if (!repeatKey || actingP || res.status !== 200 || !tag) return res;
|
|
447
|
+
const headers = Object.fromEntries(res.headers);
|
|
448
|
+
const body = await res.text();
|
|
449
|
+
rememberAnswer(repeatKey, tag, res.status, headers, body);
|
|
450
|
+
return { status: res.status, headers, body };
|
|
361
451
|
}
|
|
362
452
|
// Acting: this app outranks a browser sitting open, as one device does another.
|
|
363
453
|
const unlock = await lockCopy(ctx.copyKv, handle);
|
|
364
454
|
if (!unlock) return json(503, { error: 'this account is busy; try again' });
|
|
365
455
|
let lease = null;
|
|
366
456
|
try {
|
|
457
|
+
// Decided under the lock, so the round cannot delete the copy meanwhile.
|
|
458
|
+
const settled = await settle(ctx, handle, rec, at, { log });
|
|
459
|
+
if (!settled.ok) return json(settled.status === 409 ? 503 : settled.status || 503, { error: settled.why });
|
|
367
460
|
const s = stateAndLease(ctx, handle, at, { log });
|
|
368
461
|
if (!await s.lease.acquire() && !await s.lease.takeover()) return json(503, { error: 'could not take this account over; try again' });
|
|
369
462
|
lease = s.lease;
|
|
@@ -377,7 +470,7 @@ async function answerForAccount(request, url, ctx, tok, rec, log) {
|
|
|
377
470
|
if (url.pathname === '/api/v1/push/subscription') await notePushSubs(ctx, handle, store);
|
|
378
471
|
agent.publisher.stopPolls?.();
|
|
379
472
|
await ctx.noteNext?.(handle, nextDue(store)).catch(() => {});
|
|
380
|
-
|
|
473
|
+
forget(handle);
|
|
381
474
|
return handled ? b.response() : new Response(JSON.stringify({ error: 'not found' }), { status: 404 });
|
|
382
475
|
} finally {
|
|
383
476
|
// Done acting, however it went: the lease goes back, so a browser sitting
|
|
@@ -478,14 +571,17 @@ async function route(request, pathname, ctx, deps, log) {
|
|
|
478
571
|
if (keptBefore(ctx, rec)) return json(503, { error: MOVING });
|
|
479
572
|
if (!usable(ctx, rec)) return json(409, { error: notReady(rec) });
|
|
480
573
|
if (rec.webId !== webid) return json(403, { error: `you signed in as ${webid}, and this account belongs to ${rec.webId}` });
|
|
574
|
+
// Signing an app in is the owner being here, as signing in to FediPod is.
|
|
575
|
+
await noteOpened(ctx, handle, rec, { always: true }).catch((e) => log(`@${handle}: stamp not written: ${e?.message || e}`));
|
|
481
576
|
const app = await appOf(kv, body.client_id);
|
|
482
577
|
if (!app) return json(400, { error: 'this app has not registered here' });
|
|
483
578
|
const redirect = body.redirect_uri || 'urn:ietf:wg:oauth:2.0:oob';
|
|
484
579
|
if (redirect !== 'urn:ietf:wg:oauth:2.0:oob' && !app.redirectUris.includes(redirect)) {
|
|
485
580
|
return json(400, { error: 'this app asked to be sent somewhere it did not register' });
|
|
486
581
|
}
|
|
487
|
-
// The owner, proved: not an app checking in, so not made to wait.
|
|
488
|
-
|
|
582
|
+
// The owner, proved: not an app checking in, so not made to wait. With
|
|
583
|
+
// the hold off there is no copy to make; the app reads the pod.
|
|
584
|
+
const made = holdOn(ctx) ? await ensureCopy(ctx, handle, rec, log, { owner: true }) : { ok: true };
|
|
489
585
|
if (!made.ok) return json(made.status === 409 ? 503 : made.status, { error: made.why });
|
|
490
586
|
const code = rand(24);
|
|
491
587
|
await putJson(kv, `code/${sha(code)}`, {
|
|
@@ -537,7 +633,8 @@ async function route(request, pathname, ctx, deps, log) {
|
|
|
537
633
|
|
|
538
634
|
// Everything else needs a token.
|
|
539
635
|
const bearer = /^Bearer (.+)$/u.exec(request.headers.get('authorization') || '')?.[1];
|
|
540
|
-
const
|
|
636
|
+
const tokenHash = bearer ? sha(bearer) : null;
|
|
637
|
+
const tok = tokenHash ? await getJson(kv, `token/${tokenHash}`) : null;
|
|
541
638
|
if (!tok || Date.now() - tok.at > TOKEN_TTL_MS) {
|
|
542
639
|
// A page of this site asking with a token the gateway never gave out is
|
|
543
640
|
// FediPod's own client in a browser whose worker is not answering yet:
|
|
@@ -555,5 +652,8 @@ async function route(request, pathname, ctx, deps, log) {
|
|
|
555
652
|
// by somebody else is not theirs.
|
|
556
653
|
if (rec && rec.webId === tok.webId && keptBefore(ctx, rec)) return json(503, { error: MOVING });
|
|
557
654
|
if (!usable(ctx, rec) || rec.webId !== tok.webId) return json(401, { error: 'this account is not kept running here any more' });
|
|
558
|
-
|
|
655
|
+
// Using an app is being here: no pause, no closing, for want of opening
|
|
656
|
+
// FediPod. Hourly at most; see noteOpened.
|
|
657
|
+
const here = await noteOpened(ctx, tok.handle, rec).catch((e) => { log(`@${tok.handle}: stamp not written: ${e?.message || e}`); return rec; });
|
|
658
|
+
return answerForAccount(request, url, ctx, tok, here, log, tokenHash);
|
|
559
659
|
}
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
//
|
|
9
9
|
// At fedipod.net this works for an account it keeps: the post is read from
|
|
10
10
|
// the pod with the keeper's credential, and the followers list from the
|
|
11
|
-
// account's copy
|
|
11
|
+
// account's copy, or from the pod when there is no copy just then (the hold,
|
|
12
|
+
// copy.mjs). Nothing read is kept. An account it does not keep gives it
|
|
13
|
+
// nothing to read with,
|
|
12
14
|
// so that read is sent on to the pod, as every private read was.
|
|
13
15
|
|
|
14
16
|
import { verifyHttpSignature, makeSafeLoader } from './httpsig.mjs';
|
|
@@ -78,10 +80,15 @@ export async function answerPrivateRead(ctx, rec, { request, handle, podTarget,
|
|
|
78
80
|
let doc = null;
|
|
79
81
|
try { doc = text ? JSON.parse(text) : null; } catch { doc = null; }
|
|
80
82
|
if (!doc) { say(`refused — the pod did not give it (${actor})`); return nothing(open); }
|
|
83
|
+
// Who follows: from the account's copy while there is one, else from the pod.
|
|
81
84
|
let followers = [];
|
|
82
85
|
try {
|
|
83
|
-
|
|
84
|
-
|
|
86
|
+
let contacts = (await ctx.copyKv.get(`${handle}/d/contacts.json`))?.text || null;
|
|
87
|
+
if (!contacts) {
|
|
88
|
+
const res = await podFetch(`${rec.podHome.replace(/\/?$/u, '/')}ap-state/contacts.json`, { headers: { accept: 'application/json' } });
|
|
89
|
+
if (res.status === 200) contacts = await res.text();
|
|
90
|
+
}
|
|
91
|
+
followers = (JSON.parse(contacts || '{}').followers || []).map((f) => f?.actor).filter(Boolean);
|
|
85
92
|
} catch { followers = []; }
|
|
86
93
|
const followersUrls = [rec.podHome + 'ap/followers', base + 'ap/followers'];
|
|
87
94
|
if (!mayRead(doc, actor, { followersUrls, followers })) { say(`refused — ${actor} is not in its audience`); return nothing(open); }
|
package/lib/gateway/quiet.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// public read, stamps noteOpened from the relay, counts noteReceived after
|
|
5
5
|
// a forwarded delivery, and hands /api/open, /api/pause and /api/close here.
|
|
6
6
|
|
|
7
|
-
import { keptNow } from './copy.mjs';
|
|
7
|
+
import { keptNow, holdOn } from './copy.mjs';
|
|
8
8
|
|
|
9
9
|
// ---- accounts that go quiet -------------------------------------------------
|
|
10
10
|
//
|
|
@@ -144,9 +144,10 @@ export async function routeQuietApi(request, pathname, ctx, deps) {
|
|
|
144
144
|
if (before.closed) return j(410, { error: 'this address is closed', closedAt: before.closedAt, closedBy: before.closedBy });
|
|
145
145
|
rec = await noteOpened(ctx, handle, rec, { always: true });
|
|
146
146
|
// Whom the app would name if its owner lets the gateway act for them while
|
|
147
|
-
// it is closed, and whether they have (held-mail.mjs, keeper.mjs)
|
|
147
|
+
// it is closed, and whether they have (held-mail.mjs, keeper.mjs); and
|
|
148
|
+
// whether the gateway holds a copy of the account at all (copy.mjs).
|
|
148
149
|
return j(200, { ok: true, handle, ...await accountState(ctx, handle, rec),
|
|
149
|
-
keeper: ctx.keeperWebId || null, kept: keptNow(ctx, rec), keptBy: rec.keeper?.webId || null });
|
|
150
|
+
keeper: ctx.keeperWebId || null, kept: keptNow(ctx, rec), keptBy: rec.keeper?.webId || null, hold: holdOn(ctx) });
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
// The owner pausing their own account, or lifting the pause they set.
|
|
@@ -13,13 +13,21 @@
|
|
|
13
13
|
// before the owner stops the gateway keeping
|
|
14
14
|
// the account running
|
|
15
15
|
//
|
|
16
|
+
// The hold (copy.mjs): the round writes a person's copy to the pod and deletes
|
|
17
|
+
// it every fifteen minutes. A document asked for after that makes the copy
|
|
18
|
+
// again from the pod, and one asked for while it goes is answered 503, to be
|
|
19
|
+
// asked again in a moment. The copy's lease outlives it. With the hold off,
|
|
20
|
+
// no copy is made: /open answers 501, after writing to the pod and deleting
|
|
21
|
+
// any copy left from before, and the browser works on the pod.
|
|
22
|
+
//
|
|
16
23
|
// The token is the gateway's own, signed with a secret only it knows, and
|
|
17
24
|
// names the account and the WebID that asked; it lasts a day. The signing key
|
|
18
25
|
// and the passwords of accounts elsewhere are refused here: the copy does not
|
|
19
26
|
// hold them, and the browser reads them from the pod.
|
|
20
27
|
import crypto from 'node:crypto';
|
|
21
28
|
import { HttpStorage } from '../core/storage.mjs';
|
|
22
|
-
import { CopyStorage, copyMeta, fillCopy, dropCopy, forgetCopy, holds, lockCopy, kvDocFetch, podOnly, safeName, safeHandle, keptBefore
|
|
29
|
+
import { CopyStorage, copyMeta, fillCopy, dropCopy, forgetCopy, holds, lockCopy, kvDocFetch, podOnly, safeName, safeHandle, keptBefore, keptNow,
|
|
30
|
+
holdOn, holdOver, closing } from './copy.mjs';
|
|
23
31
|
|
|
24
32
|
const TOKEN_MS = 24 * 3600_000;
|
|
25
33
|
const b64 = (s) => Buffer.from(s).toString('base64url');
|
|
@@ -79,6 +87,14 @@ const noteFailure = (kv, handle, why = null) => kv.set(`${handle}/fill-failed-at
|
|
|
79
87
|
* credential has changed since. Returns { ok } or { ok: false, status, why }.
|
|
80
88
|
*/
|
|
81
89
|
export async function ensureCopy(ctx, handle, rec, log = console.log, { owner = false } = {}) {
|
|
90
|
+
if (await copyMeta(ctx.copyKv, handle)) return { ok: true };
|
|
91
|
+
const unlock = await lockCopy(ctx.copyKv, handle);
|
|
92
|
+
if (!unlock) return { ok: false, status: 503, why: 'the gateway is busy with this account; try again' };
|
|
93
|
+
try { return await makeCopy(ctx, handle, rec, log, { owner }); } finally { await unlock(); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** ensureCopy, for a caller already holding lockCopy for the account. */
|
|
97
|
+
export async function makeCopy(ctx, handle, rec, log = console.log, { owner = false } = {}) {
|
|
82
98
|
if (await copyMeta(ctx.copyKv, handle)) return { ok: true };
|
|
83
99
|
// The owner too: it is the gateway's credential that has to change.
|
|
84
100
|
const refused = await loginRefused(ctx);
|
|
@@ -90,26 +106,41 @@ export async function ensureCopy(ctx, handle, rec, log = console.log, { owner =
|
|
|
90
106
|
}
|
|
91
107
|
const podFetch = await ctx.keeperFetch();
|
|
92
108
|
if (!podFetch) return { ok: false, status: 501, why: 'this gateway cannot reach pods for accounts' };
|
|
93
|
-
|
|
94
|
-
|
|
109
|
+
// Marked failed before it starts and cleared when it is done, so an attempt
|
|
110
|
+
// cut off part way (a request's time running out) counts as failed too.
|
|
111
|
+
await noteFailure(ctx.copyKv, handle);
|
|
112
|
+
const stateUrl = stateUrlOf(rec);
|
|
113
|
+
const made = await fillCopy(ctx.copyKv, handle, { pod: new HttpStorage(stateUrl, podFetch), podFetch, stateUrl, webId: rec.webId, log })
|
|
114
|
+
.catch((e) => ({ ok: false, why: e.message }));
|
|
115
|
+
if (made.ok) { await ctx.copyKv.delete(`${handle}/fill-failed-at`).catch(() => {}); return { ok: true }; }
|
|
116
|
+
await noteFailure(ctx.copyKv, handle, made.why);
|
|
117
|
+
log(`copy @${handle}: not made: ${made.why}`);
|
|
118
|
+
if (refusedSignIn(made.why)) {
|
|
119
|
+
await ctx.copyKv.set(LOGIN_REFUSED, JSON.stringify({ at: Date.now(), mark: ctx.keeperMark || '', why: made.why })).catch(() => {});
|
|
120
|
+
}
|
|
121
|
+
return { ok: false, status: /active on another device/.test(made.why) ? 409 : 502, why: made.why };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The end of the hold for one account now, as the round does it: the copy
|
|
126
|
+
* written to the pod and deleted. Returns { ok } or { ok: false, why }.
|
|
127
|
+
*/
|
|
128
|
+
export async function endHold(ctx, handle, rec, log = console.log, { waitMs = 8_000 } = {}) {
|
|
129
|
+
if (!await copyMeta(ctx.copyKv, handle)) return { ok: true };
|
|
130
|
+
const podFetch = await ctx.keeperFetch();
|
|
131
|
+
if (!podFetch) return { ok: false, why: 'this gateway cannot reach the pod now' };
|
|
132
|
+
const unlock = await lockCopy(ctx.copyKv, handle, { waitMs });
|
|
133
|
+
if (!unlock) return { ok: false, why: 'the gateway is busy with this account; try again' };
|
|
95
134
|
try {
|
|
96
|
-
if (await copyMeta(ctx.copyKv, handle)) return { ok: true };
|
|
97
|
-
// Marked failed before it starts and cleared when it is done, so an attempt
|
|
98
|
-
// cut off part way (a request's time running out) counts as failed too.
|
|
99
|
-
await noteFailure(ctx.copyKv, handle);
|
|
100
135
|
const stateUrl = stateUrlOf(rec);
|
|
101
|
-
|
|
102
|
-
.catch((e) => ({ ok: false, why: e.message }));
|
|
103
|
-
if (made.ok) { await ctx.copyKv.delete(`${handle}/fill-failed-at`).catch(() => {}); return { ok: true }; }
|
|
104
|
-
await noteFailure(ctx.copyKv, handle, made.why);
|
|
105
|
-
log(`copy @${handle}: not made: ${made.why}`);
|
|
106
|
-
if (refusedSignIn(made.why)) {
|
|
107
|
-
await ctx.copyKv.set(LOGIN_REFUSED, JSON.stringify({ at: Date.now(), mark: ctx.keeperMark || '', why: made.why })).catch(() => {});
|
|
108
|
-
}
|
|
109
|
-
return { ok: false, status: /active on another device/.test(made.why) ? 409 : 502, why: made.why };
|
|
136
|
+
return await holdOver(ctx.copyKv, handle, { pod: new HttpStorage(stateUrl, podFetch), podFetch, stateUrl, log });
|
|
110
137
|
} finally { await unlock(); }
|
|
111
138
|
}
|
|
112
139
|
|
|
140
|
+
// Whether the copy is made again when it is asked for: the hold on, and the
|
|
141
|
+
// account still kept here under this identity.
|
|
142
|
+
const remakes = (ctx, rec) => holdOn(ctx) && keptNow(ctx, rec) && !keptBefore(ctx, rec) && !rec.movedTo && !rec.closedAt;
|
|
143
|
+
|
|
113
144
|
/** Write the copy to the pod and give it up. Returns { ok } or { ok: false, why }. */
|
|
114
145
|
export async function leaveCopy(ctx, handle, rec, log = console.log) {
|
|
115
146
|
if (!await copyMeta(ctx.copyKv, handle)) return { ok: true };
|
|
@@ -155,6 +186,13 @@ export async function routeStateApi(request, pathname, ctx, deps) {
|
|
|
155
186
|
// owner's rules name it.
|
|
156
187
|
const before = keptBefore(ctx, rec);
|
|
157
188
|
if (before && !await copyMeta(ctx.copyKv, handle)) return j(409, { error: 'kept under the gateway\'s former identity', moving: true });
|
|
189
|
+
// The hold off: no copy. One left from before goes to the pod first, so
|
|
190
|
+
// the browser works on a pod that has everything.
|
|
191
|
+
if (!before && !holdOn(ctx)) {
|
|
192
|
+
const ended = await endHold(ctx, handle, rec).catch((e) => ({ ok: false, why: e.message }));
|
|
193
|
+
if (!ended.ok) return j(503, { error: `the account's copy could not be written to the pod: ${ended.why}` });
|
|
194
|
+
return j(501, { error: 'this gateway keeps no copies of accounts', hold: false });
|
|
195
|
+
}
|
|
158
196
|
const made = before ? { ok: true } : await ensureCopy(ctx, handle, rec, console.log, { owner: true });
|
|
159
197
|
if (!made.ok) return j(made.status, { error: made.why, busy: made.status === 409 });
|
|
160
198
|
const origin = new URL(request.url).origin;
|
|
@@ -177,15 +215,28 @@ export async function routeStateApi(request, pathname, ctx, deps) {
|
|
|
177
215
|
// by somebody else is not theirs any more.
|
|
178
216
|
const owned = await ctx.lookup(handle);
|
|
179
217
|
if (!owned || owned.webId !== who.webId) return j(401, { error: 'this token is not for this account' });
|
|
180
|
-
if (!await copyMeta(ctx.copyKv, handle)) return j(404, { error: 'the gateway keeps no copy of this account now', gone: true });
|
|
181
218
|
const kv = ctx.copyKv;
|
|
182
219
|
const noStore = { 'cache-control': 'no-store' };
|
|
220
|
+
const gone = () => j(404, { error: 'the gateway keeps no copy of this account now', gone: true });
|
|
183
221
|
|
|
222
|
+
// No copy, or none now, is nothing to give up.
|
|
184
223
|
if (name === 'leave') {
|
|
185
224
|
if (request.method !== 'POST') return j(405, { error: 'POST' });
|
|
186
225
|
const left = await leaveCopy(ctx, handle, owned).catch((e) => ({ ok: false, why: e.message }));
|
|
187
226
|
return left.ok ? j(200, { ok: true }) : j(502, { error: `the copy could not be written to the pod: ${left.why}` });
|
|
188
227
|
}
|
|
228
|
+
// The copy's lease outlives each copy the hold makes.
|
|
229
|
+
if (name === 'lease.json' && !await copyMeta(kv, handle) && !remakes(ctx, owned)) return gone();
|
|
230
|
+
// Being written to the pod and deleted: asked again in a moment, it is made anew.
|
|
231
|
+
const comeAgain = () => j(503, { error: 'the account\'s copy is being written to the pod; try again in a moment' });
|
|
232
|
+
if (name !== 'lease.json') {
|
|
233
|
+
if (await closing(kv, handle)) return comeAgain();
|
|
234
|
+
if (!await copyMeta(kv, handle)) {
|
|
235
|
+
if (!remakes(ctx, owned)) return gone();
|
|
236
|
+
const made = await ensureCopy(ctx, handle, owned, console.log, { owner: true });
|
|
237
|
+
if (!made.ok) return j(made.status === 409 ? 503 : made.status, { error: made.why });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
189
240
|
// The owner's browser has written the copy to the pod itself (a copy kept
|
|
190
241
|
// under the gateway's former identity, which cannot write it back now).
|
|
191
242
|
// Only the lease's holder may say so, and only for such a copy.
|
|
@@ -229,11 +280,15 @@ export async function routeStateApi(request, pathname, ctx, deps) {
|
|
|
229
280
|
const w = await copy.write(name, await request.text());
|
|
230
281
|
if (w.lost) return j(409, { error: w.why });
|
|
231
282
|
if (!w.ok) return j(503, { error: w.why });
|
|
283
|
+
// Landed as the round began to write the copy to the pod: perhaps after
|
|
284
|
+
// it read this document. Sent again, it lands in the copy made next.
|
|
285
|
+
if (await closing(kv, handle)) return comeAgain();
|
|
232
286
|
return { status: 204, headers: { ...noStore, etag: w.etag }, body: null };
|
|
233
287
|
}
|
|
234
288
|
if (request.method === 'DELETE') {
|
|
235
289
|
if (!holder) return j(400, { error: 'x-fedipod-holder is required' });
|
|
236
|
-
|
|
290
|
+
if (!await copy.remove(name)) return j(409, { error: 'another agent holds this account now' });
|
|
291
|
+
return (await closing(kv, handle)) ? comeAgain() : { status: 204, headers: noStore, body: null };
|
|
237
292
|
}
|
|
238
293
|
return j(405, { error: 'GET, PUT or DELETE' });
|
|
239
294
|
}
|
package/lib/pod/notes.mjs
CHANGED
|
@@ -44,6 +44,31 @@ export const writeReplies = (pod, repliesId, doc) => pod.putJson(repliesId, doc)
|
|
|
44
44
|
/** One published note, as the pod currently serves it. */
|
|
45
45
|
export const read = (pod, noteId) => pod.getJson(noteId);
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* A note as the pod serves it, with the version the pod gave it.
|
|
49
|
+
*
|
|
50
|
+
* For a write that must not undo somebody else's: an edit or a deletion that
|
|
51
|
+
* lands between this read and the write below wins, and the writer tries
|
|
52
|
+
* again later. A pod that names no version gets an unconditional write.
|
|
53
|
+
*/
|
|
54
|
+
export async function readVersioned(pod, noteId) {
|
|
55
|
+
if (typeof pod.fetch !== 'function') return { doc: await pod.getJson(noteId), version: null };
|
|
56
|
+
const res = await pod.fetch(noteId, { headers: { accept: 'application/activity+json' } });
|
|
57
|
+
if (res.status === 404 || res.status === 410) return { doc: null, version: null };
|
|
58
|
+
if (res.status >= 400) throw new Error(`GET ${noteId} → ${res.status}`);
|
|
59
|
+
return { doc: await res.json().catch(() => null), version: res.headers?.get?.('etag') || null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Write the note only over the version read. False when it changed meanwhile. */
|
|
63
|
+
export async function writeIfUnchanged(pod, noteId, doc, version) {
|
|
64
|
+
if (!version || typeof pod.fetch !== 'function') { await pod.putJson(noteId, doc); return true; }
|
|
65
|
+
const res = await pod.fetch(noteId, { method: 'PUT',
|
|
66
|
+
headers: { 'content-type': 'application/activity+json', 'if-match': version }, body: JSON.stringify(doc) });
|
|
67
|
+
if (res.status === 412) return false;
|
|
68
|
+
if (res.status >= 400) throw new Error(`PUT ${noteId} → ${res.status}`);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
47
72
|
/**
|
|
48
73
|
* Replace a note with a Tombstone, and keep it readable.
|
|
49
74
|
*
|
package/lib/server/embed.mjs
CHANGED
|
@@ -396,7 +396,7 @@ export async function startEmbeddedAgent({
|
|
|
396
396
|
basePath: uiPath,
|
|
397
397
|
mount,
|
|
398
398
|
publicOrigin: base,
|
|
399
|
-
scheme: new URL(base).protocol,
|
|
399
|
+
scheme: new URL(base).protocol.slice(0, -1),
|
|
400
400
|
});
|
|
401
401
|
|
|
402
402
|
// A delivery landing in the inbox — through the door, or from any other
|