@wizzlethorpe/vaults 0.13.2 → 0.14.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/dist/asset-refs.js +274 -0
- package/dist/asset-refs.js.map +1 -0
- package/dist/auth.js.map +1 -1
- package/dist/build.js +252 -492
- package/dist/build.js.map +1 -1
- package/dist/commands/build.js +54 -5
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/preview.js +0 -4
- package/dist/commands/preview.js.map +1 -1
- package/dist/commands/push.js +4 -9
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/role.js +55 -14
- package/dist/commands/role.js.map +1 -1
- package/dist/config.js +25 -5
- package/dist/config.js.map +1 -1
- package/dist/foundry-importer.bundle.js +1228 -327
- package/dist/foundry-importer.js +2 -7
- package/dist/foundry-importer.js.map +1 -1
- package/dist/foundry-meta.js +284 -0
- package/dist/foundry-meta.js.map +1 -0
- package/dist/foundry-module-journal.js +112 -0
- package/dist/foundry-module-journal.js.map +1 -0
- package/dist/foundry-module-render.js +75 -0
- package/dist/foundry-module-render.js.map +1 -0
- package/dist/foundry-module.js +1075 -0
- package/dist/foundry-module.js.map +1 -0
- package/dist/frontmatter-defaults.js +68 -0
- package/dist/frontmatter-defaults.js.map +1 -0
- package/dist/index.js +9 -9
- package/dist/index.js.map +1 -1
- package/dist/manifest.js +115 -0
- package/dist/manifest.js.map +1 -0
- package/dist/render/auth-template.js +323 -35
- package/dist/render/auth-template.js.map +1 -1
- package/dist/render/bases.js +22 -38
- package/dist/render/bases.js.map +1 -1
- package/dist/render/cover.js +23 -1
- package/dist/render/cover.js.map +1 -1
- package/dist/render/handlers/builtin/download.js +90 -0
- package/dist/render/handlers/builtin/download.js.map +1 -0
- package/dist/render/handlers/builtin/foundry-manifest.js +158 -0
- package/dist/render/handlers/builtin/foundry-manifest.js.map +1 -0
- package/dist/render/handlers/builtin/index.js +3 -1
- package/dist/render/handlers/builtin/index.js.map +1 -1
- package/dist/render/pipeline.js +4 -1
- package/dist/render/pipeline.js.map +1 -1
- package/dist/render/slug.js +0 -5
- package/dist/render/slug.js.map +1 -1
- package/dist/scan.js +8 -0
- package/dist/scan.js.map +1 -1
- package/dist/settings.js +112 -6
- package/dist/settings.js.map +1 -1
- package/package.json +2 -1
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
// role list and password hashes baked in. Validates session cookies, redirects
|
|
3
3
|
// to /login on a missing/expired cookie, and rewrites every request to the
|
|
4
4
|
// matching `_variants/<role>/` path before letting Pages serve it.
|
|
5
|
+
import { htmlAttr, htmlEscape } from "../escape.js";
|
|
5
6
|
export function renderAuthMiddleware(cfg) {
|
|
6
7
|
const rolesLiteral = JSON.stringify(cfg.roles);
|
|
7
8
|
const passwordsLiteral = JSON.stringify(cfg.rolePasswords);
|
|
8
9
|
const patreonLiteral = JSON.stringify(cfg.patreon ?? null);
|
|
9
10
|
const oidcLiteral = JSON.stringify(cfg.oidc ?? null);
|
|
11
|
+
const foundryLiteral = JSON.stringify(!!cfg.foundry);
|
|
10
12
|
return `// Auto-generated by the vaults CLI. Do not edit by hand.
|
|
11
13
|
// Roles, password hashes, and routing live here so the deployed Function
|
|
12
14
|
// is fully self-contained and doesn't need any other binding besides
|
|
@@ -18,6 +20,9 @@ const ROLES = ${rolesLiteral};
|
|
|
18
20
|
const PASSWORDS = ${passwordsLiteral};
|
|
19
21
|
const PATREON = ${patreonLiteral};
|
|
20
22
|
const OIDC = ${oidcLiteral};
|
|
23
|
+
// False on a deploy that opted out of the Foundry integration; the /_batch
|
|
24
|
+
// endpoints below are the API its module syncs through and nothing else uses.
|
|
25
|
+
const FOUNDRY = ${foundryLiteral};
|
|
21
26
|
const COOKIE_NAME = "vault_role";
|
|
22
27
|
// Non-HttpOnly companion cookie carrying the role name only; the auth check
|
|
23
28
|
// uses COOKIE_NAME (which is signed and HttpOnly), this one is purely for UI.
|
|
@@ -26,6 +31,20 @@ const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
|
|
|
26
31
|
// Bearer tokens (used by Foundry, MCP clients) get a much longer lifetime
|
|
27
32
|
// since refreshing means reopening a browser-based approval flow.
|
|
28
33
|
const BEARER_MAX_AGE = 60 * 60 * 24 * 90; // 90 days
|
|
34
|
+
// Long enough to paste a URL somewhere and press install, short enough that a
|
|
35
|
+
// leaked one is worthless. Deliberately not single-use: a use-count needs
|
|
36
|
+
// server-side state, and this deploy has none by design, so the honest
|
|
37
|
+
// stateless equivalent is a small window. Pre-signed URLs everywhere else
|
|
38
|
+
// work the same way for the same reason.
|
|
39
|
+
const LINK_MAX_AGE = 60 * 10; // 10 minutes
|
|
40
|
+
// Tokens carry their purpose so the two are not interchangeable: without
|
|
41
|
+
// this a 7-day session cookie and a 90-day bearer were byte-identical in
|
|
42
|
+
// format, so either could be replayed as the other.
|
|
43
|
+
const TOKEN_TYPE_SESSION = "s";
|
|
44
|
+
const TOKEN_TYPE_BEARER = "b";
|
|
45
|
+
// A link token. Separate from a bearer because it is honoured on ordinary
|
|
46
|
+
// navigation, which a bearer deliberately is not — see readRole.
|
|
47
|
+
const TOKEN_TYPE_LINK = "l";
|
|
29
48
|
const PBKDF2_DEFAULT_ITERATIONS = 100000;
|
|
30
49
|
// Same shape as a real PBKDF2 hash (iterations:saltHex:hashHex with the
|
|
31
50
|
// expected lengths) but with all-zero salt + hash. Used to keep the
|
|
@@ -37,6 +56,25 @@ const DUMMY_PASSWORD_HASH = "100000:" + "0".repeat(32) + ":" + "0".repeat(64);
|
|
|
37
56
|
// ── Public middleware entry ────────────────────────────────────────────────
|
|
38
57
|
|
|
39
58
|
export const onRequest = async (ctx) => {
|
|
59
|
+
return withSecurityHeaders(await handleRequest(ctx));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Headers every response gets, added at the one exit so redirects and error
|
|
64
|
+
* responses are covered too.
|
|
65
|
+
*
|
|
66
|
+
* Referrer-Policy is load-bearing here rather than hygiene: the Foundry sync
|
|
67
|
+
* passes its bearer as ?_token= in the URL, and without this any external
|
|
68
|
+
* link on a page fetched that way would carry the token in the Referer.
|
|
69
|
+
*/
|
|
70
|
+
function withSecurityHeaders(response) {
|
|
71
|
+
const out = new Response(response.body, response);
|
|
72
|
+
out.headers.set("Referrer-Policy", "no-referrer");
|
|
73
|
+
out.headers.set("X-Content-Type-Options", "nosniff");
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const handleRequest = async (ctx) => {
|
|
40
78
|
const { request, env, next } = ctx;
|
|
41
79
|
const url = new URL(request.url);
|
|
42
80
|
|
|
@@ -88,6 +126,14 @@ export const onRequest = async (ctx) => {
|
|
|
88
126
|
return handleConnectApprove(request, env);
|
|
89
127
|
}
|
|
90
128
|
|
|
129
|
+
// /_link. Mints a short-lived, self-authenticating URL for one path, for
|
|
130
|
+
// consumers that cannot carry the session cookie. Foundry's module
|
|
131
|
+
// installer is the motivating one: it runs on the Foundry server, not in
|
|
132
|
+
// the browser, so it needs a URL that stands on its own.
|
|
133
|
+
if (url.pathname === "/_link" && request.method === "GET") {
|
|
134
|
+
return handleLink(request, env);
|
|
135
|
+
}
|
|
136
|
+
|
|
91
137
|
// /auth/patreon/* — only mounted when the build saw a Patreon config.
|
|
92
138
|
// Issues the same signed session cookie as password login on success;
|
|
93
139
|
// failure paths bounce back to /login with an error param.
|
|
@@ -115,7 +161,7 @@ export const onRequest = async (ctx) => {
|
|
|
115
161
|
// newline-separated paths under text/plain so the request stays CORS-
|
|
116
162
|
// simple (no preflight per file → no OPTIONS rate-limit). Response is
|
|
117
163
|
// JSON: { files: { path: content }, missing: [path, ...] }.
|
|
118
|
-
if (url.pathname === "/_batch" && request.method === "POST") {
|
|
164
|
+
if (FOUNDRY && url.pathname === "/_batch" && request.method === "POST") {
|
|
119
165
|
return withCors(await handleBatch(request, env), request);
|
|
120
166
|
}
|
|
121
167
|
|
|
@@ -123,7 +169,7 @@ export const onRequest = async (ctx) => {
|
|
|
123
169
|
// /_batch but each file is base64-encoded so it can ride in JSON. Used by
|
|
124
170
|
// the Foundry image cache so a 300-image sync is a handful of HTTP calls
|
|
125
171
|
// instead of 300 GETs that hit Cloudflare's per-IP rate limit.
|
|
126
|
-
if (url.pathname === "/_batch-images" && request.method === "POST") {
|
|
172
|
+
if (FOUNDRY && url.pathname === "/_batch-images" && request.method === "POST") {
|
|
127
173
|
return withCors(await handleBatchBinary(request, env), request);
|
|
128
174
|
}
|
|
129
175
|
|
|
@@ -135,6 +181,20 @@ export const onRequest = async (ctx) => {
|
|
|
135
181
|
// Determine the user's role from the session cookie (default = lowest).
|
|
136
182
|
const role = await readRole(request, env);
|
|
137
183
|
|
|
184
|
+
// Installing a Foundry module is two fetches: the manifest, then the zip its
|
|
185
|
+
// download field names. A gated manifest is a static file, so it cannot
|
|
186
|
+
// carry a live token for that second fetch, and the zip would 401 — the
|
|
187
|
+
// install fails halfway with nothing useful said about why.
|
|
188
|
+
//
|
|
189
|
+
// So a manifest fetched with a link token gets its download URL signed to
|
|
190
|
+
// match, with the same short expiry. The rewrite only ever fires when a
|
|
191
|
+
// valid link token is present and the field points back at this site, so it
|
|
192
|
+
// cannot be used to attach our signature to somebody else's URL.
|
|
193
|
+
const linkToken = new URL(request.url).searchParams.get("_token");
|
|
194
|
+
const linkRole = linkToken
|
|
195
|
+
? await verifyToken(linkToken, env.SESSION_SECRET, TOKEN_TYPE_LINK)
|
|
196
|
+
: null;
|
|
197
|
+
|
|
138
198
|
// env.ASSETS canonicalizes URLs (strips .html, strips index.html, redirects
|
|
139
199
|
// with 308s); passing those redirects through to the browser would expose
|
|
140
200
|
// the /_variants/<role>/ path, which the guard at the top of this function
|
|
@@ -150,6 +210,11 @@ export const onRequest = async (ctx) => {
|
|
|
150
210
|
response = await env.ASSETS.fetch(rewritten);
|
|
151
211
|
}
|
|
152
212
|
}
|
|
213
|
+
if (linkRole && response.ok && /\.json$/i.test(url.pathname)) {
|
|
214
|
+
const signed = await signManifestDownload(response, url, env, linkRole);
|
|
215
|
+
if (signed) return signed;
|
|
216
|
+
}
|
|
217
|
+
|
|
153
218
|
// Replace bare 404s with the variant's styled 404 page so the reader stays
|
|
154
219
|
// inside the site (sidebar, search, sitemap intact). Only HTML navigation
|
|
155
220
|
// requests get the page; asset/API requests get the bare 404 unchanged.
|
|
@@ -280,10 +345,31 @@ async function handleBatchInner(request, env, maxPaths, encode) {
|
|
|
280
345
|
if (!isSafePath(p)) return batchError(400, "Invalid path: " + p);
|
|
281
346
|
}
|
|
282
347
|
|
|
283
|
-
//
|
|
348
|
+
// Which rendering to return, which is not always the caller's own.
|
|
349
|
+
//
|
|
350
|
+
// A page carries its own role, and the sync client marks a page readable by
|
|
351
|
+
// players when that role is below the DM tier. If it then filled that page
|
|
352
|
+
// with the *DM's* rendering — which is what happens when the variant is
|
|
353
|
+
// always the caller's — a public page ends up holding DM content and
|
|
354
|
+
// readable by players. A base view filtered by role is exactly that shape:
|
|
355
|
+
// one row per creature for the DM, one for everyone else, same page.
|
|
356
|
+
//
|
|
357
|
+
// So a caller may ask for any variant at or below their own tier. Below,
|
|
358
|
+
// because that is content they can already read; never above.
|
|
284
359
|
const url = new URL(request.url);
|
|
360
|
+
const requested = url.searchParams.get("role");
|
|
361
|
+
let variant = role;
|
|
362
|
+
if (requested !== null) {
|
|
363
|
+
const wantIdx = ROLES.indexOf(requested);
|
|
364
|
+
if (wantIdx === -1 || wantIdx > ROLES.indexOf(role)) {
|
|
365
|
+
return batchError(403, "Cannot request that variant.");
|
|
366
|
+
}
|
|
367
|
+
variant = requested;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ASSETS.fetch is internal to the worker, so fan-out is cheap.
|
|
285
371
|
const entries = await Promise.all(paths.map(async (p) => {
|
|
286
|
-
const target = new URL("/_variants/" +
|
|
372
|
+
const target = new URL("/_variants/" + variant + "/" + encodeVariantPath(p), url.origin).toString();
|
|
287
373
|
const res = await env.ASSETS.fetch(target);
|
|
288
374
|
if (!res.ok) return [p, null];
|
|
289
375
|
return [p, await encode(res)];
|
|
@@ -337,10 +423,11 @@ async function handleConnectGet(request, env) {
|
|
|
337
423
|
const url = new URL(request.url);
|
|
338
424
|
const app = url.searchParams.get("app") || "an external app";
|
|
339
425
|
|
|
340
|
-
// Require login first; the user's role is what we're authorising.
|
|
426
|
+
// Require login first; the user's role is what we're authorising. The
|
|
427
|
+
// default role is the unauthenticated one, so it is the whole test —
|
|
428
|
+
// any other role can only have come from a verified cookie or token.
|
|
341
429
|
const role = await readRole(request, env);
|
|
342
|
-
|
|
343
|
-
if (!isLoggedIn || role === ROLES[0]) {
|
|
430
|
+
if (role === ROLES[0]) {
|
|
344
431
|
// Default role; redirect to login first, come back here on success.
|
|
345
432
|
const next = url.pathname + url.search;
|
|
346
433
|
return new Response(null, {
|
|
@@ -363,11 +450,98 @@ async function handleConnectApprove(request, env) {
|
|
|
363
450
|
return new Response("Not signed in.", { status: 401 });
|
|
364
451
|
}
|
|
365
452
|
|
|
366
|
-
const token = await signToken(role, env.SESSION_SECRET, BEARER_MAX_AGE);
|
|
453
|
+
const token = await signToken(role, env.SESSION_SECRET, BEARER_MAX_AGE, TOKEN_TYPE_BEARER);
|
|
367
454
|
const html = renderConnectCopyPage({ token, role, app });
|
|
368
455
|
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
369
456
|
}
|
|
370
457
|
|
|
458
|
+
/**
|
|
459
|
+
* Issue a short-lived URL for a single vault path.
|
|
460
|
+
*
|
|
461
|
+
* The token carries the caller's own role, so this grants nothing they could
|
|
462
|
+
* not already fetch with their cookie; it only moves that authority into a
|
|
463
|
+
* URL, and puts a short clock on it.
|
|
464
|
+
*
|
|
465
|
+
* The path is confined to this deploy: it is resolved against the origin and
|
|
466
|
+
* rejected if it leaves, so a caller cannot get us to sign a link pointing at
|
|
467
|
+
* somewhere else.
|
|
468
|
+
*/
|
|
469
|
+
async function handleLink(request, env) {
|
|
470
|
+
const url = new URL(request.url);
|
|
471
|
+
// Minting requires a session or a bearer, never a link token: otherwise one
|
|
472
|
+
// leaked install URL renews itself indefinitely instead of expiring.
|
|
473
|
+
const role = await readRole(request, env, { rejectLinkTokens: true });
|
|
474
|
+
if (role === ROLES[0]) {
|
|
475
|
+
return jsonResponse({ error: "not signed in" }, 401);
|
|
476
|
+
}
|
|
477
|
+
const requested = url.searchParams.get("path") || "";
|
|
478
|
+
if (!requested) {
|
|
479
|
+
return jsonResponse({ error: "missing path" }, 400);
|
|
480
|
+
}
|
|
481
|
+
let target;
|
|
482
|
+
try {
|
|
483
|
+
target = new URL(requested, url.origin);
|
|
484
|
+
} catch {
|
|
485
|
+
return jsonResponse({ error: "bad path" }, 400);
|
|
486
|
+
}
|
|
487
|
+
if (target.origin !== url.origin) {
|
|
488
|
+
return jsonResponse({ error: "path must be on this site" }, 400);
|
|
489
|
+
}
|
|
490
|
+
const token = await signToken(role, env.SESSION_SECRET, LINK_MAX_AGE, TOKEN_TYPE_LINK);
|
|
491
|
+
target.searchParams.set("_token", token);
|
|
492
|
+
return jsonResponse({
|
|
493
|
+
url: target.toString(),
|
|
494
|
+
role,
|
|
495
|
+
expiresInMinutes: Math.round(LINK_MAX_AGE / 60),
|
|
496
|
+
}, 200);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Re-sign a module manifest's download URL so the second half of a Foundry
|
|
501
|
+
* install authenticates too.
|
|
502
|
+
*
|
|
503
|
+
* Returns null and leaves the response untouched for anything that is not a
|
|
504
|
+
* manifest with a same-origin download field, so an ordinary JSON asset
|
|
505
|
+
* fetched with a link token is served exactly as stored.
|
|
506
|
+
*/
|
|
507
|
+
async function signManifestDownload(response, url, env, role) {
|
|
508
|
+
let manifest;
|
|
509
|
+
try { manifest = await response.clone().json(); }
|
|
510
|
+
catch { return null; }
|
|
511
|
+
if (!manifest || typeof manifest.download !== "string") return null;
|
|
512
|
+
|
|
513
|
+
// Relative resolves onto whichever host this request arrived on, which is
|
|
514
|
+
// the point: a vault can be served from several (a pages.dev name and a
|
|
515
|
+
// custom domain), and a manifest that hard-codes one of them is wrong on
|
|
516
|
+
// the others whether or not tokens are involved.
|
|
517
|
+
//
|
|
518
|
+
// An absolute URL naming a different host is left alone. That is the guard
|
|
519
|
+
// against attaching our signature to somebody else's URL, and it cannot
|
|
520
|
+
// distinguish the vault's own second hostname from anyone else's — so a
|
|
521
|
+
// manifest that hard-codes the pages.dev name will not be signed when
|
|
522
|
+
// fetched over the custom domain. Write the field relative. The CLI warns
|
|
523
|
+
// when it is not.
|
|
524
|
+
let target;
|
|
525
|
+
try { target = new URL(manifest.download, url.origin); }
|
|
526
|
+
catch { return null; }
|
|
527
|
+
if (target.origin !== url.origin) return null;
|
|
528
|
+
|
|
529
|
+
const token = await signToken(role, env.SESSION_SECRET, LINK_MAX_AGE, TOKEN_TYPE_LINK);
|
|
530
|
+
target.searchParams.set("_token", token);
|
|
531
|
+
manifest.download = target.toString();
|
|
532
|
+
return new Response(JSON.stringify(manifest), {
|
|
533
|
+
status: 200,
|
|
534
|
+
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function jsonResponse(body, status) {
|
|
539
|
+
return new Response(JSON.stringify(body), {
|
|
540
|
+
status,
|
|
541
|
+
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
371
545
|
function renderApprovePage({ app, role }) {
|
|
372
546
|
const escapedApp = escHtml(app);
|
|
373
547
|
const escapedRole = escHtml(role);
|
|
@@ -809,6 +983,15 @@ function base64UrlEncode(bytes) {
|
|
|
809
983
|
return btoa(s).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
|
|
810
984
|
}
|
|
811
985
|
|
|
986
|
+
/** Inverse of base64UrlEncode for UTF-8 text. */
|
|
987
|
+
function base64UrlDecodeUtf8(s) {
|
|
988
|
+
const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
989
|
+
const bin = atob(b64);
|
|
990
|
+
const bytes = new Uint8Array(bin.length);
|
|
991
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
992
|
+
return new TextDecoder().decode(bytes);
|
|
993
|
+
}
|
|
994
|
+
|
|
812
995
|
// State cookie: stores { state, next } (plus the PKCE verifier for OIDC)
|
|
813
996
|
// signed with SESSION_SECRET, used to verify the OAuth callback came from
|
|
814
997
|
// our /start handler and not a forgery. Shared by both OAuth flows.
|
|
@@ -819,7 +1002,12 @@ async function signStateCookie(payload, secret) {
|
|
|
819
1002
|
const exp = Math.floor(Date.now() / 1000) + STATE_TTL;
|
|
820
1003
|
const data = JSON.stringify({ ...payload, exp });
|
|
821
1004
|
const sig = await hmac(data, secret);
|
|
822
|
-
|
|
1005
|
+
// The payload carries the next path, and btoa throws on any code point
|
|
1006
|
+
// above U+00FF — so a page whose name uses a non-Latin1 character
|
|
1007
|
+
// (Sunawi with a w-circumflex, Ordogok, Japanese) turned the /start
|
|
1008
|
+
// handler into an unhandled 500. Encode the UTF-8 bytes instead.
|
|
1009
|
+
// (No backticks in this comment: it is inside the middleware template.)
|
|
1010
|
+
const value = base64UrlEncode(new TextEncoder().encode(data)) + "." + sig;
|
|
823
1011
|
return STATE_COOKIE + "=" + value
|
|
824
1012
|
+ "; Path=/; Secure; SameSite=Lax; HttpOnly; Max-Age=" + STATE_TTL;
|
|
825
1013
|
}
|
|
@@ -833,10 +1021,12 @@ async function readStateCookie(request, secret) {
|
|
|
833
1021
|
const dataB64 = raw.slice(0, dot);
|
|
834
1022
|
const sig = raw.slice(dot + 1);
|
|
835
1023
|
let data;
|
|
836
|
-
try { data =
|
|
1024
|
+
try { data = base64UrlDecodeUtf8(dataB64); }
|
|
837
1025
|
catch { return null; }
|
|
838
1026
|
const expected = await hmac(data, secret);
|
|
839
|
-
|
|
1027
|
+
// Constant-time, matching verifyToken; a plain !== leaks the signature
|
|
1028
|
+
// byte-by-byte through response timing.
|
|
1029
|
+
if (!constantTimeEqual(sig, expected)) return null;
|
|
840
1030
|
let parsed;
|
|
841
1031
|
try { parsed = JSON.parse(data); }
|
|
842
1032
|
catch { return null; }
|
|
@@ -846,7 +1036,7 @@ async function readStateCookie(request, secret) {
|
|
|
846
1036
|
|
|
847
1037
|
// ── Cookie + role lookup ──────────────────────────────────────────────────
|
|
848
1038
|
|
|
849
|
-
async function readRole(request, env) {
|
|
1039
|
+
async function readRole(request, env, opts) {
|
|
850
1040
|
const fallback = ROLES[0];
|
|
851
1041
|
if (!env.SESSION_SECRET) return fallback;
|
|
852
1042
|
|
|
@@ -856,35 +1046,78 @@ async function readRole(request, env) {
|
|
|
856
1046
|
const auth = request.headers.get("Authorization") || "";
|
|
857
1047
|
const bearerMatch = /^Bearer\\s+(.+)$/i.exec(auth);
|
|
858
1048
|
if (bearerMatch) {
|
|
859
|
-
const role = await verifyToken(bearerMatch[1], env.SESSION_SECRET);
|
|
1049
|
+
const role = await verifyToken(bearerMatch[1], env.SESSION_SECRET, TOKEN_TYPE_BEARER);
|
|
860
1050
|
if (role && ROLES.includes(role)) return role;
|
|
861
1051
|
}
|
|
862
1052
|
|
|
863
1053
|
// ?_token=<token>; used by the Foundry module so cross-origin GETs stay
|
|
864
1054
|
// CORS-simple and don't trigger a preflight per file (Cloudflare rate-
|
|
865
1055
|
// limits OPTIONS bursts and a sync is hundreds of unique URLs).
|
|
1056
|
+
//
|
|
1057
|
+
// Only honoured on a request that says it is a subresource fetch. A token
|
|
1058
|
+
// in a URL is a shareable credential that lands in browser history, access
|
|
1059
|
+
// logs and Referer, and bearers last 90 days — so a pasted link must not
|
|
1060
|
+
// quietly browse the site at someone else's role.
|
|
1061
|
+
//
|
|
1062
|
+
// Fails CLOSED: an absent Sec-Fetch-Mode means the token is ignored. The
|
|
1063
|
+
// earlier version only rejected an explicit "navigate", which left the hole
|
|
1064
|
+
// open for anything that does not send Fetch Metadata — a proxy that strips
|
|
1065
|
+
// it, or a browser older than Chrome 76 / Firefox 90 / Safari 16.4. The
|
|
1066
|
+
// query param exists purely so the sync client's cross-origin GETs stay
|
|
1067
|
+
// CORS-simple, and that client is a browser, so it always sends the header.
|
|
1068
|
+
// Anything that cannot has no reason to prefer the param: it can set an
|
|
1069
|
+
// Authorization: Bearer header freely, which is checked above.
|
|
866
1070
|
const queryToken = new URL(request.url).searchParams.get("_token");
|
|
867
|
-
if (queryToken) {
|
|
868
|
-
const role = await verifyToken(queryToken, env.SESSION_SECRET);
|
|
1071
|
+
if (queryToken && isSubresourceFetch(request)) {
|
|
1072
|
+
const role = await verifyToken(queryToken, env.SESSION_SECRET, TOKEN_TYPE_BEARER);
|
|
1073
|
+
if (role && ROLES.includes(role)) return role;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// A link token IS honoured on navigation, which is the whole reason it is a
|
|
1077
|
+
// separate type. The rule above rests on bearers lasting 90 days; a link
|
|
1078
|
+
// token lasts ten minutes, so the same reasoning does not reach it. It also
|
|
1079
|
+
// has to work this way: Foundry's module installer accepts a URL and
|
|
1080
|
+
// nothing else, so it cannot send the Authorization header the rule above
|
|
1081
|
+
// points anything header-capable towards.
|
|
1082
|
+
//
|
|
1083
|
+
// It authenticates one request and sets no cookie, so a shared link does
|
|
1084
|
+
// not turn into a session at someone else's role.
|
|
1085
|
+
// Not accepted where a caller could use one to obtain another. A link token
|
|
1086
|
+
// is a ten-minute grant, and a request that can mint a fresh one renews
|
|
1087
|
+
// itself forever — which would quietly turn the short window that justifies
|
|
1088
|
+
// honouring these on navigation into no window at all.
|
|
1089
|
+
if (queryToken && !opts?.rejectLinkTokens) {
|
|
1090
|
+
const role = await verifyToken(queryToken, env.SESSION_SECRET, TOKEN_TYPE_LINK);
|
|
869
1091
|
if (role && ROLES.includes(role)) return role;
|
|
870
1092
|
}
|
|
871
1093
|
|
|
872
1094
|
const cookie = parseCookie(request.headers.get("Cookie") || "")[COOKIE_NAME];
|
|
873
1095
|
if (!cookie) return fallback;
|
|
874
|
-
const role = await verifyToken(cookie, env.SESSION_SECRET);
|
|
1096
|
+
const role = await verifyToken(cookie, env.SESSION_SECRET, TOKEN_TYPE_SESSION);
|
|
875
1097
|
return role && ROLES.includes(role) ? role : fallback;
|
|
876
1098
|
}
|
|
877
1099
|
|
|
1100
|
+
/**
|
|
1101
|
+
* True only when the request explicitly identifies itself as a subresource
|
|
1102
|
+
* fetch rather than a navigation. Absent headers count as "not a fetch", so
|
|
1103
|
+
* the caller refuses rather than guesses.
|
|
1104
|
+
*/
|
|
1105
|
+
function isSubresourceFetch(request) {
|
|
1106
|
+
const mode = request.headers.get("Sec-Fetch-Mode");
|
|
1107
|
+
if (!mode || mode === "navigate") return false;
|
|
1108
|
+
return request.headers.get("Sec-Fetch-Dest") !== "document";
|
|
1109
|
+
}
|
|
1110
|
+
|
|
878
1111
|
// Format: <role>.<expiryUnix>.<hmacHex>
|
|
879
|
-
async function signToken(role, secret, maxAgeSeconds) {
|
|
1112
|
+
async function signToken(role, secret, maxAgeSeconds, typ) {
|
|
880
1113
|
const exp = Math.floor(Date.now() / 1000) + maxAgeSeconds;
|
|
881
|
-
const payload = role + "." + exp;
|
|
1114
|
+
const payload = typ + "." + role + "." + exp;
|
|
882
1115
|
const sig = await hmac(payload, secret);
|
|
883
1116
|
return payload + "." + sig;
|
|
884
1117
|
}
|
|
885
1118
|
|
|
886
1119
|
async function signSessionCookie(role, secret) {
|
|
887
|
-
const value = await signToken(role, secret, COOKIE_MAX_AGE);
|
|
1120
|
+
const value = await signToken(role, secret, COOKIE_MAX_AGE, TOKEN_TYPE_SESSION);
|
|
888
1121
|
// SameSite=None + Partitioned (CHIPS); required so the cookie persists
|
|
889
1122
|
// when the vault is loaded inside a cross-origin iframe (the Foundry
|
|
890
1123
|
// connect dialog). Partitioned scopes the cookie per parent origin, so
|
|
@@ -894,13 +1127,20 @@ async function signSessionCookie(role, secret) {
|
|
|
894
1127
|
+ "; Path=/; HttpOnly; Secure; SameSite=None; Partitioned; Max-Age=" + COOKIE_MAX_AGE;
|
|
895
1128
|
}
|
|
896
1129
|
|
|
897
|
-
async function verifyToken(token, secret) {
|
|
1130
|
+
async function verifyToken(token, secret, expectedTyp) {
|
|
898
1131
|
const parts = token.split(".");
|
|
899
|
-
|
|
900
|
-
|
|
1132
|
+
// 4 parts is the typed form; 3 is the untyped one issued before session
|
|
1133
|
+
// cookies and bearers were distinguishable. Legacy tokens still verify, for
|
|
1134
|
+
// either purpose, so nobody is logged out by the upgrade — they age out on
|
|
1135
|
+
// their own within 90 days. Drop this branch after that.
|
|
1136
|
+
const legacy = parts.length === 3;
|
|
1137
|
+
if (parts.length !== 4 && !legacy) return null;
|
|
1138
|
+
const [typ, role, expStr, sig] = legacy ? [expectedTyp, ...parts] : parts;
|
|
1139
|
+
if (typ !== expectedTyp) return null;
|
|
901
1140
|
const exp = Number(expStr);
|
|
902
1141
|
if (!Number.isFinite(exp) || exp < Math.floor(Date.now() / 1000)) return null;
|
|
903
|
-
const
|
|
1142
|
+
const signed = legacy ? role + "." + expStr : typ + "." + role + "." + expStr;
|
|
1143
|
+
const expected = await hmac(signed, secret);
|
|
904
1144
|
return constantTimeEqual(sig, expected) ? role : null;
|
|
905
1145
|
}
|
|
906
1146
|
|
|
@@ -985,7 +1225,7 @@ function isSharedAsset(pathname) {
|
|
|
985
1225
|
// - styles.css / user.css — build.ts (writeFile join(outputDir, ...))
|
|
986
1226
|
// - _handlers.js / _handlers.css — build.ts (handler asset bundles, root)
|
|
987
1227
|
// - katex/… — build.ts (copyKatexAssets; math vaults only)
|
|
988
|
-
// - _foundry/importer.js
|
|
1228
|
+
// - _foundry/importer.js — build.ts (foundry-importer bundle)
|
|
989
1229
|
// - login.html — build.ts (multi-role only)
|
|
990
1230
|
// - favicon.ico — build.ts (buildFavicon)
|
|
991
1231
|
// - functions/_middleware.js — build.ts (multi-role only; not served)
|
|
@@ -1002,15 +1242,56 @@ function isSharedAsset(pathname) {
|
|
|
1002
1242
|
if (pathname === "/_handlers.css") return true;
|
|
1003
1243
|
if (pathname === "/katex/katex.min.css" || pathname.startsWith("/katex/fonts/")) return true;
|
|
1004
1244
|
if (pathname === "/_foundry/importer.js") return true;
|
|
1005
|
-
if (pathname === "/_foundry/version.json") return true;
|
|
1006
1245
|
if (pathname === "/login.html") return true;
|
|
1007
1246
|
if (pathname === "/favicon.ico" || pathname === "/favicon.svg") return true;
|
|
1008
1247
|
if (pathname === "/robots.txt") return true;
|
|
1248
|
+
if (pathname === "/sitemap.xml") return true;
|
|
1009
1249
|
return false;
|
|
1010
1250
|
}
|
|
1011
1251
|
`;
|
|
1012
1252
|
}
|
|
1013
|
-
|
|
1253
|
+
/**
|
|
1254
|
+
* Render `login.html` for a deploy, showing only the sign-in methods it
|
|
1255
|
+
* actually has.
|
|
1256
|
+
*
|
|
1257
|
+
* A role is reachable by password only if a hash was set for it; Patreon and
|
|
1258
|
+
* OIDC each carry their own role mappings. A deploy authenticating purely
|
|
1259
|
+
* through a provider therefore gets no password form and no role selector —
|
|
1260
|
+
* the selector only ever chose which password to check, so with one password
|
|
1261
|
+
* role (or none) it is noise, and with none it would be a form that cannot
|
|
1262
|
+
* succeed.
|
|
1263
|
+
*/
|
|
1264
|
+
export function renderLoginPage(opts) {
|
|
1265
|
+
const { passwordRoles, patreonRoles, oidcDisplayName } = opts;
|
|
1266
|
+
let form = "";
|
|
1267
|
+
if (passwordRoles.length > 0) {
|
|
1268
|
+
// One password role means there is nothing to choose, so the role rides
|
|
1269
|
+
// as a hidden field and the visitor just types a password.
|
|
1270
|
+
const rolePicker = passwordRoles.length === 1
|
|
1271
|
+
? ` <input type="hidden" name="role" value="${htmlAttr(passwordRoles[0])}">`
|
|
1272
|
+
: ` <label for="role">Role</label>\n`
|
|
1273
|
+
+ ` <select id="role" name="role">`
|
|
1274
|
+
+ passwordRoles.map((r) => `<option value="${htmlAttr(r)}">${htmlEscape(r)}</option>`).join("")
|
|
1275
|
+
+ `</select>`;
|
|
1276
|
+
form = ` <form id="login-form" method="POST" action="/login">\n`
|
|
1277
|
+
+ `${rolePicker}\n`
|
|
1278
|
+
+ ` <label for="password">Password</label>\n`
|
|
1279
|
+
+ ` <input id="password" type="password" name="password" autocomplete="current-password" required>\n`
|
|
1280
|
+
+ ` <input type="hidden" name="next" id="next">\n`
|
|
1281
|
+
+ ` <button type="submit">Sign in</button>\n`
|
|
1282
|
+
+ ` </form>`;
|
|
1283
|
+
}
|
|
1284
|
+
// Replacer *functions*, not strings: a string replacement interprets `$&`
|
|
1285
|
+
// and friends, and oidcDisplayName is free text, so a provider named
|
|
1286
|
+
// "Acme $& Co" would splice the placeholder back into the page.
|
|
1287
|
+
const literal = (value) => () => value;
|
|
1288
|
+
return LOGIN_HTML
|
|
1289
|
+
.replace("__PASSWORD_FORM__", literal(form))
|
|
1290
|
+
.replace("__PATREON_ROLES_ATTR__", literal(patreonRoles.length > 0
|
|
1291
|
+
? ` data-patreon-roles="${htmlAttr(patreonRoles.join(","))}"` : ""))
|
|
1292
|
+
.replace("__OIDC_ATTR__", literal(oidcDisplayName ? ` data-oidc="${htmlAttr(oidcDisplayName)}"` : ""));
|
|
1293
|
+
}
|
|
1294
|
+
const LOGIN_HTML = `<!doctype html>
|
|
1014
1295
|
<html lang="en">
|
|
1015
1296
|
<head>
|
|
1016
1297
|
<meta charset="utf-8">
|
|
@@ -1065,14 +1346,7 @@ export const LOGIN_HTML = `<!doctype html>
|
|
|
1065
1346
|
<div class="login-card"__PATREON_ROLES_ATTR____OIDC_ATTR__>
|
|
1066
1347
|
<h1>Sign in</h1>
|
|
1067
1348
|
<p id="err" class="login-error" hidden></p>
|
|
1068
|
-
|
|
1069
|
-
<label for="role">Role</label>
|
|
1070
|
-
<select id="role" name="role">__ROLE_OPTIONS__</select>
|
|
1071
|
-
<label for="password">Password</label>
|
|
1072
|
-
<input id="password" type="password" name="password" autocomplete="current-password" required>
|
|
1073
|
-
<input type="hidden" name="next" id="next">
|
|
1074
|
-
<button type="submit">Sign in</button>
|
|
1075
|
-
</form>
|
|
1349
|
+
__PASSWORD_FORM__
|
|
1076
1350
|
<div id="patreon-section" hidden>
|
|
1077
1351
|
<div class="login-divider">or</div>
|
|
1078
1352
|
<a id="patreon-btn" class="patreon-btn" href="#">
|
|
@@ -1095,7 +1369,10 @@ export const LOGIN_HTML = `<!doctype html>
|
|
|
1095
1369
|
<script>
|
|
1096
1370
|
const params = new URLSearchParams(location.search);
|
|
1097
1371
|
const next = params.get("next") || "/";
|
|
1098
|
-
|
|
1372
|
+
// The password form is omitted entirely on a deploy where no role has a
|
|
1373
|
+
// password, so nothing here may assume its fields exist.
|
|
1374
|
+
const nextField = document.getElementById("next");
|
|
1375
|
+
if (nextField) nextField.value = next;
|
|
1099
1376
|
const err = params.get("error");
|
|
1100
1377
|
if (err) {
|
|
1101
1378
|
const el = document.getElementById("err");
|
|
@@ -1135,6 +1412,17 @@ export const LOGIN_HTML = `<!doctype html>
|
|
|
1135
1412
|
oidcBtn.textContent = "Sign in with " + card.dataset.oidc;
|
|
1136
1413
|
oidcBtn.href = "/auth/oidc/start?next=" + encodeURIComponent(next);
|
|
1137
1414
|
}
|
|
1415
|
+
// Each provider section leads with an "or" divider, which only reads
|
|
1416
|
+
// correctly as an alternative to the password form. With no form, the
|
|
1417
|
+
// first provider is the only way in, so its divider comes off.
|
|
1418
|
+
if (!document.getElementById("login-form")) {
|
|
1419
|
+
const sections = [document.getElementById("patreon-section"), document.getElementById("oidc-section")];
|
|
1420
|
+
const first = sections.find((s) => s && !s.hidden);
|
|
1421
|
+
if (first) {
|
|
1422
|
+
const divider = first.querySelector(".login-divider");
|
|
1423
|
+
if (divider) divider.remove();
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1138
1426
|
// Autofocus moved here so it picks the right field whether the password
|
|
1139
1427
|
// form is visible or the user is going for the Patreon button.
|
|
1140
1428
|
const pwd = document.getElementById("password");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-template.js","sourceRoot":"","sources":["../../src/render/auth-template.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,+EAA+E;AAC/E,2EAA2E;AAC3E,mEAAmE;
|
|
1
|
+
{"version":3,"file":"auth-template.js","sourceRoot":"","sources":["../../src/render/auth-template.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,+EAA+E;AAC/E,2EAA2E;AAC3E,mEAAmE;AAEnE,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AA6CpD,MAAM,UAAU,oBAAoB,CAAC,GAAuB;IAC1D,MAAM,YAAY,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;IAC/C,MAAM,gBAAgB,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;IAC3D,MAAM,cAAc,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,OAAO,IAAI,IAAI,CAAC,CAAC;IAC3D,MAAM,WAAW,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC;IACrD,MAAM,cAAc,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IAErD,OAAO;;;;;;;gBAOO,YAAY;oBACR,gBAAgB;kBAClB,cAAc;eACjB,WAAW;;;kBAGR,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0sC/B,CAAC;AACF,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,eAAe,CAAC,IAO/B;IACC,MAAM,EAAE,aAAa,EAAE,YAAY,EAAE,eAAe,EAAE,GAAG,IAAI,CAAC;IAE9D,IAAI,IAAI,GAAG,EAAE,CAAC;IACd,IAAI,aAAa,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC7B,wEAAwE;QACxE,2DAA2D;QAC3D,MAAM,UAAU,GAAG,aAAa,CAAC,MAAM,KAAK,CAAC;YAC3C,CAAC,CAAC,+CAA+C,QAAQ,CAAC,aAAa,CAAC,CAAC,CAAE,CAAC,IAAI;YAChF,CAAC,CAAC,sCAAsC;kBACpC,oCAAoC;kBACpC,aAAa,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,kBAAkB,QAAQ,CAAC,CAAC,CAAC,KAAK,UAAU,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC;kBAC7F,WAAW,CAAC;QAClB,IAAI,GAAG,0DAA0D;cAC7D,GAAG,UAAU,IAAI;cACjB,8CAA8C;cAC9C,sGAAsG;cACtG,mDAAmD;cACnD,8CAA8C;cAC9C,WAAW,CAAC;IAClB,CAAC;IAED,0EAA0E;IAC1E,qEAAqE;IACrE,gEAAgE;IAChE,MAAM,OAAO,GAAG,CAAC,KAAa,EAAE,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC;IAC/C,OAAO,UAAU;SACd,OAAO,CAAC,mBAAmB,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;SAC3C,OAAO,CAAC,wBAAwB,EAAE,OAAO,CAAC,YAAY,CAAC,MAAM,GAAG,CAAC;QAChE,CAAC,CAAC,wBAAwB,QAAQ,CAAC,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;SACrE,OAAO,CAAC,eAAe,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,eAAe,QAAQ,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAC3G,CAAC;AAED,MAAM,UAAU,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QA0IX,CAAC"}
|