@getquick/content 0.4.1 → 0.4.2

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 CHANGED
@@ -288,14 +288,16 @@ updates. Full P6 release evidence remains issue #11.
288
288
  CONSENT-1/LEGAL-1 ship as isolated entries: `@getquick/content/consent`
289
289
  (neutral core), `/consent/client` (browser runtime), `/consent/server` (the
290
290
  `/gq/consent` record and read endpoints and purge), `/consent/migrations`
291
- (the separate consent database), `/consent/astro` (default UI and legal
292
- footer as HTML) and `/consent/testing` (acceptance suite). They import no
291
+ (the separate consent database), `/consent/html` (default UI and legal
292
+ footer as HTML), `/consent/astro` (compatible re-export) and `/consent/testing` (acceptance suite). They import no
293
293
  content runtime, framework or WordPress client. Passing
294
294
  `consent: { services: ["youtube"], cmsSettings: true }` to
295
295
  `createContentRuntime` rewrites third-party embeds into consent placeholders
296
296
  at delivery and stores gq-config's Legal Contract and GA4 settings in each
297
297
  language's chrome; `runtime.consentConfiguration(chrome)` returns the page's
298
- consent configuration. See the repository's `docs/consent.md`.
298
+ consent configuration. See the repository's `docs/consent.md` export index and the
299
+ platform-owned [consent HTML reference](https://github.com/Quick-Release/gq-platform/blob/main/docs/runtime/gq-content/consent-html.md)
300
+ ([platform PR #273](https://github.com/Quick-Release/gq-platform/pull/273)).
299
301
 
300
302
  ## Design presentation and composition
301
303
 
@@ -0,0 +1,203 @@
1
+ //#region frontend/src/request-guards.ts
2
+ /**
3
+ * Whether a browser posted from the Site itself. A cross-site post carries
4
+ * its Origin (and Sec-Fetch-Site); `origins` are the Site's public origins
5
+ * when the Worker sees another (a proxy). The request's own origin is always
6
+ * accepted.
7
+ */
8
+ function isSameOrigin(request, origins) {
9
+ const origin = request.headers.get("Origin");
10
+ const site = request.headers.get("Sec-Fetch-Site");
11
+ if (site && site !== "same-origin") return false;
12
+ if (!origin) return false;
13
+ if (origin === new URL(request.url).origin || origins.has(origin)) return true;
14
+ try {
15
+ return new URL(origin).host === request.headers.get("Host");
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+ /** The body as text, or null once it exceeds `limit` bytes (declared or read). */
21
+ async function readBoundedBody(request, limit) {
22
+ if (Number(request.headers.get("Content-Length") ?? "0") > limit) return null;
23
+ if (!request.body) return "";
24
+ const reader = request.body.getReader();
25
+ const chunks = [];
26
+ let size = 0;
27
+ for (;;) {
28
+ const { done, value } = await reader.read();
29
+ if (done) break;
30
+ size += value.byteLength;
31
+ if (size > limit) {
32
+ await reader.cancel();
33
+ return null;
34
+ }
35
+ chunks.push(value);
36
+ }
37
+ const bytes = new Uint8Array(size);
38
+ let offset = 0;
39
+ for (const chunk of chunks) {
40
+ bytes.set(chunk, offset);
41
+ offset += chunk.byteLength;
42
+ }
43
+ return new TextDecoder().decode(bytes);
44
+ }
45
+ //#endregion
46
+ //#region frontend/src/client-ip.ts
47
+ function parseIpv4(text) {
48
+ const parts = text.split(".");
49
+ if (parts.length !== 4) return null;
50
+ const bytes = /* @__PURE__ */ new Uint8Array(4);
51
+ for (const [index, part] of parts.entries()) {
52
+ if (!/^(0|[1-9]\d{0,2})$/.test(part) || Number(part) > 255) return null;
53
+ bytes[index] = Number(part);
54
+ }
55
+ return bytes;
56
+ }
57
+ function parseIpv6(text) {
58
+ let head = text;
59
+ let tail = [];
60
+ const lastColon = text.lastIndexOf(":");
61
+ if (text.includes(".", lastColon)) {
62
+ const v4 = parseIpv4(text.slice(lastColon + 1));
63
+ if (!v4) return null;
64
+ head = text.slice(0, lastColon + 1);
65
+ if (head.endsWith(":") && !head.endsWith("::")) head = head.slice(0, -1);
66
+ tail = [v4[0] << 8 | v4[1], v4[2] << 8 | v4[3]];
67
+ }
68
+ const halves = head.split("::");
69
+ if (halves.length > 2) return null;
70
+ const groups = (part) => part === "" ? [] : part.split(":");
71
+ const left = groups(halves[0]);
72
+ const right = halves.length === 2 ? groups(halves[1]) : [];
73
+ const words = [];
74
+ for (const group of [...left, ...right]) {
75
+ if (!/^[0-9a-f]{1,4}$/i.test(group)) return null;
76
+ words.push(parseInt(group, 16));
77
+ }
78
+ const given = words.length + tail.length;
79
+ if (halves.length === 2 ? given > 7 : given !== 8) return null;
80
+ const all = [
81
+ ...words.slice(0, left.length),
82
+ ...Array(8 - given).fill(0),
83
+ ...words.slice(left.length),
84
+ ...tail
85
+ ];
86
+ const bytes = /* @__PURE__ */ new Uint8Array(16);
87
+ all.forEach((word, index) => {
88
+ bytes[index * 2] = word >> 8;
89
+ bytes[index * 2 + 1] = word & 255;
90
+ });
91
+ return bytes;
92
+ }
93
+ /** An IPv4 or IPv6 address; an IPv4-mapped IPv6 address reads as its IPv4. */
94
+ function parseAddress(value) {
95
+ const text = value.trim();
96
+ if (!text || text.length > 64) return null;
97
+ if (!text.includes(":")) {
98
+ const bytes = parseIpv4(text);
99
+ return bytes ? {
100
+ version: 4,
101
+ bytes
102
+ } : null;
103
+ }
104
+ const bytes = parseIpv6(text.replace(/%[0-9a-z._~-]+$/i, ""));
105
+ if (!bytes) return null;
106
+ return bytes.slice(0, 10).every((byte) => byte === 0) && bytes[10] === 255 && bytes[11] === 255 ? {
107
+ version: 4,
108
+ bytes: bytes.slice(12)
109
+ } : {
110
+ version: 6,
111
+ bytes
112
+ };
113
+ }
114
+ /** An X-Forwarded-For entry: an address, optionally `[v6]:port` or `v4:port`. */
115
+ function parseForwarded(value) {
116
+ const text = value.trim();
117
+ const bracketed = /^\[([^\]]+)\](?::\d{1,5})?$/.exec(text);
118
+ if (bracketed) return parseAddress(bracketed[1]);
119
+ const withPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d{1,5}$/.exec(text);
120
+ return parseAddress(withPort ? withPort[1] : text);
121
+ }
122
+ function format(address) {
123
+ if (address.version === 4) return address.bytes.join(".");
124
+ const words = Array.from({ length: 8 }, (_, index) => (address.bytes[index * 2] << 8 | address.bytes[index * 2 + 1]).toString(16));
125
+ let start = -1;
126
+ let length = 0;
127
+ for (let index = 0; index < 8;) {
128
+ if (words[index] !== "0") {
129
+ index += 1;
130
+ continue;
131
+ }
132
+ let end = index;
133
+ while (end < 8 && words[end] === "0") end += 1;
134
+ if (end - index > length && end - index > 1) [start, length] = [index, end - index];
135
+ index = end;
136
+ }
137
+ if (start < 0) return words.join(":");
138
+ return `${words.slice(0, start).join(":")}::${words.slice(start + length).join(":")}`;
139
+ }
140
+ function parseRange(value) {
141
+ const [text, prefixText, extra] = value.trim().split("/");
142
+ const address = extra === void 0 ? parseAddress(text ?? "") : null;
143
+ if (!address) throw new Error(`trustedProxies: "${value}" isn't an IP address or CIDR`);
144
+ const bits = address.bytes.length * 8;
145
+ const prefix = prefixText === void 0 ? bits : Number(prefixText);
146
+ if (prefixText !== void 0 && !/^\d{1,3}$/.test(prefixText) || prefix > bits) throw new Error(`trustedProxies: "${value}" has an invalid prefix length`);
147
+ if (prefix === 0) throw new Error(`trustedProxies: "${value}" would trust every address`);
148
+ return {
149
+ ...address,
150
+ prefix
151
+ };
152
+ }
153
+ function contains(range, address) {
154
+ if (range.version !== address.version) return false;
155
+ const whole = range.prefix >> 3;
156
+ for (let index = 0; index < whole; index += 1) if (range.bytes[index] !== address.bytes[index]) return false;
157
+ const rest = range.prefix & 7;
158
+ if (!rest) return true;
159
+ const mask = 255 << 8 - rest & 255;
160
+ return (range.bytes[whole] & mask) === (address.bytes[whole] & mask);
161
+ }
162
+ /**
163
+ * The client address of a request, from `CF-Connecting-IP` and, only when
164
+ * that is one of `trustedProxies` (addresses or CIDRs, IPv4 or IPv6), the
165
+ * proxy's forwarding headers: the right-most `X-Forwarded-For` entry that
166
+ * isn't itself a trusted proxy, else `X-Real-IP`. Null when there is no
167
+ * usable `CF-Connecting-IP`. Throws on an invalid `trustedProxies` entry.
168
+ */
169
+ function clientAddressResolver(trustedProxies = []) {
170
+ const ranges = trustedProxies.map(parseRange);
171
+ const trusted = (address) => ranges.some((range) => contains(range, address));
172
+ return (request) => {
173
+ const peer = parseAddress(request.headers.get("CF-Connecting-IP") ?? "");
174
+ if (!peer) return null;
175
+ if (!trusted(peer)) return format(peer);
176
+ const forwarded = (request.headers.get("X-Forwarded-For") ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
177
+ for (let index = forwarded.length - 1; index >= 0; index -= 1) {
178
+ const address = parseForwarded(forwarded[index]);
179
+ if (!address) return format(peer);
180
+ if (!trusted(address)) return format(address);
181
+ }
182
+ const real = parseAddress(request.headers.get("X-Real-IP") ?? "");
183
+ return real && !trusted(real) ? format(real) : format(peer);
184
+ };
185
+ }
186
+ /**
187
+ * A rate-limit key for a client address: an IPv4 address as itself, an IPv6
188
+ * address as its /64 (`2001:db8:1:2::/64`), since one subscriber holds a
189
+ * whole /64. `unknown` without an address.
190
+ */
191
+ function rateLimitKey(address) {
192
+ const parsed = address === null ? null : parseAddress(address);
193
+ if (!parsed) return "unknown";
194
+ if (parsed.version === 4) return format(parsed);
195
+ const network = /* @__PURE__ */ new Uint8Array(16);
196
+ network.set(parsed.bytes.slice(0, 8));
197
+ return `${format({
198
+ version: 6,
199
+ bytes: network
200
+ })}/64`;
201
+ }
202
+ //#endregion
203
+ export { readBoundedBody as i, rateLimitKey as n, isSameOrigin as r, clientAddressResolver as t };
@@ -1,36 +1,2 @@
1
- import { r as ConsentConfiguration } from "./configuration-C_JGygQI.js";
2
- import { r as LegalLanguage } from "./legal-C5Xiq59W.js";
3
- //#region frontend/src/consent/astro.d.ts
4
- export interface ConsentPageInput {
5
- configuration: ConsentConfiguration;
6
- /** The page's `lang` (BCP 47): the UI follows it (CN-C3). */
7
- language: string;
8
- /** The page language's Legal Contract, from the chrome (LEGAL-1); null when not delivered yet. */
9
- legal?: LegalLanguage | null;
10
- /** The request's Cookie header: the banner is rendered open only without a decision in force. */
11
- cookie?: string | null;
12
- now?: number;
13
- /** Include the default stylesheet (Sites restyle through its custom properties). */
14
- styles?: boolean;
15
- }
16
- export interface ConsentPage {
17
- /** For `<head>`: the configuration the client reads, and the default styles. */
18
- head: string;
19
- /** For the start of `<body>`: the banner (first in focus order) and the preferences dialog. */
20
- body: string;
21
- /** For the footer: the legal links and the persistent "Cookie settings" control. */
22
- footer: string;
23
- /** Whether the banner is rendered open. */
24
- bannerOpen: boolean;
25
- }
26
- /** The legal links and "Cookie settings" control for a footer (LG-R5, CN-R6). */
27
- export declare function legalFooter(legal: LegalLanguage | null | undefined, language: string): string;
28
- /** Everything the default UI renders on one page. */
29
- export declare function consentPage(input: ConsentPageInput): ConsentPage;
30
- /**
31
- * The default stylesheet. Sites restyle it through these custom properties or
32
- * their own CSS (CN-C4); the two first-layer choices share one class so they
33
- * keep equal prominence.
34
- */
35
- export declare const CONSENT_STYLES: string;
36
- //#endregion
1
+ import { CONSENT_STYLES, ConsentPage, ConsentPageInput, consentPage, legalFooter } from "./consent-html.js";
2
+ export { CONSENT_STYLES, type ConsentPage, type ConsentPageInput, consentPage, legalFooter };
@@ -1,94 +1,2 @@
1
- import { S as SERVICE_CATALOG, T as copyLanguage, c as grantedCategories, d as promptReason, f as readConsentCookie, x as OPTIONAL_CATEGORIES } from "./state-BYWc5TUB.js";
2
- import { a as CONSENT_COPY, c as legalRevisions, r as escapeHtml } from "./embeds-CJJemAmE.js";
3
- import { t as CONSENT_UI_VERSION } from "./version-Co2JJcK_.js";
4
- import { t as CONSENT_CONFIG_ID } from "./client-config-B3LNSOxl.js";
5
- import { t as CONSENT_PATH } from "./paths-Cmzzof3v.js";
6
- //#region frontend/src/consent/astro.ts
7
- function storageText(service, language) {
8
- const copy = CONSENT_COPY[language];
9
- return service.storage.map((item) => `${item.name}${item.match === "prefix" ? "*" : ""} (${item.kind}, ${item.duration[language]}${item.party === "third" ? `; ${copy.thirdPartyStorage}` : ""})`).join("; ");
10
- }
11
- function serviceList(services, language) {
12
- if (!services.length) return "";
13
- const copy = CONSENT_COPY[language];
14
- return `<ul class="gq-consent-services">${services.map((service) => {
15
- const storage = storageText(service, language);
16
- return `<li class="gq-consent-service"><strong>${escapeHtml(service.name)}</strong><dl><dt>${escapeHtml(copy.provider)}</dt><dd>${escapeHtml(service.provider)}</dd><dt>${escapeHtml(copy.purpose)}</dt><dd>${escapeHtml(service.purpose[language])}</dd><dt>${escapeHtml(copy.storage)}</dt><dd>${escapeHtml(storage || "—")}</dd><dt>${escapeHtml(copy.retention)}</dt><dd>${escapeHtml(service.retention[language])}</dd></dl></li>`;
17
- }).join("")}</ul>`;
18
- }
19
- function link(href, text, attributes = "") {
20
- return `<a href="${escapeHtml(href)}"${attributes}>${escapeHtml(text)}</a>`;
21
- }
22
- /** The legal links and "Cookie settings" control for a footer (LG-R5, CN-R6). */
23
- function legalFooter(legal, language) {
24
- const lang = copyLanguage(language);
25
- const copy = CONSENT_COPY[lang];
26
- const items = (legal?.roles ?? []).map((role) => `<li>${link(role.url, role.title ?? copy.legal[role.role] ?? role.role, ` data-gq-legal-role="${escapeHtml(role.role)}"`)}</li>`);
27
- items.push(`<li><button type="button" class="gq-consent-settings" data-gq-consent-open aria-haspopup="dialog" aria-controls="gq-consent-preferences">${escapeHtml(copy.cookieSettings)}</button></li>`);
28
- return `<nav class="gq-legal" aria-label="${escapeHtml(copy.legalNavigation)}"><ul>${items.join("")}</ul></nav>`;
29
- }
30
- /** Everything the default UI renders on one page. */
31
- function consentPage(input) {
32
- const { configuration } = input;
33
- const lang = copyLanguage(input.language);
34
- const copy = CONSENT_COPY[lang];
35
- const state = readConsentCookie(input.cookie);
36
- const now = input.now ?? Date.now();
37
- const bannerOpen = promptReason(configuration, state, now) !== null;
38
- const inForce = grantedCategories(configuration, state, now);
39
- const cookiePolicy = input.legal?.roles.find((role) => role.role === "cookie_policy");
40
- const config = {
41
- configuration,
42
- language: input.language,
43
- uiVersion: CONSENT_UI_VERSION,
44
- legalRevisions: legalRevisions(input.legal),
45
- endpoint: CONSENT_PATH
46
- };
47
- const head = `<script type="application/json" id="${CONSENT_CONFIG_ID}">${JSON.stringify(config).replaceAll("<", "\\u003c")}<\/script>` + (input.styles === false ? "" : `<style data-gq-consent-styles>${CONSENT_STYLES}</style>`);
48
- const services = configuration.services.map((id) => SERVICE_CATALOG[id]);
49
- const inCategory = (category) => services.filter((service) => service.category === category);
50
- const optional = configuration.categories.length > 0;
51
- const banner = !optional ? "" : `<section id="gq-consent-banner" class="gq-consent-banner" role="dialog" aria-modal="false" aria-labelledby="gq-consent-banner-title" aria-describedby="gq-consent-banner-text" lang="${escapeHtml(input.language)}"${bannerOpen ? "" : " hidden"}><h2 id="gq-consent-banner-title" class="gq-consent-banner__title">${escapeHtml(copy.bannerTitle)}</h2><p id="gq-consent-banner-text" class="gq-consent-banner__text">${escapeHtml(copy.bannerText)}` + (cookiePolicy ? ` ${link(cookiePolicy.url, copy.cookiePolicy, " class=\"gq-consent-banner__policy\"")}` : "") + `</p><div class="gq-consent-banner__actions"><button type="button" class="gq-consent-button gq-consent-button--choice" data-gq-consent-reject-all>${escapeHtml(copy.rejectAll)}</button><button type="button" class="gq-consent-button gq-consent-button--choice" data-gq-consent-accept-all>${escapeHtml(copy.acceptAll)}</button><button type="button" class="gq-consent-button gq-consent-button--secondary" data-gq-consent-customise aria-haspopup="dialog" aria-controls="gq-consent-preferences">${escapeHtml(copy.customise)}</button></div></section>`;
52
- const necessary = copy.categories.necessary;
53
- const categories = OPTIONAL_CATEGORIES.filter((category) => configuration.categories.includes(category)).map((category) => {
54
- const text = copy.categories[category];
55
- const id = `gq-consent-category-${category}`;
56
- return `<fieldset class="gq-consent-category"><legend>${escapeHtml(text.name)}</legend><label for="${id}"><input type="checkbox" id="${id}" name="${category}" value="${category}" data-gq-consent-category${inForce.includes(category) ? " checked" : ""}> ${escapeHtml(text.name)}</label><p>${escapeHtml(text.description)}</p>${serviceList(inCategory(category), lang)}</fieldset>`;
57
- });
58
- return {
59
- head,
60
- body: banner + (`<dialog id="gq-consent-preferences" class="gq-consent-preferences" aria-labelledby="gq-consent-preferences-title" lang="${escapeHtml(input.language)}"><form method="dialog"><h2 id="gq-consent-preferences-title">${escapeHtml(copy.preferencesTitle)}</h2><p>${escapeHtml(copy.preferencesText)}</p>` + (cookiePolicy ? `<p>${link(cookiePolicy.url, copy.cookiePolicy)}</p>` : "") + `<fieldset class="gq-consent-category"><legend>${escapeHtml(necessary.name)}</legend><p>${escapeHtml(copy.alwaysOn)}. ${escapeHtml(necessary.description)}</p>${serviceList(inCategory("necessary"), lang)}</fieldset>` + categories.join("") + (optional ? `<p class="gq-consent-id">${escapeHtml(copy.consentId)}: <code data-gq-consent-id data-gq-consent-none="${escapeHtml(copy.noDecision)}">${escapeHtml(state?.consentId ?? copy.noDecision)}</code></p>` : "") + `<div class="gq-consent-preferences__actions">` + (optional ? `<button type="button" class="gq-consent-button gq-consent-button--choice" data-gq-consent-reject-all>${escapeHtml(copy.rejectAll)}</button><button type="button" class="gq-consent-button gq-consent-button--choice" data-gq-consent-accept-all>${escapeHtml(copy.acceptAll)}</button><button type="button" class="gq-consent-button gq-consent-button--secondary" data-gq-consent-save>${escapeHtml(copy.save)}</button>` : "") + `<button type="button" class="gq-consent-button gq-consent-button--secondary" data-gq-consent-close>${escapeHtml(copy.close)}</button></div></form></dialog>`),
61
- footer: legalFooter(input.legal, input.language),
62
- bannerOpen
63
- };
64
- }
65
- /**
66
- * The default stylesheet. Sites restyle it through these custom properties or
67
- * their own CSS (CN-C4); the two first-layer choices share one class so they
68
- * keep equal prominence.
69
- */
70
- const CONSENT_STYLES = `
71
- .gq-consent-banner{position:fixed;inset:auto 1rem 1rem 1rem;z-index:2147483000;max-width:44rem;margin:0 auto;padding:1.25rem;background:var(--gq-consent-background,#fff);color:var(--gq-consent-color,#111);border:1px solid var(--gq-consent-border,#888);border-radius:var(--gq-consent-radius,.5rem);box-shadow:0 .5rem 2rem rgba(0,0,0,.2);font:inherit}
72
- .gq-consent-banner[hidden]{display:none}
73
- .gq-consent-banner__title,.gq-consent-preferences h2{margin:0 0 .5rem;font-size:1.125rem}
74
- .gq-consent-banner__actions,.gq-consent-preferences__actions,.gq-consent-embed__actions{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:1rem}
75
- .gq-consent-button{font:inherit;cursor:pointer;padding:.6rem 1rem;border-radius:var(--gq-consent-radius,.5rem);border:2px solid var(--gq-consent-accent,#111)}
76
- .gq-consent-button--choice{flex:1 1 10rem;background:var(--gq-consent-accent,#111);color:var(--gq-consent-accent-contrast,#fff)}
77
- .gq-consent-button--secondary{background:transparent;color:inherit}
78
- .gq-consent-button:focus-visible,.gq-consent-settings:focus-visible{outline:3px solid var(--gq-consent-focus,#1a73e8);outline-offset:2px}
79
- .gq-consent-preferences{max-width:40rem;width:calc(100% - 2rem);max-height:calc(100vh - 2rem);overflow:auto;padding:1.25rem;border:1px solid var(--gq-consent-border,#888);border-radius:var(--gq-consent-radius,.5rem);background:var(--gq-consent-background,#fff);color:var(--gq-consent-color,#111)}
80
- .gq-consent-category{border:1px solid var(--gq-consent-border,#ccc);border-radius:.375rem;margin:.75rem 0;padding:.75rem}
81
- .gq-consent-services{margin:.5rem 0 0;padding-left:1rem;font-size:.875rem}
82
- .gq-consent-services dl{display:grid;grid-template-columns:max-content 1fr;gap:.125rem .5rem;margin:.25rem 0}
83
- .gq-consent-services dd{margin:0}
84
- .gq-consent-embed{position:relative;display:flex;align-items:center;justify-content:center;width:100%;min-height:12rem;background:var(--gq-consent-embed-background,#eee);color:var(--gq-consent-color,#111)}
85
- .gq-consent-embed[data-gq-loaded]{display:block;min-height:0;background:none}
86
- .gq-consent-embed[data-gq-loaded]>iframe{width:100%;height:100%;border:0}
87
- .gq-consent-embed--decorative{position:absolute;inset:0;min-height:0;background:none}
88
- .gq-consent-embed__notice{max-width:32rem;padding:1rem;text-align:center}
89
- .gq-consent-blocked{padding:1rem;background:var(--gq-consent-embed-background,#eee)}
90
- .gq-legal ul{display:flex;flex-wrap:wrap;gap:.5rem 1rem;list-style:none;margin:0;padding:0}
91
- .gq-consent-settings{font:inherit;color:inherit;background:none;border:0;padding:0;text-decoration:underline;cursor:pointer}
92
- `.trim();
93
- //#endregion
1
+ import { CONSENT_STYLES, consentPage, legalFooter } from "./consent-html.js";
94
2
  export { CONSENT_STYLES, consentPage, legalFooter };
@@ -0,0 +1,36 @@
1
+ import { r as ConsentConfiguration } from "./configuration-C_JGygQI.js";
2
+ import { r as LegalLanguage } from "./legal-C5Xiq59W.js";
3
+ //#region frontend/src/consent/html.d.ts
4
+ export interface ConsentPageInput {
5
+ configuration: ConsentConfiguration;
6
+ /** The page's `lang` (BCP 47): the UI follows it (CN-C3). */
7
+ language: string;
8
+ /** The page language's Legal Contract, from the chrome (LEGAL-1); null when not delivered yet. */
9
+ legal?: LegalLanguage | null;
10
+ /** The request's Cookie header: the banner is rendered open only without a decision in force. */
11
+ cookie?: string | null;
12
+ now?: number;
13
+ /** Include the default stylesheet (Sites restyle through its custom properties). */
14
+ styles?: boolean;
15
+ }
16
+ export interface ConsentPage {
17
+ /** For `<head>`: the configuration the client reads, and the default styles. */
18
+ head: string;
19
+ /** For the start of `<body>`: the banner (first in focus order) and the preferences dialog. */
20
+ body: string;
21
+ /** For the footer: the legal links and the persistent "Cookie settings" control. */
22
+ footer: string;
23
+ /** Whether the banner is rendered open. */
24
+ bannerOpen: boolean;
25
+ }
26
+ /** The legal links and "Cookie settings" control for a footer (LG-R5, CN-R6). */
27
+ export declare function legalFooter(legal: LegalLanguage | null | undefined, language: string): string;
28
+ /** Everything the default UI renders on one page. */
29
+ export declare function consentPage(input: ConsentPageInput): ConsentPage;
30
+ /**
31
+ * The default stylesheet. Sites restyle it through these custom properties or
32
+ * their own CSS (CN-C4); the two first-layer choices share one class so they
33
+ * keep equal prominence.
34
+ */
35
+ export declare const CONSENT_STYLES: string;
36
+ //#endregion
@@ -0,0 +1,94 @@
1
+ import { S as SERVICE_CATALOG, T as copyLanguage, c as grantedCategories, d as promptReason, f as readConsentCookie, x as OPTIONAL_CATEGORIES } from "./state-BYWc5TUB.js";
2
+ import { a as CONSENT_COPY, c as legalRevisions, r as escapeHtml } from "./embeds-CJJemAmE.js";
3
+ import { t as CONSENT_UI_VERSION } from "./version-CGZoRnsU.js";
4
+ import { t as CONSENT_CONFIG_ID } from "./client-config-B3LNSOxl.js";
5
+ import { t as CONSENT_PATH } from "./paths-Cmzzof3v.js";
6
+ //#region frontend/src/consent/html.ts
7
+ function storageText(service, language) {
8
+ const copy = CONSENT_COPY[language];
9
+ return service.storage.map((item) => `${item.name}${item.match === "prefix" ? "*" : ""} (${item.kind}, ${item.duration[language]}${item.party === "third" ? `; ${copy.thirdPartyStorage}` : ""})`).join("; ");
10
+ }
11
+ function serviceList(services, language) {
12
+ if (!services.length) return "";
13
+ const copy = CONSENT_COPY[language];
14
+ return `<ul class="gq-consent-services">${services.map((service) => {
15
+ const storage = storageText(service, language);
16
+ return `<li class="gq-consent-service"><strong>${escapeHtml(service.name)}</strong><dl><dt>${escapeHtml(copy.provider)}</dt><dd>${escapeHtml(service.provider)}</dd><dt>${escapeHtml(copy.purpose)}</dt><dd>${escapeHtml(service.purpose[language])}</dd><dt>${escapeHtml(copy.storage)}</dt><dd>${escapeHtml(storage || "—")}</dd><dt>${escapeHtml(copy.retention)}</dt><dd>${escapeHtml(service.retention[language])}</dd></dl></li>`;
17
+ }).join("")}</ul>`;
18
+ }
19
+ function link(href, text, attributes = "") {
20
+ return `<a href="${escapeHtml(href)}"${attributes}>${escapeHtml(text)}</a>`;
21
+ }
22
+ /** The legal links and "Cookie settings" control for a footer (LG-R5, CN-R6). */
23
+ function legalFooter(legal, language) {
24
+ const lang = copyLanguage(language);
25
+ const copy = CONSENT_COPY[lang];
26
+ const items = (legal?.roles ?? []).map((role) => `<li>${link(role.url, role.title ?? copy.legal[role.role] ?? role.role, ` data-gq-legal-role="${escapeHtml(role.role)}"`)}</li>`);
27
+ items.push(`<li><button type="button" class="gq-consent-settings" data-gq-consent-open aria-haspopup="dialog" aria-controls="gq-consent-preferences">${escapeHtml(copy.cookieSettings)}</button></li>`);
28
+ return `<nav class="gq-legal" aria-label="${escapeHtml(copy.legalNavigation)}"><ul>${items.join("")}</ul></nav>`;
29
+ }
30
+ /** Everything the default UI renders on one page. */
31
+ function consentPage(input) {
32
+ const { configuration } = input;
33
+ const lang = copyLanguage(input.language);
34
+ const copy = CONSENT_COPY[lang];
35
+ const state = readConsentCookie(input.cookie);
36
+ const now = input.now ?? Date.now();
37
+ const bannerOpen = promptReason(configuration, state, now) !== null;
38
+ const inForce = grantedCategories(configuration, state, now);
39
+ const cookiePolicy = input.legal?.roles.find((role) => role.role === "cookie_policy");
40
+ const config = {
41
+ configuration,
42
+ language: input.language,
43
+ uiVersion: CONSENT_UI_VERSION,
44
+ legalRevisions: legalRevisions(input.legal),
45
+ endpoint: CONSENT_PATH
46
+ };
47
+ const head = `<script type="application/json" id="${CONSENT_CONFIG_ID}">${JSON.stringify(config).replaceAll("<", "\\u003c")}<\/script>` + (input.styles === false ? "" : `<style data-gq-consent-styles>${CONSENT_STYLES}</style>`);
48
+ const services = configuration.services.map((id) => SERVICE_CATALOG[id]);
49
+ const inCategory = (category) => services.filter((service) => service.category === category);
50
+ const optional = configuration.categories.length > 0;
51
+ const banner = !optional ? "" : `<section id="gq-consent-banner" class="gq-consent-banner" role="dialog" aria-modal="false" aria-labelledby="gq-consent-banner-title" aria-describedby="gq-consent-banner-text" lang="${escapeHtml(input.language)}"${bannerOpen ? "" : " hidden"}><h2 id="gq-consent-banner-title" class="gq-consent-banner__title">${escapeHtml(copy.bannerTitle)}</h2><p id="gq-consent-banner-text" class="gq-consent-banner__text">${escapeHtml(copy.bannerText)}` + (cookiePolicy ? ` ${link(cookiePolicy.url, copy.cookiePolicy, " class=\"gq-consent-banner__policy\"")}` : "") + `</p><div class="gq-consent-banner__actions"><button type="button" class="gq-consent-button gq-consent-button--choice" data-gq-consent-reject-all>${escapeHtml(copy.rejectAll)}</button><button type="button" class="gq-consent-button gq-consent-button--choice" data-gq-consent-accept-all>${escapeHtml(copy.acceptAll)}</button><button type="button" class="gq-consent-button gq-consent-button--secondary" data-gq-consent-customise aria-haspopup="dialog" aria-controls="gq-consent-preferences">${escapeHtml(copy.customise)}</button></div></section>`;
52
+ const necessary = copy.categories.necessary;
53
+ const categories = OPTIONAL_CATEGORIES.filter((category) => configuration.categories.includes(category)).map((category) => {
54
+ const text = copy.categories[category];
55
+ const id = `gq-consent-category-${category}`;
56
+ return `<fieldset class="gq-consent-category"><legend>${escapeHtml(text.name)}</legend><label for="${id}"><input type="checkbox" id="${id}" name="${category}" value="${category}" data-gq-consent-category${inForce.includes(category) ? " checked" : ""}> ${escapeHtml(text.name)}</label><p>${escapeHtml(text.description)}</p>${serviceList(inCategory(category), lang)}</fieldset>`;
57
+ });
58
+ return {
59
+ head,
60
+ body: banner + (`<dialog id="gq-consent-preferences" class="gq-consent-preferences" aria-labelledby="gq-consent-preferences-title" lang="${escapeHtml(input.language)}"><form method="dialog"><h2 id="gq-consent-preferences-title">${escapeHtml(copy.preferencesTitle)}</h2><p>${escapeHtml(copy.preferencesText)}</p>` + (cookiePolicy ? `<p>${link(cookiePolicy.url, copy.cookiePolicy)}</p>` : "") + `<fieldset class="gq-consent-category"><legend>${escapeHtml(necessary.name)}</legend><p>${escapeHtml(copy.alwaysOn)}. ${escapeHtml(necessary.description)}</p>${serviceList(inCategory("necessary"), lang)}</fieldset>` + categories.join("") + (optional ? `<p class="gq-consent-id">${escapeHtml(copy.consentId)}: <code data-gq-consent-id data-gq-consent-none="${escapeHtml(copy.noDecision)}">${escapeHtml(state?.consentId ?? copy.noDecision)}</code></p>` : "") + `<div class="gq-consent-preferences__actions">` + (optional ? `<button type="button" class="gq-consent-button gq-consent-button--choice" data-gq-consent-reject-all>${escapeHtml(copy.rejectAll)}</button><button type="button" class="gq-consent-button gq-consent-button--choice" data-gq-consent-accept-all>${escapeHtml(copy.acceptAll)}</button><button type="button" class="gq-consent-button gq-consent-button--secondary" data-gq-consent-save>${escapeHtml(copy.save)}</button>` : "") + `<button type="button" class="gq-consent-button gq-consent-button--secondary" data-gq-consent-close>${escapeHtml(copy.close)}</button></div></form></dialog>`),
61
+ footer: legalFooter(input.legal, input.language),
62
+ bannerOpen
63
+ };
64
+ }
65
+ /**
66
+ * The default stylesheet. Sites restyle it through these custom properties or
67
+ * their own CSS (CN-C4); the two first-layer choices share one class so they
68
+ * keep equal prominence.
69
+ */
70
+ const CONSENT_STYLES = `
71
+ .gq-consent-banner{position:fixed;inset:auto 1rem 1rem 1rem;z-index:2147483000;max-width:44rem;margin:0 auto;padding:1.25rem;background:var(--gq-consent-background,#fff);color:var(--gq-consent-color,#111);border:1px solid var(--gq-consent-border,#888);border-radius:var(--gq-consent-radius,.5rem);box-shadow:0 .5rem 2rem rgba(0,0,0,.2);font:inherit}
72
+ .gq-consent-banner[hidden]{display:none}
73
+ .gq-consent-banner__title,.gq-consent-preferences h2{margin:0 0 .5rem;font-size:1.125rem}
74
+ .gq-consent-banner__actions,.gq-consent-preferences__actions,.gq-consent-embed__actions{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:1rem}
75
+ .gq-consent-button{font:inherit;cursor:pointer;padding:.6rem 1rem;border-radius:var(--gq-consent-radius,.5rem);border:2px solid var(--gq-consent-accent,#111)}
76
+ .gq-consent-button--choice{flex:1 1 10rem;background:var(--gq-consent-accent,#111);color:var(--gq-consent-accent-contrast,#fff)}
77
+ .gq-consent-button--secondary{background:transparent;color:inherit}
78
+ .gq-consent-button:focus-visible,.gq-consent-settings:focus-visible{outline:3px solid var(--gq-consent-focus,#1a73e8);outline-offset:2px}
79
+ .gq-consent-preferences{max-width:40rem;width:calc(100% - 2rem);max-height:calc(100vh - 2rem);overflow:auto;padding:1.25rem;border:1px solid var(--gq-consent-border,#888);border-radius:var(--gq-consent-radius,.5rem);background:var(--gq-consent-background,#fff);color:var(--gq-consent-color,#111)}
80
+ .gq-consent-category{border:1px solid var(--gq-consent-border,#ccc);border-radius:.375rem;margin:.75rem 0;padding:.75rem}
81
+ .gq-consent-services{margin:.5rem 0 0;padding-left:1rem;font-size:.875rem}
82
+ .gq-consent-services dl{display:grid;grid-template-columns:max-content 1fr;gap:.125rem .5rem;margin:.25rem 0}
83
+ .gq-consent-services dd{margin:0}
84
+ .gq-consent-embed{position:relative;display:flex;align-items:center;justify-content:center;width:100%;min-height:12rem;background:var(--gq-consent-embed-background,#eee);color:var(--gq-consent-color,#111)}
85
+ .gq-consent-embed[data-gq-loaded]{display:block;min-height:0;background:none}
86
+ .gq-consent-embed[data-gq-loaded]>iframe{width:100%;height:100%;border:0}
87
+ .gq-consent-embed--decorative{position:absolute;inset:0;min-height:0;background:none}
88
+ .gq-consent-embed__notice{max-width:32rem;padding:1rem;text-align:center}
89
+ .gq-consent-blocked{padding:1rem;background:var(--gq-consent-embed-background,#eee)}
90
+ .gq-legal ul{display:flex;flex-wrap:wrap;gap:.5rem 1rem;list-style:none;margin:0;padding:0}
91
+ .gq-consent-settings{font:inherit;color:inherit;background:none;border:0;padding:0;text-decoration:underline;cursor:pointer}
92
+ `.trim();
93
+ //#endregion
94
+ export { CONSENT_STYLES, consentPage, legalFooter };
@@ -45,6 +45,13 @@ export interface ConsentServerConfig {
45
45
  * request's own origin is always accepted.
46
46
  */
47
47
  siteOrigins?: readonly string[];
48
+ /**
49
+ * The reverse proxies the Site runs in front of the Worker, as IPv4/IPv6
50
+ * addresses or CIDRs. Only a request whose `CF-Connecting-IP` is one of
51
+ * them has its client IP taken from the proxy's `X-Forwarded-For` /
52
+ * `X-Real-IP` (docs/client-ip.md).
53
+ */
54
+ trustedProxies?: readonly string[];
48
55
  /** Retention after a decision stops being in force; at most (and by default) 5 years (CN-R10). */
49
56
  retentionDays?: number;
50
57
  now?: () => number;
@@ -2,7 +2,7 @@ import { t as bearerMatches } from "./bearer-DMOCoSci.js";
2
2
  import { i as DECISION_VALIDITY_MS, l as isConsentId, t as CONSENT_ACTIONS, x as OPTIONAL_CATEGORIES } from "./state-BYWc5TUB.js";
3
3
  import { a as parseConsentRecordRequest, t as MAX_RECORD_BYTES } from "./records-Dumrz759.js";
4
4
  import { t as CONSENT_PATH } from "./paths-Cmzzof3v.js";
5
- import { n as readBoundedBody, t as isSameOrigin } from "./request-guards-BUPr_vA4.js";
5
+ import { i as readBoundedBody, n as rateLimitKey, r as isSameOrigin, t as clientAddressResolver } from "./client-ip-cnqPLdDa.js";
6
6
  //#region frontend/src/consent/server.ts
7
7
  const MAX_RETENTION_DAYS = 1826;
8
8
  const DAY_MS = 864e5;
@@ -73,6 +73,7 @@ function createConsentServer(config) {
73
73
  if (!Number.isInteger(retentionDays) || retentionDays < 1 || retentionDays > 1826) throw new Error(`retentionDays must be a whole number of days from 1 to ${MAX_RETENTION_DAYS}`);
74
74
  const now = config.now ?? Date.now;
75
75
  const origins = new Set((config.siteOrigins ?? []).map((origin) => new URL(origin).origin));
76
+ const clientAddress = clientAddressResolver(config.trustedProxies);
76
77
  /** CN-R9: same-origin only. A browser's cross-site post carries its Origin. */
77
78
  const sameOrigin = (request) => isSameOrigin(request, origins);
78
79
  const readBody = (request) => readBoundedBody(request, MAX_RECORD_BYTES);
@@ -90,7 +91,7 @@ function createConsentServer(config) {
90
91
  const bindings = await config.bindings();
91
92
  const limiter = bindings.CONSENT_RATE_LIMITER;
92
93
  if (limiter) {
93
- const key = request.headers.get("CF-Connecting-IP") ?? "unknown";
94
+ const key = rateLimitKey(clientAddress(request));
94
95
  const { success } = await limiter.limit({ key });
95
96
  if (!success) return json(429, { error: "Too many consent records; try again later." });
96
97
  }
package/dist/consent.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { C as SERVICE_IDS, D as hostMatches, E as embedService, O as hostService, S as SERVICE_CATALOG, T as copyLanguage, _ as consentConfiguration, a as consentCookie, b as COPY_LANGUAGES, c as grantedCategories, d as promptReason, f as readConsentCookie, g as MEASUREMENT_ID, h as CONSENT_COPY_REVISION, i as DECISION_VALIDITY_MS, k as isServiceId, l as isConsentId, m as serviceGranted, n as CONSENT_COOKIE, o as decide, p as serializeConsentState, r as CONSENT_COOKIE_MAX_AGE_SECONDS, s as denyAll, t as CONSENT_ACTIONS, u as parseConsentState, v as needsBanner, w as catalogService, x as OPTIONAL_CATEGORIES, y as CONSENT_CATEGORIES } from "./state-BYWc5TUB.js";
2
2
  import { a as parseConsentRecordRequest, i as isRecordToken, n as consentRecordRequest, r as isLegalRoleName, t as MAX_RECORD_BYTES } from "./records-Dumrz759.js";
3
3
  import { a as CONSENT_COPY, c as legalRevisions, i as rewriteEmbeds, l as parseLegalLanguage, n as embedPlaceholder, o as LEGAL_ROLES, r as escapeHtml, s as REQUIRED_LEGAL_ROLES, t as blockedPlaceholder } from "./embeds-CJJemAmE.js";
4
- import { t as CONSENT_UI_VERSION } from "./version-Co2JJcK_.js";
4
+ import { t as CONSENT_UI_VERSION } from "./version-CGZoRnsU.js";
5
5
  export { CONSENT_ACTIONS, CONSENT_CATEGORIES, CONSENT_COOKIE, CONSENT_COOKIE_MAX_AGE_SECONDS, CONSENT_COPY, CONSENT_COPY_REVISION, CONSENT_UI_VERSION, COPY_LANGUAGES, DECISION_VALIDITY_MS, LEGAL_ROLES, MAX_RECORD_BYTES, MEASUREMENT_ID, OPTIONAL_CATEGORIES, REQUIRED_LEGAL_ROLES, SERVICE_CATALOG, SERVICE_IDS, blockedPlaceholder, catalogService, consentConfiguration, consentCookie, consentRecordRequest, copyLanguage, decide, denyAll, embedPlaceholder, embedService, escapeHtml, grantedCategories, hostMatches, hostService, isConsentId, isLegalRoleName, isRecordToken, isServiceId, legalRevisions, needsBanner, parseConsentRecordRequest, parseConsentState, parseLegalLanguage, promptReason, readConsentCookie, rewriteEmbeds, serializeConsentState, serviceGranted };
@@ -49,6 +49,13 @@ export interface FormsServerConfig {
49
49
  bindings: () => FormsBindings | Promise<FormsBindings>;
50
50
  /** The Site's public origin(s), when the Worker sees another (a proxy). */
51
51
  siteOrigins?: readonly string[];
52
+ /**
53
+ * The reverse proxies the Site runs in front of the Worker, as IPv4/IPv6
54
+ * addresses or CIDRs. Only a request whose `CF-Connecting-IP` is one of
55
+ * them has its client IP taken from the proxy's `X-Forwarded-For` /
56
+ * `X-Real-IP` (docs/client-ip.md).
57
+ */
58
+ trustedProxies?: readonly string[];
52
59
  /** The page language for the handler's own messages (`pt-PT`, `en`). */
53
60
  language?: string;
54
61
  /** Milliseconds Gravity Forms and Turnstile have to answer (15 s). */
@@ -1,5 +1,5 @@
1
1
  import { c as formDiagnostics, t as FORMS_PATH } from "./descriptor-x8kHFQ79.js";
2
- import { n as readBoundedBody, t as isSameOrigin } from "./request-guards-BUPr_vA4.js";
2
+ import { i as readBoundedBody, n as rateLimitKey, r as isSameOrigin, t as clientAddressResolver } from "./client-ip-cnqPLdDa.js";
3
3
  import { n as honeypotFilled, r as validateSubmission } from "./validate-wRgYUNxN.js";
4
4
  import { c as formStateLocation, n as formsCopyLanguage, o as formStateCookie, t as FORMS_COPY } from "./copy-DLto_bWj.js";
5
5
  //#region frontend/src/forms/server.ts
@@ -24,6 +24,7 @@ function createFormsServer(config) {
24
24
  const restRoot = (config.restRoot ?? `${cms.origin}/wp-json`).replace(/\/+$/, "");
25
25
  if (new URL(restRoot).origin !== cms.origin) throw new Error("restRoot must be on the CMS's origin");
26
26
  const origins = new Set((config.siteOrigins ?? []).map((origin) => new URL(origin).origin));
27
+ const clientAddress = clientAddressResolver(config.trustedProxies);
27
28
  const timeoutMs = config.timeoutMs ?? 15e3;
28
29
  const fetcher = config.fetch ?? globalThis.fetch.bind(globalThis);
29
30
  const copy = FORMS_COPY[formsCopyLanguage(config.language)];
@@ -150,7 +151,7 @@ function createFormsServer(config) {
150
151
  }, source);
151
152
  }
152
153
  const token = params.get("cf-turnstile-response") ?? "";
153
- if (!token || token.length > 2048 || !await verifyTurnstile(secret, token, request.headers.get("CF-Connecting-IP"))) return outcome(request, {
154
+ if (!token || token.length > 2048 || !await verifyTurnstile(secret, token, clientAddress(request))) return outcome(request, {
154
155
  formId,
155
156
  status: "verification",
156
157
  values: checked.values
@@ -253,7 +254,7 @@ function createFormsServer(config) {
253
254
  if (!SOURCE.test(source) || source.length > 2048) return json(400, { error: "The form's page (gq_source) isn't a path on this Site." });
254
255
  const limiter = bindings.FORMS_RATE_LIMITER;
255
256
  if (limiter) {
256
- const { success } = await limiter.limit({ key: `${formId}:${request.headers.get("CF-Connecting-IP") ?? "unknown"}` });
257
+ const { success } = await limiter.limit({ key: `${formId}:${rateLimitKey(clientAddress(request))}` });
257
258
  if (!success) return outcome(request, {
258
259
  formId,
259
260
  status: "too-many"
@@ -1,6 +1,6 @@
1
1
  //#endregion
2
2
  //#region frontend/src/consent/version.ts
3
3
  /** The UI/adapter version recorded with each decision (CN-R7). */
4
- const CONSENT_UI_VERSION = `gq-content@0.4.1`;
4
+ const CONSENT_UI_VERSION = `gq-content@0.4.2`;
5
5
  //#endregion
6
6
  export { CONSENT_UI_VERSION as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getquick/content",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "license": "UNLICENSED",
5
5
  "files": [
6
6
  "dist",
@@ -77,6 +77,10 @@
77
77
  "./forms/testing": {
78
78
  "types": "./dist/forms-testing.d.ts",
79
79
  "import": "./dist/forms-testing.js"
80
+ },
81
+ "./consent/html": {
82
+ "types": "./dist/consent-html.d.ts",
83
+ "import": "./dist/consent-html.js"
80
84
  }
81
85
  },
82
86
  "publishConfig": {
@@ -1,46 +0,0 @@
1
- //#region frontend/src/request-guards.ts
2
- /**
3
- * Whether a browser posted from the Site itself. A cross-site post carries
4
- * its Origin (and Sec-Fetch-Site); `origins` are the Site's public origins
5
- * when the Worker sees another (a proxy). The request's own origin is always
6
- * accepted.
7
- */
8
- function isSameOrigin(request, origins) {
9
- const origin = request.headers.get("Origin");
10
- const site = request.headers.get("Sec-Fetch-Site");
11
- if (site && site !== "same-origin") return false;
12
- if (!origin) return false;
13
- if (origin === new URL(request.url).origin || origins.has(origin)) return true;
14
- try {
15
- return new URL(origin).host === request.headers.get("Host");
16
- } catch {
17
- return false;
18
- }
19
- }
20
- /** The body as text, or null once it exceeds `limit` bytes (declared or read). */
21
- async function readBoundedBody(request, limit) {
22
- if (Number(request.headers.get("Content-Length") ?? "0") > limit) return null;
23
- if (!request.body) return "";
24
- const reader = request.body.getReader();
25
- const chunks = [];
26
- let size = 0;
27
- for (;;) {
28
- const { done, value } = await reader.read();
29
- if (done) break;
30
- size += value.byteLength;
31
- if (size > limit) {
32
- await reader.cancel();
33
- return null;
34
- }
35
- chunks.push(value);
36
- }
37
- const bytes = new Uint8Array(size);
38
- let offset = 0;
39
- for (const chunk of chunks) {
40
- bytes.set(chunk, offset);
41
- offset += chunk.byteLength;
42
- }
43
- return new TextDecoder().decode(bytes);
44
- }
45
- //#endregion
46
- export { readBoundedBody as n, isSameOrigin as t };