Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ node_modules/
.DS_Store
*.log
.wrangler/
.dev.vars
43 changes: 39 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<email address>`. 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

Expand Down
17 changes: 17 additions & 0 deletions assets/css/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
2 changes: 1 addition & 1 deletion content/donate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions content/privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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.

Expand Down
5 changes: 3 additions & 2 deletions hugo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <givebutter-widget id="..."> tag in the same embed code; embed stays off until set
givebutterWidgetId = "j9Mr6K" # the id from the <givebutter-widget id="..."> 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/"
Expand All @@ -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]]
Expand Down
86 changes: 86 additions & 0 deletions layouts/_partials/updates-signup.html
Original file line number Diff line number Diff line change
@@ -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 -}}
<section class="updates" id="updates" aria-labelledby="updates-h">
<div class="wrap updates-inner">
<div class="updates-copy">
<h2 id="updates-h">Get updates by email</h2>
<p>We'll email you when new classes and events are announced, and with the occasional update about the organization. That's all we send.</p>
</div>
<div>
<form class="updates-form" method="post" action="{{ $p.subscribeEndpoint }}" id="updates-form">
<div class="updates-row">
<label for="up-email" class="sr">Email address</label>
<input id="up-email" name="email" type="email" required autocomplete="email" placeholder="you@example.com">
<button class="btn btn-primary" type="submit">Sign up</button>
</div>
<div class="hp" aria-hidden="true"><label>Leave this empty<input name="website" tabindex="-1" autocomplete="off"></label></div>
<input type="hidden" name="back" value="{{ .RelPermalink }}">
{{ with $p.turnstileSiteKey }}<div class="updates-ts" id="up-ts" data-sitekey="{{ . }}"></div>{{ end }}
<p class="updates-fine">No spam, ever. We never share your address, and you can unsubscribe any time. <a href="/privacy/">Privacy</a></p>
</form>
<p class="updates-msg ok" id="updates-ok" role="status" hidden><strong>You're on the list.</strong> We'll be in touch when there's something new.</p>
<p class="updates-msg warn" id="updates-err" role="alert" hidden></p>
</div>
</div>
</section>
<script>
(function () {
var form = document.getElementById('updates-form');
if (!form) return;

// Result of a submission, passed back by the Worker as ?updates=...
var q = new URLSearchParams(location.search).get('updates');
if (q === 'ok') {
form.hidden = true;
document.getElementById('updates-ok').hidden = false;
} else if (q) {
var why = {
invalid: "That email address doesn't look right. Please check it and try again.",
captcha: "The spam check didn't finish. Please try once more.",
slow: 'Too many signups from this connection. Please try again later.'
};
var err = document.getElementById('updates-err');
err.textContent = why[q] || 'Something went wrong on our end. Please try again, or use the contact form.';
err.hidden = false;
}

var box = document.getElementById('up-ts');
if (!box) return; // Turnstile not configured yet

var rendered = false, pending = false;
function render() {
if (rendered || !window.turnstile) return;
rendered = true;
window.turnstile.render(box, {
sitekey: box.dataset.sitekey,
action: 'subscribe',
callback: function () { if (pending) form.submit(); }
});
}
function load() {
if (window.turnstile) return render();
// The contact page may already be loading Turnstile; wait for it instead of loading twice.
if (document.querySelector('script[src*="challenges.cloudflare.com/turnstile"]')) {
var t = setInterval(function () { if (window.turnstile) { clearInterval(t); render(); } }, 150);
return;
}
window.cgwTurnstileReady = render;
var s = document.createElement('script');
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=cgwTurnstileReady';
s.async = true;
document.head.appendChild(s);
}
form.addEventListener('focusin', load, { once: true });
form.addEventListener('submit', function (e) {
var token = form.querySelector('[name="cf-turnstile-response"]');
if (!token || !token.value) {
// Hold the submission until the check completes, then send it automatically.
e.preventDefault();
pending = true;
load();
}
});
})();
</script>
2 changes: 1 addition & 1 deletion layouts/_shortcodes/contact-form.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
</select></div>
<div class="field"><label for="cf-msg">Message</label><textarea id="cf-msg" name="message" rows="6" required></textarea></div>
<div class="hp" aria-hidden="true"><label>Leave this empty<input name="website" tabindex="-1" autocomplete="off"></label></div>
{{ with $p.turnstileSiteKey }}<div class="cf-turnstile" data-sitekey="{{ . }}"></div><script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>{{ end }}
{{ with $p.turnstileSiteKey }}<div class="cf-turnstile" data-sitekey="{{ . }}" data-action="contact"></div><script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>{{ end }}
<input type="hidden" name="redirect" value="/contact/?sent=1">
<button class="btn btn-primary" type="submit">Send message</button>
<p class="small muted">Or email <a href="mailto:{{ $p.email }}">{{ $p.email }}</a> directly.</p>
Expand Down
1 change: 1 addition & 0 deletions layouts/baseof.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<main id="main">
{{ block "main" . }}{{ end }}
</main>
{{ partial "updates-signup.html" . }}
{{ partial "footer.html" . }}
</body>
</html>
14 changes: 4 additions & 10 deletions src/contact.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');

Expand Down
12 changes: 12 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -9,13 +10,24 @@ 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({
ok: true,
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(),
});
Expand Down
Loading
Loading