From c434b21ce0a3ba86048175edf6074dfb3beafe83 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 22 Sep 2026 07:34:34 -0500 Subject: [PATCH 1/2] Add email updates signup, a monthly donate button, and spam protection Email updates. A "Get updates by email" band sits above the footer on every page: inline, never a popup. It says plainly that the list carries new classes and events and the occasional organization update, and nothing else. Addresses go to a Cloudflare KV namespace via src/subscribe.js. The KV binding in wrangler.jsonc has no id on purpose. Wrangler 4.45+ creates the namespace on the first deploy and keeps it linked, and Workers Builds' default token has KV edit permission, so there is nothing to set up by hand. (It has no D1 permission, which is why this is KV rather than D1.) Tested end to end against wrangler dev with local KV and Cloudflare's test Turnstile secret: valid signups store and return to the page they came from; mixed-case duplicates collapse to one record; bad addresses, missing Turnstile tokens and off-site redirect targets are rejected; the honeypot pretends to succeed without storing; the sixth signup in an hour from one connection is refused; the CSV export 404s without its token. Spam protection. Turnstile now covers the signup as well as the contact form, both switched on by params.turnstileSiteKey. On the signup, Turnstile's script loads only when someone focuses the form, and a submit that beats the check is held and sent automatically once it passes. Donate. One-time and monthly are separate Givebutter widgets. Setting givebutterMonthlyWidgetId adds a labelled "Monthly gift" button beside the existing one; until it is set, the page is unchanged, so there is never a monthly button that quietly takes a one-time gift. Also removes "Choose monthly on the form", which pointed donors at an option the form does not have. Also gitignores .dev.vars, Wrangler's local secrets file. Co-Authored-By: Claude Opus 5 --- .gitignore | 1 + README.md | 28 ++++- assets/css/site.css | 22 ++++ content/donate.md | 2 - content/privacy.md | 4 +- hugo.toml | 6 +- layouts/_partials/updates-signup.html | 85 ++++++++++++++ layouts/_shortcodes/givebutter.html | 19 +++- layouts/baseof.html | 1 + src/index.js | 12 ++ src/subscribe.js | 153 ++++++++++++++++++++++++++ wrangler.jsonc | 5 + 12 files changed, 329 insertions(+), 9 deletions(-) create mode 100644 layouts/_partials/updates-signup.html create mode 100644 src/subscribe.js diff --git a/.gitignore b/.gitignore index 772d87a..627a3c0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules/ .DS_Store *.log .wrangler/ +.dev.vars diff --git a/README.md b/README.md index 6319fa6..9fbd0ec 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,35 @@ Drop an MP4 in `static/video/` and a poster still in `assets/img/`, then: Nothing loads until the visitor presses play (`preload="none"`), and the poster reserves the layout box. Re-mux phone footage with `ffmpeg -i in.mp4 -c copy -movflags +faststart out.mp4`. +## Email updates list + +The "Get updates by email" box sits above the footer on every page +(`layouts/_partials/updates-signup.html`). Submissions go to `src/subscribe.js` and are stored +in a Cloudflare KV namespace bound as `SUBSCRIBERS`. + +**No setup needed.** The binding in `wrangler.jsonc` deliberately has no id: Wrangler creates the +namespace on the first deploy and keeps it linked (automatic provisioning). This works in Workers +Builds because its default token has KV edit permission. It does *not* have D1 permission, so do +not switch this to a D1 database the same way. + +To read the list: Cloudflare dashboard, **Storage & Databases, KV**, open the namespace. Each key +is `sub:`. Deleting a key unsubscribes that person. Signing up twice updates one +record rather than creating a duplicate, and each connection is limited to five signups an hour. + +Optional: set `SUBSCRIBERS_EXPORT_TOKEN` as a secret to download the list as CSV from +`/api/subscribers?token=...`. Without it that address returns 404. + +## Donate buttons + +`/donate/` shows the one-time Givebutter widget (`givebutterWidgetId`). Set +`givebutterMonthlyWidgetId` to a second widget, whose campaign accepts recurring gifts, and a +"Monthly gift" button appears beside it. Both buttons are styled in the Givebutter dashboard. + ## Spam protection (Turnstile) -The contact form uses Turnstile when configured. **Set both halves or neither:** +The contact form and the email signup both use Turnstile when configured. **Set both halves or neither:** `params.turnstileSiteKey` in `hugo.toml`, and `TURNSTILE_SECRET` as a Worker secret. -If the secret is set but the site key has not deployed, the form renders no widget, sends no +If the secret is set but the site key has not deployed, the forms render no widget, send no token, and every submission is rejected. Deploy the site key first. ## After launch checklist diff --git a/assets/css/site.css b/assets/css/site.css index 37b8536..ad97488 100644 --- a/assets/css/site.css +++ b/assets/css/site.css @@ -211,6 +211,28 @@ ul.plain li{margin:0 0 .35em} .map-wrap iframe{width:100%;height:360px;border:0;border-radius:var(--r);background:#eee} .gb-embed{margin:1.2em 0} +/* Email updates band, above the footer on every page. Inline, never a popup. */ +.updates{background:#fff;border-top:1px solid var(--line);padding:32px 0} +.updates-inner{display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,440px);gap:20px 48px;align-items:start} +@media (max-width:760px){.updates-inner{grid-template-columns:1fr}} +.updates h2{font-size:1.25rem;margin:0 0 .3em} +.updates-copy p{margin:0;color:var(--muted);max-width:56ch} +.updates-form{margin:0} +.updates-row{display:flex;gap:8px} +.updates-row input{flex:1;min-width:0;font:inherit;padding:.55em .7em;border:1px solid #C9C2B8;border-radius:3px;background:#fff} +.updates-row input:focus{outline:2px solid var(--rust);outline-offset:1px;border-color:var(--rust)} +.updates-row .btn{flex:none} +.updates-ts{margin-top:10px} +.updates-ts:empty{display:none} +.updates-fine{margin:.6em 0 0;font-size:.85rem;color:var(--muted)} +.updates-msg{margin:0;padding:10px 14px;border:1px solid var(--line);border-left:4px solid var(--ok);border-radius:3px;background:#fff} +.updates-msg.warn{border-left-color:var(--warn)} + +/* Donate: one-time and monthly Givebutter buttons side by side */ +.gb-pair{display:flex;flex-wrap:wrap;gap:16px 32px;align-items:flex-start} +.gb-option{display:flex;flex-direction:column;gap:8px} +.gb-label{margin:0;font-family:var(--mono);font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)} + /* Footer */ .site-footer{background:var(--steel-deep);color:#D9D4CD;margin-top:0;padding:48px 0 24px;font-size:.95rem} .site-footer a{color:#fff} diff --git a/content/donate.md b/content/donate.md index 32c9412..e118c19 100644 --- a/content/donate.md +++ b/content/donate.md @@ -27,8 +27,6 @@ You'll receive an emailed receipt for every online gift. For gifts of $250 or mo {{< givebutter campaign="donate" >}} -Monthly gifts are especially helpful: they let us plan around predictable income. Choose "monthly" on the form. - ## Other ways to give - **By check:** payable to *Columbia Gadget Works*, mailed or dropped off at 1404 Grand Ave, Columbia, MO 65203. diff --git a/content/privacy.md b/content/privacy.md index de2143a..5f43e99 100644 --- a/content/privacy.md +++ b/content/privacy.md @@ -4,7 +4,7 @@ subtitle: Short, because we collect very little. description: Privacy policy for columbiagadgetworks.org. --- -*Last updated: September 20, 2026* +*Last updated: September 22, 2026* Columbia Gadget Works ("CGW", "we") operates columbiagadgetworks.org. This page explains what information the site collects and what we do with it. @@ -16,6 +16,8 @@ Columbia Gadget Works ("CGW", "we") operates columbiagadgetworks.org. This page **Contact form.** When you send a message through our [contact form](/contact/), we receive your name, email address, and message. It is delivered to a private channel on our volunteer Discord server so that the people who handle inquiries can respond. We use it only to reply to you, and we don't add you to any mailing list. +**Email updates.** If you sign up for email updates, we store the address you give us, the date, and the page you signed up from. We use it for one purpose: emailing you about new classes and events, and the occasional update about the organization. We never sell, rent, or share it. The list is held in Cloudflare storage and is readable only by the volunteers who send the updates. To unsubscribe, reply to any update or use the [contact form](/contact/), and you'll be removed. + **Email and phone.** If you email or call us, we keep the correspondence as long as needed to respond and for our records. ## Third-party services embedded on this site diff --git a/hugo.toml b/hugo.toml index b575d5f..530ab01 100644 --- a/hugo.toml +++ b/hugo.toml @@ -40,7 +40,8 @@ disableKinds = ["taxonomy", "term"] givebutterMembership = "https://givebutter.com/kxk2FA" givebutterDonate = "https://givebutter.com/v7RxV6" givebutterAccount = "T9BSo58XoxQgK1XH" # from the Givebutter embed script (acct=...) - givebutterWidgetId = "j9Mr6K" # the id from the tag in the same embed code; embed stays off until set + givebutterWidgetId = "j9Mr6K" # one-time gift button: the id from the tag in the embed code + givebutterMonthlyWidgetId = "" # monthly gift button: a second widget whose campaign accepts recurring gifts; shown only once set discord = "https://discord.gg/F7kM7ardMs" wiki = "https://wiki.comogadget.casa/" facebook = "https://www.facebook.com/columbiagadgetworks/" @@ -49,7 +50,8 @@ disableKinds = ["taxonomy", "term"] mapsQuery = "Columbia Gadget Works, 1404 Grand Ave, Columbia, MO 65203" calendarEmbedUrl = "" # optional: public Google Calendar embed URL; shown on /events/ when set contactEndpoint = "/api/contact" # handled by the Worker in src/index.js - turnstileSiteKey = "" # optional: Cloudflare Turnstile site key for the contact form + subscribeEndpoint = "/api/subscribe" # email updates signup, handled by the Worker in src/subscribe.js + turnstileSiteKey = "" # Cloudflare Turnstile site key: protects the contact form and the email signup. Deploy this BEFORE adding TURNSTILE_SECRET. [menus] [[menus.main]] diff --git a/layouts/_partials/updates-signup.html b/layouts/_partials/updates-signup.html new file mode 100644 index 0000000..50151e6 --- /dev/null +++ b/layouts/_partials/updates-signup.html @@ -0,0 +1,85 @@ +{{- /* Email updates signup. Sits above the footer on every page: inline, never a + popup. Turnstile's script is only fetched once someone focuses the form, + so pages that nobody signs up from pay nothing for it. */ -}} +{{- $p := site.Params -}} +
+
+
+

