fedipod 1.36.6 → 1.40.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 +29 -8
- package/architecture.md +8 -0
- package/bin/fedipod.mjs +6 -0
- package/cli.md +7 -1
- package/device-agent.md +3 -3
- package/gateway.md +69 -8
- package/groups.md +2 -1
- package/gui.md +3 -1
- package/lib/client/c2s.mjs +97 -43
- package/lib/client/masto/accounts.mjs +1 -0
- package/lib/client/masto/bridge.mjs +61 -0
- package/lib/client/masto/index.mjs +4 -1
- package/lib/client/masto/oauth.mjs +1 -1
- package/lib/client/masto/statuses.mjs +3 -0
- package/lib/connections/acctfeed.mjs +13 -9
- package/lib/connections/bskyfeed.mjs +13 -9
- package/lib/connections/tagfeed.mjs +14 -9
- package/lib/core/deliver.mjs +17 -1
- package/lib/core/intake/activities.mjs +18 -2
- package/lib/core/intake/group.mjs +3 -1
- package/lib/core/intake/index.mjs +226 -48
- package/lib/core/intake/notes.mjs +12 -5
- package/lib/core/intake/verify.mjs +11 -0
- package/lib/core/lease.mjs +15 -1
- package/lib/core/place.mjs +82 -0
- package/lib/core/pod-only.mjs +4 -0
- package/lib/core/publisher/collections.mjs +37 -2
- package/lib/core/publisher/index.mjs +25 -1
- package/lib/core/publisher/notes.mjs +73 -7
- package/lib/core/publisher/own.mjs +143 -0
- package/lib/core/publisher/questions.mjs +5 -3
- package/lib/core/scheduled.mjs +39 -0
- package/lib/core/social.mjs +62 -32
- package/lib/core/storage.mjs +67 -0
- package/lib/core/store.mjs +77 -5
- package/lib/core/wire.mjs +51 -15
- package/lib/device/admin/routes/lifecycle.mjs +1 -1
- package/lib/device/admin/routes/setup.mjs +17 -1
- package/lib/device/cli/commands/setup.mjs +50 -8
- package/lib/device/cli/context.mjs +1 -1
- package/lib/device/migrate.mjs +1 -1
- package/lib/device/setup.mjs +45 -8
- package/lib/gateway/account-agent.mjs +111 -0
- package/lib/gateway/copy.mjs +381 -0
- package/lib/gateway/front-core.mjs +74 -63
- package/lib/gateway/gateway-core.mjs +107 -9
- package/lib/gateway/held-mail.mjs +198 -0
- package/lib/gateway/keeper-due.mjs +39 -0
- package/lib/gateway/keeper-session.mjs +10 -0
- package/lib/gateway/keeper.mjs +72 -0
- package/lib/gateway/masto-gateway.mjs +510 -0
- package/lib/gateway/quiet.mjs +7 -2
- package/lib/gateway/relay-extras.mjs +89 -0
- package/lib/gateway/state-api.mjs +207 -0
- package/lib/gateway/token-claims.mjs +16 -0
- package/lib/pod/containers.mjs +17 -0
- package/lib/pod/location.mjs +52 -0
- package/lib/pod/notes.mjs +2 -4
- package/lib/pod/transport.mjs +214 -24
- package/lib/pod/type-index.mjs +101 -0
- package/lib/pod/urls.mjs +6 -0
- package/lib/server/embed.mjs +7 -6
- package/lib/session/README.md +5 -5
- package/lib/session/demo.html +1 -1
- package/lib/session/fedi-account.mjs +19 -10
- package/lib/session/package.json +2 -2
- package/package.json +2 -2
- package/run-agent.mjs +6 -16
- package/scripts/stage-site.mjs +17 -4
- package/web/admin/actors.js +2 -0
- package/web/admin/gateway.js +14 -1
- package/web/admin/index.html +13 -0
- package/web/admin/oauth-signin.mjs +1 -1
- package/web/admin/record.js +4 -1
- package/web/admin/setup/index.html +17 -1
- package/web/admin/setup/setup.js +18 -5
- package/web/app/README.md +2 -2
- package/web/app/admin-facade.mjs +12 -2
- package/web/app/agent.mjs +228 -51
- package/web/app/boot.mjs +75 -32
- package/web/app/copy-mode.mjs +223 -0
- package/web/app/dist/boot.js +498 -88
- package/web/app/dist/boot.js.map +4 -4
- package/web/app/dist/sw.js +4016 -2539
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/index.html +16 -0
- package/web/app/signup.mjs +65 -26
- package/web/app/sw-src.mjs +15 -53
- package/web/app/update.js +3 -2
- package/web/app/warm-start.mjs +115 -0
- package/web/app-signin/app-signin.mjs +78 -0
- package/web/app-signin/index.html +41 -0
- package/web/front/run.html +7 -1
- package/web/front/run.js +30 -4
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
// masto-gateway.mjs — Mastodon apps at the gateway's own address: elk.zone,
|
|
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).
|
|
4
|
+
//
|
|
5
|
+
// POST /api/v1/apps an app registers here, for every account
|
|
6
|
+
// GET /oauth/authorize sends the person to the sign-in page
|
|
7
|
+
// (/app-signin/, web/app-signin/), which
|
|
8
|
+
// signs them in at their own pod
|
|
9
|
+
// GET /api/authorize?client_id=… what the page shows: the app's name
|
|
10
|
+
// GET /api/authorize?address=… the pod an address here belongs to
|
|
11
|
+
// POST /api/authorize the pod sign-in, proved: a code for the app
|
|
12
|
+
// POST /oauth/token, /oauth/revoke the code for a token, and back
|
|
13
|
+
// GET /api/v1/instance, /api/v2/instance fedipod.net itself
|
|
14
|
+
// anything else under /api/v1, /api/v2 with a token: the account's own
|
|
15
|
+
// Mastodon facade (lib/client/masto), over its copy
|
|
16
|
+
//
|
|
17
|
+
// Apps, codes and tokens are kept in `ctx.mastoKv` (the same kind of store as
|
|
18
|
+
// the copy); a token is kept only as its hash. Reading is answered from the
|
|
19
|
+
// copy without the lease. Acting takes the lease — an app acting outranks a
|
|
20
|
+
// browser sitting open, as one device does another — and reads the signing
|
|
21
|
+
// key from the pod for that one request. Mail that arrived while nobody was
|
|
22
|
+
// reading is read into the copy after an app's timeline or notification check
|
|
23
|
+
// has been answered, at most every thirty seconds.
|
|
24
|
+
//
|
|
25
|
+
// Phone notifications: an app signs up for them through the account's own
|
|
26
|
+
// facade (/api/v1/push/subscription), with the one push key pair this gateway
|
|
27
|
+
// keeps for all its accounts, made the first time it is needed. When the door
|
|
28
|
+
// holds a delivery that becomes a notification (gateway-core: notifies), it
|
|
29
|
+
// starts a short run (push-background.mjs, pushHeld below) that reads the held
|
|
30
|
+
// mail into the copy and pushes each notification it makes.
|
|
31
|
+
import crypto from 'node:crypto';
|
|
32
|
+
import { MastoApi } from '../client/masto/index.mjs';
|
|
33
|
+
import { instanceConfig } from '../client/masto/instance.mjs';
|
|
34
|
+
import { TRANSPARENT_PNG } from '../client/masto/render.mjs';
|
|
35
|
+
import { bridge } from '../client/masto/bridge.mjs';
|
|
36
|
+
import { PodStore } from '../core/store.mjs';
|
|
37
|
+
import { apUrls } from '../core/wire.mjs';
|
|
38
|
+
import { Publisher } from '../core/publisher/index.mjs';
|
|
39
|
+
import { nextDue } from '../core/scheduled.mjs';
|
|
40
|
+
import { reachAccount, stateAndLease, actingAgent } from './account-agent.mjs';
|
|
41
|
+
import { ensureCopy } from './state-api.mjs';
|
|
42
|
+
import { CopyStorage, lockCopy, keptNow, keptBefore, GATEWAY_HOLDER } from './copy.mjs';
|
|
43
|
+
import { heldEntries } from './held-mail.mjs';
|
|
44
|
+
import { Push } from '../client/webpush.mjs';
|
|
45
|
+
import webpush from 'web-push';
|
|
46
|
+
|
|
47
|
+
const TOKEN_TTL_MS = 90 * 86400_000;
|
|
48
|
+
const CODE_TTL_MS = 10 * 60_000;
|
|
49
|
+
const MAIL_EVERY_MS = 30_000;
|
|
50
|
+
const SIGNIN_PAGE = '/app-signin/';
|
|
51
|
+
const API_VERSIONS = { mastodon: 7 };
|
|
52
|
+
|
|
53
|
+
const sha = (s) => crypto.createHash('sha256').update(String(s)).digest('base64url');
|
|
54
|
+
// A client id as this gateway makes them. Anything else is not looked up: an
|
|
55
|
+
// id is part of a storage key.
|
|
56
|
+
const CLIENT_ID = /^[A-Za-z0-9_-]{16,64}$/u;
|
|
57
|
+
const appOf = (kv, id) => (CLIENT_ID.test(String(id || '')) ? getJson(kv, `app/${id}`) : null);
|
|
58
|
+
const rand = (n) => crypto.randomBytes(n).toString('base64url');
|
|
59
|
+
const json = (status, obj, headers = {}) => ({
|
|
60
|
+
status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers }, body: JSON.stringify(obj),
|
|
61
|
+
});
|
|
62
|
+
// Mastodon apps call from their own pages: anyone may ask, the token is the credential.
|
|
63
|
+
const CORS = {
|
|
64
|
+
'access-control-allow-origin': '*',
|
|
65
|
+
'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
|
66
|
+
'access-control-allow-headers': 'Authorization, Content-Type, Idempotency-Key',
|
|
67
|
+
'access-control-expose-headers': 'Link',
|
|
68
|
+
'access-control-max-age': '86400',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
async function getJson(kv, key) {
|
|
72
|
+
const got = await kv.get(key);
|
|
73
|
+
if (!got) return null;
|
|
74
|
+
try { return JSON.parse(got.text); } catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
const putJson = (kv, key, obj) => kv.set(key, JSON.stringify(obj));
|
|
77
|
+
|
|
78
|
+
// A body as a Mastodon app sends it: JSON, a form, or multipart fields.
|
|
79
|
+
async function formOf(request) {
|
|
80
|
+
const ct = String(request.headers.get('content-type') || '');
|
|
81
|
+
if (ct.includes('application/json')) return request.json().catch(() => ({}));
|
|
82
|
+
if (ct.includes('multipart/form-data')) {
|
|
83
|
+
const f = await request.formData().catch(() => null);
|
|
84
|
+
return f ? Object.fromEntries([...f].filter(([, v]) => typeof v === 'string')) : {};
|
|
85
|
+
}
|
|
86
|
+
return Object.fromEntries(new URLSearchParams(await request.text()));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const parseRedirects = (v) => (Array.isArray(v) ? v : String(v || '').split(/\s+/)).map((s) => s.trim()).filter(Boolean);
|
|
90
|
+
|
|
91
|
+
// The directory key an address at this gateway names: "you", "@you",
|
|
92
|
+
// "you@host" or "@you@host".
|
|
93
|
+
function handleOf(address, host) {
|
|
94
|
+
const a = String(address || '').trim().replace(/^@/u, '').toLowerCase();
|
|
95
|
+
if (!a) return null;
|
|
96
|
+
const [name, at] = a.split('@');
|
|
97
|
+
return !at || at === String(host || '').toLowerCase() ? name : a;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// An account an app may sign in to: a browser account here, kept running, not
|
|
101
|
+
// moved or closed.
|
|
102
|
+
const usable = (ctx, rec) => !!(rec && rec.openedAt && keptNow(ctx, rec) && !rec.movedTo && !rec.closedAt);
|
|
103
|
+
// Kept under the gateway's former identity: it works again once its owner has
|
|
104
|
+
// opened FediPod in a browser, which moves it to the new one.
|
|
105
|
+
const MOVING = 'this account is moving to fedipod.net\'s new identity: open FediPod in a browser once, then try again';
|
|
106
|
+
|
|
107
|
+
function instanceV1(host) {
|
|
108
|
+
return {
|
|
109
|
+
uri: host, title: host, short_description: 'FediPod: Fediverse accounts whose home is a Solid pod.',
|
|
110
|
+
description: 'FediPod: Fediverse accounts whose home is a Solid pod.', email: '',
|
|
111
|
+
version: '4.2.0 (compatible; fedipod)', api_versions: API_VERSIONS, urls: {},
|
|
112
|
+
stats: { user_count: 0, status_count: 0, domain_count: 0 },
|
|
113
|
+
languages: ['en'], registrations: false, approval_required: false, invites_enabled: false,
|
|
114
|
+
configuration: instanceConfig(), contact_account: null, rules: [],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function instanceV2(host, origin, vapidKey) {
|
|
118
|
+
return {
|
|
119
|
+
domain: host, title: host, version: '4.2.0 (compatible; fedipod)', api_versions: API_VERSIONS,
|
|
120
|
+
source_url: 'https://github.com/jeff-zucker/FediPod',
|
|
121
|
+
description: 'FediPod: Fediverse accounts whose home is a Solid pod.',
|
|
122
|
+
usage: { users: { active_month: 0 } }, thumbnail: { url: TRANSPARENT_PNG }, languages: ['en'],
|
|
123
|
+
urls: {}, configuration: { ...instanceConfig(), vapid: { public_key: vapidKey } },
|
|
124
|
+
// Accounts are made on the front page, with a pod, not through an app.
|
|
125
|
+
registrations: { enabled: false, approval_required: false, message: null, url: `${origin}/` },
|
|
126
|
+
contact: { email: '', account: null }, rules: [],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- phone notifications ----
|
|
131
|
+
|
|
132
|
+
// The gateway's one push key pair, for every account here: an app asks for the
|
|
133
|
+
// key before it knows which account it will sign in to.
|
|
134
|
+
export async function vapidKeys(kv) {
|
|
135
|
+
const had = await getJson(kv, 'vapid');
|
|
136
|
+
if (had?.publicKey && had.privateKey) return had;
|
|
137
|
+
await kv.set('vapid', JSON.stringify(webpush.generateVAPIDKeys()), { ifNew: true });
|
|
138
|
+
return getJson(kv, 'vapid');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
class GatewayPush extends Push {
|
|
142
|
+
constructor({ keys, ...o }) { super(o); this.keys = keys; }
|
|
143
|
+
vapid() { return this.keys; }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- the account behind a token ----
|
|
147
|
+
|
|
148
|
+
// Loaded copies, kept while this running copy lives: a check that finds the
|
|
149
|
+
// copy unchanged costs one listing, not every document.
|
|
150
|
+
const reading = new Map(); // handle -> PodStore
|
|
151
|
+
const noAuthorities = { has: () => false, isLocalRequest: () => false, isLocal: () => false, wsAuthorities: () => [] };
|
|
152
|
+
|
|
153
|
+
async function readingStore(ctx, handle, log) {
|
|
154
|
+
let store = reading.get(handle);
|
|
155
|
+
if (!store) {
|
|
156
|
+
store = new PodStore({ storage: new CopyStorage(ctx.copyKv, handle, { holder: GATEWAY_HOLDER }), log });
|
|
157
|
+
if (reading.size > 200) reading.clear();
|
|
158
|
+
reading.set(handle, store);
|
|
159
|
+
}
|
|
160
|
+
await store.load();
|
|
161
|
+
return store;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// What an app reading needs of an agent: the store, the account's addresses
|
|
165
|
+
// and a publisher that only answers for them. Anything that must act — a
|
|
166
|
+
// search that fetches a remote account, say — gets the acting agent then.
|
|
167
|
+
function readingAgent(at, store, acting, log) {
|
|
168
|
+
const config = store.getConfig();
|
|
169
|
+
if (!config) return null;
|
|
170
|
+
const publicBase = config?.gateway?.frontActor ? config.gateway.frontActor.replace(/ap\/actor\/?$/u, '') : null;
|
|
171
|
+
if (publicBase) at.remote.setUrlMap(apUrls(at.pod, config.root || at.root, { publicBase }).toPod);
|
|
172
|
+
const publisher = new Publisher({ config, remote: at.remote, store, deliverer: null, publicKeyPem: null, log });
|
|
173
|
+
return {
|
|
174
|
+
store, publisher, remote: at.remote, config, viewer: false, copy: null,
|
|
175
|
+
configured: () => !!store.getConfig(), requestTakeover: async () => true, onScheduled: () => {},
|
|
176
|
+
intake: { fetchAP: async (u) => (await acting()).intake.fetchAP(u) },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Who a push service is told sends the pushes: the gateway itself, as a
|
|
181
|
+
// contact address push services accept (https or mailto).
|
|
182
|
+
const pushContact = (ctx) => (/^https:\/\//u.test(ctx.frontOrigin || '') ? `${ctx.frontOrigin.replace(/\/$/u, '')}/` : `mailto:postmaster@${ctx.host || 'localhost'}`);
|
|
183
|
+
|
|
184
|
+
function facade(agent, token, log, keys = null, contact = null) {
|
|
185
|
+
const api = new MastoApi({ agent, log, scheme: 'https', streaming: false, webPush: !!keys, scheduling: true, allowed: noAuthorities });
|
|
186
|
+
api.tokenOf = () => token;
|
|
187
|
+
if (keys) api._push = new GatewayPush({ store: agent.store, keys, log, subject: () => contact });
|
|
188
|
+
return api;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// How many push subscriptions an account has, kept beside the tokens so the
|
|
192
|
+
// door can ask without reading the account.
|
|
193
|
+
async function notePushSubs(ctx, handle, store) {
|
|
194
|
+
const n = Object.keys(store.read('webpush.json', { subs: {} }).subs || {}).length;
|
|
195
|
+
await ctx.mastoKv.set(`push/${handle}`, String(n));
|
|
196
|
+
}
|
|
197
|
+
export async function pushWanted(ctx, handle) { return Number((await ctx.mastoKv?.get(`push/${handle}`))?.text || 0) > 0; }
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Read what was held for an account into its copy, and push each notification
|
|
201
|
+
* that makes to the owner's phones and browsers. `takeover`: act even when a
|
|
202
|
+
* browser that went quiet still holds the lease (a push run); otherwise leave
|
|
203
|
+
* the mail to whoever holds it (an app's check).
|
|
204
|
+
*/
|
|
205
|
+
async function readHeld(ctx, handle, rec, { log = console.log, takeover = false, waitMs = 1000 } = {}) {
|
|
206
|
+
if (!(await ctx.listHeld(handle)).length) return 0;
|
|
207
|
+
const unlock = await lockCopy(ctx.copyKv, handle, { waitMs });
|
|
208
|
+
if (!unlock) return 0;
|
|
209
|
+
try {
|
|
210
|
+
const at = await reachAccount(ctx, handle, rec, { log });
|
|
211
|
+
if (at.skipped || !at.inCopy) return 0;
|
|
212
|
+
const { store, lease } = stateAndLease(ctx, handle, at, { log });
|
|
213
|
+
if (!await lease.acquire() && !(takeover && await lease.takeover())) return 0;
|
|
214
|
+
await store.load();
|
|
215
|
+
const agent = await actingAgent(at, store, lease, { log });
|
|
216
|
+
if (agent.skipped) return 0;
|
|
217
|
+
const pushes = [];
|
|
218
|
+
if (await pushWanted(ctx, handle)) {
|
|
219
|
+
const api = facade(agent, null, log, await vapidKeys(ctx.mastoKv), pushContact(ctx));
|
|
220
|
+
store.onEvent = (type, n) => {
|
|
221
|
+
if (type !== 'notification') return;
|
|
222
|
+
// Named in the push by who they are: a sender only a note named is
|
|
223
|
+
// fetched first, as the app would when it showed the notification.
|
|
224
|
+
pushes.push((async () => {
|
|
225
|
+
if (n.actor && !store.getActors()[n.actor]) await agent.intake.fetchAP(n.actor).catch(() => null);
|
|
226
|
+
await api.pushNotify(n);
|
|
227
|
+
})().catch((e) => log(`push @${handle}: ${e.message}`)));
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const entries = await heldEntries(ctx, handle);
|
|
231
|
+
const done = new Set(await agent.intake.takeHeld(entries));
|
|
232
|
+
for (const e of entries) if (done.has(e.name)) for (const n of e.held) await ctx.dropHeld(handle, n);
|
|
233
|
+
await Promise.all(pushes);
|
|
234
|
+
await store.commit();
|
|
235
|
+
await lease.release().catch(() => {});
|
|
236
|
+
await ctx.noteNext?.(handle, nextDue(store)).catch(() => {});
|
|
237
|
+
reading.delete(handle);
|
|
238
|
+
if (done.size) log(`@${handle}: ${done.size} held deliveries read into the copy, ${pushes.length} pushed`);
|
|
239
|
+
return done.size;
|
|
240
|
+
} finally { await unlock(); }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** For an app checking in: at most every thirty seconds, never over a browser. */
|
|
244
|
+
export async function readHeldForApp(ctx, handle, rec, { log = console.log } = {}) {
|
|
245
|
+
if (!ctx.listHeld) return 0;
|
|
246
|
+
const key = `${handle}/mail-read-at`;
|
|
247
|
+
const last = Number((await ctx.copyKv.get(key))?.text || 0);
|
|
248
|
+
if (Date.now() - last < MAIL_EVERY_MS) return 0;
|
|
249
|
+
await ctx.copyKv.set(key, String(Date.now()));
|
|
250
|
+
return readHeld(ctx, handle, rec, { log });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* For the door, when a delivery that becomes a notification was held. The
|
|
255
|
+
* door starts one run at a time for an account and notes each arrival
|
|
256
|
+
* (`pushwant/`); the run goes round again while arrivals keep coming, so a
|
|
257
|
+
* burst of likes is a run or two, not one each.
|
|
258
|
+
*/
|
|
259
|
+
export async function pushHeld(ctx, handle, rec, { log = console.log } = {}) {
|
|
260
|
+
if (!ctx.listHeld || !await pushWanted(ctx, handle)) return 0;
|
|
261
|
+
const kv = ctx.mastoKv;
|
|
262
|
+
const wanted = async () => (await kv.get(`pushwant/${handle}`))?.text || null;
|
|
263
|
+
let n = 0;
|
|
264
|
+
let seen = null;
|
|
265
|
+
for (let round = 0; round < 6; round++) {
|
|
266
|
+
seen = await wanted();
|
|
267
|
+
n += await readHeld(ctx, handle, rec, { log, takeover: true, waitMs: 20_000 });
|
|
268
|
+
if ((await wanted()) !== seen) continue;
|
|
269
|
+
// Finished: the run is over. An arrival that came in just now saw this run
|
|
270
|
+
// still going and started none, so it is looked for once more after.
|
|
271
|
+
await kv.delete(`pushrun/${handle}`).catch(() => {});
|
|
272
|
+
if ((await wanted()) === seen) return n;
|
|
273
|
+
await kv.set(`pushrun/${handle}`, String(Date.now())).catch(() => {});
|
|
274
|
+
}
|
|
275
|
+
await kv.delete(`pushrun/${handle}`).catch(() => {});
|
|
276
|
+
return n;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// A request from an app, answered by the account's facade over its copy.
|
|
280
|
+
async function answerForAccount(request, url, ctx, tok, rec, log) {
|
|
281
|
+
const handle = tok.handle;
|
|
282
|
+
const at = await reachAccount(ctx, handle, rec, { log });
|
|
283
|
+
if (at.skipped) return json(503, { error: `this account cannot be reached now: ${at.skipped}` });
|
|
284
|
+
if (!at.inCopy) {
|
|
285
|
+
const made = await ensureCopy(ctx, handle, rec, log);
|
|
286
|
+
if (!made.ok) return json(made.status === 409 ? 503 : made.status, { error: made.why });
|
|
287
|
+
at.inCopy = true;
|
|
288
|
+
}
|
|
289
|
+
const token = { token: 'gateway', scope: tok.scope };
|
|
290
|
+
const writes = request.method !== 'GET' && request.method !== 'HEAD';
|
|
291
|
+
const b = await bridge(request, url);
|
|
292
|
+
if (!writes) {
|
|
293
|
+
const store = await readingStore(ctx, handle, log);
|
|
294
|
+
let actingP = null;
|
|
295
|
+
const acting = () => (actingP ||= (async () => {
|
|
296
|
+
const s = stateAndLease(ctx, handle, at, { log });
|
|
297
|
+
await s.store.load();
|
|
298
|
+
const a = await actingAgent(at, s.store, s.lease, { log });
|
|
299
|
+
if (a.skipped) throw new Error(a.skipped);
|
|
300
|
+
return a;
|
|
301
|
+
})());
|
|
302
|
+
const agent = readingAgent(at, store, acting, log);
|
|
303
|
+
if (!agent) return json(503, { error: 'this account\'s copy holds no account' });
|
|
304
|
+
const api = facade(agent, token, log, await vapidKeys(ctx.mastoKv), pushContact(ctx));
|
|
305
|
+
const handled = await api.handle(b.req, b.res, url.pathname, url);
|
|
306
|
+
// New mail read in after this answer, for the app's next check.
|
|
307
|
+
if (/^\/api\/v[12]\/(timelines\/home|notifications)$/u.test(url.pathname)) {
|
|
308
|
+
ctx.waitUntil?.(readHeldForApp(ctx, handle, rec, { log }).catch((e) => log(`app @${handle}: held mail: ${e.message}`)));
|
|
309
|
+
}
|
|
310
|
+
return handled ? b.response() : new Response(JSON.stringify({ error: 'not found' }), { status: 404 });
|
|
311
|
+
}
|
|
312
|
+
// Acting: this app outranks a browser sitting open, as one device does another.
|
|
313
|
+
const unlock = await lockCopy(ctx.copyKv, handle);
|
|
314
|
+
if (!unlock) return json(503, { error: 'this account is busy; try again' });
|
|
315
|
+
let lease = null;
|
|
316
|
+
try {
|
|
317
|
+
const s = stateAndLease(ctx, handle, at, { log });
|
|
318
|
+
if (!await s.lease.acquire() && !await s.lease.takeover()) return json(503, { error: 'could not take this account over; try again' });
|
|
319
|
+
lease = s.lease;
|
|
320
|
+
const store = s.store;
|
|
321
|
+
await store.load();
|
|
322
|
+
const agent = await actingAgent(at, store, lease, { log });
|
|
323
|
+
if (agent.skipped) return json(503, { error: agent.skipped });
|
|
324
|
+
const api = facade(agent, token, log, await vapidKeys(ctx.mastoKv), pushContact(ctx));
|
|
325
|
+
const handled = await api.handle(b.req, b.res, url.pathname, url);
|
|
326
|
+
await store.commit();
|
|
327
|
+
if (url.pathname === '/api/v1/push/subscription') await notePushSubs(ctx, handle, store);
|
|
328
|
+
agent.publisher.stopPolls?.();
|
|
329
|
+
await ctx.noteNext?.(handle, nextDue(store)).catch(() => {});
|
|
330
|
+
reading.delete(handle);
|
|
331
|
+
return handled ? b.response() : new Response(JSON.stringify({ error: 'not found' }), { status: 404 });
|
|
332
|
+
} finally {
|
|
333
|
+
// Done acting, however it went: the lease goes back, so a browser sitting
|
|
334
|
+
// open takes it up again rather than staying a viewer for its whole term.
|
|
335
|
+
await lease?.release().catch(() => {});
|
|
336
|
+
await unlock();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---- the routes ----
|
|
341
|
+
|
|
342
|
+
export async function routeMastoGateway(request, pathname, ctx, deps, { log = console.log } = {}) {
|
|
343
|
+
const isOurs = pathname.startsWith('/api/v1/') || pathname.startsWith('/api/v2/') || pathname.startsWith('/oauth/')
|
|
344
|
+
|| pathname === '/api/authorize' || pathname === '/.well-known/oauth-authorization-server';
|
|
345
|
+
if (!isOurs) return null;
|
|
346
|
+
if (request.method === 'OPTIONS') return { status: 204, headers: CORS, body: null };
|
|
347
|
+
const out = await route(request, pathname, ctx, deps, log);
|
|
348
|
+
if (out instanceof Response) {
|
|
349
|
+
const headers = Object.fromEntries(out.headers);
|
|
350
|
+
return { status: out.status, headers: { ...headers, ...CORS }, body: out.status === 204 ? null : await out.text() };
|
|
351
|
+
}
|
|
352
|
+
return { ...out, headers: { ...(out.headers || {}), ...CORS } };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function route(request, pathname, ctx, deps, log) {
|
|
356
|
+
const kv = ctx.mastoKv;
|
|
357
|
+
const url = new URL(request.url);
|
|
358
|
+
const origin = url.origin;
|
|
359
|
+
const host = ctx.host || url.host;
|
|
360
|
+
if (!kv || !ctx.copyKv) return json(501, { error: 'this gateway does not sign in apps' });
|
|
361
|
+
|
|
362
|
+
if (pathname === '/api/v1/instance') return json(200, instanceV1(host));
|
|
363
|
+
if (pathname === '/api/v2/instance') return json(200, instanceV2(host, origin, (await vapidKeys(kv)).publicKey));
|
|
364
|
+
if (['/api/v1/custom_emojis', '/api/v1/instance/peers', '/api/v1/instance/rules'].includes(pathname) && request.method === 'GET') return json(200, []);
|
|
365
|
+
if (pathname === '/.well-known/oauth-authorization-server') {
|
|
366
|
+
return json(200, {
|
|
367
|
+
issuer: `${origin}/`, authorization_endpoint: `${origin}/oauth/authorize`, token_endpoint: `${origin}/oauth/token`,
|
|
368
|
+
revocation_endpoint: `${origin}/oauth/revoke`, registration_endpoint: `${origin}/api/v1/apps`,
|
|
369
|
+
scopes_supported: ['read', 'write', 'follow', 'push', 'profile'], response_types_supported: ['code'],
|
|
370
|
+
grant_types_supported: ['authorization_code', 'client_credentials'],
|
|
371
|
+
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'],
|
|
372
|
+
code_challenge_methods_supported: ['S256'],
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Apps sign in only where the gateway can keep accounts running.
|
|
377
|
+
if (!ctx.keeperWebId) return json(501, { error: 'Mastodon apps cannot sign in at this server yet' });
|
|
378
|
+
|
|
379
|
+
// An app registering, for every account here.
|
|
380
|
+
if (pathname === '/api/v1/apps' && request.method === 'POST') {
|
|
381
|
+
const body = await formOf(request);
|
|
382
|
+
const redirectUris = parseRedirects(body.redirect_uris);
|
|
383
|
+
const app = {
|
|
384
|
+
clientId: rand(24), clientSecret: rand(32), name: String(body.client_name || 'an app').slice(0, 200),
|
|
385
|
+
website: String(body.website || '').slice(0, 500), redirectUris, scopes: String(body.scopes || 'read'), createdAt: Date.now(),
|
|
386
|
+
};
|
|
387
|
+
await putJson(kv, `app/${app.clientId}`, app);
|
|
388
|
+
return json(200, {
|
|
389
|
+
id: app.clientId, name: app.name, website: app.website || null, client_id: app.clientId, client_secret: app.clientSecret,
|
|
390
|
+
redirect_uri: redirectUris.join(' ') || 'urn:ietf:wg:oauth:2.0:oob', redirect_uris: redirectUris, scopes: app.scopes.split(/\s+/u),
|
|
391
|
+
vapid_key: (await vapidKeys(kv)).publicKey,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// The app sends the person here; the sign-in page does the rest.
|
|
396
|
+
if (pathname === '/oauth/authorize' && request.method === 'GET') {
|
|
397
|
+
return { status: 302, headers: { location: `${SIGNIN_PAGE}${url.search}`, 'cache-control': 'no-store' }, body: null };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (pathname === '/api/authorize' && request.method === 'GET') {
|
|
401
|
+
const clientId = url.searchParams.get('client_id');
|
|
402
|
+
if (clientId) {
|
|
403
|
+
const app = await appOf(kv, clientId);
|
|
404
|
+
if (!app) return json(404, { error: 'this app has not registered here' });
|
|
405
|
+
const redirect = url.searchParams.get('redirect_uri') || '';
|
|
406
|
+
if (redirect !== 'urn:ietf:wg:oauth:2.0:oob' && !app.redirectUris.includes(redirect)) {
|
|
407
|
+
return json(400, { error: 'this app asked to be sent somewhere it did not register' });
|
|
408
|
+
}
|
|
409
|
+
let where = null; try { where = redirect.startsWith('urn:') ? null : new URL(redirect).host || null; } catch { /* a custom scheme */ }
|
|
410
|
+
return json(200, { name: app.name, website: app.website || null, sendsTo: where });
|
|
411
|
+
}
|
|
412
|
+
const handle = handleOf(url.searchParams.get('address'), host);
|
|
413
|
+
const rec = handle ? await ctx.lookup(handle) : null;
|
|
414
|
+
if (!rec?.openedAt || rec.movedTo || rec.closedAt) return json(404, { error: 'there is no account with that address here' });
|
|
415
|
+
if (keptBefore(ctx, rec)) return json(503, { error: MOVING });
|
|
416
|
+
if (!rec.keeper) {
|
|
417
|
+
return json(409, { error: 'this account works only while FediPod is open. To use it from an app, turn on '
|
|
418
|
+
+ '"Keep my account running while I\'m away" on its manage page first.' });
|
|
419
|
+
}
|
|
420
|
+
return json(200, { handle, webId: rec.webId });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// The pod sign-in, proved: a code for the app.
|
|
424
|
+
if (pathname === '/api/authorize' && request.method === 'POST') {
|
|
425
|
+
const webid = await deps.verifyPodToken(request, pathname, ctx.verifier);
|
|
426
|
+
if (!webid) return json(401, { error: 'sign in at your pod first' });
|
|
427
|
+
const body = await request.clone().json().catch(() => ({}));
|
|
428
|
+
const handle = handleOf(body.address, host);
|
|
429
|
+
const rec = handle ? await ctx.lookup(handle) : null;
|
|
430
|
+
if (keptBefore(ctx, rec)) return json(503, { error: MOVING });
|
|
431
|
+
if (!usable(ctx, rec)) return json(404, { error: 'there is no account kept running with that address here' });
|
|
432
|
+
if (rec.webId !== webid) return json(403, { error: `you signed in as ${webid}, and this account belongs to ${rec.webId}` });
|
|
433
|
+
const app = await appOf(kv, body.client_id);
|
|
434
|
+
if (!app) return json(400, { error: 'this app has not registered here' });
|
|
435
|
+
const redirect = body.redirect_uri || 'urn:ietf:wg:oauth:2.0:oob';
|
|
436
|
+
if (redirect !== 'urn:ietf:wg:oauth:2.0:oob' && !app.redirectUris.includes(redirect)) {
|
|
437
|
+
return json(400, { error: 'this app asked to be sent somewhere it did not register' });
|
|
438
|
+
}
|
|
439
|
+
const made = await ensureCopy(ctx, handle, rec, log);
|
|
440
|
+
if (!made.ok) return json(made.status === 409 ? 503 : made.status, { error: made.why });
|
|
441
|
+
const code = rand(24);
|
|
442
|
+
await putJson(kv, `code/${sha(code)}`, {
|
|
443
|
+
handle, webId: webid, clientId: app.clientId, redirect, scope: String(body.scope || 'read'), at: Date.now(),
|
|
444
|
+
...(body.code_challenge ? { challenge: body.code_challenge, challengeMethod: body.code_challenge_method || 'plain' } : {}),
|
|
445
|
+
});
|
|
446
|
+
log(`app "${app.name}" signed in to @${handle}`);
|
|
447
|
+
if (redirect === 'urn:ietf:wg:oauth:2.0:oob') return json(200, { code });
|
|
448
|
+
const to = new URL(redirect);
|
|
449
|
+
to.searchParams.set('code', code);
|
|
450
|
+
if (body.state) to.searchParams.set('state', body.state);
|
|
451
|
+
return json(200, { redirect: to.href });
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (pathname === '/oauth/token' && request.method === 'POST') {
|
|
455
|
+
const body = await formOf(request);
|
|
456
|
+
const basic = /^Basic (.+)$/u.exec(request.headers.get('authorization') || '')?.[1];
|
|
457
|
+
if (basic) { const [id, secret] = Buffer.from(basic, 'base64').toString().split(':'); body.client_id ||= decodeURIComponent(id); body.client_secret ||= decodeURIComponent(secret || ''); }
|
|
458
|
+
const app = await appOf(kv, body.client_id);
|
|
459
|
+
if (!app) return json(401, { error: 'invalid_client' });
|
|
460
|
+
const secretOk = body.client_secret && body.client_secret.length === app.clientSecret.length
|
|
461
|
+
&& crypto.timingSafeEqual(Buffer.from(body.client_secret), Buffer.from(app.clientSecret));
|
|
462
|
+
const mint = async (handle, scope, webId = null) => {
|
|
463
|
+
const token = rand(32);
|
|
464
|
+
await putJson(kv, `token/${sha(token)}`, { handle, webId, scope, clientId: app.clientId, at: Date.now() });
|
|
465
|
+
return json(200, { access_token: token, token_type: 'Bearer', scope, created_at: Math.floor(Date.now() / 1000) });
|
|
466
|
+
};
|
|
467
|
+
if (body.grant_type === 'client_credentials') {
|
|
468
|
+
if (!secretOk) return json(401, { error: 'invalid_client' });
|
|
469
|
+
return mint(null, String(body.scope || 'read'));
|
|
470
|
+
}
|
|
471
|
+
if (body.grant_type && body.grant_type !== 'authorization_code') return json(400, { error: 'unsupported_grant_type' });
|
|
472
|
+
const key = `code/${sha(body.code || '')}`;
|
|
473
|
+
const rec = await getJson(kv, key);
|
|
474
|
+
if (!rec || Date.now() - rec.at > CODE_TTL_MS || rec.clientId !== app.clientId
|
|
475
|
+
|| (body.redirect_uri && body.redirect_uri !== rec.redirect)) return json(400, { error: 'invalid_grant' });
|
|
476
|
+
// A code made with a challenge is only redeemed with its answer; one made
|
|
477
|
+
// without needs the app's secret.
|
|
478
|
+
if (rec.challenge ? !MastoApi.provesCode(rec, body.code_verifier) : !secretOk) return json(400, { error: 'invalid_grant' });
|
|
479
|
+
await kv.delete(key);
|
|
480
|
+
return mint(rec.handle, rec.scope, rec.webId);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (pathname === '/oauth/revoke' && request.method === 'POST') {
|
|
484
|
+
const body = await formOf(request);
|
|
485
|
+
if (body.token) await kv.delete(`token/${sha(body.token)}`);
|
|
486
|
+
return json(200, {});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Everything else needs a token.
|
|
490
|
+
const bearer = /^Bearer (.+)$/u.exec(request.headers.get('authorization') || '')?.[1];
|
|
491
|
+
const tok = bearer ? await getJson(kv, `token/${sha(bearer)}`) : null;
|
|
492
|
+
if (!tok || Date.now() - tok.at > TOKEN_TTL_MS) {
|
|
493
|
+
// A page of this site asking with a token the gateway never gave out is
|
|
494
|
+
// FediPod's own client in a browser whose worker is not answering yet:
|
|
495
|
+
// told to try again, not that it is signed out.
|
|
496
|
+
if (request.headers.get('sec-fetch-site') === 'same-origin') return json(503, { error: 'FediPod is starting in this browser; try again' });
|
|
497
|
+
return json(401, { error: 'The access token is invalid' });
|
|
498
|
+
}
|
|
499
|
+
if (pathname === '/api/v1/apps/verify_credentials') {
|
|
500
|
+
const app = await appOf(kv, tok.clientId);
|
|
501
|
+
return json(200, { name: app?.name || 'an app', website: app?.website || null, scopes: String(tok.scope).split(/\s+/u) });
|
|
502
|
+
}
|
|
503
|
+
if (!tok.handle) return json(403, { error: 'this token names no account' });
|
|
504
|
+
const rec = await ctx.lookup(tok.handle);
|
|
505
|
+
// The token is for the owner who signed in; an address given up and taken
|
|
506
|
+
// by somebody else is not theirs.
|
|
507
|
+
if (rec && rec.webId === tok.webId && keptBefore(ctx, rec)) return json(503, { error: MOVING });
|
|
508
|
+
if (!usable(ctx, rec) || rec.webId !== tok.webId) return json(401, { error: 'this account is not kept running here any more' });
|
|
509
|
+
return answerForAccount(request, url, ctx, tok, rec, log);
|
|
510
|
+
}
|
package/lib/gateway/quiet.mjs
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
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';
|
|
8
|
+
|
|
7
9
|
// ---- accounts that go quiet -------------------------------------------------
|
|
8
10
|
//
|
|
9
11
|
// The gateway holds no mail: every delivery it takes is two writes into the
|
|
@@ -107,7 +109,7 @@ export const closedAnswer = (headers = {}, why = 'this address is closed') => ({
|
|
|
107
109
|
// The owner of a row, proved the way the relay proves them: a pod token whose
|
|
108
110
|
// WebID is the row's, or lives on the row's pod for a row from before WebIDs
|
|
109
111
|
// were recorded.
|
|
110
|
-
async function provedOwner(request, pathname, ctx, rec, { j, verifyPodToken, webidUnderPod }) {
|
|
112
|
+
export async function provedOwner(request, pathname, ctx, rec, { j, verifyPodToken, webidUnderPod }) {
|
|
111
113
|
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
112
114
|
if (!webid) return { error: j(401, { error: 'a Solid-OIDC token proving the pod is required' }) };
|
|
113
115
|
const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
|
|
@@ -141,7 +143,10 @@ export async function routeQuietApi(request, pathname, ctx, deps) {
|
|
|
141
143
|
const before = await closedState(ctx, handle, rec);
|
|
142
144
|
if (before.closed) return j(410, { error: 'this address is closed', closedAt: before.closedAt, closedBy: before.closedBy });
|
|
143
145
|
rec = await noteOpened(ctx, handle, rec, { always: true });
|
|
144
|
-
|
|
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).
|
|
148
|
+
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 });
|
|
145
150
|
}
|
|
146
151
|
|
|
147
152
|
// The owner pausing their own account, or lifting the pause they set.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// relay-extras.mjs — the relay's single request, and what the relay notices
|
|
2
|
+
// about the requests it carries.
|
|
3
|
+
//
|
|
4
|
+
// relayOne(): sends one request a browser-run agent has already signed, with
|
|
5
|
+
// exactly the headers that were signed. It forwards a signature and cannot
|
|
6
|
+
// make one: a POST must carry one, by this account's own key, over the body
|
|
7
|
+
// its Digest names.
|
|
8
|
+
//
|
|
9
|
+
// announced(): a relayed delivery of the account's own activity — a post, an
|
|
10
|
+
// edit, a pin, an Accept, a Delete, an Undo — means its documents have just
|
|
11
|
+
// changed, so the copies the edge holds of them are out of date; the front
|
|
12
|
+
// purges them rather than serving the old ones for the rest of their hold.
|
|
13
|
+
//
|
|
14
|
+
// overLimit(): how many requests one account may have the relay carry. Counted
|
|
15
|
+
// per running instance, which bounds a runaway agent or a stolen token without
|
|
16
|
+
// any shared state; a real account sends nowhere near it.
|
|
17
|
+
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import { readCapped, safeFetch } from '../shared/safefetch.mjs';
|
|
20
|
+
|
|
21
|
+
const RELAY_MAX_BODY = 1024 * 1024;
|
|
22
|
+
const RELAY_TIMEOUT_MS = 8_000;
|
|
23
|
+
// The only headers a relayed request may carry to the remote server. Host
|
|
24
|
+
// comes from the URL; the user agent from safeFetch.
|
|
25
|
+
const RELAY_HEADERS = new Set(['date', 'digest', 'signature', 'content-type', 'accept']);
|
|
26
|
+
const keyIdOf = (signature) => (/keyId="([^"]+)"/.exec(signature || '') || [])[1] || null;
|
|
27
|
+
|
|
28
|
+
export async function relayOne(item, rec, fetchImpl) {
|
|
29
|
+
const url = String(item?.url || '');
|
|
30
|
+
const method = String(item?.method || 'POST').toUpperCase();
|
|
31
|
+
if (method !== 'GET' && method !== 'POST') return { url, status: 0, error: 'method must be GET or POST' };
|
|
32
|
+
let u;
|
|
33
|
+
try { u = new URL(url); } catch { return { url, status: 0, error: 'not a URL' }; }
|
|
34
|
+
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && process.env.AP_ALLOW_PRIVATE_TARGETS === '1')) {
|
|
35
|
+
return { url, status: 0, error: 'https only' };
|
|
36
|
+
}
|
|
37
|
+
const headers = {};
|
|
38
|
+
for (const [k, v] of Object.entries(item?.headers || {})) {
|
|
39
|
+
const name = k.toLowerCase();
|
|
40
|
+
if (RELAY_HEADERS.has(name) && typeof v === 'string') headers[name] = v;
|
|
41
|
+
}
|
|
42
|
+
const body = method === 'POST' ? String(item?.body ?? '') : undefined;
|
|
43
|
+
if (body !== undefined && Buffer.byteLength(body) > RELAY_MAX_BODY) return { url, status: 0, error: 'body too large' };
|
|
44
|
+
const keyId = keyIdOf(headers.signature);
|
|
45
|
+
if (method === 'POST' && !keyId) return { url, status: 0, error: 'a delivery must be signed' };
|
|
46
|
+
if (keyId && !keyId.startsWith(rec.actorUrl + '#')) return { url, status: 0, error: "signed with a key that is not this account's" };
|
|
47
|
+
if (headers.digest) {
|
|
48
|
+
const want = 'SHA-256=' + crypto.createHash('sha256').update(body || '').digest('base64');
|
|
49
|
+
if (headers.digest !== want) return { url, status: 0, error: 'digest does not match the body' };
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const res = await safeFetch(url, { method, headers, body, signal: AbortSignal.timeout(RELAY_TIMEOUT_MS) }, fetchImpl);
|
|
53
|
+
const out = { url, method, status: res.status };
|
|
54
|
+
// The far server asking to be left alone has to reach the agent that will
|
|
55
|
+
// do the asking again. Without this the browser build could not honour a
|
|
56
|
+
// Retry-After at all — every delivery it makes goes through here — and fell
|
|
57
|
+
// back to its own ladder against a server that had already said how long.
|
|
58
|
+
const retryAfter = res.headers.get('retry-after');
|
|
59
|
+
if (retryAfter) out.retryAfter = retryAfter;
|
|
60
|
+
if (method === 'GET') {
|
|
61
|
+
out.contentType = res.headers.get('content-type') || null;
|
|
62
|
+
out.body = await readCapped(res, RELAY_MAX_BODY);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
} catch (e) { return { url, method, status: 0, error: e.message }; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const WINDOW_MS = 10 * 60 * 1000;
|
|
69
|
+
const PER_WINDOW = 600;
|
|
70
|
+
const counts = new Map(); // handle → { since, n }
|
|
71
|
+
|
|
72
|
+
export function announced(items, actorUrl) {
|
|
73
|
+
return items.some((it) => {
|
|
74
|
+
if (String(it?.method || 'POST').toUpperCase() !== 'POST') return false;
|
|
75
|
+
try {
|
|
76
|
+
const a = JSON.parse(String(it?.body ?? ''));
|
|
77
|
+
return a?.actor === actorUrl && typeof a.type === 'string';
|
|
78
|
+
} catch { return false; }
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function overLimit(handle, n, now = Date.now()) {
|
|
83
|
+
const c = counts.get(handle);
|
|
84
|
+
const fresh = !c || now - c.since > WINDOW_MS ? { since: now, n: 0 } : c;
|
|
85
|
+
fresh.n += n;
|
|
86
|
+
counts.set(handle, fresh);
|
|
87
|
+
if (counts.size > 10_000) counts.clear();
|
|
88
|
+
return fresh.n > PER_WINDOW;
|
|
89
|
+
}
|