From 2899eb205bd699c5bb4045de80517e1283945358 Mon Sep 17 00:00:00 2001 From: Tronax Date: Wed, 5 Aug 2026 15:14:37 +0200 Subject: [PATCH] Backend Phase A: foundation, migrations, Docker setup - Go backend skeleton: config (caarlos0/env), slog JSON logging, pgxpool store, HTTP server with graceful shutdown. - httpapi: render helpers, Problem errors, middleware chain (requestID / logging / recover / CORS), /healthz and /readyz. - Migrations: full initial schema (users, sessions, lists, list_members, items, op_log SOURCE OF TRUTH, item_names) + golang-migrate runner binary using source/iofs (embedded). - Docker: multi-stage Dockerfile (Go 1.26 -> distroless nonroot), builds both server and migrate binaries. - deploy: docker-compose (caddy + backend + migrate + postgres:16), Caddyfile (auto-HTTPS), .env.example, pg extensions init script. - AGENTS.md: project context + roadmap for AI agents. Verified: image builds, both binaries run in container (smoke test). --- .gitignore | 55 +++++ AGENTS.md | 191 ++++++++++++++++++ README.md | 49 +++++ backend/.dockerignore | 22 ++ backend/Dockerfile | 33 +++ backend/cmd/migrate/main.go | 105 ++++++++++ backend/cmd/server/main.go | 87 ++++++++ backend/go.mod | 21 ++ backend/go.sum | 83 ++++++++ backend/internal/config/config.go | 70 +++++++ backend/internal/httpapi/api.go | 56 +++++ backend/internal/httpapi/health.go | 28 +++ backend/internal/httpapi/middleware.go | 125 ++++++++++++ backend/internal/httpapi/render.go | 78 +++++++ backend/internal/logging/logging.go | 25 +++ backend/internal/store/db.go | 32 +++ .../migrations/000001_init_schema.down.sql | 8 + backend/migrations/000001_init_schema.up.sql | 114 +++++++++++ backend/migrations/embed.go | 9 + deploy/.env.example | 44 ++++ deploy/Caddyfile | 31 +++ deploy/db/init/001_extensions.sql | 4 + deploy/docker-compose.yml | 102 ++++++++++ 23 files changed, 1372 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/cmd/migrate/main.go create mode 100644 backend/cmd/server/main.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/httpapi/api.go create mode 100644 backend/internal/httpapi/health.go create mode 100644 backend/internal/httpapi/middleware.go create mode 100644 backend/internal/httpapi/render.go create mode 100644 backend/internal/logging/logging.go create mode 100644 backend/internal/store/db.go create mode 100644 backend/migrations/000001_init_schema.down.sql create mode 100644 backend/migrations/000001_init_schema.up.sql create mode 100644 backend/migrations/embed.go create mode 100644 deploy/.env.example create mode 100644 deploy/Caddyfile create mode 100644 deploy/db/init/001_extensions.sql create mode 100644 deploy/docker-compose.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b67f16 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# === Secrets / local env === +.env +.env.* +!.env.example +deploy/.env +*.pem +*.key + +# === Go === +backend/bin/ +/backend/server +/backend/migrate +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +/backend/coverage/ + +# === sqlc generated (keep if committed; uncomment to ignore) === +# backend/internal/store/sqlc/ + +# === IDE / OS === +.idea/ +.vscode/ +*.iml +.DS_Store +Thumbs.db + +# === Agent / tooling local data === +.zcode/ + +# === Docker === +deploy/data/ + +# === Android / Gradle === +android/.gradle/ +android/build/ +android/app/build/ +android/local.properties +android/captures/ +android/.cxx/ +*.apk +*.aab +*.ap_ +*.dex +*.keystore +*.jks +keystore.properties + +# === Gradle wrapper (KEEP gradlew + wrapper jar; ignore caches) === +android/**/build/ +!android/gradle/wrapper/gradle-wrapper.jar diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2db252e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,191 @@ +# AGENTS.md – Kontext & Fortschritt für KI-Agenten + +> Diese Datei hält den Projekt-Stand und die Architekturentscheidungen fest, damit +> ein Agent (z.B. zuhause) sofort weiterarbeiten kann. Sie ist Teil des Repos. + +## Projekt: Mitbringsl + +Local-First Einkaufslisten-App (Bring-Alternative, werbefrei). +**Backend:** Go (net/http, pgx, golang-migrate, go-oidc) als Docker-Container. +**App:** Kotlin + Jetpack Compose, Room, Hilt, WorkManager, offline-first. +**Deployment:** docker-compose (Caddy + backend + migrate + postgres:16), auto-HTTPS. + +Vollständiger Plan liegt als genehmigtem Plan zugrunde (siehe Abschnitt "Roadmap"). + +--- + +## Getroffene Architekturentscheidungen (verbindlich) + +- **Backend-Sprache:** Go (nicht Rust). +- **DB:** PostgreSQL (`postgres:16-alpine`), Extensions `pgcrypto` + `pg_trgm`. +- **Sync:** Local-First, **keine Datenverluste** – Append-only `op_log` (SOURCE OF TRUTH), + `items`/`lists` sind Projektionen; Konfliktlösung per LWW-Register `(hlc_ts, client_id)` + + Tombstones (`deleted_at`). HLC = Hybrid Logical Clock. + - Push: `POST /api/lists/{id}/ops` (idempotent via `UNIQUE(client_id, client_seq)`). + - Pull: `GET /api/lists/{id}/ops?since={seq}`. +- **Auth:** + - Eigene User: E-Mail/Passwort, **Argon2id** (PHC-Format). + - OIDC: **Google** + **Generic OIDC**. Die **App macht den Code+PKCE-Flow selbst** + und schickt nur das `id_token` ans Backend (`POST /auth/oidc`). + Backend verifiziert via `github.com/coreos/go-oidc/v3` (Signatur gegen JWKS, + iss/aud/exp) und stellt **eigene opaque Session-Tokens** aus + (`sessions`-Tabelle, SHA-256-Hash gespeichert). + - **AppAuth-Android bewusst NICHT verwendet** (seit 2021 verwaist). Stattdessen: + Google via **Credential Manager**, Generic OIDC via **Custom Tabs + eigenes PKCE**. +- **Vorschläge:** aggregiert aus Item-Namen aller User (`item_names`-Tabelle, + pg_trgm fuzzy search). Endpoint `GET /api/suggestions?q=`. +- **MVP-Scope:** Single-Owner-Listen (`list_members` existiert, wird in Phase 2 für + geteilte Listen genutzt); keine Echtzeit-Push (nur Periodic-Pull 15 min + Pull-on-Online); + keine Web-UI. + +--- + +## Technologie-Stacks (final) + +### Backend +| Bereich | Wahl | +|---|---| +| Go-Version | **1.26** (`go.mod` hat `go 1.26`; Docker-Image `golang:1.26-alpine`) | +| HTTP | stdlib `net/http` (Go 1.22+ Routing mit Methoden + Path-Vars) | +| DB-Driver | `github.com/jackc/pgx/v5` (pgxpool) | +| Migrationen | `github.com/golang-migrate/migrate/v4` + `source/iofs` (eingebettet via `embed.FS`) | +| OIDC | `github.com/coreos/go-oidc/v3` + `golang.org/x/oauth2` | +| Passwörter | Argon2id (`golang.org/x/crypto/argon2`) | +| Sessions | opaque Tokens (32 B base64url), `sessions`-Tabelle, SHA-256-Hash | +| Config | `github.com/caarlos0/env/v11` (struct-tag env) | +| Logging | `log/slog` mit `NewJSONHandler` → stdout | +| Docker | Multi-Stage, `CGO_ENABLED=0`, `gcr.io/distroless/static-debian12:nonroot` | +| Queries | **direkt mit pgx** (sqlc wurde aus Skalierbarkeit bewusst auf später verschoben) | + +### Android (noch nicht begonnen) +| Bereich | Wahl | +|---|---| +| Build | Kotlin DSL + `libs.versions.toml`, Single-Module `:app` | +| Kotlin/AGP/Gradle | Kotlin 2.x (K2), AGP 8.9+, Gradle 8.11+, JDK 17 | +| UI | Compose BOM (2025.x) + Material 3 | +| Arch | MVVM + `ViewModel` + `StateFlow`, UDF | +| DB | Room (KSP), Source of Truth via `Flow` | +| Netzwerk | Retrofit + OkHttp + kotlinx.serialization | +| Sync | WorkManager `CoroutineWorker` (Outbox-Drain + Cursor-Pull) | +| DI | Hilt (KSP) + `hilt-navigation-compose` + `hilt-work` | +| IDs | Client-seitige UUIDs | +| Auth | Google: Credential Manager; Generic OIDC: Custom Tabs + PKCE selbst | +| Suche | `OutlinedTextField` + `DropdownMenu`, Room-FTS, Server-Fallback | +| SDK | minSdk 26, compile/target 36 | + +--- + +## Repository-Struktur + +``` +mitbringsl/ +├── AGENTS.md ← DIES DATEI +├── README.md +├── .gitignore +├── backend/ +│ ├── cmd/ +│ │ ├── server/main.go # HTTP-Server-Einstieg (verdrahtet cfg/logger/pool/api) +│ │ └── migrate/main.go # Migrations-Runner (iofs-embedded) +│ ├── internal/ +│ │ ├── config/config.go # Config (caarlos0/env) +│ │ ├── logging/logging.go # slog JSON-Setup +│ │ ├── store/db.go # pgxpool-Setup +│ │ └── httpapi/ +│ │ ├── api.go # API-Objekt + Router (Health aktiv, Rest noch auskommentiert) +│ │ ├── render.go # JSON-Render + Problem + Fehler-Sentinale + decodeJSON +│ │ ├── middleware.go # requestID/logging/recover/cors + Chain +│ │ └── health.go # /healthz + /readyz +│ ├── migrations/ +│ │ ├── embed.go # //go:embed *.sql +│ │ ├── 000001_init_schema.up.sql # users/sessions/lists/list_members/items/op_log/item_names +│ │ └── 000001_init_schema.down.sql +│ ├── Dockerfile # Multi-Stage, baut server + migrate +│ ├── .dockerignore +│ ├── go.mod / go.sum +├── deploy/ +│ ├── docker-compose.yml # caddy + backend + migrate + db (mit YAML-anchors) +│ ├── Caddyfile # auto-HTTPS +│ ├── .env.example # alle env-Vars dokumentiert +│ └── db/init/001_extensions.sql # CREATE EXTENSION pgcrypto, pg_trgm +├── docs/ # NOCH LEER (folgt Phase F) +└── android/ # NOCH LEER (folgt Phase D) +``` + +--- + +## Roadmap / Fortschritt + +Legende: ✅ erledigt · 🚧 in Arbeit · ⬜ offen + +- ✅ Repo-Struktur + `.gitignore` + `README.md` + `git init` (Branch `main`) +- ✅ **Phase A – Backend-Fundament:** Config, slog, pgxpool, `/healthz`+`/readyz`, Server-Main. +- ✅ **Phase A – Migrationen:** vollständiges Init-Schema (up+down) + `migrate`-Binary (iofs). +- ✅ **Phase A – Docker:** Dockerfile (Go 1.26 → distroless nonroot), docker-compose + (caddy/backend/migrate/db), Caddyfile, `.env.example`. + **Verifiziert:** Image baut, beide Binaries laufen im Container (Smoke-Test OK). +- ⬜ **Phase B – Auth:** Argon2id + eigene User (register/login) + Session-Middleware. +- ⬜ **Phase B – OIDC:** go-oidc-Verifikation (Google + Generic) + `POST /auth/oidc`. +- ⬜ **Phase C – Sync-Kern:** `op_log`-Append (idempotent), HLC, Projektion op→items/lists (LWW+Tombstones). +- ⬜ **Phase C – Endpoints:** `/api/lists`, `/api/lists/{id}/ops` (push+pull). +- ⬜ **Phase C – Suggestions:** `item_names`-Trigger + `/api/suggestions`. +- ⬜ **Phase D – Android-Fundament:** Gradle (Kotlin DSL, Version Catalog, Hilt/KSP, Compose BOM), + Theme, Nav, Room. +- ⬜ **Phase D – Repository + Retrofit-API + DTOs.** +- ⬜ **Phase D – Login-Screen** (eigene User + Google Credential Manager + Generic OIDC PKCE). +- ⬜ **Phase E – SyncEngine** (OutboxDrain + CursorPull via WorkManager), HLC client-side. +- ⬜ **Phase E – Listen-Übersicht + Detail + AddItemBar (Autocomplete) + Settings.** +- ⬜ **Phase F – Polish** (Fehlerbehandlung, Offline-Indikator, Empty States, Tests). +- ⬜ **Phase F – README + docs** (ARCHITECTURE/SYNC/API). + +### Wo genau weitermachen? +**Nächster Schritt = Phase B (Auth):** +1. `internal/auth/password.go` – Argon2id (PHC-Format) Hashen/Verifizieren. +2. `internal/auth/session.go` – Token generieren (crypto/rand, base64url), SHA-256-Hash, + in `sessions` einfügen, Middleware `requireAuth` (lädt `user_id` in Context). +3. `internal/httpapi/auth.go` – Handler `Register`/`Login`/`Logout`. +4. In `api.go` die `/auth/*`-Routen einkommentieren + verdrahten. +5. Dann Phase B Teil 2: OIDC (`internal/auth/oidc.go` + `POST /auth/oidc`). + +--- + +## Wichtige technische Notizen / Fallstricke (bereits gelöst) + +- **golang-migrate `iofs`-Pfad:** Der Import ist + `github.com/golang-migrate/migrate/v4/source/iofs` (MIT `/source/`). + Die Pfade ohne `/source/` gibt es nicht mehr → Build-Fehler. +- **Go-Version:** `pgx/v5 v5.10.0` braucht Go ≥ 1.25, deshalb Go 1.26 + (lokal + Docker-Image `golang:1.26-alpine`). `go.mod` hat `go 1.26`. +- **Docker-Build auf Windows/Docker Desktop:** `--network host` im Build-Container + funktioniert NICHT (WSL2-NAT). Für Tests: echtes `docker network create` + Container-Namen nutzen. +- **Compose braucht zwingend `POSTGRES_PASSWORD`** (`.env` oder Env), da + `db.environment` mit `${POSTGRES_PASSWORD:?...}`-Assertion vor dem Build validiert wird. +- **Healthcheck im `backend`-Service wurde entfernt**, weil distroless/static kein + `wget`/`curl` enthält. Später: eigenes Binary, das `/healthz` per Go-HTTP prüft, + oder `healthcheck` über Caddy/extern. +- **SQL-Queries werden direkt mit pgx geschrieben** (kein sqlc im MVP), da sqlc lokal + nicht installiert ist und ein extra Code-Gen-Schritt nötig wäre. Bei Bedarf später + problemlos nachrüstbar. + +--- + +## Build- & Test-Befehle + +```bash +# Backend lokal bauen +cd backend && go build ./... && go vet ./... + +# Backend-Image bauen +cd backend && docker build -t mitbringsl-backend:test . + +# Komplettes Stack starten (braucht deploy/.env) +cd deploy && cp .env.example .env && docker compose up -d --build + +# Migrationen manuell gegen bestehende DB anwenden +docker run --rm --network \ + -e DATABASE_URL="postgres://app:PW@:5432/appdb?sslmode=disable" \ + mitbringsl-backend:test /app/migrate up +``` + +## Git-Status +- Repo initialisiert, Branch `main`. **Remote ist noch NICHT konfiguriert.** +- Es wurde **noch nicht committet** (Stand beim Schreiben dieser Datei). diff --git a/README.md b/README.md new file mode 100644 index 0000000..e67c0eb --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# Mitbringsl + +Eine **Local-First Einkaufslisten-App** – eine schlanke, werbefreie Bring-Alternative. +Android-App (Kotlin + Jetpack Compose) mit eigenem **Go-Backend** (PostgreSQL, Docker), +OIDC-Login (Google + Generic) und verlustfreiem Sync. + +> Status: **Work in Progress / MVP**. + +## Was das Projekt kann (Ziel) + +- 📝 Einkaufslisten anlegen, Items verwalten, an-/abhaken +- 🔄 **Local-First**: voll funktionsfähig offline, automatischer Sync ohne Datenverluste + (Append-only Operations-Log + Hybrid Logical Clocks + Tombstones) +- 👤 Anmeldung mit **eigenen Usern** (E-Mail/Passwort, Argon2id) **oder** **OIDC** + (Google + beliebiger Generic-OIDC-Provider wie Keycloak/Authentik) +- 🔍 **Autocomplete** beim Tippen – Vorschläge aus den aggregierten Item-Namen aller User +- 🚫 **Keine Werbung** – cleanes Material-3-Design +- 🐳 **Self-hosted** als Docker-Container, automatisches HTTPS via Caddy + +## Repository-Aufbau + +``` +mitbringsl/ +├── backend/ # Go-API (net/http, pgx, sqlc, golang-migrate, go-oidc) +├── android/ # Android-App (Kotlin, Jetpack Compose, Room, Hilt, WorkManager) +├── deploy/ # docker-compose.yml, Caddyfile, .env.example +└── docs/ # Architektur-, Sync- und API-Doku +``` + +## Schnellstart + +### Backend (Docker) +```bash +cd deploy +cp .env.example .env # Werte anpassen (v.a. Secrets/URLs) +docker compose up -d --build +# API unter https:// (oder http://localhost:8080 ohne Caddy) +``` + +### App bauen +Siehe [`android/README.md`](android/README.md) (folgt). + +Detaillierte Doku: +- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) – Aufbau, Auth-Flows +- [`docs/SYNC.md`](docs/SYNC.md) – Sync-Modell & Konfliktlösung +- [`docs/API.md`](docs/API.md) – REST-Endpoints + +## Lizenz +Privatprojekt – alle Rechte vorbehalten. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..61bef3b --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,22 @@ +# Build artifacts +bin/ +*.exe +*.test +*.out + +# IDE / OS +.idea/ +.vscode/ +.DS_Store +Thumbs.db + +# Git +.git +.gitignore + +# Docker +Dockerfile +.dockerignore + +# Docs +*.md diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..f7d1ea2 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1 + +# ---- build stage ---- +FROM golang:1.26-alpine AS builder +WORKDIR /src + +# Cache deps first. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# Build both binaries statically (pgx speaks the wire protocol, no cgo needed). +RUN CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w" -trimpath \ + -o /out/server ./cmd/server && \ + CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w" -trimpath \ + -o /out/migrate ./cmd/migrate + +# ---- runtime stage ---- +# distroless/static ships CA certificates (needed for OIDC JWKS over HTTPS) +# and a nonroot user. +FROM gcr.io/distroless/static-debian12:nonroot +WORKDIR /app +COPY --from=builder /out/server /app/server +COPY --from=builder /out/migrate /app/migrate + +# The API server runs by default. The one-shot migrate job overrides the +# command in docker-compose to "/app/migrate up". +USER nonroot:nonroot +EXPOSE 8080 +ENTRYPOINT ["/app/server"] diff --git a/backend/cmd/migrate/main.go b/backend/cmd/migrate/main.go new file mode 100644 index 0000000..f8f4acb --- /dev/null +++ b/backend/cmd/migrate/main.go @@ -0,0 +1,105 @@ +// Package main is the migration runner. It applies embedded SQL migrations to the +// configured database. Intended to run as a one-shot container/job before the API. +// +// Usage: migrate [up|down|version|force ] +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "time" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/golang-migrate/migrate/v4/source/iofs" + + "github.com/mitbringsl/backend/internal/config" + "github.com/mitbringsl/backend/internal/logging" + "github.com/mitbringsl/backend/migrations" +) + +func main() { + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "config: %v\n", err) + os.Exit(1) + } + logging.Init(cfg.LogLevel) + + cmd := "up" + if len(os.Args) > 1 { + cmd = os.Args[1] + } + + if err := run(cfg, cmd); err != nil { + slog.Error("migration failed", "cmd", cmd, "error", err) + os.Exit(1) + } +} + +func run(cfg *config.Config, cmd string) error { + src, err := iofs.New(migrations.FS, ".") + if err != nil { + return fmt.Errorf("create source: %w", err) + } + + // Use the configured DATABASE_URL; golang-migrate accepts the same DSN + // form pgx uses (postgres://...). Allow a short startup grace period. + dbURL := cfg.DatabaseURL + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var m *migrate.Migrate + var lastErr error + for attempt := 0; attempt < 30; attempt++ { + m, lastErr = migrate.NewWithSourceInstance("iofs", src, dbURL) + if lastErr == nil { + break + } + select { + case <-ctx.Done(): + return fmt.Errorf("db not ready: %w", lastErr) + case <-time.After(time.Second): + } + } + if lastErr != nil { + return fmt.Errorf("create migrate instance: %w", lastErr) + } + defer m.Close() + + switch cmd { + case "up": + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return err + } + slog.Info("migrations applied (up)") + case "down": + if err := m.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return err + } + slog.Info("migrations applied (down)") + case "version": + v, dirty, err := m.Version() + if err != nil { + return err + } + fmt.Printf("version=%d dirty=%v\n", v, dirty) + case "force": + if len(os.Args) < 3 { + return errors.New("force requires a version argument") + } + var v int + fmt.Sscanf(os.Args[2], "%d", &v) + if err := m.Force(v); err != nil { + return err + } + slog.Info("forced migration version", "version", v) + default: + return fmt.Errorf("unknown command %q (use up|down|version|force)", cmd) + } + return nil +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..cec4a69 --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,87 @@ +// Package main is the entrypoint for the mitbringsl backend HTTP server. +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/mitbringsl/backend/internal/config" + "github.com/mitbringsl/backend/internal/httpapi" + "github.com/mitbringsl/backend/internal/logging" + "github.com/mitbringsl/backend/internal/store" +) + +func main() { + if err := run(); err != nil { + slog.Error("fatal error", "error", err) + os.Exit(1) + } +} + +func run() error { + cfg, err := config.Load() + if err != nil { + return err + } + logging.Init(cfg.LogLevel) + slog.Info("starting mitbringsl backend", + "env", cfg.AppEnv, + "addr", cfg.HTTPAddr, + "google_oidc_enabled", cfg.GoogleOIDC.Enabled, + "generic_oidc_enabled", cfg.GenericOIDC.Enabled, + ) + + rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + pool, err := store.New(rootCtx, cfg) + if err != nil { + return err + } + defer pool.Close() + slog.Info("database connected") + + api := httpapi.NewAPI(cfg, pool) + srv := &http.Server{ + Addr: cfg.HTTPAddr, + Handler: api.Handler(), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + errCh := make(chan error, 1) + go func() { + slog.Info("http server listening", "addr", cfg.HTTPAddr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }() + + select { + case <-rootCtx.Done(): + slog.Info("shutdown signal received") + case err := <-errCh: + return err + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + slog.Error("graceful shutdown failed", "error", err) + } + slog.Info("bye") + return nil +} + +// keep pgxpool import referenced even when API holds the pool directly (future-proof). +var _ = (*pgxpool.Pool)(nil) diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..a3f5c6d --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,21 @@ +module github.com/mitbringsl/backend + +go 1.26 + +require ( + github.com/caarlos0/env/v11 v11.4.1 + github.com/golang-migrate/migrate/v4 v4.18.2 + github.com/jackc/pgx/v5 v5.10.0 +) + +require ( + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/lib/pq v1.10.9 // indirect + go.uber.org/atomic v1.7.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..81ee553 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,83 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= +github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8= +github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4= +github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8= +github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..a3124a0 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,70 @@ +// Package config holds all runtime configuration for the backend. +// Values are parsed from environment variables via struct tags. +package config + +import ( + "fmt" + "time" + + "github.com/caarlos0/env/v11" +) + +// Config is the single source of runtime configuration. +type Config struct { + // HTTPAddr is the address the HTTP server listens on, e.g. ":8080". + HTTPAddr string `env:"HTTP_ADDR" envDefault:":8080"` + + // AppEnv: "development" or "production". + AppEnv string `env:"APP_ENV" envDefault:"development"` + + // LogLevel: debug | info | warn | error. + LogLevel string `env:"LOG_LEVEL" envDefault:"info"` + + // PublicBaseURL is the externally reachable URL (scheme + host), no trailing slash. + // Used e.g. for OIDC redirect URIs. Example: "https://mitbringsl.example.com". + PublicBaseURL string `env:"PUBLIC_BASE_URL" envDefault:"http://localhost:8080"` + + // Database + DatabaseURL string `env:"DATABASE_URL,required"` + DBMaxOpenConns int32 `env:"DB_MAX_OPEN_CONNS" envDefault:"25"` + DBMaxIdleConns int32 `env:"DB_MAX_IDLE_CONNS" envDefault:"5"` + DBMaxLifetime time.Duration `env:"DB_MAX_CONN_LIFETIME" envDefault:"30m"` + + // Session token config (opaque tokens issued by this backend after login). + SessionTokenBytes int `env:"SESSION_TOKEN_BYTES" envDefault:"32"` // 256-bit + SessionTokenTTL time.Duration `env:"SESSION_TOKEN_TTL" envDefault:"720h"` // 30 days + SessionCookieName string `env:"SESSION_COOKIE_NAME" envDefault:"mitbringsl_session"` + + // OIDC providers. Both optional; enable per provider. + GoogleOIDC GoogleOIDCConfig + GenericOIDC GenericOIDCConfig + + // CORS allowed origins (comma-separated). Empty = no CORS headers. + CORSAllowedOrigins []string `env:"CORS_ALLOWED_ORIGINS" envSeparator:","` +} + +// GoogleOIDCConfig for "Sign in with Google". +type GoogleOIDCConfig struct { + Enabled bool `env:"OIDC_GOOGLE_ENABLED" envDefault:"false"` + ClientID string `env:"OIDC_GOOGLE_CLIENT_ID"` // the audience the verifier accepts + Issuer string `env:"OIDC_GOOGLE_ISSUER" envDefault:"https://accounts.google.com"` +} + +// GenericOIDCConfig for any standards-compliant OIDC IdP (Keycloak, Authentik, ...). +type GenericOIDCConfig struct { + Enabled bool `env:"OIDC_GENERIC_ENABLED" envDefault:"false"` + Issuer string `env:"OIDC_GENERIC_ISSUER"` + ClientID string `env:"OIDC_GENERIC_CLIENT_ID"` +} + +// Load reads configuration from environment variables and validates basic invariants. +func Load() (*Config, error) { + var cfg Config + if err := env.Parse(&cfg); err != nil { + return nil, fmt.Errorf("parse env: %w", err) + } + return &cfg, nil +} + +// IsProduction reports whether the app runs in production mode. +func (c *Config) IsProduction() bool { return c.AppEnv == "production" } diff --git a/backend/internal/httpapi/api.go b/backend/internal/httpapi/api.go new file mode 100644 index 0000000..8aa8dae --- /dev/null +++ b/backend/internal/httpapi/api.go @@ -0,0 +1,56 @@ +package httpapi + +import ( + "net/http" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/mitbringsl/backend/internal/config" +) + +// API bundles all handler groups and wires the router. +// Handler groups are added in subsequent phases (auth, lists, items, suggestions). +type API struct { + cfg *config.Config + pool *pgxpool.Pool + + health *HealthHandler +} + +// NewAPI constructs the API with all handler groups. +func NewAPI(cfg *config.Config, pool *pgxpool.Pool) *API { + return &API{ + cfg: cfg, + pool: pool, + health: &HealthHandler{Pool: pool}, + } +} + +// Handler returns the fully wired http.Handler with all middleware applied. +func (a *API) Handler() http.Handler { + mux := http.NewServeMux() + + // --- public health endpoints (no auth) --- + mux.HandleFunc("GET /healthz", a.health.Healthz) + mux.HandleFunc("GET /readyz", a.health.Readyz) + + // --- auth endpoints (added in Phase B) --- + // mux.HandleFunc("POST /auth/register", a.auth.Register) + // mux.HandleFunc("POST /auth/login", a.auth.Login) + // mux.HandleFunc("POST /auth/oidc", a.auth.OIDC) + // mux.HandleFunc("POST /auth/logout", a.auth.Logout) + + // --- authenticated API endpoints (added in Phase C) --- + // mux.HandleFunc("GET /api/lists", a.requireAuth(a.lists.List)) + // mux.HandleFunc("GET /api/lists/{id}/ops", a.requireAuth(a.items.PullOps)) + // mux.HandleFunc("POST /api/lists/{id}/ops", a.requireAuth(a.items.PushOps)) + // mux.HandleFunc("GET /api/suggestions", a.requireAuth(a.suggest.Suggest)) + + return Chain( + mux, + requestIDMiddleware, + loggingMiddleware, + recoverMiddleware, + corsMiddleware(a.cfg), + ) +} diff --git a/backend/internal/httpapi/health.go b/backend/internal/httpapi/health.go new file mode 100644 index 0000000..c51655b --- /dev/null +++ b/backend/internal/httpapi/health.go @@ -0,0 +1,28 @@ +package httpapi + +import ( + "context" + "net/http" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// HealthHandler exposes /healthz (liveness) and /readyz (readiness + DB ping). +type HealthHandler struct { + Pool *pgxpool.Pool +} + +func (h *HealthHandler) Healthz(w http.ResponseWriter, r *http.Request) { + renderJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (h *HealthHandler) Readyz(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + if err := h.Pool.Ping(ctx); err != nil { + renderError(w, http.StatusServiceUnavailable, "Database unavailable", err.Error()) + return + } + renderJSON(w, http.StatusOK, map[string]string{"status": "ready"}) +} diff --git a/backend/internal/httpapi/middleware.go b/backend/internal/httpapi/middleware.go new file mode 100644 index 0000000..fba6256 --- /dev/null +++ b/backend/internal/httpapi/middleware.go @@ -0,0 +1,125 @@ +package httpapi + +import ( + "context" + "crypto/rand" + "encoding/hex" + "log/slog" + "net/http" + "runtime/debug" + + "github.com/mitbringsl/backend/internal/config" +) + +// ctxKey is an unexported key type for context values in this package. +type ctxKey int + +const ( + ctxKeyRequestID ctxKey = iota + ctxKeyUserID + ctxKeySessionID +) + +type wrappedWriter struct { + http.ResponseWriter + status int + bytes int +} + +func (w *wrappedWriter) WriteHeader(status int) { + w.status = status + w.ResponseWriter.WriteHeader(status) +} + +func (w *wrappedWriter) Write(b []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + n, err := w.ResponseWriter.Write(b) + w.bytes += n + return n, err +} + +// requestIDMiddleware injects a random request id into the request context and response header. +func requestIDMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get("X-Request-ID") + if id == "" { + b := make([]byte, 8) + _, _ = rand.Read(b) + id = hex.EncodeToString(b) + } + w.Header().Set("X-Request-ID", id) + ctx := context.WithValue(r.Context(), ctxKeyRequestID, id) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// loggingMiddleware logs each request as structured JSON. +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ww := &wrappedWriter{ResponseWriter: w} + next.ServeHTTP(ww, r) + slog.Info("http request", + "method", r.Method, + "path", r.URL.Path, + "status", ww.status, + "bytes", ww.bytes, + "request_id", r.Context().Value(ctxKeyRequestID), + "remote", r.RemoteAddr, + ) + }) +} + +// recoverMiddleware catches panics, logs them and returns 500. +func recoverMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + slog.Error("panic recovered", + "error", rec, + "request_id", r.Context().Value(ctxKeyRequestID), + "stack", string(debug.Stack()), + ) + renderError(w, http.StatusInternalServerError, "Internal error", "An unexpected error occurred.") + } + }() + next.ServeHTTP(w, r) + }) +} + +// corsMiddleware adds permissive-but-scoped CORS headers when configured. +// Native clients (Android) don't need CORS; this only affects browsers. +func corsMiddleware(cfg *config.Config) func(http.Handler) http.Handler { + allowed := map[string]bool{} + for _, o := range cfg.CORSAllowedOrigins { + if o != "" { + allowed[o] = true + } + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" && allowed[origin] { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + } + next.ServeHTTP(w, r) + }) + } +} + +// Chain wires middlewares in declaration order: first one runs outermost. +func Chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler { + for i := len(mws) - 1; i >= 0; i-- { + h = mws[i](h) + } + return h +} diff --git a/backend/internal/httpapi/render.go b/backend/internal/httpapi/render.go new file mode 100644 index 0000000..8a09c97 --- /dev/null +++ b/backend/internal/httpapi/render.go @@ -0,0 +1,78 @@ +// Package httpapi contains HTTP handlers, routing and middleware. +package httpapi + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + + "github.com/jackc/pgx/v5" +) + +// Problem is a small RFC 7807-ish error body. +type Problem struct { + Type string `json:"type,omitempty"` + Title string `json:"title"` + Status int `json:"status"` + Detail string `json:"detail,omitempty"` +} + +// renderJSON writes v as JSON with the given status code. +func renderJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + if v == nil { + return + } + if err := json.NewEncoder(w).Encode(v); err != nil { + slog.Error("write json response failed", "error", err) + } +} + +// renderError writes a Problem response. +func renderError(w http.ResponseWriter, status int, title, detail string) { + renderJSON(w, status, Problem{ + Title: title, + Status: status, + Detail: detail, + }) +} + +// apiError maps well-known errors to HTTP status codes. Returns true if handled. +func apiError(w http.ResponseWriter, err error) bool { + switch { + case errors.Is(err, pgx.ErrNoRows): + renderError(w, http.StatusNotFound, "Not found", "The requested resource does not exist.") + case errors.Is(err, ErrUnauthorized): + renderError(w, http.StatusUnauthorized, "Unauthorized", err.Error()) + case errors.Is(err, ErrForbidden): + renderError(w, http.StatusForbidden, "Forbidden", err.Error()) + case errors.Is(err, ErrConflict): + renderError(w, http.StatusConflict, "Conflict", err.Error()) + case errors.Is(err, ErrBadRequest): + renderError(w, http.StatusBadRequest, "Bad request", err.Error()) + default: + return false + } + return true +} + +// Sentinel domain errors mapped by apiError. +var ( + ErrBadRequest = errors.New("bad request") + ErrUnauthorized = errors.New("unauthorized") + ErrForbidden = errors.New("forbidden") + ErrConflict = errors.New("conflict") +) + +// decodeJSON decodes r.Body into v. Returns false and writes an error on failure. +func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool { + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + renderError(w, http.StatusBadRequest, "Invalid JSON", err.Error()) + return false + } + return true +} diff --git a/backend/internal/logging/logging.go b/backend/internal/logging/logging.go new file mode 100644 index 0000000..c4d297b --- /dev/null +++ b/backend/internal/logging/logging.go @@ -0,0 +1,25 @@ +// Package logging configures the structured logger (log/slog). +package logging + +import ( + "log/slog" + "os" + "strings" +) + +// Init installs a JSON handler writing to stdout at the given level. +func Init(level string) { + var lvl slog.Level + switch strings.ToLower(level) { + case "debug": + lvl = slog.LevelDebug + case "warn", "warning": + lvl = slog.LevelWarn + case "error": + lvl = slog.LevelError + default: + lvl = slog.LevelInfo + } + h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}) + slog.SetDefault(slog.New(h)) +} diff --git a/backend/internal/store/db.go b/backend/internal/store/db.go new file mode 100644 index 0000000..54be123 --- /dev/null +++ b/backend/internal/store/db.go @@ -0,0 +1,32 @@ +// Package store wraps database access (pgxpool) for the backend. +package store + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/mitbringsl/backend/internal/config" +) + +// New creates and configures a pgx connection pool, then pings the DB to verify access. +func New(ctx context.Context, cfg *config.Config) (*pgxpool.Pool, error) { + pcfg, err := pgxpool.ParseConfig(cfg.DatabaseURL) + if err != nil { + return nil, fmt.Errorf("parse database url: %w", err) + } + pcfg.MaxConns = cfg.DBMaxOpenConns + pcfg.MinConns = cfg.DBMaxIdleConns + pcfg.MaxConnLifetime = cfg.DBMaxLifetime + + pool, err := pgxpool.NewWithConfig(ctx, pcfg) + if err != nil { + return nil, fmt.Errorf("create pool: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping db: %w", err) + } + return pool, nil +} diff --git a/backend/migrations/000001_init_schema.down.sql b/backend/migrations/000001_init_schema.down.sql new file mode 100644 index 0000000..b76d82f --- /dev/null +++ b/backend/migrations/000001_init_schema.down.sql @@ -0,0 +1,8 @@ +-- Reverse of 000001. +DROP TABLE IF EXISTS item_names; +DROP TABLE IF EXISTS op_log; +DROP TABLE IF EXISTS items; +DROP TABLE IF EXISTS list_members; +DROP TABLE IF EXISTS lists; +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS users; diff --git a/backend/migrations/000001_init_schema.up.sql b/backend/migrations/000001_init_schema.up.sql new file mode 100644 index 0000000..e3805d3 --- /dev/null +++ b/backend/migrations/000001_init_schema.up.sql @@ -0,0 +1,114 @@ +-- Initial schema for mitbringsl. +-- Design notes: +-- * op_log is the append-only SOURCE OF TRUTH for sync. +-- * users/lists/items are projections of op_log (kept up to date as ops arrive). +-- * Tombstones (deleted_at) prevent resurrection of removed items by late offline ops. +-- * (client_id, client_seq) unique key on op_log makes pushes idempotent. + +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- --------------------------------------------------------------------------- +-- users +-- --------------------------------------------------------------------------- +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + password_hash TEXT, -- NULL for OIDC-only accounts + oidc_subject TEXT, -- IdP subject id + oidc_issuer TEXT, -- which IdP the subject belongs to + display_name TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (oidc_issuer, oidc_subject) +); + +-- --------------------------------------------------------------------------- +-- sessions (opaque tokens issued by this backend after login) +-- --------------------------------------------------------------------------- +CREATE TABLE sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, -- store SHA-256 hash, never the raw token + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + user_agent TEXT, + ip TEXT +); +CREATE INDEX sessions_user_id_idx ON sessions(user_id); + +-- --------------------------------------------------------------------------- +-- lists (projection) +-- --------------------------------------------------------------------------- +CREATE TABLE lists ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, -- tombstone + hlc_ts BIGINT NOT NULL DEFAULT 0, + version BIGINT NOT NULL DEFAULT 0 +); +CREATE INDEX lists_owner_idx ON lists(owner_id) WHERE deleted_at IS NULL; + +-- list_members (prepared for shared lists; MVP uses owner only) +CREATE TABLE list_members ( + list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member', -- 'owner' | 'member' + PRIMARY KEY (list_id, user_id) +); + +-- --------------------------------------------------------------------------- +-- items (projection) +-- --------------------------------------------------------------------------- +CREATE TABLE items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE, + name TEXT NOT NULL, + quantity TEXT, + checked BOOLEAN NOT NULL DEFAULT FALSE, + sort_order INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, -- tombstone + hlc_ts BIGINT NOT NULL DEFAULT 0, + client_id UUID, + checked_at TIMESTAMPTZ +); +CREATE INDEX items_list_idx ON items(list_id) WHERE deleted_at IS NULL; +CREATE INDEX items_checked_idx ON items(list_id, checked) WHERE deleted_at IS NULL; + +-- --------------------------------------------------------------------------- +-- op_log (SOURCE OF TRUTH) +-- --------------------------------------------------------------------------- +CREATE TABLE op_log ( + seq BIGSERIAL PRIMARY KEY, -- monotonic server cursor + list_id UUID NOT NULL, -- list the op belongs to (FK added after lists exist; keep loose for tombstone cleanup) + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + client_id UUID NOT NULL, + op_type TEXT NOT NULL, -- list_create|list_rename|list_delete + -- item_add|item_update|item_remove + target_id UUID NOT NULL, -- affected list/item id + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + client_seq BIGINT NOT NULL, -- idempotency key part + hlc_ts BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (client_id, client_seq) +); +CREATE INDEX op_log_list_seq_idx ON op_log(list_id, seq); +CREATE INDEX op_log_target_idx ON op_log(target_id); + +-- --------------------------------------------------------------------------- +-- item_names (autocomplete aggregation, all users) +-- --------------------------------------------------------------------------- +CREATE TABLE item_names ( + name TEXT PRIMARY KEY, -- lowercased + usage_count INTEGER NOT NULL DEFAULT 0, + last_used_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX item_names_trgm_idx ON item_names USING gin (name gin_trgm_ops); +CREATE INDEX item_names_count_idx ON item_names(usage_count DESC); diff --git a/backend/migrations/embed.go b/backend/migrations/embed.go new file mode 100644 index 0000000..0f376b6 --- /dev/null +++ b/backend/migrations/embed.go @@ -0,0 +1,9 @@ +// Package migrations embeds the SQL migration files for use by the migrate binary. +package migrations + +import "embed" + +// FS holds all migration files (*.sql) under this directory. +// +//go:embed *.sql +var FS embed.FS diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..fb3dcc9 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,44 @@ +# =========================================================================== +# mitbringsl backend configuration +# Copy this file to ".env" and adjust the values. +# cp .env.example .env +# =========================================================================== + +# --- General app behavior --- +APP_ENV=production # development | production +LOG_LEVEL=info # debug | info | warn | error + +# The externally reachable base URL (scheme + host, no trailing slash). +# Must match the domain you serve Caddy on. Used for OIDC redirect URIs etc. +PUBLIC_BASE_URL=https://mitbringsl.example.com + +# The public domain Caddy serves. Used to set the Caddy site address. +PUBLIC_DOMAIN=mitbringsl.example.com + +# --- PostgreSQL --- +POSTGRES_USER=app +# CHOOSE A STRONG PASSWORD (only required for first DB init, then stored). +POSTGRES_PASSWORD=change-me-to-a-long-random-string +POSTGRES_DB=appdb +# DATABASE_URL is composed by docker-compose from the values above. + +# --- Session tokens --- +# TTL of the opaque session token issued after login. +SESSION_TOKEN_TTL=720h # 30 days + +# --- OIDC: Google (optional) --- +OIDC_GOOGLE_ENABLED=false +# The OAuth client ID you created in Google Cloud Console (Audience the +# backend accepts). No client_secret needed: the Android app performs the +# code exchange itself and only sends the id_token to the backend. +OIDC_GOOGLE_CLIENT_ID= +OIDC_GOOGLE_ISSUER=https://accounts.google.com + +# --- OIDC: Generic provider (Keycloak, Authentik, Dex, ...; optional) --- +OIDC_GENERIC_ENABLED=false +OIDC_GENERIC_ISSUER= # e.g. https://idp.example.com/realms/main +OIDC_GENERIC_CLIENT_ID= # audience the backend accepts + +# --- CORS (only relevant for browser clients; Android doesn't need it) --- +# Comma-separated list of allowed origins, e.g. https://app.example.com +CORS_ALLOWED_ORIGINS= diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..3d1c04e --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,31 @@ +# Caddyfile for mitbringsl. +# Caddy automatically obtains and renews a Let's Encrypt certificate when the +# site address is a real domain. For local development it falls back to an +# internal CA / self-signed cert automatically. +{ + # email you@example.com # optional, for ACME account +} + +{$SITE_ADDRESS:localhost} { + reverse_proxy backend:8080 { + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + } + + # Useful default headers + header { + Strict-Transport-Security "max-age=31536000" + X-Content-Type-Options "nosniff" + Referrer-Policy "no-referrer" + } + + # Health endpoint passthrough already handled by backend; keep it simple. + request_body { + max_size 2MB + } + + log { + output stdout + format console + } +} diff --git a/deploy/db/init/001_extensions.sql b/deploy/db/init/001_extensions.sql new file mode 100644 index 0000000..39d7c50 --- /dev/null +++ b/deploy/db/init/001_extensions.sql @@ -0,0 +1,4 @@ +-- Runs only on first DB init (docker-entrypoint-initdb.d). +-- Extensions used by the application. +CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid() +CREATE EXTENSION IF NOT EXISTS pg_trgm; -- trigram fuzzy search for autocomplete diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..58b8d81 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,102 @@ +# Self-hosted deployment for mitbringsl. +# +# cp .env.example .env # fill in secrets + domains +# docker compose up -d --build +# +# Services: +# caddy public reverse proxy with automatic HTTPS (Let's Encrypt) +# backend mitbringsl Go API (image builds server + migrate binaries) +# migrate one-shot migration runner, must finish before backend starts +# db PostgreSQL 16 +name: mitbringsl + +x-backend-image: &backend-image + image: mitbringsl-backend + build: + context: ../backend + dockerfile: Dockerfile + +x-app-env: &appenv + APP_ENV: ${APP_ENV:-production} + LOG_LEVEL: ${LOG_LEVEL:-info} + DATABASE_URL: postgres://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-appdb}?sslmode=disable + +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-app} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + POSTGRES_DB: ${POSTGRES_DB:-appdb} + volumes: + - pgdata:/var/lib/postgresql/data + - ./db/init:/docker-entrypoint-initdb.d:ro # runs on first init only + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-app} -d ${POSTGRES_DB:-appdb}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: [appnet] + # Only expose the DB to the host for local debugging; remove in prod. + # ports: + # - "5432:5432" + + migrate: + <<: *backend-image + restart: "no" + command: ["/app/migrate", "up"] + environment: + <<: *appenv + depends_on: + db: + condition: service_healthy + networks: [appnet] + + backend: + <<: *backend-image + restart: unless-stopped + environment: + <<: *appenv + HTTP_ADDR: ":8080" + PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:8080} + SESSION_TOKEN_TTL: ${SESSION_TOKEN_TTL:-720h} + # OIDC (all optional) + OIDC_GOOGLE_ENABLED: ${OIDC_GOOGLE_ENABLED:-false} + OIDC_GOOGLE_CLIENT_ID: ${OIDC_GOOGLE_CLIENT_ID:-} + OIDC_GOOGLE_ISSUER: ${OIDC_GOOGLE_ISSUER:-https://accounts.google.com} + OIDC_GENERIC_ENABLED: ${OIDC_GENERIC_ENABLED:-false} + OIDC_GENERIC_ISSUER: ${OIDC_GENERIC_ISSUER:-} + OIDC_GENERIC_CLIENT_ID: ${OIDC_GENERIC_CLIENT_ID:-} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} + expose: ["8080"] + depends_on: + db: + condition: service_healthy + migrate: + condition: service_completed_successfully + networks: [appnet] + + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "80:80" # ACME HTTP-01 challenge + redirect + - "443:443" + - "443:443/udp" # HTTP/3 + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + - backend + networks: [appnet] + +volumes: + pgdata: + caddy_data: + caddy_config: + +networks: + appnet: