szabadpalya/README.md

189 lines
7.6 KiB
Markdown

# 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.
```bash
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:
```bash
./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.
```bash
./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:
```bash
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
```bash
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.