- Go 75.9%
- templ 17.4%
- CSS 5.1%
- JavaScript 1.4%
- Shell 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
Some checks failed
Deploy to staging / deploy (push) Has been cancelled
The README still walked through a Stripe checkout the site doesn't have. It now says nothing is sold online, that the catalogue is the imported items edited by hand, and that the Stripe sync code has never had an account behind it — which also means the admin has no way to add an item. main.go loses the seven cart, checkout, and order handlers no route has reached since the shop became a catalogue. Also from a pass back over this run of changes: the intro splash fades out in CSS, so it clears even if app.js doesn't load, and the backup setup creates the .ssh directory before generating a key into it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
| .claude | ||
| .github/workflows | ||
| admin | ||
| bookingmail | ||
| cmd | ||
| db | ||
| deploy | ||
| discord | ||
| docs | ||
| embed | ||
| ics | ||
| images | ||
| markdown | ||
| newsletter | ||
| shop | ||
| staff | ||
| static | ||
| uploads | ||
| views | ||
| youtube | ||
| .air.toml | ||
| .directory | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| booking_test.go | ||
| deploy.sh | ||
| flyer_test.go | ||
| go.mod | ||
| go.sum | ||
| main.go | ||
| MANIFESTO.md | ||
| README.md | ||
| signup_gate_test.go | ||
| sitemap.go | ||
| sitemap_test.go | ||
Decay Main
Site for DECAY, a community arts and technology space in Olympia, WA.
Built with Go, Echo, SQLite, and templ — see
MANIFESTO.md for the stack philosophy. The manifesto names
htmx for interactivity; nothing uses it yet, so it isn't loaded, and what
script the pages have is the hand-written static/js/app.js.
Run
Copy .env.example to .env and set a real ADMIN_PASSWORD (and
SESSION_SECRET, so admin logins survive a restart):
cp .env.example .env
go run main.go
Then visit http://localhost:8080.
The admin panel lives at /admin/login. It manages events, shop
products, blog posts, photos, board/staff people, groups, media, and
site forms, and it collects booking requests, volunteer sign-ups, and
contact messages — all stored in decay.db (SQLite, created and seeded
automatically on first run).
Accounts
Everyone signs in with their own account. Accounts live in the users
table with bcrypt-hashed passwords and are managed at /admin/users.
ADMIN_USERNAME and ADMIN_PASSWORD create the first account and
nothing more. Once any account exists they're ignored — changing them
won't change a password or let anyone in, so the real credential lives in
the database from that point on. (Startup deliberately accepts a short
ADMIN_PASSWORD for that first account and warns instead of refusing:
the admin panel is the only place to change it, so refusing to boot would
lock everyone out of fixing it.)
Access is by permission, not by role name — events, posts, shop,
photos, people, groups, media, bookings, messages, forms,
reports, staff, and users. Roles map to sets of those in
db/roles.go, and handlers check the permission, so adding a narrower
role later means adding an entry there and changing no handlers. There
are three:
- Keyholder — running the space: events, bookings, messages, photos, reports, and the staff calendar. Not the shop, the site's copy, or accounts.
- Manager — everything, including creating accounts and handing out access.
- Master — everything a manager has, and hidden. It's the owner
account, so
/admin/usersis the one place the difference shows.
A master is invisible to anyone who isn't one. Managers don't see
master accounts in the list, aren't offered the role on any form, and get
a 404 — not a 403 — from a master's edit, password, or delete URL, since
"forbidden" would confirm the account is there. The role only appears to
a master. ADMIN_USERNAME/ADMIN_PASSWORD still creates the first
account as a master, so a fresh install has one.
Two things the panel refuses, because both would lock everyone out permanently: deleting the account you're signed in as, and removing the last account that can manage accounts.
Your account
/admin/account is everyone's own page, behind no permission at all — a
keyholder who can reach nothing else still has one. It holds the display
name shown around the panel, a photo, a blurb of up to 250 characters,
and a password change (which asks for the current password, so a session
left open isn't enough to lock its owner out).
The blurb is stored as plain text, whatever gets posted: db.SanitizeBlurb
strips tags, drops script and style bodies whole, and removes control
characters before it ever reaches the column. The templates escape what
they render, so that isn't what stands between the site and an injection
— it's so nothing executable is in the database to begin with, for
whatever reads it later. An account manager can clear someone's blurb or
photo from /admin/users/<id>; nobody but the owner can set them.
Photos land in uploads/avatars/ with a web-sized copy alongside, the
same as flyers and product shots.
Live reload
Go doesn't hot-reload — go run compiles once, so every edit needs a
manual stop/rebuild/restart, and the static assets are go:embed-ed
into the binary too, so even a CSS tweak needs a rebuild. air
automates that loop:
go install github.com/air-verse/air@latest
air
.air.toml runs templ generate before every build and watches .go,
.templ, .css, and .js files, rebuilding and restarting the server
on save. decay.db and uploads/ aren't touched by a rebuild — they're
runtime data, not compiled in.
Shop
The shop is a catalogue. Nothing is sold through the site. Every item says to pick it up in person at the space, and a product page's "Want it shipped?" link opens the contact form with the subject already naming the item. Online checkout was taken out because nobody had bought through it and a sale had no way to reach anyone: no staff notice, no orders page, no address collected.
So there is no cart and no checkout. /cart redirects to /shop for the
sake of old links; /shop/checkout, /order/confirm, and
/api/order-status no longer exist.
Catalogue
The items are the ones imported from the old shop's export (see Legacy
import below). Each is edited at /admin/products/<id> — name, price,
description, sizes, sold-out, position, photo. Photos live under
uploads/products/.
There is currently no way to add a new item from the admin. The only add path the panel has is Sync from Stripe, and DECAY has never had a Stripe account connected — so that button has always been disabled, and the text beside it ("Items are added and priced in Stripe") describes a setup that never existed. A new item today means the importer or a row added to the database by hand.
The Stripe sync code is real and tested, just never used. For whoever
reads it: with STRIPE_SECRET_KEY set it would pull names, prices, and
descriptions down, matching rows on the Stripe product id rather than
the price id (Stripe prices are immutable, so editing an amount mints a
new one). It never touches photos or ordering, marks an item Stripe stops
listing as sold out rather than deleting it, and leaves alone any row with
no Stripe product id — which is every row there is.
What's left of checkout
The selling code was not all removed with the routes, in case selling online comes back:
shop/still has the cart, the PayPal and Stripe checkout clients, and order finalizing, with their tests. Stripe's was written first and never connected to an account; PayPal's replaced it.views/cart.templandviews/order_confirm.templare still there, rendered by nothing./admin/paypalstill stores PayPal credentials, and/webhooks/paypaland (with Stripe keys set)/webhooks/stripestill answer. With no way to start an order they have nothing to finalize.
None of it is reachable by a visitor. Bringing checkout back means routes
and handlers in main.go again — the old ones are in the history before
the commit that removed them — and, this time, somewhere for staff to see
an order.
Legacy import
db/products.json is generated from a shop.decay.events export:
go run ./cmd/importshop -export ../shop-decay-events-export-2026-07-24-002012
Two quirks of that export are handled in the importer. Its point-of-sale
prices exclude sales tax — a $30 shirt is listed at 27.32 — so the
tax rate from the same file is applied back, giving the price people
actually pay. And it has no image column at all, so products are matched
to photos by a table in cmd/importshop/main.go; a new product needs a
line there. Only the Merch category is imported: concessions (popcorn,
coffee) are sold at the door and donations aren't merchandise.
Photos land in uploads/products/ with web-sized copies alongside, the
same as flyers, and can be replaced per item at /admin/products/<id>.
Calendar
Events are shown three ways: /calendar is a month grid like the old
site's, /events is a paginated list of what's coming up, and
/events/archive is everything past. The grid is laid out in the venue's
timezone, so a 9pm show stays on the night it started rather than sliding
into the next day via UTC.
SQLite is the record for events. They're edited at /admin/events, and
the site publishes them as an iCalendar feed at /events.ics that
Nextcloud, Apple Calendar, and Google Calendar can all subscribe to — so
the org calendar and everyone's phone follow the site rather than the
other way round. Subscribing is read-only and needs no credentials:
- Nextcloud — Calendar → New calendar → Add subscription, paste the feed URL. Nextcloud re-fetches it on its own schedule.
- Apple / Google Calendar — "Subscribe to calendar" / "From URL".
The feed carries the whole calendar, past events included, so there's no
window rule to be surprised by — unlike the pages, it isn't paginated,
since a calendar client wants the lot in one fetch. Each event's uid column is its
iCalendar identity: it has to stay stable for the life of an event, or
subscribers get a duplicate every time they refresh. Events imported from
the old site reuse the caldav_uid it already pushed to Nextcloud, so
anything already subscribed there won't see a second copy.
Writing back — creating an event inside Nextcloud and having it appear on the site — would need a real two-way CalDAV sync with conflict rules, and iCalendar can't carry the fields a DECAY event needs. That's deliberately not built.
Setup and teardown
An event can hold the space either side of itself — setup_minutes
before the start, teardown_minutes after the end — set per event at
/admin/events/<id>. A 7pm show with three hours of load-in commits the
room from 4pm.
Held time is staff-facing and stays that way. The public event page,
/events, /calendar, the JSON, and the .ics feed all publish
starts_at/ends_at and nothing else, so an organizer who needs the
room from 4 still advertises a 7 o'clock show. The held window shows on
the event's admin page and in the tooltip on /admin/staff, which is
what staff read when placing a booking request. db/hold.go holds the
window arithmetic and ics/ics_test.go guards the feed against a hold
ever widening DTSTART/DTEND — that would rewrite what's already on
subscribers' phones.
Nothing is blocked. When one event's held window runs into another's, the admin event page names the clash and leaves the decision alone: booking requests are a free-text queue worked through by hand, not an automated scheduler, and two things in the building at once is sometimes the intent. Exactly back-to-back doesn't count as a clash — one event's teardown ending as another's load-in begins is a clean handover.
Both figures are minutes, not timestamps, so they survive a date change and travel onto every copy the Repeat tool stamps out, the same way the event's duration does.
Booking requests become events
A request in the /admin/bookings queue leaves it one of two ways:
deleted, or converted to an event. Converting clears the request —
otherwise a handled booking sits in the queue forever looking like
something still to deal with.
Nothing is lost with the row. The organizer's name and email move onto
the event, which is what the email history keys on, so the
correspondence follows. Everything else the event form has no field for
— phone, the dates they asked for, when they wrote in, the request
itself — and whatever private notes were kept on the request are folded into events.notes,
the event's own notes box at /admin/events/<id>.
The public form asks for two pieces of writing. Your request is the
ask in the requester's own words — what they want to do and what they
need — and is staff-only (booking_requests.ask). Event description
is optional, labelled as public on the form, and prefills the event's
description on conversion. Requests from before the split have only the
description.
The form used to ask for an expected headcount too. It doesn't anymore:
a guess at numbers months before there's a date wasn't something anyone
acted on. booking_requests.expected_attendance is still there holding
what people answered while it was asked, read by nothing.
Event notes are staff-facing, like held time: the public page, the JSON,
and the .ics feed never carry them. They're saved on their own form so
a details edit can't blank them, and UpdateEvent leaves the column
alone for the same reason.
Staff calendar
The public feed above is the site publishing out to Nextcloud. The
/admin/staff page does the reverse for DECAY's own business: it
subscribes in to a separate, internal Nextcloud calendar (board and
organising meetings) and shows it on a month grid with an upcoming list.
It's the same arrangement — one-way, no stored credentials — just
inverted: point STAFF_ICS_URL at that calendar's read-only .ics
share link and it's read live (cached a few minutes) on each view.
Nothing is ever written back, and leaving the variable unset simply hides
the page. Meetings stay edited in Nextcloud; this is only a window onto
them, gated on a staff permission.
Pages
/about, /support, and /policies are static copy carried over from
the old site — mission and board, how to give and who funds us, and the
safer space policy. They're in views/ rather than the database because
nobody edits them week to week; when that changes they should move behind
the admin panel.
Outbound links are only to accounts DECAY actually uses: YouTube
(@no_tape), Discord, Patreon, Givebutter, Instagram, and the beehiiv
newsletter. There's no Bandcamp and the Twitch account is unused, so
neither is linked.
Media
/media is videos and photographs together. It was /photos until it
grew videos; that URL now redirects, since old links and printed material
still point at it.
Photos are uploaded at /admin/media. Files live under
uploads/photos/ with web-sized copies in uploads/photos/web/, the
same arrangement as flyers and product shots — the page shows the copy
and links the original behind it. Tiles are cropped square so a grid of
mixed phone aspect ratios still reads as a grid; the uncropped original
is one click away.
Captions are optional and double as the image's alt text. Without one the alt is deliberately empty, which marks the image as decorative rather than reading a generated filename out to a screen reader.
Videos come from two places. Featured are the ones entered at
/admin/media — the same handful the home page carries — and they get
real embedded players. Recent uploads are read live from the channel's
public Atom feed, and are thumbnails linking out to YouTube rather than
players: a dozen embeds would load a YouTube frame each before anyone
asked to watch anything. Anything already featured is dropped from the
recent list so it isn't shown twice.
The feed needs no API key and no credentials, the same read-only
arrangement as the staff calendar. YOUTUBE_CHANNEL takes either a
handle (@no_tape) or a channel id (UC…) and defaults to DECAY's own
channel, so it works unconfigured; set it to empty to drop the section
entirely. A handle costs one extra fetch of the channel page to resolve,
which happens once per process.
Two things worth knowing if this ever misbehaves. Resolving a handle
reads the channel id out of the page's canonical /channel/UC… link
specifically — a channel page is full of other id-shaped strings, and
taking the first one resolves to a stranger's channel whose feed then
404s. And the endpoint intermittently answers 404 or 500 to a perfectly
good request, so a fetch is retried once and the results are cached for
an hour; a failed refresh keeps serving the last good list. The section
is a bonus on top of the database, so YouTube being down costs the page
that section, never the page.
Flyers and volunteers
These are the fields iCalendar can't express, so they live on the site
and each event has its own page at /events/<slug> to hold them.
Flyers are images under uploads/flyers/, uploaded per event at
/admin/events/<id>. They're runtime data, not committed — the originals
alone are ~340 MB. Pages serve a web-sized copy from
uploads/flyers/web/, capped at 1200px wide and re-encoded as JPEG,
which takes that set to ~54 MB; the original is one click behind the
image. Both the importer and the admin upload generate the web copy, so
it always exists. The feed links to a flyer with ATTACH rather than
embedding it, so calendar clients that support it can show the image
without the feed carrying the bytes.
Volunteer roles are rows in event_volunteers — door, sound,
cleanup, promote. A row existing means the job is needed; an empty
volunteer_name means it's still open. Two deliberate choices here:
- The public event page lists only the roles still open. Who has signed up is shown in the admin panel and nowhere else.
- The importer carries no names at all — only which roles an event
needed.
db/events.jsonis committed to git, and the old records hold real community members' names, emails, and phone numbers. Who volunteered is recorded through the admin panel, intodecay.db, which isn't committed.
Upcoming event pages carry a public sign-up form — name, a way to
reach them, an optional role, and a note. Offers land in a separate
volunteer_signups table and surface on the admin event page, never on
the public one. This is the one place volunteer contact details are
collected on purpose, and they're kept apart from event_volunteers,
which still holds names only; a honeypot field drops bots.
/admin/volunteers is the roster for all of it: every upcoming event
with roles or offers, the next two weeks first. A role is filled or
cleared inline, and an offer becomes the volunteer on a role in one click
(db.AssignSignup fills the role and clears the offer together). The
dashboard only raises roles open in the next two weeks — a repeating
event's copies months out aren't an emergency yet.
With DISCORD_VOLUNTEER_WEBHOOK_URL set, open roles reach the volunteer
channel three ways: straight away when a role opens (ticked on an event,
or a name cleared), from a "Post to Discord now" button on the roster, and
from an hourly sweep (admin.RunVolunteerReminders) that brings a
still-short-handed event back up once it's within a week, and again
within two days. The sweep batches everything due into one message, only
posts 10am–8pm venue time, and skips an event the channel heard about
since it entered that stage — volunteer_calls records the last post
however it was made. It's also the only way repeat copies get asked
about, since they never get the call made when an event is created.
Event data
The event archive in db/events.json is DECAY's real calendar — 676
events back to March 2025 — converted from the old PHP site's flat JSON
files. The old site keeps the current quarter in data/events/data/ and
rolls finished quarters into data/archive/<year>/Q<n>/, so the importer
reads both:
go run ./cmd/importevents -src ../decay/data/events/data -archive ../decay/data/archive
It also copies each referenced flyer image into uploads/flyers/
(-flyers-out "" skips that).
It only rewrites db/events.json. Because seeding runs once, on an
empty events table, an existing decay.db won't pick up the new file
— delete decay.db (or clear the table) to seed again. Anything entered
through /admin/events lives only in decay.db and would be lost, so
once events are managed there, stop re-importing.
Structure
main.go— server startup, routes, env configdb/— SQLite schema, queries, and seed data (events.json)cmd/importevents/— one-way converter from the old site's event JSONcmd/importshop/— one-way converter from the shop.decay.events exportics/— renders events as a subscribable iCalendar feedstaff/— reads DECAY's internal Nextcloud calendar for/admin/staffyoutube/— reads recent uploads off a channel's public feed for/mediamarkdown/— renders blog post Markdown to HTMLembed/— resolves YouTube/Bandcamp links in posts into playersmail/— best-effort SMTP notification for contact-form messagesimages/— makes web-sized copies of uploaded flyersviews/— templ page templates (.templsource + generated_templ.go)admin/— session auth, permission checks, and CRUD handlers for/admin/*db/roles.go— the permission set each role grantsstatic/css,static/js,static/img— assets, embedded into the binary at build timeuploads/— photo, flyer, and avatar uploads written at runtime (not embedded, not committed)
Editing a .templ file requires regenerating its Go code:
go install github.com/a-h/templ/cmd/templ@latest
templ generate