fedipod-server 0.28.0 → 0.29.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 +27 -28
- package/config/server.json +22 -0
- package/dist/claims.js +3 -2
- package/dist/directory.d.ts +2 -0
- package/dist/handler.d.ts +4 -1
- package/dist/handler.js +32 -3
- package/dist/handler.jsonld +4 -0
- package/dist/store-pod.js +37 -0
- package/lib/client/c2s.mjs +97 -43
- package/lib/client/masto/accounts.mjs +1 -0
- package/lib/connections/bskyfeed.mjs +6 -0
- package/lib/core/deliver.mjs +68 -22
- package/lib/core/intake/activities.mjs +18 -2
- package/lib/core/intake/group.mjs +3 -1
- package/lib/core/intake/index.mjs +50 -6
- package/lib/core/intake/notes.mjs +12 -5
- package/lib/core/lease.mjs +7 -7
- package/lib/core/place.mjs +82 -0
- package/lib/core/publisher/collections.mjs +37 -2
- package/lib/core/publisher/index.mjs +23 -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/social.mjs +62 -32
- package/lib/core/store.mjs +36 -6
- 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/caches.mjs +36 -0
- package/lib/gateway/front-core.mjs +80 -111
- package/lib/gateway/gateway-core.mjs +91 -9
- package/lib/gateway/headers.mjs +49 -0
- package/lib/gateway/quiet.mjs +7 -2
- package/lib/gateway/relay-extras.mjs +89 -0
- package/lib/gateway/token-claims.mjs +16 -0
- package/lib/pod/containers.mjs +5 -1
- package/lib/pod/inbox.mjs +22 -3
- package/lib/pod/location.mjs +52 -0
- package/lib/pod/notes.mjs +2 -4
- package/lib/pod/transport.mjs +207 -25
- 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 +14 -0
- package/lib/session/fedi-account.mjs +73 -2
- package/lib/session/package.json +10 -2
- package/package.json +1 -1
- package/run-agent.mjs +18 -0
- package/web/admin/actors.js +2 -0
- package/web/admin/index.html +9 -0
- 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 +1 -1
- package/web/app/admin-facade.mjs +1 -1
- package/web/app/agent.mjs +24 -10
- package/web/app/boot.mjs +72 -32
- package/web/app/deliver-relay.mjs +47 -7
- package/web/app/dist/boot.js +466 -89
- package/web/app/dist/boot.js.map +4 -4
- package/web/app/dist/sw.js +2792 -1996
- 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/update.js +4 -0
- package/web/front/run.html +7 -1
- package/web/front/run.js +30 -4
|
@@ -18,7 +18,10 @@ export async function setup() {
|
|
|
18
18
|
if (process.stdin.isTTY && !has('cli') && !IDENTITY_FLAGS.some(f => args.includes('--' + f))) {
|
|
19
19
|
return runBrowserSetup();
|
|
20
20
|
}
|
|
21
|
-
|
|
21
|
+
// Where on the pod: --root names it outright (scripts); otherwise the person
|
|
22
|
+
// names the container that holds `fedipod/`, asked below once the pod is known.
|
|
23
|
+
let root = flag('root') || null;
|
|
24
|
+
const place = await import(new URL('../../../../lib/core/place.mjs', import.meta.url));
|
|
22
25
|
const kind = has('group') ? 'group' : 'person';
|
|
23
26
|
const approveJoins = has('group') && has('approve-joins');
|
|
24
27
|
const summary = flag('summary');
|
|
@@ -48,10 +51,20 @@ if (!newAccount && !pod) {
|
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
if (!newAccount && !pod) { console.error('no pod given'); process.exit(2); }
|
|
54
|
+
const CONTAINER_Q = 'Store your data in a container named fedipod, inside this container';
|
|
55
|
+
let container = flag('in');
|
|
51
56
|
if (!newAccount) {
|
|
57
|
+
const podBase = pod.endsWith('/') ? pod : pod + '/';
|
|
58
|
+
if (!root) {
|
|
59
|
+
if (container == null) container = interactive ? await ask(`${CONTAINER_Q} (empty: ${new URL(podBase).pathname})`, '') : '';
|
|
60
|
+
const chosen = place.chosenRoot(podBase, container);
|
|
61
|
+
if (chosen.problem) { console.error(`Where to store it: ${chosen.problem}.`); process.exit(2); }
|
|
62
|
+
root = chosen.root;
|
|
63
|
+
}
|
|
52
64
|
const { resourceExists } = await import(new URL('../../../../lib/pod/root.mjs', import.meta.url));
|
|
53
65
|
const { apUrls, DEFAULT_ROOT: DR } = await import(new URL('../../../../lib/core/wire.mjs', import.meta.url));
|
|
54
|
-
|
|
66
|
+
// An account at the chosen place, or an older one where accounts used to go.
|
|
67
|
+
if (await resourceExists(fetch, apUrls(pod, root).actor) || await resourceExists(fetch, apUrls(pod, DR).actor)) {
|
|
55
68
|
if (String(flag('address') || 'pod').toLowerCase() !== 'front') {
|
|
56
69
|
console.error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod.');
|
|
57
70
|
console.error('To move an account whose address is at another gateway to this one, add --address front.');
|
|
@@ -115,8 +128,8 @@ if (fronted && kind === 'group') {
|
|
|
115
128
|
// warning is their call to accept. Nobody could ever find this group, and the
|
|
116
129
|
// people it would fail are not the operator reading the warning.
|
|
117
130
|
if (kind === 'group' && !newAccount && !wfHost) {
|
|
118
|
-
console.error(`${pod} is a
|
|
119
|
-
console.error('WebFinger is answered only
|
|
131
|
+
console.error(`${pod} is a suffixed pod, not a subdomained one.`);
|
|
132
|
+
console.error('WebFinger is answered only for a subdomained pod, so nobody could find this group.');
|
|
120
133
|
console.error('Give the group a pod of its own: fedipod setup --group --new-account');
|
|
121
134
|
process.exit(2);
|
|
122
135
|
}
|
|
@@ -133,6 +146,26 @@ if (fronted) {
|
|
|
133
146
|
console.log(`pods at ${issuerHost}/${podName}/ instead cannot answer WebFinger for an address,`);
|
|
134
147
|
console.log(`so setup takes an address at ${gatewayHost} for you and says so.\n`);
|
|
135
148
|
}
|
|
149
|
+
// Where the account lives is recorded in the pod's public type index. A new
|
|
150
|
+
// pod has none; a pod you bring is asked. With none, only a yes goes on.
|
|
151
|
+
let createIndex = has('create-type-index');
|
|
152
|
+
if (!createIndex) {
|
|
153
|
+
const known = newAccount ? false : await place.publicIndexKnown(fetch, pod.endsWith('/') ? pod : pod + '/');
|
|
154
|
+
if (known !== true) {
|
|
155
|
+
const why = newAccount ? 'A new pod has no public type index' : 'Your pod has no public type index';
|
|
156
|
+
if (!interactive) {
|
|
157
|
+
console.error(`${why}. FediPod records where your account lives in one; add --create-type-index to make it.`);
|
|
158
|
+
process.exit(2);
|
|
159
|
+
}
|
|
160
|
+
const yes = await ask(`${why}, the list Solid apps use to find your things. FediPod needs one to record `
|
|
161
|
+
+ 'where your account lives. Create it? It will be readable by anyone, like your profile. (y/n)', 'n');
|
|
162
|
+
if (!/^y/i.test(yes)) { console.log('Setup stopped. Nothing was written to your pod.'); process.exit(0); }
|
|
163
|
+
createIndex = true;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (newAccount && !root && container == null) {
|
|
167
|
+
container = await ask(`${CONTAINER_Q} (empty: the new pod's root)`, '');
|
|
168
|
+
}
|
|
136
169
|
console.log('The display name can be changed later; the handle and pod cannot.');
|
|
137
170
|
const go = await ask(newAccount
|
|
138
171
|
? (kind === 'group' ? 'create pod and group? (y/n)' : 'create pod and Fediverse account? (y/n)')
|
|
@@ -147,16 +180,21 @@ if (newAccount) {
|
|
|
147
180
|
const made = await createAccountWithPod({ issuer, email, password, podName });
|
|
148
181
|
pod = made.pod;
|
|
149
182
|
console.log(`account + pod created: ${pod}`);
|
|
183
|
+
if (!root) {
|
|
184
|
+
const chosen = place.chosenRootInNewPod(pod.endsWith('/') ? pod : pod + '/', container);
|
|
185
|
+
if (chosen.problem) { console.error(`Where to store it: ${chosen.problem}. The pod was made; nothing else was.`); process.exit(2); }
|
|
186
|
+
root = chosen.root;
|
|
187
|
+
}
|
|
150
188
|
if (!webfingerHost(pod)) {
|
|
151
189
|
if (kind === 'group') {
|
|
152
|
-
console.log(`\n${issuerHost} created the pod at ${pod} — a
|
|
153
|
-
console.log('WebFinger is answered only
|
|
190
|
+
console.log(`\n${issuerHost} created the pod at ${pod} — a suffixed pod.`);
|
|
191
|
+
console.log('WebFinger is answered only for a subdomained pod, so nobody could find this group.');
|
|
154
192
|
console.log('The pod exists; no actor was published.');
|
|
155
193
|
process.exit(0);
|
|
156
194
|
}
|
|
157
195
|
// A path pod's address lives at the gateway; nothing to warn about.
|
|
158
196
|
fronted = true;
|
|
159
|
-
console.log(`${issuerHost}
|
|
197
|
+
console.log(`${issuerHost} makes suffixed pods, so your address will be @${handle}@${gatewayHost}.`);
|
|
160
198
|
}
|
|
161
199
|
}
|
|
162
200
|
|
|
@@ -207,11 +245,15 @@ await agent.bootstrap({ handle, name, root, kind, approveJoins, summary, icon, g
|
|
|
207
245
|
await agent.connect({ repair: false }); // publishProfile below is the publish
|
|
208
246
|
await agent.publisher.publishProfile();
|
|
209
247
|
await agent.store.flush();
|
|
248
|
+
// Where the account lives, in the pod's public type index — made first on the yes.
|
|
249
|
+
await place.recordPlace(agent.remote, rec.remotePod, `${rec.remotePod}${root}ap/actor`, { create: createIndex })
|
|
250
|
+
.then(() => console.log('recorded where the account lives in your public type index'))
|
|
251
|
+
.catch((e) => { console.error(`could not record where the account lives in your type index: ${e.message}`); process.exitCode = 1; });
|
|
210
252
|
const finalHost = gatewayCfg ? gatewayHost : webfingerHost(rec.remotePod);
|
|
211
253
|
const what = kind === 'group' ? 'group' : 'actor';
|
|
212
254
|
console.log(finalHost
|
|
213
255
|
? `${what} published: @${handle}@${finalHost}`
|
|
214
|
-
: `${what} published, but not reachable as a handle \u2014 ${rec.remotePod} is
|
|
256
|
+
: `${what} published, but not reachable as a handle \u2014 ${rec.remotePod} is a suffixed pod`);
|
|
215
257
|
|
|
216
258
|
// Straight into serving — setup ends with a working client in the browser.
|
|
217
259
|
const { startAdmin } = await import(new URL('../../../../lib/device/admin/index.mjs', import.meta.url));
|
|
@@ -215,7 +215,7 @@ export async function finish(agent, code = 0) {
|
|
|
215
215
|
// unpack-and-go line depend on that. At a terminal with none of them, setup
|
|
216
216
|
// asks the two things it needs to open a browser and asks the rest there.
|
|
217
217
|
export const IDENTITY_FLAGS = ['new-account', 'pod', 'issuer', 'email', 'name', 'pod-name',
|
|
218
|
-
'group', 'approve-joins', 'summary', 'icon', 'root', 'keys', 'rotate-key'];
|
|
218
|
+
'group', 'approve-joins', 'summary', 'icon', 'root', 'in', 'create-type-index', 'keys', 'rotate-key'];
|
|
219
219
|
|
|
220
220
|
// Refuse before anything is asked, let alone typed: setup used to overwrite
|
|
221
221
|
// credential.json in place, and a minted credential is only shown once — so
|
package/lib/device/migrate.mjs
CHANGED
|
@@ -65,7 +65,7 @@ export const MOVED_STATE_DOCS = new Set([
|
|
|
65
65
|
'config.json', 'queue.json', 'blocklist.json', 'contacts.json', 'muted.json',
|
|
66
66
|
'pending.json', 'requests.json', 'deadletter.json', 'statuses.json',
|
|
67
67
|
'notifications.json', 'media.json', 'actors.json', 'ids.json', 'published.json',
|
|
68
|
-
'outbox.json', 'outbox-removed.json', 'parked.json', 'tagfeed.json',
|
|
68
|
+
'outbox.json', 'outbox-removed.json', 'outbox-own.json', 'liked.json', 'c2s-seen.json', 'parked.json', 'tagfeed.json',
|
|
69
69
|
'intake-attempts.json', 'inbox-channel.json', 'masto-tokens.json', 'masto-markers.json',
|
|
70
70
|
'lists.json', 'filters.json', 'scheduled.json', 'webpush.json',
|
|
71
71
|
'import-state.json',
|
package/lib/device/setup.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { insecureUrlReason } from '../shared/safefetch.mjs';
|
|
|
22
22
|
import { resourceExists } from '../pod/root.mjs';
|
|
23
23
|
import { CURRENT_LAYOUT, isCurrent } from './migrate.mjs';
|
|
24
24
|
import { completeGatewayMove } from './gateway-move.mjs';
|
|
25
|
+
import { chosenRoot, chosenRootInNewPod, publicIndexKnown, recordPlace, needsIndex } from '../core/place.mjs';
|
|
25
26
|
|
|
26
27
|
const SOLID = $rdf.Namespace('http://www.w3.org/ns/solid/terms#');
|
|
27
28
|
|
|
@@ -234,10 +235,14 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
234
235
|
mode, issuer, email, password, handle, name, podName,
|
|
235
236
|
kind = 'person', approveJoins = false, summary, icon, keys, uiPassword,
|
|
236
237
|
gateway = null, shape = 'pod', gatewayOrigin = 'https://fedipod.net',
|
|
238
|
+
// The container that holds `fedipod/` (blank: the pod's root), and the
|
|
239
|
+
// person's yes to a new public type index when their profile names none.
|
|
240
|
+
container = '', createIndex = false,
|
|
237
241
|
} = answers;
|
|
238
242
|
let { pod, root } = answers;
|
|
239
|
-
|
|
240
|
-
|
|
243
|
+
const rootGiven = !!root;
|
|
244
|
+
if (!root) root = 'fedipod/'; // until the pod is known; a resuming run
|
|
245
|
+
// overwrites this with the credential's own root below.
|
|
241
246
|
let accountWebId = null; // what createAccountWithPod reported, when it ran
|
|
242
247
|
// The private half always starts here, beside the credential and the keys —
|
|
243
248
|
// not on the pod. Every activity you receive would otherwise cost the pod
|
|
@@ -265,6 +270,9 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
265
270
|
skip('account', 'the credential is already on disk');
|
|
266
271
|
} else if (mode === 'new') {
|
|
267
272
|
begin('account');
|
|
273
|
+
// A new pod has no public type index: without a yes to making one,
|
|
274
|
+
// nothing is created at all.
|
|
275
|
+
if (!createIndex) throw needsIndex('A new pod has no public type index.');
|
|
268
276
|
const made = await createAccount({ issuer, email, password, podName: podName || handle });
|
|
269
277
|
pod = made.pod;
|
|
270
278
|
accountWebId = made.webId || null;
|
|
@@ -273,10 +281,15 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
273
281
|
// nobody could ever find this group. A person was warned before we got
|
|
274
282
|
// here and chose to continue; a group cannot.
|
|
275
283
|
if (kind === 'group' && !webfingerHost(pod)) {
|
|
276
|
-
throw new Error(`${issuer} created the pod at ${pod} — a
|
|
277
|
-
+ '
|
|
284
|
+
throw new Error(`${issuer} created the pod at ${pod} — a suffixed pod. `
|
|
285
|
+
+ 'WebFinger is only answered for a subdomained pod, so nobody '
|
|
278
286
|
+ 'could find this group. The pod exists; no actor was published.');
|
|
279
287
|
}
|
|
288
|
+
if (!rootGiven) {
|
|
289
|
+
const place = chosenRootInNewPod(pod.endsWith('/') ? pod : pod + '/', container);
|
|
290
|
+
if (place.problem) throw new Error(`Where to store it: ${place.problem}.`);
|
|
291
|
+
root = place.root;
|
|
292
|
+
}
|
|
280
293
|
done('account', pod);
|
|
281
294
|
} else {
|
|
282
295
|
// A pod you bring must be there and carry a usable WebID before a
|
|
@@ -284,7 +297,15 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
284
297
|
// reachable, and no silent 401 later on a pod whose profile is empty.
|
|
285
298
|
const usable = await checkPod(pod);
|
|
286
299
|
if (!usable.ok) throw new Error(usable.error);
|
|
287
|
-
const
|
|
300
|
+
const podBase = pod.endsWith('/') ? pod : pod + '/';
|
|
301
|
+
if (!rootGiven) {
|
|
302
|
+
const place = chosenRoot(podBase, container);
|
|
303
|
+
if (place.problem) throw new Error(`Where to store it: ${place.problem}.`);
|
|
304
|
+
root = place.root;
|
|
305
|
+
}
|
|
306
|
+
// An account at the chosen place, or an older one where accounts used to go.
|
|
307
|
+
const exists = (r) => (deps.resourceExists || resourceExists)(deps.fetch || fetch, apUrls(pod, r).actor);
|
|
308
|
+
const held = await exists(root) || (root !== DEFAULT_ROOT && await exists(DEFAULT_ROOT));
|
|
288
309
|
if (held && !frontedAddress({ pod, shape })) {
|
|
289
310
|
throw new Error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod. '
|
|
290
311
|
+ 'To move an account whose address is at another gateway to this one, ask for an address at this gateway.');
|
|
@@ -292,6 +313,12 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
292
313
|
if (held && kind === 'group') throw new Error('The pod already hosts a FediPod account, and a group cannot move between gateways.');
|
|
293
314
|
movingIn = held;
|
|
294
315
|
if (movingIn) { privateRoot = null; }
|
|
316
|
+
// Asked before anything is minted: a profile with no public type index
|
|
317
|
+
// gets one only on its person's yes.
|
|
318
|
+
if (!movingIn && !createIndex
|
|
319
|
+
&& await (deps.publicIndexKnown || publicIndexKnown)(deps.fetch || fetch, podBase) !== true) {
|
|
320
|
+
throw needsIndex('Your profile names no public type index.');
|
|
321
|
+
}
|
|
295
322
|
skip('account', movingIn ? 'the pod already holds the account — its address moves here' : 'using the pod you already have');
|
|
296
323
|
}
|
|
297
324
|
|
|
@@ -366,8 +393,8 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
366
393
|
let gatewayCfg = gateway;
|
|
367
394
|
if (!gatewayCfg && frontedAddress({ pod, shape })) {
|
|
368
395
|
if (kind === 'group') {
|
|
369
|
-
throw new Error(`a group needs a pod
|
|
370
|
-
+ (webfingerHost(pod) ? '' : `; ${pod} is
|
|
396
|
+
throw new Error(`a group needs a subdomained pod — a Gateway address for a group is not supported yet`
|
|
397
|
+
+ (webfingerHost(pod) ? '' : `; ${pod} is suffixed`) + '.');
|
|
371
398
|
}
|
|
372
399
|
const urls = apUrls(pod, root || DEFAULT_ROOT);
|
|
373
400
|
const cred = JSON.parse(fs.readFileSync(credPath, 'utf8'));
|
|
@@ -414,6 +441,16 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
414
441
|
}
|
|
415
442
|
const published = await agent.publisher.publishProfile();
|
|
416
443
|
await agent.store.flush();
|
|
444
|
+
// Where the account lives, in the owner's public type index — made first
|
|
445
|
+
// if they said yes to one.
|
|
446
|
+
if (!movingIn) {
|
|
447
|
+
const urlsAt = apUrls(pod, root);
|
|
448
|
+
try {
|
|
449
|
+
await (deps.recordPlace || recordPlace)(agent.publisher?.remote || agent.remote, urlsAt.base, urlsAt.home + 'ap/actor', { create: !!createIndex });
|
|
450
|
+
} catch (e) {
|
|
451
|
+
throw new Error(`could not record where the account lives in your type index (${e.message})`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
417
454
|
if (movingIn) {
|
|
418
455
|
// Tell the old gateway and the followers. Not fatal: left pending, it
|
|
419
456
|
// is tried again when the agent next starts acting.
|
|
@@ -448,7 +485,7 @@ export async function runSetup({ home, agent, answers, run, deps = {}, log = ()
|
|
|
448
485
|
log(wfHost
|
|
449
486
|
? `${kind === 'group' ? 'group' : 'actor'} published: @${handle}@${wfHost}`
|
|
450
487
|
: `${kind === 'group' ? 'group' : 'actor'} published, but not reachable as a handle — `
|
|
451
|
-
+ `${pod} is
|
|
488
|
+
+ `${pod} is a suffixed pod`);
|
|
452
489
|
} catch (e) {
|
|
453
490
|
for (const s of run.steps) if (s.state === 'running') s.state = 'error';
|
|
454
491
|
run.error = scrub(e.message);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// caches.mjs — what one warm gateway process keeps between requests: the
|
|
2
|
+
// pod-token verifier, and the public keys of the servers that deliver here.
|
|
3
|
+
// Built per request, each costs round trips to somebody else's server for
|
|
4
|
+
// every call; built once, a process pays them once.
|
|
5
|
+
|
|
6
|
+
// The Solid-OIDC verifier remembers the issuers' key sets it has seen. A
|
|
7
|
+
// fresh one per call re-read the caller's WebID document, the issuer's
|
|
8
|
+
// discovery document and its keys on every signed-in call.
|
|
9
|
+
let verifier = null;
|
|
10
|
+
export async function podTokenVerifier() {
|
|
11
|
+
if (!verifier) verifier = (await import('@solid/access-token-verifier')).createSolidTokenVerifier();
|
|
12
|
+
return verifier;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The keys deliveries are signed with, by key id, in the shape Fedify's
|
|
16
|
+
// verifier asks for. A key is held an hour; a key that could not be fetched
|
|
17
|
+
// is held five minutes, so a server that was down is asked again soon.
|
|
18
|
+
// Fedify re-fetches on its own when a held key no longer verifies, so a
|
|
19
|
+
// rotated key costs one failed check, not an hour of refused mail.
|
|
20
|
+
const KEY_TTL_MS = 60 * 60_000;
|
|
21
|
+
const MISS_TTL_MS = 5 * 60_000;
|
|
22
|
+
const KEY_CACHE_MAX = 500;
|
|
23
|
+
const keys = new Map(); // key id → { key, until }
|
|
24
|
+
export const senderKeys = {
|
|
25
|
+
async get(keyId) {
|
|
26
|
+
const hit = keys.get(keyId.href);
|
|
27
|
+
if (!hit) return undefined;
|
|
28
|
+
if (hit.until < Date.now()) { keys.delete(keyId.href); return undefined; }
|
|
29
|
+
return hit.key;
|
|
30
|
+
},
|
|
31
|
+
async set(keyId, key) {
|
|
32
|
+
if (keys.size >= KEY_CACHE_MAX) keys.delete(keys.keys().next().value);
|
|
33
|
+
keys.set(keyId.href, { key, until: Date.now() + (key ? KEY_TTL_MS : MISS_TTL_MS) });
|
|
34
|
+
},
|
|
35
|
+
size: () => keys.size,
|
|
36
|
+
};
|
|
@@ -23,6 +23,10 @@ import * as podPolicy from '../pod/policy.mjs';
|
|
|
23
23
|
import { podBaseOfWebId } from '../pod/urls.mjs';
|
|
24
24
|
import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, closedAnswer } from './quiet.mjs';
|
|
25
25
|
import { routeNoticesApi } from './notices.mjs';
|
|
26
|
+
import { withSecurityHeaders } from './headers.mjs';
|
|
27
|
+
import { podTokenVerifier } from './caches.mjs';
|
|
28
|
+
import { claimedWebId } from './token-claims.mjs';
|
|
29
|
+
import { withdrawn, overLimit, relayOne } from './relay-extras.mjs';
|
|
26
30
|
|
|
27
31
|
// The one WebFinger document, spelled out here rather than imported from
|
|
28
32
|
// wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
|
|
@@ -61,7 +65,7 @@ async function verifyPodToken(request, pathname, verifier) {
|
|
|
61
65
|
// Solid-OIDC binds the token to a key the client proves on every request;
|
|
62
66
|
// a token shown without the proof is one anyone who saw it could show.
|
|
63
67
|
if (!dpop) { console.log(`front: pod token without a DPoP proof refused on ${pathname}`); return null; }
|
|
64
|
-
const v = verifier ||
|
|
68
|
+
const v = verifier || await podTokenVerifier();
|
|
65
69
|
const url = request.url;
|
|
66
70
|
const { webid } = await v(authz, { header: dpop, method: request.method, url });
|
|
67
71
|
return webid || null;
|
|
@@ -89,9 +93,13 @@ const podOwners = (podBase, fetchImpl = fetch) =>
|
|
|
89
93
|
podRoot.readOwnerLinks(fetchImpl, podBase, { timeoutMs: OWNER_LOOKUP_MS });
|
|
90
94
|
|
|
91
95
|
// Whether a token's WebID vouches for this pod, on the evidence of where it
|
|
92
|
-
// lives
|
|
96
|
+
// lives: the same origin, and — for a suffixed pod — inside
|
|
97
|
+
// the WebID's own pod, wherever in it the account was put. Weak on purpose,
|
|
98
|
+
// and never used alone for an attach — see provesPod.
|
|
93
99
|
function webidUnderPod(webid, podHome) {
|
|
94
|
-
try {
|
|
100
|
+
try {
|
|
101
|
+
return new URL(webid).origin === new URL(podHome).origin && String(podHome).startsWith(podBaseOfWebId(webid));
|
|
102
|
+
} catch { return false; }
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
/**
|
|
@@ -142,15 +150,8 @@ function podHomeProblem(podHome, frontOrigin) {
|
|
|
142
150
|
}
|
|
143
151
|
|
|
144
152
|
const RELAY_MAX_REQUESTS = 20;
|
|
145
|
-
const RELAY_MAX_BODY = 1024 * 1024;
|
|
146
|
-
const RELAY_TIMEOUT_MS = 8_000;
|
|
147
|
-
// The only headers a relayed request may carry to the remote server. Host
|
|
148
|
-
// comes from the URL; the user agent from safeFetch.
|
|
149
153
|
const SERVER_PROBE_MS = 6000;
|
|
150
154
|
const SERVER_PROBE_BYTES = 64 * 1024;
|
|
151
|
-
const RELAY_HEADERS = new Set(['date', 'digest', 'signature', 'content-type', 'accept']);
|
|
152
|
-
|
|
153
|
-
const keyIdOf = (signature) => (/keyId="([^"]+)"/.exec(signature || '') || [])[1] || null;
|
|
154
155
|
|
|
155
156
|
// The owner's APIs a browser at ANOTHER origin may call: an address moving
|
|
156
157
|
// to a new gateway is driven from the new gateway's page, and it has to tell
|
|
@@ -165,49 +166,11 @@ const API_CORS = {
|
|
|
165
166
|
const withApiCors = (out) => ({ ...out, headers: { ...(out.headers || {}), ...API_CORS } });
|
|
166
167
|
const apiPreflight = () => ({ status: 204, headers: { ...API_CORS, allow: 'POST, OPTIONS' }, body: null });
|
|
167
168
|
|
|
168
|
-
async function relayOne(item, rec, fetchImpl) {
|
|
169
|
-
const url = String(item?.url || '');
|
|
170
|
-
const method = String(item?.method || 'POST').toUpperCase();
|
|
171
|
-
if (method !== 'GET' && method !== 'POST') return { url, status: 0, error: 'method must be GET or POST' };
|
|
172
|
-
let u;
|
|
173
|
-
try { u = new URL(url); } catch { return { url, status: 0, error: 'not a URL' }; }
|
|
174
|
-
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && process.env.AP_ALLOW_PRIVATE_TARGETS === '1')) {
|
|
175
|
-
return { url, status: 0, error: 'https only' };
|
|
176
|
-
}
|
|
177
|
-
const headers = {};
|
|
178
|
-
for (const [k, v] of Object.entries(item?.headers || {})) {
|
|
179
|
-
const name = k.toLowerCase();
|
|
180
|
-
if (RELAY_HEADERS.has(name) && typeof v === 'string') headers[name] = v;
|
|
181
|
-
}
|
|
182
|
-
const body = method === 'POST' ? String(item?.body ?? '') : undefined;
|
|
183
|
-
if (body !== undefined && Buffer.byteLength(body) > RELAY_MAX_BODY) return { url, status: 0, error: 'body too large' };
|
|
184
|
-
const keyId = keyIdOf(headers.signature);
|
|
185
|
-
if (method === 'POST' && !keyId) return { url, status: 0, error: 'a delivery must be signed' };
|
|
186
|
-
if (keyId && !keyId.startsWith(rec.actorUrl + '#')) return { url, status: 0, error: "signed with a key that is not this account's" };
|
|
187
|
-
if (headers.digest) {
|
|
188
|
-
const want = 'SHA-256=' + crypto.createHash('sha256').update(body || '').digest('base64');
|
|
189
|
-
if (headers.digest !== want) return { url, status: 0, error: 'digest does not match the body' };
|
|
190
|
-
}
|
|
191
|
-
try {
|
|
192
|
-
const res = await safeFetch(url, { method, headers, body, signal: AbortSignal.timeout(RELAY_TIMEOUT_MS) }, fetchImpl);
|
|
193
|
-
const out = { url, method, status: res.status };
|
|
194
|
-
// The far server asking to be left alone has to reach the agent that will
|
|
195
|
-
// do the asking again. Without this the browser build could not honour a
|
|
196
|
-
// Retry-After at all — every delivery it makes goes through here — and fell
|
|
197
|
-
// back to its own ladder against a server that had already said how long.
|
|
198
|
-
const retryAfter = res.headers.get('retry-after');
|
|
199
|
-
if (retryAfter) out.retryAfter = retryAfter;
|
|
200
|
-
if (method === 'GET') {
|
|
201
|
-
out.contentType = res.headers.get('content-type') || null;
|
|
202
|
-
out.body = await readCapped(res, RELAY_MAX_BODY);
|
|
203
|
-
}
|
|
204
|
-
return out;
|
|
205
|
-
} catch (e) { return { url, method, status: 0, error: e.message }; }
|
|
206
|
-
}
|
|
207
|
-
|
|
208
169
|
const j = (status, obj, ct = 'application/json') =>
|
|
209
170
|
({ status, headers: { 'content-type': ct, 'cache-control': 'no-store' }, body: JSON.stringify(obj) });
|
|
210
|
-
|
|
171
|
+
// Held at the edge like any other missing document: a bot's scan, a dead
|
|
172
|
+
// handle, a typo in a WebFinger query each cost one call, not one per asker.
|
|
173
|
+
const notFound = () => ({ status: 404, headers: { 'content-type': 'text/plain', ...publicFor(MISSING_EDGE_SECONDS) }, body: 'not found\n' });
|
|
211
174
|
|
|
212
175
|
// A user's public base on the front, a 1:1 mirror of their pod home.
|
|
213
176
|
// The directory key is the FULL fediverse address — handle@host — never the
|
|
@@ -243,7 +206,9 @@ function parseUserPath(pathname) {
|
|
|
243
206
|
const BROWSER_MAX_AGE = 60;
|
|
244
207
|
const publicFor = (seconds, stale = seconds * 4) => ({
|
|
245
208
|
'cache-control': `public, max-age=${Math.min(seconds, BROWSER_MAX_AGE)}`,
|
|
246
|
-
|
|
209
|
+
// `durable`: one copy for every region, and one that a deploy does not
|
|
210
|
+
// empty. Without it each region held its own and every deploy started cold.
|
|
211
|
+
'netlify-cdn-cache-control': `public, durable, s-maxage=${seconds}, stale-while-revalidate=${stale}`,
|
|
247
212
|
});
|
|
248
213
|
// How long the edge holds a public document, and a handle's WebFinger
|
|
249
214
|
// answer. Every server that has heard of an account asks for its actor and
|
|
@@ -252,6 +217,15 @@ const publicFor = (seconds, stale = seconds * 4) => ({
|
|
|
252
217
|
// can afford.
|
|
253
218
|
const PUBLIC_EDGE_SECONDS = 600;
|
|
254
219
|
const WEBFINGER_EDGE_SECONDS = 3600;
|
|
220
|
+
// A closed or moved address stays gone; a picture's address stays where it is.
|
|
221
|
+
const GONE_EDGE_SECONDS = 3600;
|
|
222
|
+
const MEDIA_EDGE_SECONDS = 86400;
|
|
223
|
+
// A public document the pod would not give (missing, or not public) is asked
|
|
224
|
+
// for again and again by every server that shows the account — a pinned-posts
|
|
225
|
+
// collection an old account never wrote, a forum count nobody published. Held
|
|
226
|
+
// briefly, so the edge absorbs the asking; briefly, so a document published a
|
|
227
|
+
// moment later is not "missing" for long.
|
|
228
|
+
const MISSING_EDGE_SECONDS = 120;
|
|
255
229
|
|
|
256
230
|
// Where a pod owner opts their identity in. An operator may name it, because
|
|
257
231
|
// the path it takes is one their pod server can no longer serve; the dot says
|
|
@@ -344,51 +318,6 @@ export async function routeFront(request, ctx) {
|
|
|
344
318
|
return { ...out, headers: withSecurityHeaders(out.headers, out.body) };
|
|
345
319
|
}
|
|
346
320
|
|
|
347
|
-
// Every response this file makes, hardened in one place rather than in each of
|
|
348
|
-
// the dozen shapes below.
|
|
349
|
-
//
|
|
350
|
-
// `nosniff` matters most: the front serves user-supplied JSON straight from
|
|
351
|
-
// somebody's pod (the proxied actor and object documents), and without it a
|
|
352
|
-
// browser is free to decide for itself that a document is HTML and run what is
|
|
353
|
-
// inside it. The rest is the same posture the app already has — nothing may be
|
|
354
|
-
// framed, no base tag may be rewritten, no plugin content.
|
|
355
|
-
//
|
|
356
|
-
// A content-security-policy goes on the HTML only: it would mean nothing on a
|
|
357
|
-
// JSON document, and `frame-ancestors` has to be a header rather than a meta
|
|
358
|
-
// tag anyway.
|
|
359
|
-
//
|
|
360
|
-
// `script-src 'self'` is the one that matters, and it is only possible because
|
|
361
|
-
// none of these pages carries inline script any more — each has its own file and
|
|
362
|
-
// its own route above. A policy cannot tell an inline block the author wrote
|
|
363
|
-
// from one an attacker injected, so as long as any inline script has to run,
|
|
364
|
-
// every inline script may.
|
|
365
|
-
//
|
|
366
|
-
// `connect-src` allows https: because the pages sign in against the user's own
|
|
367
|
-
// pod, which is a different origin by definition and not one we can name here.
|
|
368
|
-
function withSecurityHeaders(headers = {}, body = null) {
|
|
369
|
-
const ct = String(headers['content-type'] || '');
|
|
370
|
-
const isHtml = ct.startsWith('text/html');
|
|
371
|
-
return {
|
|
372
|
-
...headers,
|
|
373
|
-
'x-content-type-options': 'nosniff',
|
|
374
|
-
'referrer-policy': 'same-origin',
|
|
375
|
-
'x-frame-options': 'SAMEORIGIN',
|
|
376
|
-
...(isHtml && body ? {
|
|
377
|
-
'content-security-policy': [
|
|
378
|
-
"default-src 'self'",
|
|
379
|
-
"script-src 'self'", // no inline script: see above
|
|
380
|
-
"style-src 'self' 'unsafe-inline'",
|
|
381
|
-
"img-src 'self' https: data:",
|
|
382
|
-
"connect-src 'self' https:", // sign-in goes to the user's own pod
|
|
383
|
-
"object-src 'none'", // no plugin content, ever
|
|
384
|
-
"base-uri 'none'", // no rewriting where relative URLs resolve
|
|
385
|
-
"frame-ancestors 'self'", // nobody else may frame these pages
|
|
386
|
-
"form-action 'self'", // a form here submits here
|
|
387
|
-
].join('; '),
|
|
388
|
-
} : {}),
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
|
-
|
|
392
321
|
async function route(request, ctx) {
|
|
393
322
|
const url = new URL(request.url);
|
|
394
323
|
const { pathname } = url;
|
|
@@ -643,8 +572,8 @@ async function route(request, ctx) {
|
|
|
643
572
|
}
|
|
644
573
|
|
|
645
574
|
// The owner's say over an account that goes quiet (quiet.mjs).
|
|
646
|
-
const quiet = await routeQuietApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod });
|
|
647
|
-
if (quiet) return quiet;
|
|
575
|
+
const quiet = await routeQuietApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod, apiPreflight });
|
|
576
|
+
if (quiet) return withApiCors(quiet);
|
|
648
577
|
|
|
649
578
|
// The relay: the front sends requests a browser has already signed. A page
|
|
650
579
|
// may not set the Date or Host header, and both are inside an HTTP
|
|
@@ -669,7 +598,11 @@ async function route(request, ctx) {
|
|
|
669
598
|
// Acting through the relay is being here. Hourly at most; see noteOpened.
|
|
670
599
|
await noteOpened(ctx, handle, rec).catch((e) => console.log(`relay @${handle}: stamp not written: ${e?.message || e}`));
|
|
671
600
|
if (items.length > RELAY_MAX_REQUESTS) return withApiCors(j(400, { error: `at most ${RELAY_MAX_REQUESTS} requests per call` }));
|
|
601
|
+
const slow = overLimit(handle, items.length) && j(429, { error: 'too many relayed requests — slow down' });
|
|
602
|
+
if (slow) return withApiCors({ ...slow, headers: { ...slow.headers, 'retry-after': '60' } });
|
|
672
603
|
const results = await Promise.all(items.map((it) => relayOne(it, rec, ctx.fetchImpl || fetch)));
|
|
604
|
+
// Something of the account's withdrawn: the edge's copies go.
|
|
605
|
+
if (withdrawn(items, rec.actorUrl)) await ctx.purge?.([`u-${handle}`]).catch((e) => console.log(`purge @${handle}: ${e?.message || e}`));
|
|
673
606
|
// One line per relayed request, so a lookup that fails on the far side is
|
|
674
607
|
// visible here and not only as an empty result in someone's browser.
|
|
675
608
|
for (const r of results) console.log(`relay @${handle}: ${r.method || ''} ${r.url} → ${r.status}${r.error ? ` (${r.error})` : ''}`);
|
|
@@ -754,7 +687,8 @@ async function route(request, ctx) {
|
|
|
754
687
|
return j(403, { error: 'the token proves a different pod than the one you listed' });
|
|
755
688
|
}
|
|
756
689
|
if (action === 'opt-in') {
|
|
757
|
-
const { httpStatus, ...reply } = await ctx.agentControl.optIn({ podBase, webId: webid
|
|
690
|
+
const { httpStatus, ...reply } = await ctx.agentControl.optIn({ podBase, webId: webid,
|
|
691
|
+
container: String(body.container || ''), createIndex: body.createIndex === true });
|
|
758
692
|
if (reply.doorSecret) {
|
|
759
693
|
// The secret appears here and nowhere else; the command is the
|
|
760
694
|
// paste-and-run way to open the owner door once.
|
|
@@ -808,7 +742,7 @@ async function route(request, ctx) {
|
|
|
808
742
|
if (!rec) return notFound();
|
|
809
743
|
// A closed address is gone, and says so rather than pretending never to
|
|
810
744
|
// have existed: the name stays taken.
|
|
811
|
-
if ((await closedState(ctx, m[1], rec)).closed) return closedAnswer({ 'access-control-allow-origin': '*' });
|
|
745
|
+
if ((await closedState(ctx, m[1], rec)).closed) return closedAnswer({ 'access-control-allow-origin': '*', ...publicFor(GONE_EDGE_SECONDS) });
|
|
812
746
|
// A fronted identity's documents live on its pod; the pod's own actor id
|
|
813
747
|
// is the alias, so a client signing in by the fronted address can find
|
|
814
748
|
// the pod (and its login) without a lookup only the host could answer.
|
|
@@ -906,13 +840,28 @@ async function route(request, ctx) {
|
|
|
906
840
|
if (!webid) return json(401, { error: 'a Solid-OIDC token proving this account\'s owner is required' });
|
|
907
841
|
const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
|
|
908
842
|
if (!owner) return json(403, { error: 'this outbox belongs to its owner alone' });
|
|
909
|
-
const
|
|
910
|
-
|
|
843
|
+
const onPod = (u) => u.replace(rec.actorUrl.replace(/ap\/actor$/u, ''), rec.podHome);
|
|
844
|
+
const { status, reason, location, object } = await handleOwnerPost(request, identFor(rec),
|
|
845
|
+
{ podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), ownerWebId: webid,
|
|
846
|
+
exists: async (u) => ((await (ctx.podGet || fetch)(onPod(u), { podHome: rec.podHome })).status !== 404) });
|
|
911
847
|
console.log(`door @${up.handle}: owner post → ${status} (${reason})`);
|
|
912
|
-
if (status !==
|
|
913
|
-
return json(
|
|
848
|
+
if (status !== 201) return json(status, { error: reason });
|
|
849
|
+
return json(201, { accepted: true, ...(location ? { id: location } : {}), ...(object ? { object } : {}),
|
|
914
850
|
note: 'it goes out when your FediPod agent next runs' }, location ? { location } : {});
|
|
915
851
|
}
|
|
852
|
+
// Its owner, signed in, reads every message the account produced (§5.1:
|
|
853
|
+
// the outbox is filtered by who asks); that view is on the pod, under the
|
|
854
|
+
// owner's own rule. Anyone else reads the public one.
|
|
855
|
+
// The token is only read here, not verified: the redirect grants nothing,
|
|
856
|
+
// the pod checks the credential when the owner arrives there, and a
|
|
857
|
+
// verification per read would let anyone make the front fetch keys from a
|
|
858
|
+
// server they name.
|
|
859
|
+
if ((request.method === 'GET' || request.method === 'HEAD') && request.headers.get('authorization')) {
|
|
860
|
+
const webid = claimedWebId(request);
|
|
861
|
+
if (webid && (rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome))) {
|
|
862
|
+
return { status: 303, headers: { ...cors, location: rec.podHome + 'ap/private/outbox', 'cache-control': 'no-store' }, body: '' };
|
|
863
|
+
}
|
|
864
|
+
}
|
|
916
865
|
if (rec.inboxOnly && (request.method === 'GET' || request.method === 'HEAD')) {
|
|
917
866
|
return { status: 303, headers: { ...cors, location: rec.podHome + 'ap/outbox', 'cache-control': 'no-store' }, body: '' };
|
|
918
867
|
}
|
|
@@ -935,14 +884,20 @@ async function route(request, ctx) {
|
|
|
935
884
|
if (movedBase && up.rest !== 'ap/actor') {
|
|
936
885
|
return { status: 301, headers: { ...open, location: movedBase + up.rest, 'cache-control': 'no-store' }, body: '' };
|
|
937
886
|
}
|
|
938
|
-
if (closed) return gone(open, CLOSED);
|
|
887
|
+
if (closed) return gone({ ...open, ...publicFor(GONE_EDGE_SECONDS) }, CLOSED);
|
|
939
888
|
const podTarget = rec.podHome + up.rest;
|
|
889
|
+
// The owner's own documents — the full outbox, the liked list, the pending
|
|
890
|
+
// lists — are read at the pod, which decides who may; never held here,
|
|
891
|
+
// where one reader's answer would be served to the next.
|
|
892
|
+
if (up.rest.startsWith('ap/private/')) {
|
|
893
|
+
return { status: 303, headers: { ...open, location: podTarget, 'cache-control': 'no-store' }, body: '' };
|
|
894
|
+
}
|
|
940
895
|
// Media stays on the pod (lib/pod/urls.mjs keeps `media` off the front), but
|
|
941
896
|
// the id rewrite below turns media links onto the front like every other
|
|
942
897
|
// pod url in a document. Answer those by pointing at the pod: bytes are not
|
|
943
898
|
// a document to cap and relabel, and remotes follow a redirect for a picture.
|
|
944
899
|
if (up.rest.startsWith('ap/media/')) {
|
|
945
|
-
return { status: 302, headers: { location: podTarget,
|
|
900
|
+
return { status: 302, headers: { location: podTarget, ...publicFor(MEDIA_EDGE_SECONDS) }, body: '' };
|
|
946
901
|
}
|
|
947
902
|
// The pod this read belongs to travels with it: an adapter reading a store
|
|
948
903
|
// directly (the CSS server component) has no access control of its own and
|
|
@@ -958,7 +913,11 @@ async function route(request, ctx) {
|
|
|
958
913
|
const got = await podRoot.readPublicDocument(
|
|
959
914
|
ctx.podGet || ((u) => fetch(u, { headers: { accept } })),
|
|
960
915
|
podTarget, { podHome: rec.podHome, accept });
|
|
961
|
-
if (got.text === null)
|
|
916
|
+
if (got.text === null) {
|
|
917
|
+
const hold = [401, 403, 404, 410].includes(got.status) ? { ...publicFor(MISSING_EDGE_SECONDS), 'netlify-cache-tag': `u-${up.handle}` } : {};
|
|
918
|
+
// A stranger is not told a document exists that they may not read (§3.2).
|
|
919
|
+
return { status: got.status === 401 || got.status === 403 ? 404 : got.status, headers: { ...open, ...hold }, body: '' };
|
|
920
|
+
}
|
|
962
921
|
let text = got.text;
|
|
963
922
|
text = swap(text, rec.podHome, base);
|
|
964
923
|
// The moved stub: the pod's actor now carries the NEW gateway's ids, and a
|
|
@@ -973,6 +932,9 @@ async function route(request, ctx) {
|
|
|
973
932
|
doc.preferredUsername = up.handle; // so @handle@front cross-checks
|
|
974
933
|
doc.inbox = base + 'ap/inbox/'; // deliveries come to the front to be verified
|
|
975
934
|
doc.endpoints = { ...(doc.endpoints || {}), sharedInbox: base + 'ap/inbox/' };
|
|
935
|
+
// The owner's own documents are read at the pod, where their credential
|
|
936
|
+
// works; they keep the pod's address.
|
|
937
|
+
for (const k of ['liked', 'ownerOutbox']) if (typeof doc[k] === 'string') doc[k] = doc[k].replace(base, rec.podHome);
|
|
976
938
|
if (movedBase) {
|
|
977
939
|
doc.movedTo = rec.movedTo;
|
|
978
940
|
const aka = new Set([].concat(doc.alsoKnownAs || []).filter((a) => typeof a === 'string' && a !== doc.id));
|
|
@@ -988,9 +950,16 @@ async function route(request, ctx) {
|
|
|
988
950
|
} catch { /* leave the rewritten text as-is if it will not parse */ }
|
|
989
951
|
}
|
|
990
952
|
// `readPublicDocument` asked for it with no credential, so a document that
|
|
991
|
-
// came back is one anybody can read — which is what makes it cacheable.
|
|
992
|
-
|
|
993
|
-
|
|
953
|
+
// came back is one anybody can read — which is what makes it cacheable. A
|
|
954
|
+
// deleted post's Tombstone answers 410 Gone, and still says what it was.
|
|
955
|
+
let kind = null;
|
|
956
|
+
try { kind = JSON.parse(text)?.type; } catch { /* not JSON: a page or a container */ }
|
|
957
|
+
return { status: kind === 'Tombstone' ? 410 : 200,
|
|
958
|
+
headers: { ...open, 'content-type': got.type || (container ? 'text/turtle' : page ? 'text/html' : AP_CT), ...publicFor(PUBLIC_EDGE_SECONDS),
|
|
959
|
+
// The outbox answers its signed-in owner differently, so the edge keeps
|
|
960
|
+
// the public copy for those who show no credential.
|
|
961
|
+
...(up.rest === 'ap/outbox' ? { 'netlify-vary': 'header=Authorization', vary: 'Authorization' } : {}),
|
|
962
|
+
'netlify-cache-tag': `u-${up.handle}` },
|
|
994
963
|
body: text };
|
|
995
964
|
}
|
|
996
965
|
|