szabadpalya/README.md

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's manifest.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

  1. The raised amount and backer count are not stored. They are always derived from Pledge rows (AUTHORIZED + CAPTURED), so the progress bar cannot drift from reality. See api/internal/store/campaigns.go.
  2. A backer's identity is never public. There is no "anonymous" toggle because there is nothing to toggle. This is why no Támogatók tab 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.