Get updates by email

+

We'll email you when new classes and events are announced, and with the occasional update about the organization. That's all we send.

+
+
+
+
+ + + +
+ + + {{ with $p.turnstileSiteKey }}
{{ end }} +

No spam, ever. We never share your address, and you can unsubscribe any time. Privacy

+
+ + +
+
+
+ diff --git a/layouts/_shortcodes/givebutter.html b/layouts/_shortcodes/givebutter.html index a793a1f..e3c1116 100644 --- a/layouts/_shortcodes/givebutter.html +++ b/layouts/_shortcodes/givebutter.html @@ -3,11 +3,26 @@ {{- $url := cond (eq $which "membership") $p.givebutterMembership $p.givebutterDonate -}} {{- $label := .Get "label" | default (cond (eq $which "membership") "Set up membership dues" "Donate now") -}} {{- if and $p.givebutterWidgetId $p.givebutterAccount (eq $which "donate") }} -
- +{{- /* One-time and monthly are separate Givebutter widgets, each styled in the + Givebutter dashboard. The monthly one only appears once its id is set, so + there is never a "monthly" button that quietly takes a one-time gift. */}} +
+
+ {{ with $p.givebutterMonthlyWidgetId }}

One-time gift

{{ end }} + +
+ {{- with $p.givebutterMonthlyWidgetId }} +
+

