fedipod 1.44.0 → 1.47.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 +8 -13
- package/architecture.md +5 -2
- package/gateway.md +56 -24
- package/gui.md +3 -2
- package/lib/client/c2s.mjs +23 -74
- 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 +12 -1
- package/lib/core/intake/received.mjs +78 -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 +18 -9
- package/lib/gateway/gateway-core.mjs +46 -14
- package/lib/gateway/held-mail.mjs +11 -4
- package/lib/gateway/httpsig.mjs +6 -1
- package/lib/gateway/keeper.mjs +9 -6
- package/lib/gateway/masto-gateway.mjs +140 -40
- package/lib/gateway/private-read.mjs +98 -0
- package/lib/gateway/quiet.mjs +4 -3
- package/lib/gateway/state-api.mjs +73 -18
- package/lib/pod/notes.mjs +25 -0
- package/lib/pod/urls.mjs +3 -0
- package/lib/server/embed.mjs +25 -0
- package/lib/surface/routes/owner.mjs +2 -2
- package/package.json +2 -2
- 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 +12 -3
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +582 -282
- package/web/app/dist/sw.js.map +4 -4
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// app is closed nobody reads it, so the door keeps it here instead
|
|
7
7
|
// (ctx.holdMail), and writes it to the pod inbox as one document when the app
|
|
8
8
|
// says it is open (POST /api/here) or when the timer comes round (flushAll).
|
|
9
|
-
// While the app is open, mail goes straight to the pod as it always has.
|
|
9
|
+
// While the app is open, mail goes straight to the pod as it always has. With
|
|
10
|
+
// the hold off (copy.mjs: FEDIPOD_HOLD), nothing is held: mail goes straight
|
|
11
|
+
// to the pod whether the app is open or not.
|
|
10
12
|
//
|
|
11
13
|
// A batch is plain JSON, `{ "batch": [ { name, body, receipt } ] }`: `name` is
|
|
12
14
|
// what the item would have been called in the inbox, `body` its bytes, and
|
|
@@ -23,7 +25,7 @@
|
|
|
23
25
|
// presentAt(handle) -> ms when it last said so
|
|
24
26
|
// noteNext(handle, at, {earliest}) when the account's keeper next has work
|
|
25
27
|
|
|
26
|
-
import { copyMeta, keptNow } from './copy.mjs';
|
|
28
|
+
import { copyMeta, keptNow, holdOn } from './copy.mjs';
|
|
27
29
|
|
|
28
30
|
// The app says it is open every five minutes; a little over that counts.
|
|
29
31
|
export const PRESENT_MS = 8 * 60_000;
|
|
@@ -37,7 +39,11 @@ const inboxOf = (rec) => rec.inboxUrl || rec.podHome + 'ap/inbox/';
|
|
|
37
39
|
// Only a browser account is held: its owner signs in (so the row has
|
|
38
40
|
// `openedAt`), and its app says when it is open. An account run by a DeviceAgent
|
|
39
41
|
// drains whenever mail lands, and keeps its mail as it always has.
|
|
40
|
-
export const holdsMail = (ctx, rec) => !!(ctx.holdMail && rec?.openedAt);
|
|
42
|
+
export const holdsMail = (ctx, rec) => !!(ctx.holdMail && rec?.openedAt && holdOn(ctx));
|
|
43
|
+
// A browser account whose app may be closed: its mail is held, or with the
|
|
44
|
+
// hold off, sent to the pod, and either way a follow or a notification starts
|
|
45
|
+
// the work that answers it (afterHeld).
|
|
46
|
+
export const mayBeAway = (ctx, rec) => !!(ctx.holdMail && rec?.openedAt);
|
|
41
47
|
|
|
42
48
|
// Asked on every delivery, so it is remembered a minute per running copy.
|
|
43
49
|
const seen = new Map(); // handle -> { at, asked }
|
|
@@ -204,5 +210,6 @@ export async function routeHereApi(request, pathname, ctx, deps) {
|
|
|
204
210
|
if (body.nextAt !== undefined && ctx.noteNext) await ctx.noteNext(handle, body.nextAt || null).catch(() => {});
|
|
205
211
|
const flushed = await flushHeld(ctx, handle, rec)
|
|
206
212
|
.catch((e) => { console.log(`here @${handle}: held mail not delivered: ${e?.message || e}`); return 0; });
|
|
207
|
-
|
|
213
|
+
// `hold`: with it off, a browser working from a copy goes back to the pod.
|
|
214
|
+
return j(200, { ok: true, handle, flushed, hold: holdOn(ctx) });
|
|
208
215
|
}
|
package/lib/gateway/httpsig.mjs
CHANGED
|
@@ -49,14 +49,16 @@ export function makeSafeLoader({ getActors = null, fetchImpl = fetch } = {}) {
|
|
|
49
49
|
export async function verifyHttpSignature(request, { documentLoader, keyCache, timeWindow } = {}) {
|
|
50
50
|
const hadSig = request.headers.get('signature') != null;
|
|
51
51
|
let result = null;
|
|
52
|
+
let threw = null;
|
|
52
53
|
try {
|
|
53
54
|
result = await verifyRequestDetailed(request, {
|
|
54
55
|
documentLoader,
|
|
55
56
|
...(keyCache ? { keyCache } : {}),
|
|
56
57
|
timeWindow: timeWindow ?? { hours: 1 },
|
|
57
58
|
});
|
|
58
|
-
} catch {
|
|
59
|
+
} catch (e) {
|
|
59
60
|
result = null; // the library itself failed — no more informative than a bad signature
|
|
61
|
+
threw = e?.message || String(e);
|
|
60
62
|
}
|
|
61
63
|
// Which signature the sender used: RFC 9421 carries a Signature-Input
|
|
62
64
|
// header beside Signature; the cavage draft carries Signature alone. The
|
|
@@ -83,9 +85,12 @@ export async function verifyHttpSignature(request, { documentLoader, keyCache, t
|
|
|
83
85
|
reason: 'key-unfetchable', checks: { signature: false, keyFetched: false },
|
|
84
86
|
};
|
|
85
87
|
}
|
|
88
|
+
// `why` is for the log line, not the receipt: the library's own word for
|
|
89
|
+
// the failure, or that it threw.
|
|
86
90
|
return {
|
|
87
91
|
verified: false, method, keyId: null, actor: null,
|
|
88
92
|
reason: 'bad-signature', checks: { signature: false },
|
|
93
|
+
why: threw ? `library threw: ${threw.split('\n')[0].slice(0, 120)}` : (kind || 'unknown'),
|
|
89
94
|
};
|
|
90
95
|
}
|
|
91
96
|
|
package/lib/gateway/keeper.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { keeperSession } from './keeper-session.mjs';
|
|
|
18
18
|
import { flushHeld, heldEntries } from './held-mail.mjs';
|
|
19
19
|
import { publishDue, nextDue } from '../core/scheduled.mjs';
|
|
20
20
|
import { lockCopy } from './copy.mjs';
|
|
21
|
-
import { reachAccount, stateAndLease, actingAgent } from './account-agent.mjs';
|
|
21
|
+
import { reachAccount, settle, stateAndLease, actingAgent } from './account-agent.mjs';
|
|
22
22
|
|
|
23
23
|
export { keeperSession };
|
|
24
24
|
|
|
@@ -27,10 +27,11 @@ export { keeperSession };
|
|
|
27
27
|
* keeperCredential and the held-mail stores. `session` may be handed in (a
|
|
28
28
|
* test does); otherwise the keeper's grant is used. Returns what happened.
|
|
29
29
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
30
|
+
* While the hold is on, the account is worked on in its copy (copy.mjs), made
|
|
31
|
+
* from the pod if there is none: its held mail is read straight into the
|
|
32
|
+
* copy, and the pod is written by the round, not here. With the hold off, it
|
|
33
|
+
* is worked on in the pod, and any held mail goes to the pod inbox first, so
|
|
34
|
+
* the drain reads it.
|
|
34
35
|
*/
|
|
35
36
|
export async function keepOnce(ctx, handle, rec, { log = console.log, session = null } = {}) {
|
|
36
37
|
// A forum, or one of its categories, is run whole by the forum's own code
|
|
@@ -40,9 +41,11 @@ export async function keepOnce(ctx, handle, rec, { log = console.log, session =
|
|
|
40
41
|
const at = await reachAccount(ctx, handle, rec, { log, session });
|
|
41
42
|
if (at.skipped) return { skipped: at.skipped };
|
|
42
43
|
|
|
43
|
-
const unlock =
|
|
44
|
+
const unlock = ctx.copyKv ? await lockCopy(ctx.copyKv, handle) : async () => {};
|
|
44
45
|
if (!unlock) return { skipped: 'the gateway is busy with this account', retry: 'soon' };
|
|
45
46
|
try {
|
|
47
|
+
const settled = await settle(ctx, handle, rec, at, { log });
|
|
48
|
+
if (!settled.ok) return { skipped: `the account's copy could not be made: ${settled.why}`, retry: 'soon' };
|
|
46
49
|
// Held mail goes in first, so this drain reads it.
|
|
47
50
|
const flushed = !at.inCopy && ctx.listHeld
|
|
48
51
|
? await flushHeld(ctx, handle, rec).catch((e) => { say(`held mail not delivered: ${e.message}`); return 0; }) : 0;
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// private-read.mjs — a followers-only or direct post, read at its address by
|
|
2
|
+
// a server it was sent to (ActivityPub §3.2). The read is signed the way a
|
|
3
|
+
// delivery is; the signer is resolved; and the post is served only to an
|
|
4
|
+
// actor in its audience: a follower for a post sent to followers, a named
|
|
5
|
+
// recipient for a direct one. That is the rule Mastodon applies to its own
|
|
6
|
+
// posts (StatusPolicy#show?). Anyone else learns nothing — 404, as for a
|
|
7
|
+
// document that is not there, which §3.2 allows for a private target.
|
|
8
|
+
//
|
|
9
|
+
// At fedipod.net this works for an account it keeps: the post is read from
|
|
10
|
+
// the pod with the keeper's credential, and the followers list from the
|
|
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,
|
|
14
|
+
// so that read is sent on to the pod, as every private read was.
|
|
15
|
+
|
|
16
|
+
import { verifyHttpSignature, makeSafeLoader } from './httpsig.mjs';
|
|
17
|
+
import { asSigned } from './gateway-core.mjs';
|
|
18
|
+
import { senderKeys } from './caches.mjs';
|
|
19
|
+
import { keptNow } from './copy.mjs';
|
|
20
|
+
|
|
21
|
+
const AP_CT = 'application/activity+json';
|
|
22
|
+
const PUBLIC_NAMES = new Set(['https://www.w3.org/ns/activitystreams#Public', 'as:Public', 'Public']);
|
|
23
|
+
const list = (v) => (v == null ? [] : Array.isArray(v) ? v : [v])
|
|
24
|
+
.map((x) => (typeof x === 'string' ? x : x?.id)).filter((x) => typeof x === 'string');
|
|
25
|
+
|
|
26
|
+
/** Who a document was sent to: the actors named, and whether followers were. */
|
|
27
|
+
export function audienceOf(doc, followersUrls = []) {
|
|
28
|
+
const o = doc?.type === 'Create' && doc.object && typeof doc.object === 'object' ? doc.object : doc;
|
|
29
|
+
const named = new Set(['to', 'cc', 'audience'].flatMap((k) => [...list(doc?.[k]), ...list(o?.[k])]));
|
|
30
|
+
return {
|
|
31
|
+
named,
|
|
32
|
+
isPublic: [...named].some((a) => PUBLIC_NAMES.has(a)),
|
|
33
|
+
toFollowers: followersUrls.some((f) => named.has(f)),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Whether `actor` may read `doc`: named in its audience, or a follower when it went to followers. */
|
|
38
|
+
export function mayRead(doc, actor, { followersUrls = [], followers = [] } = {}) {
|
|
39
|
+
if (typeof actor !== 'string' || !actor) return false;
|
|
40
|
+
const a = audienceOf(doc, followersUrls);
|
|
41
|
+
if (a.isPublic || a.named.has(actor)) return true;
|
|
42
|
+
return a.toFollowers && followers.includes(actor);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The actor behind a signed read, or null with why. `origin` is the door's
|
|
47
|
+
* own (see asSigned in gateway-core.mjs); a GET has no body to digest.
|
|
48
|
+
*/
|
|
49
|
+
export async function signedReader(request, { fetchImpl = fetch, origin = null } = {}) {
|
|
50
|
+
if (!request.headers.get('signature')) return { actor: null, reason: 'no-signature' };
|
|
51
|
+
const v = await verifyHttpSignature(asSigned(request, new Uint8Array(0), origin), {
|
|
52
|
+
documentLoader: makeSafeLoader({ fetchImpl }), keyCache: senderKeys,
|
|
53
|
+
});
|
|
54
|
+
return v.verified && typeof v.actor === 'string' ? { actor: v.actor, reason: null } : { actor: null, reason: v.reason };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Refused reads are told nothing, and never held at the edge.
|
|
58
|
+
const nothing = (open) => ({ status: 404, headers: { ...open, 'cache-control': 'no-store', vary: 'Signature' }, body: '' });
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A read under `ap/private/` at the front. Unsigned: sent on to the pod, which
|
|
62
|
+
* decides (the owner reads there with their own credential). Signed: answered
|
|
63
|
+
* here for a kept account, to a reader the post was sent to.
|
|
64
|
+
*/
|
|
65
|
+
export async function answerPrivateRead(ctx, rec, { request, handle, podTarget, base, open, swap }) {
|
|
66
|
+
if (!request.headers.get('signature')) {
|
|
67
|
+
return { status: 303, headers: { ...open, location: podTarget, 'cache-control': 'no-store' }, body: '' };
|
|
68
|
+
}
|
|
69
|
+
const say = (what) => console.log(`private read @${handle}: ${what}`);
|
|
70
|
+
if (!keptNow(ctx, rec) || typeof ctx.keeperFetch !== 'function' || !ctx.copyKv) { say('refused — account not kept here'); return nothing(open); }
|
|
71
|
+
const { actor, reason } = await signedReader(request, { fetchImpl: ctx.fetchImpl || fetch, origin: ctx.frontOrigin || null });
|
|
72
|
+
if (!actor) { say(`refused — ${reason}`); return nothing(open); }
|
|
73
|
+
const podFetch = await ctx.keeperFetch();
|
|
74
|
+
if (!podFetch) { say('refused — no keeper credential'); return nothing(open); }
|
|
75
|
+
let text = null;
|
|
76
|
+
try {
|
|
77
|
+
const res = await podFetch(podTarget, { headers: { accept: AP_CT } });
|
|
78
|
+
if (res.status === 200) text = await res.text();
|
|
79
|
+
} catch { /* unreachable: nothing to serve */ }
|
|
80
|
+
let doc = null;
|
|
81
|
+
try { doc = text ? JSON.parse(text) : null; } catch { doc = null; }
|
|
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.
|
|
84
|
+
let followers = [];
|
|
85
|
+
try {
|
|
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);
|
|
92
|
+
} catch { followers = []; }
|
|
93
|
+
const followersUrls = [rec.podHome + 'ap/followers', base + 'ap/followers'];
|
|
94
|
+
if (!mayRead(doc, actor, { followersUrls, followers })) { say(`refused — ${actor} is not in its audience`); return nothing(open); }
|
|
95
|
+
say(`served to ${actor}`);
|
|
96
|
+
return { status: 200, headers: { ...open, 'content-type': AP_CT, 'cache-control': 'no-store', vary: 'Signature' },
|
|
97
|
+
body: request.method === 'HEAD' ? '' : swap(text, rec.podHome, base) };
|
|
98
|
+
}
|
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.
|