@marver-design/marver 0.10.2 → 0.11.1
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/CHANGELOG.md +161 -0
- package/README.md +16 -3
- package/dist/auth-B5yuwnOq.mjs +494 -0
- package/dist/{build-zUf_kwKp.mjs → build-DI31MFTB.mjs} +3 -2
- package/dist/cli.mjs +6 -6
- package/dist/{collab-s3k5byM1.mjs → collab-CWaG3Q4w.mjs} +43 -11
- package/dist/{comments-J06jqCVV.mjs → comments-CRqaO2MH.mjs} +26 -11
- package/dist/{comments-BZBKhKRO.mjs → comments-DZyobpxG.mjs} +8 -2
- package/dist/{manifest-DIsp3ldB.mjs → config-t9coJ-Pq.mjs} +3 -233
- package/dist/{daemon-BpKBJZee.mjs → daemon-DSmaS453.mjs} +23 -5
- package/dist/{dev-D2PG6igI.mjs → dev-BWIL2ewj.mjs} +117 -13
- package/dist/{init-DwV0_d9G.mjs → init-BSclDg4I.mjs} +2 -1
- package/dist/manifest-C2tzkNaC.mjs +233 -0
- package/dist/marver-id-B8-3WiHk.mjs +411 -0
- package/dist/marver-id-gate-BDzW6ahN.mjs +728 -0
- package/dist/{plugin-C1sDewZ3.mjs → plugin-DjBFxEQ7.mjs} +12 -84
- package/dist/{profile-BkiWglVE.mjs → profile-DcsJyppw.mjs} +9 -6
- package/dist/secure-cookie-_K1Hsx8H.mjs +26 -0
- package/dist/{serve-CZqPnj19.mjs → serve-3APqaNSB.mjs} +196 -43
- package/dist/sync-DELGomPk.mjs +397 -0
- package/dist/update-DuWDj5nR.mjs +77 -0
- package/dist/utm-CxC3QN5X.mjs +20 -0
- package/package.json +1 -1
- package/src/shared/utm.ts +2 -2
- package/templates/instructions/configure.md +4 -0
- package/templates/instructions/publish.md +99 -16
- package/dist/auth-KQ9Aj-nB.mjs +0 -245
- package/dist/events-BMtBvvgU.mjs +0 -101
- package/dist/sync-BJKKmy1n.mjs +0 -150
- package/dist/{shot-D7I0MCLu.mjs → shot-Cyv3GN79.mjs} +1 -1
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
import { i as avatarSourceFor, n as attachAvatar, u as provisionFromMarverId } from "./auth-B5yuwnOq.mjs";
|
|
2
|
+
import { TransactionStore, browserBinding, verifyAssertion } from "./marver-id-B8-3WiHk.mjs";
|
|
3
|
+
import { t as poweredByUrl } from "./utm-CxC3QN5X.mjs";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { isIP } from "node:net";
|
|
6
|
+
import { lookup } from "node:dns/promises";
|
|
7
|
+
//#region src/server/avatar.ts
|
|
8
|
+
/**
|
|
9
|
+
* Fetching somebody's picture, once, and keeping our own copy.
|
|
10
|
+
*
|
|
11
|
+
* A Marver ID assertion carries a `picture` URL - almost always Google's CDN.
|
|
12
|
+
* The canvas could just render that URL and be done, and it must not. Hotlinking
|
|
13
|
+
* would put a request to Google on every page view of a private canvas, which
|
|
14
|
+
* hands a third party a log of who looks at what, and leaves the avatar to
|
|
15
|
+
* vanish the day the URL rotates. So the picture is fetched ONCE, at sign-in,
|
|
16
|
+
* and stored as a data URI beside the account like any other avatar.
|
|
17
|
+
*
|
|
18
|
+
* That decision buys a problem: the canvas server now makes an outbound request
|
|
19
|
+
* to an address that arrived in a token. Everything below is about making that
|
|
20
|
+
* safe and, failing that, making it harmless - the whole thing is best-effort,
|
|
21
|
+
* and every failure path returns null so a sign-in never breaks over a picture.
|
|
22
|
+
*/
|
|
23
|
+
/** Small enough to sit in a JSON store beside every account, without thinking. */
|
|
24
|
+
const MAX_BYTES = 65536;
|
|
25
|
+
const TIMEOUT_MS = 5e3;
|
|
26
|
+
/** What a browser will actually render, and nothing that can carry script. */
|
|
27
|
+
const ALLOWED = /* @__PURE__ */ new Set([
|
|
28
|
+
"image/png",
|
|
29
|
+
"image/jpeg",
|
|
30
|
+
"image/webp",
|
|
31
|
+
"image/gif"
|
|
32
|
+
]);
|
|
33
|
+
/**
|
|
34
|
+
* Is this address one a self-hosted canvas can safely be asked to fetch?
|
|
35
|
+
*
|
|
36
|
+
* The canvas is somebody's own server, usually inside their own network, and it
|
|
37
|
+
* is being handed a URL. Without this check "fetch my picture" is a request to
|
|
38
|
+
* probe the metadata endpoint, or a database on the private subnet, or another
|
|
39
|
+
* service on the same box - the classic shape of SSRF, and worth more to an
|
|
40
|
+
* attacker on a self-hosted deployment than on a shared one.
|
|
41
|
+
*
|
|
42
|
+
* Stated honestly: this resolves the name and rejects private space, which does
|
|
43
|
+
* not close DNS rebinding - the name can resolve differently between this check
|
|
44
|
+
* and the connection Node actually makes. Pinning the address needs a custom
|
|
45
|
+
* agent. What bounds the residual risk is everything around it: the URL is https
|
|
46
|
+
* only, arrives inside a signature from a configured issuer, is never followed
|
|
47
|
+
* through a redirect, times out in five seconds, and the response is discarded
|
|
48
|
+
* unless it is a small image. The value of the remaining hole is one blind
|
|
49
|
+
* request; it is defence in depth rather than a wall.
|
|
50
|
+
*/
|
|
51
|
+
/**
|
|
52
|
+
* Every hextet of an IPv6 address, or null if it is not one.
|
|
53
|
+
*
|
|
54
|
+
* Prefix matching on the printed form is what let the first version through:
|
|
55
|
+
* `fe80:` is a string test, and link-local is fe80::/10 - so fe90:: and febf::
|
|
56
|
+
* sailed past. Likewise `::ffff:` was only recognised in its dotted form, and
|
|
57
|
+
* `::ffff:a00:1` is the same 10.0.0.1 written in hex. Comparing numbers instead
|
|
58
|
+
* of text removes the whole class.
|
|
59
|
+
*/
|
|
60
|
+
function v6Parts(addr) {
|
|
61
|
+
let s = addr.toLowerCase();
|
|
62
|
+
const zone = s.indexOf("%");
|
|
63
|
+
if (zone >= 0) s = s.slice(0, zone);
|
|
64
|
+
const tail4 = /^(.*:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s);
|
|
65
|
+
if (tail4) {
|
|
66
|
+
const b = [
|
|
67
|
+
Number(tail4[2]),
|
|
68
|
+
Number(tail4[3]),
|
|
69
|
+
Number(tail4[4]),
|
|
70
|
+
Number(tail4[5])
|
|
71
|
+
];
|
|
72
|
+
if (b.some((n) => n > 255)) return null;
|
|
73
|
+
s = `${tail4[1]}${((b[0] << 8 | b[1]) >>> 0).toString(16)}:${((b[2] << 8 | b[3]) >>> 0).toString(16)}`;
|
|
74
|
+
}
|
|
75
|
+
const halves = s.split("::");
|
|
76
|
+
if (halves.length > 2) return null;
|
|
77
|
+
const head = halves[0] ? halves[0].split(":") : [];
|
|
78
|
+
const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
|
|
79
|
+
const gap = halves.length === 2 ? 8 - head.length - tail.length : 0;
|
|
80
|
+
if (gap < 0 || halves.length === 1 && head.length !== 8) return null;
|
|
81
|
+
const parts = [
|
|
82
|
+
...head,
|
|
83
|
+
...Array(gap).fill("0"),
|
|
84
|
+
...tail
|
|
85
|
+
];
|
|
86
|
+
if (parts.length !== 8) return null;
|
|
87
|
+
const nums = parts.map((h) => /^[0-9a-f]{1,4}$/.test(h) ? parseInt(h, 16) : NaN);
|
|
88
|
+
return nums.some((n) => !Number.isInteger(n)) ? null : nums;
|
|
89
|
+
}
|
|
90
|
+
function isPublicAddress(addr) {
|
|
91
|
+
if (isIP(addr) === 6) {
|
|
92
|
+
const p = v6Parts(addr);
|
|
93
|
+
if (!p) return false;
|
|
94
|
+
if (p.slice(0, 5).every((x) => x === 0) && p[5] === 65535) return isPublicAddress(`${p[6] >> 8}.${p[6] & 255}.${p[7] >> 8}.${p[7] & 255}`);
|
|
95
|
+
/**
|
|
96
|
+
* Everything else must be global unicast, and then survive three exceptions.
|
|
97
|
+
*
|
|
98
|
+
* This is an ALLOWLIST, and the earlier blocklist is why. Naming the ranges
|
|
99
|
+
* to refuse means the default is "allowed", and IPv6 has far too much
|
|
100
|
+
* special-purpose space for that default to be safe: reserved blocks,
|
|
101
|
+
* documentation space, and several prefixes that carry an IPv4 address
|
|
102
|
+
* inside them and route to it. Every one I had not thought of was a hole,
|
|
103
|
+
* and the list of things I have not thought of is not something I can
|
|
104
|
+
* enumerate. Requiring 2000::/3 inverts it - the default becomes "refused",
|
|
105
|
+
* and the exceptions below are the small, auditable part.
|
|
106
|
+
*
|
|
107
|
+
* The three exceptions are all inside global unicast and none are reachable
|
|
108
|
+
* the way an avatar host is:
|
|
109
|
+
* 2001::/23 IETF protocol assignments - Teredo, benchmarking, ORCHID.
|
|
110
|
+
* Teredo in particular tunnels IPv4 inside the address.
|
|
111
|
+
* 2001:db8::/32 documentation. Never routed, and a favourite in examples.
|
|
112
|
+
* 2002::/16 6to4, which embeds an IPv4 address and routes to it.
|
|
113
|
+
*
|
|
114
|
+
* NAT64 (64:ff9b::/96) is refused simply by being outside 2000::/3, and
|
|
115
|
+
* that is the right outcome: it is a live route to whatever IPv4 address it
|
|
116
|
+
* carries, including the metadata endpoint. An IPv6-only canvas loses
|
|
117
|
+
* nothing real, because an avatar host that matters has a AAAA record.
|
|
118
|
+
*/
|
|
119
|
+
if ((p[0] & 57344) !== 8192) return false;
|
|
120
|
+
if (p[0] === 8193 && p[1] < 512) return false;
|
|
121
|
+
if (p[0] === 8193 && p[1] === 3512) return false;
|
|
122
|
+
if (p[0] === 8194) return false;
|
|
123
|
+
if (p[0] >= 16383 && p[0] <= 16383) return false;
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
const q = addr.split(".").map(Number);
|
|
127
|
+
if (q.length !== 4 || q.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false;
|
|
128
|
+
const [a, b] = q;
|
|
129
|
+
if (a === 0 || a === 127) return false;
|
|
130
|
+
if (a === 10) return false;
|
|
131
|
+
if (a === 172 && b >= 16 && b <= 31) return false;
|
|
132
|
+
if (a === 192 && b === 168) return false;
|
|
133
|
+
if (a === 169 && b === 254) return false;
|
|
134
|
+
if (a === 100 && b >= 64 && b <= 127) return false;
|
|
135
|
+
if (a === 192 && b === 0) return false;
|
|
136
|
+
if (a === 198 && (b === 18 || b === 19)) return false;
|
|
137
|
+
if (a === 198 && b === 51 && q[2] === 100) return false;
|
|
138
|
+
if (a === 203 && b === 0 && q[2] === 113) return false;
|
|
139
|
+
if (a >= 224) return false;
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Fetch a picture and return it as a data URI, or null.
|
|
144
|
+
*
|
|
145
|
+
* Null for every kind of failure, deliberately and without distinction: a
|
|
146
|
+
* picture is decoration, and there is no failure here worth turning into a
|
|
147
|
+
* refused sign-in or an error somebody has to read.
|
|
148
|
+
*/
|
|
149
|
+
async function fetchAvatar(pictureUrl) {
|
|
150
|
+
let url;
|
|
151
|
+
try {
|
|
152
|
+
url = new URL(pictureUrl);
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
if (url.protocol !== "https:") return null;
|
|
157
|
+
if (url.username || url.password) return null;
|
|
158
|
+
try {
|
|
159
|
+
const { address } = await lookup(url.hostname);
|
|
160
|
+
if (!isPublicAddress(address)) return null;
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
return await avatarFromResponse(await fetch(url, {
|
|
166
|
+
redirect: "error",
|
|
167
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
168
|
+
headers: { accept: ALLOWED_ACCEPT }
|
|
169
|
+
}));
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const ALLOWED_ACCEPT = [...ALLOWED].join(", ");
|
|
175
|
+
/**
|
|
176
|
+
* Turn a response into a stored avatar, or refuse it.
|
|
177
|
+
*
|
|
178
|
+
* Separate from the fetch so the rules can be tested against real Responses
|
|
179
|
+
* without needing real TLS - these are the checks that decide what ends up in
|
|
180
|
+
* somebody's auth store, and they are worth more than a test that restates a
|
|
181
|
+
* list of MIME types back to itself.
|
|
182
|
+
*
|
|
183
|
+
* SVG is deliberately absent from the allowed set. Browsers render it, which
|
|
184
|
+
* makes it feel like an image, and it is a document that can carry script - an
|
|
185
|
+
* avatar is one of the few things a canvas shows without the author having
|
|
186
|
+
* written it, so it is exactly where that matters.
|
|
187
|
+
*/
|
|
188
|
+
async function avatarFromResponse(res) {
|
|
189
|
+
if (!res.ok) return null;
|
|
190
|
+
const type = (res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
191
|
+
if (!ALLOWED.has(type)) return null;
|
|
192
|
+
const declared = Number(res.headers.get("content-length") ?? "0");
|
|
193
|
+
if (declared && declared > MAX_BYTES) return null;
|
|
194
|
+
const buf = await readCapped(res);
|
|
195
|
+
if (!buf || buf.byteLength === 0) return null;
|
|
196
|
+
return `data:${type};base64,${buf.toString("base64")}`;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Read a body, and stop reading the moment it is too big.
|
|
200
|
+
*
|
|
201
|
+
* The cap used to be applied to the result of arrayBuffer(), which is a check
|
|
202
|
+
* performed after the thing it was checking has already been allocated. A
|
|
203
|
+
* chunked response, or one whose Content-Length simply lies, could stream
|
|
204
|
+
* hundreds of megabytes into the canvas process inside the five second window,
|
|
205
|
+
* and a handful of concurrent sign-ins would be enough to end it. Counting as
|
|
206
|
+
* the chunks arrive and cancelling at the limit is the difference between a
|
|
207
|
+
* bound and a wish.
|
|
208
|
+
*/
|
|
209
|
+
async function readCapped(res) {
|
|
210
|
+
const body = res.body;
|
|
211
|
+
if (!body || typeof body.getReader !== "function") {
|
|
212
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
213
|
+
return buf.byteLength > MAX_BYTES ? null : buf;
|
|
214
|
+
}
|
|
215
|
+
const reader = body.getReader();
|
|
216
|
+
const chunks = [];
|
|
217
|
+
let size = 0;
|
|
218
|
+
try {
|
|
219
|
+
for (;;) {
|
|
220
|
+
const { done, value } = await reader.read();
|
|
221
|
+
if (done) break;
|
|
222
|
+
if (!value) continue;
|
|
223
|
+
size += value.byteLength;
|
|
224
|
+
if (size > MAX_BYTES) {
|
|
225
|
+
await reader.cancel().catch(() => {});
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
chunks.push(value);
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
return Buffer.concat(chunks);
|
|
234
|
+
}
|
|
235
|
+
//#endregion
|
|
236
|
+
//#region src/server/marver-id-gate.ts
|
|
237
|
+
/**
|
|
238
|
+
* The three endpoints that bracket a Marver ID sign-in.
|
|
239
|
+
*
|
|
240
|
+
* GET /__mv/id/start mints a nonce bound to this browser, then sends the
|
|
241
|
+
* tab to the identity service. A plain redirect - no
|
|
242
|
+
* JavaScript is involved in leaving.
|
|
243
|
+
* GET /__mv/id/finish where the identity service sends the tab back, with
|
|
244
|
+
* the assertion in the URL FRAGMENT. Reads it, hands
|
|
245
|
+
* it to the callback, and shows what happened.
|
|
246
|
+
* POST /__mv/id/callback verifies the assertion server-side and, if the owner
|
|
247
|
+
* invited this address, issues a canvas session.
|
|
248
|
+
*
|
|
249
|
+
* This used to be a popup that handed the assertion back by postMessage. That
|
|
250
|
+
* design is dead on arrival for social sign-in: Google serves
|
|
251
|
+
* `Cross-Origin-Opener-Policy: same-origin`, which permanently severs the
|
|
252
|
+
* popup's `window.opener` - and it does not come back when the popup returns to
|
|
253
|
+
* our origin. Microsoft and Apple do the same. So the whole flow now happens in
|
|
254
|
+
* ONE tab, and nothing depends on two windows being able to talk. It also fixes
|
|
255
|
+
* popup blockers and mobile, where popups were never good.
|
|
256
|
+
*
|
|
257
|
+
* The assertion rides in the fragment on the way back, which is the one part of
|
|
258
|
+
* a URL a browser never sends to a server: it stays out of this canvas's access
|
|
259
|
+
* logs and out of any `Referer`.
|
|
260
|
+
*
|
|
261
|
+
* The important architectural choice is unchanged: a Marver ID sign-in ends with
|
|
262
|
+
* the SAME `mv_s` session cookie a password sign-in produces. Nothing downstream
|
|
263
|
+
* - the gate, the comment API, the event stream - learns that a new kind of
|
|
264
|
+
* login exists. One session concept, one place to revoke it.
|
|
265
|
+
*
|
|
266
|
+
* The assertion never touches the browser's storage. It is read from the
|
|
267
|
+
* fragment, POSTed straight here, and consumed. Authored frames run same-origin
|
|
268
|
+
* in this canvas, so anything left in localStorage would be readable by them.
|
|
269
|
+
*/
|
|
270
|
+
/**
|
|
271
|
+
* Names the browser across the two requests. Not a session - just a handle.
|
|
272
|
+
*
|
|
273
|
+
* Over https it carries the `__Host-` prefix, which is not decoration. The
|
|
274
|
+
* handle is what binds a sign-in to the browser that started it, and without
|
|
275
|
+
* the prefix a sibling host on a shared parent domain can set one - "cookie
|
|
276
|
+
* tossing" - which means choosing the binding for somebody else's sign-in. The
|
|
277
|
+
* prefix makes a cookie host-only and unsettable by any other host, and the
|
|
278
|
+
* browser enforces it rather than us. Plain over http, where the prefix is
|
|
279
|
+
* invalid; there is no https there to have, and localhost has no siblings.
|
|
280
|
+
*
|
|
281
|
+
* What that leaves, stated rather than assumed. The prefix protects against
|
|
282
|
+
* every OTHER host, and against nothing on this one: cookies ignore ports, so a
|
|
283
|
+
* second service on the same hostname can still write the handle, and so can
|
|
284
|
+
* anyone who takes over the hostname itself. A browser that does not implement
|
|
285
|
+
* the prefix gets no protection at all. So the assumption a self-hosted canvas
|
|
286
|
+
* is making is exclusive control of its own hostname, and modern browsers -
|
|
287
|
+
* which is the same assumption its TLS certificate already makes.
|
|
288
|
+
*/
|
|
289
|
+
const BROWSER_COOKIE = "mv_b";
|
|
290
|
+
const BROWSER_COOKIE_SECURE = "__Host-mv_b";
|
|
291
|
+
const browserCookieName = (secure) => secure ? BROWSER_COOKIE_SECURE : BROWSER_COOKIE;
|
|
292
|
+
const SESSION_COOKIE = "mv_s";
|
|
293
|
+
/** The double-submit half of the session pair. See where it is set. */
|
|
294
|
+
const CSRF_COOKIE = "mv_c";
|
|
295
|
+
const MONTH = 2592e3;
|
|
296
|
+
/** Matches TRANSACTION_TTL_MS - one deadline, expressed in both halves. */
|
|
297
|
+
const HANDLE_TTL_S = 900;
|
|
298
|
+
/**
|
|
299
|
+
* Where to put somebody back, after they sign in.
|
|
300
|
+
*
|
|
301
|
+
* A canvas link carries its board and thread in the fragment - `#/b/strategy`,
|
|
302
|
+
* `#/b/strategy?c=<thread>` - which no server ever receives. The gate's script
|
|
303
|
+
* reads it and hands it here on the query string, which means that by the time
|
|
304
|
+
* it arrives it is an ordinary attacker-reachable parameter: anyone can request
|
|
305
|
+
* /__mv/id/start?next=<anything>.
|
|
306
|
+
*
|
|
307
|
+
* It ends up in location.replace() on this canvas, so it is an open redirect if
|
|
308
|
+
* it is wrong. Only a hash route on this same canvas survives.
|
|
309
|
+
*
|
|
310
|
+
* The rejections worth naming:
|
|
311
|
+
* "#//evil.test" - a browser reads what follows a bare // as a host
|
|
312
|
+
* "javascript:.." - not a route at all
|
|
313
|
+
* anything with a control character, which is how a value gets smuggled past
|
|
314
|
+
* a check and into a header or a log
|
|
315
|
+
*/
|
|
316
|
+
function safeHash(raw) {
|
|
317
|
+
if (!raw) return null;
|
|
318
|
+
const value = raw.trim();
|
|
319
|
+
if (!value.startsWith("#/")) return null;
|
|
320
|
+
if (value.startsWith("#//") || value.startsWith("#/\\")) return null;
|
|
321
|
+
if (/[\u0000-\u001f\u007f]/.test(value)) return null;
|
|
322
|
+
if (value.length > 512) return null;
|
|
323
|
+
if (/(^|\/)\.\.?(\/|$)/.test(value.slice(1))) return null;
|
|
324
|
+
if (!/^#\/[\w\-./~%!$&'()*+,;=:@?[\]]*$/.test(value)) return null;
|
|
325
|
+
return value;
|
|
326
|
+
}
|
|
327
|
+
function marverIdHandler(dir, issuer, canvasName, branding = true) {
|
|
328
|
+
const transactions = new TransactionStore();
|
|
329
|
+
const pinned = (process.env.MARVER_PUBLIC_ORIGIN ?? "").trim().replace(/\/$/, "");
|
|
330
|
+
let publicOrigin;
|
|
331
|
+
{
|
|
332
|
+
if (!pinned) throw new Error("MARVER_PUBLIC_ORIGIN is required when MARVER_ID_ISSUER is set.\n Set it to this canvas's exact public origin - scheme, host and port -\n e.g. https://canvas.example.com, or http://localhost:4173 in development.");
|
|
333
|
+
let u;
|
|
334
|
+
try {
|
|
335
|
+
u = new URL(pinned);
|
|
336
|
+
} catch {
|
|
337
|
+
throw new Error(`MARVER_PUBLIC_ORIGIN is not a URL: ${pinned}`);
|
|
338
|
+
}
|
|
339
|
+
const loopback = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]";
|
|
340
|
+
if (u.username || u.password) throw new Error(`MARVER_PUBLIC_ORIGIN must not carry credentials: ${pinned}`);
|
|
341
|
+
if (u.pathname !== "/" || u.search || u.hash) throw new Error(`MARVER_PUBLIC_ORIGIN must be a bare origin: ${pinned}`);
|
|
342
|
+
if (!(u.protocol === "https:" || u.protocol === "http:" && loopback)) throw new Error(`MARVER_PUBLIC_ORIGIN must be https, or http on loopback: ${pinned}`);
|
|
343
|
+
publicOrigin = u.origin;
|
|
344
|
+
}
|
|
345
|
+
return async function handle(req, res, url) {
|
|
346
|
+
const origin = publicOrigin;
|
|
347
|
+
const secure = origin.startsWith("https://");
|
|
348
|
+
if (req.method === "GET" && url.pathname === "/__mv/id/start") {
|
|
349
|
+
let browserId = readCookie(req, browserCookieName(secure));
|
|
350
|
+
const headers = [];
|
|
351
|
+
if (!browserId) browserId = randomBytes(24).toString("base64url");
|
|
352
|
+
headers.push(cookie(browserCookieName(secure), browserId, {
|
|
353
|
+
maxAge: HANDLE_TTL_S,
|
|
354
|
+
secure
|
|
355
|
+
}));
|
|
356
|
+
const next = safeHash(url.searchParams.get("next"));
|
|
357
|
+
const tx = transactions.mint(origin, browserBinding(browserId), next ?? void 0);
|
|
358
|
+
const authorize = `${issuer}/authorize?origin=${encodeURIComponent(origin)}&nonce=${encodeURIComponent(tx.nonce)}` + (canvasName ? `&name=${encodeURIComponent(canvasName)}` : "");
|
|
359
|
+
if (headers.length) res.setHeader("set-cookie", headers);
|
|
360
|
+
res.statusCode = 302;
|
|
361
|
+
res.setHeader("location", authorize);
|
|
362
|
+
res.setHeader("cache-control", "no-store");
|
|
363
|
+
res.end();
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
if (req.method === "GET" && url.pathname === "/__mv/id/finish") {
|
|
367
|
+
res.statusCode = 200;
|
|
368
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
369
|
+
res.setHeader("cache-control", "no-store");
|
|
370
|
+
res.setHeader("referrer-policy", "no-referrer");
|
|
371
|
+
res.setHeader("content-security-policy", "frame-ancestors 'none'");
|
|
372
|
+
res.setHeader("x-frame-options", "DENY");
|
|
373
|
+
res.end(finishPage(canvasName, new URL(origin).host, `${issuer}/switch?origin=${encodeURIComponent(origin)}`, branding));
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
if (req.method === "POST" && url.pathname === "/__mv/id/callback") {
|
|
377
|
+
const { body, tooLarge } = await readBody(req, 16e3);
|
|
378
|
+
if (tooLarge) {
|
|
379
|
+
res.setHeader("connection", "close");
|
|
380
|
+
return json(res, 413, { error: "too large" });
|
|
381
|
+
}
|
|
382
|
+
let assertion = "";
|
|
383
|
+
try {
|
|
384
|
+
const parsed = JSON.parse(body);
|
|
385
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
386
|
+
const a = parsed.assertion;
|
|
387
|
+
if (typeof a === "string") assertion = a;
|
|
388
|
+
}
|
|
389
|
+
} catch {}
|
|
390
|
+
if (!assertion) return json(res, 400, { error: "bad request" });
|
|
391
|
+
const browserId = readCookie(req, browserCookieName(secure));
|
|
392
|
+
if (!browserId) {
|
|
393
|
+
console.warn("[marver-id] callback with no browser handle");
|
|
394
|
+
return json(res, 401, { error: "not signed in" });
|
|
395
|
+
}
|
|
396
|
+
let landing = null;
|
|
397
|
+
const result = await verifyAssertion({
|
|
398
|
+
token: assertion,
|
|
399
|
+
origin,
|
|
400
|
+
issuer,
|
|
401
|
+
store: transactions,
|
|
402
|
+
browser: browserBinding(browserId),
|
|
403
|
+
onConsumed: (tx) => {
|
|
404
|
+
landing = tx.next ?? null;
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
if (!result.ok) {
|
|
408
|
+
console.warn(`[marver-id] assertion rejected: ${result.reason}`);
|
|
409
|
+
return json(res, 401, { error: "not signed in" });
|
|
410
|
+
}
|
|
411
|
+
const session = provisionFromMarverId(dir, {
|
|
412
|
+
...result.identity,
|
|
413
|
+
issuer
|
|
414
|
+
}, { ownerEmail: process.env.MARVER_OWNER_EMAIL });
|
|
415
|
+
if (!session) {
|
|
416
|
+
console.warn(`[marver-id] ${result.identity.email} is not on this canvas's list`);
|
|
417
|
+
return json(res, 403, {
|
|
418
|
+
error: "not invited",
|
|
419
|
+
email: result.identity.email
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
const picture = result.identity.picture;
|
|
423
|
+
const qualified = `${issuer}#${result.identity.subject}`;
|
|
424
|
+
if (picture) {
|
|
425
|
+
const before = avatarSourceFor(dir, qualified, result.identity.email, picture);
|
|
426
|
+
if (before.wanted) {
|
|
427
|
+
const avatar = await fetchAvatar(picture);
|
|
428
|
+
if (avatar) attachAvatar(dir, qualified, avatar, picture, before.source);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
res.setHeader("set-cookie", [
|
|
432
|
+
cookie(SESSION_COOKIE, session.session, {
|
|
433
|
+
maxAge: MONTH,
|
|
434
|
+
secure
|
|
435
|
+
}),
|
|
436
|
+
`${CSRF_COOKIE}=${randomBytes(16).toString("base64url")}; Path=/; Max-Age=${MONTH}; SameSite=Lax${secure ? "; Secure" : ""}`,
|
|
437
|
+
cookie(browserCookieName(secure), "", {
|
|
438
|
+
maxAge: 0,
|
|
439
|
+
secure
|
|
440
|
+
})
|
|
441
|
+
]);
|
|
442
|
+
return json(res, 200, {
|
|
443
|
+
ok: true,
|
|
444
|
+
next: landing
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
return json(res, 404, { error: "not found" });
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* The page the identity service sends the tab back to.
|
|
452
|
+
*
|
|
453
|
+
* Fixed bytes for everyone: the assertion is in the fragment, which the browser
|
|
454
|
+
* never sent here, so there is nothing to template and nothing to escape. The
|
|
455
|
+
* script reads it, wipes it out of the address bar before doing anything else -
|
|
456
|
+
* so it cannot be left behind in history or read off the screen - and hands it
|
|
457
|
+
* to the callback over a SAME-ORIGIN POST, which is what keeps the browser
|
|
458
|
+
* handle cookie flowing under SameSite=Lax.
|
|
459
|
+
*
|
|
460
|
+
* A refusal is shown HERE rather than bounced back to the gate, because this is
|
|
461
|
+
* the page somebody is looking at. Being told "that account was not invited" on
|
|
462
|
+
* the screen in front of you beats being returned to a form that silently
|
|
463
|
+
* refuses to move.
|
|
464
|
+
*/
|
|
465
|
+
/**
|
|
466
|
+
* The mark and the escaper, defined here rather than imported from serve.ts.
|
|
467
|
+
*
|
|
468
|
+
* serve.ts imports this module, so reaching back for them would be a cycle. The
|
|
469
|
+
* paths are the same ones the gate draws - if one changes, both change, which
|
|
470
|
+
* is exactly the coupling the shared look depends on.
|
|
471
|
+
*/
|
|
472
|
+
const MARK_AT = (size) => `<svg viewBox="0 0 256 256" width="${size}" height="${size}" fill="currentColor" aria-hidden><path d="M239.29,59.28l-64.8,144a8,8,0,0,1-7.3,4.72H24a8,8,0,0,1-7.3-11.28l64.8-144A8,8,0,0,1,88.81,48H232A8,8,0,0,1,239.29,59.28Z" opacity=".1"/><path d="M245.43,47.31A15.94,15.94,0,0,0,232,40H88.81a16,16,0,0,0-14.59,9.43l-64.8,144A16,16,0,0,0,24,216H167.19a16,16,0,0,0,14.59-9.43l64.8-144A16,16,0,0,0,245.43,47.31ZM167.19,200H24L88.81,56H232Z"/></svg>`;
|
|
473
|
+
const MARK = MARK_AT(18);
|
|
474
|
+
const MARK_LG = MARK_AT(24);
|
|
475
|
+
const ARROW = `<svg class="up" viewBox="0 0 256 256" width="11" height="11" fill="currentColor" aria-hidden><path d="M200,64V168a8,8,0,0,1-16,0V83.31L69.66,197.66a8,8,0,0,1-11.32-11.32L172.69,72H88a8,8,0,0,1,0-16H192A8,8,0,0,1,200,64Z"/></svg>`;
|
|
476
|
+
/** The canvas name is the operator's own, but it still lands in HTML. */
|
|
477
|
+
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
478
|
+
/**
|
|
479
|
+
* `share: { branding: false }` is documented as stripping every Marver mention,
|
|
480
|
+
* and this page was the exception nobody noticed - the last screen of the flow,
|
|
481
|
+
* still wearing the footer an operator had explicitly turned off. A setting that
|
|
482
|
+
* holds almost everywhere is a setting you cannot rely on.
|
|
483
|
+
*/
|
|
484
|
+
function finishPage(canvasName, host, switchUrl, branding = true) {
|
|
485
|
+
const name = esc(canvasName || "this canvas");
|
|
486
|
+
const where = esc(host);
|
|
487
|
+
return `<!doctype html>
|
|
488
|
+
<html lang="en"><head><meta charset="utf-8" />
|
|
489
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
490
|
+
<meta name="robots" content="noindex" />
|
|
491
|
+
<title>Signing in - ${name}</title>
|
|
492
|
+
<link rel="icon" href="/__mv/favicon/favicon.ico" sizes="48x48" />
|
|
493
|
+
<style>
|
|
494
|
+
/* The gate's own tokens. This page is the last thing somebody sees before the
|
|
495
|
+
canvas opens - or the only thing, if they are refused - so it belongs to
|
|
496
|
+
the same surface rather than looking like an error from somewhere else. */
|
|
497
|
+
* { box-sizing: border-box }
|
|
498
|
+
body { margin: 0; min-height: 100vh; display: flex; flex-direction: column;
|
|
499
|
+
align-items: center; justify-content: center; gap: 18px;
|
|
500
|
+
background-color: #e7e9ef;
|
|
501
|
+
background-image: radial-gradient(#c9cbd5 1px, transparent 1px);
|
|
502
|
+
background-size: 20px 20px;
|
|
503
|
+
font: 500 14px -apple-system, system-ui, sans-serif; color: #18181b;
|
|
504
|
+
-webkit-font-smoothing: antialiased }
|
|
505
|
+
.card { width: 340px; padding: 24px; border-radius: 24px;
|
|
506
|
+
background: rgba(255,255,255,.64);
|
|
507
|
+
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
|
|
508
|
+
box-shadow: 0 1px 2px rgba(24,24,27,.04), 0 12px 32px rgba(24,24,27,.07);
|
|
509
|
+
display: flex; flex-direction: column; gap: 14px }
|
|
510
|
+
header { display: flex; align-items: center; gap: 9px }
|
|
511
|
+
header svg { color: #0088ff; flex: none }
|
|
512
|
+
h1 { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: -.01em }
|
|
513
|
+
/* Pulled up against the address below it: the state line introduces that
|
|
514
|
+
chip rather than floating between it and the canvas name. */
|
|
515
|
+
.state { margin: 0 0 -8px; padding: 0 4px; font-size: 13.5px; font-weight: 600 }
|
|
516
|
+
.chip { height: 40px; padding: 0 16px; border-radius: 999px;
|
|
517
|
+
background: rgba(24,24,27,.05); display: flex; align-items: center;
|
|
518
|
+
font: 500 13px ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
519
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
|
520
|
+
p.lead { margin: 0; padding: 0 4px; font-size: 12.5px; line-height: 1.5;
|
|
521
|
+
color: rgba(24,24,27,.66) }
|
|
522
|
+
p.lead strong { color: #18181b; font-weight: 600 }
|
|
523
|
+
a.cta { height: 40px; border-radius: 999px; background: #18181b; color: #fafafa;
|
|
524
|
+
font: 600 13px -apple-system, system-ui, sans-serif; text-decoration: none;
|
|
525
|
+
display: flex; align-items: center; justify-content: center }
|
|
526
|
+
footer a { display: inline-flex; align-items: center; gap: 7px;
|
|
527
|
+
font: 600 12.5px -apple-system, system-ui, sans-serif;
|
|
528
|
+
color: rgba(24,24,27,.5); text-decoration: none }
|
|
529
|
+
footer a > svg:first-of-type { color: #0088ff }
|
|
530
|
+
footer .md, footer .up { transition: color .15s, opacity .15s }
|
|
531
|
+
footer a:hover .md { color: #0088ff; text-decoration: underline; text-underline-offset: 3px }
|
|
532
|
+
footer .up { opacity: .45; margin-left: -3px }
|
|
533
|
+
footer a:hover .up { opacity: 1; color: #0088ff }
|
|
534
|
+
[hidden] { display: none !important }
|
|
535
|
+
/* Shown only if the wait becomes long enough to be worth explaining.
|
|
536
|
+
Painting the whole card first meant every successful sign-in flashed a
|
|
537
|
+
panel naming the canvas and its address on the way past - a screen nobody
|
|
538
|
+
needs, appearing and vanishing, which reads as a stutter rather than as
|
|
539
|
+
progress. The card is for when something has to be SAID; getting in says
|
|
540
|
+
itself by the canvas appearing. */
|
|
541
|
+
.wait { width: 34px; height: 34px; border-radius: 50%;
|
|
542
|
+
border: 3px solid rgba(24,24,27,.12); border-top-color: #0088ff;
|
|
543
|
+
animation: spin .7s linear infinite }
|
|
544
|
+
@keyframes spin { to { transform: rotate(360deg) } }
|
|
545
|
+
@media (prefers-reduced-motion: reduce) {
|
|
546
|
+
.wait { animation: none; border-top-color: rgba(24,24,27,.35) }
|
|
547
|
+
}
|
|
548
|
+
</style></head>
|
|
549
|
+
<body>
|
|
550
|
+
<div class="wait" id="wait" role="status" aria-label="Signing you in" hidden></div>
|
|
551
|
+
<div class="card" id="card" hidden>
|
|
552
|
+
<header>${MARK_LG}<h1 id="t">${name}</h1></header>
|
|
553
|
+
<p class="state" id="s"></p>
|
|
554
|
+
<div class="chip">${where}</div>
|
|
555
|
+
<p class="lead" id="m"></p>
|
|
556
|
+
<p class="lead" id="m2" hidden></p>
|
|
557
|
+
<a class="cta" id="back" href="/" data-switch="${esc(switchUrl)}" hidden>Use a different account</a>
|
|
558
|
+
</div>
|
|
559
|
+
${branding ? `<footer id="mark" hidden><a href="${poweredByUrl(canvasName, "published-canvas", "sign-in")}" target="_blank" rel="noopener">${MARK} <span>Powered by <span class="md">Marver.design</span></span> ${ARROW}</a></footer>` : ""}
|
|
560
|
+
<script>
|
|
561
|
+
(function () {
|
|
562
|
+
var s = document.getElementById('s'), m = document.getElementById('m'), back = document.getElementById('back')
|
|
563
|
+
var m2 = document.getElementById('m2')
|
|
564
|
+
var wait = document.getElementById('wait'), card = document.getElementById('card')
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* A spinner, but only if the wait is long enough to need one.
|
|
568
|
+
*
|
|
569
|
+
* This page is usually one POST - fifty to two hundred milliseconds - and a
|
|
570
|
+
* spinner that appears and vanishes inside that window is worse than no
|
|
571
|
+
* spinner at all: it is a flicker, and a flicker reads as something going
|
|
572
|
+
* wrong. Held back, the fast path shows only the dotted ground, which is the
|
|
573
|
+
* same ground the canvas itself is drawn on - so it looks like the canvas
|
|
574
|
+
* loading rather than like a screen in between.
|
|
575
|
+
*
|
|
576
|
+
* Past a second and a half the wait is real, and silence stops being calm and
|
|
577
|
+
* starts being broken. That is when the spinner earns its place.
|
|
578
|
+
*/
|
|
579
|
+
var slow = setTimeout(function () { wait.hidden = false }, 1500)
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Stop waiting and show the card - only ever for something worth reading.
|
|
583
|
+
*
|
|
584
|
+
* The badge belongs to the CARD, not to the page. Left outside the hidden
|
|
585
|
+
* card it sat by itself in the middle of an empty ground on every successful
|
|
586
|
+
* sign-in - which is the same flash the card used to be, wearing a smaller
|
|
587
|
+
* hat. Nothing is on screen until there is something to say.
|
|
588
|
+
*/
|
|
589
|
+
var mark = document.getElementById('mark')
|
|
590
|
+
function speak() {
|
|
591
|
+
clearTimeout(slow)
|
|
592
|
+
wait.hidden = true
|
|
593
|
+
card.hidden = false
|
|
594
|
+
if (mark) mark.hidden = false
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* State, what happened, and - separately - what to do about it.
|
|
598
|
+
*
|
|
599
|
+
* The advice used to run on from the diagnosis in one paragraph, so the
|
|
600
|
+
* sentence that tells somebody how to get unstuck was buried at the end of
|
|
601
|
+
* the sentence explaining why they are stuck. They are two different thoughts
|
|
602
|
+
* and the person only needs the second one.
|
|
603
|
+
*/
|
|
604
|
+
function stop(state, msg, cta, advice) {
|
|
605
|
+
speak()
|
|
606
|
+
s.textContent = state; m.textContent = msg
|
|
607
|
+
if (advice) { m2.textContent = advice; m2.hidden = false }
|
|
608
|
+
if (cta) { back.textContent = cta; back.hidden = false }
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Refused, and told which account did it. */
|
|
612
|
+
function refused(email) {
|
|
613
|
+
speak()
|
|
614
|
+
s.textContent = "You haven't been invited"
|
|
615
|
+
m.textContent = ''
|
|
616
|
+
m.appendChild(document.createTextNode('You are signed in as '))
|
|
617
|
+
var b = document.createElement('strong')
|
|
618
|
+
b.textContent = email
|
|
619
|
+
m.appendChild(b)
|
|
620
|
+
m.appendChild(document.createTextNode(', and that address is not on the invite list for this canvas.'))
|
|
621
|
+
m2.textContent = 'Ask whoever owns it to add you, or sign in with the address they invited.'
|
|
622
|
+
m2.hidden = false
|
|
623
|
+
back.textContent = 'Use a different account'
|
|
624
|
+
// Signing out happens at the identity service - this canvas cannot reach
|
|
625
|
+
// across origins to do it, and sending them back here would just hand them
|
|
626
|
+
// the same account and the same refusal.
|
|
627
|
+
back.href = back.getAttribute('data-switch') || '/'
|
|
628
|
+
back.hidden = false
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Take it, then erase it - before any await, so a slow network cannot leave
|
|
632
|
+
// the assertion sitting in the address bar.
|
|
633
|
+
var assertion = location.hash.replace(/^#/, '')
|
|
634
|
+
try { history.replaceState(null, '', location.pathname) } catch (e) {}
|
|
635
|
+
|
|
636
|
+
if (!assertion) return stop('Nothing to sign in with', 'That link is incomplete. Start again from the canvas.', 'Back to the canvas')
|
|
637
|
+
|
|
638
|
+
fetch('/__mv/id/callback', {
|
|
639
|
+
method: 'POST',
|
|
640
|
+
headers: { 'content-type': 'application/json' },
|
|
641
|
+
body: JSON.stringify({ assertion: assertion })
|
|
642
|
+
}).then(function (res) {
|
|
643
|
+
if (res.ok) {
|
|
644
|
+
// Back to the board or thread the link pointed at. The server decides
|
|
645
|
+
// where that is - it held the deep link the whole time - so a value in
|
|
646
|
+
// this page cannot send anybody somewhere else.
|
|
647
|
+
return res.json().then(function (body) {
|
|
648
|
+
var to = body && typeof body.next === 'string' && body.next.indexOf('#/') === 0 ? body.next : ''
|
|
649
|
+
location.replace('/' + to)
|
|
650
|
+
}, function () { location.replace('/') })
|
|
651
|
+
}
|
|
652
|
+
if (res.status === 403) {
|
|
653
|
+
return res.json().then(function (body) {
|
|
654
|
+
var who = body && typeof body.email === 'string' ? body.email : ''
|
|
655
|
+
// textContent, never innerHTML: the address is attested, but it is still
|
|
656
|
+
// a string arriving over the wire and this page will not be the place
|
|
657
|
+
// that learns the difference the hard way.
|
|
658
|
+
who ? refused(who) : stop("You haven't been invited",
|
|
659
|
+
'That account is not on the invite list for this canvas. Ask whoever owns it to add your address.',
|
|
660
|
+
'Use a different account')
|
|
661
|
+
}, function () {
|
|
662
|
+
stop("You haven't been invited",
|
|
663
|
+
'That account is not on the invite list for this canvas.',
|
|
664
|
+
'Use a different account',
|
|
665
|
+
'Ask whoever owns it to add your address.')
|
|
666
|
+
})
|
|
667
|
+
}
|
|
668
|
+
stop('That sign-in did not work', 'Start again from the canvas, or try a different account.', 'Try again')
|
|
669
|
+
}).catch(function () {
|
|
670
|
+
stop('Could not reach the canvas', 'It may have stopped. Try again in a moment.', 'Try again')
|
|
671
|
+
})
|
|
672
|
+
})()
|
|
673
|
+
<\/script>
|
|
674
|
+
</body></html>`;
|
|
675
|
+
}
|
|
676
|
+
function json(res, status, body) {
|
|
677
|
+
res.statusCode = status;
|
|
678
|
+
res.setHeader("content-type", "application/json");
|
|
679
|
+
res.setHeader("cache-control", "no-store");
|
|
680
|
+
res.end(JSON.stringify(body));
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
function cookie(name, value, o) {
|
|
684
|
+
return `${name}=${value}; Path=/; Max-Age=${o.maxAge}; HttpOnly; SameSite=Lax${o.secure ? "; Secure" : ""}`;
|
|
685
|
+
}
|
|
686
|
+
function readCookie(req, name) {
|
|
687
|
+
return new RegExp(`(?:^|;\\s*)${name}=([\\w-]+)`).exec(String(req.headers.cookie ?? ""))?.[1] ?? "";
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Read a bounded request body.
|
|
691
|
+
*
|
|
692
|
+
* Counts BYTES, not decoded characters, and signals an overflow rather than
|
|
693
|
+
* destroying the socket - the previous version killed the connection and then
|
|
694
|
+
* tried to write a response onto it, which cannot arrive.
|
|
695
|
+
*/
|
|
696
|
+
function readBody(req, limit, timeoutMs = 1e4) {
|
|
697
|
+
return new Promise((resolve) => {
|
|
698
|
+
const chunks = [];
|
|
699
|
+
let size = 0;
|
|
700
|
+
let done = false;
|
|
701
|
+
const finish = (tooLarge) => {
|
|
702
|
+
if (done) return;
|
|
703
|
+
done = true;
|
|
704
|
+
clearTimeout(timer);
|
|
705
|
+
resolve({
|
|
706
|
+
body: tooLarge ? "" : Buffer.concat(chunks).toString("utf8"),
|
|
707
|
+
tooLarge
|
|
708
|
+
});
|
|
709
|
+
};
|
|
710
|
+
const timer = setTimeout(() => {
|
|
711
|
+
req.pause();
|
|
712
|
+
finish(true);
|
|
713
|
+
}, timeoutMs);
|
|
714
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
715
|
+
req.on("data", (c) => {
|
|
716
|
+
size += c.length;
|
|
717
|
+
if (size > limit) {
|
|
718
|
+
req.pause();
|
|
719
|
+
return finish(true);
|
|
720
|
+
}
|
|
721
|
+
chunks.push(c);
|
|
722
|
+
});
|
|
723
|
+
req.on("end", () => finish(false));
|
|
724
|
+
req.on("error", () => finish(true));
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
//#endregion
|
|
728
|
+
export { marverIdHandler };
|