Monthly gift

+ +
+ {{- end }}
+{{- with $p.givebutterMonthlyWidgetId }} +

Monthly gifts are especially helpful: they let us plan around predictable income.

+{{- end }} {{- else }}

{{ $label }} Secure checkout via Givebutter. Card, bank, Apple Pay, Google Pay, Venmo, and PayPal accepted.

{{- end }} diff --git a/layouts/baseof.html b/layouts/baseof.html index 15663fe..980b1c1 100644 --- a/layouts/baseof.html +++ b/layouts/baseof.html @@ -9,6 +9,7 @@
{{ block "main" . }}{{ end }}
+{{ partial "updates-signup.html" . }} {{ partial "footer.html" . }} diff --git a/src/index.js b/src/index.js index ae38bab..723628f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,7 @@ // Cloudflare Worker entry point. Static files built by Hugo (public/) are served as assets; // only /api/* reaches this script. See wrangler.jsonc. import { handleContact } from './contact.js'; +import { handleSubscribe, handleExport } from './subscribe.js'; export default { async fetch(request, env) { @@ -9,6 +10,14 @@ export default { if (request.method !== 'POST') return new Response('POST only', { status: 405 }); return handleContact(request, env); } + if (pathname === '/api/subscribe') { + if (request.method !== 'POST') return new Response('POST only', { status: 405 }); + return handleSubscribe(request, env); + } + if (pathname === '/api/subscribers') { + if (request.method !== 'GET') return new Response('GET only', { status: 405 }); + return handleExport(request, env); + } if (pathname === '/api/health') { // Reports which secrets are present (names only, never values) so a misconfigured form is diagnosable. return Response.json({ @@ -16,6 +25,9 @@ export default { configured: { DISCORD_WEBHOOK_URL: Boolean(env.DISCORD_WEBHOOK_URL), TURNSTILE_SECRET: Boolean(env.TURNSTILE_SECRET), + SUBSCRIBERS: Boolean(env.SUBSCRIBERS), + SUBSCRIBERS_EXPORT_TOKEN: Boolean(env.SUBSCRIBERS_EXPORT_TOKEN), + DISCORD_SIGNUP_WEBHOOK_URL: Boolean(env.DISCORD_SIGNUP_WEBHOOK_URL), }, envKeys: Object.keys(env).filter(k => k !== 'ASSETS').sort(), }); diff --git a/src/subscribe.js b/src/subscribe.js new file mode 100644 index 0000000..c4ce573 --- /dev/null +++ b/src/subscribe.js @@ -0,0 +1,153 @@ +// Email updates signup, mounted by src/index.js (Cloudflare Worker): +// POST /api/subscribe add an address to the list +// GET /api/subscribers export the list as CSV (optional, see below) +// +// Bindings: +// SUBSCRIBERS (KV namespace) - declared in wrangler.jsonc WITHOUT an id, so +// Wrangler creates it automatically on the first deploy. Nothing +// to set up by hand. It appears under Storage & Databases -> KV. +// TURNSTILE_SECRET (secret, optional) - the same Turnstile widget as the +// contact form. Add it only AFTER params.turnstileSiteKey has +// deployed, or every signup is rejected. +// SUBSCRIBERS_EXPORT_TOKEN (secret, optional) - enables the CSV export. +// Without it the export returns 404. The addresses can always be +// read in the dashboard instead: each key is `sub:`. +// DISCORD_SIGNUP_WEBHOOK_URL (secret, optional) - posts each new signup to a +// Discord channel. + +const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; +const SIGNUPS_PER_HOUR = 5; + +export async function handleSubscribe(request, env) { + const ct = request.headers.get('content-type') || ''; + let data; + try { + if (ct.includes('application/json')) data = await request.json(); + else data = Object.fromEntries((await request.formData()).entries()); + } catch { + return done(request, '/', 'error', 400, 'Could not read the form'); + } + + const back = safePath(data.back); + const email = (data.email || '').toString().trim().slice(0, 200); + const honeypot = (data.website || '').toString(); + + if (honeypot) return done(request, back, 'ok', 200); // bot: pretend success + if (!EMAIL_RE.test(email)) return done(request, back, 'invalid', 400, 'Invalid email address'); + + if (env.TURNSTILE_SECRET) { + const v = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + secret: env.TURNSTILE_SECRET, + response: data['cf-turnstile-response'], + remoteip: request.headers.get('CF-Connecting-IP'), + }), + }).then(r => r.json()).catch(() => ({ success: false })); + if (!v.success) return done(request, back, 'captcha', 400, 'Captcha failed'); + } + + if (!env.SUBSCRIBERS) return done(request, back, 'error', 500, 'Mailing list is not configured'); + + const ip = request.headers.get('CF-Connecting-IP') || 'unknown'; + if (await isRateLimited(env.SUBSCRIBERS, ip)) return done(request, back, 'slow', 429, 'Too many signups'); + + // Keyed by lowercased email, so signing up twice updates one record. + const key = `sub:${email.toLowerCase()}`; + const existing = await env.SUBSCRIBERS.get(key, { type: 'json' }).catch(() => null); + const now = new Date().toISOString(); + const record = { + email, + subscribed: (existing && existing.subscribed) || now, + updated: now, + page: back, + country: request.headers.get('CF-IPCountry') || '', + }; + await env.SUBSCRIBERS.put(key, JSON.stringify(record)); + if (!existing) await ping(env, record); + + return done(request, back, 'ok', 200); +} + +export async function handleExport(request, env) { + if (!env.SUBSCRIBERS_EXPORT_TOKEN || !env.SUBSCRIBERS) return new Response('Not found', { status: 404 }); + const given = new URL(request.url).searchParams.get('token') || bearer(request); + if (!timingSafeEqual(given || '', env.SUBSCRIBERS_EXPORT_TOKEN)) return new Response('Not found', { status: 404 }); + + const rows = []; + let cursor; + do { + const page = await env.SUBSCRIBERS.list({ prefix: 'sub:', cursor }); + for (const k of page.keys) { + const rec = await env.SUBSCRIBERS.get(k.name, { type: 'json' }).catch(() => null); + if (rec) rows.push(rec); + } + cursor = page.list_complete ? null : page.cursor; + } while (cursor); + + rows.sort((a, b) => (a.subscribed || '').localeCompare(b.subscribed || '')); + const csv = [ + 'email,subscribed,updated,page,country', + ...rows.map(r => [r.email, r.subscribed, r.updated, r.page, r.country].map(cell).join(',')), + ].join('\n') + '\n'; + return new Response(csv, { + headers: { + 'content-type': 'text/csv; charset=utf-8', + 'content-disposition': 'attachment; filename="cgw-subscribers.csv"', + 'cache-control': 'no-store', + 'x-robots-tag': 'noindex, nofollow', + }, + }); +} + +async function isRateLimited(kv, ip) { + const key = `rl:${ip}`; + const n = parseInt((await kv.get(key)) || '0', 10); + if (n >= SIGNUPS_PER_HOUR) return true; + await kv.put(key, String(n + 1), { expirationTtl: 3600 }); + return false; +} + +async function ping(env, record) { + if (!env.DISCORD_SIGNUP_WEBHOOK_URL) return; + await fetch(env.DISCORD_SIGNUP_WEBHOOK_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + username: 'Email updates', + embeds: [{ title: 'New signup', color: 0xBF4D28, fields: [{ name: 'Address', value: record.email }], timestamp: record.subscribed }], + }), + }).catch(() => {}); // a failed ping must not fail the signup +} + +function bearer(request) { + const h = request.headers.get('authorization') || ''; + return h.startsWith('Bearer ') ? h.slice(7) : ''; +} + +function cell(v) { + const s = v === null || v === undefined ? '' : v.toString(); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +function timingSafeEqual(a, b) { + const enc = new TextEncoder(); + const x = enc.encode(a); + const y = enc.encode(b); + let diff = x.length ^ y.length; + for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] || 0) ^ (y[i] || 0); + return diff === 0; +} + +// Only same-site paths, so the redirect cannot be pointed at another domain. +function safePath(v) { + const s = (v || '/').toString().split(/[?#]/)[0]; + return s.startsWith('/') && !s.startsWith('//') ? s : '/'; +} + +function done(request, back, status, code, error) { + const wantsJson = (request.headers.get('accept') || '').includes('application/json'); + if (wantsJson) return Response.json(error ? { ok: false, error } : { ok: true }, { status: code }); + return Response.redirect(new URL(`${back}?updates=${status}#updates`, request.url).toString(), 303); +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 0c2cad4..752454c 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -12,6 +12,11 @@ "html_handling": "auto-trailing-slash", "not_found_handling": "404-page" }, + // Email updates list. No id on purpose: Wrangler creates the namespace on the + // first deploy and keeps it linked afterwards (automatic provisioning, wrangler + // 4.45+). Workers Builds' default token has KV edit permission, so this needs no + // manual setup. Do not add a D1 database the same way: that token has no D1 access. + "kv_namespaces": [{ "binding": "SUBSCRIBERS" }], // Do not delete variables/secrets that were set in the dashboard on each deploy. "keep_vars": true, "observability": { "enabled": true } From bd6744e06cf60d732ce6e1266f0a60120280eb26 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 22 Sep 2026 07:40:53 -0500 Subject: [PATCH 2/2] Turn on Turnstile for both forms and verify action and hostname Sets the Turnstile site key, so the widget now appears on the contact form and the email signup. The secret is not in the repo: it goes into the Worker as TURNSTILE_SECRET once this has deployed. Server-side verification moves into src/turnstile.js, shared by both handlers, and now checks more than `success`. Each widget carries an action (contact, subscribe) and the server requires the token's action to match the form and its hostname to match the site, so a token minted for the other form or on another domain is refused. The handlers are otherwise unchanged: the check gates them rather than replacing anything. Cloudflare's published test secret answers for a dummy host with no action and flags the response, so flagged test responses skip those two checks; the real secret never sets the flag. Tested: unit tests against every siteverify outcome (genuine, wrong action, wrong host, replayed, forged, network failure, missing token, no secret), all passing; and end to end through wrangler dev against Cloudflare's live siteverify with the always-pass and always-fail test secrets. Donate: the Givebutter form now offers one-time, monthly and yearly gifts, so the second-widget mechanism is removed and the "choose Monthly" guidance is restored, since it is true again. Co-Authored-By: Claude Opus 5 --- README.md | 27 +++++++++++++------ assets/css/site.css | 5 ---- content/donate.md | 2 ++ content/privacy.md | 2 +- hugo.toml | 5 ++-- layouts/_partials/updates-signup.html | 1 + layouts/_shortcodes/contact-form.html | 2 +- layouts/_shortcodes/givebutter.html | 19 ++----------- src/contact.js | 14 +++------- src/subscribe.js | 16 +++-------- src/turnstile.js | 39 +++++++++++++++++++++++++++ 11 files changed, 75 insertions(+), 57 deletions(-) create mode 100644 src/turnstile.js diff --git a/README.md b/README.md index 9fbd0ec..08f1ce1 100644 --- a/README.md +++ b/README.md @@ -76,18 +76,29 @@ record rather than creating a duplicate, and each connection is limited to five Optional: set `SUBSCRIBERS_EXPORT_TOKEN` as a secret to download the list as CSV from `/api/subscribers?token=...`. Without it that address returns 404. -## Donate buttons +## Donate button -`/donate/` shows the one-time Givebutter widget (`givebutterWidgetId`). Set -`givebutterMonthlyWidgetId` to a second widget, whose campaign accepts recurring gifts, and a -"Monthly gift" button appears beside it. Both buttons are styled in the Givebutter dashboard. +`/donate/` embeds one Givebutter widget (`givebutterWidgetId`). Its form offers one-time, monthly +and yearly gifts; that choice, and the button's look, are both set in the Givebutter dashboard. ## Spam protection (Turnstile) -The contact form and the email signup both use Turnstile when configured. **Set both halves or neither:** -`params.turnstileSiteKey` in `hugo.toml`, and `TURNSTILE_SECRET` as a Worker secret. -If the secret is set but the site key has not deployed, the forms render no widget, send no -token, and every submission is rejected. Deploy the site key first. +The contact form and the email signup both use Cloudflare Turnstile. The site key is set in +`hugo.toml` (`turnstileSiteKey`); it is public and safe in git. The secret lives only in the +Worker as `TURNSTILE_SECRET` (Workers & Pages, website, Settings, Variables and Secrets, type +Secret). Never commit it. + +`src/turnstile.js` does the server-side check for both forms. It requires `success`, and also that +the token's `action` matches the form (`contact` or `subscribe`) and its `hostname` matches the site, +so a token minted for one form or another domain is refused. + +**Rollout order matters.** With no secret set, the check is skipped. If the secret is added while +the live pages have no widget, every submission is rejected for lacking a token. So the site key +must be deployed first, then the secret added. + +For local testing with `wrangler dev`, put Cloudflare's published always-pass test secret +(`1x0000000000000000000000000000000AA`) in `.dev.vars` (gitignored). Test tokens skip the action +and hostname checks, since Cloudflare answers them for a dummy host; the real secret never does. ## After launch checklist diff --git a/assets/css/site.css b/assets/css/site.css index ad97488..6c63000 100644 --- a/assets/css/site.css +++ b/assets/css/site.css @@ -228,11 +228,6 @@ ul.plain li{margin:0 0 .35em} .updates-msg{margin:0;padding:10px 14px;border:1px solid var(--line);border-left:4px solid var(--ok);border-radius:3px;background:#fff} .updates-msg.warn{border-left-color:var(--warn)} -/* Donate: one-time and monthly Givebutter buttons side by side */ -.gb-pair{display:flex;flex-wrap:wrap;gap:16px 32px;align-items:flex-start} -.gb-option{display:flex;flex-direction:column;gap:8px} -.gb-label{margin:0;font-family:var(--mono);font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)} - /* Footer */ .site-footer{background:var(--steel-deep);color:#D9D4CD;margin-top:0;padding:48px 0 24px;font-size:.95rem} .site-footer a{color:#fff} diff --git a/content/donate.md b/content/donate.md index e118c19..d8516a7 100644 --- a/content/donate.md +++ b/content/donate.md @@ -27,6 +27,8 @@ You'll receive an emailed receipt for every online gift. For gifts of $250 or mo {{< givebutter campaign="donate" >}} +Monthly gifts are especially helpful: they let us plan around predictable income. Choose **Monthly** on the form, or **Yearly** if you prefer to give once a year. + ## Other ways to give - **By check:** payable to *Columbia Gadget Works*, mailed or dropped off at 1404 Grand Ave, Columbia, MO 65203. diff --git a/content/privacy.md b/content/privacy.md index 5f43e99..a85afd4 100644 --- a/content/privacy.md +++ b/content/privacy.md @@ -26,7 +26,7 @@ Some pages include content from other services. When you interact with them, tho - **Givebutter** processes donations and membership dues. We receive your name, email, and gift details so we can send a receipt and acknowledge your gift. We never see your full card number. See [Givebutter's privacy policy](https://givebutter.com/privacy). - **Google Maps** provides the embedded map on the Visit and Contact pages. See [Google's privacy policy](https://policies.google.com/privacy). -- **Cloudflare Turnstile**, if enabled, checks that form submissions aren't automated. It sets no tracking cookies. +- **Cloudflare Turnstile** checks that submissions to the contact form and the email signup aren't automated. It sets no tracking cookies. See [Cloudflare's privacy policy](https://www.cloudflare.com/privacypolicy/). We don't share or sell personal information to anyone. diff --git a/hugo.toml b/hugo.toml index 530ab01..55ac622 100644 --- a/hugo.toml +++ b/hugo.toml @@ -40,8 +40,7 @@ disableKinds = ["taxonomy", "term"] givebutterMembership = "https://givebutter.com/kxk2FA" givebutterDonate = "https://givebutter.com/v7RxV6" givebutterAccount = "T9BSo58XoxQgK1XH" # from the Givebutter embed script (acct=...) - givebutterWidgetId = "j9Mr6K" # one-time gift button: the id from the tag in the embed code - givebutterMonthlyWidgetId = "" # monthly gift button: a second widget whose campaign accepts recurring gifts; shown only once set + givebutterWidgetId = "j9Mr6K" # the id from the tag; its form offers one-time, monthly and yearly gifts discord = "https://discord.gg/F7kM7ardMs" wiki = "https://wiki.comogadget.casa/" facebook = "https://www.facebook.com/columbiagadgetworks/" @@ -51,7 +50,7 @@ disableKinds = ["taxonomy", "term"] calendarEmbedUrl = "" # optional: public Google Calendar embed URL; shown on /events/ when set contactEndpoint = "/api/contact" # handled by the Worker in src/index.js subscribeEndpoint = "/api/subscribe" # email updates signup, handled by the Worker in src/subscribe.js - turnstileSiteKey = "" # Cloudflare Turnstile site key: protects the contact form and the email signup. Deploy this BEFORE adding TURNSTILE_SECRET. + turnstileSiteKey = "0x4AAAAAAE_62c1QS97IgYEj" # Cloudflare Turnstile site key (public): protects the contact form and the email signup. Must be live BEFORE TURNSTILE_SECRET is added. [menus] [[menus.main]] diff --git a/layouts/_partials/updates-signup.html b/layouts/_partials/updates-signup.html index 50151e6..3091317 100644 --- a/layouts/_partials/updates-signup.html +++ b/layouts/_partials/updates-signup.html @@ -55,6 +55,7 @@

Get updates by email

rendered = true; window.turnstile.render(box, { sitekey: box.dataset.sitekey, + action: 'subscribe', callback: function () { if (pending) form.submit(); } }); } diff --git a/layouts/_shortcodes/contact-form.html b/layouts/_shortcodes/contact-form.html index 0ee6248..99a78f2 100644 --- a/layouts/_shortcodes/contact-form.html +++ b/layouts/_shortcodes/contact-form.html @@ -12,7 +12,7 @@
- {{ with $p.turnstileSiteKey }}
{{ end }} + {{ with $p.turnstileSiteKey }}
{{ end }}

Or email {{ $p.email }} directly.

diff --git a/layouts/_shortcodes/givebutter.html b/layouts/_shortcodes/givebutter.html index e3c1116..a793a1f 100644 --- a/layouts/_shortcodes/givebutter.html +++ b/layouts/_shortcodes/givebutter.html @@ -3,26 +3,11 @@ {{- $url := cond (eq $which "membership") $p.givebutterMembership $p.givebutterDonate -}} {{- $label := .Get "label" | default (cond (eq $which "membership") "Set up membership dues" "Donate now") -}} {{- if and $p.givebutterWidgetId $p.givebutterAccount (eq $which "donate") }} -{{- /* One-time and monthly are separate Givebutter widgets, each styled in the - Givebutter dashboard. The monthly one only appears once its id is set, so - there is never a "monthly" button that quietly takes a one-time gift. */}} -
-
- {{ with $p.givebutterMonthlyWidgetId }}

One-time gift

{{ end }} - -
- {{- with $p.givebutterMonthlyWidgetId }} -
-

Monthly gift

- -
- {{- end }} + -{{- with $p.givebutterMonthlyWidgetId }} -

Monthly gifts are especially helpful: they let us plan around predictable income.

-{{- end }} {{- else }}

{{ $label }} Secure checkout via Givebutter. Card, bank, Apple Pay, Google Pay, Venmo, and PayPal accepted.

{{- end }} diff --git a/src/contact.js b/src/contact.js index 2fca15c..8737387 100644 --- a/src/contact.js +++ b/src/contact.js @@ -8,6 +8,8 @@ // Rollout order matters: deploy the site key in hugo.toml FIRST, then add TURNSTILE_SECRET. // If the secret exists but the page has no widget, no token is sent and every submission is // rejected with ?error=captcha (the page now shows that error, but nothing gets delivered). +import { verifyTurnstile } from './turnstile.js'; + export async function handleContact(request, env) { const ct = request.headers.get('content-type') || ''; let data; @@ -25,16 +27,8 @@ export async function handleContact(request, env) { if (!name || !email || !message || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return done(request, '/contact/?error=1', 400, 'Missing or invalid fields'); - if (env.TURNSTILE_SECRET) { - const token = data['cf-turnstile-response']; - const ip = request.headers.get('CF-Connecting-IP'); - const v = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ secret: env.TURNSTILE_SECRET, response: token, remoteip: ip }), - }).then(r => r.json()).catch(() => ({ success: false })); - if (!v.success) return done(request, '/contact/?error=captcha', 400, 'Captcha failed'); - } + const ts = await verifyTurnstile(request, env, data['cf-turnstile-response'], 'contact'); + if (!ts.ok) return done(request, '/contact/?error=captcha', 400, 'Captcha failed'); if (!env.DISCORD_WEBHOOK_URL) return done(request, '/contact/?error=config', 500, 'Form not configured'); diff --git a/src/subscribe.js b/src/subscribe.js index c4ce573..1c29af2 100644 --- a/src/subscribe.js +++ b/src/subscribe.js @@ -15,6 +15,8 @@ // DISCORD_SIGNUP_WEBHOOK_URL (secret, optional) - posts each new signup to a // Discord channel. +import { verifyTurnstile } from './turnstile.js'; + const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; const SIGNUPS_PER_HOUR = 5; @@ -35,18 +37,8 @@ export async function handleSubscribe(request, env) { if (honeypot) return done(request, back, 'ok', 200); // bot: pretend success if (!EMAIL_RE.test(email)) return done(request, back, 'invalid', 400, 'Invalid email address'); - if (env.TURNSTILE_SECRET) { - const v = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - secret: env.TURNSTILE_SECRET, - response: data['cf-turnstile-response'], - remoteip: request.headers.get('CF-Connecting-IP'), - }), - }).then(r => r.json()).catch(() => ({ success: false })); - if (!v.success) return done(request, back, 'captcha', 400, 'Captcha failed'); - } + const ts = await verifyTurnstile(request, env, data['cf-turnstile-response'], 'subscribe'); + if (!ts.ok) return done(request, back, 'captcha', 400, 'Captcha failed'); if (!env.SUBSCRIBERS) return done(request, back, 'error', 500, 'Mailing list is not configured'); diff --git a/src/turnstile.js b/src/turnstile.js new file mode 100644 index 0000000..abebf88 --- /dev/null +++ b/src/turnstile.js @@ -0,0 +1,39 @@ +// Turnstile server-side verification, shared by the contact form and the email +// signup. Checking `success` alone would accept a token minted for a different +// form or on a different site, so the action and hostname are checked too. +// +// TURNSTILE_SECRET is read from the Worker's secrets. With no secret set the +// check is skipped, so the order of rollout matters: the site key in hugo.toml +// must be live (widget on the page) BEFORE the secret is added, or every +// submission is rejected for lacking a token. + +export async function verifyTurnstile(request, env, token, action) { + if (!env.TURNSTILE_SECRET) return { ok: true, skipped: true }; + if (!token) return { ok: false, codes: ['missing-input-response'] }; + + let v; + try { + const r = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + secret: env.TURNSTILE_SECRET, + response: token, + remoteip: request.headers.get('CF-Connecting-IP') || undefined, + }), + }); + v = await r.json(); + } catch { + return { ok: false, codes: ['siteverify-unreachable'] }; // fail closed + } + + if (!v.success) return { ok: false, codes: v['error-codes'] || [] }; + + // Cloudflare's published test secrets answer for a dummy hostname with no + // action, and flag it. The real secret never sets this flag. + if (v.metadata && v.metadata.result_with_testing_key) return { ok: true, test: true }; + + if (v.action !== action) return { ok: false, codes: ['action-mismatch'] }; + if (v.hostname !== new URL(request.url).hostname) return { ok: false, codes: ['hostname-mismatch'] }; + return { ok: true }; +}