No description
  • Go 75.9%
  • templ 17.4%
  • CSS 5.1%
  • JavaScript 1.4%
  • Shell 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
moonwxyz bf17b34ddf
Some checks failed
Deploy to staging / deploy (push) Has been cancelled
Describe the shop as the catalogue it is, and drop its dead checkout handlers
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>
2026-10-08 15:48:42 -07:00
.claude local decay-main work on top of seeded origin/master 2026-08-11 18:14:20 -07:00
.github/workflows Store text with LF, on every platform 2026-08-13 11:23:18 -07:00
admin Add a search box to the admin events, messages, bookings, and subscribers 2026-10-08 14:40:44 -07:00
bookingmail Align the one thing gofmt was actually complaining about 2026-08-13 11:26:09 -07:00
cmd Import door-sheet attendance and money into the reports 2026-09-30 19:47:08 -07:00
db Add a search box to the admin events, messages, bookings, and subscribers 2026-10-08 14:40:44 -07:00
deploy Describe the shop as the catalogue it is, and drop its dead checkout handlers 2026-10-08 15:48:42 -07:00
discord Manage volunteer slots on one roster; remind Discord as events get close 2026-09-25 13:50:35 -07:00
docs local decay-main work on top of seeded origin/master 2026-08-11 18:14:20 -07:00
embed Embed YouTube and Bandcamp links in blog posts 2026-07-26 20:40:00 -07:00
ics Hold the space either side of an event, and let a request finish 2026-08-13 10:04:36 -07:00
images Refuse an image too big to open, before it is opened 2026-08-13 11:01:36 -07:00
mail Bring the newsletter in-house 2026-09-08 13:07:56 -07:00
markdown Serve blog posts as JSON or HTML via content negotiation 2026-07-29 13:38:37 -07:00
newsletter Keep category events out of a recurring event's newsletter block 2026-09-25 14:57:39 -07:00
shop Store text with LF, on every platform 2026-08-13 11:23:18 -07:00
staff Rename internal meetings feature to Staff calendar 2026-07-26 20:49:58 -07:00
static Describe the shop as the catalogue it is, and drop its dead checkout handlers 2026-10-08 15:48:42 -07:00
uploads Default flyer-less events to the building mural photo 2026-08-09 01:19:36 -07:00
views Add a search box to the admin events, messages, bookings, and subscribers 2026-10-08 14:40:44 -07:00
youtube Store text with LF, on every platform 2026-08-13 11:23:18 -07:00
.air.toml Add air for live reload during development 2026-07-23 14:25:55 -07:00
.directory local decay-main work on top of seeded origin/master 2026-08-11 18:14:20 -07:00
.env.example Describe the shop as the catalogue it is, and drop its dead checkout handlers 2026-10-08 15:48:42 -07:00
.gitattributes Store text with LF, on every platform 2026-08-13 11:23:18 -07:00
.gitignore Wait for the database instead of failing the write 2026-08-13 10:44:08 -07:00
booking_test.go Count what's waiting in the admin nav, and email hosted-form responses 2026-10-08 14:11:41 -07:00
deploy.sh Point deploy.sh at forgejo, not the stale GitHub mirror 2026-09-08 13:27:36 -07:00
flyer_test.go Fall back to a default flyer image instead of 404ing 2026-08-11 23:01:44 -07:00
go.mod Store text with LF, on every platform 2026-08-13 11:23:18 -07:00
go.sum Add Stripe Checkout integration to the shop 2026-08-07 21:58:02 -07:00
main.go Describe the shop as the catalogue it is, and drop its dead checkout handlers 2026-10-08 15:48:42 -07:00
MANIFESTO.md Wait for the database instead of failing the write 2026-08-13 10:44:08 -07:00
README.md Describe the shop as the catalogue it is, and drop its dead checkout handlers 2026-10-08 15:48:42 -07:00
signup_gate_test.go Show staff the keyholder on public event pages; add headings and a skip link 2026-09-26 11:13:51 -07:00
sitemap.go Serve robots.txt and a sitemap 2026-10-08 14:46:23 -07:00
sitemap_test.go Serve robots.txt and a sitemap 2026-10-08 14:46:23 -07:00

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/users is 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.templ and views/order_confirm.templ are still there, rendered by nothing.
  • /admin/paypal still stores PayPal credentials, and /webhooks/paypal and (with Stripe keys set) /webhooks/stripe still 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.json is committed to git, and the old records hold real community members' names, emails, and phone numbers. Who volunteered is recorded through the admin panel, into decay.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 config
  • db/ — SQLite schema, queries, and seed data (events.json)
  • cmd/importevents/ — one-way converter from the old site's event JSON
  • cmd/importshop/ — one-way converter from the shop.decay.events export
  • ics/ — renders events as a subscribable iCalendar feed
  • staff/ — reads DECAY's internal Nextcloud calendar for /admin/staff
  • youtube/ — reads recent uploads off a channel's public feed for /media
  • markdown/ — renders blog post Markdown to HTML
  • embed/ — resolves YouTube/Bandcamp links in posts into players
  • mail/ — best-effort SMTP notification for contact-form messages
  • images/ — makes web-sized copies of uploaded flyers
  • views/ — templ page templates (.templ source + generated _templ.go)
  • admin/ — session auth, permission checks, and CRUD handlers for /admin/*
  • db/roles.go — the permission set each role grants
  • static/css, static/js, static/img — assets, embedded into the binary at build time
  • uploads/ — 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