diff --git a/.gitignore b/.gitignore index dcb846d..11b0186 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,7 @@ node_modules/ dist/ .wrangler/ .dev.vars* +.env.local +.env.*.local .vscode/ **.env diff --git a/README.md b/README.md index 29f8bac..68febd5 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,30 @@ Images and other local assets belong beside the Markdown that uses them. Past-te The attached classroom image used on the home and About pages is `public/assets/images/about-classroom.png`. Sponsor marks and student portraits are also local files under `public/assets/images/`. +## Mailing list + +The signup forms submit to the same-origin `POST /api/subscribe` Worker endpoint. The Worker validates the request, verifies the Turnstile token, and idempotently stores a normalized email in the D1 `subscribers` table. It does not expose a subscriber list or send bulk email. Successful signups receive a signed `/unsubscribe` link. + +One-time Cloudflare setup: + +```sh +npx wrangler d1 create simc-mailing-list +# Copy the returned database_id into wrangler.jsonc. +npx wrangler d1 migrations apply simc-mailing-list --remote +npx wrangler secret put TURNSTILE_SECRET +npx wrangler secret put UNSUBSCRIBE_SECRET +``` + +Set `VITE_TURNSTILE_SITE_KEY` as a public build-time variable in the Cloudflare Workers Build settings. For local development, put the matching local/test sitekey in the ignored `.env.local` file and set `TURNSTILE_SECRET` plus `UNSUBSCRIBE_SECRET` in the ignored `.dev.vars` file. Then run: + +```sh +npm run build +npx wrangler d1 migrations apply simc-mailing-list --local +npx wrangler dev +``` + +Run the remote migration command before deploying any future migration. The current Cloudflare Workers Build command builds and deploys the Worker, but does not apply D1 migrations automatically. + ## Deployment Cloudflare Workers Builds watches the production `main` branch and runs `npm run build` followed by `npx wrangler deploy` on every push. diff --git a/index.html b/index.html index a08a60e..4fd928e 100644 --- a/index.html +++ b/index.html @@ -7,12 +7,14 @@ + Seattle Infinity Math Circle
+ diff --git a/migrations/0001_create_subscribers.sql b/migrations/0001_create_subscribers.sql new file mode 100644 index 0000000..18960b0 --- /dev/null +++ b/migrations/0001_create_subscribers.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS subscribers ( + id TEXT PRIMARY KEY NOT NULL, + email TEXT NOT NULL UNIQUE, + subscribed INTEGER NOT NULL DEFAULT 1 CHECK (subscribed IN (0, 1)), + unsubscribe_token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/src/components/mailing-list-form.tsx b/src/components/mailing-list-form.tsx new file mode 100644 index 0000000..ec9417a --- /dev/null +++ b/src/components/mailing-list-form.tsx @@ -0,0 +1,167 @@ +import { useCallback, useEffect, useRef, useState, type FormEvent, type RefObject } from 'react'; + +const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY?.trim(); + +type SubscriptionStatus = 'idle' | 'submitting' | 'success' | 'error'; + +export interface MailingListSubscriptionState { + email: string; + onEmailChange: (value: string) => void; + status: SubscriptionStatus; + message: string; + unsubscribeUrl: string | null; + isSubmitting: boolean; + turnstileContainerRef: RefObject; + submit: (event: FormEvent) => Promise; +} + +function useTurnstile(siteKey: string | undefined) { + const containerRef = useRef(null); + const widgetIdRef = useRef(null); + const [token, setToken] = useState(''); + + useEffect(() => { + if (!siteKey) return undefined; + + let cancelled = false; + let pollId: number | undefined; + let timeoutId: number | undefined; + + const renderWidget = () => { + const turnstile = window.turnstile; + if (cancelled || widgetIdRef.current !== null || !containerRef.current || !turnstile) { + return widgetIdRef.current !== null; + } + + widgetIdRef.current = turnstile.render(containerRef.current, { + sitekey: siteKey, + appearance: 'interaction-only', + callback: (nextToken) => { + if (!cancelled) setToken(nextToken); + }, + 'expired-callback': () => { + if (!cancelled) setToken(''); + }, + 'error-callback': () => { + if (!cancelled) setToken(''); + }, + }); + return true; + }; + + if (!renderWidget()) { + pollId = window.setInterval(() => { + if (renderWidget() && pollId !== undefined) window.clearInterval(pollId); + }, 50); + timeoutId = window.setTimeout(() => { + if (pollId !== undefined) window.clearInterval(pollId); + }, 10000); + } + + return () => { + cancelled = true; + if (pollId !== undefined) window.clearInterval(pollId); + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + if (widgetIdRef.current !== null && window.turnstile) window.turnstile.remove(widgetIdRef.current); + widgetIdRef.current = null; + }; + }, [siteKey]); + + const reset = useCallback(() => { + setToken(''); + if (widgetIdRef.current !== null && window.turnstile) window.turnstile.reset(widgetIdRef.current); + }, []); + + return { containerRef, token, reset }; +} + +export function useMailingListSubscription(): MailingListSubscriptionState { + const [email, setEmail] = useState(''); + const [status, setStatus] = useState('idle'); + const [message, setMessage] = useState(''); + const [unsubscribeUrl, setUnsubscribeUrl] = useState(null); + const turnstile = useTurnstile(TURNSTILE_SITE_KEY); + + const onEmailChange = useCallback((value: string) => { + setEmail(value); + setStatus('idle'); + setMessage(''); + setUnsubscribeUrl(null); + }, []); + + const submit = useCallback(async (event: FormEvent) => { + event.preventDefault(); + if (status === 'submitting') return; + + const form = event.currentTarget; + if (!form.reportValidity()) return; + + if (!TURNSTILE_SITE_KEY) { + setStatus('error'); + setMessage('The mailing list is not configured yet.'); + return; + } + + if (!turnstile.token) { + setStatus('error'); + setMessage('Please complete the security check and try again.'); + return; + } + + setStatus('submitting'); + setMessage(''); + setUnsubscribeUrl(null); + + try { + const response = await fetch('/api/subscribe', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: email.trim().toLowerCase(), turnstileToken: turnstile.token }), + }); + const result = await response.json().catch(() => null); + + if (!response.ok || result?.ok !== true) { + if (response.status === 503) throw new Error('The mailing list is temporarily unavailable. Please try again later.'); + throw new Error('We could not add you to the mailing list. Please try again.'); + } + + setEmail(''); + setStatus('success'); + setMessage("You're on the list."); + setUnsubscribeUrl(typeof result.unsubscribeUrl === 'string' ? result.unsubscribeUrl : null); + } catch (error) { + setStatus('error'); + setMessage(error instanceof Error ? error.message : 'We could not add you to the mailing list. Please try again.'); + } finally { + turnstile.reset(); + } + }, [email, status, turnstile.reset, turnstile.token]); + + return { + email, + onEmailChange, + status, + message, + unsubscribeUrl, + isSubmitting: status === 'submitting', + turnstileContainerRef: turnstile.containerRef, + submit, + }; +} + +export function MailingListFeedback({ + state, + statusId, +}: { + state: MailingListSubscriptionState; + statusId: string; +}) { + if (!state.message) return null; + + return ( +

+ {state.message} + {state.unsubscribeUrl && <>{' '}Unsubscribe} +

+ ); +} diff --git a/src/components/site-shell.tsx b/src/components/site-shell.tsx index c040cea..b19d5e2 100644 --- a/src/components/site-shell.tsx +++ b/src/components/site-shell.tsx @@ -1,5 +1,6 @@ -import { useState, type ButtonHTMLAttributes, type ReactNode } from 'react'; +import { type ButtonHTMLAttributes, type ReactNode } from 'react'; import { Link } from 'react-router-dom'; +import { MailingListFeedback, useMailingListSubscription } from './mailing-list-form'; import { DISCORD_URL, INSTAGRAM_URL, SPONSORS } from '../data/site'; type ButtonTone = 'primary' | 'light' | 'outline'; @@ -11,13 +12,14 @@ export interface ButtonProps { external?: boolean; type?: ButtonHTMLAttributes['type']; form?: string; + disabled?: boolean; } -export function Button({ href, children, tone = 'primary', external = false, type = 'button', form }: ButtonProps) { +export function Button({ href, children, tone = 'primary', external = false, type = 'button', form, disabled = false }: ButtonProps) { const className = `button button-${tone}`; if (external) return {children}; if (href) return {children}; - return ; + return ; } function Logo({ full = false, inverse = false }: { full?: boolean; inverse?: boolean }) { @@ -45,8 +47,7 @@ function Header() { } export function SignupBanner() { - const [email, setEmail] = useState(''); - const [submitted, setSubmitted] = useState(false); + const subscription = useMailingListSubscription(); return (
@@ -54,11 +55,15 @@ export function SignupBanner() {

Sign up for our mailing list so you don't miss out!

); diff --git a/src/data/site.ts b/src/data/site.ts index b9d7938..2027e58 100644 --- a/src/data/site.ts +++ b/src/data/site.ts @@ -3,7 +3,6 @@ import { EVENT_CONTENT, type ContentRecord } from '../content'; export const EMAIL = 'seattleinfinitymathcircle@gmail.com'; export const DISCORD_URL = 'https://discord.gg/wwyZnWB2tw'; -export const MAILING_LIST_URL = 'https://forms.gle/FDvWGo1FHqQSGkNK6'; export const INSTAGRAM_URL = 'https://www.instagram.com/seattleinfinitymathcircle/'; export const CALENDAR_EMBED_URL = 'https://calendar.google.com/calendar/embed?height=600&wkst=1&bgcolor=%23ffffff&ctz=America%2FLos_Angeles&showTitle=1&showCalendars=1&mode=AGENDA&src=YTkxNzhhNzU4ZGRjYjhmM2FjM2ZmNGQ1MWQ2OGNiNTcwNjdmMDUyODljZTc4YmUyNDliMDM0MWJhNmQyYzY3MUBncm91cC5jYWxlbmRhci5nb29nbGUuY29t&color=%23C0CA33'; diff --git a/src/pages/static-pages.tsx b/src/pages/static-pages.tsx index 41d5e3f..fc35fcb 100644 --- a/src/pages/static-pages.tsx +++ b/src/pages/static-pages.tsx @@ -1,10 +1,10 @@ -import { useState } from 'react'; import { PersonCard } from '../components/content-card'; +import { MailingListFeedback, useMailingListSubscription } from '../components/mailing-list-form'; import { MarkdownBody } from '../components/markdown-content'; import { Intro } from '../components/page-primitives'; import { Button, SignupBanner } from '../components/site-shell'; import { PAGE_CONTENT_BY_SLUG, type ContentRecord } from '../content'; -import { CALENDAR_EMBED_URL, DISCORD_URL, EMAIL, MAILING_LIST_URL, PEOPLE } from '../data/site'; +import { CALENDAR_EMBED_URL, DISCORD_URL, EMAIL, PEOPLE } from '../data/site'; interface MarkdownPageProps { record?: ContentRecord; @@ -45,17 +45,18 @@ export function PotmPage() { } function ContactMailingForm() { - const [email, setEmail] = useState(''); - const [submitted, setSubmitted] = useState(false); + const subscription = useMailingListSubscription(); return ( -
{ event.preventDefault(); if (email.trim()) setSubmitted(true); }}> +

Join our mailing list

-

Sign up for our mailing list so you don't miss out on any of our fun events!

+

Sign up for our mailing list so you don't miss out on any of our fun events!

- { setEmail(event.target.value); setSubmitted(false); }} required /> - + subscription.onEmailChange(event.target.value)} aria-describedby="contact-email-status" disabled={subscription.isSubmitting} required /> +
+
+ ); } diff --git a/src/press-releases/newsletters/index.md b/src/press-releases/newsletters/index.md index 25dbf06..9ec4744 100644 --- a/src/press-releases/newsletters/index.md +++ b/src/press-releases/newsletters/index.md @@ -6,7 +6,7 @@ blurb: See past and current SIMC Newsletters # Newsletters -Join our [mailing list](https://forms.gle/FDvWGo1FHqQSGkNK6) to stay updated on our events! +Join our [mailing list](/contact) to stay updated on our events! ## 2023-2024 Newsletters - [**January**](http://eepurl.com/iHasYA) http://eepurl.com/iHasYA diff --git a/src/styles.css b/src/styles.css index 0bb82c9..9573a15 100644 --- a/src/styles.css +++ b/src/styles.css @@ -135,6 +135,7 @@ h3 { font-family: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif; font-si .button-outline { border-color: var(--accent); background: var(--surface); color: var(--on-surface); } .button-primary:hover { background: var(--accent-soft); color: var(--on-surface); } .button-light:hover, .button-outline:hover { background: var(--accent-soft); } +.button:disabled { cursor: wait; opacity: .7; transform: none; } .main-nav .button { width: 140px; min-width: 140px; } .source-image { display: block; object-fit: cover; } @@ -195,13 +196,19 @@ h3 { font-family: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif; font-si .results-year-rule span { flex: 0 0 auto; color: var(--award-gold); font-family: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif; font-size: 20px; font-weight: 600; line-height: 26px; white-space: nowrap; } .results-awards-grid { display: flex; width: 100%; height: calc(var(--results-rows) * 49px); flex-wrap: wrap; align-content: center; align-items: center; justify-content: center; column-gap: 40px; overflow: hidden; padding: 0 20px; } -.signup-banner { display: flex; width: 100%; height: 132px; align-items: center; gap: 20px; padding: 24px 80px; background: var(--accent-soft); } +.signup-banner { display: flex; width: 100%; min-height: 132px; height: auto; align-items: center; gap: 20px; padding: 24px 80px; background: var(--accent-soft); } .signup-copy { display: flex; height: 84px; flex-direction: column; justify-content: center; gap: 6px; color: var(--text); } .signup-copy h2 { font-family: 'Source Serif 4', Georgia, serif; font-size: 36px; font-weight: 600; line-height: normal; white-space: nowrap; } .signup-copy p { line-height: 21px; white-space: nowrap; } .signup-spacer { width: auto; min-width: 1px; flex: 1 1 0; } -.signup-form { width: 424px; flex: 0 0 424px; } +.signup-form { display: flex; width: 424px; flex: 0 0 424px; flex-direction: column; gap: 6px; } .signup-banner > .button { width: 205px; min-width: 205px; } +.turnstile-container { width: 100%; min-height: 0; } +.turnstile-container:empty { display: none; } +.mailing-list-status { margin: 0; color: var(--muted); font-size: 13px; line-height: 18px; } +.mailing-list-status a { color: inherit; font-weight: 600; text-decoration: underline; text-underline-offset: 3px; } +.mailing-list-status-error { color: #a33b32; } +:root[data-theme='dark'] .mailing-list-status-error { color: #ffb4ab; } .page-intro { display: flex; width: 100%; height: 243px; flex-direction: column; gap: 20px; padding: 60px 80px; background: var(--accent); color: var(--on-accent); } .page-intro h1 { color: var(--on-accent); } @@ -302,6 +309,7 @@ h3 { font-family: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif; font-si .contact-form-row { display: grid; width: 100%; grid-template-columns: 340px 205px; gap: 15px; margin-top: auto; } input { width: 100%; height: 48px; padding: 11px 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--input-bg); color: var(--input-text); outline: none; } +input:disabled { cursor: wait; opacity: .75; } input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); } .about-hero { display: grid; width: 100%; height: 426px; grid-template-columns: 550px minmax(0, 1fr); gap: 48px; padding: 80px; background: var(--accent); } diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..568376f 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,23 @@ /// + +interface ImportMetaEnv { + readonly VITE_TURNSTILE_SITE_KEY?: string; +} + +interface TurnstileRenderOptions { + sitekey: string; + appearance?: 'always' | 'execute' | 'interaction-only'; + callback?: (token: string) => void; + 'expired-callback'?: () => void; + 'error-callback'?: (errorCode?: string) => void; +} + +interface TurnstileApi { + render(container: HTMLElement, options: TurnstileRenderOptions): string; + reset(widgetId?: string): void; + remove(widgetId?: string): void; +} + +interface Window { + turnstile?: TurnstileApi; +} diff --git a/worker/index.js b/worker/index.js index 910f22e..8136a23 100644 --- a/worker/index.js +++ b/worker/index.js @@ -1,5 +1,189 @@ +const MAX_BODY_BYTES = 4096; +const MAX_EMAIL_LENGTH = 254; +const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u; +const textEncoder = new TextEncoder(); + +const JSON_HEADERS = { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + 'x-content-type-options': 'nosniff', +}; + +const HTML_HEADERS = { + 'cache-control': 'no-store', + 'content-type': 'text/html; charset=utf-8', + 'x-content-type-options': 'nosniff', +}; + +function json(body, status = 200, extraHeaders = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { ...JSON_HEADERS, ...extraHeaders }, + }); +} + +function html(body, status = 200) { + return new Response(body, { status, headers: HTML_HEADERS }); +} + +function methodNotAllowed(allow) { + return json({ ok: false, error: 'method-not-allowed' }, 405, { allow }); +} + +function isSameOrigin(request) { + const origin = request.headers.get('origin'); + if (!origin) return true; + + try { + return new URL(origin).origin === new URL(request.url).origin; + } catch { + return false; + } +} + +function normalizeEmail(value) { + if (typeof value !== 'string') return null; + + const email = value.trim().toLowerCase(); + if (email.length === 0 || email.length > MAX_EMAIL_LENGTH || !EMAIL_PATTERN.test(email)) return null; + return email; +} + +async function readJson(request) { + const contentLength = Number(request.headers.get('content-length')); + if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) return null; + + let body; + try { + body = await request.text(); + } catch { + return null; + } + if (textEncoder.encode(body).byteLength > MAX_BODY_BYTES) return null; + + try { + return JSON.parse(body); + } catch { + return null; + } +} + +async function verifyTurnstile(token, request, secret) { + const form = new URLSearchParams({ secret, response: token }); + const clientIp = request.headers.get('cf-connecting-ip'); + if (clientIp) form.set('remoteip', clientIp); + + try { + const response = await fetch(TURNSTILE_VERIFY_URL, { + method: 'POST', + body: form, + }); + + if (!response.ok) return { ok: false, unavailable: true }; + const result = await response.json(); + return { ok: result?.success === true, unavailable: false }; + } catch { + return { ok: false, unavailable: true }; + } +} + +function base64UrlEncode(bytes) { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, ''); +} + +async function signPayload(payload, secret) { + const key = await crypto.subtle.importKey( + 'raw', + textEncoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + const signature = await crypto.subtle.sign('HMAC', key, textEncoder.encode(payload)); + return new Uint8Array(signature); +} + +function createUnsubscribeToken() { + return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32))); +} + +async function hashUnsubscribeToken(token, secret) { + return base64UrlEncode(await signPayload(token, secret)); +} + +async function subscribe(request, env) { + if (!env.DB || !env.TURNSTILE_SECRET || !env.UNSUBSCRIBE_SECRET) { + return json({ ok: false, error: 'service-not-configured' }, 503); + } + + const contentType = request.headers.get('content-type') || ''; + if (!contentType.toLowerCase().includes('application/json')) { + return json({ ok: false, error: 'json-required' }, 415); + } + + const body = await readJson(request); + const email = normalizeEmail(body?.email); + const turnstileToken = typeof body?.turnstileToken === 'string' ? body.turnstileToken.trim() : ''; + if (!email || turnstileToken.length === 0 || turnstileToken.length > 2048) { + return json({ ok: false, error: 'invalid-request' }, 400); + } + + const verification = await verifyTurnstile(turnstileToken, request, env.TURNSTILE_SECRET); + if (verification.unavailable) return json({ ok: false, error: 'verification-unavailable' }, 503); + if (!verification.ok) return json({ ok: false, error: 'verification-failed' }, 400); + + try { + const unsubscribeToken = createUnsubscribeToken(); + await env.DB.prepare( + `INSERT INTO subscribers (id, email, subscribed) + VALUES (?1, ?2, 1, ?3) + ON CONFLICT(email) DO UPDATE SET subscribed = 1, unsubscribe_token_hash = excluded.unsubscribe_token_hash`, + ).bind(crypto.randomUUID(), email, await hashUnsubscribeToken(unsubscribeToken, env.UNSUBSCRIBE_SECRET)).run(); + + const unsubscribeUrl = new URL('/unsubscribe', request.url); + unsubscribeUrl.searchParams.set('token', unsubscribeToken); + + return json({ ok: true, unsubscribeUrl: unsubscribeUrl.toString() }); + } catch { + return json({ ok: false, error: 'database-unavailable' }, 503); + } +} + +async function unsubscribe(request, env) { + if (request.method !== 'GET') return methodNotAllowed('GET'); + if (!env.DB || !env.UNSUBSCRIBE_SECRET) { + return html('Unsubscribe unavailable

Unsubscribe is temporarily unavailable.

', 503); + } + + const token = new URL(request.url).searchParams.get('token'); + if (!token || token.length > 512) { + return html('Invalid unsubscribe link

This unsubscribe link is invalid or expired.

', 400); + } + + try { + await env.DB.prepare('UPDATE subscribers SET subscribed = 0 WHERE unsubscribe_token_hash = ?1').bind(await hashUnsubscribeToken(token, env.UNSUBSCRIBE_SECRET)).run(); + return html('You are unsubscribed

You have been unsubscribed from the SIMC mailing list.

Return to SIMC

'); + } catch { + return html('Unsubscribe unavailable

We could not update your subscription. Please try again later.

', 503); + } +} + export default { async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/api/subscribe') { + if (!isSameOrigin(request)) return json({ ok: false, error: 'origin-not-allowed' }, 403); + if (request.method !== 'POST') return methodNotAllowed('POST'); + return subscribe(request, env); + } + + if (url.pathname === '/unsubscribe') return unsubscribe(request, env); + if (url.pathname.startsWith('/api/')) return json({ ok: false, error: 'not-found' }, 404); + return env.ASSETS.fetch(request); }, }; diff --git a/wrangler.jsonc b/wrangler.jsonc index 4e9ae29..37ac88b 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -6,6 +6,22 @@ "assets": { "directory": "./dist", "binding": "ASSETS", + "run_worker_first": ["/api/*", "/unsubscribe"], "not_found_handling": "single-page-application" + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "simc-mailing-list", + "database_id": "REPLACE_WITH_D1_DATABASE_ID", + "migrations_dir": "migrations" + } + ], + "secrets": { + "required": ["TURNSTILE_SECRET", "UNSUBSCRIBE_SECRET"] + }, + "observability": { + "enabled": true, + "head_sampling_rate": 1 } }