189 lines
7.6 KiB
Markdown
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.
|