7.6 KiB
Szabadpálya
Community funding for Hungarian public projects, with no platform fee.
Backend Go + Gin + pgx, Postgres 17 · Frontend React 19 + React Router + TypeScript + Vite + Tailwind 4 · Deployment Docker Compose, self-hosted.
The product is Hungarian: every user-facing string, campaign and reward tier is in Hungarian. Everything else — code, comments, commit messages, documentation — is in English.
Layout
api/ Go server: JSON API, the HTML document, share images, migrations
api/test/ development fixture and the seeder — never built into the shipping image
web/ Vite + React Router single-page app
scripts/ up.sh, db.sh, dev.sh, go.sh, ci.sh
One binary serves everything in production: the API under /api, the hashed
Vite assets under /assets, the generated share images, and the HTML document
for every other path.
Getting started
Docker and pnpm are all you need installed. The Go toolchain is not required on
the host — scripts/go.sh runs it in a container against a cached module volume.
cp .env.example .env # defaults are the development ones; set ADMIN_EMAILS
./scripts/up.sh
That is the whole thing. up.sh starts Postgres, applies any migration that has
not run yet, then runs the API on :3000 and Vite on :5173 until Ctrl-C stops
both. Open http://localhost:5173.
A fresh database is empty. To load the fixture — 4 campaigns, 550 pledges:
./scripts/dev.sh seed # wipes campaigns first, so it is safe to repeat
In development Vite serves the page and proxies /api to the Go process. The
share tags are therefore not exercised in development — Vite owns
index.html there, and the document template only runs in the container. That
is what the CI smoke test is for.
The scripts
./scripts/up.sh |
everything at once, the normal way to start |
./scripts/db.sh {up|down|psql|logs|reset} |
Postgres on localhost:5433 |
./scripts/dev.sh {server|migrate|seed} |
one Go process, reading .env |
./scripts/go.sh <args> |
the Go toolchain itself: go test ./..., go mod tidy |
./scripts/ci.sh [--fast] |
the pipeline (see CI below) |
dev.sh needs .env and a running database; go.sh needs neither, which is
why the static half of CI goes through it and works on a fresh clone.
Why the Go server renders HTML
Every campaign needs its own og: tags. The crawlers that build a Facebook
share card do not execute JavaScript, so a plain SPA shell makes every shared
campaign look identical — which is exactly the weakness SPEC.md section 8
identifies in the commercial Hungarian platforms.
The seam is deliberately sharp:
- Go owns
<head>— title, description,og:tags, the theme script, and the hashed asset tags read out of Vite'smanifest.json. - React owns everything inside
#root.
No markup is produced in two places, so no markup can drift. The document also
inlines the data the first route would otherwise fetch, in a
<script type="application/json" id="__DATA__"> block; the client reads it once
and refetches on any later navigation, because funding totals move.
The share image itself is drawn in Go (api/internal/ogimage) rather than by a
JSX-to-PNG renderer, since Go has no equivalent of Satori.
Database
./scripts/db.sh {up|down|psql|logs|reset}
Schema lives in api/internal/store/migrations/*.sql and is applied by a small
runner in api/internal/store/store.go: every file that has not run yet, in
filename order, each in its own transaction, behind an advisory lock so two
containers starting at once cannot race. To add one, drop in
000N_what_i_changed.sql.
Two rules that run through the model
- The raised amount and backer count are not stored. They are always derived
from
Pledgerows (AUTHORIZED+CAPTURED), so the progress bar cannot drift from reality. Seeapi/internal/store/campaigns.go. - A backer's identity is never public. There is no "anonymous" toggle
because there is nothing to toggle. This is why no
Támogatóktab exists.
CI
./scripts/ci.sh is the whole pipeline, and the only definition of "does it
pass". A hosted runner would just call it; nothing in it assumes GitHub.
./scripts/ci.sh # full run, ~40s, includes the container smoke test
./scripts/ci.sh --fast # static checks only
Static: go build, go vet, gofmt, tsc, eslint, vite build, plus two
project rules — code comments must be English, and no secrets may be committed.
Container: builds the image, migrates and seeds a throwaway szabadpalya_ci
database using the binaries from that image, boots it, and then asserts the
things that only a running system can prove — that the share image is a real
PNG, that the numbers come out of pledge rows, that og: tags are
per-campaign, that the bootstrap payload is present and parseable, that a used
magic link cannot be replayed, that a cross-origin write is refused, and that a
signed-in non-admin gets nothing. This half is the point.
Enable the pre-push hook (fast lane) once per clone:
git config core.hooksPath .githooks
Auth
Passwordless magic links, implemented in api/internal/auth. No passwords to
leak, no reset flow to get wrong. Session tokens and link tokens are stored as
sha256 hashes, so a database dump cannot be replayed into a login, and a link is
redeemed by a single UPDATE ... WHERE consumedAt IS NULL — which is what makes
it single-use even against two simultaneous requests.
Admin rights come from either the isAdmin flag on the user row or the
ADMIN_EMAILS allowlist. The allowlist exists so a fresh deployment can grant
the first admin without database surgery; the flag is what a future admin UI
will set.
Anyone may request a magic link and sign in — that is not the same as being an
admin. The gate is the API: every /api/admin/* route answers 401 to anyone
who is not an admin, and the client turns that into a redirect. The /admin
document itself is public HTML carrying no data, which is a deliberate change
from the previous stack's server-side redirect — CI asserts both halves.
Email is not wired up yet (TODO.md section 7), so the link is printed to the server log instead:
magic link for you@example.org:
http://localhost:5173/api/auth/verify?token=…&callback=%2Fadmin
Copy it out of the terminal and open it. Set ADMIN_EMAILS to your own address
first, or you will sign in with no admin rights.
Health
GET /api/health is a readiness probe. It runs SELECT 1 and returns:
200 {"status":"ok","database":"ok","latencyMs":2}
503 {"status":"degraded","database":"unreachable"}
It deliberately fails when Postgres is unreachable — an app that is running but cannot reach its database serves nothing useful, and that is precisely the state a plain process check misses. The body carries no error detail: the endpoint is unauthenticated and must not leak connection strings or stack traces.
This is why the server does not migrate at boot: a process that exits when
the database is down can never report that the database is down, and one blip
would take the whole site with it. Migrations run from the same image via
server -migrate, which is what the compose migrate service does.
Running in production
export SESSION_SECRET=$(openssl rand -base64 32)
docker compose up -d --build
Three services: db, migrate (runs once, migrates, exits) and app. The app
starts only after migrate has completed successfully. BASE_URL must be the
real external origin — it decides whether the session cookie is marked Secure
and what host magic links point at.