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:
commit
2899eb205b
23 changed files with 1372 additions and 0 deletions
55
.gitignore
vendored
Normal file
55
.gitignore
vendored
Normal 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
191
AGENTS.md
Normal 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
49
README.md
Normal 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
22
backend/.dockerignore
Normal 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
33
backend/Dockerfile
Normal 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
105
backend/cmd/migrate/main.go
Normal 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
|
||||
}
|
||||
87
backend/cmd/server/main.go
Normal file
87
backend/cmd/server/main.go
Normal 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
21
backend/go.mod
Normal 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
83
backend/go.sum
Normal 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=
|
||||
70
backend/internal/config/config.go
Normal file
70
backend/internal/config/config.go
Normal 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" }
|
||||
56
backend/internal/httpapi/api.go
Normal file
56
backend/internal/httpapi/api.go
Normal 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),
|
||||
)
|
||||
}
|
||||
28
backend/internal/httpapi/health.go
Normal file
28
backend/internal/httpapi/health.go
Normal 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"})
|
||||
}
|
||||
125
backend/internal/httpapi/middleware.go
Normal file
125
backend/internal/httpapi/middleware.go
Normal 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
|
||||
}
|
||||
78
backend/internal/httpapi/render.go
Normal file
78
backend/internal/httpapi/render.go
Normal 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
|
||||
}
|
||||
25
backend/internal/logging/logging.go
Normal file
25
backend/internal/logging/logging.go
Normal 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))
|
||||
}
|
||||
32
backend/internal/store/db.go
Normal file
32
backend/internal/store/db.go
Normal 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
|
||||
}
|
||||
8
backend/migrations/000001_init_schema.down.sql
Normal file
8
backend/migrations/000001_init_schema.down.sql
Normal 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;
|
||||
114
backend/migrations/000001_init_schema.up.sql
Normal file
114
backend/migrations/000001_init_schema.up.sql
Normal 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);
|
||||
9
backend/migrations/embed.go
Normal file
9
backend/migrations/embed.go
Normal 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
44
deploy/.env.example
Normal 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
31
deploy/Caddyfile
Normal 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
|
||||
}
|
||||
}
|
||||
4
deploy/db/init/001_extensions.sql
Normal file
4
deploy/db/init/001_extensions.sql
Normal 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
102
deploy/docker-compose.yml
Normal 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:
|
||||
Loading…
Add table
Add a link
Reference in a new issue