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
package/web/app/index.html
CHANGED
|
@@ -191,6 +191,13 @@ a { color: var(--accent); }
|
|
|
191
191
|
<span id="signed-pod-label" class="grouplabel">Your pod</span>
|
|
192
192
|
<p id="signed-pod" role="status" aria-labelledby="signed-pod-label"></p>
|
|
193
193
|
|
|
194
|
+
<div id="container-field">
|
|
195
|
+
<label for="container">Store your data in a container named fedipod, inside this container</label>
|
|
196
|
+
<input type="text" id="container" name="container" autocomplete="off" spellcheck="false"
|
|
197
|
+
aria-describedby="container-hint">
|
|
198
|
+
<p class="hint" id="container-hint">Your data will be at <span id="place">…</span>. This is permanent.</p>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
194
201
|
<!-- Shown when the pod's account lives at another gateway: this is a
|
|
195
202
|
move, not a new account. -->
|
|
196
203
|
<p id="movein-note" hidden>Moving <strong id="movein-from"></strong> to this site.
|
|
@@ -217,6 +224,15 @@ a { color: var(--accent); }
|
|
|
217
224
|
<p class="hint">The handle and the pod cannot be changed afterwards. Everything else — display
|
|
218
225
|
name, bio, pictures — you set later in the client.</p>
|
|
219
226
|
<p class="err" id="form-error" role="alert"></p>
|
|
227
|
+
<div id="index-ask" role="alertdialog" aria-labelledby="index-ask-text" hidden>
|
|
228
|
+
<p id="index-ask-text">Your pod has no public type index, the list Solid apps use to find your
|
|
229
|
+
things. FediPod needs one to record where your account lives. Create it? It will be readable
|
|
230
|
+
by anyone, like your profile.</p>
|
|
231
|
+
<p>
|
|
232
|
+
<button type="button" id="index-no">No, stop</button>
|
|
233
|
+
<button type="button" class="primary" id="index-yes">Create it and continue</button>
|
|
234
|
+
</p>
|
|
235
|
+
</div>
|
|
220
236
|
<p>
|
|
221
237
|
<button type="button" id="back-1">Back</button>
|
|
222
238
|
<button type="submit" class="primary" id="submit">Create your Fediverse account</button>
|
package/web/app/signup.mjs
CHANGED
|
@@ -16,14 +16,18 @@ import * as podState from '../../lib/pod/state.mjs';
|
|
|
16
16
|
import { resourceExists } from '../../lib/pod/root.mjs';
|
|
17
17
|
import { podBaseOfWebId } from '../../lib/pod/urls.mjs';
|
|
18
18
|
import { cacheOpenedKeys } from './keys-browser.mjs';
|
|
19
|
+
import { chosenRoot, findAccount, recordPlace, hasPublicIndex } from '../../lib/core/place.mjs';
|
|
19
20
|
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
const
|
|
21
|
+
// Everything the agent publishes hangs under a container named `fedipod`, in
|
|
22
|
+
// the container its owner chose (lib/core/place.mjs). The root is stored on the
|
|
23
|
+
// config, so the agent reads it rather than guessing, and the place is recorded
|
|
24
|
+
// in the owner's public type index, which is how the account is found again.
|
|
25
|
+
const actorUrlFor = (pod, root) => `${pod}${root}ap/actor`;
|
|
26
|
+
const keysDocFor = (pod, root) => `${pod}${root}ap-state/keys.json`;
|
|
27
|
+
const stateOf = (pod, root) => `${pod}${root}ap-state/`;
|
|
28
|
+
|
|
29
|
+
/** The account's config as it sits under `root`, read as its owner. */
|
|
30
|
+
const readConfigAt = (remote, pod) => (root) => podState.readConfig(remote, { state: stateOf(pod, root) });
|
|
27
31
|
|
|
28
32
|
const HANDLE_RE = /^[a-z0-9-]{2,30}$/;
|
|
29
33
|
|
|
@@ -42,7 +46,7 @@ export function handleProblem(handle) {
|
|
|
42
46
|
// identity being built, so changing the handle on the form starts a clean
|
|
43
47
|
// attempt.
|
|
44
48
|
const PROGRESS = new Map();
|
|
45
|
-
const progressKey = (webId, a) => [webId, a.handle, a.shape || 'pod'].join('|');
|
|
49
|
+
const progressKey = (webId, a) => [webId, a.handle, a.shape || 'pod', a.container || ''].join('|');
|
|
46
50
|
|
|
47
51
|
/** A fronted name is one per gateway; a taken one is refused before anything is made. */
|
|
48
52
|
async function assertFrontNameFree(frontOrigin, handle) {
|
|
@@ -58,7 +62,12 @@ async function assertFrontNameFree(frontOrigin, handle) {
|
|
|
58
62
|
* note) so a page can draw the same tick list the CLI setup shows. Resumable:
|
|
59
63
|
* see PROGRESS above.
|
|
60
64
|
*
|
|
61
|
-
* answers: { handle, shape?: 'pod'|'front', gateway? }
|
|
65
|
+
* answers: { handle, shape?: 'pod'|'front', gateway?, container?, createIndex? }
|
|
66
|
+
* container the path of the container that holds `fedipod/` (the pod root
|
|
67
|
+
* when empty)
|
|
68
|
+
* createIndex the person's yes to making a public type index when their
|
|
69
|
+
* profile names none; without it, sign-up stops before writing
|
|
70
|
+
* anything and says so (error code 'needs-index')
|
|
62
71
|
* session: { webId, issuer, fetch } — the Solid-OIDC session from the pod's
|
|
63
72
|
* own login (oidc-session.mjs). The pod is where the WebID lives.
|
|
64
73
|
*
|
|
@@ -91,11 +100,27 @@ export async function signUp(answers, { session, onStep = () => {}, frontOrigin
|
|
|
91
100
|
skip: (note) => onStep(key, 'skipped', note),
|
|
92
101
|
});
|
|
93
102
|
|
|
103
|
+
const place = chosenRoot(pod, answers.container);
|
|
104
|
+
if (place.problem) throw new Error(`Where to store it: ${place.problem}.`);
|
|
105
|
+
const root = place.root;
|
|
106
|
+
const remote = new BrowserRemotePod(session, { webId, role: 'signup', log: () => {} });
|
|
107
|
+
|
|
94
108
|
// --- the pod --- (the session came from it; what is checked is that it has no account yet)
|
|
95
109
|
const podStep = step('pod');
|
|
96
110
|
if (!prog.pod) {
|
|
97
111
|
podStep.running('checking your pod');
|
|
98
|
-
if (await
|
|
112
|
+
if (await findAccount(remote, pod, readConfigAt(remote, pod))
|
|
113
|
+
|| await resourceExists(session.fetch, actorUrlFor(pod, root))) {
|
|
114
|
+
throw new Error('The pod already hosts a FediPod account. If you want a second account, put it on a different pod.');
|
|
115
|
+
}
|
|
116
|
+
if (await resourceExists(session.fetch, `${pod}${root}`)) {
|
|
117
|
+
throw new Error(`${new URL(pod + root).pathname} is already there and is not a FediPod account — choose another container.`);
|
|
118
|
+
}
|
|
119
|
+
// Where the account lives is recorded in the public type index. With none,
|
|
120
|
+
// the person is asked first; nothing is written before they have said yes.
|
|
121
|
+
if (!answers.createIndex && !await hasPublicIndex(remote, pod)) {
|
|
122
|
+
throw Object.assign(new Error('Your profile names no public type index.'), { code: 'needs-index' });
|
|
123
|
+
}
|
|
99
124
|
prog.pod = pod;
|
|
100
125
|
podStep.ok(pod);
|
|
101
126
|
} else {
|
|
@@ -108,11 +133,11 @@ export async function signUp(answers, { session, onStep = () => {}, frontOrigin
|
|
|
108
133
|
const pathPod = new URL(pod).pathname !== '/';
|
|
109
134
|
const fronted = pathPod || wantsFront;
|
|
110
135
|
if (fronted && !frontOrigin) {
|
|
111
|
-
throw new Error(`${pod} is a
|
|
136
|
+
throw new Error(`${pod} is a suffixed pod, so its address must live at a gateway, and this page has none.`);
|
|
112
137
|
}
|
|
113
138
|
if (pathPod && !wantsFront) await assertFrontNameFree(frontOrigin, handle);
|
|
114
139
|
|
|
115
|
-
const actorUrl = actorUrlFor(pod);
|
|
140
|
+
const actorUrl = actorUrlFor(pod, root); // where the documents live, always
|
|
116
141
|
const frontActor = fronted ? `${frontOrigin.replace(/\/$/, '')}/u/${handle}/ap/actor` : null;
|
|
117
142
|
|
|
118
143
|
// Connect the gateway (fedipod.net) before the key is made, because a
|
|
@@ -133,7 +158,7 @@ export async function signUp(answers, { session, onStep = () => {}, frontOrigin
|
|
|
133
158
|
// bare pod root sends this identity's mail to <pod>/ap/inbox/ — outside
|
|
134
159
|
// the container the agent drains, where nothing would ever read it. The
|
|
135
160
|
// manage surface has always sent `urls.home`; this is the same value.
|
|
136
|
-
body: JSON.stringify({ handle, podHome: `${pod}${
|
|
161
|
+
body: JSON.stringify({ handle, podHome: `${pod}${root}`, actorUrl, kind: 'person', fronted }),
|
|
137
162
|
});
|
|
138
163
|
const d = await res.json().catch(() => ({}));
|
|
139
164
|
if (res.status !== 201 || !d.hmacSecret) {
|
|
@@ -163,7 +188,6 @@ export async function signUp(answers, { session, onStep = () => {}, frontOrigin
|
|
|
163
188
|
// The opened copy is kept in this browser (IndexedDB) so the worker can boot
|
|
164
189
|
// itself after an idle kill with no read. A browser that has no copy reads
|
|
165
190
|
// the pod's with its session — see keys-browser.mjs.
|
|
166
|
-
const remote = new BrowserRemotePod(session, { webId, role: 'signup', log: () => {} });
|
|
167
191
|
const keysStep = step('keys');
|
|
168
192
|
let keys;
|
|
169
193
|
if (!prog.keysStored) {
|
|
@@ -177,13 +201,13 @@ export async function signUp(answers, { session, onStep = () => {}, frontOrigin
|
|
|
177
201
|
// pod this key may sit on.
|
|
178
202
|
try {
|
|
179
203
|
await podState.provisionKey(remote, {
|
|
180
|
-
stateUrl:
|
|
181
|
-
keysUrl: keysDocFor(pod),
|
|
204
|
+
stateUrl: stateOf(pod, root),
|
|
205
|
+
keysUrl: keysDocFor(pod, root),
|
|
182
206
|
keys,
|
|
183
207
|
});
|
|
184
208
|
} catch (e) {
|
|
185
209
|
throw new Error(`could not store the signing key on the pod (${e.message}). `
|
|
186
|
-
+ `You are signed in as ${webId} — that WebID must own ${pod} and
|
|
210
|
+
+ `You are signed in as ${webId} — that WebID must own ${pod} and ${root} in it must be writable by it.`);
|
|
187
211
|
}
|
|
188
212
|
// This browser's own opened copy, so the boot that follows needs no read.
|
|
189
213
|
// Best effort: a browser that refuses IndexedDB (private mode) reads the
|
|
@@ -197,7 +221,7 @@ export async function signUp(answers, { session, onStep = () => {}, frontOrigin
|
|
|
197
221
|
}
|
|
198
222
|
|
|
199
223
|
const config = {
|
|
200
|
-
remotePod: pod, root
|
|
224
|
+
remotePod: pod, root, handle, name: handle, issuer: String(session.issuer || '').replace(/\/+$/, ''),
|
|
201
225
|
createdAt: new Date().toISOString(),
|
|
202
226
|
...(gateway ? { gateway } : {}),
|
|
203
227
|
};
|
|
@@ -205,11 +229,24 @@ export async function signUp(answers, { session, onStep = () => {}, frontOrigin
|
|
|
205
229
|
// sign-in — which arrives with only an OIDC session — can read the account's
|
|
206
230
|
// config and key from the pod and boot.
|
|
207
231
|
try {
|
|
208
|
-
await podState.writeConfig(remote, { state:
|
|
232
|
+
await podState.writeConfig(remote, { state: stateOf(pod, root) }, config);
|
|
209
233
|
} catch (e) {
|
|
210
234
|
throw new Error(`could not store the config on the pod (${e.message})`);
|
|
211
235
|
}
|
|
212
236
|
|
|
237
|
+
// --- where it lives, in the public type index ---
|
|
238
|
+
if (!prog.placed) {
|
|
239
|
+
const placeStep = step('place');
|
|
240
|
+
placeStep.running('recording where your account lives');
|
|
241
|
+
try {
|
|
242
|
+
await recordPlace(remote, pod, actorUrl, { create: !!answers.createIndex });
|
|
243
|
+
} catch (e) {
|
|
244
|
+
throw new Error(`could not record where your account lives in your type index (${e.message})`);
|
|
245
|
+
}
|
|
246
|
+
prog.placed = true;
|
|
247
|
+
placeStep.ok();
|
|
248
|
+
}
|
|
249
|
+
|
|
213
250
|
PROGRESS.delete(key); // finished — nothing left to resume
|
|
214
251
|
|
|
215
252
|
const host = gateway?.frontActor ? new URL(gateway.frontActor).host : new URL(pod).host;
|
|
@@ -232,14 +269,16 @@ export async function signUp(answers, { session, onStep = () => {}, frontOrigin
|
|
|
232
269
|
export async function readAccount(session) {
|
|
233
270
|
const pod = podBaseOfWebId(session.webId);
|
|
234
271
|
const remote = new BrowserRemotePod(session, { webId: session.webId, role: 'signup', log: () => {} });
|
|
235
|
-
|
|
236
|
-
|
|
272
|
+
// A person account: the browser build is persons only.
|
|
273
|
+
const found = await findAccount(remote, pod, readConfigAt(remote, pod), (c) => (c.kind || 'person') === 'person');
|
|
274
|
+
if (!found) return null;
|
|
275
|
+
const { root, config } = found;
|
|
237
276
|
const frontActor = config.gateway?.frontActor || null;
|
|
238
277
|
let frontHost = null;
|
|
239
278
|
try { frontHost = frontActor ? new URL(frontActor).host : null; } catch { frontHost = null; }
|
|
240
279
|
let podHost = '';
|
|
241
280
|
try { podHost = new URL(config.remotePod || pod).host; } catch { podHost = ''; }
|
|
242
|
-
return { pod, config, frontActor, frontHost, address: `@${config.handle}@${frontHost || podHost}` };
|
|
281
|
+
return { pod, root, config, frontActor, frontHost, address: `@${config.handle}@${frontHost || podHost}` };
|
|
243
282
|
}
|
|
244
283
|
|
|
245
284
|
/**
|
|
@@ -268,9 +307,9 @@ export async function moveIn(answers, { session, onStep = () => {}, frontOrigin
|
|
|
268
307
|
const here = await readAccount(session);
|
|
269
308
|
if (!here?.frontActor) throw new Error('this pod holds no account at another gateway to move here');
|
|
270
309
|
if (here.frontHost === new URL(origin).host) throw new Error('this account already lives at this gateway');
|
|
271
|
-
const { pod, config: old } = here;
|
|
310
|
+
const { pod, root, config: old } = here;
|
|
272
311
|
const remote = new BrowserRemotePod(session, { webId: session.webId, role: 'signup', log: () => {} });
|
|
273
|
-
const urls = { state:
|
|
312
|
+
const urls = { state: stateOf(pod, root) };
|
|
274
313
|
const keys = await podState.readKeys(remote, urls);
|
|
275
314
|
if (!keys?.rsa) throw new Error('could not read this account\'s signing key on the pod');
|
|
276
315
|
if (isKeyEnvelope(keys)) throw new Error(`this account's key is still under a password from an earlier version — sign in once at ${here.frontHost} first, then come back`);
|
|
@@ -282,7 +321,7 @@ export async function moveIn(answers, { session, onStep = () => {}, frontOrigin
|
|
|
282
321
|
await assertFrontNameFree(origin, handle);
|
|
283
322
|
const res = await session.fetch(`${origin}/api/attach`, {
|
|
284
323
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
285
|
-
body: JSON.stringify({ handle, podHome: `${pod}${
|
|
324
|
+
body: JSON.stringify({ handle, podHome: `${pod}${root}`, actorUrl: actorUrlFor(pod, root), kind: old.kind || 'person', fronted: true }),
|
|
286
325
|
});
|
|
287
326
|
const d = await res.json().catch(() => ({}));
|
|
288
327
|
if (res.status !== 201 || !d.hmacSecret) throw new Error(`could not attach to this gateway (HTTP ${res.status}): ${d.error || ''}`);
|
|
@@ -311,7 +350,7 @@ export async function moveIn(answers, { session, onStep = () => {}, frontOrigin
|
|
|
311
350
|
} catch (e) {
|
|
312
351
|
throw new Error(`could not update your account on the pod (${e.message})`);
|
|
313
352
|
}
|
|
314
|
-
await cacheOpenedKeys(actorUrlFor(pod), keys);
|
|
353
|
+
await cacheOpenedKeys(actorUrlFor(pod, root), keys);
|
|
315
354
|
keysStep.ok();
|
|
316
355
|
|
|
317
356
|
return { config, address: `@${handle}@${new URL(origin).host}`, movedFrom: config.movedFrom };
|
package/web/app/sw-src.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { BrowserAgent } from './agent.mjs';
|
|
10
10
|
import { getSession } from './oidc-session.mjs';
|
|
11
11
|
import { ADMIN_PATHS } from './admin-facade.mjs';
|
|
12
|
+
import { bridge } from '../../lib/client/masto/bridge.mjs';
|
|
12
13
|
|
|
13
14
|
let agent = null;
|
|
14
15
|
let booting = null;
|
|
@@ -105,17 +106,19 @@ self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
|
|
|
105
106
|
// browsers kill an idle service worker and restart it fresh, and the client at
|
|
106
107
|
// /app/ never posts 'boot' — so without on-demand boot every request after a
|
|
107
108
|
// restart 503s. On a boot failure `booting` is cleared so the next request retries.
|
|
108
|
-
async function bootFromSession(frontOrigin) {
|
|
109
|
+
async function bootFromSession(frontOrigin, restart) {
|
|
109
110
|
const oidc = await getSession();
|
|
110
111
|
if (!oidc) throw new Error('no session — sign in first');
|
|
111
112
|
const a = new BrowserAgent({ log: (m) => console.log('[sw-agent]', m) });
|
|
112
|
-
await a.boot({ oidc, frontOrigin: frontOrigin || self.location.origin });
|
|
113
|
+
await a.boot({ oidc, frontOrigin: frontOrigin || self.location.origin, restart });
|
|
113
114
|
agent = a;
|
|
114
115
|
return a;
|
|
115
116
|
}
|
|
116
|
-
|
|
117
|
+
// `restart`: booting on demand after the browser killed the worker, not
|
|
118
|
+
// because the owner just signed in.
|
|
119
|
+
function ensureBooting(frontOrigin, { restart = false } = {}) {
|
|
117
120
|
if (agent) return Promise.resolve(agent);
|
|
118
|
-
if (!booting) booting = bootFromSession(frontOrigin).catch((err) => { booting = null; throw err; });
|
|
121
|
+
if (!booting) booting = bootFromSession(frontOrigin, restart).catch((err) => { booting = null; throw err; });
|
|
119
122
|
return booting;
|
|
120
123
|
}
|
|
121
124
|
|
|
@@ -144,6 +147,10 @@ self.addEventListener('message', (e) => {
|
|
|
144
147
|
self.addEventListener('fetch', (e) => {
|
|
145
148
|
const url = new URL(e.request.url);
|
|
146
149
|
if (url.origin !== self.location.origin || !(isFacade(url.pathname) || isAdmin(url.pathname))) return; // static → network
|
|
150
|
+
// A Mastodon app somewhere else signing in at this address, or the pod
|
|
151
|
+
// sending its owner back to that sign-in: the gateway's, not this browser's
|
|
152
|
+
// agent, whose own client signs in from a page on this origin.
|
|
153
|
+
if (url.pathname.startsWith('/oauth/') && !sameOriginReferrer(e.request)) return;
|
|
147
154
|
e.respondWith(serve(e.request, url));
|
|
148
155
|
});
|
|
149
156
|
|
|
@@ -156,63 +163,18 @@ async function serve(request, url) {
|
|
|
156
163
|
}
|
|
157
164
|
// Boot on demand from the stored session, so the client works even when the
|
|
158
165
|
// worker was restarted (idle-killed) and nobody posted 'boot' this time.
|
|
159
|
-
if (!agent) { try { await ensureBooting(); } catch { /* no session → 503 below */ } }
|
|
166
|
+
if (!agent) { try { await ensureBooting(undefined, { restart: true }); } catch { /* no session → 503 below */ } }
|
|
160
167
|
if (!agent) return json(503, { error: 'not signed in on this browser — sign in at the front page' });
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
// which is most of a JPEG, so the boundary search found nothing and every
|
|
164
|
-
// media and avatar upload answered "422 file required". readBody() does
|
|
165
|
-
// `data += chunk`, which decodes a Buffer the same way it always did, so the
|
|
166
|
-
// JSON and form paths are unchanged.
|
|
167
|
-
const bodyBytes = (request.method === 'GET' || request.method === 'HEAD')
|
|
168
|
-
? null : Buffer.from(new Uint8Array(await request.arrayBuffer()));
|
|
169
|
-
// The admin facade is JSON-only (the gate above enforces the content type),
|
|
170
|
-
// and takes its body already decoded.
|
|
171
|
-
const bodyText = bodyBytes ? new TextDecoder().decode(bodyBytes) : '';
|
|
172
|
-
const reqHeaders = {}; for (const [k, v] of request.headers) reqHeaders[k.toLowerCase()] = v;
|
|
173
|
-
// `host` is a forbidden header name, so a fetch Request never carries one and
|
|
174
|
-
// the loop above cannot produce it — but it is a header every real request
|
|
175
|
-
// arrives with, and the agent reads it to say where it lives. Without it the
|
|
176
|
-
// notifications `Link` header named `https://undefined/`, so a client that
|
|
177
|
-
// paged by following it (which is how a client is meant to page) walked off
|
|
178
|
-
// the origin and saw nothing past the first screen.
|
|
179
|
-
reqHeaders.host = url.host;
|
|
180
|
-
const listeners = {};
|
|
181
|
-
// The body's events fire on the next microtask. A route that reads the body
|
|
182
|
-
// may only register for them after an await or two — the facade dispatches
|
|
183
|
-
// through its area modules first — so a listener that arrives after the
|
|
184
|
-
// events have fired is given them at once, in the order it asks.
|
|
185
|
-
let fired = false;
|
|
186
|
-
const req = { method: request.method, url: url.pathname + url.search, headers: reqHeaders,
|
|
187
|
-
// notAllowed() above let this through, so it came from this origin. Said
|
|
188
|
-
// out loud rather than left implicit: MastoApi asks (through the
|
|
189
|
-
// authorities object in agent.mjs) whether a request is the owner's own,
|
|
190
|
-
// and in a browser that question means exactly this.
|
|
168
|
+
const { req, res, bodyText, response } = await bridge(request, url, {
|
|
169
|
+
// notAllowed() above let this through, so it came from this origin.
|
|
191
170
|
sameOrigin: true,
|
|
192
|
-
socket: { encrypted: url.protocol === 'https:' },
|
|
193
|
-
on(ev, cb) {
|
|
194
|
-
(listeners[ev] ||= []).push(cb);
|
|
195
|
-
if (fired) { if (ev === 'data' && bodyBytes?.length) cb(bodyBytes); else if (ev === 'end') cb(); }
|
|
196
|
-
return req;
|
|
197
|
-
},
|
|
198
|
-
destroy() {} };
|
|
199
|
-
queueMicrotask(() => {
|
|
200
|
-
fired = true;
|
|
201
|
-
if (bodyBytes?.length) (listeners.data || []).forEach((cb) => cb(bodyBytes));
|
|
202
|
-
(listeners.end || []).forEach((cb) => cb());
|
|
203
171
|
});
|
|
204
|
-
let status = 200; const outHeaders = {}; const chunks = [];
|
|
205
|
-
const res = {
|
|
206
|
-
writeHead(s, h) { status = s; if (h) Object.assign(outHeaders, h); return res; },
|
|
207
|
-
setHeader(k, v) { outHeaders[k] = v; }, getHeader(k) { return outHeaders[k]; },
|
|
208
|
-
write(c) { chunks.push(c); }, end(c) { if (c) chunks.push(c); },
|
|
209
|
-
};
|
|
210
172
|
try {
|
|
211
173
|
const handled = isAdmin(url.pathname)
|
|
212
174
|
? await agent.admin.handle(req, res, url.pathname, url, bodyText)
|
|
213
175
|
: await agent.masto.handle(req, res, url.pathname, url);
|
|
214
176
|
if (!handled) return fetch(request);
|
|
215
|
-
return
|
|
177
|
+
return response();
|
|
216
178
|
} catch (err) {
|
|
217
179
|
return json(500, { error: err.message });
|
|
218
180
|
}
|
package/web/app/update.js
CHANGED
|
@@ -64,7 +64,8 @@
|
|
|
64
64
|
setTimeout(go, 4000); // a pages-only change: no new worker will take over
|
|
65
65
|
} catch { /* offline, or the site did not answer: try again later */ }
|
|
66
66
|
};
|
|
67
|
-
//
|
|
67
|
+
// Every ten minutes: a deploy reaches an open page soon enough, and each
|
|
68
|
+
// open tab asks the site 144 times a day rather than 1,440.
|
|
68
69
|
setTimeout(check, 5000);
|
|
69
|
-
setInterval(check, 60 * 1000);
|
|
70
|
+
setInterval(check, 10 * 60 * 1000);
|
|
70
71
|
})();
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// warm-start.mjs — what a browser worker's last start read, kept in this
|
|
2
|
+
// browser so that a restart moments later need not read the pod again.
|
|
3
|
+
//
|
|
4
|
+
// A browser stops an idle service worker after about thirty seconds, and a
|
|
5
|
+
// client in a background tab wakes it about once a minute. Each wake was a
|
|
6
|
+
// whole sign-in against the pod. Kept here: where the account lives, its state
|
|
7
|
+
// documents as the pod holds them (bytes and ETags), and when the last full
|
|
8
|
+
// start and the last inbox drain were. A document is marked pending before it
|
|
9
|
+
// is written to the pod and confirmed after, so a worker stopped in between
|
|
10
|
+
// re-reads that one document rather than trusting a copy the pod has moved
|
|
11
|
+
// past. Signing out deletes the database.
|
|
12
|
+
const DB = 'fedipod-warm';
|
|
13
|
+
|
|
14
|
+
export const WARM_GAP_MS = 5 * 60_000; // a restart this soon after the last is the same visit
|
|
15
|
+
export const FULL_EVERY_MS = 60 * 60_000; // a full start still happens at least hourly
|
|
16
|
+
export const DRAIN_EVERY_MS = 2 * 60_000; // the inbox drain's own fallback cadence
|
|
17
|
+
|
|
18
|
+
export const isWarm = (meta, now = Date.now()) =>
|
|
19
|
+
!!meta && now - meta.wakeAt < WARM_GAP_MS && now - meta.fullAt < FULL_EVERY_MS;
|
|
20
|
+
|
|
21
|
+
function open() {
|
|
22
|
+
return new Promise((res, rej) => {
|
|
23
|
+
const r = indexedDB.open(DB, 1);
|
|
24
|
+
r.onupgradeneeded = () => { r.result.createObjectStore('meta'); r.result.createObjectStore('docs'); };
|
|
25
|
+
r.onsuccess = () => res(r.result);
|
|
26
|
+
r.onerror = () => rej(r.error);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// One transaction, then the connection is closed, so a sign-out can delete
|
|
31
|
+
// the database without waiting on a worker that still holds it open.
|
|
32
|
+
async function tx(stores, mode, work) {
|
|
33
|
+
const db = await open();
|
|
34
|
+
try {
|
|
35
|
+
return await new Promise((res, rej) => {
|
|
36
|
+
const t = db.transaction(stores, mode);
|
|
37
|
+
let out;
|
|
38
|
+
Promise.resolve(work(t)).then((v) => { out = v; });
|
|
39
|
+
t.oncomplete = () => res(out);
|
|
40
|
+
t.onerror = () => rej(t.error);
|
|
41
|
+
t.onabort = () => rej(t.error);
|
|
42
|
+
});
|
|
43
|
+
} finally { db.close(); }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const docKey = (webId, name) => `${webId}\n${name}`;
|
|
47
|
+
const range = (webId) => IDBKeyRange.bound(`${webId}\n`, `${webId}\n`);
|
|
48
|
+
const ask = (rq) => new Promise((res, rej) => { rq.onsuccess = () => res(rq.result); rq.onerror = () => rej(rq.error); });
|
|
49
|
+
|
|
50
|
+
export const warmMeta = (webId) => tx('meta', 'readonly', (t) => ask(t.objectStore('meta').get(webId)).then((m) => m || null));
|
|
51
|
+
|
|
52
|
+
export const saveMeta = (webId, patch) => tx('meta', 'readwrite', async (t) => {
|
|
53
|
+
const s = t.objectStore('meta');
|
|
54
|
+
const had = (await ask(s.get(webId))) || {};
|
|
55
|
+
s.put({ ...had, ...patch }, webId);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const saveDoc = (webId, name, doc) => tx('docs', 'readwrite', (t) => {
|
|
59
|
+
t.objectStore('docs').put({ name, ...doc }, docKey(webId, name));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export const dropDoc = (webId, name) => tx('docs', 'readwrite', (t) => {
|
|
63
|
+
t.objectStore('docs').delete(docKey(webId, name));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export const loadDocs = (webId) => tx('docs', 'readonly', (t) => ask(t.objectStore('docs').getAll(range(webId))));
|
|
67
|
+
|
|
68
|
+
// Everything the pod held at a load, in place of whatever was kept before.
|
|
69
|
+
export const replaceDocs = (webId, docs, listingEtag) => tx(['docs', 'meta'], 'readwrite', async (t) => {
|
|
70
|
+
const d = t.objectStore('docs');
|
|
71
|
+
d.delete(range(webId));
|
|
72
|
+
for (const doc of docs) d.put({ ...doc, pending: false }, docKey(webId, doc.name));
|
|
73
|
+
const m = t.objectStore('meta');
|
|
74
|
+
const had = (await ask(m.get(webId))) || {};
|
|
75
|
+
m.put({ ...had, listingEtag: listingEtag || null }, webId);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
export function deleteWarm() {
|
|
79
|
+
return new Promise((res) => {
|
|
80
|
+
let rq; try { rq = indexedDB.deleteDatabase(DB); } catch { res(); return; }
|
|
81
|
+
rq.onsuccess = rq.onerror = rq.onblocked = () => res();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Keep `store`'s documents in step with the pod, for `webId`. Before a write
|
|
87
|
+
* or removal the document is marked pending; after it lands, the bytes the
|
|
88
|
+
* pod now holds are kept. A load replaces the whole copy.
|
|
89
|
+
*/
|
|
90
|
+
export function keepInStep(store, webId, log = () => {}) {
|
|
91
|
+
const fail = (e) => log(`warm copy: ${e?.message || e}`);
|
|
92
|
+
store.onWriting = (name) => saveDoc(webId, name, { text: null, etag: null, pending: true }).catch(fail);
|
|
93
|
+
store.onSettled = (name) => {
|
|
94
|
+
const text = store.lastText.get(name);
|
|
95
|
+
(text == null ? dropDoc(webId, name) : saveDoc(webId, name, { text, etag: store.etags.get(name) || null, pending: false }))
|
|
96
|
+
.catch(fail);
|
|
97
|
+
};
|
|
98
|
+
store.onLoaded = () => {
|
|
99
|
+
const docs = [...store.lastText].map(([name, text]) => ({ name, text, etag: store.etags.get(name) || null }));
|
|
100
|
+
replaceDocs(webId, docs, store.etags.get('')).catch(fail);
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Put a kept copy into `store`, and answer the names of documents it caught
|
|
106
|
+
* mid-write: those, and only those, need reading from the pod again. While the
|
|
107
|
+
* lease stayed with this browser nothing else wrote the state, so the rest is
|
|
108
|
+
* what the pod holds. With any pending, the container's ETag is left out too,
|
|
109
|
+
* so a later full load lists it afresh.
|
|
110
|
+
*/
|
|
111
|
+
export function takeUp(store, docs, listingEtag) {
|
|
112
|
+
const pending = docs.filter((d) => d.pending).map((d) => d.name);
|
|
113
|
+
store.restore({ docs: docs.filter((d) => !d.pending && d.text != null), listingEtag: pending.length ? null : listingEtag });
|
|
114
|
+
return pending;
|
|
115
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// app-signin.mjs — a Mastodon app asked to use an account at this gateway
|
|
2
|
+
// (lib/gateway/masto-gateway.mjs). The person gives their address here, signs
|
|
3
|
+
// in at their own pod with the fediverse-session library, and the page proves
|
|
4
|
+
// that sign-in to the gateway with one signed request; the gateway hands the
|
|
5
|
+
// app its code. No password of ours anywhere.
|
|
6
|
+
import { fediLogin } from './fedi-login.mjs';
|
|
7
|
+
|
|
8
|
+
const $ = (id) => document.getElementById(id);
|
|
9
|
+
const say = (text, error = false) => { const s = $('signin-status'); s.textContent = text; s.setAttribute('role', error ? 'alert' : 'status'); };
|
|
10
|
+
const here = location.origin + location.pathname;
|
|
11
|
+
const params = new URLSearchParams(location.search);
|
|
12
|
+
// The address last used here, so the trip to the pod and back does not ask again.
|
|
13
|
+
const KEY = 'fedipod-app-address';
|
|
14
|
+
const remembered = () => { try { return sessionStorage.getItem(KEY) || localStorage.getItem(KEY) || ''; } catch { return ''; } };
|
|
15
|
+
const remember = (a) => { try { sessionStorage.setItem(KEY, a); localStorage.setItem(KEY, a); } catch { /* keeps no site data */ } };
|
|
16
|
+
const login = fediLogin({ dbName: 'fedipod-app-signin', clientName: 'Sign in to an app', redirectUri: here });
|
|
17
|
+
// The person's own yes to this app, given by pressing the button in this tab.
|
|
18
|
+
// It rides the trip to the pod and back, and nothing else stands for it: a
|
|
19
|
+
// page that sends someone here, signed in before, gets nothing without it.
|
|
20
|
+
const CONSENT = 'fedipod-app-consent';
|
|
21
|
+
const request = `${params.get('client_id')}\n${params.get('redirect_uri') || ''}`;
|
|
22
|
+
const consented = () => { try { return sessionStorage.getItem(CONSENT) === request; } catch { return false; } };
|
|
23
|
+
const consent = (on) => { try { if (on) sessionStorage.setItem(CONSENT, request); else sessionStorage.removeItem(CONSENT); } catch { /* keeps no site data */ } };
|
|
24
|
+
|
|
25
|
+
// Back from the pod: this finishes the sign-in and returns to the app's request.
|
|
26
|
+
try { await login.resume(); } catch (e) { say(e.message, true); }
|
|
27
|
+
|
|
28
|
+
// Proves the pod sign-in to the gateway. True when the app has its code.
|
|
29
|
+
async function prove(address) {
|
|
30
|
+
const s = await login.getSession();
|
|
31
|
+
if (!s || !consented()) return false;
|
|
32
|
+
consent(false);
|
|
33
|
+
say('Signing you in…');
|
|
34
|
+
const res = await s.fetch(`${location.origin}/api/authorize`, {
|
|
35
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({ ...Object.fromEntries(params), address }),
|
|
37
|
+
});
|
|
38
|
+
const d = await res.json().catch(() => ({}));
|
|
39
|
+
if (res.ok && d.redirect) { say('Signed in. Going back to the app…'); location.href = d.redirect; return true; }
|
|
40
|
+
if (res.ok && d.code) { say(`Signed in. The app asks you to paste this code into it: ${d.code}`); return true; }
|
|
41
|
+
say(d.error || `The sign-in was refused (HTTP ${res.status}).`, true);
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function start() {
|
|
46
|
+
if (!params.get('client_id')) { say('This page is for an app that asked to use your account. Start from the app.', true); return; }
|
|
47
|
+
const r = await fetch(`/api/authorize?${new URLSearchParams({ client_id: params.get('client_id'), redirect_uri: params.get('redirect_uri') || '' })}`);
|
|
48
|
+
const app = await r.json().catch(() => ({}));
|
|
49
|
+
if (!r.ok) { say(app.error || `This app cannot sign in here (HTTP ${r.status}).`, true); return; }
|
|
50
|
+
const asking = $('asking');
|
|
51
|
+
const name = document.createElement('strong');
|
|
52
|
+
name.textContent = app.name;
|
|
53
|
+
asking.append(name, ' is asking to use your account');
|
|
54
|
+
if (app.sendsTo) { const where = document.createElement('code'); where.textContent = app.sendsTo; asking.append(', and will be sent back to ', where); }
|
|
55
|
+
asking.append('.');
|
|
56
|
+
asking.hidden = false;
|
|
57
|
+
const address = remembered();
|
|
58
|
+
$('address').value = address;
|
|
59
|
+
// Back from the pod after pressing the button: finish. Otherwise ask.
|
|
60
|
+
if (!(address && consented() && await prove(address))) $('signin-form').hidden = false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
$('signin-form').addEventListener('submit', async (ev) => {
|
|
64
|
+
ev.preventDefault();
|
|
65
|
+
const address = $('address').value.trim();
|
|
66
|
+
if (!address) { say('Write your address here first.', true); return; }
|
|
67
|
+
const r = await fetch(`/api/authorize?address=${encodeURIComponent(address)}`);
|
|
68
|
+
const who = await r.json().catch(() => ({}));
|
|
69
|
+
if (!r.ok) { say(who.error || `That address was not found (HTTP ${r.status}).`, true); return; }
|
|
70
|
+
remember(address);
|
|
71
|
+
consent(true);
|
|
72
|
+
const s = await login.getSession();
|
|
73
|
+
if (s && s.webId === who.webId) { await prove(address); return; }
|
|
74
|
+
say('Taking you to your pod…');
|
|
75
|
+
try { await login.login(who.webId, { returnTo: location.href, redirectUri: here }); } catch (e) { say(e.message, true); }
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await start();
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Let an app use your account</title>
|
|
7
|
+
<link rel="stylesheet" href="/app-signin/tokens.css">
|
|
8
|
+
<style>
|
|
9
|
+
body { font-family: var(--font); background: var(--surface); color: var(--fg); margin: 0; line-height: 1.5; }
|
|
10
|
+
main { max-width: 30rem; margin: 8vh auto; padding: 1.5rem 1.75rem; background: var(--raised);
|
|
11
|
+
border: 1px solid var(--line); border-radius: 10px; }
|
|
12
|
+
h1 { color: var(--heading); font-size: 1.5rem; margin: 0 0 1rem; }
|
|
13
|
+
a { color: var(--link); text-decoration: underline; }
|
|
14
|
+
code { word-break: break-all; background: var(--field-bg); padding: .05em .35em; border-radius: 4px; }
|
|
15
|
+
label { display: block; margin: 1rem 0 .4rem; font-weight: bold; }
|
|
16
|
+
input { font: inherit; width: 100%; box-sizing: border-box; padding: .5rem .7rem; background: var(--field-bg);
|
|
17
|
+
color: var(--field-text); border: 1px solid var(--field-edge); border-radius: 6px; }
|
|
18
|
+
.hint { color: var(--sub); }
|
|
19
|
+
button { font: inherit; padding: .6rem 1.1rem; background: var(--btn); color: var(--btn-text); border: none;
|
|
20
|
+
border-radius: 9px; cursor: pointer; }
|
|
21
|
+
button:hover { background: var(--btn-hover); }
|
|
22
|
+
:focus-visible { outline: 3px solid var(--ring); outline-offset: 2px; }
|
|
23
|
+
#signin-status { margin-top: 1rem; padding: .6rem .8rem; border: 1px solid var(--line); border-radius: 8px; }
|
|
24
|
+
#signin-status:empty { display: none; }
|
|
25
|
+
</style>
|
|
26
|
+
</head>
|
|
27
|
+
<body>
|
|
28
|
+
<main>
|
|
29
|
+
<h1>Let an app use your account</h1>
|
|
30
|
+
<p id="asking" hidden></p>
|
|
31
|
+
<form id="signin-form" hidden>
|
|
32
|
+
<label for="address">Your address here</label>
|
|
33
|
+
<input type="text" id="address" name="address" autocomplete="username">
|
|
34
|
+
<p class="hint">You sign in at your own pod. No password is typed on this page.</p>
|
|
35
|
+
<p><button type="submit" id="signin">Sign in with your pod</button></p>
|
|
36
|
+
</form>
|
|
37
|
+
<p id="signin-status" role="status" aria-live="polite"></p>
|
|
38
|
+
</main>
|
|
39
|
+
<script type="module" src="/app-signin/app-signin.mjs"></script>
|
|
40
|
+
</body>
|
|
41
|
+
</html>
|
package/web/front/run.html
CHANGED
|
@@ -46,12 +46,18 @@ Fediverse services straight from your pod.</p>
|
|
|
46
46
|
<label for="run-issuer">Your identity provider</label>
|
|
47
47
|
<input type="url" id="run-issuer" autocomplete="off" placeholder="https://this.server">
|
|
48
48
|
</p>
|
|
49
|
+
<p class="field">
|
|
50
|
+
<label for="run-container">Store your data in a container named fedipod, inside this container</label>
|
|
51
|
+
<input type="text" id="run-container" autocomplete="off" spellcheck="false" placeholder="/"
|
|
52
|
+
aria-describedby="run-container-hint">
|
|
53
|
+
</p>
|
|
54
|
+
<p class="hint" id="run-container-hint">Leave it empty for your pod's root. This is permanent.</p>
|
|
49
55
|
<p class="actions">
|
|
50
56
|
<button type="button" id="run-continue" class="primary" disabled>Sign in with your pod & opt in</button>
|
|
51
57
|
<button type="button" id="run-leave">Stop running it here</button>
|
|
52
58
|
</p>
|
|
53
59
|
</form>
|
|
54
|
-
<
|
|
60
|
+
<div class="hint" id="run-note" hidden></div>
|
|
55
61
|
|
|
56
62
|
<script type="module" src="/run.js"></script>
|
|
57
63
|
</body>
|