@getvitops/astro 7.0.0 → 8.0.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 +151 -34
- package/dist/index.mjs +10 -3
- package/dist/index.mjs.map +1 -1
- package/dist/routes/conversion.d.mts +87 -4
- package/dist/routes/conversion.d.mts.map +1 -1
- package/dist/routes/conversion.mjs +142 -34
- package/dist/routes/conversion.mjs.map +1 -1
- package/package.json +22 -7
- package/src/components/Gallery.astro +77 -69
- package/src/components/Marquee.astro +63 -0
- package/src/components/ReviewCard.astro +60 -0
- package/src/components/ReviewCardBody.astro +67 -0
- package/src/schemas/Article.astro +2 -2
- package/src/schemas/Breadcrumb.astro +2 -2
- package/src/schemas/Carousel.astro +2 -2
- package/src/schemas/FAQ.astro +2 -2
- package/src/schemas/LocalBusiness.astro +2 -2
- package/src/schemas/Organization.astro +2 -2
- package/src/scripts/tracking.ts +4 -2
- package/virtual.d.ts +0 -11
|
@@ -7,6 +7,14 @@ interface ConversionRequestContext {
|
|
|
7
7
|
request: Request;
|
|
8
8
|
locals?: unknown;
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Records a hit for `id` at time `at` (ms) and returns the ms since the previous
|
|
12
|
+
* hit for that `id`, or `null` if this is the first. Pure interface — a store
|
|
13
|
+
* decides nothing, it only remembers.
|
|
14
|
+
*/
|
|
15
|
+
interface RateLimitStore {
|
|
16
|
+
hit(id: string, at: number): Promise<number | null> | number | null;
|
|
17
|
+
}
|
|
10
18
|
interface ConversionRouteOptions {
|
|
11
19
|
/** Where notifications go. Build it from your site config. */
|
|
12
20
|
context: NotifyContext;
|
|
@@ -21,19 +29,94 @@ interface ConversionRouteOptions {
|
|
|
21
29
|
binding?: (locals: unknown) => EmailBinding | undefined;
|
|
22
30
|
/**
|
|
23
31
|
* Reject a submission before anything is sent. Return a message to refuse.
|
|
24
|
-
* This is where a consumer puts required-field checks
|
|
32
|
+
* This is where a consumer puts required-field checks.
|
|
25
33
|
*/
|
|
26
34
|
validate?: (body: Record<string, unknown>) => string | null;
|
|
27
35
|
/** Called with every channel outcome, delivered or not. Defaults to console. */
|
|
28
36
|
onResult?: (result: Awaited<ReturnType<typeof notify>>) => void;
|
|
29
37
|
/** Injected in tests. */
|
|
30
38
|
now?: () => number;
|
|
39
|
+
/**
|
|
40
|
+
* Where to send a no-JS `<form method="post">` submission on success. Without
|
|
41
|
+
* this, that visitor's browser navigates to the raw `{"success":true}` JSON
|
|
42
|
+
* body as its next page — functional, but not a page anyone should see. Has
|
|
43
|
+
* no effect on the beacon/fetch (JSON) path, which always gets a JSON response.
|
|
44
|
+
*/
|
|
45
|
+
redirectTo?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Where to send a refused no-JS submission (validation, spam checks, malformed
|
|
48
|
+
* body). Falls back to `redirectTo` if omitted, and to the JSON error response
|
|
49
|
+
* if neither is set.
|
|
50
|
+
*/
|
|
51
|
+
errorRedirectTo?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Field names treated as honeypots: a bot fills every input it finds, a person
|
|
54
|
+
* never sees these (hidden by CSS in the form markup). A non-empty value is
|
|
55
|
+
* silently treated as spam — the response looks identical to a real success,
|
|
56
|
+
* denying the bot the signal it needs to know the trick worked, but nothing is
|
|
57
|
+
* notified and the field never reaches `notify()`. Defaults to the same names
|
|
58
|
+
* `render.ts`'s `OMIT_FIELDS` already knows about (`website`, `_honeypot`,
|
|
59
|
+
* `honeypot`), so the two lists don't need to be kept in sync by hand.
|
|
60
|
+
*/
|
|
61
|
+
honeypotFields?: string[];
|
|
62
|
+
/**
|
|
63
|
+
* Reject a submission whose `Content-Length` exceeds this many bytes. Default
|
|
64
|
+
* 25_000 — generous for a contact form with no file upload, tiny for anything
|
|
65
|
+
* meant to relay large payloads. Best-effort: `Content-Length` can be absent
|
|
66
|
+
* or spoofed, same caveat as the origin check below.
|
|
67
|
+
*/
|
|
68
|
+
maxBodyBytes?: number;
|
|
69
|
+
/**
|
|
70
|
+
* Reject a submission whose `Origin` header doesn't match this origin.
|
|
71
|
+
* Defaults to `context.canonical`'s origin. Pass `false` to disable.
|
|
72
|
+
*
|
|
73
|
+
* Best-effort, not a CSRF guard: browsers omit `Origin` on plenty of
|
|
74
|
+
* legitimate same-origin requests, so its *absence* is never treated as a
|
|
75
|
+
* mismatch — only a present-and-different value is rejected. That still stops
|
|
76
|
+
* the common case (a script hitting the endpoint directly from elsewhere)
|
|
77
|
+
* without breaking real submissions from older or privacy-hardened browsers.
|
|
78
|
+
*/
|
|
79
|
+
allowedOrigin?: string | false;
|
|
80
|
+
/**
|
|
81
|
+
* Minimum interval between accepted submissions from the same IP. Pass
|
|
82
|
+
* `false` to disable. `windowMs` defaults to 10 seconds — enough to blunt a
|
|
83
|
+
* script firing in a loop without troubling someone who fat-fingers a form
|
|
84
|
+
* and resubmits.
|
|
85
|
+
*/
|
|
86
|
+
rateLimit?: {
|
|
87
|
+
windowMs?: number;
|
|
88
|
+
store?: RateLimitStore;
|
|
89
|
+
} | false;
|
|
90
|
+
/**
|
|
91
|
+
* Verify a Cloudflare Turnstile token server-side. The **secret** key —
|
|
92
|
+
* unlike `site.security.turnstile.siteKey`, which is public config — must come
|
|
93
|
+
* from an env var, never a config file (the same rule as every other
|
|
94
|
+
* credential in this toolchain).
|
|
95
|
+
*/
|
|
96
|
+
turnstile?: {
|
|
97
|
+
secretKey: string;
|
|
98
|
+
/** The submitted field carrying the token. Default `cf-turnstile-response`
|
|
99
|
+
* — the name Cloudflare's widget submits under. */
|
|
100
|
+
fieldName?: string;
|
|
101
|
+
/**
|
|
102
|
+
* Reject a submission with no token at all, rather than only a present-and-
|
|
103
|
+
* invalid one. Default `false`: an absent token is the ordinary shape of a
|
|
104
|
+
* visitor with JS disabled, and this factory is built around that path
|
|
105
|
+
* staying usable. Set `true` only for a form where verified-human matters
|
|
106
|
+
* enough to accept that trade-off — doing so means that form no longer
|
|
107
|
+
* works without JS, on purpose.
|
|
108
|
+
*/
|
|
109
|
+
required?: boolean;
|
|
110
|
+
};
|
|
31
111
|
}
|
|
32
112
|
/**
|
|
33
113
|
* Build a POST handler for conversions.
|
|
34
114
|
*
|
|
35
|
-
* Returns 204 for a beacon and 200 with `{ success }` for a
|
|
36
|
-
* `navigator.sendBeacon` and a fetch-submitting
|
|
115
|
+
* Returns 204 for a beacon and 200 with `{ success }` for a JSON (fetch-based)
|
|
116
|
+
* form submission, matching what `navigator.sendBeacon` and a fetch-submitting
|
|
117
|
+
* form each expect. A no-JS `<form method="post">` — `application/
|
|
118
|
+
* x-www-form-urlencoded` or `multipart/form-data` — is parsed the same way and,
|
|
119
|
+
* on success, redirected to `redirectTo` if one is configured.
|
|
37
120
|
*
|
|
38
121
|
* **A failed notification does not fail the request.** The visitor has already
|
|
39
122
|
* submitted; refusing them because our mail did not go out turns a lost
|
|
@@ -44,5 +127,5 @@ declare function createConversionRoute(options: ConversionRouteOptions): ({
|
|
|
44
127
|
locals
|
|
45
128
|
}: ConversionRequestContext) => Promise<Response>;
|
|
46
129
|
//#endregion
|
|
47
|
-
export { ConversionRequestContext, ConversionRouteOptions, createConversionRoute };
|
|
130
|
+
export { ConversionRequestContext, ConversionRouteOptions, RateLimitStore, createConversionRoute };
|
|
48
131
|
//# sourceMappingURL=conversion.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"conversion.d.mts","names":[],"sources":["../../src/routes/conversion.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"conversion.d.mts","names":[],"sources":["../../src/routes/conversion.ts"],"mappings":";;;AAsPA;;AAAA,UAjLiB,wBAAA;EACf,OAAA,EAAS,OAAO;EAChB,MAAA;AAAA;;;;;;UAQe,cAAA;EACf,GAAA,CAAI,EAAA,UAAY,EAAA,WAAa,OAAO;AAAA;AAAA,UAkBrB,sBAAA;EA4KD;EA1Kd,OAAA,EAAS,aAAA;EA0K0B;;;;AAA2C;;;;EAjK9E,OAAA,IAAW,MAAA,cAAoB,YAAA;;;;;EAK/B,QAAA,IAAY,IAAA,EAAM,MAAA;;EAElB,QAAA,IAAY,MAAA,EAAQ,OAAA,CAAQ,UAAA,QAAkB,MAAA;;EAE9C,GAAA;;;;;;;EAOA,UAAA;;;;;;EAMA,eAAA;;;;;;;;;;EAUA,cAAA;;;;;;;EAOA,YAAA;;;;;;;;;;;EAWA,aAAA;;;;;;;EAOA,SAAA;IAAc,QAAA;IAAmB,KAAA,GAAQ,cAAA;EAAA;;;;;;;EAOzC,SAAA;IACE,SAAA;;;IAGA,SAAA;;;;;;;;;IASA,QAAA;EAAA;AAAA;;;;;;;;;;;;;;iBA4DY,qBAAA,CAAsB,OAAA,EAAS,sBAAA;EAwB/B,OAAA;EAAA;AAAA,GAAqB,wBAAA,KAA2B,OAAA,CAAQ,QAAA"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { notify } from "@getvitops/utils/notify";
|
|
2
|
+
import { verifyTurnstileToken } from "@getvitops/utils/security";
|
|
2
3
|
import { parseTrackingCookie } from "@getvitops/utils/tracking";
|
|
3
4
|
|
|
4
5
|
//#region src/routes/conversion.ts
|
|
@@ -10,11 +11,14 @@ import { parseTrackingCookie } from "@getvitops/utils/tracking";
|
|
|
10
11
|
* off the request, build the event, notify whoever the config says. That is the
|
|
11
12
|
* same three lines in every project, so it ships here.
|
|
12
13
|
*
|
|
13
|
-
* What deliberately does **not** ship here is validation and business
|
|
14
|
-
* consumer's form has its own required fields
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `validate`;
|
|
14
|
+
* What deliberately does **not** ship here is field-level validation and business
|
|
15
|
+
* rules — a consumer's form has its own required fields and its own response
|
|
16
|
+
* shape, and baking a guess at those in would make the factory something to
|
|
17
|
+
* fight rather than mount. The handler takes the parsed body and an optional
|
|
18
|
+
* `validate`; that part is the caller's. What **does** ship here is generic abuse
|
|
19
|
+
* resistance (honeypot, origin, rate limit, body size): this is a public,
|
|
20
|
+
* unauthenticated endpoint that sends email, so leaving it wide open makes it a
|
|
21
|
+
* relay and a cost amplifier regardless of whose form is behind it.
|
|
18
22
|
*
|
|
19
23
|
* The path matters: the capture script beacons `tel:` conversions at
|
|
20
24
|
* `TRACKING_ENDPOINT` (`/api/track`, exported from `@getvitops/astro/tracking`),
|
|
@@ -34,9 +38,24 @@ import { parseTrackingCookie } from "@getvitops/utils/tracking";
|
|
|
34
38
|
* organizationName: 'Acme',
|
|
35
39
|
* },
|
|
36
40
|
* binding: () => env.EMAIL,
|
|
41
|
+
* // A no-JS <form method="post"> submission never sees a JSON response body —
|
|
42
|
+
* // it's a full navigation, so send it somewhere real.
|
|
43
|
+
* redirectTo: '/thanks',
|
|
37
44
|
* });
|
|
38
45
|
* ```
|
|
39
46
|
*
|
|
47
|
+
* Cloudflare Turnstile is a separate, opt-in layer on top of the checks above —
|
|
48
|
+
* pass `turnstile: { secretKey }` and a submission carrying a
|
|
49
|
+
* `cf-turnstile-response` token gets it verified server-side via
|
|
50
|
+
* `verifyTurnstileToken` (`@getvitops/utils/security`). The widget itself is the
|
|
51
|
+
* consumer's own markup (`<script src="https://challenges.cloudflare.com/
|
|
52
|
+
* turnstile/v0/api.js" async defer><\/script>` + `<div class="cf-turnstile"
|
|
53
|
+
* data-sitekey="…">` on their form) — there is no `FormRenderer` for this to hook
|
|
54
|
+
* into generically, matching how the rest of the form is already hand-written.
|
|
55
|
+
* `site.security.turnstile.siteKey` is what the generated privacy policy reads to
|
|
56
|
+
* name Turnstile as a processor; wiring this option up is what makes that true
|
|
57
|
+
* rather than an assertion nothing checks.
|
|
58
|
+
*
|
|
40
59
|
* Two things this example used to get wrong, both of which cost a consumer real
|
|
41
60
|
* time. It read the binding off `locals.runtime.env`, which Astro removed in v6
|
|
42
61
|
* — and because the resulting throw precedes the response, Astro re-reads the
|
|
@@ -46,65 +65,154 @@ import { parseTrackingCookie } from "@getvitops/utils/tracking";
|
|
|
46
65
|
* current one. It also called a `toNotifyContext(config)` helper that does not
|
|
47
66
|
* exist — `context` is a plain `NotifyContext`, built however you like.
|
|
48
67
|
*/
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Per-isolate, in-memory default. Good enough for a single Worker instance
|
|
70
|
+
* fielding occasional contact-form spam; not durable across a fleet, and not
|
|
71
|
+
* shared with other routes. Inject a KV- or Durable-Object-backed `store` for
|
|
72
|
+
* real cross-instance limiting — the interface is one method wide on purpose.
|
|
73
|
+
*/
|
|
74
|
+
var MemoryRateLimitStore = class {
|
|
75
|
+
lastHit = /* @__PURE__ */ new Map();
|
|
76
|
+
hit(id, at) {
|
|
77
|
+
const prev = this.lastHit.get(id);
|
|
78
|
+
this.lastHit.set(id, at);
|
|
79
|
+
return prev === void 0 ? null : at - prev;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
/** Fields that are never part of a submission's meaning. `cf-turnstile-response`
|
|
83
|
+
* is excluded unconditionally, matching `render.ts`'s `OMIT_FIELDS` — the field
|
|
84
|
+
* carries a verification token, not something the visitor wrote. */
|
|
85
|
+
const CONTROL_FIELDS = new Set([
|
|
86
|
+
"event",
|
|
87
|
+
"phone",
|
|
88
|
+
"cf-turnstile-response"
|
|
89
|
+
]);
|
|
90
|
+
const DEFAULT_HONEYPOT_FIELDS = [
|
|
91
|
+
"website",
|
|
92
|
+
"_honeypot",
|
|
93
|
+
"honeypot"
|
|
94
|
+
];
|
|
95
|
+
const DEFAULT_MAX_BODY_BYTES = 25e3;
|
|
96
|
+
const DEFAULT_RATE_LIMIT_WINDOW_MS = 1e4;
|
|
97
|
+
function toFormData(body, honeypotFields) {
|
|
52
98
|
const out = {};
|
|
53
99
|
for (const [key, value] of Object.entries(body)) {
|
|
54
|
-
if (CONTROL_FIELDS.has(key)) continue;
|
|
100
|
+
if (CONTROL_FIELDS.has(key) || honeypotFields.includes(key)) continue;
|
|
55
101
|
if (value === void 0 || value === null) continue;
|
|
56
102
|
out[key] = String(value);
|
|
57
103
|
}
|
|
58
104
|
return out;
|
|
59
105
|
}
|
|
106
|
+
function hasHoneypot(body, honeypotFields) {
|
|
107
|
+
return honeypotFields.some((field) => {
|
|
108
|
+
const value = body[field];
|
|
109
|
+
return typeof value === "string" && value.trim() !== "";
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
function originOf(url) {
|
|
113
|
+
if (!url) return null;
|
|
114
|
+
try {
|
|
115
|
+
return new URL(url).origin;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** A `Location` header response — used for the no-JS form path instead of
|
|
121
|
+
* `Response.redirect()`, which requires a base URL this factory doesn't have. */
|
|
122
|
+
function redirectTo(location, status = 303) {
|
|
123
|
+
return new Response(null, {
|
|
124
|
+
status,
|
|
125
|
+
headers: { Location: location }
|
|
126
|
+
});
|
|
127
|
+
}
|
|
60
128
|
/**
|
|
61
129
|
* Build a POST handler for conversions.
|
|
62
130
|
*
|
|
63
|
-
* Returns 204 for a beacon and 200 with `{ success }` for a
|
|
64
|
-
* `navigator.sendBeacon` and a fetch-submitting
|
|
131
|
+
* Returns 204 for a beacon and 200 with `{ success }` for a JSON (fetch-based)
|
|
132
|
+
* form submission, matching what `navigator.sendBeacon` and a fetch-submitting
|
|
133
|
+
* form each expect. A no-JS `<form method="post">` — `application/
|
|
134
|
+
* x-www-form-urlencoded` or `multipart/form-data` — is parsed the same way and,
|
|
135
|
+
* on success, redirected to `redirectTo` if one is configured.
|
|
65
136
|
*
|
|
66
137
|
* **A failed notification does not fail the request.** The visitor has already
|
|
67
138
|
* submitted; refusing them because our mail did not go out turns a lost
|
|
68
139
|
* notification into a lost conversion. Failures are reported through `onResult`.
|
|
69
140
|
*/
|
|
70
141
|
function createConversionRoute(options) {
|
|
71
|
-
const { context, binding, validate, onResult, now = Date.now } = options;
|
|
142
|
+
const { context, binding, validate, onResult, now = Date.now, redirectTo: successRedirect, errorRedirectTo = successRedirect, honeypotFields = DEFAULT_HONEYPOT_FIELDS, maxBodyBytes = DEFAULT_MAX_BODY_BYTES } = options;
|
|
143
|
+
const allowedOrigin = options.allowedOrigin === false ? null : options.allowedOrigin ?? originOf(context.canonical);
|
|
144
|
+
const rateLimit = options.rateLimit === false ? null : {
|
|
145
|
+
windowMs: options.rateLimit?.windowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS,
|
|
146
|
+
store: options.rateLimit?.store ?? new MemoryRateLimitStore()
|
|
147
|
+
};
|
|
72
148
|
return async ({ request, locals }) => {
|
|
149
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
150
|
+
const isFormPost = contentType.includes("multipart/form-data") || contentType.includes("application/x-www-form-urlencoded");
|
|
151
|
+
const respondError = (error, status) => isFormPost && errorRedirectTo ? redirectTo(errorRedirectTo) : Response.json({
|
|
152
|
+
success: false,
|
|
153
|
+
error
|
|
154
|
+
}, { status });
|
|
155
|
+
if (Number(request.headers.get("content-length") ?? 0) > maxBodyBytes) return respondError("Request too large.", 413);
|
|
156
|
+
if (allowedOrigin) {
|
|
157
|
+
const requestOrigin = request.headers.get("origin");
|
|
158
|
+
if (requestOrigin && requestOrigin !== allowedOrigin) return respondError("Origin not allowed.", 403);
|
|
159
|
+
}
|
|
73
160
|
let body;
|
|
74
161
|
try {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
|
|
162
|
+
if (isFormPost) {
|
|
163
|
+
const formData = await request.formData();
|
|
164
|
+
body = {};
|
|
165
|
+
for (const [key, value] of formData.entries()) if (typeof value === "string") body[key] = value;
|
|
166
|
+
} else {
|
|
167
|
+
const parsed = await request.json();
|
|
168
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return respondError("Malformed request.", 400);
|
|
169
|
+
body = parsed;
|
|
170
|
+
}
|
|
81
171
|
} catch {
|
|
82
|
-
return
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
172
|
+
return respondError("Malformed request.", 400);
|
|
173
|
+
}
|
|
174
|
+
const ip = request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? void 0;
|
|
175
|
+
if (rateLimit && ip) {
|
|
176
|
+
const sinceLastHit = await rateLimit.store.hit(ip, now());
|
|
177
|
+
if (sinceLastHit !== null && sinceLastHit < rateLimit.windowMs) return respondError("Too many requests.", 429);
|
|
86
178
|
}
|
|
87
179
|
const isCall = body["event"] === "call";
|
|
88
|
-
if (isCall && typeof body["phone"] !== "string") return
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
180
|
+
if (isCall && typeof body["phone"] !== "string") return respondError("Missing phone.", 400);
|
|
181
|
+
const spam = !isCall && hasHoneypot(body, honeypotFields);
|
|
182
|
+
if (!isCall && !spam && options.turnstile) {
|
|
183
|
+
const { secretKey, fieldName = "cf-turnstile-response", required = false } = options.turnstile;
|
|
184
|
+
const token = body[fieldName];
|
|
185
|
+
if (typeof token === "string" && token) {
|
|
186
|
+
if (!(await verifyTurnstileToken(token, secretKey, ip)).success) return respondError("Verification failed.", 400);
|
|
187
|
+
} else if (required) return respondError("Verification required.", 400);
|
|
188
|
+
}
|
|
189
|
+
const refusal = !spam && validate?.(body);
|
|
190
|
+
if (refusal) return respondError(refusal, 400);
|
|
191
|
+
const respondSuccess = () => isCall ? new Response(null, { status: 204 }) : isFormPost && successRedirect ? redirectTo(successRedirect) : Response.json({ success: true });
|
|
192
|
+
if (spam) {
|
|
193
|
+
onResult?.({
|
|
194
|
+
results: [{
|
|
195
|
+
channel: "email",
|
|
196
|
+
sent: false,
|
|
197
|
+
reason: "Skipped: honeypot triggered."
|
|
198
|
+
}],
|
|
199
|
+
delivered: false
|
|
200
|
+
});
|
|
201
|
+
return respondSuccess();
|
|
202
|
+
}
|
|
203
|
+
const turnstileFieldName = options.turnstile?.fieldName;
|
|
204
|
+
const excludedFields = turnstileFieldName ? [...honeypotFields, turnstileFieldName] : honeypotFields;
|
|
97
205
|
const result = await notify({
|
|
98
206
|
type: isCall ? "call" : "form",
|
|
99
|
-
...isCall ? { phone: String(body["phone"]).replace(/^tel:/, "") } : { formData: toFormData(body) },
|
|
207
|
+
...isCall ? { phone: String(body["phone"]).replace(/^tel:/, "") } : { formData: toFormData(body, excludedFields) },
|
|
100
208
|
tracking: parseTrackingCookie(request.headers.get("cookie")),
|
|
101
209
|
userAgent: request.headers.get("user-agent") ?? void 0,
|
|
102
|
-
ip
|
|
210
|
+
ip,
|
|
103
211
|
at: now()
|
|
104
212
|
}, context, { email: binding?.(locals) });
|
|
105
213
|
if (onResult) onResult(result);
|
|
106
214
|
else for (const r of result.results) if (!r.sent) console.warn(`[vitops] notification not sent (${r.channel}): ${r.reason}`);
|
|
107
|
-
return
|
|
215
|
+
return respondSuccess();
|
|
108
216
|
};
|
|
109
217
|
}
|
|
110
218
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"conversion.mjs","names":[],"sources":["../../src/routes/conversion.ts"],"sourcesContent":["/**\n * The conversion endpoint, as a factory.\n *\n * A site's contact form and its `tel:` beacon both mean the same thing — someone\n * got in touch — and both want the same treatment: read the attribution cookie\n * off the request, build the event, notify whoever the config says. That is the\n * same three lines in every project, so it ships here.\n *\n * What deliberately does **not** ship here is validation and business rules. A\n * consumer's form has its own required fields, its own spam handling and its own\n * response shape, and baking a guess at those in would make the factory something\n * to fight rather than mount. The handler takes the parsed body and an optional\n * `validate`; everything else is the caller's.\n *\n * The path matters: the capture script beacons `tel:` conversions at\n * `TRACKING_ENDPOINT` (`/api/track`, exported from `@getvitops/astro/tracking`),\n * and the integration warns at build if no route answers it.\n *\n * ```ts\n * // src/pages/api/track.ts\n * export const prerender = false;\n * import { env } from 'cloudflare:workers';\n * import { createConversionRoute } from '@getvitops/astro/routes';\n *\n * export const POST = createConversionRoute({\n * context: {\n * // A bare address is shorthand for `{ provider: 'cloudflare', to }`.\n * notifications: { email: 'sales@acme.ca' },\n * canonical: 'https://acme.ca',\n * organizationName: 'Acme',\n * },\n * binding: () => env.EMAIL,\n * });\n * ```\n *\n * Two things this example used to get wrong, both of which cost a consumer real\n * time. It read the binding off `locals.runtime.env`, which Astro removed in v6\n * — and because the resulting throw precedes the response, Astro re-reads the\n * request and reports a misleading \"Body has already been used\" instead of the\n * actual error. This package peers on `astro >= 7`, so that form could not work\n * for any supported consumer; `import { env } from 'cloudflare:workers'` is the\n * current one. It also called a `toNotifyContext(config)` helper that does not\n * exist — `context` is a plain `NotifyContext`, built however you like.\n */\nimport { type EmailBinding, notify, type NotifyContext } from '@getvitops/utils/notify';\nimport { parseTrackingCookie } from '@getvitops/utils/tracking';\nimport type { ConversionEvent } from '@getvitops/utils/tracking';\n\n/** The subset of Astro's `APIContext` this needs — typed structurally so the\n * factory does not pin an Astro version. */\nexport interface ConversionRequestContext {\n request: Request;\n locals?: unknown;\n}\n\nexport interface ConversionRouteOptions {\n /** Where notifications go. Build it from your site config. */\n context: NotifyContext;\n /**\n * The Workers `send_email` binding for this request, e.g. `() => env.EMAIL`\n * with `env` imported from `cloudflare:workers`. Omit it in dev and the\n * notification is printed instead of sent.\n *\n * Not `(locals) => locals.runtime.env.EMAIL` — Astro removed `locals.runtime`\n * in v6, and this package peers on `>= 7`.\n */\n binding?: (locals: unknown) => EmailBinding | undefined;\n /**\n * Reject a submission before anything is sent. Return a message to refuse.\n * This is where a consumer puts required-field checks and spam rules.\n */\n validate?: (body: Record<string, unknown>) => string | null;\n /** Called with every channel outcome, delivered or not. Defaults to console. */\n onResult?: (result: Awaited<ReturnType<typeof notify>>) => void;\n /** Injected in tests. */\n now?: () => number;\n}\n\n/** Fields that are never part of a submission's meaning. */\nconst CONTROL_FIELDS = new Set(['event', 'phone']);\n\nfunction toFormData(body: Record<string, unknown>): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [key, value] of Object.entries(body)) {\n if (CONTROL_FIELDS.has(key)) continue;\n if (value === undefined || value === null) continue;\n out[key] = String(value);\n }\n return out;\n}\n\n/**\n * Build a POST handler for conversions.\n *\n * Returns 204 for a beacon and 200 with `{ success }` for a form, matching what\n * `navigator.sendBeacon` and a fetch-submitting form each expect.\n *\n * **A failed notification does not fail the request.** The visitor has already\n * submitted; refusing them because our mail did not go out turns a lost\n * notification into a lost conversion. Failures are reported through `onResult`.\n */\nexport function createConversionRoute(options: ConversionRouteOptions) {\n const { context, binding, validate, onResult, now = Date.now } = options;\n\n return async ({ request, locals }: ConversionRequestContext): Promise<Response> => {\n let body: Record<string, unknown>;\n try {\n const parsed: unknown = await request.json();\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return Response.json({ success: false, error: 'Malformed request.' }, { status: 400 });\n }\n body = parsed as Record<string, unknown>;\n } catch {\n return Response.json({ success: false, error: 'Malformed request.' }, { status: 400 });\n }\n\n const isCall = body['event'] === 'call';\n if (isCall && typeof body['phone'] !== 'string') {\n return Response.json({ success: false, error: 'Missing phone.' }, { status: 400 });\n }\n\n const refusal = validate?.(body);\n if (refusal) return Response.json({ success: false, error: refusal }, { status: 400 });\n\n const event: ConversionEvent = {\n type: isCall ? 'call' : 'form',\n ...(isCall\n ? { phone: String(body['phone']).replace(/^tel:/, '') }\n : { formData: toFormData(body) }),\n tracking: parseTrackingCookie(request.headers.get('cookie')),\n userAgent: request.headers.get('user-agent') ?? undefined,\n // Cloudflare's own header first; `x-forwarded-for` is the portable fallback\n // and may carry a proxy chain, whose first entry is the client.\n ip:\n request.headers.get('cf-connecting-ip') ??\n request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??\n undefined,\n at: now(),\n };\n\n const result = await notify(event, context, { email: binding?.(locals) });\n\n if (onResult) onResult(result);\n else\n for (const r of result.results) {\n if (!r.sent) console.warn(`[vitops] notification not sent (${r.channel}): ${r.reason}`);\n }\n\n return isCall ? new Response(null, { status: 204 }) : Response.json({ success: true });\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+EA,MAAM,iBAAiB,IAAI,IAAI,CAAC,SAAS,OAAO,CAAC;AAEjD,SAAS,WAAW,MAAuD;CACzE,MAAM,MAA8B,CAAC;CACrC,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,IAAI,GAAG;EAC/C,IAAI,eAAe,IAAI,GAAG,GAAG;EAC7B,IAAI,UAAU,UAAa,UAAU,MAAM;EAC3C,IAAI,OAAO,OAAO,KAAK;CACzB;CACA,OAAO;AACT;;;;;;;;;;;AAYA,SAAgB,sBAAsB,SAAiC;CACrE,MAAM,EAAE,SAAS,SAAS,UAAU,UAAU,MAAM,KAAK,QAAQ;CAEjE,OAAO,OAAO,EAAE,SAAS,aAA0D;EACjF,IAAI;EACJ,IAAI;GACF,MAAM,SAAkB,MAAM,QAAQ,KAAK;GAC3C,IAAI,OAAO,WAAW,YAAY,WAAW,QAAQ,MAAM,QAAQ,MAAM,GACvE,OAAO,SAAS,KAAK;IAAE,SAAS;IAAO,OAAO;GAAqB,GAAG,EAAE,QAAQ,IAAI,CAAC;GAEvF,OAAO;EACT,QAAQ;GACN,OAAO,SAAS,KAAK;IAAE,SAAS;IAAO,OAAO;GAAqB,GAAG,EAAE,QAAQ,IAAI,CAAC;EACvF;EAEA,MAAM,SAAS,KAAK,aAAa;EACjC,IAAI,UAAU,OAAO,KAAK,aAAa,UACrC,OAAO,SAAS,KAAK;GAAE,SAAS;GAAO,OAAO;EAAiB,GAAG,EAAE,QAAQ,IAAI,CAAC;EAGnF,MAAM,UAAU,WAAW,IAAI;EAC/B,IAAI,SAAS,OAAO,SAAS,KAAK;GAAE,SAAS;GAAO,OAAO;EAAQ,GAAG,EAAE,QAAQ,IAAI,CAAC;EAkBrF,MAAM,SAAS,MAAM,OAAO;GAf1B,MAAM,SAAS,SAAS;GACxB,GAAI,SACA,EAAE,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,QAAQ,SAAS,EAAE,EAAE,IACpD,EAAE,UAAU,WAAW,IAAI,EAAE;GACjC,UAAU,oBAAoB,QAAQ,QAAQ,IAAI,QAAQ,CAAC;GAC3D,WAAW,QAAQ,QAAQ,IAAI,YAAY,KAAK;GAGhD,IACE,QAAQ,QAAQ,IAAI,kBAAkB,KACtC,QAAQ,QAAQ,IAAI,iBAAiB,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,EAAE,EAAE,KAAK,KAC5D;GACF,IAAI,IAAI;EAGsB,GAAG,SAAS,EAAE,OAAO,UAAU,MAAM,EAAE,CAAC;EAExE,IAAI,UAAU,SAAS,MAAM;OAE3B,KAAK,MAAM,KAAK,OAAO,SACrB,IAAI,CAAC,EAAE,MAAM,QAAQ,KAAK,mCAAmC,EAAE,QAAQ,KAAK,EAAE,QAAQ;EAG1F,OAAO,SAAS,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC,IAAI,SAAS,KAAK,EAAE,SAAS,KAAK,CAAC;CACvF;AACF"}
|
|
1
|
+
{"version":3,"file":"conversion.mjs","names":[],"sources":["../../src/routes/conversion.ts"],"sourcesContent":["/**\n * The conversion endpoint, as a factory.\n *\n * A site's contact form and its `tel:` beacon both mean the same thing — someone\n * got in touch — and both want the same treatment: read the attribution cookie\n * off the request, build the event, notify whoever the config says. That is the\n * same three lines in every project, so it ships here.\n *\n * What deliberately does **not** ship here is field-level validation and business\n * rules — a consumer's form has its own required fields and its own response\n * shape, and baking a guess at those in would make the factory something to\n * fight rather than mount. The handler takes the parsed body and an optional\n * `validate`; that part is the caller's. What **does** ship here is generic abuse\n * resistance (honeypot, origin, rate limit, body size): this is a public,\n * unauthenticated endpoint that sends email, so leaving it wide open makes it a\n * relay and a cost amplifier regardless of whose form is behind it.\n *\n * The path matters: the capture script beacons `tel:` conversions at\n * `TRACKING_ENDPOINT` (`/api/track`, exported from `@getvitops/astro/tracking`),\n * and the integration warns at build if no route answers it.\n *\n * ```ts\n * // src/pages/api/track.ts\n * export const prerender = false;\n * import { env } from 'cloudflare:workers';\n * import { createConversionRoute } from '@getvitops/astro/routes';\n *\n * export const POST = createConversionRoute({\n * context: {\n * // A bare address is shorthand for `{ provider: 'cloudflare', to }`.\n * notifications: { email: 'sales@acme.ca' },\n * canonical: 'https://acme.ca',\n * organizationName: 'Acme',\n * },\n * binding: () => env.EMAIL,\n * // A no-JS <form method=\"post\"> submission never sees a JSON response body —\n * // it's a full navigation, so send it somewhere real.\n * redirectTo: '/thanks',\n * });\n * ```\n *\n * Cloudflare Turnstile is a separate, opt-in layer on top of the checks above —\n * pass `turnstile: { secretKey }` and a submission carrying a\n * `cf-turnstile-response` token gets it verified server-side via\n * `verifyTurnstileToken` (`@getvitops/utils/security`). The widget itself is the\n * consumer's own markup (`<script src=\"https://challenges.cloudflare.com/\n * turnstile/v0/api.js\" async defer></script>` + `<div class=\"cf-turnstile\"\n * data-sitekey=\"…\">` on their form) — there is no `FormRenderer` for this to hook\n * into generically, matching how the rest of the form is already hand-written.\n * `site.security.turnstile.siteKey` is what the generated privacy policy reads to\n * name Turnstile as a processor; wiring this option up is what makes that true\n * rather than an assertion nothing checks.\n *\n * Two things this example used to get wrong, both of which cost a consumer real\n * time. It read the binding off `locals.runtime.env`, which Astro removed in v6\n * — and because the resulting throw precedes the response, Astro re-reads the\n * request and reports a misleading \"Body has already been used\" instead of the\n * actual error. This package peers on `astro >= 7`, so that form could not work\n * for any supported consumer; `import { env } from 'cloudflare:workers'` is the\n * current one. It also called a `toNotifyContext(config)` helper that does not\n * exist — `context` is a plain `NotifyContext`, built however you like.\n */\nimport { type EmailBinding, notify, type NotifyContext } from '@getvitops/utils/notify';\nimport { verifyTurnstileToken } from '@getvitops/utils/security';\nimport { parseTrackingCookie } from '@getvitops/utils/tracking';\nimport type { ConversionEvent } from '@getvitops/utils/tracking';\n\n/** The subset of Astro's `APIContext` this needs — typed structurally so the\n * factory does not pin an Astro version. */\nexport interface ConversionRequestContext {\n request: Request;\n locals?: unknown;\n}\n\n/**\n * Records a hit for `id` at time `at` (ms) and returns the ms since the previous\n * hit for that `id`, or `null` if this is the first. Pure interface — a store\n * decides nothing, it only remembers.\n */\nexport interface RateLimitStore {\n hit(id: string, at: number): Promise<number | null> | number | null;\n}\n\n/**\n * Per-isolate, in-memory default. Good enough for a single Worker instance\n * fielding occasional contact-form spam; not durable across a fleet, and not\n * shared with other routes. Inject a KV- or Durable-Object-backed `store` for\n * real cross-instance limiting — the interface is one method wide on purpose.\n */\nclass MemoryRateLimitStore implements RateLimitStore {\n private lastHit = new Map<string, number>();\n hit(id: string, at: number): number | null {\n const prev = this.lastHit.get(id);\n this.lastHit.set(id, at);\n return prev === undefined ? null : at - prev;\n }\n}\n\nexport interface ConversionRouteOptions {\n /** Where notifications go. Build it from your site config. */\n context: NotifyContext;\n /**\n * The Workers `send_email` binding for this request, e.g. `() => env.EMAIL`\n * with `env` imported from `cloudflare:workers`. Omit it in dev and the\n * notification is printed instead of sent.\n *\n * Not `(locals) => locals.runtime.env.EMAIL` — Astro removed `locals.runtime`\n * in v6, and this package peers on `>= 7`.\n */\n binding?: (locals: unknown) => EmailBinding | undefined;\n /**\n * Reject a submission before anything is sent. Return a message to refuse.\n * This is where a consumer puts required-field checks.\n */\n validate?: (body: Record<string, unknown>) => string | null;\n /** Called with every channel outcome, delivered or not. Defaults to console. */\n onResult?: (result: Awaited<ReturnType<typeof notify>>) => void;\n /** Injected in tests. */\n now?: () => number;\n /**\n * Where to send a no-JS `<form method=\"post\">` submission on success. Without\n * this, that visitor's browser navigates to the raw `{\"success\":true}` JSON\n * body as its next page — functional, but not a page anyone should see. Has\n * no effect on the beacon/fetch (JSON) path, which always gets a JSON response.\n */\n redirectTo?: string;\n /**\n * Where to send a refused no-JS submission (validation, spam checks, malformed\n * body). Falls back to `redirectTo` if omitted, and to the JSON error response\n * if neither is set.\n */\n errorRedirectTo?: string;\n /**\n * Field names treated as honeypots: a bot fills every input it finds, a person\n * never sees these (hidden by CSS in the form markup). A non-empty value is\n * silently treated as spam — the response looks identical to a real success,\n * denying the bot the signal it needs to know the trick worked, but nothing is\n * notified and the field never reaches `notify()`. Defaults to the same names\n * `render.ts`'s `OMIT_FIELDS` already knows about (`website`, `_honeypot`,\n * `honeypot`), so the two lists don't need to be kept in sync by hand.\n */\n honeypotFields?: string[];\n /**\n * Reject a submission whose `Content-Length` exceeds this many bytes. Default\n * 25_000 — generous for a contact form with no file upload, tiny for anything\n * meant to relay large payloads. Best-effort: `Content-Length` can be absent\n * or spoofed, same caveat as the origin check below.\n */\n maxBodyBytes?: number;\n /**\n * Reject a submission whose `Origin` header doesn't match this origin.\n * Defaults to `context.canonical`'s origin. Pass `false` to disable.\n *\n * Best-effort, not a CSRF guard: browsers omit `Origin` on plenty of\n * legitimate same-origin requests, so its *absence* is never treated as a\n * mismatch — only a present-and-different value is rejected. That still stops\n * the common case (a script hitting the endpoint directly from elsewhere)\n * without breaking real submissions from older or privacy-hardened browsers.\n */\n allowedOrigin?: string | false;\n /**\n * Minimum interval between accepted submissions from the same IP. Pass\n * `false` to disable. `windowMs` defaults to 10 seconds — enough to blunt a\n * script firing in a loop without troubling someone who fat-fingers a form\n * and resubmits.\n */\n rateLimit?: { windowMs?: number; store?: RateLimitStore } | false;\n /**\n * Verify a Cloudflare Turnstile token server-side. The **secret** key —\n * unlike `site.security.turnstile.siteKey`, which is public config — must come\n * from an env var, never a config file (the same rule as every other\n * credential in this toolchain).\n */\n turnstile?: {\n secretKey: string;\n /** The submitted field carrying the token. Default `cf-turnstile-response`\n * — the name Cloudflare's widget submits under. */\n fieldName?: string;\n /**\n * Reject a submission with no token at all, rather than only a present-and-\n * invalid one. Default `false`: an absent token is the ordinary shape of a\n * visitor with JS disabled, and this factory is built around that path\n * staying usable. Set `true` only for a form where verified-human matters\n * enough to accept that trade-off — doing so means that form no longer\n * works without JS, on purpose.\n */\n required?: boolean;\n };\n}\n\n/** Fields that are never part of a submission's meaning. `cf-turnstile-response`\n * is excluded unconditionally, matching `render.ts`'s `OMIT_FIELDS` — the field\n * carries a verification token, not something the visitor wrote. */\nconst CONTROL_FIELDS = new Set(['event', 'phone', 'cf-turnstile-response']);\nconst DEFAULT_HONEYPOT_FIELDS = ['website', '_honeypot', 'honeypot'];\nconst DEFAULT_MAX_BODY_BYTES = 25_000;\nconst DEFAULT_RATE_LIMIT_WINDOW_MS = 10_000;\n\nfunction toFormData(\n body: Record<string, unknown>,\n honeypotFields: string[],\n): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [key, value] of Object.entries(body)) {\n if (CONTROL_FIELDS.has(key) || honeypotFields.includes(key)) continue;\n if (value === undefined || value === null) continue;\n out[key] = String(value);\n }\n return out;\n}\n\nfunction hasHoneypot(body: Record<string, unknown>, honeypotFields: string[]): boolean {\n return honeypotFields.some((field) => {\n const value = body[field];\n return typeof value === 'string' && value.trim() !== '';\n });\n}\n\nfunction originOf(url: string | undefined): string | null {\n if (!url) return null;\n try {\n return new URL(url).origin;\n } catch {\n return null;\n }\n}\n\n/** A `Location` header response — used for the no-JS form path instead of\n * `Response.redirect()`, which requires a base URL this factory doesn't have. */\nfunction redirectTo(location: string, status = 303): Response {\n return new Response(null, { status, headers: { Location: location } });\n}\n\n/**\n * Build a POST handler for conversions.\n *\n * Returns 204 for a beacon and 200 with `{ success }` for a JSON (fetch-based)\n * form submission, matching what `navigator.sendBeacon` and a fetch-submitting\n * form each expect. A no-JS `<form method=\"post\">` — `application/\n * x-www-form-urlencoded` or `multipart/form-data` — is parsed the same way and,\n * on success, redirected to `redirectTo` if one is configured.\n *\n * **A failed notification does not fail the request.** The visitor has already\n * submitted; refusing them because our mail did not go out turns a lost\n * notification into a lost conversion. Failures are reported through `onResult`.\n */\nexport function createConversionRoute(options: ConversionRouteOptions) {\n const {\n context,\n binding,\n validate,\n onResult,\n now = Date.now,\n redirectTo: successRedirect,\n errorRedirectTo = successRedirect,\n honeypotFields = DEFAULT_HONEYPOT_FIELDS,\n maxBodyBytes = DEFAULT_MAX_BODY_BYTES,\n } = options;\n\n const allowedOrigin =\n options.allowedOrigin === false ? null : (options.allowedOrigin ?? originOf(context.canonical));\n\n const rateLimit =\n options.rateLimit === false\n ? null\n : {\n windowMs: options.rateLimit?.windowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS,\n store: options.rateLimit?.store ?? new MemoryRateLimitStore(),\n };\n\n return async ({ request, locals }: ConversionRequestContext): Promise<Response> => {\n const contentType = request.headers.get('content-type') ?? '';\n const isFormPost =\n contentType.includes('multipart/form-data') ||\n contentType.includes('application/x-www-form-urlencoded');\n\n const respondError = (error: string, status: number): Response =>\n isFormPost && errorRedirectTo\n ? redirectTo(errorRedirectTo)\n : Response.json({ success: false, error }, { status });\n\n const contentLength = Number(request.headers.get('content-length') ?? 0);\n if (contentLength > maxBodyBytes) {\n return respondError('Request too large.', 413);\n }\n\n if (allowedOrigin) {\n const requestOrigin = request.headers.get('origin');\n if (requestOrigin && requestOrigin !== allowedOrigin) {\n return respondError('Origin not allowed.', 403);\n }\n }\n\n let body: Record<string, unknown>;\n try {\n if (isFormPost) {\n const formData = await request.formData();\n body = {};\n for (const [key, value] of formData.entries()) {\n if (typeof value === 'string') body[key] = value;\n }\n } else {\n // Not just `application/json`: `navigator.sendBeacon` sends a bare\n // string as `text/plain`, and this must keep accepting that body.\n const parsed: unknown = await request.json();\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return respondError('Malformed request.', 400);\n }\n body = parsed as Record<string, unknown>;\n }\n } catch {\n return respondError('Malformed request.', 400);\n }\n\n const ip =\n request.headers.get('cf-connecting-ip') ??\n request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??\n undefined;\n\n if (rateLimit && ip) {\n const sinceLastHit = await rateLimit.store.hit(ip, now());\n if (sinceLastHit !== null && sinceLastHit < rateLimit.windowMs) {\n return respondError('Too many requests.', 429);\n }\n }\n\n const isCall = body['event'] === 'call';\n if (isCall && typeof body['phone'] !== 'string') {\n return respondError('Missing phone.', 400);\n }\n\n const spam = !isCall && hasHoneypot(body, honeypotFields);\n\n if (!isCall && !spam && options.turnstile) {\n const {\n secretKey,\n fieldName = 'cf-turnstile-response',\n required = false,\n } = options.turnstile;\n const token = body[fieldName];\n if (typeof token === 'string' && token) {\n const verification = await verifyTurnstileToken(token, secretKey, ip);\n if (!verification.success) return respondError('Verification failed.', 400);\n } else if (required) {\n return respondError('Verification required.', 400);\n }\n }\n\n const refusal = !spam && validate?.(body);\n if (refusal) return respondError(refusal, 400);\n\n const respondSuccess = (): Response =>\n isCall\n ? new Response(null, { status: 204 })\n : isFormPost && successRedirect\n ? redirectTo(successRedirect)\n : Response.json({ success: true });\n\n // A honeypot trip never reaches `notify()` — the response is identical to a\n // real success, so a bot gets no signal that it was caught.\n if (spam) {\n onResult?.({\n results: [{ channel: 'email', sent: false, reason: 'Skipped: honeypot triggered.' }],\n delivered: false,\n });\n return respondSuccess();\n }\n\n const turnstileFieldName = options.turnstile?.fieldName;\n const excludedFields = turnstileFieldName\n ? [...honeypotFields, turnstileFieldName]\n : honeypotFields;\n\n const event: ConversionEvent = {\n type: isCall ? 'call' : 'form',\n ...(isCall\n ? { phone: String(body['phone']).replace(/^tel:/, '') }\n : { formData: toFormData(body, excludedFields) }),\n tracking: parseTrackingCookie(request.headers.get('cookie')),\n userAgent: request.headers.get('user-agent') ?? undefined,\n ip,\n at: now(),\n };\n\n const result = await notify(event, context, { email: binding?.(locals) });\n\n if (onResult) onResult(result);\n else\n for (const r of result.results) {\n if (!r.sent) console.warn(`[vitops] notification not sent (${r.channel}): ${r.reason}`);\n }\n\n return respondSuccess();\n };\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getvitops/astro",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.0.1",
|
|
4
4
|
"description": "Astro integration for the Vitops design system — favicons/PWA, web-component scripts, and CSS, via a getvitops() integration + <Head /> component.",
|
|
5
|
+
"homepage": "https://docs.vitops.ca",
|
|
6
|
+
"bugs": "https://github.com/getvitops/design-system/issues",
|
|
5
7
|
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/getvitops/design-system.git",
|
|
11
|
+
"directory": "packages/astro"
|
|
12
|
+
},
|
|
6
13
|
"files": [
|
|
7
14
|
"dist",
|
|
8
15
|
"src/components/Head.astro",
|
|
@@ -37,7 +44,10 @@
|
|
|
37
44
|
"src/components/Tree.astro",
|
|
38
45
|
"src/components/TreeNode.astro",
|
|
39
46
|
"src/components/tree.ts",
|
|
40
|
-
"src/exports/tracking.ts"
|
|
47
|
+
"src/exports/tracking.ts",
|
|
48
|
+
"src/components/ReviewCard.astro",
|
|
49
|
+
"src/components/ReviewCardBody.astro",
|
|
50
|
+
"src/components/Marquee.astro"
|
|
41
51
|
],
|
|
42
52
|
"type": "module",
|
|
43
53
|
"types": "./dist/index.d.mts",
|
|
@@ -75,6 +85,8 @@
|
|
|
75
85
|
"./components/NavShellToggle.astro": "./src/components/NavShellToggle.astro",
|
|
76
86
|
"./components/Tree.astro": "./src/components/Tree.astro",
|
|
77
87
|
"./components/tree.ts": "./src/components/tree.ts",
|
|
88
|
+
"./components/ReviewCard.astro": "./src/components/ReviewCard.astro",
|
|
89
|
+
"./components/Marquee.astro": "./src/components/Marquee.astro",
|
|
78
90
|
"./schemas/*": "./src/schemas/*",
|
|
79
91
|
"./package.json": "./package.json"
|
|
80
92
|
},
|
|
@@ -82,17 +94,17 @@
|
|
|
82
94
|
"access": "public"
|
|
83
95
|
},
|
|
84
96
|
"dependencies": {
|
|
85
|
-
"@getvitops/core": "
|
|
86
|
-
"@getvitops/
|
|
87
|
-
"@getvitops/
|
|
88
|
-
"@getvitops/vite": "
|
|
97
|
+
"@getvitops/core": "8.0.1",
|
|
98
|
+
"@getvitops/generator": "8.0.1",
|
|
99
|
+
"@getvitops/utils": "8.0.1",
|
|
100
|
+
"@getvitops/vite": "8.0.1"
|
|
89
101
|
},
|
|
90
102
|
"devDependencies": {
|
|
91
103
|
"@astrojs/sitemap": "^3.7.3",
|
|
92
104
|
"@tailwindcss/vite": "^4.3.3",
|
|
93
105
|
"astro": "^7.1.2",
|
|
94
106
|
"tailwindcss": "^4.3.3",
|
|
95
|
-
"vitest": "
|
|
107
|
+
"vitest": "4.1.9"
|
|
96
108
|
},
|
|
97
109
|
"peerDependencies": {
|
|
98
110
|
"@astrojs/sitemap": ">=3.6",
|
|
@@ -114,5 +126,8 @@
|
|
|
114
126
|
"tailwindcss": {
|
|
115
127
|
"optional": true
|
|
116
128
|
}
|
|
129
|
+
},
|
|
130
|
+
"engines": {
|
|
131
|
+
"node": ">=24"
|
|
117
132
|
}
|
|
118
133
|
}
|