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).
This commit is contained in:
Tronax 2026-08-05 15:14:37 +02:00
commit 2899eb205b
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
23 changed files with 1372 additions and 0 deletions

55
.gitignore vendored Normal file
View file

@ -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

191
AGENTS.md Normal file
View file

@ -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 <net> \
-e DATABASE_URL="postgres://app:PW@<db-host>: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).

49
README.md Normal file
View file

@ -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://<deine-domain> (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.

22
backend/.dockerignore Normal file
View file

@ -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

33
backend/Dockerfile Normal file
View file

@ -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"]

105
backend/cmd/migrate/main.go Normal file
View file

@ -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 <n>]
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
}

View file

@ -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)

21
backend/go.mod Normal file
View file

@ -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
)

83
backend/go.sum Normal file
View file

@ -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=

View file

@ -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" }

View file

@ -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),
)
}

View file

@ -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"})
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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))
}

View file

@ -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
}

View file

@ -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;

View file

@ -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);

View file

@ -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

44
deploy/.env.example Normal file
View file

@ -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=

31
deploy/Caddyfile Normal file
View file

@ -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
}
}

View file

@ -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

102
deploy/docker-compose.yml Normal file
View file

@ -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: