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..08f1ce1 100644 --- a/README.md +++ b/README.md @@ -58,12 +58,47 @@ 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 button + +`/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 uses 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 -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 37b8536..6c63000 100644 --- a/assets/css/site.css +++ b/assets/css/site.css @@ -211,6 +211,23 @@ 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)} + /* 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..d8516a7 100644 --- a/content/donate.md +++ b/content/donate.md @@ -27,7 +27,7 @@ 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. +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 diff --git a/content/privacy.md b/content/privacy.md index de2143a..a85afd4 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 @@ -24,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 b575d5f..55ac622 100644 --- a/hugo.toml +++ b/hugo.toml @@ -40,7 +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" # the id from the tag in the same embed code; embed stays off until 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/" @@ -49,7 +49,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 = "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 new file mode 100644 index 0000000..3091317 --- /dev/null +++ b/layouts/_partials/updates-signup.html @@ -0,0 +1,86 @@ +{{- /* 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/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/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/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/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..1c29af2 --- /dev/null +++ b/src/subscribe.js @@ -0,0 +1,145 @@ +// 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. + +import { verifyTurnstile } from './turnstile.js'; + +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'); + + 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'); + + 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/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 }; +} 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 }